Search code examples
d3.jstopojsonmap-projections

Lines on eclipse map not drawing to scale in d3.js map


I have the following map of the US showing the currently predicted weather conditions for each county in or near the eclipse zone at the time of the eclipse. I want to be able to show the lines indicating the northern, southern and center lines of the zone of totality but I can't get them to scale properly.

The red line thats show is supposed to be northern line but it's not drawing to scale.

Map of weather in the eclipse zone

Here's the code, any ideas? The line at the very bottom

svg.append('path').datum(feature.geometry).attr('class', 'mine').attr("d", path2);

is where I'm trying to draw the line.

Thanks

<!DOCTYPE html>
<meta charset="utf-8">
<style>

    .counties {
        fill: none;
        stroke: #ddd;
    }

    .states {
        fill: none;
        stroke: #000;
        stroke-linejoin: round;
    }

    .mine {
        fill: #f00;
        stroke: #f00;
        stroke-linejoin: round;
    }

</style>
<svg width="960" height="600"></svg>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://d3js.org/d3-scale-chromatic.v1.min.js"></script>
<script src="https://d3js.org/topojson.v2.min.js"></script>
<script>

    var svg = d3.select("svg"),
        width = +svg.attr("width"),
        height = +svg.attr("height");

    var unemployment = d3.map();

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

    var x = d3.scaleLinear()
        .domain([1, 10])
        .rangeRound([600, 860]);

    var color = d3.scaleThreshold()
        .domain(d3.range(2, 10))
        .range(d3.schemeBlues[9]);

    var g = svg.append("g")
        .attr("class", "key")
        .attr("transform", "translate(0,40)");

    g.selectAll("rect")
        .data(color.range().map(function (d) {
            d = color.invertExtent(d);
            if (d[0] == null) d[0] = x.domain()[0];
            if (d[1] == null) d[1] = x.domain()[1];
            return d;
        }))
        .enter().append("rect")
        .attr("height", 8)
        .attr("x", function (d) {
            return x(d[0]);
        })
        .attr("width", function (d) {
            return x(d[1]) - x(d[0]);
        })
        .attr("fill", function (d) {
            return color(d[0]);
        });

    g.append("text")
        .attr("class", "caption")
        .attr("x", x.range()[0])
        .attr("y", -6)
        .attr("fill", "#000")
        .attr("text-anchor", "start")
        .attr("font-weight", "bold")
        .text("Forecast Conditions");

    g.call(d3.axisBottom(x)
        .tickSize(13)
        .tickFormat(function (x, i) {
            //return i ? x : x;
            if (i == 0)
                return "Clear";
            else if (i == 7)
                return "Rain";
            else
                return "";
        })
        .tickValues(color.domain()))
        .select(".domain")
        .remove();

    d3.queue()
        .defer(d3.json, "https://d3js.org/us-10m.v1.json")
        .defer(d3.tsv, "unemployment.tsv", function (d) {
            var forecast = {
                forecastNum: d.forecastNum,
                name: d.name,
                forecastText: d.forecastText
            };
            unemployment.set(d.id, forecast);
        })
        .await(ready);

    function ready(error, us) {
        if (error) throw error;

        var feature = {
            type: "Feature",
            properties: {},
            geometry: {
                type: "LineString",
                coordinates: [
                    [136.9522, 45.1172],
                    [36.8017, 13.6517],
                ]
            }
        };
        svg.append("g")
            .attr("class", "counties")
            .selectAll("path")
            .data(topojson.feature(us, us.objects.counties).features)
            .enter().append("path")
            .attr("fill", function (d) {
                return color(d.forecastNum = unemployment.get(d.id).forecastNum);
            })
            .attr("d", path)
            .append("title")
            .text(function (d) {
                var fc = unemployment.get(d.id);

                var s = fc.name + " " + fc.forecastText;
                console.log('[' + s + "]");
                return s;
            });

        svg.append("path")
            .datum(topojson.mesh(us, us.objects.states, function (a, b) {
                return a !== b;
            }))
            .attr("class", "states")
            .attr("d", path)
        ;
//        svg.append("circle").attr("r",50).attr("transform", function() {return "translate(" + projection([-75,43]) + ")";});
        svg.append('path').datum(feature.geometry).attr('class', 'mine').attr("d", path2);

    }

</script>

