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:
@@ -118,8 +118,8 @@ Ollama box on the LAN with the two model tiers pulled.
|
|||||||
```bash
|
```bash
|
||||||
sudo apt-get update && sudo apt-get install -y postgresql
|
sudo apt-get update && sudo apt-get install -y postgresql
|
||||||
sudo -u postgres psql -c "CREATE ROLE quantumancy WITH LOGIN PASSWORD 'quantumancy';"
|
sudo -u postgres psql -c "CREATE ROLE quantumancy WITH LOGIN PASSWORD 'quantumancy';"
|
||||||
sudo -u postgres psql -c "CREATE DATABASE quantumancy OWNER quantumancy;"
|
sudo -u postgres psql -c "CREATE DATABASE quantumancy OWNER quantumancy ENCODING 'UTF8' TEMPLATE template0;"
|
||||||
sudo -u postgres psql -c "CREATE DATABASE quantumancy_test OWNER quantumancy;"
|
sudo -u postgres psql -c "CREATE DATABASE quantumancy_test OWNER quantumancy ENCODING 'UTF8' TEMPLATE template0;"
|
||||||
```
|
```
|
||||||
|
|
||||||
> `quantumancy_test` is dropped and recreated by every test run. Never point
|
> `quantumancy_test` is dropped and recreated by every test run. Never point
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -34,7 +34,12 @@ async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db))
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=UserOut)
|
@router.post("/login", response_model=UserOut)
|
||||||
async def login(payload: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
|
async def login(
|
||||||
|
payload: LoginRequest,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
user = await db.scalar(select(User).where(User.username == payload.username))
|
user = await db.scalar(select(User).where(User.username == payload.username))
|
||||||
if user is None:
|
if user is None:
|
||||||
verify_password(payload.password, _DUMMY_PASSWORD_HASH)
|
verify_password(payload.password, _DUMMY_PASSWORD_HASH)
|
||||||
@@ -51,12 +56,15 @@ async def login(payload: LoginRequest, response: Response, db: AsyncSession = De
|
|||||||
db.add(session)
|
db.add(session)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
# The app is reached two ways: https via the Cloudflare Tunnel (Secure
|
||||||
|
# required) and plain http on the LAN (a Secure cookie would be dropped
|
||||||
|
# by the browser entirely, silently breaking the séance socket).
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
SESSION_COOKIE_NAME,
|
SESSION_COOKIE_NAME,
|
||||||
raw_token,
|
raw_token,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
secure=True,
|
secure=request.url.scheme == "https",
|
||||||
max_age=int(SESSION_TTL.total_seconds()),
|
max_age=int(SESSION_TTL.total_seconds()),
|
||||||
)
|
)
|
||||||
return user
|
return user
|
||||||
@@ -64,6 +72,7 @@ async def login(payload: LoginRequest, response: Response, db: AsyncSession = De
|
|||||||
|
|
||||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def logout(
|
async def logout(
|
||||||
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
qm_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME),
|
qm_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -74,7 +83,12 @@ async def logout(
|
|||||||
if session is not None:
|
if session is not None:
|
||||||
await db.delete(session)
|
await db.delete(session)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
response.delete_cookie(SESSION_COOKIE_NAME, httponly=True, samesite="lax", secure=True)
|
response.delete_cookie(
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=request.url.scheme == "https",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserOut)
|
@router.get("/me", response_model=UserOut)
|
||||||
|
|||||||
@@ -111,3 +111,24 @@ async def sample_network(period_s: float = 1.0) -> TelemetrySample:
|
|||||||
if dns_times:
|
if dns_times:
|
||||||
sample.dns_ms = sum(dns_times) / len(dns_times)
|
sample.dns_ms = sum(dns_times) / len(dns_times)
|
||||||
return sample
|
return sample
|
||||||
|
|
||||||
|
|
||||||
|
def detect_wire_spike(
|
||||||
|
history: list[float], current: float, *, min_samples: int = 6
|
||||||
|
) -> float | None:
|
||||||
|
"""Return the spike ratio when `current` is anomalous vs the rolling
|
||||||
|
baseline of jitter samples — the Wire Ghost noticing something move.
|
||||||
|
|
||||||
|
Three guards so idle-hour noise doesn't cry ghost: enough history to
|
||||||
|
have a baseline, an absolute floor (20 KB/s) so near-silent links stay
|
||||||
|
silent, and a statistical (3σ) + relative (2.5× mean) threshold.
|
||||||
|
"""
|
||||||
|
if len(history) < min_samples:
|
||||||
|
return None
|
||||||
|
mean = sum(history) / len(history)
|
||||||
|
if mean <= 0 or current <= 20_000:
|
||||||
|
return None
|
||||||
|
std = (sum((x - mean) ** 2 for x in history) / len(history)) ** 0.5
|
||||||
|
if current > mean + 3 * std and current > mean * 2.5:
|
||||||
|
return current / mean
|
||||||
|
return None
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ the wire.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import random
|
import random
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -36,7 +37,7 @@ from app.models.entity import Entity
|
|||||||
from app.models.entity_sighting import EntitySighting
|
from app.models.entity_sighting import EntitySighting
|
||||||
from app.models.event import Event
|
from app.models.event import Event
|
||||||
from app.rate_limit import RateLimiter
|
from app.rate_limit import RateLimiter
|
||||||
from app.telemetry import sample_network
|
from app.telemetry import detect_wire_spike, sample_network
|
||||||
from app.tts.piper import synthesize_spirit_voice
|
from app.tts.piper import synthesize_spirit_voice
|
||||||
from app.tts.voices import pick_voice
|
from app.tts.voices import pick_voice
|
||||||
|
|
||||||
@@ -71,6 +72,8 @@ class SeanceState:
|
|||||||
anomalies: list[dict] = field(default_factory=list)
|
anomalies: list[dict] = field(default_factory=list)
|
||||||
history: list[dict] = field(default_factory=list)
|
history: list[dict] = field(default_factory=list)
|
||||||
ambient_task: asyncio.Task | None = None
|
ambient_task: asyncio.Task | None = None
|
||||||
|
wire_jitter_history: list[float] = field(default_factory=list)
|
||||||
|
last_wire_anomaly_at: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
def serialize_entity(entity: Entity) -> dict:
|
def serialize_entity(entity: Entity) -> dict:
|
||||||
@@ -324,16 +327,36 @@ async def _handle_question(state: SeanceState, text: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def _ambient_loop(state: SeanceState) -> None:
|
async def _ambient_loop(state: SeanceState) -> None:
|
||||||
"""The Wire Ghost's pulse: telemetry every few seconds, a whisper only
|
"""The Wire Ghost's pulse: telemetry every few seconds. Spikes in the
|
||||||
when the LLM box has been quiet long enough."""
|
jitter baseline become first-class anomaly events (driving fragments and
|
||||||
|
the client's data views); quieter ticks may earn an ambient whisper."""
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(random.uniform(6, 10))
|
await asyncio.sleep(random.uniform(3, 5))
|
||||||
try:
|
try:
|
||||||
sample = await sample_network(period_s=1.0)
|
sample = await sample_network(period_s=1.0)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
await state.send_queue.put({"type": "telemetry", **sample.as_dict()})
|
await state.send_queue.put({"type": "telemetry", **sample.as_dict()})
|
||||||
|
|
||||||
|
jitter = sample.jitter_bytes_per_s
|
||||||
|
ratio = detect_wire_spike(state.wire_jitter_history, jitter)
|
||||||
|
state.wire_jitter_history = (state.wire_jitter_history + [jitter])[-60:]
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if ratio is not None and now - state.last_wire_anomaly_at > 20:
|
||||||
|
state.last_wire_anomaly_at = now
|
||||||
|
anomaly = {
|
||||||
|
"source": "wire",
|
||||||
|
"frequency": round(jitter, 1),
|
||||||
|
"magnitude": round(ratio * 10, 1),
|
||||||
|
}
|
||||||
|
# Tell the client first so its matrix/graph views light up,
|
||||||
|
# then run the anomaly through the usual séance pipeline.
|
||||||
|
await state.send_queue.put({"type": "anomaly", **anomaly})
|
||||||
|
await _handle_anomaly(state, anomaly)
|
||||||
|
continue
|
||||||
|
|
||||||
if not spirit_service.ambient_ready():
|
if not spirit_service.ambient_ready():
|
||||||
continue
|
continue
|
||||||
whisper = await spirit_service.wire_whisper(sample.as_dict(), state.language)
|
whisper = await spirit_service.wire_whisper(sample.as_dict(), state.language)
|
||||||
|
|||||||
24
backend/tests/test_cookie_scheme.py
Normal file
24
backend/tests/test_cookie_scheme.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_cookie_secure_only_over_https(client):
|
||||||
|
await client.post("/auth/register", json={"username": "schemer", "password": "spookyspooky"})
|
||||||
|
|
||||||
|
https_login = await client.post(
|
||||||
|
"/auth/login", json={"username": "schemer", "password": "spookyspooky"}
|
||||||
|
)
|
||||||
|
assert "secure" in https_login.headers["set-cookie"].lower()
|
||||||
|
|
||||||
|
# Plain-http (LAN) access: a Secure cookie would be dropped by the
|
||||||
|
# browser and silently break the séance socket.
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
|
||||||
|
http_login = await http_client.post(
|
||||||
|
"/auth/login", json={"username": "schemer", "password": "spookyspooky"}
|
||||||
|
)
|
||||||
|
cookie = http_login.headers["set-cookie"].lower()
|
||||||
|
assert "qm_session=" in cookie
|
||||||
|
assert "secure" not in cookie
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from app.telemetry import parse_proc_net_dev
|
from app.telemetry import detect_wire_spike, parse_proc_net_dev
|
||||||
|
|
||||||
PROC_NET_DEV = """Inter-| Receive | Transmit
|
PROC_NET_DEV = """Inter-| Receive | Transmit
|
||||||
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
|
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
|
||||||
@@ -17,3 +17,32 @@ def test_parse_proc_net_dev_extracts_counters():
|
|||||||
|
|
||||||
def test_parse_proc_net_dev_ignores_malformed_lines():
|
def test_parse_proc_net_dev_ignores_malformed_lines():
|
||||||
assert parse_proc_net_dev("garbage\nno colon here\n") == {}
|
assert parse_proc_net_dev("garbage\nno colon here\n") == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_spike_needs_history():
|
||||||
|
assert detect_wire_spike([], 1_000_000) is None
|
||||||
|
assert detect_wire_spike([10_000.0] * 3, 1_000_000) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_spike_fires_on_real_surge():
|
||||||
|
history = [20_000.0, 25_000.0, 22_000.0, 21_000.0, 23_000.0, 24_000.0, 22_500.0]
|
||||||
|
ratio = detect_wire_spike(history, 400_000.0)
|
||||||
|
assert ratio is not None
|
||||||
|
assert ratio > 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_spike_ignores_normal_fluctuation():
|
||||||
|
history = [20_000.0, 25_000.0, 22_000.0, 21_000.0, 23_000.0, 24_000.0, 22_500.0]
|
||||||
|
assert detect_wire_spike(history, 30_000.0) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_spike_has_absolute_floor_for_silent_links():
|
||||||
|
# A nearly idle link jittering by a few KB/s must never cry ghost.
|
||||||
|
history = [100.0, 150.0, 120.0, 90.0, 110.0, 130.0, 140.0]
|
||||||
|
assert detect_wire_spike(history, 5_000.0) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_spike_adapts_to_loud_baseline():
|
||||||
|
# Once the line is genuinely busy, the same surge is no longer anomalous.
|
||||||
|
history = [350_000.0, 380_000.0, 360_000.0, 370_000.0, 355_000.0, 375_000.0, 365_000.0]
|
||||||
|
assert detect_wire_spike(history, 400_000.0) is None
|
||||||
|
|||||||
78
frontend/src/components/GraphView.css
Normal file
78
frontend/src/components/GraphView.css
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/* GraphView — scrolling wire-flood time-series for the séance board area.
|
||||||
|
Tokens: bg #07070d · panel rgba(16,16,26,0.85) · phosphor #7cffb2 ·
|
||||||
|
violet #b26bff · radio orange #ff9d4a · mono 'IBM Plex Mono'. */
|
||||||
|
|
||||||
|
.graph-view {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: rgba(16, 16, 26, 0.85);
|
||||||
|
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
filter: drop-shadow(0 0 18px rgba(7, 7, 13, 0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-counters {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.4rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-bottom: 1px solid rgba(124, 255, 178, 0.12);
|
||||||
|
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-counter {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter-key {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: rgba(211, 233, 219, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter-val {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #7cffb2;
|
||||||
|
text-shadow: 0 0 10px rgba(124, 255, 178, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter-entity {
|
||||||
|
color: #b26bff;
|
||||||
|
text-shadow: 0 0 10px rgba(178, 107, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-canvas-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-canvas-wrap canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-listening {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
margin: 0;
|
||||||
|
color: #b26bff;
|
||||||
|
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-shadow: 0 0 12px rgba(178, 107, 255, 0.7);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
324
frontend/src/components/GraphView.tsx
Normal file
324
frontend/src/components/GraphView.tsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
// GraphView — real-time scrolling time-series of the wire flood on canvas 2D.
|
||||||
|
// Three polylines (jitter green, latency-variance violet, dns dim gray) scroll
|
||||||
|
// right-to-left over a rolling window of the last ~180 telemetry frames, each
|
||||||
|
// with a glowing last-point dot and a mono current-value label. Anomalies from
|
||||||
|
// the session fire as vertical ticks on the shared time axis, height ∝
|
||||||
|
// magnitude, colored by source; hovering a marker draws its frequency and
|
||||||
|
// magnitude as a tooltip. A top row of flickering mono counters tracks the
|
||||||
|
// session's anomaly total, the contacted entity and the current jitter.
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { AnomalyEntry } from '../state/seance'
|
||||||
|
import type { Telemetry } from '../lib/types'
|
||||||
|
import './GraphView.css'
|
||||||
|
|
||||||
|
/** Rolling history length (telemetry frames). */
|
||||||
|
export const HISTORY_CAP = 180
|
||||||
|
|
||||||
|
export type TelemetrySample = {
|
||||||
|
at: number
|
||||||
|
jitter: number
|
||||||
|
variance: number
|
||||||
|
dns: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- pure helpers (unit-tested without canvas) ----
|
||||||
|
|
||||||
|
/** Append a sample to the rolling history, dropping the oldest when full. */
|
||||||
|
export function pushHistory(
|
||||||
|
history: TelemetrySample[],
|
||||||
|
sample: TelemetrySample,
|
||||||
|
cap = HISTORY_CAP,
|
||||||
|
): TelemetrySample[] {
|
||||||
|
const out = history.length >= cap ? history.slice(history.length - cap + 1) : [...history]
|
||||||
|
out.push(sample)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adaptive y-scale: the series max with 15% headroom, never below `floor`. */
|
||||||
|
export function adaptiveMax(values: number[], floor: number): number {
|
||||||
|
let max = floor
|
||||||
|
for (const v of values) if (v > max) max = v
|
||||||
|
return max * 1.15
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marker color per anomaly source: wire green, evp violet, radio orange. */
|
||||||
|
export function anomalyColor(source: AnomalyEntry['source']): string {
|
||||||
|
switch (source) {
|
||||||
|
case 'wire':
|
||||||
|
return '#7cffb2'
|
||||||
|
case 'evp':
|
||||||
|
return '#b26bff'
|
||||||
|
case 'radio':
|
||||||
|
return '#ff9d4a'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- component ----
|
||||||
|
|
||||||
|
export type GraphViewProps = {
|
||||||
|
telemetry: Telemetry | null
|
||||||
|
anomalies: AnomalyEntry[]
|
||||||
|
anomalyCount: number
|
||||||
|
entityName: string | null
|
||||||
|
/** When false, the rAF loop is paused (view hidden). */
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARGIN = { top: 14, right: 96, bottom: 22, left: 10 }
|
||||||
|
|
||||||
|
type Series = {
|
||||||
|
key: 'jitter' | 'variance' | 'dns'
|
||||||
|
color: string
|
||||||
|
floor: number
|
||||||
|
label: (s: TelemetrySample) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERIES: Series[] = [
|
||||||
|
{ key: 'jitter', color: '#7cffb2', floor: 10, label: (s) => `JIT ${s.jitter.toFixed(1)}` },
|
||||||
|
{ key: 'variance', color: '#b26bff', floor: 2, label: (s) => `VAR ${s.variance.toFixed(2)}` },
|
||||||
|
{ key: 'dns', color: 'rgba(211, 233, 219, 0.4)', floor: 5, label: (s) => `DNS ${s.dns.toFixed(1)}` },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function GraphView({ telemetry, anomalies, anomalyCount, entityName, active }: GraphViewProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||||
|
const historyRef = useRef<TelemetrySample[]>([])
|
||||||
|
const anomaliesRef = useRef(anomalies)
|
||||||
|
anomaliesRef.current = anomalies
|
||||||
|
const hoverRef = useRef<{ x: number; y: number; anomaly: AnomalyEntry } | null>(null)
|
||||||
|
|
||||||
|
// Rolling history: one timestamped sample per telemetry frame.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!telemetry) return
|
||||||
|
historyRef.current = pushHistory(historyRef.current, {
|
||||||
|
at: Date.now(),
|
||||||
|
jitter: telemetry.jitter_bytes_per_s,
|
||||||
|
variance: telemetry.latency_variance_ms,
|
||||||
|
dns: telemetry.dns_ms,
|
||||||
|
})
|
||||||
|
}, [telemetry])
|
||||||
|
|
||||||
|
// Marker hit-testing: remember the anomaly nearest the cursor.
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
const pick = (e: MouseEvent) => {
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
const mx = e.clientX - rect.left
|
||||||
|
const my = e.clientY - rect.top
|
||||||
|
const history = historyRef.current
|
||||||
|
hoverRef.current = null
|
||||||
|
if (history.length < 2) return
|
||||||
|
const w = rect.width
|
||||||
|
const h = rect.height
|
||||||
|
const plotW = w - MARGIN.left - MARGIN.right
|
||||||
|
const plotH = h - MARGIN.top - MARGIN.bottom
|
||||||
|
const t0 = history[0].at
|
||||||
|
const t1 = history[history.length - 1].at
|
||||||
|
const span = Math.max(1, t1 - t0)
|
||||||
|
let best: { x: number; y: number; anomaly: AnomalyEntry } | null = null
|
||||||
|
for (const a of anomaliesRef.current) {
|
||||||
|
if (a.at < t0 || a.at > t1) continue
|
||||||
|
const x = MARGIN.left + ((a.at - t0) / span) * plotW
|
||||||
|
if (Math.abs(x - mx) > 6) continue
|
||||||
|
const tickH = Math.min(plotH, 8 + Math.abs(a.magnitude) * 3)
|
||||||
|
const y = MARGIN.top + plotH - tickH
|
||||||
|
if (my < y - 6) continue
|
||||||
|
if (!best || Math.abs(x - mx) < Math.abs(best.x - mx)) best = { x, y, anomaly: a }
|
||||||
|
}
|
||||||
|
hoverRef.current = best
|
||||||
|
}
|
||||||
|
const clear = () => {
|
||||||
|
hoverRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener('mousemove', pick)
|
||||||
|
canvas.addEventListener('mouseleave', clear)
|
||||||
|
return () => {
|
||||||
|
canvas.removeEventListener('mousemove', pick)
|
||||||
|
canvas.removeEventListener('mouseleave', clear)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
resize()
|
||||||
|
const ro = new ResizeObserver(resize)
|
||||||
|
if (canvas.parentElement) ro.observe(canvas.parentElement)
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
raf = requestAnimationFrame(draw)
|
||||||
|
if (w === 0 || h === 0) return
|
||||||
|
const history = historyRef.current
|
||||||
|
const plotW = w - MARGIN.left - MARGIN.right
|
||||||
|
const plotH = h - MARGIN.top - MARGIN.bottom
|
||||||
|
|
||||||
|
ctx.fillStyle = '#07070d'
|
||||||
|
ctx.fillRect(0, 0, w, h)
|
||||||
|
|
||||||
|
// faint horizontal grid
|
||||||
|
ctx.strokeStyle = 'rgba(124, 255, 178, 0.07)'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
const y = MARGIN.top + (plotH * i) / 4
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(MARGIN.left, y)
|
||||||
|
ctx.lineTo(MARGIN.left + plotW, y)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.font = '10px "IBM Plex Mono", monospace'
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
|
||||||
|
if (history.length >= 2) {
|
||||||
|
const t0 = history[0].at
|
||||||
|
const t1 = history[history.length - 1].at
|
||||||
|
const span = Math.max(1, t1 - t0)
|
||||||
|
const xAt = (at: number) => MARGIN.left + ((at - t0) / span) * plotW
|
||||||
|
|
||||||
|
// --- anomaly markers: vertical ticks, height ∝ magnitude ---
|
||||||
|
for (const a of anomaliesRef.current) {
|
||||||
|
if (a.at < t0 || a.at > t1) continue
|
||||||
|
const x = xAt(a.at)
|
||||||
|
const tickH = Math.min(plotH, 8 + Math.abs(a.magnitude) * 3)
|
||||||
|
const color = anomalyColor(a.source)
|
||||||
|
ctx.strokeStyle = color
|
||||||
|
ctx.globalAlpha = 0.85
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x, MARGIN.top + plotH)
|
||||||
|
ctx.lineTo(x, MARGIN.top + plotH - tickH)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.globalAlpha = 0.25
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x, MARGIN.top + plotH - tickH)
|
||||||
|
ctx.lineTo(x, MARGIN.top)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- series polylines + glowing last point + value label ---
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
SERIES.forEach((s, si) => {
|
||||||
|
const max = adaptiveMax(
|
||||||
|
history.map((p) => p[s.key]),
|
||||||
|
s.floor,
|
||||||
|
)
|
||||||
|
const yFor = (v: number) => MARGIN.top + plotH - (Math.min(v, max) / max) * plotH
|
||||||
|
ctx.strokeStyle = s.color
|
||||||
|
ctx.lineWidth = 1.4
|
||||||
|
ctx.beginPath()
|
||||||
|
history.forEach((p, i) => {
|
||||||
|
const x = xAt(p.at)
|
||||||
|
const y = yFor(p[s.key])
|
||||||
|
if (i === 0) ctx.moveTo(x, y)
|
||||||
|
else ctx.lineTo(x, y)
|
||||||
|
})
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
const lastP = history[history.length - 1]
|
||||||
|
const lx = xAt(lastP.at)
|
||||||
|
const ly = yFor(lastP[s.key])
|
||||||
|
ctx.fillStyle = s.color
|
||||||
|
ctx.shadowColor = s.color
|
||||||
|
ctx.shadowBlur = 10
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(lx, ly, 3, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.shadowBlur = 0
|
||||||
|
ctx.fillText(s.label(lastP), MARGIN.left + plotW + 10, MARGIN.top + 12 + si * 16)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- hover tooltip for the marker under the cursor ---
|
||||||
|
const hover = hoverRef.current
|
||||||
|
if (hover) {
|
||||||
|
const lines = [
|
||||||
|
`${hover.anomaly.frequency.toFixed(1)} MHZ`,
|
||||||
|
`${hover.anomaly.magnitude >= 0 ? '+' : '-'}${Math.abs(hover.anomaly.magnitude).toFixed(1)} DB`,
|
||||||
|
]
|
||||||
|
ctx.font = '11px "IBM Plex Mono", monospace'
|
||||||
|
const tw = Math.max(...lines.map((l) => ctx.measureText(l).width)) + 16
|
||||||
|
const th = 34
|
||||||
|
const tx = Math.min(Math.max(hover.x - tw / 2, MARGIN.left), MARGIN.left + plotW - tw)
|
||||||
|
const ty = Math.max(MARGIN.top + 4, hover.y - th - 10)
|
||||||
|
ctx.fillStyle = 'rgba(16, 16, 26, 0.94)'
|
||||||
|
ctx.strokeStyle = anomalyColor(hover.anomaly.source)
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.rect(tx, ty, tw, th)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.fillStyle = '#d3e9db'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText(lines[0], tx + tw / 2, ty + 11)
|
||||||
|
ctx.fillText(lines[1], tx + tw / 2, ty + 24)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Empty state: the grid alone would be a blank box — say we're listening.
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillStyle = 'rgba(178, 107, 255, 0.75)'
|
||||||
|
ctx.font = '12px "IBM Plex Mono", monospace'
|
||||||
|
ctx.fillText('· · ·', MARGIN.left + plotW / 2, MARGIN.top + plotH / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(draw)
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
ro.disconnect()
|
||||||
|
}
|
||||||
|
}, [active])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="graph-view">
|
||||||
|
<div className="graph-counters" aria-live="polite">
|
||||||
|
<span className="graph-counter flicker-soft">
|
||||||
|
<span className="counter-key">{t('seance.views.graph.anomalies', { defaultValue: 'ANOMALIES' })}</span>
|
||||||
|
<span className="counter-val">{anomalyCount}</span>
|
||||||
|
</span>
|
||||||
|
<span className="graph-counter flicker-soft">
|
||||||
|
<span className="counter-key">{t('seance.views.graph.entity', { defaultValue: 'ENTITY' })}</span>
|
||||||
|
<span className="counter-val counter-entity">{entityName ?? '—'}</span>
|
||||||
|
</span>
|
||||||
|
<span className="graph-counter flicker-soft">
|
||||||
|
<span className="counter-key">{t('seance.views.graph.jitter', { defaultValue: 'JITTER' })}</span>
|
||||||
|
<span className="counter-val">
|
||||||
|
{telemetry ? `${telemetry.jitter_bytes_per_s.toFixed(1)} KB/S` : '—'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="graph-canvas-wrap">
|
||||||
|
<canvas ref={canvasRef} />
|
||||||
|
{!telemetry && (
|
||||||
|
<p className="graph-listening flicker" role="status">
|
||||||
|
{t('seance.views.listening', { defaultValue: 'listening…' })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
frontend/src/components/MatrixView.css
Normal file
57
frontend/src/components/MatrixView.css
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/* MatrixView — "matrix rain" live-data view for the séance board area.
|
||||||
|
Tokens: bg #07070d · phosphor #7cffb2 · violet #b26bff · blood #ff3b5c. */
|
||||||
|
|
||||||
|
.matrix-view {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
background: #07070d;
|
||||||
|
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
filter: drop-shadow(0 0 18px rgba(7, 7, 13, 0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-view canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-listening {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
bottom: 12px;
|
||||||
|
margin: 0;
|
||||||
|
color: #b26bff;
|
||||||
|
font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-shadow: 0 0 12px rgba(178, 107, 255, 0.7);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- view switcher (BOARD / MATRIX / GRAPHS) ----------
|
||||||
|
Styles live here because SeancePage.css is outside this change's scope;
|
||||||
|
the tab buttons themselves reuse the existing .mode-tab look. */
|
||||||
|
|
||||||
|
.seance-view-tabs {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 6;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
background: rgba(16, 16, 26, 0.85);
|
||||||
|
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||||
|
border-radius: 5px;
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seance-view-tabs .mode-tab {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
frontend/src/components/views.test.ts
Normal file
141
frontend/src/components/views.test.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { initialSeanceState, seanceReducer } from '../state/seance'
|
||||||
|
import type { AnomalyEntry, SeanceState, Utterance } from '../state/seance'
|
||||||
|
import type { ServerFrame } from '../lib/types'
|
||||||
|
import { anomalyDensity, anomalyString, collectDataStrings, pushRing, utteranceWord, DATA_CAP } from './MatrixView'
|
||||||
|
import { adaptiveMax, anomalyColor, pushHistory, HISTORY_CAP } from './GraphView'
|
||||||
|
|
||||||
|
const AT = 1_700_000_000_000
|
||||||
|
|
||||||
|
function frame(state: SeanceState, f: ServerFrame, at = AT): SeanceState {
|
||||||
|
return seanceReducer(state, { type: 'frame', frame: f, at })
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastRow(state: SeanceState) {
|
||||||
|
return state.transcript[state.transcript.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('seanceReducer — server anomaly frames', () => {
|
||||||
|
it('appends a server anomaly to both the anomalies list and the transcript', () => {
|
||||||
|
const state = frame(initialSeanceState, {
|
||||||
|
type: 'anomaly',
|
||||||
|
source: 'wire',
|
||||||
|
frequency: 432.8,
|
||||||
|
magnitude: -3.1,
|
||||||
|
})
|
||||||
|
expect(state.anomalies).toHaveLength(1)
|
||||||
|
expect(state.anomalies[0]).toMatchObject({
|
||||||
|
source: 'wire',
|
||||||
|
frequency: 432.8,
|
||||||
|
magnitude: -3.1,
|
||||||
|
at: AT,
|
||||||
|
})
|
||||||
|
expect(lastRow(state)).toMatchObject({
|
||||||
|
kind: 'anomaly',
|
||||||
|
source: 'wire',
|
||||||
|
frequency: 432.8,
|
||||||
|
magnitude: -3.1,
|
||||||
|
at: AT,
|
||||||
|
})
|
||||||
|
// Same entry id shared between the list and the transcript row.
|
||||||
|
const row = lastRow(state)
|
||||||
|
if (row.kind === 'anomaly') expect(row.id).toBe(state.anomalies[0].id)
|
||||||
|
// The server-side count only moves when anomaly_ack arrives.
|
||||||
|
expect(state.anomalyCount).toBe(0)
|
||||||
|
expect(initialSeanceState.anomalies).toEqual([]) // reducer is pure
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caps server anomalies at 200, dropping the oldest', () => {
|
||||||
|
let state = initialSeanceState
|
||||||
|
for (let i = 1; i <= 205; i++) {
|
||||||
|
state = frame(
|
||||||
|
state,
|
||||||
|
{ type: 'anomaly', source: 'radio', frequency: 100 + i, magnitude: 5 },
|
||||||
|
AT + i,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
expect(state.anomalies).toHaveLength(200)
|
||||||
|
expect(state.anomalies[0].frequency).toBe(106)
|
||||||
|
expect(state.anomalies[199].frequency).toBe(305)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MatrixView helpers', () => {
|
||||||
|
it('pushRing appends and caps, dropping the oldest', () => {
|
||||||
|
let ring: number[] = []
|
||||||
|
for (let i = 1; i <= 5; i++) ring = pushRing(ring, i, 3)
|
||||||
|
expect(ring).toEqual([3, 4, 5])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('anomalyString formats frequency and signed magnitude', () => {
|
||||||
|
expect(anomalyString({ frequency: 101.44, magnitude: 8.23 })).toBe('101.4MHZ +8.2DB')
|
||||||
|
expect(anomalyString({ frequency: 88, magnitude: -3.06 })).toBe('88.0MHZ -3.1DB')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('utteranceWord picks a single uppercase word and rejects noise', () => {
|
||||||
|
expect(utteranceWord('the veil is thin')).toBe('VEIL') // 'IS' filtered (<3), middle of the rest
|
||||||
|
expect(utteranceWord('mirela')).toBe('MIRELA')
|
||||||
|
expect(utteranceWord('... --- ...')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collectDataStrings gathers words, anomalies, telemetry and the entity name', () => {
|
||||||
|
const anomalies: AnomalyEntry[] = [
|
||||||
|
{ id: 1, source: 'wire', frequency: 55.2, magnitude: 1.2, at: AT },
|
||||||
|
]
|
||||||
|
const utterances: Utterance[] = [
|
||||||
|
{ id: 'u1', kind: 'fragment', text: 'we are below', entity: null, audioUrl: null, at: AT },
|
||||||
|
]
|
||||||
|
const ring = collectDataStrings({
|
||||||
|
anomalies,
|
||||||
|
utterances,
|
||||||
|
telemetry: { jitter_bytes_per_s: 23.68, latency_variance_ms: 1, latency_mean_ms: 5.94, dns_ms: 7 },
|
||||||
|
entityName: 'Mirela',
|
||||||
|
})
|
||||||
|
expect(ring).toContain('BELOW')
|
||||||
|
expect(ring).toContain('55.2MHZ +1.2DB')
|
||||||
|
expect(ring).toContain('JIT 23.7KB/S')
|
||||||
|
expect(ring).toContain('RTT 5.9MS')
|
||||||
|
expect(ring).toContain('MIRELA')
|
||||||
|
expect(ring.length).toBeLessThanOrEqual(DATA_CAP)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collectDataStrings degrades to an empty ring with no live data', () => {
|
||||||
|
expect(
|
||||||
|
collectDataStrings({ anomalies: [], utterances: [], telemetry: null, entityName: null }),
|
||||||
|
).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('anomalyDensity scales 0→1 with the recent anomaly rate', () => {
|
||||||
|
const now = AT
|
||||||
|
expect(anomalyDensity([], now)).toBe(0)
|
||||||
|
const recent = Array.from({ length: 10 }, (_, i) => ({ at: now - i * 500 }))
|
||||||
|
expect(anomalyDensity(recent, now, 10_000)).toBe(1)
|
||||||
|
const stale = [{ at: now - 60_000 }]
|
||||||
|
expect(anomalyDensity(stale, now, 10_000)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GraphView helpers', () => {
|
||||||
|
it('pushHistory appends and caps at HISTORY_CAP, dropping the oldest', () => {
|
||||||
|
let history = pushHistory([], { at: AT, jitter: 1, variance: 1, dns: 1 })
|
||||||
|
expect(history).toHaveLength(1)
|
||||||
|
for (let i = 1; i <= HISTORY_CAP + 5; i++) {
|
||||||
|
history = pushHistory(history, { at: AT + i, jitter: i, variance: i, dns: i })
|
||||||
|
}
|
||||||
|
expect(history).toHaveLength(HISTORY_CAP)
|
||||||
|
expect(history[history.length - 1].jitter).toBe(HISTORY_CAP + 5)
|
||||||
|
expect(history[0].jitter).toBe(6)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adaptiveMax respects the floor and adds headroom', () => {
|
||||||
|
expect(adaptiveMax([], 10)).toBeCloseTo(11.5)
|
||||||
|
expect(adaptiveMax([2, 4, 100, 7], 10)).toBeCloseTo(115)
|
||||||
|
expect(adaptiveMax([1, 2], 50)).toBeCloseTo(57.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('anomalyColor maps sources to their token colors', () => {
|
||||||
|
expect(anomalyColor('wire')).toBe('#7cffb2')
|
||||||
|
expect(anomalyColor('evp')).toBe('#b26bff')
|
||||||
|
expect(anomalyColor('radio')).toBe('#ff9d4a')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -105,7 +105,10 @@
|
|||||||
"latencyVar": "latency σ²",
|
"latencyVar": "latency σ²",
|
||||||
"latencyMean": "latency μ",
|
"latencyMean": "latency μ",
|
||||||
"dns": "dns",
|
"dns": "dns",
|
||||||
"units": {"bytes": "B/s", "ms": "ms"}
|
"units": {
|
||||||
|
"bytes": "B/s",
|
||||||
|
"ms": "ms"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"wire": {
|
"wire": {
|
||||||
"hint": "the wire knocks loudest when nobody is touching it — flip passive listening on and let the line murmur on its own."
|
"hint": "the wire knocks loudest when nobody is touching it — flip passive listening on and let the line murmur on its own."
|
||||||
@@ -156,6 +159,18 @@
|
|||||||
"toast": {
|
"toast": {
|
||||||
"rate_limited": "the veil is thin — slow your knocking",
|
"rate_limited": "the veil is thin — slow your knocking",
|
||||||
"veil_crowded": "too many spirits crowd the line"
|
"veil_crowded": "too many spirits crowd the line"
|
||||||
|
},
|
||||||
|
"views": {
|
||||||
|
"board": "BOARD",
|
||||||
|
"matrix": "MATRIX",
|
||||||
|
"graphs": "GRAPHS",
|
||||||
|
"tuning": "tuning the veil…",
|
||||||
|
"listening": "listening…",
|
||||||
|
"graph": {
|
||||||
|
"anomalies": "anomalies",
|
||||||
|
"entity": "entity",
|
||||||
|
"jitter": "jitter"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"codex": {
|
"codex": {
|
||||||
|
|||||||
@@ -105,7 +105,10 @@
|
|||||||
"latencyVar": "latencia σ²",
|
"latencyVar": "latencia σ²",
|
||||||
"latencyMean": "latencia μ",
|
"latencyMean": "latencia μ",
|
||||||
"dns": "dns",
|
"dns": "dns",
|
||||||
"units": {"bytes": "B/s", "ms": "ms"}
|
"units": {
|
||||||
|
"bytes": "B/s",
|
||||||
|
"ms": "ms"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"wire": {
|
"wire": {
|
||||||
"hint": "el cable golpea más fuerte cuando nadie lo toca — activa la escucha pasiva y deja que la línea murmure sola."
|
"hint": "el cable golpea más fuerte cuando nadie lo toca — activa la escucha pasiva y deja que la línea murmure sola."
|
||||||
@@ -156,6 +159,18 @@
|
|||||||
"toast": {
|
"toast": {
|
||||||
"rate_limited": "el velo es delgado — golpea más despacio",
|
"rate_limited": "el velo es delgado — golpea más despacio",
|
||||||
"veil_crowded": "demasiados espíritus se apiñan en la línea"
|
"veil_crowded": "demasiados espíritus se apiñan en la línea"
|
||||||
|
},
|
||||||
|
"views": {
|
||||||
|
"board": "TABLERO",
|
||||||
|
"matrix": "MATRIZ",
|
||||||
|
"graphs": "GRÁFICAS",
|
||||||
|
"tuning": "afinando el velo…",
|
||||||
|
"listening": "escuchando…",
|
||||||
|
"graph": {
|
||||||
|
"anomalies": "anomalías",
|
||||||
|
"entity": "entidad",
|
||||||
|
"jitter": "fluctuación"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"codex": {
|
"codex": {
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export type ServerFrame =
|
|||||||
| { type: 'status'; state: SessionStatus }
|
| { type: 'status'; state: SessionStatus }
|
||||||
| { type: 'entity'; is_new: boolean; entity: SpiritEntity }
|
| { type: 'entity'; is_new: boolean; entity: SpiritEntity }
|
||||||
| { type: 'anomaly_ack'; count: number }
|
| { type: 'anomaly_ack'; count: number }
|
||||||
|
| { type: 'anomaly'; source: 'radio' | 'evp' | 'wire'; frequency: number; magnitude: number }
|
||||||
| { type: 'utterance'; id: string; kind: UtteranceKind; text: string; entity: string | null }
|
| { type: 'utterance'; id: string; kind: UtteranceKind; text: string; entity: string | null }
|
||||||
| { type: 'audio'; id: string; url: string }
|
| { type: 'audio'; id: string; url: string }
|
||||||
| { type: 'reply_start' }
|
| { type: 'reply_start' }
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import type { Toast } from '../state/seance'
|
|||||||
import { GhostCanvas } from '../three/GhostCanvas'
|
import { GhostCanvas } from '../three/GhostCanvas'
|
||||||
import type { GhostMood, GhostVisualState } from '../three/GhostScene'
|
import type { GhostMood, GhostVisualState } from '../three/GhostScene'
|
||||||
import { PlanchetteBoard } from '../components/PlanchetteBoard'
|
import { PlanchetteBoard } from '../components/PlanchetteBoard'
|
||||||
|
import { MatrixView } from '../components/MatrixView'
|
||||||
|
import { GraphView } from '../components/GraphView'
|
||||||
import { Transcript } from '../components/Transcript'
|
import { Transcript } from '../components/Transcript'
|
||||||
import { PlanchetteMachine } from '../lib/planchette'
|
import { PlanchetteMachine } from '../lib/planchette'
|
||||||
import type { PlanchetteSnapshot } from '../lib/planchette'
|
import type { PlanchetteSnapshot } from '../lib/planchette'
|
||||||
@@ -35,6 +37,14 @@ import type { Language, Mode } from '../lib/types'
|
|||||||
import './SeancePage.css'
|
import './SeancePage.css'
|
||||||
|
|
||||||
const MODES: readonly Mode[] = ['wire', 'evp', 'radio', 'ouija']
|
const MODES: readonly Mode[] = ['wire', 'evp', 'radio', 'ouija']
|
||||||
|
|
||||||
|
// Board-area views; labels fall back to these until the i18n JSONs land.
|
||||||
|
const VIEWS = [
|
||||||
|
{ id: 'board', fallback: 'BOARD' },
|
||||||
|
{ id: 'matrix', fallback: 'MATRIX' },
|
||||||
|
{ id: 'graphs', fallback: 'GRAPHS' },
|
||||||
|
] as const
|
||||||
|
type BoardView = (typeof VIEWS)[number]['id']
|
||||||
const TOAST_TTL_MS = 6000
|
const TOAST_TTL_MS = 6000
|
||||||
/** SDR sample rate must match RtlSdr.open()'s default. */
|
/** SDR sample rate must match RtlSdr.open()'s default. */
|
||||||
const SDR_SAMPLE_RATE_HZ = 2_048_000
|
const SDR_SAMPLE_RATE_HZ = 2_048_000
|
||||||
@@ -84,6 +94,7 @@ function SeanceSession({ username }: { username: string }) {
|
|||||||
const [departed, setDeparted] = useState(false)
|
const [departed, setDeparted] = useState(false)
|
||||||
const [question, setQuestion] = useState('')
|
const [question, setQuestion] = useState('')
|
||||||
const [dossierOpen, setDossierOpen] = useState(true)
|
const [dossierOpen, setDossierOpen] = useState(true)
|
||||||
|
const [view, setView] = useState<BoardView>('board')
|
||||||
|
|
||||||
// The planchette machine lives for the whole session; the board ticks it.
|
// The planchette machine lives for the whole session; the board ticks it.
|
||||||
const machineRef = useRef(new PlanchetteMachine())
|
const machineRef = useRef(new PlanchetteMachine())
|
||||||
@@ -248,8 +259,17 @@ function SeanceSession({ username }: { username: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ---- unstable-veil banner ---- */}
|
{/* ---- veil banner: 'connecting' is a neutral tuning state;
|
||||||
{state.connection !== 'open' && (
|
only an unstable or dead line raises the warning ---- */}
|
||||||
|
{state.connection === 'connecting' && (
|
||||||
|
<div className="seance-conn-banner conn-connecting" role="status">
|
||||||
|
<span className="conn-glyph" aria-hidden>
|
||||||
|
⟁
|
||||||
|
</span>
|
||||||
|
{t('seance.views.tuning', { defaultValue: 'tuning the veil…' })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(state.connection === 'unstable' || state.connection === 'closed') && (
|
||||||
<div className={`seance-conn-banner conn-${state.connection}`} role="alert">
|
<div className={`seance-conn-banner conn-${state.connection}`} role="alert">
|
||||||
<span className="conn-glyph" aria-hidden>
|
<span className="conn-glyph" aria-hidden>
|
||||||
⟁
|
⟁
|
||||||
@@ -261,7 +281,42 @@ function SeanceSession({ username }: { username: string }) {
|
|||||||
{/* ---- main: board + side column ---- */}
|
{/* ---- main: board + side column ---- */}
|
||||||
<main className="seance-main">
|
<main className="seance-main">
|
||||||
<section className="seance-board-wrap">
|
<section className="seance-board-wrap">
|
||||||
<PlanchetteBoard machine={machineRef.current} hue={entityHue} />
|
{/* view switcher: BOARD / MATRIX / GRAPHS (styles in MatrixView.css,
|
||||||
|
buttons reuse the .mode-tab look) */}
|
||||||
|
<nav className="seance-view-tabs" aria-label="views">
|
||||||
|
{VIEWS.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v.id}
|
||||||
|
type="button"
|
||||||
|
className={`mode-tab ${view === v.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setView(v.id)}
|
||||||
|
>
|
||||||
|
{t(`seance.views.${v.id}`, { defaultValue: v.fallback })}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* The PlanchetteMachine lives in this page, so unmounting the board
|
||||||
|
component for another view loses nothing. */}
|
||||||
|
{view === 'board' && <PlanchetteBoard machine={machineRef.current} hue={entityHue} />}
|
||||||
|
{view === 'matrix' && (
|
||||||
|
<MatrixView
|
||||||
|
anomalies={state.anomalies}
|
||||||
|
utterances={state.utterances}
|
||||||
|
telemetry={state.telemetry}
|
||||||
|
entityName={state.entity?.name ?? null}
|
||||||
|
active={view === 'matrix'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{view === 'graphs' && (
|
||||||
|
<GraphView
|
||||||
|
telemetry={state.telemetry}
|
||||||
|
anomalies={state.anomalies}
|
||||||
|
anomalyCount={state.anomalyCount}
|
||||||
|
entityName={state.entity?.name ?? null}
|
||||||
|
active={view === 'graphs'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{state.status && (
|
{state.status && (
|
||||||
<div className="seance-status-overlay" aria-live="polite">
|
<div className="seance-status-overlay" aria-live="polite">
|
||||||
|
|||||||
@@ -220,6 +220,34 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS
|
|||||||
case 'anomaly_ack':
|
case 'anomaly_ack':
|
||||||
return { ...state, anomalyCount: frame.count }
|
return { ...state, anomalyCount: frame.count }
|
||||||
|
|
||||||
|
case 'anomaly': {
|
||||||
|
// Server-pushed anomaly (the wire-telemetry detector) — same shape
|
||||||
|
// and handling as a locally-detected one.
|
||||||
|
const entry: AnomalyEntry = {
|
||||||
|
id: nextId(),
|
||||||
|
source: frame.source,
|
||||||
|
frequency: frame.frequency,
|
||||||
|
magnitude: frame.magnitude,
|
||||||
|
at,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
anomalies: pushCapped(state.anomalies, entry, ANOMALY_CAP),
|
||||||
|
transcript: pushCapped(
|
||||||
|
state.transcript,
|
||||||
|
{
|
||||||
|
id: entry.id,
|
||||||
|
kind: 'anomaly',
|
||||||
|
source: frame.source,
|
||||||
|
frequency: frame.frequency,
|
||||||
|
magnitude: frame.magnitude,
|
||||||
|
at,
|
||||||
|
},
|
||||||
|
TRANSCRIPT_CAP,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case 'utterance': {
|
case 'utterance': {
|
||||||
const ut: Utterance = {
|
const ut: Utterance = {
|
||||||
id: frame.id,
|
id: frame.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user