Home > Enterprise >  D3 Bar Chart Zoom Not Centered
D3 Bar Chart Zoom Not Centered

Time:02-21

I have a bar chart with zoom function. The issue is, the zooming isn't actually centered. If, I place the cursor, on a bar and zoom, the bar underneath the cursor moves away as opposed to staying there, However, if I set the MARGIN.LEFT = 0, then the issue is rectified and No matter what bar I have my cursor on, when I zoom the bar stays there, right underneath. Could anyone help me with this?

Working Code Here: https://codesandbox.io/s/d3-zoom-not-centered-sfziyk

D3 Code:

const MARGIN = {
  LEFT: 60,
  RIGHT: 40,
  TOP: 10,
  BOTTOM: 130
};
// total width incl margin
const VIEWPORT_WIDTH = 1140;
// total height incl margin
const VIEWPORT_HEIGHT = 400;

const WIDTH = VIEWPORT_WIDTH - MARGIN.LEFT - MARGIN.RIGHT;
const HEIGHT = VIEWPORT_HEIGHT - MARGIN.TOP - MARGIN.BOTTOM;

const svg = d3
  .select(".chart-container")
  .append("svg")
  .attr("width", WIDTH   MARGIN.LEFT   MARGIN.RIGHT)
  .attr("height", HEIGHT   MARGIN.TOP   MARGIN.BOTTOM);

const g = svg
  .append("g")
  .attr("transform", `translate(${MARGIN.LEFT}, ${MARGIN.TOP})`);

g.append("text")
  .attr("class", "x axis-label")
  .attr("x", WIDTH / 2)
  .attr("y", HEIGHT   110)
  .attr("font-size", "20px")
  .attr("text-anchor", "middle")
  .text("Month");

g.append("text")
  .attr("class", "y axis-label")
  .attr("x", -(HEIGHT / 2))
  .attr("y", -60)
  .attr("font-size", "20px")
  .attr("text-anchor", "middle")
  .attr("transform", "rotate(-90)")
  .text("");

const zoom = d3.zoom().scaleExtent([0.5, 10]).on("zoom", zoomed);
svg.call(zoom);
function zoomed(event) {
  x.range([0, WIDTH].map((d) => event.transform.applyX(d)));
  barsGroup
    .selectAll("rect.profit")
    .attr("x", (d) => x(d.month))
    .attr("width", 0.5 * x.bandwidth());
  barsGroup
    .selectAll("rect.revenue")
    .attr("x", (d) => x(d.month)   0.5 * x.bandwidth())
    .attr("width", 0.5 * x.bandwidth());
  xAxisGroup.call(xAxisCall);
}

const x = d3.scaleBand().range([0, WIDTH]).paddingInner(0.3).paddingOuter(0.2);

const y = d3.scaleLinear().range([HEIGHT, 0]);

const xAxisGroup = g
  .append("g")
  .attr("class", "x axis")
  .attr("transform", `translate(0, ${HEIGHT})`);

const yAxisGroup = g.append("g").attr("class", "y axis");

const xAxisCall = d3.axisBottom(x);

const yAxisCall = d3
  .axisLeft(y)
  .ticks(3)
  .tickFormat((d) => "$"   d);

const defs = svg.append("defs");
const barsClipPath = defs
  .append("clipPath")
  .attr("id", "bars-clip-path")
  .append("rect")
  .attr("x", 0)
  .attr("y", 0)
  .attr("width", WIDTH)
  .attr("height", 400);

const barsGroup = g.append("g");
const zoomGroup = barsGroup.append("g");

barsGroup.attr("class", "bars");
zoomGroup.attr("class", "zoom");

barsGroup.attr("clip-path", "url(#bars-clip-path)");
xAxisGroup.attr("clip-path", "url(#bars-clip-path)");

d3.csv("data.csv").then((data) => {
  data.forEach((d) => {
    d.profit = Number(d.profit);
    d.revenue = Number(d.revenue);
    d.month = d.month;
  });

  var y0 = d3.max(data, (d) => d.profit);
  var y1 = d3.max(data, (d) => d.revenue);

  var maxdomain = y1;

  if (y0 > y1) var maxdomain = y0;

  x.domain(data.map((d) => d.month));
  y.domain([0, maxdomain]);

  xAxisGroup
    .call(xAxisCall)
    .selectAll("text")
    .attr("y", "10")
    .attr("x", "-5")
    .attr("text-anchor", "end")
    .attr("transform", "rotate(-40)");

  yAxisGroup.call(yAxisCall);

  const rects = zoomGroup.selectAll("rect").data(data);

  rects.exit().remove();

  rects
    .attr("y", (d) => y(d.profit))
    .attr("x", (d) => x(d.month))
    .attr("width", 0.5 * x.bandwidth())
    .attr("height", (d) => HEIGHT - y(d.profit));

  rects
    .enter()
    .append("rect")
    .attr("class", "profit")
    .attr("y", (d) => y(d.profit))
    .attr("x", (d) => x(d.month))
    .attr("width", 0.5 * x.bandwidth())
    .attr("height", (d) => HEIGHT - y(d.profit))
    .attr("fill", "grey");

  const rects_revenue = zoomGroup.selectAll("rect.revenue").data(data);

  rects_revenue.exit().remove();

  rects_revenue
    .attr("y", (d) => y(d.revenue))
    .attr("x", (d) => x(d.month))
    .attr("width", 0.5 * x.bandwidth())
    .attr("height", (d) => HEIGHT - y(d.revenue));

  rects_revenue
    .enter()
    .append("rect")
    .attr("class", "revenue")
    .style("fill", "red")
    .attr("y", (d) => y(d.revenue))
    .attr("x", (d) => x(d.month)   0.5 * x.bandwidth())
    .attr("width", 0.5 * x.bandwidth())
    .attr("height", (d) => HEIGHT - y(d.revenue))
    .attr("fill", "grey");
});

CodePudding user response:

When you call the zoom on the svg, all zoom behaviour is relative to the svg.

Imagine that your x-axis is at initial zoom level of length 100 representing the domain [0, 100]. So the x-scale has range([0, 100]) and domain([0, 100]). Add a left margin of 10.

If you zoom by scale 2 at the midpoint of your axis at x=50 you would expect to get the following behaviour after the zoom:

  • The midpoint does not move.
  • The interval [25, 75] is visible.

However, since the zoom is called on the svg you have to account for the left margin of 10. The zoom does not occur at the midpoint but at x = 10 50 = 60. The transform is thus x -> x * k t with k = 2 and t = -60. This results in

  • x = 50 -> 2 * 50 - 60 = 40,
  • x = 80 -> 2 * 80 - 60 = 100,
  • x = 30 -> 2 * 30 - 60 = 0.

Visible after the zoom is the interval [30, 80] and the point x = 50 is shifted to the left.

This is what you observe in your chart.

In order to get the expected behaviour, you can do two things:

a. Follow the bar chart example where the range of the x-scale does not start at 0 but at the left margin. The g which is translated by margin.left and margin.top is also omitted here. Instead, the ranges of the axes incorporate the margins directly.

b. Add a rect with fill: none; pointer-events: all; to the svg that is of the size of the chart without the margins. Then call the zoom on that rectangle, as done in this example.

Note that all the new examples on ObservableHQ follow the pattern "a" that needs fewer markup.

  • Related