d3动画地图:放大后,动画开始时如何正确投射点?

d3 animated map: after zooming in, how to correctly project points when animation begins?

我在 d3 中制作了随时间变化的点地图。它很好用。我添加了缩放。那也行。然而,它们并不完全协同工作。

我发现当我调用函数绘制圆圈时,我需要重新设置投影。我已经正确调整了比例。因此,如果您放大并按播放,圆圈的大小是正确的。

但是它们不在正确的位置,因为我不知道投影的 translate 部分需要是什么。

因此,如果您缩放和平移,然后播放,您会看到如下内容:

如果有人能提供见解,我将不胜感激。我的代码在下面,这里还有一个 Plunker:https://plnkr.co/edit/HlMLKgARm0hgzLPN?open=lib%2Fscript.js (us.json 文件太大,无法粘贴到此处,但在 plunker 中。)

<!DOCTYPE html>

<head>
  <meta charset="utf-8">
  <script src="https://d3js.org/d3.v4.min.js"></script>
  <script src="//d3js.org/topojson.v1.min.js"></script>
  <script src="https://unpkg.com/geo-albers-usa-territories@0.1.0/dist/geo-albers-usa-territories.js"></script>

  <style>
    body {
      font-family: Arial, Helvetica, sans-serif;
      font-size: 12px;
      color: #696969;
    }

    #play-button {
      position: absolute;
      bottom: 130px;
      left: 50px;
      background: #333;
      padding-right: 26px;
      border-radius: 3px;
      border: none;
      color: white;
      margin: 0;
      padding: 0 12px;
      width: 60px;
      cursor: pointer;
      height: 30px;
    }

    #play-button:hover {
      background-color: #696969;
    }

    .ticks {
      font-size: 10px;
    }

    .track,
    .track-inset,
    .track-overlay {
      stroke-linecap: round;
    }

    .track {
      stroke: #000;
      stroke-opacity: 0.3;
      stroke-width: 10px;
    }

    .track-inset {
      stroke: #dcdcdc;
      stroke-width: 8px;
    }

    .track-overlay {
      pointer-events: stroke;
      stroke-width: 50px;
      stroke: transparent;
      cursor: crosshair;
    }

    .handle {
      fill: #fff;
      stroke: #000;
      stroke-opacity: 0.5;
      stroke-width: 1.25px;
    }

    #zoom-buttons {
      position: absolute;
      margin-left: 10px;
      margin-top: 10px;
      padding: 5px;
      background: #fff;
    }

    #zoom-buttons button {
      background: #efefef;
      color: #231F20;
      border: 0;
      padding: 0;
      border-radius: 2px;
      width: 25px;
      height: 25px;
    }
  </style>
</head>

