Skip to content
ctzn-geo

Component catalog

29 entries: 12 forms, 7 interactions and 10 supporting components. 25 are shipped and 4 are blocked on data this site does not have. Each blocked entry lists the data it would need, and none is built against invented inputs.

Each page in the sidebar makes one argument with one or two of these components. This index lists the components themselves, for developers reusing them: what each shows and hides, what it needs as input, how it behaves when data is missing, and its known limits.

The small pictures are diagrams of each form and carry no data. Each entry links to a page that draws the component live.

Section

Status

Forms

How a value becomes a mark on the page.

Choropleth

Shipped

GeoMap · form="choropleth"

One fill per polygon, coloured by a classified value. The default form, and the one every other form on this site is an argument about.

Shows
Where a rate is high and low, at whatever resolution the boundaries were drawn at.
Hides
How many people each colour stands for. A polygon's area is its visual weight, and area is not population, which is what `/elections` and `/cartogram` are both about.

Minimal usage

<GeoMap
  tiles={{ url, sourceLayer: "tract", joinKey: "geoid" }}
  bounds={bounds}
  dark={dark}
  form="choropleth"
  colors={colors}
  noDataColor={noDataFor(dark)}
  label="Median household income by census tract, New York, choropleth"
/>

Input contract

PropTypeNote
tiles · requiredGeoMapTilesPMTiles url, source layer, join key. Construction-scope: a change refits and rebuilds layers.
bounds · required[w, s, e, n]The subject extent. Fitted once, and again only when this or the tile identity changes, never on resize.
colorsRecord<string, string>geoid → fill. A geoid absent here takes `noDataColor`, which is how absence stays distinct from a low value.
noDataColor · requiredstringRequired, not defaulted: a map with no declared absence colour would paint absence as whatever the ramp's zero happens to be.
highlightreadonly string[]Author's marks, usually the extremes. State in the caption how they were chosen.
selectedreadonly string[]The reader's selection, controlled. Distinct from `highlight` and from `hovered`.
cameraGeoMapCamera | nullSupplying this suppresses the refit and takes down `constrainToSubject`'s walls; a map that stops following gets them back without a refit.
labelstringThe map's accessible name. Names the subject, not the canvas.

Compatible with

Every dataset: the three cities, Europe, US counties, the world, and presidential returns with their history. Any measure the dataset declares; intensive measures are the honest case, and `areaBiasCaveat` says so for extensive ones.

Data it requires

A polygon tileset with a stable join key, and a flat geoid → value map. Geometry and values stay separate; the paint is a `match` expression on the join property, not a feature-state join.

How it behaves when things are missing

Loading`useScopedData` holds the previous scope's values as `stale` so the map dims rather than blanking. See `/europe` mid-level-change.
Failed requestAn explicit failed-request panel with retry, never a no-data map. `/europe` and `/cartogram` both show it.
No featuresA tileset with no features renders the basemap and the legend reports zero classified features.
Missing valuesFour cases kept apart: zero population, sparse, suppressed, unmatched. `/suppression` is the page about it.
Light and dark`basemapStyle(dark)` is rebuilt and `setStyle` swapped; the ramp is resolved per theme by `lib/scale.ts`.
Narrow screenFills its container; the explorer stacks its controls. Checked at 320px.

Accessibility

`role="region"` with a real `aria-label` naming measure, geography, place and form. Tooltips are click-to-pin with Escape and focus return (`FeatureInspector`), so the facts are reachable without hover. The canvas is not a tab stop and does not trap navigation.

Known limits

Exact Fisher–Jenks5000Above this many valid values the classifier runs on a deterministic sample; the work is quadratic in the sample. JENKS_EXACT_LIMIT
Paint expressionone `match` per repaint3,144 counties is a 6,288-element expression. Measured fine; nationwide tracts are not a claim this repo has tested.

Proportional symbols

Shipped

DotMap · GeoMap form="symbols"

One circle per geography, area proportional to an extensive quantity and colour to a rate. Not dot density.

Shows
Quantity, at last: a circle's area is the count, so a large empty county is small and a dense small one is large.
Hides
Boundaries, and anything under an overlap. Collisions hide dense places, which is the same bias the choropleth had, inverted. `/cartogram` is the answer to that.

Minimal usage

<GeoMap
  tiles={{ url: pointsUrl("us-county", 2020), sourceLayer: "county" }}
  form="symbols"
  radii={radii}
  minRadius={0}
  circleStroke={{ width: 1, color: "rgba(4,5,7,0.9)" }}
  colors={colors}
  noDataColor={noDataFor(dark)}
/>

Input contract

PropTypeNote
radiiRecord<string, number>geoid → radius in px at the scope's fitted zoom. Screen-constant unless `symbolZoomScale` opts in.
minRadiusnumberZero means a feature absent from `radii` draws no circle, the honest mark for an unknown quantity, rather than the dot a zero would get.
circleStroke{ width, color }The base ring. Selection and controlled hover override it with a thicker one, because a `line` layer draws nothing over points.
symbolZoomScalebooleanOff by default. When on, the anchor is the scope's fitted zoom, not the zoom a repaint happened to run at.

Compatible with

Any dataset with a point tileset and an extensive quantity: presidential (votes cast), city (population). Disabled above 400 features by `formEligible`, which counts valid observations rather than polygons.

Data it requires

A representative-point tileset paired with the polygon one, plus an extensive count. A rate alone cannot size a circle; multiplying a rounded percentage by population to recover a count is ruled out.

How it behaves when things are missing

LoadingNot applicable: handed its data by the explorer above it.
Failed requestNot applicable: handed its data by the explorer above it.
No featuresNo radii means no circles, and the size key says so rather than drawing an empty frame.
Missing valuesAn unknown quantity draws nothing. A zero quantity draws the minimum circle, and `SizeLegend` discloses the floor as well as the cap.
Light and darkStroke colour flips with the theme so a light circle on a light basemap keeps its edge.
Narrow screenRadii are in screen pixels, so a narrow panel shows the same circles over less ground: denser, not smaller.

Accessibility

Both channels in the tooltip: the coloured measure and the sized one. A symbol map whose tooltip reports only the colour leaves the larger of two circles unquantifiable, which is the one thing size was added to say.

Known limits

