Files
qtalker---/frontend/src/components/MatrixView.tsx
Indiana c372427ced feat: matrix/graph data views, wire-generated anomalies, http cookie fix
- auth: session cookie Secure only over https — plain-http LAN access was
  silently dropping the cookie, killing WS auth ('connection unstable')
- wire ghost: server-side spike detection on jitter baseline (3σ + 2.5×mean,
  20KB/s floor, 20s throttle) — wire anomalies now flood every session with
  zero hardware, pushed to clients as {type:'anomaly'} frames
- telemetry cadence 3-5s for live graphs; ambient whispers unchanged
- frontend: MATRIX view (data-rain interleaved with live utterances/anomaly/
  telemetry strings), GRAPHS view (scrolling jitter/variance/dns lines +
  anomaly markers + counters), BOARD/MATRIX/GRAPHS switcher
- connection banner: 'connecting' is now neutral 'tuning the veil…', only
  unstable/closed warns
- db: recreated quantumancy(+_test) as UTF8 (was SQL_ASCII — crashed on
  non-ASCII spirit text); README quickstart updated
- i18n: seance.views.* EN/ES
2026-07-21 00:02:41 +00:00

246 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<T>(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<AnomalyEntry, 'frequency' | 'magnitude'>): 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<AnomalyEntry, 'at'>[],
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<HTMLCanvasElement | null>(null)
// Live data flows into the rAF loop through refs (no re-render per frame).
const ringRef = useRef<string[]>([])
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 (
<div className="matrix-view">
<canvas ref={canvasRef} aria-hidden />
{listening && (
<p className="matrix-listening flicker" role="status">
{t('seance.views.listening', { defaultValue: 'listening…' })}
</p>
)}
</div>
)
}