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:
Indiana
2026-07-21 00:02:41 +00:00
parent 6edbbbbc2a
commit c372427ced
16 changed files with 1086 additions and 16 deletions

View 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>
)
}