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
This commit is contained in:
245
frontend/src/components/MatrixView.tsx
Normal file
245
frontend/src/components/MatrixView.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user