Question Details

No question body available.

Tags

javascript d3.js charts

Answers (1)

Accepted Answer Available
Accepted Answer
October 20, 2025 Score: 1 Rep: 15,903 Quality: High Completeness: 80%

When you pan or scale the graph interior, you're not only setting zoomTransform = event.transform but also (implicitly) updating a zoom state variable on the SVG node. (This is how event.transform gives you not just the delta but also the current zoom level, so that zoomTransform.rescaleX(xScale) continues to do the right thing after the first tick.) The “resetting” you're observing when panning after dragging the axes is because the transform stored on the SVG is not updated when the axes are dragged, which means a stale value is used to produce event.transform.

This should be easy to solve: just update the transform with x- and y-scales when dragging the axes. But unfortunately, d3 currently does not let you specify a transform’s x scale and y scale separately (no, seriously). As a result we'll have to implement what you might hope d3 would do automatically ourselves.

The following is adapted from https://observablehq.com/@d3/x-y-zoom. The main change from your code is that each axis gets its own transform (tx and ty), which update when either the main graph is panned or scaled or when either axis is dragged. Note that rather than update the values of tx and ty to those given by event.transform, we update them using the delta between event.transform and zoomTransform (because tx and ty are derived from two separate sources). We've also removed currentXScale and currentYScale — a reasonable attempt at mimicking additional x and y transforms — and just get brand new scales from the transforms (tx().rescaleX(xScale)) on every tick.

const container = d3.select("#container"); const width = 1000; const height = 600;

const margin = { top: 40, right: 60, bottom: 60, left: 60 }; const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom;

// Base scales (never mutate these) let xScale = d3.scaleLinear().domain([0, 100]).range([0, innerWidth]); let yScale = d3.scaleLinear().domain([0, 1]).range([innerHeight, 0]);

// Zoom transform let zoomTransform = d3.zoomIdentity;

// Initial data const data = d3.range(0, 20).map(d => ({ x: d * 5, y: Math.random() }));

const svg = container.append("svg") .attr("width", width) .attr("height", height);

// set up the ancillary zooms and an accessor for their transforms const gx = svg.append("g"); const gy = svg.append("g"); const zoomX = d3.zoom().scaleExtent([0.5, 5]); const zoomY = d3.zoom().scaleExtent([0.5, 5]); const tx = () => d3.zoomTransform(gx.node()); const ty = () => d3.zoomTransform(gy.node()); gx.call(zoomX).attr("pointer-events", "none"); gy.call(zoomY).attr("pointer-events", "none");

// center the action (handles multitouch) function center(event, target) { if (event.sourceEvent) { const p = d3.pointers(event, target); return [d3.mean(p, d => d[0]), d3.mean(p, d => d[1])]; } return [width / 2, height / 2]; }

const zoom = d3.zoom() .on("zoom", (event) => { const t = event.transform; const k = t.k / zoomTransform.k;

if (k === 1) { // pure translation? gx.call(zoomX.translateBy, (t.x - zoomTransform.x) / tx().k, 0); gy.call(zoomY.translateBy, 0, (t.y - zoomTransform.y) / ty().k); } else { // if not, we're zooming on a fixed point const point = center(event, this); gx.call(zoomX.scaleBy, k, point); gy.call(zoomY.scaleBy, k, point); }

zoomTransform = t;

draw(); });

svg.call(zoom);

function draw() { const xr = tx().rescaleX(xScale); const yr = ty().rescaleY(yScale);

svg.selectAll("circle").remove();

svg.selectAll("circle") .data(data) .join("circle") .attr("cx", d => margin.left + xr(d.x)) .attr("cy", d => margin.top + yr(d.y)) .attr("r", 10) .style("fill", "steelblue");

// Axes svg.selectAll(".x-axis").remove(); svg.selectAll(".y-axis").remove();

svg.append("g") .attr("class", "x-axis") .attr("transform", translate(${margin.left}, ${height - margin.bottom})) .call(d3.axisBottom(xr).ticks(10));

svg.append("g") .attr("class", "y-axis") .attr("transform", translate(${margin.left}, ${margin.top})) .call(d3.axisLeft(yr).ticks(10)); }

const xHandleSvg = container.append("svg") .attr("width", "100%") .attr("height", margin.bottom) .style("position", "absolute") .style("bottom", "0px") .style("left", "0px");

xHandleSvg.append("rect") .attr("width", "100%") .attr("height", margin.bottom) .attr("fill", "transparent") .style("cursor", "ew-resize") .call(d3.drag().on("drag", (event) => { const xr = tx().rescaleX(xScale); const dx = event.dx; const [xMin, xMax] = xr.range(); const center = (xMin + xMax) / 2; const scaleFactor = 1 + dx / innerWidth;

gx.call(zoomX.scaleBy, scaleFactor, [center, 0]);

draw(); }));

const yHandleSvg = container.append("svg") .attr("width", margin.left) .attr("height", "100%") .style("position", "absolute") .style("top", ${margin.top}px) .style("left", "0px");

yHandleSvg.append("rect") .attr("width", margin.left) .attr("height", "100%") .attr("fill", "transparent") .style("cursor", "ns-resize") .call(d3.drag().on("drag", (event) => { const yr = ty().rescaleY(yScale); const dy = event.dy; const [yMin, yMax] = yScale.range(); const center = (yMin + yMax) / 2; const scaleFactor = 1 - dy / innerHeight;

gy.call(zoomY.scaleBy, scaleFactor, [0, center]); draw(); }));

draw();