Creating Azimuthal Equidistant Maps with D3.js

Creating Azimuthal Equidistant Maps with D3.js

Introduction

D3.js (Data-Driven Documents) is the gold standard for web-based data visualization, and its geo module provides powerful tools for creating map projections. In this tutorial, you'll learn how to create azimuthal equidistant maps—projections where distances and directions from a center point are preserved accurately.

By the end of this guide, you'll be able to:

  • Set up a D3.js project with geo projections
  • Create an azimuthal equidistant map centered on any location
  • Add graticules (latitude/longitude grid lines)
  • Draw country boundaries and coastlines
  • Add distance rings and azimuth labels
  • Make the map interactive with pan and zoom

Prerequisites

Before starting, you should have:

  • Basic knowledge of HTML, CSS, and JavaScript
  • Node.js installed (for local development) or use a CDN
  • A text editor or IDE
  • Understanding of basic cartographic concepts

Setting Up Your Project

HTML Structure

Create an index.html file with the following structure:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Azimuthal Equidistant Map - D3.js</title>
    <style>
        body {
            margin: 0;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            background: #1a1a2e;
        }

        #map-container {
            background: #0d1b2a;
            border-radius: 50%;
            overflow: hidden;
        }

        .land {
            fill: #2d6a4f;
            stroke: #40916c;
            stroke-width: 0.5;
        }

        .graticule {
            fill: none;
            stroke: #778da9;
            stroke-width: 0.7;
            stroke-opacity: 0.8;
        }

        .boundary {
            fill: none;
            stroke: #778da9;
            stroke-width: 0.5;
        }

        .ocean {
            fill: #0d1b2a;
        }
    </style>
</head>
<body>
    <div id="map-container"></div>

    <script src="https://d3js.org/d3.v7.min.js"></script>
    <script src="https://d3js.org/topojson.v3.min.js"></script>
    <script src="map.js"></script>
</body>
</html>

Getting World Data

D3.js works best with TopoJSON format for geographic data. You can use the Natural Earth dataset:

// We'll load world data from a CDN
const worldDataUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";

Understanding the Azimuthal Equidistant Projection

The Mathematics

The azimuthal equidistant projection maps the sphere onto a plane such that:

  • All points are at their true distance from the center point
  • All points are in their true direction (azimuth) from the center

The projection formulas are:

$$x = k \cdot \cos(\phi) \cdot \sin(\lambda - \lambda_0)$$

$$y = k \cdot [\cos(\phi_0) \cdot \sin(\phi) - \sin(\phi_0) \cdot \cos(\phi) \cdot \cos(\lambda - \lambda_0)]$$

Where: - $(\phi, \lambda)$ is the point's latitude and longitude - $(\phi_0, \lambda_0)$ is the center point - $k$ is a scale factor based on angular distance from center

D3's Implementation

D3.js handles all this math for you with d3.geoAzimuthalEquidistant():

const projection = d3.geoAzimuthalEquidistant()
    .rotate([-centerLon, -centerLat])  // Note: negative values!
    .scale(200)
    .translate([width / 2, height / 2]);

Important: D3 uses .rotate() with negative longitude and latitude values because it rotates the globe under a fixed projection plane.


Building the Map

Step 1: Basic Setup

Create map.js:

// Configuration
const width = 800;
const height = 800;
const centerLat = 40.7128;   // New York City
const centerLon = -74.0060;

// Create SVG container
const svg = d3.select("#map-container")
    .append("svg")
    .attr("width", width)
    .attr("height", height)
    .attr("viewBox", [0, 0, width, height]);

// Create the projection
const projection = d3.geoAzimuthalEquidistant()
    .rotate([-centerLon, -centerLat])
    .scale(125)
    .translate([width / 2, height / 2])
    .clipAngle(179.9);  // Full globe (use 90 for hemisphere)

// Create path generator
const path = d3.geoPath().projection(projection);

D3.js azimuthal equidistant projection basic setup with ocean background

Step 2: Add the Ocean Background

// Add circular ocean background
svg.append("circle")
    .attr("cx", width / 2)
    .attr("cy", height / 2)
    .attr("r", projection.scale() * Math.PI)
    .attr("class", "ocean");

