Creating Lambert Azimuthal Equal-Area Maps with D3.js

Creating Lambert Azimuthal Equal-Area Maps with D3.js

Introduction

While the azimuthal equidistant projection preserves distances from a center point, the Lambert Azimuthal Equal-Area projection preserves something different: area. Every region on the map has the same proportional area as on the actual Earth.

This makes it invaluable for:

  • Thematic maps showing population density, land use, or resources
  • Statistical visualizations where area represents data
  • Climate and environmental maps where accurate area comparison matters
  • Choropleth maps that shade regions by data values

In this tutorial, you'll learn how to create Lambert Equal-Area maps with D3.js, understand when to choose this projection, and implement interactive features.


Equal-Area vs Equidistant: When to Use Which?

Property Equidistant Equal-Area
Preserves Distance from center Area/size of regions
Best for Navigation, radio, distance measurement Thematic maps, statistics
Shape distortion Moderate at edges More pronounced at edges
Distance accuracy Perfect from center Not preserved

Choose Equal-Area when: - Comparing sizes of countries or regions - Creating density maps (population per square km) - Visualizing data where area represents magnitude - Making choropleth or heat maps

Choose Equidistant when: - Measuring distances from a specific point - Navigation or direction finding - Radio propagation or coverage maps


The Mathematics Behind Lambert Equal-Area

The Lambert Azimuthal Equal-Area projection preserves area by adjusting the radial scale. The 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 the scale factor $k'$ is:

$$k' = \sqrt{\frac{2}{1 + \sin(\phi_0)\sin(\phi) + \cos(\phi_0)\cos(\phi)\cos(\lambda - \lambda_0)}}$$

This differs from the equidistant projection's linear radial scaling, creating the area-preserving property at the cost of some shape distortion.


Setting Up Your D3.js Project

HTML Structure

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

        #map-container {
            position: relative;
        }

        .land {
            fill: #52796f;
            stroke: #84a98c;
            stroke-width: 0.5;
        }

        .graticule {
            fill: none;
            stroke: #6c757d;
            stroke-width: 0.7;
            stroke-opacity: 0.85;
        }

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

        .ocean {
            fill: #1b263b;
        }

        .country:hover {
            fill: #74c69d;
            cursor: pointer;
        }

        .tooltip {
            position: absolute;
            background: rgba(0, 0, 0, 0.8);
            color: white;
            padding: 8px 12px;
            border-radius: 4px;
            font-size: 12px;
            pointer-events: none;
            opacity: 0;
            transition: opacity 0.2s;
        }
    </style>
</head>
<body>
    <div id="map-container">
        <div class="tooltip"></div>
    </div>

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

Building the Lambert Equal-Area Map

Step 1: Initialize the Projection

The key difference from azimuthal equidistant is using d3.geoAzimuthalEqualArea():

// Configuration
const width = 800;
const height = 800;
const centerLat = 45.0;   // Europe-centered example
const centerLon = 10.0;

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

// Create EQUAL-AREA projection (not equidistant!)
const projection = d3.geoAzimuthalEqualArea()
    .rotate([-centerLon, -centerLat])
    .scale(195)
    .translate([width / 2, height / 2])
    .clipAngle(179.9);  // Full globe view (use 90 for hemisphere)

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

D3.js Lambert equal-area projection initial setup

Step 2: Add Background and Graticule

// Circular clip path for clean edges
svg.append("defs")
    .append("clipPath")
    .attr("id", "map-clip")
    .append("circle")
    .attr("cx", width / 2)
    .attr("cy", height / 2)
    .attr("r", projection.scale() * 2);

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

// Graticule
const graticule = d3.geoGraticule()
    .step([10, 10]);  // Denser grid for better area reference

svg.append("path")
    .datum(graticule())
    .attr("class", "graticule")
    .attr("d", path)
    .attr("clip-path", "url(#map-clip)");

Step 3: Load World Data with Country Interaction

