{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "map-2d",
  "type": "registry:component",
  "title": "Map 2D",
  "description": "Lightweight 2D map primitive — SVG canvas with equirectangular projection, markers, popups, GeoJSON polygon layers, zoom controls, and an optional backdrop image.",
  "dependencies": [
    "@vllnt/ui@^0.2.1"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/map-2d/map-2d.tsx",
      "content": "\"use client\";\n\nimport {\n  type ComponentPropsWithoutRef,\n  createContext,\n  forwardRef,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nimport { cn } from \"@vllnt/ui\";\n\nconst MIN_ZOOM = 1;\nconst MAX_ZOOM = 32;\nconst ZOOM_STEP = 1.5;\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 * Localizable strings.\n *\n * @public\n */\nexport type Map2DLabels = {\n  /** Aria-label for the map region. Defaults to `\"Map\"`. */\n  region?: string;\n  /** Aria-label for the zoom-in button. Defaults to `\"Zoom in\"`. */\n  zoomIn?: string;\n  /** Aria-label for the zoom-out button. Defaults to `\"Zoom out\"`. */\n  zoomOut?: string;\n};\n\nconst DEFAULT_LABELS = {\n  region: \"Map\",\n  zoomIn: \"Zoom in\",\n  zoomOut: \"Zoom out\",\n} as const satisfies Required<Map2DLabels>;\n\ntype MapCtx = {\n  height: number;\n  labels: Required<Map2DLabels>;\n  pan: { x: number; y: number };\n  project: (position: GeoPosition) => { x: number; y: number };\n  setPan: (next: { x: number; y: number }) => void;\n  width: number;\n  zoom: number;\n  zoomIn: () => void;\n  zoomOut: () => void;\n};\n\nconst MapContext = createContext<MapCtx | null>(null);\n\nfunction useMapContext(): MapCtx {\n  const ctx = useContext(MapContext);\n  if (!ctx) {\n    throw new Error(\"Map2D subcomponent used outside its root.\");\n  }\n  return ctx;\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n  return Math.min(Math.max(value, min), max);\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\n/**\n * Props for {@link Map2D}.\n *\n * @public\n */\nexport type Map2DProps = {\n  /** Optional URL of a backdrop image (world map, terrain, etc.). */\n  backdrop?: string;\n  /** Aria-label for an optional backdrop image. */\n  backdropAlt?: string;\n  /** Initial center as `[lng, lat]`. Defaults to `[0, 20]`. */\n  center?: GeoPosition;\n  /** Localizable strings. */\n  labels?: Map2DLabels;\n  /** Initial zoom factor. Defaults to `1`. */\n  zoom?: number;\n} & ComponentPropsWithoutRef<\"section\">;\n\nfunction useMapState(arguments_: {\n  center: GeoPosition;\n  initialZoom: number;\n  resolvedLabels: Required<Map2DLabels>;\n}): MapCtx {\n  const { center, initialZoom, resolvedLabels } = arguments_;\n  const initialPan = useMemo(() => {\n    const target = projectEquirectangular(\n      center,\n      VIEWBOX_WIDTH,\n      VIEWBOX_HEIGHT,\n    );\n    return {\n      x: VIEWBOX_WIDTH / 2 - target.x,\n      y: VIEWBOX_HEIGHT / 2 - target.y,\n    };\n  }, [center]);\n  const [pan, setPan] = useState<{ x: number; y: number }>(initialPan);\n  const [zoom, setZoom] = useState<number>(\n    clamp(initialZoom, MIN_ZOOM, MAX_ZOOM),\n  );\n\n  const zoomIn = useCallback(() => {\n    setZoom((current) => clamp(current * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM));\n  }, []);\n  const zoomOut = useCallback(() => {\n    setZoom((current) => clamp(current / ZOOM_STEP, MIN_ZOOM, MAX_ZOOM));\n  }, []);\n\n  const project = useCallback(\n    (position: GeoPosition) =>\n      projectEquirectangular(position, VIEWBOX_WIDTH, VIEWBOX_HEIGHT),\n    [],\n  );\n\n  return useMemo(\n    () => ({\n      height: VIEWBOX_HEIGHT,\n      labels: resolvedLabels,\n      pan,\n      project,\n      setPan,\n      width: VIEWBOX_WIDTH,\n      zoom,\n      zoomIn,\n      zoomOut,\n    }),\n    [pan, project, resolvedLabels, zoom, zoomIn, zoomOut],\n  );\n}\n\n/**\n * Container for `MapZoomIn` / `MapZoomOut` controls.\n *\n * @public\n */\nexport const MapControls = forwardRef<\n  HTMLDivElement,\n  ComponentPropsWithoutRef<\"div\">\n>(({ children, className, ...rest }, ref) => (\n  <div\n    aria-label=\"Map controls\"\n    className={cn(\n      \"absolute right-3 top-3 z-20 flex flex-col gap-1 rounded-md border border-border bg-background/95 p-1 shadow-sm backdrop-blur\",\n      className,\n    )}\n    ref={ref}\n    {...rest}\n  >\n    {children}\n  </div>\n));\nMapControls.displayName = \"MapControls\";\n\ntype ControlButtonProps = {\n  ariaLabel: string;\n  glyph: ReactNode;\n  onActivate: () => void;\n} & Omit<ComponentPropsWithoutRef<\"button\">, \"aria-label\" | \"onClick\" | \"type\">;\n\nconst ControlButton = forwardRef<HTMLButtonElement, ControlButtonProps>(\n  ({ ariaLabel, className, glyph, onActivate, ...rest }, ref) => (\n    <button\n      aria-label={ariaLabel}\n      className={cn(\n        \"inline-flex size-7 items-center justify-center rounded text-sm font-semibold hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n        className,\n      )}\n      onClick={onActivate}\n      ref={ref}\n      type=\"button\"\n      {...rest}\n    >\n      {glyph}\n    </button>\n  ),\n);\nControlButton.displayName = \"ControlButton\";\n\n/**\n * Zoom-in button. Multiplies the zoom factor up to a max.\n *\n * @public\n */\nexport const MapZoomIn = forwardRef<\n  HTMLButtonElement,\n  Omit<ComponentPropsWithoutRef<\"button\">, \"aria-label\" | \"onClick\" | \"type\">\n>(({ ...rest }, ref) => {\n  const { labels, zoomIn } = useMapContext();\n  return (\n    <ControlButton\n      ariaLabel={labels.zoomIn}\n      glyph=\"+\"\n      onActivate={zoomIn}\n      ref={ref}\n      {...rest}\n    />\n  );\n});\nMapZoomIn.displayName = \"MapZoomIn\";\n\n/**\n * Zoom-out button.\n *\n * @public\n */\nexport const MapZoomOut = forwardRef<\n  HTMLButtonElement,\n  Omit<ComponentPropsWithoutRef<\"button\">, \"aria-label\" | \"onClick\" | \"type\">\n>(({ ...rest }, ref) => {\n  const { labels, zoomOut } = useMapContext();\n  return (\n    <ControlButton\n      ariaLabel={labels.zoomOut}\n      glyph=\"−\"\n      onActivate={zoomOut}\n      ref={ref}\n      {...rest}\n    />\n  );\n});\nMapZoomOut.displayName = \"MapZoomOut\";\n\n/**\n * Props for {@link MapMarker}.\n *\n * @public\n */\nexport type MapMarkerProps = {\n  /** Optional click handler. */\n  onSelect?: () => void;\n  /** Optional popup content rendered above the marker on hover/focus. */\n  popup?: ReactNode;\n  /** Geographic position. */\n  position: GeoPosition;\n  /** Optional accessible label. */\n  title?: string;\n} & Omit<ComponentPropsWithoutRef<\"button\">, \"onClick\" | \"title\" | \"type\">;\n\n/**\n * Custom marker icon slot. Pass any SVG element as children. Falls back\n * to a circle if omitted.\n *\n * @public\n */\nexport const MapMarkerIcon = forwardRef<\n  SVGGElement,\n  ComponentPropsWithoutRef<\"g\">\n>(({ children, className, ...rest }, ref) => (\n  <g className={cn(\"text-primary\", className)} ref={ref} {...rest}>\n    {children}\n  </g>\n));\nMapMarkerIcon.displayName = \"MapMarkerIcon\";\n\ntype MarkerVisualProps = {\n  children?: ReactNode;\n};\n\nfunction MarkerVisual({ children }: MarkerVisualProps): ReactNode {\n  if (children) return children;\n  return (\n    <g>\n      <circle\n        className=\"fill-primary stroke-background\"\n        cx=\"0\"\n        cy=\"0\"\n        r=\"7\"\n        strokeWidth=\"2\"\n      />\n      <circle className=\"fill-background\" cx=\"0\" cy=\"0\" r=\"2\" />\n    </g>\n  );\n}\n\n/**\n * A marker placed at a geographic position.\n *\n * @public\n */\nexport const MapMarker = forwardRef<HTMLButtonElement, MapMarkerProps>(\n  (props, ref) => {\n    const { children, className, onSelect, popup, position, title, ...rest } =\n      props;\n    const { project } = useMapContext();\n    const point = project(position);\n    const markerId = useId();\n    const popupId = `${markerId}-popup`;\n    const labelText =\n      title ??\n      (typeof popup === \"string\" ? popup : `Marker at ${position.join(\", \")}`);\n\n    return (\n      <foreignObject height=\"48\" width=\"48\" x={point.x - 24} y={point.y - 24}>\n        <button\n          aria-describedby={popup ? popupId : undefined}\n          aria-label={labelText}\n          className={cn(\n            \"group relative inline-flex h-full w-full cursor-pointer items-center justify-center bg-transparent p-0 outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n            className,\n          )}\n          data-marker-id={markerId}\n          onClick={onSelect}\n          ref={ref}\n          type=\"button\"\n          {...rest}\n        >\n          <svg\n            className=\"pointer-events-none size-6 overflow-visible\"\n            viewBox=\"-10 -10 20 20\"\n          >\n            <MarkerVisual>{children}</MarkerVisual>\n          </svg>\n          {popup ? (\n            <span\n              className=\"pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden min-w-32 max-w-xs -translate-x-1/2 rounded-md border bg-popover px-2 py-1 text-center text-xs text-popover-foreground shadow-md group-hover:block group-focus-visible:block\"\n              id={popupId}\n              role=\"tooltip\"\n            >\n              {popup}\n            </span>\n          ) : null}\n        </button>\n      </foreignObject>\n    );\n  },\n);\nMapMarker.displayName = \"MapMarker\";\n\n/**\n * Props for {@link MapPopup}.\n *\n * @public\n */\nexport type MapPopupProps = {\n  /** Geographic anchor. */\n  position: GeoPosition;\n} & ComponentPropsWithoutRef<\"div\">;\n\n/**\n * Always-visible popup anchored at a geographic position. Use this when\n * you need a popup that lives outside the marker hover lifecycle.\n *\n * @public\n */\nexport const MapPopup = forwardRef<HTMLDivElement, MapPopupProps>(\n  (props, ref) => {\n    const { children, className, position, ...rest } = props;\n    const { project } = useMapContext();\n    const point = project(position);\n    return (\n      <foreignObject\n        height=\"200\"\n        width=\"320\"\n        x={point.x - 160}\n        y={point.y - 220}\n      >\n        <div\n          className={cn(\n            \"pointer-events-auto inline-block max-w-xs -translate-y-2 rounded-md border bg-popover px-3 py-2 text-sm text-popover-foreground shadow-md\",\n            className,\n          )}\n          ref={ref}\n          {...rest}\n        >\n          {children}\n        </div>\n      </foreignObject>\n    );\n  },\n);\nMapPopup.displayName = \"MapPopup\";\n\n/**\n * GeoJSON polygon-style payload accepted by {@link MapLayer}.\n *\n * @public\n */\nexport type GeoJSONPolygon = {\n  /** Outer ring positions; close the ring by repeating the first point. */\n  coordinates: GeoPosition[];\n  /** Stable id. */\n  id: string;\n  /** Polygon kind. */\n  type: \"polygon\";\n};\n\n/**\n * Props for {@link MapLayer}.\n *\n * @public\n */\nexport type MapLayerProps = {\n  /** Polygon shapes to render. */\n  data: GeoJSONPolygon[];\n  /** Fill color (CSS color). Defaults to `\"rgba(59,130,246,0.15)\"` (blue/15). */\n  fill?: string;\n  /** Stroke color (CSS color). Defaults to `\"currentColor\"`. */\n  stroke?: string;\n  /** Stroke width in viewBox units. Defaults to `2`. */\n  strokeWidth?: number;\n} & Omit<ComponentPropsWithoutRef<\"g\">, \"fill\">;\n\n/**\n * GeoJSON-style polygon overlay layer. Pass `data` as an array of polygon\n * descriptors; the marker projection handles the coordinates the same way.\n *\n * @public\n */\nexport const MapLayer = forwardRef<SVGGElement, MapLayerProps>((props, ref) => {\n  const {\n    className,\n    data,\n    fill = \"rgba(59, 130, 246, 0.15)\",\n    stroke = \"currentColor\",\n    strokeWidth = 2,\n    ...rest\n  } = props;\n  const { project } = useMapContext();\n  return (\n    <g\n      className={cn(\"text-blue-500/70\", className)}\n      data-layer=\"polygon\"\n      ref={ref}\n      {...rest}\n    >\n      {data.map((shape) => {\n        const points = shape.coordinates\n          .map((coord) => {\n            const projected = project(coord);\n            return `${projected.x.toString()},${projected.y.toString()}`;\n          })\n          .join(\" \");\n        return (\n          <polygon\n            data-shape-id={shape.id}\n            fill={fill}\n            key={shape.id}\n            points={points}\n            stroke={stroke}\n            strokeWidth={strokeWidth}\n          />\n        );\n      })}\n    </g>\n  );\n});\nMapLayer.displayName = \"MapLayer\";\n\ntype StageProps = {\n  backdrop?: string;\n  backdropAlt?: string;\n  children?: ReactNode;\n};\n\ntype DragState = null | {\n  originPan: { x: number; y: number };\n  originX: number;\n  originY: number;\n};\n\ntype PanHandlers = {\n  onPointerCancel: (event: ReactPointerEvent<SVGSVGElement>) => void;\n  onPointerDown: (event: ReactPointerEvent<SVGSVGElement>) => void;\n  onPointerMove: (event: ReactPointerEvent<SVGSVGElement>) => void;\n  onPointerUp: (event: ReactPointerEvent<SVGSVGElement>) => void;\n};\n\nfunction usePanHandlers(): PanHandlers {\n  const { height, pan, setPan, width, zoom } = useMapContext();\n  const dragRef = useRef<DragState>(null);\n\n  const onPointerDown = useCallback(\n    (event: ReactPointerEvent<SVGSVGElement>): void => {\n      dragRef.current = {\n        originPan: pan,\n        originX: event.clientX,\n        originY: event.clientY,\n      };\n      event.currentTarget.setPointerCapture(event.pointerId);\n    },\n    [pan],\n  );\n\n  const onPointerMove = useCallback(\n    (event: ReactPointerEvent<SVGSVGElement>): void => {\n      const drag = dragRef.current;\n      if (!drag) return;\n      const target = event.currentTarget;\n      const rect = target.getBoundingClientRect();\n      if (rect.width <= 0 || rect.height <= 0) return;\n      const scaleX = width / rect.width;\n      const scaleY = height / rect.height;\n      const dx = (event.clientX - drag.originX) * scaleX;\n      const dy = (event.clientY - drag.originY) * scaleY;\n      setPan({\n        x: drag.originPan.x + dx / zoom,\n        y: drag.originPan.y + dy / zoom,\n      });\n    },\n    [height, setPan, width, zoom],\n  );\n\n  const onPointerEnd = useCallback(\n    (event: ReactPointerEvent<SVGSVGElement>): void => {\n      const target = event.currentTarget;\n      if (target.hasPointerCapture(event.pointerId)) {\n        target.releasePointerCapture(event.pointerId);\n      }\n      dragRef.current = null;\n    },\n    [],\n  );\n\n  return {\n    onPointerCancel: onPointerEnd,\n    onPointerDown,\n    onPointerMove,\n    onPointerUp: onPointerEnd,\n  };\n}\n\nfunction Stage({ backdrop, backdropAlt, children }: StageProps): ReactNode {\n  const { height, pan, width, zoom } = useMapContext();\n  const handlers = usePanHandlers();\n\n  const innerWidth = width / zoom;\n  const innerHeight = height / zoom;\n  const viewX = (width - innerWidth) / 2 - pan.x;\n  const viewY = (height - innerHeight) / 2 - pan.y;\n\n  return (\n    <svg\n      className=\"block h-full w-full cursor-grab touch-none active:cursor-grabbing\"\n      data-pan-x={pan.x}\n      data-pan-y={pan.y}\n      data-zoom={zoom}\n      preserveAspectRatio=\"xMidYMid slice\"\n      role=\"presentation\"\n      viewBox={`${viewX.toString()} ${viewY.toString()} ${innerWidth.toString()} ${innerHeight.toString()}`}\n      {...handlers}\n    >\n      <rect className=\"fill-muted\" height={height} width={width} x=\"0\" y=\"0\" />\n      {backdrop ? (\n        <image\n          aria-label={backdropAlt}\n          height={height}\n          href={backdrop}\n          preserveAspectRatio=\"xMidYMid slice\"\n          width={width}\n          x=\"0\"\n          y=\"0\"\n        />\n      ) : null}\n      {children}\n    </svg>\n  );\n}\n\ntype ChildBuckets = {\n  controls: ReactNode;\n  layers: ReactNode[];\n  markers: ReactNode[];\n  popups: ReactNode[];\n};\n\nconst SLOT_DISPLAY_NAMES = {\n  controls: MapControls.displayName,\n  layer: MapLayer.displayName,\n  marker: MapMarker.displayName,\n  popup: MapPopup.displayName,\n} as const;\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\ntype SlotKey = \"controls\" | \"layer\" | \"marker\" | \"popup\";\n\nconst SLOT_KEY_BY_NAME: Record<string, SlotKey> = {\n  [SLOT_DISPLAY_NAMES.controls]: \"controls\",\n  [SLOT_DISPLAY_NAMES.layer]: \"layer\",\n  [SLOT_DISPLAY_NAMES.marker]: \"marker\",\n  [SLOT_DISPLAY_NAMES.popup]: \"popup\",\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) return accumulator;\n      const key = SLOT_KEY_BY_NAME[name];\n      if (!key) return accumulator;\n      switch (key) {\n        case \"controls\":\n          accumulator.controls = child;\n          break;\n\n        case \"marker\":\n          accumulator.markers.push(child);\n          break;\n\n        case \"layer\":\n          accumulator.layers.push(child);\n          break;\n\n        case \"popup\":\n          accumulator.popups.push(child);\n          break;\n      }\n      return accumulator;\n    },\n    { controls: null, layers: [], markers: [], popups: [] },\n  );\n}\n\n/**\n * Lightweight 2D map primitive — renders an SVG canvas with an\n * equirectangular projection so children placed by `[lng, lat]` land in\n * the right spot. Compose with {@link MapMarker}, {@link MapPopup},\n * {@link MapLayer}, and {@link MapControls}. An optional backdrop image\n * (Natural Earth SVG, terrain raster, etc.) renders behind the overlays.\n *\n * Out of scope for the MVP: live map tiles (no Mapbox / MapLibre runtime),\n * marker clustering, fullscreen mode, scale + compass controls, fit-bounds.\n * The component stays small enough to drop into any project — swap the\n * backdrop image to change the basemap.\n *\n * @example\n * ```tsx\n * <Map2D center={[2.3522, 48.8566]} zoom={4}>\n *   <MapMarker position={[2.3522, 48.8566]} popup=\"Paris\" />\n *   <MapMarker position={[-0.1276, 51.5074]} popup=\"London\" />\n *   <MapControls>\n *     <MapZoomIn />\n *     <MapZoomOut />\n *   </MapControls>\n * </Map2D>\n * ```\n *\n * @public\n */\nexport const Map2D = forwardRef<HTMLElement, Map2DProps>((props, ref) => {\n  const {\n    backdrop,\n    backdropAlt,\n    center = [0, 20],\n    children,\n    className,\n    labels,\n    zoom: initialZoom = 1,\n    ...rest\n  } = props;\n\n  const resolvedLabels = useMemo(\n    () => ({ ...DEFAULT_LABELS, ...labels }),\n    [labels],\n  );\n\n  const ctx = useMapState({ center, initialZoom, resolvedLabels });\n  const buckets = useMemo(() => bucketChildren(children), [children]);\n\n  return (\n    <MapContext.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        <Stage backdrop={backdrop} backdropAlt={backdropAlt}>\n          {buckets.layers}\n          {buckets.markers}\n          {buckets.popups}\n        </Stage>\n        {buckets.controls}\n      </section>\n    </MapContext.Provider>\n  );\n});\nMap2D.displayName = \"Map2D\";\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
