{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "route-map",
  "type": "registry:component",
  "title": "Route Map",
  "description": "Standalone SVG map with animated route paths, waypoints, and progress indicator. For trade routes, voyages, migrations, delivery tracking.",
  "dependencies": [
    "@vllnt/ui@^0.2.1"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/route-map/route-map.tsx",
      "content": "\"use client\";\n\nimport {\n  type ComponentPropsWithoutRef,\n  forwardRef,\n  type ReactNode,\n  useId,\n  useMemo,\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 * A single waypoint along the route.\n *\n * @public\n */\nexport type RouteWaypoint = {\n  /** Stable identifier. */\n  id: string;\n  /** Human-readable label rendered next to the marker. */\n  label: ReactNode;\n  /** Optional 1-based ordinal. Defaults to the array index + 1. */\n  order?: number;\n  /** Geographic position. */\n  position: GeoPosition;\n};\n\n/**\n * Color theme for the route line + waypoint markers.\n *\n * @public\n */\nexport type RouteColor =\n  | \"amber\"\n  | \"blue\"\n  | \"emerald\"\n  | \"purple\"\n  | \"red\"\n  | \"rose\";\n\nconst ROUTE_PALETTE: Record<\n  RouteColor,\n  { dot: string; line: string; text: string }\n> = {\n  amber: {\n    dot: \"fill-amber-500\",\n    line: \"stroke-amber-500\",\n    text: \"text-amber-600\",\n  },\n  blue: {\n    dot: \"fill-blue-500\",\n    line: \"stroke-blue-500\",\n    text: \"text-blue-600\",\n  },\n  emerald: {\n    dot: \"fill-emerald-500\",\n    line: \"stroke-emerald-500\",\n    text: \"text-emerald-600\",\n  },\n  purple: {\n    dot: \"fill-purple-500\",\n    line: \"stroke-purple-500\",\n    text: \"text-purple-600\",\n  },\n  red: { dot: \"fill-red-500\", line: \"stroke-red-500\", text: \"text-red-600\" },\n  rose: {\n    dot: \"fill-rose-500\",\n    line: \"stroke-rose-500\",\n    text: \"text-rose-600\",\n  },\n};\n\n/**\n * Line drawing style.\n *\n * @public\n */\nexport type RouteLineStyle = \"dashed\" | \"dotted\" | \"solid\";\n\nconst LINE_DASH: Record<RouteLineStyle, string> = {\n  dashed: \"10,8\",\n  dotted: \"2,6\",\n  solid: \"0\",\n};\n\n/**\n * Localizable strings.\n *\n * @public\n */\nexport type RouteMapLabels = {\n  /** Aria-label for the route region. Defaults to `\"Route map\"`. */\n  region?: string;\n};\n\nconst DEFAULT_LABELS = {\n  region: \"Route map\",\n} as const satisfies Required<RouteMapLabels>;\n\nfunction projectEquirectangular(position: GeoPosition): {\n  x: number;\n  y: number;\n} {\n  const [lng, lat] = position;\n  const x = ((lng + 180) / 360) * VIEWBOX_WIDTH;\n  const y = ((90 - lat) / 180) * VIEWBOX_HEIGHT;\n  return { x, y };\n}\n\n/**\n * Props for {@link RouteMap}.\n *\n * @public\n */\nexport type RouteMapProps = {\n  /** When `true`, the route line draws progressively from start to end. */\n  animated?: boolean;\n  /** Animation duration in seconds. Defaults to `4`. */\n  animationDurationSeconds?: number;\n  /** Optional URL of a backdrop image (world map, terrain). */\n  backdrop?: string;\n  /** Aria-label for the backdrop image when set. */\n  backdropAlt?: string;\n  /** Color theme. Defaults to `\"red\"`. */\n  color?: RouteColor;\n  /** Localizable strings. */\n  labels?: RouteMapLabels;\n  /** Line drawing style. Defaults to `\"solid\"`. */\n  lineStyle?: RouteLineStyle;\n  /** When `true`, renders a moving indicator that travels along the route. */\n  showProgressIndicator?: boolean;\n  /** Ordered list of waypoints along the route. */\n  waypoints: RouteWaypoint[];\n} & ComponentPropsWithoutRef<\"section\">;\n\ntype ProjectedWaypoint = {\n  ordinal: number;\n  raw: RouteWaypoint;\n  x: number;\n  y: number;\n};\n\nfunction projectWaypoints(waypoints: RouteWaypoint[]): ProjectedWaypoint[] {\n  return waypoints.map((waypoint, index) => {\n    const point = projectEquirectangular(waypoint.position);\n    return {\n      ordinal: waypoint.order ?? index + 1,\n      raw: waypoint,\n      x: point.x,\n      y: point.y,\n    };\n  });\n}\n\nfunction pathFor(points: ProjectedWaypoint[]): string {\n  if (points.length === 0) return \"\";\n  return points\n    .map(\n      (point, index) =>\n        `${index === 0 ? \"M\" : \"L\"}${point.x.toString()},${point.y.toString()}`,\n    )\n    .join(\" \");\n}\n\nfunction pathLength(points: ProjectedWaypoint[]): number {\n  if (points.length < 2) return 0;\n  const total = points.reduce<{ length: number; previous?: ProjectedWaypoint }>(\n    (accumulator, point) => {\n      if (!accumulator.previous) return { length: 0, previous: point };\n      const dx = point.x - accumulator.previous.x;\n      const dy = point.y - accumulator.previous.y;\n      return {\n        length: accumulator.length + Math.hypot(dx, dy),\n        previous: point,\n      };\n    },\n    { length: 0 },\n  );\n  return total.length;\n}\n\ntype RouteLineProps = {\n  animated: boolean;\n  animationDurationSeconds: number;\n  color: RouteColor;\n  lineStyle: RouteLineStyle;\n  pathDefinition: string;\n  pathId: string;\n  totalLength: number;\n};\n\nfunction RouteLine({\n  animated,\n  animationDurationSeconds,\n  color,\n  lineStyle,\n  pathDefinition,\n  pathId,\n  totalLength,\n}: RouteLineProps): ReactNode {\n  if (!pathDefinition) return null;\n  const palette = ROUTE_PALETTE[color];\n  const dashArray = animated\n    ? totalLength > 0\n      ? `${totalLength.toString()} ${totalLength.toString()}`\n      : LINE_DASH[lineStyle]\n    : LINE_DASH[lineStyle];\n\n  return (\n    <g data-route-line>\n      <path\n        className={cn(\"fill-none stroke-[3]\", palette.line)}\n        d={pathDefinition}\n        id={pathId}\n        strokeDasharray={dashArray}\n        strokeDashoffset={animated ? totalLength : 0}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      >\n        {animated ? (\n          <animate\n            attributeName=\"stroke-dashoffset\"\n            dur={`${animationDurationSeconds.toString()}s`}\n            fill=\"freeze\"\n            from={totalLength}\n            to=\"0\"\n          />\n        ) : null}\n      </path>\n    </g>\n  );\n}\n\ntype ProgressIndicatorProps = {\n  animationDurationSeconds: number;\n  color: RouteColor;\n  pathId: string;\n  visible: boolean;\n};\n\nfunction ProgressIndicator({\n  animationDurationSeconds,\n  color,\n  pathId,\n  visible,\n}: ProgressIndicatorProps): ReactNode {\n  if (!visible) return null;\n  const palette = ROUTE_PALETTE[color];\n  return (\n    <g data-route-progress>\n      <circle\n        className={cn(\"stroke-background\", palette.dot)}\n        r=\"6\"\n        strokeWidth=\"2\"\n      >\n        <animateMotion\n          dur={`${animationDurationSeconds.toString()}s`}\n          repeatCount=\"indefinite\"\n          rotate=\"auto\"\n        >\n          <mpath href={`#${pathId}`} />\n        </animateMotion>\n      </circle>\n    </g>\n  );\n}\n\ntype WaypointDotsProps = {\n  color: RouteColor;\n  waypoints: ProjectedWaypoint[];\n};\n\nfunction WaypointDots({ color, waypoints }: WaypointDotsProps): ReactNode {\n  const palette = ROUTE_PALETTE[color];\n  return (\n    <g data-route-waypoints>\n      {waypoints.map((point) => {\n        const labelText =\n          typeof point.raw.label === \"string\" ? point.raw.label : undefined;\n        return (\n          <g\n            data-waypoint-id={point.raw.id}\n            data-waypoint-ordinal={point.ordinal}\n            key={point.raw.id}\n            transform={`translate(${point.x.toString()}, ${point.y.toString()})`}\n          >\n            <circle\n              className={cn(\n                \"stroke-background outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                palette.dot,\n              )}\n              r=\"6\"\n              strokeWidth=\"2\"\n            >\n              {labelText ? <title>{labelText}</title> : null}\n            </circle>\n            <text\n              className={cn(\n                \"select-none text-[10px] font-semibold\",\n                palette.text,\n              )}\n              dominantBaseline=\"middle\"\n              textAnchor=\"middle\"\n              y=\"-12\"\n            >\n              {point.raw.label}\n            </text>\n            <text\n              className=\"select-none fill-background text-[8px] font-bold\"\n              dominantBaseline=\"central\"\n              textAnchor=\"middle\"\n              y=\"0\"\n            >\n              {point.ordinal}\n            </text>\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\ntype DataSummaryProps = {\n  titleId: string;\n  waypoints: RouteWaypoint[];\n};\n\nfunction DataSummary({ titleId, waypoints }: DataSummaryProps): ReactNode {\n  return (\n    <div aria-labelledby={titleId} className=\"sr-only\" role=\"region\">\n      <h3 id={titleId}>Route waypoint summary</h3>\n      <ol>\n        {waypoints.map((waypoint) => (\n          <li key={waypoint.id}>\n            {typeof waypoint.label === \"string\"\n              ? waypoint.label\n              : `Waypoint ${waypoint.id}`}\n            {`: ${waypoint.position[0].toString()}, ${waypoint.position[1].toString()}`}\n          </li>\n        ))}\n      </ol>\n    </div>\n  );\n}\n\ntype StageProps = {\n  animated: boolean;\n  animationDurationSeconds: number;\n  backdrop?: string;\n  backdropAlt?: string;\n  color: RouteColor;\n  lineStyle: RouteLineStyle;\n  pathDefinition: string;\n  pathId: string;\n  showProgressIndicator: boolean;\n  totalLength: number;\n  waypoints: ProjectedWaypoint[];\n};\n\nfunction Stage(props: StageProps): ReactNode {\n  const {\n    animated,\n    animationDurationSeconds,\n    backdrop,\n    backdropAlt,\n    color,\n    lineStyle,\n    pathDefinition,\n    pathId,\n    showProgressIndicator,\n    totalLength,\n    waypoints,\n  } = props;\n  const showProgress: boolean = showProgressIndicator && totalLength > 0;\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      <RouteLine\n        animated={animated}\n        animationDurationSeconds={animationDurationSeconds}\n        color={color}\n        lineStyle={lineStyle}\n        pathDefinition={pathDefinition}\n        pathId={pathId}\n        totalLength={totalLength}\n      />\n      <WaypointDots color={color} waypoints={waypoints} />\n      <ProgressIndicator\n        animationDurationSeconds={animationDurationSeconds}\n        color={color}\n        pathId={pathId}\n        visible={showProgress}\n      />\n    </svg>\n  );\n}\n\n/**\n * Map for animated route paths, waypoints, and journey progression.\n * Use for trade routes, military campaigns, exploration voyages,\n * migration paths, and delivery tracking. Standalone SVG primitive —\n * no external map library or tile provider required. Drop in a backdrop\n * image (Natural Earth SVG, terrain raster) to add geographic context.\n *\n * @example\n * ```tsx\n * <RouteMap\n *   animated\n *   showProgressIndicator\n *   color=\"red\"\n *   waypoints={[\n *     { id: \"chang\", label: \"Chang'an\", position: [108.94, 34.34] },\n *     { id: \"kashgar\", label: \"Kashgar\", position: [75.99, 39.47] },\n *     { id: \"samarkand\", label: \"Samarkand\", position: [66.97, 39.65] },\n *     { id: \"constantinople\", label: \"Constantinople\", position: [28.98, 41.01] },\n *   ]}\n * />\n * ```\n *\n * @public\n */\nexport const RouteMap = forwardRef<HTMLElement, RouteMapProps>((props, ref) => {\n  const {\n    animated = false,\n    animationDurationSeconds = 4,\n    backdrop,\n    backdropAlt,\n    children,\n    className,\n    color = \"red\",\n    labels,\n    lineStyle = \"solid\",\n    showProgressIndicator = false,\n    waypoints,\n    ...rest\n  } = props;\n  const titleId = useId();\n  const pathId = useId();\n  const resolvedLabels = useMemo(\n    () => ({ ...DEFAULT_LABELS, ...labels }),\n    [labels],\n  );\n  const projected = useMemo(() => projectWaypoints(waypoints), [waypoints]);\n  const pathDefinition = useMemo(() => pathFor(projected), [projected]);\n  const totalLength = useMemo(() => pathLength(projected), [projected]);\n\n  return (\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\n        animated={animated}\n        animationDurationSeconds={animationDurationSeconds}\n        backdrop={backdrop}\n        backdropAlt={backdropAlt}\n        color={color}\n        lineStyle={lineStyle}\n        pathDefinition={pathDefinition}\n        pathId={pathId}\n        showProgressIndicator={showProgressIndicator}\n        totalLength={totalLength}\n        waypoints={projected}\n      />\n      {children ? (\n        <div className=\"absolute bottom-3 left-3 z-10 max-w-xs rounded-md border bg-background/95 px-3 py-2 text-xs text-foreground shadow-sm backdrop-blur\">\n          {children}\n        </div>\n      ) : null}\n      <DataSummary titleId={titleId} waypoints={waypoints} />\n    </section>\n  );\n});\nRouteMap.displayName = \"RouteMap\";\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
