pepstock-org / Charba

J2CL and GWT Charts library based on CHART.JS
https://pepstock-org.github.io/Charba-Wiki/docs
Apache License 2.0
62 stars 6 forks source link

Linked Charts with Charba: small date intervals can be a problem #89

Closed Speykious closed 1 year ago

Speykious commented 1 year ago

I want to be able to create linked time series charts with Charba, where zooming and panning in one zooms in the other.

The previous issue #88 derived into a discussion about this, so I wanted to properly continue it here. When doing my tests to synchronize charts, I realized that it wasn't working because of the random dataset example I was working with.

With the following code:

private TimeSeriesLineChartWidget createLineChart() {
    TimeSeriesLineChartWidget chart = new TimeSeriesLineChartWidget();

    TimeSeriesLineOptions chartOptions = chart.getOptions();
    chartOptions.setResponsive(true);
    chartOptions.setAspectRatio(3.5);
    chartOptions.setMaintainAspectRatio(true);
    chartOptions.getLegend().setDisplay(true);
    chartOptions.getTitle().setDisplay(true);
    chartOptions.getTitle().setText("oui");
    chartOptions.getTooltips().setEnabled(true);
    chartOptions.setAnimationEnabled(false);
    chartOptions.getDecimation().setEnabled(true);
    chartOptions.getDecimation().setAlgorithm(DecimationAlgorithm.MIN_MAX);

    // tooltip interaction options
    Interaction interaction = chartOptions.getInteraction();
    interaction.setMode(InteractionMode.NEAREST);
    interaction.setAxis(InteractionAxis.X);
    interaction.setIntersect(false);

    // axes options
    CartesianTimeSeriesAxis xAxis = chartOptions.getScales().getTimeAxis();
    xAxis.getTitle().setDisplay(true);
    xAxis.getTitle().setText("Time");
    xAxis.getTicks().setSource(TickSource.DATA);
    xAxis.getTime().setUnit(TimeUnit.SECOND);
    xAxis.getTime().getDisplayFormats().setDisplayFormat(TimeUnit.SECOND, "m’ss”");
    xAxis.getTime().getDisplayFormats().setDisplayFormat(TimeUnit.MINUTE, "H:mm:ss");
    xAxis.getTime().getDisplayFormats().setDisplayFormat(TimeUnit.HOUR, "H:mm:ss");

    xAxis.setMin((ScaleContext context) -> {
        return _minDate;
    });

    xAxis.setMax((ScaleContext context) -> {
        return _maxDate;
    });

    CartesianLinearAxis yAxis = chartOptions.getScales().getLinearAxis();
    yAxis.getTitle().setDisplay(true);
    yAxis.getTitle().setText("Fromage");
    yAxis.setDisplay(true);
    yAxis.setBeginAtZero(true);

    // zoom options
    ZoomOptions zoomOptions = new ZoomOptions();
    zoomOptions.getPan().setEnabled(true);
    zoomOptions.getPan().setModifierKey(ModifierKey.ALT);
    zoomOptions.getPan().setMode(Mode.X);
    zoomOptions.getZoom().setMode(Mode.X);
    zoomOptions.getZoom().getDrag().setEnabled(true);
    zoomOptions.getZoom().getWheel().setEnabled(true);
    zoomOptions.getZoom().getWheel().setSpeed(0.3);
    zoomOptions.getZoom().getWheel().setModifierKey(ModifierKey.ALT);
    zoomOptions.getZoom().getPinch().setEnabled(true);
    zoomOptions.store(chart);

    zoomOptions.getZoom().setCompletedCallback((ZoomContext context) -> {
        CartesianTimeSeriesAxis timeAxis = chartOptions.getScales().getTimeAxis();
        ScaleItem scaleAxis = chart.getNode().getScales().getItems().get(timeAxis.getId().value());

        for (TimeSeriesLineChartWidget rawChart : _rawCharts) {
            if (rawChart == chart)
                continue;
            _minDate = scaleAxis.getMinAsDate();
            _maxDate = scaleAxis.getMaxAsDate();
            rawChart.update();
        }
    });

    long start = new Date().getTime();

    TimeSeriesItem[] data = new TimeSeriesItem[20];
    for (int i = 0; i < data.length; i++)
        data[i] = new TimeSeriesItem(new Date((long) i + start), Random.nextDouble());

    // dataset
    TimeSeriesLineDataset dataset = chart.newDataset();
    dataset.setLabel("fromage");
    dataset.setBorderColor(Color.CHARBA);
    dataset.setBorderWidth(1);
    dataset.setPointRadius(0);
    dataset.setParsing(false);
    dataset.setTimeSeriesData(data);

    chart.getData().setDatasets(dataset);

    _rawCharts.add(chart);
    return chart;
}

as soon as I try to zoom, the synchronization completely breaks and the second graph shows nothing (the actual graph is there, but hidden far to the right). image

I realized after hours of debugging, with the help of a coworker, that the dataset may have been too short and too compact - maybe I was losing precision on the dates?

