javascriptd3.js

D3: Position marker does not follow graph when zooming and panning


I have a D3.js graph with a vertical red line that marks the value of variable x. The value of x is determined by a range slider on the page.

The problem is that when the graph is panned/zoomed, the vertical line does not move together with the graph's scales and content.

I've tried various approaches, but to no avail. How can I get the vertical line to keep its position relative to the graph content, and still update its horizontal position based on the value of x?

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Binet/Fibonacci</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/mathjs/13.2.0/math.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
    <style>
        body {
            font-family: Arial, sans-serif;
            text-align: center;
            margin: 20px;
        }

        #slider {
            margin: 20px;
            width: 80%;
        }

        svg {
            border: 1px solid black;
            margin-top: 20px;
        }
    </style>
</head>
<body>
    <input type="range" id="slider" min="0" max="30" step="0.02" value="0" />
    <p>Value of x: <span id="xValue">0.00</span></p>
    <p>Fibonacci number (y): <span id="yValue">0</span></p>
    <p>y real: <span id="yReal">0</span></p>
    <p>y imaginary: <span id="yImag">0</span></p>
    <p>y imaginary (decimal): <span id="yImagDec">0</span></p>

    <div>
        <label for="yImagMultip">y imaginary multiplier: </label>
        <input type="number" id="yImagMultip" value="1000" step="10" />
    </div>

    <svg width="800" height="400" id="chartArea"></svg>

    <script>
        const phi = math.bignumber((1 + Math.sqrt(5)) / 2);
        const psi = math.bignumber((1 - Math.sqrt(5)) / 2);

        function fibonacci(x) {
            const n = math.bignumber(x);
            const y = math.divide(
                math.add(
                    math.pow(phi, n),
                    math.multiply(-1, math.pow(psi, n))
                ),
                math.sqrt(5)
            );
            return y;
        }

        const slider = document.getElementById("slider");
        const xValue = document.getElementById("xValue");
        const yValue = document.getElementById("yValue");
        const yReal = document.getElementById("yReal");
        const yImag = document.getElementById("yImag");
        const yImagDec = document.getElementById("yImagDec");
        const yImagMultipInput = document.getElementById("yImagMultip");

        const svg = d3.select("#chartArea");
        const margin = { top: 20, right: 30, bottom: 30, left: 60 };
        const width = +svg.attr("width") - margin.left - margin.right;
        const height = +svg.attr("height") - margin.top - margin.bottom;

        const graphArea = svg.append("g")
            .attr("transform", `translate(${margin.left},${margin.top})`);

        let realPoints = [];
        let imaginaryPoints = [];
        let x; // x scale
        let y; // y scale
        let xAxis; // x axis
        let yAxis; // y axis
        let line; // line generator

        function updateData() {
            realPoints = [];
            imaginaryPoints = [];
            for (let x = 0; x <= 30; x += 0.01) {
                const yVal = fibonacci(x);
                realPoints.push({ x, y: math.re(yVal) }); // Real part
                const imaginaryPart = math.im(yVal); // Extract imaginary part
                imaginaryPoints.push({ x, y: imaginaryPart });
            }
        }

        function drawGraph() {
            // Update scales
            x = d3.scaleLinear()
                .domain([0, 30])
                .range([0, width]);

            y = d3.scaleLinear()
                .domain([-50, 250])
                .range([height, 0]);

            // Update axes
            xAxis = graphArea.selectAll(".x-axis").empty() ? graphArea.append("g").attr("class", "x-axis") : xAxis;
            yAxis = graphArea.selectAll(".y-axis").empty() ? graphArea.append("g").attr("class", "y-axis") : yAxis;

            xAxis.attr("transform", `translate(0,${height})`).call(d3.axisBottom(x));
            yAxis.call(d3.axisLeft(y));

            // Update or draw real curve
            line = d3.line()
                .x(d => x(d.x))
                .y(d => y(d.y));

            const realLinePath = graphArea.selectAll(".real-line")
                .data([realPoints]);

            realLinePath.enter()
                .append("path")
                .attr("fill", "none")
                .attr("stroke", "blue")
                .attr("class", "real-line")
                .merge(realLinePath)
                .attr("d", line);

            // Update or draw imaginary curve
            const imaginaryLinePath = graphArea.selectAll(".imaginary-line")
                .data([imaginaryPoints]);

            imaginaryLinePath.enter()
                .append("path")
                .attr("fill", "none")
                .attr("stroke", "orange")
                .attr("class", "imaginary-line")
                .merge(imaginaryLinePath)
                .attr("d", d3.line()
                    .x(d => x(d.x))
                    .y(d => y(d.y * +yImagMultipInput.value)));

            // Update vertical line
            updateVerticalLine();
        }

        function updateVerticalLine() {
            const currentX = +slider.value;
            const verticalLine = graphArea.selectAll(".current-line").data([currentX]);

            verticalLine.enter()
                .append("line")
                .attr("class", "current-line")
                .attr("stroke", "red")
                .attr("stroke-width", 1)
                .attr("stroke-dasharray", "4")
                .merge(verticalLine)
                .attr("x1", x(currentX))
                .attr("x2", x(currentX))
                .attr("y1", height)
                .attr("y2", 0);
        }

        function handleZoom(event) {
            const new_x = event.transform.rescaleX(x);
            const new_y = event.transform.rescaleY(y);

            xAxis.call(d3.axisBottom(new_x));
            yAxis.call(d3.axisLeft(new_y));

            graphArea.selectAll(".real-line")
                .attr("d", d3.line()
                    .x(d => new_x(d.x))
                    .y(d => new_y(d.y)));

            graphArea.selectAll(".imaginary-line")
                .attr("d", d3.line()
                    .x(d => new_x(d.x))
                    .y(d => new_y(d.y * +yImagMultipInput.value)));

            updateVerticalLine();
        }

        const zoom = d3.zoom()
            .scaleExtent([0.5, 20])
            .on("zoom", handleZoom);

        svg.append("rect")
            .attr("width", width)
            .attr("height", height)
            .style("fill", "none")
            .style("pointer-events", "all")
            .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')')
            .call(zoom);

        slider.addEventListener("input", function () {
            const x = parseFloat(this.value).toFixed(2);
            xValue.textContent = x;

            const yVal = fibonacci(x);
            yValue.textContent = math.format(yVal, { precision: 5 });
            yReal.textContent = math.format(math.re(yVal), { precision: 5 });
            const imaginaryPart = math.im(yVal);
            yImag.textContent = math.format(imaginaryPart, { precision: 5 });
            yImagDec.textContent = math.format(imaginaryPart, { precision: 15, notation: 'fixed' });
            
            if (imaginaryPart == 0) {
                yImag.textContent = "0";
                yImagDec.textContent = "0";
            }

            drawGraph(); // Refresh the graph
        });

        yImagMultipInput.addEventListener("input", drawGraph);

        updateData(); // Generate data first
        drawGraph(); // Draw the graph initially
    </script>
