line on d3 map not forming a curve
Asked Answered
R

1

5

I have created a map using d3.js. I want to show a curved line between two locations. I am able to show a line, but sometimes it does not form a perfect curve. For some lines, the lines curve behind the map (across the anti-meridian) to their destination.

Here's a code pen demonstrating the problem: https://codepen.io/peeyush-pant/pen/WqbPax

And an image:

enter image description here

Here's my projection data:

var projection = d3.geoEquirectangular();

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

And here's how I draw the lines:

  arcGroup.selectAll("myPath")
    .data(links)
    .enter()
    .append("path")
    .attr("class", "line")
    .attr("id", function (d, i) {
      return "line" + i;
    })
    .attr("d", function (d) {
      return path(d)
    })
    .style("fill", "none")
    .style("stroke", '#fff787')
    .style("stroke-width", 1.5);

Thank you.

Riles answered 12/6, 2019 at 12:27 Comment(0)
B
6

D3 geoPath can be used to create paths that follow greater circle distance: they aren't curved for style, they are curved as needed, depending on projection, to represent the shortest path on earth to connect two points. D3 geoPaths are dynamically re sampled to allow this.

This behavior is unusual in web geographic mapping libraries, most of which treat latitude and longitude as Cartesian data rather than three dimensional data: where latitude and longitude are points on a sphere. In treating data as Cartesian, lines are straight when connecting two points. In d3 this can be accomplished with methods such as these.

If you want a consistent curve for all line segments, we will treat the data as Cartesian and interpolate a curve. As we won't be using d3.geoPath for this, there is no need to convert your destinations and sources into geojson LineStrings, we can just use the points directly.

We can use a curve interpolator for this, but the default interpolators won't work without adding control points between the end and start destinations. Instead, let's try a custom curve - see these answers (a,b) for more on custom curves.

Our custom curve could take any point after the first to find the mid point between it and the point before it and offset a point to create a control point forming a triangle between the prior point and the current point, then we just draw a quadratic curve between them:

var curve = function(context) {
  var custom = d3.curveLinear(context);
  custom._context = context;
  custom.point = function(x,y) {
    x = +x, y = +y;
    switch (this._point) {
      case 0: this._point = 1; 
        this._line ? this._context.lineTo(x, y) : this._context.moveTo(x, y);
        this.x0 = x; this.y0 = y;        
        break;
      case 1: this._point = 2;
      default: 
        var x1 = this.x0 * 0.5 + x * 0.5;
        var y1 = this.y0 * 0.5 + y * 0.5;
        var m = 1/(y1 - y)/(x1 - x);
        var r = -100; // offset of mid point.
        var k = r / Math.sqrt(1 + (m*m) );
        if (m == Infinity) {
          y1 += r;
        }
        else {
          y1 += k;
          x1 += m*k;
        }     
        this._context.quadraticCurveTo(x1,y1,x,y); 
        this.x0 = x; this.y0 = y;        
        break;
    }
  }
  return custom;
}

With this in hand we can simply draw lines with something like:

d3.line()
 .curve(curve)
 .x(function(d) { return d.lon; })
 .y(function(d) { return d.lat; })

As seen below:

let data = [{
        "source": {
            "lat": 40.712776,
            "lon": -74.005974    
        },
        "destination": {
            "lat": 21.05,
            "lon": 105.55
        }
    },
             {
        "source": {
            "lat": 40.712776,
            "lon": -74.005974    
        },
        "destination": {
            "lat": -35.15,
            "lon": 149.08
        }
    }]

var curve = function(context) {
  var custom = d3.curveLinear(context);
  custom._context = context;
  custom.point = function(x,y) {
    x = +x, y = +y;
    switch (this._point) {
      case 0: this._point = 1; 
        this._line ? this._context.lineTo(x, y) : this._context.moveTo(x, y);
        this.x0 = x; this.y0 = y;        
        break;
      case 1: this._point = 2;
      default: 
        var x1 = this.x0 * 0.5 + x * 0.5;
        var y1 = this.y0 * 0.5 + y * 0.5;
        var m = 1/(y1 - y)/(x1 - x);
        var r = -100; // offset of mid point.
        var k = r / Math.sqrt(1 + (m*m) );
        if (m == Infinity) {
          y1 += r;
        }
        else {
          y1 += k;
          x1 += m*k;
        }     
        this._context.quadraticCurveTo(x1,y1,x,y); 
        this.x0 = x; this.y0 = y;        
        break;
    }
  }
  return custom;
}

var projection = d3.geoEquirectangular().translate([250,150]).scale(500/Math.PI/2);
var path = d3.geoPath(projection);

var svg = d3.select("body")
  .append("svg")
  .attr("width", 500)
  .attr("height", 300);
  
