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:
+5
-15
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import type { Phase } from './types'
|
import type { Phase } from './types'
|
||||||
import { BootSequence } from './components/BootSequence'
|
import { BootSequence } from './components/BootSequence'
|
||||||
import { ECGAnimation } from './components/ECGAnimation'
|
|
||||||
import { LoginScreen } from './components/LoginScreen'
|
import { LoginScreen } from './components/LoginScreen'
|
||||||
import { DashboardLayout } from './components/DashboardLayout'
|
import { DashboardLayout } from './components/DashboardLayout'
|
||||||
import { AccessibilityProvider } from './contexts/AccessibilityContext'
|
import { AccessibilityProvider } from './contexts/AccessibilityContext'
|
||||||
@@ -46,13 +45,12 @@ function SkipButton({ onSkip }: { onSkip: () => void }) {
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [phase, setPhase] = useState<Phase>('pmr')
|
const [phase, setPhase] = useState<Phase>('pmr')
|
||||||
const cursorPositionRef = useRef<{ x: number; y: number } | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initModel()
|
initModel()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const skipToLogin = () => setPhase('login')
|
const skipToDashboard = () => setPhase('pmr')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccessibilityProvider>
|
<AccessibilityProvider>
|
||||||
@@ -66,18 +64,10 @@ function App() {
|
|||||||
|
|
||||||
{phase === 'boot' && (
|
{phase === 'boot' && (
|
||||||
<BootSequence
|
<BootSequence
|
||||||
onComplete={() => setPhase('ecg')}
|
onComplete={() => setPhase('login')}
|
||||||
onCursorPositionReady={(pos) => { cursorPositionRef.current = pos }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === 'ecg' && (
|
|
||||||
<ECGAnimation
|
|
||||||
onComplete={() => setPhase('login')}
|
|
||||||
startPosition={cursorPositionRef.current}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(phase === 'login' || phase === 'pmr') && (
|
{(phase === 'login' || phase === 'pmr') && (
|
||||||
<DetailPanelProvider>
|
<DetailPanelProvider>
|
||||||
<DashboardLayout />
|
<DashboardLayout />
|
||||||
@@ -88,8 +78,8 @@ function App() {
|
|||||||
<LoginScreen onComplete={() => setPhase('pmr')} />
|
<LoginScreen onComplete={() => setPhase('pmr')} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(phase === 'boot' || phase === 'ecg') && (
|
{phase === 'boot' && (
|
||||||
<SkipButton onSkip={skipToLogin} />
|
<SkipButton onSkip={skipToDashboard} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AccessibilityProvider>
|
</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'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -26,7 +26,7 @@ interface BootConfig {
|
|||||||
holdAfterComplete: number
|
holdAfterComplete: number
|
||||||
fadeOutDuration: number
|
fadeOutDuration: number
|
||||||
cursorShrinkDuration: number
|
cursorShrinkDuration: number
|
||||||
ecgStartDelay: number
|
completionDelay: number
|
||||||
}
|
}
|
||||||
colors: {
|
colors: {
|
||||||
bright: string
|
bright: string
|
||||||
@@ -37,7 +37,6 @@ interface BootConfig {
|
|||||||
|
|
||||||
interface BootSequenceProps {
|
interface BootSequenceProps {
|
||||||
onComplete: () => void
|
onComplete: () => void
|
||||||
onCursorPositionReady?: (position: { x: number; y: number }) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TypedSegment {
|
interface TypedSegment {
|
||||||
@@ -91,7 +90,7 @@ const BOOT_CONFIG: BootConfig = {
|
|||||||
holdAfterComplete: 1000,
|
holdAfterComplete: 1000,
|
||||||
fadeOutDuration: 600,
|
fadeOutDuration: 600,
|
||||||
cursorShrinkDuration: 600,
|
cursorShrinkDuration: 600,
|
||||||
ecgStartDelay: 0,
|
completionDelay: 0,
|
||||||
},
|
},
|
||||||
colors: COLORS,
|
colors: COLORS,
|
||||||
}
|
}
|
||||||
@@ -194,14 +193,12 @@ const TOTAL_CHARS = TYPED_LINES.reduce((sum, l) => sum + l.totalChars, 0)
|
|||||||
// Main Component
|
// Main Component
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export function BootSequence({ onComplete, onCursorPositionReady }: BootSequenceProps) {
|
export function BootSequence({ onComplete }: BootSequenceProps) {
|
||||||
const [typedCount, setTypedCount] = useState(0)
|
const [typedCount, setTypedCount] = useState(0)
|
||||||
const [phase, setPhase] = useState<'typing' | 'holding' | 'fading' | 'done'>('typing')
|
const [phase, setPhase] = useState<'typing' | 'holding' | 'fading' | 'done'>('typing')
|
||||||
const [isVisible, setIsVisible] = useState(true)
|
const [isVisible, setIsVisible] = useState(true)
|
||||||
const cursorRef = useRef<HTMLSpanElement>(null)
|
|
||||||
const cursorAnchorRef = useRef<HTMLSpanElement>(null)
|
const cursorAnchorRef = useRef<HTMLSpanElement>(null)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const cursorCapturedRef = useRef(false)
|
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const [cursorPos, setCursorPos] = useState<{ left: number; top: number } | 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
|
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
: false
|
: 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
|
// Typing engine — runs as a self-scheduling setTimeout chain
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -267,18 +253,16 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence
|
|||||||
}
|
}
|
||||||
}, [typedCount, phase, reducedMotion])
|
}, [typedCount, phase, reducedMotion])
|
||||||
|
|
||||||
// Hold phase: capture cursor, then start fading
|
// Hold phase: then start fading
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase !== 'holding') return
|
if (phase !== 'holding') return
|
||||||
|
|
||||||
captureCursorPosition()
|
|
||||||
|
|
||||||
const fadeTimer = setTimeout(() => {
|
const fadeTimer = setTimeout(() => {
|
||||||
setPhase('fading')
|
setPhase('fading')
|
||||||
}, BOOT_CONFIG.timing.holdAfterComplete)
|
}, BOOT_CONFIG.timing.holdAfterComplete)
|
||||||
|
|
||||||
return () => clearTimeout(fadeTimer)
|
return () => clearTimeout(fadeTimer)
|
||||||
}, [phase, captureCursorPosition])
|
}, [phase])
|
||||||
|
|
||||||
// Fade phase: wait for animations to finish, then complete
|
// Fade phase: wait for animations to finish, then complete
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -293,7 +277,7 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence
|
|||||||
setIsVisible(false)
|
setIsVisible(false)
|
||||||
setPhase('done')
|
setPhase('done')
|
||||||
onComplete()
|
onComplete()
|
||||||
}, longestFade + BOOT_CONFIG.timing.ecgStartDelay)
|
}, longestFade + BOOT_CONFIG.timing.completionDelay)
|
||||||
|
|
||||||
return () => clearTimeout(completeTimer)
|
return () => clearTimeout(completeTimer)
|
||||||
}, [phase, onComplete])
|
}, [phase, onComplete])
|
||||||
@@ -354,7 +338,7 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence
|
|||||||
spans.push(
|
spans.push(
|
||||||
<span
|
<span
|
||||||
key={segIdx}
|
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 }}
|
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
|
||||||
>
|
>
|
||||||
{visibleText}
|
{visibleText}
|
||||||
@@ -411,7 +395,7 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence
|
|||||||
spans.push(
|
spans.push(
|
||||||
<span
|
<span
|
||||||
key={segIdx}
|
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 }}
|
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
|
||||||
>
|
>
|
||||||
{seg.text}
|
{seg.text}
|
||||||
@@ -469,7 +453,6 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence
|
|||||||
{/* Cursor rendered outside fading wrapper — shrinks independently */}
|
{/* Cursor rendered outside fading wrapper — shrinks independently */}
|
||||||
{cursorPos && phase !== 'done' && (
|
{cursorPos && phase !== 'done' && (
|
||||||
<span
|
<span
|
||||||
ref={cursorRef}
|
|
||||||
className="absolute animate-blink"
|
className="absolute animate-blink"
|
||||||
style={{
|
style={{
|
||||||
left: cursorPos.left,
|
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 {
|
:root {
|
||||||
/* Original design system tokens (for boot/ECG phases) */
|
/* Original design system tokens (for boot phase) */
|
||||||
--bg: #FFFFFF;
|
--bg: #FFFFFF;
|
||||||
--text: #334155;
|
--text: #334155;
|
||||||
--heading: #0F172A;
|
--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