// MatrixView — a "matrix rain" of spirit data on canvas 2D. // Classic falling-glyph columns (katakana / latin / digits / runes, phosphor // green heads with fading trails) interleaved with slower violet 'data drops' // that carry REAL live strings: utterance words, anomaly readings, telemetry // values and the summoned entity's name. Roughly every 8s one column flashes // blood-red for a beat — the veil twitching. Rain density scales gently with // the recent anomaly rate. import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import type { AnomalyEntry, Utterance } from '../state/seance' import type { Telemetry } from '../lib/types' import './MatrixView.css' const GLYPHS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZᚠᚢᚦᚨᚱᚲᚷᚹᚺᚾᛁᛃᛇᛈᛉᛊᛏᛒᛖᛗᛚᛜᛞᛟ' const CELL = 16 /** How many recent data strings the rain remembers. */ export const DATA_CAP = 24 const FLASH_EVERY_MS = 8000 const FLASH_LEN_MS = 550 const ANOMALY_WINDOW_MS = 10_000 const MAX_DATA_DROPS = 4 // ---- pure helpers (unit-tested without canvas) ---- /** Append to a bounded ring buffer, dropping the oldest entry when full. */ export function pushRing(ring: T[], item: T, cap: number): T[] { const out = ring.length >= cap ? ring.slice(ring.length - cap + 1) : [...ring] out.push(item) return out } /** `101.4MHZ +8.2DB` — the string a data drop carries for one anomaly. */ export function anomalyString(a: Pick): string { const sign = a.magnitude >= 0 ? '+' : '-' return `${a.frequency.toFixed(1)}MHZ ${sign}${Math.abs(a.magnitude).toFixed(1)}DB` } /** Extract an uppercase single word suitable for a data drop, else null. */ export function utteranceWord(text: string): string | null { const words = text.toUpperCase().split(/[^A-Z0-9ᚠ-ᛟ]+/).filter((w) => w.length >= 3 && w.length <= 12) if (words.length === 0) return null return words[Math.floor(words.length / 2)] // middle word reads best } /** * Build the ring buffer of recent live-data strings the rain draws from: * recent utterance words, anomaly readings, telemetry values, entity name. */ export function collectDataStrings(input: { anomalies: AnomalyEntry[] utterances: Utterance[] telemetry: Telemetry | null entityName: string | null }): string[] { let ring: string[] = [] const push = (s: string | null) => { if (s) ring = pushRing(ring, s, DATA_CAP) } for (const u of input.utterances.slice(-6)) push(utteranceWord(u.text)) for (const a of input.anomalies.slice(-6)) push(anomalyString(a)) if (input.telemetry) { push(`JIT ${input.telemetry.jitter_bytes_per_s.toFixed(1)}KB/S`) push(`RTT ${input.telemetry.latency_mean_ms.toFixed(1)}MS`) } if (input.entityName) push(input.entityName.toUpperCase()) return ring } /** * Rain density 0..1 from the recent anomaly rate — more anomalies, more rain. * Saturates at ~1 anomaly per second over the window. */ export function anomalyDensity( anomalies: Pick[], now: number, windowMs = ANOMALY_WINDOW_MS, ): number { let recent = 0 for (const a of anomalies) if (now - a.at <= windowMs) recent++ return Math.min(1, recent / (windowMs / 1000)) } // ---- component ---- export type MatrixViewProps = { anomalies: AnomalyEntry[] utterances: Utterance[] telemetry: Telemetry | null entityName: string | null /** When false, the rAF loop is paused (view hidden). */ active: boolean } type Drop = { col: number; y: number; speed: number; text: string } export function MatrixView({ anomalies, utterances, telemetry, entityName, active }: MatrixViewProps) { const { t } = useTranslation() const canvasRef = useRef(null) // Live data flows into the rAF loop through refs (no re-render per frame). const ringRef = useRef([]) const anomaliesRef = useRef(anomalies) anomaliesRef.current = anomalies useEffect(() => { ringRef.current = collectDataStrings({ anomalies, utterances, telemetry, entityName }) }, [anomalies, utterances, telemetry, entityName]) // Reactive empty-state: nothing has whispered through the wire yet. const listening = anomalies.length === 0 && utterances.length === 0 && telemetry === null && entityName === null useEffect(() => { if (!active) return const canvas = canvasRef.current if (!canvas) return const ctx = canvas.getContext('2d') if (!ctx) return let raf = 0 let w = 0 let h = 0 let cols = 0 let heads: number[] = [] let speeds: number[] = [] let drops: Drop[] = [] let flashCol = -1 let flashAt = 0 let nextFlash = performance.now() + FLASH_EVERY_MS const resize = () => { const parent = canvas.parentElement if (!parent) return const dpr = Math.min(window.devicePixelRatio, 2) w = parent.clientWidth h = parent.clientHeight if (w === 0 || h === 0) return canvas.width = w * dpr canvas.height = h * dpr canvas.style.width = `${w}px` canvas.style.height = `${h}px` ctx.setTransform(dpr, 0, 0, dpr, 0, 0) ctx.fillStyle = '#07070d' ctx.fillRect(0, 0, w, h) cols = Math.max(1, Math.floor(w / CELL)) heads = Array.from({ length: cols }, () => Math.random() * (h / CELL)) speeds = Array.from({ length: cols }, () => 0.4 + Math.random() * 0.8) drops = [] } resize() const ro = new ResizeObserver(resize) if (canvas.parentElement) ro.observe(canvas.parentElement) const draw = (now: number) => { raf = requestAnimationFrame(draw) if (w === 0 || h === 0) return const density = 0.3 + 0.7 * anomalyDensity(anomaliesRef.current, now) // Fading trail: translucent veil over the previous frame. ctx.fillStyle = 'rgba(7, 7, 13, 0.09)' ctx.fillRect(0, 0, w, h) ctx.font = `${CELL - 2}px "IBM Plex Mono", monospace` ctx.textAlign = 'center' ctx.textBaseline = 'middle' // The veil twitch: pick a column to burn red for a beat. if (now >= nextFlash) { flashCol = Math.floor(Math.random() * cols) flashAt = now nextFlash = now + FLASH_EVERY_MS * (0.75 + Math.random() * 0.5) } if (flashCol >= 0 && now - flashAt >= FLASH_LEN_MS) flashCol = -1 // --- glyph rain --- for (let i = 0; i < cols; i++) { if (Math.random() > density) continue // density gates column activity const x = i * CELL + CELL / 2 const y = heads[i] * CELL const ch = GLYPHS[Math.floor(Math.random() * GLYPHS.length)] if (i === flashCol) { ctx.fillStyle = 'rgba(255, 59, 92, 0.07)' ctx.fillRect(i * CELL, 0, CELL, h) ctx.shadowColor = '#ff3b5c' ctx.shadowBlur = 14 ctx.fillStyle = '#ff3b5c' ctx.fillText(ch, x, y) ctx.shadowBlur = 0 } else { ctx.fillStyle = '#7cffb2' ctx.fillText(ch, x, y) } heads[i] += speeds[i] if (y > h && Math.random() < 0.02 + density * 0.04) heads[i] = 0 } // --- violet data drops: live strings riding the rain --- const ring = ringRef.current if (drops.length < MAX_DATA_DROPS && ring.length > 0 && Math.random() < 0.025) { drops.push({ col: Math.floor(Math.random() * cols), y: -CELL, speed: 1 + Math.random() * 1.2, // slower than the rain text: ring[Math.floor(Math.random() * ring.length)], }) } ctx.shadowColor = '#b26bff' ctx.shadowBlur = 9 for (let i = drops.length - 1; i >= 0; i--) { const d = drops[i] d.y += d.speed const chars = d.text.slice(0, 16) const x = d.col * CELL + CELL / 2 // Reads top → bottom; the leading (bottom) glyph burns brightest. for (let j = 0; j < chars.length; j++) { const cy = d.y - (chars.length - 1 - j) * CELL if (cy < -CELL || cy > h + CELL) continue ctx.fillStyle = '#b26bff' ctx.globalAlpha = j === chars.length - 1 ? 1 : 0.2 + (j / chars.length) * 0.6 ctx.fillText(chars[j], x, cy) } ctx.globalAlpha = 1 if (d.y - chars.length * CELL > h) drops.splice(i, 1) } ctx.shadowBlur = 0 } raf = requestAnimationFrame(draw) return () => { cancelAnimationFrame(raf) ro.disconnect() } }, [active]) return (
{listening && (

{t('seance.views.listening', { defaultValue: 'listening…' })}

)}
) }