javascripthtmlchart.js

How to create horizontal scrolling with a locked y axis for Chart.js?


I have a floating chart that I am using as a scheduler and I am trying to make it so the y axis is sticky when scrolling through the chart. I found this site and a video that I was trying to follow it but I can't get it to work, http://www.java2s.com/example/javascript/chart.js/create-a-horizontal-scrolling-chartjs-line-chart-with-a-locked-y-axis.html and https://youtu.be/IV6Qy5EQZlA?si=FjXcGK6W-4PjN0LP. Is there an easier way to make a sticky y axis than what I have found online or what am I missing that would make this code work? I was hoping there was an option to enable y axis sticky. I can see I have my Chart Axis up through the dev tools, but I can't seem to get it populated with the labels.

HTML:

<div class="chartWrapper">
    <div class="chartAreaWrapper">
        <canvas id="myChart" height="300" width="3000%"></canvas>
    </div>
    <canvas id="myChartAxis" height="300" width="100px"></canvas>
</div>

CSS:

.chartWrapper {
  position: relative;
}
.chartWrapper > canvas {
  position: absolute;
  left: 0;
  top: 0;
  pointer-events:none;
}
.chartAreaWrapper {
  width: 600px;
  overflow-x: scroll;
}

JavaScript:

var ctx = document.getElementById("myChart").getContext("2d");
    const ctx2 = document.getElementById('myChartAxis');
    
    const chartData = {
        labels: ["Person 1", "Person 2"],
      datasets: [{
            data: [
                [new Date('2024-11-18T08:00:00'), new Date('2024-11-18T10:30:00')],
                [new Date('2024-11-18T07:00:00'), new Date('2024-11-18T09:45:00')]
            ],
            backgroundColor: ["#8A2BE2", "#007F5C"]
        },
        {
            data: [
                [new Date('2024-11-18T11:00:00'), new Date('2024-11-18T15:00:00')],
                [new Date('2024-11-18T10:15:00'), new Date('2024-11-18T13:00:00')]
            ],
            backgroundColor: ["#8A2BE2", "#007F5C"]
        },
        {
            data: [
                [new Date('2024-11-18T14:00:00'), new Date('2024-11-18T15:00:00')],
                [new Date('2024-11-18T15:15:00'), new Date('2024-11-18T17:00:00')]
            ],
            backgroundColor: ["#8A2BE2", "#007F5C"]
        },
        {
            data: [
                [new Date('2024-11-18T15:15:00'), new Date('2024-11-18T17:00:00')],
                null
            ],
            backgroundColor: ["#8A2BE2", "#007F5C"]
        },
        ],
    }

    var myChart = new Chart(ctx, {
        type: 'bar',
        data: chartData,
        options: {
            maintainAspectRatio: false,
            responsive: false,
            indexAxis: 'y',
            plugins: {
                legend: {
                    display: false
                },
                title: {
                    display: false
                },
                tooltip: {
                    enabled: false
                }
            },
            scales: {
                y: {
                    stacked: true,
                    grid: {
                        display: false
                    },
                },
                x: {
                    grid: {
                        display: false
                    },
                    type: 'time',
                    time: {
                        unit: 'minute',

                    },
                    ticks: {
                        stepSize: 15,
                    },
                    min: new Date('2024-11-18T06:00:00'),
                    max: new Date('2024-11-18T17:00:00'),
                }
            },
            animation: {
                onComplete: function () {
                    var sourceCanvas = this.chart.ctx.canvas;
                    var copyWidth = this.scale.xScalePaddingLeft - 5;
                    var copyHeight = this.scale.endPoint + 5;
                    var targetCtx = document.getElementById("myChartAxis").getContext("2d");
                    targetCtx.canvas.width = copyWidth;
                    targetCtx.drawImage(sourceCanvas, 0, 0, copyWidth, copyHeight, 0, 0, copyWidth, copyHeight);
                }
            }
        }
    });