Step 3: Add Graticules

Graticules are the latitude/longitude grid lines that help readers understand position:

// Create graticule generator
const graticule = d3.geoGraticule()
    .step([15, 15]);  // Lines every 15 degrees

// Add graticule to SVG
svg.append("path")
    .datum(graticule())
    .attr("class", "graticule")
    .attr("d", path);

D3.js azimuthal equidistant map with graticule grid lines

Step 4: Load and Render World Data

// Load world topology
async function loadMap() {
    const world = await d3.json(
        "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"
    );

    // Extract land features
    const land = topojson.feature(world, world.objects.land);
    const countries = topojson.feature(world, world.objects.countries);
    const borders = topojson.mesh(
        world, 
        world.objects.countries, 
        (a, b) => a !== b
    );

    // Draw land masses
    svg.append("path")
        .datum(land)
        .attr("class", "land")
        .attr("d", path);

    // Draw country borders
    svg.append("path")
        .datum(borders)
        .attr("class", "boundary")
        .attr("d", path);
}

loadMap();

D3.js azimuthal equidistant map with country boundaries


Adding Distance Rings

Distance rings help users measure how far locations are from the center point:

function addDistanceRings(svg, projection, centerLat, centerLon) {
    const earthRadius = 6371; // km
    const ringDistances = [2500, 5000, 7500, 10000, 12500, 15000, 17500];

    const ringGroup = svg.append("g").attr("class", "distance-rings");

    ringDistances.forEach(distance => {
        // Calculate angular distance
        const angularDistance = (distance / earthRadius) * (180 / Math.PI);

        // Create a circle at this distance from center
        const ring = d3.geoCircle()
            .center([centerLon, centerLat])
            .radius(angularDistance);

        ringGroup.append("path")
            .datum(ring())
            .attr("d", path)
            .attr("fill", "none")
            .attr("stroke", "#ffd166")
            .attr("stroke-width", 1)
            .attr("stroke-dasharray", "4,4")
            .attr("opacity", 0.6);

        // Add distance label
        const labelPoint = projection([
            centerLon,
            centerLat + angularDistance
        ]);

        if (labelPoint) {
            ringGroup.append("text")
                .attr("x", labelPoint[0] + 5)
                .attr("y", labelPoint[1])
                .attr("fill", "#ffd166")
                .attr("font-size", "10px")
                .text(`${distance.toLocaleString()} km`);
        }
    });
}

D3.js azimuthal equidistant map with distance rings showing kilometers from center


Adding Azimuth Labels

Azimuth labels around the edge show compass directions:

function addAzimuthLabels(svg, width, height) {
    const centerX = width / 2;
    const centerY = height / 2;
    const radius = Math.min(width, height) / 2 - 20;

    const directions = [
        { angle: 0, label: "N" },
        { angle: 45, label: "NE" },
        { angle: 90, label: "E" },
        { angle: 135, label: "SE" },
        { angle: 180, label: "S" },
        { angle: 225, label: "SW" },
        { angle: 270, label: "W" },
        { angle: 315, label: "NW" }
    ];

    const labelGroup = svg.append("g").attr("class", "azimuth-labels");

    directions.forEach(({ angle, label }) => {
        // Convert to radians (0° is North, clockwise)
        const radians = (angle - 90) * (Math.PI / 180);

        const x = centerX + radius * Math.cos(radians);
        const y = centerY + radius * Math.sin(radians);

        labelGroup.append("text")
            .attr("x", x)
            .attr("y", y)
            .attr("text-anchor", "middle")
            .attr("dominant-baseline", "middle")
            .attr("fill", "#e0e1dd")
            .attr("font-size", "14px")
            .attr("font-weight", "bold")
            .text(label);
    });

    // Add degree markers every 30°
    for (let deg = 0; deg < 360; deg += 30) {
        if (deg % 45 !== 0) {  // Skip cardinal/intercardinal
            const radians = (deg - 90) * (Math.PI / 180);
            const x = centerX + (radius - 10) * Math.cos(radians);
            const y = centerY + (radius - 10) * Math.sin(radians);

            labelGroup.append("text")
                .attr("x", x)
                .attr("y", y)
                .attr("text-anchor", "middle")
                .attr("fill", "#778da9")
                .attr("font-size", "10px")
                .text(`${deg}°`);
        }
    }
}

