File
Blob: worker/src/client/exhibit/exhibit.tsx
| 1 | import { useRef, useState } from 'react'; |
| 2 | import { playbackView } from '@/client/radio/playback.ts'; |
| 3 | import type { SessionState, SignalBuffer } from '@/client/radio/session-state.ts'; |
| 4 | import type { RadioActions } from '@/client/radio/use-radio.ts'; |
| 5 | import { Login } from '@/client/ui/login.tsx'; |
| 6 | import { BoardNode } from './board/board-node.tsx'; |
| 7 | import { AudioPanel } from './audio/audio-panel.tsx'; |
| 8 | import { SpectrumPanel } from './spectrum/spectrum-panel.tsx'; |
| 9 | import { ControlsPanel } from './controls/controls-panel.tsx'; |
| 10 | import { SignalPopover } from './details/signal-popover.tsx'; |
| 11 | import { SfuNode } from './diagram/sfu-node.tsx'; |
| 12 | import { Wires } from './diagram/wires.tsx'; |
| 13 | import { BRANCHES, branchStyles } from './diagram/diagram-model.ts'; |
| 14 | import 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. */ |
| 17 | export 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 | } |