Skip to main content

ns-ui

Tacho Disc

Today's security activity as one rotating polar disc under a fixed stylus: sign-ins, permission grants and revocations scribed at their true time-of-day angle, radius by category, older days stacked behind as dimmer discs. Dragging or arrowing it scrubs a real, bound event list.

Usage

registry/core/tacho-disc/demo.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { TachoDisc, type SecurityEvent } from "./component";

const ACTORS = ["j.chen", "mrivera", "svc-deploy", "a.novak", "t.singh"];

function startOfToday(): number {
  const d = new Date();
  d.setHours(0, 0, 0, 0);
  return d.getTime();
}

function at(dayStart: number, hh: number, mm: number, ss = 0): number {
  return dayStart + hh * 3_600_000 + mm * 60_000 + ss * 1_000;
}

function baseline(dayStart: number, offset: number, dense: boolean): SecurityEvent[] {
  const out: SecurityEvent[] = [];
  const p = (i: number) => ACTORS[i % ACTORS.length]!;
  let n = 0;
  const push = (e: Omit<SecurityEvent, "id">) => out.push({ ...e, id: `d${offset}-${n++}` });

  push({ time: at(dayStart, 8, 12), category: "sign-in", actor: p(0), action: "Sign-in (SSO)", result: "success" });
  push({ time: at(dayStart, 9, 4), category: "sign-in", actor: p(1), action: "Sign-in (SSO)", result: "success" });
  push({ time: at(dayStart, 10, 41), category: "grant", actor: p(2), action: "Added deploy scope", result: "success" });
  push({ time: at(dayStart, 11, 55), category: "sign-in", actor: p(3), action: "Sign-in (password)", result: "success" });
  push({ time: at(dayStart, 13, 20), category: "sign-in", actor: p(0), action: "Sign-in (SSO)", result: "success" });
  push({ time: at(dayStart, 14, 33), category: "grant", actor: p(4), action: "Elevated to admin", result: "success" });
  push({ time: at(dayStart, 16, 8), category: "sign-in", actor: p(2), action: "Sign-in (SSO)", result: "success" });
  push({ time: at(dayStart, 17, 47), category: "sign-in", actor: p(1), action: "Sign-in (password)", result: "success" });

  if (dense) {
    // credential-stuffing run: many failed attempts seconds apart, tightly
    // clustered — this is the shape the whole component exists to show, and
    // it only reads as dense because marks sit at TRUE angular time.
    for (let i = 0; i < 9; i++) {
      push({
        time: at(dayStart, 3, 10) + i * 95_000,
        category: "sign-in",
        actor: "unknown@203.0.113.4",
        action: "Sign-in attempt (password)",
        result: "failed",
      });
    }
    push({ time: at(dayStart, 3, 27), category: "revocation", actor: "security-bot", action: "Revoked session key", result: "revoked" });
  }

  return out;
}

const SEEDED: SecurityEvent[] = [
  ...baseline(startOfToday(), 0, true),
  ...baseline(startOfToday() - 86_400_000, 1, false),
  ...baseline(startOfToday() - 2 * 86_400_000, 2, false),
];
const MAX_LIVE = 40; // cap the LIVE tail only — the seeded credential-stuffing
// arc and the dimmer prior-day discs must never be the thing an idle preview
// tab evicts; only ever-growing live arrivals get capped.

export default function TachoDiscDemo() {
  // seeded and live are tracked separately and merged for render, so capping
  // never eats into the seeded baseline (today's dense arc, the prior two
  // days) — only the appended live tail is ever trimmed.
  const [live, setLive] = useState<SecurityEvent[]>([]);
  const events = live.length > 0 ? [...SEEDED, ...live] : SEEDED;
  const burstRef = useRef(0);
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);

  // A short live burst of failed sign-ins arrives a beat after mount, then
  // again on a loop — demonstrates the 90ms scribe on arrival and the
  // coalesced aria-live announcement, through real state updates rather than
  // a scripted animation.
  useEffect(() => {
    const startBurst = () => {
      // guard: if a prior burst's tick interval is still armed (e.g. the tab
      // was backgrounded and timers got throttled/coalesced), clear it before
      // arming a new one — otherwise two tick intervals stack and append
      // forever every 260ms instead of stopping after 4 ticks.
      if (timerRef.current) clearInterval(timerRef.current);
      burstRef.current = 0;
      timerRef.current = setInterval(() => {
        burstRef.current += 1;
        setLive((prev) => {
          const next = [
            ...prev,
            {
              id: `live-${Date.now()}-${burstRef.current}`,
              time: Date.now(),
              category: "sign-in" as const,
              actor: "unknown@198.51.100.7",
              action: "Sign-in attempt (password)",
              result: "failed" as const,
            },
          ];
          return next.length > MAX_LIVE ? next.slice(next.length - MAX_LIVE) : next;
        });
        if (burstRef.current >= 4 && timerRef.current) {
          clearInterval(timerRef.current);
          timerRef.current = null;
        }
      }, 260);
    };
    const initial = setTimeout(startBurst, 1400);
    const loop = setInterval(startBurst, 9000);
    return () => {
      clearTimeout(initial);
      clearInterval(loop);
      if (timerRef.current) clearInterval(timerRef.current);
    };
  }, []);

  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">ns-ui / tacho-disc</p>

      <div className="w-full max-w-4xl rounded-[16px] border border-border bg-background p-6">
        <TachoDisc events={events} label="Security activity" />
      </div>

      <p className="max-w-md text-center text-xs text-ns-muted">
        Drag the disc to scrub time; arrow the list or the disc to step event to event. The tight arc of thick marks is
        a credential-stuffing run — nine failures ninety seconds apart, sitting at their true angle, not spread evenly.
      </p>
    </div>
  );
}

