Defense & Aerospace

Patrol Route

A mission route on accurate world geometry with ordered geo-projected waypoints, a flown-portion overlay driven by real segment lengths, an along-path aircraft glyph, and per-leg ETA chips.

Install

npx shadcn@latest add @paragon/patrol-route

Also installs: world-map

patrol-route.tsx

"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { WorldMap, project } from "@/registry/paragon/ui/world-map";
import { WORLD_VIEWBOX } from "@/registry/paragon/ui/world-map-data";
import { cn } from "@/lib/utils";

export interface Waypoint {
  name: string;
  lng: number;
  lat: number;
  /** Minutes of flight time on the leg arriving at this waypoint. */
  legMinutes?: number;
}

export interface PatrolRouteProps extends React.ComponentProps<"div"> {
  waypoints?: Waypoint[];
  /** Overall route completion, 0–1. */
  progress?: number;
  callsign?: string;
  static?: boolean;
}

const DEFAULT_WAYPOINTS: Waypoint[] = [
  { name: "IP ALPHA", lng: 42, lat: 35 },
  { name: "WP-1", lng: 47, lat: 32, legMinutes: 22 },
  { name: "WP-2", lng: 52, lat: 28, legMinutes: 26 },
  { name: "ORBIT", lng: 55, lat: 24, legMinutes: 18 },
  { name: "RTB", lng: 50, lat: 22, legMinutes: 31 },
];

/**
 * A mission patrol route on accurate `world-map` geometry. Waypoints are
 * geo-projected and joined by a polyline; the flown portion is drawn with a
 * `stroke-dashoffset` proportional to `progress` (computed from real segment
 * lengths, not eyeballed), and the aircraft glyph is placed at the exact
 * along-path point. Per-leg ETA chips read in tabular figures. Reduced motion
 * removes the moving dash animation, keeping the static progress state.
 */
export function PatrolRoute({
  waypoints = DEFAULT_WAYPOINTS,
  progress = 0.46,
  callsign = "REAPER 1-1 / DEMO",
  static: isStatic = false,
  className,
  ...props
}: PatrolRouteProps) {
  const reduced = useReducedMotion();
  const pts = React.useMemo(
    () => waypoints.map((w) => ({ ...w, ...project(w.lng, w.lat) })),
    [waypoints],
  );

  // Cumulative segment lengths → total path length.
  const { segLens, total } = React.useMemo(() => {
    const segLens: number[] = [];
    let total = 0;
    for (let i = 1; i < pts.length; i++) {
      const dx = pts[i].x - pts[i - 1].x;
      const dy = pts[i].y - pts[i - 1].y;
      const l = Math.hypot(dx, dy);
      segLens.push(l);
      total += l;
    }
    return { segLens, total };
  }, [pts]);

  // Aircraft position at `progress` along the total path.
  const aircraft = React.useMemo(() => {
    let target = Math.max(0, Math.min(1, progress)) * total;
    for (let i = 0; i < segLens.length; i++) {
      if (target <= segLens[i] || i === segLens.length - 1) {
        const f = segLens[i] === 0 ? 0 : Math.min(1, target / segLens[i]);
        return {
          x: pts[i].x + (pts[i + 1].x - pts[i].x) * f,
          y: pts[i].y + (pts[i + 1].y - pts[i].y) * f,
        };
      }
      target -= segLens[i];
    }
    return { x: pts[0].x, y: pts[0].y };
  }, [progress, segLens, total, pts]);

  const flownLen = Math.max(0, Math.min(1, progress)) * total;
  const d = "M " + pts.map((p) => `${p.x} ${p.y}`).join(" L ");

  // Current leg: how many waypoints have been passed.
  const currentWp = React.useMemo(() => {
    let acc = 0;
    for (let i = 0; i < segLens.length; i++) {
      acc += segLens[i];
      if (flownLen < acc) return i + 1;
    }
    return pts.length;
  }, [segLens, flownLen, pts.length]);

  return (
    <div
      className={cn(
        "w-full max-w-2xl rounded-xl bg-card p-2 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <style href="paragon-patrol-route" precedence="paragon">{`
        @keyframes pg-patrol-dash { to { stroke-dashoffset: -12; } }
        .pg-patrol-flow { animation: pg-patrol-dash 1s linear infinite; }
        @media (prefers-reduced-motion: reduce) { .pg-patrol-flow { animation: none; } }
      `}</style>

      <div className="relative overflow-hidden rounded-lg">
        <WorldMap tooltip={false} className="[&_svg]:opacity-90" />
        <svg
          viewBox={WORLD_VIEWBOX}
          className="pointer-events-none absolute inset-0 h-full w-full"
        >
          {/* full planned route */}
          <path
            d={d}
            fill="none"
            stroke="var(--color-muted-foreground)"
            strokeOpacity={0.5}
            strokeWidth={1}
            strokeDasharray="1 3"
            strokeLinejoin="round"
            strokeLinecap="round"
            vectorEffect="non-scaling-stroke"
          />
          {/* flown portion */}
          <path
            d={d}
            fill="none"
            stroke="var(--color-primary)"
            strokeWidth={1.6}
            strokeLinejoin="round"
            strokeLinecap="round"
            pathLength={total}
            strokeDasharray={`${flownLen} ${total}`}
            className={!isStatic && !reduced ? "pg-patrol-flow" : undefined}
            vectorEffect="non-scaling-stroke"
          />
          {pts.map((p, i) => (
            <g key={i}>
              <circle
                cx={p.x}
                cy={p.y}
                r={2.2}
                fill="var(--color-card)"
                stroke="var(--color-primary)"
                strokeWidth={1.2}
                vectorEffect="non-scaling-stroke"
              />
              <text
                x={p.x}
                y={p.y - 4}
                textAnchor="middle"
                fontSize={5.5}
                fill="var(--color-foreground)"
                className="font-mono"
              >
                {p.name}
              </text>
            </g>
          ))}
          {/* aircraft */}
          <g transform={`translate(${aircraft.x} ${aircraft.y})`}>
            <circle r={4} fill="var(--color-primary)" fillOpacity={0.2} />
            <circle r={2.2} fill="var(--color-primary)" stroke="var(--color-card)" strokeWidth={0.8} />
          </g>
        </svg>
      </div>

      <div className="mt-2 flex items-center justify-between px-1">
        <span className="font-mono text-xs font-medium">{callsign}</span>
        <span className="tabular-nums text-xs text-muted-foreground">
          {Math.round(progress * 100)}% · WP {currentWp}/{pts.length}
        </span>
      </div>

      <ol className="mt-1.5 flex flex-wrap gap-1 px-1 pb-0.5">
        {waypoints.map((w, i) => (
          <li
            key={i}
            className="inline-flex items-center gap-1 rounded-md bg-secondary/60 px-1.5 py-0.5 text-[10px]"
          >
            <span className="font-mono font-medium">{w.name}</span>
            {w.legMinutes != null && (
              <span className="tabular-nums text-muted-foreground">
                +{w.legMinutes}m
              </span>
            )}
          </li>
        ))}
      </ol>
    </div>
  );
}