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

About This Article

Who It Is For

  • Frontend developers building browser-based map visualizations with D3
  • Readers who want an interactive azimuthal equidistant implementation rather than a static export
  • JavaScript users learning how projection settings affect SVG world maps

Methodology

  • Builds the example incrementally from projection setup to graticules, country features, rings, and interaction
  • Uses D3 geo projection APIs and original screenshots from the tutorial implementation
  • Emphasizes the relationship between projection parameters and visible map behavior in the browser

Notes

  • Performance and usability depend on dataset resolution, projection scale, and how much interactivity is layered on top
  • The browser version is best for exploratory interaction, while exported graphics may need a separate print-focused workflow

Sources

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