Symbol form disabled above400 featuresPast this the circles are more overlap than signal. `formEligible` explains the refusal rather than hiding the control. SYMBOL_LIMIT
Size cap98th percentileSo one Los Angeles does not set a scale that renders every rural county invisible. Capped features are counted and disclosed.

Value-by-alpha

Shipped

alphaScale · AlphaLegend

A choropleth whose opacity carries population, so a sparsely populated polygon fades toward the basemap.

Shows
Rate and weight at once, without leaving a huge empty county shouting at full strength.
Hides
The faded end. Low opacity is hard to read against any basemap, and an unknown weight is not a low one; it is absent, and drawn as absent.

Minimal usage

const scale = alphaScale(weights, { mode: "rank" });
<GeoMap opacities={scale.opacities} colors={colors} … />
<AlphaLegend scale={scale} fill={midStop} canvas={canvas} label="residents" format={fmt} />

Input contract

PropTypeNote
scale · requiredAlphaScaleFrom `alphaScale(weights, { mode })`. Ties rank together, so two equal populations get equal opacity, which they used not to.
mode · required"rank" | "absolute"Rank spreads the channel evenly; absolute keeps ratios. The key names which, because they answer different questions.
fill · requiredstringA representative ramp stop, so the key's swatches composite the way the map does.
canvas · requiredstringThe map's background. Without it the key shows opacity over white and the map shows it over the basemap.

Compatible with

Any dataset carrying a population or other weight beside the measure. City and national both do.

Data it requires

A weight per feature, of the same vintage as the measure. An absent weight is absent, never floored to zero, which would read as an observed emptiness.

How it behaves when things are missing

LoadingNot applicable: handed its data by the explorer above it.
Failed requestNot applicable: handed its data by the explorer above it.
No featuresNo valid weights means the scale reports so and the caller falls back to a flat opacity rather than painting everything invisible.
Missing valuesAn unknown weight is left out of `opacities` entirely and takes the no-data fill, rather than the most transparent one.
Light and darkThe key composites against the theme's own canvas colour, so it matches the map in both.
Narrow screenA three-stop key, laid out inline.

Accessibility

`describeAlpha` writes the key's sentence beside the arithmetic, including whether the weighting is by rank or absolute and what the extremes are. Opacity is never the only channel: the colour still carries the measure.

Known limits

Legibility floorthe key states itBelow the lowest stop a fill is not reliably distinguishable from the basemap; the key shows the stop rather than implying a continuum to zero.

Bivariate choropleth

Shipped

BivariateChoropleth

Two measures, nine colours, 3×3 tertiles. The one form here that cannot be half-read without its key.

Shows
Where two measures agree and where they part; the corners of the key are the interesting places.
Hides
Both marginals. A cell says a feature is in the top third on each axis and nothing about where inside that third it sits, or how wide the third is.

Minimal usage

const summary = bivariateSummary(rows, { x: "income", y: "education" });
<BivariateChoropleth summary={summary} palette="teal-pink" onCell={setActive} … />
<BivariateLegend summary={summary} selected={cell} onSelect={setCell} … />

Input contract

PropTypeNote
summary · requiredBivariateSummaryCuts, occupancy counts, per-cell ranges and the three missingness counts. Computed once and shared with the key.
palette · requiredBivariateIdThree, one of which is deliberately bad (two greens), kept as the counterexample.
onCell(cell) => voidMap-to-key. The other direction is `BivariateLegend.onSelect`; both are needed or the key is decoration.

Compatible with

Any dataset with two measures at the same level and vintage. Refused below 20 features, where tertiles stop meaning anything. Raw population is not offered as an axis: it is extensive, and a bivariate map of it is an area-bias exhibit rather than a relationship.

Data it requires

Two measures, same features, same vintage. The cut basis is a choice the page exposes: each axis's own available values, or complete pairs only. They give different cuts and the page says which is in force.

How it behaves when things are missing

LoadingScope-keyed through `useScopedData`; new geometry never paints against the previous scope's values.
Failed requestExplicit, with retry. This was the explorer that committed a payload without its scope.
No featuresBelow 20 features the form is refused with a visible reason, not a disabled button with a title attribute.
Missing valuesSplit three ways (missing X, missing Y, missing both) and counted. Collapsing them was the defect this replaced.
Light and darkNine swatches resolved per theme; the key and the map use one resolver.
Narrow screenThe rotated key has an upright table alternative, which is also the accessible representation.

Accessibility

Key cells are buttons, keyboard-selectable, with counts and ranges in their accessible names. The rotated 3×3 has an unrotated table beside it, because a diamond of divs is not a control.

Known limits

Minimum features20Nine cells out of fewer than twenty features is noise with a key attached. BIVARIATE_MIN_FEATURES
Equal thirdsderived, not promisedTies prevent exact thirds. The occupancy counts are shown so the reader sees what the cuts actually did.

Small multiples

Shipped

FacetGrid

One panel per category on a shared geography, deliberately without map chrome.

Shows
How several shares of one population are distributed, compared panel to panel under a stated domain policy.
Hides
Whatever the scale mode clips, and the fact that a share map of a small population is mostly sampling noise.

Minimal usage

const scales = facetScales(panels, { mode: "pooled-p98" });
<FacetGrid panels={panels} scales={scales} … />

Input contract

PropTypeNote
scales · requiredFacetScalesFrom `facetScales(panels, { mode })`. Four named modes, each with an explicit domain policy.
mode · requiredFacetScaleModeshared-full, pooled-p98, per-panel, or composition-0-100. `describeFacetScale` writes the caption.

Compatible with

Any dataset with several comparable measures at one level. Composition mode is only honest when the panels are shares of one whole.

Data it requires

Several measures over the same features, at the same vintage. Shares that do not sum to one should not use the 0–100% composition domain.

How it behaves when things are missing

LoadingPanels mount as they scroll into view, so the page is readable before the last one has drawn.
Failed requestNot applicable: handed its data by the explorer above it.
No features`describeEmptyPanel` says why a panel is blank: no valid values is a different statement from all values zero.
Missing valuesClipped observations are counted per panel and named in the caption; the upper endpoint is labelled as a clip, not as a maximum.
Light and darkPanels share one resolver with the rest of the page; `FacetGrid` used to interpolate privately in gamma-encoded sRGB.
Narrow screenGrid collapses to one column; each panel keeps its own caption.

