{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "story-map",
  "type": "registry:component",
  "title": "Story Map",
  "description": "Standalone SVG scroll-driven narrative map — IntersectionObserver tracks the active chapter and the map shifts to its center + zoom.",
  "dependencies": [
    "@vllnt/ui@^0.2.1"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/story-map/story-map.tsx",
      "content": "\"use client\";\n\nimport {\n  Children,\n  type ComponentPropsWithoutRef,\n  createContext,\n  forwardRef,\n  isValidElement,\n  type ReactElement,\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 the chapter marker.\n *\n * @public\n */\nexport type StoryMapColor =\n  | \"amber\"\n  | \"blue\"\n  | \"emerald\"\n  | \"purple\"\n  | \"red\"\n  | \"rose\";\n\nconst PALETTE: Record<StoryMapColor, string> = {\n  amber: \"fill-amber-500\",\n  blue: \"fill-blue-500\",\n  emerald: \"fill-emerald-500\",\n  purple: \"fill-purple-500\",\n  red: \"fill-red-500\",\n  rose: \"fill-rose-500\",\n};\n\n/**\n * Optional image media rendered inside the chapter card.\n *\n * @public\n */\nexport type StoryMapMedia = {\n  /** Required alt text. */\n  alt: string;\n  /** Optional caption rendered beneath the image. */\n  caption?: ReactNode;\n  /** Image URL. */\n  src: string;\n  /** Media kind. Image is the supported value today. */\n  type: \"image\";\n};\n\n/**\n * Localizable strings.\n *\n * @public\n */\nexport type StoryMapLabels = {\n  /** Aria-label for the narrative column. Defaults to `\"Narrative\"`. */\n  narrative?: string;\n  /** Aria-label for the progress strip. Defaults to `\"Story progress\"`. */\n  progress?: string;\n  /** Aria-label for the map region. Defaults to `\"Story map\"`. */\n  region?: string;\n};\n\nconst DEFAULT_LABELS = {\n  narrative: \"Narrative\",\n  progress: \"Story progress\",\n  region: \"Story map\",\n} as const satisfies Required<StoryMapLabels>;\n\ntype RegisterArguments = {\n  center: GeoPosition;\n  color: StoryMapColor;\n  id: string;\n  zoom: number;\n};\n\ntype Ctx = {\n  activeId?: string;\n  registerChapter: (id: string, node: HTMLElement | null) => void;\n  registerMarker: (entry: RegisterArguments) => void;\n  setActiveId: (id: string) => void;\n  unregisterMarker: (id: string) => void;\n};\n\nconst StoryMapContext = createContext<Ctx | null>(null);\n\nfunction useStoryMapContext(): Ctx {\n  const ctx = useContext(StoryMapContext);\n  if (!ctx) {\n    throw new Error(\"StoryMap subcomponent used outside its root.\");\n  }\n  return ctx;\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\n/**\n * Props for {@link StoryMapChapter}.\n *\n * @public\n */\nexport type StoryMapChapterProps = {\n  /** Center the map on this position when the chapter is active. */\n  center: GeoPosition;\n  /** Color theme for the chapter marker. Defaults to `\"red\"`. */\n  color?: StoryMapColor;\n  /** Stable id. Defaults to a generated id. */\n  id?: string;\n  /** Optional image media. */\n  media?: StoryMapMedia;\n  /** Optional subtitle. */\n  subtitle?: ReactNode;\n  /** Chapter title. */\n  title: ReactNode;\n  /** Map zoom factor when the chapter is active. `1` shows the full world; `8` zooms in tight. Defaults to `2`. */\n  zoom?: number;\n} & Omit<ComponentPropsWithoutRef<\"article\">, \"id\" | \"title\">;\n\ntype ChapterMediaProps = {\n  media: StoryMapMedia;\n};\n\nfunction ChapterMedia({ media }: ChapterMediaProps): ReactNode {\n  return (\n    <figure className=\"overflow-hidden rounded-xl border bg-muted\">\n      <img\n        alt={media.alt}\n        className=\"aspect-video w-full object-cover\"\n        loading=\"lazy\"\n        src={media.src}\n      />\n      {media.caption ? (\n        <figcaption className=\"border-t bg-background px-3 py-2 text-xs text-muted-foreground\">\n          {media.caption}\n        </figcaption>\n      ) : null}\n    </figure>\n  );\n}\n\ntype ChapterHeaderProps = {\n  subtitle?: ReactNode;\n  title: ReactNode;\n};\n\nfunction ChapterHeader({ subtitle, title }: ChapterHeaderProps): ReactNode {\n  return (\n    <header className=\"space-y-1\">\n      <h3 className=\"text-xl font-semibold tracking-tight text-foreground\">\n        {title}\n      </h3>\n      {subtitle ? (\n        <p className=\"text-sm text-muted-foreground\">{subtitle}</p>\n      ) : null}\n    </header>\n  );\n}\n\n/**\n * Single chapter section. Place narrative paragraphs as children.\n *\n * @public\n */\nexport const StoryMapChapter = forwardRef<HTMLElement, StoryMapChapterProps>(\n  (props, forwardedRef) => {\n    const {\n      center,\n      children,\n      className,\n      color = \"red\",\n      id,\n      media,\n      subtitle,\n      title,\n      zoom = 2,\n      ...rest\n    } = props;\n    const generatedId = useId();\n    const chapterId = id ?? generatedId;\n    const localRef = useRef<HTMLElement | null>(null);\n    const { registerChapter, registerMarker, unregisterMarker } =\n      useStoryMapContext();\n\n    useEffect(() => {\n      registerMarker({ center, color, id: chapterId, zoom });\n      return () => {\n        unregisterMarker(chapterId);\n      };\n    }, [center, chapterId, color, registerMarker, unregisterMarker, zoom]);\n\n    const refCallback = useCallback(\n      (node: HTMLElement | null) => {\n        localRef.current = node;\n        registerChapter(chapterId, node);\n        if (typeof forwardedRef === \"function\") forwardedRef(node);\n        else if (forwardedRef) forwardedRef.current = node;\n      },\n      [chapterId, forwardedRef, registerChapter],\n    );\n\n    return (\n      <article\n        className={cn(\n          \"flex min-h-screen flex-col justify-center gap-3 py-12\",\n          className,\n        )}\n        data-chapter-id={chapterId}\n        id={chapterId}\n        ref={refCallback}\n        {...rest}\n      >\n        <ChapterHeader subtitle={subtitle} title={title} />\n        {media ? <ChapterMedia media={media} /> : null}\n        {children ? (\n          <div className=\"space-y-2 text-sm leading-relaxed text-foreground [&_blockquote]:my-3 [&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-muted-foreground\">\n            {children}\n          </div>\n        ) : null}\n      </article>\n    );\n  },\n);\nStoryMapChapter.displayName = \"StoryMapChapter\";\n\ntype Marker = RegisterArguments;\n\ntype StageProps = {\n  activeId?: string;\n  backdrop?: string;\n  backdropAlt?: string;\n  markers: Marker[];\n};\n\nfunction Stage({\n  activeId,\n  backdrop,\n  backdropAlt,\n  markers,\n}: StageProps): ReactNode {\n  const active = markers.find((marker) => marker.id === activeId);\n  const zoom = active?.zoom ?? 1;\n  const innerWidth = VIEWBOX_WIDTH / zoom;\n  const innerHeight = VIEWBOX_HEIGHT / zoom;\n  const center = active\n    ? projectEquirectangular(active.center)\n    : { x: VIEWBOX_WIDTH / 2, y: VIEWBOX_HEIGHT / 2 };\n  const viewX = center.x - innerWidth / 2;\n  const viewY = center.y - innerHeight / 2;\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className=\"block h-full w-full transition-[viewBox] duration-500\"\n      data-active-chapter-id={activeId ?? \"\"}\n      data-active-zoom={zoom}\n      preserveAspectRatio=\"xMidYMid slice\"\n      viewBox={`${viewX.toString()} ${viewY.toString()} ${innerWidth.toString()} ${innerHeight.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      {markers.map((marker) => {\n        const point = projectEquirectangular(marker.center);\n        const isActive = marker.id === activeId;\n        return (\n          <g\n            data-marker-active={isActive ? \"true\" : undefined}\n            data-marker-id={marker.id}\n            key={marker.id}\n            transform={`translate(${point.x.toString()}, ${point.y.toString()})`}\n          >\n            <circle\n              className={cn(\"stroke-background\", PALETTE[marker.color])}\n              r={isActive ? 12 : 6}\n              strokeWidth={2}\n            />\n            {isActive ? (\n              <circle\n                className={cn(\"opacity-30\", PALETTE[marker.color])}\n                r={24}\n              />\n            ) : null}\n          </g>\n        );\n      })}\n    </svg>\n  );\n}\n\nfunction chapterIdsFromChildren(children: ReactNode): string[] {\n  const ids: string[] = [];\n  Children.forEach(children, (child) => {\n    if (!isValidElement(child)) return;\n    const element = child as ReactElement<{ id?: string }>;\n    if (typeof element.props.id === \"string\") ids.push(element.props.id);\n  });\n  return ids;\n}\n\ntype ProgressStripProps = {\n  activeId?: string;\n  ids: string[];\n  label: string;\n};\n\nfunction ProgressStrip({\n  activeId,\n  ids,\n  label,\n}: ProgressStripProps): ReactNode {\n  if (ids.length === 0) return null;\n  const activeIndex = activeId ? ids.indexOf(activeId) : -1;\n  const ratio = activeIndex < 0 ? 0 : (activeIndex + 1) / ids.length;\n  return (\n    <div\n      aria-label={label}\n      aria-valuemax={100}\n      aria-valuemin={0}\n      aria-valuenow={Math.round(ratio * 100)}\n      className=\"sticky top-0 z-20 h-1 w-full bg-border\"\n      role=\"progressbar\"\n    >\n      <span\n        className=\"block h-full bg-primary transition-[width] duration-200\"\n        style={{ width: `${(ratio * 100).toString()}%` }}\n      />\n    </div>\n  );\n}\n\nfunction useChapterRegistry(): {\n  activeId?: string;\n  markerEntries: Marker[];\n  registerChapter: (id: string, node: HTMLElement | null) => void;\n  registerMarker: (entry: RegisterArguments) => void;\n  setActiveId: (id: string) => void;\n  unregisterMarker: (id: string) => void;\n} {\n  const chapterMapRef = useRef<Map<string, HTMLElement>>(new Map());\n  const markersRef = useRef<Map<string, Marker>>(new Map());\n  const [chapterIds, setChapterIds] = useState<string[]>([]);\n  const [markerEntries, setMarkerEntries] = useState<Marker[]>([]);\n  const [activeId, setActiveId] = useState<string | undefined>();\n\n  const registerChapter = useCallback(\n    (id: string, node: HTMLElement | null) => {\n      const map = chapterMapRef.current;\n      if (node) map.set(id, node);\n      else map.delete(id);\n      setChapterIds([...map.keys()]);\n    },\n    [],\n  );\n\n  const registerMarker = useCallback((entry: RegisterArguments) => {\n    markersRef.current.set(entry.id, entry);\n    setMarkerEntries([...markersRef.current.values()]);\n  }, []);\n\n  const unregisterMarker = useCallback((id: string) => {\n    markersRef.current.delete(id);\n    setMarkerEntries([...markersRef.current.values()]);\n  }, []);\n\n  useEffect(() => {\n    if (typeof IntersectionObserver === \"undefined\") return;\n    const observer = new IntersectionObserver(\n      (entries) => {\n        const visible = entries\n          .filter((entry) => entry.isIntersecting)\n          .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);\n        const first = visible[0];\n        if (first?.target instanceof HTMLElement) {\n          const id = first.target.dataset.chapterId;\n          if (id) setActiveId(id);\n        }\n      },\n      { rootMargin: \"-30% 0px -50% 0px\", threshold: 0.1 },\n    );\n    [...chapterMapRef.current.values()].forEach((node) => {\n      observer.observe(node);\n    });\n    return () => {\n      observer.disconnect();\n    };\n  }, [chapterIds]);\n\n  return {\n    activeId,\n    markerEntries,\n    registerChapter,\n    registerMarker,\n    setActiveId,\n    unregisterMarker,\n  };\n}\n\n/**\n * Props for {@link StoryMap}.\n *\n * @public\n */\nexport type StoryMapProps = {\n  /** Optional URL of a backdrop image (world map, terrain). */\n  backdrop?: string;\n  /** Aria-label for the backdrop image. */\n  backdropAlt?: string;\n  /** Localizable strings. */\n  labels?: StoryMapLabels;\n} & ComponentPropsWithoutRef<\"section\">;\n\ntype ShellProps = {\n  activeId?: string;\n  backdrop?: string;\n  backdropAlt?: string;\n  children: ReactNode;\n  className?: string;\n  labels: Required<StoryMapLabels>;\n  markers: Marker[];\n  orderedIds: string[];\n  titleId: string;\n};\n\nconst Shell = forwardRef<HTMLElement, ShellProps>(function Shell(props, ref) {\n  const {\n    activeId,\n    backdrop,\n    backdropAlt,\n    children,\n    className,\n    labels,\n    markers,\n    orderedIds,\n    titleId,\n  } = props;\n  return (\n    <section\n      aria-labelledby={titleId}\n      className={cn(\n        \"relative flex w-full flex-col overflow-hidden rounded-2xl border bg-background text-foreground md:flex-row\",\n        className,\n      )}\n      ref={ref}\n    >\n      <span className=\"sr-only\" id={titleId}>\n        {labels.region}\n      </span>\n      <ProgressStrip\n        activeId={activeId}\n        ids={orderedIds}\n        label={labels.progress}\n      />\n      <div\n        className=\"sticky top-0 hidden h-[80vh] flex-1 self-start md:block\"\n        data-story-map-stage\n      >\n        <Stage\n          activeId={activeId}\n          backdrop={backdrop}\n          backdropAlt={backdropAlt}\n          markers={markers}\n        />\n      </div>\n      <div\n        aria-label={labels.narrative}\n        className=\"flex-1 px-6 md:max-w-xl\"\n        role=\"region\"\n      >\n        {children}\n      </div>\n      <div\n        className=\"block aspect-[2/1] w-full border-t border-border md:hidden\"\n        data-story-map-stage-mobile\n      >\n        <Stage\n          activeId={activeId}\n          backdrop={backdrop}\n          backdropAlt={backdropAlt}\n          markers={markers}\n        />\n      </div>\n    </section>\n  );\n});\n\n/**\n * Scroll-driven narrative map. Place {@link StoryMapChapter} children\n * in the narrative column; an `IntersectionObserver` tracks the active\n * chapter and the SVG map shifts to center on its `[lng, lat]` and\n * `zoom`. Standalone SVG primitive — no external map library required.\n *\n * @example\n * ```tsx\n * <StoryMap>\n *   <StoryMapChapter\n *     center={[12.49, 41.89]}\n *     zoom={4}\n *     title=\"The Fall of Rome\"\n *   >\n *     <p>In 476 AD...</p>\n *   </StoryMapChapter>\n *   <StoryMapChapter\n *     center={[28.98, 41.01]}\n *     zoom={4}\n *     title=\"Constantinople Endures\"\n *   >\n *     <p>While Rome fell, Constantinople thrived...</p>\n *   </StoryMapChapter>\n * </StoryMap>\n * ```\n *\n * @public\n */\nexport const StoryMap = forwardRef<HTMLElement, StoryMapProps>((props, ref) => {\n  const { backdrop, backdropAlt, children, className, labels, ...rest } = props;\n  const titleId = useId();\n  const resolvedLabels = useMemo(\n    () => ({ ...DEFAULT_LABELS, ...labels }),\n    [labels],\n  );\n\n  const {\n    activeId,\n    markerEntries,\n    registerChapter,\n    registerMarker,\n    setActiveId,\n    unregisterMarker,\n  } = useChapterRegistry();\n\n  const ctx = useMemo<Ctx>(\n    () => ({\n      activeId,\n      registerChapter,\n      registerMarker,\n      setActiveId,\n      unregisterMarker,\n    }),\n    [activeId, registerChapter, registerMarker, setActiveId, unregisterMarker],\n  );\n\n  const orderedIds = useMemo(\n    () => chapterIdsFromChildren(children),\n    [children],\n  );\n\n  return (\n    <StoryMapContext.Provider value={ctx}>\n      <Shell\n        activeId={activeId}\n        backdrop={backdrop}\n        backdropAlt={backdropAlt}\n        className={className}\n        labels={resolvedLabels}\n        markers={markerEntries}\n        orderedIds={orderedIds}\n        ref={ref}\n        titleId={titleId}\n        {...rest}\n      >\n        {children}\n      </Shell>\n    </StoryMapContext.Provider>\n  );\n});\nStoryMap.displayName = \"StoryMap\";\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