Component

registry/core/tacho-disc/component.tsx
"use client";

import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from "react";

// ---------------------------------------------------------------------------
// TachoDisc — a day of security activity (sign-ins, permission grants,
// revocations) drawn as one polar disc: 24 hours mapped to 360 degrees under
// a fixed stylus at 12 o'clock, every event scribed at its true time-of-day
// angle. Radius encodes category (sign-ins outer, grants middle, revocations
// inner, the last drawn as a punched hole rather than a line). Older days sit
// behind as smaller, dimmer discs sharing the same rotation, so the same
// time-of-day lines up radially across nights. A faint hour graduation —
// ticks every hour, numerals at 00/06/12/18 — shares that same rotation
// rather than sitting fixed under the stylus, so a tick always sits at its
// own true hour angle and reads as chart paper under the event ink.
//
// ONE governing scalar drives the whole disc: `discAngle`, the real
// time-of-day angle of "now". Every mark's screen position is its true
// timestamp angle plus that single rotation — nothing is laid out by index,
// so a credential-stuffing run (many failures seconds apart) piles into a
// visibly dense arc instead of being spread evenly like ordinary traffic.
// That's the whole point: even-spacing-by-index would hide exactly the
// pattern this exists to show.
//
// The disc is an index onto a real list, not a decoration standing alone:
// - The event list (time / actor / action / result, all text) is the primary
//   semantic surface. All disc marks are aria-hidden.
// - Hovering or arrow-stepping a list row rotates a faint sector highlight to
//   that event's angle on the disc.
// - The disc itself is role="slider": dragging it (or arrowing it while
//   focused, event-to-event, not minute-by-minute) finds the nearest event
//   and scrolls the list to it, updating aria-valuetext with a live readout.
// - Live-arriving failures draw in with a 90ms scribe and announce through a
//   single coalesced aria-live region ("N failed sign-ins in the last
//   minute"), never one announcement per mark. A revocation is a true hole
//   (background fill + border ring) that appears whole in one frame — no
//   scribe, it isn't an arrival, it's an absence.
// ---------------------------------------------------------------------------

export interface SecurityEvent {
  id: string;
  /** ms since epoch */
  time: number;
  category: "sign-in" | "grant" | "revocation";
  actor: string;
  action: string;
  result: "success" | "failed" | "revoked";
}

export interface TachoDiscProps {
  /** every event in the visible window — today plus however many prior days feed the dimmer stacked discs. */
  events: SecurityEvent[];
  /** how many prior days render as dimmer discs behind today's. Default 2. */
  historyDays?: number;
  /** current instant; defaults to an internally ticking clock. Pass to control/test. */
  now?: number;
  /** accessible label stem, e.g. "Security activity". */
  label?: string;
  className?: string;
}

const DAY_MS = 86_400_000;
const MIN_PER_DAY = 1440;

const BANDS: Record<SecurityEvent["category"], { r: number; seg: number }> = {
  "sign-in": { r: 118, seg: 15 },
  grant: { r: 87, seg: 13 },
  revocation: { r: 55, seg: 0 },
};
const CX = 140;
const CY = 140;
const DISC_R = 132;
const HOLE_R = 5;

// hour graduation — chart paper under the event ink, drawn once at true,
// unrotated hour angles and sharing the same group rotation as every event
// mark (see discAngle below): a tick sits exactly where a same-hour event
// would land, at every rotation, not just when "now" happens to be that
// hour. Faint minor ticks every hour, a slightly longer major tick plus a
// numeral at the four cardinal hours.
const GRAD_R_OUTER = DISC_R;
const GRAD_R_MINOR = DISC_R - 4;
const GRAD_R_MAJOR = DISC_R - 8;
const GRAD_LABEL_R = DISC_R + 11;
const HOUR_TICKS = Array.from({ length: 24 }, (_, h) => ({
  hour: h,
  angle: (h / 24) * 360,
  major: h % 6 === 0,
}));

