{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "map-timeline",
  "type": "registry:component",
  "title": "Map Timeline",
  "description": "Standalone SVG map + time slider — era polygons and year-pinned events appear as the user scrubs the timeline.",
  "dependencies": [
    "@vllnt/ui@^0.2.1"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/map-timeline/map-timeline.tsx",
      "content": "\"use client\";\n\nimport {\n  type ChangeEvent,\n  type ComponentPropsWithoutRef,\n  createContext,\n  forwardRef,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\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 * Color theme for layers and event markers.\n *\n * @public\n */\nexport type MapTimelineColor =\n  | \"amber\"\n  | \"blue\"\n  | \"emerald\"\n  | \"purple\"\n  | \"red\"\n  | \"rose\";\n\nconst PALETTE: Record<\n  MapTimelineColor,\n  { dot: string; fill: string; stroke: string }\n> = {\n  amber: {\n    dot: \"fill-amber-500\",\n    fill: \"rgba(245, 158, 11, 0.25)\",\n    stroke: \"#b45309\",\n  },\n  blue: {\n    dot: \"fill-blue-500\",\n    fill: \"rgba(59, 130, 246, 0.25)\",\n    stroke: \"#1d4ed8\",\n  },\n  emerald: {\n    dot: \"fill-emerald-500\",\n    fill: \"rgba(16, 185, 129, 0.25)\",\n    stroke: \"#047857\",\n  },\n  purple: {\n    dot: \"fill-purple-500\",\n    fill: \"rgba(168, 85, 247, 0.25)\",\n    stroke: \"#7c3aed\",\n  },\n  red: {\n    dot: \"fill-red-500\",\n    fill: \"rgba(239, 68, 68, 0.25)\",\n    stroke: \"#b91c1c\",\n  },\n  rose: {\n    dot: \"fill-rose-500\",\n    fill: \"rgba(244, 63, 94, 0.25)\",\n    stroke: \"#be123c\",\n  },\n};\n\n/**\n * Localizable strings.\n *\n * @public\n */\nexport type MapTimelineLabels = {\n  /** Pause-button label. Defaults to `\"Pause\"`. */\n  pause?: string;\n  /** Play-button label. Defaults to `\"Play\"`. */\n  play?: string;\n  /** Aria-label for the map region. Defaults to `\"Map timeline\"`. */\n  region?: string;\n  /** Aria-label for the timeline slider. Defaults to `\"Year\"`. */\n  slider?: string;\n};\n\nconst DEFAULT_LABELS = {\n  pause: \"Pause\",\n  play: \"Play\",\n  region: \"Map timeline\",\n  slider: \"Year\",\n} as const satisfies Required<MapTimelineLabels>;\n\ntype Ctx = {\n  endYear: number;\n  isPlaying: boolean;\n  labels: Required<MapTimelineLabels>;\n  setIsPlaying: (next: boolean) => void;\n  setYear: (next: number) => void;\n  speed: number;\n  startYear: number;\n  year: number;\n};\n\nconst TimelineContext = createContext<Ctx | null>(null);\n\nfunction useTimelineContext(): Ctx {\n  const ctx = useContext(TimelineContext);\n  if (!ctx) {\n    throw new Error(\"MapTimeline 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(position: GeoPosition): {\n  x: number;\n  y: number;\n} {\n  const [lng, lat] = position;\n  return {\n    x: ((lng + 180) / 360) * VIEWBOX_WIDTH,\n    y: ((90 - lat) / 180) * VIEWBOX_HEIGHT,\n  };\n}\n\nfunction formatYear(year: number): string {\n  if (year < 0) return `${Math.abs(year).toString()} BCE`;\n  return `${year.toString()} CE`;\n}\n\nfunction ringToPath(ring: GeoPosition[]): string {\n  return ring\n    .map((position, index) => {\n      const projected = projectEquirectangular(position);\n      return `${index === 0 ? \"M\" : \"L\"}${projected.x.toString()},${projected.y.toString()}`;\n    })\n    .join(\" \");\n}\n\n/**\n * GeoJSON-style polygon for a {@link MapTimelineLayer}. Pass either a\n * `polygon` (single ring) or a `polygons` array (multi-polygon).\n *\n * @public\n */\nexport type MapTimelineGeometry =\n  | { polygon: GeoPosition[]; polygons?: never; type: \"polygon\" }\n  | { polygon?: never; polygons: GeoPosition[][]; type: \"multipolygon\" };\n\n/**\n * Props for {@link MapTimelineLayer}.\n *\n * @public\n */\nexport type MapTimelineLayerProps = {\n  /** Color theme. Defaults to `\"blue\"`. */\n  color?: MapTimelineColor;\n  /** Year (inclusive) when the layer disappears. */\n  endYear: number;\n  /** Polygon geometry. */\n  geometry: MapTimelineGeometry;\n  /** Stable id used for analytics + React keys. */\n  id?: string;\n  /** Display label rendered in the centroid when visible. */\n  label?: ReactNode;\n  /** Year (inclusive) when the layer first appears. */\n  startYear: number;\n} & Omit<ComponentPropsWithoutRef<\"g\">, \"id\">;\n\nfunction geometryRings(geometry: MapTimelineGeometry): GeoPosition[][] {\n  if (geometry.type === \"polygon\") return [geometry.polygon];\n  return geometry.polygons;\n}\n\nfunction ringCentroid(ring: GeoPosition[]): { x: number; y: number } {\n  if (ring.length === 0) return { x: 0, y: 0 };\n  const total = ring.reduce<{ x: number; y: number }>(\n    (accumulator, position) => {\n      const projected = projectEquirectangular(position);\n      return { x: accumulator.x + projected.x, y: accumulator.y + projected.y };\n    },\n    { x: 0, y: 0 },\n  );\n  return { x: total.x / ring.length, y: total.y / ring.length };\n}\n\n/**\n * Geographic layer pinned to a year window.\n *\n * @public\n */\nexport const MapTimelineLayer = forwardRef<SVGGElement, MapTimelineLayerProps>(\n  (props, ref) => {\n    const {\n      color = \"blue\",\n      endYear,\n      geometry,\n      id,\n      label,\n      startYear,\n      ...rest\n    } = props;\n    const { year } = useTimelineContext();\n    if (year < startYear || year > endYear) return null;\n    const palette = PALETTE[color];\n    const rings = geometryRings(geometry);\n    const centroid = rings[0] ? ringCentroid(rings[0]) : { x: 0, y: 0 };\n    return (\n      <g data-layer-id={id} data-state=\"visible\" ref={ref} {...rest}>\n        {rings.map((ring, index) => (\n          <path\n            d={`${ringToPath(ring)} Z`}\n            fill={palette.fill}\n            key={`${id ?? \"layer\"}-ring-${index.toString()}`}\n            stroke={palette.stroke}\n            strokeWidth={1.5}\n          />\n        ))}\n        {label ? (\n          <text\n            className=\"select-none fill-foreground text-[11px] font-semibold\"\n            dominantBaseline=\"middle\"\n            textAnchor=\"middle\"\n            x={centroid.x}\n            y={centroid.y}\n          >\n            {label}\n          </text>\n        ) : null}\n      </g>\n    );\n  },\n);\nMapTimelineLayer.displayName = \"MapTimelineLayer\";\n\n/**\n * Props for {@link MapTimelineEvent}.\n *\n * @public\n */\nexport type MapTimelineEventProps = {\n  /** Color theme. Defaults to `\"red\"`. */\n  color?: MapTimelineColor;\n  /** Optional description rendered in the tooltip. */\n  description?: ReactNode;\n  /** Stable identifier. */\n  id?: string;\n  /** Geographic position. */\n  position: GeoPosition;\n  /** Title rendered in the tooltip. */\n  title?: ReactNode;\n  /** Inclusive ± window in years around `year` when the marker shows. Defaults to `0` (exact match). */\n  toleranceYears?: number;\n  /** Year the event happened. */\n  year: number;\n} & Omit<ComponentPropsWithoutRef<\"g\">, \"id\">;\n\n/**\n * Year-pinned event marker.\n *\n * @public\n */\nexport const MapTimelineEvent = forwardRef<SVGGElement, MapTimelineEventProps>(\n  (props, ref) => {\n    const {\n      color = \"red\",\n      description,\n      id,\n      position,\n      title,\n      toleranceYears = 0,\n      year: eventYear,\n      ...rest\n    } = props;\n    const { year } = useTimelineContext();\n    const visible = Math.abs(year - eventYear) <= toleranceYears;\n    if (!visible) return null;\n    const palette = PALETTE[color];\n    const projected = projectEquirectangular(position);\n    return (\n      <g\n        data-event-id={id}\n        data-event-year={eventYear}\n        ref={ref}\n        transform={`translate(${projected.x.toString()}, ${projected.y.toString()})`}\n        {...rest}\n      >\n        <circle\n          className={cn(\"stroke-background\", palette.dot)}\n          r=\"6\"\n          strokeWidth=\"2\"\n        >\n          {title ? (\n            <title>{typeof title === \"string\" ? title : \"\"}</title>\n          ) : null}\n        </circle>\n        {title ? (\n          <text\n            className=\"select-none fill-foreground text-[10px] font-semibold\"\n            dominantBaseline=\"middle\"\n            textAnchor=\"middle\"\n            y=\"-12\"\n          >\n            {title}\n          </text>\n        ) : null}\n        {description ? (\n          <text\n            className=\"select-none fill-muted-foreground text-[9px]\"\n            dominantBaseline=\"middle\"\n            textAnchor=\"middle\"\n            y=\"20\"\n          >\n            {description}\n          </text>\n        ) : null}\n      </g>\n    );\n  },\n);\nMapTimelineEvent.displayName = \"MapTimelineEvent\";\n\n/**\n * Container for the slider + play button row.\n *\n * @public\n */\nexport const MapTimelineControls = forwardRef<\n  HTMLDivElement,\n  ComponentPropsWithoutRef<\"div\">\n>(({ children, className, ...rest }, ref) => (\n  <div\n    className={cn(\n      \"flex items-center gap-3 border-t border-border bg-muted/40 px-4 py-2\",\n      className,\n    )}\n    ref={ref}\n    {...rest}\n  >\n    {children}\n  </div>\n));\nMapTimelineControls.displayName = \"MapTimelineControls\";\n\n/**\n * Range-input slider bound to the current year.\n *\n * @public\n */\nexport const MapTimelineSlider = forwardRef<\n  HTMLInputElement,\n  Omit<\n    ComponentPropsWithoutRef<\"input\">,\n    \"max\" | \"min\" | \"onChange\" | \"type\" | \"value\"\n  >\n>(({ className, ...rest }, ref) => {\n  const { endYear, labels, setYear, startYear, year } = useTimelineContext();\n  const sliderId = useId();\n  const handleChange = (event: ChangeEvent<HTMLInputElement>): void => {\n    setYear(Number.parseInt(event.target.value, 10));\n  };\n  return (\n    <div className=\"flex flex-1 items-center gap-2 text-xs font-medium text-muted-foreground\">\n      <span aria-hidden=\"true\">{formatYear(startYear)}</span>\n      <input\n        aria-label={labels.slider}\n        aria-valuemax={endYear}\n        aria-valuemin={startYear}\n        aria-valuenow={year}\n        className={cn(\"flex-1 accent-primary\", className)}\n        id={sliderId}\n        max={endYear}\n        min={startYear}\n        onChange={handleChange}\n        ref={ref}\n        type=\"range\"\n        value={year}\n        {...rest}\n      />\n      <span aria-hidden=\"true\">{formatYear(endYear)}</span>\n      <span\n        className=\"ml-2 inline-flex min-w-20 justify-center rounded-md border border-border bg-background px-2 py-0.5 text-foreground\"\n        data-current-year={year}\n      >\n        {formatYear(year)}\n      </span>\n    </div>\n  );\n});\nMapTimelineSlider.displayName = \"MapTimelineSlider\";\n\n/**\n * Play / pause toggle that auto-advances the year on `requestAnimationFrame`.\n *\n * @public\n */\nexport const MapTimelinePlayButton = forwardRef<\n  HTMLButtonElement,\n  Omit<ComponentPropsWithoutRef<\"button\">, \"aria-pressed\" | \"onClick\" | \"type\">\n>(({ className, ...rest }, ref) => {\n  const { isPlaying, labels, setIsPlaying } = useTimelineContext();\n  const handleClick = (): void => {\n    setIsPlaying(!isPlaying);\n  };\n  return (\n    <button\n      aria-label={isPlaying ? labels.pause : labels.play}\n      aria-pressed={isPlaying}\n      className={cn(\n        \"inline-flex h-8 items-center rounded-md border border-border bg-background px-3 text-xs font-medium hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n        className,\n      )}\n      data-playing={isPlaying ? \"true\" : undefined}\n      onClick={handleClick}\n      ref={ref}\n      type=\"button\"\n      {...rest}\n    >\n      <span aria-hidden=\"true\">{isPlaying ? \"❚❚\" : \"▶\"}</span>\n    </button>\n  );\n});\nMapTimelinePlayButton.displayName = \"MapTimelinePlayButton\";\n\n/**\n * Props for {@link MapTimeline}.\n *\n * @public\n */\nexport type MapTimelineProps = {\n  /** Optional URL of a backdrop image (world map, terrain). */\n  backdrop?: string;\n  /** Aria-label for the backdrop image. */\n  backdropAlt?: string;\n  /** End of the timeline window (inclusive). */\n  endYear: number;\n  /** Initial year. Defaults to `startYear`. */\n  initialYear?: number;\n  /** Localizable strings. */\n  labels?: MapTimelineLabels;\n  /** Fires when the year changes (slider drag, play tick, or programmatic). */\n  onYearChange?: (next: number) => void;\n  /** Years per second when playing. Defaults to `25`. */\n  speed?: number;\n  /** Start of the timeline window (inclusive). Negative for BCE. */\n  startYear: number;\n} & Omit<ComponentPropsWithoutRef<\"section\">, \"onChange\">;\n\nfunction useTimelineCtx(arguments_: {\n  endYear: number;\n  isPlaying: boolean;\n  labels: Required<MapTimelineLabels>;\n  setIsPlaying: (next: boolean) => void;\n  setYear: (next: number) => void;\n  speed: number;\n  startYear: number;\n  year: number;\n}): Ctx {\n  const {\n    endYear,\n    isPlaying,\n    labels,\n    setIsPlaying,\n    setYear,\n    speed,\n    startYear,\n    year,\n  } = arguments_;\n  return useMemo<Ctx>(\n    () => ({\n      endYear,\n      isPlaying,\n      labels,\n      setIsPlaying,\n      setYear,\n      speed,\n      startYear,\n      year,\n    }),\n    [endYear, isPlaying, labels, setIsPlaying, setYear, speed, startYear, year],\n  );\n}\n\nfunction useTimelineState(arguments_: {\n  endYear: number;\n  initialYear?: number;\n  onYearChange?: (next: number) => void;\n  speed: number;\n  startYear: number;\n}): {\n  isPlaying: boolean;\n  setIsPlaying: (next: boolean) => void;\n  setYear: (next: number) => void;\n  year: number;\n} {\n  const { endYear, initialYear, onYearChange, startYear } = arguments_;\n  const [year, setYear] = useState<number>(\n    clamp(initialYear ?? startYear, startYear, endYear),\n  );\n  const [isPlaying, setIsPlaying] = useState(false);\n\n  const updateYear = useCallback(\n    (next: number) => {\n      const clamped = clamp(next, startYear, endYear);\n      setYear((current) => {\n        if (clamped >= endYear) setIsPlaying(false);\n        if (current === clamped) return current;\n        onYearChange?.(clamped);\n        return clamped;\n      });\n    },\n    [endYear, onYearChange, startYear],\n  );\n\n  return { isPlaying, setIsPlaying, setYear: updateYear, year };\n}\n\nfunction usePlayback(arguments_: {\n  endYear: number;\n  isPlaying: boolean;\n  setIsPlaying: (next: boolean) => void;\n  setYear: (next: number) => void;\n  speed: number;\n  startYear: number;\n  year: number;\n}): void {\n  const { endYear, isPlaying, setIsPlaying, setYear, speed, year } = arguments_;\n  const yearRef = useRef(year);\n  useEffect(() => {\n    yearRef.current = year;\n  }, [year]);\n  useEffect(() => {\n    if (!isPlaying) return;\n    if (typeof window === \"undefined\") return;\n    let frame = 0;\n    let last: null | number = null;\n    const step = (timestamp: number): void => {\n      if (last !== null) {\n        const delta = (timestamp - last) / 1000;\n        const next = yearRef.current + delta * speed;\n        if (next >= endYear) {\n          setYear(endYear);\n          setIsPlaying(false);\n          return;\n        }\n        setYear(next);\n      }\n      last = timestamp;\n      frame = window.requestAnimationFrame(step);\n    };\n    frame = window.requestAnimationFrame(step);\n    return () => {\n      window.cancelAnimationFrame(frame);\n    };\n  }, [endYear, isPlaying, setIsPlaying, setYear, speed]);\n}\n\ntype StageProps = {\n  backdrop?: string;\n  backdropAlt?: string;\n  children: ReactNode;\n};\n\nfunction Stage({ backdrop, backdropAlt, children }: StageProps): ReactNode {\n  return (\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      <rect\n        className=\"fill-muted\"\n        height={VIEWBOX_HEIGHT}\n        width={VIEWBOX_WIDTH}\n        x=\"0\"\n        y=\"0\"\n      />\n      {backdrop ? (\n        <image\n          aria-label={backdropAlt}\n          height={VIEWBOX_HEIGHT}\n          href={backdrop}\n          preserveAspectRatio=\"xMidYMid slice\"\n          width={VIEWBOX_WIDTH}\n          x=\"0\"\n          y=\"0\"\n        />\n      ) : null}\n      {children}\n    </svg>\n  );\n}\n\ntype ChildBuckets = {\n  footer: ReactNode[];\n  stage: 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 === MapTimelineControls.displayName)\n        accumulator.footer.push(child);\n      else accumulator.stage.push(child);\n      return accumulator;\n    },\n    { footer: [], stage: [] },\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\ntype ShellProps = {\n  backdrop?: string;\n  backdropAlt?: string;\n  buckets: ChildBuckets;\n  className?: string;\n  region: string;\n  titleId: string;\n  year: number;\n};\n\nconst Shell = forwardRef<HTMLElement, ShellProps>(function Shell(props, ref) {\n  const { backdrop, backdropAlt, buckets, className, region, titleId, year } =\n    props;\n  return (\n    <section\n      aria-labelledby={titleId}\n      className={cn(\n        \"flex w-full flex-col overflow-hidden rounded-2xl border bg-background text-foreground\",\n        className,\n      )}\n      ref={ref}\n    >\n      <span className=\"sr-only\" id={titleId}>\n        {region}\n      </span>\n      <div\n        className=\"relative aspect-[2/1] w-full overflow-hidden\"\n        data-current-year={year}\n      >\n        <Stage backdrop={backdrop} backdropAlt={backdropAlt}>\n          {buckets.stage}\n        </Stage>\n      </div>\n      {buckets.footer}\n    </section>\n  );\n});\n\n/**\n * Combined map + timeline. Pass {@link MapTimelineLayer} (era polygons),\n * {@link MapTimelineEvent} (year-pinned markers), and\n * {@link MapTimelineControls} as children. The slider scrubs the\n * current year; layers and events appear / disappear as the year\n * crosses their windows.\n *\n * Standalone SVG primitive — no external map library required.\n *\n * @example\n * ```tsx\n * <MapTimeline startYear={-500} endYear={2025} initialYear={1}>\n *   <MapTimelineLayer\n *     startYear={-27}\n *     endYear={476}\n *     color=\"red\"\n *     label=\"Roman Empire\"\n *     geometry={{ type: 'polygon', polygon: romanRing }}\n *   />\n *   <MapTimelineEvent\n *     year={79}\n *     position={[14.48, 40.75]}\n *     title=\"Vesuvius\"\n *     description=\"Pompeii destroyed\"\n *   />\n *   <MapTimelineControls>\n *     <MapTimelinePlayButton />\n *     <MapTimelineSlider />\n *   </MapTimelineControls>\n * </MapTimeline>\n * ```\n *\n * @public\n */\nexport const MapTimeline = forwardRef<HTMLElement, MapTimelineProps>(\n  (props, ref) => {\n    const {\n      backdrop,\n      backdropAlt,\n      children,\n      className,\n      endYear,\n      initialYear,\n      labels,\n      onYearChange,\n      speed = 25,\n      startYear,\n      ...rest\n    } = props;\n    const titleId = useId();\n    const resolvedLabels = useMemo(\n      () => ({ ...DEFAULT_LABELS, ...labels }),\n      [labels],\n    );\n\n    const { isPlaying, setIsPlaying, setYear, year } = useTimelineState({\n      endYear,\n      initialYear,\n      onYearChange,\n      speed,\n      startYear,\n    });\n\n    usePlayback({\n      endYear,\n      isPlaying,\n      setIsPlaying,\n      setYear,\n      speed,\n      startYear,\n      year,\n    });\n\n    const ctx = useTimelineCtx({\n      endYear,\n      isPlaying,\n      labels: resolvedLabels,\n      setIsPlaying,\n      setYear,\n      speed,\n      startYear,\n      year,\n    });\n\n    const buckets = bucketChildren(children);\n    return (\n      <TimelineContext.Provider value={ctx}>\n        <Shell\n          backdrop={backdrop}\n          backdropAlt={backdropAlt}\n          buckets={buckets}\n          className={className}\n          ref={ref}\n          region={resolvedLabels.region}\n          titleId={titleId}\n          year={year}\n          {...rest}\n        />\n      </TimelineContext.Provider>\n    );\n  },\n);\nMapTimeline.displayName = \"MapTimeline\";\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
