6. Frontend Components
6.1 WebSocket Hook (useSocket.ts)
import { useEffect, useRef, useCallback } from "react";
import { useLocoStore } from "./store";
type Envelope = { type: string; id?: string; payload?: unknown };
export function useSocket(url: string) {
const wsRef = useRef<WebSocket | null>(null);
const applyEvent = useLocoStore((s) => s.applyEvent);
const connect = useCallback(() => {
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onmessage = (e) => {
const env: Envelope = JSON.parse(e.data);
applyEvent(env);
};
ws.onclose = () => setTimeout(connect, 1000); // reconnect with backoff
}, [url, applyEvent]);
useEffect(() => {
connect();
return () => wsRef.current?.close();
}, [connect]);
const send = useCallback((env: Envelope) => {
wsRef.current?.send(JSON.stringify(env));
}, []);
return { send };
}
6.2 Zustand Store for Locomotive State
import { create } from "zustand";
type LocoState = {
addr: number;
speed: number;
forward: boolean;
functions: number[];
};
type Store = {
locos: Record<number, LocoState>;
applyEvent: (env: { type: string; payload?: any }) => void;
};
export const useLocoStore = create<Store>((set) => ({
locos: {},
applyEvent: (env) => {
if (env.type === "loco.state") {
const st = env.payload as LocoState;
set((s) => ({ locos: { ...s.locos, [st.addr]: st } }));
}
},
}));
6.3 Control Component (Material UI)
import { useEffect } from "react";
import {
Card,
CardContent,
CardActions,
Typography,
Slider,
Stack,
IconButton,
ToggleButton,
ToggleButtonGroup,
} from "@mui/material";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import StopIcon from "@mui/icons-material/Stop";
function LocoControl({ addr }: { addr: number }) {
const { send } = useSocket(`ws://${location.host}/api/v1/ws`);
const state = useLocoStore((s) => s.locos[addr]);
useEffect(() => {
send({ type: "loco.subscribe", payload: { addr } });
return () => send({ type: "loco.unsubscribe", payload: { addr } });
}, [addr, send]);
const setSpeed = (speed: number) =>
send({
type: "loco.setSpeed",
payload: { addr, speed, forward: state?.forward ?? true },
});
const setDirection = (forward: boolean) =>
send({
type: "loco.setSpeed",
payload: { addr, speed: state?.speed ?? 0, forward },
});
return (
<Card sx={{ maxWidth: 480, m: 2 }}>
<CardContent>
<Typography variant="h5" gutterBottom>
Loco #{addr}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
{state?.speed ?? 0} step{state?.forward ? " ▶" : " ◀"}
</Typography>
<Slider
value={state?.speed ?? 0}
min={0}
max={127}
aria-label="Throttle"
onChange={(_, v) => setSpeed(v as number)}
/>
</CardContent>
<CardActions>
<ToggleButtonGroup
exclusive
value={state?.forward ? "fwd" : "rev"}
onChange={(_, v) => v && setDirection(v === "fwd")}
size="small"
>
<ToggleButton value="rev" aria-label="Reverse">
<ArrowBackIcon />
</ToggleButton>
<ToggleButton value="fwd" aria-label="Forward">
<ArrowForwardIcon />
</ToggleButton>
</ToggleButtonGroup>
<IconButton color="error" onClick={() => setSpeed(0)} aria-label="Stop">
<StopIcon />
</IconButton>
</CardActions>
</Card>
);
}
6.3a Train control in the Throttle overlay
Train driving is not a separate page. It lives inside the same
full-screen Throttle overlay as single-vehicle control (ThrottlePage →
ThrottleCockpit, §6.3b). The picker lists vehicles and trains
together (useThrottleTargetSelection persists the last target in
localStorage).
When a train is selected:
- the slider / direction / stop dispatch
train.setSpeedon the data-plane WS (useDccBus().setTrainSpeed, debounced viauseDebouncedTrainSpeedSend) — not onloco-server's control plane; - speed and direction are read from the leading member's
loco.state(first powered, non-excluded member inPositionorder); - the client
loco.subscribes every powered member address on the data plane (notrain.subscribe); - the function area becomes
<TrainFunctionAccordions>— one collapsed accordion per powered member, leading first; each summary shows the member name, a small current DCC speed read from that member'sloco.state, and (for the leading vehicle) a prowadzący chip; each body is that member's<FunctionGridButton>grid wired toloco.toggleFnon the data plane; - the train owner sees a cog on every powered member (including
the leading one) that opens
<TrainMemberSettingsDialog>and persists per-member timing viaPATCH /api/v1/trains/{id}/members/{memberId}(lessees: cog hidden).
Per-member settings (TrainMemberSettingsDialog)
Owner-only cog popup. Fields depend on whether the member is the leading vehicle:
| Field | Trailing members | Leading vehicle |
|---|---|---|
speedMultiplier (0.05–4.0) |
editable | fixed 1.0 (not sent on PATCH) |
excludeFromSpeed |
checkbox | not offered |
startDelayMs |
0 or 50–1000 ms (step 50) | editable |
accelRampMs + accelRampMaxSteps |
0 or 0.5–5 s (step 0.5 s), 1–10 steps | editable |
brakeRampMs + brakeRampMaxSteps |
same ranges as accel ramp | editable |
When excludeFromSpeed is checked on a trailing member, all timing
fields are cleared on save and that vehicle is skipped by
train.setSpeed fan-out on the daemon.
Strings live under throttle.json → train.memberSettings.* (pl + en).
How dcc-bus applies member timing
Each train.setSpeed is handled by TrainSpeedScheduler
(pkgs/bigfred/dcc-bus/service/train_speed_scheduler.go). A new command
cancels any pending delay/ramp goroutines for that train.
Per powered member (after computing the effective target speed from the
slider, multiplier, and Reversed):
- Acceleration ramp — when target > current,
accelRampMs > 0, and either the member is already above the consist-start threshold (DCC speed > 1) orstartDelayMs == 0. The daemon issues intermediatesetSpeedsteps in one goroutine: apply first, thensleepbetween steps. Total ramp duration and max steps come from member settings; step count is reduced until each interval is ≥ 500 ms. - Braking ramp — when target < current and
brakeRampMs > 0. Same step/sleep pattern as acceleration (including stop to 0). - Start delay — on a consist start (leading vehicle was at DCC
speed ≤ 1), when the member is also at speed ≤ 1,
startDelayMs > 0, and acceleration ramp does not apply: a singlesleep, then one immediatesetSpeedto the target. - Immediate — otherwise the target speed is written synchronously.
Acceleration ramp takes precedence over start delay when both could
apply at standstill (start delay only wins when acceleration ramp is
disabled or blocked because startDelayMs > 0 and current speed ≤ 1).
The slider witness stays the leading member; trailing speeds may lag during ramps and appear in each accordion header.
// ThrottlePage.tsx (ConnectedThrottle, excerpt — train branch)
const { setTrainSpeed, setFunction, subscribe, states } = useDccBus();
const trainCtx = useSelectedTrainContext(layoutID, trainId);
useEffect(() => {
void subscribe(trainCtx.poweredMembers.map((m) => m.dccAddress));
}, [trainCtx.poweredMembers, subscribe]);
const witness = states.get(trainCtx.leadingAddr);
const { queueSpeed } = useDebouncedTrainSpeedSend(setTrainSpeed);
// slider → queueSpeed(trainId, speed, forward)
// accordions → states.get(member.dccAddress)?.speed in header;
// setFunction(member.dccAddress, fn, on) in body
Single-vehicle selection keeps the flat function grid and
loco.setSpeed unchanged. <TakeoverThrottleOverlay> still drives one
vehicle via the legacy selectedAddress props on ThrottleCockpit.
6.3b Throttle mode – full-screen overlay
Throttle mode (tryb sterowania „Throttle”, see §1) is how a
driver or a signalman (after a granted takeover) operates a
vehicle or train in real time. It is not a separate application
route the user navigates away from: it is a layer above the rest of
BigFred that hosts the driving surface (ThrottleSlider, function and
script buttons, command-station picker, script console, takeover
banners, dead-man's switch affordances).
Entry, exit and shell layout
The sticky top AppBar in AppShell.tsx carries a Throttle button
(labelled via vehicle.json, icon: engineer / maszynista) among the
account-level controls. Clicking it toggles throttle mode:
- Open – a full-screen overlay is rendered immediately below the
AppBar. It occupies the remaining viewport height (position: fixed, top aligned to the AppBar, highz-index). Every other page (admin screens, vehicle lists, future radio panel) stays mounted underneath but is visually covered; only the AppBar remains visible. - Close – the same button (or an explicit close control on the overlay) dismisses the layer without tearing down WebSocket subscriptions, so re-entry is instant.
Gating: the button is shown to users who may drive at least one vehicle or train in the current session layout (drivers on owned/leased scope; signalmen only while they hold active takeover authority on a target). Exact rules follow the permission matrix in §11.
Controls available inside the overlay
While throttle mode is open and driving authority is held, the operator can:
- set speed and direction (
loco.setSpeed/train.setSpeed), - toggle registered DCC functions (
loco.toggleFn), - start and stop attached scripts (
script.run/script.stop), - trigger emergency braking (
system.estop); - trigger Radio Stop (
system.radioStop, §4.6) – layout-wide halt with confirmation overlay and radiostop sound on every throttle session.
Throttle commands (loco.*, train.setSpeed, system.estop) travel
over the data-plane WebSocket to dcc-bus (§7e.4). Radio Stop and
other control-plane actions use /api/v1/ws (§4.2, §7e.7).
Left toolbar – Fullscreen and Radio Stop
The overlay renders a thin toolbar pinned to its left edge
(<ThrottleToolbar>), above the vehicle/train driving surface. Two
controls live there, left to right:
- Fullscreen toggle (
<FullscreenButton>) – toggles the browser Fullscreen API on the overlay container (element.requestFullscreen()/document.exitFullscreen()). The icon flips betweenFullscreenandFullscreenExitbased ondocument.fullscreenElement. Strings:throttle.fullscreen.enter/throttle.fullscreen.exit. Shown to every operator in throttle mode (no extra gate). - Radio Stop (
<RadioStopButton>) – sits immediately to the right of the Fullscreen toggle. Red, radio-handset icon. Rendered only whenuseCanDriveAny()passes (same gate as the AppBar Throttle toggle and §4.6.2). Pressing it opens<RadioStopConfirmOverlay>.
<RadioStopConfirmOverlay> is a centred MUI Dialog containing a
primary red button „Uruchom radiostop” and, below it, a neutral
„Anuluj”. Only the former dispatches system.radioStop {} on the
control plane (useControlPlane()), then closes; „Anuluj” just
dismisses. Strings: throttle.radioStop.button,
throttle.radioStop.run, throttle.radioStop.cancel,
throttle.radioStop.tooltip.
// ThrottleToolbar.tsx (excerpt)
function ThrottleToolbar({ overlayRef }: { overlayRef: RefObject<HTMLElement> }) {
const canDrive = useCanDriveAny();
return (
<Stack className="throttle-toolbar" direction="row" spacing={1}>
<FullscreenButton target={overlayRef} />
{canDrive && <RadioStopButton />}
</Stack>
);
}
function RadioStopButton() {
const { send } = useControlPlane();
const [open, setOpen] = useState(false);
return (
<>
<IconButton color="error" aria-label={t("throttle:radioStop.button")}
onClick={() => setOpen(true)}>
<SettingsInputAntennaIcon />
</IconButton>
<Dialog open={open} onClose={() => setOpen(false)}>
<Stack spacing={2} sx={{ p: 3, alignItems: "stretch" }}>
<Button variant="contained" color="error" size="large"
onClick={() => { send({ type: "system.radioStop", payload: {} }); setOpen(false); }}>
{t("throttle:radioStop.run")}
</Button>
<Button variant="text" onClick={() => setOpen(false)}>
{t("throttle:radioStop.cancel")}
</Button>
</Stack>
</Dialog>
</>
);
}
Radiostop alarm playback
A small useRadioStopSound() hook (mounted once inside the overlay)
subscribes to the control-plane system.radioStop push event and
plays the bundled asset at /sounds/radiostop.ogg:
function useRadioStopSound() {
useControlPlaneEvent("system.radioStop", () => {
new Audio("/sounds/radiostop.ogg").play().catch(() => {/* autoplay blocked */});
});
}
The same clip plays on every open throttle session in the layout
(the operator who pressed the button and everyone else), so the alarm
is heard simultaneously. Non-throttle surfaces (dashboard) receive the
same event but only show a toast (throttle.radioStop.toast) — they do
not mount the audio hook.
Radio and chat (driver side)
The throttle toolbar carries two more icons next to the Radio Stop button: a radio icon and a chat icon. They are the driver's walkie-talkie surface (§4.4) and are independent of the layout-wide Radio Stop.
Radio icon (<ThrottleRadioButton> → <ThrottleRadioOverlay>).
Opens an on-screen overlay used to send a radio message about the
currently driven vehicle/train (that target is the message
context). The overlay stacks, top to bottom:
- Interlocking picker — a text field with search that filters
the layout's interlockings (
GET /api/v1/interlockings); the picked box becomesto.interlockingId. - Phrase picker — the same searchable table of the closed
RadioPhrasevocabulary used on the signalman side (<RadioPhrasePickerDialog>), so the driver visually finds the phrase.
Selecting a phrase emits
radio.send { to:{ interlockingId }, context:{ vehicleId | trainId }, phrase }
and plays /sounds/interlockings/radio-sent.ogg on ack.
Chat icon (<ThrottleChatButton> → <ThrottleChatOverlay>). Opens a
popup/overlay showing this driver's chat history with the various
signalmen (their own conversations only, §4.4.3), seeded via
radio.replay { scope:"user" } (Redis, §4.4.4) and kept live by
radio.message. Same line format as the signalman panel.
Unread indicator + incoming alert. When a radio.message arrives for
the driver:
- the chat icon lights red (an unread badge) until the chat overlay is opened;
- an alert-style popup is shown in the throttle view (similar to an on-screen alert), surfacing the translated phrase and its vehicle/train context;
- the receiver plays
/sounds/interlockings/{phrase}.ogg.
A small useRadioSounds() hook (sibling of useRadioStopSound(),
§6.3b) plays radio-sent.ogg on the sender's radio.send ack and
{phrase}.ogg on each inbound radio.message.
Server as source of truth (multi-pilot sync)
The DCC bus and the backend command station are shared state. A
physical throttle on the layout, another browser tab, or an external
API/MCP client may change the same locomotive while BigFred is open.
The overlay therefore must not treat the last outbound command as
ground truth. Instead it renders from server push events — principally
loco.state for speed, direction and the runtime functions array —
and re-fetches function definitions on vehicle.functionsChanged. When
an external pilot moves the speed step or flips a function, every open
throttle overlay subscribed to that address converges to the server
state within one polling/event round trip (see M1 acceptance criteria).
When a takeover is active, the affected driver's overlay for
that target becomes read-only telemetry (controlledBy.kind ==
"signalman"); the signalman's overlay receives full write access
until takeover.released.
Illustrative shell wiring
// AppShell.tsx (excerpt) – Throttle toggle on the top bar
import EngineeringIcon from "@mui/icons-material/Engineering";
import { ThrottleOverlay } from "./ThrottleOverlay";
export function AppShell({ children }: { children: React.ReactNode }) {
const [throttleOpen, setThrottleOpen] = useState(false);
const canDrive = useCanDriveAny(); // owned, leased, or takeover-held scope
return (
<>
<AppBar position="sticky">
<Toolbar>
<Typography variant="h6" sx={{ flexGrow: 1 }}>BigFred</Typography>
{canDrive && (
<IconButton
color="inherit"
aria-label={t("vehicle:throttle.open")}
aria-pressed={throttleOpen}
onClick={() => setThrottleOpen((v) => !v)}
>
<EngineeringIcon />
</IconButton>
)}
{/* account / admin menus, locale toggle … */}
</Toolbar>
</AppBar>
<main>{children}</main>
{throttleOpen && (
<ThrottleOverlay onClose={() => setThrottleOpen(false)} />
)}
</>
);
}
ThrottleOverlay hosts vehicle/train selection and mounts
ThrottlePage / ThrottleCockpit content from §6.3a and §6.3b.
Dual-WebSocket model (after §7e ships)
When §7e is live, the overlay manages two independent WebSocket connections (see §7e.7 for the full lifecycle):
- Control-plane WS to
loco-server(/api/v1/ws) — already open since login. Carriessession.*,takeover.*,radio.*,script.*,presence,auth.elevationChanged, and the command-station picker (session.setCommandStation/session.commandStationChanged). - Data-plane WS to the picked
dcc-busdaemon (ws://host:<port>/ws?token=<jwt>, returned viasession.opened.availableCommandStations[i].wsUrl). Carriesloco.subscribe/loco.unsubscribe/loco.setSpeed/loco.toggleFn/train.setSpeed/system.estop/ping. Re-opened when the user switches command stations.
<ThrottleCockpit> slider and function toggles dispatch on the data
plane via useDccBus(); <ScriptButtons>, the takeover banner and
the radio panel keep using the control plane (useSocket). Selecting
the right socket is encapsulated; component code does not know
about ports.
The command-station dropdown inside <ThrottleHeader> renders
status per row (RUNNING / STOPPED / STARTING / DEGRADED)
based on availableCommandStations[i].status and disables rows
whose wsUrl == null until the user selects them (selection
triggers daemon spawn). The <SharedBusChip> lights up when
dcc-bus.opened.sharedBus === true to surface §3a.4 rule 9 to the
driver.
Settings icon — connection and command-retry feedback
The settings / cog control in <ThrottleCockpit> (top-right of the
driving header) opens <ThrottleSetupDialog>: command-station picker,
control-plane and data-plane connection chips, and spawn error retry
(§17). Its icon reflects transient reliability state instead of showing
reconnect toasts in the overlay.
| Visual | Condition | aria-label key |
|---|---|---|
SettingsIcon (default) |
Data plane connected; no command retry in flight | throttle:setup.open |
CircularProgress (small) |
Data-plane reconnect after a prior successful open (connectionLost: dccReconnecting, or status closed / error) |
throttle:reconnecting |
Rotating SyncIcon |
A driving command or Radio Stop is being resent (commandRetrying from speed / train-speed / function hooks, or radioStopRetrying from <RadioStopButton onRetryingChange>) |
throttle:commandRetrying |
Priority: connection lost beats command retry; the cog is disabled while either spinner is shown so setup cannot be opened mid-handshake.
ThrottlePage passes connectionLost and commandRetrying into
<ThrottleCockpit>; radio-stop retry state is collected inside the
cockpit from the left-toolbar <RadioStopButton>. Full retry budgets
and WebSocket backoff are documented in
§17 Reliability.
6.3e Vehicle catalogue and function editor
Route for the owner's vehicle catalogue (lista pojazdów / lokomotyw):
/vehicles (LocoListPage.tsx). The page lists every vehicle the caller
owns (GET /api/v1/vehicles, filtered to ownerUserId == me), with columns
for kind, DCC address, name and number. Each row exposes two owner-only
actions in the trailing action column:
| Control | Icon (MUI) | Behaviour |
|---|---|---|
| Edytuj | Edit |
Opens VehicleDialog — metadata form (name, kind, number, optional DCC address, Rp1 function default F2, emergency lights function default F0, Dead Man's Switch option) persisted on domain.Vehicle and copied into the allowed_vehicles Redis snapshot for dcc-bus (§7e.5). |
| Edytuj funkcje | Tune (or Functions) |
Navigates to /vehicles/{addr}/functions — the function-definition editor described below. Tooltip and aria-label come from vehicle.json (vehicle.functions.edit). |
Lessees and non-owners never see either action. Vehicles without a DCC
address (dummy) may still open the function editor (definitions are stored
for when an address is added later), but the throttle will not emit DCC for
them until dccAddress is set.
Function editor page (VehicleFunctionsPage.tsx)
Route: /vehicles/{addr}/functions. Header shows vehicle name and DCC
address; a back link returns to /vehicles.
The page edits the resolved function list for that vehicle
(GET /api/v1/vehicles/{addr}/functions). When source: "template" the UI
shows a read-only banner (“Lista dziedziczona ze szablonu …”) until the
first mutation, which triggers server-side copy-on-write (§3a.6).
Adding a slot — toolbar button Dodaj funkcję opens a dialog:
- Numer — pick an unused DCC slot from
F0–F31(dropdown of free numbers only). - Tytuł — free-text label shown on the throttle button and in tooltips
(
namefield on the wire). - Ikona — visual picker grid populated from
GET /api/v1/function-icons(closed catalogue in §3a.8); choosing an icon while Tytuł is empty copies the icon label into the title field.
Confirming calls PUT …/functions/{num}.
Editing — each list row is editable for title and icon;
changes debounce to the same PUT endpoint.
Removing — row action Usuń → DELETE …/functions/{num}.
Reordering — the list is a drag-and-drop sortable (@dnd-kit or
equivalent). On drop the client posts
POST …/functions/reorder { positions: [{ num, position }, …] }.
position is dense 0..n-1 in display order.
The list is sorted by position ascending at all times. The same order
is used in throttle mode: <FunctionButtons> renders one button per
registered function, left-to-right / top-to-bottom in position order.
Reordering on this page therefore immediately changes how the driver sees
functions in the Throttle overlay (after refetch or
vehicle.functionsChanged).
Throttle visibility — every function row the owner registered for this
vehicle appears in <FunctionButtons> for that vehicle inside throttle mode
(§6.3b). There is no separate “favourites” subset: the catalogue on this page
is the throttle button row (scripts from §6.7 still append after the
function buttons). Lessees and signalmen with driving authority see the same
buttons but cannot open this editor.
// VehicleFunctionsPage.tsx (structure sketch)
function VehicleFunctionsPage() {
const { addr } = useParams();
const { data: fns = [], refetch } = useQuery({
queryKey: ["vehicle-functions", addr],
queryFn: () => fetch(`/api/v1/vehicles/${addr}/functions`).then((r) => r.json()),
});
const icons = useFunctionIcons(); // GET /api/v1/function-icons, cached
const onReorder = (ordered: ResolvedFunction[]) =>
fetch(`/api/v1/vehicles/${addr}/functions/reorder`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
positions: ordered.map((f, i) => ({ num: f.num, position: i })),
}),
}).then(() => refetch());
return (
<Container>
<FunctionList
items={fns.sort((a, b) => a.position - b.position)}
icons={icons}
onReorder={onReorder}
onSave={(f) => putFunction(addr, f)}
onDelete={(num) => deleteFunction(addr, num)}
/>
</Container>
);
}
FunctionButtons.tsx already sorts by position when rendering; no
second sort key is applied in the throttle.
6.3c Layout dashboard (HomePage)
After login the default route is / (HomePage.tsx). This dashboard
(pulpit makiety, see §1) is the operational home screen for the
layout the user picked on the login form. It renders three MUI
DataGrid / Table panels stacked vertically (or tabbed on very
small screens), each fed by REST on mount and kept fresh by WebSocket
fan-out (§4.2).
1. Layout vehicle roster
Default view: vehicles added to this layout (GET
/api/v1/layouts/{layoutId}/vehicles). Columns include at minimum DCC
address, name, owner login.
Toolbar actions (i18n keys in home.json / vehicle.json):
- Pokaż moje pojazdy (Show my vehicles) – toggles the table
between the shared roster and the caller's own catalogue (
GET …/vehicles/mine), marking which rows are already on the layout (onLayout: true). Lets a driver review their fleet without losing layout context. - Dodaj mój pojazd do makiety (Add my vehicle to layout) –
opens a picker dialog listing owned vehicles not yet on the roster;
confirming fires
POST …/vehicles { vehicleAddr }. Only vehicles the user owns may be added; leased vehicles are excluded.
Row actions (owner only): remove from layout (DELETE …/vehicles/{addr}).
2. Online users
Live table of everyone currently connected to the layout (GET
/api/v1/layouts/{layoutId}/presence, updated on
layout.presenceChanged). Columns:
| Column | Content |
|---|---|
| Login | login |
| Role | effective role in this layout (driver / signalman / admin, via role namespace) |
| Interlocking | if the user occupies a signal box: interlocking name; otherwise em dash |
One row per user, not per tab – multiple WS sessions from the same login collapse into a single row.
3. Interlockings
Table of interlockings whitelisted in this layout (GET
/api/v1/interlockings, enriched with occupant). Columns: name,
location, Obstawia (staffed by – occupant login or "wolna" /
vacant). Rows are clickable: navigation to /interlockings/:id
(InterlockingPage).
All three panels share the active layoutId from useMe(); there is
no layout switcher on this page (layout is immutable for the session).
// HomePage.tsx (structure sketch)
function HomePage() {
const me = useMe().data!;
const layoutId = me.layoutId;
const [showMine, setShowMine] = useState(false);
const vehicles = useQuery({
queryKey: ["layout-vehicles", layoutId, showMine],
queryFn: () =>
fetch(
showMine
? `/api/v1/layouts/${layoutId}/vehicles/mine`
: `/api/v1/layouts/${layoutId}/vehicles`,
).then((r) => r.json()),
});
// presence + interlockings analogous; useSocket merges WS events
return (
<Container maxWidth="lg">
<LayoutVehiclesTable data={vehicles.data} showMine={showMine}
onToggleMine={() => setShowMine((v) => !v)} layoutId={layoutId} />
<OnlineUsersTable layoutId={layoutId} />
<InterlockingsTable layoutId={layoutId} onRowClick={(id) => navigate(`/interlockings/${id}`)} />
</Container>
);
}
6.3d Interlocking view and occupation
Route: /interlockings/:id (InterlockingPage.tsx). Opened from the
dashboard interlockings table (§6.3c) or via direct link. Visible to
every authenticated user in the layout; occupation controls are
enabled only for users with the layout-scoped signalman role.
Layout of the page
- Header – interlocking name, location, current occupant (live via
interlocking.occupantChanged). - Action bar (signalmen only):
- Obsadź nastawnię (Occupy interlocking) – visible when the
caller is not the active occupant. Calls
POST /api/v1/interlockings/{id}/join. If the box is vacant the join succeeds immediately. If another signalman is already staffing it, the UI shows a confirmation dialog naming the incumbent and explaining that they will be displaced; on confirm the client retries with{ force: true }. This prevents a forgotten session from blocking the interlocking indefinitely while still requiring an explicit human decision. - Opuść nastawnię (Leave interlocking) – visible when the
caller is the active occupant. Calls
POST /api/v1/interlockings/{id}/leave. - Command-station picker (cog button) – a settings / cog icon
(
Settings, identical to the throttle, §6.3b) that opens a setup dialog (<ThrottleSetupDialog>reused) hosting the command-station dropdown + connection status. The signalman needs a picked command station to drive a taken-over target in the throttle overlay (the throttle dispatch invariant requiressession.CommandStationID != nil, §4.5.1) and to scope a per-target „Zatrzymaj skład" estop. It is populated fromsession.opened.availableCommandStations, firessession.setCommandStationon change, and re-renders onsession.commandStationChanged/layout.commandStationsChanged— exactly like the throttle picker. When the list has a single entry the UI MAY auto-pick it. - Radio Stop bar – directly above the panels the staffed box
renders a Radio Stop button (
<RadioStopButton variant="bar">). It uses the same red radio-handset icon as the throttle button (§6.3b) but, unlike the icon-only throttle control, it shows the text label „Radio stop" next to the icon. Pressing it opens the same<RadioStopConfirmOverlay>and, on confirm, dispatchessystem.radioStop {}on the control plane (layout-wide halt, §4.6). It is shown to any signalman staffing the box (see the extended authorization in §4.6.2), independent of whether they currently hold a takeover. - Three-panel work area – below the Radio Stop bar the staffed box renders a three-column layout: a radio chat panel on the left, a vehicle/train roster panel in the centre, and a train announcements panel on the right (details below). On narrow screens the three collapse into tabs (Radio | Składy | Zapowiedzi).
Left panel – radio chat (<InterlockingChatPanel>)
The signalman's group chat with every driver in the layout. It is a
fixed-width, vertically scrollable column (overflow-y: auto)
showing all traffic exchanged with all drivers (§4.4.3) ordered by time.
- Data: seeded on mount via
radio.replay { scope:"interlocking", interlockingId }(Redis-backed, §4.4.4) or the REST replay endpoint (§4.1), then kept live byradio.messageevents. - Line format:
({driverLogin}) {vehicle or train name}: {radio phrase translated into the signalman's language}. The login + context name come from the message's denormalizedfrom.login/context.*.name; the phrase is rendered through theradio.jsoni18n catalogue (the closedRadioPhrasevocabulary maps 1:1 to keys, §7c). - Reply affordance: each line carries a „Odpowiedz" icon button
pinned to a fixed position on the right of the row. The chat text
wraps (
white-space: normal, the icon column has a fixed width) so the icon never shifts as the message grows. Pressing it opens the phrase picker popup (below) pre-addressed to that driver and pre-filled with the same vehicle/train context, so a reply stays in the same conversation thread. - Phrase picker popup (
<RadioPhrasePickerDialog>): instead of a free-text field this is a searchable table of the closedRadioPhrasevocabulary — one row per phrase showing its translated label (and optionally a short description), with a text search box in the header that filters rows client-side so the operator can find a message visually quickly. Selecting a row emitsradio.send { to:{ userId }, context:{ vehicleId | trainId }, phrase }and plays/sounds/interlockings/radio-sent.oggon ack. An optional cappednotefield may accompany the phrase.
Centre panel – vehicle/train roster (<InterlockingRosterPanel>)
A fixed-width, scrollable table of the vehicles and trains on the
current layout, with a live "in motion" indicator (derived from each
target's loco.state.speed != 0). The table header carries a search
box that filters rows client-side.
Columns:
| Column | Content |
|---|---|
| Skład | ({driverLogin}) {vehicle or train name} — the owner/driver login plus the train (skład) or single vehicle (lokomotywa) name. A small chip shows „w ruchu" when the target is moving. |
| Akcje | three icon buttons (left→right): Radio, Stop, Przejęcie kontroli. |
Action icons:
- Radio (
SettingsInputAntenna) – opens the same<RadioPhrasePickerDialog>(searchable phrase table) pre-addressed to that driver, with the row's vehicle/train as the message context. Sendsradio.send. - Stop (
Stop, red) – „Zatrzymaj skład": emitssystem.estopTarget { target, targetId }(§4.2) to brake only that one vehicle/train. Distinct from the layout-wide Radio Stop (§4.6); the tooltip makes the single-target scope explicit. - Przejęcie kontroli (
Engineering) – opens the takeover flow (takeover.request { target, targetId }, §4.3). After the 15 s window grants the takeover, the signalman gets a closable throttle overlay (below) and the driver is evicted from their throttle.
Right panel – train announcements (<InterlockingTrainAnnouncementsPanel>)
A fixed-width, scrollable list of pre-configured station PA messages for the current interlocking. Each row shows a human-readable label (translated via i18n) and acts as a play button — there is no confirmation step.
- Data: read from a static TypeScript manifest
(
web/src/config/trainAnnouncements.ts). The manifest maps interlocking name → ordered list of{ soundKey, labelKey }entries; a"default"key supplies the fallback list when a box has no dedicated catalogue. Labels resolve through thetrainAnnouncementsi18n namespace (labelKey→trainAnnouncements.{labelKey}). No backend table, REST endpoint or WebSocket action — editing the list is a frontend code change (manifest + i18n + Ogg asset). - Playback: clicking a row plays
/sounds/train-announcements/{soundKey}.ogglocally on the clicking browser tab only viaHTMLAudioElement(same caching pattern asuseRadioSounds). No WebSocket frame is sent; other users (including other signalmen staffing the same box from another device) do not hear the announcement. - Re-click: starting a new announcement stops any in-flight
announcement on that tab (
audio.pause(); audio.currentTime = 0) before playing the newly selected file. - Empty state: when the interlocking has no configured announcements the panel shows a short hint instead of an empty list.
- Visibility: shown only in the staffed work area (same gate as the chat and roster panels). Observers who have not occupied the box see the header and occupation controls but not the three-panel work area.
Example entries (labels in Polish; sound keys are kebab-case filenames
without the .ogg extension):
| Label (PL) | soundKey |
|---|---|
| Po torze 1 przejedzie pociąg towarowy | track-1-freight |
| Odjazd pociągu do st. Głuszyca | departure-gluszyca |
| Odjazd pociągu do st. Wrocław | departure-wroclaw |
| Odjazd pociągu do st. Warszawa Centralna | departure-warszawa-centralna |
Assets live under web/public/sounds/train-announcements/. To add or
change announcements, edit the manifest, the matching i18n keys and drop
the Ogg file — no migration or admin UI required.
Takeover throttle overlay (signalman drives without leaving the box)
When a takeover is granted (§4.3), the signalman does not leave
the interlocking view. Instead a closable throttle overlay
(<TakeoverThrottleOverlay>) opens on top of the interlocking view,
hosting the same ThrottleCockpit driving surface (§6.3a, §6.3b) for
the taken-over vehicle. The interlocking chat + roster stay mounted
underneath.
- The overlay is driven by the 5-minute self-lease created on grant;
a small countdown badge shows the remaining lease time
(
takeover.granted.leaseExpiresAt). - Close gate: the overlay's close control is disabled while the
target's speed is not 0 (read from
loco.state). The operator must bring the target to a standstill before closing. Closing the overlay (at speed 0) releases the takeover (revokes the lease, emitstakeover.released { reason:"signalman_released" }). - Leaving the interlocking, displacement, or the 5-minute lease expiry also release the takeover and tear the overlay down.
Leaving the view while still occupying
If the active occupant navigates away from /interlockings/:id (back
to the dashboard, admin page, browser back, …) while still holding an
InterlockingSession, the router blocks the transition and shows a
dialog:
You are staffing this interlocking. Leave the interlocking?
- Confirm –
POST …/leave, then proceed with navigation. - Cancel – stay on the interlocking view.
Implementation: React Router useBlocker (or equivalent) keyed off
"am I the occupant?" local state synced from REST + WS. Closing the
browser tab does not auto-leave (the session stays until explicit
leave, displacement, or logout) – only in-app navigation triggers the
prompt.
Displaced occupant UX
When interlocking.occupantChanged { reason:"displaced" } targets the
current user, show a non-blocking toast, clear occupation state, and
disable takeover/radio actions that require active occupation until
they re-join or navigate away.
// InterlockingPage.tsx (occupation hook sketch)
function useInterlockingOccupation(interlockingId: number) {
const me = useMe().data!;
const isSignalman = /* effective role in layout includes signalman */;
const join = async (force = false) => {
const res = await fetch(`/api/v1/interlockings/${interlockingId}/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ force }),
});
if (res.status === 409 && !force) {
const incumbent = await res.json(); // { occupant: { login } }
const ok = await confirmDisplaceDialog(incumbent);
if (ok) return join(true);
return;
}
// refresh local occupant state …
};
// useBlocker: when isOccupying && navigating away → leave dialog
return { isSignalman, join, leave, isOccupying, … };
}
6.4 MUI Setup – Theme, Roboto Font, App Shell
Following MUI's installation guide, install the core package, the styled-engine, the icons package, and the Roboto font:
npm install @mui/material @emotion/react @emotion/styled
npm install @mui/icons-material
npm install @fontsource/roboto
src/theme.ts – central theme configuration. Material UI ships with
sensible defaults and a responsive 12-column grid; here we just tweak
palette and breakpoints to suit a throttle-style UI that must work on
small touchscreens:
import { createTheme } from "@mui/material/styles";
export const theme = createTheme({
palette: {
mode: "dark", // a command station console is easier to read in dark mode
primary: { main: "#90caf9" },
error: { main: "#ef5350" },
},
shape: { borderRadius: 12 },
components: {
MuiSlider: {
styleOverrides: {
thumb: { width: 28, height: 28 }, // larger touch targets on phones
},
},
},
});
src/main.tsx – wire up ThemeProvider + CssBaseline (CSS reset) and
the Roboto font once at the root:
import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
import { createRoot } from "react-dom/client";
import { ThemeProvider, CssBaseline } from "@mui/material";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { theme } from "./theme";
import { App } from "./App";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render(
<ThemeProvider theme={theme}>
<CssBaseline />
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</ThemeProvider>,
);
src/components/AppShell.tsx – top-level navigation that adapts to
phone vs. desktop via MUI's useMediaQuery and breakpoint system:
import { AppBar, Toolbar, Typography, IconButton, Drawer, useMediaQuery, useTheme } from "@mui/material";
import MenuIcon from "@mui/icons-material/Menu";
import { useState } from "react";
export function AppShell({ children }: { children: React.ReactNode }) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const [open, setOpen] = useState(!isMobile);
return (
<>
<AppBar position="sticky">
<Toolbar>
{isMobile && (
<IconButton color="inherit" onClick={() => setOpen((v) => !v)} edge="start">
<MenuIcon />
</IconButton>
)}
<Typography variant="h6">BigFred Control</Typography>
</Toolbar>
</AppBar>
<Drawer
variant={isMobile ? "temporary" : "permanent"}
open={open}
onClose={() => setOpen(false)}
>
{/* loco list / nav */}
</Drawer>
<main>{children}</main>
</>
);
}
6.5 Why Material UI Fits This Project
- Accessibility out of the box.
Slider,ToggleButton,IconButtonand friends ship with proper ARIA attributes, keyboard handling and focus management. This matters when the app is used on a phone with voice-over enabled or with a hardware keyboard. - Responsive primitives.
Grid,Stack,useMediaQueryand thesxprop make it trivial to render the sameLocoCardas a wide row on desktop and as a single-column stack on a phone, without writing custom CSS. - Theming. A single
createThemecall defines colors, spacing, typography and touch-target sizes globally. Dark mode for a control room is a one-line switch. - Icon coverage.
@mui/icons-materialexposes the full Material Symbols catalogue, which already contains everything a model railway UI needs (PlayArrow,Stop,Lightbulb,VolumeUp,Settings,Power, etc.) – no separate icon library required. - Maturity. MUI is the largest React UI library; long-term support and community size reduce the risk of an unmaintained dependency in a hobby-but-long-lived project. See MUI Overview.
6.6 REST via TanStack Query (List / Edit)
export const useLocos = () =>
useQuery({
queryKey: ["locos"],
queryFn: () => fetch("/api/v1/locos").then((r) => r.json()),
});
6.7 Script Buttons and Console (browser side)
With execution moved to the server (§3a.7), the frontend's job is
trivial: render a button per attached script that emits
script.run / script.stop, and a console pane that subscribes to
script.log events for the currently-displayed throttle. No
PyScript, no Web Worker, no Python source files. Goja runs on the
server; the browser just operates the play/stop button.
// ScriptButtons.tsx
function ScriptButtons({ vehicle }: { vehicle: Vehicle }) {
const { data: scripts = [] } = useQuery({
queryKey: ["vehicle-scripts", vehicle.addr],
queryFn: () => fetch(`/api/v1/vehicles/${vehicle.addr}/scripts`).then(r => r.json()),
});
const { send } = useSocket();
const activeRuns = useScriptStore((s) => s.activeRuns); // map<attachmentId, runId>
return (
<Stack direction="row" spacing={1}>
{scripts.map((s) => {
const runId = activeRuns[s.attachmentId];
const running = !!runId;
return (
<IconButton
key={s.attachmentId}
color={running ? "secondary" : "primary"}
onClick={() => {
if (running) send({ type: "script.stop", payload: { runId } });
else send({ type: "script.run", payload: { scriptId: s.id, attachmentId: s.attachmentId } });
}}
>
<FunctionIcon name={s.icon} />
</IconButton>
);
})}
</Stack>
);
}
useScriptStore is a tiny Zustand slice that listens for
script.runStarted / script.runStopped events on the existing WS
and keeps activeRuns[attachmentId] = runId. That's the entire
client-side state. Stop on the phone is just
send({ type:"script.stop", payload:{ runId } }) – the server
forwards it to the executor, which interrupts the VM.
ScriptConsole.tsx is a <List> that subscribes to script.log
events for the active throttle's runId and script.runStopped
events to flush the buffer with the final { reason, durationMs }
line. The editor (ScriptEditor.tsx) on the Scripts page uses
@monaco-editor/react with language="javascript", posts the
edited source via PUT /api/v1/scripts/{id}, and otherwise does
nothing executable.
6.8 Internationalization (pointer)
Every user-visible string in the components above (button labels,
error toasts, table headers, plural counters) is rendered through
react-i18next with namespace catalogues bundled into web/dist.
Backend codes (ApiError.code, RadioPhrase, FunctionIcon,
AuditAction, …) map 1:1 to translation keys; user-entered names
and audit-log denormalized snapshots are rendered verbatim. The
I18nextProvider wraps the app above ThemeProvider and
QueryClientProvider in main.tsx. The full specification —
namespace layout, key naming, plural rules, locale persistence,
type-safe key generation — lives in §7c i18n.
Components in this section omit the boilerplate t("…") calls in
their snippets for brevity; in real code, no string literal that
reaches the DOM is hard-coded.