A world map
Real country shapes in fixed scenes: Natural Earth’s 110m countries from world-atlas, projected once with d3-geo into one set of map units, and drawn as regions with their own outlines. Continents enter to their countries, which land exactly where the continent was drawn. Lesotho is a hole in South Africa’s outline, and each label sits at its country’s labelAt, the middle of its biggest piece.
This one is an app of its own, apps/world in the repository, built on the published package. The
frame below runs it, built from exactly these files. Start with source.ts; data.ts is the
projection, which the explorer never sees. main.tsx keeps the focus in the URL hash, builds a
breadcrumb from onSceneChange, and shows the selected country from onNodeSelect.
import { createSceneSource, type ExplorerNode, type SceneSource } from "@data-xplorer/core";import { continentByName, continents, WORLD, type Place } from "./data";
/** * The world as fixed scenes of regions: continents at the top, each entered to its countries. A * focus is a continent's name. Every scene is in the same map units, and a continent's scene is * bounded by that continent's own box, so its countries land exactly where it was drawn. */type ContinentName = string;
// No `ramp` or `color`: each region keeps the explorer's stable colour of its own, so neighbours// stand apart, as on a political map.function region(place: Place, drillsInto: ContinentName | null, sublabel: string | null): ExplorerNode<ContinentName> { return { id: place.name, label: place.name, sublabel, weight: 1, // a fixed scene sizes nothing by weight href: `#${encodeURIComponent(place.name)}`, drillsInto, shape: "region", box: place.box, outline: place.outline, labelAt: place.labelAt, };}
export function worldMapSource(): SceneSource<ContinentName> { return createSceneSource<ContinentName>({ load(focus) { if (focus === null) { return { id: "world", heading: "The world", layout: "fixed", bounds: WORLD, clusters: continents.map((c) => region(c, c.name, `${c.countries.length} countries`)), nodes: [], edges: [], }; } const continent = continentByName.get(focus); if (!continent) throw new Error(`no continent ${focus}`); return { id: continent.name, heading: continent.name, layout: "fixed", bounds: continent.box, clusters: [], nodes: continent.countries.map((country) => region(country, null, null)), edges: [], }; }, parentOf: (focus) => ({ kind: "anchored", focus: null, anchorId: focus }), });}
export const decodeContinent = (hash: string): ContinentName | null => (continentByName.has(hash) ? hash : null);import type { ExplorerOutline, ExplorerPoint, ExplorerRect } from "@data-xplorer/core";import { geoArea, geoCentroid, geoNaturalEarth1, geoPath, geoStream, type GeoPermissibleObjects } from "d3-geo";import { feature } from "topojson-client";import type { GeometryCollection, Topology } from "topojson-specification";import atlas from "world-atlas/countries-110m.json";import { CONTINENTS, LABEL_AT } from "./continents";
/** * The world, projected once: every country's outline and box in one set of map units (the Natural * Earth projection's), which is all a fixed layout needs. The explorer never sees a longitude. */export interface Place { name: string; /** Polygons, each its edge then its holes, in map units. */ outline: ExplorerOutline; box: ExplorerRect; labelAt: ExplorerPoint;}
export interface Continent extends Place { countries: Place[];}
const projection = geoNaturalEarth1();const [[left, top], [right, bottom]] = geoPath(projection).bounds({ type: "Sphere" });const mapWidth = right - left;
/** The whole map's extent: the projected globe. */export const WORLD: ExplorerRect = { x: left, y: top, width: mapWidth, height: bottom - top };
const project = ([lon, lat]: [number, number]): ExplorerPoint => { const [x, y] = projection([lon, lat])!; return { x, y };};
/** A feature's projected rings, as d3 streams them: cut at the antimeridian, ready to draw. */function projectedRings(object: GeoPermissibleObjects): number[][][] { const rings: number[][][] = []; let ring: number[][] = []; geoStream( object, projection.stream({ point: (x, y) => void ring.push([x, y]), lineStart: () => void (ring = []), lineEnd: () => void (ring.length > 2 && rings.push(ring)), polygonStart() {}, polygonEnd() {}, sphere() {}, }), ); return rings;}
const area = (ring: number[][]) => Math.abs(ring.reduce((sum, [x, y], i) => sum + x! * ring[(i + 1) % ring.length]![1]! - ring[(i + 1) % ring.length]![0]! * y!, 0) / 2);const midX = (ring: number[][]) => ring.reduce((sum, [x]) => sum + x!, 0) / ring.length;
function inside(ring: number[][], [px, py]: number[]): boolean { let hit = false; for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { const [xi, yi] = ring[i]!; const [xj, yj] = ring[j]!; if (yi! > py! !== yj! > py! && px! < ((xj! - xi!) * (py! - yi!)) / (yj! - yi!) + xi!) hit = !hit; } return hit;}
/** * Rings into polygons: biggest first, each either a hole in a polygon already found (Lesotho in * South Africa) or a polygon of its own. A piece cut off at the antimeridian (Chukotka, half of * Fiji) is moved back beside the rest of its country, so the country's box stays compact. */function toOutline(rings: number[][][]): ExplorerOutline { const sorted = [...rings].sort((a, b) => area(b) - area(a)); const main = sorted[0] ? midX(sorted[0]) : 0; const polygons: number[][][][] = []; for (let ring of sorted) { const shift = midX(ring) - main > mapWidth / 2 ? -mapWidth : main - midX(ring) > mapWidth / 2 ? mapWidth : 0; if (shift) ring = ring.map(([x, y]) => [x! + shift, y!]); const container = polygons.find((polygon) => inside(polygon[0]!, ring[0]!)); if (container) container.push(ring); else polygons.push([ring]); } return polygons;}
function boxOf(outline: ExplorerOutline): ExplorerRect { let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity]; for (const polygon of outline) for (const [x, y] of polygon[0]!) { x0 = Math.min(x0, x!); y0 = Math.min(y0, y!); x1 = Math.max(x1, x!); y1 = Math.max(y1, y!); } return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };}
const topology = atlas as unknown as Topology<{ countries: GeometryCollection<{ name: string }> }>;const countryByName = new Map<string, Place>();for (const country of feature(topology, topology.objects.countries).features) { const outline = toOutline(projectedRings(country)); // The label goes in the middle of the country's biggest piece, not of its box (the USA's box // takes in Alaska, whose middle is in Canada). const biggest = country.geometry.type === "MultiPolygon" ? country.geometry.coordinates.map((coordinates) => ({ type: "Polygon" as const, coordinates })).sort((a, b) => geoArea(b) - geoArea(a))[0]! : country; countryByName.set(country.properties.name, { name: country.properties.name, outline, box: boxOf(outline), labelAt: project(geoCentroid(biggest) as [number, number]), });}
/** Continents, their countries alphabetical. A continent's outline is all of its countries'. */export const continents: Continent[] = Object.entries(CONTINENTS).map(([name, names]) => { const countries = names.map((n) => countryByName.get(n)!).sort((a, b) => a.name.localeCompare(b.name)); const outline = countries.flatMap((c) => c.outline); return { name, countries, outline, box: boxOf(outline), labelAt: project(LABEL_AT[name]!) };});
export const continentByName = new Map(continents.map((c) => [c.name, c]));/** * Which continent each country of `world-atlas`'s 110m map is in, by the name it carries there. * The map has no continents of its own; these follow Natural Earth's `CONTINENT` field, so Russia * is in Europe, Turkey and the Caucasus in Asia, and Greenland in North America. */export const CONTINENTS: Record<string, string[]> = { Africa: [ "Algeria", "Angola", "Benin", "Botswana", "Burkina Faso", "Burundi", "Cameroon", "Central African Rep.", "Chad", "Congo", "Côte d'Ivoire", "Dem. Rep. Congo", "Djibouti", "Egypt", "Eq. Guinea", "Eritrea", "eSwatini", "Ethiopia", "Gabon", "Gambia", "Ghana", "Guinea", "Guinea-Bissau", "Kenya", "Lesotho", "Liberia", "Libya", "Madagascar", "Malawi", "Mali", "Mauritania", "Morocco", "Mozambique", "Namibia", "Niger", "Nigeria", "Rwanda", "S. Sudan", "Senegal", "Sierra Leone", "Somalia", "Somaliland", "South Africa", "Sudan", "Tanzania", "Togo", "Tunisia", "Uganda", "W. Sahara", "Zambia", "Zimbabwe", ], Asia: [ "Afghanistan", "Armenia", "Azerbaijan", "Bangladesh", "Bhutan", "Brunei", "Cambodia", "China", "Cyprus", "Georgia", "India", "Indonesia", "Iran", "Iraq", "Israel", "Japan", "Jordan", "Kazakhstan", "Kuwait", "Kyrgyzstan", "Laos", "Lebanon", "Malaysia", "Mongolia", "Myanmar", "N. Cyprus", "Nepal", "North Korea", "Oman", "Pakistan", "Palestine", "Philippines", "Qatar", "Saudi Arabia", "South Korea", "Sri Lanka", "Syria", "Taiwan", "Tajikistan", "Thailand", "Timor-Leste", "Turkey", "Turkmenistan", "United Arab Emirates", "Uzbekistan", "Vietnam", "Yemen", ], Europe: [ "Albania", "Austria", "Belarus", "Belgium", "Bosnia and Herz.", "Bulgaria", "Croatia", "Czechia", "Denmark", "Estonia", "Finland", "France", "Germany", "Greece", "Hungary", "Iceland", "Ireland", "Italy", "Kosovo", "Latvia", "Lithuania", "Luxembourg", "Macedonia", "Moldova", "Montenegro", "Netherlands", "Norway", "Poland", "Portugal", "Romania", "Russia", "Serbia", "Slovakia", "Slovenia", "Spain", "Sweden", "Switzerland", "Ukraine", "United Kingdom", ], "North America": [ "Bahamas", "Belize", "Canada", "Costa Rica", "Cuba", "Dominican Rep.", "El Salvador", "Greenland", "Guatemala", "Haiti", "Honduras", "Jamaica", "Mexico", "Nicaragua", "Panama", "Puerto Rico", "Trinidad and Tobago", "United States of America", ], "South America": [ "Argentina", "Bolivia", "Brazil", "Chile", "Colombia", "Ecuador", "Falkland Is.", "Guyana", "Paraguay", "Peru", "Suriname", "Uruguay", "Venezuela", ], Oceania: ["Australia", "Fiji", "New Caledonia", "New Zealand", "Papua New Guinea", "Solomon Is.", "Vanuatu"], Antarctica: ["Antarctica", "Fr. S. Antarctic Lands"],};
/** Where each continent's name goes, as [longitude, latitude]: its countries' middle can be at sea. */export const LABEL_AT: Record<string, [number, number]> = { Africa: [20, 5], Asia: [95, 35], Europe: [30, 55], "North America": [-100, 45], "South America": [-60, -15], Oceania: [140, -25], Antarctica: [0, -80],};import { useEffect, useState } from "react";import { createRoot } from "react-dom/client";import { DataExplorer, type ExplorerItem } from "@data-xplorer/core";import { decodeContinent, worldMapSource } from "./source";
const source = worldMapSource();
function readHash(): string { return decodeURIComponent(window.location.hash.slice(1));}
/** * The world as an app: the focus (a continent) lives in the URL hash, written on `onSettle` and * read back on Back/Forward; a breadcrumb from `onSceneChange`; the selected country from * `onNodeSelect`. */function WorldMap() { const [focus, setFocus] = useState<string | null>(() => decodeContinent(readHash())); const [selected, setSelected] = useState<ExplorerItem<string> | null>(null);
useEffect(() => { const onHashChange = () => setFocus(decodeContinent(readHash())); window.addEventListener("hashchange", onHashChange); return () => window.removeEventListener("hashchange", onHashChange); }, []);
function handleSettle(next: string | null) { const hash = next ?? ""; if (hash !== readHash()) window.location.hash = hash; // one history entry per settled scene setFocus(next); }
return ( <main className="example"> <header className="example__header"> <h1>A world map</h1> <div className="example__intro"> <p> Natural Earth's 110m countries (from <code>world-atlas</code>), projected once with{" "} <code>d3-geo</code> and drawn as <code>region</code>s in <strong>fixed</strong> scenes: continents at the top, each entered to its countries. Every country keeps its own shape and place, so zooming into a continent shows its countries exactly where it was, its neighbours round it. Lesotho is a hole in South Africa; point at it to see the difference. </p> </div> </header>
<nav aria-label="Where you are" className="example__trail"> <span>The world</span> {focus !== null && <span>{focus}</span>} </nav>
<DataExplorer source={source} focus={focus} onSettle={handleSettle} onNodeSelect={setSelected} background={{ color: "#0b1d2e", grid: false }} />
<aside className="example__selection" aria-live="polite"> {selected ? ( <> <h2>{selected.label}</h2> {selected.sublabel && <p>{selected.sublabel}</p>} <p>{selected.drillsInto !== null ? "Double-click or zoom in to enter it." : "A country."}</p> </> ) : ( <p className="example__hint"> Scroll or pinch to zoom, drag to pan, click a country to select it, double-click a continent to enter it. </p> )} </aside> </main> );}
createRoot(document.getElementById("root")!).render(<WorldMap />);