<body>
  <div id="zoom-buttons">
    <button id="zoom-in">+</button>
    <button id="zoom-out">-</button>
  </div>
  <div id="vis">
    <button id="play-button">Play</button>
  </div>
  <script>

    var formatDateIntoYear = d3.timeFormat("%Y");
    var formatDate = d3.timeFormat("%b %Y");
    var parseDate = d3.timeParse("%m/%d/%y");

    var startDate = new Date("2004-11-01"),
      endDate = new Date("2017-04-01");

    var margin = { top: 50, right: 50, bottom: 0, left: 50 },
      width = 960 - margin.left - margin.right,
      height = 750 - margin.top - margin.bottom;

    var svg = d3.select("#vis")
      .append("svg")
      .attr("width", width + margin.left + margin.right)
      .attr("height", height + margin.top + margin.bottom);

    var projection = geoAlbersUsaTerritories.geoAlbersUsaTerritories()
      .scale(width + 100)
      .translate([width / 2, height / 2.2]);

    var path = d3.geoPath()
      .projection(projection);

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

    svg.call(zoom)
    ////////// slider //////////

    var moving = false;
    var currentValue = 0;
    var targetValue = width;

    var playButton = d3.select("#play-button");

    var x = d3.scaleTime()
      .domain([startDate, endDate])
      .range([0, targetValue])
      .clamp(true);

    var slider = svg.append("g")
      .attr("class", "slider")
      .attr("transform", "translate(" + margin.left + "," + height + ")");

    slider.append("line")
      .attr("class", "track")
      .attr("x1", x.range()[0])
      .attr("x2", x.range()[1])
      .select(function () { return this.parentNode.appendChild(this.cloneNode(true)); })
      .attr("class", "track-inset")
      .select(function () { return this.parentNode.appendChild(this.cloneNode(true)); })
      .attr("class", "track-overlay")
      .call(d3.drag()
        .on("start.interrupt", function () { slider.interrupt(); })
        .on("start drag", function () {
          currentValue = d3.event.x;
          update(x.invert(currentValue));
        })
      );

    slider.insert("g", ".track-overlay")
      .attr("class", "ticks")
      .attr("transform", "translate(0," + 18 + ")")
      .selectAll("text")
      .data(x.ticks(10))
      .enter()
      .append("text")
      .attr("x", x)
      .attr("y", 10)
      .attr("text-anchor", "middle")
      .text(function (d) { return formatDateIntoYear(d); });

    var handle = slider.insert("circle", ".track-overlay")
      .attr("class", "handle")
      .attr("r", 9);

    var label = slider.append("text")
      .attr("class", "label")
      .attr("text-anchor", "middle")
      .text(formatDate(startDate))
      .attr("transform", "translate(0," + (-25) + ")")


    ////////// map //////////

    var dataset;

    var layer1 = svg.append("g")
      .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

    d3.json("us.json", function (error, us) {
      if (error) throw error;

      layer1.selectAll("path")
        .data(topojson.feature(us, us.objects.states).features)
        .enter().append("path")
        .attr("d", path)
        .attr("class", "feature")
        .attr("stroke", "#ccc")
        .attr("fill", "#fff")

      layer1.append("path")
        .datum(topojson.mesh(us, us.objects.states, function (a, b) { return a !== b; }))
        .attr("class", "mesh")
        .attr("d", path)
        .attr('fill', 'none')

    });

    var plot = svg.append("g")
      .attr("class", "plot")
      .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

    let zoomScale = 1; // initial zoom level

    d3.csv("circles.csv", prepare, function (data) {
      dataset = data;
      drawPlot(dataset);

      playButton
        .on("click", function () {
          var button = d3.select(this);
          if (button.text() == "Pause") {
            moving = false;
            clearInterval(timer);
            button.text("Play");
          } else {
            moving = true;
            timer = setInterval(step, 100);
            button.text("Pause");
          }
          console.log("Slider moving: " + moving);
        })
    })

    function prepare(d) {
      d.id = d.id;
      d.date = parseDate(d.date);
      return d;
    }

    function step() {
      update(x.invert(currentValue));
      currentValue = currentValue + (targetValue / 151);
      if (currentValue > targetValue) {
        moving = false;
        currentValue = 0;
        clearInterval(timer);
        // timer = 0;
        playButton.text("Play");
        console.log("Slider moving: " + moving);
      }
    }

    function drawPlot(data) {

      console.log('zoom scale', zoomScale)

      // update projection based on zoom
      projection = geoAlbersUsaTerritories.geoAlbersUsaTerritories()
        .scale((width + 100) * zoomScale) // this works
        .translate([width / 2, height / 2.2]) // what does this need to be?

      var locations = plot.selectAll(".location")
        .data(data);

      locations.enter()
        .append("circle")
        .attr("class", "location")
        .attr('cx', d => projection([d.lat, d.lng])[0])
        .attr('cy', d => projection([d.lat, d.lng])[1])
        .style("fill", '#333')
        .style("stroke", '#111')
        .style("opacity", 0.5)
        .attr("r", 3)
        .transition()
        .duration(400)
        .attr("r", 8)

      locations.exit()
        .remove();
    }

    function update(h) {
      handle.attr("cx", x(h));
      label
        .attr("x", x(h))
        .text(formatDate(h));

      var newData = dataset.filter(d => d.date < h)
      drawPlot(newData);
    }

    function zoomed() {

      zoomScale = d3.event.transform.k

      d3.zoomIdentity
        .scale(zoomScale)

      layer1
        .selectAll('path') // To prevent stroke width from scaling
        .attr('transform', d3.event.transform);

      plot
        .selectAll('circle')
        .attr('transform', d3.event.transform);

      plot
        .selectAll('.city')
        .attr('transform', d3.event.transform);

    }

    d3.select('#zoom-in').on('click', function () {
      console.log('zoomin in')
      zoom.scaleBy(svg.transition().duration(750), 1.3);
    });

    d3.select('#zoom-out').on('click', function () {
      zoom.scaleBy(svg.transition().duration(750), 1 / 1.3);
    });

  </script>
</body>

circles.csv:

