Search code examples
javascriptd3.jssvgtopojson

Append linked graphics at fixed locations on d3 geo plot


I'm trying to append a tree of linked graphics to a spinning globe using d3 geo. I've adapted the spinning globe demos seen here (sans drag and drop) and here, and have managed to append a force directed layout of nodes/links that I found here.

This is a fiddle of what I have so far. The force graph appears near the south pole, apologies for the jumping links I think this is simply a css problem as it appears correctly in my simulation (I've left off the stylesheets for now).

As I want the nodes fixed at particular latitude/longitudes, I'd like to just get rid of the force simulation entirely. However all attempts to remove it while keeping the nodes and links results in them disappearing altogether. I've also struggled fixing their locations and overlaying the nodes over the map graphics (you can see the nodes go behind the landmasses)

To summarize, I'd like to:

  • remove force layout but keep nodes/links
  • fix nodes at specific latitude/longitude during rotation
  • overlay nodes/links on top of geo map features

Assistance on any of these points would be greatly appreciated.

HTML

<!doctype html>
<html lang="en">
<body>
<script src="//d3js.org/d3.v3.min.js"></script>
<script src="//d3js.org/topojson.v1.min.js"></script>
<div id="vis"></div>
</body>
</html>

Script

(function (){
  var config = {
    "projection": "Orthographic",
    "clip": true, "friction": 1,
    "linkStrength": 1,
    "linkDistance": 20,
    "charge": 50,
    "gravity": 1,
    "theta": .8 };

  var width = window.innerWidth,
      height = window.innerHeight - 5,
      fill = d3.scale.category20(),
      feature,
      origin = [0, -90],
      velocity = [0.01, 0],
      t0 = Date.now(),
      nodes = [{x: width/2, y: height/2}],
      links = [];

  var projection = d3.geo.orthographic()
      .scale(height/2)
      .translate([(width/2)-125, height/2])
      .clipAngle(config.clip ? 90 : null)

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

  var force = d3.layout.force()
     .linkDistance(config.linkDistance)
     .linkStrength(config.linkStrength)
     .gravity(config.gravity)
     .size([width, height])
     .charge(-config.charge);

  var svg = d3.select("#vis").append("svg")
      .attr("width", width)
      .attr("height", height)
      .call(d3.behavior.drag()
        .origin(function() { var r = projection.rotate(); return {x: 2 * r[0], y: -2 * r[1]}; })
        .on("drag", function() { force.start(); var r = [d3.event.x / 2, -d3.event.y / 2, projection.rotate()[2]]; t0 = Date.now(); origin = r; projection.rotate(r); }))

  for(x=0;x<20;x++){
    source = nodes[~~(Math.random() * nodes.length)]
    target = {x: source.x + Math.random(), y: source.y + Math.random(), group: Math.random()}
    links.push({source: source, target: target})
    nodes.push(target)
  }

  var node = svg.selectAll("path.node")
      .data(nodes)
      .enter().append("path").attr("class", "node")
      .style("fill", function(d) { return fill(d.group); })
      .style("stroke", function(d) { return d3.rgb(fill(d.group)).darker(); })
      .call(force.drag);
  console.log(node)
  var link = svg.selectAll("path.link")
      .data(links)
      .enter().append("path").attr("class", "link")

  force
     .nodes(nodes)
     .links(links)
     .on("tick", tick)
     .start();

  var url = "https://raw.githubusercontent.com/d3/d3.github.com/master/world-110m.v1.json";
  d3.json(url, function(error, topo) {
    if (error) throw error;

    var land = topojson.feature(topo, topo.objects.land);

    svg.append("path")
     .datum(land)
     .attr("class", "land")
     .attr("d", path)

    d3.timer(function() {
      force.start();
      var dt = Date.now() - t0;
      projection.rotate([velocity[0] * dt + origin[0], velocity[1] * dt + origin[1]]);
      svg.selectAll("path")
        .filter(function(d) {
          return d.type == "FeatureCollection";})
        .attr("d", path);
    });
  });

  function tick() {
    node.attr("d", function(d) { var p = path({"type":"Feature","geometry":{"type":"Point","coordinates":[d.x, d.y]}}); return p ? p : 'M 0 0' });
    link.attr("d", function(d) { var p = path({"type":"Feature","geometry":{"type":"LineString","coordinates":[[d.source.x, d.source.y],[d.target.x, d.target.y]]}}); return p ? p : 'M 0 0' });
  }

  function clip(d) {
    return path(circle.clip(d));
  }
})();

Solution

  • Assuming that you used the force so that you could add points and links, let's step back a bit, let's drop anything force related, no nodes and no links. A force layout is not necessary for either in this situation. Let's start with your globe with animation and drag (and move to d3v5 while we are at it):

      var width = 500,
          height = 500,
    	  t0 = Date.now(),
    	  velocity = [0.01, 0],
    	  origin = [0, -45];
      
      var projection = d3.geoOrthographic()
          .scale(height/2.1)
          .translate([width/2, height/2])
          .clipAngle(90)
    
      var path = d3.geoPath()
          .projection(projection);
      
      var svg = d3.select("body").append("svg")
          .attr("width", width)
          .attr("height", height)
          .call(d3.drag()
          .subject(function() { var r = projection.rotate(); return {x: 2 * r[0], y: -2 * r[1]}; })
          .on("drag", function() { var r = [d3.event.x / 2, -d3.event.y / 2, projection.rotate()[2]]; t0 = Date.now(); origin = r; projection.rotate(r); }))
    
      d3.json("https://unpkg.com/world-atlas@1/world/110m.json").then(function(topo) {
        var land = topojson.feature(topo, topo.objects.land);
        
        svg.append("path")
         .datum(land)
         .attr("class", "land")
         .attr("d", path);
    
        d3.timer(function() {
          var dt = Date.now() - t0;
          projection.rotate([velocity[0] * dt + origin[0], velocity[1] * dt + origin[1]]);
          svg.selectAll("path")
             .attr("d", path);
        });
    
    	
      });
    <script type="text/javascript" src="https://d3js.org/d3.v5.js"></script>
    <script src="https://d3js.org/topojson.v1.min.js"></script>

    Other than moving to v5 I made some minor modifications to optimize for snippet view (eg: size) or brevity (eg: hard coding the clip angle), but the code is substantively the same minus the force/nodes/links

    I think that checks half of your first requirement "remove force layout but keep nodes/links". This also provides us with simpler code to work with in satisfying the rest of the requirements.

    Ok, now that we have a base map, we can add points, and then we can add lines. But, let's break it down, first we add the points, then we add the links.

    Add Georeferenced Points

    Let's take a data format, I'll go with a dictionary of points/nodes we want to show:

      var points = {
         "Vancouver":[-123,49.25],
         "Tokyo":[139.73,35.68],
         "Honolulu":[-157.86,21.3],
         "London":[0,50.5],
         "Kampala":[32.58,0.3]
      }
    

    Since we are dealing with an orthographic projection, it is wise to use geojson points with a d3.geoPath as this will automatically clip those points that are on the far side of the globe. A geojson point looks like this (as you have created in your fiddle):

    { type: "Point", geometry: [long,lat] }
    

    So, we can get an array of geojson points with:

    var geojsonPoints = d3.entries(points).map(function(d) {
        return {type: "Point", coordinates: d.value}
    })
    

    d3.entries returns an array when fed an object. Each item in the array represents a key value pair of the original object {key: key, value: value}, see the docs for more info

    Now we can add our geojson points to the svg:

    svg.selectAll()
      .data(geojsonPoints)
      .enter()
      .append("path")
      .attr("d",path)
      .attr("fill","white")
      .attr("stroke-width",2)
      .attr("stroke","steelblue");
    

    And as these are points, we need to set the path's point radius:

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

    Lastly, since I removed the filter you had applied in the timer function, all paths will be updated together on each rotation, which simplifies the code a bit.

    Ok, altogether, that gives us:

    var width = 500,
        height = 500,
    	  t0 = Date.now(),
    	  velocity = [0.01, 0],
    	  origin = [0, -45];
        
      var points = {
       "Vancouver":[-123,49.25],
    	 "Tokyo":[139.73,35.68],
    	 "Honolulu":[-157.86,21.3],
    	 "London":[0,50.5],
    	 "Kampala":[32.58,0.3]
      }    
    
      
      var projection = d3.geoOrthographic()
          .scale(height/2.1)
          .translate([width/2, height/2])
          .clipAngle(90)
    
      var path = d3.geoPath()
          .projection(projection)
          .pointRadius(5);
      
      var svg = d3.select("body").append("svg")
          .attr("width", width)
          .attr("height", height)
          .call(d3.drag()
          .subject(function() { var r = projection.rotate(); return {x: 2 * r[0], y: -2 * r[1]}; })
          .on("drag", function() { var r = [d3.event.x / 2, -d3.event.y / 2, projection.rotate()[2]]; t0 = Date.now(); origin = r; projection.rotate(r); }))
    
      d3.json("https://unpkg.com/world-atlas@1/world/110m.json").then(function(topo) {
        var land = topojson.feature(topo, topo.objects.land);
        
        svg.append("path")
         .datum(land)
         .attr("class", "land")
         .attr("d", path);
         
      	var geojsonPoints = d3.entries(points).map(function(d) {
    		  return {type: "Point", coordinates: d.value}
    	  });
        
    	  svg.selectAll(null)
    	  .data(geojsonPoints)
    	  .enter()
    	  .append("path")
    	  .attr("d",path)
    	  .attr("fill","white")
    	  .attr("stroke-width",2)
    	  .attr("stroke","steelblue");
       
    
        d3.timer(function() {
          var dt = Date.now() - t0;
          projection.rotate([velocity[0] * dt + origin[0], velocity[1] * dt + origin[1]]);
          svg.selectAll("path")
             .attr("d", path);
        });
    
    	
      });
    <script type="text/javascript" src="https://d3js.org/d3.v5.js"></script>
    <script src="https://d3js.org/topojson.v1.min.js"></script>

    We could append circles, but this introduces a new problem: we need to check to see if each circle should be visible at each movement of the globe by seeing if the angle between the current rotational center and the point is greater than 90 degrees. So, for ease, I've used geojson and relied on the projection and path to hide those points on the far side of the globe.

    Paths

    The reason I prefer the above format for points is it allows us a human readable list of links:

    var links = [
      { source: "Vancouver",    target: "Tokyo" },
      { source: "Tokyo",        target: "Honolulu" },
      { source: "Honolulu",     target: "Vancouver" },
      { source: "Tokyo",        target: "London" },
      { source: "London",       target: "Kampala" }
    ]
    

    Now, as above, we need to convert that to geojson. A geojson line looks like (as you have created in your fiddle):

    {type:"LineString", coordinates: [[long,lat],[long,lat], ... ]
    

    So, we can create an array of geojson lines with:

    var geojsonLinks = links.map(function(d) {
        return {type: "LineString", coordinates: [points[d.source],points[d.target]] }
    })
    

    This takes advantage of the dictionary data structure for the points.

    Now you can append them like so:

    svg.selectAll(null)
      .data(geojsonLinks)
      .enter()
      .append("path")
      .attr("d", path)
      .attr("stroke-width", 2)
      .attr("stroke", "steelblue")
      .attr("fill","none")
    

    Like with the points, they are updated each timer tick:

    var width = 500,
        height = 500,
    	  t0 = Date.now(),
    	  velocity = [0.01, 0],
    	  origin = [0, -45];
        
      var points = {
       "Vancouver":[-123,49.25],
    	 "Tokyo":[139.73,35.68],
    	 "Honolulu":[-157.86,21.3],
    	 "London":[0,50.5],
    	 "Kampala":[32.58,0.3]
      }    
      
      var links = [
    	{ source: "Vancouver",target: "Tokyo" },
        { source: "Tokyo", 		target: "Honolulu" },
    	{ source: "Honolulu", target: "Vancouver" },
    	{ source: "Tokyo", 		target: "London" },
    	{ source: "London",		target: "Kampala" }
      ]  
    
      
      var projection = d3.geoOrthographic()
          .scale(height/2.1)
          .translate([width/2, height/2])
          .clipAngle(90)
    
      var path = d3.geoPath()
          .projection(projection)
          .pointRadius(5);
      
      var svg = d3.select("body").append("svg")
          .attr("width", width)
          .attr("height", height)
          .call(d3.drag()
          .subject(function() { var r = projection.rotate(); return {x: 2 * r[0], y: -2 * r[1]}; })
          .on("drag", function() { var r = [d3.event.x / 2, -d3.event.y / 2, projection.rotate()[2]]; t0 = Date.now(); origin = r; projection.rotate(r); }))
    
      d3.json("https://unpkg.com/world-atlas@1/world/110m.json").then(function(topo) {
        var land = topojson.feature(topo, topo.objects.land);
        
        svg.append("path")
         .datum(land)
         .attr("class", "land")
         .attr("d", path);
         
      	var geojsonPoints = d3.entries(points).map(function(d) {
    		  return {type: "Point", coordinates: d.value}
    	  });
        
    	  var geojsonLinks = links.map(function(d) {
    		  return {type: "LineString", coordinates: [points[d.source],points[d.target]] }
    	  })
        
        svg.selectAll(null)
    	  .data(geojsonLinks)
    	  .enter()
    	  .append("path")
    	  .attr("d",path)
    	  .attr("fill","none")
    	  .attr("stroke-width",2)
    	  .attr("stroke","steelblue");
        
    	  svg.selectAll(null)
    	  .data(geojsonPoints)
    	  .enter()
    	  .append("path")
    	  .attr("d",path)
    	  .attr("fill","white")
    	  .attr("stroke-width",2)
    	  .attr("stroke","steelblue");
       
    
        d3.timer(function() {
          var dt = Date.now() - t0;
          projection.rotate([velocity[0] * dt + origin[0], velocity[1] * dt + origin[1]]);
          svg.selectAll("path")
             .attr("d", path);
        });
    
    	
      });
    <script type="text/javascript" src="https://d3js.org/d3.v5.js"></script>
    <script src="https://d3js.org/topojson.v1.min.js"></script>

    Now keep in mind, the layering of the svg paths is done in order of their appending - the first appended will be behind the second appended. So if you want the links to be ontop of the points, just swap the order they are appended. You can use g groups to manage ordering too - the gs are layered too.