d3.json("https://unpkg.com/world-atlas@1/world/110m.json").then(function(world) {
 
  var worldOutline = svg.append("path")
    .datum(topojson.mesh(world))
    .attr("d", path );
    
  var line = d3.line()
    .x(function(d) {
      return projection([d.lon,d.lat])[0];
    })
    .y(function(d) {
      return projection([d.lon,d.lat])[1];
    })
    .curve(curve);
    
  svg.selectAll(null)
    .data(data)
    .enter()
    .append("path")
    .datum(function(d) {
      return [d.source,d.destination]; // d3.line expects an array where each item represnts a vertex.
    })
    .attr("d",line)
    .style("stroke","black")
    .style("stroke-width",1.5);

});
path {
  fill: none;
  stroke: #ccc;
  stroke-width: 1px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<script src="https://unpkg.com/topojson-client@3"></script>

Below, just for fun, I compare straight lines using d3.line, curved lines using d3.line with a custom curve interpolator, and plain old d3.geoPath with some animation:

 let data = [{
        "source": {
            "lat": 40.712776,
            "lon": -74.005974    
        },
        "destination": {
            "lat": 21.05,
            "lon": 105.55
        }
    },
             {
        "source": {
            "lat": 40.712776,
            "lon": -74.005974    
        },
        "destination": {
            "lat": -35.15,
            "lon": 149.08
        }
    }]

var curve = function(context) {
  var custom = d3.curveLinear(context);
  custom._context = context;
  custom.point = function(x,y) {
    x = +x, y = +y;
    switch (this._point) {
      case 0: this._point = 1; 
        this._line ? this._context.lineTo(x, y) : this._context.moveTo(x, y);
        this.x0 = x; this.y0 = y;        
        break;
      case 1: this._point = 2;
      default: 
        var x1 = this.x0 * 0.5 + x * 0.5;
        var y1 = this.y0 * 0.5 + y * 0.5;
        var m = 1/(y1 - y)/(x1 - x);
        var r = -100; // offset of mid point.
        var k = r / Math.sqrt(1 + (m*m) );
        if (m == Infinity) {
          y1 += r;
        }
        else {
          y1 += k;
          x1 += m*k;
        }     
        this._context.quadraticCurveTo(x1,y1,x,y); 
        this.x0 = x; this.y0 = y;        
        break;
    }
  }
  return custom;
}

var projection = d3.geoEquirectangular().translate([250,150]).scale(500/Math.PI/2);
var path = d3.geoPath(projection);

var svg = d3.select("body")
  .append("svg")
  .attr("width", 500)
  .attr("height", 300);
  
d3.json("https://unpkg.com/world-atlas@1/world/110m.json").then(function(world) {
 
  var worldOutline = svg.append("path")
    .datum(topojson.mesh(world))
    .attr("d", path );
    
  var line = d3.line()
    .x(function(d) {
      return projection([d.lon,d.lat])[0];
    })
    .y(function(d) {
      return projection([d.lon,d.lat])[1];
    })
    .curve(curve);
    
  var fauxArcPaths = svg.selectAll(null)
    .data(data)
    .enter()
    .append("path")
    .datum(function(d) {
      return [d.source,d.destination];
    })
    .attr("d",line)
    .style("stroke","black")
    .style("stroke-width",1.5);
    
  var greatCirclePaths = svg.selectAll(null)
    .data(data)
    .enter()
    .append("path")
    .datum(function(d) {
      return {type:"LineString",coordinates:
        [[d.source.lon,d.source.lat],[d.destination.lon,d.destination.lat]] }
    })
    .attr("d",path)
    .style("stroke","steelblue")
    .style("stroke-width",1.5);
    
  var straightline = d3.line()
    .x(function(d) {
      return projection([d.lon,d.lat])[0];
    })
    .y(function(d) {
      return projection([d.lon,d.lat])[1];
    });
    
  var straightPaths = svg.selectAll(null)
    .data(data)
    .enter()
    .append("path")
    .datum(function(d) {
      return [d.source,d.destination];
    })
    .attr("d",straightline)
    .style("stroke-width",1.5)
    .style("stroke","orange");
   
  // animate:
  d3.interval(function(elapsed) {
      projection.rotate([ -elapsed / 150, elapsed/300 ]);
      straightPaths.attr("d",straightline);
      greatCirclePaths.attr("d",path);
      fauxArcPaths.attr("d",line);
      worldOutline.attr("d",path);
  }, 50);
        
    
  
});
path {
  fill: none;
  stroke: #aaa;
  stroke-width: 1px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<script src="https://unpkg.com/topojson-client@3"></script>
Behemoth answered 12/6, 2019 at 18:33 Comment(1)
Thank you @andrew, your suggestion really helped.Riles

© 2022 - 2024 — McMap. All rights reserved.