import * as React from "react"
import DottedMap from "dotted-map"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
/**
* The land, as a lattice of dots. 60 rows of a diagonal grid is fine enough for
* a coastline to read at card width and coarse enough to stay a stipple rather
* than a photograph.
*/
const GRID_HEIGHT = 60
/** How wide a dot is drawn, in grid units — a little under the 1-unit spacing. */
const DOT_WIDTH = 0.62
/** The largest marker, in grid units, and the floor a small one never drops under. */
const MAX_RADIUS = 3.4
const MIN_RADIUS = 0.9
/** The ring that keeps two overlapping markers apart, in grid units. */
const MARKER_RING = 0.35
const DEFAULT_FORMAT = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })
type Land = { path: string; width: number; height: number; project: DottedMap }
// Built on first use and kept, because laying out the grid costs a couple of
// hundred milliseconds: on a server that is once per process, and a page that
// never draws a map never pays it at all.
let land: Land | undefined
/**
* The land dots as one path rather than three thousand circles.
*
* A subpath that is a lone `moveto` is drawn as a filled dot when the stroke has
* round caps, which is what turns the whole lattice into a single element: the
* markup stays a few kilobytes and the DOM stays one node deep, where a circle
* per dot is three thousand nodes on every page that shows a map.
*/
function landDots(): Land {
if (land) return land
const map = new DottedMap({ height: GRID_HEIGHT, grid: "diagonal" })
const path = map
.getPoints()
.map((point) => `M${round(point.x)} ${round(point.y)}h0`)
.join("")
land = { path, width: map.image.width, height: map.image.height, project: map }
return land
}
/** One decimal is a fiftieth of a dot's width — invisible, and a third of the bytes. */
function round(value: number): number {
return Math.round(value * 10) / 10
}
export type WorldMapMarker = {
id: string
lat: number
lng: number
value: number
label: string
}
export type WorldMapProps = React.ComponentProps<"div"> & {
markers: WorldMapMarker[]
valueFormatter?: (n: number) => string
/**
* The height of the map's box in pixels; the map keeps its own proportions
* inside it. A height class in `className` — `h-44`, `max-sm:h-44` — wins
* over it, so a page can give the map another box at a breakpoint.
*/
height?: number
}
/**
* A dotted world map with a proportional marker at each place.
*
* Area carries the value, so the radius goes as its square root — a country
* twice another's traffic draws a marker twice the area, not twice the width,
* which is the one scaling a reader estimates correctly. A marker under the
* floor would be a speck nobody can point at, so the smallest is still a dot.
*
* The map itself is decoration for a screen reader — it is one `role="img"` with
* a summary — and every marker is repeated underneath as text, so the numbers
* are readable without a pointer. Each marker also carries a `<title>`, which is
* the tooltip a browser shows on hover without any JavaScript at all.
*/
function WorldMap({
className,
style,
markers,
valueFormatter = DEFAULT_FORMAT,
height = 280,
"aria-label": ariaLabel,
...props
}: WorldMapProps) {
const { path, width, height: rows, project } = landDots()
// A marker is placeable only when it has a measurable value *and* a point
// the projection can plot — a latitude the projection cannot place, a pole
// under Mercator, has no point on this map, and leaving it out beats
// drawing it at the edge. Computed once, so the drawing, the summary and
// the text rows can never name a different set of markers than each other.
const placeable = markers.flatMap((marker) => {
if (!Number.isFinite(marker.value)) return []
const pin = project.getPin({ lat: marker.lat, lng: marker.lng })
if (!pin) return []
return [{ marker, x: round(pin.x), y: round(pin.y) }]
})
const values = placeable.map(({ marker }) => marker.value)
const peak = values.length > 0 ? Math.max(...values) : 0
const summary =
ariaLabel ??
(values.length === 0
? "World map with no markers."
: `World map with ${placeable.length} marker${placeable.length === 1 ? "" : "s"}, ${valueFormatter(
Math.min(...values)
)} to ${valueFormatter(peak)}.`)
const placed = placeable.map(({ marker, x, y }) => {
const share = peak > 0 ? Math.sqrt(Math.max(0, marker.value) / peak) : 0
return { marker, x, y, r: Math.max(MIN_RADIUS, MAX_RADIUS * share) }
})
return (
<div
data-slot="world-map"
// The height travels as a custom property the class reads, not as an
// inline height no class could outrank.
className={cn("h-(--world-map-height) w-full", className)}
style={{ "--world-map-height": `${height}px`, ...style } as React.CSSProperties}
{...props}
>
<svg
role="img"
aria-label={summary}
viewBox={`0 0 ${width} ${rows}`}
preserveAspectRatio="xMidYMid meet"
className="h-full w-full"
>
<path
data-slot="world-map-land"
d={path}
fill="none"
stroke="var(--muted-foreground)"
strokeWidth={DOT_WIDTH}
strokeLinecap="round"
/>
<g data-slot="world-map-markers">
{placed.map(({ marker, x, y, r }) => (
<circle
key={marker.id}
data-slot="world-map-marker"
cx={x}
cy={y}
r={round(r)}
fill="var(--brand)"
// Ringed in the surface it sits on, so two markers that touch
// still read as two.
stroke="var(--chart-surface, var(--card))"
strokeWidth={MARKER_RING}
>
<title>{`${marker.label}: ${valueFormatter(marker.value)}`}</title>
</circle>
))}
</g>
</svg>
{/* The same rows as text: a map a reader cannot see is still a table. */}
<ul data-slot="world-map-data" aria-label="World map data" className="sr-only">
{placeable.map(({ marker }) => (
<li key={marker.id}>{`${marker.label}: ${valueFormatter(marker.value)}`}</li>
))}
</ul>
</div>
)
}
export { WorldMap }