Defense & Aerospace

Asset Track Table

An ISR track table with classification tone, bearing, speed, and confidence bars, wired to a mini world-map so hovering a row pings the track's geo-projected location.

Install

npx shadcn@latest add @paragon/asset-track-table

Also installs: world-map

asset-track-table.tsx

"use client";

import * as React from "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 type TrackClass = "hostile" | "unknown" | "friendly" | "neutral";

export interface AssetTrack {
  id: string;
  classification: TrackClass;
  type: string;
  lng: number;
  lat: number;
  /** Bearing in degrees. */
  bearing: number;
  /** Speed in knots. */
  speed: number;
  /** Track confidence, 0–1. */
  confidence: number;
}

export interface AssetTrackTableProps extends React.ComponentProps<"div"> {
  tracks?: AssetTrack[];
}

const CLASS_TONE: Record<TrackClass, string> = {
  hostile: "var(--color-destructive)",
  unknown: "var(--color-warning)",
  friendly: "var(--color-success)",
  neutral: "var(--color-muted-foreground)",
};

const DEFAULT_TRACKS: AssetTrack[] = [
  { id: "TK-1174", classification: "hostile", type: "Fast air", lng: 51, lat: 26, bearing: 210, speed: 420, confidence: 0.94 },
  { id: "TK-2251", classification: "unknown", type: "Surface", lng: 44, lat: 15, bearing: 300, speed: 18, confidence: 0.61 },
  { id: "BF-09", classification: "friendly", type: "CAP", lng: 55, lat: 24, bearing: 45, speed: 480, confidence: 0.99 },
  { id: "TK-2309", classification: "unknown", type: "Slow mover", lng: 60, lat: 20, bearing: 275, speed: 90, confidence: 0.44 },
  { id: "CV-77", classification: "neutral", type: "Merchant", lng: 58, lat: 18, bearing: 95, speed: 14, confidence: 0.72 },
];

/**
 * An ISR track table wired to a mini common-operating-picture. Hovering or
 * focusing a row geo-projects the track onto the accurate `world-map` and
 * fires the shared map ping — table and map share one coordinate space. A
 * confidence bar reads as a proportion; classification is color-toned.
 * Fully keyboard-navigable rows.
 */
export function AssetTrackTable({
  tracks = DEFAULT_TRACKS,
  className,
  ...props
}: AssetTrackTableProps) {
  const [active, setActive] = React.useState<string | null>(tracks[0]?.id ?? null);
  const point = React.useMemo(() => {
    const t = tracks.find((x) => x.id === active);
    return t ? { ...project(t.lng, t.lat), tone: CLASS_TONE[t.classification] } : null;
  }, [active, tracks]);

  return (
    <div
      className={cn(
        "grid w-full max-w-3xl gap-2 rounded-xl bg-card p-2 text-card-foreground shadow-border sm:grid-cols-[1fr_200px]",
        className,
      )}
      {...props}
    >
      <div className="overflow-hidden rounded-lg border border-border">
        <table className="w-full border-collapse text-xs">
          <thead>
            <tr className="border-b border-border bg-secondary/50 text-[10px] uppercase tracking-wider text-muted-foreground">
              <th className="px-2 py-1.5 text-left font-medium">Track</th>
              <th className="px-2 py-1.5 text-left font-medium">Type</th>
              <th className="px-2 py-1.5 text-right font-medium">Brg</th>
              <th className="px-2 py-1.5 text-right font-medium">Spd</th>
              <th className="px-2 py-1.5 text-left font-medium">Conf</th>
            </tr>
          </thead>
          <tbody>
            {tracks.map((t) => {
              const on = t.id === active;
              return (
                <tr
                  key={t.id}
                  tabIndex={0}
                  onMouseEnter={() => setActive(t.id)}
                  onFocus={() => setActive(t.id)}
                  className={cn(
                    "cursor-default border-b border-border/60 outline-none transition-colors duration-150 last:border-0",
                    on ? "bg-secondary" : "hover:bg-secondary/50 focus-visible:bg-secondary/50",
                  )}
                >
                  <td className="px-2 py-1.5">
                    <span className="inline-flex items-center gap-1.5 font-mono font-medium">
                      <span
                        className="size-1.5 shrink-0 rounded-full"
                        style={{ backgroundColor: CLASS_TONE[t.classification] }}
                      />
                      {t.id}
                    </span>
                  </td>
                  <td className="px-2 py-1.5 text-muted-foreground">{t.type}</td>
                  <td className="px-2 py-1.5 text-right tabular-nums">{t.bearing}°</td>
                  <td className="px-2 py-1.5 text-right tabular-nums">{t.speed}</td>
                  <td className="px-2 py-1.5">
                    <span className="flex items-center gap-1.5">
                      <span className="h-1 w-10 overflow-hidden rounded-full bg-secondary">
                        <span
                          className="block h-full rounded-full transition-[width] duration-(--duration-base) ease-(--ease-out)"
                          style={{
                            width: `${Math.round(t.confidence * 100)}%`,
                            backgroundColor: CLASS_TONE[t.classification],
                          }}
                        />
                      </span>
                      <span className="w-7 shrink-0 text-right tabular-nums text-[10px] text-muted-foreground">
                        {Math.round(t.confidence * 100)}%
                      </span>
                    </span>
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>

      <div className="relative overflow-hidden rounded-lg border border-border">
        <WorldMap tooltip={false} className="[&_svg]:opacity-90" />
        {point && (
          <svg
            viewBox={WORLD_VIEWBOX}
            className="pointer-events-none absolute inset-0 h-full w-full"
          >
            <circle cx={point.x} cy={point.y} r={3} fill={point.tone} className="pg-map-ping" />
            <circle
              cx={point.x}
              cy={point.y}
              r={2.4}
              fill={point.tone}
              stroke="var(--color-card)"
              strokeWidth={0.8}
            />
          </svg>
        )}
      </div>
    </div>
  );
}