From b266f1f149832f69cdb6191fdfac86e8165a23aa Mon Sep 17 00:00:00 2001 From: Andy Charlwood Date: Tue, 17 Feb 2026 03:26:17 +0000 Subject: [PATCH] feat: remove ECG phase entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deleted src/components/ECGAnimation.tsx (686 lines) - Removed 'ecg' from Phase type - Removed ECG import, rendering, and cursor position handoff from App.tsx - Cleaned up BootSequence: removed onCursorPositionReady prop, captureCursorPosition callback, cursorRef, and ECG-specific naming - Renamed ecgStartDelay → completionDelay, ecg-seed-dot → boot-seed-dot - Skip button now goes directly to dashboard ('pmr' phase) - Boot flow simplified: boot → login → pmr (no ECG intermediary) - Bundle size reduced ~8KB --- src/App.tsx | 20 +- src/components/BootSequence.tsx | 35 +- src/components/ECGAnimation.tsx | 686 -------------------------------- src/index.css | 2 +- src/types/index.ts | 2 +- 5 files changed, 16 insertions(+), 729 deletions(-) delete mode 100644 src/components/ECGAnimation.tsx diff --git a/src/App.tsx b/src/App.tsx index 40ff2a4..4502e9f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,6 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useEffect } from 'react' import type { Phase } from './types' import { BootSequence } from './components/BootSequence' -import { ECGAnimation } from './components/ECGAnimation' import { LoginScreen } from './components/LoginScreen' import { DashboardLayout } from './components/DashboardLayout' import { AccessibilityProvider } from './contexts/AccessibilityContext' @@ -46,13 +45,12 @@ function SkipButton({ onSkip }: { onSkip: () => void }) { function App() { const [phase, setPhase] = useState('pmr') - const cursorPositionRef = useRef<{ x: number; y: number } | null>(null) useEffect(() => { initModel() }, []) - const skipToLogin = () => setPhase('login') + const skipToDashboard = () => setPhase('pmr') return ( @@ -66,18 +64,10 @@ function App() { {phase === 'boot' && ( setPhase('ecg')} - onCursorPositionReady={(pos) => { cursorPositionRef.current = pos }} + onComplete={() => setPhase('login')} /> )} - {phase === 'ecg' && ( - setPhase('login')} - startPosition={cursorPositionRef.current} - /> - )} - {(phase === 'login' || phase === 'pmr') && ( @@ -88,8 +78,8 @@ function App() { setPhase('pmr')} /> )} - {(phase === 'boot' || phase === 'ecg') && ( - + {phase === 'boot' && ( + )} diff --git a/src/components/BootSequence.tsx b/src/components/BootSequence.tsx index 4729f97..4b6b138 100644 --- a/src/components/BootSequence.tsx +++ b/src/components/BootSequence.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react' +import { useEffect, useLayoutEffect, useState, useRef } from 'react' import { motion, AnimatePresence } from 'framer-motion' // ============================================================================= @@ -26,7 +26,7 @@ interface BootConfig { holdAfterComplete: number fadeOutDuration: number cursorShrinkDuration: number - ecgStartDelay: number + completionDelay: number } colors: { bright: string @@ -37,7 +37,6 @@ interface BootConfig { interface BootSequenceProps { onComplete: () => void - onCursorPositionReady?: (position: { x: number; y: number }) => void } interface TypedSegment { @@ -91,7 +90,7 @@ const BOOT_CONFIG: BootConfig = { holdAfterComplete: 1000, fadeOutDuration: 600, cursorShrinkDuration: 600, - ecgStartDelay: 0, + completionDelay: 0, }, colors: COLORS, } @@ -194,14 +193,12 @@ const TOTAL_CHARS = TYPED_LINES.reduce((sum, l) => sum + l.totalChars, 0) // Main Component // ============================================================================= -export function BootSequence({ onComplete, onCursorPositionReady }: BootSequenceProps) { +export function BootSequence({ onComplete }: BootSequenceProps) { const [typedCount, setTypedCount] = useState(0) const [phase, setPhase] = useState<'typing' | 'holding' | 'fading' | 'done'>('typing') const [isVisible, setIsVisible] = useState(true) - const cursorRef = useRef(null) const cursorAnchorRef = useRef(null) const containerRef = useRef(null) - const cursorCapturedRef = useRef(false) const timeoutRef = useRef | null>(null) const [cursorPos, setCursorPos] = useState<{ left: number; top: number } | null>(null) @@ -209,17 +206,6 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence ? window.matchMedia('(prefers-reduced-motion: reduce)').matches : false - // Capture cursor position for ECG handoff - const captureCursorPosition = useCallback(() => { - if (cursorRef.current && onCursorPositionReady && !cursorCapturedRef.current) { - const rect = cursorRef.current.getBoundingClientRect() - onCursorPositionReady({ - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2, - }) - cursorCapturedRef.current = true - } - }, [onCursorPositionReady]) // Typing engine — runs as a self-scheduling setTimeout chain useEffect(() => { @@ -267,18 +253,16 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence } }, [typedCount, phase, reducedMotion]) - // Hold phase: capture cursor, then start fading + // Hold phase: then start fading useEffect(() => { if (phase !== 'holding') return - captureCursorPosition() - const fadeTimer = setTimeout(() => { setPhase('fading') }, BOOT_CONFIG.timing.holdAfterComplete) return () => clearTimeout(fadeTimer) - }, [phase, captureCursorPosition]) + }, [phase]) // Fade phase: wait for animations to finish, then complete useEffect(() => { @@ -293,7 +277,7 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence setIsVisible(false) setPhase('done') onComplete() - }, longestFade + BOOT_CONFIG.timing.ecgStartDelay) + }, longestFade + BOOT_CONFIG.timing.completionDelay) return () => clearTimeout(completeTimer) }, [phase, onComplete]) @@ -354,7 +338,7 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence spans.push( {visibleText} @@ -411,7 +395,7 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence spans.push( {seg.text} @@ -469,7 +453,6 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence {/* Cursor rendered outside fading wrapper — shrinks independently */} {cursorPos && phase !== 'done' && ( void - startPosition?: { x: number; y: number } | null -} - -interface Point { - x: number - y: number -} - -interface Beat { - startTime: number - widthPx: number - amplitude: number - startWX: number -} - -interface LetterLayout { - char: string - startX: number - endX: number - baselineY: number -} - -// ============================================================================= -// Constants -// ============================================================================= - -const TRACE_SPEED = 350 // pixels per second -const HEAD_SCREEN_RATIO = 0.75 // Head stays at 75% of screen during ECG -const FLAT_GAP_SECONDS = 0.5 // Gap after last beat before text -const HOLD_SECONDS = 2 // Hold after text completes, before flatline/transition -const FLATLINE_DRAW_SECONDS = 0.3 // Time to draw flatline -const FADE_TO_BLACK_SECONDS = 0.2 // Canvas fade out -const BG_TRANSITION_SECONDS = 0.2 // Background color transition -const SKIP_TEXT = true // Skip text phase — transition directly after heartbeats - -// ============================================================================= -// Letter Definitions (ECG waveform shapes for each letter) -// ============================================================================= - -const ECG_LETTERS: Record = { - A: [ - { x: 0, y: 0 }, { x: 0.48, y: 1 }, { x: 0.53, y: 0.42 }, - { x: 0.6, y: 0.42 }, { x: 1, y: 0 }, - ], - N: [ - { x: 0, y: 0 }, { x: 0.12, y: 1 }, { x: 0.72, y: 0 }, - { x: 0.88, y: 1 }, { x: 1, y: 0 }, - ], - D: [ - { x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.5, y: 1 }, - { x: 0.85, y: 0.55 }, { x: 1, y: 0 }, - ], - R: [ - { x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.35, y: 1 }, - { x: 0.5, y: 0.6 }, { x: 0.55, y: 0.45 }, { x: 1, y: 0 }, - ], - E: [ - { x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.4, y: 1 }, - { x: 0.45, y: 0.5 }, { x: 0.65, y: 0.5 }, { x: 0.7, y: 0 }, - { x: 1, y: 0 }, - ], - W: [ - { x: 0, y: 0 }, { x: 0.05, y: 1 }, { x: 0.27, y: 0 }, - { x: 0.5, y: 0.65 }, { x: 0.73, y: 0 }, { x: 0.95, y: 1 }, - { x: 1, y: 0 }, - ], - C: [ - { x: 0, y: 0 }, { x: 0.08, y: 0.6 }, { x: 0.18, y: 1 }, - { x: 0.6, y: 1 }, { x: 0.8, y: 0.5 }, { x: 0.95, y: 0.1 }, - { x: 1, y: 0 }, - ], - H: [ - { x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.18, y: 0.5 }, - { x: 0.82, y: 0.5 }, { x: 0.9, y: 1 }, { x: 1, y: 0 }, - ], - L: [ - { x: 0, y: 0 }, { x: 0.12, y: 1 }, { x: 0.3, y: 1 }, - { x: 0.38, y: 0 }, { x: 1, y: 0 }, - ], - O: [ - { x: 0, y: 0 }, { x: 0.2, y: 0.85 }, { x: 0.35, y: 1 }, - { x: 0.65, y: 1 }, { x: 0.8, y: 0.85 }, { x: 1, y: 0 }, - ], -} - -const ECG_TEXT = 'ANDREW CHARLWOOD' - -// ============================================================================= -// Helper Functions -// ============================================================================= - -function generateHeartbeatPoints(amplitude: number): Point[] { - const points: Point[] = [] - const steps = 200 - for (let i = 0; i <= steps; i++) { - const t = i / steps - let y = 0 - - // P wave: gentle rounded bump - if (t >= 0.02 && t < 0.14) { - y = 0.06 * Math.sin(((t - 0.02) / 0.12) * Math.PI) - } - // PR segment flat (0.14–0.24) - // Q wave: small sharp dip - else if (t >= 0.24 && t < 0.28) { - y = -0.08 * Math.sin(((t - 0.24) / 0.04) * Math.PI) - } - // R wave: tall sharp spike - else if (t >= 0.28 && t < 0.36) { - y = 1.0 * Math.sin(((t - 0.28) / 0.08) * Math.PI) - } - // S wave: dip below baseline - else if (t >= 0.36 && t < 0.42) { - y = -0.2 * Math.sin(((t - 0.36) / 0.06) * Math.PI) - } - // ST segment flat (0.42–0.54) - // T wave: broad rounded bump - else if (t >= 0.54 && t < 0.78) { - y = 0.15 * Math.sin(((t - 0.54) / 0.24) * Math.PI) - } - - points.push({ x: t, y: y * amplitude }) - } - return points -} - -function interpolateLetterY(points: Point[], t: number): number { - if (t <= points[0].x) return points[0].y - if (t >= points[points.length - 1].x) return points[points.length - 1].y - for (let i = 0; i < points.length - 1; i++) { - if (t >= points[i].x && t <= points[i + 1].x) { - const seg = (t - points[i].x) / (points[i + 1].x - points[i].x) - return points[i].y + (points[i + 1].y - points[i].y) * seg - } - } - return 0 -} - -function getTextTotalWidth(letterWidth: number, letterGap: number, spaceWidth: number): number { - const chars = ECG_TEXT.replace(/ /g, '').length - const spaces = ECG_TEXT.split(' ').length - 1 - return chars * (letterWidth + letterGap) - letterGap + spaces * spaceWidth -} - -function layoutText( - offsetX: number, - letterWidth: number, - letterGap: number, - spaceWidth: number, - baselineY: number, - rowGap: number, - maxRowWidth: number -): LetterLayout[] { - const words = ECG_TEXT.split(' ') - const layout: LetterLayout[] = [] - let cursor = offsetX - let currentBaselineY = baselineY - let rowWidth = 0 - - for (let w = 0; w < words.length; w++) { - const word = words[w] - const wordWidth = word.length * (letterWidth + letterGap) - letterGap - - if (w > 0) { - const withSpace = rowWidth + spaceWidth + wordWidth - if (maxRowWidth > 0 && withSpace > maxRowWidth) { - // Wrap to next row - cursor += spaceWidth - currentBaselineY += rowGap - rowWidth = 0 - } else { - cursor += spaceWidth - rowWidth += spaceWidth - } - } - - for (const char of word) { - layout.push({ - char, - startX: cursor, - endX: cursor + letterWidth, - baselineY: currentBaselineY, - }) - cursor += letterWidth + letterGap - rowWidth += letterWidth + letterGap - } - rowWidth -= letterGap - } - - return layout -} - -/** Measure where each character's rendered stroke crosses the baseline. - * Returns left/right ratios (0–1) within the character cell. */ -function measureCharBaselineEdges( - font: string, - lineWidth: number, - charWidth: number -): Map { - const padding = Math.ceil(charWidth) - const width = Math.ceil(charWidth + padding * 2) - const height = Math.ceil(charWidth * 3) - const baseline = Math.ceil(height * 0.6) - - const canvas = document.createElement('canvas') - canvas.width = width - canvas.height = height - const ctx = canvas.getContext('2d')! - - const centerX = width / 2 - const halfChar = charWidth / 2 - const uniqueChars = [...new Set(ECG_TEXT.replace(/ /g, ''))] - const results = new Map() - - for (const char of uniqueChars) { - ctx.clearRect(0, 0, width, height) - ctx.font = font - ctx.textAlign = 'center' - ctx.textBaseline = 'alphabetic' - ctx.strokeStyle = '#fff' - ctx.lineWidth = lineWidth - ctx.strokeText(char, centerX, baseline) - - // Scan ±2 rows around baseline for stroke pixels - const y0 = Math.max(0, baseline - 2) - const scanH = 5 - const data = ctx.getImageData(0, y0, width, scanH).data - - let minX = width - let maxX = 0 - for (let r = 0; r < scanH; r++) { - for (let x = 0; x < width; x++) { - if (data[(r * width + x) * 4 + 3] > 10) { - if (x < minX) minX = x - if (x > maxX) maxX = x - } - } - } - - const leftEdge = centerX - halfChar - if (minX <= maxX) { - results.set(char, { - leftRatio: Math.max(0, (minX - leftEdge) / charWidth), - rightRatio: Math.min(1, (maxX - leftEdge) / charWidth), - }) - } else { - // Fallback: full width - results.set(char, { leftRatio: 0, rightRatio: 1 }) - } - } - - return results -} - -// ============================================================================= -// Main Component -// ============================================================================= - -export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) { - const canvasRef = useRef(null) - const containerRef = useRef(null) - const animationRef = useRef(null) - const startTsRef = useRef(null) - const bgTransitionedRef = useRef(false) - const completedRef = useRef(false) - - const lineColor = '#00ff41' - const loginBgColor = '#1E293B' - - const reducedMotion = typeof window !== 'undefined' - ? window.matchMedia('(prefers-reduced-motion: reduce)').matches - : false - - const finishAnimation = useCallback(() => { - if (completedRef.current) return - completedRef.current = true - if (animationRef.current) { - cancelAnimationFrame(animationRef.current) - } - onComplete() - }, [onComplete]) - - useEffect(() => { - // Reduced motion: skip to end immediately - if (reducedMotion) { - const timer = setTimeout(finishAnimation, 100) - return () => clearTimeout(timer) - } - - const canvas = canvasRef.current - const container = containerRef.current - if (!canvas || !container) return - - const ctx = canvas.getContext('2d') - if (!ctx) return - - // Setup canvas dimensions - const vw = window.innerWidth - const vh = window.innerHeight - const dpr = window.devicePixelRatio || 1 - - canvas.width = vw * dpr - canvas.height = vh * dpr - ctx.scale(dpr, dpr) - - // Scale factors based on viewport - const scale = Math.min(1.2, Math.max(0.45, vw / 1200)) - const LETTER_W = 72 * scale - const LETTER_G = 10 * scale - const SPACE_W = 30 * scale - - // Layout parameters - const baselineY = vh * 0.5 - const ecgMaxDefl = vh * 0.25 - // Cap text deflection to letter width so font doesn't overflow cells on mobile - const textMaxDefl = Math.min(vh * 0.08, LETTER_W * 1.15) - - // Calculate start offset from cursor position if provided - const startOffsetX = startPosition ? startPosition.x : 0 - - // Build beats with cursor offset - const beats: Beat[] = [ - { startTime: 0.6, widthPx: 150 * scale, amplitude: 0.3, startWX: 0 }, - { startTime: 1.4, widthPx: 190 * scale, amplitude: 0.55, startWX: 0 }, - { startTime: 2.3, widthPx: 230 * scale, amplitude: 0.85, startWX: 0 }, - { startTime: 3.2, widthPx: 270 * scale, amplitude: 1.0, startWX: 0 }, - ] - - // Apply start offset to all beats - beats.forEach((b) => { - b.startWX = b.startTime * TRACE_SPEED + startOffsetX - }) - - // Calculate text layout — single line, viewport scrolls through - const lastBeat = beats[beats.length - 1] - const lastBeatEndWX = lastBeat.startWX + lastBeat.widthPx - const textStartWX = lastBeatEndWX + FLAT_GAP_SECONDS * TRACE_SPEED - const totalTextW = getTextTotalWidth(LETTER_W, LETTER_G, SPACE_W) - const textEndWX = SKIP_TEXT ? textStartWX : textStartWX + totalTextW - const textLayout = layoutText( - textStartWX, LETTER_W, LETTER_G, SPACE_W, - baselineY, 0, Infinity - ) - - // Calculate timing phases - const textEndTime = (textEndWX - startOffsetX) / TRACE_SPEED - const holdEndTime = textEndTime - const flatlineEndTime = textEndTime + FLATLINE_DRAW_SECONDS - const fadeStartTime = flatlineEndTime + (SKIP_TEXT ? 0.3 : HOLD_SECONDS) - const fadeEndTime = fadeStartTime + FADE_TO_BLACK_SECONDS - const bgTransitionEndTime = fadeEndTime + BG_TRANSITION_SECONDS - const exitEndTime = bgTransitionEndTime - - // Get Y at a given world X position - const getYAtX = (wx: number): number => { - // Check beats - for (const b of beats) { - if (wx >= b.startWX && wx <= b.startWX + b.widthPx) { - const prog = (wx - b.startWX) / b.widthPx - const pts = generateHeartbeatPoints(b.amplitude) - const idx = Math.min(Math.floor(prog * (pts.length - 1)), pts.length - 1) - return baselineY - pts[idx].y * ecgMaxDefl - } - } - - // Check text letters - for (const item of textLayout) { - if (wx >= item.startX && wx <= item.endX) { - const t = (wx - item.startX) / (item.endX - item.startX) - const ld = ECG_LETTERS[item.char] - if (ld) return baselineY - interpolateLetterY(ld, t) * textMaxDefl - } - } - - return baselineY - } - - // Text rendering properties (drawn directly each frame — avoids offscreen canvas DPR/size issues on mobile) - const textFont = `bold ${Math.round(textMaxDefl / 0.715)}px Arial, Helvetica, sans-serif` - const textLineWidth = 2 * scale - // Measure where each character's stroke crosses the baseline (for connector lines) - const charEdges = measureCharBaselineEdges(textFont, textLineWidth, LETTER_W) - - // Animation loop - const animate = (timestamp: number) => { - if (!startTsRef.current) startTsRef.current = timestamp - const elapsed = (timestamp - startTsRef.current) / 1000 - - // Check for animation completion - if (elapsed >= exitEndTime) { - finishAnimation() - return - } - - // Clear canvas - ctx.clearRect(0, 0, vw, vh) - - // Calculate current head position - let headWX = elapsed * TRACE_SPEED + startOffsetX - const isFlatlinePhase = elapsed >= holdEndTime && elapsed < fadeStartTime - const isFadePhase = elapsed >= fadeStartTime && elapsed < fadeEndTime - const isBgTransitionPhase = elapsed >= fadeEndTime - - if (elapsed >= textEndTime) { - headWX = textEndWX - } - - // Calculate viewport and head screen position - const headSXEcg = HEAD_SCREEN_RATIO * vw - - // Simple continuous scrolling - viewport follows head when it exceeds 75% of screen - const viewOff = Math.max(0, headWX - headSXEcg) - const headSX = headWX - viewOff - - // Calculate fade alpha - let fadeAlpha = 1 - if (isFadePhase) { - fadeAlpha = Math.max(0, 1 - (elapsed - flatlineEndTime) / FADE_TO_BLACK_SECONDS) - } else if (isBgTransitionPhase) { - fadeAlpha = 0 - } - - // Background color transition — delayed until after HOLD - if (!bgTransitionedRef.current && elapsed >= fadeStartTime) { - bgTransitionedRef.current = true - container.style.transition = `background ${BG_TRANSITION_SECONDS * 1000}ms ease-out` - container.style.background = loginBgColor - } - - ctx.save() - ctx.globalAlpha = fadeAlpha - - // Draw ECG trace - always draw from start for continuity - // Performance is fine since we're only drawing ~1000 pixels per frame - const traceStart = Math.floor(startOffsetX) - const traceEnd = Math.min( - Math.ceil(elapsed >= textEndTime ? textEndWX : headWX), - Math.ceil(viewOff + vw), - Math.ceil(textStartWX) // Stop trace before text — only the dot draws through letters - ) - - if (traceEnd > traceStart) { - // Outer glow layer - ctx.beginPath() - ctx.strokeStyle = 'rgba(0, 255, 65, 0.25)' - ctx.lineWidth = 6 * scale - ctx.lineJoin = 'round' - ctx.lineCap = 'round' - ctx.shadowColor = lineColor - ctx.shadowBlur = 14 * scale - - for (let wx = traceStart; wx <= traceEnd; wx++) { - const sx = wx - viewOff - const sy = getYAtX(wx) - if (wx === traceStart) ctx.moveTo(sx, sy) - else ctx.lineTo(sx, sy) - } - ctx.stroke() - - // Main trace layer - ctx.beginPath() - ctx.strokeStyle = lineColor - ctx.lineWidth = 2 * scale - ctx.shadowBlur = 4 * scale - - for (let wx = traceStart; wx <= traceEnd; wx++) { - const sx = wx - viewOff - const sy = getYAtX(wx) - if (wx === traceStart) ctx.moveTo(sx, sy) - else ctx.lineTo(sx, sy) - } - ctx.stroke() - } - - // Draw flatline after text — during flatline draw phase and fade phase - if (isFlatlinePhase || isFadePhase) { - const flatlineProgress = Math.min(1, (elapsed - holdEndTime) / FLATLINE_DRAW_SECONDS) - // Use actual head screen position, not finalHeadSX - const flatlineStartSX = headSX - const flatlineEndSX = flatlineStartSX + flatlineProgress * (vw - flatlineStartSX + 50) - ctx.beginPath() - ctx.strokeStyle = lineColor - ctx.lineWidth = 2 * scale - ctx.shadowBlur = 8 * scale - ctx.shadowColor = lineColor - ctx.moveTo(flatlineStartSX, baselineY) - ctx.lineTo(flatlineEndSX, baselineY) - ctx.stroke() - } - - // Text reveal — draw letters directly each frame - const isTextPhase = headWX > textStartWX - const isTextDone = elapsed >= textEndTime - - if (isTextPhase && !SKIP_TEXT) { - ctx.save() - - // Clip for progressive reveal - const revealX = isTextDone ? vw : (headWX - viewOff) - ctx.beginPath() - ctx.rect(0, 0, revealX, vh) - ctx.clip() - - // Common text properties - ctx.font = textFont - ctx.textAlign = 'center' - ctx.textBaseline = 'alphabetic' - ctx.lineJoin = 'round' - ctx.lineCap = 'round' - - // Pass 1: Outer glow layer (matches trace glow) - ctx.strokeStyle = 'rgba(0, 255, 65, 0.25)' - ctx.lineWidth = 6 * scale - ctx.shadowColor = lineColor - ctx.shadowBlur = 14 * scale - - for (const item of textLayout) { - const screenX = (item.startX + item.endX) / 2 - viewOff - if (screenX + LETTER_W < 0 || screenX - LETTER_W > vw) continue - ctx.strokeText(item.char, screenX, baselineY) - } - for (let i = 0; i < textLayout.length - 1; i++) { - const curr = textLayout[i] - const next = textLayout[i + 1] - const currEdge = charEdges.get(curr.char) - const nextEdge = charEdges.get(next.char) - if (!currEdge || !nextEdge) continue - const fromX = curr.startX + currEdge.rightRatio * LETTER_W - viewOff - const toX = next.startX + nextEdge.leftRatio * LETTER_W - viewOff - if (toX < 0 || fromX > vw) continue - ctx.beginPath() - ctx.moveTo(fromX, baselineY) - ctx.lineTo(toX, baselineY) - ctx.stroke() - } - - // Connect last character's right stroke edge to cell edge (glow layer) - { - const lastChar = textLayout[textLayout.length - 1] - const lastEdge = charEdges.get(lastChar.char) - if (lastEdge) { - const fromX = lastChar.startX + lastEdge.rightRatio * LETTER_W - viewOff - const toX = lastChar.endX - viewOff - if (fromX < vw && toX > 0) { - ctx.beginPath() - ctx.moveTo(fromX, baselineY) - ctx.lineTo(toX, baselineY) - ctx.stroke() - } - } - } - - // Pass 2: Main line layer (matches trace line) - ctx.strokeStyle = lineColor - ctx.lineWidth = textLineWidth - ctx.shadowBlur = 4 * scale - - for (const item of textLayout) { - const screenX = (item.startX + item.endX) / 2 - viewOff - if (screenX + LETTER_W < 0 || screenX - LETTER_W > vw) continue - ctx.strokeText(item.char, screenX, baselineY) - } - for (let i = 0; i < textLayout.length - 1; i++) { - const curr = textLayout[i] - const next = textLayout[i + 1] - const currEdge = charEdges.get(curr.char) - const nextEdge = charEdges.get(next.char) - if (!currEdge || !nextEdge) continue - const fromX = curr.startX + currEdge.rightRatio * LETTER_W - viewOff - const toX = next.startX + nextEdge.leftRatio * LETTER_W - viewOff - if (toX < 0 || fromX > vw) continue - ctx.beginPath() - ctx.moveTo(fromX, baselineY) - ctx.lineTo(toX, baselineY) - ctx.stroke() - } - - // Connect last character's right stroke edge to its cell edge (bridges gap to flatline) - const lastChar = textLayout[textLayout.length - 1] - const lastEdge = charEdges.get(lastChar.char) - if (lastEdge) { - const fromX = lastChar.startX + lastEdge.rightRatio * LETTER_W - viewOff - const toX = lastChar.endX - viewOff - if (fromX < vw && toX > 0) { - ctx.beginPath() - ctx.moveTo(fromX, baselineY) - ctx.lineTo(toX, baselineY) - ctx.stroke() - } - } - - ctx.restore() - } - - // Draw dot/head - ctx.globalAlpha = fadeAlpha - ctx.shadowBlur = 0 - - if (headSX >= -20 && headSX <= vw + 20 && elapsed < flatlineEndTime) { - const headY = isFlatlinePhase ? baselineY : getYAtX(headWX) - - // Glow gradient - const grad = ctx.createRadialGradient(headSX, headY, 0, headSX, headY, 20 * scale) - grad.addColorStop(0, 'rgba(255,255,255,0.8)') - grad.addColorStop(0.3, 'rgba(0,255,65,0.6)') - grad.addColorStop(1, 'rgba(0,255,65,0)') - ctx.fillStyle = grad - ctx.beginPath() - ctx.arc(headSX, headY, 20 * scale, 0, Math.PI * 2) - ctx.fill() - - // Core dot - ctx.fillStyle = lineColor - ctx.beginPath() - ctx.arc(headSX, headY, 3 * scale, 0, Math.PI * 2) - ctx.fill() - } - - ctx.restore() - - // Scanlines - ctx.fillStyle = 'rgba(0, 0, 0, 0.05)' - for (let sly = 0; sly < vh; sly += 4) { - ctx.fillRect(0, sly + 2, vw, 2) - } - - // Vignette - const vig = ctx.createRadialGradient(vw / 2, vh / 2, vh * 0.3, vw / 2, vh / 2, vh * 0.85) - vig.addColorStop(0, 'rgba(0,0,0,0)') - vig.addColorStop(1, 'rgba(0,0,0,0.4)') - ctx.fillStyle = vig - ctx.fillRect(0, 0, vw, vh) - - animationRef.current = requestAnimationFrame(animate) - } - - animationRef.current = requestAnimationFrame(animate) - - return () => { - if (animationRef.current) { - cancelAnimationFrame(animationRef.current) - } - } - }, [startPosition, finishAnimation, reducedMotion]) - - // Reduced motion fallback - if (reducedMotion) { - return ( - - ) - } - - return ( - - - - - - ) -} - -export type { ECGAnimationProps } diff --git a/src/index.css b/src/index.css index ea7f2d9..11098bb 100644 --- a/src/index.css +++ b/src/index.css @@ -77,7 +77,7 @@ } :root { - /* Original design system tokens (for boot/ECG phases) */ + /* Original design system tokens (for boot phase) */ --bg: #FFFFFF; --text: #334155; --heading: #0F172A; diff --git a/src/types/index.ts b/src/types/index.ts index f1b3af1..1318f19 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1 +1 @@ -export type Phase = 'boot' | 'ecg' | 'login' | 'pmr' +export type Phase = 'boot' | 'login' | 'pmr'