id,date,lng,lat
1,11/24/04,38.285973,-122.365474
2,03/22/05,38.285973,-122.365474
3,06/02/05,37.792949,-122.459974
4,06/14/05,37.909155,-122.6518833333
5,07/01/05,33.631724,-117.950935
6,08/31/05,33.631724,-117.950935
7,09/01/05,34.039715,-118.888678
8,10/01/05,37.9217562781,-121.9406676292
9,11/14/05,34.039466,-118.579902
10,12/09/05,37.703584,-122.432026
11,02/14/06,38.38033,-123.08087
12,04/06/06,34.045192,-118.940949
13,05/26/06,34.045192,-118.940949
14,06/14/06,37.824757,-122.201442
15,07/07/06,37.816214,-122.210626
16,08/03/06,37.815468,-122.193975
17,09/14/06,37.822113,-122.195606
18,10/25/06,37.814383,-122.184705
19,11/20/06,37.822113,-122.195606
20,12/21/06,37.814383,-122.184705
21,01/23/07,37.31874272,-122.18163484
22,01/30/07,37.5738,-122.471
23,02/25/07,37.5738,-122.471
24,03/16/07,39.044386,-122.534122
25,04/13/07,39.044386,-122.534122
26,05/24/07,34.038791,-118.874559
27,06/16/07,37.31874272,-122.18163484
28,07/23/07,37.27148803,-122.15488797
29,08/13/07,37.32495478,-122.17827938
30,09/06/07,37.31874272,-122.18163484
31,10/19/07,34.125873,-118.707833
32,11/12/07,37.593929,-122.515068
33,12/11/07,37.593929,-122.515068
34,01/01/08,34.086431,-118.704786
35,02/06/08,37.005294,-121.683883
36,03/01/08,37.005294,-121.683883
37,04/01/08,34.038826,-118.875761
38,06/17/08,37.324748,-122.402458
39,07/03/08,37.324748,-122.402458
40,09/18/08,38.1186745227,-122.9507392645
41,10/08/08,37.91733,-122.335
42,11/19/08,37.8963,-122.355
43,12/18/08,37.4872,-121.929
44,01/20/09,34.040533,-118.891725
45,02/13/09,37.30647158,-122.17003327
46,03/20/09,37.30647158,-122.17003327
47,04/10/09,37.741558075,-122.4431838989
48,07/20/09,37.7649307251,-122.4374237061
49,08/10/09,37.502882,-122.478332
50,09/15/09,37.502882,-122.478332
51,10/19/09,37.9058615694,-122.6426225665
52,11/06/09,37.9058615694,-122.6426225665
53,12/17/09,37.4061431885,-122.2400054932
54,02/16/10,39.21226,-123.173217
55,03/14/10,37.895213,-122.031581
56,04/14/10,37.4225234985,-122.1740188599
57,05/05/10,38.601578,-121.138257
58,06/19/10,37.063256,-121.208691
59,07/01/10,37.67334,-122.408981
60,08/24/10,37.67334,-122.408981
61,09/01/10,37.754429,-122.136554
62,10/19/10,37.754429,-122.136554
63,11/06/10,38.5835812168,-122.699008584
64,12/27/10,37.548524,-122.505283
65,01/18/11,37.548524,-122.505283
66,02/22/11,37.919418335,-122.494720459
67,03/11/11,37.27148803,-122.15488797
68,04/26/11,37.27148803,-122.15488797
69,05/16/11,38.927165,-122.986536
70,07/14/11,38.771485,-121.042486
71,09/19/11,38.719269,-120.991916
72,11/16/11,37.27148803,-122.15488797
73,03/28/12,37.27148803,-122.15488797
74,04/19/12,38.593377,-121.462526
75,05/04/12,37.27148803,-122.15488797
76,07/19/12,37.27148803,-122.15488797
77,08/10/12,38.589829,-121.455835
78,09/16/12,34.099758,-118.711652
79,10/21/12,37.6951599121,-122.4476928711
80,11/15/12,37.8140938,-122.1833
81,12/03/12,37.19563508,-121.94371251
82,01/15/13,37.909155,-122.6518833333
83,03/24/13,37.19563508,-121.94371251
84,04/17/13,37.727423,-122.481812
85,05/13/13,37.8577162003,-122.5106131314
86,06/07/13,38.5785140991,-122.6990890503
87,07/03/13,37.281701,-120.862655
88,08/23/13,38.200491,-122.962797
89,09/22/13,37.6238162908,-122.1350258589
90,10/23/13,32.550129,-117.102812
91,11/14/13,32.548826,-117.101935
92,12/06/13,32.547429,-117.10247
93,02/04/14,38.6128130576,-122.7812719345
94,04/15/14,38.5835711527,-122.6989656687
95,07/03/14,37.4002395942,-122.2135899068
96,08/05/14,38.6361160278,-122.858757019
97,09/19/14,36.5,-119
98,10/28/14,36.5,-119
99,11/05/14,35.272334,-120.650649
100,12/19/14,37.8826501667,-122.5498195
101,02/06/15,37.8826501667,-122.5498195
102,03/01/15,33.570829,-117.551149
103,04/10/15,37.053838,-122.13295
104,05/18/15,37.900347,-122.024344
105,06/29/15,38.726416,-123.041826
106,08/20/15,39.3925,-123.648889
107,09/19/15,33.171683,-117.109108
108,10/22/16,37.9372660312,-122.1399235725
109,12/13/16,39.155156,-121.564922
110,02/16/17,32.825965,-117.053016
111,03/24/17,37.215688703,-122.0270562172

