attention / src /components /loop /LoopFigure.tsx
whassan's picture
Add looping diagrams for every architecture and tighten explorer UX.
ce35403 verified
Raw History Blame Contribute Delete
7.98 kB
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { Pause, Play, RotateCcw, SkipForward } from "lucide-react";
export type LoopStop = {
x: number;
y: number;
ids: string[];
inspect: string;
title: string;
note: string;
};
type Props = {
stops: LoopStop[];
loopMs?: number;
viewBox: string;
ariaLabel: string;
onSelect: (id: string) => void;
children: (api: {
on: (id: string) => boolean;
markerId: string;
token: { x: number; y: number };
step: number;
}) => ReactNode;
};
const HOLD = 0.38;
export function LoopFigure({
stops,
loopMs = 16000,
viewBox,
ariaLabel,
onSelect,
children,
}: Props) {
const [playing, setPlaying] = useState(true);
const [progress, setProgress] = useState(0);
const started = useRef(performance.now());
const pausedAt = useRef(0);
const rawId = useId().replace(/:/g, "");
const markerId = `arr-${rawId}`;
useEffect(() => {
if (!playing) return;
let frame = 0;
const tick = (now: number) => {
setProgress(((now - started.current) % loopMs) / loopMs);
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [playing, loopMs]);
const motion = useMemo(() => alongPath(stops, progress), [stops, progress]);
const current = stops[motion.step];
const lit = useMemo(() => new Set(current.ids), [current]);
useEffect(() => {
onSelect(current.inspect);
}, [current.inspect, onSelect]);
const jump = (next: number) => {
const idx = ((next % stops.length) + stops.length) % stops.length;
started.current = performance.now() - (idx / stops.length) * loopMs;
setProgress(idx / stops.length);
};
return (
<div className="overflow-hidden rounded-xl border border-white/10 bg-[#f7f4ec]">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-black/10 bg-[#111827] px-3 py-3 sm:px-4">
<div className="min-w-0">
<p className="font-mono text-[10px] tracking-[0.22em] text-cyan-200/80 uppercase">
Loop · {motion.step + 1} / {stops.length}
</p>
<p className="text-sm font-medium text-white">{current.title}</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
aria-label={playing ? "Pause animation" : "Play animation"}
onClick={() => {
if (playing) {
pausedAt.current = performance.now();
setPlaying(false);
} else {
started.current += performance.now() - pausedAt.current;
setPlaying(true);
}
}}
className="inline-flex min-h-11 items-center gap-1.5 rounded-md border border-white/15 bg-white/5 px-3 py-2 text-xs text-slate-100 hover:bg-white/10"
>
{playing ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
{playing ? "Pause" : "Play"}
</button>
<button
type="button"
aria-label="Step to next block"
onClick={() => {
setPlaying(false);
jump(motion.step + 1);
}}
className="inline-flex min-h-11 items-center gap-1.5 rounded-md border border-white/15 bg-white/5 px-3 py-2 text-xs text-slate-100 hover:bg-white/10"
>
<SkipForward className="h-3.5 w-3.5" />
Step
</button>
<button
type="button"
aria-label="Restart animation"
onClick={() => {
jump(0);
setPlaying(true);
}}
className="inline-flex min-h-11 items-center gap-1.5 rounded-md border border-white/15 bg-white/5 px-3 py-2 text-xs text-slate-100 hover:bg-white/10"
>
<RotateCcw className="h-3.5 w-3.5" />
Restart
</button>
</div>
</div>
<div className="border-b border-black/10 bg-[#111827] px-3 py-2 sm:px-4">
<label className="flex items-center gap-3">
<span className="hidden font-mono text-[10px] text-slate-400 uppercase sm:inline">
Scrub
</span>
<input
type="range"
min={0}
max={1}
step={0.001}
value={progress}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress * 100)}
aria-label="Scrub architecture animation"
onChange={(event) => {
const t = Number(event.target.value);
setPlaying(false);
pausedAt.current = performance.now();
started.current = performance.now() - t * loopMs;
setProgress(t);
}}
className="h-11 w-full accent-cyan-300"
/>
</label>
</div>
<div className="overflow-x-auto">
<svg
viewBox={viewBox}
className="h-auto w-full min-w-[20rem]"
role="img"
aria-label={ariaLabel}
>
<defs>
<marker
id={markerId}
markerWidth="8"
markerHeight="8"
refX="6"
refY="4"
orient="auto"
>
<path d="M0 0 L8 4 L0 8 Z" fill="#444" />
</marker>
</defs>
{children({
on: (id) => lit.has(id),
markerId,
token: { x: motion.x, y: motion.y },
step: motion.step,
})}
<circle
cx={motion.x}
cy={motion.y}
r="8"
fill="#e11d48"
stroke="#fff"
strokeWidth="2"
/>
</svg>
</div>
<p className="border-t border-black/10 bg-white/70 px-4 py-3 text-sm text-slate-800">
{current.note}
</p>
</div>
);
}
function alongPath(stops: LoopStop[], t: number) {
const n = stops.length;
const x = ((((t % 1) + 1) % 1) * n);
const step = Math.min(n - 1, Math.floor(x));
const f = x - step;
const a = stops[step];
const b = stops[(step + 1) % n];
if (f < HOLD) return { x: a.x, y: a.y, step };
const u = (f - HOLD) / (1 - HOLD);
const ease = u * u * (3 - 2 * u);
return {
x: a.x + (b.x - a.x) * ease,
y: a.y + (b.y - a.y) * ease,
step,
};
}
export const FILL = {
orange: "#e39b4a",
yellow: "#e6cf4e",
cyan: "#78c3c4",
pink: "#efb0c2",
green: "#86c86f",
purple: "#9b8ec8",
white: "#f4f4f4",
blue: "#7eb6d9",
};
export function FigBox({
x,
y,
w,
h,
fill,
label,
lit,
onClick,
}: {
x: number;
y: number;
w: number;
h: number;
fill: string;
label: string;
lit: boolean;
onClick: () => void;
}) {
return (
<g onClick={onClick} className="cursor-pointer">
{lit ? (
<rect
x={x - 4}
y={y - 4}
width={w + 8}
height={h + 8}
rx="8"
fill="none"
stroke="#e11d48"
strokeWidth="2"
className="pulse-ring"
/>
) : null}
<rect
x={x}
y={y}
width={w}
height={h}
rx="5"
fill={fill}
stroke={lit ? "#111" : "#555"}
strokeWidth={lit ? 2.4 : 1}
/>
<text
x={x + w / 2}
y={y + h / 2 + 4}
textAnchor="middle"
fontSize={label.length > 18 ? 11 : 13}
fill="#1a1a1a"
fontWeight={lit ? 700 : 600}
>
{label}
</text>
</g>
);
}
export function Arrow({
x1,
y1,
x2,
y2,
active,
markerId,
}: {
x1: number;
y1: number;
x2: number;
y2: number;
active: boolean;
markerId: string;
}) {
return (
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={active ? "#111" : "#888"}
strokeWidth={active ? 2.3 : 1.2}
markerEnd={`url(#${markerId})`}
/>
);
}