Defense & Aerospace

Radar Scope

A plan-position-indicator radar with a trig-correct rotating sweep, computed range rings and bearing ticks, and classification-toned contact blips that fade with the afterglow.

Install

npx shadcn@latest add @paragon/radar-scope

radar-scope.tsx

"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface RadarBlip {
  /** Bearing in degrees, 0 = north (up), clockwise. */
  bearing: number;
  /** Range as a fraction of scope radius, 0–1. */
  range: number;
  /** Track classification tone. */
  tone?: "hostile" | "unknown" | "friendly";
  label?: string;
}

export interface RadarScopeProps extends React.ComponentProps<"div"> {
  /** Detected contacts. */
  blips?: RadarBlip[];
  /** Seconds per full sweep revolution. */
  sweepSeconds?: number;
  /** Number of range rings. */
  rings?: number;
  /** Max range label, e.g. "80 NM". */
  rangeLabel?: string;
  /** Diameter in px. */
  size?: number;
  /** Freeze the sweep. */
  static?: boolean;
}

const TONE: Record<NonNullable<RadarBlip["tone"]>, string> = {
  hostile: "var(--color-destructive)",
  unknown: "var(--color-warning)",
  friendly: "var(--color-success)",
};

const DEFAULT_BLIPS: RadarBlip[] = [
  { bearing: 42, range: 0.62, tone: "hostile", label: "TK-1174" },
  { bearing: 118, range: 0.44, tone: "unknown", label: "TK-2251" },
  { bearing: 205, range: 0.78, tone: "friendly", label: "BF-09" },
  { bearing: 291, range: 0.35, tone: "unknown", label: "TK-2309" },
  { bearing: 338, range: 0.7, tone: "hostile", label: "TK-1180" },
];

/**
 * A plan-position-indicator (PPI) radar scope. The sweep is a CSS rotation
 * of a conic-gradient afterglow wedge over a static grid of computed range
 * rings and bearing ticks; all blip coordinates are derived by trig from
 * (bearing, range) so nothing is eyeballed. Reduced motion parks the sweep
 * at 12 o'clock and shows every blip at full brightness.
 */
