Route Map

Standalone SVG map with animated route paths, waypoints, and progress indicator. For trade routes, voyages, migrations, delivery tracking.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/route-map.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

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

Dependencies

  • @vllnt/ui@^0.3.0