{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "choropleth",
  "title": "Choropleth",
  "description": "World choropleth map shading countries by a metric, with a hover tooltip and legend.",
  "dependencies": [
    "maplibre-gl",
    "next-themes"
  ],
  "registryDependencies": [
    "@mapcn/map"
  ],
  "files": [
    {
      "path": "src/registry/blocks/choropleth/page.tsx",
      "content": "\"use client\";\n\nimport { useMemo, useState } from \"react\";\nimport { useTheme } from \"next-themes\";\n\nimport { Map, MapControls, MapGeoJSON, MapPopup } from \"@/components/ui/map\";\nimport { useWorldData } from \"@/lib/use-world-data\";\nimport { mapConfig, visitorsByCountry, type Theme } from \"./data\";\n\n// WebGL paint can't read CSS variables, so we build a concrete value→color\n// expression per theme. The hovered country is highlighted in place via the\n// `hover` feature-state, restricted to countries that actually have data.\nfunction buildFillColor(theme: Theme): unknown[] {\n  const { base, ramp, hover } = mapConfig.colors[theme];\n  const [s0, s1, s2, s3, s4] = mapConfig.scaleStops;\n  const ramped = [\n    \"interpolate\",\n    [\"linear\"],\n    [\"coalesce\", [\"get\", \"visitors\"], 0],\n    s0,\n    base,\n    s1,\n    ramp[0],\n    s2,\n    ramp[1],\n    s3,\n    ramp[2],\n    s4,\n    ramp[3],\n  ];\n  return [\n    \"case\",\n    [\n      \"all\",\n      [\"boolean\", [\"feature-state\", \"hover\"], false],\n      [\">\", [\"coalesce\", [\"get\", \"visitors\"], 0], 0],\n    ],\n    hover,\n    ramped,\n  ];\n}\n\nconst legendGradientStyle = {\n  \"--choropleth-ramp-light\": `linear-gradient(to right, ${mapConfig.colors.light.ramp.join(\", \")})`,\n  \"--choropleth-ramp-dark\": `linear-gradient(to right, ${mapConfig.colors.dark.ramp.join(\", \")})`,\n} as React.CSSProperties;\n\ninterface HoverInfo {\n  name: string;\n  visitors: number;\n  lng: number;\n  lat: number;\n}\n\ninterface CountryProperties {\n  NAME_LONG: string;\n  visitors: number;\n}\n\ntype CountryFeatureCollection = GeoJSON.FeatureCollection<\n  GeoJSON.Geometry,\n  CountryProperties\n>;\n\nexport default function Page() {\n  const { resolvedTheme } = useTheme();\n  const theme: Theme = resolvedTheme === \"dark\" ? \"dark\" : \"light\";\n  const [hover, setHover] = useState<HoverInfo | null>(null);\n  const world = useWorldData();\n\n  const countries = useMemo<CountryFeatureCollection | null>(() => {\n    if (!world) return null;\n    return {\n      type: \"FeatureCollection\",\n      features: world.features.map((f) => ({\n        ...f,\n        properties: {\n          NAME_LONG: f.properties.NAME_LONG,\n          visitors: visitorsByCountry[f.properties.NAME_LONG] ?? 0,\n        },\n      })),\n    };\n  }, [world]);\n\n  // Recompute paint only when the theme changes; MapGeoJSON recolors in place.\n  const fillPaint = useMemo(\n    () => ({\n      \"fill-color\": buildFillColor(theme) as never,\n      \"fill-opacity\": 0.92,\n    }),\n    [theme],\n  );\n\n  return (\n    <div className=\"bg-card relative h-screen overflow-hidden\">\n      <Map\n        blank\n        center={mapConfig.view.center}\n        zoom={mapConfig.view.zoom}\n        minZoom={mapConfig.view.minZoom}\n        maxZoom={mapConfig.view.maxZoom}\n        scrollZoom={false}\n        dragRotate={false}\n        pitchWithRotate={false}\n        loading={!countries}\n      >\n        {countries && (\n          <MapGeoJSON<CountryProperties>\n            data={countries}\n            promoteId=\"NAME_LONG\"\n            fillPaint={fillPaint}\n            interactive\n            onHover={(e) => {\n              const visitors = e?.feature.properties.visitors ?? 0;\n              // Only countries with data are interactive.\n              if (!e || visitors <= 0) {\n                setHover(null);\n                return;\n              }\n              setHover({\n                name: e.feature.properties.NAME_LONG,\n                visitors,\n                lng: e.longitude,\n                lat: e.latitude,\n              });\n            }}\n          />\n        )}\n        <MapControls className=\"bottom-2\" />\n        {hover && (\n          <MapPopup\n            longitude={hover.lng}\n            latitude={hover.lat}\n            offset={12}\n            closeOnClick={false}\n            className=\"pointer-events-none p-2\"\n          >\n            <p className=\"text-xs font-medium\">{hover.name}</p>\n            <div className=\"flex items-center justify-between gap-4 pt-1\">\n              <span className=\"text-muted-foreground flex items-center gap-1.5 text-[11px]\">\n                <span\n                  className=\"size-2 rounded-full\"\n                  style={{ backgroundColor: mapConfig.colors[theme].hover }}\n                />\n                Visitors\n              </span>\n              <span className=\"text-foreground text-xs font-semibold tabular-nums\">\n                {hover.visitors.toLocaleString()}\n              </span>\n            </div>\n          </MapPopup>\n        )}\n      </Map>\n\n      <div\n        className=\"bg-card absolute bottom-4 left-4 z-10 rounded-lg border px-3 py-2.5 backdrop-blur-sm\"\n        style={legendGradientStyle}\n      >\n        <p className=\"text-foreground text-xs font-medium\">\n          Visitors by country\n        </p>\n        <div className=\"mt-2 h-2 w-40 rounded-full [background-image:var(--choropleth-ramp-light)] dark:[background-image:var(--choropleth-ramp-dark)]\" />\n        <div className=\"text-muted-foreground flex items-center justify-between pt-1.5 text-[10px]\">\n          <span>Low</span>\n          <span>High</span>\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:page",
      "target": "app/choropleth/page.tsx"
    },
    {
      "path": "src/registry/blocks/choropleth/data.ts",
      "content": "/** Visitor counts keyed by the feature `NAME_LONG`. */\nexport const visitorsByCountry: Record<string, number> = {\n  \"United States\": 100,\n  Canada: 70,\n  Brazil: 65,\n  \"United Kingdom\": 95,\n  Germany: 80,\n  France: 55,\n  India: 90,\n  China: 45,\n  Japan: 35,\n  Australia: 25,\n  \"South Africa\": 20,\n  Egypt: 15,\n};\nexport type Theme = \"light\" | \"dark\";\n\n/** Resolved colors for the choropleth, per theme. */\ninterface ChoroplethColors {\n  /** Fill for countries with no data (value 0). */\n  base: string;\n  /** Sequential fill ramp from low → high, mapped to `scaleStops`. */\n  ramp: [string, string, string, string];\n  /** Fill for the hovered country. */\n  hover: string;\n}\n\n/**\n * Central map config: colors, the value→color scale, and the initial view.\n * Colors are concrete hex values (not CSS variables) since WebGL paint can't\n * read them. Update `colors.light`/`colors.dark` below for custom colors.\n */\nexport const mapConfig = {\n  view: {\n    center: [12, 28] as [number, number],\n    zoom: 1.4,\n    minZoom: 1,\n    maxZoom: 4,\n  },\n  scaleStops: [0, 25, 50, 75, 100] as const,\n  colors: {\n    light: {\n      base: \"#f0f0f0\",\n      ramp: [\"#d4d4d4\", \"#a3a3a3\", \"#737373\", \"#404040\"],\n      hover: \"#0a0a0a\",\n    },\n    dark: {\n      base: \"#2a2a2a\",\n      ramp: [\"#404040\", \"#737373\", \"#a3a3a3\", \"#d4d4d4\"],\n      hover: \"#ffffff\",\n    },\n  } satisfies Record<Theme, ChoroplethColors>,\n};\n",
      "type": "registry:component",
      "target": "app/choropleth/data.ts"
    },
    {
      "path": "src/lib/use-world-data.ts",
      "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\nexport const WORLD_GEOJSON =\n  \"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@v5.1.2/geojson/ne_110m_admin_0_countries.geojson\";\n\nexport interface WorldFeatureProperties {\n  NAME_LONG: string;\n}\n\nexport type WorldData = GeoJSON.FeatureCollection<\n  GeoJSON.Geometry,\n  WorldFeatureProperties\n>;\n\nexport function useWorldData(url: string = WORLD_GEOJSON): WorldData | null {\n  const [data, setData] = useState<WorldData | null>(null);\n\n  useEffect(() => {\n    let active = true;\n    fetch(url)\n      .then((res) => res.json() as Promise<WorldData>)\n      .then((world) => {\n        if (active) setData(world);\n      });\n    return () => {\n      active = false;\n    };\n  }, [url]);\n\n  return data;\n}\n",
      "type": "registry:lib",
      "target": "@lib/use-world-data.ts"
    }
  ],
  "meta": {
    "iframeHeight": "720px"
  },
  "categories": [
    "visualization",
    "choropleth"
  ],
  "type": "registry:block"
}
