Orbit Radar
NewInstallation
npx shadcn@latest add https://uibrik.com/r/orbit-radar.jsonInstall the packages
npm install @number-flow/react @tabler/icons-react motion react-use-measureAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/orbit-radar/orbit-radar.tsx"use client"; import { useEffect, useMemo, useRef, useState, type PointerEvent } from "react"; import { motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; import { Announce, ChartCard, DataTable, Headline, Legend, Tooltip, TooltipRow, TooltipTitle, useChartCursor, useWidth, } from "../_charts/parts"; import { clamp, type Point } from "../_charts/scale"; import { accentVars, DOT, GRID, SETTLE, seriesColor } from "../_charts/theme"; import { formatValue, type ValueFormat } from "../_charts/format"; export type RadarAxis = string; /** One shape on the radar: a name and a value per axis, in axis order. */ export type RadarSet = { key: string; label: string; values: number[]; color?: string }; const SAMPLE_AXES: RadarAxis[] = ["Speed", "Reliability", "Security", "Support", "Value", "Scale"]; const SAMPLE_SETS: RadarSet[] = [ { key: "us", label: "Ours", values: [88, 92, 84, 76, 70, 90] }, { key: "them", label: "Competitor", values: [64, 78, 90, 58, 82, 60] }, { key: "avg", label: "Industry", values: [60, 65, 62, 55, 58, 60] }, ]; // Clear space around the rings for the axis labels. const MARGIN = 56; const RINGS = 4; const f = (v: number) => Math.round(v * 100) / 100; /** A smooth closed curve through every point (Catmull-Rom as cubic segments). */ function closedPath(pts: readonly Point[]) { const n = pts.length; if (n < 3) return ""; let d = `M${f(pts[0][0])},${f(pts[0][1])}`; for (let i = 0; i < n; i++) { const p0 = pts[(i - 1 + n) % n]; const p1 = pts[i]; const p2 = pts[(i + 1) % n]; const p3 = pts[(i + 2) % n]; d += `C${f(p1[0] + (p2[0] - p0[0]) / 6)},${f(p1[1] + (p2[1] - p0[1]) / 6)},${f(p2[0] - (p3[0] - p1[0]) / 6)},${f(p2[1] - (p3[1] - p1[1]) / 6)},${f(p2[0])},${f(p2[1])}`; } return `${d}Z`; } export type OrbitRadarProps = { /** Names what is plotted. */ title?: string; /** One name per spoke, 3 to 8. They run clockwise from the top. */ axes?: RadarAxis[]; /** The shapes to draw, 1 to 4, each with a value per axis. */ sets?: RadarSet[]; /** Value at the outer ring. */ max?: number; /** Intl.NumberFormat options for every number. */ format?: ValueFormat; /** First shape colour. Empty uses the chart palette. */ accent?: string; /** Called with the axis under the cursor, or null when it leaves. */ onActiveChange?: (index: number | null) => void; className?: string; }; export function OrbitRadar({ title = "Product scorecard", axes = SAMPLE_AXES, sets = SAMPLE_SETS, max = 100, format = {}, accent = "", onActiveChange, className, }: OrbitRadarProps) { const reduce = useReducedMotion(); const [ref, width] = useWidth(); const n = axes.length; const cursor = useChartCursor(n); const { active } = cursor; const [hidden, setHidden] = useState<string[]>([]); const [focus, setFocus] = useState<string | null>(null); const all = useMemo(() => sets.map((s, i) => ({ ...s, color: s.color ?? seriesColor(i) })), [sets]); const visible = all.filter((s) => !hidden.includes(s.key)); const lit = focus !== null && !hidden.includes(focus) ? focus : null; const fmt = (v: number) => formatValue(v, format); const side = clamp(width, 0, 400); const c = side / 2; const R = Math.max(0, c - MARGIN); const angle = (i: number) => ((-90 + (360 * i) / n) * Math.PI) / 180; const at = (i: number, radius: number): Point => [c + radius * Math.cos(angle(i)), c + radius * Math.sin(angle(i))]; const shape = (values: readonly number[]): Point[] => axes.map((_, i) => at(i, R * clamp((values[i] ?? 0) / max, 0, 1))); const collapsed = closedPath(axes.map(() => [c, c] as Point)); const mean = (s?: (typeof all)[number]) => (s && n ? s.values.slice(0, n).reduce((a, b) => a + b, 0) / n : 0); const head = visible[0] ?? all[0]; const readout = head ? (active !== null ? (head.values[active] ?? 0) : mean(head)) : 0; const onPointerMove = (e: PointerEvent<HTMLDivElement>) => { if (!n) return; const rect = e.currentTarget.getBoundingClientRect(); const dx = e.clientX - rect.left - c; const dy = e.clientY - rect.top - c; const deg = (((Math.atan2(dy, dx) * 180) / Math.PI + 90) % 360 + 360) % 360; cursor.point(Math.round(deg / (360 / n)) % n); }; const toggle = (key: string) => setHidden((h) => (h.includes(key) ? h.filter((k) => k !== key) : visible.length > 1 ? [...h, key] : h)); const callback = useRef(onActiveChange); useEffect(() => { callback.current = onActiveChange; }); useEffect(() => callback.current?.(active), [active]); const tip: Point | null = active !== null && visible.length ? [ visible.reduce((sum, s) => sum + shape(s.values)[active][0], 0) / visible.length, Math.min(...visible.map((s) => shape(s.values)[active][1])), ] : null; return ( <ChartCard title={title} value={<Headline value={readout} format={format} />} meta={ <span className="text-xs text-muted-foreground"> {head ? `${head.label} · ${active !== null ? axes[active] : "average"}` : ""} </span> } footer={<Legend items={all} mark="dot" hidden={hidden} onToggle={toggle} active={lit} onActiveChange={setFocus} />} className={cn("max-w-120", className)} style={accentVars(accent)} > <div ref={ref} className="flex justify-center"> <div role="group" aria-label={`${title}: ${n} axes. Arrow keys move between them.`} className="relative cursor-crosshair touch-none rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-foreground/20" style={{ width: side, height: side }} onPointerMove={onPointerMove} onPointerDown={onPointerMove} {...cursor.bind} > {side > 0 && n >= 3 && ( <svg width={side} height={side} className="block overflow-visible" aria-hidden> {/* Rings, drawn as the same straight-sided shape the data lives on. */} {Array.from({ length: RINGS }, (_, r) => { const ring = Array.from({ length: n }, (_, i) => at(i, (R * (r + 1)) / RINGS)); return ( <polygon key={r} points={ring.map((p) => p.map(f).join(",")).join(" ")} fill="none" stroke={GRID} strokeWidth={1} strokeLinejoin="round" /> ); })} {axes.map((label, i) => { const [x1, y1] = at(i, R); const on = active === i; const [lx, ly] = at(i, R + 16); const cos = Math.cos(angle(i)); const sin = Math.sin(angle(i)); return ( <g key={label}> <line x1={c} y1={c} x2={x1} y2={y1} stroke={GRID} strokeWidth={1} className={cn("transition-[stroke] duration-150", on && "stroke-foreground/25")} /> <text x={lx} y={ly + (Math.abs(sin) > 0.3 ? sin * 4 : 0)} dy="0.32em" textAnchor={cos > 0.3 ? "start" : cos < -0.3 ? "end" : "middle"} className={cn( "text-[11px] font-medium transition-[fill] duration-150", on ? "fill-foreground" : "fill-muted-foreground", )} > {label} </text> </g> ); })} {all.map((s) => { const off = hidden.includes(s.key); const dim = lit !== null && lit !== s.key; const pts = shape(s.values); const d = closedPath(pts); return ( <motion.g key={s.key} initial={false} animate={{ opacity: off ? 0 : dim ? 0.3 : 1 }} transition={{ duration: reduce ? 0 : 0.2 }} style={{ pointerEvents: "none" }} > <motion.path fill={s.color} fillOpacity={0.12} stroke={s.color} strokeWidth={2} strokeLinejoin="round" initial={{ d: collapsed }} animate={{ d }} transition={reduce ? { duration: 0 } : SETTLE} /> {pts.map((p, i) => ( <motion.circle key={axes[i]} r={active === i ? DOT + 1 : DOT - 1} fill={s.color} className="stroke-background" strokeWidth={2} initial={{ cx: c, cy: c }} animate={{ cx: p[0], cy: p[1] }} transition={reduce ? { duration: 0 } : SETTLE} /> ))} </motion.g> ); })} </svg> )} <Tooltip x={tip?.[0] ?? 0} y={(tip?.[1] ?? 0) - 6} bounds={side} visible={active !== null && tip !== null}> {active !== null && ( <> <TooltipTitle>{axes[active]}</TooltipTitle> {visible.map((s) => ( <TooltipRow key={s.key} color={s.color} value={fmt(s.values[active] ?? 0)} label={s.label} /> ))} </> )} </Tooltip> </div> </div> <Announce> {active !== null ? `${axes[active]}: ${visible.map((s) => `${s.label} ${fmt(s.values[active] ?? 0)}`).join(", ")}` : ""} </Announce> <DataTable caption={title} columns={["Axis", ...all.map((s) => s.label)]} rows={axes.map((a, i) => [a, ...all.map((s) => fmt(s.values[i] ?? 0))])} /> </ChartCard> ); }
Adds the files to
components/uibrik/orbit-radar and installs @number-flow/react, @tabler/icons-react, motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/orbit-radar.Props
OrbitRadar- title
- Type string
- "Product scorecard"
- Names what is plotted.
- max
- Type number
- 100
- Value at the outer ring.
- accent
- Type string
- ""
- First shape colour. Empty uses the chart palette.
- axes
- Type string[]
- –
- One name per spoke, 3 to 8, clockwise from the top.
- sets
- Type { key: string; label: string; values: number[]; color?: string }[]
- –
- The shapes to draw, each with a value per axis.
- format
- Type Intl.NumberFormatOptions
- {}
- Formats every number.
- onActiveChange
- Type (index: number | null) => void
- –
- Called with the axis under the cursor.
- className
- Type string
- –
- Classes for the outer shell.