D3 map replicating on zoom

I am using react with d3, and it is just repliacting the map on zoom, and not zooming in place, the demo loom video can be found below,

Here is the snippet to it:

import React, { useEffect, useRef, useState } from 'react';
import * as d3 from 'd3';
import * as topojson from 'topojson-client';

const Chart = () => {
  const svgRef = useRef(null);
  const [isMapLoading, setMapLoading] = useState<boolean>(false);
  const [geoData, setGeoData] = useState(null);
  const gRef = useRef(null);

  const width = 975;
  const height = 610;

  const zoomed = (event) => {
    const { transform } = event;
    gRef.current
      .attr('transform', transform)
      .attr('stroke-width', 1 / transform.k);
  };

  const zoom = d3.zoom().scaleExtent([1, 8]).on('zoom', zoomed);

  const reset = (states, svg) => {
    states.transition().style('fill', null);
    svg
      .transition()
      .duration(750)
      .call(
        zoom.transform,
        d3.zoomIdentity,
        d3.zoomTransform(svg.node()).invert([width / 2, height / 2])
      );
  };

  const clicked = (event, d, states) => {
    const path = d3.geoPath();
    const svg = d3.select(svgRef.current);

    const [[x0, y0], [x1, y1]] = path.bounds(d);
    event.stopPropagation();
    states.transition().style('fill', null);
    d3.select(this).transition().style('fill', 'red');
    svg
      .transition()
      .duration(750)
      .call(
        zoom.transform,
        d3.zoomIdentity
          .translate(width / 2, height / 2)
          .scale(
            Math.min(8, 0.9 / Math.max((x1 - x0) / width, (y1 - y0) / height))
          )
          .translate(-(x0 + x1) / 2, -(y0 + y1) / 2),
        d3.pointer(event, svg.node())
      );
  };

  useEffect(() => {
    setMapLoading(true);

    d3.json('geojson/regions/south_america.json')
      .then((data) => {
        setGeoData(data);
        setMapLoading(false);
      })
      .catch((err) => {
        console.error(err);
      });
  }, []);

  useEffect(() => {
    if (geoData) {
      const svg = d3
        .select(svgRef.current)
        .attr('viewBox', [0, 0, width, height])
        .attr('width', '100%')
        .attr('height', '100%')
        // eslint-disable-next-line @typescript-eslint/no-use-before-define
        .on('click', () => reset(states, svg));

      const path = d3.geoPath();

      const g = svg.append('g');

      gRef.current = g;

      const states = g
        .append('g')
        .attr('fill', '#444')
        .attr('cursor', 'pointer')
        .selectAll('path')
        .data(topojson.feature(geoData, geoData.objects.states).features)
        .join('path')
        .on('click', (event, d) => clicked(event, d, states))
        .attr('d', path);

      states.append('title').text((d) => d.properties.name);

      g.append('path')
        .attr('fill', 'none')
        .attr('stroke', 'white')
        .attr('stroke-linejoin', 'round')
        .attr(
          'd',
          path(
            topojson.mesh(geoData, geoData.objects.states, (a, b) => a !== b)
          )
        );
    }
  }, [geoData]);

  return <svg ref={svgRef} />;
};

const Page = () => {
  return <Chart />;
};

export default Page;
1 Like

Taking a quick look at your code, it looks like you’ve got svg.call(zoom) where zoom then modifies the svg. That creates a weird feedback loop where changes in the SVG are reflected in subsequent calls to zoom.

To avoid that, the standard technique is to place the SVG within a DIV of the same dimensions. You then call zoom on the DIV but modify the SVG. Here’s an example built to illustrate precisely this issue:

Thank you @mcmcclur for the quick response,

I am trying to create a geoJson map and I want it to behave zoom to a particular country if I click on that basically that country should move to the center, I will definitely try the approach you mentioned above but at the same time if you have any idea of how can I achieve the thing I mentioned above that would be of great help.

I guess this does what you want:

I was just trying to replicate it but I am having a hard time converting it into react, here is the updated code, but it still faces the same issue

const Chart = () => {
  const svgRef = useRef(null);
  const [isMapLoading, setMapLoading] = useState<boolean>(false);
  const [geoData, setGeoData] = useState(null);
  const gRef = useRef(null);

  const width = 975;
  const height = 610;

  const zoomed = (event) => {
    const { transform } = event;
    gRef.current
      .attr('transform', transform)
      .attr('stroke-width', 1 / transform.k);
  };

  const zoom = d3.zoom().scaleExtent([1, 8]).on('zoom', zoomed);

  const reset = (states, svg) => {
    states.transition().style('fill', null);
    svg
      .transition()
      .duration(750)
      .call(
        zoom.transform,
        d3.zoomIdentity,
        d3.zoomTransform(svg.node()).invert([width / 2, height / 2])
      );
  };

  const clicked = (event, d, states) => {
    console.log({ d });
    const path = d3.geoPath();
    const svg = d3.select(svgRef.current);

    const [[x0, y0], [x1, y1]] = path.bounds(d);
    event.stopPropagation();
    states.transition().style('fill', null);
    // d3.select(this).transition().style('fill', 'red');

    svg
      .transition()
      .duration(750)
      .call(
        zoom.transform,
        d3.zoomIdentity
          .translate(width / 2, height / 2)
          .scale(
            Math.min(8, 0.9 / Math.max((x1 - x0) / width, (y1 - y0) / height))
          )
          .translate(-(x0 + x1) / 2, -(y0 + y1) / 2),
        d3.pointer(event, svg.node())
      );
  };

  useEffect(() => {
    setMapLoading(true);

    d3.json('geojson/regions/south_america.json')
      .then((data) => {
        setGeoData(data);
        setMapLoading(false);
      })
      .catch((err) => {
        console.error(err);
      });
  }, []);

  useEffect(() => {
    if (geoData) {
      const svg = d3
        .select(svgRef.current)
        .attr('viewBox', [0, 0, width, height])
        .attr('width', '100%')
        .attr('height', '100%');

      const path = d3.geoPath();
      const g = svg.append('g');
      gRef.current = g;

      const states = g
        .append('g')
        .attr('fill', '#444')
        .attr('cursor', 'pointer')
        .selectAll('path')
        .data(topojson.feature(geoData, geoData.objects.states).features)
        .join('path')
        .on('click', (event, d) => clicked(event, d, states))
        .attr('d', path);

      svg.on('click', () => reset(states, svg));

      states.append('title').text((d) => d.properties.name);

      g.append('path')
        .attr('fill', 'none')
        .attr('stroke', 'white')
        .attr('stroke-linejoin', 'round')
        .attr(
          'd',
          path(
            topojson.mesh(geoData, geoData.objects.states, (a, b) => a !== b)
          )
        );

      svg.call(zoom);
    }
  }, [geoData]);

  return <svg ref={svgRef} />;
};

Also when I try to do something like placing the svg inside the div and calling zoom to it,

d3.select(containerRef.current)
      .transition()
      .duration(750)
      .call(
        zoom.transform,
        d3.zoomIdentity
          .translate(width / 2, height / 2)
          .scale(
            Math.min(8, 0.9 / Math.max((x1 - x0) / width, (y1 - y0) / height))
          )
          .translate(-(x0 + x1) / 2, -(y0 + y1) / 2),
        d3.pointer(event, svg.node())
      );

It creates some weird loop again