{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "geo-choropleth",
  "title": "Country values",
  "description": "Values per country as dot density: land dots switch on in an ordered-dither pattern, and a change rebuilds each country's print dot by dot.",
  "registryDependencies": [
    "https://elements.lumesec.ai/r/lumesec-theme.json",
    "https://elements.lumesec.ai/r/lumesec-canvas.json",
    "https://elements.lumesec.ai/r/lumesec-color.json",
    "https://elements.lumesec.ai/r/lumesec-geo.json",
    "https://elements.lumesec.ai/r/lumesec-geo-countries.json",
    "https://elements.lumesec.ai/r/lumesec-geo-data.json",
    "https://elements.lumesec.ai/r/lumesec-math.json",
    "https://elements.lumesec.ai/r/lumesec-motion.json",
    "https://elements.lumesec.ai/r/lumesec-roll-text.json",
    "https://elements.lumesec.ai/r/lumesec-use-controllable-state.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/components/lumesec/geo-choropleth.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { localPoint, snapCenter, snapSize, useCanvas, type CanvasSurface } from \"@/lib/lumesec/canvas\";\nimport { usePalette } from \"@/lib/lumesec/color\";\nimport {\n  ARROW_DIRECTIONS,\n  BAYER4,\n  bayer4,\n  countryIndex,\n  countryMembership,\n  countryOutline,\n  createDotBatch,\n  createProjection,\n  createScreenPoint,\n  cubicBezier,\n  GEO_TOKENS,\n  geoInk,\n  mapLattice,\n  projectionAspect,\n  projectionOutlineSvgPath,\n  spatialNavigate,\n  wrapLongitude,\n  type DotBatch,\n  type GeoInk,\n  type MapProjection,\n} from \"@/lib/lumesec/geo\";\nimport { COUNTRIES, countryAt } from \"@/lib/lumesec/geo-countries\";\nimport { formatCompact, quantile } from \"@/lib/lumesec/geo-data\";\nimport { clamp, hash, rgba, smoothstep } from \"@/lib/lumesec/math\";\nimport { prefersReducedMotion, springStep, useAnimationLoop, useLazyRef, useReducedMotion } from \"@/lib/lumesec/motion\";\nimport { RollText } from \"@/lib/lumesec/roll-text\";\nimport { useControllableState } from \"@/lib/lumesec/use-controllable-state\";\nimport { cn } from \"@/lib/utils\";\n\n/** One record: an ISO 3166-1 alpha-2 country code and its value. */\nexport interface CountryValue {\n  /** ISO 3166-1 alpha-2 code, for example `DE`. Case does not matter; Kosovo is `XK`. */\n  readonly code: string;\n  /** The value for the country. Non-finite values are skipped. */\n  readonly value: number;\n}\n\n/** How values are split into density classes. */\nexport type GeoChoroplethScale = \"quantile\" | \"linear\" | \"log\";\n\n/** The flat projections the map can use. */\nexport type GeoChoroplethProjection = \"equalEarth\" | \"equirectangular\";\n\n/** Props of `GeoChoropleth`. Other `div` props go to the root. */\nexport interface GeoChoroplethProps extends Omit<React.ComponentProps<\"div\">, \"defaultValue\"> {\n  /**\n   * Values per country. Without it the map shows demo values for 41 countries. An empty array shows the plain base and\n   * \"No data\". Unknown codes are skipped with one development warning; a code listed twice keeps its last value.\n   */\n  data?: readonly CountryValue[];\n  /** The selected country code (controlled), or null. Use with `onValueChange`. */\n  value?: string | null;\n  /** The country selected at first when uncontrolled. */\n  defaultValue?: string | null;\n  /** Called with the upper-case code when a country is selected, and with null when the selection is cleared. */\n  onValueChange?: (value: string | null) => void;\n  /** Number of density classes, 3 to 7. Class `k` of `n` lights `(k + 1) / n` of a country's dots. */\n  classes?: number;\n  /** How values are split into classes: equal counts per class, equal value steps, or equal steps of the logarithm. */\n  scale?: GeoChoroplethScale;\n  /** Formats values in the list, the legend, the hover chip and the hidden table. */\n  format?: (value: number) => string;\n  /** Unit shown after values in the hover chip and the hidden table, and above the list. */\n  unit?: string;\n  /** Rows in the ranked list. 0 hides the list. */\n  listSize?: number;\n  /** The map projection. */\n  projection?: GeoChoroplethProjection;\n  /** Accessible name of the map, also the caption of the hidden table. */\n  label?: string;\n}\n\n/**\n * Values per country as dot density. Each country's land dots switch on in a 4×4 ordered-dither pattern, so a higher\n * class reads as a denser print; countries without data stay as the plain base. When the data changes, every country\n * that moves to another class rebuilds its print dot by dot, outward from its middle, with a bright front where dots\n * switch. Pointing at a country runs a short light around its border and rolls its value in a chip, and the ranked\n * list beside the map follows. Small countries that get fewer than three dots are drawn as a 2×2 cluster at their\n * label point, so their values stay visible and can be pointed at.\n *\n * @example\n * <GeoChoropleth\n *   label=\"Requests by country\"\n *   unit=\"requests\"\n *   data={[{ code: \"US\", value: 412000 }, { code: \"DE\", value: 168000 }, { code: \"JP\", value: 118000 }]}\n *   onValueChange={(code) => setCountry(code)}\n * />\n */\nexport function GeoChoropleth({\n  data,\n  value,\n  defaultValue = null,\n  onValueChange,\n  classes = 5,\n  scale = \"quantile\",\n  format = formatCompact,\n  unit,\n  listSize = 6,\n  projection = \"equalEarth\",\n  label = \"Values by country\",\n  className,\n  ...props\n}: GeoChoroplethProps) {\n  const [selected, setSelected] = useControllableState<string | null>({\n    value: value === undefined ? undefined : normalizeCode(value),\n    defaultValue: normalizeCode(defaultValue),\n    onChange: onValueChange,\n  });\n  const reducedMotion = useReducedMotion();\n  const baseId = React.useId();\n  const classCount = Number.isFinite(classes) ? clamp(Math.round(classes), 3, 7) : 5;\n  const rowCount = Number.isFinite(listSize) ? clamp(Math.round(listSize), 0, 50) : 6;\n  const records = data ?? DEMO_DATA;\n  const model = React.useMemo(() => buildModel(records, classCount, scale), [records, classCount, scale]);\n  const { entries } = model;\n  const texts = entries.map((entry) => format(entry.value));\n  const textOf = (entry: Entry) => texts[entry.rank - 1];\n  const withUnit = (text: string) => (unit ? `${text} ${unit}` : text);\n\n  /** The country under the pointer, the keyboard focus on the map, or the list row in focus. */\n  const [active, setActive] = React.useState<ActiveCountry | null>(null);\n  /** Bumped when a country is selected, so its border light runs again even when it was already lit. */\n  const [trace, setTrace] = React.useState(0);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  const activeEntry = active ? (model.byCode.get(active.code) ?? null) : null;\n  const selectedEntry = selected ? (model.byCode.get(selected) ?? null) : null;\n  const shownEntry = activeEntry ?? selectedEntry;\n\n  // The chip keeps its last country while it fades out.\n  const [chipCode, setChipCode] = React.useState<string | null>(shownEntry?.code ?? null);\n  if (shownEntry && shownEntry.code !== chipCode) setChipCode(shownEntry.code);\n  const chipEntry = chipCode ? (model.byCode.get(chipCode) ?? null) : null;\n\n  const rows = entries.slice(0, rowCount);\n  // the country in focus gets its own row under the top rows when it is not among them\n  const extraEntry = rowCount > 0 && shownEntry && shownEntry.rank > rowCount ? shownEntry : null;\n  const options = extraEntry ? [...rows, extraEntry] : rows;\n  const showList = rowCount > 0;\n\n  const mapRef = React.useRef<HTMLDivElement>(null);\n  const dotsRef = React.useRef<HTMLCanvasElement>(null);\n  const lightRef = React.useRef<HTMLCanvasElement>(null);\n  const chipRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  // created on first use, for the first projection; it holds the state of every country\n  const engineRef = useLazyRef(() => createEngine(projection));\n\n  const aspect = projectionAspect(projection, LAT_RANGE);\n  const outlinePath = React.useMemo(() => outlineSvgPath(projection), [projection]);\n\n  const loop = useAnimationLoop(mapRef, step);\n  const palette = usePalette(mapRef, GEO_TOKENS, () => {\n    // the loop re-reads the ink and repaints everything\n    engineRef.current.inkStale = true;\n    loop.kick();\n  });\n\n  // New data, classes or scale: every country whose level changes rebuilds its print. Declared before the canvases,\n  // so the first lattice is laid out for the data and the first data dithers in from nothing.\n  React.useEffect(() => {\n    const state = engineRef.current;\n    setTargets(state, model);\n    const relayout = state.scene !== null && state.scene.codesKey !== model.codesKey;\n    if (state.scene && relayout) {\n      state.scene = layoutDots(state.scene.base, state.hasData, state.scene);\n      state.fullPaint = true;\n    }\n    scheduleTransitions(state, prefersReducedMotion(), relayout);\n    state.overlayDirty = true;\n    loop.kick();\n  }, [model, engineRef, loop]);\n\n  const dots = useCanvas(\n    dotsRef,\n    (surface) => {\n      rebuildScene(engineRef.current, surface);\n      if (dotsRef.current) dotsRef.current.dataset.ready = \"\";\n      loop.kick();\n    },\n    { maxDpr: 2 },\n  );\n  const light = useCanvas(\n    lightRef,\n    () => {\n      engineRef.current.overlayDirty = true;\n      loop.kick();\n    },\n    { maxDpr: 2 },\n  );\n\n  // Another projection needs another lattice.\n  React.useEffect(() => {\n    const state = engineRef.current;\n    if (state.projection === projection) return;\n    state.projection = projection;\n    const surface = dots.current;\n    if (!surface) return;\n    rebuildScene(state, surface);\n    loop.kick();\n  }, [projection, dots, engineRef, loop]);\n\n  // Unknown codes are skipped; say so once per data set while developing.\n  const unknownKey = model.unknown.join(\",\");\n  const warned = React.useRef(\"\");\n  React.useEffect(() => {\n    if (!unknownKey || warned.current === unknownKey || process.env.NODE_ENV === \"production\") return;\n    warned.current = unknownKey;\n    console.warn(`GeoChoropleth: skipped unknown country codes: ${unknownKey.replaceAll(\",\", \", \")}.`);\n  }, [unknownKey]);\n\n  // The shown country gets the border light, the static outline and the chip.\n  const shownIndex = shownEntry ? shownEntry.index : -1;\n  const activeIndex = activeEntry ? activeEntry.index : -1;\n  const selectedIndex = selectedEntry ? selectedEntry.index : -1;\n  const chipAtPointer = active?.source === \"pointer\" && activeEntry !== null;\n  React.useEffect(() => {\n    const state = engineRef.current;\n    const reduced = prefersReducedMotion();\n    state.outlines.active = activeIndex;\n    state.outlines.selected = selectedIndex;\n    if (shownIndex !== state.light.country || trace !== state.light.trace) {\n      state.light.country = shownIndex;\n      state.light.trace = trace;\n      // the loop stamps the start time on its next frame\n      state.light.start = shownIndex >= 0 && !reduced ? LIGHT_PENDING : -1;\n    }\n    const chip = state.chip;\n    if (shownIndex >= 0 && !chip.visible) chip.jump = true;\n    chip.visible = shownIndex >= 0;\n    chip.country = shownIndex;\n    chip.atPointer = chipAtPointer;\n    state.overlayDirty = true;\n    loop.kick();\n  }, [shownIndex, activeIndex, selectedIndex, chipAtPointer, trace, engineRef, loop]);\n\n  // The chip is measured once per content change; the loop places it.\n  const chipText = chipEntry ? textOf(chipEntry) : \"\";\n  React.useLayoutEffect(() => {\n    const element = chipRef.current;\n    if (!element) return;\n    engineRef.current.chip.width = element.offsetWidth;\n    engineRef.current.chip.height = element.offsetHeight;\n  }, [chipCode, chipText, unit, engineRef]);\n\n  function step(time: number, delta: number) {\n    const state = engineRef.current;\n    const reduced = prefersReducedMotion();\n    const scene = state.scene;\n    if (state.inkStale && palette.current) {\n      state.inkStale = false;\n      state.ink = geoInk(palette.current);\n      state.batch?.setRamp(rampOf(state.ink));\n      state.fullPaint = true;\n      state.overlayDirty = true;\n    }\n    if (state.light.start === LIGHT_PENDING) state.light.start = time;\n    let busy = false;\n    if (scene) {\n      state.intro = false;\n      busy = advanceCountries(state, scene, delta, reduced);\n    }\n\n    const surface = dots.current;\n    if (scene && surface && state.ink) {\n      state.batch ??= createDotBatch(scene.count, rampOf(state.ink), { tones: RAMP_TONES, levels: 32 });\n      if (state.fullPaint) paintAll(surface, scene, state, state.batch);\n      else if (state.dirty.size) paintDirty(surface, scene, state, state.batch);\n      state.fullPaint = false;\n      state.dirty.clear();\n    }\n\n    const overlay = light.current;\n    if (overlay && state.ink && (state.overlayDirty || state.light.start >= 0)) {\n      busy = paintOverlay(overlay, scene, state, state.ink, time, reduced) || busy;\n      state.overlayDirty = false;\n    }\n\n    busy = moveChip(chipRef.current, scene, state, delta, reduced) || busy;\n    return busy;\n  }\n\n  /* Interaction ------------------------------------------------------------------------------------- */\n\n  function select(code: string | null) {\n    setSelected(code);\n    if (code) setTrace((count) => count + 1);\n  }\n\n  function describe(entry: Entry, isSelected: boolean) {\n    const rank = `rank ${entry.rank} of ${entries.length}`;\n    return `${entry.name}, ${withUnit(textOf(entry))}, ${rank}${isSelected ? \", selected\" : \"\"}`;\n  }\n\n  function codeAt(x: number, y: number) {\n    const scene = engineRef.current.scene;\n    if (!scene) return null;\n    const index = hitTest(scene, engineRef.current.hasData, x, y);\n    return index >= 0 ? COUNTRIES[index].code : null;\n  }\n\n  function handlePointerMove(event: React.PointerEvent<HTMLDivElement>) {\n    if (event.pointerType === \"touch\") return;\n    // the map's own px: localPoint undoes CSS zoom on an ancestor\n    const point = localPoint(event.currentTarget, event.clientX, event.clientY);\n    const chip = engineRef.current.chip;\n    chip.pointerX = point.x;\n    chip.pointerY = point.y;\n    const code = codeAt(point.x, point.y);\n    if (code) {\n      if (active?.code !== code || active.source !== \"pointer\") setActive({ code, source: \"pointer\" });\n    } else if (active?.source === \"pointer\") {\n      setActive(null);\n    }\n    loop.kick();\n  }\n\n  function handlePointerLeave() {\n    if (active?.source === \"pointer\") setActive(null);\n  }\n\n  function handleMapClick(event: React.MouseEvent<HTMLDivElement>) {\n    const point = localPoint(event.currentTarget, event.clientX, event.clientY);\n    const code = codeAt(point.x, point.y);\n    select(code && code !== selected ? code : null);\n  }\n\n  function handleMapKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {\n    const scene = engineRef.current.scene;\n    const direction = ARROW_DIRECTIONS[event.key];\n    if (direction) {\n      event.preventDefault();\n      if (!scene || !entries.length) return;\n      const candidates = entries.filter((entry) => Number.isFinite(scene.anchorX[entry.index]));\n      if (!candidates.length) return;\n      const points = candidates.map((entry) => ({ x: scene.anchorX[entry.index], y: scene.anchorY[entry.index] }));\n      const fromCode = active?.source === \"map-key\" ? active.code : (selected ?? null);\n      const from = candidates.findIndex((entry) => entry.code === fromCode);\n      const next = from < 0 ? candidates[0] : candidates[spatialNavigate(points, from, direction)];\n      setActive({ code: next.code, source: \"map-key\" });\n      setAnnouncement(describe(next, next.code === selected));\n      return;\n    }\n    if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      if (activeEntry) {\n        select(activeEntry.code);\n        setAnnouncement(describe(activeEntry, true));\n      }\n      return;\n    }\n    if (event.key === \"Escape\" && (selected || active)) {\n      event.preventDefault();\n      select(null);\n      setActive(null);\n      setAnnouncement(\"Selection cleared\");\n    }\n  }\n\n  function handleMapBlur() {\n    if (active?.source === \"map-key\") setActive(null);\n  }\n\n  function handleListKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {\n    if (!options.length) return;\n    const current = active ? options.findIndex((entry) => entry.code === active.code) : -1;\n    const start =\n      current >= 0\n        ? current\n        : Math.max(\n            0,\n            options.findIndex((entry) => entry.code === selected),\n          );\n    let next = -1;\n    if (event.key === \"ArrowDown\") next = current < 0 ? start : Math.min(options.length - 1, current + 1);\n    else if (event.key === \"ArrowUp\") next = current < 0 ? start : Math.max(0, current - 1);\n    else if (event.key === \"Home\") next = 0;\n    else if (event.key === \"End\") next = options.length - 1;\n    if (next >= 0) {\n      event.preventDefault();\n      setActive({ code: options[next].code, source: \"list\" });\n      return;\n    }\n    if ((event.key === \"Enter\" || event.key === \" \") && current >= 0) {\n      event.preventDefault();\n      select(options[current].code);\n      return;\n    }\n    if (event.key === \"Escape\" && (selected || active)) {\n      event.preventDefault();\n      select(null);\n      setActive(null);\n    }\n  }\n\n  function handleListFocus(event: React.FocusEvent<HTMLDivElement>) {\n    if (event.target !== event.currentTarget || active?.source === \"list\" || !options.length) return;\n    const start = options.findIndex((entry) => entry.code === selected);\n    setActive({ code: options[Math.max(0, start)].code, source: \"list\" });\n  }\n\n  function handleListBlur() {\n    if (active?.source === \"list\") setActive(null);\n  }\n\n  function handleListPointerLeave() {\n    if (active?.source === \"list\" && document.activeElement !== listRef.current) setActive(null);\n  }\n\n  const optionId = (code: string) => `${baseId}-row-${code}`;\n  const activeOption =\n    active && options.some((entry) => entry.code === active.code) ? optionId(active.code) : undefined;\n  const legendLevels = Array.from({ length: classCount }, (_, i) => (i + 1) / classCount);\n\n  return (\n    <div\n      data-slot=\"geo-choropleth\"\n      className={cn(\n        \"@container grid w-full max-w-[660px] gap-3 rounded-xl border border-border bg-card p-3 text-[13px] leading-[1.35] text-card-foreground\",\n        className,\n      )}\n      {...props}\n    >\n      {/* the card is at least 560px wide when its content box reaches 534px */}\n      <div className={cn(\"grid gap-3\", showList && \"@[534px]:grid-cols-[minmax(0,1fr)_180px] @[534px]:items-center\")}>\n        <div\n          ref={mapRef}\n          tabIndex={0}\n          role=\"group\"\n          aria-roledescription=\"map\"\n          aria-label={label}\n          aria-describedby={`${baseId}-hint`}\n          onPointerMove={handlePointerMove}\n          onPointerLeave={handlePointerLeave}\n          onClick={handleMapClick}\n          onKeyDown={handleMapKeyDown}\n          onBlur={handleMapBlur}\n          data-hover={chipAtPointer ? \"\" : undefined}\n          className=\"relative w-full touch-manipulation rounded-lg outline-none select-none [-webkit-tap-highlight-color:transparent] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-lumesec focus-visible:outline-solid data-[hover]:cursor-pointer\"\n          style={{ aspectRatio: aspect }}\n        >\n          <svg\n            aria-hidden=\"true\"\n            viewBox={`0 0 ${OUTLINE_WIDTH} ${round2(OUTLINE_WIDTH / aspect)}`}\n            preserveAspectRatio=\"none\"\n            className=\"pointer-events-none absolute inset-0 size-full overflow-visible text-border\"\n          >\n            <path d={outlinePath} fill=\"none\" stroke=\"currentColor\" strokeWidth={1} vectorEffect=\"non-scaling-stroke\" />\n          </svg>\n          <canvas\n            ref={dotsRef}\n            aria-hidden=\"true\"\n            className=\"absolute inset-0 block size-full opacity-0 transition-opacity duration-500 ease-out data-[ready]:opacity-100 motion-reduce:transition-none\"\n          />\n          <canvas\n            ref={lightRef}\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute -inset-2 block h-[calc(100%+16px)] w-[calc(100%+16px)]\"\n          />\n          {!entries.length ? (\n            <div className=\"pointer-events-none absolute inset-0 grid place-items-center\">\n              <span className=\"rounded-md border border-border bg-card px-2 py-0.5 text-[12px] text-muted-foreground\">\n                No data\n              </span>\n            </div>\n          ) : null}\n          <div\n            ref={chipRef}\n            aria-hidden=\"true\"\n            data-visible={shownEntry ? \"\" : undefined}\n            className=\"pointer-events-none absolute top-0 left-0 z-10 flex items-center gap-2 rounded-md border border-border bg-popover px-2 py-[3px] text-[12px] leading-[18px] whitespace-nowrap text-popover-foreground opacity-0 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.35)] transition-opacity duration-150 will-change-transform data-[visible]:opacity-100 motion-reduce:transition-none\"\n          >\n            {chipEntry ? (\n              <>\n                <span className=\"font-medium\">{chipEntry.name}</span>\n                <span className=\"flex items-baseline gap-1\">\n                  <ValueRoll value={chipEntry.value} text={textOf(chipEntry)} className=\"font-mono tabular-nums\" />\n                  {unit ? <span className=\"text-muted-foreground\">{unit}</span> : null}\n                </span>\n              </>\n            ) : null}\n          </div>\n          <span id={`${baseId}-hint`} className=\"sr-only\">\n            Arrow keys move between countries with data, Enter selects, Escape clears the selection.\n          </span>\n          <span aria-live=\"polite\" className=\"sr-only\">\n            {announcement}\n          </span>\n        </div>\n\n        {showList ? (\n          <div className=\"grid min-w-0 content-start gap-1\">\n            <div className=\"flex items-baseline justify-between gap-2 px-2 text-[11.5px] text-muted-foreground\">\n              <span>{entries.length ? `Top ${rows.length}` : \"Ranked\"}</span>\n              {unit ? <span className=\"truncate\">{unit}</span> : null}\n            </div>\n            <div\n              ref={listRef}\n              role=\"listbox\"\n              tabIndex={entries.length ? 0 : -1}\n              aria-label={`${label}, ranked`}\n              aria-activedescendant={activeOption}\n              onKeyDown={handleListKeyDown}\n              onFocus={handleListFocus}\n              onBlur={handleListBlur}\n              onPointerLeave={handleListPointerLeave}\n              className=\"grid rounded-lg outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lumesec focus-visible:outline-solid\"\n            >\n              {rows.length ? (\n                <RankedRows\n                  entries={options}\n                  extra={extraEntry}\n                  textOf={textOf}\n                  optionId={optionId}\n                  activeCode={active?.code ?? null}\n                  selectedCode={selectedEntry?.code ?? null}\n                  reducedMotion={reducedMotion}\n                  unit={unit}\n                  onHover={(code) => setActive({ code, source: \"list\" })}\n                  onChoose={(code) => select(code === selected ? null : code)}\n                />\n              ) : (\n                <div className=\"px-2 py-1.5 text-[12.5px] text-muted-foreground\">No data</div>\n              )}\n            </div>\n            {/* keeps the room for the out-of-list row, so the card never changes height */}\n            {rows.length > 0 && !extraEntry && rows.length < entries.length ? (\n              <div aria-hidden=\"true\" className=\"-mt-1 h-[29px]\" />\n            ) : null}\n          </div>\n        ) : null}\n      </div>\n\n      <Legend\n        levels={entries.length ? legendLevels : []}\n        breaks={entries.length ? model.breaks.map((value) => format(value)) : []}\n      />\n\n      <table className=\"sr-only\">\n        <caption>{label}</caption>\n        <thead>\n          <tr>\n            <th scope=\"col\">Country</th>\n            <th scope=\"col\">Value</th>\n          </tr>\n        </thead>\n        <tbody>\n          {entries.map((entry) => (\n            <tr key={entry.code}>\n              <th scope=\"row\">{entry.name}</th>\n              <td>{withUnit(textOf(entry))}</td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n\n/* Ranked list ---------------------------------------------------------------------------------------- */\n\ninterface RankedRowsProps {\n  entries: readonly Entry[];\n  extra: Entry | null;\n  textOf: (entry: Entry) => string;\n  optionId: (code: string) => string;\n  activeCode: string | null;\n  selectedCode: string | null;\n  reducedMotion: boolean;\n  unit: string | undefined;\n  onHover: (code: string) => void;\n  onChoose: (code: string) => void;\n}\n\n/** The ranked rows. Rows that change rank slide to their new place on a spring (FLIP). */\nfunction RankedRows({\n  entries,\n  extra,\n  textOf,\n  optionId,\n  activeCode,\n  selectedCode,\n  reducedMotion,\n  unit,\n  onHover,\n  onChoose,\n}: RankedRowsProps) {\n  const nodes = React.useRef(new Map<string, HTMLDivElement>());\n  const tops = React.useRef(new Map<string, number>());\n  const order = entries.map((entry) => entry.code).join(\",\");\n\n  React.useLayoutEffect(() => {\n    const previous = tops.current;\n    const next = new Map<string, number>();\n    let entering = 0;\n    for (const [code, node] of nodes.current) {\n      const top = node.offsetTop;\n      next.set(code, top);\n      if (reducedMotion || typeof node.animate !== \"function\" || previous.size === 0) continue;\n      const before = previous.get(code);\n      if (before === undefined) {\n        node.animate(\n          [\n            { opacity: 0, transform: \"translateY(6px)\" },\n            { opacity: 1, transform: \"none\" },\n          ],\n          { duration: 320, delay: 40 * entering++, easing: \"cubic-bezier(.2,.8,.2,1)\", fill: \"backwards\" },\n        );\n      } else if (before !== top) {\n        node.animate([{ transform: `translateY(${before - top}px)` }, { transform: \"none\" }], {\n          duration: 560,\n          easing: \"cubic-bezier(.3,1.2,.4,1)\",\n        });\n      }\n    }\n    tops.current = next;\n  }, [order, reducedMotion]);\n\n  return entries.map((entry) => {\n    const isExtra = entry === extra;\n    const isSelected = entry.code === selectedCode;\n    return (\n      <div\n        key={entry.code}\n        ref={(node) => {\n          if (node) nodes.current.set(entry.code, node);\n          else nodes.current.delete(entry.code);\n        }}\n        id={optionId(entry.code)}\n        role=\"option\"\n        aria-selected={isSelected}\n        aria-label={`${entry.name}, ${textOf(entry)}${unit ? ` ${unit}` : \"\"}, rank ${entry.rank}`}\n        data-active={entry.code === activeCode ? \"\" : undefined}\n        onPointerEnter={(event) => {\n          if (event.pointerType !== \"touch\") onHover(entry.code);\n        }}\n        onClick={() => onChoose(entry.code)}\n        className={cn(\n          \"relative grid h-6 cursor-pointer grid-cols-[14px_minmax(0,1fr)_auto_14px] items-center gap-x-1.5 rounded-md pr-1.5 pl-1 transition-colors duration-150 data-[active]:bg-muted motion-reduce:transition-none\",\n          isExtra &&\n            \"mt-[5px] before:pointer-events-none before:absolute before:inset-x-1 before:-top-[3px] before:border-t before:border-dashed before:border-border\",\n        )}\n      >\n        <span\n          className={cn(\n            \"text-right font-mono text-[10.5px] text-muted-foreground tabular-nums\",\n            isSelected && \"font-medium text-lumesec\",\n          )}\n        >\n          {entry.rank}\n        </span>\n        <span\n          className={cn(\"truncate text-[12.5px]\", isSelected ? \"font-medium text-foreground\" : \"text-foreground/85\")}\n        >\n          {entry.name}\n        </span>\n        <ValueRoll\n          value={entry.value}\n          text={textOf(entry)}\n          className=\"justify-self-end font-mono text-[12px] tabular-nums\"\n        />\n        <DitherTile level={entry.level} columns={4} rows={4} />\n      </div>\n    );\n  });\n}\n\n/* Legend --------------------------------------------------------------------------------------------- */\n\n/** Dots per legend tile row; the tiles join into one strip that gets denser to the right. */\nconst LEGEND_COLUMNS = 10;\nconst LEGEND_GAP = 2;\n\n/**\n * One dither tile per class, joined into a strip that gets denser to the right: the lowest and the largest value at\n * its ends, the class breaks under the joins. Then the \"No data\" swatch.\n */\nfunction Legend({ levels, breaks }: { levels: readonly number[]; breaks: readonly string[] }) {\n  const tileWidth = LEGEND_COLUMNS * DOT_PITCH;\n  const stride = tileWidth + LEGEND_GAP;\n  const total = levels.length ? levels.length * stride - LEGEND_GAP : 0;\n  const inner = breaks.slice(1, -1);\n  const longest = inner.reduce((max, text) => Math.max(max, text.length), 0);\n  // Geist Mono at 10.5px is about 6.3px per character: show every other break when neighbours would touch\n  const every = longest * 6.3 > stride - 3 ? 2 : 1;\n  const ends = \"font-mono text-[10.5px] leading-[14px] tabular-nums text-muted-foreground\";\n  return (\n    <div className=\"flex flex-wrap items-start justify-between gap-x-5 gap-y-2\">\n      {levels.length ? (\n        <div className=\"grid grid-cols-[auto_auto_auto] items-center gap-x-1.5 gap-y-1\">\n          <span className={cn(ends, \"text-right\")}>{breaks[0]}</span>\n          <div className=\"flex\" style={{ gap: LEGEND_GAP }}>\n            {levels.map((level, i) => (\n              <DitherTile key={i} level={level} columns={LEGEND_COLUMNS} rows={4} offset={i * LEGEND_COLUMNS} />\n            ))}\n          </div>\n          <span className={ends}>{breaks[breaks.length - 1]}</span>\n          <div aria-hidden=\"true\" className={cn(ends, \"relative col-start-2 h-3.5\")} style={{ width: total }}>\n            {inner.map((text, i) =>\n              (i + 1) % every === 0 ? (\n                <span\n                  key={i}\n                  className=\"absolute top-0 -translate-x-1/2 whitespace-nowrap\"\n                  style={{ left: (i + 1) * stride - LEGEND_GAP / 2 }}\n                >\n                  {text}\n                </span>\n              ) : null,\n            )}\n          </div>\n        </div>\n      ) : null}\n      <div className=\"flex h-3.5 items-center gap-2 text-[11.5px] leading-[14px] text-muted-foreground\">\n        <DitherTile level={null} columns={4} rows={4} />\n        No data\n      </div>\n    </div>\n  );\n}\n\n/**\n * A tile of dots lit in the same 4×4 ordered-dither pattern as the map. When the level changes, dots switch one after\n * another in dither order (rising) or in reverse order (falling), like a country on the map.\n */\nfunction DitherTile({\n  level,\n  columns,\n  rows,\n  offset = 0,\n}: {\n  level: number | null;\n  columns: number;\n  rows: number;\n  offset?: number;\n}) {\n  const [state, setState] = React.useState({ level, direction: 1 });\n  if (state.level !== level) setState({ level, direction: (level ?? 0) >= (state.level ?? 0) ? 1 : -1 });\n  const width = columns * DOT_PITCH;\n  const height = rows * DOT_PITCH;\n  const inset = (DOT_PITCH - TILE_DOT) / 2;\n  const dots: React.ReactNode[] = [];\n  for (let row = 0; row < rows; row++) {\n    for (let column = 0; column < columns; column++) {\n      const order = BAYER4[(row & 3) * 4 + ((column + offset) & 3)];\n      const lit = level !== null && bayer4(row, column + offset) < level;\n      const delay = (state.direction > 0 ? order : 15 - order) * 22;\n      dots.push(\n        <rect\n          key={`${row}:${column}`}\n          x={column * DOT_PITCH + inset}\n          y={row * DOT_PITCH + inset}\n          width={TILE_DOT}\n          height={TILE_DOT}\n          className={cn(\n            \"transition-[fill,fill-opacity] duration-150 ease-out motion-reduce:transition-none\",\n            lit ? \"fill-lumesec\" : \"fill-[color-mix(in_srgb,var(--muted-foreground)_70%,var(--card))]\",\n          )}\n          style={{\n            fillOpacity: lit && level !== null ? 0.35 + 0.55 * level : level === null ? NO_DATA_ALPHA : UNLIT_ALPHA,\n            transitionDelay: `${delay}ms`,\n          }}\n        />,\n      );\n    }\n  }\n  return (\n    <svg\n      aria-hidden=\"true\"\n      width={width}\n      height={height}\n      viewBox={`0 0 ${width} ${height}`}\n      className=\"block flex-none\"\n      shapeRendering=\"crispEdges\"\n    >\n      {dots}\n    </svg>\n  );\n}\n\n/** Rolling value text that rolls up when the value grows and down when it shrinks. */\nfunction ValueRoll({ value, text, className }: { value: number; text: string; className?: string }) {\n  const [state, setState] = React.useState({ value, direction: 1 });\n  if (state.value !== value) setState({ value, direction: value >= state.value ? 1 : -1 });\n  return <RollText text={text} direction={state.direction} className={className} />;\n}\n\n/* Data model ----------------------------------------------------------------------------------------- */\n\ninterface Entry {\n  readonly code: string;\n  /** Index into COUNTRIES. */\n  readonly index: number;\n  readonly name: string;\n  readonly value: number;\n  /** 1 is the largest value. */\n  readonly rank: number;\n  /** Density class, 0 to classes − 1. */\n  readonly klass: number;\n  /** Target dot level: (class + 1) / classes. */\n  readonly level: number;\n}\n\ninterface Model {\n  /** Countries with data, largest value first. */\n  readonly entries: readonly Entry[];\n  readonly byCode: ReadonlyMap<string, Entry>;\n  /** classes + 1 values: the lowest value, the class boundaries and the largest value. */\n  readonly breaks: readonly number[];\n  readonly unknown: readonly string[];\n  /** The sorted codes with data; the cluster layout depends on it. */\n  readonly codesKey: string;\n}\n\ninterface ActiveCountry {\n  code: string;\n  source: \"pointer\" | \"map-key\" | \"list\";\n}\n\nfunction normalizeCode(code: string | null | undefined) {\n  return code ? code.trim().toUpperCase() : null;\n}\n\nfunction buildModel(records: readonly CountryValue[], classes: number, scale: GeoChoroplethScale): Model {\n  const values = new Map<number, number>();\n  const unknown: string[] = [];\n  for (const record of records) {\n    if (!record || typeof record.code !== \"string\") continue;\n    const index = countryIndex(record.code.trim());\n    if (index < 0) {\n      if (!unknown.includes(record.code)) unknown.push(record.code);\n      continue;\n    }\n    if (typeof record.value !== \"number\" || !Number.isFinite(record.value)) continue;\n    values.set(index, record.value);\n  }\n  const list = [...values.values()];\n  const classify = classifier(list, classes, scale);\n  const sorted = [...values.entries()].sort(\n    ([a, va], [b, vb]) => vb - va || COUNTRIES[a].name.localeCompare(COUNTRIES[b].name),\n  );\n  const entries = sorted.map(([index, value], i): Entry => {\n    const klass = classify.classOf(value);\n    return {\n      code: COUNTRIES[index].code,\n      index,\n      name: COUNTRIES[index].name,\n      value,\n      rank: i + 1,\n      klass,\n      level: (klass + 1) / classes,\n    };\n  });\n  return {\n    entries,\n    byCode: new Map(entries.map((entry) => [entry.code, entry])),\n    breaks: classify.breaks,\n    unknown,\n    codesKey: entries\n      .map((entry) => entry.code)\n      .sort()\n      .join(\",\"),\n  };\n}\n\n/** Class of a value and the class breaks for quantile, linear and log scales. */\nfunction classifier(values: readonly number[], classes: number, scale: GeoChoroplethScale) {\n  const sorted = [...values].sort((a, b) => a - b);\n  const n = sorted.length;\n  const min = n ? sorted[0] : 0;\n  const max = n ? sorted[n - 1] : 0;\n  const top = classes - 1;\n  if (scale === \"quantile\") {\n    // the middle rank of a value among equal values, so ties share a class\n    const classOf = (value: number) => {\n      if (n <= 1) return top;\n      let below = 0;\n      let equal = 0;\n      for (const other of sorted) {\n        if (other < value) below++;\n        else if (other === value) equal++;\n      }\n      const position = (below + Math.max(0, equal - 1) / 2) / (n - 1);\n      return Math.min(top, Math.floor(position * classes));\n    };\n    const breaks = Array.from({ length: classes + 1 }, (_, i) => (n ? quantile(sorted, i / classes) : 0));\n    return { classOf, breaks };\n  }\n  if (scale === \"log\") {\n    const positive = sorted.filter((v) => v > 0);\n    const low = positive.length ? Math.log(positive[0]) : 0;\n    const high = positive.length ? Math.log(positive[positive.length - 1]) : 0;\n    const classOf = (value: number) => {\n      if (high <= low) return top;\n      if (value <= 0) return 0;\n      return Math.min(top, Math.max(0, Math.floor(((Math.log(value) - low) / (high - low)) * classes)));\n    };\n    const breaks = Array.from({ length: classes + 1 }, (_, i) => Math.exp(low + ((high - low) * i) / classes));\n    return { classOf, breaks: positive.length ? breaks : breaks.map(() => 0) };\n  }\n  const classOf = (value: number) =>\n    max <= min ? top : Math.min(top, Math.floor(((value - min) / (max - min)) * classes));\n  const breaks = Array.from({ length: classes + 1 }, (_, i) => min + ((max - min) * i) / classes);\n  return { classOf, breaks };\n}\n\n/* Map scene ------------------------------------------------------------------------------------------ */\n\n/** Latitude crop: the inhabited world without Antarctica, so the dots stay large. */\nconst LAT_RANGE: readonly [number, number] = [-56, 78];\n/** Lattice pitch and dot sizes in CSS px. */\nconst DOT_PITCH = 3.5;\nconst DOT_SIZE = 2;\n/** Front dots draw this much larger. */\nconst FRONT_GROW = 0.6;\n/** Dot size inside the SVG tiles of the list and the legend. */\nconst TILE_DOT = 2;\n/** Countries with data that get fewer dots than this are drawn as a 2×2 cluster. */\nconst SMALL_COUNTRY = 3;\n/** Base alpha of the unlit dots: countries with data, then countries without. */\nconst UNLIT_ALPHA = 0.5;\nconst NO_DATA_ALPHA = 0.35;\n/** The overlay canvas reaches this far past the map, so border light near the edge is not cut. */\nconst OVERLAY_PAD = 8;\n/** Width of the SVG outline's viewBox; the height follows the projection's aspect. */\nconst OUTLINE_WIDTH = 1000;\n\n/** Each country's print is a spring from its old to its new level (progress 0 to 1). */\nconst LEVEL_STIFFNESS = 64;\nconst LEVEL_DAMPING = 0.9;\n/** Seconds between the starts of countries, the largest change first. */\nconst STAGGER = 0.025;\n/** Share of the progress over which the change travels from the country's middle to its far edge. */\nconst SPREAD = 0.78;\n/** How far toward the accent the dots in the moving front brighten (0 to 0.5). */\nconst FRONT_BAND = 0.3;\n/** Half-width of a switching dot's flare, in units of its own change (0 to 1). */\nconst FRONT_WINDOW = 0.45;\n\n/** Ramp of the dot batch: land at tone 0, the accent at 0.5, the glint at 1. */\nconst RAMP_TONES = 9;\nconst TONE_LAND = 0;\nconst TONE_ACCENT = 0.5;\n\n/** Border light: the dash length and the run time. */\nconst LIGHT_DASH = 28;\nconst LIGHT_SECONDS = 0.7;\n/** Light start marker: the loop sets the real start time on its next frame. */\nconst LIGHT_PENDING = -2;\nconst lightEase = cubicBezier(0.3, 0, 0.2, 1);\n\nconst round2 = (value: number) => Math.round(value * 100) / 100;\n\n/** SVG path of the projection's border at a nominal width, for the server markup. */\nfunction outlineSvgPath(kind: GeoChoroplethProjection) {\n  const aspect = projectionAspect(kind, LAT_RANGE);\n  return projectionOutlineSvgPath(\n    createProjection(kind, { width: OUTLINE_WIDTH, height: round2(OUTLINE_WIDTH / aspect), latRange: LAT_RANGE }),\n  );\n}\n\n/** The lattice for one canvas size: dot positions on the device grid, dither thresholds and owners. */\ninterface BaseScene {\n  readonly projection: MapProjection;\n  readonly width: number;\n  readonly height: number;\n  readonly dpr: number;\n  readonly count: number;\n  readonly x: Float32Array;\n  readonly y: Float32Array;\n  readonly row: Int16Array;\n  readonly column: Int16Array;\n  readonly jitter: Float32Array;\n  /** COUNTRIES index per dot, or −1. */\n  readonly owner: Int16Array;\n  /** Dots per country. */\n  readonly counts: Int32Array;\n  /** Lattice cell grid anchored to the projection frame: cell → dot index or −1. */\n  readonly columns: number;\n  readonly rows: number;\n  readonly cells: Int32Array;\n  /** Projected label point per country (NaN outside the map). */\n  readonly labelX: Float32Array;\n  readonly labelY: Float32Array;\n  readonly dotSize: number;\n  readonly frontSize: number;\n}\n\n/** The dots of the current data: the lattice plus the cluster dots of small countries, with their state. */\ninterface Scene {\n  readonly base: BaseScene;\n  readonly codesKey: string;\n  readonly count: number;\n  readonly x: Float32Array;\n  readonly y: Float32Array;\n  readonly threshold: Float32Array;\n  /** Effective owner per dot after clusters claim their cells. */\n  readonly owner: Int16Array;\n  /** 1 for cluster dots outside the land lattice; they only show while their country has data. */\n  readonly added: Uint8Array;\n  /** Order in which a dot changes, 0 (the middle) to 1 (the far edge, a little uneven). */\n  readonly reach: Float32Array;\n  /** Dot level and presence (1 with data), and their values when the current change started. */\n  readonly level: Float32Array;\n  readonly levelFrom: Float32Array;\n  readonly presence: Float32Array;\n  readonly presenceFrom: Float32Array;\n  /** The dot's own progress through the current change, 0 to 1. */\n  readonly progress: Float32Array;\n  /** Dots of country c: order[start[c]] to order[start[c + 1] − 1]. */\n  readonly start: Int32Array;\n  readonly order: Int32Array;\n  readonly cells: Int32Array;\n  /** Where a country's chip, keyboard focus and light anchor: its label point or its cluster's centre. */\n  readonly anchorX: Float32Array;\n  readonly anchorY: Float32Array;\n  /** Cluster box per country (x0, y0, x1, y1), NaN when not clustered. */\n  readonly clusterBox: Float32Array;\n}\n\ninterface CountryMotion {\n  /** Level the country moves to: its class level, 0 without data. */\n  target: number;\n  /** 1 with data, 0 without. */\n  presence: number;\n  /** Spring progress through the change, 0 to 1. */\n  position: number;\n  velocity: number;\n  /** Seconds before the change starts (stagger). */\n  delay: number;\n}\n\ninterface Engine {\n  projection: GeoChoroplethProjection;\n  scene: Scene | null;\n  ink: GeoInk | null;\n  /** Set when the theme changed; the loop re-reads the ink. */\n  inkStale: boolean;\n  batch: DotBatch | null;\n  /** True until the first lattice is built: the first data then dithers in from nothing. */\n  intro: boolean;\n  motion: CountryMotion[];\n  /** Target level per country and whether it has data. */\n  targets: Float32Array;\n  hasData: Uint8Array;\n  /** Rank per country (1 is the largest value), so equal changes start with the larger value. */\n  ranks: Int32Array;\n  codesKey: string;\n  running: Set<number>;\n  dirty: Set<number>;\n  fullPaint: boolean;\n  overlayDirty: boolean;\n  outlines: { active: number; selected: number };\n  /** The border light: its country, the selection count that started it, and its start in seconds (−1: none). */\n  light: { country: number; trace: number; start: number };\n  chip: ChipMotion;\n  outlineCache: Map<string, { path: Path2D; length: number }>;\n}\n\ninterface ChipMotion {\n  visible: boolean;\n  country: number;\n  atPointer: boolean;\n  /** Place at once instead of springing (it was hidden). */\n  jump: boolean;\n  pointerX: number;\n  pointerY: number;\n  x: { position: number; velocity: number };\n  y: { position: number; velocity: number };\n  width: number;\n  height: number;\n}\n\nfunction createEngine(projection: GeoChoroplethProjection): Engine {\n  return {\n    projection,\n    scene: null,\n    ink: null,\n    inkStale: true,\n    batch: null,\n    intro: true,\n    motion: COUNTRIES.map(() => ({ target: 0, presence: 0, position: 1, velocity: 0, delay: 0 })),\n    targets: new Float32Array(COUNTRIES.length),\n    hasData: new Uint8Array(COUNTRIES.length),\n    ranks: new Int32Array(COUNTRIES.length),\n    codesKey: \"\",\n    running: new Set(),\n    dirty: new Set(),\n    fullPaint: true,\n    overlayDirty: true,\n    outlines: { active: -1, selected: -1 },\n    light: { country: -1, trace: 0, start: -1 },\n    chip: {\n      visible: false,\n      country: -1,\n      atPointer: false,\n      jump: true,\n      pointerX: 0,\n      pointerY: 0,\n      x: { position: 0, velocity: 0 },\n      y: { position: 0, velocity: 0 },\n      width: 0,\n      height: 0,\n    },\n    outlineCache: new Map(),\n  };\n}\n\nfunction setTargets(state: Engine, model: Model) {\n  state.targets.fill(0);\n  state.hasData.fill(0);\n  state.ranks.fill(COUNTRIES.length + 1);\n  for (const entry of model.entries) {\n    state.targets[entry.index] = entry.level;\n    state.hasData[entry.index] = 1;\n    state.ranks[entry.index] = entry.rank;\n  }\n  state.codesKey = model.codesKey;\n}\n\nfunction buildBase(width: number, height: number, dpr: number, kind: GeoChoroplethProjection): BaseScene {\n  const projection = createProjection(kind, { width, height, latRange: LAT_RANGE });\n  const lattice = mapLattice(projection, { pitch: DOT_PITCH, layout: \"square\" });\n  const owner = Int16Array.from(countryMembership(lattice));\n  // A land dot on the coast can have its centre just outside every outline: give it to the country around it.\n  for (let i = 0; i < lattice.count; i++) {\n    if (owner[i] < 0) owner[i] = nearbyCountry(projection, lattice.x[i], lattice.y[i]);\n  }\n  const counts = new Int32Array(COUNTRIES.length);\n  for (let i = 0; i < lattice.count; i++) if (owner[i] >= 0) counts[owner[i]]++;\n\n  const frame = projection.frame;\n  const columns = Math.floor((frame.x1 - frame.x0) / DOT_PITCH) + 1;\n  const rows = Math.floor((frame.y1 - frame.y0) / DOT_PITCH) + 1;\n  const cells = new Int32Array(columns * rows).fill(-1);\n  const dotSize = snapSize(DOT_SIZE, dpr);\n  const x = new Float32Array(lattice.count);\n  const y = new Float32Array(lattice.count);\n  for (let i = 0; i < lattice.count; i++) {\n    // centre each dot so its left and top edges fall on device pixels: crisp squares\n    x[i] = snapCenter(lattice.x[i], dotSize, dpr);\n    y[i] = snapCenter(lattice.y[i], dotSize, dpr);\n    const row = lattice.row[i];\n    const column = lattice.column[i];\n    if (row >= 0 && row < rows && column >= 0 && column < columns) cells[row * columns + column] = i;\n  }\n  const labelX = new Float32Array(COUNTRIES.length).fill(Number.NaN);\n  const labelY = new Float32Array(COUNTRIES.length).fill(Number.NaN);\n  const point = createScreenPoint();\n  COUNTRIES.forEach((country, index) => {\n    // wrapped, so a label longitude given past 180° still lands on the map\n    if (projection.project(country.lat, wrapLongitude(country.lon), point)) {\n      labelX[index] = point.x;\n      labelY[index] = point.y;\n    }\n  });\n  return {\n    projection,\n    width,\n    height,\n    dpr,\n    count: lattice.count,\n    x,\n    y,\n    row: lattice.row,\n    column: lattice.column,\n    jitter: lattice.jitter,\n    owner,\n    counts,\n    columns,\n    rows,\n    cells,\n    labelX,\n    labelY,\n    dotSize,\n    frontSize: snapSize(DOT_SIZE + FRONT_GROW, dpr),\n  };\n}\n\n/** The country most of the eight points around a dot fall in, or −1. */\nfunction nearbyCountry(projection: MapProjection, x: number, y: number) {\n  const votes = new Map<number, number>();\n  let best = -1;\n  let bestVotes = 0;\n  for (let dy = -1; dy <= 1; dy++) {\n    for (let dx = -1; dx <= 1; dx++) {\n      if (!dx && !dy) continue;\n      const point = projection.invert(x + dx * DOT_PITCH * 0.35, y + dy * DOT_PITCH * 0.35);\n      const country = point ? countryAt(point.lat, point.lon) : undefined;\n      if (!country) continue;\n      const index = countryIndex(country.code);\n      const count = (votes.get(index) ?? 0) + 1;\n      votes.set(index, count);\n      if (count > bestVotes) {\n        bestVotes = count;\n        best = index;\n      }\n    }\n  }\n  return best;\n}\n\n/**\n * Builds the lattice for a canvas size and lays out the dots for the current data. The first build starts every dot\n * at nothing, so the data dithers in; later builds (resize, projection) show the targets at once.\n */\nfunction rebuildScene(state: Engine, surface: CanvasSurface) {\n  // the intro ends with the first frame the loop draws (not here: effects may run twice while developing)\n  const animate = state.intro && !prefersReducedMotion();\n  state.scene = layoutDots(\n    buildBase(surface.width, surface.height, surface.dpr, state.projection),\n    state.hasData,\n    null,\n  );\n  state.running.clear();\n  scheduleTransitions(state, !animate, true);\n  state.fullPaint = true;\n  state.overlayDirty = true;\n  state.chip.jump = true;\n}\n\n/**\n * Places a 2×2 cluster for every small country with data and builds the dot arrays. Clusters sit at the label point,\n * or move up to two cells to avoid another cluster and the dots of other countries with data. On the same lattice\n * (`previous`) the dots keep what they show; otherwise they start at nothing.\n */\nfunction layoutDots(base: BaseScene, hasData: Uint8Array, previous: Scene | null): Scene {\n  const { columns, rows, projection } = base;\n  const frame = projection.frame;\n  const cells = Int32Array.from(base.cells);\n  const owner: number[] = Array.from(base.owner);\n  const claimed = new Uint8Array(cells.length);\n  const added: { row: number; column: number; owner: number }[] = [];\n  const clusterBox = new Float32Array(COUNTRIES.length * 4).fill(Number.NaN);\n  const anchorX = Float32Array.from(base.labelX);\n  const anchorY = Float32Array.from(base.labelY);\n\n  for (let c = 0; c < COUNTRIES.length; c++) {\n    if (!hasData[c] || base.counts[c] >= SMALL_COUNTRY || !Number.isFinite(base.labelX[c])) continue;\n    const gx = (base.labelX[c] - frame.x0) / DOT_PITCH - 0.5;\n    const gy = (base.labelY[c] - frame.y0) / DOT_PITCH - 0.5;\n    const column0 = Math.floor(gx);\n    const row0 = Math.floor(gy);\n    let bestCost = Infinity;\n    let bestRow = -1;\n    let bestColumn = -1;\n    for (let dr = -2; dr <= 2; dr++) {\n      for (let dc = -2; dc <= 2; dc++) {\n        const row = row0 + dr;\n        const column = column0 + dc;\n        if (row < 0 || column < 0 || row + 1 >= rows || column + 1 >= columns) continue;\n        let cost = (column + 0.5 - gx) ** 2 + (row + 0.5 - gy) ** 2;\n        for (let k = 0; k < 4 && cost < Infinity; k++) {\n          const cell = (row + (k >> 1)) * columns + column + (k & 1);\n          if (claimed[cell]) cost = Infinity;\n          const dot = cells[cell];\n          if (dot < 0) continue;\n          const other = owner[dot];\n          if (other === c) cost -= 0.5;\n          else if (other >= 0) cost += hasData[other] ? 4 : 0.6;\n        }\n        if (cost < bestCost) {\n          bestCost = cost;\n          bestRow = row;\n          bestColumn = column;\n        }\n      }\n    }\n    if (bestRow < 0) continue;\n    for (let k = 0; k < 4; k++) {\n      const row = bestRow + (k >> 1);\n      const column = bestColumn + (k & 1);\n      const cell = row * columns + column;\n      claimed[cell] = 1;\n      if (cells[cell] >= 0) owner[cells[cell]] = c;\n      else added.push({ row, column, owner: c });\n    }\n    const x0 = frame.x0 + (bestColumn + 0.5) * DOT_PITCH;\n    const y0 = frame.y0 + (bestRow + 0.5) * DOT_PITCH;\n    clusterBox.set([x0, y0, x0 + DOT_PITCH, y0 + DOT_PITCH], c * 4);\n    anchorX[c] = x0 + DOT_PITCH / 2;\n    anchorY[c] = y0 + DOT_PITCH / 2;\n  }\n\n  const count = base.count + added.length;\n  const x = new Float32Array(count);\n  const y = new Float32Array(count);\n  const threshold = new Float32Array(count);\n  const jitter = new Float32Array(count);\n  const owners = new Int16Array(count);\n  const extra = new Uint8Array(count);\n  x.set(base.x);\n  y.set(base.y);\n  jitter.set(base.jitter);\n  for (let i = 0; i < base.count; i++) {\n    threshold[i] = bayer4(base.row[i], base.column[i]);\n    owners[i] = owner[i];\n  }\n  added.forEach((dot, k) => {\n    const i = base.count + k;\n    const cx = frame.x0 + (dot.column + 0.5) * DOT_PITCH;\n    const cy = frame.y0 + (dot.row + 0.5) * DOT_PITCH;\n    x[i] = Math.round((cx - base.dotSize / 2) * base.dpr) / base.dpr + base.dotSize / 2;\n    y[i] = Math.round((cy - base.dotSize / 2) * base.dpr) / base.dpr + base.dotSize / 2;\n    threshold[i] = bayer4(dot.row, dot.column);\n    jitter[i] = hash(dot.row * 31 + dot.column * 7 + 0.5);\n    owners[i] = dot.owner;\n    extra[i] = 1;\n    cells[dot.row * columns + dot.column] = i;\n  });\n\n  // dots per country (counting sort), then each dot's reach from its country's anchor\n  const start = new Int32Array(COUNTRIES.length + 1);\n  for (let i = 0; i < count; i++) if (owners[i] >= 0) start[owners[i] + 1]++;\n  for (let c = 0; c < COUNTRIES.length; c++) start[c + 1] += start[c];\n  const fill = start.slice(0, COUNTRIES.length);\n  const order = new Int32Array(start[COUNTRIES.length]);\n  for (let i = 0; i < count; i++) if (owners[i] >= 0) order[fill[owners[i]]++] = i;\n  const reach = new Float32Array(count);\n  for (let c = 0; c < COUNTRIES.length; c++) {\n    if (start[c] === start[c + 1]) continue;\n    if (!Number.isFinite(clusterBox[c * 4])) anchorOnDots(c, start, order, x, y, anchorX, anchorY);\n    const ax = anchorX[c];\n    const ay = anchorY[c];\n    let far = 0;\n    for (let k = start[c]; k < start[c + 1]; k++) far = Math.max(far, Math.hypot(x[order[k]] - ax, y[order[k]] - ay));\n    for (let k = start[c]; k < start[c + 1]; k++) {\n      const i = order[k];\n      const n = far > 0 ? Math.hypot(x[i] - ax, y[i] - ay) / far : 0;\n      reach[i] = n * 0.82 + jitter[i] * 0.18;\n    }\n  }\n\n  const level = new Float32Array(count);\n  const presence = new Float32Array(count);\n  if (previous && previous.base === base) {\n    // same lattice: every dot keeps what it shows, cluster dots are new\n    level.set(previous.level.subarray(0, base.count));\n    presence.set(previous.presence.subarray(0, base.count));\n  }\n  return {\n    base,\n    codesKey: codesKeyOf(hasData),\n    count,\n    x,\n    y,\n    threshold,\n    owner: owners,\n    added: extra,\n    reach,\n    level,\n    levelFrom: Float32Array.from(level),\n    presence,\n    presenceFrom: Float32Array.from(presence),\n    progress: new Float32Array(count).fill(1),\n    start,\n    order,\n    cells,\n    anchorX,\n    anchorY,\n    clusterBox,\n  };\n}\n\n/**\n * Keeps a country's anchor on its own dots: the label point when one of its dots is near it, else the dot nearest to\n * the centre of its dots (a label point off the map, or in a gap of the lattice).\n */\nfunction anchorOnDots(\n  country: number,\n  start: Int32Array,\n  order: Int32Array,\n  x: Float32Array,\n  y: Float32Array,\n  anchorX: Float32Array,\n  anchorY: Float32Array,\n) {\n  const lx = anchorX[country];\n  const ly = anchorY[country];\n  let sumX = 0;\n  let sumY = 0;\n  let nearLabel = Infinity;\n  for (let k = start[country]; k < start[country + 1]; k++) {\n    const i = order[k];\n    sumX += x[i];\n    sumY += y[i];\n    if (Number.isFinite(lx)) nearLabel = Math.min(nearLabel, Math.hypot(x[i] - lx, y[i] - ly));\n  }\n  if (nearLabel <= DOT_PITCH * 1.5) return;\n  const n = start[country + 1] - start[country];\n  const cx = sumX / n;\n  const cy = sumY / n;\n  let best = order[start[country]];\n  let bestDistance = Infinity;\n  for (let k = start[country]; k < start[country + 1]; k++) {\n    const i = order[k];\n    const distance = Math.hypot(x[i] - cx, y[i] - cy);\n    if (distance < bestDistance) {\n      bestDistance = distance;\n      best = i;\n    }\n  }\n  anchorX[country] = x[best];\n  anchorY[country] = y[best];\n}\n\nfunction codesKeyOf(hasData: Uint8Array) {\n  const codes: string[] = [];\n  for (let c = 0; c < COUNTRIES.length; c++) if (hasData[c]) codes.push(COUNTRIES[c].code);\n  return codes.sort().join(\",\");\n}\n\n/**\n * Starts a change for every country whose dots do not show its target yet. The largest changes start first, 25 ms\n * apart. With `immediate` (reduced motion, a resize) the dots jump to the targets. `restart` also restarts countries\n * already on their way to the same target (after a relayout reset their dots).\n */\nfunction scheduleTransitions(state: Engine, immediate: boolean, restart = false) {\n  const scene = state.scene;\n  if (!scene) return;\n  const changes: { country: number; size: number }[] = [];\n  for (let c = 0; c < COUNTRIES.length; c++) {\n    const motion = state.motion[c];\n    const target = state.targets[c];\n    const presence = state.hasData[c];\n    let size = 0;\n    for (let k = scene.start[c]; k < scene.start[c + 1]; k++) {\n      const i = scene.order[k];\n      size = Math.max(size, Math.abs(scene.level[i] - target), Math.abs(scene.presence[i] - presence) * 0.25);\n    }\n    const retarget = motion.target !== target || motion.presence !== presence;\n    motion.target = target;\n    motion.presence = presence;\n    if (size < 1e-4) continue;\n    if (!retarget && !restart && !immediate && state.running.has(c)) continue;\n    changes.push({ country: c, size });\n  }\n  changes.sort((a, b) => b.size - a.size || state.ranks[a.country] - state.ranks[b.country]);\n  changes.forEach(({ country }, rank) => {\n    const motion = state.motion[country];\n    for (let k = scene.start[country]; k < scene.start[country + 1]; k++) {\n      const i = scene.order[k];\n      scene.levelFrom[i] = immediate ? motion.target : scene.level[i];\n      scene.presenceFrom[i] = immediate ? motion.presence : scene.presence[i];\n      if (immediate) {\n        scene.level[i] = motion.target;\n        scene.presence[i] = motion.presence;\n        scene.progress[i] = 1;\n      } else {\n        scene.progress[i] = 0;\n      }\n    }\n    motion.position = immediate ? 1 : 0;\n    motion.velocity = 0;\n    motion.delay = immediate ? 0 : rank * STAGGER;\n    if (immediate) state.running.delete(country);\n    else state.running.add(country);\n    state.dirty.add(country);\n  });\n}\n\n/** Steps every running country's spring and moves its dots. Returns whether any country is still changing. */\nfunction advanceCountries(state: Engine, scene: Scene, delta: number, reduced: boolean) {\n  let busy = false;\n  for (const country of state.running) {\n    const motion = state.motion[country];\n    if (motion.delay > 0 && !reduced) {\n      motion.delay -= delta;\n      busy = true;\n      if (motion.delay > 0) continue;\n    }\n    if (reduced) {\n      motion.position = 1;\n      motion.velocity = 0;\n    } else {\n      springStep(motion, 1, delta, LEVEL_STIFFNESS, LEVEL_DAMPING);\n    }\n    const done = Math.abs(1 - motion.position) < 1e-3 && Math.abs(motion.velocity) < 1e-2;\n    if (done) {\n      motion.position = 1;\n      motion.velocity = 0;\n    }\n    const p = motion.position;\n    for (let k = scene.start[country]; k < scene.start[country + 1]; k++) {\n      const i = scene.order[k];\n      const q = done ? 1 : clamp((p - scene.reach[i] * SPREAD) / (1 - SPREAD), 0, 1);\n      scene.progress[i] = q;\n      scene.level[i] = scene.levelFrom[i] + (motion.target - scene.levelFrom[i]) * q;\n      scene.presence[i] = scene.presenceFrom[i] + (motion.presence - scene.presenceFrom[i]) * q;\n    }\n    state.dirty.add(country);\n    if (done) state.running.delete(country);\n    else busy = true;\n  }\n  return busy;\n}\n\nfunction rampOf(ink: GeoInk) {\n  return [ink.land, ink.accent, ink.glint];\n}\n\n/** Queues dot i: lit in the accent when its level passes its dither threshold, bright while its threshold is crossed. */\nfunction addDot(scene: Scene, state: Engine, batch: DotBatch, i: number) {\n  const country = scene.owner[i];\n  const level = scene.level[i];\n  const threshold = scene.threshold[i];\n  const presence = country >= 0 ? scene.presence[i] : 0;\n  let tone = TONE_LAND;\n  let alpha = NO_DATA_ALPHA + (UNLIT_ALPHA - NO_DATA_ALPHA) * presence;\n  let size = scene.base.dotSize;\n  let x = scene.x[i];\n  let y = scene.y[i];\n  if (level > threshold) {\n    tone = TONE_ACCENT;\n    alpha = 0.35 + 0.55 * level;\n  }\n  const q = scene.progress[i];\n  if (country >= 0 && q > 0 && q < 1) {\n    // the front passes this dot: every dot brightens a little toward the accent ...\n    const band = Math.sin(Math.PI * q) * FRONT_BAND;\n    tone = tone < TONE_ACCENT ? tone + (TONE_ACCENT - tone) * band * 2 : tone + (1 - tone) * band;\n    alpha += band * 0.4;\n    const from = scene.levelFrom[i];\n    const to = state.motion[country].target;\n    if ((from - threshold) * (to - threshold) < 0) {\n      // ... and a dot that switches flares in the glint around the moment it crosses its threshold\n      const crossing = (threshold - from) / (to - from);\n      const glow = 1 - Math.abs(q - crossing) / FRONT_WINDOW;\n      if (glow > 0) {\n        tone = TONE_ACCENT + (1 - TONE_ACCENT) * glow;\n        alpha = Math.max(alpha, 0.55 + 0.45 * glow);\n        if (glow > 0.45) {\n          const front = scene.base.frontSize;\n          const dpr = scene.base.dpr;\n          x = snapCenter(x, front, dpr);\n          y = snapCenter(y, front, dpr);\n          size = front;\n        }\n      }\n    }\n  }\n  if (scene.added[i]) alpha *= presence;\n  batch.add(x, y, size, tone, alpha);\n}\n\nfunction paintAll(surface: CanvasSurface, scene: Scene, state: Engine, batch: DotBatch) {\n  const { context, width, height } = surface;\n  context.clearRect(0, 0, width, height);\n  for (let i = 0; i < scene.count; i++) addDot(scene, state, batch, i);\n  batch.flush(context);\n}\n\n/** Repaints only the countries that changed: each dot's cell is cleared and the dot drawn again. */\nfunction paintDirty(surface: CanvasSurface, scene: Scene, state: Engine, batch: DotBatch) {\n  const { context } = surface;\n  const clear = DOT_PITCH - 0.4;\n  for (const country of state.dirty) {\n    for (let k = scene.start[country]; k < scene.start[country + 1]; k++) {\n      const i = scene.order[k];\n      context.clearRect(scene.x[i] - clear / 2, scene.y[i] - clear / 2, clear, clear);\n      addDot(scene, state, batch, i);\n    }\n  }\n  batch.flush(context);\n}\n\n/** Static outline path of a country: its border, or a rounded box around its cluster. Cached per scene. */\nfunction outlineOf(state: Engine, scene: Scene, country: number) {\n  const key = `${country}|${scene.base.projection.key}|${scene.codesKey}`;\n  const cached = state.outlineCache.get(key);\n  if (cached) return cached;\n  let result: { path: Path2D; length: number };\n  const box = scene.clusterBox.subarray(country * 4, country * 4 + 4);\n  if (Number.isFinite(box[0])) {\n    const pad = DOT_SIZE / 2 + 2;\n    const width = box[2] - box[0] + pad * 2;\n    const height = box[3] - box[1] + pad * 2;\n    const path = new Path2D();\n    path.roundRect(box[0] - pad, box[1] - pad, width, height, 3);\n    result = { path, length: 2 * (width + height) };\n  } else {\n    const outline = countryOutline(COUNTRIES[country].code, scene.base.projection);\n    result = { path: outline.path, length: outline.length };\n  }\n  if (state.outlineCache.size > 64) state.outlineCache.clear();\n  state.outlineCache.set(key, result);\n  return result;\n}\n\n/**\n * The overlay: a 0.75px accent outline for the selected and the active country, and the border light, a short bright\n * dash that runs once around the shown country. Returns whether the light is still running.\n */\nfunction paintOverlay(\n  surface: CanvasSurface,\n  scene: Scene | null,\n  state: Engine,\n  ink: GeoInk,\n  time: number,\n  reduced: boolean,\n) {\n  const { context, width, height } = surface;\n  context.clearRect(0, 0, width, height);\n  if (!scene) return false;\n  const light = state.light;\n  let t = 1;\n  if (light.start >= 0 && !reduced) {\n    t = (time - light.start) / LIGHT_SECONDS;\n    if (t >= 1) light.start = -1;\n  } else {\n    light.start = -1;\n  }\n  const running = light.start >= 0 && light.country >= 0;\n  context.save();\n  context.translate(OVERLAY_PAD, OVERLAY_PAD);\n  // outlines may run past the latitude crop (Arctic islands); the glow may still spill a little over the edge\n  context.beginPath();\n  context.rect(-3, -3, scene.base.width + 6, scene.base.height + 6);\n  context.clip();\n  context.lineJoin = \"round\";\n  context.lineCap = \"round\";\n  const drawn = new Set<number>();\n  for (const country of [state.outlines.selected, state.outlines.active]) {\n    if (country < 0 || drawn.has(country)) continue;\n    drawn.add(country);\n    const outline = outlineOf(state, scene, country);\n    const fade = running && country === light.country ? smoothstep(0, 0.6, clamp(t, 0, 1)) : 1;\n    context.globalAlpha = 0.9 * fade;\n    context.strokeStyle = rgba(ink.accent);\n    context.lineWidth = 0.75;\n    context.stroke(outline.path);\n  }\n  if (running) {\n    const outline = outlineOf(state, scene, light.country);\n    const progress = lightEase(clamp(t, 0, 1));\n    const fade = smoothstep(0, 0.08, t) * (1 - smoothstep(0.82, 1, t));\n    context.setLineDash([LIGHT_DASH, outline.length]);\n    context.lineDashOffset = -progress * outline.length;\n    context.globalAlpha = 0.3 * fade;\n    context.strokeStyle = rgba(ink.accent);\n    context.lineWidth = 4;\n    context.stroke(outline.path);\n    context.globalAlpha = fade;\n    context.strokeStyle = rgba(ink.glint);\n    context.lineWidth = 1.5;\n    context.stroke(outline.path);\n    context.setLineDash([]);\n  }\n  context.restore();\n  context.globalAlpha = 1;\n  return running;\n}\n\n/**\n * Keeps the chip next to the pointer, or next to the country's anchor, inside the map. It follows on a stiff spring.\n * Returns whether it is still moving.\n */\nfunction moveChip(element: HTMLDivElement | null, scene: Scene | null, state: Engine, delta: number, reduced: boolean) {\n  const chip = state.chip;\n  if (!element || !scene || !chip.visible || chip.country < 0) return false;\n  const width = scene.base.width;\n  const height = scene.base.height;\n  const ax = chip.atPointer ? chip.pointerX : scene.anchorX[chip.country];\n  const ay = chip.atPointer ? chip.pointerY : scene.anchorY[chip.country];\n  if (!Number.isFinite(ax) || !Number.isFinite(ay)) return false;\n  const gap = chip.atPointer ? 14 : 8;\n  let tx = ax + gap;\n  let ty = ay - chip.height - gap;\n  if (tx + chip.width > width - 4) tx = ax - chip.width - gap;\n  if (ty < 4) ty = ay + gap + (chip.atPointer ? 6 : 0);\n  tx = clamp(tx, 4, Math.max(4, width - chip.width - 4));\n  ty = clamp(ty, 4, Math.max(4, height - chip.height - 4));\n  if (chip.jump || reduced) {\n    chip.jump = false;\n    chip.x.position = tx;\n    chip.y.position = ty;\n    chip.x.velocity = 0;\n    chip.y.velocity = 0;\n  } else {\n    springStep(chip.x, tx, delta, 700, 0.9);\n    springStep(chip.y, ty, delta, 700, 0.9);\n  }\n  element.style.transform = `translate3d(${chip.x.position.toFixed(1)}px,${chip.y.position.toFixed(1)}px,0)`;\n  return (\n    Math.abs(chip.x.position - tx) > 0.1 ||\n    Math.abs(chip.y.position - ty) > 0.1 ||\n    Math.abs(chip.x.velocity) + Math.abs(chip.y.velocity) > 1\n  );\n}\n\n/** The country with data under (x, y): the owner of the nearest dot, else the country whose outline holds the point. */\nfunction hitTest(scene: Scene, hasData: Uint8Array, x: number, y: number) {\n  const frame = scene.base.projection.frame;\n  const { columns, rows } = scene.base;\n  const column = Math.round((x - frame.x0) / DOT_PITCH - 0.5);\n  const row = Math.round((y - frame.y0) / DOT_PITCH - 0.5);\n  let best = -1;\n  let bestDistance = (DOT_PITCH * 1.6) ** 2;\n  for (let r = row - 2; r <= row + 2; r++) {\n    for (let c = column - 2; c <= column + 2; c++) {\n      if (r < 0 || c < 0 || r >= rows || c >= columns) continue;\n      const dot = scene.cells[r * columns + c];\n      if (dot < 0) continue;\n      const distance = (scene.x[dot] - x) ** 2 + (scene.y[dot] - y) ** 2;\n      if (distance < bestDistance) {\n        bestDistance = distance;\n        best = dot;\n      }\n    }\n  }\n  if (best >= 0) {\n    const owner = scene.owner[best];\n    if (owner >= 0 && hasData[owner]) return owner;\n  }\n  const point = scene.base.projection.invert(x, y);\n  const country = point ? countryAt(point.lat, point.lon) : undefined;\n  const index = country ? countryIndex(country.code) : -1;\n  return index >= 0 && hasData[index] ? index : -1;\n}\n\n/* Demo data ------------------------------------------------------------------------------------------ */\n\n/** Invented requests per day for 41 countries, shown while `data` is not set. */\nconst DEMO_DATA: readonly CountryValue[] = [\n  { code: \"US\", value: 412000 },\n  { code: \"IN\", value: 268000 },\n  { code: \"DE\", value: 168000 },\n  { code: \"GB\", value: 142000 },\n  { code: \"BR\", value: 131000 },\n  { code: \"JP\", value: 118000 },\n  { code: \"FR\", value: 96000 },\n  { code: \"CA\", value: 88000 },\n  { code: \"KR\", value: 64000 },\n  { code: \"AU\", value: 57000 },\n  { code: \"NL\", value: 52000 },\n  { code: \"ID\", value: 47000 },\n  { code: \"MX\", value: 44000 },\n  { code: \"ES\", value: 41000 },\n  { code: \"IT\", value: 39000 },\n  { code: \"CN\", value: 33000 },\n  { code: \"PL\", value: 31000 },\n  { code: \"SE\", value: 28000 },\n  { code: \"TR\", value: 26000 },\n  { code: \"VN\", value: 24000 },\n  { code: \"PH\", value: 21000 },\n  { code: \"NG\", value: 19000 },\n  { code: \"ZA\", value: 17000 },\n  { code: \"AR\", value: 16000 },\n  { code: \"SA\", value: 15000 },\n  { code: \"AE\", value: 14000 },\n  { code: \"EG\", value: 12000 },\n  { code: \"CO\", value: 11000 },\n  { code: \"TH\", value: 10500 },\n  { code: \"UA\", value: 9800 },\n  { code: \"IL\", value: 9400 },\n  { code: \"NO\", value: 8700 },\n  { code: \"FI\", value: 7600 },\n  { code: \"CL\", value: 7100 },\n  { code: \"PK\", value: 6800 },\n  { code: \"KE\", value: 5200 },\n  { code: \"MA\", value: 4300 },\n  { code: \"PE\", value: 3900 },\n  { code: \"KZ\", value: 3100 },\n  { code: \"NZ\", value: 2900 },\n  { code: \"RU\", value: 2400 },\n];\n",
      "type": "registry:component",
      "target": "@components/lumesec/geo-choropleth.tsx"
    }
  ],
  "meta": {
    "tag": "geo-choropleth",
    "collection": "location",
    "react": "GeoChoropleth",
    "kind": "react",
    "data": "input"
  },
  "docs": "Country values docs: https://elements.lumesec.ai/components/location/choropleth",
  "categories": [
    "location",
    "maps-traffic"
  ],
  "type": "registry:component"
}