Search code examples
d3.jswebgl-globed3-geo

D3 globe with country names label layer


I'm struggling to find any example of a D3 globe (or Globe-gl etc.) that has country names as a label layer. I'm essentially looking for this demo

https://observablehq.com/@michael-keith/draggable-globe-in-d3

but with country names (ideally at the countries' geographical centre). They should be zoomable with the globe, of course. None of the 37 forks of the globe demo seem to have added this. I'd appreciate any pointers at all.

I've added a pen in case that is easier: <https://codepen.io/alhuber1502/pen/YzbGPMB>

Many thanks!


Solution

  • To place the text on the globe you use the path method path.centroid. This returns the center of a path (as drawn, not geographically). This can be used to place the text as follows:

    let text = svg.selectAll(null)
                      .data(data)
                      .enter()
                      .append("text")
                      .attr("transform", function(d) { return "translate("+path.centroid(d)+")"});
    

    However, this doesn't work for features with no visible path (ie, features on the far side of the earth that aren't rendered). For this we need to do a check, otherwise the text will appear at [0,0]:

           .style("opacity", function(d) {
             if(path(d)) return 1; else return 0;
           })
    

    This checks to see if any path data is created by the path generator, if not, we hide the text. If a path data isn't created, the feature isn't visible and shouldn't be labelled.

    We update these every rotate event:

      text.attr("transform", function(d) { return "translate("+path.centroid(d)+")"})
          .style("opacity", function(d) {
             if(path(d)) return 1; else return 0;
           })
    

    We don't need to update the style every zoom event as we aren't rotating the earth (and hence hiding/revealing new paths).

    I've taken your example (upped it to v7 for d3.json) and applied the above changes.

    d3.json("https://static.observableusercontent.com/files/cbb0b433d100be8f4c48e19de6f0702d83c76df3def6897d7a4ccdb48d2f5f039bc3ae1141dd1005c253ca13c506f5824ae294f4549e5af914d0e3cb467bd8b0?response-content-disposition=attachment%3Bfilename*%3DUTF-8%27%27world.json").then(function(data) {
      
        let width = 500;
        let height = 500;
        const sensitivity = 75;
    
        let projection = d3
          .geoOrthographic()
          .scale(250)
          .center([0, 0])
          .rotate([0, -30])
          .translate([width / 2, height / 2]);
    
        const initialScale = projection.scale();
        let path = d3.geoPath().projection(projection);
    
        let svg = d3
          .select("svg")
          .attr("width", width)
          .attr("height", height);
    
        let globe = svg
          .append("circle")
          .attr("fill", "#EEE")
          .attr("stroke", "#000")
          .attr("stroke-width", "0.2")
          .attr("cx", width / 2)
          .attr("cy", height / 2)
          .attr("r", initialScale);
    
     
    
        let map = svg.append("g");
    
        map
          .append("g")
          .attr("class", "countries")
          .selectAll("path")
          .data(data.features)
          .enter()
          .append("path")
          .attr("class", (d) => "country_" + d.properties.name.replace(" ", "_"))
          .attr("d", path)
          .attr("fill", "white")
          .style("stroke", "black")
          .style("stroke-width", 0.3)
          .style("opacity", 0.8);
        
       let text = map
          .append("g")
          .selectAll("text")
          .data(data.features)
          .enter()
          .append("text")
          .attr("transform", function(d) { return "translate(" +  path.centroid(d) + ")"; })
          .text(function(d) { return d.properties.name; })
          .style("opacity", function(d) {
            if(path(d)) return 1; else return 0;
          })
       
     
       svg
          .call(
            d3.drag().on("drag", () => {
              const rotate = projection.rotate();
              const k = sensitivity / projection.scale();
              projection.rotate([
                rotate[0] + d3.event.dx * k,
                rotate[1] - d3.event.dy * k
              ]);
              path = d3.geoPath().projection(projection);
              svg.selectAll("path").attr("d", path);
              text.attr("transform", function(d) { return "translate(" + path.centroid(d) + ")"; })
                .style("opacity", function(d) {
                 if(path(d)) return 1; else return 0;
               })
            })
          )
          .call(
            d3.zoom().on("zoom", () => {
              if (d3.event.transform.k > 0.3) {
                projection.scale(initialScale * d3.event.transform.k);
                path = d3.geoPath().projection(projection);
                svg.selectAll("path").attr("d", path);
                globe.attr("r", projection.scale());
                text.attr("transform", function(d) { return "translate(" + path.centroid(d) + ")"; })
              } else {
                d3.event.transform.k = 0.3;
              }
            })
          );   
    
    
        function placeText(d, projection, text) {
            d3.select(this)
              .attr("opacity", path(d) == NULL ? 1 : 0)
              .attr("transform",  "translate(" + path.centroid(d) + ")");
        }
    
    })
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.9.0/d3.min.js"></script>
    <svg></svg>