zoom + update + automatic panning in d3 : best practices?

I’m stuck with my (probably horribly convoluted) d3.js code trying to implement a way of always keeping the most recent data point visible during update. Everything I tried seems to break the chart completely. This leads me to believe that I’m doing the whole thing wrong, i.e. that my approach to combining zooming, data updating, etc is not as intended in d3.

I’d be extremely happy if you could help me un-shittify this code a bit. Thanks!

Below is a reproducible example.

<!DOCTYPE html>
<html lang="en">
<head>
    <script src="https://d3js.org/d3.v7.min.js"></script>
    <style>
        .chart-container {
            width: 100%;
            height: 500px;
        }
    </style>
</head>
<body>

<div id="chart" class="chart-container"></div>

<script>
    let chartsState = {};
    function createChart(containerId, initialData, colors, labels) {
        const svgWidth = document.getElementById(containerId).clientWidth;
        const svgHeight = document.getElementById(containerId).clientHeight;
        const margin = { top: 30, right: 30, bottom: 40, left: 50 };
        const width = svgWidth - margin.left - margin.right;
        const height = svgHeight - margin.top - margin.bottom;

        if (!chartsState[containerId]) {
            chartsState[containerId] = {
                currentZoomState: null,
            };
        }

        const svg = d3.select(`#${containerId}`)
            .append("svg")
            .attr("width", svgWidth)
            .attr("height", svgHeight)
            .append("g")
            .attr("transform", `translate(${margin.left},${margin.top})`);

        svg.append("defs")
            .append("clipPath")
            .attr("id", "clip")
            .append("rect")
            .attr("width", width)
            .attr("height", height);

        const numberOfXTicks = Math.max(6, Math.min(10, Math.floor(width / 100)));
        const numberOfYTicks = Math.max(6, Math.min(10, Math.floor(height / 30)));

        let data = initialData;

        const dataMax = d3.max(data.flat(), d => d.x);
        const dataMin = d3.min(data.flat(), d => d.x);

        const xScale = d3.scaleTime()
            .domain([new Date(dataMin / 1000), new Date(dataMax / 1000)])
            .range([0, width]);

        const yScale = d3.scaleLinear()
            .domain([0, d3.max(data.flat(), d => d.y)])
            .range([height, 0]);

        const xAxis = d3.axisBottom(xScale)
            .ticks(numberOfXTicks)
            .tickSize(-height);

        const yAxis = d3.axisLeft(yScale)
            .ticks(numberOfYTicks)
            .tickSize(-width);

        const gX = svg.append("g")
            .attr("class", "x-axis grid")
            .attr("transform", `translate(0,${height})`)
            .call(xAxis);

        const gY = svg.append("g")
            .attr("class", "y-axis grid")
            .call(yAxis);

        const line = d3.line()
            .x(d => xScale(new Date(d.x / 1000)))
            .y(d => yScale(d.y))
            .curve(d3.curveLinear);

        data.forEach((lineData, i) => {
            svg.append("path")
                .datum(lineData)
                .attr("fill", "none")
                .attr("stroke", colors[i])
                .attr("stroke-width", 1.5)
                .attr("class", "line")
                .attr("clip-path", "url(#clip)")
                .attr("d", line);
        });

        const zoom = d3.zoom()
            .on("zoom", zoomed);

        svg.append("rect")
            .attr("width", width)
            .attr("height", height)
            .style("fill", "none")
            .style("pointer-events", "all")
            .call(zoom);

        function updateChart(newData) {
            data.forEach((lineData, i) => {
                lineData.push(newData[i][newData[i].length - 1]);
            });

            const dataMax = d3.max(data.flat(), d => d.x);
            const dataMin = d3.min(data.flat(), d => d.x);
            xScale.domain([new Date(dataMin / 1000), new Date(dataMax / 1000)]);

            const newXScale = chartsState[containerId].currentZoomState
                ? chartsState[containerId].currentZoomState.rescaleX(xScale)
                : xScale;

            xAxis.scale(newXScale);
            gX.call(xAxis);

            const line = d3.line()
                .x(d => newXScale(new Date(d.x / 1000)))
                .y(d => yScale(d.y))
                .curve(d3.curveLinear);

            const lines = svg.selectAll(".line")
                .data(data);

            lines.enter()
                .append("path")
                .attr("class", "line")
                .merge(lines)
                .attr("d", line);

            lines.exit().remove();

            yScale.domain([0, d3.max(data.flat(), d => d.y)]);
            gY.call(yAxis.scale(yScale));
        }

        function zoomed(event) {
            const transform = event.transform;
            chartsState[containerId].currentZoomState = transform;

            const newXScale = transform.rescaleX(xScale);

            gX.call(xAxis.scale(newXScale).tickSize(-height));

            const line = d3.line()
                .x(d => newXScale(new Date(d.x / 1000)))
                .y(d => yScale(d.y))
                .curve(d3.curveLinear);

            svg.selectAll(".line")
                .attr("d", (d, i) => line(data[i]));
        }

        return { svg, xScale, yScale, xAxis, yAxis, containerId, update: updateChart };
    }

    function generateRandomData() {
        const now = Date.now();
        return [
            [{ x: now, y: Math.random() * 100 }],
            [{ x: now, y: Math.random() * 100 }]
        ];
    }

    const initialData = generateRandomData();
    const colors = ["steelblue", "orange"];
    const labels = ["Series 1", "Series 2"];

    const chart = createChart('chart', initialData, colors, labels);

    setInterval(() => {
        const newData = generateRandomData();
        chart.update(newData);
    }, 1000);

</script>

</body>
</html>

Can you summarize your current approach?

Essentially I’m trying to update xscale domain on data update to include the most recent data point while maintaining the current zoom level (by computing the visible data).

E.g. like the below. When I do this, the data shifts by one unit on each update, but maintains current pan, as in, it does NOT display the domain including the recent data point. I can’t seem to get this to work. I tried various ways to do this, but my guess is that my approach is flawed on a more fundamental level (which is why I posted a “full” standalone version of my chart code).

    const latestDataMax = d3.max(data.flat(), d => d.x);

    const currentDomain = xScale.domain();
    const visibleDuration = currentDomain[1] - currentDomain[0];

    const newDomainStart = new Date(latestDataMax / 1000 - visibleDuration);
    const newDomainEnd = new Date(latestDataMax / 1000);

    xScale.domain([newDomainStart, newDomainEnd]);

    const newXScale = chartsState[containerId].currentZoomState
        ? chartsState[containerId].currentZoomState.rescaleX(xScale)
        : xScale;

    xAxis.scale(newXScale);
    gX.call(xAxis);

To make sure I understand correctly: You want to translate your zoom transform so that the new point is included in the visible extent? If so, should the translation be just enough to include the new point, or should it center on the new point?

Yes. The user will have zoomed in to some extent (which should be preserved), and the visible data range translated so that the most recent datapoint is just visible on the right.

I’ve put together an example that might help you solve your problem: