Completed boot loading to ECG, to name written
This commit is contained in:
+11
-27
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import type { Phase } from './types'
|
||||
import { BootSequence } from './components/BootSequence'
|
||||
import { ECGAnimation } from './components/ECGAnimation'
|
||||
@@ -8,43 +8,27 @@ 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')
|
||||
}, [])
|
||||
const cursorPositionRef = useRef<{ x: number; y: number } | null>(null)
|
||||
|
||||
return (
|
||||
<AccessibilityProvider>
|
||||
<div className="min-h-screen bg-black">
|
||||
{phase === 'boot' && (
|
||||
<BootSequence
|
||||
onComplete={handleBootComplete}
|
||||
onCursorPositionReady={handleCursorPositionReady}
|
||||
<BootSequence
|
||||
onComplete={() => setPhase('ecg')}
|
||||
onCursorPositionReady={(pos) => { cursorPositionRef.current = pos }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{phase === 'ecg' && (
|
||||
<ECGAnimation
|
||||
onComplete={handleECGComplete}
|
||||
startPosition={cursorPosition}
|
||||
<ECGAnimation
|
||||
onComplete={() => setPhase('login')}
|
||||
startPosition={cursorPositionRef.current}
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
|
||||
{phase === 'login' && (
|
||||
<LoginScreen onComplete={handleLoginComplete} />
|
||||
<LoginScreen onComplete={() => setPhase('pmr')} />
|
||||
)}
|
||||
|
||||
{phase === 'pmr' && <PMRInterface />}
|
||||
|
||||
+357
-200
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
// =============================================================================
|
||||
@@ -25,6 +25,8 @@ interface BootConfig {
|
||||
cursorBlinkInterval: number
|
||||
holdAfterComplete: number
|
||||
fadeOutDuration: number
|
||||
cursorShrinkDuration: number
|
||||
ecgStartDelay: number
|
||||
}
|
||||
colors: {
|
||||
bright: string
|
||||
@@ -38,10 +40,34 @@ interface BootSequenceProps {
|
||||
onCursorPositionReady?: (position: { x: number; y: number }) => void
|
||||
}
|
||||
|
||||
interface TypedSegment {
|
||||
text: string
|
||||
color: string
|
||||
bold?: boolean
|
||||
isSeedDot?: boolean
|
||||
}
|
||||
|
||||
interface TypedLine {
|
||||
segments: TypedSegment[]
|
||||
totalChars: number
|
||||
pauseAfter: number // ms to pause after this line completes
|
||||
speed: number // ms per character (0 = instant)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Configuration
|
||||
// =============================================================================
|
||||
|
||||
// Global speed multiplier for typing animation.
|
||||
// 1.0 = default (~3.3s typing). Lower = faster, higher = slower.
|
||||
const TYPING_SPEED = 2
|
||||
|
||||
const COLORS = {
|
||||
bright: '#00ff41',
|
||||
dim: '#3a6b45',
|
||||
cyan: '#00e5ff',
|
||||
}
|
||||
|
||||
const BOOT_CONFIG: BootConfig = {
|
||||
header: 'CLINICAL TERMINAL v3.2.1',
|
||||
lines: [
|
||||
@@ -57,195 +83,349 @@ const BOOT_CONFIG: BootConfig = {
|
||||
{ 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' },
|
||||
{ type: 'ready', text: 'READY \u2014 Rendering CV..', style: 'bright' },
|
||||
],
|
||||
timing: {
|
||||
lineDelay: 220,
|
||||
cursorBlinkInterval: 530,
|
||||
holdAfterComplete: 400,
|
||||
fadeOutDuration: 800,
|
||||
},
|
||||
colors: {
|
||||
bright: '#00ff41',
|
||||
dim: '#3a6b45',
|
||||
cyan: '#00e5ff',
|
||||
cursorBlinkInterval: 300,
|
||||
holdAfterComplete: 900,
|
||||
fadeOutDuration: 600,
|
||||
cursorShrinkDuration: 600,
|
||||
ecgStartDelay: 0,
|
||||
},
|
||||
colors: COLORS,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
function getCumulativeDelay(lineIndex: number): number {
|
||||
return lineIndex * BOOT_CONFIG.timing.lineDelay
|
||||
// Apply speed multiplier — instant lines (speed=0) stay instant
|
||||
function s(ms: number): number {
|
||||
return Math.round(ms * TYPING_SPEED)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Line Components
|
||||
// =============================================================================
|
||||
// Build typed lines from BOOT_CONFIG
|
||||
function buildTypedLines(): TypedLine[] {
|
||||
const lines: TypedLine[] = []
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
// Header
|
||||
const headerText = BOOT_CONFIG.header
|
||||
lines.push({
|
||||
segments: [{ text: headerText, color: COLORS.bright, bold: true }],
|
||||
totalChars: headerText.length,
|
||||
pauseAfter: s(40),
|
||||
speed: s(18),
|
||||
})
|
||||
|
||||
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
|
||||
for (const line of BOOT_CONFIG.lines) {
|
||||
switch (line.type) {
|
||||
case 'status': {
|
||||
const text = line.text || ''
|
||||
lines.push({
|
||||
segments: [{ text, color: COLORS.dim }],
|
||||
totalChars: text.length,
|
||||
pauseAfter: s(40),
|
||||
speed: s(14),
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'separator': {
|
||||
const text = line.text || '---'
|
||||
lines.push({
|
||||
segments: [{ text, color: COLORS.dim }],
|
||||
totalChars: text.length,
|
||||
pauseAfter: s(50),
|
||||
speed: 0, // instant
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'field': {
|
||||
const label = (line.label || '').padEnd(9)
|
||||
const value = line.value || ''
|
||||
const valueColor = line.style === 'cyan' ? COLORS.cyan : COLORS.bright
|
||||
lines.push({
|
||||
segments: [
|
||||
{ text: label, color: COLORS.cyan },
|
||||
{ text: value, color: valueColor },
|
||||
],
|
||||
totalChars: label.length + value.length,
|
||||
pauseAfter: s(30),
|
||||
speed: s(10),
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'module': {
|
||||
const prefix = '[OK] '
|
||||
const name = line.text || ''
|
||||
lines.push({
|
||||
segments: [
|
||||
{ text: '[OK]', color: COLORS.bright, bold: true },
|
||||
{ text: ' ', color: COLORS.dim },
|
||||
{ text: name, color: COLORS.dim },
|
||||
],
|
||||
totalChars: prefix.length + name.length,
|
||||
pauseAfter: s(50),
|
||||
speed: 0, // instant — stdout output
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'ready': {
|
||||
const prefix = '> '
|
||||
const body = line.text || ''
|
||||
const seedDot = '.'
|
||||
lines.push({
|
||||
segments: [
|
||||
{ text: prefix + body, color: COLORS.bright, bold: true },
|
||||
{ text: seedDot, color: COLORS.bright, bold: true, isSeedDot: true },
|
||||
],
|
||||
totalChars: prefix.length + body.length + seedDot.length,
|
||||
pauseAfter: 0,
|
||||
speed: s(16),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
const TYPED_LINES = buildTypedLines()
|
||||
const TOTAL_CHARS = TYPED_LINES.reduce((sum, l) => sum + l.totalChars, 0)
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export function BootSequence({ onComplete, onCursorPositionReady }: BootSequenceProps) {
|
||||
const [typedCount, setTypedCount] = useState(0)
|
||||
const [phase, setPhase] = useState<'typing' | 'holding' | 'fading' | 'done'>('typing')
|
||||
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
|
||||
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)
|
||||
|
||||
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
|
||||
// Capture cursor position for ECG handoff
|
||||
const captureCursorPosition = useCallback(() => {
|
||||
if (cursorRef.current && onCursorPositionReady && !cursorCaptured) {
|
||||
if (cursorRef.current && onCursorPositionReady && !cursorCapturedRef.current) {
|
||||
const rect = cursorRef.current.getBoundingClientRect()
|
||||
const position = {
|
||||
onCursorPositionReady({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
}
|
||||
onCursorPositionReady(position)
|
||||
setCursorCaptured(true)
|
||||
})
|
||||
cursorCapturedRef.current = true
|
||||
}
|
||||
}, [onCursorPositionReady, cursorCaptured])
|
||||
}, [onCursorPositionReady])
|
||||
|
||||
// Handle completion sequence
|
||||
// Typing engine — runs as a self-scheduling setTimeout chain
|
||||
useEffect(() => {
|
||||
if (reducedMotion) {
|
||||
// Reduced motion: show everything instantly, then complete
|
||||
const timer = setTimeout(onComplete, 500)
|
||||
return () => clearTimeout(timer)
|
||||
if (reducedMotion || phase !== 'typing') return
|
||||
|
||||
// All characters typed
|
||||
if (typedCount >= TOTAL_CHARS) {
|
||||
setPhase('holding')
|
||||
return
|
||||
}
|
||||
|
||||
// Show cursor after all lines are rendered
|
||||
const cursorTimer = setTimeout(() => {
|
||||
setShowCursor(true)
|
||||
}, totalBootTime)
|
||||
// Find which line the cursor is on and position within it
|
||||
let lineStart = 0
|
||||
let lineIdx = 0
|
||||
for (let i = 0; i < TYPED_LINES.length; i++) {
|
||||
if (lineStart + TYPED_LINES[i].totalChars > typedCount) {
|
||||
lineIdx = i
|
||||
break
|
||||
}
|
||||
lineStart += TYPED_LINES[i].totalChars
|
||||
}
|
||||
|
||||
// Capture cursor position and start morph
|
||||
const morphTimer = setTimeout(() => {
|
||||
captureCursorPosition()
|
||||
setIsMorphing(true)
|
||||
}, fadeStartTime - 100)
|
||||
const line = TYPED_LINES[lineIdx]
|
||||
const posInLine = typedCount - lineStart
|
||||
|
||||
// Fade out and complete
|
||||
const fadeTimer = setTimeout(() => {
|
||||
setIsVisible(false)
|
||||
}, fadeStartTime)
|
||||
|
||||
const completeTimer = setTimeout(() => {
|
||||
onComplete()
|
||||
}, fadeStartTime + BOOT_CONFIG.timing.fadeOutDuration)
|
||||
if (posInLine === 0 && line.speed === 0) {
|
||||
// Instant line: show all chars at once after a brief pause
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setTypedCount(lineStart + line.totalChars)
|
||||
}, line.pauseAfter || 10)
|
||||
} else if (posInLine === 0 && lineIdx > 0) {
|
||||
// Start of a new typed line — apply previous line's pauseAfter
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setTypedCount(prev => prev + 1)
|
||||
}, TYPED_LINES[lineIdx - 1].pauseAfter)
|
||||
} else {
|
||||
// Type one character at the line's speed
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setTypedCount(prev => prev + 1)
|
||||
}, line.speed)
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(cursorTimer)
|
||||
clearTimeout(morphTimer)
|
||||
clearTimeout(fadeTimer)
|
||||
clearTimeout(completeTimer)
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}, [onComplete, totalBootTime, fadeStartTime, captureCursorPosition, reducedMotion])
|
||||
}, [typedCount, phase, reducedMotion])
|
||||
|
||||
// Hold phase: capture cursor, then start fading
|
||||
useEffect(() => {
|
||||
if (phase !== 'holding') return
|
||||
|
||||
captureCursorPosition()
|
||||
|
||||
const fadeTimer = setTimeout(() => {
|
||||
setPhase('fading')
|
||||
}, BOOT_CONFIG.timing.holdAfterComplete)
|
||||
|
||||
return () => clearTimeout(fadeTimer)
|
||||
}, [phase, captureCursorPosition])
|
||||
|
||||
// Fade phase: wait for animations to finish, then complete
|
||||
useEffect(() => {
|
||||
if (phase !== 'fading') return
|
||||
|
||||
const longestFade = Math.max(
|
||||
BOOT_CONFIG.timing.fadeOutDuration,
|
||||
BOOT_CONFIG.timing.cursorShrinkDuration
|
||||
)
|
||||
|
||||
const completeTimer = setTimeout(() => {
|
||||
setIsVisible(false)
|
||||
setPhase('done')
|
||||
onComplete()
|
||||
}, longestFade + BOOT_CONFIG.timing.ecgStartDelay)
|
||||
|
||||
return () => clearTimeout(completeTimer)
|
||||
}, [phase, onComplete])
|
||||
|
||||
// Reduced motion: skip animation
|
||||
useEffect(() => {
|
||||
if (!reducedMotion) return
|
||||
const timer = setTimeout(onComplete, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [reducedMotion, onComplete])
|
||||
|
||||
// Track cursor anchor position relative to the content container
|
||||
useLayoutEffect(() => {
|
||||
if (!cursorAnchorRef.current || !containerRef.current || phase === 'done') return
|
||||
const anchor = cursorAnchorRef.current.getBoundingClientRect()
|
||||
const container = containerRef.current.getBoundingClientRect()
|
||||
setCursorPos({
|
||||
left: anchor.left - container.left,
|
||||
top: anchor.top - container.top,
|
||||
})
|
||||
}, [typedCount, phase])
|
||||
|
||||
// Render the typed lines up to typedCount
|
||||
const renderLines = () => {
|
||||
let remaining = typedCount
|
||||
const renderedLines: React.ReactNode[] = []
|
||||
let cursorPlaced = false
|
||||
|
||||
for (let lineIdx = 0; lineIdx < TYPED_LINES.length; lineIdx++) {
|
||||
const line = TYPED_LINES[lineIdx]
|
||||
|
||||
// During typing, render this line if we've started typing into it (or it's the first line with cursor)
|
||||
if (phase === 'typing' && remaining <= 0 && lineIdx > 0) break
|
||||
|
||||
const charsForLine = Math.min(Math.max(0, remaining), line.totalChars)
|
||||
remaining -= charsForLine
|
||||
|
||||
// Cursor goes on the line currently being typed, or the last line in non-typing phases
|
||||
const isCursorLine = phase === 'typing'
|
||||
? !cursorPlaced && (charsForLine < line.totalChars || remaining <= 0)
|
||||
: lineIdx === TYPED_LINES.length - 1
|
||||
|
||||
// Render segments
|
||||
let charBudget = phase === 'typing' ? charsForLine : line.totalChars
|
||||
const spans: React.ReactNode[] = []
|
||||
|
||||
for (let segIdx = 0; segIdx < line.segments.length; segIdx++) {
|
||||
const seg = line.segments[segIdx]
|
||||
if (charBudget <= 0 && phase === 'typing') break
|
||||
|
||||
const visibleChars = phase === 'typing'
|
||||
? Math.min(charBudget, seg.text.length)
|
||||
: seg.text.length
|
||||
const visibleText = seg.text.slice(0, visibleChars)
|
||||
charBudget -= visibleChars
|
||||
|
||||
if (seg.isSeedDot && visibleChars > 0) {
|
||||
spans.push(
|
||||
<span
|
||||
key={segIdx}
|
||||
className={phase === 'holding' ? 'ecg-seed-dot animate-seed-pulse' : 'ecg-seed-dot'}
|
||||
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
|
||||
>
|
||||
{visibleText}
|
||||
</span>
|
||||
)
|
||||
} else if (visibleChars > 0) {
|
||||
spans.push(
|
||||
<span
|
||||
key={segIdx}
|
||||
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
|
||||
>
|
||||
{visibleText}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Invisible placeholder to mark cursor position (actual cursor rendered outside fading wrapper)
|
||||
if (isCursorLine && phase !== 'done') {
|
||||
cursorPlaced = true
|
||||
spans.push(
|
||||
<span
|
||||
key="cursor-anchor"
|
||||
ref={cursorAnchorRef}
|
||||
className="inline-block align-middle"
|
||||
style={{ width: 8, height: 16, marginLeft: 1 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
renderedLines.push(
|
||||
<div key={lineIdx} className="font-mono text-sm leading-relaxed whitespace-nowrap">
|
||||
{spans}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return renderedLines
|
||||
}
|
||||
|
||||
// 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="fixed inset-0 z-50 flex flex-col justify-center bg-black px-5 py-8 sm: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} />
|
||||
))}
|
||||
{(() => {
|
||||
// Render all lines fully
|
||||
const lines: React.ReactNode[] = []
|
||||
for (let lineIdx = 0; lineIdx < TYPED_LINES.length; lineIdx++) {
|
||||
const line = TYPED_LINES[lineIdx]
|
||||
const spans: React.ReactNode[] = []
|
||||
for (let segIdx = 0; segIdx < line.segments.length; segIdx++) {
|
||||
const seg = line.segments[segIdx]
|
||||
spans.push(
|
||||
<span
|
||||
key={segIdx}
|
||||
className={seg.isSeedDot ? 'ecg-seed-dot' : undefined}
|
||||
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
|
||||
>
|
||||
{seg.text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
lines.push(
|
||||
<div key={lineIdx} className="font-mono text-sm leading-relaxed whitespace-nowrap">
|
||||
{spans}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return lines
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -255,14 +435,16 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence
|
||||
<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"
|
||||
className="fixed inset-0 z-50 flex flex-col justify-center bg-black px-5 py-8 sm:p-10 font-mono text-sm overflow-hidden"
|
||||
initial={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
|
||||
exit={{ opacity: 1 }}
|
||||
transition={{ duration: 0 }}
|
||||
>
|
||||
{/* CRT Scanlines */}
|
||||
<div
|
||||
<motion.div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
animate={{ opacity: phase === 'fading' || phase === 'done' ? 0 : 1 }}
|
||||
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
|
||||
style={{
|
||||
background: `repeating-linear-gradient(
|
||||
0deg,
|
||||
@@ -274,62 +456,37 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2 relative z-10">
|
||||
{/* Header */}
|
||||
{/* Content container */}
|
||||
<div ref={containerRef} className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2 relative z-10">
|
||||
{/* Text fades out independently */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||
animate={{ opacity: phase === 'fading' || phase === 'done' ? 0 : 1 }}
|
||||
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
|
||||
>
|
||||
<BootLineHeader text={BOOT_CONFIG.header} />
|
||||
{renderLines()}
|
||||
</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: getCumulativeDelay(index) / 1000,
|
||||
duration: 0.4,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
>
|
||||
<BootLineRenderer line={line} />
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* Blinking Cursor */}
|
||||
{showCursor && (
|
||||
<motion.div
|
||||
{/* Cursor rendered outside fading wrapper — shrinks independently */}
|
||||
{cursorPos && phase !== 'done' && (
|
||||
<span
|
||||
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' }}
|
||||
className="absolute animate-blink"
|
||||
style={{
|
||||
height: 16,
|
||||
backgroundColor: BOOT_CONFIG.colors.bright,
|
||||
animation: isMorphing ? undefined : 'blink 530ms infinite',
|
||||
left: cursorPos.left,
|
||||
top: cursorPos.top + (phase === 'fading' ? 12 : 0),
|
||||
width: 8,
|
||||
height: phase === 'fading' ? 4 : 16,
|
||||
backgroundColor: COLORS.bright,
|
||||
filter: phase === 'fading' ? 'blur(1px)' : 'none',
|
||||
boxShadow: phase === 'fading' ? '0 0 12px rgba(0,255,65,0.9)' : 'none',
|
||||
transition: phase === 'fading'
|
||||
? `top ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out, height ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out, filter ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out, box-shadow ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out`
|
||||
: 'none',
|
||||
animationDuration: `${BOOT_CONFIG.timing.cursorBlinkInterval}ms`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CSS for blink animation */}
|
||||
<style>{`
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
+254
-126
@@ -26,13 +26,7 @@ interface LetterLayout {
|
||||
char: string
|
||||
startX: number
|
||||
endX: number
|
||||
startConnector: number
|
||||
endConnector: number
|
||||
}
|
||||
|
||||
interface ConnectorProfile {
|
||||
leftInset: number
|
||||
rightInset: number
|
||||
baselineY: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -42,24 +36,11 @@ interface ConnectorProfile {
|
||||
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 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 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)
|
||||
// =============================================================================
|
||||
@@ -122,17 +103,30 @@ function generateHeartbeatPoints(amplitude: number): Point[] {
|
||||
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)
|
||||
|
||||
// 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
|
||||
@@ -160,32 +154,112 @@ function layoutText(
|
||||
offsetX: number,
|
||||
letterWidth: number,
|
||||
letterGap: number,
|
||||
spaceWidth: 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 (const char of ECG_TEXT) {
|
||||
if (char === ' ') {
|
||||
cursor += spaceWidth
|
||||
continue
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
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
|
||||
// =============================================================================
|
||||
@@ -238,7 +312,7 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
// Scale factors based on viewport
|
||||
const scale = Math.min(1.2, Math.max(0.35, vw / 1400))
|
||||
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
|
||||
@@ -246,17 +320,18 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
||||
// Layout parameters
|
||||
const baselineY = vh * 0.5
|
||||
const ecgMaxDefl = vh * 0.25
|
||||
const textMaxDefl = vh * 0.08
|
||||
// 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: 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 },
|
||||
{ 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
|
||||
@@ -264,25 +339,26 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
||||
b.startWX = b.startTime * TRACE_SPEED + startOffsetX
|
||||
})
|
||||
|
||||
// Calculate text layout
|
||||
// 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 = textStartWX + totalTextW
|
||||
const textLayout = layoutText(textStartWX, LETTER_W, LETTER_G, SPACE_W)
|
||||
const textLayout = layoutText(
|
||||
textStartWX, LETTER_W, LETTER_G, SPACE_W,
|
||||
baselineY, 0, Infinity
|
||||
)
|
||||
|
||||
// Calculate timing phases
|
||||
const textEndTime = textEndWX / TRACE_SPEED
|
||||
const holdEndTime = textEndTime + HOLD_SECONDS
|
||||
const flatlineEndTime = holdEndTime + FLATLINE_DRAW_SECONDS
|
||||
const fadeEndTime = flatlineEndTime + FADE_TO_BLACK_SECONDS
|
||||
const textEndTime = (textEndWX - startOffsetX) / TRACE_SPEED
|
||||
const holdEndTime = textEndTime
|
||||
const flatlineEndTime = textEndTime + FLATLINE_DRAW_SECONDS
|
||||
const fadeStartTime = flatlineEndTime + HOLD_SECONDS
|
||||
const fadeEndTime = fadeStartTime + 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 => {
|
||||
// Check beats
|
||||
@@ -307,35 +383,11 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
// 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) => {
|
||||
@@ -353,8 +405,8 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
||||
|
||||
// Calculate current head position
|
||||
let headWX = elapsed * TRACE_SPEED + startOffsetX
|
||||
const isFlatlinePhase = elapsed >= holdEndTime && elapsed < flatlineEndTime
|
||||
const isFadePhase = elapsed >= flatlineEndTime && elapsed < fadeEndTime
|
||||
const isFlatlinePhase = elapsed >= holdEndTime && elapsed < fadeStartTime
|
||||
const isFadePhase = elapsed >= fadeStartTime && elapsed < fadeEndTime
|
||||
const isBgTransitionPhase = elapsed >= fadeEndTime
|
||||
|
||||
if (elapsed >= textEndTime) {
|
||||
@@ -365,18 +417,10 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
||||
let headSX: number
|
||||
let viewOff: number
|
||||
const headSXEcg = HEAD_SCREEN_RATIO * vw
|
||||
|
||||
if (headWX <= textStartWX) {
|
||||
viewOff = Math.max(0, headWX - headSXEcg)
|
||||
headSX = headWX - viewOff
|
||||
} else if (headWX >= textEndWX || elapsed >= textEndTime) {
|
||||
viewOff = textEndWX - finalHeadSX
|
||||
headSX = headWX - viewOff
|
||||
} else {
|
||||
const p = (headWX - textStartWX) / (textEndWX - textStartWX)
|
||||
headSX = headSXEcg + p * (finalHeadSX - headSXEcg)
|
||||
viewOff = headWX - headSX
|
||||
}
|
||||
|
||||
// Simple continuous scrolling - viewport follows head when it exceeds 75% of screen
|
||||
viewOff = Math.max(0, headWX - headSXEcg)
|
||||
headSX = headWX - viewOff
|
||||
|
||||
// Calculate fade alpha
|
||||
let fadeAlpha = 1
|
||||
@@ -386,8 +430,8 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
||||
fadeAlpha = 0
|
||||
}
|
||||
|
||||
// Background color transition
|
||||
if (!bgTransitionedRef.current && elapsed >= flatlineEndTime) {
|
||||
// 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
|
||||
@@ -396,11 +440,13 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
||||
ctx.save()
|
||||
ctx.globalAlpha = fadeAlpha
|
||||
|
||||
// Draw ECG trace (beats only, up to text start)
|
||||
const traceStart = Math.max(0, Math.floor(viewOff))
|
||||
// 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(viewOff + vw),
|
||||
Math.ceil(textStartWX) // Stop trace before text — only the dot draws through letters
|
||||
)
|
||||
|
||||
if (traceEnd > traceStart) {
|
||||
@@ -436,40 +482,122 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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(finalHeadSX, baselineY)
|
||||
ctx.moveTo(flatlineStartSX, baselineY)
|
||||
ctx.lineTo(flatlineEndSX, baselineY)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Mask-based text reveal
|
||||
// Text reveal — draw letters directly each frame
|
||||
const isTextPhase = headWX > textStartWX
|
||||
const isTextDone = elapsed >= textEndTime
|
||||
|
||||
if (isTextPhase && textCtx) {
|
||||
// Create clipping region based on trace head position
|
||||
if (isTextPhase) {
|
||||
ctx.save()
|
||||
|
||||
// Clip for progressive reveal
|
||||
const revealX = isTextDone ? vw : (headWX - viewOff)
|
||||
ctx.beginPath()
|
||||
ctx.rect(0, 0, isTextDone ? vw : headSX + 20 * scale, vh)
|
||||
ctx.rect(0, 0, revealX, vh)
|
||||
ctx.clip()
|
||||
|
||||
// Draw pre-rendered text through the clip
|
||||
ctx.drawImage(textCanvas, -viewOff, 0)
|
||||
// Common text properties
|
||||
ctx.font = textFont
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'alphabetic'
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.lineCap = 'round'
|
||||
|
||||
// Apply neon glow to text
|
||||
ctx.globalCompositeOperation = 'source-over'
|
||||
// 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 = 8 * scale
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Shield } from 'lucide-react'
|
||||
import { useAccessibility } from '../contexts/AccessibilityContext'
|
||||
@@ -11,19 +11,23 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [passwordDots, setPasswordDots] = useState(0)
|
||||
const [showCursor, setShowCursor] = useState(true)
|
||||
const [isTypingUsername, setIsTypingUsername] = useState(true)
|
||||
const [isTypingPassword, setIsTypingPassword] = useState(false)
|
||||
const [activeField, setActiveField] = useState<'username' | 'password' | null>('username')
|
||||
const [buttonPressed, setButtonPressed] = useState(false)
|
||||
const [isExiting, setIsExiting] = useState(false)
|
||||
const { requestFocusAfterLogin } = useAccessibility()
|
||||
|
||||
|
||||
const fullUsername = 'A.CHARLWOOD'
|
||||
const passwordLength = 8
|
||||
|
||||
const prefersReducedMotion = typeof window !== 'undefined'
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
const prefersReducedMotion = typeof window !== 'undefined'
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
: false
|
||||
|
||||
// Refs for interval cleanup
|
||||
const usernameIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const passwordIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const cursorIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const triggerComplete = useCallback(() => {
|
||||
setIsExiting(true)
|
||||
setTimeout(() => {
|
||||
@@ -36,6 +40,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
if (prefersReducedMotion) {
|
||||
setUsername(fullUsername)
|
||||
setPasswordDots(passwordLength)
|
||||
setActiveField(null)
|
||||
setTimeout(() => {
|
||||
setButtonPressed(true)
|
||||
setTimeout(() => {
|
||||
@@ -45,33 +50,37 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsTypingUsername(true)
|
||||
// Username typing: 30ms per character
|
||||
let usernameIndex = 0
|
||||
|
||||
const usernameInterval = setInterval(() => {
|
||||
usernameIntervalRef.current = setInterval(() => {
|
||||
if (usernameIndex <= fullUsername.length) {
|
||||
setUsername(fullUsername.slice(0, usernameIndex))
|
||||
usernameIndex++
|
||||
} else {
|
||||
clearInterval(usernameInterval)
|
||||
setIsTypingUsername(false)
|
||||
setIsTypingPassword(true)
|
||||
|
||||
if (usernameIntervalRef.current) {
|
||||
clearInterval(usernameIntervalRef.current)
|
||||
}
|
||||
setActiveField('password')
|
||||
|
||||
// Password dots: 20ms per dot, after 150ms pause
|
||||
setTimeout(() => {
|
||||
let dotCount = 0
|
||||
const passwordInterval = setInterval(() => {
|
||||
passwordIntervalRef.current = setInterval(() => {
|
||||
if (dotCount <= passwordLength) {
|
||||
setPasswordDots(dotCount)
|
||||
dotCount++
|
||||
} else {
|
||||
clearInterval(passwordInterval)
|
||||
setIsTypingPassword(false)
|
||||
|
||||
if (passwordIntervalRef.current) {
|
||||
clearInterval(passwordIntervalRef.current)
|
||||
}
|
||||
setActiveField(null)
|
||||
|
||||
// Button press: after 150ms pause
|
||||
setTimeout(() => {
|
||||
setButtonPressed(true)
|
||||
setTimeout(() => {
|
||||
triggerComplete()
|
||||
}, 100)
|
||||
}, 200)
|
||||
}, 150)
|
||||
}
|
||||
}, 20)
|
||||
@@ -81,47 +90,66 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
}, [triggerComplete, prefersReducedMotion])
|
||||
|
||||
useEffect(() => {
|
||||
const cursorInterval = setInterval(() => {
|
||||
// Cursor blink: 530ms interval
|
||||
cursorIntervalRef.current = setInterval(() => {
|
||||
setShowCursor(prev => !prev)
|
||||
}, 530)
|
||||
|
||||
startLoginSequence()
|
||||
|
||||
return () => clearInterval(cursorInterval)
|
||||
|
||||
// Delay start slightly for card entrance
|
||||
const startTimeout = setTimeout(() => {
|
||||
startLoginSequence()
|
||||
}, 200)
|
||||
|
||||
return () => {
|
||||
if (cursorIntervalRef.current) clearInterval(cursorIntervalRef.current)
|
||||
if (usernameIntervalRef.current) clearInterval(usernameIntervalRef.current)
|
||||
if (passwordIntervalRef.current) clearInterval(passwordIntervalRef.current)
|
||||
clearTimeout(startTimeout)
|
||||
}
|
||||
}, [startLoginSequence])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center z-50"
|
||||
style={{ backgroundColor: '#1E293B' }}
|
||||
role="status"
|
||||
aria-label="Clinical system login"
|
||||
>
|
||||
<motion.div
|
||||
className="bg-white p-8"
|
||||
className="bg-white"
|
||||
style={{
|
||||
width: '320px',
|
||||
padding: '32px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15), 0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #E5E7EB',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.03)',
|
||||
}}
|
||||
initial={{ opacity: 0 }}
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={isExiting ? { scale: 1.03, opacity: 0 } : { scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
{/* Branding */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
{/* Branding Header */}
|
||||
<div
|
||||
className="flex flex-col items-center"
|
||||
style={{ marginBottom: '28px' }}
|
||||
>
|
||||
<div
|
||||
className="p-3 rounded-lg mb-3"
|
||||
style={{ backgroundColor: 'rgba(0, 94, 184, 0.08)' }}
|
||||
style={{
|
||||
padding: '10px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'rgba(0, 94, 184, 0.07)',
|
||||
marginBottom: '10px',
|
||||
}}
|
||||
>
|
||||
<Shield
|
||||
size={28}
|
||||
size={26}
|
||||
style={{ color: '#005EB8' }}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontFamily: "'Inter', system-ui, sans-serif",
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
color: '#64748B',
|
||||
@@ -132,7 +160,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontFamily: "'Inter', system-ui, sans-serif",
|
||||
fontSize: '11px',
|
||||
fontWeight: 400,
|
||||
color: '#94A3B8',
|
||||
@@ -144,13 +172,13 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div className="space-y-5">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{/* Username Field */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontFamily: "'Inter', system-ui, sans-serif",
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: '#64748B',
|
||||
@@ -162,26 +190,24 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
fontFamily: "'Geist Mono', 'Courier New', monospace",
|
||||
padding: '9px 11px',
|
||||
fontFamily: "'Geist Mono', 'Fira Code', monospace",
|
||||
fontSize: '13px',
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #D1D5DB',
|
||||
backgroundColor: activeField === 'username' ? '#FFFFFF' : '#FAFAFA',
|
||||
border: activeField === 'username' ? '1px solid #005EB8' : '1px solid #E5E7EB',
|
||||
borderRadius: '4px',
|
||||
color: '#111827',
|
||||
minHeight: '38px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'background-color 150ms ease-out, border-color 150ms ease-out',
|
||||
}}
|
||||
>
|
||||
<span>{username}</span>
|
||||
{isTypingUsername && (
|
||||
{activeField === 'username' && (
|
||||
<span
|
||||
style={{
|
||||
opacity: showCursor ? 1 : 0,
|
||||
color: '#005EB8',
|
||||
marginLeft: '1px',
|
||||
}}
|
||||
style={{ opacity: showCursor ? 1 : 0, color: '#005EB8' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
|
||||
</span>
|
||||
@@ -194,7 +220,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontFamily: "'Inter', system-ui, sans-serif",
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: '#64748B',
|
||||
@@ -206,27 +232,25 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
fontFamily: "'Geist Mono', 'Courier New', monospace",
|
||||
padding: '9px 11px',
|
||||
fontFamily: "'Geist Mono', 'Fira Code', monospace",
|
||||
fontSize: '13px',
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #D1D5DB',
|
||||
backgroundColor: activeField === 'password' ? '#FFFFFF' : '#FAFAFA',
|
||||
border: activeField === 'password' ? '1px solid #005EB8' : '1px solid #E5E7EB',
|
||||
borderRadius: '4px',
|
||||
color: '#111827',
|
||||
letterSpacing: '0.15em',
|
||||
minHeight: '38px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'background-color 150ms ease-out, border-color 150ms ease-out',
|
||||
}}
|
||||
>
|
||||
<span>{'\u2022'.repeat(passwordDots)}</span>
|
||||
{isTypingPassword && (
|
||||
{activeField === 'password' && (
|
||||
<span
|
||||
style={{
|
||||
opacity: showCursor ? 1 : 0,
|
||||
color: '#005EB8',
|
||||
marginLeft: '2px',
|
||||
}}
|
||||
style={{ opacity: showCursor ? 1 : 0, color: '#005EB8' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
|
||||
</span>
|
||||
@@ -238,8 +262,8 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
<button
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '11px 16px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
padding: '10px 16px',
|
||||
fontFamily: "'Inter', system-ui, sans-serif",
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: '#FFFFFF',
|
||||
@@ -248,7 +272,6 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 100ms ease-out',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
Log In
|
||||
@@ -258,18 +281,17 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
{/* Footer */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: '24px',
|
||||
paddingTop: '20px',
|
||||
marginTop: '22px',
|
||||
paddingTop: '18px',
|
||||
borderTop: '1px solid #E5E7EB',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontFamily: "'Inter', system-ui, sans-serif",
|
||||
fontSize: '11px',
|
||||
color: '#94A3B8',
|
||||
textAlign: 'center',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
>
|
||||
Secure clinical system login
|
||||
|
||||
Reference in New Issue
Block a user