Solution

  • Your map is performing as expected but you can make it work as desired.

    The Problem

    This file: https://d3js.org/us-10m.v1.json is already projected (it consists of x,y coordinates on a 2d plane with an arbitrary unit of distance).

    Your line is not projected (it consists of longitude/latitude pairs which represent points on a 3d globe).

    Ultimately, you have features in two different coordinate systems which causes problems because you are plotting these points from two different coordinate systems the same way.

    Determining if Geographic Data is Already Projected in d3

    To determine if your data is already projected, you can check to see:

    • does the geoPath use a null projection?
    • are the coordinate pairs valid latitude longitude pairs?

    Looking at this file, you can see that no projection is assigned to the geopath (this would be done like: d3.geoPath().projection(projection)).

    Also, you can see that the topojson coordinates are roughly within the bounds of [0,0] and [960,600] (convert to geojson to see plain coordinates). These are not valid longitude/latitude pairs (which are [+/-180,+/-90]).

    Your line feature, however, is plotted with longitude/latitude pairs. This feature is not projected (if using a specific spheroid to represent the earth, it might be said to be "projected" in WGS84, but in reality, WGS84 in this context just represents a datum, not a projection. For reference, WGS84 is the datum/reference spheroid used by d3 when it converts from long/lats to points on a plane).

    What is Happening?

    A null projection will take in coordinates [x,y] and return those very same coordinates. As such, a quick give away of already projected features is to view the features in a viewer (mapshaper.org), if the feature is upside down, then you have projected data. This happens because svg coordinate space places zero at the top, while longitude/latitude pairs place zero at the equator, with -90 further down.

    When drawing your map, you have a line you wish to draw:

           coordinates: [
                [136.9522, 45.1172],
                [36.8017, 13.6517],
            ]
    

    As you are using a null projection, the line simply goes from a point 45 pixels down from the top and 137 pixels from the left, to a point 13 pixels down and 37 pixels from the left. Also, longitudes in the continental US are negative, but of course, then they won't appear on your map as it is at all.

    Solution

    You need to use consistent projections for your data. To do so you can do one of:

    1. Figure out the projection used for the topojson and convert your coordinates to that projection so you can use the converted coordinates in your line's coordinate array and "project" the line with a null projection too.

    2. Figure out the projection used for the topojson and emulate it with a d3.geoProjection, this solution will use a geoPath with a null projection for the topojson (as currently), and a geoPath with a pretty specific projection to convert the line to the same coordinate space

    3. Unproject the topojson and use the same projection for both features (line and topojson).

    4. Find an unprojected topojson/geojson/etc of the US and use the same projection for both features (line and topojson).

    Given that the source file you have is likely made by Mike Bostock, and he likely used the same projection formula to create the file as he implemented in d3, we might get lucky and figure out what d3.geoProjection we could use to project our coordinates to the same coordinate space as the topojson (option 2 above).

    The default d3.geoAlbers projection is centered on the US, an exception compared with most other d3 projections. This might contain by default the key projection parameters used to create the topojson, namely standard parallels, centering point, and rotation.

    However, the default scale of a d3 projection anticipates a 960 x 500 projection plane. The default translate of a d3 projection assumes a translate of [960/2,500/2]. If we change these to accommodate a 600 pixel tall feature, we get lucky and can project coordinates to a very similar coordinate space, if not the same coordinate space:

    var projection = d3.geoAlbers();
    var scale = d3.geoAlbers().scale(); // get the default scale
    
    projection.scale(scale * 1.2) // adjust for 600 pixels tall
      .translate([960/2,600/2]    // adjust for 600 pixels tall here too.
    
    var geoPath = d3.geoPath.projection(projection)  // use the projection to draw features.
    

    This gives us a successful overlap. Here is a unprojected data topojson overlaid on the projected topojson. This allows us to achieve your goal by projecting the line on top of the unprojected features, see here (I don't have your data tsv, so the choropleth is just noise).

    You can also scale already projected data using a geoTransform rather than a geoProjection, see this answer. This will allow you to choose svg sizes that don't require 960 px by 600 px to show all features.

    Solution Caveat

    Sadly, options 1,2 and 3 require you to know how the geographic data in the topojson was projected so that you can emulate it or reverse it. If you do not have this information, these options are not possible. This is true whenever dealing with geographic data that use different spatial reference systems (SRS, or coordinate reference systems, CRS).

    That leaves option 4 in these cases. A unprojected topojson for the States can be found here in this block (along with good projection parameters). Here is the raw topojson. Here is a demonstration of your line (This topojson data does not have county ids however and would therefore break your chloropleth).