Search code examples
d3.jsmap-projections

How to adjust the size of a d3.js globe?


I'm trying to set the size of this globe to 200 x 200px.

I've learned that the projection is currently sized 960 x 500px.

Changing the size of the SVG doesn't shrink the globe. I'm having trouble understanding why.

Without luck I have tried to add the following to the code:

var width = 200;
var height = 200;

And

const width = 200;
const height = 200;

And

const svg = d3.select('svg')
.attr('width', 200).attr('height', 200);

How would I best approach this, and what am I doing wrong?

My code:

<!DOCTYPE html>
<html>
    <body>
        <svg></svg>
        <script src="https://d3js.org/d3.v4.min.js"></script>
        <script src="https://d3js.org/topojson.v1.min.js"></script>
        <script>
            const width = 960;
            const height = 500;
            const config = {
              speed: 0.005,
              verticalTilt: -20,
              horizontalTilt: 0
            }
            let locations = [];
            const svg = d3.select('svg')
                .attr('width', width).attr('height', height);
            const markerGroup = svg.append('g');
            const projection = d3.geoOrthographic();
            const initialScale = projection.scale();
            const path = d3.geoPath().projection(projection);
            const center = [width/2, height/2];

            drawGlobe();    
            drawGraticule();
            enableRotation();    

            function drawGlobe() {  
                d3.queue()
                    .defer(d3.json, 'world-110m.json')          
                    .defer(d3.json, 'locations.json')
                    .await((error, worldData, locationData) => {
                        svg.selectAll(".segment")
                            .data(topojson.feature(worldData, worldData.objects.countries).features)
                            .enter().append("path")
                            .attr("class", "segment")
                            .attr("d", path)
                            .style("stroke", "silver")
                            .style("stroke-width", "1px")
                            .style("fill", (d, i) => 'silver')
                            .style("opacity", ".5");
                            locations = locationData;
                            drawMarkers();                   
                    });
            }

            function drawGraticule() {
                const graticule = d3.geoGraticule()
                    .step([10, 10]);

                svg.append("path")
                    .datum(graticule)
                    .attr("class", "graticule")
                    .attr("d", path)
                    .style("fill", "#fff")
                    .style("stroke", "#ececec");
            }

            function enableRotation() {
                d3.timer(function (elapsed) {
                    projection.rotate([config.speed * elapsed - 120, config.verticalTilt, config.horizontalTilt]);
                    svg.selectAll("path").attr("d", path);
                    drawMarkers();
                });
            }        

            function drawMarkers() {
                const markers = markerGroup.selectAll('circle')
                    .data(locations);
                markers
                    .enter()
                    .append('circle')
                    .merge(markers)
                    .attr('cx', d => projection([d.longitude, d.latitude])[0])
                    .attr('cy', d => projection([d.longitude, d.latitude])[1])
                    .attr('fill', d => {
                        const coordinate = [d.longitude, d.latitude];
                        gdistance = d3.geoDistance(coordinate, projection.invert(center));
                        return gdistance > 1.57 ? 'none' : 'tomato';
                    })
                    .attr('r', 7);

                markerGroup.each(function () {
                    this.parentNode.appendChild(this);
                });
            }
        </script>
    </body>
</html>

Solution

  • Projection.scale()

    The scale of the projection determines the size of the projected world. Generally speaking d3 projections have a default scale value that will fill a 960x500 SVG/Canvas. A map produced with d3.geoOrthographic doesn't have a long edge, so this is 500x500 pixels. The default scale value is: 249.5 - half the width/height (allowing for stroke width). This scale factor is linear on both width and height: double it and double both (quadruple projected size of world). So if you want a 200x200 px world you'll want: 99.5 to be your scale value.

    This is the default for d3.geoOrthographic, other scales have other scale defaults. For a Mercator, for example, it is 480/π: 2π of longitude across 960 pixels of width.

    Projection.translate()

    However, if you change the scale for a 200x200 pixel world, you'll have an issue with the default projection translate. By default this is set to [250,480] - half of [500,960], the default D3 anticipated size of the SVG/Canvas. This coordinate is where the geographic center of the projection (by default 0°N,0°W) is projected to. You'll want to change this to a value of [100,100]: the center of your SVG/Canvas.

    Solution

    const projection = d3.geoOrthographic()
      .scale(99.5)
      .translate([100,100]);
    

    Automagic Solution

    There is an easier way, but understanding the mechanics can be useful.

    projection.fitSize()/.fitExtent() both set scale and translate automatically based on a specified width/height / extent. In your case this is easy to solve manually, but you could also use:

     d3.geoOrthographic()
       .fitSize([width,height],geoJsonObject)
    

    or

     d3.geoOrthographic()
       .fitExtent([[left,top],[right,bottom]],geojsonObject)
    

    As you're using topojson: topojson.feature returns a geojson object (with a features property containing individual features - the array of features can't be passed to fitSize or fitExtent).