A common-operating-picture map on accurate world geometry with geo-projected affiliation glyphs, speed-scaled heading trails, selectable tracks, and a hover detail card.
npx shadcn@latest add @paragon/threat-mapAlso 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";
const [, , VB_W, VB_H] = WORLD_VIEWBOX.split(" ").map(Number);
export type TrackAffiliation = "hostile" | "unknown" | "friendly";
export interface ThreatTrack {
id: string;
affiliation: TrackAffiliation;
lng: number;
lat: number;
/** Heading in degrees for the trail direction, 0 = north. */
heading: number;
/** Speed in knots (drives trail length). */
speed: number;
type?: string;
}
export interface ThreatMapProps
extends Omit<React.ComponentProps<"div">, "onSelect"> {
tracks?: ThreatTrack[];
/** Controlled selected track id. */
selectedId?: string | null;
onSelect?: (id: string | null) => void;
static?: boolean;
}
const AFFIL: Record<TrackAffiliation, { color: string; label: string }> = {
hostile: { color: "var(--color-destructive)", label: "HOSTILE" },
unknown: { color: "var(--color-warning)", label: "UNKNOWN" },
friendly: { color: "var(--color-success)", label: "FRIENDLY" },
};
const DEFAULT_TRACKS: ThreatTrack[] = [
{ id: "TK-1174", affiliation: "hostile", lng: 51, lat: 26, heading: 210, speed: 420, type: "Fast air" },
{ id: "TK-2251", affiliation: "unknown", lng: 44, lat: 15, heading: 300, speed: 180, type: "Surface" },
{ id: "BF-09", affiliation: "friendly", lng: 55, lat: 24, heading: 45, speed: 480, type: "CAP" },
{ id: "TK-2309", affiliation: "unknown", lng: 60, lat: 20, heading: 275, speed: 90, type: "Slow mover" },
{ id: "TK-1180", affiliation: "hostile", lng: 48, lat: 30, heading: 160, speed: 510, type: "Fast air" },
];
/**
* A common-operating-picture threat map. Tracks are geo-projected onto the
* accurate `world-map` geometry via its `project(lng,lat)`; each carries a
* MIL-STD-style affiliation glyph (chevron / square / diamond), a heading
* trail whose length is computed from speed, and a hover/selection detail
* card. Markers pulse only when hostile-and-selected. Reduced motion keeps
* the picture static.
*/
export function ThreatMap({
tracks = DEFAULT_TRACKS,
selectedId,
onSelect,
static: isStatic = false,
className,
...props
}: ThreatMapProps) {
const reduced = useReducedMotion();
const [internalSel, setInternalSel] = React.useState<string | null>(null);
const [hoverId, setHoverId] = React.useState<string | null>(null);
const sel = selectedId !== undefined ? selectedId : internalSel;
const select = (id: string | null) => {
if (selectedId === undefined) setInternalSel(id);
onSelect?.(id);
};
const points = React.useMemo(
() =>
tracks.map((t) => {
const p = project(t.lng, t.lat);
// trail: opposite of heading, length scaled by speed
const len = 4 + (t.speed / 520) * 14;
const rad = ((t.heading - 90) * Math.PI) / 180;
return {
...t,
x: p.x,
y: p.y,
tx: p.x - len * Math.cos(rad),
ty: p.y - len * Math.sin(rad),
hx: p.x + 6 * Math.cos(rad),
hy: p.y + 6 * Math.sin(rad),
};
}),
[tracks],
);
const active = hoverId ?? sel;
const activeTrack = points.find((p) => p.id === active);
return (
<div
className={cn(
"relative w-full overflow-hidden rounded-xl bg-card p-2 text-card-foreground shadow-border",
className,
)}
{...props}
>
<style href="paragon-threat-map" precedence="paragon">{`
.pg-threat-ping { transform-box: fill-box; transform-origin: center; animation: pg-threat-ping 1.8s var(--ease-out) infinite; }
@keyframes pg-threat-ping { 0% { transform: scale(1); opacity: 0.5; } 70%, 100% { transform: scale(3.2); opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .pg-threat-ping { animation: none; opacity: 0; } }
`}</style>
<div className="relative">
<WorldMap tooltip={false} className="[&_svg]:opacity-90" />
{/* Track overlay shares the exact world-map viewBox coordinate space */}
<svg
viewBox={WORLD_VIEWBOX}
className="pointer-events-none absolute inset-0 h-full w-full"
>
{points.map((p) => {
const isActive = p.id === active;
const c = AFFIL[p.affiliation].color;
return (
<g key={p.id} className="pointer-events-auto">
{/* heading trail */}
<line
x1={p.tx}
y1={p.ty}
x2={p.x}
y2={p.y}
stroke={c}
strokeWidth={1}
strokeOpacity={0.5}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
{p.affiliation === "hostile" && isActive && !isStatic && !reduced && (
<circle cx={p.x} cy={p.y} r={3} fill={c} className="pg-threat-ping" />
)}
<TrackGlyph
affiliation={p.affiliation}
x={p.x}
y={p.y}
color={c}
active={isActive}
onEnter={() => setHoverId(p.id)}
onLeave={() => setHoverId((h) => (h === p.id ? null : h))}
onSelect={() => select(sel === p.id ? null : p.id)}
label={`${p.id} ${AFFIL[p.affiliation].label}`}
/>
</g>
);
})}
</svg>
{activeTrack && (
<div
className="pointer-events-none absolute z-10 w-40 -translate-x-1/2 -translate-y-full rounded-lg bg-popover p-2 text-popover-foreground shadow-overlay"
style={{
left: `${(activeTrack.x / VB_W) * 100}%`,
top: `${(activeTrack.y / VB_H) * 100}%`,
marginTop: -10,
}}
>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-xs font-semibold">
{activeTrack.id}
</span>
<span
className="rounded px-1 py-0.5 text-[9px] font-semibold tracking-wide"
style={{
color: AFFIL[activeTrack.affiliation].color,
backgroundColor: `color-mix(in oklch, ${AFFIL[activeTrack.affiliation].color} 15%, transparent)`,
}}
>
{AFFIL[activeTrack.affiliation].label}
</span>
</div>
<dl className="mt-1 grid grid-cols-2 gap-x-2 gap-y-0.5 text-[10px]">
<dt className="text-muted-foreground">Type</dt>
<dd className="text-right tabular-nums">{activeTrack.type}</dd>
<dt className="text-muted-foreground">Hdg</dt>
<dd className="text-right tabular-nums">{activeTrack.heading}°</dd>
<dt className="text-muted-foreground">Spd</dt>
<dd className="text-right tabular-nums">{activeTrack.speed} kt</dd>
</dl>
</div>
)}
</div>
{/* legend */}
<div className="mt-1.5 flex items-center gap-3 px-1 pb-0.5 text-[10px]">
{(Object.keys(AFFIL) as TrackAffiliation[]).map((a) => (
<span key={a} className="inline-flex items-center gap-1.5 text-muted-foreground">
<span className="size-2 rounded-[2px]" style={{ backgroundColor: AFFIL[a].color }} />
{AFFIL[a].label}
</span>
))}
</div>
</div>
);
}
function TrackGlyph({
affiliation,
x,
y,
color,
active,
onEnter,
onLeave,
onSelect,
label,
}: {
affiliation: TrackAffiliation;
x: number;
y: number;
color: string;
active: boolean;
onEnter: () => void;
onLeave: () => void;
onSelect: () => void;
label: string;
}) {
const r = active ? 3.4 : 2.8;
// MIL-STD-2525 inspired: hostile = diamond, unknown = quadrant, friendly = circle
let shape: React.ReactNode;
if (affiliation === "hostile") {
shape = (
<path
d={`M ${x} ${y - r} L ${x + r} ${y} L ${x} ${y + r} L ${x - r} ${y} Z`}
fill={active ? color : "none"}
fillOpacity={active ? 0.35 : 0}
stroke={color}
strokeWidth={1.2}
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
);
} else if (affiliation === "unknown") {
shape = (
<rect
x={x - r}
y={y - r}
width={r * 2}
height={r * 2}
rx={0.6}
fill={active ? color : "none"}
fillOpacity={active ? 0.35 : 0}
stroke={color}
strokeWidth={1.2}
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
);
} else {
shape = (
<circle
cx={x}
cy={y}
r={r}
fill={active ? color : "none"}
fillOpacity={active ? 0.35 : 0}
stroke={color}
strokeWidth={1.2}
vectorEffect="non-scaling-stroke"
/>
);
}
return (
<g
role="button"
tabIndex={0}
aria-label={label}
className="cursor-pointer outline-none [&:focus-visible>*]:opacity-100"
style={{ transition: "opacity 150ms var(--ease-out)" }}
onMouseEnter={onEnter}
onMouseLeave={onLeave}
onFocus={onEnter}
onBlur={onLeave}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
>
{/* hit area */}
<circle cx={x} cy={y} r={6} fill="transparent" />
<circle cx={x} cy={y} r={r + 1.4} fill={color} fillOpacity={0.14} />
{shape}
</g>
);
}