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.
npx shadcn@latest add @paragon/patrol-routeAlso installs: world-map
"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>
);
}