Three self-contained features, verified complete and cross-wired end-to-end (audited: backend 54/54 tests, frontend 110/110 tests, tsc --noEmit clean, i18n coverage script clean): - EMF mode: DeviceMotion/DeviceOrientation-based field-meter sensing, a fifth séance channel alongside Wire/EVP/Radio/Ouija, with its own fragment prompt persona and full frontend gauge UI. - Armory (shop/waitlist): pre-order capture page for the future Ultimate Quantum Box hardware line, rate-limited public endpoint, explicitly no payment collection. - Haunting layer: ambient possession effects (dread-bed audio, title glitching, idle-paced whispers/manifests), respects prefers-reduced-motion, mounted once at the app root. Plus WebUSB robustness fixes in lib/sdr.ts (Terratec vendor ID, explicit selectConfiguration, isSecureContext gate). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
// 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<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>
|
|
)
|
|
}
|