{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "metro-map",
  "title": "Metro Map",
  "description": "Lines and stations drawn from data, for a process or a path with branches: arrow keys between stations, and it scrolls inside itself when narrow.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/metro-map.tsx",
      "content": "import type * as React from \"react\"\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\"\nimport { useReducedMotion } from \"motion/react\"\nimport {\n  buildMetro,\n  centeredScroll,\n  edgeMask,\n  scrollEdges,\n  stationInDirection,\n  type MetroLayout,\n  type MetroLine,\n  type MetroLineTone,\n  type MetroStop,\n  type PlacedStation,\n} from \"@/registry/miseui/ui/metro-map-layout\"\nimport { MetroLegend, MetroLines, MetroStation, type MetroMapVariant } from \"@/registry/miseui/ui/metro-map-parts\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype MetroMapEffect = \"ride\"\n\nconst LAYOUT: Record<MetroMapVariant, MetroLayout> = {\n  transit: { orientation: \"horizontal\", gap: 112, laneGap: 88, padTime: 64, padLane: 72, bend: \"diagonal\", corner: 8 },\n  tube: { orientation: \"horizontal\", gap: 112, laneGap: 88, padTime: 64, padLane: 72, bend: \"orthogonal\", corner: 16 },\n  branch: { orientation: \"vertical\", gap: 56, laneGap: 16, padTime: 28, padLane: 16, bend: \"diagonal\", corner: 4 },\n}\n\nconst LABEL_ROOM_PX = 12\n\ntype RootAttributes = Omit<React.ComponentProps<\"nav\">, \"children\" | \"onSelect\">\n\ninterface MetroMapProps extends RootAttributes {\n  /** What the map shows, read out as the name of the navigation landmark. */\n  label: string\n  lines: readonly MetroLine[]\n  /** `transit` 45 degree bends and rings, `tube` right angles and ticks, `branch` a vertical list with the lines in its margin. */\n  variant?: MetroMapVariant\n  /** `ride` inks the line up to the selected station, so the map also says how far along someone is. */\n  effect?: MetroMapEffect\n  selected?: string | null\n  defaultSelected?: string | null\n  /** Gets the pressed element too, so a dialog can grow out of it and return focus to it. */\n  onSelect?: (id: string, element: HTMLElement) => void\n}\n\nfunction useStationFocus(scrollerRef: React.RefObject<HTMLDivElement | null>, reduceMotion: boolean) {\n  const findStation = (id: string) =>\n    [...(scrollerRef.current?.querySelectorAll<HTMLElement>(\"[data-station]\") ?? [])].find((element) => element.dataset.station === id)\n\n  const center = (id: string, behavior: ScrollBehavior) => {\n    const scroller = scrollerRef.current\n    const element = findStation(id)\n    if (!scroller || !element) return\n    scroller.scrollTo?.({ left: centeredScroll(element.offsetLeft, element.offsetWidth, scroller.clientWidth), behavior })\n  }\n\n  const focusStation = (id: string) => {\n    findStation(id)?.focus({ preventScroll: true })\n    center(id, reduceMotion ? \"auto\" : \"smooth\")\n  }\n\n  return { center, focusStation }\n}\n\nfunction useScrollFade(scrollerRef: React.RefObject<HTMLDivElement | null>) {\n  const [edges, setEdges] = useState({ start: false, end: false })\n  const measure = useCallback(() => {\n    const scroller = scrollerRef.current\n    if (!scroller) return\n    const next = scrollEdges(scroller)\n    setEdges((previous) => (previous.start === next.start && previous.end === next.end ? previous : next))\n  }, [scrollerRef])\n\n  useEffect(() => {\n    measure()\n    const scroller = scrollerRef.current\n    if (!scroller || typeof ResizeObserver === \"undefined\") return\n    const observer = new ResizeObserver(measure)\n    observer.observe(scroller)\n    return () => observer.disconnect()\n  }, [measure, scrollerRef])\n\n  return { mask: edgeMask(edges), onScroll: measure }\n}\n\nfunction MetroMap({\n  label, lines, variant = \"transit\", effect, selected, defaultSelected = null, onSelect, className, ...props\n}: MetroMapProps) {\n  const scrollerRef = useRef<HTMLDivElement>(null)\n  const reduceMotion = useReducedMotion() ?? false\n  const plan = useMemo(() => buildMetro(lines, LAYOUT[variant]), [lines, variant])\n  const [uncontrolled, setUncontrolled] = useState(defaultSelected)\n  const [focused, setFocused] = useState<string | null>(null)\n  const current = selected === undefined ? uncontrolled : selected\n  const enabled = plan.stations.filter((station) => !station.disabled)\n  const tabStop = focused ?? current ?? enabled[0]?.id\n  const { center, focusStation } = useStationFocus(scrollerRef, reduceMotion)\n  const fade = useScrollFade(scrollerRef)\n  const layout = LAYOUT[variant]\n  const branch = variant === \"branch\"\n  // The labels start past the widest lane, so the lines keep a clear margin.\n  const gutter = plan.width + LABEL_ROOM_PX\n\n  // Opens on the current station even when it starts out of view on a narrow screen.\n  useEffect(() => {\n    if (current) center(current, \"auto\")\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- the first paint only; later moves scroll with their own behaviour\n  }, [])\n\n  const pick = (station: PlacedStation, element: HTMLElement) => {\n    setUncontrolled(station.id)\n    onSelect?.(station.id, element)\n  }\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {\n    if (!tabStop) return\n    let next: string | null | undefined = stationInDirection(plan.stations, tabStop, event.key)\n    if (event.key === \"Home\") next = enabled[0]?.id\n    if (event.key === \"End\") next = enabled.at(-1)?.id\n    if (!next) {\n      // A dead-end arrow still must not scroll the page out from under the map.\n      if (event.key.startsWith(\"Arrow\")) event.preventDefault()\n      return\n    }\n    event.preventDefault()\n    setFocused(next)\n    focusStation(next)\n  }\n\n  return (\n    <nav\n      aria-label={label}\n      data-slot=\"metro-map\"\n      data-variant={variant}\n      data-effect={effect}\n      className={cn(\"grid w-full min-w-0 grid-cols-[minmax(0,1fr)] gap-3\", className)}\n      onKeyDown={onKeyDown}\n      {...props}\n    >\n      <div\n        ref={scrollerRef}\n        data-slot=\"metro-map-scroller\"\n        className=\"min-w-0 overflow-x-auto overscroll-x-contain\"\n        style={{ maskImage: fade.mask }}\n        onScroll={fade.onScroll}\n      >\n        <div\n          data-slot=\"metro-map-canvas\"\n          className=\"relative\"\n          style={{ width: branch ? \"100%\" : plan.width, height: plan.height, minWidth: branch ? gutter + 160 : undefined }}\n        >\n          <MetroLines\n            plan={plan}\n            variant={variant}\n            selected={plan.stations.find((station) => station.id === current)}\n            ride={effect === \"ride\"}\n            reduceMotion={reduceMotion}\n          />\n          {plan.stations.map((station) => (\n            <MetroStation\n              key={station.id}\n              station={station}\n              variant={variant}\n              selected={station.id === current}\n              focusable={station.id === tabStop}\n              gap={layout.gap}\n              gutter={gutter}\n              labelAbove={station.lane === 0}\n              onPick={pick}\n              onFocusStation={setFocused}\n            />\n          ))}\n        </div>\n      </div>\n      <MetroLegend plan={plan} />\n    </nav>\n  )\n}\n\nexport { MetroMap, type MetroMapProps, type MetroMapVariant, type MetroMapEffect, type MetroLine, type MetroStop, type MetroLineTone }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/metro-map-layout.ts",
      "content": "export type MetroLineTone = \"mise\" | \"muted\" | \"ink\"\n\nexport interface MetroStop {\n  /** A stop with the same id on two lines is drawn once, as an interchange. */\n  id: string\n  label: string\n  note?: string\n  /** Order along the line. Only the order counts: stops are evenly spaced, so a busy stretch never overlaps. */\n  at: number\n  /** Which parallel track it sits on, from 0. */\n  lane: number\n  href?: string\n  disabled?: boolean\n}\n\nexport interface MetroLine {\n  id: string\n  label: string\n  /** Lines differ by tone and, for `ink`, by dash too, so the map still reads without colour. */\n  tone?: MetroLineTone\n  stops: readonly MetroStop[]\n}\n\nconst EDGE_FADE_PX = 24\n\n/** Which ends of a scroller have more behind them. */\nexport function scrollEdges({ scrollLeft, clientWidth, scrollWidth }: { scrollLeft: number; clientWidth: number; scrollWidth: number }) {\n  return { start: scrollLeft > 1, end: scrollLeft + clientWidth < scrollWidth - 1 }\n}\n\n/** A hard cut through a label reads as a bug. A fade reads as more to scroll, and stays off an edge with nothing behind it. */\nexport function edgeMask({ start, end }: { start: boolean; end: boolean }) {\n  if (!start && !end) return undefined\n  const from = start ? `transparent, black ${EDGE_FADE_PX}px` : \"black 0\"\n  const to = end ? `black calc(100% - ${EDGE_FADE_PX}px), transparent` : \"black 100%\"\n  return `linear-gradient(to right, ${from}, ${to})`\n}\n\nexport interface MetroPoint {\n  x: number\n  y: number\n}\n\nexport interface PlacedStation extends MetroStop, MetroPoint {\n  lineLabels: readonly string[]\n  tone: MetroLineTone\n}\n\nexport interface PlacedLine {\n  id: string\n  label: string\n  tone: MetroLineTone\n  d: string\n  points: readonly MetroPoint[]\n}\n\nexport interface MetroLayout {\n  orientation: \"horizontal\" | \"vertical\"\n  gap: number\n  laneGap: number\n  padTime: number\n  padLane: number\n  bend: \"diagonal\" | \"orthogonal\"\n  corner: number\n}\n\nexport interface MetroPlan {\n  width: number\n  height: number\n  lines: readonly PlacedLine[]\n  stations: readonly PlacedStation[]\n}\n\n/** A polyline with its corners cut by a quadratic, the way a drawn metro line turns. */\nexport function roundedPath(points: readonly MetroPoint[], radius: number) {\n  return points\n    .map((point, step) => {\n      const before = points[step - 1]\n      const after = points[step + 1]\n      if (!before) return `M${point.x} ${point.y}`\n      if (!after || radius <= 0) return `L${point.x} ${point.y}`\n      const inLength = Math.hypot(point.x - before.x, point.y - before.y) || 1\n      const outLength = Math.hypot(after.x - point.x, after.y - point.y) || 1\n      const cut = Math.min(radius, inLength / 2, outLength / 2)\n      const startX = point.x - ((point.x - before.x) / inLength) * cut\n      const startY = point.y - ((point.y - before.y) / inLength) * cut\n      const endX = point.x + ((after.x - point.x) / outLength) * cut\n      const endY = point.y + ((after.y - point.y) / outLength) * cut\n      return `L${startX} ${startY}Q${point.x} ${point.y} ${endX} ${endY}`\n    })\n    .join(\"\")\n}\n\n/** Share of the line's length up to a station: how far along it someone has come. */\nexport function progressAlong(points: readonly MetroPoint[], station: MetroPoint) {\n  const lengths = points.slice(1).map((point, step) => {\n    const before = points[step] as MetroPoint\n    return Math.hypot(point.x - before.x, point.y - before.y)\n  })\n  const total = lengths.reduce((sum, length) => sum + length, 0)\n  const index = points.findIndex((point) => point.x === station.x && point.y === station.y)\n  if (total === 0 || index < 0) return 0\n  return lengths.slice(0, index).reduce((sum, length) => sum + length, 0) / total\n}\n\n/** Time and lane are kept apart from x and y here, so one route serves both orientations. */\nfunction routeStops(stops: readonly MetroStop[], time: (at: number) => number, lane: (index: number) => number, layout: MetroLayout) {\n  return stops.flatMap((stop, step): Array<[number, number]> => {\n    const place: [number, number] = [time(stop.at), lane(stop.lane)]\n    const previous = stops[step - 1]\n    if (!previous || previous.lane === stop.lane) return [place]\n    const from = time(previous.at)\n    const fromLane = lane(previous.lane)\n    if (layout.bend === \"orthogonal\") {\n      const middle = (from + place[0]) / 2\n      return [[middle, fromLane], [middle, place[1]], place]\n    }\n    // A true 45 degrees when there is room; a tight hop takes what the gap allows.\n    const run = Math.min(Math.abs(place[1] - fromLane), place[0] - from)\n    return [[place[0] - run, fromLane], place]\n  })\n}\n\nexport function buildMetro(lines: readonly MetroLine[], layout: MetroLayout): MetroPlan {\n  const allStops = lines.flatMap((line) => line.stops)\n  const ranks = [...new Set(allStops.map((stop) => stop.at))].sort((a, b) => a - b)\n  const maxLane = Math.max(0, ...allStops.map((stop) => stop.lane))\n  const time = (at: number) => layout.padTime + ranks.indexOf(at) * layout.gap\n  const lane = (index: number) => layout.padLane + index * layout.laneGap\n  const toPoint = ([along, across]: [number, number]): MetroPoint =>\n    layout.orientation === \"horizontal\" ? { x: along, y: across } : { x: across, y: along }\n\n  const placedLines = lines.map((line): PlacedLine => {\n    const stops = [...line.stops].sort((a, b) => a.at - b.at)\n    const points = routeStops(stops, time, lane, layout).map(toPoint)\n    return { id: line.id, label: line.label, tone: line.tone ?? \"mise\", d: roundedPath(points, layout.corner), points }\n  })\n\n  const stations = allStops.reduce<PlacedStation[]>((placed, stop) => {\n    if (placed.some((station) => station.id === stop.id)) return placed\n    const owners = lines.filter((line) => line.stops.some((other) => other.id === stop.id))\n    const point = toPoint([time(stop.at), lane(stop.lane)])\n    return [...placed, { ...stop, ...point, lineLabels: owners.map((line) => line.label), tone: owners[0]?.tone ?? \"mise\" }]\n  }, [])\n\n  const along = layout.padTime * 2 + Math.max(ranks.length - 1, 0) * layout.gap\n  const across = layout.padLane * 2 + maxLane * layout.laneGap\n  return {\n    width: layout.orientation === \"horizontal\" ? along : across,\n    height: layout.orientation === \"horizontal\" ? across : along,\n    lines: placedLines,\n    stations: [...stations].sort((a, b) => a.at - b.at || a.lane - b.lane),\n  }\n}\n\nconst DIRECTION: Record<string, MetroPoint> = {\n  ArrowRight: { x: 1, y: 0 },\n  ArrowLeft: { x: -1, y: 0 },\n  ArrowDown: { x: 0, y: 1 },\n  ArrowUp: { x: 0, y: -1 },\n}\n\nconst OFF_AXIS_PENALTY = 2\n\n/** The station an arrow key points at: ahead on that axis, preferring the one nearest the line of travel. */\nexport function stationInDirection(stations: readonly PlacedStation[], fromId: string, key: string) {\n  const from = stations.find((station) => station.id === fromId)\n  const direction = DIRECTION[key]\n  if (!from || !direction) return null\n  const scored = stations.flatMap((station) => {\n    if (station.id === fromId || station.disabled) return []\n    const dx = station.x - from.x\n    const dy = station.y - from.y\n    const ahead = dx * direction.x + dy * direction.y\n    if (ahead <= 0) return []\n    const aside = Math.abs(dx * direction.y) + Math.abs(dy * direction.x)\n    return [{ id: station.id, score: ahead + aside * OFF_AXIS_PENALTY }]\n  })\n  return [...scored].sort((a, b) => a.score - b.score)[0]?.id ?? null\n}\n\n/** Where to scroll so an element sits in the middle of its scroller. */\nexport function centeredScroll(left: number, width: number, viewWidth: number) {\n  return Math.max(0, left + width / 2 - viewWidth / 2)\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/metro-map-parts.tsx",
      "content": "import type * as React from \"react\"\nimport { motion } from \"motion/react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport { progressAlong, type MetroLineTone, type MetroPlan, type PlacedStation } from \"@/registry/miseui/ui/metro-map-layout\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\nexport type MetroMapVariant = \"transit\" | \"tube\" | \"branch\"\n\nconst TONE_CLASS: Record<MetroLineTone, string> = {\n  mise: \"text-mise\",\n  muted: \"text-muted-foreground\",\n  ink: \"text-foreground\",\n}\n\nconst STROKE: Record<MetroMapVariant, number> = { transit: 6, tube: 8, branch: 2 }\nconst DASH_FOR_INK = \"1 10\"\n\ninterface LinesProps {\n  plan: MetroPlan\n  variant: MetroMapVariant\n  selected: PlacedStation | undefined\n  ride: boolean\n  reduceMotion: boolean\n}\n\nexport function MetroLines({ plan, variant, selected, ride, reduceMotion }: LinesProps) {\n  const width = STROKE[variant]\n  const ridden = ride && selected ? plan.lines.find((line) => line.points.some((point) => point.x === selected.x && point.y === selected.y)) : undefined\n  const progress = ridden && selected ? progressAlong(ridden.points, selected) : 0\n  return (\n    <svg aria-hidden=\"true\" data-slot=\"metro-map-lines\" className=\"pointer-events-none absolute inset-0\" width={plan.width} height={plan.height} fill=\"none\">\n      {plan.lines.map((line) => (\n        <path\n          key={line.id}\n          data-line={line.id}\n          d={line.d}\n          className={cn(TONE_CLASS[line.tone], ride && \"opacity-30\")}\n          stroke=\"currentColor\"\n          strokeWidth={width}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeDasharray={line.tone === \"ink\" ? DASH_FOR_INK : undefined}\n        />\n      ))}\n      {ridden ? (\n        <motion.path\n          key={ridden.id}\n          data-slot=\"metro-map-ride\"\n          data-progress={progress.toFixed(3)}\n          d={ridden.d}\n          className={TONE_CLASS[ridden.tone]}\n          stroke=\"currentColor\"\n          strokeWidth={width}\n          strokeLinecap=\"round\"\n          initial={false}\n          animate={{ pathLength: progress }}\n          transition={reduceMotion ? { duration: 0 } : SPRING_DEFAULT}\n        />\n      ) : null}\n    </svg>\n  )\n}\n\nfunction StationMarker({ station, variant, selected }: { station: PlacedStation; variant: MetroMapVariant; selected: boolean }) {\n  const interchange = station.lineLabels.length > 1\n  if (variant === \"branch\") {\n    return (\n      <span\n        data-slot=\"metro-map-marker\"\n        className={cn(\"block size-2.5 rounded-full bg-foreground\", selected && \"size-3 bg-mise ring-2 ring-foreground\", interchange && !selected && \"ring-2 ring-foreground ring-offset-2 ring-offset-background\")}\n      />\n    )\n  }\n  // The Underground's convention: a tick for a stop, a ring only where something is worth noticing.\n  if (variant === \"tube\" && !selected && !interchange) {\n    return <span data-slot=\"metro-map-marker\" data-shape=\"tick\" className={cn(\"block h-3 w-1.5 -translate-y-2.5 bg-current\", TONE_CLASS[station.tone])} />\n  }\n  return (\n    <span\n      data-slot=\"metro-map-marker\"\n      data-shape=\"ring\"\n      className={cn(\"block size-4 rounded-full border-[3px] border-foreground bg-background\", interchange && \"size-5\", selected && \"size-5 bg-mise\")}\n    />\n  )\n}\n\ninterface StationProps {\n  station: PlacedStation\n  variant: MetroMapVariant\n  selected: boolean\n  focusable: boolean\n  gap: number\n  gutter: number\n  labelAbove: boolean\n  onPick: (station: PlacedStation, element: HTMLElement) => void\n  onFocusStation: (id: string) => void\n}\n\nconst STATION_FOCUS = \"outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n\nfunction stationStyle(station: PlacedStation, variant: MetroMapVariant): React.CSSProperties {\n  if (variant === \"branch\") return { top: station.y - 24 }\n  return { left: station.x - 20, top: station.y - 20 }\n}\n\nexport function MetroStation({ station, variant, selected, focusable, gap, gutter, labelAbove, onPick, onFocusStation }: StationProps) {\n  const branch = variant === \"branch\"\n  const shared = {\n    \"data-station\": station.id,\n    \"aria-current\": selected ? (\"location\" as const) : undefined,\n    tabIndex: focusable ? 0 : -1,\n    style: stationStyle(station, variant),\n    onFocus: () => onFocusStation(station.id),\n    onClick: (event: React.MouseEvent<HTMLElement>) => onPick(station, event.currentTarget),\n    className: cn(\n      \"group absolute rounded-md\",\n      STATION_FOCUS,\n      branch ? \"inset-x-0 flex h-12 flex-col justify-center text-start focus-visible:ring-inset\" : \"size-10\",\n      station.disabled && \"pointer-events-none opacity-50\",\n    ),\n  }\n  const content = (\n    <>\n      <span className={cn(\"absolute grid size-5 place-items-center\", !branch && \"inset-0 m-auto\")} style={branch ? { left: station.x - 10, top: 14 } : undefined}>\n        <StationMarker station={station} variant={variant} selected={selected} />\n      </span>\n      <span\n        data-slot=\"metro-map-label\"\n        className={cn(\n          \"text-[13px] leading-tight text-muted-foreground group-hover:text-foreground\",\n          selected && \"font-semibold text-foreground\",\n          branch ? \"block\" : \"absolute left-1/2 grid -translate-x-1/2 text-center\",\n          !branch && (labelAbove ? \"bottom-full mb-1\" : \"top-full mt-1\"),\n        )}\n        style={branch ? { paddingLeft: gutter } : { width: gap - 8 }}\n      >\n        <span className=\"block\">{station.label}</span>\n        {station.note ? <span className=\"block font-normal text-muted-foreground\">{station.note}</span> : null}\n        {station.lineLabels.length > 1 ? <span className=\"sr-only\">Interchange: {station.lineLabels.join(\", \")}</span> : null}\n      </span>\n    </>\n  )\n  if (station.href) return <a href={station.href} {...shared}>{content}</a>\n  return <button type=\"button\" disabled={station.disabled} {...shared}>{content}</button>\n}\n\nexport function MetroLegend({ plan }: { plan: MetroPlan }) {\n  if (plan.lines.length < 2) return null\n  return (\n    <ul data-slot=\"metro-map-legend\" className=\"flex flex-wrap gap-x-4 gap-y-1 text-[13px] text-muted-foreground\">\n      {plan.lines.map((line) => (\n        <li key={line.id} className=\"flex min-w-0 items-center gap-2\">\n          <span aria-hidden=\"true\" className={cn(\"w-4 border-t-2 border-current\", TONE_CLASS[line.tone], line.tone === \"ink\" && \"border-dotted\")} />\n          {line.label}\n        </li>\n      ))}\n    </ul>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "type": "registry:ui"
}