Search code examples
javascripttypescriptcanvaschart.jshtml5-canvas

Obtaining the coordinate of a null value when using spanGaps with ChartJS?


I'm wondering if ChartJS exposes an API for plugins that allows us to obtain the coordinate of a null point that has been "Spanned" by ChartJS?

For example as illustrated in this question ChartJS enables smooth curves through null points when settings spanGaps to true.

So we could have data points like this.

data: [7, null, 11, null,  5 , null,  8, null,   3, null,  7],

Corresponding to these labels.

["Red", "Blue", "Yellow", "Green", "Purple", "Orange", "Blue", "Yellow", "Green", "Purple", "Green"],

Is there a way ( Perhaps via the Plugin API ) to get the values of the nulls?

So for a given quadratic curve that chart JS draws for:

data: [7, null, 11, null,  5 , null,  8, null,   3, null,  7],

We could call for example:

const arr = ChartJSPluginAPI.deriveNulls(data);

And get something like:

data: [7, 8, 11, 6, 5 , 6,  8, 4, 3, 5, 7],

Thoughts?


Solution

  • Finding the interpolated values as they are calculated by chart.js is not trivial, because chart.js performs the interpolation in graphical mode only, so we have to get the graphical values of the existing points for the relevant datasets meta, perform the interpolation, and then get back to the real space, using the y-axis scaling. This is however safer than trying to emulate the interpolation mathematically in real space.

    The first thing in finding the intermediate values is to identify the functions used by chart.js to interpolate all the values of the curves; there are three functions: _steppedInterpolation for stepped line charts, _bezierInterpolation if there is a tension option set, and the default linear interpolation as _pointInLine.

    const _bezierInterpolation = Chart.helpers._bezierInterpolation,
        _steppedInterpolation = Chart.helpers._steppedInterpolation,
        _pointInLine = Chart.helpers._pointInLine;
    

    Note that if modules are used, the helpers module needs to be imported separately.

    An important point is to perform the computation after the animation is completed, since for instance the bezier coefficients (if bezier interpolation was used) are constantly recomputed during the animation, and their final values can only be obtained after the animation is finalized. So, we implement the computation in the animation's onComplete handler:

    onComplete: function({chart}){
       const datasetMetas = chart.getSortedVisibleDatasetMetas();
       for(const datasetMeta of datasetMetas){
          if(datasetMeta.type === 'line'){
             const controller = datasetMeta.controller,
                spanGaps = controller.options.spanGaps,
                dataRaw = controller._data;
             if(spanGaps && dataRaw.includes(null)){
                const gData = datasetMeta.data,
                   yScale = datasetMeta.yScale,
                   yValues = []; // the final result
                const tension = controller.options.tension || controller.options.elements.line.tension;
                   interpolation = controller.options.stepped ? _steppedInterpolation :
                      tension ? _bezierInterpolation : _pointInLine;
                for(let i = 0; i < gData.length; i++){
                   if(dataRaw[i] !== null){
                      yValues.push(dataRaw[i]);
                   }
                   else if(i === 0 || i ===gData.length-1){
                      yValues.push(null); // no interpolation for extreme points
                   }
                   else{
                      const pLeft = gData[i-1],
                         pThis = gData[i],
                         pRight = gData[i+1];
                      const xgLeft = pLeft.x, xg = pThis.x, xgRight = pRight.x,
                         frac = (xg - xgLeft) / (xgRight - xgLeft);
                      let {y: yg} = interpolation(pLeft, pRight, frac);
                      yValues.push(yScale.getValueForPixel(yg));
                   }
                }
                console.log(`For dataset ${controller.index}:`, yValues);
             }
          }
       }
    }
    

    This solution assumes the index axis is x and the value axis is y; it should work regardless of the type of the x axis (category, linear, time, etc.).

    Here's a full snippet with this solution applied to the OP example.

    const _bezierInterpolation = Chart.helpers._bezierInterpolation,
        _steppedInterpolation = Chart.helpers._steppedInterpolation,
        _pointInLine = Chart.helpers._pointInLine;
    // note: different access with modules
    
    const config = {
        type: 'line',
        data: {
            labels: ["Red", "Blue", "Yellow", "Green", "Purple", "Orange", "Blue", "Yellow", "Green", "Purple", "Green"],
            datasets: [{
                label: '# of Votes',
                data: [7, null, 11, null,  5 , null,  8, null,   3, null,  7],
                spanGaps: true,
                fill: true,
                borderWidth: 1,
                pointHitRadius: 25,
                tension: 0.4
            }]
        },
        options: {
           animation:{
                onComplete: function({chart}){
                    const datesetMetas = chart.getSortedVisibleDatasetMetas();
                    for(const datasetMeta of datesetMetas){
                        if(datasetMeta.type === 'line'){
                            const controller = datasetMeta.controller,
                                spanGaps = controller.options.spanGaps,
                                dataRaw = controller._data;
                            if(spanGaps && dataRaw.includes(null)){
                                const gData = datasetMeta.data,
                                    yScale = datasetMeta.yScale,
                                    yValues = []; // the final result
                                const tension = controller.options.tension || controller.options.elements.line.tension;
                                    interpolation = controller.options.stepped ? _steppedInterpolation :
                                        tension ? _bezierInterpolation : _pointInLine;
                                for(let i = 0; i < gData.length; i++){
                                    if(dataRaw[i] !== null){
                                        yValues.push(dataRaw[i]);
                                    }
                                    else if(i === 0 || i ===gData.length-1){
                                        yValues.push(null); // no interpolation for extreme points
                                    }
                                    else{
                                        const pLeft = gData[i-1],
                                            pThis = gData[i],
                                            pRight = gData[i+1];
                                        const xgLeft = pLeft.x, xg = pThis.x, xgRight = pRight.x,
                                            frac = (xg - xgLeft) / (xgRight - xgLeft);
                                        let {y: yg} = interpolation(pLeft, pRight, frac);
                                        yValues.push(yScale.getValueForPixel(yg));
                                    }
                                }
                                console.log(`For dataset ${controller.index}:`, yValues);
                            }
                        }
                    }
                }
            },
            scales: {
                y: {
                    min: 0,
                    max: 20
                }
            }
        }
    }
    
    const chart = new Chart('chartJSContainer', config);
    <div style="min-height: 60vh">
        <canvas id="chartJSContainer" style="background-color: #eee;">
        </canvas>
    </div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js" integrity="sha512-ZwR1/gSZM3ai6vCdI+LVF1zSq/5HznD3ZSTk7kajkaj4D292NLuduDCO1c/NT8Id+jE58KYLKT7hXnbtryGmMg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>