D3.js azimuthal equidistant map with compass direction labels around the edge


Making It Interactive

Changing the Center Point

Allow users to click anywhere to re-center the map:

svg.on("click", function(event) {
    const [x, y] = d3.pointer(event);
    const coords = projection.invert([x, y]);

    if (coords) {
        const [newLon, newLat] = coords;
        updateProjection(newLat, newLon);
    }
});

function updateProjection(lat, lon) {
    projection.rotate([-lon, -lat]);

    // Transition all paths smoothly
    svg.selectAll("path")
        .transition()
        .duration(750)
        .attr("d", path);

    // Update rings and labels...
}

Adding Zoom

const zoom = d3.zoom()
    .scaleExtent([0.5, 8])
    .on("zoom", (event) => {
        svg.selectAll("g").attr("transform", event.transform);
    });

svg.call(zoom);

Interactive D3.js azimuthal equidistant map with pan and zoom


Complete Working Example

Here's the full map.js file:

(async function() {
    // Configuration
    const width = 800;
    const height = 800;
    let centerLat = 40.7128;
    let centerLon = -74.0060;

    // Create SVG
    const svg = d3.select("#map-container")
        .append("svg")
        .attr("width", width)
        .attr("height", height);

    // Create projection
    const projection = d3.geoAzimuthalEquidistant()
        .rotate([-centerLon, -centerLat])
        .scale(125)
        .translate([width / 2, height / 2])
        .clipAngle(179.9);

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

    // Ocean background
    svg.append("circle")
        .attr("cx", width / 2)
        .attr("cy", height / 2)
        .attr("r", projection.scale() * Math.PI)
        .attr("class", "ocean");

    // Graticules
    const graticule = d3.geoGraticule().step([15, 15]);
    svg.append("path")
        .datum(graticule())
        .attr("class", "graticule")
        .attr("d", path);

    // Load and render world
    const world = await d3.json(
        "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"
    );

    const land = topojson.feature(world, world.objects.land);
    const borders = topojson.mesh(
        world, 
        world.objects.countries, 
        (a, b) => a !== b
    );

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

    svg.append("path")
        .datum(borders)
        .attr("class", "boundary")
        .attr("d", path);

    // Add center marker
    const center = projection([centerLon, centerLat]);
    svg.append("circle")
        .attr("cx", center[0])
        .attr("cy", center[1])
        .attr("r", 5)
        .attr("fill", "#ef476f");

    console.log("Azimuthal Equidistant map centered on:", centerLat, centerLon);
})();

Performance Tips

  1. Use TopoJSON instead of GeoJSON—it's significantly smaller
  2. Simplify geometries for large datasets using topojson.simplify()
  3. Use Canvas for very detailed maps:
const context = canvas.getContext("2d");
const path = d3.geoPath()
    .projection(projection)
    .context(context);
  1. Throttle interactions when re-rendering on pan/zoom

Common Pitfalls

1. Rotation Direction

Remember: D3's .rotate() takes negative values of your center coordinates:

// To center on New York (40.7°N, 74°W)
.rotate([74.006, -40.7128])  // Note the signs!

2. Clip Angle

  • clipAngle(90) shows a hemisphere
  • clipAngle(180) shows the full globe (with severe distortion at edges)

3. Antimeridian Cutting

D3 handles the antimeridian (180°/-180° longitude) automatically, but complex polygons may need preprocessing.


Next Steps

  • Add tooltips showing country names and distances
  • Implement a search box to center on any location
  • Add a layer for cities or custom markers
  • Export the map as SVG or PNG

Check out our Lambert Equal-Area tutorial for a projection that preserves area instead of distance.


Resources

d3.js javascript azimuthal equidistant web development tutorial programming

Try the Map Generator

Put what you've learned into practice! Create your own azimuthal equidistant or Lambert equal-area projection map centered on any location.

Generate a Map