Task 1b: Rebuild boot sequence and ECG animation
- Refactored BootSequence to config-driven architecture with type-safe line components - Added cursor position capture and smooth cursor-to-dot morph transition - Rebuilt ECGAnimation with mask-based text reveal technique - Implemented connector lines between letters with per-character profiles - ECG trace now starts from cursor position (no teleport) - Added prefers-reduced-motion support for both phases - Updated App.tsx to pass cursor position between components Quality checks: typecheck ✓, lint ✓, build ✓
This commit is contained in:
+27
-4
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { Phase } from './types'
|
||||
import { BootSequence } from './components/BootSequence'
|
||||
import { ECGAnimation } from './components/ECGAnimation'
|
||||
@@ -8,20 +8,43 @@ import { AccessibilityProvider } from './contexts/AccessibilityContext'
|
||||
|
||||
function App() {
|
||||
const [phase, setPhase] = useState<Phase>('boot')
|
||||
const [cursorPosition, setCursorPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
const handleBootComplete = useCallback(() => {
|
||||
setPhase('ecg')
|
||||
}, [])
|
||||
|
||||
const handleCursorPositionReady = useCallback((position: { x: number; y: number }) => {
|
||||
setCursorPosition(position)
|
||||
}, [])
|
||||
|
||||
const handleECGComplete = useCallback(() => {
|
||||
setPhase('login')
|
||||
}, [])
|
||||
|
||||
const handleLoginComplete = useCallback(() => {
|
||||
setPhase('pmr')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AccessibilityProvider>
|
||||
<div className="min-h-screen bg-black">
|
||||
{phase === 'boot' && (
|
||||
<BootSequence onComplete={() => setPhase('ecg')} />
|
||||
<BootSequence
|
||||
onComplete={handleBootComplete}
|
||||
onCursorPositionReady={handleCursorPositionReady}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'ecg' && (
|
||||
<ECGAnimation onComplete={() => setPhase('login')} />
|
||||
<ECGAnimation
|
||||
onComplete={handleECGComplete}
|
||||
startPosition={cursorPosition}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'login' && (
|
||||
<LoginScreen onComplete={() => setPhase('pmr')} />
|
||||
<LoginScreen onComplete={handleLoginComplete} />
|
||||
)}
|
||||
|
||||
{phase === 'pmr' && <PMRInterface />}
|
||||
|
||||
+295
-52
@@ -1,97 +1,340 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
type BootLineType = 'header' | 'status' | 'separator' | 'field' | 'module' | 'ready'
|
||||
|
||||
type BootLineStyle = 'bright' | 'dim' | 'cyan'
|
||||
|
||||
interface BootLine {
|
||||
html: string
|
||||
delay: number
|
||||
type: BootLineType
|
||||
text?: string
|
||||
label?: string
|
||||
value?: string
|
||||
style?: BootLineStyle
|
||||
}
|
||||
|
||||
const bootLines: BootLine[] = [
|
||||
|
||||
{ html: '<span class="text-[#00ff41] font-bold">CLINICAL TERMINAL v3.2.1</span>', delay: 0 },
|
||||
{ html: '<span class="text-[#3a6b45]">Initialising pharmacist profile...</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#00e5ff]">SYSTEM </span><span class="text-[#00ff41]">NHS Norfolk & Waveney ICB</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#00e5ff]">USER </span><span class="text-[#00ff41]">Andy Charlwood</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#00e5ff]">ROLE </span><span class="text-[#00ff41]">Deputy Head of Population Health & Data Analysis</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#00e5ff]">LOCATION </span><span class="text-[#00ff41]">Norwich, UK</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#3a6b45]">Loading modules...</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">pharmacist_core.sys</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">population_health.mod</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">data_analytics.eng</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
|
||||
{ html: '<span class="text-[#00ff41] font-bold">> READY — Rendering CV..<span class="ecg-seed-dot" id="ecg-seed-dot">.</span></span>', delay: 220 },
|
||||
]
|
||||
|
||||
// Precompute cumulative delays so the first render can use them
|
||||
const bootLineDelays: number[] = (() => {
|
||||
const delays: number[] = []
|
||||
let total = 0
|
||||
bootLines.forEach((line) => {
|
||||
delays.push(total)
|
||||
total += line.delay
|
||||
})
|
||||
return delays
|
||||
})()
|
||||
interface BootConfig {
|
||||
header: string
|
||||
lines: BootLine[]
|
||||
timing: {
|
||||
lineDelay: number
|
||||
cursorBlinkInterval: number
|
||||
holdAfterComplete: number
|
||||
fadeOutDuration: number
|
||||
}
|
||||
colors: {
|
||||
bright: string
|
||||
dim: string
|
||||
cyan: string
|
||||
}
|
||||
}
|
||||
|
||||
interface BootSequenceProps {
|
||||
onComplete: () => void
|
||||
onCursorPositionReady?: (position: { x: number; y: number }) => void
|
||||
}
|
||||
|
||||
export function BootSequence({ onComplete }: BootSequenceProps) {
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
useEffect(() => {
|
||||
const totalBootTime = bootLines.reduce((sum, l) => sum + l.delay, 0)
|
||||
const fadeStartTime = totalBootTime + 400
|
||||
// =============================================================================
|
||||
// Configuration
|
||||
// =============================================================================
|
||||
|
||||
const BOOT_CONFIG: BootConfig = {
|
||||
header: 'CLINICAL TERMINAL v3.2.1',
|
||||
lines: [
|
||||
{ type: 'status', text: 'Initialising pharmacist profile...', style: 'dim' },
|
||||
{ type: 'separator', text: '---', style: 'dim' },
|
||||
{ type: 'field', label: 'SYSTEM', value: 'NHS Norfolk & Waveney ICB', style: 'cyan' },
|
||||
{ type: 'field', label: 'USER', value: 'Andy Charlwood', style: 'bright' },
|
||||
{ type: 'field', label: 'ROLE', value: 'Deputy Head of Population Health & Data Analysis', style: 'bright' },
|
||||
{ type: 'field', label: 'LOCATION', value: 'Norwich, UK', style: 'bright' },
|
||||
{ type: 'separator', text: '---', style: 'dim' },
|
||||
{ type: 'status', text: 'Loading modules...', style: 'dim' },
|
||||
{ type: 'module', text: 'pharmacist_core.sys', style: 'dim' },
|
||||
{ type: 'module', text: 'population_health.mod', style: 'dim' },
|
||||
{ type: 'module', text: 'data_analytics.eng', style: 'dim' },
|
||||
{ type: 'separator', text: '---', style: 'dim' },
|
||||
{ type: 'ready', text: 'READY — Rendering CV..', style: 'bright' },
|
||||
],
|
||||
timing: {
|
||||
lineDelay: 220,
|
||||
cursorBlinkInterval: 530,
|
||||
holdAfterComplete: 400,
|
||||
fadeOutDuration: 800,
|
||||
},
|
||||
colors: {
|
||||
bright: '#00ff41',
|
||||
dim: '#3a6b45',
|
||||
cyan: '#00e5ff',
|
||||
},
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
function getCumulativeDelay(lineIndex: number): number {
|
||||
return lineIndex * BOOT_CONFIG.timing.lineDelay
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Line Components
|
||||
// =============================================================================
|
||||
|
||||
function BootLineHeader({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed">
|
||||
<span
|
||||
className="font-bold"
|
||||
style={{ color: BOOT_CONFIG.colors.bright }}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BootLineStatus({ line }: { line: BootLine }) {
|
||||
const color = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.dim
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed" style={{ color }}>
|
||||
{line.text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BootLineSeparator({ line }: { line: BootLine }) {
|
||||
const color = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.dim
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed" style={{ color }}>
|
||||
{line.text || '---'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BootLineField({ line }: { line: BootLine }) {
|
||||
const valueColor = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.bright
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed">
|
||||
<span style={{ color: BOOT_CONFIG.colors.cyan }}>
|
||||
{(line.label || '').padEnd(9)}
|
||||
</span>
|
||||
<span style={{ color: valueColor }}>{line.value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BootLineModule({ line }: { line: BootLine }) {
|
||||
const textColor = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.dim
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed">
|
||||
<span className="font-bold" style={{ color: BOOT_CONFIG.colors.bright }}>
|
||||
[OK]
|
||||
</span>{' '}
|
||||
<span style={{ color: textColor }}>{line.text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BootLineReady({ line }: { line: BootLine }) {
|
||||
const color = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.bright
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed">
|
||||
<span className="font-bold" style={{ color }}>
|
||||
> {line.text}
|
||||
<span className="ecg-seed-dot">.</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BootLineRenderer({ line }: { line: BootLine }) {
|
||||
switch (line.type) {
|
||||
case 'header':
|
||||
return <BootLineHeader text={line.text || ''} />
|
||||
case 'status':
|
||||
return <BootLineStatus line={line} />
|
||||
case 'separator':
|
||||
return <BootLineSeparator line={line} />
|
||||
case 'field':
|
||||
return <BootLineField line={line} />
|
||||
case 'module':
|
||||
return <BootLineModule line={line} />
|
||||
case 'ready':
|
||||
return <BootLineReady line={line} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export function BootSequence({ onComplete, onCursorPositionReady }: BootSequenceProps) {
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
const [showCursor, setShowCursor] = useState(false)
|
||||
const [cursorCaptured, setCursorCaptured] = useState(false)
|
||||
const [isMorphing, setIsMorphing] = useState(false)
|
||||
const cursorRef = useRef<HTMLDivElement>(null)
|
||||
const reducedMotion = typeof window !== 'undefined'
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
: false
|
||||
|
||||
// Calculate total boot time
|
||||
const totalBootTime = BOOT_CONFIG.lines.length * BOOT_CONFIG.timing.lineDelay
|
||||
const fadeStartTime = totalBootTime + BOOT_CONFIG.timing.holdAfterComplete
|
||||
|
||||
// Capture cursor position when boot completes
|
||||
const captureCursorPosition = useCallback(() => {
|
||||
if (cursorRef.current && onCursorPositionReady && !cursorCaptured) {
|
||||
const rect = cursorRef.current.getBoundingClientRect()
|
||||
const position = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
}
|
||||
onCursorPositionReady(position)
|
||||
setCursorCaptured(true)
|
||||
}
|
||||
}, [onCursorPositionReady, cursorCaptured])
|
||||
|
||||
// Handle completion sequence
|
||||
useEffect(() => {
|
||||
if (reducedMotion) {
|
||||
// Reduced motion: show everything instantly, then complete
|
||||
const timer = setTimeout(onComplete, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
|
||||
// Show cursor after all lines are rendered
|
||||
const cursorTimer = setTimeout(() => {
|
||||
setShowCursor(true)
|
||||
}, totalBootTime)
|
||||
|
||||
// Capture cursor position and start morph
|
||||
const morphTimer = setTimeout(() => {
|
||||
captureCursorPosition()
|
||||
setIsMorphing(true)
|
||||
}, fadeStartTime - 100)
|
||||
|
||||
// Fade out and complete
|
||||
const fadeTimer = setTimeout(() => {
|
||||
setIsVisible(false)
|
||||
}, fadeStartTime)
|
||||
|
||||
const completeTimer = setTimeout(() => {
|
||||
onComplete()
|
||||
}, fadeStartTime+2000)
|
||||
}, fadeStartTime + BOOT_CONFIG.timing.fadeOutDuration)
|
||||
|
||||
return () => {
|
||||
clearTimeout(cursorTimer)
|
||||
clearTimeout(morphTimer)
|
||||
clearTimeout(fadeTimer)
|
||||
clearTimeout(completeTimer)
|
||||
}
|
||||
}, [onComplete])
|
||||
|
||||
}, [onComplete, totalBootTime, fadeStartTime, captureCursorPosition, reducedMotion])
|
||||
|
||||
// Reduced motion: instant render
|
||||
if (reducedMotion) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col justify-center bg-black p-10 font-mono text-sm overflow-hidden">
|
||||
<div className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2">
|
||||
<BootLineHeader text={BOOT_CONFIG.header} />
|
||||
{BOOT_CONFIG.lines.map((line, index) => (
|
||||
<BootLineRenderer key={index} line={line} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex flex-col justify-center bg-black p-10 font-mono text-sm overflow-hidden"
|
||||
initial={{ opacity: 1 }}
|
||||
exit={{ opacity: 1 }}
|
||||
transition={{ delay: 2, duration: 0.8, ease: 'easeOut' }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
|
||||
>
|
||||
<div className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2">
|
||||
{bootLines.map((line, index) => (
|
||||
{/* CRT Scanlines */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: `repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0.15) 0px,
|
||||
transparent 1px,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.15) 3px
|
||||
)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2 relative z-10">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||
>
|
||||
<BootLineHeader text={BOOT_CONFIG.header} />
|
||||
</motion.div>
|
||||
|
||||
{/* Lines */}
|
||||
{BOOT_CONFIG.lines.map((line, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="whitespace-nowrap leading-relaxed"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
delay: (bootLineDelays[index] ?? 0) / 1000,
|
||||
delay: getCumulativeDelay(index) / 1000,
|
||||
duration: 0.4,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: line.html }}
|
||||
/>
|
||||
>
|
||||
<BootLineRenderer line={line} />
|
||||
</motion.div>
|
||||
))}
|
||||
<motion.div
|
||||
className="inline-block w-2 h-4 bg-[#00ff41] ml-1 animate-blink"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 2 + (bootLineDelays[bootLineDelays.length + 1] ?? 0) / 1000 }}
|
||||
/>
|
||||
|
||||
{/* Blinking Cursor */}
|
||||
{showCursor && (
|
||||
<motion.div
|
||||
ref={cursorRef}
|
||||
className="inline-block ml-1"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: isMorphing ? 0 : 1,
|
||||
scaleX: isMorphing ? 0 : 1,
|
||||
width: isMorphing ? 0 : 8,
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
style={{
|
||||
height: 16,
|
||||
backgroundColor: BOOT_CONFIG.colors.bright,
|
||||
animation: isMorphing ? undefined : 'blink 530ms infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CSS for blink animation */}
|
||||
<style>{`
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export type { BootConfig, BootLine, BootLineType }
|
||||
export { BOOT_CONFIG }
|
||||
|
||||
+283
-83
@@ -1,8 +1,13 @@
|
||||
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 {
|
||||
@@ -21,35 +26,113 @@ interface LetterLayout {
|
||||
char: string
|
||||
startX: number
|
||||
endX: number
|
||||
centerX: number
|
||||
startConnector: number
|
||||
endConnector: number
|
||||
}
|
||||
|
||||
interface ConnectorProfile {
|
||||
leftInset: number
|
||||
rightInset: 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 = 0.3 // Hold after text completes
|
||||
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 CONNECTOR_PROFILES: Record<string, ConnectorProfile> = {
|
||||
C: { leftInset: 20, rightInset: 8 },
|
||||
O: { leftInset: 17, rightInset: 7 },
|
||||
D: { leftInset: 0, rightInset: 13 },
|
||||
L: { leftInset: 5, rightInset: 0 },
|
||||
E: { leftInset: 5, rightInset: 0 },
|
||||
}
|
||||
|
||||
const DEFAULT_PROFILE: ConnectorProfile = { leftInset: 0, rightInset: 0 }
|
||||
|
||||
const BASE_LEFT_INSET = 9
|
||||
const BASE_RIGHT_INSET = 0
|
||||
|
||||
// =============================================================================
|
||||
// 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}],
|
||||
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
|
||||
if (t >= 0.05 && t < 0.2) { y = 0.12 * Math.sin(((t - 0.05) / 0.15) * Math.PI) }
|
||||
else if (t >= 0.25 && t < 0.32) { y = -0.1 * Math.sin(((t - 0.25) / 0.07) * Math.PI) }
|
||||
else if (t >= 0.32 && t < 0.42) { y = 1.0 * Math.sin(((t - 0.32) / 0.1) * Math.PI) }
|
||||
else if (t >= 0.42 && t < 0.5) { y = -0.25 * Math.sin(((t - 0.42) / 0.08) * Math.PI) }
|
||||
else if (t >= 0.55 && t < 0.75) { y = 0.2 * Math.sin(((t - 0.55) / 0.2) * Math.PI) }
|
||||
if (t >= 0.05 && t < 0.2) {
|
||||
y = 0.12 * Math.sin(((t - 0.05) / 0.15) * Math.PI)
|
||||
} else if (t >= 0.25 && t < 0.32) {
|
||||
y = -0.1 * Math.sin(((t - 0.25) / 0.07) * Math.PI)
|
||||
} else if (t >= 0.32 && t < 0.42) {
|
||||
y = 1.0 * Math.sin(((t - 0.32) / 0.1) * Math.PI)
|
||||
} else if (t >= 0.42 && t < 0.5) {
|
||||
y = -0.25 * Math.sin(((t - 0.42) / 0.08) * Math.PI)
|
||||
} else if (t >= 0.55 && t < 0.75) {
|
||||
y = 0.2 * Math.sin(((t - 0.55) / 0.2) * Math.PI)
|
||||
}
|
||||
points.push({ x: t, y: y * amplitude })
|
||||
}
|
||||
return points
|
||||
@@ -67,25 +150,47 @@ function interpolateLetterY(points: Point[], t: number): number {
|
||||
return 0
|
||||
}
|
||||
|
||||
function ecgGetTextWidth(lw: number, lg: number, sw: number): number {
|
||||
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 * (lw + lg) - lg + spaces * sw
|
||||
return chars * (letterWidth + letterGap) - letterGap + spaces * spaceWidth
|
||||
}
|
||||
|
||||
function ecgLayoutText(offsetX: number, lw: number, lg: number, sw: number): LetterLayout[] {
|
||||
function layoutText(
|
||||
offsetX: number,
|
||||
letterWidth: number,
|
||||
letterGap: number,
|
||||
spaceWidth: number
|
||||
): LetterLayout[] {
|
||||
const layout: LetterLayout[] = []
|
||||
let cursor = offsetX
|
||||
for (let i = 0; i < ECG_TEXT.length; i++) {
|
||||
const ch = ECG_TEXT[i]
|
||||
if (ch === ' ') { cursor += sw; continue }
|
||||
layout.push({ char: ch, startX: cursor, endX: cursor + lw, centerX: cursor + lw / 2 })
|
||||
cursor += lw + lg
|
||||
|
||||
for (const char of ECG_TEXT) {
|
||||
if (char === ' ') {
|
||||
cursor += spaceWidth
|
||||
continue
|
||||
}
|
||||
const profile = CONNECTOR_PROFILES[char] ?? DEFAULT_PROFILE
|
||||
const startX = cursor
|
||||
const endX = cursor + letterWidth
|
||||
layout.push({
|
||||
char,
|
||||
startX,
|
||||
endX,
|
||||
startConnector: startX + BASE_LEFT_INSET + profile.leftInset,
|
||||
endConnector: endX - BASE_RIGHT_INSET - profile.rightInset,
|
||||
})
|
||||
cursor += letterWidth + letterGap
|
||||
}
|
||||
|
||||
return layout
|
||||
}
|
||||
|
||||
export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const animationRef = useRef<number | null>(null)
|
||||
@@ -93,6 +198,13 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
||||
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
|
||||
@@ -103,6 +215,12 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
||||
}, [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
|
||||
@@ -110,6 +228,7 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Setup canvas dimensions
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
@@ -118,50 +237,56 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
||||
canvas.height = vh * dpr
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
// Scale factors based on viewport
|
||||
const scale = Math.min(1.2, Math.max(0.35, vw / 1400))
|
||||
const LETTER_W = 72 * scale
|
||||
const LETTER_G = 10 * scale
|
||||
const SPACE_W = 30 * scale
|
||||
const TRACE_SPEED = 450 * scale
|
||||
const FLAT_GAP = 0.4
|
||||
const FLATLINE_HOLD = 0.3
|
||||
const FLATLINE_DRAW = 0.3
|
||||
const FADE_TO_BLACK = 0.2
|
||||
const BG_TRANSITION = 0.2
|
||||
|
||||
// Layout parameters
|
||||
const baselineY = vh * 0.5
|
||||
const ecgMaxDefl = vh * 0.25
|
||||
const textMaxDefl = vh * 0.08
|
||||
const lineColor = '#00ff41'
|
||||
const loginBgColor = '#1E293B'
|
||||
|
||||
// Calculate start offset from cursor position if provided
|
||||
const startOffsetX = startPosition ? startPosition.x : 0
|
||||
|
||||
// Build beats with cursor offset
|
||||
const beats: Beat[] = [
|
||||
{ startTime: 0.5, widthPx: 60 * scale, amplitude: 0.3, startWX: 0 },
|
||||
{ startTime: 1.2, widthPx: 90 * scale, amplitude: 0.55, startWX: 0 },
|
||||
{ startTime: 2.0, widthPx: 120 * scale, amplitude: 0.85, startWX: 0 },
|
||||
{ startTime: 2.8, widthPx: 140 * scale, amplitude: 1.0, startWX: 0 },
|
||||
{ startTime: 0.6, widthPx: 60 * scale, amplitude: 0.3, startWX: 0 },
|
||||
{ startTime: 1.4, widthPx: 80 * scale, amplitude: 0.55, startWX: 0 },
|
||||
{ startTime: 2.3, widthPx: 120 * scale, amplitude: 0.85, startWX: 0 },
|
||||
{ startTime: 3.2, widthPx: 140 * scale, amplitude: 1.0, startWX: 0 },
|
||||
]
|
||||
beats.forEach((b) => { b.startWX = b.startTime * TRACE_SPEED })
|
||||
|
||||
// Apply start offset to all beats
|
||||
beats.forEach((b) => {
|
||||
b.startWX = b.startTime * TRACE_SPEED + startOffsetX
|
||||
})
|
||||
|
||||
// Calculate text layout
|
||||
const lastBeat = beats[beats.length - 1]
|
||||
const lastBeatEndWX = lastBeat.startWX + lastBeat.widthPx
|
||||
const textStartWX = lastBeatEndWX + FLAT_GAP * TRACE_SPEED
|
||||
const totalTextW = ecgGetTextWidth(LETTER_W, LETTER_G, SPACE_W)
|
||||
const textStartWX = lastBeatEndWX + FLAT_GAP_SECONDS * TRACE_SPEED
|
||||
const totalTextW = getTextTotalWidth(LETTER_W, LETTER_G, SPACE_W)
|
||||
const textEndWX = textStartWX + totalTextW
|
||||
const textLayout = ecgLayoutText(textStartWX, LETTER_W, LETTER_G, SPACE_W)
|
||||
const fontSize = Math.round(textMaxDefl / 0.715)
|
||||
const textLayout = layoutText(textStartWX, LETTER_W, LETTER_G, SPACE_W)
|
||||
|
||||
const headScreenRatio = 0.75
|
||||
const finalHeadSX = (vw - totalTextW) / 2 + totalTextW
|
||||
// Calculate timing phases
|
||||
const textEndTime = textEndWX / TRACE_SPEED
|
||||
const holdEndTime = textEndTime + FLATLINE_HOLD
|
||||
const flatlineEndTime = holdEndTime + FLATLINE_DRAW
|
||||
const fadeEndTime = flatlineEndTime + FADE_TO_BLACK
|
||||
const bgTransitionEndTime = fadeEndTime + BG_TRANSITION
|
||||
const holdEndTime = textEndTime + HOLD_SECONDS
|
||||
const flatlineEndTime = holdEndTime + FLATLINE_DRAW_SECONDS
|
||||
const fadeEndTime = flatlineEndTime + FADE_TO_BLACK_SECONDS
|
||||
const bgTransitionEndTime = fadeEndTime + BG_TRANSITION_SECONDS
|
||||
const exitEndTime = bgTransitionEndTime
|
||||
|
||||
// Final head position (centered text end)
|
||||
const finalHeadSX = (vw - totalTextW) / 2 + totalTextW
|
||||
|
||||
// Get Y at a given world X position
|
||||
const getYAtX = (wx: number): number => {
|
||||
for (let i = 0; i < beats.length; i++) {
|
||||
const b = beats[i]
|
||||
// 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)
|
||||
@@ -169,29 +294,65 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
||||
return baselineY - pts[idx].y * ecgMaxDefl
|
||||
}
|
||||
}
|
||||
for (let j = 0; j < textLayout.length; j++) {
|
||||
const item = textLayout[j]
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Offscreen canvas for pre-rendering text
|
||||
const textCanvas = document.createElement('canvas')
|
||||
textCanvas.width = vw * dpr
|
||||
textCanvas.height = vh * dpr
|
||||
const textCtx = textCanvas.getContext('2d')
|
||||
if (textCtx) {
|
||||
textCtx.scale(dpr, dpr)
|
||||
textCtx.font = `bold ${Math.round(textMaxDefl / 0.715)}px Arial, Helvetica, sans-serif`
|
||||
textCtx.textAlign = 'center'
|
||||
textCtx.textBaseline = 'alphabetic'
|
||||
textCtx.strokeStyle = lineColor
|
||||
textCtx.lineWidth = 1.5 * scale
|
||||
|
||||
// Pre-render all letters
|
||||
for (const item of textLayout) {
|
||||
const centerX = (item.startX + item.endX) / 2
|
||||
textCtx.strokeText(item.char, centerX, baselineY)
|
||||
}
|
||||
|
||||
// Draw connector lines
|
||||
for (let i = 0; i < textLayout.length - 1; i++) {
|
||||
const curr = textLayout[i]
|
||||
const next = textLayout[i + 1]
|
||||
textCtx.beginPath()
|
||||
textCtx.moveTo(curr.endConnector, baselineY)
|
||||
textCtx.lineTo(next.startConnector, baselineY)
|
||||
textCtx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
let headWX = elapsed * TRACE_SPEED
|
||||
// Calculate current head position
|
||||
let headWX = elapsed * TRACE_SPEED + startOffsetX
|
||||
const isFlatlinePhase = elapsed >= holdEndTime && elapsed < flatlineEndTime
|
||||
const isFadePhase = elapsed >= flatlineEndTime && elapsed < fadeEndTime
|
||||
const isBgTransitionPhase = elapsed >= fadeEndTime
|
||||
@@ -200,9 +361,10 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
||||
headWX = textEndWX
|
||||
}
|
||||
|
||||
// Calculate viewport and head screen position
|
||||
let headSX: number
|
||||
let viewOff: number
|
||||
const headSXEcg = headScreenRatio * vw
|
||||
const headSXEcg = HEAD_SCREEN_RATIO * vw
|
||||
|
||||
if (headWX <= textStartWX) {
|
||||
viewOff = Math.max(0, headWX - headSXEcg)
|
||||
@@ -216,33 +378,41 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
||||
viewOff = headWX - headSX
|
||||
}
|
||||
|
||||
// Calculate fade alpha
|
||||
let fadeAlpha = 1
|
||||
if (isFadePhase) {
|
||||
fadeAlpha = Math.max(0, 1 - (elapsed - flatlineEndTime) / FADE_TO_BLACK)
|
||||
fadeAlpha = Math.max(0, 1 - (elapsed - flatlineEndTime) / FADE_TO_BLACK_SECONDS)
|
||||
} else if (isBgTransitionPhase) {
|
||||
fadeAlpha = 0
|
||||
}
|
||||
|
||||
// Background color transition
|
||||
if (!bgTransitionedRef.current && elapsed >= flatlineEndTime) {
|
||||
bgTransitionedRef.current = true
|
||||
container.style.transition = `background ${BG_TRANSITION * 1000}ms ease-out`
|
||||
container.style.transition = `background ${BG_TRANSITION_SECONDS * 1000}ms ease-out`
|
||||
container.style.background = loginBgColor
|
||||
}
|
||||
|
||||
ctx.save()
|
||||
ctx.globalAlpha = fadeAlpha
|
||||
|
||||
// Draw ECG trace (beats only, up to text start)
|
||||
const traceStart = Math.max(0, Math.floor(viewOff))
|
||||
const traceEnd = Math.min(Math.ceil(elapsed >= textEndTime ? textEndWX : headWX), Math.ceil(viewOff + vw))
|
||||
const traceEnd = Math.min(
|
||||
Math.ceil(elapsed >= textEndTime ? textEndWX : headWX),
|
||||
Math.ceil(viewOff + vw)
|
||||
)
|
||||
|
||||
if (traceEnd > traceStart) {
|
||||
// Outer glow layer
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = 'rgba(0, 255, 65, 0.25)'
|
||||
ctx.lineWidth = 6
|
||||
ctx.lineWidth = 6 * scale
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.lineCap = 'round'
|
||||
ctx.shadowColor = lineColor
|
||||
ctx.shadowBlur = 14
|
||||
ctx.shadowBlur = 14 * scale
|
||||
|
||||
for (let wx = traceStart; wx <= traceEnd; wx++) {
|
||||
const sx = wx - viewOff
|
||||
const sy = getYAtX(wx)
|
||||
@@ -251,10 +421,12 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
// Main trace layer
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = lineColor
|
||||
ctx.lineWidth = 2
|
||||
ctx.shadowBlur = 4
|
||||
ctx.lineWidth = 2 * scale
|
||||
ctx.shadowBlur = 4 * scale
|
||||
|
||||
for (let wx = traceStart; wx <= traceEnd; wx++) {
|
||||
const sx = wx - viewOff
|
||||
const sy = getYAtX(wx)
|
||||
@@ -264,42 +436,52 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
if (isFlatlinePhase) {
|
||||
const flatlineProgress = (elapsed - holdEndTime) / FLATLINE_DRAW
|
||||
// Draw flatline after text
|
||||
if (isFlatlinePhase || (elapsed >= holdEndTime && elapsed < textEndTime)) {
|
||||
const flatlineProgress = isFlatlinePhase
|
||||
? (elapsed - holdEndTime) / FLATLINE_DRAW_SECONDS
|
||||
: 1
|
||||
const flatlineEndSX = finalHeadSX + flatlineProgress * (vw - finalHeadSX + 50)
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = lineColor
|
||||
ctx.lineWidth = 2
|
||||
ctx.shadowBlur = 8
|
||||
ctx.lineWidth = 2 * scale
|
||||
ctx.shadowBlur = 8 * scale
|
||||
ctx.shadowColor = lineColor
|
||||
ctx.moveTo(finalHeadSX, baselineY)
|
||||
ctx.lineTo(flatlineEndSX, baselineY)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
ctx.shadowColor = lineColor
|
||||
ctx.shadowBlur = 8
|
||||
ctx.font = `bold ${fontSize}px Arial, Helvetica, sans-serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'alphabetic'
|
||||
ctx.lineWidth = 1.5 * scale
|
||||
ctx.strokeStyle = lineColor
|
||||
// Mask-based text reveal
|
||||
const isTextPhase = headWX > textStartWX
|
||||
const isTextDone = elapsed >= textEndTime
|
||||
|
||||
for (let k = 0; k < textLayout.length; k++) {
|
||||
const item = textLayout[k]
|
||||
const letterProgress = (headWX - item.startX) / (item.endX - item.startX)
|
||||
if (letterProgress > 0.3) {
|
||||
const alpha = Math.min(1, (letterProgress - 0.3) * 1.43)
|
||||
ctx.globalAlpha = fadeAlpha * alpha
|
||||
const lsx = item.centerX - viewOff
|
||||
ctx.strokeText(item.char, lsx, baselineY)
|
||||
}
|
||||
if (isTextPhase && textCtx) {
|
||||
// Create clipping region based on trace head position
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.rect(0, 0, isTextDone ? vw : headSX + 20 * scale, vh)
|
||||
ctx.clip()
|
||||
|
||||
// Draw pre-rendered text through the clip
|
||||
ctx.drawImage(textCanvas, -viewOff, 0)
|
||||
|
||||
// Apply neon glow to text
|
||||
ctx.globalCompositeOperation = 'source-over'
|
||||
ctx.shadowColor = lineColor
|
||||
ctx.shadowBlur = 8 * scale
|
||||
|
||||
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)')
|
||||
@@ -309,19 +491,22 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
||||
ctx.arc(headSX, headY, 20 * scale, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// Core dot
|
||||
ctx.fillStyle = lineColor
|
||||
ctx.beginPath()
|
||||
ctx.arc(headSX, headY, 3, 0, Math.PI * 2)
|
||||
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)')
|
||||
@@ -338,7 +523,20 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
}
|
||||
}
|
||||
}, [finishAnimation])
|
||||
}, [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>
|
||||
@@ -357,3 +555,5 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export type { ECGAnimationProps }
|
||||
|
||||
Reference in New Issue
Block a user