用头撞墙想了半天终于想通了

在这里工作的 Plunker: https://plnkr.co/edit/LnISm6YJp1A5HFPT?open=lib%2Fscript.js

我需要做的不是创建新的投影,而是在创建圆圈时对其进行变换,以根据任何平移说明当前的 x,y 位置,并根据任何缩放变换比例。

下面是代码和一些注释,如果这对任何人都有帮助:

<!DOCTYPE html>

<head>
    <meta charset="utf-8">
    <script src="https://d3js.org/d3.v4.min.js"></script>
    <script src="//d3js.org/topojson.v1.min.js"></script>
    <script src="https://unpkg.com/geo-albers-usa-territories@0.1.0/dist/geo-albers-usa-territories.js"></script>

    <style>
        body {
            font-family: Arial, Helvetica, sans-serif;
            font-size: 12px;
            color: #696969;
        }

        #play-button {
            position: absolute;
            bottom: 130px;
            left: 50px;
            background: #333;
            padding-right: 26px;
            border-radius: 3px;
            border: none;
            color: white;
            margin: 0;
            padding: 0 12px;
            width: 60px;
            cursor: pointer;
            height: 30px;
        }

        #play-button:hover {
            background-color: #696969;
        }

        .ticks {
            font-size: 10px;
        }

        .track,
        .track-inset,
        .track-overlay {
            stroke-linecap: round;
        }

        .track {
            stroke: #000;
            stroke-opacity: 0.3;
            stroke-width: 10px;
        }

        .track-inset {
            stroke: #dcdcdc;
            stroke-width: 8px;
        }

        .track-overlay {
            pointer-events: stroke;
            stroke-width: 50px;
            stroke: transparent;
            cursor: crosshair;
        }

        .handle {
            fill: #fff;
            stroke: #000;
            stroke-opacity: 0.5;
            stroke-width: 1.25px;
        }

        #zoom-buttons {
            position: absolute;
            margin-left: 10px;
            margin-top: 10px;
            padding: 5px;
            background: #fff;
        }

        #zoom-buttons button {
            background: #efefef;
            color: #231F20;
            border: 0;
            padding: 0;
            border-radius: 2px;
            width: 25px;
            height: 25px;
        }
    </style>
</head>