Accessibility

A summary table carries the same numbers as the panels, and an 'inspect this panel' action replaces five competing sets of map controls. Panels are noninteractive by design and are not tab stops.

Known limits

Concurrent WebGL contexts~16 in ChromePast it the browser destroys the oldest context silently. Five panels is safe; the catalog you are reading mounts none.
Default clip98th percentileCounted and captioned. `shared-full` offers the uncapped comparison.

Dorling cartogram

Shipped

DorlingCartogram

Circles sized by an extensive quantity and pushed apart to reduce overlap, with the pairs that still overlap counted and reported.

Shows
Quantity with nothing hidden under anything else, and how far each place had to move to achieve that.
Hides
Position, shape and adjacency, completely. A displaced circle is at no longitude and latitude at all.

Minimal usage

<DorlingCartogram
  data={data}
  coordinates={coordinates}
  breaks={breaks}
  fixedSizes={referenceCycleVotes}
  sizeLabel="votes cast"
  valueLabel="Democratic two-party share"
/>

Input contract

PropTypeNote
coordinates · requiredRecord<string, [lon, lat]>Every feature's representative point, all at once. A layout cannot start until the whole table has arrived.
fixedSizesRecord<string, number | null>The reference period's quantities, so stepping through time recolours without rearranging. Quantities rather than radii, because radii are pixels the caller cannot compute.
breaks · requiredBreaks | nullComputed on the full data, as everywhere. Held to the reference period so a colour change is a change in the data.
showOriginsbooleanTicks at each circle's true position. On by default: the displacement is the cost of the form and should be visible.

Compatible with

Presidential returns today. Any dataset with a point coordinate table, an extensive quantity and a rate.

Data it requires

A flat `{ geoid: [lon, lat] }` table, not a point tileset. A tileset serves the tiles a viewport asked for; a layout needs every coordinate before it draws anything. The county table is published beside the county tiles.

How it behaves when things are missing

Loading`useScopedData` reports it while the coordinate table is in flight.
Failed requestA failed request for the coordinate table shows an error panel with a retry, worded apart from an unreported county and from a withheld estimate.
No featuresNo coordinates means no panel. The solver is never run against a partial table, because a cartogram of the counties that happen to be present is a picture of nothing.
Missing valuesA feature with no quantity is dropped and counted, never drawn at radius zero. `describeLayout` says how many.
Light and darkSVG, resolved per theme like the rest.
Narrow screenA fixed viewBox scaled to the container, so the layout does not re-solve on resize.

Accessibility

The whole panel is one `role="img"` whose accessible name is `describeLayout`: circle count, the cap, the largest displacement in kilometres, how many exceed the cap, how many were dropped, and how many pairs still overlap. Reduced motion needs nothing: the layout never animates, because it never morphs.

Known limits

Solver budget200 iterationsBounded work. `Layout.remainingOverlaps` says whether it was enough, separately from whether it converged.
Gravity0Measured, not preferred: attraction reaches a stable equilibrium in which circles still overlap: 49 pairs at gravity 0.05 against none at zero.

Projection distortion panel

Shipped

ProjectionPanel

A graticule shaded by how much a projection inflates or shrinks each patch of ground.

Shows
Area distortion everywhere, with a number, rather than at the two or three countries a reader has opinions about.
Hides
The data. These panels carry no measure at all, only the frame a measure would be painted into.

Minimal usage

<ProjectionPanel projection="equal-earth" dark={dark} marker={at} onHoverCell={setAt} />
<ProjectionLegend dark={dark} />

Input contract

PropTypeNote
projection · requiredProjectionIdmercator, equal-earth, or lambert-cea. The third is the anti-exhibit: also exactly equal-area, and hideous.
marker[lon, lat] | nullA position on the sphere, shared across panels. Not a camera: a shared centre and zoom mean different things in two projections.

Compatible with

None; it is not pointed at data. It sits beside a world map and shares a pointer position with it.

Data it requires

No geometry at all. Country polygons on this site are vector tiles with no coordinate table for a second renderer, so the panels draw what can be projected exactly: the graticule. That limit is stated on the page rather than worked around.

How it behaves when things are missing

LoadingNone: closed-form maths, no fetch.
Failed requestNone.
No featuresNone.
Missing valuesNot applicable; there are no observations to be missing.
Light and darkA diverging ramp resolved per theme, centred on true area.
Narrow screenThree panels stack to one column.

Accessibility

Each panel is a labelled figure with the factor at the marked latitude in text. The ramp's ticks are rounded strings; a raw `2 ** 1.5` used to print seventeen digits and lose hydration.

Known limits

Equal-area claimverified numericallyCell areas are compared against the exact spherical integral, not asserted from the projection's name.
Ramp endsfloors, not maximaMercator near its 85° cut-off exceeds 100× true area, far beyond where the colour stops changing.

Boundary-vintage mismatch

Shipped

VintageMap

Current values drawn on matched boundaries and on deliberately mismatched ones, side by side.

Shows
That a join landing is not the same as a join being right. Shared IDs across vintages do not prove unchanged geometry.
Hides
It is not a change map. Nothing here is a value over time; both panels carry the same estimates.

Minimal usage

<VintageMap vintage={2010} values={values} … />

Input contract

PropTypeNote
vintage · required2010 | 2020Which boundary year to paint onto. 2010 is the anti-exhibit and is labelled as one.

Compatible with

City tracts, where both vintages exist.

Data it requires

Two boundary vintages of one geography plus one set of values, and the join counts between them. A harmonised longitudinal comparison needs more than this and is not offered.

How it behaves when things are missing

LoadingNot applicable: handed its data by the explorer above it.
Failed requestNot applicable: handed its data by the explorer above it.
No featuresThe join-health table reports zero matches rather than an empty map.
Missing valuesMatched, unmatched-left and unmatched-right are counted separately in the join-health table.
Light and darkShared with the rest.
Narrow screenSplit-screen stacks; the join-health table scrolls.

Accessibility

The join-health table is the accessible route to the same finding, and reads as a table rather than as a colour difference.

Known limits

Deliberately wrongone modeThe mismatch mode is an anti-exhibit and is explicitly labelled. It must never become a default.

