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.
npx shadcn@latest add @paragon/radar-scope"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>
);
}