export function RadarScope({
  blips = DEFAULT_BLIPS,
  sweepSeconds = 4,
  rings = 4,
  rangeLabel = "80 NM",
  size = 320,
  static: isStatic = false,
  className,
  style,
  ...props
}: RadarScopeProps) {
  const reduced = useReducedMotion();
  const frozen = isStatic || reduced;
  const C = 100; // center in a 200x200 viewBox
  const R = 92; // scope radius

  // Bearing ticks every 30°, longer at cardinals — computed, not placed.
  const ticks = React.useMemo(
    () =>
      Array.from({ length: 12 }, (_, i) => {
        const deg = i * 30;
        const rad = ((deg - 90) * Math.PI) / 180;
        const cardinal = deg % 90 === 0;
        const inner = R - (cardinal ? 10 : 5);
        return {
          deg,
          x1: C + inner * Math.cos(rad),
          y1: C + inner * Math.sin(rad),
          x2: C + R * Math.cos(rad),
          y2: C + R * Math.sin(rad),
        };
      }),
    [],
  );

  const cardinals = React.useMemo(() => {
    const marks = [
      { deg: 0, t: "N" },
      { deg: 90, t: "E" },
      { deg: 180, t: "S" },
      { deg: 270, t: "W" },
    ];
    return marks.map((m) => {
      const rad = ((m.deg - 90) * Math.PI) / 180;
      const r = R - 18;
      return { ...m, x: C + r * Math.cos(rad), y: C + r * Math.sin(rad) };
    });
  }, []);

  const points = React.useMemo(
    () =>
      blips.map((b, i) => {
        const rad = ((b.bearing - 90) * Math.PI) / 180;
        const rr = Math.max(0, Math.min(1, b.range)) * R;
        return {
          ...b,
          key: i,
          x: C + rr * Math.cos(rad),
          y: C + rr * Math.sin(rad),
          color: TONE[b.tone ?? "unknown"],
        };
      }),
    [blips],
  );

  return (
    <div
      className={cn(
        "relative select-none rounded-xl bg-[#04140f] p-3 shadow-border dark:bg-[#04140f]",
        className,
      )}
      style={{ width: size, ...style }}
      {...props}
    >
      <style href="paragon-radar-scope" precedence="paragon">{`
        @property --pg-radar-angle { syntax: "<angle>"; initial-value: 0deg; inherits: false; }
        @keyframes pg-radar-sweep { to { --pg-radar-angle: 360deg; } }
        .pg-radar-wedge {
          background: conic-gradient(from var(--pg-radar-angle),
            color-mix(in oklch, #37f5a0 55%, transparent) 0deg,
            color-mix(in oklch, #37f5a0 10%, transparent) 26deg,
            transparent 42deg, transparent 360deg);
          -webkit-mask: radial-gradient(circle at center, #000 0 96%, transparent 96%);
          mask: radial-gradient(circle at center, #000 0 96%, transparent 96%);
          animation: pg-radar-sweep var(--pg-radar-dur, 4s) linear infinite;
        }
        .pg-radar-line {
          transform-origin: center;
          animation: pg-radar-spin var(--pg-radar-dur, 4s) linear infinite;
        }
        @keyframes pg-radar-spin { to { transform: rotate(360deg); } }
        .pg-radar-blip { animation: pg-radar-fade var(--pg-radar-dur, 4s) linear infinite; }
        @keyframes pg-radar-fade {
          0%, 4% { opacity: 1; }
          80% { opacity: 0.18; }
          100% { opacity: 0.18; }
        }
        @media (prefers-reduced-motion: reduce) {
          .pg-radar-wedge, .pg-radar-line, .pg-radar-blip { animation: none !important; }
        }
      `}</style>

      <div
        className="relative aspect-square w-full"
        style={
          { "--pg-radar-dur": `${sweepSeconds}s` } as React.CSSProperties
        }
      >
        <svg viewBox="0 0 200 200" className="absolute inset-0 h-full w-full">
          <defs>
            <radialGradient id="pg-radar-bg" cx="50%" cy="50%" r="50%">
              <stop offset="0%" stopColor="#0a2b1f" />
              <stop offset="100%" stopColor="#04140f" />
            </radialGradient>
          </defs>
          <circle cx={C} cy={C} r={R} fill="url(#pg-radar-bg)" />
          {/* range rings — radii computed as even fractions of R */}
          {Array.from({ length: rings }, (_, i) => {
            const rr = (R * (i + 1)) / rings;
            return (
              <circle
                key={i}
                cx={C}
                cy={C}
                r={rr}
                fill="none"
                stroke="#1c7a56"
                strokeOpacity={0.5}
                strokeWidth={0.6}
                vectorEffect="non-scaling-stroke"
              />
            );
          })}
          {/* cross-hair spokes */}
          <line x1={C - R} y1={C} x2={C + R} y2={C} stroke="#1c7a56" strokeOpacity={0.35} strokeWidth={0.6} vectorEffect="non-scaling-stroke" />
          <line x1={C} y1={C - R} x2={C} y2={C + R} stroke="#1c7a56" strokeOpacity={0.35} strokeWidth={0.6} vectorEffect="non-scaling-stroke" />
          {/* bearing ticks */}
          {ticks.map((t) => (
            <line
              key={t.deg}
              x1={t.x1}
              y1={t.y1}
              x2={t.x2}
              y2={t.y2}
              stroke="#2ea87a"
              strokeWidth={0.8}
              strokeLinecap="round"
              vectorEffect="non-scaling-stroke"
            />
          ))}
          {cardinals.map((m) => (
            <text
              key={m.t}
              x={m.x}
              y={m.y}
              textAnchor="middle"
              dominantBaseline="central"
              fontSize={7}
              fill="#4fd6a0"
              className="font-mono"
            >
              {m.t}
            </text>
          ))}

          {/* rotating sweep line (frozen = points to 12 o'clock) */}
          <line
            x1={C}
            y1={C}
            x2={C}
            y2={C - R}
            stroke="#37f5a0"
            strokeWidth={1}
            strokeLinecap="round"
            vectorEffect="non-scaling-stroke"
            className={frozen ? undefined : "pg-radar-line"}
          />

          {/* blips */}
          {points.map((p) => (
            <g key={p.key} className={frozen ? undefined : "pg-radar-blip"}>
              <circle cx={p.x} cy={p.y} r={3.2} fill={p.color} fillOpacity={0.2} />
              <circle
                cx={p.x}
                cy={p.y}
                r={1.6}
                fill={p.color}
                stroke="#04140f"
                strokeWidth={0.5}
              />
            </g>
          ))}
        </svg>

        {/* afterglow wedge (HTML layer, conic-gradient rotation) */}
        {!frozen && (
          <div
            aria-hidden
            className="pg-radar-wedge pointer-events-none absolute inset-0 rounded-full"
          />
        )}
      </div>

      <div className="mt-2 flex items-center justify-between font-mono text-[10px] tracking-wide text-[#4fd6a0]">
        <span>PPI · AIR SEARCH</span>
        <span className="tabular-nums">RNG {rangeLabel}</span>
      </div>
    </div>
  );
}