Defense & Aerospace

Threat Map

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.

Install

npx shadcn@latest add @paragon/threat-map

Also installs: world-map

threat-map.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";

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>
  );
}