function clamp(v: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, v));
}

function startOfDay(ms: number): number {
  const d = new Date(ms);
  d.setHours(0, 0, 0, 0);
  return d.getTime();
}

function minutesOfDay(ms: number): number {
  const d = new Date(ms);
  return d.getHours() * 60 + d.getMinutes() + d.getSeconds() / 60;
}

function angleForMs(ms: number): number {
  return (minutesOfDay(ms) / MIN_PER_DAY) * 360;
}

// rounded to 3 decimals: Math.sin/cos can differ by a ULP between Node (SSR)
// and the browser's engine (CSR), which surfaced as x1="29.148748315591874"
// on the server vs 29.148748315591888 on the client — a hydration mismatch
// despite both being "correct". Every mark's coords come through here.
function r3(n: number): number {
  return Math.round(n * 1000) / 1000;
}

function polar(r: number, angleDeg: number): { x: number; y: number } {
  const rad = (angleDeg * Math.PI) / 180;
  return { x: r3(CX + r * Math.sin(rad)), y: r3(CY - r * Math.cos(rad)) };
}

function angDist(a: number, b: number): number {
  const d = Math.abs(a - b) % 360;
  return d > 180 ? 360 - d : d;
}

function formatClock(ms: number): string {
  const d = new Date(ms);
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}

function nearbyFailedCount(events: SecurityEvent[], atMinutes: number, windowMin: number): number {
  let n = 0;
  for (const e of events) {
    if (e.result !== "failed") continue;
    if (angDist((minutesOfDay(e.time) / MIN_PER_DAY) * 360, (atMinutes / MIN_PER_DAY) * 360) <= (windowMin / MIN_PER_DAY) * 360) {
      n++;
    }
  }
  return n;
}

// -- draw-in mark for a live-arriving failure: undrawn -> drawn over 90ms,
// via an imperative stroke-dashoffset transition (never React state on the
// per-mark hot path, matching the ref-write idiom used across the registry).
function ScribeLine({
  x1,
  y1,
  x2,
  y2,
  live,
  reduced,
  width,
}: {
  x1: number;
  y1: number;
  x2: number;
  y2: number;
  live: boolean;
  reduced: boolean;
  width: number;
}) {
  const ref = useRef<SVGLineElement | null>(null);
  const scribedRef = useRef(false);
  useEffect(() => {
    const el = ref.current;
    if (!el || !live || reduced || scribedRef.current) return;
    scribedRef.current = true;
    const len = Math.hypot(x2 - x1, y2 - y1) || 1;
    el.style.strokeDasharray = `${len}`;
    el.style.strokeDashoffset = `${len}`;
    const id = requestAnimationFrame(() => {
      el.style.transition = "stroke-dashoffset 90ms ease-out";
      el.style.strokeDashoffset = "0";
    });
    return () => cancelAnimationFrame(id);
  }, [live, reduced, x1, y1, x2, y2]);
  return <line ref={ref} x1={x1} y1={y1} x2={x2} y2={y2} stroke="var(--foreground)" strokeWidth={width} strokeLinecap="round" />;
}