</body>
</html>

You may also notice that interacting with the page controls resets the zoom level. I should mention that this is not intentional. It's a separate issue that I will probably need to ask a separate question about. But if anyone reading knows how to fix this, I welcome your feedback.


Solution

  • Your updateVerticalLine function only ever uses the original scales and not the zoom modified ones to set the position. Here's a quick modification:

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Binet/Fibonacci</title>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/mathjs/13.2.0/math.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
        <style>
            body {
                font-family: Arial, sans-serif;
                text-align: center;
                margin: 20px;
            }
    
            #slider {
                margin: 20px;
                width: 80%;
            }
    
            svg {
                border: 1px solid black;
                margin-top: 20px;
            }
        </style>
    </head>
    <body>
        <input type="range" id="slider" min="0" max="30" step="0.02" value="0" />
        <p>Value of x: <span id="xValue">0.00</span></p>
        <p>Fibonacci number (y): <span id="yValue">0</span></p>
        <p>y real: <span id="yReal">0</span></p>
        <p>y imaginary: <span id="yImag">0</span></p>
        <p>y imaginary (decimal): <span id="yImagDec">0</span></p>
    
        <div>
            <label for="yImagMultip">y imaginary multiplier: </label>
            <input type="number" id="yImagMultip" value="1000" step="10" />
        </div>
    
        <svg width="800" height="400" id="chartArea"></svg>
    
        <script>
            const phi = math.bignumber((1 + Math.sqrt(5)) / 2);
            const psi = math.bignumber((1 - Math.sqrt(5)) / 2);
    
            function fibonacci(x) {
                const n = math.bignumber(x);
                const y = math.divide(
                    math.add(
                        math.pow(phi, n),
                        math.multiply(-1, math.pow(psi, n))
                    ),
                    math.sqrt(5)
                );
                return y;
            }
    
            const slider = document.getElementById("slider");
            const xValue = document.getElementById("xValue");
            const yValue = document.getElementById("yValue");
            const yReal = document.getElementById("yReal");
            const yImag = document.getElementById("yImag");
            const yImagDec = document.getElementById("yImagDec");
            const yImagMultipInput = document.getElementById("yImagMultip");
    
            const svg = d3.select("#chartArea");
            const margin = { top: 20, right: 30, bottom: 30, left: 60 };
            const width = +svg.attr("width") - margin.left - margin.right;
            const height = +svg.attr("height") - margin.top - margin.bottom;
    
            const graphArea = svg.append("g")
                .attr("transform", `translate(${margin.left},${margin.top})`);
    
            let realPoints = [];
            let imaginaryPoints = [];
            let x; // x scale
            let y; // y scale
            let xAxis; // x axis
            let yAxis; // y axis
            let line; // line generator
    
            function updateData() {
                realPoints = [];
                imaginaryPoints = [];
                for (let x = 0; x <= 30; x += 0.01) {
                    const yVal = fibonacci(x);
                    realPoints.push({ x, y: math.re(yVal) }); // Real part
                    const imaginaryPart = math.im(yVal); // Extract imaginary part
                    imaginaryPoints.push({ x, y: imaginaryPart });
                }
            }
    
            function drawGraph() {
                // Update scales
                x = d3.scaleLinear()
                    .domain([0, 30])
                    .range([0, width]);
    
                y = d3.scaleLinear()
                    .domain([-50, 250])
                    .range([height, 0]);
    
                // Update axes
                xAxis = graphArea.selectAll(".x-axis").empty() ? graphArea.append("g").attr("class", "x-axis") : xAxis;
                yAxis = graphArea.selectAll(".y-axis").empty() ? graphArea.append("g").attr("class", "y-axis") : yAxis;
    
                xAxis.attr("transform", `translate(0,${height})`).call(d3.axisBottom(x));
                yAxis.call(d3.axisLeft(y));
    
                // Update or draw real curve
                line = d3.line()
                    .x(d => x(d.x))
                    .y(d => y(d.y));
    
                const realLinePath = graphArea.selectAll(".real-line")
                    .data([realPoints]);
    
                realLinePath.enter()
                    .append("path")
                    .attr("fill", "none")
                    .attr("stroke", "blue")
                    .attr("class", "real-line")
                    .merge(realLinePath)
                    .attr("d", line);
    
                // Update or draw imaginary curve
                const imaginaryLinePath = graphArea.selectAll(".imaginary-line")
                    .data([imaginaryPoints]);
    
                imaginaryLinePath.enter()
                    .append("path")
                    .attr("fill", "none")
                    .attr("stroke", "orange")
                    .attr("class", "imaginary-line")
                    .merge(imaginaryLinePath)
                    .attr("d", d3.line()
                        .x(d => x(d.x))
                        .y(d => y(d.y * +yImagMultipInput.value)));
    
                // Update vertical line
                updateVerticalLine(x);
            }
    
            function updateVerticalLine(x_to_use) {
                const currentX = +slider.value;
                const verticalLine = graphArea.selectAll(".current-line").data([currentX]);
    
                verticalLine.enter()
                    .append("line")
                    .attr("class", "current-line")
                    .attr("stroke", "red")
                    .attr("stroke-width", 1)
                    .attr("stroke-dasharray", "4")
                    .merge(verticalLine)
                    .attr("x1", x_to_use(currentX))
                    .attr("x2", x_to_use(currentX))
                    .attr("y1", height)
                    .attr("y2", 0);
            }
    
            function handleZoom(event) {
                const new_x = event.transform.rescaleX(x);
                const new_y = event.transform.rescaleY(y);
    
                xAxis.call(d3.axisBottom(new_x));
                yAxis.call(d3.axisLeft(new_y));
    
                graphArea.selectAll(".real-line")
                    .attr("d", d3.line()
                        .x(d => new_x(d.x))
                        .y(d => new_y(d.y)));
    
                graphArea.selectAll(".imaginary-line")
                    .attr("d", d3.line()
                        .x(d => new_x(d.x))
                        .y(d => new_y(d.y * +yImagMultipInput.value)));
    
                updateVerticalLine(new_x);
            }
    
            const zoom = d3.zoom()
                .scaleExtent([0.5, 20])
                .on("zoom", handleZoom);
    
            svg.append("rect")
                .attr("width", width)
                .attr("height", height)
                .style("fill", "none")
                .style("pointer-events", "all")
                .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')')
                .call(zoom);
    
            slider.addEventListener("input", function () {
                const x = parseFloat(this.value).toFixed(2);
                xValue.textContent = x;
    
                const yVal = fibonacci(x);
                yValue.textContent = math.format(yVal, { precision: 5 });
                yReal.textContent = math.format(math.re(yVal), { precision: 5 });
                const imaginaryPart = math.im(yVal);
                yImag.textContent = math.format(imaginaryPart, { precision: 5 });
                yImagDec.textContent = math.format(imaginaryPart, { precision: 15, notation: 'fixed' });
                
                if (imaginaryPart == 0) {
                    yImag.textContent = "0";
                    yImagDec.textContent = "0";
                }
    
                drawGraph(); // Refresh the graph
            });
    
            yImagMultipInput.addEventListener("input", drawGraph);
    
            updateData(); // Generate data first
            drawGraph(); // Draw the graph initially
        </script>
    </body>
    </html>