Temporal choropleth

Shipped

HistoryChoropleth

Forty cycles on one geometry and one fixed scale, scrubbable, with pinned sparklines and an explicit delta view.

Shows
That the map inverts. Fixed cuts are what make two years comparable at all.
Hides
Counties that did not yet exist, which are absent rather than zero, and the fact that one geometry across 156 years is itself an approximation.

Minimal usage

<HistoryChoropleth cycle={1964} baseline={1960} mode="delta" … />

Input contract

PropTypeNote
cycle · requirednumberOne of the forty actual cycles. Never interpolated: there is no election in 1965.
baselinenumber | nullTurns the map into a difference in percentage points, with the units stated.

Compatible with

Presidential history only.

Data it requires

Returns at real election years on one geometry, with fixed thresholds declared. Interpolating between cycles would manufacture results.

How it behaves when things are missing

LoadingOne data file for all forty cycles, inlined; no per-cycle request.
Failed requestNot applicable: handed its data by the explorer above it.
No featuresA cycle with no returns for a county leaves it absent.
Missing valuesA missing historical observation breaks the sparkline rather than becoming a zero.
Light and darkFixed thresholds, theme-resolved swatches.
Narrow screenPrev/next buttons and the range slider both work; playback is paused by default and honours reduced motion.

Accessibility

Previous/next controls beside the slider, so scrubbing is not a drag-only gesture. Playback never starts on its own. The camera and the pinned selection survive a time change.

Known limits

Cycles40 (1868–2024)The geometry is one modern vintage throughout; the page says what that costs.

Uncertainty-aware choropleth

Blocked on data

UncertaintyChoropleth (not built)

Estimate beside its reliability. Contract specified; not built, because this repo has no margins of error.

Shows
Would show that a precise-looking boundary does not imply a precise estimate.
Hides
Any threshold-based masking hides substantively important places, and the threshold is an editorial choice rather than a fact.
Not built, so there is no live page.

Minimal usage

Not built. The data it would need is listed under Data it requires.

Compatible with

None yet. Would apply to any dataset whose ingestion carried the contract below; the ACS city and national datasets are the plausible first ones.

Data it requires

Estimate, margin of error, confidence level, universe/denominator and provenance per observation. Existing flat city rows carry no estimate/MoE contract; ingestion changes are a prerequisite. MoE propagation for derived percentages must be defined before those are enabled.

How it behaves when things are missing

Loadingn/a
Failed requestn/a
No featuresn/a
Missing valuesThe contract's central requirement: sampling uncertainty, suppression, missing joins and zero population stay four distinct things. Population-weighted opacity is shipped and is *not* uncertainty.
Light and darkn/a
Narrow screenn/a

Accessibility

n/a: not built.

Origin–destination flow explorer

Blocked on data

FlowMap (not built)

Directed flows from a selected origin. Contract specified; no vetted flows table here.

Shows
Would show that places are connected, not just coloured.
Hides
Thresholds, overlap and bundling suppress small or geographically close connections. Arcs are schematic, not observed routes.
Not built, so there is no live page.

Minimal usage

Not built. The data it would need is listed under Data it requires.

Compatible with

None yet. Would need a relational adapter of its own; no existing dataset describes a flows table.

Data it requires

Origin id, destination id, magnitude, period and status, with endpoint coordinates in a separate lookup. `MapFamily` does not describe a flows table. Net flow is not total movement.

How it behaves when things are missing

Loadingn/a
Failed requestn/a
No featuresn/a
Missing valuesn/a
Light and darkn/a
Narrow screenn/a

Accessibility

n/a: not built.

Deterministic dot-density map

Blocked on data

DotDensityMap (not built)

Category dots inside each polygon. Contract specified; this repo has shares, not counts.

Shows
Would show composition without polygon area dominating it.
Hides
Within-polygon placement is synthetic and must never read as individual addresses.
Not built, so there is no live page.

Minimal usage

Not built. The data it would need is listed under Data it requires.

Compatible with

None yet. Would start at one city and one category composition, not a build matrix of every measure and dot weight.

Data it requires

Preserved extensive category counts at ingestion. The contract rules out recovering them by multiplying rounded percentages by population, which is the only thing the current race-share rows would allow.

How it behaves when things are missing

Loadingn/a
Failed requestn/a
No featuresn/a
Missing valuesn/a
Light and darkn/a
Narrow screenn/a

Accessibility

n/a: not built.

Interactions

What a reader can do across marks, and across views.

Synchronized comparison

Shipped

CompareMaps

Two panels, one camera, and explicit locks for measure, cuts, palette and time.

Shows
One representational decision in isolation. Whatever differs on screen is the decision, because everything else is locked.
Hides
Nothing it can help, but a shared legend is refused unless the cut points match, so it cannot imply comparability it has not got.

Minimal usage

<CompareMaps
  a={{ id: "a", label: "Quantiles", spec: specA, render: (w) => <Choropleth {...w} /> }}
  b={{ id: "b", label: "Equal interval", spec: specB, render: (w) => <Choropleth {...w} /> }}
  locked={locked}
  onLockedChange={setLocked}
/>

Input contract

PropTypeNote
a, b · requiredComparePanelEach carries an `EncodingSpec` and a render prop. A render prop rather than a GeoMap config, because the panels are whole wrappers with their own settings and absence vocabularies.
locked · requiredbooleanCamera lock. Whichever panel the reader last touched leads; the other follows, so dragging either side works.
extraLocks{ id, label, locked, onToggle, note }[]The page's own locks (measure, cuts, palette, time), declared here so a reader sees one list of what is tied together.

Compatible with

Any two panels whose bounds and projection are compatible; `camerasCompatible` refuses the rest rather than mirroring nonsense.

Data it requires

None beyond what the two panels already need. `sameEncoding` compares the full specification; a matching scheme id is not enough, because two quantile maps over different data have different cut points.

How it behaves when things are missing

LoadingEach panel loads independently; the lock survives.
Failed requestA failed panel shows its own error; the other keeps working.
No featuresTwo empty panels are still aligned.
Missing valuesPer panel, in each panel's own vocabulary; the datasets are not unified.
Light and darkBoth panels swap together.
Narrow screenStacks vertically; swipe mode is offered but is not the only interface.

Accessibility

