CalloutsATM
The illustration way to label a map: data-callouts="true" leaves a gutter on both sides, and every region in view gets a line from a dot inside it out to the gutter, its name written above the line's end. Regions in the west of the view go to the left gutter, the east to the right; on each side the labels keep their regions' order from top to bottom and never overlap. Hovering a callout lifts its region, clicking it picks the region.
On this page (10)
§Europe's countries
A focus decides what is labelled: the children of the framed region, here Europe's countries. When there are more regions than the gutters hold, the largest win; the rest stay unlabelled until you zoom in. The gutter is --geo-map-callout-width wide (10rem by default), the names are --geo-map-callout-size high (0.875rem): the rows are spaced by that size, and a name longer than the gutter is shortened by its measured width.
§Europe, labelled
§Germany's states
Sixteen states fit on two gutters at any useful height. The dot sits inside each region's largest part - moved inside when the centroid of a curved shape falls outside it - so the line always points at land of that region.
§Germany, labelled
Hover a name: its state lifts. Click one: it is picked, and its callout stays marked.
§Larger names
For a map on a wall or a slide, set both properties on the map: the rows space out, so fewer regions are labelled in the same height - the largest first.
§Large callouts
§Any view
Without a focus, callouts label the regions drawn at the current level - pan and zoom, and they follow. The callouts option is part of the state (setState(name, { callouts: true })), so a switch bound with data-geo-param="callouts" turns them on and off.
§CSS view file
The stylesheet and the script are the Geo Map's.
@layer components { .geo-map { /* the map's own colors: derived from the theme, literal where the theme has no token (water, routes, cells) */ --geo-map-ocean: color-mix(in oklab, var(--background) 90%, #6c9bab); --geo-map-grid: color-mix(in oklab, var(--foreground) 13%, transparent); --geo-map-border: color-mix(in oklab, var(--foreground) 38%, transparent); --geo-map-route: #c14e2e; --geo-map-cell-fill: color-mix(in oklab, #4e958b 32%, transparent); --geo-map-cell-border: #347871; /* a pick: a coral that reads on every palette (the theme's primary is near-black in the default theme) */ --geo-map-select: oklch(0.6 0.19 25); --geo-map-mix: 100%; /* a tone's drawn lightness = l0 + l * lk, its chroma c * ck: identity in light mode; dark mode maps the pastels into the dark range instead of mixing them toward the background (which greyed them) */ --geo-map-l0: 0; --geo-map-lk: 1; --geo-map-ck: 1; --geo-map-callout-width: 10rem; --geo-map-callout-size: 0.875rem; --geo-map-tone-0: oklch(0.91 0.055 150); --geo-map-tone-1: oklch(0.93 0.06 95); --geo-map-tone-2: oklch(0.89 0.06 60); --geo-map-tone-3: oklch(0.88 0.05 18); --geo-map-tone-4: oklch(0.88 0.045 318); --geo-map-tone-5: oklch(0.88 0.045 260); --geo-map-tone-6: oklch(0.9 0.05 205); --geo-map-tone: var(--geo-map-tone-0); position: relative; isolation: isolate; overflow: hidden; block-size: var(--geo-map-height, 28rem); min-block-size: 16rem; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--card); color: var(--card-foreground); font-family: var(--font-sans); &[data-palette="ocean"] { --geo-map-tone-0: oklch(0.93 0.035 215); --geo-map-tone-1: oklch(0.89 0.05 225); --geo-map-tone-2: oklch(0.85 0.06 235); --geo-map-tone-3: oklch(0.81 0.07 245); --geo-map-tone-4: oklch(0.87 0.05 200); --geo-map-tone-5: oklch(0.83 0.06 255); --geo-map-tone-6: oklch(0.91 0.04 240); } &[data-palette="forest"] { --geo-map-tone-0: oklch(0.93 0.045 135); --geo-map-tone-1: oklch(0.89 0.07 135); --geo-map-tone-2: oklch(0.85 0.075 145); --geo-map-tone-3: oklch(0.81 0.075 158); --geo-map-tone-4: oklch(0.87 0.06 170); --geo-map-tone-5: oklch(0.83 0.065 140); --geo-map-tone-6: oklch(0.91 0.05 125); } &[data-palette="sunset"] { --geo-map-tone-0: oklch(0.92 0.06 82); --geo-map-tone-1: oklch(0.88 0.075 65); --geo-map-tone-2: oklch(0.84 0.085 50); --geo-map-tone-3: oklch(0.8 0.085 32); --geo-map-tone-4: oklch(0.86 0.065 15); --geo-map-tone-5: oklch(0.82 0.07 350); --geo-map-tone-6: oklch(0.9 0.06 40); } &[data-palette="heat"] { --geo-map-tone-0: oklch(0.96 0.055 95); --geo-map-tone-1: oklch(0.92 0.08 85); --geo-map-tone-2: oklch(0.88 0.09 72); --geo-map-tone-3: oklch(0.84 0.095 58); --geo-map-tone-4: oklch(0.8 0.1 45); --geo-map-tone-5: oklch(0.76 0.1 35); --geo-map-tone-6: oklch(0.72 0.1 27); } &[data-palette="mono"] { --geo-map-tone-0: color-mix(in oklab, var(--foreground) 5%, var(--card)); --geo-map-tone-1: color-mix(in oklab, var(--foreground) 9%, var(--card)); --geo-map-tone-2: color-mix(in oklab, var(--foreground) 13%, var(--card)); --geo-map-tone-3: color-mix(in oklab, var(--foreground) 17%, var(--card)); --geo-map-tone-4: color-mix(in oklab, var(--foreground) 7%, var(--card)); --geo-map-tone-5: color-mix(in oklab, var(--foreground) 11%, var(--card)); --geo-map-tone-6: color-mix(in oklab, var(--foreground) 15%, var(--card)); } &[data-palette="chart"] { --geo-map-tone-0: color-mix(in oklab, var(--chart-1) 45%, var(--card)); --geo-map-tone-1: color-mix(in oklab, var(--chart-2) 40%, var(--card)); --geo-map-tone-2: color-mix(in oklab, var(--chart-3) 35%, var(--card)); --geo-map-tone-3: color-mix(in oklab, var(--chart-4) 30%, var(--card)); --geo-map-tone-4: color-mix(in oklab, var(--chart-5) 25%, var(--card)); --geo-map-tone-5: color-mix(in oklab, var(--chart-1) 25%, var(--card)); --geo-map-tone-6: color-mix(in oklab, var(--chart-3) 20%, var(--card)); } &[data-variant="plain"] { border: 0; border-radius: 0; background: transparent; } } .dark .geo-map { --geo-map-ocean: color-mix(in oklab, var(--background) 92%, #5a9fb3); --geo-map-border: color-mix(in oklab, var(--background) 70%, transparent); --geo-map-grid: color-mix(in oklab, var(--foreground) 10%, transparent); --geo-map-route: #ff9c76; --geo-map-cell-fill: color-mix(in oklab, #54bbaf 25%, transparent); --geo-map-cell-border: #8adbd2; --geo-map-mix: 58%; --geo-map-select: oklch(0.72 0.16 28); --geo-map-l0: 0.2; --geo-map-lk: 0.33; --geo-map-ck: 0.9; &:is([data-palette="mono"], [data-palette="chart"]) { --geo-map-l0: 0; --geo-map-lk: 1; --geo-map-ck: 1; } } .geo-map-svg { display: block; inline-size: 100%; block-size: 100%; touch-action: none; cursor: grab; user-select: none; &:active { cursor: grabbing; } &:focus-visible { outline: 2px solid var(--ring); outline-offset: -3px; } } .geo-map-sphere path { fill: var(--geo-map-ocean); stroke: var(--geo-map-grid); } .geo-map-graticule path { fill: none; stroke: var(--geo-map-grid); stroke-width: 0.6; pointer-events: none; } /* a region's fill (and its lifted copy's): its tone, light as written; dark through the lightness map above. The context, the pick and the cells set their own fill on a more specific selector */ .geo-map-region, .geo-map-lifted { fill: color-mix(in oklab, var(--geo-map-tone) var(--geo-map-mix), var(--background)); @supports (color: oklch(from red l c h)) { fill: oklch( from var(--geo-map-tone) calc(var(--geo-map-l0) + l * var(--geo-map-lk)) calc(c * var(--geo-map-ck)) h ); } &[data-tone="0"] { --geo-map-tone: var(--geo-map-tone-0); } &[data-tone="1"] { --geo-map-tone: var(--geo-map-tone-1); } &[data-tone="2"] { --geo-map-tone: var(--geo-map-tone-2); } &[data-tone="3"] { --geo-map-tone: var(--geo-map-tone-3); } &[data-tone="4"] { --geo-map-tone: var(--geo-map-tone-4); } &[data-tone="5"] { --geo-map-tone: var(--geo-map-tone-5); } &[data-tone="6"] { --geo-map-tone: var(--geo-map-tone-6); } } .geo-map-region { stroke: var(--geo-map-border); stroke-width: 0.65; stroke-linejoin: round; vector-effect: non-scaling-stroke; transition: fill 180ms ease, opacity 180ms ease, stroke 180ms ease; outline: none; &:focus-visible { stroke: var(--ring); stroke-width: 2.5; } } .geo-map-regions .geo-map-region:hover, .geo-map-cells .geo-map-region:hover { stroke: var(--foreground); stroke-width: 1.2; } /* around a focus: the rest of the world, dimmed and inert */ .geo-map-context { pointer-events: none; & .geo-map-region { fill: color-mix(in oklab, var(--muted) 70%, var(--geo-map-ocean)); opacity: 0.55; } } .geo-map[data-border="none"] :is(.geo-map-regions, .geo-map-context) .geo-map-region { stroke: none; } .geo-map[data-border="dashed"] :is(.geo-map-regions, .geo-map-context) .geo-map-region { stroke-dasharray: 3 2; } .geo-map[data-border="cased"] :is(.geo-map-regions, .geo-map-context) .geo-map-region { stroke: var(--background); stroke-width: 2; paint-order: stroke fill; } /* the selection: fill (default), outline, pulse, lift */ .geo-map-regions .geo-map-region[aria-pressed="true"], .geo-map-cells .geo-map-region[aria-pressed="true"] { fill: color-mix(in oklab, var(--geo-map-select) 62%, var(--card)); stroke: var(--geo-map-select); stroke-width: 1.4; } .geo-map[data-highlight="outline"] :is(.geo-map-regions, .geo-map-cells) .geo-map-region[aria-pressed="true"] { fill: color-mix(in oklab, var(--geo-map-select) 14%, transparent); stroke: var(--geo-map-select); stroke-width: 2.4; stroke-dasharray: 6 4; animation: geo-map-march 900ms linear infinite; } .geo-map[data-highlight="pulse"] :is(.geo-map-regions, .geo-map-cells) .geo-map-region[aria-pressed="true"] { animation: geo-map-pulse 1.6s ease-in-out infinite; } .geo-map[data-highlight="lift"] :is(.geo-map-regions, .geo-map-cells) .geo-map-region[aria-pressed="true"] { fill: color-mix(in oklab, var(--foreground) 22%, var(--geo-map-ocean)); stroke: none; } @keyframes geo-map-march { to { stroke-dashoffset: -20; } } @keyframes geo-map-pulse { 50% { fill: color-mix(in oklab, var(--geo-map-select) 28%, var(--card)); stroke-width: 3; } } /* hover (and data-highlight="lift"): a copy of the region raised a little, its shadow on the map below */ .geo-map-lift { pointer-events: none; } .geo-map-lifted { stroke: var(--foreground); stroke-width: 1; stroke-linejoin: round; vector-effect: non-scaling-stroke; translate: 0 -3px; filter: drop-shadow(0 4px 3px rgb(0 0 0 / 0.28)); transition: translate 160ms ease-out, filter 160ms ease-out; @starting-style { translate: 0 0; filter: drop-shadow(0 0 0 rgb(0 0 0 / 0)); } &[aria-pressed="true"] { fill: color-mix(in oklab, var(--geo-map-select) 62%, var(--card)); stroke: var(--geo-map-select); } &.geo-map-cell { fill: var(--geo-map-cell-fill); stroke: var(--geo-map-cell-border); } } .geo-map[data-highlight="lift"] .geo-map-lifted[aria-pressed="true"] { translate: 0 -5px; filter: drop-shadow(0 7px 5px rgb(0 0 0 / 0.32)); } .geo-map-cells .geo-map-region { fill: var(--geo-map-cell-fill); stroke: var(--geo-map-cell-border); stroke-width: 1.2; } .geo-map-lines { pointer-events: none; } .geo-map-line { fill: none; stroke: var(--geo-map-route); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; } .geo-map-markers { pointer-events: none; } .geo-map-marker { & > :not(text) { fill: var(--geo-map-route); stroke: var(--background); stroke-width: 2; } & text { font: 500 11px var(--font-sans); fill: var(--foreground); stroke: var(--background); stroke-width: 3px; paint-order: stroke; stroke-linejoin: round; } } .geo-map-hatch { fill: none; stroke: var(--muted-foreground); stroke-width: 1; } /* callouts: a leader line from each region to its name outside the map, the name written on the line */ .geo-map-callout { cursor: pointer; } .geo-map-callout-line { fill: none; stroke: color-mix(in oklab, var(--foreground) 45%, transparent); stroke-width: 0.8; vector-effect: non-scaling-stroke; } .geo-map-callout-dot { fill: var(--foreground); } /* the script spaces the rows and shortens the names by this size (--geo-map-callout-size) */ .geo-map-callout-label { font: 500 var(--geo-map-callout-size) var(--font-sans); fill: var(--foreground); } .geo-map-callout:is([data-hover], [data-selected]) { & .geo-map-callout-line { stroke: var(--geo-map-select); stroke-width: 1.6; } & .geo-map-callout-dot { fill: var(--geo-map-select); } & .geo-map-callout-label { fill: var(--geo-map-select); font-weight: 600; } } .geo-map-tooltip { position: absolute; z-index: 3; max-inline-size: 14rem; padding: 0.35rem 0.6rem; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--popover-foreground); background: var(--popover); box-shadow: var(--shadow-md); pointer-events: none; font-size: 0.75rem; white-space: nowrap; &[hidden] { display: none; } } .geo-map-error { position: absolute; inset: auto 1rem 1rem; padding: 0.5rem 0.75rem; border: 1px solid var(--destructive); border-radius: var(--radius-md); color: var(--destructive); background: var(--card); font-size: 0.8125rem; } .geo-map[data-geo-status="loading"] .geo-map-svg { animation: geo-map-loading 1.2s ease-in-out infinite alternate; } @keyframes geo-map-loading { to { opacity: 0.55; } } @media (prefers-reduced-motion: reduce) { .geo-map-region, .geo-map-lifted { transition: none; } .geo-map[data-highlight] :is(.geo-map-regions, .geo-map-cells) .geo-map-region[aria-pressed="true"], .geo-map[data-geo-status="loading"] .geo-map-svg { animation: none; } } @media (prefers-contrast: more) { .geo-map { --geo-map-border: var(--foreground); } .geo-map-region { stroke-width: 1; } .geo-map-callout-line { stroke: var(--foreground); } } @media (forced-colors: active) { .geo-map-sphere path { fill: Canvas; stroke: CanvasText; } .geo-map-region, .geo-map-lifted { fill: Canvas; stroke: CanvasText; } .geo-map-regions .geo-map-region[aria-pressed="true"], .geo-map-lifted[aria-pressed="true"] { fill: Highlight; stroke: CanvasText; } .geo-map-line, .geo-map-callout-line { stroke: CanvasText; } .geo-map-callout-label, .geo-map-marker text { fill: CanvasText; } }}§JS view file
// -- Geo Map (geo-map) -------------------------------------------------------// Vector geography in SVG: countries, continents and first-order administrative// regions (Natural Earth v5.1.2) on five projections, plus the library the map// is made of - WGS84 geodesics, S2 cells, a Morton spatial index and weighted// routing over application-supplied segments (df$.shadcn.geo).//// - Lazy: the official engines arrive on the first map or the first// df$.shadcn.geo.load() - d3-geo (projections, clipping, paths; with its// d3-array helper), GeographicLib (ellipsoidal geodesics) and defuss-dson// (the DGEO metadata), each from its pinned jsDelivr build (GEO_VENDOR_URLS,// or configure({ urls }) to self-host). S2, Morton, routing and the DGEO// decoder are this file's own code: a vendor would cost more than it buys -// VERIFIED: s2js, the reference tests/e2e/site/geo-lib.e2e.ts compares this// S2 against, bundles to 168,690 bytes minified; Morton and Dijkstra are a// few dozen lines each.// - Data: a manifest of DGEO chunks (data/ beside this file - the world// overview stays resident, a country's detail and its states load when they// come into view, the original 10m states from 24x). data-src, a// <meta name="geo-map-data">, configure({ data }) or the release on jsDelivr.// - Regions are state: the selection (one or several, picked by click or set by// setState) and the focus (a region framed in view, its children shown -// Europe shows its countries, Germany its states) are attributes on the// element, so CSS animates them and render() reproduces them.// - Callouts: a leader line from every region in view to its name outside the// map (data-callouts).//// State lives ON the element (data-* + the store); the bound `api` is the only// state mutator (AGENTS.md "State API").import { defussGlobals, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';const df$ = defussGlobals();const dfDollar = defussQuery();// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type this file's API docs// state - arguments, return values, event details - holds against its code.// 'default' = nothing picked, 'selected' = one or more regions picked, 'focused' = a region framed with its childrenconst geoMapStates = ['default', 'selected', 'focused'];// -- types ----------------------------------------------------------------------/** A position in WGS84 degrees, longitude first (GeoJSON order): [longitude, latitude]. */export type LonLat = [number, number];/** A rectangle in degrees: [west, south, east, north]; west > east crosses the antimeridian. */export type GeoBounds = [number, number, number, number];/** A boundary level: continents, countries (and territories), first-order administrative regions. */export type GeoLevel = 'continent' | 'country' | 'state';/** A map projection. */export type GeoProjectionName = 'equalEarth' | 'equalArea' | 'mercator' | 'equirectangular' | 'orthographic';/** A fill palette for the regions. */export type GeoPalette = 'atlas' | 'ocean' | 'forest' | 'sunset' | 'heat' | 'mono' | 'chart';/** How region borders are drawn. */export type GeoBorder = 'natural' | 'cased' | 'dashed' | 'none';/** What a region is: its identity and place in the hierarchy. */export interface GeoRegionProperties { /** the id within its level: a continent's name, a country's ADM0_A3 code, a state's adm1_code */ id: string; /** the display name (English) */ name: string; /** its level */ level: GeoLevel; /** a state's country (ADM0_A3) */ parent?: string; /** a country's continent */ continent?: string;}/** A region's outline: a polygon or several, rings in D3 winding (the smaller spherical area is the inside). */export interface GeoGeometry { /** 'Polygon' or 'MultiPolygon' */ type: 'Polygon' | 'MultiPolygon'; /** rings of [longitude, latitude] positions (a MultiPolygon: one list of rings per polygon) */ coordinates: LonLat[][] | LonLat[][][];}/** A region as a GeoJSON feature. */export interface GeoRegion { /** always 'Feature' */ type: 'Feature'; /** identity, name, level and parent */ properties: GeoRegionProperties; /** the outline */ geometry: GeoGeometry;}/** A reference ellipsoid: equatorial radius and flattening. */export interface GeoEllipsoid { /** the equatorial radius in metres */ a: number; /** the flattening (0 = a sphere) */ f: number;}/** The inverse geodesic problem solved: how far apart two positions are and in which direction. */export interface GeoInverse { /** the surface distance along the geodesic, in metres */ meters: number; /** the bearing leaving the first position, degrees clockwise from north */ initialBearing: number; /** the bearing arriving at the second position */ finalBearing: number;}/** An S2 cell: its 64-bit id, its hexadecimal token and its level. */export interface S2Cell { /** the exact cell id (never a Number - 64 bits do not fit) */ id: bigint; /** the canonical lowercase hex token (trailing zeros dropped) */ token: string; /** 0 (a cube face) to 30 (about 1 cm²) */ level: number;}/** A node of a route graph. */export interface GeoRouteNode { /** a unique id */ id: string; /** where it is */ position: LonLat; /** a display name */ name?: string;}/** A segment between two nodes, with the length the application measured. */export interface GeoRouteSegment { /** a unique id */ id: string; /** the start node's id */ from: string; /** the end node's id */ to: string; /** the network length in metres - authoritative, independent of the drawn geometry */ lengthMeters: number; /** usable in both directions (default: only from the from node to the to node) */ bidirectional?: boolean; /** the drawn path; its first and last positions equal the nodes' (default: the straight pair) */ coordinates?: LonLat[];}/** A position inside a segment, as a fraction of its length. */export interface GeoSegmentPosition { /** the segment's id */ segment: string; /** 0 (its from node) to 1 (its to node) */ fraction: number;}/** One segment a route travels. */export interface GeoRouteStep { /** the segment's id */ segment: string; /** the node it leaves */ from: string; /** the node it reaches */ to: string; /** travelled against its direction */ reversed: boolean; /** the metres it contributes */ lengthMeters: number; /** its path, in travel order */ coordinates: LonLat[];}/** A shortest route. */export interface GeoRoute { /** the summed segment lengths in metres */ lengthMeters: number; /** the nodes passed, in order */ nodeIds: string[]; /** the segments travelled */ steps: GeoRouteStep[]; /** the whole path */ coordinates: LonLat[];}/** The nearest node to a position. */export interface GeoNearestNode { /** the node */ node: GeoRouteNode; /** its WGS84 distance from the position, in metres */ meters: number;}/** A weighted directed graph: Dijkstra over the supplied lengths, a Morton index over the node positions. */export interface GeoRouteGraph { /** the shortest route between two nodes, or null when none connects them */ route: (from: string, to: string) => GeoRoute | null; /** the shortest route between two positions inside segments (each cut proportionally) */ routeAt: (from: GeoSegmentPosition, to: GeoSegmentPosition) => GeoRoute | null; /** the node closest to a position (WGS84), optionally among the nodes in a rectangle */ nearestNode: (position: LonLat, bounds?: GeoBounds) => GeoNearestNode | null; /** the nodes inside a rectangle (Morton prefix ranges, then the exact test) */ nodesInBounds: (bounds: GeoBounds) => GeoRouteNode[]; /** the node ids in Morton (Z) order */ mortonOrder: () => string[];}/** A Morton (Z-order) index over positions. */export interface GeoMortonIndex { /** how many items it holds */ size: number; /** the items in Morton order */ ordered: () => unknown[]; /** the items inside a rectangle */ query: (bounds: GeoBounds) => unknown[];}/** A quantized TopoJSON topology (what a DGEO chunk decodes to). */export interface GeoTopology { /** always 'Topology' */ type: 'Topology'; /** the named geometry collections */ objects: Record<string, unknown>; /** the delta-encoded arcs */ arcs: number[][][]; /** the quantization transform */ transform?: { scale: [number, number]; translate: [number, number] };}/** A marker on the map. */export interface GeoMarker { /** a unique id */ id: string; /** a label beside it (and its accessible name) */ label?: string; /** where: [longitude, latitude], or an S2 cell's center */ position: LonLat | { s2: string }; /** a CSS color (default: the route color) */ color?: string; /** circle (default), diamond, square or pin */ shape?: 'circle' | 'diamond' | 'square' | 'pin';}/** A line on the map: given positions, or the geodesic between two. */export interface GeoLine { /** a unique id */ id: string; /** the positions it passes */ coordinates?: LonLat[]; /** with `to` instead of coordinates: the WGS84 geodesic from here */ from?: LonLat; /** the geodesic's end */ to?: LonLat; /** a CSS color */ color?: string; /** the stroke width in px */ width?: number; /** an SVG dash array ("4 3") */ dash?: string;}/** A region's or cell's own look, over its palette fill. */export interface GeoTileStyle { /** a CSS fill (a color, a token, the hatch url) */ fill?: string; /** a CSS stroke */ stroke?: string; /** the stroke width in px */ strokeWidth?: number; /** an SVG dash array */ dash?: string; /** 0 to 1 */ opacity?: number;}/** What a map draws over its boundaries (the script.geo-map-layers JSON has the same shape). */export interface GeoLayers { /** markers */ markers?: GeoMarker[]; /** lines */ lines?: GeoLine[]; /** S2 cells, by token */ cells?: string[]; /** per-region looks, by key (country:DEU, state:DEU-1591, continent:Europe, s2:47a1) */ styles?: Record<string, GeoTileStyle>;}/** A map's state: every field optional in setState() (merged into the current one); getState() reports all. */export interface GeoMapConfig { /** the projection (default equalEarth) */ projection?: GeoProjectionName; /** the boundary level, or 'auto' by zoom: continents below 2x, countries below 8x, states above (default country) */ level?: GeoLevel | 'auto'; /** the fill palette (default atlas) */ palette?: GeoPalette; /** the border style (default natural) */ border?: GeoBorder; /** the latitude / longitude grid (default true) */ graticule?: boolean; /** leader lines from the regions in view to their names outside the map (default false) */ callouts?: boolean; /** the picked regions' keys (level:id - country:FRA); a string may list them separated by spaces or commas */ selected?: string[] | string; /** the framed key (a region, or an S2 cell as s2:<token>), or null; its children are shown, the rest dimmed */ focus?: string | null; /** the view's center */ center?: LonLat; /** the zoom factor, 1 to 1024 */ zoom?: number;}/** setState() configs per state. */export interface GeoMapStateConfigs { /** Nothing picked, nothing framed - the selection and the focus are cleared. */ default: GeoMapConfig; /** One or more regions picked (`config.selected`): highlighted the way data-highlight says. */ selected: GeoMapConfig; /** A region framed in view (`config.focus`) with its children shown - a continent's countries, a country's states. */ focused: GeoMapConfig;}/** One search result. */export interface GeoSearchHit { /** the region's key (country:DEU) */ key: string; /** its name */ name: string; /** its level */ level: GeoLevel; /** what a list shows: the name, a state's country after a comma */ label: string;}/** A region drawn in the current view. */export interface GeoVisibleRegion { /** its key */ key: string; /** its name */ name: string; /** its level */ level: GeoLevel; /** its label point in SVG pixels */ anchor: [number, number] | null;}/** What the last render drew and loaded. */export interface GeoRenderStats { /** regions drawn */ visibleRegions: number; /** context regions drawn dimmed around a focus */ contextRegions: number; /** S2 cells drawn */ visibleCells: number; /** markers drawn */ visibleMarkers: number; /** callouts drawn */ callouts: number; /** milliseconds the render took */ renderMs: number; /** detail chunks in the cache */ cachedChunks: number; /** encoded bytes of those chunks */ loadedBytes: number; /** chunks still loading */ loading: number;}/** The `geo-map-select` event's detail: the picked regions. */export interface GeoMapSelectDetail { /** their keys */ keys: string[]; /** their names (a key whose region is not loaded names itself) */ names: string[];}/** The `geo-map-focus` event's detail: the framed region. */export interface GeoMapFocusDetail { /** its key, or null when the focus was cleared */ key: string | null; /** its name, or null */ name: string | null;}/** The `geo-map-view` event's detail: where the camera came to rest. */export interface GeoMapViewDetail { /** the center */ center: LonLat; /** the zoom factor */ zoom: number; /** the level drawn */ level: GeoLevel;}/** The `geo-map-error` event's detail: what could not be loaded. */export interface GeoMapErrorDetail { /** the message */ message: string; /** the URL that failed, or '' */ url: string;}/** The engines' URLs (the pinned official builds). */export interface GeoVendorUrls { /** d3-array (UMD; d3-geo's helpers) */ d3Array: string; /** d3-geo (UMD) */ d3Geo: string; /** GeographicLib geodesic (UMD) */ geodesic: string; /** defuss-dson (ESM) */ dson: string;}/** What configure() sets for every map and the library. */export interface GeoConfigureOptions { /** the default data manifest URL (a map's data-src wins) */ data?: string; /** engine URLs to load instead of the pinned jsDelivr builds (the same versions, self-hosted) */ urls?: Partial<GeoVendorUrls>; /** the bounds a focus frames, by region key, over the built-in ones (Europe, North America, Oceania) */ views?: Record<string, GeoBounds>;}// -- engines -------------------------------------------------------------------------------/** The pinned official builds the component loads (never @latest). */export const GEO_VENDOR_URLS: GeoVendorUrls = { d3Array: 'https://cdn.jsdelivr.net/npm/d3-array@3.2.4/dist/d3-array.min.js', d3Geo: 'https://cdn.jsdelivr.net/npm/d3-geo@3.1.1/dist/d3-geo.min.js', geodesic: 'https://cdn.jsdelivr.net/npm/geographiclib-geodesic@2.2.0/geographiclib-geodesic.min.js', dson: 'https://cdn.jsdelivr.net/npm/defuss-dson@1.1.2/dist/index.mjs',};/** * the data the release ships: its npm package on jsDelivr (pinned to the release core reports). Not the GitHub route * the docs load the bundles from - VERIFIED (curl, 2026-11-02): the repository is past jsDelivr's 50 MB limit for * /gh/, which answers a file's first fetch with 403 "Package size exceeded" now and then (3 of 31 fresh chunks), and * a map fetches hundreds of them; /npm/ (72 MB unpacked, within its limit) served 41 of 41 */const releaseData = (): string => `https://cdn.jsdelivr.net/npm/defuss-shadcn@${df$.shared?.abi ?? '0.0.0'}/dist/components/geo-map/data/manifest.json`;interface Engines { d3: any; geodesic: any; DSON: { parse(text: string): unknown; stringify(value: unknown): string };}let engines: Engines | null = null;let enginesPromise: Promise<Engines> | null = null;const settings: { data: string; urls: GeoVendorUrls; views: Record<string, GeoBounds> } = { data: '', urls: { ...GEO_VENDOR_URLS }, views: {},};const LOAD_MS = 20_000;const metaContent = (name: string): string => ((dfDollar(`meta[name="${name}"]`).get(0) ?? null) as HTMLMetaElement | null)?.content ?? '';/** the one vendor import site (verify: VENDOR_IMPORTS) - the official engines, never our code */function vendor(vendorUrl: string): Promise<any> { // a microtask later: a failing import is a rejection, never a synchronous throw out of the caller return Promise.resolve().then(() => import(/* @vite-ignore */ vendorUrl));}/** * Load the engines once: d3-geo with d3-array, GeographicLib and defuss-dson (the pinned builds, or the configured * URLs). Every map calls it; call it yourself before using the geodesic functions. A failed load can be retried. * @returns resolves when every engine is ready */function load(): Promise<void> { if (engines) return Promise.resolve(); if (!enginesPromise) { const urls = settings.urls; const present = (globalThis as any).d3; // a page that already has d3 (v7 bundles d3-geo) keeps it - the UMD builds install on that global const d3 = present?.geoPath && present?.geoEqualEarth ? Promise.resolve() : vendor(urls.d3Array).then(() => vendor(urls.d3Geo)); const pending = Promise.all([d3, vendor(urls.geodesic), vendor(urls.dson)]).then(([, , dson]) => { const g = globalThis as any; if (!g.d3?.geoPath) throw new Error('d3-geo did not install globalThis.d3'); if (!g.geodesic?.Geodesic) throw new Error('GeographicLib did not install globalThis.geodesic'); const DSON = dson?.DSON ?? dson?.default?.DSON; if (!DSON) throw new Error('defuss-dson exports no DSON'); engines = { d3: g.d3, geodesic: g.geodesic, DSON }; return engines; }); pending.catch(() => { if (enginesPromise === pending) enginesPromise = null; }); enginesPromise = pending; } let timer = 0; const deadline = new Promise<never>((_, reject) => { timer = setTimeout( () => reject(new Error(`the geography engines did not load within ${LOAD_MS / 1000} s`)), LOAD_MS, ) as unknown as number; }); return Promise.race([enginesPromise, deadline]) .then(() => undefined) .finally(() => clearTimeout(timer));}const ready = (): Engines => { if (!engines) throw new Error('the geography engines are not loaded - await df$.shadcn.geo.load() first'); return engines;};/** * Set the data URL, the engine URLs or the focus views for every map on the page (maps already showing keep their * data). Engine URLs only take effect before the engines first load. * @param options - `{ data, urls, views }` */function configure(options: GeoConfigureOptions): void { if (options.data !== undefined) settings.data = String(options.data); if (options.urls) settings.urls = { ...settings.urls, ...options.urls }; if (options.views) settings.views = { ...settings.views, ...options.views };}// -- validation ----------------------------------------------------------------------------function assertLonLat(point: unknown): asserts point is LonLat { const p = point as number[]; if (!Array.isArray(p) || p.length !== 2 || !p.every(Number.isFinite) || Math.abs(p[0]!) > 180 || Math.abs(p[1]!) > 90) throw new RangeError('expected [longitude, latitude] in [-180, 180] x [-90, 90] degrees');}function assertBounds(b: GeoBounds): void { if (!Array.isArray(b) || b.length !== 4) throw new RangeError('expected [west, south, east, north]'); assertLonLat([b[0], b[1]]); assertLonLat([b[2], b[3]]); if (b[1] > b[3]) throw new RangeError('south must not exceed north');}/** * Whether a position lies inside a rectangle (one whose west exceeds its east crosses the antimeridian). * @param point - [longitude, latitude] * @param bounds - [west, south, east, north] * @returns true inside or on the edge */function inBounds(point: LonLat, bounds: GeoBounds): boolean { return ( point[1] >= bounds[1] && point[1] <= bounds[3] && (bounds[0] <= bounds[2] ? point[0] >= bounds[0] && point[0] <= bounds[2] : point[0] >= bounds[0] || point[0] <= bounds[2]) );}const wrapLon = (lon: number): number => ((((lon + 180) % 360) + 360) % 360) - 180;const clampZoom = (z: number): number => Math.max(1, Math.min(1024, z));const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v));// -- geodesy (GeographicLib) ------------------------------------------------------------------/** WGS84: the GPS reference ellipsoid. */const WGS84: GeoEllipsoid = Object.freeze({ a: 6378137, f: 1 / 298.257223563 });const solvers = new Map<string, any>();function solver(ellipsoid: GeoEllipsoid = WGS84): any { const key = `${ellipsoid.a}/${ellipsoid.f}`; let s = solvers.get(key); if (!s) { if ( !Number.isFinite(ellipsoid.a) || ellipsoid.a <= 0 || !Number.isFinite(ellipsoid.f) || ellipsoid.f < 0 || ellipsoid.f >= 1 / 50 ) throw new RangeError('an ellipsoid needs a > 0 and 0 <= f < 1/50'); s = new (ready().geodesic.Geodesic.Geodesic)(ellipsoid.a, ellipsoid.f); solvers.set(key, s); } return s;}/** * The distance and bearings between two positions along the ellipsoid's surface (Karney's algorithm - nearly * antipodal points included). Excludes altitude, terrain and road detours. * @param a - [longitude, latitude] of the start * @param b - [longitude, latitude] of the end * @param ellipsoid - the reference ellipsoid (default WGS84) * @returns metres and the bearings at both ends */function inverse(a: LonLat, b: LonLat, ellipsoid?: GeoEllipsoid): GeoInverse { assertLonLat(a); assertLonLat(b); const r = solver(ellipsoid).Inverse(a[1], a[0], b[1], b[0]); return { meters: r.s12, initialBearing: r.azi1, finalBearing: r.azi2 };}/** * Where a geodesic leads: the position a given distance away from a start in a given bearing. * @param origin - [longitude, latitude] of the start * @param bearing - degrees clockwise from north * @param meters - the distance along the surface * @param ellipsoid - the reference ellipsoid (default WGS84) * @returns the end position */function direct(origin: LonLat, bearing: number, meters: number, ellipsoid?: GeoEllipsoid): LonLat { assertLonLat(origin); if (!Number.isFinite(bearing) || !Number.isFinite(meters)) throw new RangeError('a finite bearing and distance'); const p = solver(ellipsoid).Direct(origin[1], origin[0], bearing, meters); return [p.lon2, p.lat2];}/** * The geodesic between two positions as positions at most `maxStepMeters` apart - a line the map draws as the * shortest surface path. * @param a - the start * @param b - the end * @param maxStepMeters - the largest spacing between samples (default 20000) * @param ellipsoid - the reference ellipsoid (default WGS84) * @returns the positions, a first and b last */function geodesicPath(a: LonLat, b: LonLat, maxStepMeters: number = 20_000, ellipsoid?: GeoEllipsoid): LonLat[] { if (!Number.isFinite(maxStepMeters) || maxStepMeters <= 0) throw new RangeError('maxStepMeters must be positive'); const { meters } = inverse(a, b, ellipsoid); const n = Math.max(1, Math.ceil(meters / maxStepMeters)); if (n > 100_000) throw new RangeError('too many geodesic samples (at most 100000 segments)'); const line = solver(ellipsoid).InverseLine(a[1], a[0], b[1], b[0]); const points: LonLat[] = [[a[0], a[1]]]; for (let i = 1; i < n; i++) { const p = line.Position((meters * i) / n); points.push([p.lon2, p.lat2]); } points.push([b[0], b[1]]); return points;}/** * The length of a polyline along the ellipsoid: the sum of its segments' geodesic distances. * @param points - two or more positions * @param ellipsoid - the reference ellipsoid (default WGS84) * @returns metres */function polylineLength(points: LonLat[], ellipsoid?: GeoEllipsoid): number { let sum = 0; for (let i = 1; i < points.length; i++) sum += inverse(points[i - 1]!, points[i]!, ellipsoid).meters; return sum;}// -- S2 (cube faces, Hilbert curve, 64-bit ids) ----------------------------------------------// VERIFIED: (tests/e2e/geography/geo-lib.e2e.ts) tokens, levels, centers, vertices, edge neighbors, parents and// children equal s2js 1.44.0 (a port of Google's S2 library) on random positions and levels.const S2_MAX = 2 ** 30;const SWAP = 1;const INVERT = 2;const POS_TO_IJ = [ [0, 1, 3, 2], [0, 2, 3, 1], [3, 2, 0, 1], [3, 1, 0, 2],];const POS_TO_ORIENTATION = [SWAP, 0, 0, INVERT | SWAP];const LOOKUP_POS = new Int32Array(1024);const LOOKUP_IJ = new Int32Array(1024);function initLookup(level: number, i: number, j: number, orig: number, pos: number, orientation: number): void { if (level === 4) { const ij = (i << 4) + j; LOOKUP_POS[(ij << 2) + orig] = (pos << 2) + orientation; LOOKUP_IJ[(pos << 2) + orig] = (ij << 2) + orientation; return; } const r = POS_TO_IJ[orientation]!; for (let k = 0; k < 4; k++) initLookup( level + 1, (i << 1) + (r[k]! >> 1), (j << 1) + (r[k]! & 1), orig, (pos << 2) + k, orientation ^ POS_TO_ORIENTATION[k]!, );}for (const o of [0, SWAP, INVERT, SWAP | INVERT]) initLookup(0, 0, 0, o, 0, o);const RAD = 180 / Math.PI;function lonLatToXyz(p: LonLat): [number, number, number] { const phi = p[1] / RAD; const theta = p[0] / RAD; const c = Math.cos(phi); return [Math.cos(theta) * c, Math.sin(theta) * c, Math.sin(phi)];}function xyzToLonLat(x: number, y: number, z: number): LonLat { return [Math.atan2(y, x) * RAD, Math.atan2(z, Math.hypot(x, y)) * RAD];}function xyzToFaceUV(x: number, y: number, z: number): [number, number, number] { const ax = Math.abs(x); const ay = Math.abs(y); const az = Math.abs(z); let f = ax > ay ? (ax > az ? 0 : 2) : ay > az ? 1 : 2; if ((f === 0 && x < 0) || (f === 1 && y < 0) || (f === 2 && z < 0)) f += 3; switch (f) { case 0: return [0, y / x, z / x]; case 1: return [1, -x / y, z / y]; case 2: return [2, -x / z, -y / z]; case 3: return [3, z / x, y / x]; case 4: return [4, z / y, -x / y]; default: return [5, -y / z, -x / z]; }}function faceUVToXyz(f: number, u: number, v: number): [number, number, number] { switch (f) { case 0: return [1, u, v]; case 1: return [-u, 1, v]; case 2: return [-u, -v, 1]; case 3: return [-1, -v, -u]; case 4: return [v, -1, -u]; default: return [v, u, -1]; }}const stToUV = (s: number): number => (s >= 0.5 ? (4 * s * s - 1) / 3 : (1 - 4 * (1 - s) * (1 - s)) / 3);const uvToST = (u: number): number => (u >= 0 ? 0.5 * Math.sqrt(1 + 3 * u) : 1 - 0.5 * Math.sqrt(1 - 3 * u));const stToIJ = (s: number): number => clamp(Math.floor(S2_MAX * s), 0, S2_MAX - 1);function fromFaceIJ(f: number, i: number, j: number): bigint { let n = BigInt(f) << 60n; let bits = f & SWAP; for (let k = 7; k >= 0; k--) { bits += ((i >> (k * 4)) & 15) << 6; bits += ((j >> (k * 4)) & 15) << 2; bits = LOOKUP_POS[bits]!; n |= BigInt(bits >> 2) << BigInt(k * 8); bits &= SWAP | INVERT; } return n * 2n + 1n;}function faceIJ(id: bigint): [number, number, number] { const f = Number(id >> 61n); let orientation = f & SWAP; let i = 0; let j = 0; let nbits = 2; for (let k = 7; k >= 0; k--) { orientation += Number((id >> BigInt(k * 8 + 1)) & BigInt((1 << (2 * nbits)) - 1)) << 2; orientation = LOOKUP_IJ[orientation]!; i += (orientation >> 6) * 2 ** (k * 4); j += ((orientation >> 2) & 15) * 2 ** (k * 4); orientation &= SWAP | INVERT; nbits = 4; } return [f, i, j];}const lsb = (id: bigint): bigint => id & -id;const lsbForLevel = (level: number): bigint => 1n << BigInt(2 * (30 - level));function levelOf(id: bigint): number { let tz = 0; while (tz < 64 && ((id >> BigInt(tz)) & 1n) === 0n) tz++; return 30 - (tz >> 1);}const parentAt = (id: bigint, level: number): bigint => { const l = lsbForLevel(level); return (id & -l) | l;};const tokenOf = (id: bigint): string => id.toString(16).padStart(16, '0').replace(/0+$/, '') || 'X';/** * An S2 cell id from a hexadecimal token or a bigint, validated. A string is ALWAYS a hex token - pass a decimal id * as BigInt(decimalString). * @param cell - a token ('47a1') or an id * @returns the 64-bit id */function s2Id(cell: string | bigint): bigint { if (typeof cell === 'string' && !/^[0-9a-fA-F]{1,16}$/.test(cell)) throw new RangeError('expected a 1-16 digit hexadecimal S2 token'); const id = typeof cell === 'bigint' ? cell : BigInt(`0x${cell.padEnd(16, '0')}`); if (id <= 0n || id >= 1n << 64n || Number(id >> 61n) > 5 || (lsb(id) & 0x1555555555555555n) === 0n) throw new RangeError('invalid S2 cell id'); return id;}/** * The S2 cell holding a position at a level. * @param point - [longitude, latitude] * @param level - 0 to 30 (default 12, about 2 km across) * @returns its id, token and level */function s2Cell(point: LonLat, level: number = 12): S2Cell { assertLonLat(point); if (!Number.isInteger(level) || level < 0 || level > 30) throw new RangeError('an S2 level is 0 to 30'); const [f, u, v] = xyzToFaceUV(...lonLatToXyz(point)); const id = parentAt(fromFaceIJ(f, stToIJ(uvToST(u)), stToIJ(uvToST(v))), level); return { id, token: tokenOf(id), level };}/** * A cell's canonical token. * @param cell - a token or an id * @returns the lowercase hex token */function s2Token(cell: string | bigint): string { return tokenOf(s2Id(cell));}/** * A cell's level. * @param cell - a token or an id * @returns 0 to 30 */function s2Level(cell: string | bigint): number { return levelOf(s2Id(cell));}/** the cell's (s, t) square on its face: face, lower i and j, size in leaf cells */function cellSquare(id: bigint): [number, number, number, number] { const [f, i, j] = faceIJ(id); const size = 2 ** (30 - levelOf(id)); return [f, Math.floor(i / size) * size, Math.floor(j / size) * size, size];}/** * The center of a cell (in its face's (s, t) square - not the original GPS fix a cell was made from). * @param cell - a token or an id * @returns [longitude, latitude] */function s2Center(cell: string | bigint): LonLat { const [f, i, j, size] = cellSquare(s2Id(cell)); return xyzToLonLat(...faceUVToXyz(f, stToUV((i + size / 2) / S2_MAX), stToUV((j + size / 2) / S2_MAX)));}/** * A cell's four corners (counterclockwise, as S2 orders them). * @param cell - a token or an id * @returns four [longitude, latitude] positions */function s2Vertices(cell: string | bigint): LonLat[] { const [f, i, j, size] = cellSquare(s2Id(cell)); const u0 = stToUV(i / S2_MAX); const u1 = stToUV((i + size) / S2_MAX); const v0 = stToUV(j / S2_MAX); const v1 = stToUV((j + size) / S2_MAX); return [ [u0, v0], [u1, v0], [u1, v1], [u0, v1], ].map(([u, v]) => xyzToLonLat(...faceUVToXyz(f, u!, v!)));}/** * A cell as a region the map can draw (great-circle edges; its ring wound for D3). * @param cell - a token or an id * @returns a GeoJSON polygon feature (properties.id = the token) */function s2Polygon(cell: string | bigint): GeoRegion { const token = s2Token(cell); // S2 gives counterclockwise vertices; D3 takes the clockwise ring as the small region const ring = s2Vertices(cell).reverse(); ring.push([ring[0]![0], ring[0]![1]]); return { type: 'Feature', properties: { id: token, name: `S2 ${token}`, level: 'state' }, geometry: { type: 'Polygon', coordinates: [ring] }, };}function fromFaceIJWrap(f: number, i: number, j: number): bigint { i = clamp(i, -1, S2_MAX); j = clamp(j, -1, S2_MAX); const limit = 1 + Number.EPSILON; const u = clamp((2 * i + 1 - S2_MAX) / S2_MAX, -limit, limit); const v = clamp((2 * j + 1 - S2_MAX) / S2_MAX, -limit, limit); const [g, u2, v2] = xyzToFaceUV(...faceUVToXyz(f, u, v)); return fromFaceIJ(g, stToIJ(0.5 * (u2 + 1)), stToIJ(0.5 * (v2 + 1)));}/** * The four cells sharing an edge with a cell, at its level (below, right, above, left in its face's frame). * @param cell - a token or an id * @returns four tokens */function s2Neighbors(cell: string | bigint): string[] { const id = s2Id(cell); const level = levelOf(id); const size = 2 ** (30 - level); const [f, i, j] = faceIJ(id); return [ [i, j - size], [i + size, j], [i, j + size], [i - size, j], ].map(([a, b]) => tokenOf(parentAt(fromFaceIJWrap(f, a!, b!), level)));}/** * A cell's four children, one level finer (none at level 30). * @param cell - a token or an id * @returns their tokens in Hilbert order */function s2Children(cell: string | bigint): string[] { const id = s2Id(cell); if (levelOf(id) === 30) return []; const low = lsb(id); const begin = id - low + (low >> 2n); const step = low >> 1n; return [0n, 1n, 2n, 3n].map((k) => tokenOf(begin + k * step));}/** * The cell containing a cell at a coarser level. * @param cell - a token or an id * @param level - 0 to the cell's own level (default one coarser) * @returns the parent's token */function s2Parent(cell: string | bigint, level?: number): string { const id = s2Id(cell); const own = levelOf(id); const target = level ?? own - 1; if (!Number.isInteger(target) || target < 0 || target > own) throw new RangeError(`a parent level is 0 to ${own}`); return tokenOf(parentAt(id, target));}/** * A position from either form a marker takes: [longitude, latitude] as given, or an S2 cell's center. * @param value - a position or `{ s2: token }` * @returns [longitude, latitude] */function resolvePosition(value: LonLat | { s2: string | bigint }): LonLat { if (Array.isArray(value)) { assertLonLat(value); return [value[0], value[1]]; } return s2Center((value as { s2: string | bigint }).s2);}// -- Morton (Z-order over a longitude / latitude grid - not S2's Hilbert curve) ------------------function checkBits(bits: number): void { if (!Number.isInteger(bits) || bits < 1 || bits > 26) throw new RangeError('Morton bits are 1 to 26');}/** * Interleave two grid coordinates into a Morton key (x on the even bits, y on the odd). * @param x - 0 to 2^bits - 1 * @param y - 0 to 2^bits - 1 * @param bits - bits per coordinate, 1 to 26 (default 16) * @returns the key */function mortonEncode(x: number, y: number, bits: number = 16): bigint { checkBits(bits); if (![x, y].every((v) => Number.isInteger(v) && v >= 0 && v < 2 ** bits)) throw new RangeError('grid coordinate out of range'); const bx = BigInt(x); const by = BigInt(y); let key = 0n; for (let b = 0; b < bits; b++) { const s = BigInt(b); key |= (((bx >> s) & 1n) << (2n * s)) | (((by >> s) & 1n) << (2n * s + 1n)); } return key;}/** * Split a Morton key back into its grid coordinates. * @param key - the key * @param bits - bits per coordinate (default 16) * @returns [x, y] */function mortonDecode(key: bigint, bits: number = 16): [number, number] { checkBits(bits); if (key < 0n || key >= 1n << BigInt(2 * bits)) throw new RangeError('Morton key out of range'); let x = 0n; let y = 0n; for (let b = 0; b < bits; b++) { const s = BigInt(b); x |= ((key >> (2n * s)) & 1n) << s; y |= ((key >> (2n * s + 1n)) & 1n) << s; } return [Number(x), Number(y)];}function mortonGrid(p: LonLat, bits: number): [number, number] { const n = 2 ** bits; return [Math.min(n - 1, Math.floor(((p[0] + 180) / 360) * n)), Math.min(n - 1, Math.floor(((p[1] + 90) / 180) * n))];}/** * A position's Morton key on a 2^bits x 2^bits longitude / latitude grid. * @param point - [longitude, latitude] * @param bits - bits per coordinate (default 16) * @returns the key */function mortonKey(point: LonLat, bits: number = 16): bigint { assertLonLat(point); checkBits(bits); return mortonEncode(...mortonGrid(point, bits), bits);}/** * A Morton index over items with positions: sorted keys, rectangle queries by recursive prefix ranges, then the exact * position test (no grid false positives). * @param items - the items * @param position - an item's [longitude, latitude] * @param bits - bits per coordinate (default 16) * @returns the index */function mortonIndex<T>(items: readonly T[], position: (item: T) => LonLat, bits: number = 16): GeoMortonIndex { checkBits(bits); const entries = items .map((item) => { const point = position(item); assertLonLat(point); return { key: mortonKey(point, bits), point: [point[0], point[1]] as LonLat, item }; }) .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); const lowerBound = (key: bigint): number => { let lo = 0; let hi = entries.length; while (lo < hi) { const m = (lo + hi) >>> 1; if (entries[m]!.key < key) lo = m + 1; else hi = m; } return lo; }; const query = (bounds: GeoBounds): T[] => { assertBounds(bounds); if (bounds[0] > bounds[2]) return [...query([bounds[0], bounds[1], 180, bounds[3]]), ...query([-180, bounds[1], bounds[2], bounds[3]])]; const [x0, y0] = mortonGrid([bounds[0], bounds[1]], bits); const [x1, y1] = mortonGrid([bounds[2], bounds[3]], bits); const out: T[] = []; const walk = (x: number, y: number, size: number, depth: number, prefix: bigint): void => { if (x > x1 || y > y1 || x + size - 1 < x0 || y + size - 1 < y0) return; const shift = BigInt(2 * (bits - depth)); const begin = lowerBound(prefix << shift); const end = lowerBound((prefix + 1n) << shift); if (begin === end) return; // a small range is cheaper to test directly; pruning stays exact if (end - begin <= 32 || depth === bits || (x >= x0 && y >= y0 && x + size - 1 <= x1 && y + size - 1 <= y1)) { for (let i = begin; i < end; i++) if (inBounds(entries[i]!.point, bounds)) out.push(entries[i]!.item); return; } const half = size / 2; for (let q = 0; q < 4; q++) walk(x + (q & 1) * half, y + (q >> 1) * half, half, depth + 1, prefix * 4n + BigInt(q)); }; walk(0, 0, 2 ** bits, 0, 0n); return out; }; return { size: entries.length, ordered: () => entries.map((e) => e.item), query };}// -- routing (Dijkstra over supplied lengths) ---------------------------------------------------interface RouteArc { to: string; segment: Required<Pick<GeoRouteSegment, 'coordinates'>> & GeoRouteSegment; reversed: boolean;}/** a binary min-heap; stale Dijkstra entries are skipped on pop */class Queue { private values: { id: string; cost: number }[] = []; push(value: { id: string; cost: number }): void { let i = this.values.length; this.values.push(value); while (i > 0) { const p = (i - 1) >>> 1; if (this.values[p]!.cost <= value.cost) break; this.values[i] = this.values[p]!; i = p; } this.values[i] = value; } pop(): { id: string; cost: number } | undefined { if (!this.values.length) return undefined; const first = this.values[0]!; const last = this.values.pop()!; if (this.values.length) { let i = 0; while (i * 2 + 1 < this.values.length) { let c = i * 2 + 1; if (c + 1 < this.values.length && this.values[c + 1]!.cost < this.values[c]!.cost) c++; if (this.values[c]!.cost >= last.cost) break; this.values[i] = this.values[c]!; i = c; } this.values[i] = last; } return first; }}function pointOnLine(points: LonLat[], fraction: number): LonLat { if (fraction <= 0) return [points[0]![0], points[0]![1]]; const last = points[points.length - 1]!; if (fraction >= 1) return [last[0], last[1]]; const lengths = points.slice(1).map((p, i) => inverse(points[i]!, p).meters); const total = lengths.reduce((a, b) => a + b, 0); if (!total) return [points[0]![0], points[0]![1]]; const target = total * fraction; let before = 0; for (let i = 0; i < lengths.length; i++) { if (before + lengths[i]! >= target) return direct(points[i]!, inverse(points[i]!, points[i + 1]!).initialBearing, target - before); before += lengths[i]!; } return [last[0], last[1]];}function sliceLine(points: LonLat[], from: number, to: number): LonLat[] { const total = polylineLength(points); const out = [pointOnLine(points, from)]; let before = 0; for (let i = 1; i < points.length - 1; i++) { before += inverse(points[i - 1]!, points[i]!).meters; const f = total ? before / total : 0; if (f > from && f < to) out.push([points[i]![0], points[i]![1]]); } out.push(pointOnLine(points, to)); return out;}/** * A route graph over nodes and the segments between them. Dijkstra minimizes the SUPPLIED lengths (no A*: an * application's lengths may undercut the surface distance, so a geodesic heuristic would not be admissible); a * Morton index finds nodes by position. Needs the engines (df$.shadcn.geo.load()) for nearestNode and routeAt. * @param nodes - the nodes (unique ids) * @param segments - the segments (unique ids; lengths finite and >= 0) * @returns the graph */function routeGraph(nodes: GeoRouteNode[], segments: GeoRouteSegment[]): GeoRouteGraph { const byId = new Map<string, GeoRouteNode>(); const segs = new Map<string, RouteArc['segment']>(); const adjacency = new Map<string, RouteArc[]>(); for (const n of nodes) { if (!n.id || byId.has(n.id)) throw new Error(`duplicate or empty node id: ${n.id}`); assertLonLat(n.position); byId.set(n.id, { ...n, position: [n.position[0], n.position[1]] }); adjacency.set(n.id, []); } for (const edge of segments) { if (!edge.id || segs.has(edge.id)) throw new Error(`duplicate or empty segment id: ${edge.id}`); if (!byId.has(edge.from) || !byId.has(edge.to)) throw new Error(`unknown endpoint of segment ${edge.id}`); if (!Number.isFinite(edge.lengthMeters) || edge.lengthMeters < 0) throw new RangeError(`segment ${edge.id}: the length must be finite and >= 0`); const a = byId.get(edge.from)!.position; const b = byId.get(edge.to)!.position; const points = edge.coordinates?.map((p) => { assertLonLat(p); return [p[0], p[1]] as LonLat; }) ?? [a, b]; const end = points[points.length - 1]!; if (points.length < 2 || points[0]![0] !== a[0] || points[0]![1] !== a[1] || end[0] !== b[0] || end[1] !== b[1]) throw new Error(`segment ${edge.id}: its geometry must start and end at its nodes`); const segment = { ...edge, coordinates: points }; segs.set(edge.id, segment); adjacency.get(edge.from)!.push({ to: edge.to, segment, reversed: false }); if (edge.bidirectional) adjacency.get(edge.to)!.push({ to: edge.from, segment, reversed: true }); } const index = mortonIndex([...byId.values()], (n) => n.position); const copy = (n: GeoRouteNode): GeoRouteNode => ({ ...n, position: [n.position[0], n.position[1]] }); const route = (from: string, to: string): GeoRoute | null => { if (!byId.has(from) || !byId.has(to)) throw new Error('unknown route node'); const costs = new Map([[from, 0]]); const previous = new Map<string, { from: string; arc: RouteArc }>(); const queue = new Queue(); queue.push({ id: from, cost: 0 }); for (let current = queue.pop(); current; current = queue.pop()) { if (current.cost !== costs.get(current.id)) continue; if (current.id === to) break; for (const arc of adjacency.get(current.id)!) { const cost = current.cost + arc.segment.lengthMeters; if (!Number.isFinite(cost)) throw new RangeError('route cost overflow'); if (cost < (costs.get(arc.to) ?? Infinity)) { costs.set(arc.to, cost); previous.set(arc.to, { from: current.id, arc }); queue.push({ id: arc.to, cost }); } } } if (!costs.has(to)) return null; const steps: GeoRouteStep[] = []; const ids = [to]; for (let id = to; id !== from;) { const p = previous.get(id)!; const coordinates = p.arc.segment.coordinates.map((q) => [q[0], q[1]] as LonLat); if (p.arc.reversed) coordinates.reverse(); steps.push({ segment: p.arc.segment.id, from: p.from, to: id, reversed: p.arc.reversed, lengthMeters: p.arc.segment.lengthMeters, coordinates, }); id = p.from; ids.push(id); } steps.reverse(); ids.reverse(); const coordinates = steps.flatMap((step, i) => (i ? step.coordinates.slice(1) : step.coordinates)); if (!steps.length) { const p = byId.get(from)!.position; coordinates.push([p[0], p[1]], [p[0], p[1]]); } return { lengthMeters: costs.get(to)!, nodeIds: ids, steps, coordinates }; }; const routeAt = (from: GeoSegmentPosition, to: GeoSegmentPosition): GeoRoute | null => { const allNodes = [...byId.values()]; const cuts = new Map<string, Map<number, string>>(); const originals = new Map<string, string>(); let sequence = 0; const unique = (): string => { let id: string; do id = `__geo_cut_${sequence++}`; while (byId.has(id) || segs.has(id)); return id; }; const resolve = (p: GeoSegmentPosition): string => { const edge = segs.get(p.segment); if (!edge) throw new Error(`unknown segment: ${p.segment}`); if (!Number.isFinite(p.fraction) || p.fraction < 0 || p.fraction > 1) throw new RangeError('a fraction is 0 to 1'); if (p.fraction === 0) return edge.from; if (p.fraction === 1) return edge.to; if (!cuts.has(edge.id)) cuts.set(edge.id, new Map()); const map = cuts.get(edge.id)!; if (!map.has(p.fraction)) { const id = unique(); map.set(p.fraction, id); allNodes.push({ id, position: pointOnLine(edge.coordinates, p.fraction) }); } return map.get(p.fraction)!; }; const a = resolve(from); const b = resolve(to); const positionOf = (id: string): LonLat => allNodes.find((n) => n.id === id)!.position; const split: GeoRouteSegment[] = []; for (const edge of segs.values()) { const map = cuts.get(edge.id); if (!map) { split.push(edge); continue; } const parts: [number, string][] = [[0, edge.from], ...map.entries(), [1, edge.to]]; parts.sort((x, y) => x[0] - y[0]); for (let i = 1; i < parts.length; i++) { const prev = parts[i - 1]!; const next = parts[i]!; const coordinates = sliceLine(edge.coordinates, prev[0], next[0]); coordinates[0] = [...positionOf(prev[1])] as LonLat; coordinates[coordinates.length - 1] = [...positionOf(next[1])] as LonLat; const id = unique(); originals.set(id, edge.id); // the interior cut keeps the recorded length, proportionally split.push({ ...edge, id, from: prev[1], to: next[1], coordinates, lengthMeters: edge.lengthMeters * (next[0] - prev[0]), }); } } const result = routeGraph(allNodes, split).route(a, b); if (result) for (const step of result.steps) step.segment = originals.get(step.segment) ?? step.segment; return result; }; const nearestNode = (position: LonLat, bounds?: GeoBounds): GeoNearestNode | null => { assertLonLat(position); let best: GeoNearestNode | null = null; for (const node of (bounds ? index.query(bounds) : [...byId.values()]) as GeoRouteNode[]) { const meters = inverse(position, node.position).meters; if (!best || meters < best.meters) best = { node: copy(node), meters }; } return best; }; return { route, routeAt, nearestNode, nodesInBounds: (bounds) => (index.query(bounds) as GeoRouteNode[]).map(copy), mortonOrder: () => (index.ordered() as GeoRouteNode[]).map((n) => n.id), };}/** * The position a given distance along a route, measured in its own (supplied) metres; within a step its geometry is * parametrized by its ellipsoidal arc length. Needs the engines. * @param route - a route from a graph * @param meters - 0 to route.lengthMeters * @returns [longitude, latitude] */function positionAlongRoute(route: GeoRoute, meters: number): LonLat { if (!Number.isFinite(meters) || meters < 0 || meters > route.lengthMeters) throw new RangeError('a distance on the route'); let remaining = meters; for (const step of route.steps) { if (remaining <= step.lengthMeters && step.lengthMeters > 0) return pointOnLine(step.coordinates, remaining / step.lengthMeters); remaining -= step.lengthMeters; } const last = route.coordinates[route.coordinates.length - 1]!; return [last[0], last[1]];}// -- DGEO / TopoJSON decoding ---------------------------------------------------------------------const DGEO_HEADER = 20;const DGEO_MAX = 256 * 1024 * 1024;const MAX_ARCS = 2_000_000;const utf8 = new TextDecoder('utf-8', { fatal: true });function readVarint(data: Uint8Array, cursor: { i: number }, end: number): number { let value = 0; let scale = 1; for (let k = 0; k < 5; k++) { if (cursor.i >= end) throw new Error('DGEO: truncated varint'); const b = data[cursor.i++]!; value += (b & 127) * scale; if (value > 0xffffffff) throw new Error('DGEO: varint overflow'); if (b < 128) { if (k && b === 0) throw new Error('DGEO: noncanonical varint'); return value; } scale *= 128; } throw new Error('DGEO: oversized varint');}function checkTopology(t: GeoTopology, arcCount: number): void { if (!t || t.type !== 'Topology' || !t.objects || typeof t.objects !== 'object') throw new Error('invalid topology'); const tr = t.transform; if ( !tr || tr.scale.length !== 2 || tr.translate.length !== 2 || ![...tr.scale, ...tr.translate].every(Number.isFinite) ) throw new Error('a quantized topology needs its transform'); let refs = 0; const indices = (value: unknown, depth: number): void => { if (depth > 8 || ++refs > 10_000_000) throw new Error('excessive arc references'); if (Array.isArray(value)) { for (const x of value) indices(x, depth + 1); return; } if (!Number.isInteger(value) || ((value as number) < 0 ? -(value as number) - 1 : (value as number)) >= arcCount) throw new Error('arc reference out of range'); }; const visit = (o: any, depth: number): void => { if (!o || depth > 32) throw new Error('invalid geometry tree'); if (o.type === 'GeometryCollection') (o.geometries ?? []).forEach((g: unknown) => visit(g, depth + 1)); else if (['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'].includes(o.type)) indices(o.arcs, 0); }; Object.values(t.objects).forEach((o) => visit(o, 0));}/** * Decode a topology: DGEO bytes (DSON metadata + varint arcs) or quantized TopoJSON as UTF-8 JSON - for first-party * data (DSON is no sandbox for hostile metadata). Needs the engines. * @param bytes - the file's bytes * @returns the topology, arcs delta-encoded as in TopoJSON */function decode(bytes: Uint8Array): GeoTopology { if (bytes.length > DGEO_MAX) throw new Error('topology size limit exceeded'); if (bytes[0] !== 68) { const t = JSON.parse(utf8.decode(bytes)) as GeoTopology; if (!Array.isArray(t.arcs) || t.arcs.length > MAX_ARCS) throw new Error('invalid arcs'); checkTopology(t, t.arcs.length); return t; } if (bytes.length < DGEO_HEADER || bytes[1] !== 71 || bytes[2] !== 69 || bytes[3] !== 79) throw new Error('invalid DGEO header'); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); if (view.getUint16(4, true) !== 1 || view.getUint16(6, true) !== 0) throw new Error('unsupported DGEO version'); const m = view.getUint32(8, true); const count = view.getUint32(12, true); const rawLength = view.getUint32(16, true); const base = DGEO_HEADER + m + (count + 1) * 4; if (m > 64 * 1024 * 1024 || count > MAX_ARCS || base + rawLength !== bytes.length) throw new Error('invalid DGEO lengths'); const meta = ready().DSON.parse(utf8.decode(bytes.subarray(DGEO_HEADER, DGEO_HEADER + m))) as GeoTopology; checkTopology(meta, count); const offset = (k: number): number => view.getUint32(DGEO_HEADER + m + k * 4, true); if (offset(0) !== 0 || offset(count) !== rawLength) throw new Error('invalid DGEO offset table'); const arcs: number[][][] = []; for (let k = 0; k < count; k++) { const start = offset(k); const stop = offset(k + 1); if (stop < start || stop > rawLength) throw new Error('non-monotonic DGEO offsets'); const cursor = { i: base + start }; const end = base + stop; const arc: number[][] = []; while (cursor.i < end) { const x = readVarint(bytes, cursor, end); const y = readVarint(bytes, cursor, end); arc.push([x % 2 ? -(x + 1) / 2 : x / 2, y % 2 ? -(y + 1) / 2 : y / 2]); } if (arc.length < 2) throw new Error('a DGEO arc needs two points'); arcs.push(arc); } return { ...meta, arcs };}/** * A topology object's geometries as GeoJSON features (TopoJSON's feature(): arcs stitched, shared points dropped, * reversed arcs turned). * @param topology - a decoded topology * @param object - the object's name (world.dgeo: 'countries', 'continents'; a chunk: 'regions') * @returns the features */function features(topology: GeoTopology, object: string): GeoRegion[] { const o = topology.objects[object] as any; if (!o) throw new Error(`the topology has no object "${object}"`); const [kx, ky] = topology.transform?.scale ?? [1, 1]; const [dx, dy] = topology.transform?.translate ?? [0, 0]; // the arcs, absolute and transformed once const arcs = topology.arcs.map((arc) => { let x = 0; let y = 0; return arc.map((p) => { x += p[0]!; y += p[1]!; return [x * kx + dx, y * ky + dy] as LonLat; }); }); const line = (indices: number[]): LonLat[] => { const points: LonLat[] = []; for (const i of indices) { if (points.length) points.pop(); const start = points.length; for (const p of arcs[i < 0 ? ~i : i]!) points.push([p[0], p[1]]); if (i < 0) { const tail = points.splice(start).reverse(); points.push(...tail); } } return points; }; const ring = (indices: number[]): LonLat[] => { const points = line(indices); while (points.length < 4) points.push([points[0]![0], points[0]![1]]); return points; }; const geometry = (g: any): any => { switch (g.type) { case 'Polygon': return { type: 'Polygon', coordinates: g.arcs.map(ring) }; case 'MultiPolygon': return { type: 'MultiPolygon', coordinates: g.arcs.map((p: number[][]) => p.map(ring)) }; case 'LineString': { const points = line(g.arcs); if (points.length < 2) points.push([points[0]![0], points[0]![1]]); return { type: 'LineString', coordinates: points }; } case 'MultiLineString': return { type: 'MultiLineString', coordinates: g.arcs.map(line) }; case 'Point': return { type: 'Point', coordinates: [g.coordinates[0] * kx + dx, g.coordinates[1] * ky + dy] }; case 'MultiPoint': return { type: 'MultiPoint', coordinates: g.coordinates.map((c: number[]) => [c[0]! * kx + dx, c[1]! * ky + dy]), }; case 'GeometryCollection': return { type: 'GeometryCollection', geometries: g.geometries.map(geometry) }; default: return null; } }; const feature = (g: any): GeoRegion => ({ type: 'Feature', ...(g.id != null ? { id: g.id } : {}), properties: g.properties ?? {}, geometry: geometry(g), }) as GeoRegion; return o.type === 'GeometryCollection' ? o.geometries.map(feature) : [feature(o)];}/** * A region from any GeoJSON polygon feature, its rings turned to D3's winding where needed (each ring by its * spherical area: an outer ring encloses the smaller side) - so RFC 7946 and D3-wound input both draw. The input is * not changed. Needs the engines. * @param region - a Polygon / MultiPolygon feature with properties `{ id, name, level, parent? }` * @returns the region, ready for a map */function rewind(region: GeoRegion): GeoRegion { const { d3 } = ready(); const g = region.geometry; if (!g || (g.type !== 'Polygon' && g.type !== 'MultiPolygon')) throw new Error('a region is a Polygon or MultiPolygon'); const fix = (polygon: LonLat[][]): LonLat[][] => polygon.map((ring, i) => { if (ring.length < 4) throw new Error('a ring needs four positions (closed)'); ring.forEach(assertLonLat); const copy = ring.map((p) => [p[0], p[1]] as LonLat); const big = d3.geoArea({ type: 'Polygon', coordinates: [copy] }) > 2 * Math.PI; // the outer ring encloses the small side, a hole the large one return (i === 0 ? big : !big) ? copy.reverse() : copy; }); const coordinates = g.type === 'Polygon' ? fix(g.coordinates as LonLat[][]) : (g.coordinates as LonLat[][][]).map(fix); return { type: 'Feature', properties: { ...region.properties }, geometry: { type: g.type, coordinates } as GeoGeometry, };}/** conservative rectangles for the broad phase: split strips, sampled parallels, a 0.1° latitude pad */function boundsProxy(id: string, bounds: GeoBounds): GeoRegion { const [west, south, east, north] = bounds; const intervals = west > east ? [ [west, 180], [-180, east], ] : [[west, east]]; const polygons: LonLat[][][] = []; const bottom = Math.max(-90, south - 0.1); const top = Math.min(90, north + 0.1); for (const [w, e] of intervals as [number, number][]) { const count = Math.max(1, Math.ceil((e - w) / 60)); for (let part = 0; part < count; part++) { const a = w + ((e - w) * part) / count; const b = w + ((e - w) * (part + 1)) / count; const steps = Math.max(1, Math.ceil((b - a) / 5)); const ring: LonLat[] = [ [a, bottom], [a, top], ]; for (let j = 1; j <= steps; j++) ring.push([a + ((b - a) * j) / steps, top]); ring.push([b, bottom]); for (let j = steps - 1; j >= 0; j--) ring.push([a + ((b - a) * j) / steps, bottom]); polygons.push([ring]); } } return { type: 'Feature', properties: { id, name: id, level: 'country' }, geometry: { type: 'MultiPolygon', coordinates: polygons }, };}// -- data -------------------------------------------------------------------------------------------interface GeoManifest { version: number; initial: string; details: Record<string, string>; states: Record<string, string>; stateDetails?: Record<string, string>; bounds?: Record<string, GeoBounds>; names?: string;}interface Dataset { url: string; manifest: GeoManifest | null; countries: GeoRegion[]; continents: GeoRegion[]; /** a GeoJSON source's states (memory - no chunks) */ memoryStates: GeoRegion[]; countryIds: Set<string>; proxies: Map<string, GeoRegion>; byKey: Map<string, GeoRegion>; chunks: Map<string, { regions: GeoRegion[]; bytes: number }>; loading: Set<string>; failed: Map<string, { until: number; attempts: number }>; bytes: number; maps: Set<HTMLElement>; names: Promise<SearchEntry[]> | null; /** state key → parent country id (names.json) */ parents: Map<string, string>;}interface SearchEntry { key: string; name: string; level: GeoLevel; label: string; norm: string;}const datasets = new Map<string, Promise<Dataset>>();const CACHE_BYTES = 24 * 1024 * 1024;const MAX_LOADS = 4;let activeLoads = 0;const loadQueue: { ds: Dataset; path: string }[] = [];const keyOf = (r: GeoRegion): string => `${r.properties.level}:${r.properties.id}`;async function fetchBytes(url: string): Promise<Uint8Array> { const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`); return new Uint8Array(await response.arrayBuffer());}function index(ds: Dataset, regions: GeoRegion[]): void { for (const r of regions) ds.byKey.set(keyOf(r), r);}function openDataset(url: string): Promise<Dataset> { let known = datasets.get(url); if (!known) { known = (async () => { await load(); const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`); const json = (await response.json()) as any; const ds: Dataset = { url, manifest: null, countries: [], continents: [], memoryStates: [], countryIds: new Set(), proxies: new Map(), byKey: new Map(), chunks: new Map(), loading: new Set(), failed: new Map(), bytes: 0, maps: new Set(), names: null, parents: new Map(), }; if (json?.type === 'FeatureCollection') { // an application's own regions (GeoJSON): every level resident, wound for D3 const regions = (json.features as GeoRegion[]).map(rewind); ds.continents = regions.filter((r) => r.properties.level === 'continent'); ds.countries = regions.filter((r) => r.properties.level === 'country'); ds.memoryStates = regions.filter((r) => r.properties.level === 'state'); for (const s of ds.memoryStates) if (s.properties.parent) ds.parents.set(keyOf(s), s.properties.parent); } else { const manifest = json as GeoManifest; if (manifest.version !== 1 || !manifest.initial || !manifest.details || !manifest.states) throw new Error(`${url} is no geo-map manifest (version 1)`); ds.manifest = manifest; const topology = decode(await fetchBytes(new URL(manifest.initial, url).href)); ds.countries = features(topology, 'countries'); ds.continents = features(topology, 'continents'); for (const [id, b] of Object.entries(manifest.bounds ?? {})) ds.proxies.set(id, boundsProxy(id, b)); } ds.countryIds = new Set(ds.countries.map((r) => r.properties.id)); index(ds, ds.countries); index(ds, ds.continents); index(ds, ds.memoryStates); return ds; })(); known.catch(() => { if (datasets.get(url) === known) datasets.delete(url); }); datasets.set(url, known); } return known;}/** the chunk's regions when cached (touched for the LRU), else undefined - and wanted */function chunk(ds: Dataset, path: string, wanted: Set<string>): GeoRegion[] | undefined { wanted.add(path); const entry = ds.chunks.get(path); if (entry) { ds.chunks.delete(path); ds.chunks.set(path, entry); return entry.regions; } if ( !ds.loading.has(path) && (ds.failed.get(path)?.until ?? 0) <= Date.now() && !loadQueue.some((q) => q.ds === ds && q.path === path) ) loadQueue.push({ ds, path }); return undefined;}/** the maps on the page that read a dataset: a map that left it (an SPA swapped it out) lets go of its chunks - it * registers again on its next render if it comes back */function liveMaps(ds: Dataset): HTMLElement[] { for (const el of ds.maps) if (!el.isConnected) ds.maps.delete(el); return [...ds.maps];}const wantedBySomeMap = (ds: Dataset, path: string): boolean => liveMaps(ds).some((el) => el._geo?.wanted.has(path));function pump(): void { while (activeLoads < MAX_LOADS && loadQueue.length) { // the newest request first: the view the reader looks at now const next = loadQueue.pop()!; if (next.ds.chunks.has(next.path) || next.ds.loading.has(next.path) || !wantedBySomeMap(next.ds, next.path)) continue; activeLoads++; next.ds.loading.add(next.path); void loadChunk(next.ds, next.path); }}async function loadChunk(ds: Dataset, path: string): Promise<void> { const url = new URL(path, ds.url).href; try { const bytes = await fetchBytes(url); const regions = features(decode(bytes), 'regions'); ds.chunks.set(path, { regions, bytes: bytes.length }); ds.bytes += bytes.length; ds.failed.delete(path); index(ds, regions); // the budget counts encoded bytes (decoded heap is larger); what a map shows is never evicted for (const [key, entry] of ds.chunks) { if (ds.bytes <= CACHE_BYTES || ds.chunks.size <= 1) break; if (!wantedBySomeMap(ds, key)) { ds.chunks.delete(key); ds.bytes -= entry.bytes; } } } catch (err) { const attempts = (ds.failed.get(path)?.attempts ?? 0) + 1; ds.failed.set(path, { attempts, until: Date.now() + Math.min(60_000, 1000 * 2 ** attempts) }); for (const el of liveMaps(ds)) reportError(el, err, url); } finally { ds.loading.delete(path); activeLoads--; for (const el of liveMaps(ds)) requestRender(el); pump(); }}const normalize = (s: string): string => s .normalize('NFD') .replace(/[̀-ͯ]/g, '') .toLowerCase() .trim();/** the search entries: continents and countries now, every state once names.json arrived */function searchEntries(ds: Dataset): Promise<SearchEntry[]> { if (!ds.names) { const resident: SearchEntry[] = [...ds.continents, ...ds.countries, ...ds.memoryStates].map((r) => { const parent = r.properties.parent ? ds.byKey.get(`country:${r.properties.parent}`)?.properties.name : ''; const label = parent ? `${r.properties.name}, ${parent}` : r.properties.name; return { key: keyOf(r), name: r.properties.name, level: r.properties.level, label, norm: normalize(label) }; }); const namesPath = ds.manifest ? (ds.manifest.names ?? 'names.json') : ''; const pending: Promise<SearchEntry[]> = !namesPath ? Promise.resolve(resident) : fetch(new URL(namesPath, ds.url).href) .then((r) => (r.ok ? r.json() : { states: [] })) .catch(() => ({ states: [] })) .then((json: { states?: [string, string, string][] }) => { const states = (json.states ?? []).map(([id, name, parent]) => { ds.parents.set(`state:${id}`, parent); const country = ds.byKey.get(`country:${parent}`)?.properties.name ?? parent; const label = `${name}, ${country}`; return { key: `state:${id}`, name, level: 'state' as GeoLevel, label, norm: normalize(label) }; }); return [...resident, ...states]; }); ds.names = pending; } return ds.names;}function rank(entries: SearchEntry[], query: string, limit: number): GeoSearchHit[] { const q = normalize(query); if (!q) return []; const order: GeoLevel[] = ['continent', 'country', 'state']; const scored: [number, SearchEntry][] = []; for (const e of entries) { const name = normalize(e.name); const score = name === q || e.norm === q ? 0 : name.startsWith(q) ? 1 : name.split(/[\s-]+/).some((w) => w.startsWith(q)) ? 2 : e.norm.includes(q) ? 3 : -1; if (score >= 0) scored.push([score * 10 + order.indexOf(e.level), e]); } scored.sort((a, b) => a[0] - b[0] || a[1].label.localeCompare(b[1].label)); return scored.slice(0, limit).map(([, e]) => ({ key: e.key, name: e.name, level: e.level, label: e.label }));}// -- projection + framing ----------------------------------------------------------------------------const PROJECTIONS: GeoProjectionName[] = ['equalEarth', 'equalArea', 'mercator', 'equirectangular', 'orthographic'];const LEVELS: (GeoLevel | 'auto')[] = ['auto', 'continent', 'country', 'state'];const PALETTES: GeoPalette[] = ['atlas', 'ocean', 'forest', 'sunset', 'heat', 'mono', 'chart'];const BORDERS: GeoBorder[] = ['natural', 'cased', 'dashed', 'none'];const HOME: LonLat = [0, 12];interface Extent { x0: number; y0: number; x1: number; y1: number;}function makeProjection(name: GeoProjectionName, box: Extent, center: LonLat, zoom: number, clip = true): any { const { d3 } = ready(); const make = { equalEarth: d3.geoEqualEarth, // Lambert's azimuthal equal-area, turned to the camera: every area true, like Equal Earth, and the shapes true // at the center too - a framed country is drawn at its real size and in its real shape, wherever it lies equalArea: d3.geoAzimuthalEqualArea, mercator: d3.geoMercator, equirectangular: d3.geoEquirectangular, orthographic: d3.geoOrthographic, }[name]; const p = make().precision(0.35); p.fitExtent( [ [box.x0 + 12, box.y0 + 12], [Math.max(box.x0 + 13, box.x1 - 12), Math.max(box.y0 + 13, box.y1 - 12)], ], { type: 'Sphere' }, ); const scale = p.scale(); if (name === 'orthographic') p.rotate([-center[0], -center[1], 0]).clipAngle(90); else if (name === 'equalArea') p.rotate([-center[0], -center[1], 0]); else p.rotate([-center[0], 0, 0]).center([0, clamp(center[1], -85, 85)]); p.scale(scale * zoom).translate([(box.x0 + box.x1) / 2, (box.y0 + box.y1) / 2]); // a flat projection zoomed in never shows the empty band past a pole (the card's background over the Arctic, as a // wide Europe frame did): once the map is taller than its box the view stops at the pole's edge; zoomed out, the // whole outline stays where the camera puts it. Only the drawn view - frame() measures unclamped (clip false) if (clip && name !== 'orthographic' && name !== 'equalArea') { const [[, top], [, bottom]] = d3.geoPath(p).bounds({ type: 'Sphere' }) as [[number, number], [number, number]]; const dy = bottom - top <= box.y1 - box.y0 ? 0 : top > box.y0 ? box.y0 - top : bottom < box.y1 ? box.y1 - bottom : 0; if (dy) p.translate([(box.x0 + box.x1) / 2, (box.y0 + box.y1) / 2 + dy]); } if (clip) p.clipExtent([ [box.x0, box.y0], [box.x1, box.y1], ]); return p;}/** the built-in focus frames - where a continent's outline (Russia in Europe, the Aleutians) frames poorly */const VIEWS: Record<string, GeoBounds> = { 'continent:Europe': [-25, 34, 45, 71], 'continent:North America': [-168, 7, -20, 72], 'continent:Oceania': [110, -48, -175, 5],};/** * The part of a region a focus frames: its largest polygon plus every polygon near the growing cluster - France * frames the mainland and Corsica, not French Guiana; an archipelago chains together. */function mainBody(region: GeoRegion): GeoRegion { const { d3 } = ready(); const g = region.geometry; if (g.type !== 'MultiPolygon' || g.coordinates.length < 2) return region; const parts = (g.coordinates as LonLat[][][]).map((coordinates) => { const poly = { type: 'Polygon', coordinates }; return { coordinates, area: d3.geoArea(poly) as number, centroid: d3.geoCentroid(poly) as LonLat }; }); parts.sort((a, b) => b.area - a.area); const included = [parts[0]!]; let rest = parts.slice(1); for (let changed = true; changed;) { changed = false; const area = included.reduce((s, p) => s + p.area, 0); const center = d3.geoCentroid({ type: 'MultiPolygon', coordinates: included.map((p) => p.coordinates) }) as LonLat; const radius = Math.sqrt(area / Math.PI); const next = rest.filter((p) => { const near = d3.geoDistance(center, p.centroid) <= 2 * (radius + Math.sqrt(p.area / Math.PI)) + 0.02; if (near) { included.push(p); changed = true; } return !near; }); rest = next; } return { ...region, geometry: { type: 'MultiPolygon', coordinates: included.map((p) => p.coordinates) } };}/** the camera that frames a region (or bounds) inside a box */function frame(target: GeoRegion, box: Extent, projection: GeoProjectionName): { center: LonLat; zoom: number } { const { d3 } = ready(); const center = d3.geoCentroid(target) as LonLat; const c: LonLat = [wrapLon(center[0]), clamp(center[1], -85, 85)]; const p = makeProjection(projection, box, c, 1, false); const b = d3.geoPath(p).bounds(target); const w = b[1][0] - b[0][0]; const h = b[1][1] - b[0][1]; const pad = 28; // the target's size at zoom 1 may be far below a pixel (an S2 cell from level 8, a microstate): only a point (no // size) is guarded - a 1px floor capped every such frame at the box's width (390x on a 28rem map) const zoom = clampZoom( Math.min((box.x1 - box.x0 - pad * 2) / Math.max(1e-9, w), (box.y1 - box.y0 - pad * 2) / Math.max(1e-9, h)), ); return { center: c, zoom: Number.isFinite(zoom) ? zoom : 1 };}// -- the map instance ---------------------------------------------------------------------------------interface GeoView { projection: GeoProjectionName; level: GeoLevel | 'auto'; palette: GeoPalette; border: GeoBorder; graticule: boolean; callouts: boolean; selected: string[]; focus: string | null; center: LonLat; zoom: number;}interface GeoInstance { view: GeoView; ds: Dataset | null; status: 'loading' | 'ready' | 'error'; ready: Promise<void>; svg: SVGSVGElement; layers: Record<string, SVGGElement>; tooltip: HTMLOutputElement; hatch: string; width: number; height: number; projection: any; box: Extent; frameId: number; flight: { id: number; target: { center: LonLat; zoom: number }; done: () => void } | null; home: { center: LonLat; zoom: number }; /** the selection and focus the markup declared - what 'selected' / 'focused' fall back to without a config */ authored: { focus: string | null; selected: string[] }; restTimer: number; pendingFit: string | null; pointers: Map<number, [number, number]>; moved: boolean; drawn: Map<string, GeoRegion>; anchors: Map<string, [number, number]>; hover: string | null; roving: string | null; layersData: Required<GeoLayers>; cellRegions: GeoRegion[]; wanted: Set<string>; stats: GeoRenderStats; abort: AbortController; resize: ResizeObserver; attrs: MutationObserver; lastView: string;}const NS = 'http://www.w3.org/2000/svg';let nextId = 0;function svgEl<K extends keyof SVGElementTagNameMap>( tag: K, attrs: Record<string, string> = {},): SVGElementTagNameMap[K] { const el = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); return el;}/** a layer's children replaced - through df$ (AGENTS.md "DOM through df$") */function setChildren(parent: Element, nodes: Element[]): void { dfDollar(parent).empty().append(nodes);}/** the one path of a layer (the sphere, the graticule): made once, its d updated */function layerPath(layer: SVGGElement, d: string): void { let p = layer.firstElementChild as SVGPathElement | null; if (!p) { p = svgEl('path'); dfDollar(layer).append(p); } if (p.getAttribute('d') !== d) p.setAttribute('d', d);}function hash(s: string): number { let h = 2166136261; for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619); return h >>> 0;}const reducedMotion = (): boolean => typeof globalThis.matchMedia === 'function' && globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches;/** the keys of a selection config: an array, or a string separated by spaces or commas */function keyList(value: unknown): string[] { const list = Array.isArray(value) ? value.map(String) : typeof value === 'string' ? value.split(/[\s,]+/) : []; return [...new Set(list.map((k) => k.trim()).filter(Boolean))];}function parseCenter(value: string | undefined): LonLat | null { if (!value) return null; const parts = value.split(',').map((v) => Number(v.trim())); if (parts.length !== 2 || !parts.every(Number.isFinite)) return null; return [wrapLon(parts[0]!), clamp(parts[1]!, -85, 85)];}/** the view the element's attributes declare (the camera from the instance - it is no markup) */function viewFromAttributes(el: HTMLElement, camera: { center: LonLat; zoom: number }): GeoView { const d = el.dataset; const pick = <T extends string>(value: string | undefined, allowed: T[], fallback: T): T => allowed.includes(value as T) ? (value as T) : fallback; return { projection: pick(d.projection, PROJECTIONS, 'equalEarth'), level: pick(d.level, LEVELS, 'country'), palette: pick(d.palette, PALETTES, 'atlas'), border: pick(d.border, BORDERS, 'natural'), graticule: d.graticule !== 'false', callouts: d.callouts !== undefined && d.callouts !== 'false', selected: keyList(d.selected ?? ''), focus: d.focus || null, center: camera.center, zoom: camera.zoom, };}const stateNameOf = (v: Pick<GeoView, 'focus' | 'selected'>): string => v.focus ? 'focused' : v.selected.length ? 'selected' : 'default';/** a map's state as getState() reports it */function configOf(geo: GeoInstance): Required<Omit<GeoMapConfig, 'selected'>> & { selected: string[] } { const v = geo.view; const camera = geo.flight?.target ?? { center: v.center, zoom: v.zoom }; return { projection: v.projection, level: v.level, palette: v.palette, border: v.border, graticule: v.graticule, callouts: v.callouts, selected: [...v.selected], focus: v.focus, center: [camera.center[0], camera.center[1]], zoom: camera.zoom, };}function requestRender(el: HTMLElement): void { const geo = el._geo as GeoInstance | undefined; if (!geo || geo.frameId) return; geo.frameId = requestAnimationFrame(() => { geo.frameId = 0; renderMap(el); });}function reportError(el: HTMLElement, err: unknown, url: string): void { const message = err instanceof Error ? err.message : String(err); // a chunk or the data could not be loaded - the map keeps what it has el.dispatchEvent(new CustomEvent<GeoMapErrorDetail>('geo-map-error', { detail: { message, url }, bubbles: true }));}/** the level drawn: the configured one ('auto' by zoom) */function levelFor(view: GeoView): GeoLevel { return view.level === 'auto' ? (view.zoom >= 8 ? 'state' : view.zoom >= 2 ? 'country' : 'continent') : view.level;}/** the token of the S2 cell in focus (s2:<token>), or null - also for a focus that names no valid cell */function cellFocus(view: GeoView): string | null { if (!view.focus?.startsWith('s2:')) return null; try { return s2Token(view.focus.slice(3)); } catch { return null; }}/** the cells drawn: the focused cell's four children - the S2 hierarchy one level down - else the layer's cells */function cellsDrawn(geo: GeoInstance): GeoRegion[] { const token = cellFocus(geo.view); return token ? s2Children(token).map(s2Polygon) : geo.cellRegions;}/** the regions drawn - and, around a focus, the dimmed context */function selectRegions(geo: GeoInstance, path: any): { regions: GeoRegion[]; context: GeoRegion[] } { const ds = geo.ds!; const v = geo.view; const m = ds.manifest; const zoom = v.zoom; geo.wanted = new Set(); const visible = (r: GeoRegion): boolean => !!path(zoom >= 3 ? (ds.proxies.get(r.properties.id) ?? r) : r); const statesOf = (id: string): GeoRegion[] | undefined => { if (!m) return ds.memoryStates.filter((s) => s.properties.parent === id); const p = zoom >= 24 ? (m.stateDetails?.[id] ?? m.states[id]) : m.states[id]; return p ? chunk(ds, p, geo.wanted) : []; }; const countryShape = (r: GeoRegion): GeoRegion[] => { const p = zoom >= 3 && m ? m.details[r.properties.id] : undefined; return (p && chunk(ds, p, geo.wanted)) || [r]; }; const focus = v.focus ? ds.byKey.get(v.focus) : undefined; const focusKey = v.focus; // an S2 cell in focus frames a place, not a region: the regions are drawn as without one if (focusKey && !focusKey.startsWith('s2:')) { const [level, ...rest] = focusKey.split(':'); const id = rest.join(':'); const countries = ds.countries.filter(visible); if (level === 'continent') { const inside = countries.filter((c) => c.properties.continent === id); return { regions: inside.flatMap(countryShape), context: countries.filter((c) => c.properties.continent !== id).flatMap(countryShape), }; } const countryId = level === 'country' ? id : (focus?.properties.parent ?? ds.parents.get(focusKey) ?? ''); const states = countryId ? statesOf(countryId) : undefined; const home = ds.byKey.get(`country:${countryId}`); return { regions: states?.length ? states : home ? countryShape(home) : [], context: countries.filter((c) => c.properties.id !== countryId).flatMap(countryShape), }; } const level = levelFor(v); if (level === 'continent') return { regions: ds.continents, context: [] }; const regions: GeoRegion[] = []; for (const c of ds.countries) { if (!visible(c)) continue; if (level === 'state' && zoom >= 3) { const states = statesOf(c.properties.id); regions.push(...(states && states.length ? states : countryShape(c))); } else regions.push(...countryShape(c)); } // parents with states but no 50m country (Gibraltar, Akrotiri...): their states, by the bounds index if (level === 'state' && zoom >= 3 && m) for (const id of Object.keys(m.states)) { if (ds.countryIds.has(id)) continue; const proxy = ds.proxies.get(id); if (proxy && path(proxy)) regions.push(...(statesOf(id) ?? [])); } return { regions, context: [] };}/** a region's label point: the projected centroid of its largest polygon, moved inside when it falls out */function anchorOf(path: any, projection: any, region: GeoRegion): [number, number] | null { const { d3 } = ready(); const g = region.geometry; let largest: any = g; if (g.type === 'MultiPolygon') { let best = -1; for (const coordinates of g.coordinates as LonLat[][][]) { const poly = { type: 'Polygon', coordinates }; const a = path.area(poly); if (a > best) { best = a; largest = poly; } } } const c = path.centroid(largest) as [number, number]; if (!c.every(Number.isFinite)) return null; const inside = (pt: [number, number]): boolean => { const ll = projection.invert?.(pt); return !!ll && d3.geoContains(largest, ll); }; if (inside(c)) return c; const [[x0, y0], [x1, y1]] = path.bounds(largest); let best: [number, number] | null = null; let dist = Infinity; for (let i = 1; i < 8; i++) for (let j = 1; j < 8; j++) { const pt: [number, number] = [x0 + ((x1 - x0) * i) / 8, y0 + ((y1 - y0) * j) / 8]; const d = Math.hypot(pt[0] - c[0], pt[1] - c[1]); if (d < dist && inside(pt)) { best = pt; dist = d; } } return best ?? c;}/** a length custom property of the map in px (px, rem or em), else the fallback */function cssPx(el: HTMLElement, name: string, fallback: number): number { const raw = getComputedStyle(el).getPropertyValue(name).trim(); const n = parseFloat(raw); if (!Number.isFinite(n)) return fallback; if (raw.endsWith('rem')) return n * parseFloat(getComputedStyle(document.documentElement).fontSize || '16'); if (raw.endsWith('em')) return n * parseFloat(getComputedStyle(el).fontSize || '16'); return n;}/** the callouts' gutter width in px (--geo-map-callout-width, default 10rem) */const gutterOf = (el: HTMLElement): number => cssPx(el, '--geo-map-callout-width', 160);let measure: OffscreenCanvasRenderingContext2D | null | undefined;/** a name's width in px in a font - canvas text metrics, an estimate where there is no canvas */function textWidth(text: string, font: string, size: number): number { if (measure === undefined) measure = typeof OffscreenCanvas === 'undefined' ? null : new OffscreenCanvas(1, 1).getContext('2d'); if (!measure) return text.length * size * 0.58; measure.font = font; return measure.measureText(text).width;}function renderMap(el: HTMLElement): void { const geo = el._geo as GeoInstance | undefined; if (!geo || geo.status !== 'ready' || !geo.ds) return; const { d3 } = ready(); const start = performance.now(); const v = geo.view; const W = geo.width; const H = geo.height; const gutter = v.callouts ? Math.min(gutterOf(el), W / 3) : 0; geo.box = { x0: gutter, y0: 0, x1: W - gutter, y1: H }; geo.svg.setAttribute('viewBox', `0 0 ${W} ${H}`); geo.projection = makeProjection(v.projection, geo.box, v.center, v.zoom); // the camera follows a view stopped at a pole (makeProjection), so a pan toward it does not wind up unseen if (!geo.flight && v.projection !== 'orthographic' && v.projection !== 'equalArea') { const seen = geo.projection.invert?.([(geo.box.x0 + geo.box.x1) / 2, (geo.box.y0 + geo.box.y1) / 2]); if (seen && Number.isFinite(seen[1]) && Math.abs(seen[1] - v.center[1]) > 1e-6) v.center = [v.center[0], clamp(seen[1], -85, 85)]; } const path = d3.geoPath(geo.projection); const { regions, context } = selectRegions(geo, path); geo.ds.maps.add(el); pump(); const L = geo.layers; layerPath(L.sphere!, path({ type: 'Sphere' }) ?? ''); layerPath(L.graticule!, v.graticule ? (path(d3.geoGraticule10()) ?? '') : ''); const selected = new Set(v.selected); const styles = geo.layersData.styles; geo.drawn = new Map(); let visibleRegions = 0; let contextRegions = 0; let visibleCells = 0; const reconcile = (layer: SVGGElement, items: GeoRegion[], kind: 'region' | 'context' | 'cell'): void => { const existing = new Map<string, SVGPathElement>(); for (const child of [...layer.children]) existing.set(child.getAttribute('data-key') ?? '', child as SVGPathElement); const keep = new Set<string>(); for (const region of items) { const key = kind === 'cell' ? `s2:${region.properties.id}` : keyOf(region); if (keep.has(key)) continue; const d = path(region); if (!d) continue; keep.add(key); if (kind === 'context') contextRegions++; else { geo.drawn.set(key, region); if (kind === 'cell') visibleCells++; else visibleRegions++; } let p = existing.get(key); if (!p) { p = svgEl('path', { 'data-key': key, class: kind === 'cell' ? 'geo-map-region geo-map-cell' : 'geo-map-region', }); if (kind !== 'context') { p.setAttribute('role', 'button'); p.setAttribute('tabindex', '-1'); p.setAttribute( 'aria-label', `${region.properties.name} (${kind === 'cell' ? 'S2 cell' : region.properties.level})`, ); } layer.append(p); } if (p.getAttribute('d') !== d) p.setAttribute('d', d); if (kind !== 'cell') p.setAttribute('data-tone', String(hash(region.properties.id) % 7)); if (kind !== 'context') p.setAttribute('aria-pressed', selected.has(key) ? 'true' : 'false'); const style = styles[key]; p.removeAttribute('style'); if (style) { if (style.fill !== undefined) p.style.fill = style.fill; if (style.stroke !== undefined) p.style.stroke = style.stroke; if (style.strokeWidth !== undefined) p.style.strokeWidth = String(style.strokeWidth); if (style.opacity !== undefined) p.style.opacity = String(style.opacity); if (style.dash !== undefined) p.style.strokeDasharray = style.dash; } } for (const [key, p] of existing) if (!keep.has(key)) p.remove(); }; reconcile(L.context!, context, 'context'); reconcile(L.regions!, regions, 'region'); reconcile(L.cells!, cellsDrawn(geo), 'cell'); // the roving tab stop: the selected region in view, else the first drawn const stops = [...L.regions!.children, ...L.cells!.children] as SVGPathElement[]; const stop = stops.find((p) => p.getAttribute('data-key') === geo.roving) ?? stops.find((p) => p.getAttribute('aria-pressed') === 'true') ?? stops[0]; geo.roving = stop?.getAttribute('data-key') ?? null; for (const p of stops) { const t = p === stop ? '0' : '-1'; if (p.getAttribute('tabindex') !== t) p.setAttribute('tabindex', t); } // lines setChildren( L.lines!, geo.layersData.lines.flatMap((line) => { let coordinates = line.coordinates; if (!coordinates && line.from && line.to) coordinates = geodesicPath(line.from, line.to); const d = coordinates ? path({ type: 'LineString', coordinates }) : null; if (!d) return []; const p = svgEl('path', { d, class: 'geo-map-line', 'data-line': line.id }); if (line.color) p.style.stroke = line.color; if (line.width !== undefined) p.style.strokeWidth = String(line.width); if (line.dash) p.style.strokeDasharray = line.dash; return [p]; }), ); // markers let visibleMarkers = 0; setChildren( L.markers!, geo.layersData.markers.flatMap((marker) => { const pos = resolvePosition(marker.position); if (!path({ type: 'Point', coordinates: pos })) return []; const xy = geo.projection(pos) as [number, number]; visibleMarkers++; const g = svgEl('g', { class: 'geo-map-marker', transform: `translate(${xy[0].toFixed(2)},${xy[1].toFixed(2)})`, 'data-marker': marker.id, 'data-shape': marker.shape ?? 'circle', role: 'img', 'aria-label': marker.label ?? marker.id, }); const shape = marker.shape === 'diamond' ? svgEl('path', { d: 'M0,-7L7,0L0,7L-7,0Z' }) : marker.shape === 'square' ? svgEl('rect', { x: '-5', y: '-5', width: '10', height: '10', rx: '2' }) : marker.shape === 'pin' ? svgEl('path', { d: 'M0,1C-18,-14 -5,-22 0,-22C5,-22 18,-14 0,1Z' }) : svgEl('circle', { r: '5' }); if (marker.color) shape.style.fill = marker.color; g.append(shape); if (marker.label) { const text = svgEl('text', { x: '10', y: marker.shape === 'pin' ? '-12' : '-6' }); text.textContent = marker.label; g.append(text); } return [g]; }), ); // anchors (callouts + keyboard movement) for the regions drawn geo.anchors = new Map(); const callable = [...geo.drawn].filter(([k]) => !k.startsWith('s2:')); if (callable.length <= 400) for (const [key, region] of callable) { const a = anchorOf(path, geo.projection, region); if (a && a[0] >= geo.box.x0 && a[0] <= geo.box.x1 && a[1] >= 0 && a[1] <= H) geo.anchors.set(key, a); } let calloutCount = 0; if (v.callouts) calloutCount = drawCallouts(el, geo, W, H, gutter, path); else setChildren(L.callouts!, []); drawLift(geo); geo.stats = { visibleRegions, contextRegions, visibleCells, visibleMarkers, callouts: calloutCount, renderMs: performance.now() - start, cachedChunks: geo.ds.chunks.size, loadedBytes: geo.ds.bytes, // this map's chunks still on their way (a failed one waits for its retry) loading: [...geo.wanted].filter((p) => !geo.ds!.chunks.has(p) && !geo.ds!.failed.has(p)).length, }; if (el.hasAttribute('aria-busy') !== geo.stats.loading > 0) dfDollar(el).attr('aria-busy', geo.stats.loading ? 'true' : null); // the pending fit of a focused state: its chunk has arrived if (geo.pendingFit && geo.ds.byKey.has(geo.pendingFit) && !geo.flight) { const key = geo.pendingFit; geo.pendingFit = null; flyTo(el, targetFor(geo, key), false); } writeOutputs(el); // a render finished: what it drew and what is still loading el.dispatchEvent(new CustomEvent<GeoRenderStats>('geo-map-render', { detail: { ...geo.stats } }));}function drawCallouts(el: HTMLElement, geo: GeoInstance, W: number, H: number, gutter: number, path: any): number { const layer = geo.layers.callouts!; const focus = geo.view.focus; const entries = [...geo.anchors].map(([key, anchor]) => { const region = geo.drawn.get(key)!; return { key, anchor, name: region.properties.name, area: path.area(region) as number }; }); // the rows and the names follow the label's size (--geo-map-callout-size, default 0.875rem) const size = cssPx(el, '--geo-map-callout-size', 14); const row = Math.max(18, Math.round(size * 1.6)); // measured in the hover weight, so a highlighted name still fits const font = `600 ${size}px ${getComputedStyle(el).fontFamily}`; const room = gutter - 12; const fit = (name: string): string => { if (textWidth(name, font, size) <= room) return name; let n = name.length - 1; while (n > 1 && textWidth(`${name.slice(0, n)}…`, font, size) > room) n--; return `${name.slice(0, n).trimEnd()}…`; }; const capacity = Math.max(1, Math.floor((H - 16) / row)); const mid = (geo.box.x0 + geo.box.x1) / 2; // the largest regions when there are more than the gutters hold; then west to the left gutter, east to the right - // a full side gives its regions nearest the middle to the other one, so lines do not cross the map const shown = entries.sort((a, b) => b.area - a.area).slice(0, capacity * 2); shown.sort((a, b) => a.anchor[0] - b.anchor[0]); const west = shown.filter((e) => e.anchor[0] < mid).length; const split = clamp(west, shown.length - capacity, capacity); const sides: Record<'left' | 'right', typeof entries> = { left: shown.slice(0, split), right: shown.slice(split) }; const groups: SVGGElement[] = []; for (const side of ['left', 'right'] as const) { const list = sides[side].sort((a, b) => a.anchor[1] - b.anchor[1]); const top = Math.round(size) + row; const bottom = H - 8; const ys = list.map((e) => clamp(e.anchor[1], top, bottom)); for (let i = 1; i < ys.length; i++) ys[i] = Math.max(ys[i]!, ys[i - 1]! + row); if (ys.length && ys[ys.length - 1]! > bottom) { ys[ys.length - 1] = bottom; for (let i = ys.length - 2; i >= 0; i--) ys[i] = Math.min(ys[i]!, ys[i + 1]! - row); } list.forEach((e, i) => { const y = ys[i]!; const edge = side === 'left' ? gutter : W - gutter; const outer = side === 'left' ? 6 : W - 6; const g = svgEl('g', { class: 'geo-map-callout', 'data-key': e.key, 'data-side': side }); if (focus && e.key === focus) g.setAttribute('data-focus', ''); g.append( svgEl('polyline', { class: 'geo-map-callout-line', points: `${e.anchor[0].toFixed(1)},${e.anchor[1].toFixed(1)} ${edge.toFixed(1)},${y.toFixed(1)} ${outer.toFixed(1)},${y.toFixed(1)}`, }), svgEl('circle', { class: 'geo-map-callout-dot', cx: e.anchor[0].toFixed(1), cy: e.anchor[1].toFixed(1), r: '2.5', }), ); const text = svgEl('text', { class: 'geo-map-callout-label', x: String(outer), y: (y - Math.max(4, Math.round(size * 0.3))).toFixed(1), 'text-anchor': side === 'left' ? 'start' : 'end', }); text.textContent = fit(e.name); g.append(text); if (geo.view.selected.includes(e.key)) g.setAttribute('data-selected', ''); groups.push(g); }); } setChildren(layer, groups); return groups.length;}/** the lifted copies: the hovered region, and the selection when data-highlight="lift" */function drawLift(geo: GeoInstance): void { const el = dfDollar(geo.svg).closest('.geo-map').get(0) as HTMLElement | undefined; const keys = new Set<string>(); if (el?.dataset.highlight === 'lift') for (const k of geo.view.selected) keys.add(k); if (geo.hover) keys.add(geo.hover); const existing = new Map<string, SVGPathElement>(); for (const c of [...geo.layers.lift!.children]) existing.set(c.getAttribute('data-key') ?? '', c as SVGPathElement); const keep: SVGPathElement[] = []; for (const key of keys) { const source = (dfDollar(geo.svg) .find(`.geo-map-regions [data-key="${CSS.escape(key)}"], .geo-map-cells [data-key="${CSS.escape(key)}"]`) .get(0) ?? null) as unknown as SVGPathElement | null; if (!source) continue; let copy = existing.get(key); if (!copy) copy = svgEl('path', { 'data-key': key, class: 'geo-map-lifted' }); copy.setAttribute('d', source.getAttribute('d') ?? ''); for (const a of ['data-tone', 'style', 'aria-pressed']) { const value = source.getAttribute(a); if (value === null) copy.removeAttribute(a); else copy.setAttribute(a, value); } if (key.startsWith('s2:')) copy.classList.add('geo-map-cell'); keep.push(copy); } setChildren(geo.layers.lift!, keep);}/** the camera a key frames: a built-in or configured view, else the region's main body */function targetFor(geo: GeoInstance, key: string): { center: LonLat; zoom: number } { const box = geo.box.x1 > geo.box.x0 ? geo.box : { x0: 0, y0: 0, x1: geo.width, y1: geo.height }; const bounds = settings.views[key] ?? VIEWS[key]; if (bounds) return frame(boundsProxy(key, bounds), box, geo.view.projection); const cell = cellFocus({ ...geo.view, focus: key }); if (cell) return frame(s2Polygon(cell), box, geo.view.projection); const region = geo.ds?.byKey.get(key); if (region) return frame(region.properties.level === 'continent' ? region : mainBody(region), box, geo.view.projection); // a state whose chunk is not here yet: its country first, the state once it arrives const parent = geo.ds?.parents.get(key); const home = parent ? geo.ds?.byKey.get(`country:${parent}`) : undefined; geo.pendingFit = key; return home ? frame(mainBody(home), box, geo.view.projection) : { center: geo.view.center, zoom: geo.view.zoom };}/** move the camera - a flight (great-circle center, logarithmic zoom) unless motion is reduced or `animate` is false */function flyTo(el: HTMLElement, target: { center: LonLat; zoom: number }, animate = true): Promise<void> { const geo = el._geo as GeoInstance; const { d3 } = ready(); cancelFlight(geo); const from = { center: geo.view.center, zoom: geo.view.zoom }; const to = { center: [wrapLon(target.center[0]), clamp(target.center[1], -85, 85)] as LonLat, zoom: clampZoom(target.zoom), }; if (!animate || reducedMotion() || !geo.width) { geo.view.center = to.center; geo.view.zoom = to.zoom; requestRender(el); announceView(el); return Promise.resolve(); } const interpolate = d3.geoInterpolate(from.center, to.center); const distance = d3.geoDistance(from.center, to.center); const ms = clamp(420 + distance * 260 + Math.abs(Math.log2(to.zoom / from.zoom)) * 90, 450, 1400); let t0 = -1; return new Promise<void>((resolve) => { const step = (now: number): void => { // timed by the frames: the first frame's timestamp may come before performance.now() read at the start if (t0 < 0) t0 = now; const t = Math.min(1, (now - t0) / ms); const e = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2; const c = interpolate(e) as LonLat; geo.view.center = [wrapLon(c[0]), clamp(c[1], -85, 85)]; geo.view.zoom = from.zoom * (to.zoom / from.zoom) ** e; renderMap(el); if (t < 1 && geo.flight) { geo.flight.id = requestAnimationFrame(step); return; } geo.view.center = to.center; geo.view.zoom = to.zoom; geo.flight = null; renderMap(el); announceView(el); resolve(); }; geo.flight = { id: requestAnimationFrame(step), target: to, done: resolve }; });}/** stop a flight where it is (a pan, a zoom, another flight took over); its promise resolves */function cancelFlight(geo: GeoInstance): void { if (!geo.flight) return; cancelAnimationFrame(geo.flight.id); const done = geo.flight.done; geo.flight = null; done();}function announceView(el: HTMLElement): void { const geo = el._geo as GeoInstance; const v = geo.view; const key = `${v.center[0].toFixed(4)},${v.center[1].toFixed(4)},${v.zoom.toFixed(4)}`; if (key === geo.lastView) return; geo.lastView = key; // the camera came to rest (a pan, a zoom, a flight) el.dispatchEvent( new CustomEvent<GeoMapViewDetail>('geo-map-view', { detail: { center: [v.center[0], v.center[1]], zoom: v.zoom, level: levelFor(v) }, bubbles: true, }), );}// -- markup + state ---------------------------------------------------------------------------------/** set an attribute only when it says something other than the element already says (absent = the default) */function reflect(el: HTMLElement, name: string, value: string | null, fallback: string | null): void { const current = el.getAttribute(name); if ((current ?? fallback) === (value ?? fallback)) return; dfDollar(el).attr(name, value === fallback && current === null ? null : value);}/** * The markup of a state, on the live element and on render()'s detached copy: the view options, the selection * and the focus as data-* attributes. The SVG, the tooltip and the camera are the runtime's (no markup). */function applyMarkup(el: HTMLElement, name: string, config: GeoMapConfig): void { if (config.projection !== undefined) reflect(el, 'data-projection', config.projection, 'equalEarth'); if (config.level !== undefined) reflect(el, 'data-level', config.level, 'country'); if (config.palette !== undefined) reflect(el, 'data-palette', config.palette, 'atlas'); if (config.border !== undefined) reflect(el, 'data-border', config.border, 'natural'); if (config.graticule !== undefined) reflect(el, 'data-graticule', config.graticule ? null : 'false', null); if (config.callouts !== undefined) reflect(el, 'data-callouts', config.callouts ? 'true' : null, null); const selected = name === 'default' ? [] : keyList(config.selected ?? ''); const now = keyList(el.getAttribute('data-selected') ?? ''); if (selected.join(' ') !== now.join(' ')) dfDollar(el).attr('data-selected', selected.length ? selected.join(' ') : null); const focus = name === 'focused' ? config.focus || null : null; if ((el.getAttribute('data-focus') || null) !== focus) dfDollar(el).attr('data-focus', focus);}function checkConfig(config: GeoMapConfig): void { if (config.projection !== undefined && !PROJECTIONS.includes(config.projection)) throw new RangeError(`geo-map: unknown projection "${config.projection}" (${PROJECTIONS.join(', ')})`); if (config.level !== undefined && !LEVELS.includes(config.level)) throw new RangeError(`geo-map: unknown level "${config.level}" (${LEVELS.join(', ')})`); if (config.palette !== undefined && !PALETTES.includes(config.palette)) throw new RangeError(`geo-map: unknown palette "${config.palette}" (${PALETTES.join(', ')})`); if (config.border !== undefined && !BORDERS.includes(config.border)) throw new RangeError(`geo-map: unknown border "${config.border}" (${BORDERS.join(', ')})`); if (config.center !== undefined) assertLonLat(config.center); if ( config.zoom !== undefined && (!Number.isFinite(Number(config.zoom)) || Number(config.zoom) < 1 || Number(config.zoom) > 1024) ) throw new RangeError('geo-map: zoom is 1 to 1024');}/** the state's DOM work: the markup, then the view (a new focus flies there unless the config names a camera) */function triggerStateChange( el: HTMLElement, name: string, config: GeoMapConfig, incoming: GeoMapConfig,): Promise<void> { const geo = el._geo as GeoInstance; checkConfig(config); // a cell passed as the focus must name one; a focus already on the element (written from outside) is not // re-checked, so a map that holds an invalid one can still be reset if (incoming.focus?.startsWith('s2:')) s2Token(incoming.focus.slice(3)); // entered without saying what (nothing, or an empty value - the State tab's cleared field): the selection / // focus the markup declared if (name === 'focused' && !config.focus && geo.authored.focus) config = { ...config, focus: geo.authored.focus }; if (name === 'selected' && !keyList(config.selected ?? '').length) config = { ...config, selected: geo.authored.selected }; // the state the config amounts to: 'focused' without a focus is a selection (or nothing) let landed = name; if (name === 'focused' && !config.focus) landed = keyList(config.selected ?? '').length ? 'selected' : 'default'; if (name === 'selected' && !keyList(config.selected ?? '').length) landed = 'default'; applyMarkup(el, landed, config); if (landed !== name) el.dataset.stateName = landed; return geo.ready.then(() => { const before = geo.view.focus; syncView(el, false); if (geo.status !== 'ready') return undefined; // nothing drawn: the error alert stays, the attributes are the state const camera = incoming.center !== undefined || incoming.zoom !== undefined; if (camera) return flyTo( el, { center: (incoming.center as LonLat) ?? geo.view.center, zoom: Number(incoming.zoom ?? geo.view.zoom) }, false, ); if (geo.view.focus && geo.view.focus !== before) return flyTo(el, targetFor(geo, geo.view.focus)); requestRender(el); return undefined; });}/** take the element's attributes into the view; a focus that changed flies there (when `fly`) */function syncView(el: HTMLElement, fly: boolean): void { const geo = el._geo as GeoInstance | undefined; if (!geo) return; const previous = geo.view; const next = viewFromAttributes(el, { center: previous.center, zoom: previous.zoom }); const changed = JSON.stringify({ ...next, center: 0, zoom: 0 }) !== JSON.stringify({ ...previous, center: 0, zoom: 0 }); if (!changed) return; geo.view = next; if (next.selected.join(' ') !== previous.selected.join(' ')) announceSelection(el); if (next.focus !== previous.focus) { announceFocus(el); if (fly && next.focus && geo.status === 'ready') void flyTo(el, targetFor(geo, next.focus)); } requestRender(el);}function nameOfKey(geo: GeoInstance, key: string): string { return geo.ds?.byKey.get(key)?.properties.name ?? (key.startsWith('s2:') ? `S2 ${key.slice(3)}` : key);}function announceSelection(el: HTMLElement): void { const geo = el._geo as GeoInstance; const keys = [...geo.view.selected]; // the selection changed - by a click, a key or setState el.dispatchEvent( new CustomEvent<GeoMapSelectDetail>('geo-map-select', { detail: { keys, names: keys.map((k) => nameOfKey(geo, k)) }, bubbles: true, }), );}function announceFocus(el: HTMLElement): void { const geo = el._geo as GeoInstance; const key = geo.view.focus; // the focus changed: a region framed (its children shown), or the focus cleared el.dispatchEvent( new CustomEvent<GeoMapFocusDetail>('geo-map-focus', { detail: { key, name: key ? nameOfKey(geo, key) : null }, bubbles: true, }), );}/** Registry-level API; pass the element explicitly. Unknown names throw. */export const geoMapApi = componentState({ component: 'geo-map', states: geoMapStates, mergeConfig: true, apply: (el, state, _previous, incoming) => triggerStateChange(el as HTMLElement, state.name, state.config as GeoMapConfig, incoming as GeoMapConfig), // the state the attributes and the camera show now - a click or a pan changes them without setState read: (el) => { const geo = (el as HTMLElement)._geo as GeoInstance | undefined; if (!geo) return { name: (el as HTMLElement).dataset.stateName || 'default', config: {} }; const view = viewFromAttributes(el as HTMLElement, { center: geo.view.center, zoom: geo.view.zoom }); const config = configOf({ ...geo, view: { ...view, center: geo.view.center, zoom: geo.view.zoom } }); return { name: stateNameOf(view), config: config as unknown as Record<string, unknown> }; }, markup: (el, state) => applyMarkup(el as HTMLElement, state.name, state.config as GeoMapConfig),});/** commit what the user did (a pick, a focus by double click) the way setState would have written it */function userChange(el: HTMLElement, patch: { selected?: string[]; focus?: string | null }): void { const geo = el._geo as GeoInstance; const config = { ...configOf(geo), ...patch }; const name = stateNameOf({ focus: config.focus ?? null, selected: keyList(config.selected) }); if (patch.focus !== undefined && patch.focus !== geo.view.focus) { act(el, Promise.resolve(geoMapApi.setState(el, name, { selected: config.selected, focus: config.focus ?? null }))); return; } applyMarkup(el, name, config); syncView(el, true); geoMapApi.commit(el, name, configOf(geo) as unknown as Record<string, unknown>);}// -- interaction -------------------------------------------------------------------------------------function pixelOf(geo: GeoInstance, e: { clientX: number; clientY: number }): [number, number] { const r = geo.svg.getBoundingClientRect(); return [((e.clientX - r.left) * geo.width) / (r.width || 1), ((e.clientY - r.top) * geo.height) / (r.height || 1)];}function unprojectVisible(projection: any, pixel: [number, number]): LonLat | null { const { d3 } = ready(); const p = projection.invert?.(pixel); if (!p || !p.every(Number.isFinite) || Math.abs(p[1]) > 90) return null; if (!d3.geoPath(projection)({ type: 'Point', coordinates: p })) return null; const back = projection(p); if (!back || Math.hypot(back[0] - pixel[0], back[1] - pixel[1]) > 0.1) return null; return [p[0], p[1]];}function pan(el: HTMLElement, dx: number, dy: number): void { const geo = el._geo as GeoInstance; if (!geo.projection) return; cancelFlight(geo); const delta = RAD / geo.projection.scale(); geo.view.center = [wrapLon(geo.view.center[0] - dx * delta), clamp(geo.view.center[1] + dy * delta, -85, 85)]; requestRender(el);}function zoomAt(el: HTMLElement, factor: number, pixel?: [number, number]): void { const geo = el._geo as GeoInstance; if (!geo.projection) return; cancelFlight(geo); const zoom = clampZoom(geo.view.zoom * factor); let center: LonLat = [...geo.view.center]; const before = pixel ? unprojectVisible(geo.projection, pixel) : null; // keep the place under the cursor: the projections are not linear in their center, so the correction repeats for (let i = 0; pixel && before && i < 4; i++) { const after = unprojectVisible(makeProjection(geo.view.projection, geo.box, center, zoom), pixel); if (!after) break; const dLon = wrapLon(before[0] - after[0]); const dLat = before[1] - after[1]; center = [wrapLon(center[0] + dLon), clamp(center[1] + dLat, -85, 85)]; if (Math.abs(dLon) + Math.abs(dLat) < 1e-6) break; } geo.view.center = center; geo.view.zoom = zoom; requestRender(el);}function settleSoon(el: HTMLElement): void { const geo = el._geo as GeoInstance; clearTimeout(geo.restTimer); geo.restTimer = setTimeout(() => announceView(el), 160) as unknown as number;}const keyAt = (target: EventTarget | null): string | null => { if (!(target instanceof Element)) return null; const hit = dfDollar(target).closest('[data-key]').get(0) as Element | undefined; return hit && !dfDollar(hit).closest('.geo-map-context').get(0) ? hit.getAttribute('data-key') : null;};function pick(el: HTMLElement, key: string, additive: boolean): void { const geo = el._geo as GeoInstance; const mode = el.dataset.select ?? 'single'; if (mode === 'none') return; const now = geo.view.selected; let next: string[]; if (mode === 'multiple' || additive) next = now.includes(key) ? now.filter((k) => k !== key) : [...now, key]; else next = now.length === 1 && now[0] === key ? [] : [key]; geo.roving = key; userChange(el, { selected: next });}function showTooltip(geo: GeoInstance, key: string | null, pixel?: [number, number]): void { const t = geo.tooltip; if (!key) { t.hidden = true; return; } const region = geo.drawn.get(key); if (!region) return; const parent = region.properties.parent ? geo.ds?.byKey.get(`country:${region.properties.parent}`)?.properties.name : ''; dfDollar(t).text(parent ? `${region.properties.name}, ${parent}` : region.properties.name); t.hidden = false; if (pixel) { t.style.left = `${Math.min(pixel[0] + 14, geo.width - 8)}px`; t.style.top = `${pixel[1] + 12}px`; }}function setHover(el: HTMLElement, key: string | null): void { const geo = el._geo as GeoInstance; if (geo.hover === key) return; geo.hover = key; for (const c of dfDollar(geo.svg).find('.geo-map-callout').toArray() as Element[]) c.toggleAttribute('data-hover', c.getAttribute('data-key') === key); drawLift(geo); writeOutputs(el);}/** the region nearest in an arrow's direction from the roving one (by label points) */function moveRoving(el: HTMLElement, key: string, dir: [number, number]): void { const geo = el._geo as GeoInstance; const from = geo.anchors.get(key); if (!from) return; let best: string | null = null; let score = Infinity; for (const [k, a] of geo.anchors) { if (k === key) continue; const dx = a[0] - from[0]; const dy = a[1] - from[1]; const along = dx * dir[0] + dy * dir[1]; if (along <= 0) continue; const across = Math.abs(dx * dir[1] - dy * dir[0]); const s = along + across * 2; if (s < score) { score = s; best = k; } } if (!best) return; geo.roving = best; renderMap(el); const target = dfDollar(geo.svg) .find(`[data-key="${CSS.escape(best)}"][role="button"]`) .get(0) as unknown as SVGElement | undefined; target?.focus();}function bindInteractions(el: HTMLElement): void { const geo = el._geo as GeoInstance; const svg = geo.svg; const signal = geo.abort.signal; svg.addEventListener( 'pointerdown', (e) => { if (e.button !== 0) return; geo.pointers.set(e.pointerId, pixelOf(geo, e)); geo.moved = false; }, { signal }, ); svg.addEventListener( 'pointermove', (e) => { const next = pixelOf(geo, e); const previous = geo.pointers.get(e.pointerId); if (!previous) { const key = keyAt(e.target); setHover(el, key); showTooltip(geo, key, next); return; } if (Math.hypot(next[0] - previous[0], next[1] - previous[1]) < 0.5) return; if (!svg.hasPointerCapture(e.pointerId)) svg.setPointerCapture(e.pointerId); const others = [...geo.pointers.entries()].filter(([id]) => id !== e.pointerId); if (others.length) { const other = others[0]![1]; const a = Math.hypot(previous[0] - other[0], previous[1] - other[1]); const b = Math.hypot(next[0] - other[0], next[1] - other[1]); if (a > 2 && b > 2) zoomAt(el, b / a); } else pan(el, next[0] - previous[0], next[1] - previous[1]); if (Math.hypot(next[0] - previous[0], next[1] - previous[1]) > 1) geo.moved = true; geo.pointers.set(e.pointerId, next); showTooltip(geo, null); settleSoon(el); }, { signal }, ); const release = (e: PointerEvent): void => { geo.pointers.delete(e.pointerId); }; svg.addEventListener('pointerup', release, { signal }); svg.addEventListener('pointercancel', release, { signal }); svg.addEventListener('lostpointercapture', release, { signal }); svg.addEventListener( 'pointerleave', () => { setHover(el, null); showTooltip(geo, null); }, { signal }, ); svg.addEventListener( 'click', (e) => { if (geo.moved) return; const key = keyAt(e.target); if (key) pick(el, key, e.shiftKey || e.metaKey || e.ctrlKey); }, { signal }, ); svg.addEventListener( 'dblclick', (e) => { e.preventDefault(); const key = keyAt(e.target); // a region or an S2 cell: drill into it (its children); a state, a leaf cell, the background: zoom in there const drill = key && el.dataset.drill !== 'none' && !key.startsWith('state:') && !(key.startsWith('s2:') && s2Level(key.slice(3)) === 30); if (drill) userChange(el, { focus: key, selected: [] }); else zoomAt(el, 2, pixelOf(geo, e)); settleSoon(el); }, { signal }, ); svg.addEventListener( 'wheel', (e) => { e.preventDefault(); const delta = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? geo.height : 1); zoomAt(el, Math.exp(-delta * 0.0015), pixelOf(geo, e)); settleSoon(el); }, { signal, passive: false }, ); svg.addEventListener( 'keydown', (e) => { const key = keyAt(e.target); const arrows: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1], }; if (key && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); pick(el, key, e.shiftKey); return; } if (key && arrows[e.key] && !e.shiftKey) { e.preventDefault(); moveRoving(el, key, arrows[e.key]!); return; } if (e.key === 'Escape') { if (geo.view.selected.length) userChange(el, { selected: [] }); else if (geo.view.focus) act(el, focusUp(el)); return; } if (!['+', '=', '-', 'Home', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return; e.preventDefault(); if (e.key === '+' || e.key === '=') zoomAt(el, 1.5); else if (e.key === '-') zoomAt(el, 1 / 1.5); else if (e.key === 'Home') act(el, reset(el)); else { const [x, y] = arrows[e.key]!; pan(el, -x * 50, -y * 50); } settleSoon(el); }, { signal }, ); svg.addEventListener( 'focusin', (e) => { const key = keyAt(e.target); if (key) { geo.roving = key; setHover(el, key); } }, { signal }, ); svg.addEventListener('focusout', () => setHover(el, null), { signal });}// -- outside controls + outputs ------------------------------------------------------------------function writeOutputs(el: HTMLElement): void { if (!el.id) return; const geo = el._geo as GeoInstance; for (const out of dfDollar(`output[data-geo-for="${CSS.escape(el.id)}"]`).toArray() as HTMLOutputElement[]) { const kind = out.dataset.geoOutput ?? 'selection'; const v = geo.view; // the level of an S2 focus's children; a focus that names no valid cell has none (and must not throw here) const cell = cellFocus(v); const text = kind === 'focus' ? v.focus ? nameOfKey(geo, v.focus) : 'World' : kind === 'zoom' ? `${v.zoom < 10 ? v.zoom.toFixed(1) : Math.round(v.zoom)}x` : kind === 'level' ? v.focus ? v.focus.startsWith('s2:') ? cell ? `S2 level ${s2Level(cell) + 1}` : '' : v.focus.startsWith('continent:') ? 'country' : 'state' : levelFor(v) : kind === 'hover' ? geo.hover ? nameOfKey(geo, geo.hover) : '' : kind === 'center' ? `${v.center[1].toFixed(2)}°, ${v.center[0].toFixed(2)}°` : kind === 'regions' ? String(geo.stats.visibleRegions) : v.selected.map((k) => nameOfKey(geo, k)).join(', '); if (out.textContent !== text) dfDollar(out).text(text); }}const mapFor = (control: Element): HTMLElement | null => { const id = control.getAttribute('data-geo-for') ?? ''; const el = (id ? dfDollar(`#${CSS.escape(id)}`).get(0) : null) as HTMLElement | null; return el?.matches('.geo-map') && el._geo ? el : null;};/** a datalist of the best matches under a search input */async function suggest(input: HTMLInputElement, el: HTMLElement): Promise<GeoSearchHit[]> { const hits = await search(el, input.value, 12); let list = (input.list ?? null) as HTMLDataListElement | null; if (!list) { list = document.createElement('datalist'); list.id = `${el.id}-geo-names-${nextId++}`; list.setAttribute('data-ce-chrome', ''); dfDollar(input).after(list); input.setAttribute('list', list.id); } setChildren( list, hits.map((h) => { const o = document.createElement('option'); o.value = h.label; return o; }), ); return hits;}async function runSearch(input: HTMLInputElement, el: HTMLElement): Promise<void> { const value = input.value.trim(); if (!value) return; const hits = await search(el, value, 12); const hit = hits.find((h) => normalize(h.label) === normalize(value)) ?? hits[0]; input.setCustomValidity(hit ? '' : `No region named "${value}"`); input.toggleAttribute('aria-invalid', !hit); if (!hit) { input.reportValidity(); return; } await focusRegion(el, hit.key);}/** a control's action, run without a caller to await it: a failure is reported as the map's geo-map-error */function act(el: HTMLElement, action: Promise<unknown>): void { action.catch((err) => reportError(el, err, ''));}function bindControls(): void { if (document.__geoMapInit) return; document.__geoMapInit = true; dfDollar(document).on('click', (e: Event) => { const control = e.target instanceof Element ? (dfDollar(e.target).closest('[data-geo-for][data-geo-action]').get(0) as HTMLElement | undefined) : undefined; if (!control || control.tagName === 'INPUT') return; const el = mapFor(control); if (!el) return; const value = control.dataset.value ?? ''; const geo = el._geo as GeoInstance; switch (control.dataset.geoAction) { case 'zoom-in': zoomAt(el, 1.6); settleSoon(el); break; case 'zoom-out': zoomAt(el, 1 / 1.6); settleSoon(el); break; case 'reset': act(el, reset(el)); break; case 'up': act(el, focusUp(el)); break; case 'focus': act(el, focusRegion(el, value || geo.view.selected[0] || null)); break; case 'select': act(el, select(el, keyList(value))); break; case 'clear': act(el, select(el, [])); break; } }); dfDollar(document).on('change', (e: Event) => { const control = e.target instanceof HTMLElement ? e.target : null; if (!control?.matches('[data-geo-for]')) return; const el = mapFor(control); if (!el) return; if (control.matches('input[data-geo-action="search"]')) { act(el, runSearch(control as HTMLInputElement, el)); return; } const param = control.dataset.geoParam; if (!param) return; const value = control instanceof HTMLInputElement && control.type === 'checkbox' ? control.checked : (control as HTMLInputElement).value; const current = el.api!.getState(); act(el, Promise.resolve(geoMapApi.setState(el, current.name, { [param]: value }))); }); dfDollar(document).on('input', (e: Event) => { const input = e.target instanceof HTMLInputElement ? e.target : null; if (!input?.matches('[data-geo-for][data-geo-action="search"]')) return; const el = mapFor(input); if (!el) return; input.setCustomValidity(''); input.removeAttribute('aria-invalid'); act(el, suggest(input, el)); }); dfDollar(document).on('keydown', (e: Event) => { const ev = e as KeyboardEvent; const input = ev.target instanceof HTMLInputElement ? ev.target : null; if (ev.key !== 'Enter' || !input?.matches('[data-geo-for][data-geo-action="search"]')) return; const el = mapFor(input); if (!el) return; ev.preventDefault(); act(el, runSearch(input, el)); });}// -- the public map API ---------------------------------------------------------------------------const mapOf = (el: HTMLElement | string): HTMLElement => { const node = (typeof el === 'string' ? dfDollar(el).get(0) : el) as HTMLElement | undefined; if (!node?.matches?.('.geo-map') || !node._geo) throw new Error('not an initialized .geo-map'); return node;};/** * Pick regions (an empty list clears the selection); they are highlighted the way data-highlight says. * @param el - the .geo-map element, or a selector for it * @param keys - region keys: country:FRA, state:DEU-1591, continent:Europe, s2:47a1 * @returns resolves when the map shows them */function select(el: HTMLElement | string, keys: string[]): Promise<void> { const node = mapOf(el); const geo = node._geo as GeoInstance; const name = geo.view.focus ? 'focused' : keys.length ? 'selected' : 'default'; return Promise.resolve(geoMapApi.setState(node, name, { selected: keys }) as Promise<void>);}/** * Frame a region and show its children - a continent's countries, a country's states; everything else is dimmed. * An S2 cell (s2:<token>) frames the cell and shows its four children. A name works too (the best search hit). null * clears the focus. * @param el - the .geo-map element, or a selector for it * @param region - a key (continent:Europe, country:DEU, s2:479f), a name ('Germany'), or null * @returns resolves when the camera has arrived */async function focusRegion(el: HTMLElement | string, region: string | null): Promise<void> { const node = mapOf(el); const geo = node._geo as GeoInstance; await geo.ready; let key = region; if (key?.startsWith('s2:')) key = `s2:${s2Token(key.slice(3))}`; else if (key && !/^(continent|country|state):/.test(key)) key = (await search(node, key, 1))[0]?.key ?? null; if (region && !key) throw new Error(`geo-map: no region named "${region}"`); if (key?.startsWith('state:') && geo.ds && !geo.ds.parents.has(key) && !geo.ds.byKey.has(key)) await searchEntries(geo.ds); await geoMapApi.setState(node, key ? 'focused' : 'default', { focus: key, selected: [] });}/** * Step out of the focus: a state to its country, a country to its continent, a continent to the world; an S2 cell * to its parent, a cube face to the world. * @param el - the .geo-map element, or a selector for it * @returns resolves when the camera has arrived */async function focusUp(el: HTMLElement | string): Promise<void> { const node = mapOf(el); const geo = node._geo as GeoInstance; const key = geo.view.focus; if (!key) return reset(node); const cell = cellFocus(geo.view); if (cell) return s2Level(cell) ? focusRegion(node, `s2:${s2Parent(cell)}`) : reset(node); const region = geo.ds?.byKey.get(key); const parent = key.startsWith('state:') ? `country:${region?.properties.parent ?? geo.ds?.parents.get(key) ?? ''}` : key.startsWith('country:') && region?.properties.continent ? `continent:${region.properties.continent}` : null; if (parent && geo.ds?.byKey.has(parent)) return focusRegion(node, parent); return reset(node);}/** * Back to the whole world: no focus, no selection, the authored camera. * @param el - the .geo-map element, or a selector for it * @returns resolves when the camera has arrived */async function reset(el: HTMLElement | string): Promise<void> { const node = mapOf(el); const geo = node._geo as GeoInstance; await geo.ready; await geoMapApi.setState(node, 'default', {}); if (geo.status === 'ready') await flyTo(node, geo.home);}/** * Find regions by name: continents and countries at once, every state once the names index has loaded. Accents and * case are ignored; whole names rank before prefixes, prefixes before parts. * @param el - the .geo-map element, or a selector for it * @param query - what was typed * @param limit - the most hits (default 10) * @returns the hits, best first */async function search(el: HTMLElement | string, query: string, limit: number = 10): Promise<GeoSearchHit[]> { const node = mapOf(el); const geo = node._geo as GeoInstance; await geo.ready; if (!geo.ds) return []; return rank(await searchEntries(geo.ds), query, limit);}/** * Zoom by a factor around the center (2 = twice as close). * @param el - the .geo-map element, or a selector for it * @param factor - greater than 0 */function zoomBy(el: HTMLElement | string, factor: number): void { if (!Number.isFinite(factor) || factor <= 0) throw new RangeError('a positive zoom factor'); const node = mapOf(el); zoomAt(node, factor); settleSoon(node);}/** * Move the camera - flying there unless `animate` is false or motion is reduced. * @param el - the .geo-map element, or a selector for it * @param center - [longitude, latitude] * @param zoom - 1 to 1024 (default: the current zoom) * @param animate - fly (default true) * @returns resolves when the camera has arrived */async function setView( el: HTMLElement | string, center: LonLat, zoom?: number, animate: boolean = true,): Promise<void> { const node = mapOf(el); const geo = node._geo as GeoInstance; assertLonLat(center); await geo.ready; await flyTo(node, { center, zoom: zoom ?? geo.view.zoom }, animate);}/** * Replace what the map draws over its boundaries - markers, lines, S2 cells, per-region looks (each one given * replaces that layer; leave one out to keep it). * @param el - the .geo-map element, or a selector for it * @param layers - `{ markers, lines, cells, styles }` */function setLayers(el: HTMLElement | string, layers: GeoLayers): void { const node = mapOf(el); const geo = node._geo as GeoInstance; const next = { ...geo.layersData }; if (layers.markers) { for (const m of layers.markers) resolvePosition(m.position); next.markers = layers.markers.map((m) => ({ ...m })); } if (layers.lines) { for (const l of layers.lines) { if (l.coordinates) { if (l.coordinates.length < 2) throw new RangeError(`line ${l.id}: two positions at least`); l.coordinates.forEach(assertLonLat); } else if (!l.from || !l.to) throw new RangeError(`line ${l.id}: coordinates, or from and to`); } next.lines = layers.lines.map((l) => ({ ...l })); } if (layers.cells) { next.cells = layers.cells.map((c) => s2Token(c)); geo.cellRegions = next.cells.map(s2Polygon); } if (layers.styles) next.styles = { ...layers.styles }; geo.layersData = next; requestRender(node);}/** * The regions drawn now, with their label points. * @param el - the .geo-map element, or a selector for it * @returns one entry per region in view */function visible(el: HTMLElement | string): GeoVisibleRegion[] { const geo = mapOf(el)._geo as GeoInstance; return [...geo.drawn].map(([key, r]) => ({ key, name: r.properties.name, level: r.properties.level, anchor: geo.anchors.get(key) ?? null, }));}/** * A region by key, when the map has it (resident, or its chunk loaded). * @param el - the .geo-map element, or a selector for it * @param key - country:DEU, state:DEU-1591, continent:Europe * @returns the region, or null */function region(el: HTMLElement | string, key: string): GeoRegion | null { return (mapOf(el)._geo as GeoInstance).ds?.byKey.get(key) ?? null;}/** * Where a position is drawn in the last rendered view (SVG pixels), or null when it is out of view or behind the * globe. * @param el - the .geo-map element, or a selector for it * @param point - [longitude, latitude] * @returns [x, y] or null */function project(el: HTMLElement | string, point: LonLat): [number, number] | null { const geo = mapOf(el)._geo as GeoInstance; assertLonLat(point); if (!geo.projection || !ready().d3.geoPath(geo.projection)({ type: 'Point', coordinates: point })) return null; return geo.projection(point) as [number, number];}/** * The position drawn at an SVG pixel of the last rendered view, or null off the globe. * @param el - the .geo-map element, or a selector for it * @param pixel - [x, y] * @returns [longitude, latitude] or null */function unproject(el: HTMLElement | string, pixel: [number, number]): LonLat | null { const geo = mapOf(el)._geo as GeoInstance; return geo.projection ? unprojectVisible(geo.projection, pixel) : null;}/** * What the last render drew and loaded. * @param el - the .geo-map element, or a selector for it * @returns the counts and timings */function stats(el: HTMLElement | string): GeoRenderStats { return { ...(mapOf(el)._geo as GeoInstance).stats };}/** * The fill that hatches a region (an SVG pattern of the map) - for `styles: { 'country:DEU': { fill } }`. * @param el - the .geo-map element, or a selector for it * @returns a CSS url() fill */function hatch(el: HTMLElement | string): string { return (mapOf(el)._geo as GeoInstance).hatch;}df$.geoMapApi = geoMapApi;df$.geoMapStates = geoMapStates;// public imperative APIs (AGENTS.md "No window globals": df$.shadcn.geoMap, df$.shadcn.geo)df$.geoMap = { select, focus: focusRegion, up: focusUp, reset, search, zoomBy, setView, setLayers, visible, region, project, unproject, stats, hatch,};df$.geo = { load, configure, urls: GEO_VENDOR_URLS, WGS84, inverse, direct, path: geodesicPath, length: polylineLength, s2Cell, s2Id, s2Token, s2Level, s2Center, s2Vertices, s2Polygon, s2Neighbors, s2Children, s2Parent, resolvePosition, inBounds, mortonEncode, mortonDecode, mortonKey, mortonIndex, routeGraph, positionAlongRoute, decode, features, rewind,};// -- init ----------------------------------------------------------------------------------------------function layersFrom(el: HTMLElement): Required<GeoLayers> { const script = dfDollar(el).children('script.geo-map-layers').get(0) as HTMLScriptElement | undefined; const empty = { markers: [], lines: [], cells: [], styles: {} }; if (!script) return empty; const json = JSON.parse(script.textContent || '{}') as GeoLayers; return { ...empty, ...json } as Required<GeoLayers>;}function dataUrl(el: HTMLElement): string { const raw = el.dataset.src || metaContent('geo-map-data') || settings.data || releaseData(); return new URL(raw, document.baseURI).href;}function mount(el: HTMLElement): void { const camera = { center: parseCenter(el.dataset.center) ?? HOME, zoom: clampZoom(Number(el.dataset.zoom) || 1), }; // runtime chrome (data-ce-chrome): the HTML Preview Editor never writes it into a source const svg = svgEl('svg', { 'data-ce-chrome': '', class: 'geo-map-svg', role: 'group', tabindex: '0', 'aria-roledescription': 'map', 'aria-label': el.getAttribute('aria-label') || 'Interactive map. Arrow keys pan, plus and minus zoom, Home resets.', }); const pattern = svgEl('pattern', { id: `geo-map-hatch-${nextId++}`, width: '8', height: '8', patternUnits: 'userSpaceOnUse', }); pattern.append(svgEl('path', { d: 'M-2,2L2,-2M0,8L8,0M6,10L10,6', class: 'geo-map-hatch' })); const defs = svgEl('defs'); defs.append(pattern); svg.append(defs); const layers: Record<string, SVGGElement> = {}; for (const name of ['sphere', 'graticule', 'context', 'regions', 'cells', 'lines', 'lift', 'markers', 'callouts']) { layers[name] = svgEl('g', { class: `geo-map-${name}` }); if (['sphere', 'graticule', 'context', 'lift', 'lines'].includes(name)) layers[name]!.setAttribute('aria-hidden', 'true'); svg.append(layers[name]!); } const tooltip = document.createElement('output'); tooltip.className = 'geo-map-tooltip'; tooltip.setAttribute('data-ce-chrome', ''); tooltip.hidden = true; tooltip.setAttribute('aria-hidden', 'true'); let layersData: Required<GeoLayers>; let layerError = ''; try { layersData = layersFrom(el); } catch (err) { layersData = { markers: [], lines: [], cells: [], styles: {} }; layerError = `script.geo-map-layers is no valid JSON: ${err instanceof Error ? err.message : err}`; } const geo: GeoInstance = { view: viewFromAttributes(el, camera), ds: null, status: 'loading', ready: Promise.resolve(), svg, layers, tooltip, hatch: `url(#${pattern.id})`, width: el.clientWidth || 800, height: el.clientHeight || 480, projection: null, box: { x0: 0, y0: 0, x1: 0, y1: 0 }, frameId: 0, flight: null, pendingFit: null, pointers: new Map(), moved: false, drawn: new Map(), anchors: new Map(), hover: null, roving: null, layersData, cellRegions: [], wanted: new Set(), stats: { visibleRegions: 0, contextRegions: 0, visibleCells: 0, visibleMarkers: 0, callouts: 0, renderMs: 0, cachedChunks: 0, loadedBytes: 0, loading: 0, }, abort: new AbortController(), resize: new ResizeObserver((entries) => { const r = entries[0]!.contentRect; if (r.width > 0 && r.height > 0 && (r.width !== geo.width || r.height !== geo.height)) { geo.width = r.width; geo.height = r.height; requestRender(el); } }), attrs: new MutationObserver(() => syncView(el, true)), lastView: '', home: camera, authored: { focus: el.dataset.focus || null, selected: keyList(el.dataset.selected ?? '') }, restTimer: 0, }; el._geo = geo; dfDollar(el).append(svg).append(tooltip); el.dataset.geoStatus = 'loading'; dfDollar(el).attr('aria-busy', 'true'); geo.ready = (async () => { try { await load(); try { layersData.cells = layersData.cells.map((c) => s2Token(c)); geo.cellRegions = layersData.cells.map(s2Polygon); for (const m of layersData.markers) resolvePosition(m.position); } catch (err) { layersData.cells = []; layersData.markers = layersData.markers.filter((m) => { try { resolvePosition(m.position); return true; } catch { return false; } }); layerError ||= `script.geo-map-layers: ${err instanceof Error ? err.message : err}`; } geo.ds = await openDataset(dataUrl(el)); geo.ds.maps.add(el); geo.status = 'ready'; el.dataset.geoStatus = 'ready'; // a focused map without an authored camera opens framed on its focus if (geo.view.focus && !el.dataset.center && !el.dataset.zoom) { geo.box = { x0: 0, y0: 0, x1: geo.width, y1: geo.height }; const target = targetFor(geo, geo.view.focus); geo.view.center = target.center; geo.view.zoom = target.zoom; } renderMap(el); announceView(el); if (layerError) { // the layers block is broken: the map draws without what it could not read, and says so dfDollar(el).append( dfDollar('<output class="geo-map-error" role="alert" data-ce-chrome></output>').text(layerError), ); reportError(el, new Error(layerError), ''); } } catch (err) { geo.status = 'error'; el.dataset.geoStatus = 'error'; dfDollar(el).attr('aria-busy', null); const message = err instanceof Error ? err.message : String(err); dfDollar(el).children('.geo-map-error').remove(); dfDollar(el).append( dfDollar('<output class="geo-map-error" role="alert" data-ce-chrome></output>').text( `The map could not be loaded. ${message}`, ), ); reportError(el, err, ''); } })(); geo.resize.observe(el); geo.attrs.observe(el, { attributes: true, attributeFilter: [ 'data-projection', 'data-level', 'data-palette', 'data-border', 'data-graticule', 'data-callouts', 'data-selected', 'data-focus', 'data-highlight', ], }); bindInteractions(el);}function init(): void { (dfDollar('.geo-map:not([data-init])').toArray() as HTMLElement[]).forEach((el) => { el.dataset.init = ''; bindControls(); mount(el); const geo = el._geo as GeoInstance; // el.store + el.api (AGENTS.md "State through stores"): the state the attributes declare const name = stateNameOf(geo.view); bindComponent(el, geoMapApi, { name, config: configOf(geo) as unknown as Record<string, unknown> }); // the first state change waits for the engines and the data: el.api.settled() resolves once the map is drawn void geoMapApi.setState(el, name, {}); });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub