Route Map

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

Signaler un 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
bash

Storybook

Explorez les variantes, controles et verifications d'accessibilite dans le playground Storybook interactif.

Voir dans Storybook

Code

"use client";

import {
  type ComponentPropsWithoutRef,
  forwardRef,
  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 = forwardRef<HTMLElement, RouteMapProps>((props, ref) => {
  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";
typescript

Dependances

  • @vllnt/ui@^0.2.1