{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "choropleth-map",
  "type": "registry:component",
  "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.2.1"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/choropleth-map/choropleth-map.tsx",
      "content": "\"use client\";\n\nimport {\n  type ComponentPropsWithoutRef,\n  createContext,\n  forwardRef,\n  type ReactNode,\n  useCallback,\n  useContext,\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\nconst DEFAULT_SCALE: ChoroplethColorScale = [\"#f1f5f9\", \"#1d4ed8\"];\nconst DEFAULT_MISSING = \"#e5e7eb\";\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 = useContext(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 a neutral gray. */\n  missingColor?: string;\n  /** Fires after a region click. */\n  onSelectRegion?: (region: ChoroplethRegion) => void;\n  /** Region polygons. */\n  regions: ChoroplethRegion[];\n} & ComponentPropsWithoutRef<\"section\">;\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 = forwardRef<\n  HTMLDivElement,\n  ChoroplethTooltipProps\n>(({ children, className, ...rest }, ref) => {\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 = forwardRef<\n  HTMLDivElement,\n  ChoroplethLegendProps\n>(({ className, title, ...rest }, ref) => {\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 = forwardRef<HTMLElement, ChoroplethMapProps>(\n  (props, ref) => {\n    const {\n      children,\n      className,\n      colorScale = DEFAULT_SCALE,\n      data,\n      domain: domainProperty,\n      labels,\n      missingColor = DEFAULT_MISSING,\n      onSelectRegion,\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  },\n);\nChoroplethMap.displayName = \"ChoroplethMap\";\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
