The audit that produced these had every verifier agent die, so none were
confirmed. Checked each against the running system rather than guessing.
1. COOKIE Secure FLAG — REAL, fixed. The Cloudflare Tunnel runs OFF this box
(observed source 10.30.20.67, 155 requests in the journal) and uvicorn
only honours X-Forwarded-* from --forwarded-allow-ips, default 127.0.0.1.
Proven by hitting the LAN IP with X-Forwarded-Proto: https and watching
Secure vanish from Set-Cookie. Every internet visitor's session cookie
was going out without it.
Fixed in the unit drop-in with --proxy-headers and an allow-list scoped
to the tunnel host — NOT "*", because trusting that header from anywhere
would let a LAN client forge the IP the per-IP limiters key on. Verified
both directions: trusted source + header gets Secure, plain LAN http
correctly does not, and a spoof from an untrusted host is ignored.
2. DOUBLE GUEST ON REMOUNT — REAL but narrow, left alone. The guestAttempted
ref already covers StrictMode's double-effect (refs survive it). The only
hole is unmounting during the in-flight request, which needs navigating
away and back inside ~200ms and costs one unused row. Not worth
complicating the open door's happy path for.
3. SILENT REDIRECT WHEN RATE-LIMITED — REAL, fixed. A visitor whose guest
provisioning was refused got bounced to /enter with no explanation — and
at 5/hour/IP a household or cafe behind one NAT reaches that easily. The
failure reason (the backend's own in-fiction line) now rides along in
router state and /enter shows it, so nobody is silently handed a login
form they never asked for.
4. RATE LIMITER KEYS NEVER EVICTED — REAL, fixed. defaultdict entries
survived forever even once their hit list emptied. The open door made
this materially worse: every visitor is now a real account, so every
visitor permanently added a key across eleven limiter instances. Added an
opportunistic sweep every 512 admitted calls — no background task, cost
lands on whoever generates the load. Three tests; verified they catch it
by disabling the sweep and watching one fail.
5. SUMMON RACE vs TELEMETRY — REAL, fixed. Nothing serialised summoning.
_handle_anomaly checks `state.entity is None` then awaits a summon
containing a multi-second LLM mint, and the ESP32's HTTP ingestion path
calls _handle_anomaly on the SAME SeanceState — which is the entire point
of the device integration. Both could pass the check: two entities
minted, two essence credits, two item rolls, state.entity clobbered by
whichever finished last. Now guarded by a per-session asyncio.Lock.
6. LEGACY ENTITIES STUCK AT DEFAULT TRAITS — mechanism REAL, zero rows
affected here. The ALTER defaults traits to '{}' with no backfill and
roll_traits only runs at mint, so a pre-migration spirit would read 0.5
for everything — making `trust` always correct and `cross_over`
unreachable. This install has 0 such rows. Added a signature-seeded
backfill anyway, guarded to empty-traits rows so it can never touch a
spirit that already has a real nature.
(A seventh claim from the same batch — that iOS EMF is silently dead — was
refuted earlier and deliberately left untouched.)
34 targeted tests pass; deployed and verified live.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1278 lines
44 KiB
TypeScript
1278 lines
44 KiB
TypeScript
// The main séance screen: ghost backdrop, planchette board, mode panels
|
||
// (wire / evp / radio / ouija / emf), transmission log and direct-contact row.
|
||
//
|
||
// Wiring notes:
|
||
// - PlanchetteBoard runs its own rAF loop and ticks the PlanchetteMachine
|
||
// itself; this page only enqueues utterance text and polls snapshot()
|
||
// (non-mutating) for the ouija panel readout.
|
||
// - Each mode panel is conditionally mounted, so mic / SDR teardown happens
|
||
// in the panel's unmount cleanup — switching modes or leaving the page
|
||
// always releases the hardware.
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import type { FormEvent } from 'react'
|
||
import { Link, Navigate } from 'react-router-dom'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { useAuth } from '../state/auth'
|
||
import { SeanceProvider, useSeance } from '../state/seance'
|
||
import type { Toast } from '../state/seance'
|
||
import { GhostCanvas } from '../three/GhostCanvas'
|
||
import type { GhostMood, GhostVisualState } from '../three/GhostScene'
|
||
import { PlanchetteBoard } from '../components/PlanchetteBoard'
|
||
import { MatrixView } from '../components/MatrixView'
|
||
import { GraphView } from '../components/GraphView'
|
||
import { Transcript } from '../components/Transcript'
|
||
import { EntityCard } from '../components/EntityCard'
|
||
import { RitualPanel } from '../components/RitualPanel'
|
||
import { DeviceWhisper } from '../components/DeviceWhisper'
|
||
import { JudgmentPanel } from '../components/JudgmentPanel'
|
||
import { TelemetryReadout } from '../components/TelemetryReadout'
|
||
import { VeilConditions } from '../components/VeilConditions'
|
||
import { ModeHint } from '../components/ModeHint'
|
||
import { CameraPanel } from '../components/CameraPanel'
|
||
import { PlanchetteMachine } from '../lib/planchette'
|
||
import type { PlanchetteSnapshot } from '../lib/planchette'
|
||
import { EvpListener } from '../lib/evp'
|
||
import { SpectrumScope } from '../components/SpectrumScope'
|
||
import type { ScopeAnomaly } from '../components/SpectrumScope'
|
||
import { ListenToggle } from '../components/ListenToggle'
|
||
import { SpectrumSonifier } from '../lib/spectrumSonify'
|
||
import { EmfSensorListener } from '../lib/emf'
|
||
import {
|
||
MagnetometerListener,
|
||
isSupported as magIsSupported,
|
||
} from '../lib/magnetometer'
|
||
import {
|
||
isSecureContext,
|
||
isSupported,
|
||
RtlSdr,
|
||
SpectrumAnomalyDetector,
|
||
SWEEP_END_MHZ,
|
||
SWEEP_START_MHZ,
|
||
} from '../lib/sdr'
|
||
import { persistLanguage, storedLanguage } from '../i18n'
|
||
import type { Language, Mode } from '../lib/types'
|
||
import './SeancePage.css'
|
||
|
||
const MODES: readonly Mode[] = ['wire', 'evp', 'radio', 'ouija', 'emf', 'camera']
|
||
// Fallback tab labels for modes the i18n catalogs don't carry yet.
|
||
const MODE_LABEL_DEFAULTS: Partial<Record<Mode, string>> = { emf: 'FIELD', camera: 'LENS' }
|
||
|
||
// 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
|
||
/** SDR sample rate must match RtlSdr.open()'s default. */
|
||
const SDR_SAMPLE_RATE_HZ = 2_048_000
|
||
|
||
export function SeancePage() {
|
||
const { user, checking, guest } = useAuth()
|
||
const { t } = useTranslation()
|
||
// Why auto-provisioning failed, carried to /enter so the seeker is told
|
||
// what happened instead of being dropped on a bare login form.
|
||
const [guestFailure, setGuestFailure] = useState<string | null>(null)
|
||
// One auto-attempt only. Guards React StrictMode's double-effect and
|
||
// stops a failed attempt (rate limit / offline) from retrying forever.
|
||
const guestAttempted = useRef(false)
|
||
|
||
// The open door. There is no sign-in wall: if nobody is authenticated
|
||
// once the session check settles, silently provision a wanderer — a real
|
||
// guest account (POST /auth/guest) — and drop straight into the séance.
|
||
// Point and shoot. Registering later is how a seeker *keeps* what they
|
||
// find and unlocks device pairing; it was never meant to be the toll to
|
||
// get in. A returning visitor already has a cookie, so `user` is set and
|
||
// this never fires for them.
|
||
useEffect(() => {
|
||
if (checking || user || guestAttempted.current) return
|
||
guestAttempted.current = true
|
||
void guest().catch((err: unknown) =>
|
||
// The backend's own in-fiction detail (e.g. the veil-is-crowded
|
||
// rate-limit line) is already the thrown message — pass it along
|
||
// rather than inventing a second wording for the same condition.
|
||
setGuestFailure(err instanceof Error && err.message ? err.message : 'unknown'),
|
||
)
|
||
}, [checking, user, guest])
|
||
|
||
// Still resolving a session, or provisioning the wanderer: hold the veil.
|
||
if (checking || (!user && !guestFailure)) {
|
||
return (
|
||
<div className="seance-loading" role="status">
|
||
<div className="loading-sigil" aria-hidden>
|
||
⟁
|
||
</div>
|
||
<p className="loading-text">{t('common.loading')}</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Auto-provision failed (the veil is genuinely crowded, or offline). Fall
|
||
// back to the manual door — but carry the reason, so /enter can explain
|
||
// rather than silently presenting a login form to someone who never asked
|
||
// for one. Verified reachable: the guest limiter is 5/hour/IP, which a
|
||
// household or cafe behind one NAT address reaches easily.
|
||
if (!user) {
|
||
return <Navigate to="/enter" replace state={{ guestFailure }} />
|
||
}
|
||
|
||
return (
|
||
<SeanceProvider>
|
||
<SeanceSession username={user.username} />
|
||
</SeanceProvider>
|
||
)
|
||
}
|
||
|
||
export default SeancePage
|
||
|
||
// ---- the séance itself (rendered inside SeanceProvider) ----
|
||
|
||
function SeanceSession({ username }: { username: string }) {
|
||
const {
|
||
state,
|
||
audioPlayer,
|
||
setMode,
|
||
setPassive,
|
||
setLanguage,
|
||
summon,
|
||
ask,
|
||
playUtterance,
|
||
dismissToast,
|
||
} = useSeance()
|
||
const { logout } = useAuth()
|
||
const { t, i18n } = useTranslation()
|
||
|
||
const [departed, setDeparted] = useState(false)
|
||
// Guests are recognized by their generated name prefix — no backend flag.
|
||
// Dismissal is per-visit on purpose: the nudge should return next séance.
|
||
const isWanderer = username.startsWith('wanderer-')
|
||
const [wandererNoteDismissed, setWandererNoteDismissed] = useState(false)
|
||
const [question, setQuestion] = useState('')
|
||
const [dossierOpen, setDossierOpen] = useState(true)
|
||
const [view, setView] = useState<BoardView>('board')
|
||
|
||
// The planchette machine lives for the whole session; the board ticks it.
|
||
const machineRef = useRef(new PlanchetteMachine())
|
||
const [boardSnap, setBoardSnap] = useState<PlanchetteSnapshot | null>(null)
|
||
|
||
// Tell the backend which tongue we whisper in (queued while connecting).
|
||
useEffect(() => {
|
||
setLanguage(storedLanguage())
|
||
}, [setLanguage])
|
||
|
||
// Feed every fresh utterance (greetings, fragments, replies) to the board.
|
||
const lastEnqueuedRef = useRef<string | null>(null)
|
||
useEffect(() => {
|
||
const uts = state.utterances
|
||
if (uts.length === 0) return
|
||
const lastSeen = lastEnqueuedRef.current
|
||
const idx = lastSeen === null ? -1 : uts.findIndex((u) => u.id === lastSeen)
|
||
if (lastSeen !== null && idx === -1) return // store was capped; don't re-spell history
|
||
const fresh = uts.slice(idx + 1)
|
||
for (const u of fresh) machineRef.current.enqueue(u.text)
|
||
lastEnqueuedRef.current = uts[uts.length - 1].id
|
||
}, [state.utterances])
|
||
|
||
// Poll the machine's snapshot for the ouija readout (board owns ticking).
|
||
useEffect(() => {
|
||
let lastSig = ''
|
||
const id = window.setInterval(() => {
|
||
const s = machineRef.current.snapshot()
|
||
const sig = `${s.phase}|${s.word ?? ''}|${s.index}|${s.queued.length}|${s.spelled.length}`
|
||
if (sig !== lastSig) {
|
||
lastSig = sig
|
||
setBoardSnap(s)
|
||
}
|
||
}, 150)
|
||
return () => window.clearInterval(id)
|
||
}, [])
|
||
|
||
// Open the dossier whenever a new presence steps through.
|
||
const entityId = state.entity?.id ?? null
|
||
useEffect(() => {
|
||
if (entityId) setDossierOpen(true)
|
||
}, [entityId])
|
||
|
||
const ghostVisual = useMemo<GhostVisualState | null>(
|
||
() =>
|
||
state.entity
|
||
? { hue: state.entity.visual.hue, form: state.entity.visual.form }
|
||
: null,
|
||
[state.entity],
|
||
)
|
||
|
||
const ghostMood: GhostMood = state.speakingId
|
||
? 'speaking'
|
||
: state.status === 'gathering' || state.status === 'summoning'
|
||
? 'gathering'
|
||
: state.status === 'attuning'
|
||
? 'attuning'
|
||
: 'idle'
|
||
|
||
const getAmplitude = useCallback(() => audioPlayer.getAmplitude(), [audioPlayer])
|
||
|
||
const switchLanguage = (lang: Language) => {
|
||
setLanguage(lang)
|
||
void i18n.changeLanguage(lang)
|
||
persistLanguage(lang)
|
||
}
|
||
|
||
const handleLogout = () => {
|
||
void logout()
|
||
.catch(() => undefined)
|
||
.finally(() => setDeparted(true))
|
||
}
|
||
|
||
const streaming = state.replyStreaming.active
|
||
const submitQuestion = (e: FormEvent) => {
|
||
e.preventDefault()
|
||
if (streaming) return
|
||
ask(question)
|
||
setQuestion('')
|
||
}
|
||
|
||
if (departed) return <Navigate to="/" replace />
|
||
|
||
const entityHue = state.entity?.visual.hue ?? 150
|
||
const entity = state.entity
|
||
|
||
return (
|
||
<div className="seance-page">
|
||
<GhostCanvas
|
||
className="seance-ghost"
|
||
visual={ghostVisual}
|
||
mood={ghostMood}
|
||
getAmplitude={getAmplitude}
|
||
/>
|
||
|
||
{/* ---- top bar ---- */}
|
||
<header className="seance-topbar">
|
||
<div className="seance-brand">
|
||
<span className="brand-sigil" aria-hidden>
|
||
⟁
|
||
</span>
|
||
{t('common.appName')}
|
||
</div>
|
||
|
||
<nav className="seance-modes" aria-label="modes">
|
||
{MODES.map((m) => (
|
||
<button
|
||
key={m}
|
||
type="button"
|
||
className={`mode-tab ${state.mode === m ? 'active' : ''}`}
|
||
onClick={() => setMode(m)}
|
||
>
|
||
{t(`seance.modes.${m}`, { defaultValue: MODE_LABEL_DEFAULTS[m] })}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
|
||
<div className="seance-top-right">
|
||
<label className="passive-toggle" title={t('seance.passive')}>
|
||
<input
|
||
type="checkbox"
|
||
checked={state.passive}
|
||
onChange={(e) => setPassive(e.target.checked)}
|
||
/>
|
||
<span className="passive-track" aria-hidden />
|
||
<span className="passive-label">{t('seance.passive')}</span>
|
||
</label>
|
||
|
||
<div className="lang-toggle" role="group" aria-label="language">
|
||
<button
|
||
type="button"
|
||
className={state.language === 'en' ? 'active' : ''}
|
||
onClick={() => switchLanguage('en')}
|
||
>
|
||
EN
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={state.language === 'es' ? 'active' : ''}
|
||
onClick={() => switchLanguage('es')}
|
||
>
|
||
ES
|
||
</button>
|
||
</div>
|
||
|
||
<Link to="/shop" className="seance-codex-link">
|
||
{t('nav.shop', { defaultValue: 'shop' })}
|
||
</Link>
|
||
|
||
<Link to="/codex" className="seance-codex-link">
|
||
{t('nav.codex')}
|
||
</Link>
|
||
|
||
<Link to="/log" className="seance-codex-link">
|
||
{t('nav.log', { defaultValue: 'log' })}
|
||
</Link>
|
||
|
||
<Link to="/inventory" className="seance-codex-link">
|
||
{t('nav.inventory', { defaultValue: 'inventory' })}
|
||
</Link>
|
||
|
||
<Link to="/devices" className="seance-codex-link">
|
||
{t('nav.devices', { defaultValue: 'devices' })}
|
||
</Link>
|
||
|
||
<span className="seance-user" title={username}>
|
||
{username}
|
||
</span>
|
||
<button type="button" className="seance-logout" onClick={handleLogout}>
|
||
{t('nav.logout')}
|
||
</button>
|
||
|
||
<span
|
||
className={`conn-dot conn-${state.connection}`}
|
||
title={t(`seance.connection.${state.connection}`)}
|
||
aria-label={t(`seance.connection.${state.connection}`)}
|
||
/>
|
||
</div>
|
||
</header>
|
||
|
||
{/* ---- veil banner: 'connecting' is a neutral tuning state;
|
||
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">
|
||
<span className="conn-glyph" aria-hidden>
|
||
⟁
|
||
</span>
|
||
{t(`seance.connection.${state.connection}`)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ---- wanderer note: guests can claim a permanent name ---- */}
|
||
{isWanderer && !wandererNoteDismissed && (
|
||
<div className="seance-wanderer-note" role="note">
|
||
<span>{t('seance.wanderer.note')}</span>{' '}
|
||
<Link to="/enter">{t('seance.wanderer.claim')}</Link>
|
||
<button
|
||
type="button"
|
||
className="wanderer-note-dismiss"
|
||
aria-label={t('seance.wanderer.dismiss')}
|
||
onClick={() => setWandererNoteDismissed(true)}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* ---- main: board + side column ---- */}
|
||
<main className="seance-main">
|
||
<section className="seance-board-wrap">
|
||
{/* 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 && (
|
||
<div className="seance-status-overlay" aria-live="polite">
|
||
<span className="status-sigil" aria-hidden />
|
||
<span className="status-text">{t(`seance.status.${state.status}`)}</span>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<aside className="seance-side">
|
||
<EntityCard
|
||
entity={entity}
|
||
isNew={state.entityIsNew}
|
||
open={dossierOpen}
|
||
onToggleOpen={() => setDossierOpen((o) => !o)}
|
||
/>
|
||
|
||
<VeilConditions />
|
||
<DeviceWhisper />
|
||
<RitualPanel />
|
||
<JudgmentPanel />
|
||
|
||
<section className="mode-panel">
|
||
{state.mode === 'wire' && <WirePanel />}
|
||
{state.mode === 'evp' && <EvpPanel />}
|
||
{state.mode === 'radio' && <RadioPanel />}
|
||
{state.mode === 'ouija' && <OuijaPanel snapshot={boardSnap} />}
|
||
{state.mode === 'emf' && <EmfPanel />}
|
||
{state.mode === 'camera' && <CameraPanel />}
|
||
</section>
|
||
</aside>
|
||
</main>
|
||
|
||
{/* ---- bottom: transmission log + direct contact ---- */}
|
||
<footer className="seance-bottom">
|
||
<div className="seance-transcript-wrap">
|
||
<div className="seance-transcript-title">
|
||
<span>{t('seance.transcriptTitle')}</span>
|
||
<span className="anomaly-count">
|
||
{t('seance.anomaly.count', { count: state.anomalyCount })}
|
||
</span>
|
||
</div>
|
||
<Transcript
|
||
entries={state.transcript}
|
||
streaming={state.replyStreaming}
|
||
speakingId={state.speakingId}
|
||
onReplay={playUtterance}
|
||
/>
|
||
</div>
|
||
|
||
<form className="seance-contact" onSubmit={submitQuestion}>
|
||
<input
|
||
type="text"
|
||
value={question}
|
||
onChange={(e) => setQuestion(e.target.value)}
|
||
placeholder={t('seance.askPlaceholder')}
|
||
disabled={streaming}
|
||
maxLength={280}
|
||
aria-label={t('seance.ask')}
|
||
/>
|
||
<button type="submit" className="ask-btn" disabled={streaming || !question.trim()}>
|
||
{t('seance.ask')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="summon-btn"
|
||
onClick={summon}
|
||
disabled={state.status === 'summoning'}
|
||
>
|
||
{state.status === 'summoning' ? t('seance.summoning') : t('seance.summon')}
|
||
</button>
|
||
</form>
|
||
</footer>
|
||
|
||
{/* ---- toasts ---- */}
|
||
<div className="seance-toasts" aria-live="polite">
|
||
{state.toasts.map((toast) => (
|
||
<ToastCard key={toast.id} toast={toast} onDismiss={dismissToast} />
|
||
))}
|
||
</div>
|
||
|
||
{/* ---- CRT overlays (above everything, never interactive) ---- */}
|
||
<div className="seance-scanlines" aria-hidden />
|
||
<div className="seance-vignette" aria-hidden />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- toasts ----
|
||
|
||
function ToastCard({ toast, onDismiss }: { toast: Toast; onDismiss: (id: number) => void }) {
|
||
const { t } = useTranslation()
|
||
useEffect(() => {
|
||
const id = window.setTimeout(() => onDismiss(toast.id), TOAST_TTL_MS)
|
||
return () => window.clearTimeout(id)
|
||
}, [toast.id, onDismiss])
|
||
|
||
return (
|
||
<div className="seance-toast" role="alert">
|
||
<span className="toast-glyph" aria-hidden>
|
||
⚠
|
||
</span>
|
||
<span className="toast-msg">
|
||
{t(`seance.toast.${toast.code}`, { defaultValue: toast.message })}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="toast-dismiss"
|
||
onClick={() => onDismiss(toast.id)}
|
||
aria-label={t('common.dismiss')}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- wire panel: raw line telemetry ----
|
||
|
||
function WirePanel() {
|
||
const { state } = useSeance()
|
||
const { t } = useTranslation()
|
||
|
||
return (
|
||
<div className="wire-panel">
|
||
<TelemetryReadout telemetry={state.telemetry} />
|
||
<p className="wire-count">{t('seance.anomaly.count', { count: state.anomalyCount })}</p>
|
||
{!state.passive && (
|
||
<p className="wire-hint dim">
|
||
{t('seance.wire.hint', {
|
||
defaultValue:
|
||
'the wire knocks loudest when nobody is touching it — flip passive listening on and let the line murmur on its own.',
|
||
})}
|
||
</p>
|
||
)}
|
||
<ModeHint mode="wire" suppressed={state.passive} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- EVP panel: microphone voice-band listening ----
|
||
|
||
function EvpPanel() {
|
||
const { sendAnomaly, entropy } = useSeance()
|
||
const { user } = useAuth()
|
||
const { t } = useTranslation()
|
||
// "listening_tool" unlock (GET /auth/me's `unlocks`) lowers the EVP
|
||
// detector's threshold so fainter deviations register — see lib/evp.ts.
|
||
const hasListeningTool = user?.unlocks?.includes('listening_tool') ?? false
|
||
const [listening, setListening] = useState(false)
|
||
const [pending, setPending] = useState(false)
|
||
// Why the mic isn't open, so the notice can be truthful about the cause
|
||
// instead of always claiming the seeker refused it.
|
||
const [micFailure, setMicFailure] = useState<
|
||
'denied' | 'insecure' | 'absent' | 'busy' | null
|
||
>(null)
|
||
const [bandDb, setBandDb] = useState<number | null>(null)
|
||
const [nyquist, setNyquist] = useState(24000)
|
||
const [markers, setMarkers] = useState<ScopeAnomaly[]>([])
|
||
const binsRef = useRef<Float64Array | null>(null)
|
||
const listenerRef = useRef<EvpListener | null>(null)
|
||
const lastMeterRef = useRef(0)
|
||
const scratchRef = useRef<[Float64Array, Float64Array] | null>(null)
|
||
const sonifierRef = useRef(new SpectrumSonifier())
|
||
// Mirrors `nyquist` state for use inside the rAF callbacks, which close
|
||
// over the render that started the listener and would otherwise keep
|
||
// reading the pre-start default forever.
|
||
const nyquistRef = useRef(24000)
|
||
|
||
// Release the microphone when the panel unmounts (mode switch / leaving).
|
||
useEffect(
|
||
() => () => {
|
||
const l = listenerRef.current
|
||
listenerRef.current = null
|
||
if (l) void l.stop()
|
||
},
|
||
[],
|
||
)
|
||
|
||
const start = async () => {
|
||
if (pending || listening) return
|
||
setMicFailure(null)
|
||
setPending(true)
|
||
const listener = listenerRef.current ?? new EvpListener()
|
||
listenerRef.current = listener
|
||
try {
|
||
await listener.start({
|
||
onAnomaly: (a) => {
|
||
sendAnomaly('evp', a.frequency, a.magnitude)
|
||
const at = performance.now()
|
||
// Cap the marker list: a noisy room can fire faster than markers
|
||
// expire, and an unbounded array would grow for the whole session.
|
||
setMarkers((prev) => [...prev.slice(-11), { freq: a.frequency, magnitude: a.magnitude, at }])
|
||
sonifierRef.current.ping(a.frequency, a.magnitude, { startHz: 0, endHz: nyquistRef.current }, at)
|
||
},
|
||
onFrame: (db) => {
|
||
// Double-buffer: the scope may be mid-draw on the other buffer,
|
||
// so writing into the one it isn't holding avoids tearing. Two
|
||
// fixed arrays also means zero allocation per frame — allocating
|
||
// a typed array 60x/sec is exactly the garbage that causes
|
||
// visible jank on a mid-range phone.
|
||
const pair = scratchRef.current
|
||
const buffers =
|
||
pair && pair[0].length === db.length
|
||
? pair
|
||
: ([new Float64Array(db.length), new Float64Array(db.length)] as [
|
||
Float64Array,
|
||
Float64Array,
|
||
])
|
||
scratchRef.current = buffers
|
||
const out = buffers[0] === binsRef.current ? buffers[1] : buffers[0]
|
||
for (let i = 0; i < db.length; i++) out[i] = db[i]
|
||
binsRef.current = out
|
||
// Real thermal/acoustic noise from this room, banked toward the
|
||
// next summon — see lib/entropy.ts.
|
||
entropy.addFrame(db)
|
||
|
||
const now = performance.now()
|
||
if (now - lastMeterRef.current > 200) {
|
||
lastMeterRef.current = now
|
||
// Voice band ≈ 300–3400 Hz, mapped through the *actual* Nyquist
|
||
// rather than an assumed 48 kHz.
|
||
const nyq = nyquistRef.current
|
||
const n = db.length
|
||
const lo = Math.floor((300 / nyq) * n)
|
||
const hi = Math.min(n - 1, Math.ceil((3400 / nyq) * n))
|
||
let peak = -Infinity
|
||
for (let i = lo; i <= hi; i++) if (db[i] > peak) peak = db[i]
|
||
setBandDb(Number.isFinite(peak) ? peak : null)
|
||
}
|
||
},
|
||
}, { hasListeningTool })
|
||
if (listenerRef.current === listener) {
|
||
const nyq = listener.nyquistHz
|
||
if (nyq) {
|
||
nyquistRef.current = nyq
|
||
setNyquist(nyq)
|
||
}
|
||
setListening(true)
|
||
}
|
||
} catch (err) {
|
||
if (listenerRef.current === listener) listenerRef.current = null
|
||
// Blaming the seeker for a refusal that never happened sends them to
|
||
// fix the wrong thing. getUserMedia's DOMException names distinguish
|
||
// the real causes, so each one gets truthful, actionable copy:
|
||
// NotAllowedError is a genuine denial; NotFoundError means there's no
|
||
// microphone at all; NotReadableError means another app holds it; and
|
||
// on a bare http:// origin mediaDevices is simply absent.
|
||
const name = err instanceof DOMException ? err.name : ''
|
||
const noMediaApi =
|
||
typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia
|
||
setMicFailure(
|
||
noMediaApi || name === 'SecurityError'
|
||
? 'insecure'
|
||
: name === 'NotFoundError' || name === 'OverconstrainedError'
|
||
? 'absent'
|
||
: name === 'NotReadableError' || name === 'AbortError'
|
||
? 'busy'
|
||
: 'denied',
|
||
)
|
||
setBandDb(null)
|
||
} finally {
|
||
setPending(false)
|
||
}
|
||
}
|
||
|
||
const stop = async () => {
|
||
const l = listenerRef.current
|
||
listenerRef.current = null
|
||
if (l) await l.stop()
|
||
setListening(false)
|
||
setBandDb(null)
|
||
binsRef.current = null
|
||
setMarkers([])
|
||
}
|
||
|
||
return (
|
||
<div className="evp-panel">
|
||
<SpectrumScope
|
||
binsRef={binsRef}
|
||
active={listening}
|
||
axisMin={0}
|
||
axisMax={nyquist}
|
||
unitLabel="Hz"
|
||
formatTick={(v) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${Math.round(v)}`)}
|
||
// Browser AnalyserNode dB values run roughly -100 (silence) to 0
|
||
// (clipping); a real room sits well down that range.
|
||
dbFloor={-100}
|
||
dbCeil={-18}
|
||
anomalies={markers}
|
||
hue={150}
|
||
label={t('seance.evp.scopeLabel', {
|
||
defaultValue: 'live audio spectrum from the microphone',
|
||
})}
|
||
/>
|
||
<div className="evp-readout scope-controls">
|
||
<span className="dim">{t('seance.evp.band')}</span>
|
||
<span className="evp-level">{bandDb !== null ? `${bandDb.toFixed(0)} dB` : '—'}</span>
|
||
<ListenToggle sonifier={sonifierRef.current} disabled={!listening} />
|
||
</div>
|
||
{listening && (
|
||
<p className="evp-listening flicker">
|
||
<span className="rec-dot" aria-hidden />
|
||
{t('seance.evp.listening')}
|
||
</p>
|
||
)}
|
||
{micFailure && (
|
||
<div className="panel-notice error">
|
||
<h4>{t(`seance.evp.fail.${micFailure}Title`)}</h4>
|
||
<p>{t(`seance.evp.fail.${micFailure}`)}</p>
|
||
</div>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="panel-action"
|
||
onClick={() => void (listening ? stop() : start())}
|
||
disabled={pending}
|
||
>
|
||
{listening ? t('seance.evp.stop') : t('seance.evp.start')}
|
||
</button>
|
||
<ModeHint mode="evp" suppressed={listening || pending} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- radio panel: RTL-SDR FM sweep ----
|
||
|
||
function RadioPanel() {
|
||
const { sendAnomaly, entropy } = useSeance()
|
||
const { t } = useTranslation()
|
||
// WebUSB hides itself entirely outside secure ground (https / localhost).
|
||
const secure = useMemo(() => isSecureContext(), [])
|
||
const supported = useMemo(() => isSupported(), [])
|
||
const [phase, setPhase] = useState<'idle' | 'busy' | 'sweeping'>('idle')
|
||
const [error, setError] = useState<{ kind: 'cancelled' | 'claim' | 'other'; message?: string } | null>(null)
|
||
const [tunedMhz, setTunedMhz] = useState<number | null>(null)
|
||
const [markers, setMarkers] = useState<ScopeAnomaly[]>([])
|
||
// Written per sweep step, read by the scope's own rAF loop — see
|
||
// SpectrumScope's binsRef doc for why this isn't state.
|
||
const binsRef = useRef<Float64Array | null>(null)
|
||
const sdrRef = useRef<RtlSdr | null>(null)
|
||
const detectorRef = useRef<SpectrumAnomalyDetector | null>(null)
|
||
const lastTuneUiRef = useRef(0)
|
||
const sonifierRef = useRef(new SpectrumSonifier())
|
||
|
||
// False once this panel unmounts, so an open sequence still awaiting the
|
||
// device picker can tell it was abandoned — sdrRef alone can't cover that
|
||
// window, because it isn't assigned until after the awaits complete.
|
||
const mountedRef = useRef(true)
|
||
|
||
// Power down the dongle on unmount (mode switch / leaving the page).
|
||
useEffect(
|
||
() => () => {
|
||
mountedRef.current = false
|
||
const sdr = sdrRef.current
|
||
sdrRef.current = null
|
||
detectorRef.current = null
|
||
if (sdr) {
|
||
sdr.stopSweep()
|
||
void sdr.close()
|
||
}
|
||
},
|
||
[],
|
||
)
|
||
|
||
const stop = useCallback(() => {
|
||
const sdr = sdrRef.current
|
||
sdrRef.current = null
|
||
detectorRef.current = null
|
||
if (sdr) {
|
||
sdr.stopSweep()
|
||
void sdr.close()
|
||
}
|
||
setPhase('idle')
|
||
setTunedMhz(null)
|
||
binsRef.current = null
|
||
setMarkers([])
|
||
}, [])
|
||
|
||
const start = async () => {
|
||
if (!supported || sdrRef.current || phase === 'busy') return
|
||
setError(null)
|
||
setPhase('busy')
|
||
const sdr = new RtlSdr()
|
||
try {
|
||
await sdr.requestDevice()
|
||
await sdr.open()
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err)
|
||
const name = err instanceof DOMException ? err.name : ''
|
||
// NotFoundError: the seeker dismissed the picker — a gentle nudge only.
|
||
// Security/Network/claim failures: something else is holding the
|
||
// dongle (kernel DVB driver, missing WinUSB driver) — troubleshoot.
|
||
setError(
|
||
name === 'NotFoundError' || /no device selected/i.test(msg)
|
||
? { kind: 'cancelled' }
|
||
: name === 'SecurityError' || name === 'NetworkError' || /claim/i.test(msg)
|
||
? { kind: 'claim' }
|
||
: { kind: 'other', message: msg },
|
||
)
|
||
setPhase('idle')
|
||
void sdr.close()
|
||
return
|
||
}
|
||
// requestDevice() waits on the browser's device picker and open() runs
|
||
// ~20 sequential control transfers, so the seeker has ample time to
|
||
// switch modes or leave. Without this the dongle would be left claimed
|
||
// and sweeping forever against an unmounted panel, and could only be
|
||
// freed by closing the tab.
|
||
if (!mountedRef.current) {
|
||
void sdr.close()
|
||
return
|
||
}
|
||
sdrRef.current = sdr
|
||
detectorRef.current = new SpectrumAnomalyDetector()
|
||
setPhase('sweeping')
|
||
void sdr
|
||
.sweep(SWEEP_START_MHZ * 1e6, SWEEP_END_MHZ * 1e6, 1_800_000, {
|
||
onSpectrum: (centerHz, db) => {
|
||
if (sdrRef.current !== sdr) return
|
||
// powerSpectrumDb() allocates a fresh array per sweep step, so
|
||
// this is already a distinct object each time — the scope uses
|
||
// that identity change to know a new row is ready.
|
||
binsRef.current = db
|
||
entropy.addFrame(db)
|
||
const now = performance.now()
|
||
if (now - lastTuneUiRef.current > 250) {
|
||
lastTuneUiRef.current = now
|
||
setTunedMhz(centerHz / 1e6)
|
||
}
|
||
const det = detectorRef.current
|
||
if (det) {
|
||
const a = det.process(centerHz, SDR_SAMPLE_RATE_HZ, db, now)
|
||
if (a) {
|
||
// `a.frequency` is already in MHz (see SpectrumAnomalyDetector).
|
||
sendAnomaly('radio', a.frequency, a.magnitude)
|
||
setMarkers((prev) => [
|
||
...prev.slice(-11),
|
||
{ freq: a.frequency, magnitude: a.magnitude, at: now },
|
||
])
|
||
sonifierRef.current.ping(
|
||
a.frequency,
|
||
a.magnitude,
|
||
{ startHz: SWEEP_START_MHZ, endHz: SWEEP_END_MHZ },
|
||
now,
|
||
)
|
||
}
|
||
}
|
||
},
|
||
onError: (err) => {
|
||
if (sdrRef.current === sdr) setError({ kind: 'other', message: err.message })
|
||
},
|
||
})
|
||
.then(() => {
|
||
if (sdrRef.current === sdr) setPhase('idle')
|
||
})
|
||
.catch((err: unknown) => {
|
||
if (sdrRef.current === sdr) {
|
||
setError({ kind: 'other', message: err instanceof Error ? err.message : String(err) })
|
||
setPhase('idle')
|
||
}
|
||
})
|
||
}
|
||
|
||
if (!secure) {
|
||
return (
|
||
<div className="panel-notice">
|
||
<h4>{t('seance.radio.unsupportedTitle')}</h4>
|
||
<p>
|
||
{t('seance.radio.insecure', {
|
||
defaultValue:
|
||
'the radio dead only speak through consecrated ground — open this site via its https address.',
|
||
})}
|
||
</p>
|
||
<p className="dim troubleshoot">
|
||
{t('seance.radio.insecureHint', {
|
||
defaultValue: 'localhost counts as hallowed; a bare http://lan-ip does not.',
|
||
})}
|
||
</p>
|
||
<button type="button" className="panel-action" disabled>
|
||
{t('seance.radio.start')}
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!supported) {
|
||
return (
|
||
<div className="panel-notice">
|
||
<h4>{t('seance.radio.unsupportedTitle')}</h4>
|
||
<p>{t('seance.radio.unsupported')}</p>
|
||
<p className="dim troubleshoot">{t('seance.radio.troubleshoot')}</p>
|
||
<button type="button" className="panel-action" disabled>
|
||
{t('seance.radio.start')}
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="radio-panel">
|
||
<SpectrumScope
|
||
binsRef={binsRef}
|
||
active={phase === 'sweeping'}
|
||
// The sweep walks the whole band, so the axis is the band itself —
|
||
// markers are placed by the anomaly's absolute MHz, which is what
|
||
// the detector reports.
|
||
axisMin={SWEEP_START_MHZ}
|
||
axisMax={SWEEP_END_MHZ}
|
||
unitLabel="MHz"
|
||
formatTick={(v) => v.toFixed(0)}
|
||
dbFloor={-95}
|
||
dbCeil={-25}
|
||
anomalies={markers}
|
||
hue={272}
|
||
label={t('seance.radio.scopeLabel', {
|
||
defaultValue: 'live radio spectrum from the receiver',
|
||
})}
|
||
/>
|
||
<div className="radio-readout scope-controls">
|
||
{tunedMhz !== null ? (
|
||
<span className="tuned-freq">{tunedMhz.toFixed(1)} MHz</span>
|
||
) : (
|
||
<span className="dim">—</span>
|
||
)}
|
||
{phase === 'sweeping' && (
|
||
<span className="dim flicker">
|
||
{t('seance.radio.sweeping', { start: SWEEP_START_MHZ, end: SWEEP_END_MHZ })}
|
||
</span>
|
||
)}
|
||
<ListenToggle sonifier={sonifierRef.current} disabled={phase !== 'sweeping'} />
|
||
</div>
|
||
{error?.kind === 'cancelled' && (
|
||
<div className="panel-notice">
|
||
<p>{t('seance.radio.noDevice')}</p>
|
||
</div>
|
||
)}
|
||
{error?.kind === 'claim' && (
|
||
<div className="panel-notice error">
|
||
<h4>{t('seance.radio.claimFailed')}</h4>
|
||
<p>
|
||
{t('seance.radio.claimFix', {
|
||
defaultValue:
|
||
'linux: the kernel driver dvb_usb_rtl28xxu is holding the dongle — `sudo modprobe -r dvb_usb_rtl28xxu` frees it until reboot (a udev rule banishes it for good). windows: swap the driver for WinUSB with Zadig.',
|
||
})}
|
||
</p>
|
||
<button type="button" className="panel-subaction" onClick={() => void start()}>
|
||
{t('seance.radio.tryAgain', { defaultValue: 'try again' })}
|
||
</button>
|
||
</div>
|
||
)}
|
||
{error?.kind === 'other' && (
|
||
<div className="panel-notice error">
|
||
<h4>{t('seance.radio.errorTitle', { defaultValue: 'the static pushed back' })}</h4>
|
||
<p>{error.message}</p>
|
||
</div>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="panel-action"
|
||
onClick={() => void (phase === 'sweeping' ? stop() : start())}
|
||
disabled={phase === 'busy'}
|
||
>
|
||
{phase === 'sweeping' ? t('seance.radio.stop') : t('seance.radio.start')}
|
||
</button>
|
||
<ModeHint mode="radio" suppressed={phase !== 'idle'} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- ouija panel: what the planchette is doing ----
|
||
|
||
function OuijaPanel({ snapshot }: { snapshot: PlanchetteSnapshot | null }) {
|
||
const { t } = useTranslation()
|
||
const word = snapshot?.word ?? null
|
||
|
||
return (
|
||
<div className="ouija-panel">
|
||
{word ? (
|
||
<div className="ouija-spelling">
|
||
<span className="dim ouija-spelling-label">
|
||
{t('seance.ouija.spelling', { defaultValue: 'the planchette spells' })}
|
||
</span>
|
||
<div className="ouija-word" aria-live="polite">
|
||
{word.split('').map((ch, i) => (
|
||
<span
|
||
key={i}
|
||
className={
|
||
snapshot && i === snapshot.index ? 'lit' : snapshot && i < snapshot.index ? 'done' : ''
|
||
}
|
||
>
|
||
{ch}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="dim ouija-drift">
|
||
{t('seance.ouija.drift', {
|
||
defaultValue: 'the planchette drifts, waiting for words — ask below, or summon.',
|
||
})}
|
||
</p>
|
||
)}
|
||
{snapshot && snapshot.spelled.length > 0 && (
|
||
<p className="ouija-history dim">{snapshot.spelled.slice(-6).join(' · ')}</p>
|
||
)}
|
||
<ModeHint mode="ouija" suppressed={word !== null} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- EMF panel: the phone becomes a field meter ----
|
||
|
||
/** Highest field strength the gauge renders before pinning the needle. */
|
||
const EMF_GAUGE_MAX = 6
|
||
|
||
function EmfPanel() {
|
||
const { sendAnomaly, entropy } = useSeance()
|
||
const { t } = useTranslation()
|
||
const supported = useMemo(() => EmfSensorListener.isSupported(), [])
|
||
// A true magnetometer measures the magnetic field itself; the motion
|
||
// listener only infers disturbance from movement, so a phone sitting
|
||
// still beside a running motor reads nothing on it. Where the hardware
|
||
// exists (Chromium/Android) we run both — this is additive, and the
|
||
// motion path stays the only option on iOS.
|
||
const magSupported = useMemo(() => magIsSupported(), [])
|
||
const [magField, setMagField] = useState<number | null>(null)
|
||
const magRef = useRef<MagnetometerListener | null>(null)
|
||
|
||
useEffect(
|
||
() => () => {
|
||
const m = magRef.current
|
||
magRef.current = null
|
||
if (m) m.stop()
|
||
},
|
||
[],
|
||
)
|
||
const [listening, setListening] = useState(false)
|
||
const [pending, setPending] = useState(false)
|
||
const [denied, setDenied] = useState(false)
|
||
const [field, setField] = useState<number | null>(null)
|
||
const [baseline, setBaseline] = useState<number | null>(null)
|
||
const listenerRef = useRef<EmfSensorListener | null>(null)
|
||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||
|
||
// Drop the sensor listeners when the panel unmounts (mode switch / leaving).
|
||
useEffect(
|
||
() => () => {
|
||
const l = listenerRef.current
|
||
listenerRef.current = null
|
||
if (l) l.stop()
|
||
},
|
||
[],
|
||
)
|
||
|
||
const startMagnetometer = () => {
|
||
if (!magSupported || magRef.current) return
|
||
const mag = new MagnetometerListener()
|
||
magRef.current = mag
|
||
void mag
|
||
.start({
|
||
onReading: (r) => {
|
||
setMagField(r.magnitude)
|
||
// Real magnetic-field jitter is genuine physical noise — bank it
|
||
// toward the next summon like every other live sensor.
|
||
entropy.addFrame([r.x, r.y, r.z])
|
||
},
|
||
onAnomaly: (a) => sendAnomaly('emf', a.magnitude, Math.abs(a.deviation)),
|
||
})
|
||
.catch(() => {
|
||
// No magnetometer permission or no hardware: the motion listener
|
||
// still carries the mode, so this stays silent.
|
||
magRef.current = null
|
||
})
|
||
}
|
||
|
||
const stopMagnetometer = () => {
|
||
const m = magRef.current
|
||
magRef.current = null
|
||
if (m) m.stop()
|
||
setMagField(null)
|
||
}
|
||
|
||
const drawGauge = (strength: number) => {
|
||
const canvas = canvasRef.current
|
||
if (!canvas) return
|
||
const ctx = canvas.getContext('2d')
|
||
if (!ctx) return
|
||
const w = canvas.width
|
||
const h = canvas.height
|
||
ctx.clearRect(0, 0, w, h)
|
||
const cx = w / 2
|
||
const cy = h - 14
|
||
const r = Math.min(w / 2 - 14, h - 30)
|
||
const norm = Math.max(0, Math.min(1, strength / EMF_GAUGE_MAX))
|
||
const hue = 150 + norm * 122 // phosphor at rest → violet when it surges
|
||
|
||
// Track arc.
|
||
ctx.lineWidth = 10
|
||
ctx.lineCap = 'round'
|
||
ctx.strokeStyle = 'rgba(124, 255, 178, 0.12)'
|
||
ctx.beginPath()
|
||
ctx.arc(cx, cy, r, Math.PI, 2 * Math.PI)
|
||
ctx.stroke()
|
||
|
||
// Lit arc.
|
||
if (norm > 0.004) {
|
||
ctx.strokeStyle = `hsla(${hue} 90% 68% / 0.92)`
|
||
ctx.shadowColor = `hsla(${hue} 90% 68% / 0.8)`
|
||
ctx.shadowBlur = 12
|
||
ctx.beginPath()
|
||
ctx.arc(cx, cy, r, Math.PI, Math.PI + norm * Math.PI)
|
||
ctx.stroke()
|
||
ctx.shadowBlur = 0
|
||
}
|
||
|
||
// Tick marks.
|
||
ctx.lineWidth = 1
|
||
ctx.strokeStyle = 'rgba(211, 233, 219, 0.25)'
|
||
for (let i = 0; i <= 8; i++) {
|
||
const a = Math.PI + (i / 8) * Math.PI
|
||
ctx.beginPath()
|
||
ctx.moveTo(cx + Math.cos(a) * (r - 10), cy + Math.sin(a) * (r - 10))
|
||
ctx.lineTo(cx + Math.cos(a) * (r - 15), cy + Math.sin(a) * (r - 15))
|
||
ctx.stroke()
|
||
}
|
||
|
||
// Needle.
|
||
const ang = Math.PI + norm * Math.PI
|
||
ctx.lineWidth = 2
|
||
ctx.strokeStyle = `hsla(${hue} 85% 78% / 0.95)`
|
||
ctx.beginPath()
|
||
ctx.moveTo(cx, cy)
|
||
ctx.lineTo(cx + Math.cos(ang) * (r - 20), cy + Math.sin(ang) * (r - 20))
|
||
ctx.stroke()
|
||
ctx.fillStyle = '#7cffb2'
|
||
ctx.beginPath()
|
||
ctx.arc(cx, cy, 3, 0, 2 * Math.PI)
|
||
ctx.fill()
|
||
}
|
||
|
||
// Paint the resting needle once (and whenever support is re-checked).
|
||
useEffect(() => {
|
||
if (supported) drawGauge(0)
|
||
}, [supported])
|
||
|
||
const start = async () => {
|
||
if (pending || listening || !supported) return
|
||
setDenied(false)
|
||
setPending(true)
|
||
const listener = listenerRef.current ?? new EmfSensorListener()
|
||
listenerRef.current = listener
|
||
try {
|
||
// iOS gates the senses behind an explicit, gesture-triggered grant.
|
||
if (EmfSensorListener.needsPermission()) {
|
||
const granted = await EmfSensorListener.requestPermission()
|
||
if (!granted) {
|
||
if (listenerRef.current === listener) setDenied(true)
|
||
return
|
||
}
|
||
}
|
||
if (listenerRef.current !== listener) return // unmounted mid-permission
|
||
listener.start({
|
||
onAnomaly: (a) => sendAnomaly('emf', a.frequency, a.magnitude),
|
||
onReading: (strength, base) => {
|
||
drawGauge(strength)
|
||
setField(strength)
|
||
setBaseline(base)
|
||
},
|
||
})
|
||
setListening(true)
|
||
startMagnetometer()
|
||
} finally {
|
||
setPending(false)
|
||
}
|
||
}
|
||
|
||
const stop = () => {
|
||
const l = listenerRef.current
|
||
if (l) l.stop()
|
||
setListening(false)
|
||
stopMagnetometer()
|
||
setField(null)
|
||
setBaseline(null)
|
||
drawGauge(0)
|
||
}
|
||
|
||
const calibrate = () => {
|
||
listenerRef.current?.reset()
|
||
}
|
||
|
||
if (!supported) {
|
||
return (
|
||
<div className="panel-notice">
|
||
<h4>
|
||
{t('seance.emf.unsupportedTitle', { defaultValue: 'this vessel carries no field senses' })}
|
||
</h4>
|
||
<p>
|
||
{t('seance.emf.unsupported', {
|
||
defaultValue:
|
||
'a phone in your hand becomes the meter — this vessel has no motion senses to lend. open the veil on a phone, or let the other modes answer.',
|
||
})}
|
||
</p>
|
||
<button type="button" className="panel-action" disabled>
|
||
{t('seance.emf.start', { defaultValue: 'raise the meter' })}
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="emf-panel">
|
||
<canvas ref={canvasRef} width={260} height={140} className="emf-gauge" aria-hidden />
|
||
{magField !== null && (
|
||
<div className="emf-readout">
|
||
<span className="dim">
|
||
{t('seance.emf.trueField', { defaultValue: 'magnetic field' })}
|
||
</span>
|
||
<span className="evp-level">{magField.toFixed(1)} µT</span>
|
||
</div>
|
||
)}
|
||
<div className="emf-readout">
|
||
<span className="emf-level flicker">{field !== null ? field.toFixed(2) : '—'}</span>
|
||
<span className="emf-baseline dim">
|
||
{t('seance.emf.baseline', { defaultValue: 'resting field' })}{' '}
|
||
{baseline !== null ? baseline.toFixed(2) : '—'}
|
||
</span>
|
||
</div>
|
||
{listening && (
|
||
<p className="emf-listening flicker">
|
||
<span className="rec-dot" aria-hidden />
|
||
{t('seance.emf.listening', { defaultValue: 'feeling for disturbances in the field…' })}
|
||
</p>
|
||
)}
|
||
{denied && (
|
||
<div className="panel-notice error">
|
||
<h4>{t('seance.emf.deniedTitle', { defaultValue: 'the field senses stay shut' })}</h4>
|
||
<p>
|
||
{t('seance.emf.denied', {
|
||
defaultValue:
|
||
'this vessel refused its motion senses. grant motion access in your browser settings, then raise the meter again.',
|
||
})}
|
||
</p>
|
||
</div>
|
||
)}
|
||
<div className="emf-actions">
|
||
<button
|
||
type="button"
|
||
className="panel-action"
|
||
onClick={() => (listening ? stop() : void start())}
|
||
disabled={pending}
|
||
>
|
||
{listening
|
||
? t('seance.emf.stop', { defaultValue: 'ground the meter' })
|
||
: t('seance.emf.start', { defaultValue: 'raise the meter' })}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="panel-subaction"
|
||
onClick={calibrate}
|
||
disabled={!listening}
|
||
>
|
||
{t('seance.emf.calibrate', { defaultValue: 'calibrate' })}
|
||
</button>
|
||
</div>
|
||
<ModeHint mode="emf" suppressed={listening || pending} />
|
||
</div>
|
||
)
|
||
}
|