And so I tried the code above, with these modifications:

     TimeSeriesItem[] data = new TimeSeriesItem[20];
     for (int i = 0; i < data.length; i++)
-        data[i] = new TimeSeriesItem(new Date((long) i + start), Random.nextDouble());
+        data[i] = new TimeSeriesItem(new Date(((long) i) * 17271 + start), Random.nextDouble());

And now it works! image

I'm glad I managed to fix my problem, but I was wondering why the dates I generated created this problem in the first place. For our use-case, it will probably not be relevant, but it's probably important to mention here that such a thing is happening.

stockiNail commented 1 year ago

@Speykious thank you very much. I think Charba cannot do many things to solve but I wanted to test it and have a look to JS plugin (having already done some PRs overthere). Maybe the plugin (JS I meant) can be fixed.

stockiNail commented 1 year ago

@Speykious as shared, here is a sample javascript based where the defect you found is present also using natively the zoom plugin.

https://codepen.io/stockinail/pen/RwYabEv

EDIT: removing truncation, the codepen is working correctly. Therefore it sounds working well

stockiNail commented 1 year ago

@Speykious the issue is that the zoom plugin (but in whole CHART.JS world) the dates are managed as numbers (epoch). Therefore the zoom in a chart instance set, for example, min = 2.5 but when the date object is created, 0.5 is lost (in the other chart instance there is the difference). This is the reason why it doesn't work now and why with higher numbers as time, it works better. Let me think what I can do for that.

stockiNail commented 1 year ago

@Speykious I have found the solution, but unfortunately it could be a breaking change for Charba.

Currently the callback for min and max in time/timeSeries axis accepts only Date as return value. I have changed locally, changing the generics type from Date to Object, in order to accept also double, and it works.

image

The data that I have loaded have got epoch from 0 to 20.

The _minDate and _maxDate are now double and onComplete, I read min and max as double (instead of Date) from the scale.

I think I can try to add it to next version (minor) even if it's a breaking change... Hopefully it could not create a big mess... But we have to think about.

Speykious commented 1 year ago

I assume it is a breaking change because of the method type changing. But fundamentally, if we continue to use Dates, it shouldn't change, no?

stockiNail commented 1 year ago

The breaking change is only in MinMaxCallback class where the generics for time axis will not be a Date (as is today) but an Object. In this way the user can return a Date or a Number and it will be stored as number for min or max. Today a number is already stored but the time of a Date.

That said, the breaking change is ONLY for users who are not using the lambda to define the scriptable options. To work with time axis, we can continue (must!) working with Date, nothing changes here. Only for MinMaxCallback. In your use case, where you want to keep aligned more charts by Zoom plugin, the scriptable option should return a number (and not a Date anymore). This is the only change.

Nevertheless, I'm thinking to add it in next version, anyway, because many users are using lambda and nothing change for them.

I'm attaching here the code, FYI, see my comments related to Date --> double:

package org.pepstock.charba.elemento.client;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;

import org.pepstock.charba.client.Charba;
import org.pepstock.charba.client.callbacks.ScaleContext;
import org.pepstock.charba.client.colors.Color;
import org.pepstock.charba.client.configuration.CartesianLinearAxis;
import org.pepstock.charba.client.configuration.CartesianTimeSeriesAxis;
import org.pepstock.charba.client.configuration.TimeSeriesLineOptions;
import org.pepstock.charba.client.data.TimeSeriesItem;
import org.pepstock.charba.client.data.TimeSeriesLineDataset;
import org.pepstock.charba.client.enums.Bounds;
import org.pepstock.charba.client.enums.ModifierKey;
import org.pepstock.charba.client.enums.TickSource;
import org.pepstock.charba.client.enums.TimeUnit;
import org.pepstock.charba.client.gwt.widgets.TimeSeriesLineChartWidget;
import org.pepstock.charba.client.items.ScaleItem;
import org.pepstock.charba.client.zoom.ZoomContext;
import org.pepstock.charba.client.zoom.ZoomOptions;
import org.pepstock.charba.client.zoom.ZoomPlugin;
import org.pepstock.charba.client.zoom.enums.Mode;

import com.google.gwt.core.client.EntryPoint;
import com.google.gwt.user.client.Random;
import com.google.gwt.user.client.ui.RootPanel;

public class Main implements EntryPoint {

    private double _minDate = Double.NaN; // <-- as double instead of Date
    private double _maxDate = Double.NaN; // <-- as double instead of Date

    private List<TimeSeriesLineChartWidget> _rawCharts = new ArrayList<>();

    @Override
    public void onModuleLoad() {

        Charba.enable();

        ZoomPlugin.enable();
        TimeSeriesLineChartWidget chart1 = createLineChart();
        TimeSeriesLineChartWidget chart2 = createLineChart();

        _rawCharts.add(chart1);
        _rawCharts.add(chart2);

        RootPanel.get().add(chart1);
        RootPanel.get().add(chart2);
    }

