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.
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>