Skip to content

Commit 578d732

Browse files
committed
feat: step 4 - running the router
1 parent 06f3e69 commit 578d732

File tree

4 files changed

+614
-32
lines changed

4 files changed

+614
-32
lines changed
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
import { useMemo } from 'react';
2+
import { motion } from 'framer-motion';
3+
import type { OnboardingStatus } from './status-icon';
4+
5+
// --- Layout ---
6+
const VB_W = 700;
7+
const VB_H = 120;
8+
const MID_Y = VB_H / 2;
9+
const PAD_X = 20;
10+
const LINE_W = VB_W - PAD_X * 2;
11+
12+
// --- Colors ---
13+
const MUTED = 'hsl(var(--muted-foreground))';
14+
const PRIMARY = 'hsl(var(--primary))';
15+
const DESTRUCTIVE = 'hsl(var(--destructive))';
16+
17+
// --- Paths ---
18+
const NUM_POINTS = 12;
19+
20+
function buildPoints(flat: boolean): [number, number][] {
21+
const points: [number, number][] = [];
22+
const startX = PAD_X;
23+
const endX = PAD_X + LINE_W;
24+
const cx = startX + LINE_W / 2;
25+
26+
if (flat) {
27+
for (let i = 0; i < NUM_POINTS; i++) {
28+
points.push([startX + (i * LINE_W) / (NUM_POINTS - 1), MID_Y]);
29+
}
30+
} else {
31+
points.push([startX, MID_Y]);
32+
points.push([cx - 60, MID_Y]);
33+
points.push([cx - 30, MID_Y]);
34+
points.push([cx - 18, MID_Y - 8]);
35+
points.push([cx - 8, MID_Y]);
36+
points.push([cx, MID_Y - 40]);
37+
points.push([cx + 8, MID_Y + 18]);
38+
points.push([cx + 18, MID_Y]);
39+
points.push([cx + 32, MID_Y - 10]);
40+
points.push([cx + 50, MID_Y]);
41+
points.push([cx + 80, MID_Y]);
42+
points.push([endX, MID_Y]);
43+
}
44+
45+
return points;
46+
}
47+
48+
function pointsToPath(points: [number, number][]): string {
49+
const [first, ...rest] = points;
50+
return `M ${first[0]} ${first[1]} ` + rest.map(([x, y]) => `L ${x} ${y}`).join(' ');
51+
}
52+
53+
/** Normalized cumulative distance for each point (0→1) */
54+
function cumulativeProgress(points: [number, number][]): number[] {
55+
let total = 0;
56+
const dists = [0];
57+
for (let i = 1; i < points.length; i++) {
58+
const dx = points[i][0] - points[i - 1][0];
59+
const dy = points[i][1] - points[i - 1][1];
60+
total += Math.sqrt(dx * dx + dy * dy);
61+
dists.push(total);
62+
}
63+
return dists.map((d) => d / total);
64+
}
65+
66+
const FLAT_POINTS = buildPoints(true);
67+
const BEAT_POINTS = buildPoints(false);
68+
const FLAT_PATH = pointsToPath(FLAT_POINTS);
69+
const BEAT_PATH = pointsToPath(BEAT_POINTS);
70+
const FLAT_PROGRESS = cumulativeProgress(FLAT_POINTS);
71+
const BEAT_PROGRESS = cumulativeProgress(BEAT_POINTS);
72+
73+
// --- Animation timing ---
74+
const SCAN_DURATION = 3;
75+
const ALIVE_DURATION = 2.2;
76+
// Beam trail length as fraction of path
77+
const SCAN_TRAIL = 0.35;
78+
const ALIVE_TRAIL = 0.45;
79+
80+
type MonitorState = 'scanning' | 'alive' | 'failed';
81+
82+
function statusToState(status: OnboardingStatus): MonitorState {
83+
switch (status) {
84+
case 'pending':
85+
return 'scanning';
86+
case 'ok':
87+
return 'alive';
88+
case 'fail':
89+
case 'error':
90+
return 'failed';
91+
}
92+
}
93+
94+
export const MetricsMonitor = ({ status }: { status: OnboardingStatus }) => {
95+
const state = statusToState(status);
96+
97+
const isAlive = state === 'alive';
98+
const isFailed = state === 'failed';
99+
const shouldAnimate = !isFailed;
100+
101+
const points = isAlive ? BEAT_POINTS : FLAT_POINTS;
102+
const path = isAlive ? BEAT_PATH : FLAT_PATH;
103+
const progress = isAlive ? BEAT_PROGRESS : FLAT_PROGRESS;
104+
const color = isAlive ? PRIMARY : isFailed ? DESTRUCTIVE : MUTED;
105+
106+
const trail = isAlive ? ALIVE_TRAIL : SCAN_TRAIL;
107+
const drawDuration = isAlive ? ALIVE_DURATION : SCAN_DURATION;
108+
const drawFraction = 1 / (1 + trail); // portion of cycle where dot moves (rest is tail exit)
109+
const cycleDuration = drawDuration;
110+
111+
// Dot keyframes synced to stroke reveal
112+
const dotKeyframes = useMemo(() => {
113+
const xs: number[] = [];
114+
const ys: number[] = [];
115+
const times: number[] = [];
116+
117+
// Dot goes 0→1 in drawFraction, holds at end while tail exits
118+
for (let i = 0; i < points.length; i++) {
119+
xs.push(points[i][0]);
120+
ys.push(points[i][1]);
121+
times.push(progress[i] * drawFraction);
122+
}
123+
// Hold at end while tail exits
124+
xs.push(points[points.length - 1][0]);
125+
ys.push(points[points.length - 1][1]);
126+
times.push(1);
127+
128+
// Gradient x1 (trailing edge, transparent) and x2 (leading edge, opaque)
129+
const trailPx = trail * LINE_W;
130+
const gx1: number[] = [];
131+
const gx2: number[] = [];
132+
for (let i = 0; i < xs.length; i++) {
133+
gx2.push(xs[i]);
134+
gx1.push(xs[i] - trailPx);
135+
}
136+
137+
return { xs, ys, times, gx1, gx2 };
138+
}, [points, progress, drawFraction, trail]);
139+
const lineOpacity = isAlive ? 1 : 0.4;
140+
141+
return (
142+
<div className="w-full overflow-hidden rounded-lg border border-border bg-muted/30">
143+
<svg viewBox={`0 0 ${VB_W} ${VB_H}`} className="w-full">
144+
<defs>
145+
<filter id="beat-glow" x="-20%" y="-40%" width="140%" height="180%">
146+
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur" />
147+
<feFlood floodColor={PRIMARY} floodOpacity="0.4" result="color" />
148+
<feComposite in="color" in2="blur" operator="in" result="glow" />
149+
<feMerge>
150+
<feMergeNode in="glow" />
151+
<feMergeNode in="SourceGraphic" />
152+
</feMerge>
153+
</filter>
154+
155+
{/* Beam gradient: transparent at trailing edge → solid at leading edge */}
156+
<motion.linearGradient
157+
id="beam-grad"
158+
gradientUnits="userSpaceOnUse"
159+
y1={MID_Y}
160+
y2={MID_Y}
161+
animate={{
162+
x1: dotKeyframes.gx1,
163+
x2: dotKeyframes.gx2,
164+
}}
165+
transition={{
166+
x1: {
167+
duration: cycleDuration,
168+
times: dotKeyframes.times,
169+
ease: 'linear',
170+
repeat: Infinity,
171+
},
172+
x2: {
173+
duration: cycleDuration,
174+
times: dotKeyframes.times,
175+
ease: 'linear',
176+
repeat: Infinity,
177+
},
178+
}}
179+
>
180+
<stop offset="0%" stopColor={color} stopOpacity={0} />
181+
<stop offset="100%" stopColor={color} stopOpacity={lineOpacity} />
182+
</motion.linearGradient>
183+
</defs>
184+
185+
{/* Background grid lines */}
186+
{[0.25, 0.5, 0.75].map((f) => (
187+
<line
188+
key={f}
189+
x1={PAD_X}
190+
y1={VB_H * f}
191+
x2={PAD_X + LINE_W}
192+
y2={VB_H * f}
193+
stroke={MUTED}
194+
strokeOpacity={0.08}
195+
strokeWidth={1}
196+
/>
197+
))}
198+
199+
{shouldAnimate ? (
200+
<g key={`anim-${state}`}>
201+
{/* Beam trail sweeps L→R with gradient fade */}
202+
<motion.path
203+
d={path}
204+
fill="none"
205+
stroke="url(#beam-grad)"
206+
strokeWidth={isAlive ? 2.5 : 2}
207+
strokeLinecap="round"
208+
strokeLinejoin="round"
209+
pathLength={1}
210+
style={{ strokeDasharray: `${trail} 1` }}
211+
filter={isAlive ? 'url(#beat-glow)' : undefined}
212+
animate={{
213+
strokeDashoffset: [trail, -1],
214+
}}
215+
transition={{
216+
duration: cycleDuration,
217+
ease: 'linear',
218+
repeat: Infinity,
219+
}}
220+
/>
221+
222+
{/* Leading dot */}
223+
<motion.circle
224+
r={isAlive ? 5 : 4}
225+
fill={color}
226+
filter={isAlive ? 'url(#beat-glow)' : undefined}
227+
animate={{
228+
cx: dotKeyframes.xs,
229+
cy: dotKeyframes.ys,
230+
opacity: isAlive
231+
? [...Array(points.length).fill(1), 0]
232+
: points
233+
.map((_, i) => {
234+
const p = progress[i];
235+
if (p < 0.65) return 0.7;
236+
return 0.7 * (1 - (p - 0.65) / 0.35);
237+
})
238+
.concat([0]),
239+
}}
240+
transition={{
241+
cx: {
242+
duration: cycleDuration,
243+
times: dotKeyframes.times,
244+
ease: 'linear',
245+
repeat: Infinity,
246+
},
247+
cy: {
248+
duration: cycleDuration,
249+
times: dotKeyframes.times,
250+
ease: 'linear',
251+
repeat: Infinity,
252+
},
253+
opacity: {
254+
duration: cycleDuration,
255+
times: dotKeyframes.times,
256+
ease: 'linear',
257+
repeat: Infinity,
258+
},
259+
}}
260+
/>
261+
262+
{/* Pulsing ring radiating from dot (alive only) */}
263+
{isAlive && (
264+
<motion.circle
265+
r={5}
266+
fill="none"
267+
stroke={PRIMARY}
268+
strokeWidth={2}
269+
animate={{
270+
cx: dotKeyframes.xs,
271+
cy: dotKeyframes.ys,
272+
r: [5, 16],
273+
opacity: [0.6, 0],
274+
strokeWidth: [2, 0.5],
275+
}}
276+
transition={{
277+
cx: {
278+
duration: cycleDuration,
279+
times: dotKeyframes.times,
280+
ease: 'linear',
281+
repeat: Infinity,
282+
},
283+
cy: {
284+
duration: cycleDuration,
285+
times: dotKeyframes.times,
286+
ease: 'linear',
287+
repeat: Infinity,
288+
},
289+
r: {
290+
duration: 1,
291+
ease: 'easeOut',
292+
repeat: Infinity,
293+
},
294+
opacity: {
295+
duration: 1,
296+
ease: 'easeOut',
297+
repeat: Infinity,
298+
},
299+
strokeWidth: {
300+
duration: 1,
301+
ease: 'easeOut',
302+
repeat: Infinity,
303+
},
304+
}}
305+
/>
306+
)}
307+
</g>
308+
) : (
309+
<path d={FLAT_PATH} fill="none" stroke={DESTRUCTIVE} strokeWidth={1.5} strokeLinecap="round" opacity={0.4} />
310+
)}
311+
</svg>
312+
</div>
313+
);
314+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { CheckCircledIcon } from '@radix-ui/react-icons';
2+
3+
export type OnboardingStatus = 'pending' | 'ok' | 'fail' | 'error';
4+
5+
export const StatusIcon = ({ status }: { status: OnboardingStatus }) => {
6+
switch (status) {
7+
case 'pending':
8+
return (
9+
<span className="relative -mt-[1px] flex size-6 shrink-0 items-center justify-center">
10+
<span className="absolute inline-flex size-3 animate-ping rounded-full bg-green-400 opacity-75" />
11+
<span className="relative inline-flex size-3 rounded-full bg-green-500" />
12+
</span>
13+
);
14+
case 'ok':
15+
return (
16+
<span className="-mt-[1px] flex size-6 shrink-0 items-center justify-center text-green-600 dark:text-green-400">
17+
<CheckCircledIcon className="size-5" />
18+
</span>
19+
);
20+
case 'error':
21+
case 'fail':
22+
return (
23+
<span className="-mt-[1px] flex size-6 shrink-0 items-center justify-center">
24+
<span className="inline-flex size-3 rounded-full bg-destructive" />
25+
</span>
26+
);
27+
}
28+
};

0 commit comments

Comments
 (0)