<body>
    <div id="zoom-buttons">
        <button id="zoom-in">+</button>
        <button id="zoom-out">-</button>
    </div>
    <div id="vis">
        <button id="play-button">Play</button>
    </div>
    <script>

        var formatDateIntoYear = d3.timeFormat("%Y");
        var formatDate = d3.timeFormat("%b %Y");
        var parseDate = d3.timeParse("%m/%d/%y");

        var startDate = new Date("2004-11-01"),
            endDate = new Date("2017-04-01");

        var margin = { top: 50, right: 50, bottom: 0, left: 50 },
            width = 960 - margin.left - margin.right,
            height = 750 - margin.top - margin.bottom;

        var svg = d3.select("#vis")
            .append("svg")
            .attr("width", width + margin.left + margin.right)
            .attr("height", height + margin.top + margin.bottom);

        var projection = geoAlbersUsaTerritories.geoAlbersUsaTerritories()
            .scale(width + 100)
            .translate([width / 2, height / 2.2]);

        var path = d3.geoPath()
            .projection(projection);

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

        svg.call(zoom)
        ////////// slider //////////

        var moving = false;
        var currentValue = 0;
        var targetValue = width;

        var playButton = d3.select("#play-button");

        var x = d3.scaleTime()
            .domain([startDate, endDate])
            .range([0, targetValue])
            .clamp(true);

        var slider = svg.append("g")
            .attr("class", "slider")
            .attr("transform", "translate(" + margin.left + "," + height + ")");

        slider.append("line")
            .attr("class", "track")
            .attr("x1", x.range()[0])
            .attr("x2", x.range()[1])
            .select(function () { return this.parentNode.appendChild(this.cloneNode(true)); })
            .attr("class", "track-inset")
            .select(function () { return this.parentNode.appendChild(this.cloneNode(true)); })
            .attr("class", "track-overlay")
            .call(d3.drag()
                .on("start.interrupt", function () { slider.interrupt(); })
                .on("start drag", function () {
                    currentValue = d3.event.x;
                    update(x.invert(currentValue));
                })
            );

        slider.insert("g", ".track-overlay")
            .attr("class", "ticks")
            .attr("transform", "translate(0," + 18 + ")")
            .selectAll("text")
            .data(x.ticks(10))
            .enter()
            .append("text")
            .attr("x", x)
            .attr("y", 10)
            .attr("text-anchor", "middle")
            .text(function (d) { return formatDateIntoYear(d); });

        var handle = slider.insert("circle", ".track-overlay")
            .attr("class", "handle")
            .attr("r", 9);

        var label = slider.append("text")
            .attr("class", "label")
            .attr("text-anchor", "middle")
            .text(formatDate(startDate))
            .attr("transform", "translate(0," + (-25) + ")")


        ////////// map //////////

        var dataset;

        var layer1 = svg.append("g")
            .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

        d3.json("us.json", function (error, us) {
            if (error) throw error;

            layer1.selectAll("path")
                .data(topojson.feature(us, us.objects.states).features)
                .enter().append("path")
                .attr("d", path)
                .attr("class", "feature")
                .attr("stroke", "#ccc")
                .attr("fill", "#fff")

            layer1.append("path")
                .datum(topojson.mesh(us, us.objects.states, function (a, b) { return a !== b; }))
                .attr("class", "mesh")
                .attr("d", path)
                .attr('fill', 'none')

        });

        var plot = svg.append("g")
            .attr("class", "plot")
            .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

        // define initial event transform
        let zoomScale = 1,
            panX = 0,
            panY = 0

        d3.csv("circles.csv", prepare, function (data) {
            dataset = data;
            drawPlot(dataset);

            playButton
                .on("click", function () {
                    var button = d3.select(this);
                    if (button.text() == "Pause") {
                        moving = false;
                        clearInterval(timer);
                        button.text("Play");
                    } else {
                        moving = true;
                        timer = setInterval(step, 100);
                        button.text("Pause");
                    }
                })
        })

        function prepare(d) {
            d.id = d.id;
            d.date = parseDate(d.date);
            return d;
        }

        function step() {
            update(x.invert(currentValue));
            currentValue = currentValue + (targetValue / 151);
            if (currentValue > targetValue) {
                moving = false;
                currentValue = 0;
                clearInterval(timer);
                playButton.text("Play");
            }
        }

        function drawPlot(data) {

            var locations = plot.selectAll(".location")
                .data(data);

            //transform circles based on pan and zoom
            locations.enter()
                .append("circle")
                .attr('transform', "translate(" + panX + "," + panY + ") scale(" + zoomScale + ")")
                .attr("class", "location")
                .attr('cx', d => projection([d.lat, d.lng])[0])
                .attr('cy', d => projection([d.lat, d.lng])[1])
                .style("fill", '#333')
                .style("stroke", '#111')
                .style("opacity", 0.5)
                .attr("r", 3)
                .transition()
                .duration(400)
                .attr("r", 8)

            locations.exit()
                .remove();
        }

        function update(h) {
            handle.attr("cx", x(h));
            label
                .attr("x", x(h))
                .text(formatDate(h));

            var newData = dataset.filter(d => d.date < h)
            drawPlot(newData);
        }

        function zoomed() {

            // update global transform properties so that new circles know where to go and how large to be
            zoomScale = d3.event.transform.k,
                panX = d3.event.transform.x,
                panY = d3.event.transform.y

            d3.zoomIdentity
                .scale(zoomScale)

            layer1
                .selectAll('path') // To prevent stroke width from scaling
                .attr('transform', d3.event.transform);

            plot
                .selectAll('circle')
                .attr('transform', d3.event.transform);

            plot
                .selectAll('.city')
                .attr('transform', d3.event.transform);

        }

        d3.select('#zoom-in').on('click', function () {
            zoom.scaleBy(svg.transition().duration(750), 1.3);
        });

        d3.select('#zoom-out').on('click', function () {
            zoom.scaleBy(svg.transition().duration(750), 1 / 1.3);
        });

    </script>
</body>