export function TachoDisc({ events, historyDays = 2, now: controlledNow, label = "Security activity", className = "" }: TachoDiscProps) {
  // Quantised to the minute: Date.now() on the server and again on the client
  // milliseconds later gave two different rotation angles (-334.03333 vs
  // -334.0375) and React discarded the subtree as a hydration mismatch. The
  // dial only re-reads the clock every 60s and never renders sub-minute
  // precision, so flooring to the minute costs nothing and makes both renders
  // agree. The interval below still moves it in real time after mount.
  const [internalNow, setInternalNow] = useState(() => Math.floor(Date.now() / 60_000) * 60_000);
  const nowMs = controlledNow ?? internalNow;

  const reducedRef = useRef(false);
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    reducedRef.current = mq.matches;
    setReduced(mq.matches);
    const onChange = () => {
      reducedRef.current = mq.matches;
      setReduced(mq.matches);
    };
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  useEffect(() => {
    if (controlledNow !== undefined) return;
    const id = window.setInterval(() => setInternalNow(Date.now()), 60_000);
    return () => window.clearInterval(id);
  }, [controlledNow]);

  const todayStart = useMemo(() => startOfDay(nowMs), [nowMs]);

  // group by day offset (0 = today) once; drawn oldest-first so today paints on top.
  const layers = useMemo(() => {
    const byOffset = new Map<number, SecurityEvent[]>();
    for (const e of events) {
      const offset = Math.floor((todayStart - startOfDay(e.time)) / DAY_MS);
      if (offset < 0 || offset > historyDays) continue;
      const arr = byOffset.get(offset) ?? [];
      arr.push(e);
      byOffset.set(offset, arr);
    }
    const out: { offset: number; events: SecurityEvent[] }[] = [];
    for (let o = historyDays; o >= 0; o--) {
      out.push({ offset: o, events: (byOffset.get(o) ?? []).slice().sort((a, b) => a.time - b.time) });
    }
    return out;
  }, [events, todayStart, historyDays]);

  const todayEvents = layers[layers.length - 1]?.events ?? [];

  // -- shared index: the disc IS the list's index, not a separate widget --
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  const [scrubbing, setScrubbing] = useState(false);
  const [tabStop, setTabStop] = useState(0);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const scrollToIndex = useCallback((i: number, immediate = false) => {
    itemRefs.current[i]?.scrollIntoView({
      block: "nearest",
      behavior: reducedRef.current || immediate ? "auto" : "smooth",
    });
  }, []);

  // -- live arrivals: track which ids have been seen so only genuinely new
  // failure marks scribe in; the initial mount never animates. --
  const seenIdsRef = useRef<Set<string> | null>(null);
  const [liveIds, setLiveIds] = useState<Set<string>>(new Set());
  const [liveMessage, setLiveMessage] = useState("");
  const pendingFailuresRef = useRef(0);
  const announceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const announceParityRef = useRef(false);

  useEffect(() => {
    const seen = seenIdsRef.current;
    if (seen === null) {
      seenIdsRef.current = new Set(events.map((e) => e.id));
      return;
    }
    const fresh: string[] = [];
    for (const e of events) {
      if (!seen.has(e.id)) {
        seen.add(e.id);
        fresh.push(e.id);
        if (e.result === "failed") pendingFailuresRef.current += 1;
      }
    }
    if (fresh.length > 0) {
      setLiveIds((prev) => {
        const next = new Set(prev);
        for (const id of fresh) next.add(id);
        return next;
      });
    }
    // clear-and-reschedule: every arrival pushes the window out, so one burst
    // (however long it runs) resolves to exactly one announcement, not one
    // per arrival and not a truncated first window plus a trailing one.
    if (pendingFailuresRef.current > 0) {
      if (announceTimerRef.current) clearTimeout(announceTimerRef.current);
      announceTimerRef.current = setTimeout(() => {
        const n = pendingFailuresRef.current;
        pendingFailuresRef.current = 0;
        announceTimerRef.current = null;
        // a zero-width toggle forces a real text-node change even when the
        // message repeats verbatim across bursts — that's what actually
        // re-triggers the announcement (see autosave-ratchet).
        announceParityRef.current = !announceParityRef.current;
        setLiveMessage(`${n} failed sign-in${n === 1 ? "" : "s"} in the last minute${announceParityRef.current ? "​" : ""}`);
      }, 700);
    }
  }, [events]);

  useEffect(() => {
    return () => {
      if (announceTimerRef.current) clearTimeout(announceTimerRef.current);
    };
  }, []);

  const discAngle = useMemo(() => -angleForMs(nowMs), [nowMs]);

  const activeEvent = activeIndex !== null ? todayEvents[activeIndex] : undefined;

  const failuresLastHour = useMemo(() => nearbyFailedCount(todayEvents, minutesOfDay(nowMs), 60), [todayEvents, nowMs]);

  const valueNow = activeEvent ? Math.round(minutesOfDay(activeEvent.time)) : Math.round(minutesOfDay(nowMs));
  const valueText = activeEvent
    ? scrubbing
      ? `scrubbing ${formatClock(activeEvent.time)}, ${nearbyFailedCount(todayEvents, minutesOfDay(activeEvent.time), 30)} failed sign-ins nearby`
      : `${formatClock(activeEvent.time)}, ${activeEvent.actor}, ${activeEvent.action}, ${activeEvent.result}`
    : `now ${formatClock(nowMs)}, ${failuresLastHour} failed sign-in${failuresLastHour === 1 ? "" : "s"} in the last hour`;

  const setActive = useCallback(
    (i: number | null, scroll = true, immediate = false) => {
      setActiveIndex(i);
      if (i !== null && scroll) scrollToIndex(i, immediate);
    },
    [scrollToIndex]
  );

  const nearestIndexAtAngle = useCallback(
    (screenAngle: number, radius: number) => {
      if (todayEvents.length === 0) return null;
      const trueAngle = (((screenAngle - discAngle) % 360) + 360) % 360;
      let bestCategory: SecurityEvent["category"] = "sign-in";
      let bestDr = Infinity;
      (Object.keys(BANDS) as SecurityEvent["category"][]).forEach((cat) => {
        const dr = Math.abs(radius - BANDS[cat].r);
        if (dr < bestDr) {
          bestDr = dr;
          bestCategory = cat;
        }
      });
      let candidates = todayEvents.map((e, i) => ({ e, i })).filter(({ e }) => e.category === bestCategory);
      if (candidates.length === 0) candidates = todayEvents.map((e, i) => ({ e, i }));
      let best = candidates[0]!;
      let bestDist = Infinity;
      for (const c of candidates) {
        const d = angDist(angleForMs(c.e.time), trueAngle);
        if (d < bestDist) {
          bestDist = d;
          best = c;
        }
      }
      return best.i;
    },
    [todayEvents, discAngle]
  );

  // The interactive box: role="slider" and every pointer/keyboard handler
  // live on this plain <div>, not the <svg> — a focus-visible outline on an
  // SVG root is unverifiable across browsers, so the SVG below is purely
  // aria-hidden decoration sized to fill this box.
  const discBoxRef = useRef<HTMLDivElement | null>(null);
  const draggingRef = useRef(false);

  const angleFromPointer = (clientX: number, clientY: number) => {
    const rect = discBoxRef.current?.getBoundingClientRect();
    if (!rect) return null;
    const scale = 280 / rect.width;
    const x = (clientX - rect.left) * scale - CX;
    const y = (clientY - rect.top) * scale - CY;
    let deg = (Math.atan2(x, -y) * 180) / Math.PI;
    if (deg < 0) deg += 360;
    return { deg, radius: Math.hypot(x, y) };
  };

  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
    discBoxRef.current?.setPointerCapture(e.pointerId);
    draggingRef.current = true;
    setScrubbing(true);
    const p = angleFromPointer(e.clientX, e.clientY);
    if (p) {
      const idx = nearestIndexAtAngle(p.deg, p.radius);
      if (idx !== null) setActive(idx, true, true);
    }
  };
  const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
    // live hover preview (not just active drags) — the disc is an index, so
    // sweeping it with the pointer should rotate the highlight the same way
    // hovering the list does, even before a drag starts. But a hover sweep
    // (not dragging) must only move the highlight, never re-target the list's
    // scroll position on every ~30Hz sample — only an actual drag scrolls,
    // and it scrolls immediately (no smooth re-target chase mid-gesture).
    const p = angleFromPointer(e.clientX, e.clientY);
    if (p) {
      const idx = nearestIndexAtAngle(p.deg, p.radius);
      if (idx !== null) setActive(idx, draggingRef.current, true);
    }
  };
  const onPointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (!draggingRef.current) return;
    draggingRef.current = false;
    setScrubbing(false);
    discBoxRef.current?.releasePointerCapture(e.pointerId);
  };

  const onDiscKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    if (todayEvents.length === 0) return;
    const step = (delta: number) => {
      e.preventDefault();
      const base = activeIndex ?? 0;
      const next = clamp(base + delta, 0, todayEvents.length - 1);
      setActive(next);
    };
    switch (e.key) {
      case "ArrowLeft":
      case "ArrowDown":
        step(-1);
        break;
      case "ArrowRight":
      case "ArrowUp":
        step(1);
        break;
      case "Home":
        e.preventDefault();
        setActive(0);
        break;
      case "End":
        e.preventDefault();
        setActive(todayEvents.length - 1);
        break;
      default:
        break;
    }
  };

  const onListKeyDown = (e: ReactKeyboardEvent<HTMLButtonElement>, i: number) => {
    const move = (next: number) => {
      e.preventDefault();
      const clamped = clamp(next, 0, todayEvents.length - 1);
      setTabStop(clamped);
      setActiveIndex(clamped);
      itemRefs.current[clamped]?.focus();
    };
    switch (e.key) {
      case "ArrowDown":
        move(i + 1);
        break;
      case "ArrowUp":
        move(i - 1);
        break;
      case "Home":
        move(0);
        break;
      case "End":
        move(todayEvents.length - 1);
        break;
      default:
        break;
    }
  };

  return (
    <div className={`flex flex-col gap-6 sm:flex-row sm:items-start ${className}`}>
      <style>{CSS}</style>

      <div
        ref={discBoxRef}
        data-tacho-disc=""
        role="slider"
        tabIndex={0}
        aria-label={`${label} disc`}
        aria-valuemin={0}
        aria-valuemax={MIN_PER_DAY - 1}
        aria-valuenow={valueNow}
        aria-valuetext={valueText}
        className="ns-tacho-disc h-64 w-64 shrink-0 touch-none select-none rounded-full focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ns-accent"
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerUp}
        onPointerCancel={onPointerUp}
        onKeyDown={onDiscKeyDown}
      >
      <svg viewBox="-16 -16 312 312" aria-hidden="true" className="block h-full w-full">
        <circle cx={CX} cy={CY} r={DISC_R} fill="none" stroke="var(--border)" strokeWidth={1} aria-hidden="true" />

        {/* fixed stylus at 12 o'clock — outside the rotating group, never moves */}
        <g aria-hidden="true">
          <polygon points={`${CX - 5},4 ${CX + 5},4 ${CX},15`} fill="var(--foreground)" />
          <line x1={CX} y1={15} x2={CX} y2={28} stroke="var(--foreground)" strokeWidth={2} />
        </g>

        {/* the one governing scalar: discAngle. every layer below shares this rotation. */}
        <g transform={`rotate(${discAngle} ${CX} ${CY})`} className={reduced ? "" : "ns-tacho-rotate"} aria-hidden="true">
          {/* hour graduation — chart paper, drawn under the event ink and rotating
              with it so a tick always sits at its own true hour angle. */}
          <g>
            {HOUR_TICKS.map(({ hour, angle, major }) => {
              const inner = polar(major ? GRAD_R_MAJOR : GRAD_R_MINOR, angle);
              const outer = polar(GRAD_R_OUTER, angle);
              return (
                <g key={hour}>
                  <line
                    x1={inner.x}
                    y1={inner.y}
                    x2={outer.x}
                    y2={outer.y}
                    stroke="var(--ns-muted)"
                    strokeWidth={major ? 1 : 0.5}
                    strokeOpacity={major ? 0.6 : 0.35}
                  />
                  {major && (
                    <g transform={`rotate(${-discAngle} ${polar(GRAD_LABEL_R, angle).x} ${polar(GRAD_LABEL_R, angle).y})`}>
                      <text
                        x={polar(GRAD_LABEL_R, angle).x}
                        y={polar(GRAD_LABEL_R, angle).y}
                        textAnchor="middle"
                        dominantBaseline="middle"
                        fill="var(--ns-muted)"
                        fontSize={9}
                        style={{ fontFamily: "var(--font-mono)" }}
                      >
                        {String(hour).padStart(2, "0")}
                      </text>
                    </g>
                  )}
                </g>
              );
            })}
          </g>

          {layers.map(({ offset, events: dayEvents }) => {
            const isToday = offset === 0;
            const layerScale = 1 - offset * 0.13;
            const opacity = isToday ? 1 : Math.max(0.16, 0.5 - offset * 0.15);
            return (
              <g key={offset} opacity={opacity}>
                {dayEvents.map((ev) => {
                  const angle = angleForMs(ev.time);
                  if (ev.category === "revocation") {
                    const r = BANDS.revocation.r * layerScale;
                    const p = polar(r, angle);
                    return (
                      <circle
                        key={ev.id}
                        cx={p.x}
                        cy={p.y}
                        r={HOLE_R * layerScale}
                        fill="var(--background)"
                        stroke="var(--border)"
                        strokeWidth={1.5}
                      />
                    );
                  }
                  const band = BANDS[ev.category];
                  const r = band.r * layerScale;
                  const seg = band.seg * layerScale;
                  const p1 = polar(r - seg / 2, angle);
                  const p2 = polar(r + seg / 2, angle);
                  const failed = ev.result === "failed";
                  if (isToday && failed) {
                    return (
                      <ScribeLine
                        key={ev.id}
                        x1={p1.x}
                        y1={p1.y}
                        x2={p2.x}
                        y2={p2.y}
                        width={8}
                        live={liveIds.has(ev.id)}
                        reduced={reduced}
                      />
                    );
                  }
                  return (
                    <line
                      key={ev.id}
                      x1={p1.x}
                      y1={p1.y}
                      x2={p2.x}
                      y2={p2.y}
                      stroke="var(--foreground)"
                      strokeWidth={failed ? 8 : 3}
                      strokeLinecap="round"
                    />
                  );
                })}
                {isToday && activeEvent && (
                  <line
                    x1={CX}
                    y1={CY}
                    x2={polar(DISC_R - 4, angleForMs(activeEvent.time)).x}
                    y2={polar(DISC_R - 4, angleForMs(activeEvent.time)).y}
                    stroke="var(--ns-accent)"
                    strokeWidth={16}
                    strokeOpacity={0.1}
                    strokeLinecap="round"
                  />
                )}
              </g>
            );
          })}
        </g>
      </svg>
      </div>

      <div className="flex min-w-0 flex-1 flex-col gap-2">
        <div className="flex items-baseline justify-between gap-3">
          <h3 className="text-sm font-medium text-foreground">{label}</h3>
          <span className="font-mono text-xs tabular-nums text-ns-muted">last 24h · now {formatClock(nowMs)}</span>
        </div>

        <ul
          role="list"
          aria-label={`${label} events, last 24 hours`}
          className="ns-tacho-list flex max-h-72 flex-col gap-1 overflow-y-auto rounded-[12px] border border-border bg-background p-1"
        >
          {todayEvents.length === 0 && <li className="px-3 py-4 text-sm text-ns-muted">No activity in the last 24 hours.</li>}
          {todayEvents.map((ev, i) => {
            const isActive = activeIndex === i;
            return (
              <li key={ev.id}>
                <button
                  type="button"
                  ref={(el) => {
                    itemRefs.current[i] = el;
                  }}
                  tabIndex={tabStop === i ? 0 : -1}
                  aria-current={isActive ? "true" : undefined}
                  aria-label={`${formatClock(ev.time)}, ${ev.actor}, ${ev.action}, ${ev.result}`}
                  className={`ns-tacho-row flex w-full items-center gap-3 rounded-[6px] px-2 py-1.5 text-left text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent ${
                    isActive ? "ns-tacho-row-active" : ""
                  }`}
                  onMouseEnter={() => setActive(i)}
                  onFocus={() => {
                    setTabStop(i);
                    setActiveIndex(i);
                  }}
                  onKeyDown={(e) => onListKeyDown(e, i)}
                >
                  <span aria-hidden="true" className="w-3 shrink-0 text-center text-foreground">
                    {isActive ? "›" : ""}
                  </span>
                  <time className={`w-12 shrink-0 font-mono text-xs tabular-nums text-ns-muted ${isActive ? "font-semibold" : ""}`}>
                    {formatClock(ev.time)}
                  </time>
                  <span className={`w-44 shrink-0 truncate text-foreground ${isActive ? "font-semibold" : ""}`}>{ev.actor}</span>
                  <span className="min-w-0 flex-1 truncate text-ns-muted">{ev.action}</span>
                  <span
                    className={`shrink-0 font-mono text-xs uppercase tracking-wide ${
                      ev.result === "failed" ? "text-foreground" : "text-ns-muted"
                    }`}
                  >
                    {ev.result}
                  </span>
                </button>
              </li>
            );
          })}
        </ul>

        <div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
          {liveMessage}
        </div>
      </div>
    </div>
  );
}