For equal-area maps, we often want to interact with individual countries:

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

    // Get country names lookup
    const countryNames = await d3.json(
        "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-names.json"
    );

    const countries = topojson.feature(world, world.objects.countries);

    // Draw individual countries (for hover effects)
    svg.selectAll(".country")
        .data(countries.features)
        .join("path")
        .attr("class", "country land")
        .attr("d", path)
        .attr("clip-path", "url(#map-clip)")
        .on("mouseenter", handleMouseEnter)
        .on("mousemove", handleMouseMove)
        .on("mouseleave", handleMouseLeave);

    // Country borders on top
    const borders = topojson.mesh(
        world,
        world.objects.countries,
        (a, b) => a !== b
    );

    svg.append("path")
        .datum(borders)
        .attr("class", "boundary")
        .attr("d", path)
        .attr("clip-path", "url(#map-clip)");
}

loadMap();

D3.js Lambert equal-area map with country boundaries


Adding Tooltips with Area Information

Since this is an equal-area projection, let's show country areas on hover:

const tooltip = d3.select(".tooltip");

// Country area data (in km²) - subset for example
const countryAreas = {
    "Russia": 17098242,
    "Canada": 9984670,
    "United States of America": 9833520,
    "China": 9596961,
    "Brazil": 8515767,
    "Australia": 7692024,
    "India": 3287263,
    "Argentina": 2780400,
    // ... add more as needed
};

function handleMouseEnter(event, d) {
    const countryName = d.properties.name;
    const area = countryAreas[countryName];

    let tooltipText = countryName;
    if (area) {
        tooltipText += `<br>Area: ${area.toLocaleString()} km²`;
    }

    tooltip
        .html(tooltipText)
        .style("opacity", 1);
}

function handleMouseMove(event) {
    tooltip
        .style("left", (event.offsetX + 15) + "px")
        .style("top", (event.offsetY - 10) + "px");
}

function handleMouseLeave() {
    tooltip.style("opacity", 0);
}

D3.js Lambert equal-area map with hover tooltip showing country area


Creating a Choropleth with Equal-Area

One of the best uses for equal-area projections is choropleth maps. Here's how to color countries by a data value:

// Example: Population density data
const populationDensity = {
    "India": 464,
    "Bangladesh": 1265,
    "Japan": 347,
    "United Kingdom": 281,
    "Germany": 240,
    "China": 153,
    "France": 119,
    "United States of America": 36,
    "Brazil": 25,
    "Russia": 9,
    "Canada": 4,
    "Australia": 3,
    // ... more data
};

// Create color scale
const colorScale = d3.scaleSequential()
    .domain([0, 500])  // Density range
    .interpolator(d3.interpolateYlOrRd);

// Apply colors to countries
svg.selectAll(".country")
    .style("fill", d => {
        const density = populationDensity[d.properties.name];
        return density ? colorScale(density) : "#52796f";
    });

Adding a Legend

function addLegend(svg, colorScale, width) {
    const legendWidth = 200;
    const legendHeight = 15;
    const legendX = width - legendWidth - 30;
    const legendY = 30;

    const legend = svg.append("g")
        .attr("transform", `translate(${legendX}, ${legendY})`);

    // Create gradient
    const defs = svg.append("defs");
    const gradient = defs.append("linearGradient")
        .attr("id", "legend-gradient");

    const numStops = 10;
    for (let i = 0; i <= numStops; i++) {
        const offset = i / numStops;
        gradient.append("stop")
            .attr("offset", `${offset * 100}%`)
            .attr("stop-color", colorScale(offset * 500));
    }

    // Legend rectangle
    legend.append("rect")
        .attr("width", legendWidth)
        .attr("height", legendHeight)
        .style("fill", "url(#legend-gradient)");

    // Legend axis
    const legendScale = d3.scaleLinear()
        .domain([0, 500])
        .range([0, legendWidth]);

    const legendAxis = d3.axisBottom(legendScale)
        .ticks(5)
        .tickFormat(d => d + "/km²");

    legend.append("g")
        .attr("transform", `translate(0, ${legendHeight})`)
        .call(legendAxis)
        .selectAll("text")
        .attr("fill", "#e0e1dd")
        .attr("font-size", "10px");

    legend.append("text")
        .attr("y", -5)
        .attr("fill", "#e0e1dd")
        .attr("font-size", "12px")
        .text("Population Density");
}

addLegend(svg, colorScale, width);

D3.js Lambert equal-area choropleth map with GDP per capita legend


Comparing Projections Side-by-Side

Want to show users the difference between equal-area and equidistant? Create both:

function createComparisonView() {
    const container = d3.select("body")
        .append("div")
        .style("display", "flex")
        .style("gap", "20px");

    // Equal-Area projection
    const svgEqualArea = container.append("svg")
        .attr("width", 400)
        .attr("height", 400);

    const projEqualArea = d3.geoAzimuthalEqualArea()
        .rotate([-centerLon, -centerLat])
        .scale(200)
        .translate([200, 200])
        .clipAngle(90);

    // Equidistant projection
    const svgEquidistant = container.append("svg")
        .attr("width", 400)
        .attr("height", 400);

    const projEquidistant = d3.geoAzimuthalEquidistant()
        .rotate([-centerLon, -centerLat])
        .scale(200)
        .translate([200, 200])
        .clipAngle(90);

    // Render both with same data...
}

Mercator vs Lambert equal-area projection comparison


Handling Specific Regions

For regional maps (Europe, Africa, etc.), adjust your clip angle and scale:

// Europe-focused view
const europeProjection = d3.geoAzimuthalEqualArea()
    .rotate([-10, -52])     // Center on Europe
    .scale(1000)            // Zoom in
    .translate([width / 2, height / 2])
    .clipAngle(30);         // Show only 30° from center

// Africa-focused view
const africaProjection = d3.geoAzimuthalEqualArea()
    .rotate([-20, 0])       // Center on Africa
    .scale(600)
    .translate([width / 2, height / 2])
    .clipAngle(45);

Lambert equal-area regional map example


Animation: Morphing Between Projections

Create a smooth transition between equidistant and equal-area:

let isEqualArea = true;

const projEqualArea = d3.geoAzimuthalEqualArea()
    .rotate([-centerLon, -centerLat])
    .scale(200)
    .translate([width / 2, height / 2]);

const projEquidistant = d3.geoAzimuthalEquidistant()
    .rotate([-centerLon, -centerLat])
    .scale(200)
    .translate([width / 2, height / 2]);

function toggleProjection() {
    isEqualArea = !isEqualArea;
    const targetProj = isEqualArea ? projEqualArea : projEquidistant;

    path.projection(targetProj);

    svg.selectAll("path")
        .transition()
        .duration(1000)
        .attrTween("d", function(d) {
            // Custom interpolation for smooth morphing
            return d3.geoInterpolate(
                path(d),
                d3.geoPath().projection(targetProj)(d)
            );
        });
}

// Add toggle button
d3.select("body").append("button")
    .text("Toggle Projection")
    .on("click", toggleProjection);

Complete Working Example

(async function() {
    const width = 800;
    const height = 800;
    const centerLat = 45;
    const centerLon = 10;

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

    const projection = d3.geoAzimuthalEqualArea()
        .rotate([-centerLon, -centerLat])
        .scale(400)
        .translate([width / 2, height / 2])
        .clipAngle(90);

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

    // Clip path
    svg.append("defs")
        .append("clipPath")
        .attr("id", "map-clip")
        .append("circle")
        .attr("cx", width / 2)
        .attr("cy", height / 2)
        .attr("r", projection.scale() * Math.SQRT2);

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

    // Graticule
    svg.append("path")
        .datum(d3.geoGraticule().step([10, 10])())
        .attr("class", "graticule")
        .attr("d", path)
        .attr("clip-path", "url(#map-clip)");

    // Load 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)
        .attr("clip-path", "url(#map-clip)");

    svg.append("path")
        .datum(borders)
        .attr("class", "boundary")
        .attr("d", path)
        .attr("clip-path", "url(#map-clip)");

    // Title
    svg.append("text")
        .attr("x", width / 2)
        .attr("y", 30)
        .attr("text-anchor", "middle")
        .attr("fill", "#e0e1dd")
        .attr("font-size", "18px")
        .text("Lambert Azimuthal Equal-Area Projection");

    console.log("Lambert Equal-Area map rendered");
})();

Key Differences from Equidistant Code

Aspect Equidistant Equal-Area
Projection d3.geoAzimuthalEquidistant() d3.geoAzimuthalEqualArea()
Radius calculation scale * π scale * 2
Common clip angles 180° (full globe) 90° (hemisphere) or 180° (full globe)
Primary use Distance rings Choropleth coloring

Next Steps

  • Add data-driven coloring for choropleth maps
  • Implement smooth projection transitions
  • Create printable high-resolution exports
  • Build a Python version with Cartopy

Resources

d3.js javascript lambert equal-area 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