// 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, emf amber. */ export function anomalyColor(source: AnomalyEntry['source']): string { switch (source) { case 'wire': return '#7cffb2' case 'evp': return '#b26bff' case 'radio': return '#ff9d4a' case 'emf': return '#ffd24a' } } // ---- 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(null) const historyRef = useRef([]) 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 (
{t('seance.views.graph.anomalies', { defaultValue: 'ANOMALIES' })} {anomalyCount} {t('seance.views.graph.entity', { defaultValue: 'ENTITY' })} {entityName ?? '—'} {t('seance.views.graph.jitter', { defaultValue: 'JITTER' })} {telemetry ? `${telemetry.jitter_bytes_per_s.toFixed(1)} KB/S` : '—'}
{!telemetry && (

{t('seance.views.listening', { defaultValue: 'listening…' })}

)}
) }