const CSS = `
.ns-tacho-rotate{ transition: transform 60s linear; }
.ns-tacho-row-active{ background-color: var(--border); }
@media (prefers-reduced-motion: reduce){
  .ns-tacho-rotate{ transition: none !important; }
}
`;

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/tacho-disc.tsx, so point the import there.

Use when a sign-in / key-usage / permission-change log where the reader's actual question is time-of-day shaped ("was last night normal?") and event-level detail at true angular time must survive — a credential-stuffing run needs to visibly read as a dense arc, not get smoothed away. Reach for hero-chart-recorder instead for a decorative ambient strip chart with no real bound list and no per-event semantics; reach for heatmap-calendar-tide instead when you want time-of-day intentionally aggregated away into a coarse tide pattern across many days rather than kept at individual-event resolution for one day.

Install

npx shadcn add https://design.helpmarq.com/r/tacho-disc.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build <TachoDisc events historyDays? now? label? className?> where `events: SecurityEvent[]` is `{ id, time /* ms epoch */, category: "sign-in"|"grant"|"revocation", actor, action, result: "success"|"failed"|"revoked" }[]`. STRUCTURE: a flex row (column on narrow viewports) of an SVG disc on one side and a real event list on the other — the list is the primary semantic surface, the disc is aria-hidden geometry plus one role="slider" control. GEOMETRY / THE ONE GOVERNING SCALAR: 24 hours maps to 360 degrees (angleForMs = minutesOfDay/1440*360, clockwise from 12 o'clock). Compute exactly one rotation value, `discAngle = -angleForMs(now)`, and apply it as a single SVG <g transform="rotate(discAngle cx cy)"> wrapping every day's marks — every mark inside is positioned at its OWN true, unrotated timestamp angle; the shared group rotation is what makes "now" line up under a fixed stylus glyph drawn OUTSIDE that group at 12 o'clock, which itself never moves. This is the falsifiable constraint: marks sit at true angular time on the shared clock. Do NOT lay marks out evenly by array index — a burst of failures seconds apart must pile into a visibly dense arc because it geometrically is one; index-based spacing would hide exactly the pattern the component exists to reveal. HOUR GRADUATION: inside that same rotated group, under every event layer, draw a faint hour ring — a tick at each of the 24 true hour angles, a slightly longer major tick plus a `00`/`06`/`12`/`18` numeral (Geist Mono, var(--ns-muted), counter-rotated in place so the glyph itself stays upright as the disc turns) at the cardinal hours — so the dial reads as a day at a glance instead of a bare fan of event ticks; it shares discAngle rather than sitting fixed under the stylus, because a fixed graduation would drift out of true-hour alignment with the events the moment "now" moved off the hour. It is chart paper: thinner and dimmer than every event mark, never var(--foreground), never mistakable for one. RADIUS BY CATEGORY: sign-ins draw as a radial line segment at the outer radius band, grants at a middle radius band, revocations at the innermost radius but as a punched HOLE — a small circle filled var(--background) with a var(--border) ring stroke, appearing whole in a single frame with no draw-in animation (it is an absence being marked, not an arrival). Ordinary sign-in/grant events (result "success") draw as a 3px hairline (var(--foreground), round cap). Any event with result "failed" draws at 8px stroke width regardless of category. OLDER DAYS: group events by calendar day (0 = today); render days oldest-first inside the SAME rotated group so they share discAngle, each day's own sub-group scaled slightly smaller (~13% per day back, applied to that day's radius numbers, not a DOM-level CSS scale/transform-origin trick) and faded down toward ~0.16 opacity, so older nights sit behind today as visibly smaller, dimmer, concentric discs, with the same time-of-day landing at the same angle across all of them for at-a-glance night-over-night comparison. LIVE ARRIVALS: track which event ids were present at first mount; any id that appears afterward on a TODAY, failed, sign-in/grant mark gets a 90ms stroke-dashoffset draw-in (dasharray = own line length, offset animated length\u21920 via an imperative ref effect on mount of that mark, one real transition, never a CSS keyframe percentage hack) — skip the animation entirely under prefers-reduced-motion, drawing it fully formed instead. Revocations never scribe. THE DISC AS INDEX, NOT DECORATION: keep one shared piece of state, `activeIndex` into today's chronologically-sorted events, plus a transient `scrubbing` boolean. Hovering a list row, or arrow/Home/End-ing within the list (roving tabindex, one row's button has tabIndex 0 at a time, ArrowUp/Down moves both the DOM focus and activeIndex, Home/End jump to first/last), sets activeIndex and — inside the disc's rotated "today" sub-group only — draws a low-opacity var(--ns-accent) wedge (fillOpacity ~0.1, a wide radial line from center outward) at that event's angle: this is the one and only place --ns-accent appears, purely interaction-driven, never a resting/decorative color. The disc SVG itself is role="slider" tabIndex 0, aria-valuemin 0, aria-valuemax 1439 (minutes in a day), aria-valuenow the active (or, if none, "now") event's minute-of-day, aria-valuetext formatted `scrubbing HH:MM, N failed sign-ins nearby` while a pointer drag is live, `HH:MM, actor, action, result` when a row/step is focused but not dragging, or `now HH:MM, N failed sign-ins in the last hour` at rest. ArrowLeft/Down and ArrowRight/Up on the focused disc step activeIndex to the previous/next event chronologically (event-to-event, never a fixed-minute increment) and Home/End jump to the day's first/last event; every activeIndex change (from either the list or the disc) calls scrollIntoView({block:'nearest'}) on the corresponding list row, so scrubbing the disc scrolls the list to the nearest event and vice versa. Dragging directly on the disc (pointerdown/move/up, pointer capture held for the gesture) converts the pointer's angle-from-center back through the current discAngle into a true time-of-day, picks the category band whose radius the pointer is nearest, and sets activeIndex to the angularly-nearest event within that band (falling back to nearest across all categories if that band is empty for the day) — this is a live scrub, not a discrete click. LIVE-FAILURE ANNOUNCEMENT: a visually-hidden role="status" aria-live="polite" aria-atomic="true" node is the ONLY place new failures are spoken. Batch every failed arrival behind a ~700ms debounce and announce once per batch — "4 failed sign-ins in the last minute", pluralized — never one live-region update per mark; this is a hard constraint, not a nicety, since a real credential-stuffing run would otherwise spam a screen reader with dozens of individual announcements. TOKENS: every disc mark, ring, and hole uses var(--foreground)/var(--border)/var(--background) only; var(--ns-accent) is reserved solely for the interaction-driven highlight wedge and the slider/list focus-visible rings — never a resting color, never the failure or revocation encoding itself (severity is stroke weight and hole shape, not color). REDUCED MOTION: the rotating group's 60s linear CSS transition (the mechanism that makes discAngle glide smoothly as the internal clock ticks once a minute) is disabled outright, so the disc jumps to each new minute's angle rather than sweeping — the update cadence itself doesn't need to change, only whether it's animated; the scribe draw-in is likewise skipped, marks simply appear complete. DEMO: three days of baseline sign-ins/grants, today seeded with a tight nine-failure credential-stuffing run (~95s apart) followed by a session-key revocation around 03:10\u201303:27, then a real setInterval appends a fresh 4-failure burst to the SAME today array a beat after mount and again every ~9s, driving the live scribe-in and coalesced announcement through actual state updates rather than a scripted replay.

Props

PropTypeDefaultDescription
eventsSecurityEvent[]—every event in the visible window — today plus however many prior days feed the dimmer stacked discs.
historyDays?number2how many prior days render as dimmer discs behind today's. Default 2.
now?number—current instant; defaults to an internally ticking clock. Pass to control/test.
label?string"Security activity"accessible label stem, e.g. "Security activity".
className?string——