Locks are buttons with pressed state and a note saying what differs when unlocked. Focus and viewport survive swapping sides. Keyboard-operable throughout; swipe is an alternative, not a requirement.

Known limits

Echo suppressionin `GeoMap`A naive mirror oscillates: A reports, B jumps, B's jump reports back differing in the last bit, and the pair judders for as long as the mouse is held.
PanelstwoTwo WebGL contexts. A grid of comparisons would hit the context limit.

Linked map, scatterplot and table

Shipped

LinkedExplorer · Scatterplot · FeatureTable

One scope-qualified selection across four surfaces, with a correlation that reconciles its own counts.

Shows
Which places are off the trend, and that the trend itself moves when the level does.
Hides
Nothing about individuals. An area-level correlation is not individual-level evidence and not causation, and the caption says so every time.

Minimal usage

const r = correlate(pairs, { weighting: "observations" });
<Scatterplot points={points} selected={selected} onBrush={setSelected} … />
<FeatureTable rows={rows} selected={selected} onHover={setHovered} … />

Input contract

PropTypeNote
selected · requiredReadonlySet<string>Scope-qualified keys from `lib/feature-key.ts`, such as `city/nyc/tract/2020#36061000202`. A tract id must not carry into an unrelated layer.
onBrush(keys) => voidDrag to brush, Shift-drag to add, click empty space to clear. The same gestures in the plot and on the map.
onHover(key | null) => voidTable-row hover outlines the polygon through `GeoMap.hovered`, without the pointer entering the map.

Compatible with

Two measures at one level in one dataset. Changing level clears the selection, because the keys no longer refer to anything.

Data it requires

Two measures on the same features plus an optional weight for the per-resident correlation. The complete-pair count and the excluded count are both shown with any r.

How it behaves when things are missing

LoadingScope-keyed; the table and plot empty together with the map.
Failed requestOne error for the scope, not three.
No featuresZero complete pairs reports no correlation rather than an r of zero.
Missing valuesExcluded pairs are counted and split by which axis was missing, with a note that suppression is not random.
Light and darkPlot, table and map share one resolver.
Narrow screenThe table is the narrow-screen route to the same facts; the plot stacks under the map.

Accessibility

The table is the keyboard and non-map route to the same selection, because thousands of polygons are not individual tab stops. Click-to-pin, Escape to clear, focus returns to the trigger.

Known limits

Table rowsvirtualised by page2,324 tracts render as pages rather than as one list.
CorrelationPearson rWeighting by observations or by people, labelled either way. They answer different questions and can disagree.

Pinned feature inspector

Shipped

FeatureInspector

Click-to-pin facts about one feature, with Escape and focus return.

Shows
Name, stable key, every value, the weight, the source, the vintage and the absence reason: the things a hover tooltip has no room for.
Hides
Nothing, which is the point of having it.

Minimal usage

<FeatureInspector
  title={name}
  subtitle={`${levelLabel} ${key} · ${place}`}
  facts={facts}
  onClear={() => setSelected(null)}
  returnFocusTo={mapRef}
/>

Input contract

PropTypeNote
facts · requiredInspectorFact[]Label, value, and whether the value is absent, so an absence prints its reason rather than an em dash.
onClear · required() => voidBound to the Clear button and to Escape.
returnFocusToRefObject<HTMLElement>Where focus goes on dismiss. Without it a keyboard reader is dropped at the top of the document.

Compatible with

Any dataset; the facts are the caller's.

Data it requires

Not applicable: handed its data by the explorer above it.

How it behaves when things are missing

LoadingNot applicable: handed its data by the explorer above it.
Failed requestNot applicable: handed its data by the explorer above it.
No featuresNothing pinned renders nothing, rather than an empty panel holding space.
Missing valuesAn absent fact prints the dataset's own absence label and the reason, never a blank.
Light and darkShared.
Narrow screenFull width under the map, which is where it is most useful.

Accessibility

This *is* the accessibility answer for map hover. Escape dismisses, focus returns to the trigger, and the facts are ordinary text a screen reader reads in order.

Known limits

Pinned featuresone, or a setAdditive selection pins many; the inspector then summarises rather than listing thousands.

Edge-aware tooltip

Shipped

MapTooltip

A tooltip measured against its container and clamped to stay in frame.

Shows
One or two facts at the pointer, without leaving the figure.
Hides
Everything the inspector shows. A tooltip is a pointer affordance and cannot be the only route to a fact.

Minimal usage

<MapTooltip x={hover.x} y={hover.y} container={frameRef}>…</MapTooltip>

Input contract

PropTypeNote
container · requiredRefObject<HTMLElement>The frame to stay inside. Seven wrappers previously used a bare `x + 12` / `y + 12`, which walks off the edge near any corner.
gap, edgenumberOffset from the pointer, and the minimum margin from the frame.

Compatible with

Any figure with a positioned container.

Data it requires

Not applicable: handed its data by the explorer above it.

How it behaves when things are missing

LoadingNot applicable: handed its data by the explorer above it.
Failed requestNot applicable: handed its data by the explorer above it.
No featuresNo hover, no tooltip.
Missing valuesPrints the dataset's absence label, not an empty line.
Light and darkShared.
Narrow screenClamping matters most here: at 320px a tooltip near the right edge has nowhere to go and flips instead.

Accessibility

`aria-hidden` and deliberately so: it is a pointer affordance, and the same facts are reachable through the inspector and the table. Meaning is never carried by the tooltip alone.

Known limits

Placementmeasured, not guessedPure, and tested against a zero-size frame, which used to produce NaN.

Shareable example state

Shipped

useUrlState · ShareLink

Versioned encoding of a curated configuration into the URL, with per-field fallbacks.

Shows
That a claim about a map can be linked to, so an argument about a choice can be checked rather than described.
Hides
Nothing, but an old or invalid link falls back per field and says which fields it could not honour.

Minimal usage

const [state, setState] = useUrlState(defaults);
// ?e=1~city:nyc~level:tract~measure:income~scheme:quintile

Input contract

PropTypeNote
STATE_VERSION · requirednumberBumped when the encoding changes. A link from an older version decodes what it can and reports the rest.
decodeState · required(query) => DecodeResultReturns the state *and* the problems, so the page can list what it ignored instead of silently substituting.

