Search code examples
d3.jsgeojsontopojson

d3.js v5 : Unable to get data points to show on map of US


I can successfully get a map of the US to render however, my data points do not. (I understand that d3.js made some significant changes with v5 so please note that similar questions previously asked do not apply)

$(document).ready(function () {

var us = d3.json('https://unpkg.com/us-atlas@1/us/10m.json');
var meteoriteData = d3.json('https://raw.githubusercontent.com/FreeCodeCamp/ProjectReferenceData/master/meteorite-strike-data.json');

var svg = d3.select("svg")
    .style("width", "1110px")
    .style("height", "714px");

var path = d3.geoPath();

Promise.all([us, meteoriteData]).then(function (values) {

    var map = values[0];
    console.log("map", map);

    var meteoriteData = values[1];
    console.log("meteoriteData", meteoriteData);

    svg.append("g")
        .attr("fill", "#ccc")
        .selectAll("path")
        .data(topojson.feature(map, map.objects.states).features)
        .enter().append("path")
        .attr("d", path),

        svg.append("path")
            .datum(topojson.mesh(map, map.objects.states, (a, b) => a !== b))
            .attr("fill", "none")
            .attr("stroke", "white")
            .attr("stroke-linejoin", "round")
            .attr("pointer-events", "none")
            .attr("d", path),

        svg.selectAll("circle")
            .data(meteoriteData)
            .enter()
            .append("circle")
            .attr("class", "circles")
            .attr("cx", function (d) { return ([d.geometry.coordinates[0], d.geometry.coordinates[1]])[1]; })
            .attr("cy", function (d) { return ([d.geometry.coordinates[0], d.geometry.coordinates[1]])[0]; })
            .attr("r", "1px");
});

});

And a working copy can be found here.. https://codepen.io/lady-ace/pen/PooORoy


Solution

  • There's a number of issues here:

    Passing .data an array

    First, when using

    svg.selectAll("circle")
       .data(meteoriteData)
    

    selectAll().data() requries you to pass a function or an array. In your case you need to pass it the data array - however, meteoriteData is an object. It is a geojson feature collection with the following structure:

    {
      "type": "FeatureCollection",
      "features": [
          /* features */
      ]
    }
    

    All the individual geojson features are in an array inside that object. To get the array of features, in this case features representing meteors, we need to use:

    svg.selectAll("circle")
       .data(meteoriteData.features)
    

    Now we can create one circle for every feature in the feature collection. If you do this, you can find the circles when inspecting the SVG element, but they won't placed correctly.


    Positioning Points

    If you make the above change, you won't see circles in the right places. You are not positioning the circles correctly here:

     .attr("cx", function (d) { return ([d.geometry.coordinates[0], d.geometry.coordinates[1]])[1]; })
    

    This is the same as:

     .attr("cx", function(d) { return d.geometry.coordinates[1]; })
    

    Two issues here: One, geojson is [longitude,latitude], or [x,y] (you are getting the y coordinate here, but setting the x value).

    But, the bigger concern is you are not projecting your data. This is a raw coordinate from your geojson:

    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [
          -113,
          54.21667
        ]
       ...
    

    You are taking the longitude and directly turning it into a pixel value. But your geojson uses a 3D coordinate space (unprojected points on a 3D globe) with units measures in degrees. If we simply convert this to pixels, cx = -113, your circle will appear off screen to the left of your SVG.

    Using a Projection

    You need to project your data, to do so we would define a projection function and use something like:

      .attr("cx", function(d) { return projection(d.geometry.coordinates)[0] })
    

    This gets both longitude and latitude and passes them to a projection function and then grabs the returned x value and sets it as the value for cx.

    A projection takes an unprojected coordinate in 3 dimensional space (points on a globe or long/lat pairs) with units in degrees, and returns a point in 2 dimensional space with units in pixels.

    But, this now brings us to the most difficult part:

    What projection should you use?

    We need to align our points with the US features that have already been drawn, but you don't define a projection for the US states - if you do not supply a projection to d3.geoPath, it uses a null projection, it takes supplied coordinates and plots them on the SVG as though they are pixel coordinates. No transform takes place. I know that your US features are projected though, because Alaska isn't where it is supposed to be, the coordinate values in the topojson exceed +/- 180 degrees in longitude, +/- 90 in latitude, and the map looks like it is projected with an Albers projection.

    If the geoPath is not projecting the data with a d3 projection but the data is drawn as though projected, then the data is pre-projected - this topojson stores already projected points.

    We have projected data (the US states) and unprojected data (meteor strikes), mixing them is always a challenge.

    The challenge here is creating a projection that replicates the projection function used to create the US states dataset. We can replicate that projection, as it is a problematic file that leads to many questions. How to do so is explained here. But this is more complicated than is should be: mixing projected and unprojected data is burdensome, inflexible, and more complicated than need be.

    I would suggest you use unprojected data for both the US and the meteors:

    var projection = d3.geoAlbersUsa();             // create an Albers USA projection
    var path = d3.geoPath().projection(projection); // set the projection for the path
    

    We can draw the paths the same way, provided we find an unprojected topojson/geojson of the US, and we can place points with:

    .attr("cx", function(d) { return projection(d.geometry.coordinates)[0]; })
    .attr("cy", function(d) { return projection(d.geometry.coordinates)[1]; })
    

    As for finding an unprojected US topojson, here's one.

    And here's a working version using the above approach and projecting all data (I also filter the features to get rid of ones without coordinates, those that the USA Albers might break on, as it is a composite projection).