Solution

  • There is no simple option combination that would give you a statically positioned
    axis. The approach you included however will work, except for the onComplete handler that seems to come from some older version of chart.js.

    In a modern version of chart.js (the current version at the time of writing this is 4.4.6), this in onComplete is the chart instance itself and a direct translation of the included code could be:

    animation: {
       onComplete: function (){
          const sourceCanvas = this.ctx.canvas;
          const copyWidth = Math.ceil(this.scales.y.right);
          const copyHeight = Math.ceil(this.scales.y.bottom);
          const targetCtx = document.getElementById("myChartAxis").getContext("2d");
          targetCtx.canvas.width = copyWidth;
    
          targetCtx.fillStyle = 'white';
          targetCtx.fillRect(0, 0, copyWidth, copyHeight);
          targetCtx.drawImage(sourceCanvas, 0, 0, copyWidth, copyHeight, 0, 0, copyWidth, copyHeight);
       }
    }
    

    This is however slightly naive, since there's no provision taken for the case when there's a zoom level set in the browser, or the chart is viewed on a retina screen; in those cases the devicePixelRatio will not be 1 and the alignment of the two axes will fail. Taking the device pixel ratio used by chart.js, available as chart.currentDevicePixelRatio we'll get to:

    animation: {
       onComplete: function (){
          const dpr = this.currentDevicePixelRatio;
          const sourceCanvas = this.ctx.canvas;
          const copyWidth = Math.ceil(this.scales.y.right);
          const copyHeight = Math.ceil(this.scales.y.bottom);
          const targetCtx = document.getElementById("myChartAxis").getContext("2d");
          targetCtx.canvas.width = copyWidth;
          targetCtx.canvas.height = copyHeight;
    
          targetCtx.fillStyle = 'white';
          targetCtx.fillRect(0, 0, copyWidth, copyHeight);
          targetCtx.drawImage(sourceCanvas, 0, 0, copyWidth * dpr, copyHeight * dpr, 0, 0, copyWidth, copyHeight);
    
          this.ctx.fillStyle = 'white';
          this.ctx.fillRect(0, 0, copyWidth, copyHeight);
       }
    }
    

    The final fillRect on the original canvas is done to hide the original axis, after the overlapping one is drawn, since on a tabled like the iPad one can drag the chart to the right for some distance - elastic scroll or overscroll - it will come back to the original position when released, but it will show the inconvenient double axis.

    Here's that version in a jsFiddle.

    For the case that there's no animation, and in order to avoid possible issues with rendering while the animation is on, we could consider moving the code in a plugin's afterDraw method. That method will receive the chart instance as its first argument: afterDraw(chart), otherwise the code is identical. here it is in a demo snippet:

    const ctx = document.getElementById("myChart").getContext("2d");
    
    const chartData = {
       labels: ["Person 1", "Person 2"],
       datasets: [{
          data: [
             [new Date('2024-11-18T08:00:00'), new Date('2024-11-18T10:30:00')],
             [new Date('2024-11-18T07:00:00'), new Date('2024-11-18T09:45:00')]
          ],
          backgroundColor: ["#8A2BE2", "#007F5C"]
       },
          {
             data: [
                [new Date('2024-11-18T11:00:00'), new Date('2024-11-18T15:00:00')],
                [new Date('2024-11-18T10:15:00'), new Date('2024-11-18T13:00:00')]
             ],
             backgroundColor: ["#8A2BE2", "#007F5C"]
          },
          {
             data: [
                [new Date('2024-11-18T14:00:00'), new Date('2024-11-18T15:00:00')],
                [new Date('2024-11-18T15:15:00'), new Date('2024-11-18T17:00:00')]
             ],
             backgroundColor: ["#8A2BE2", "#007F5C"]
          },
          {
             data: [
                [new Date('2024-11-18T15:15:00'), new Date('2024-11-18T17:00:00')],
                null
             ],
             backgroundColor: ["#8A2BE2", "#007F5C"]
          },
       ],
    }
    
    Chart.defaults.borderColor = 'rgb(202, 202, 202)';
    const myChart = new Chart(ctx, {
       type: 'bar',
       data: chartData,
       options: {
          maintainAspectRatio: false,
          responsive: false,
          indexAxis: 'y',
          plugins: {
             legend: {
                display: false
             },
             title: {
                display: false
             },
             tooltip: {
                enabled: false
             }
          },
          scales: {
             y: {
                stacked: true,
                grid: {
                   display: false,
    
                },
             },
             x: {
                grid: {
                   display: false
                },
                type: 'time',
                time: {
                   unit: 'minute',
    
                },
                ticks: {
                   stepSize: 15,
                },
                min: new Date('2024-11-18T06:00:00'),
                max: new Date('2024-11-18T17:00:00'),
             }
          }
       },
       plugins: [{
          afterDraw: function (chart) {
             const dpr = chart.currentDevicePixelRatio;
             const sourceCanvas = chart.ctx.canvas;
             const copyWidth = Math.ceil(chart.scales.y.right);
             const copyHeight = Math.ceil(chart.scales.y.bottom);
             const targetCtx = document.getElementById("myChartAxis").getContext("2d");
             targetCtx.canvas.width = copyWidth;
             targetCtx.canvas.height = copyHeight;
    
             targetCtx.fillStyle = 'white';
             targetCtx.fillRect(0, 0, copyWidth, copyHeight);
             targetCtx.drawImage(sourceCanvas, 0, 0, copyWidth * dpr, copyHeight * dpr, 0, 0, copyWidth, copyHeight);
    
             chart.ctx.fillStyle = 'white';
             chart.ctx.fillRect(0, 0, copyWidth, copyHeight);
    
             document.querySelector('#dpr').innerText = `dpr = ${dpr}`;
          }
       }]
    });
    .chartWrapper {
       position: relative;
    }
    .chartWrapper > canvas {
       position: absolute;
       left: 0;
       top: 0;
       pointer-events:none;
       border1: 2px solid green
    }
    .chartAreaWrapper {
       width: 600px;
       overflow-x: scroll;
    }
    <div class="chartWrapper">
       <div class="chartAreaWrapper">
          <canvas id="myChart" height="220" width="3000%"></canvas>
       </div>
       <canvas id="myChartAxis" height="220" width="100px"></canvas>
    </div>
    <span id="dpr"></span>
    
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>

    A slightly different approach, that avoids some of the complications of the previous method, (while possibly creating others) is to use chart.js itself to draw the secondary axis. We can construct another chart for the second canvas with the same labels as the real one and with no data. We have to be careful to match the sizing settings of both axes, and then hide the x axis and all its features for the second canvas and hde the y axis for the first canvas, but not its grid lines. In order to have the space allocated for axes but not make them visible, we'll use transparent color rgba(0,0,0,0):

    const ctx = document.getElementById("myChart").getContext("2d");
    const ctx2 = document.getElementById('myChartAxis');
    
    const chartData = {
       labels: ["Person 1", "Person 2"],
       datasets: [{
          data: [
             [new Date('2024-11-18T08:00:00'), new Date('2024-11-18T10:30:00')],
             [new Date('2024-11-18T07:00:00'), new Date('2024-11-18T09:45:00')]
          ],
          backgroundColor: ["#8A2BE2", "#007F5C"]
       },
          {
             data: [
                [new Date('2024-11-18T11:00:00'), new Date('2024-11-18T15:00:00')],
                [new Date('2024-11-18T10:15:00'), new Date('2024-11-18T13:00:00')]
             ],
             backgroundColor: ["#8A2BE2", "#007F5C"]
          },
          {
             data: [
                [new Date('2024-11-18T14:00:00'), new Date('2024-11-18T15:00:00')],
                [new Date('2024-11-18T15:15:00'), new Date('2024-11-18T17:00:00')]
             ],
             backgroundColor: ["#8A2BE2", "#007F5C"]
          },
          {
             data: [
                [new Date('2024-11-18T15:15:00'), new Date('2024-11-18T17:00:00')],
                null
             ],
             backgroundColor: ["#8A2BE2", "#007F5C"]
          },
       ],
    }
    
    new Chart(ctx, {
       type: 'bar',
       data: chartData,
       options: {
          maintainAspectRatio: false,
          responsive: false,
          indexAxis: 'y',
          plugins: {
             legend: {
                display: false
             },
             title: {
                display: false
             },
             tooltip: {
                enabled: false
             }
          },
          scales: {
             y: {
                stacked: true,
                ticks:{
                   color: 'rgba(0,0,0,0)',
                },
                grid: {
                   display: false,
                },
             },
             x: {
                grid: {
                   display: false
                },
                type: 'time',
                time: {
                   unit: 'minute',
    
                },
                ticks: {
                   stepSize: 15,
                },
                min: new Date('2024-11-18T06:00:00'),
                max: new Date('2024-11-18T17:00:00'),
             }
          }
       }
    });
    new Chart(ctx2, {
       type: "bar",
       data: {labels: chartData.labels, datasets: [{}]},
       options: {
          maintainAspectRatio: false,
          responsive: false,
          indexAxis: 'y',
          scales:{
             x: {
                ticks: {
                   color: 'rgba(0,0,0,0)',
                },
                border: {
                   color: 'rgba(0,0,0,0)'
                },
                grid:{
                   color: 'rgba(0,0,0,0)'
                }
             },
             y: {
                grid:{
                   color: 'rgba(0,0,0,0)'
                },
                backgroundColor: 'white',
                stacked: true
             }
          },
          plugins:{
             legend:{
                display: false
             }
          }
       }
    });
    .chartWrapper {
       position: relative;
    }
    .chartWrapper > canvas {
       position: absolute;
       left: 0;
       top: 0;
       pointer-events:none;
       border1: 2px solid green
    }
    .chartAreaWrapper {
       width: 600px;
       overflow-x: scroll;
    }
    <div class="chartWrapper">
       <div class="chartAreaWrapper">
          <canvas id="myChart" height="220" width="3000%"></canvas>
       </div>
       <canvas id="myChartAxis" height="220" width="300px"></canvas>
    </div>
    
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>