Security

Risk Radar

A security risk radar chart plotting risk across dimensions like identity, endpoint, and cloud, with a composite score and an on-view draw-in.

Install

npx shadcn@latest add @paragon/risk-radar

risk-radar.tsx

"use client";

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

export interface RiskDimension {
  label: string;
  /** Primary series score, 0–1 (0 = healthy, 1 = severe). */
  value: number;
  /** Optional second series (e.g. target / last quarter), 0–1. */
  target?: number;
}

export interface RiskRadarProps extends React.ComponentProps<"div"> {
  dimensions?: RiskDimension[];
  size?: number;
  /** Number of concentric grid rings. */
  rings?: number;
  /** Accent hue for the primary risk fill. */
  color?: string;
  /** Hue for the comparison series (target). */
  compareColor?: string;
  /** Label for the primary series (legend + tooltip). */
  seriesLabel?: string;
  /** Label for the comparison series. */
  compareLabel?: string;
  /** Show the comparison polygon when `target` is present on dimensions. */
  showCompare?: boolean;
  /** Upper bound of the scale (values are divided by this). */
  maxScale?: number;
  /** Accessible description override. */
  label?: string;
  /** Renders the polygon at full scale immediately, no draw. */
  static?: boolean;
}

const DEFAULT_DIMENSIONS: RiskDimension[] = [
  { label: "Identity", value: 0.35, target: 0.2 },
  { label: "Endpoint", value: 0.62, target: 0.4 },
  { label: "Network", value: 0.28, target: 0.25 },
  { label: "Data", value: 0.5, target: 0.3 },
  { label: "App Sec", value: 0.72, target: 0.45 },
  { label: "Cloud", value: 0.44, target: 0.35 },
];

const clamp01 = (n: number) => Math.min(Math.max(n, 0), 1);

/**
 * A security risk radar across dimensions with an optional comparison series
 * (e.g. current vs. target). Every vertex is computed via polar→cartesian trig;
 * grid rings, spokes, and both polygons share one coordinate space. Hover a
 * vertex for a category tooltip; hover a legend item to emphasize its series.
 * The polygons scale up from center once on enter. Reduced motion renders the
 * final geometry with no draw.
 */
