{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "choropleth-map",
  "title": "Choropleth Map",
  "description": "Standalone SVG choropleth — region polygons shaded by data value with tooltip, legend, and accessible data-table fallback.",
  "dependencies": [
    "@vllnt/ui@^0.3.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/choropleth-map/choropleth-map.tsx",
      "content": "\"use client\";\n\nimport {\n  type ComponentPropsWithoutRef,\n  createContext,\n  type ReactNode,\n  use,\n  useCallback,\n  useId,\n  useMemo,\n  useState,\n} from \"react\";\n\nimport { cn } from \"@vllnt/ui\";\n\nconst VIEWBOX_WIDTH = 1000;\nconst VIEWBOX_HEIGHT = 500;\n\n/**\n * Geographic coordinate `[longitude, latitude]`.\n *\n * @public\n */\nexport type GeoPosition = [number, number];\n\n/**\n * A region polygon. Outer ring closes by repeating the first point;\n * holes are out of scope for the MVP.\n *\n * @public\n */\nexport type ChoroplethRegion = {\n  /** Outer ring as `[lng, lat]` positions. */\n  coordinates: GeoPosition[];\n  /** Stable identifier — matches keys in the `data` map. */\n  id: string;\n  /** Human-readable region name shown in the default tooltip. */\n  name: string;\n};\n\n/**\n * Two-stop color scale `[low, high]` for sequential data, or three stops\n * `[low, mid, high]` for diverging data. Linear interpolation between stops.\n *\n * @public\n */\nexport type ChoroplethColorScale = [string, string, string] | [string, string];\n\n/**\n * Localizable strings.\n *\n * @public\n */\nexport type ChoroplethMapLabels = {\n  /** Aria-label for the SVG canvas. Defaults to `\"Choropleth map\"`. */\n  region?: string;\n};\n\nconst DEFAULT_LABELS = {\n  region: \"Choropleth map\",\n} as const satisfies Required<ChoroplethMapLabels>;\n\n/**\n * Default sequential ramp (slate-100 → blue-700). The interpolation engine\n * operates in sRGB hex space, so these defaults are light-mode optimized and\n * fixed; under dark mode or a custom theme they render unchanged. Pass an\n * explicit `colorScale` to match the active palette.\n */\nconst DEFAULT_SCALE: ChoroplethColorScale = [\"#f1f5f9\", \"#1d4ed8\"];\n\n/** sRGB fallback for missing regions when the theme `--muted` token is unavailable. */\nconst DEFAULT_MISSING = \"#e5e7eb\";\n\n/**\n * Resolve the theme `--muted` token to an `oklch()` color so regions with no\n * data adapt to the active preset. Falls back to {@link DEFAULT_MISSING} on the\n * server or when the token has no value.\n */\nfunction resolveMissingColor(): string {\n  if (typeof window === \"undefined\") return DEFAULT_MISSING;\n  const muted = getComputedStyle(document.documentElement)\n    .getPropertyValue(\"--muted\")\n    .trim();\n  return muted ? `oklch(${muted})` : DEFAULT_MISSING;\n}\n\ntype Hover = { id: string; value?: number };\n\ntype ChoroplethCtx = {\n  colorFor: (value?: number) => string;\n  hover?: Hover;\n  legend?: { domain: [number, number]; scale: ChoroplethColorScale };\n  regionByid: Map<string, ChoroplethRegion>;\n  setHover: (next?: Hover) => void;\n  valueFor: (id: string) => null | number;\n};\n\nconst ChoroplethContext = createContext<ChoroplethCtx | null>(null);\n\nfunction useChoroplethContext(): ChoroplethCtx {\n  const ctx = use(ChoroplethContext);\n  if (!ctx) {\n    throw new Error(\"ChoroplethMap subcomponent used outside its root.\");\n  }\n  return ctx;\n}\n\nfunction projectEquirectangular(\n  position: GeoPosition,\n  width: number,\n  height: number,\n): { x: number; y: number } {\n  const [lng, lat] = position;\n  const x = ((lng + 180) / 360) * width;\n  const y = ((90 - lat) / 180) * height;\n  return { x, y };\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n  return Math.min(Math.max(value, min), max);\n}\n\nfunction parseHex(color: string): [number, number, number] | undefined {\n  const match = /^#([\\da-f]{6})$/i.exec(color.trim());\n  if (!match) return undefined;\n  const hex = match[1] ?? \"\";\n  return [\n    Number.parseInt(hex.slice(0, 2), 16),\n    Number.parseInt(hex.slice(2, 4), 16),\n    Number.parseInt(hex.slice(4, 6), 16),\n  ];\n}\n\nfunction toHex(channel: number): string {\n  return clamp(Math.round(channel), 0, 255).toString(16).padStart(2, \"0\");\n}\n\nfunction lerp(a: number, b: number, t: number): number {\n  return a + (b - a) * t;\n}\n\nfunction interpolateColor(stops: string[], t: number): string {\n  if (stops.length === 0) return \"#000000\";\n  if (stops.length === 1) return stops[0] ?? \"#000000\";\n  const segments = stops.length - 1;\n  const scaledT = clamp(t, 0, 1) * segments;\n  const segmentIndex = Math.min(Math.floor(scaledT), segments - 1);\n  const localT = scaledT - segmentIndex;\n  const lower = stops[segmentIndex];\n  const upper = stops[segmentIndex + 1];\n  if (!lower || !upper) return stops[0] ?? \"#000000\";\n  const lowerRgb = parseHex(lower);\n  const upperRgb = parseHex(upper);\n  if (!lowerRgb || !upperRgb) return lower;\n  const [lr, lg, lb] = lowerRgb;\n  const [ur, ug, ub] = upperRgb;\n  const r = lerp(lr, ur, localT);\n  const g = lerp(lg, ug, localT);\n  const b = lerp(lb, ub, localT);\n  return `#${toHex(r)}${toHex(g)}${toHex(b)}`;\n}\n\nfunction computeDomain(values: number[]): [number, number] {\n  const first = values[0];\n  if (first === undefined) return [0, 1];\n  const min = values.reduce(\n    (accumulator, value) => Math.min(accumulator, value),\n    first,\n  );\n  const max = values.reduce(\n    (accumulator, value) => Math.max(accumulator, value),\n    first,\n  );\n  if (min === max) return [min, max + 1];\n  return [min, max];\n}\n\n/**\n * Props for {@link ChoroplethMap}.\n *\n * @public\n */\nexport type ChoroplethMapProps = {\n  /** Color stops. Two stops = sequential; three stops = diverging. Defaults to a blue ramp. */\n  colorScale?: ChoroplethColorScale;\n  /** Map of region id → numeric value. */\n  data: Record<string, number>;\n  /** Optional explicit `[min, max]` value domain. Defaults to the data extent. */\n  domain?: [number, number];\n  /** Localizable strings. */\n  labels?: ChoroplethMapLabels;\n  /** Color used when a region has no data. Defaults to the theme `--muted` token. */\n  missingColor?: string;\n  /** Fires after a region click. */\n  onSelectRegion?: (region: ChoroplethRegion) => void;\n  /** Region polygons. */\n  regions: ChoroplethRegion[];\n} & ComponentPropsWithoutRef<\"section\"> & {\n    ref?: React.Ref<HTMLElement>;\n  };\n\ntype RegionPathProps = {\n  active: boolean;\n  onSelect: (region: ChoroplethRegion) => void;\n  region: ChoroplethRegion;\n  selectedId?: string;\n  setHoverFn: (next?: Hover) => void;\n};\n\nfunction regionPath(region: ChoroplethRegion): string {\n  return region.coordinates\n    .map((coord, index) => {\n      const projected = projectEquirectangular(\n        coord,\n        VIEWBOX_WIDTH,\n        VIEWBOX_HEIGHT,\n      );\n      return `${index === 0 ? \"M\" : \"L\"}${projected.x.toString()},${projected.y.toString()}`;\n    })\n    .join(\" \");\n}\n\nfunction RegionPath({\n  active,\n  onSelect,\n  region,\n  selectedId,\n  setHoverFn,\n}: RegionPathProps): ReactNode {\n  const { colorFor, valueFor } = useChoroplethContext();\n  const value = valueFor(region.id) ?? undefined;\n  const fill = colorFor(value);\n  const handleEnter = (): void => {\n    setHoverFn({ id: region.id, value });\n  };\n  const handleLeave = (): void => {\n    setHoverFn();\n  };\n  const handleSelect = (): void => {\n    onSelect(region);\n  };\n  return (\n    <path\n      aria-label={`${region.name}${value === undefined ? \" no data\" : ` ${value.toString()}`}`}\n      className={cn(\n        \"cursor-pointer outline-none transition-[opacity,filter]\",\n        active ? \"opacity-100\" : \"opacity-90 hover:opacity-100\",\n        selectedId === region.id ? \"stroke-foreground\" : \"stroke-background\",\n      )}\n      d={regionPath(region) + \" Z\"}\n      data-region-id={region.id}\n      data-selected={selectedId === region.id ? \"true\" : undefined}\n      data-value={value}\n      fill={fill}\n      onBlur={handleLeave}\n      onClick={handleSelect}\n      onFocus={handleEnter}\n      onMouseEnter={handleEnter}\n      onMouseLeave={handleLeave}\n      strokeWidth={selectedId === region.id ? 2 : 0.75}\n      tabIndex={0}\n    />\n  );\n}\n\ntype ChoroplethTooltipRender = (arguments_: {\n  region: ChoroplethRegion;\n  value?: number;\n}) => ReactNode;\n\n/**\n * Tooltip slot. Pass a render-prop function via `children` for full\n * control, or omit it to use the default `Region Name · value` layout.\n *\n * @public\n */\nexport type ChoroplethTooltipProps = {\n  /** Render-prop receiving the hovered region + value. */\n  children?: ChoroplethTooltipRender;\n} & Omit<ComponentPropsWithoutRef<\"div\">, \"children\">;\n\nexport const ChoroplethTooltip = ({\n  children,\n  className,\n  ref,\n  ...rest\n}: ChoroplethTooltipProps & { ref?: React.Ref<HTMLDivElement> }) => {\n  const { hover, regionByid } = useChoroplethContext();\n  if (!hover) return null;\n  const region = regionByid.get(hover.id);\n  if (!region) return null;\n  return (\n    <div\n      className={cn(\n        \"pointer-events-none absolute left-3 top-3 z-10 max-w-xs rounded-md border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-md\",\n        className,\n      )}\n      data-tooltip-region-id={region.id}\n      ref={ref}\n      role=\"status\"\n      {...rest}\n    >\n      {children ? (\n        children({ region, value: hover.value })\n      ) : (\n        <span>\n          <span className=\"font-medium\">{region.name}</span>\n          {hover.value === undefined ? (\n            <span className=\"text-muted-foreground\"> · no data</span>\n          ) : (\n            <span> · {hover.value.toLocaleString()}</span>\n          )}\n        </span>\n      )}\n    </div>\n  );\n};\nChoroplethTooltip.displayName = \"ChoroplethTooltip\";\n\n/**\n * Legend slot. Renders a horizontal color ramp with min / max labels.\n *\n * @public\n */\nexport type ChoroplethLegendProps = {\n  /** Optional title rendered above the ramp. */\n  title?: ReactNode;\n} & Omit<ComponentPropsWithoutRef<\"div\">, \"children\">;\n\nexport const ChoroplethLegend = ({\n  className,\n  ref,\n  title,\n  ...rest\n}: ChoroplethLegendProps & { ref?: React.Ref<HTMLDivElement> }) => {\n  const { legend } = useChoroplethContext();\n  if (!legend) return null;\n  const stops = legend.scale.join(\", \");\n  return (\n    <div\n      className={cn(\n        \"absolute bottom-3 right-3 z-10 flex flex-col gap-1 rounded-md border bg-background/95 px-2 py-1 text-[11px] text-foreground shadow-sm backdrop-blur\",\n        className,\n      )}\n      data-legend\n      ref={ref}\n      {...rest}\n    >\n      {title ? (\n        <span className=\"font-medium uppercase tracking-wide text-muted-foreground\">\n          {title}\n        </span>\n      ) : null}\n      <div\n        aria-hidden=\"true\"\n        className=\"h-2 w-32 rounded-full\"\n        style={{ background: `linear-gradient(to right, ${stops})` }}\n      />\n      <div className=\"flex justify-between text-muted-foreground\">\n        <span>{legend.domain[0].toLocaleString()}</span>\n        <span>{legend.domain[1].toLocaleString()}</span>\n      </div>\n    </div>\n  );\n};\nChoroplethLegend.displayName = \"ChoroplethLegend\";\n\ntype DataSummaryProps = {\n  data: Record<string, number>;\n  regions: ChoroplethRegion[];\n  titleId: string;\n};\n\nfunction DataSummary({ data, regions, titleId }: DataSummaryProps): ReactNode {\n  return (\n    <div aria-labelledby={titleId} className=\"sr-only\" role=\"region\">\n      <h3 id={titleId}>Choropleth data summary</h3>\n      <table>\n        <thead>\n          <tr>\n            <th scope=\"col\">Region</th>\n            <th scope=\"col\">Value</th>\n          </tr>\n        </thead>\n        <tbody>\n          {regions.map((region) => {\n            const value = data[region.id];\n            return (\n              <tr key={region.id}>\n                <td>{region.name}</td>\n                <td>\n                  {value === undefined ? \"no data\" : value.toLocaleString()}\n                </td>\n              </tr>\n            );\n          })}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n\ntype ChildBuckets = {\n  legend: ReactNode;\n  tooltip: ReactNode;\n};\n\nfunction bucketChildren(children: ReactNode): ChildBuckets {\n  const list: ReactNode[] = Array.isArray(children) ? children : [children];\n  return list.reduce<ChildBuckets>(\n    (accumulator, child) => {\n      const name = displayName(child);\n      if (name === ChoroplethLegend.displayName) accumulator.legend = child;\n      else if (name === ChoroplethTooltip.displayName)\n        accumulator.tooltip = child;\n      return accumulator;\n    },\n    { legend: null, tooltip: null },\n  );\n}\n\nfunction displayName(child: ReactNode): string | undefined {\n  if (typeof child !== \"object\" || child === null) return undefined;\n  if (!(\"type\" in child)) return undefined;\n  const type = (child as { type: unknown }).type;\n  if (typeof type !== \"object\" && typeof type !== \"function\") return undefined;\n  const name = (type as { displayName?: unknown }).displayName;\n  return typeof name === \"string\" ? name : undefined;\n}\n\nfunction useChoroplethState(arguments_: {\n  colorScale: ChoroplethColorScale;\n  data: Record<string, number>;\n  domain: [number, number];\n  missingColor: string;\n  regions: ChoroplethRegion[];\n}): ChoroplethCtx {\n  const { colorScale, data, domain, missingColor, regions } = arguments_;\n  const regionByid = useMemo(\n    () =>\n      new Map<string, ChoroplethRegion>(\n        regions.map((region) => [region.id, region]),\n      ),\n    [regions],\n  );\n\n  const valueFor = useCallback(\n    (id: string): null | number => data[id] ?? null,\n    [data],\n  );\n\n  const colorFor = useCallback(\n    (value?: number): string => {\n      if (value === undefined) return missingColor;\n      const [min, max] = domain;\n      const span = max - min;\n      const t = span === 0 ? 0.5 : (value - min) / span;\n      return interpolateColor(colorScale, t);\n    },\n    [colorScale, domain, missingColor],\n  );\n\n  const [hover, setHover] = useState<Hover | undefined>();\n\n  return useMemo<ChoroplethCtx>(\n    () => ({\n      colorFor,\n      hover,\n      legend: { domain, scale: colorScale },\n      regionByid,\n      setHover,\n      valueFor,\n    }),\n    [colorFor, colorScale, domain, hover, regionByid, valueFor],\n  );\n}\n\ntype RegionsLayerProps = {\n  onSelect: (region: ChoroplethRegion) => void;\n  regions: ChoroplethRegion[];\n  selectedId?: string;\n  setHoverFn: (next?: Hover) => void;\n};\n\nfunction RegionsLayer({\n  onSelect,\n  regions,\n  selectedId,\n  setHoverFn,\n}: RegionsLayerProps): ReactNode {\n  return (\n    <g>\n      {regions.map((region) => (\n        <RegionPath\n          active={selectedId === region.id}\n          key={region.id}\n          onSelect={onSelect}\n          region={region}\n          selectedId={selectedId}\n          setHoverFn={setHoverFn}\n        />\n      ))}\n    </g>\n  );\n}\n\n/**\n * Region-colored data map (choropleth). Standalone SVG primitive — no\n * external map library or tile provider required. Pass an array of\n * {@link ChoroplethRegion} polygons, a `data` map (region id → numeric\n * value), and an optional `colorScale`. Hover any region to surface the\n * tooltip; click to fire `onSelectRegion`.\n *\n * Compose with {@link ChoroplethLegend} (color ramp + min / max labels)\n * and {@link ChoroplethTooltip} (custom render-prop).\n *\n * @example\n * ```tsx\n * <ChoroplethMap\n *   regions={countries}\n *   data={{ FR: 2937, DE: 4082, IT: 2107 }}\n *   colorScale={[\"#f1f5f9\", \"#1d4ed8\"]}\n * >\n *   <ChoroplethTooltip />\n *   <ChoroplethLegend title=\"GDP (B USD)\" />\n * </ChoroplethMap>\n * ```\n *\n * @public\n */\nexport const ChoroplethMap = (props: ChoroplethMapProps) => {\n  const {\n    children,\n    className,\n    colorScale = DEFAULT_SCALE,\n    data,\n    domain: domainProperty,\n    labels,\n    missingColor = resolveMissingColor(),\n    onSelectRegion,\n    ref,\n    regions,\n    ...rest\n  } = props;\n  const titleId = useId();\n  const resolvedLabels = useMemo(\n    () => ({ ...DEFAULT_LABELS, ...labels }),\n    [labels],\n  );\n\n  const domain = useMemo(\n    () => domainProperty ?? computeDomain(Object.values(data)),\n    [data, domainProperty],\n  );\n\n  const ctx = useChoroplethState({\n    colorScale,\n    data,\n    domain,\n    missingColor,\n    regions,\n  });\n  const buckets = useMemo(() => bucketChildren(children), [children]);\n\n  const [selectedId, setSelectedId] = useState<string | undefined>();\n\n  const handleSelect = useCallback(\n    (region: ChoroplethRegion) => {\n      setSelectedId(region.id);\n      onSelectRegion?.(region);\n    },\n    [onSelectRegion],\n  );\n\n  return (\n    <ChoroplethContext.Provider value={ctx}>\n      <section\n        aria-label={resolvedLabels.region}\n        className={cn(\n          \"relative aspect-[2/1] w-full overflow-hidden rounded-2xl border bg-background text-foreground\",\n          className,\n        )}\n        ref={ref}\n        {...rest}\n      >\n        <svg\n          aria-hidden=\"true\"\n          className=\"block h-full w-full\"\n          preserveAspectRatio=\"xMidYMid meet\"\n          viewBox={`0 0 ${VIEWBOX_WIDTH.toString()} ${VIEWBOX_HEIGHT.toString()}`}\n        >\n          <RegionsLayer\n            onSelect={handleSelect}\n            regions={regions}\n            selectedId={selectedId}\n            setHoverFn={ctx.setHover}\n          />\n        </svg>\n        {buckets.tooltip}\n        {buckets.legend}\n        <DataSummary data={data} regions={regions} titleId={titleId} />\n      </section>\n    </ChoroplethContext.Provider>\n  );\n};\nChoroplethMap.displayName = \"ChoroplethMap\";\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component",
  "version": "0.3.0",
  "stability": "stable"
}