Compatible with

Every explorer that has adopted it.

Data it requires

None. Validation is against available valid observations, not only geometry feature counts, which is the defect `lib/eligibility.ts` fixed.

How it behaves when things are missing

LoadingThe URL is read before the first paint, so a shared link opens on its example rather than on the default.
Failed requestAn unparseable query falls back to defaults and lists the problem.
No featuresNo query is the curated default.
Missing valuesAn id that no longer exists is reported by name, not dropped.
Light and darkNot encoded: the theme is the reader's, not the author's.
Narrow screenA copy button rather than a text field to select from.

Accessibility

The share control is a button with live confirmation. Link problems are listed as text, not as a disabled control with a title attribute.

Known limits

Encodingversioned, v1Decoding is per field, so one unknown key does not discard a whole link. STATE_VERSION

Coverage and extent inspector

Shipped

coverageOf · region frames

What the default frame leaves out, counted, with region buttons to reach it.

Shows
That a map's frame is an editorial choice. Alaska is a sixth of the country's land and 30 of its 3,144 county-equivalents.
Hides
Nothing it can help, but the region frames are at different scales and the disclosure says so; comparing two framings without being told is being misled by the frame.

Minimal usage

const coverage = coverageOf(rows, REGIONS);
<p>{describeCoverage(coverage, measureLabel)}</p>

Input contract

PropTypeNote
regions · requiredCoverageRegion[]Each with its own bounds, an `includes` predicate and an optional scale note.
rows · requiredRecord<string, boolean>Whether each feature has a value. Counts values, not features. The two differ and the difference is the finding.

Compatible with

Any dataset whose data covers more than its default frame. US counties and the world both do; the world's computed coverage can disagree with the declared one, and says so.

Data it requires

The data file itself. A feature the data file has never heard of and a feature with no value are both grey and are counted separately.

How it behaves when things are missing

LoadingNot applicable: handed its data by the explorer above it.
Failed requestNot applicable: handed its data by the explorer above it.
No featuresA region with no features in the data says so rather than reporting zero coverage.
Missing valuesUnmatched and unreported are separate columns in the join-health table.
Light and darkText and a table.
Narrow screenThe table scrolls; the frame buttons wrap.

Accessibility

Frame buttons are a labelled group with pressed state and feature counts in their names. The coverage sentence is text, and the table carries the same numbers.

Known limits

Inset scalesdifferent, and disclosedAlaska at the lower 48's zoom does not fit in a 768px panel.

Boundary overlay and relationship inspector

Blocked on data

BoundaryOverlay (not built)

Multiple boundary layers with explicit containment semantics. Contract specified; no validated crosswalk data here.

Shows
Would make incompatible administrative cuts tangible.
Hides
A line overlay does not establish district-level values and does not explain why a boundary was drawn.
Not built, so there is no live page.

Minimal usage

Not built. The data it would need is listed under Data it requires.

Compatible with

None yet. Would apply to the city dataset first, where community and council districts cut across tracts.

Data it requires

Additional boundaries plus validated relationship or crosswalk data, each overlay identified by URL, source layer and vintage. Never average tract medians into a district median.

How it behaves when things are missing

Loadingn/a
Failed requestn/a
No featuresn/a
Missing valuesn/a
Light and darkn/a
Narrow screenn/a

Accessibility

n/a: not built.

Known limits

PrerequisitemetReference-source replacement now works, which was the blocker on extending `reference` into a scoped collection.

Supporting components

What a form needs in order to be read: the keys, the distributions, and the contracts underneath them.

The map lifecycle

Shipped

GeoMap

One MapLibre/PMTiles lifecycle under every map wrapper on the site: sources, layers, theme swaps, refits, constraints, camera and selection.

Shows
Nothing by itself; it is what every form above is built on.
Hides
Its own complexity, deliberately. The contract is documented on the component because there is no browser test harness in this repo to enforce it.

Minimal usage

<GeoMap tiles={tiles} bounds={bounds} dark={dark} noDataColor={c} label="…" />

Input contract

PropTypeNote
(construction scope) · requiredtiles, bounds, projectionRead at construction; later changes are handled by the style-swap and refit effects rather than by tearing the map down.
(refit scope) · requiredtiles.url | sourceLayer | joinKey | boundsA change here refits the camera and recomputes `constrainToSubject`. Neither identity fires on resize.
onHover, onSelectcallbacksHeld in refs and bound once. MapLibre tracks enter/leave per listener, so rebinding mid-hover loses the leave: a tooltip that will not dismiss.

Compatible with

Every dataset. Every map wrapper on the site is built on it.

Data it requires

A PMTiles archive with a stable join key. Painting is a `match` expression on the join property, not a feature-state join.

How it behaves when things are missing

LoadingTile and style failures surface separately from value-fetch errors, because they are different failures.
Failed requestA failed style or tileset is not a map full of no-data colour.
No featuresBasemap with attribution, and the caller's legend reporting zero.
Missing values`noDataColor` is a required prop for exactly this reason.
Light and dark`setStyle` with a `styleEpoch` bump and a retry, because `setProjection` throws until the style resolves and `load` is not late enough.
Narrow screen`ResizeObserver` resizes and never refits, because a container change must not throw away the reader's pan.

Accessibility

`role="region"` with a caller-supplied `aria-label` naming the subject. Attribution is always on for basemap-bearing maps and is not collapsible away.

Known limits

Lifecycle test matrixabsentThe test harness has no jsdom, testing-library or headless browser. The two scope identities are pure functions in `lib/map-scope.ts`, where they are tested, and the rest of the lifecycle is not.
Pan boundsnone past a full turnA 360° longitude range measures zero degrees wide after MapLibre wraps it, and the view-projection matrix goes singular.

Classification legend

Shipped

legendModel · Legend

A legend built from the classifier's actual edges, counts and weights, not from the scheme's name.

Shows
What each swatch means, how many features are in it, and whether the classification was weighted.
Hides
Nothing, now. It used to hide the scheme: five of the 9 schemes fell through to 'Continuous, linear'.

Minimal usage

const model = legendModel(breaks, swatchesFor, 24, requestedBins);

Input contract

