|
| 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 | +}; |
0 commit comments