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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user