PropTypeNote
breaks · requiredBreaksRead for `counts` and `weights` rather than recounted, because recounting would be a second implementation of the tie rule, which is the bug class being removed.
requestednumberThe bins asked for, so the legend can say when the classifier returned fewer.

Compatible with

All 9 schemes, 3–7 bins.

Data it requires

Not applicable: handed its data by the explorer above it.

How it behaves when things are missing

LoadingCleared or explicitly marked while a new scope loads, so an old legend never labels new geometry.
Failed requestHidden, with the error shown instead.
No featuresReports zero classified features rather than drawing empty swatches.
Missing valuesAbsence counts are part of the model, in the dataset's own vocabulary.
Light and darkOne resolver for map, legend and strip, so a value is the same colour on all three.
Narrow screenSwatches shrink; labels alternate.

Accessibility

`describeBreaks` writes the sentence beside the arithmetic. Ties, reduced class counts and log transforms are all named rather than implied.

Known limits

Continuousa gradient, not swatches`breaks.edges.length + 1 || 6` gave continuous legends *one* swatch, because `0 + 1` is `1` and `1` is not falsy.

Size key

Shipped

SizeLegend

Three reference circles, drawn with the map's own rule, plus the cap and the floor.

Shows
What a circle's area means, at the size the map draws it.
Hides
Nothing, but it discloses that both ends are compressed: above the cap and below the floor, two different quantities get the same circle.

Minimal usage

<SizeLegend cap={cap} capNote={note} maxRadius={20} minRadius={2} format={fmt} label="votes cast" />

Input contract

PropTypeNote
cap · requirednumberPassed in rather than recomputed, so the key and the map agree by construction.
maxRadius, minRadius · requirednumberBoth, because the floor is a disclosure too: `format(floorAt)` names the quantity below which every circle is identical.

Compatible with

Any symbol map.

Data it requires

Not applicable: handed its data by the explorer above it.

How it behaves when things are missing

LoadingNot applicable: handed its data by the explorer above it.
Failed requestNot applicable: handed its data by the explorer above it.
No featuresNo cap means no key, rather than a key for nothing.
Missing valuesDistinguishes zero size from unknown size in its note: an unknown quantity draws no circle at all.
Light and darkCircles use the map's fill and stroke.
Narrow screenInline, wrapping.

Accessibility

The reference quantities are text beside each circle. Reading the key does not require judging circle areas by eye, which people do badly.

Known limits

Zoomscreen-constant by defaultA static key beside circles that grow with zoom is wrong at every zoom but one.

Bivariate key

Shipped

BivariateLegend

A rotated 3×3 key that is also a control, with an unrotated table beside it.

Shows
What each of nine colours means, how many features are in each cell, and the range of each cell on both axes.
Hides
Nothing, but the rotation is decorative and the table is the real representation.

Minimal usage

<BivariateLegend summary={summary} selected={cell} onSelect={setCell} … />
<BivariateTable summary={summary} selected={cell} onSelect={setCell} … />

Input contract

PropTypeNote
onSelect(cell | null) => voidKey-to-map. Passing the selected cell again clears it. Without this the cells were noninteractive divs and the interaction was one-way.
active[x, y] | nullThe cell under the pointer on the map: map-to-key, which is the other direction.

Compatible with

The three bivariate palettes.

Data it requires

Not applicable: handed its data by the explorer above it.

How it behaves when things are missing

LoadingHidden with the map.
Failed requestHidden with the map.
No featuresA cell with no features is drawn and labelled empty, not omitted, because an omitted cell would imply the colour is impossible.
Missing valuesMissing-X, missing-Y and missing-both are counted beside the key, not folded into one number.
Light and darkNine swatches per theme.
Narrow screenThe table is the narrow-screen representation.

Accessibility

Cells are buttons with counts and ranges in their accessible names, reachable by keyboard and touch. The table is the non-rotated equivalent, and is not a fallback: it is offered at every size.

Known limits

Cells3×3Nine is already at the limit of what a reader can hold. More cells is a better-resolved key nobody can use.

Distribution strip

Shipped

DistributionStrip

A histogram with the classifier's cut lines drawn on it, coloured by the same resolver as the map.

Shows
What the classification did to the actual distribution, which is the only way to see that quantiles and equal interval disagree.
Hides
That a histogram bin spanning a cut is an aggregate approximation, which the caption states.

Minimal usage

<DistributionStrip breaks={breaks} ramp={ramp} dark={dark} format={fmt} label="Income" />

Input contract

PropTypeNote
breaks · requiredBreaksThe same object the map painted from, so the cut lines are the map's cuts.
ramp · requiredRampIdResolved through `lib/scale.ts`. It used to colour continuous histograms by linear position instead, which disagreed with the map.

Compatible with

Any classified measure.

Data it requires

Not applicable: handed its data by the explorer above it.

How it behaves when things are missing

LoadingCleared with the legend.
Failed requestHidden.
No featuresReports no valid values rather than drawing a flat strip.
Missing valuesFeatures without a value are excluded and counted in the caption.
Light and darkOne resolver with the map and legend.
Narrow screenFull width; the axis labels reduce to the two endpoints.

Accessibility

An accessible description naming the range, the feature count, the excluded count and where the cuts fall.

Known limits

Tie rule`binOf`'s `>=`It used to run its own `>` loop, so a value exactly on a cut landed in a different bin here than on the map.

Classifier

Shipped

computeBreaks

9 schemes, 3–7 bins, one tie rule, and exact Fisher–Jenks under a declared limit.

Shows
The cut points, the counts, and the weight sums, as data.
Hides
Nothing, but `quintile_pop` will fall back to feature quantiles when weights are missing, and the label now says so rather than continuing to promise equal numbers of people.

Minimal usage

const breaks = computeBreaks(values, "quintile", { bins: 5, weights, midpoint });

Input contract

PropTypeNote
scheme · requiredSchemeIdquantile, quintile_pop, equal, jenks, stddev, tails, topquartile, fixed, continuous.
bins3–7Supported by the API throughout; a legend says when the classifier returned fewer than asked.
weights(number | null)[]For population-weighted quantiles. Invalid or missing weights trigger a disclosed fallback, not a silent one.

Compatible with

Every dataset and measure.

Data it requires

Values with sentinels already normalised at ingestion. A finite sentinel is not rejected by every classifier, so it has to be handled upstream.

