feat: remove ECG phase entirely
- 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
This commit is contained in:
+4
-14
@@ -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<Phase>('pmr')
|
||||
const cursorPositionRef = useRef<{ x: number; y: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
initModel()
|
||||
}, [])
|
||||
|
||||
const skipToLogin = () => setPhase('login')
|
||||
const skipToDashboard = () => setPhase('pmr')
|
||||
|
||||
return (
|
||||
<AccessibilityProvider>
|
||||
@@ -66,15 +64,7 @@ function App() {
|
||||
|
||||
{phase === 'boot' && (
|
||||
<BootSequence
|
||||
onComplete={() => setPhase('ecg')}
|
||||
onCursorPositionReady={(pos) => { cursorPositionRef.current = pos }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'ecg' && (
|
||||
<ECGAnimation
|
||||
onComplete={() => setPhase('login')}
|
||||
startPosition={cursorPositionRef.current}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -88,8 +78,8 @@ function App() {
|
||||
<LoginScreen onComplete={() => setPhase('pmr')} />
|
||||
)}
|
||||
|
||||
{(phase === 'boot' || phase === 'ecg') && (
|
||||
<SkipButton onSkip={skipToLogin} />
|
||||
{phase === 'boot' && (
|
||||
<SkipButton onSkip={skipToDashboard} />
|
||||
)}
|
||||
</div>
|
||||
</AccessibilityProvider>
|
||||
|
||||
@@ -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<HTMLSpanElement>(null)
|
||||
const cursorAnchorRef = useRef<HTMLSpanElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const cursorCapturedRef = useRef(false)
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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(
|
||||
<span
|
||||
key={segIdx}
|
||||
className={phase === 'holding' ? 'ecg-seed-dot animate-seed-pulse' : 'ecg-seed-dot'}
|
||||
className={phase === 'holding' ? 'boot-seed-dot animate-seed-pulse' : 'boot-seed-dot'}
|
||||
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
|
||||
>
|
||||
{visibleText}
|
||||
@@ -411,7 +395,7 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence
|
||||
spans.push(
|
||||
<span
|
||||
key={segIdx}
|
||||
className={seg.isSeedDot ? 'ecg-seed-dot' : undefined}
|
||||
className={seg.isSeedDot ? 'boot-seed-dot' : undefined}
|
||||
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
|
||||
>
|
||||
{seg.text}
|
||||
@@ -469,7 +453,6 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence
|
||||
{/* Cursor rendered outside fading wrapper — shrinks independently */}
|
||||
{cursorPos && phase !== 'done' && (
|
||||
<span
|
||||
ref={cursorRef}
|
||||
className="absolute animate-blink"
|
||||
style={{
|
||||
left: cursorPos.left,
|
||||
|
||||
@@ -1,686 +0,0 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface ECGAnimationProps {
|
||||
onComplete: () => 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<string, Point[]> = {
|
||||
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<string, { leftRatio: number; rightRatio: number }> {
|
||||
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<string, { leftRatio: number; rightRatio: number }>()
|
||||
|
||||
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<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const animationRef = useRef<number | null>(null)
|
||||
const startTsRef = useRef<number | null>(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 (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
className="fixed inset-0 z-50 bg-[#1E293B]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
className="fixed inset-0 z-50 bg-black"
|
||||
initial={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export type { ECGAnimationProps }
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export type Phase = 'boot' | 'ecg' | 'login' | 'pmr'
|
||||
export type Phase = 'boot' | 'login' | 'pmr'
|
||||
|
||||
Reference in New Issue
Block a user