Skip to content
File

Blob: worker/src/client/exhibit/exhibit.tsx

typescript167 lines
1import { useRef, useState } from 'react';
2import { playbackView } from '@/client/radio/playback.ts';
3import type { SessionState, SignalBuffer } from '@/client/radio/session-state.ts';
4import type { RadioActions } from '@/client/radio/use-radio.ts';
5import { Login } from '@/client/ui/login.tsx';
6import { BoardNode } from './board/board-node.tsx';
7import { AudioPanel } from './audio/audio-panel.tsx';
8import { SpectrumPanel } from './spectrum/spectrum-panel.tsx';
9import { ControlsPanel } from './controls/controls-panel.tsx';
10import { SignalPopover } from './details/signal-popover.tsx';
11import { SfuNode } from './diagram/sfu-node.tsx';
12import { Wires } from './diagram/wires.tsx';
13import { BRANCHES, branchStyles } from './diagram/diagram-model.ts';
14import type { ExplanationTrigger, Selection, SignalDetails } from './diagram/diagram-model.ts';
15 
16/** Adapts the radio snapshot to the exhibit; panels receive only their own data and actions. */
17export function Exhibit({
18 state,
19 signal,
20 actions,
21}: {
22 state: SessionState;
23 signal: SignalBuffer;
24 actions: RadioActions;
25}) {
26 const diagramRef = useRef<HTMLDivElement>(null);
27 const [details, setDetails] = useState<SignalDetails | null>(null);
28 const selected = details?.selection ?? null;
29 const select = (selection: Selection, anchor: HTMLButtonElement) =>
30 setDetails((previous) =>
31 previous?.selection === selection && previous.anchor === anchor
32 ? null
33 : { selection, anchor },
34 );
35 const connected = state.phase === 'connected';
36 const playback = playbackView(state);
37 const error =
38 state.issues.connection ??
39 state.issues.control ??
40 state.issues.status ??
41 state.issues.heartbeat;
42 const active = {
43 audio: connected && state.audio.at > 0 && state.now - state.audio.at < 3000,
44 spectrum: connected && signal.at > 0 && state.now - signal.at < 1200,
45 robot: connected && state.telemetryAt > 0 && state.now - state.telemetryAt < 3000,
46 };
47 const explanation = (selection: Selection): ExplanationTrigger => ({
48 expanded: selected === selection,
49 onSelect: (anchor) => select(selection, anchor),
50 });
51 const held = connected && !!state.viewerId && state.room.controller === state.viewerId;
52 return (
53 <>
54 {state.auth === 'required' ? <Login onLogin={actions.login} /> : null}
55 <div className="flex flex-col items-start gap-1 rounded-t-xl border border-line bg-panel px-4 py-2 lg:flex-row lg:items-center lg:justify-between">
56 <div
57 className="flex w-full justify-between gap-3 lg:w-auto lg:gap-5 [&_button]:flex [&_button]:min-h-10 [&_button]:items-center [&_button]:gap-1.5 [&_button]:text-[10px] sm:[&_button]:text-xs [&_button[aria-expanded=true]]:underline [&_button]:underline-offset-4 [&_i]:h-px [&_i]:w-3 [&_i]:bg-current"
58 aria-label="Explore signal paths"
59 >
60 {BRANCHES.map((branch) => (
61 <button
62 key={branch}
63 className={branchStyles[branch].text}
64 onClick={(event) => select(branch, event.currentTarget)}
65 aria-haspopup="dialog"
66 aria-expanded={selected === branch}
67 aria-controls={selected === branch ? 'signal-detail' : undefined}
68 >
69 <i />
70 {branch === 'robot'
71 ? 'Telemetry & commands'
72 : branch === 'audio'
73 ? 'Audio'
74 : 'Spectrum'}
75 </button>
76 ))}
77 </div>
78 </div>
79 <div
80 className="relative isolate grid grid-cols-1 gap-0 border-x border-b border-line bg-surface p-4 sm:p-6 lg:grid-cols-[minmax(205px,1fr)_minmax(145px,0.72fr)_minmax(370px,1.8fr)] lg:gap-6 lg:p-7"
81 ref={diagramRef}
82 >
83 <Wires
84 container={diagramRef}
85 active={active}
86 selected={selected}
87 returning={
88 state.ack.state === 'pending' ||
89 (state.ack.state === 'confirmed' && state.now - state.ack.at < 2000)
90 }
91 />
92 <BoardNode
93 led={state.led}
94 cpu={state.telemetry?.hardware?.cpuBusyBps}
95 {...explanation('board')}
96 />
97 <SfuNode {...explanation('sfu')} />
98 <div className="relative z-1 flex min-w-0 flex-col gap-6 pl-3 sm:pl-4 lg:gap-4 lg:pl-0 before:absolute before:-top-7 before:bottom-8 before:left-0 before:w-px before:bg-line lg:before:hidden">
99 <AudioPanel
100 phase={state.phase}
101 available={state.auth === 'ready' && (state.room.online || state.phase !== 'idle')}
102 track={playback.track}
103 position={playback.position}
104 duration={playback.duration}
105 kbps={state.audio.kbps}
106 live={active.audio}
107 muted={state.muted}
108 volume={state.volume}
109 audioBlocked={state.audioBlocked}
110 onConnect={actions.connect}
111 onDisconnect={actions.disconnect}
112 onToggleAudio={actions.toggleAudio}
113 onVolumeChange={actions.setVolume}
114 {...explanation('audio')}
115 />
116 <SpectrumPanel
117 signal={signal}
118 live={active.spectrum}
119 paused={playback.paused}
120 {...explanation('spectrum')}
121 />
122 <ControlsPanel
123 telemetry={state.telemetry}
124 trackCount={playback.trackCount}
125 paused={playback.paused}
126 led={state.led}
127 auth={state.auth}
128 connected={connected}
129 held={held}
130 occupied={!!state.room.controller && !held}
131 busy={state.busy}
132 ack={state.ack}
133 live={active.robot}
134 onToggleControl={actions.toggleControl}
135 onSend={actions.send}
136 {...explanation('robot')}
137 />
138 </div>
139 </div>
140 <div className="flex items-start gap-2.5 rounded-b-xl border-x border-b border-line bg-panel px-4 py-3 sm:items-center sm:px-5 [&>.status-dot]:mt-1.5 sm:[&>.status-dot]:mt-0 [&>p]:flex-1 [&>p]:text-xs [&>p]:leading-relaxed">
141 <span
142 className={`status-dot inline-block size-1.5 shrink-0 rounded-full ${connected ? 'bg-spectrum' : 'bg-muted'}`}
143 />
144 <p role="status" aria-live="polite">
145 {state.message}
146 </p>
147 <span className="hidden font-mono text-[9px] whitespace-nowrap text-muted lg:block">
148 {state.received.toLocaleString()} telemetry · {state.spectrumCount.toLocaleString()}{' '}
149 spectrum
150 </span>
151 </div>
152 {error ? (
153 <p className="mt-3 text-sm text-error" role="alert">
154 {error}
155 </p>
156 ) : null}
157 <SignalPopover
158 details={details}
159 analysis={state.telemetry?.spectrum}
160 hardware={state.telemetry?.hardware}
161 radioStack={state.telemetry?.stackFree}
162 close={() => setDetails(null)}
163 />
164 </>
165 );
166}