export function RiskRadar({
  dimensions = DEFAULT_DIMENSIONS,
  size = 300,
  rings = 4,
  color = "#f43f5e",
  compareColor = "#3b82f6",
  seriesLabel = "Current",
  compareLabel = "Target",
  showCompare = true,
  maxScale = 1,
  label,
  static: isStatic = false,
  className,
  ...props
}: RiskRadarProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();
  const inView = useInView(ref, { once: true, margin: "0px 0px -32px 0px" });
  const animate = !isStatic && !reduced;
  const drawn = !animate || inView;

  const [active, setActive] = React.useState<number | null>(null);
  const [hoverSeries, setHoverSeries] = React.useState<
    "current" | "target" | null
  >(null);

  const cx = size / 2;
  const cy = size / 2;
  const radius = size / 2 - 46;
  const count = dimensions.length;

  const hasCompare =
    showCompare && dimensions.some((d) => typeof d.target === "number");

  const angleOf = (i: number) => -Math.PI / 2 + (i / count) * Math.PI * 2;

  const vertex = React.useCallback(
    (i: number, t: number) => {
      const angle = angleOf(i);
      return [
        cx + radius * t * Math.cos(angle),
        cy + radius * t * Math.sin(angle),
      ] as const;
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [cx, cy, radius, count],
  );

  const ringPolygon = (t: number) =>
    dimensions
      .map((_, i) => {
        const [x, y] = vertex(i, t);
        return `${x.toFixed(2)},${y.toFixed(2)}`;
      })
      .join(" ");

  const seriesPolygon = (key: "value" | "target") =>
    dimensions
      .map((d, i) => {
        const raw = key === "value" ? d.value : (d.target ?? 0);
        const [x, y] = vertex(i, clamp01(raw / maxScale));
        return `${x.toFixed(2)},${y.toFixed(2)}`;
      })
      .join(" ");

  const avg = Math.round(
    (dimensions.reduce((s, d) => s + clamp01(d.value / maxScale), 0) /
      (count || 1)) *
      100,
  );

  // Tooltip position tracks the active vertex.
  const tip =
    active != null
      ? (() => {
          const d = dimensions[active];
          const [x, y] = vertex(active, clamp01(d.value / maxScale));
          return { d, x, y };
        })()
      : null;

  const dimCurrent = hoverSeries === "target";
  const dimTarget = hoverSeries === "current";

  return (
    <div
      ref={ref}
      data-slot="risk-radar"
      className={cn("inline-flex flex-col items-center", className)}
      {...props}
    >
      <div className="relative" style={{ width: size, height: size }}>
        <svg
          role="img"
          aria-label={
            label ??
            `Risk radar, ${avg}% average risk: ${dimensions
              .map((d) => `${d.label} ${Math.round(clamp01(d.value / maxScale) * 100)}%`)
              .join(", ")}`
          }
          width={size}
          height={size}
          viewBox={`0 0 ${size} ${size}`}
          className="block overflow-visible"
          onPointerLeave={() => setActive(null)}
        >
          {/* Grid rings */}
          {Array.from({ length: rings }, (_, r) => {
            const t = (r + 1) / rings;
            return (
              <polygon
                key={r}
                points={ringPolygon(t)}
                fill="none"
                stroke="var(--color-border)"
                strokeWidth={1}
                strokeLinejoin="round"
                vectorEffect="non-scaling-stroke"
              />
            );
          })}

          {/* Spokes */}
          {dimensions.map((_, i) => {
            const [x, y] = vertex(i, 1);
            return (
              <line
                key={i}
                x1={cx}
                y1={cy}
                x2={x}
                y2={y}
                stroke="var(--color-border)"
                strokeWidth={1}
                vectorEffect="non-scaling-stroke"
              />
            );
          })}

          {/* Comparison (target) polygon — drawn under current */}
          {hasCompare && (
            <polygon
              points={seriesPolygon("target")}
              fill={compareColor}
              fillOpacity={dimTarget ? 0.06 : 0.14}
              stroke={compareColor}
              strokeWidth={1.5}
              strokeDasharray="4 3"
              strokeLinejoin="round"
              vectorEffect="non-scaling-stroke"
              style={{
                transformBox: "fill-box",
                transformOrigin: "center",
                transform: `scale(${drawn ? 1 : 0.4})`,
                opacity: drawn ? (dimTarget ? 0.5 : 1) : 0,
                transition: animate
                  ? "transform 600ms var(--ease-out) 80ms, opacity 300ms var(--ease-out) 80ms, fill-opacity 150ms var(--ease-out)"
                  : "opacity 150ms var(--ease-out), fill-opacity 150ms var(--ease-out)",
              }}
            />
          )}

          {/* Primary (current) polygon */}
          <polygon
            points={seriesPolygon("value")}
            fill={color}
            fillOpacity={dimCurrent ? 0.08 : 0.2}
            stroke={color}
            strokeWidth={2}
            strokeLinejoin="round"
            vectorEffect="non-scaling-stroke"
            style={{
              transformBox: "fill-box",
              transformOrigin: "center",
              transform: `scale(${drawn ? 1 : 0.4})`,
              opacity: drawn ? (dimCurrent ? 0.5 : 1) : 0,
              transition: animate
                ? "transform 600ms var(--ease-out), opacity 300ms var(--ease-out), fill-opacity 150ms var(--ease-out)"
                : "opacity 150ms var(--ease-out), fill-opacity 150ms var(--ease-out)",
            }}
          />

          {/* Interactive vertices (current series) */}
          {dimensions.map((d, i) => {
            const [x, y] = vertex(i, clamp01(d.value / maxScale));
            const isActive = active === i;
            return (
              <g key={i}>
                {/* Enlarged invisible hit target */}
                <circle
                  cx={x}
                  cy={y}
                  r={12}
                  fill="transparent"
                  className="cursor-pointer outline-none"
                  tabIndex={0}
                  role="button"
                  aria-label={`${d.label}: ${seriesLabel} ${Math.round(clamp01(d.value / maxScale) * 100)}%${
                    typeof d.target === "number"
                      ? `, ${compareLabel} ${Math.round(clamp01(d.target / maxScale) * 100)}%`
                      : ""
                  }`}
                  onPointerEnter={() => setActive(i)}
                  onFocus={() => setActive(i)}
                  onBlur={() => setActive(null)}
                />
                <circle
                  cx={x}
                  cy={y}
                  r={isActive ? 5 : 3.2}
                  fill={color}
                  stroke="var(--color-background)"
                  strokeWidth={1.5}
                  className="pointer-events-none transition-[r] duration-150 ease-out"
                  style={{
                    opacity: drawn ? 1 : 0,
                    transition: animate
                      ? "opacity 300ms var(--ease-out) 300ms, r 150ms var(--ease-out)"
                      : "r 150ms var(--ease-out)",
                  }}
                />
              </g>
            );
          })}

          {/* Axis labels at ring edge, anchored by quadrant */}
          {dimensions.map((d, i) => {
            const [lx, ly] = vertex(i, 1.18);
            const dx = lx - cx;
            const anchor =
              Math.abs(dx) < 6 ? "middle" : dx > 0 ? "start" : "end";
            const isActive = active === i;
            return (
              <text
                key={i}
                x={lx}
                y={ly}
                textAnchor={anchor}
                dominantBaseline="middle"
                className={cn(
                  "text-[10px] font-medium transition-[fill] duration-150 ease-out",
                  isActive ? "fill-foreground" : "fill-muted-foreground",
                )}
              >
                {d.label}
              </text>
            );
          })}
        </svg>

        {/* Hover tooltip */}
        {tip && (
          <div
            className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full rounded-md bg-popover px-2.5 py-1.5 text-xs whitespace-nowrap text-popover-foreground shadow-overlay"
            style={{ left: tip.x, top: tip.y - 10 }}
          >
            <div className="font-medium">{tip.d.label}</div>
            <div className="mt-0.5 flex items-center gap-1.5">
              <span
                className="size-1.5 rounded-full"
                style={{ backgroundColor: color }}
              />
              <span className="text-muted-foreground">{seriesLabel}</span>
              <span className="ml-auto pl-2 font-medium tabular-nums">
                {Math.round(clamp01(tip.d.value / maxScale) * 100)}%
              </span>
            </div>
            {typeof tip.d.target === "number" && (
              <div className="mt-0.5 flex items-center gap-1.5">
                <span
                  className="size-1.5 rounded-full"
                  style={{ backgroundColor: compareColor }}
                />
                <span className="text-muted-foreground">{compareLabel}</span>
                <span className="ml-auto pl-2 font-medium tabular-nums">
                  {Math.round(clamp01(tip.d.target / maxScale) * 100)}%
                </span>
              </div>
            )}
          </div>
        )}
      </div>

      {/* Legend + composite */}
      <div className="mt-2 flex items-center gap-4">
        <LegendItem
          color={color}
          label={seriesLabel}
          onEnter={() => setHoverSeries("current")}
          onLeave={() => setHoverSeries(null)}
        />
        {hasCompare && (
          <LegendItem
            color={compareColor}
            label={compareLabel}
            dashed
            onEnter={() => setHoverSeries("target")}
            onLeave={() => setHoverSeries(null)}
          />
        )}
        <span className="text-xs text-muted-foreground">
          Composite{" "}
          <span className="font-semibold text-foreground tabular-nums">
            {avg}%
          </span>
        </span>
      </div>
    </div>
  );
}

function LegendItem({
  color,
  label,
  dashed,
  onEnter,
  onLeave,
}: {
  color: string;
  label: string;
  dashed?: boolean;
  onEnter: () => void;
  onLeave: () => void;
}) {
  return (
    <button
      type="button"
      onPointerEnter={onEnter}
      onPointerLeave={onLeave}
      onFocus={onEnter}
      onBlur={onLeave}
      className="group flex items-center gap-1.5 rounded-md text-xs text-muted-foreground outline-none transition-[color] duration-(--duration-fast) hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
    >
      <span
        aria-hidden
        className={cn(
          "h-0 w-3.5 shrink-0 rounded-full border-t-2",
          dashed && "border-dashed",
        )}
        style={{ borderColor: color }}
      />
      {label}
    </button>
  );
}