How it behaves when things are missing

LoadingNot applicable.
Failed requestNot applicable: invalid input returns a `Breaks` that says what it could not do.
No featuresNo valid values returns a null classification rather than throwing.
Missing valuesNulls and non-finite values are excluded and counted.
Light and darkNot applicable: no colour here at all.
Narrow screenNot applicable.

Accessibility

`describeBreaks` is the accessible sentence, and it is here rather than in a legend component so nothing can render a different claim.

Known limits

Exact Fisher–Jenks5000Above it, a deterministic sample. The work is quadratic in the sample and runs synchronously. JENKS_EXACT_LIMIT
Bins3–7Variable-bin schemes only; fixed thresholds and continuous have their own rules.

Colour scale

Shipped

sampleRamp · rampSwatches

One colour resolver for map, legend and strip, interpolating in linear light.

Shows
That a continuous scheme is continuous. Intermediate inputs produce intermediate colours and endpoints match.
Hides
Nothing, and the interpolation space is documented in the file rather than assumed from a palette's name.

Minimal usage

sampleRamp(stops, t);   // continuous
rampSteps(stops, n);    // discrete classes

Input contract

PropTypeNote
mix · required(a, b, t) => stringInterpolates in linear light, not gamma-encoded sRGB. `FacetGrid` was doing the latter privately.
contrast · required(a, b) => numberSo palette direction and contrast are tested rather than assumed from a familiar name.

Compatible with

Every palette, both themes.

Data it requires

None. Pure, React-free, MapLibre-free.

How it behaves when things are missing

LoadingNot applicable.
Failed requestNot applicable.
No featuresA constant domain stays valid rather than dividing by a zero range.
Missing valuesAbsence is the caller's `noDataColor`, never a ramp position.
Light and darkStops are per theme; the same `t` resolves to the theme's own colour.
Narrow screenNot applicable.

Accessibility

Contrast is measurable here, which is what makes 'this palette is readable in both themes' a test rather than an opinion.

Known limits

Roundingremoved`rampAt` was documented 'interpolate the ramp continuously' and did `Math.round` to the nearest of six stops, so `continuous` painted six bands.

Form and scheme eligibility

Shipped

formEligible

Pure rules for which forms, schemes and palettes a configuration may use, with the reason attached.

Shows
Why a control is unavailable, as a sentence, in the same place the control is.
Hides
Nothing. A refusal without a reason was the defect.

Minimal usage

const verdict = formEligible("symbols", { features, observations, intensive });

Input contract

PropTypeNote
Capability · required{ features, observations, … }Both counts, kept separate. Every guard used to check the polygon count where the observation count was meant, erring toward letting a scheme through.
Verdict · required{ ok, reason }The reason is the UI's copy, so a disabled control and a standalone validation cannot disagree.

Compatible with

Every dataset, via `normalizeConfig`.

Data it requires

The counts, not the data.

How it behaves when things are missing

LoadingAn unknown count is not zero: eligibility is withheld rather than refused.
Failed requestNot applicable.
No featuresZero valid observations refuses every scheme, with that as the reason.
Missing valuesCounted as observations, which is the whole point of the two-count split.
Light and darkNot applicable.
Narrow screenNot applicable.

Accessibility

The reason is rendered as visible text, not as a `title` attribute on a disabled button, which is unreachable by keyboard and by touch.

Known limits

Symbols> 400 featuresRefused above this. SYMBOL_LIMIT
Bivariate< 20 featuresRefused below this. BIVARIATE_MIN_FEATURES

Absence vocabulary

Shipped

absenceOf

Four distinct reasons a feature has no value, and the rule that none of them is zero.

Shows
Which kind of nothing this is: no residents, too few to report, withheld by the publisher, or a join that did not land.
Hides
It refuses to infer more than the input supports. A missing row is not evidence of a publisher's decision.

Minimal usage

const reason = absenceOf(row, { population });  // "unpopulated" | "sparse" | "suppressed" | "unmatched"

Input contract

PropTypeNote
SPARSE_POPULATION · requirednumberExported. 50 is an editorial threshold and not a published Census rule, so the UI discloses the number it used.
(per-dataset vocabularies) · requiredper datasetDeliberately not unified. `europe/lib/nuts.ts` keeps its own `unpublished`, because dataset-specific semantics are a requirement rather than duplication.

Compatible with

City and national. Europe and the election datasets keep their own.

Data it requires

Population beside the measure, and the ability to tell a missing row from a present row with a missing value.

How it behaves when things are missing

LoadingNot applicable.
Failed requestNot applicable.
No featuresNot applicable.
Missing valuesThis *is* the missing-data contract.
Light and darkNot applicable.
Narrow screenNot applicable.

Accessibility

The label and the tooltip read from one function, so they cannot disagree; a legend once said 'No residents' beside a tooltip printing `pop. 31`.

Known limits

Sparse threshold50Any population from 1 to 49 used to be labelled 'No residents'. It is now `sparse`, and the number is shown. SPARSE_POPULATION

Editorial figure frame

Shipped

Section · ShowsHides

The composition every page uses: eyebrow, argument, collapsible controls, figure, and a shows/hides pair.

Shows
That the shows/hides caption is structural rather than a habit. A form without one is visibly incomplete.
Hides
Nothing of its own, but it makes hiding easy to do accidentally, because a `figure` of `null` is allowed and a thin shows/hides pair still renders.

Minimal usage

<Section eyebrow="…" title="…" controls={controls} figure={<Figure />}>
  <p>The argument.</p>
</Section>

Input contract

PropTypeNote
figure · requiredReactNodeRequired, and `null` is allowed for a section whose claim is carried by its controls, but the decision has to be made.
controlsOpenbooleanExpanded suits a page where choosing is the point; collapsed suits one with a chosen default.

Compatible with

Every page on the site, and every entry in this catalog.

Data it requires

None. It composes; it reads no data and imposes no dataset vocabulary on what it wraps.

How it behaves when things are missing

LoadingThe frame renders; the figure reports its own state.
Failed requestSame.
No featuresSame.
Missing valuesSame.
Light and darkShared tokens.
Narrow screenThe shows/hides pair stacks from two columns to one.

Accessibility

The controls are a `<details>` with a real summary, so they are reachable and announced. Headings are ordered.