    private TimeSeriesLineChartWidget createLineChart() {
        TimeSeriesLineChartWidget chart = new TimeSeriesLineChartWidget();

        TimeSeriesLineOptions chartOptions = chart.getOptions();
        chartOptions.setResponsive(true);
        chartOptions.setAspectRatio(5);
        chartOptions.setMaintainAspectRatio(true);
        chartOptions.getLegend().setDisplay(true);
        chartOptions.getTitle().setDisplay(true);
        chartOptions.getTitle().setText("oui");
        chartOptions.getTooltips().setEnabled(true);

        // axes options
        CartesianTimeSeriesAxis xAxis = chartOptions.getScales().getTimeAxis();
        xAxis.getTitle().setDisplay(true);
        xAxis.getTitle().setText("Time");
        xAxis.getTicks().setSource(TickSource.DATA);
        xAxis.getTime().setUnit(TimeUnit.MILLISECOND);
        xAxis.getTime().getDisplayFormats().setDisplayFormat(TimeUnit.SECOND, "m’ss”");
        xAxis.getTime().getDisplayFormats().setDisplayFormat(TimeUnit.MINUTE, "H:mm:ss");
        xAxis.getTime().getDisplayFormats().setDisplayFormat(TimeUnit.HOUR, "H:mm:ss");

        xAxis.setMin((ScaleContext context) -> {
            return _minDate; // <-- return double value instead of Date
        });

        xAxis.setMax((ScaleContext context) -> {
            return _maxDate; // <-- return double value instead of Date
        });
        CartesianLinearAxis yAxis = chartOptions.getScales().getLinearAxis();
        yAxis.getTitle().setDisplay(true);
        yAxis.getTitle().setText("Fromage");
        yAxis.setDisplay(true);
        yAxis.setBeginAtZero(true);

        // zoom options
        ZoomOptions zoomOptions = new ZoomOptions();
        zoomOptions.getPan().setEnabled(false);
        zoomOptions.getPan().setModifierKey(ModifierKey.ALT);
        zoomOptions.getPan().setMode(Mode.X);
        zoomOptions.getZoom().setMode(Mode.X);
        zoomOptions.getZoom().getDrag().setEnabled(false);
        zoomOptions.getZoom().getWheel().setEnabled(true);
        zoomOptions.getZoom().getWheel().setSpeed(0.3);
        zoomOptions.getZoom().getPinch().setEnabled(false);
        zoomOptions.store(chart);

        zoomOptions.getZoom().setCompletedCallback((ZoomContext context) -> {
            CartesianTimeSeriesAxis timeAxis = chartOptions.getScales().getTimeAxis();
            ScaleItem scaleAxis = chart.getNode().getScales().getItems().get(timeAxis.getId().value());
            _minDate = scaleAxis.getMin(); // <-- get double value instead of Date
            _maxDate = scaleAxis.getMax(); // <-- get double value instead of Date

            for (TimeSeriesLineChartWidget rawChart : _rawCharts) {
                if (rawChart == chart)
                  continue;
                rawChart.update();
            }
        });

        TimeSeriesItem[] data = new TimeSeriesItem[20];
        for (int i = 0; i < data.length; i++)
            data[i] = new TimeSeriesItem(new Date((long) i), Random.nextDouble());

        // dataset
        TimeSeriesLineDataset dataset = chart.newDataset();
        dataset.setLabel("fromage");
        dataset.setBorderColor(Color.CHARBA);
        dataset.setBorderWidth(1);
        dataset.setPointRadius(3);
        dataset.setTimeSeriesData(data);

        chart.getData().setDatasets(dataset);

        _rawCharts.add(chart);
        return chart;
    }

}
stockiNail commented 1 year ago

@Speykious I had more time yesterday to go in deep. You don't need new Charba version to link more charts. The key point is that all chart instances must be changed by the plugin and not setting the min or max to all others. Therefore we need to use the zoom plugin api to keep aligned all charts instance.

If you can try the following:

  1. remove scriptable options to min and max (not needed) from time axis.
  2. remove _minDate and _maxDate (not needed)
  3. use the following onComplete code:
zoomOptions.getZoom().setCompletedCallback((ZoomContext context) -> {
        CartesianTimeSeriesAxis timeAxis = chartOptions.getScales().getTimeAxis();
        ScaleItem scaleAxis = chart.getNode().getScales().getItems().get(timeAxis.getId().value());
    double minDate = scaleAxis.getMin();
    double maxDate = scaleAxis.getMax();
    ScaleRange range = new ScaleRange(minDate, maxDate);

    for (IsChart rawChart : CHARTS) {
        if (rawChart == cCahrt)
          continue;
        ZoomPlugin.zoomScale(rawChart, axis.getId(), range); // <-- zoom the other charts
    }
});
Speykious commented 1 year ago

That works too! Thanks for your time.

stockiNail commented 1 year ago

That works too! Thanks for your time.

The last code is also solving the problem related to the reset of the zoom. With the other version, it didn't work. FYI, I'll add a show case related to zooming on linked charts. Thank you too!

I'll close the issue when version 6.2 will be released. The same for #88.

stockiNail commented 1 year ago

Fixed in version 6.2

Added a show case with linked charts instances and zooming: Ext. plugins --> Zoom Plugin --> Zoom wheel grouping