Architecture¶
Overview¶
The app is built on Panel (which wraps Bokeh server) with separate modules for state, UI callbacks, plotting, and data I/O.
app.py ──────── Layout, widget creation, callback wiring
│
├── state.py ──────── Per-session state (AppState class)
│
├── callbacks.py ──── Event handlers (CallbackManager class)
│
├── plotting.py ───── Bokeh figure creation with LTTB downsampling
│
├── data_loading.py ─ HDF5 reading, annotation Excel I/O
│
└── config.py ─────── Constants, paths, color palettes, user lists
Key design decisions¶
Per-session state¶
Each browser session gets its own AppState instance. This avoids shared mutable state between users. The only shared state is the module-level user lists in config.py, which admins can modify at runtime.
Persistent ColumnDataSources¶
Annotation overlays use persistent ColumnDataSource objects created once and shared between AppState and the Bokeh figure. Updating .data on a source re-renders just the overlay quads, not the whole plot. Annotation changes appear immediately.
LTTB downsampling¶
Raw files can hold 500K+ points per axis. Sending them all to the browser would be slow. The plotting module uses Largest Triangle Three Buckets (LTTB) to reduce each axis to ~10,000 points for the main plot and ~2,000 for the range selector. LTTB keeps the shape of the signal: peaks, valleys, and rapid transitions stay; flat regions compress. The lttbc C extension makes this fast; without it, the code falls back to uniform strided sampling.
Box-select via invisible scatter points¶
Bokeh’s BoxSelectTool selects data indices from point-based glyphs (scatter, circle) but not from line glyphs. To enable time-range selection on signal lines, invisible scatter points (size=0, alpha=0) are rendered on top of the lines. The selected.on_change("indices", ...) callback converts selected indices to timestamps.
HDF5 server-side filtering¶
Data loading uses PyTables where clauses (pd.read_hdf(..., where="timestamp >= ts_start & timestamp <= ts_end")) so only the visible time window leaves disk. On a 123 MB file, that’s 14 ms instead of 1.38 s. Fixed-format HDF5 files fall back to in-memory filtering.
Explicit y-range¶
The plot uses Range1d, not DataRange1d, for the y-axis. DataRange1d would auto-expand to include the annotation quad overlays, which would squash the signal to a thin line. The y-range is computed from the signal with 5% padding.
Canvas rendering, not WebGL¶
The app uses the default canvas backend instead of WebGL because:
WebGL doesn’t support hatch patterns (used for segment/scoring/review overlays)
WebGL has rendering glitches when updating CDS data in place
With LTTB, canvas is fast enough at 10,000 points per axis
Data flow¶
Network latency indicator¶
The header includes a network latency indicator that pings /favicon.ico every 10 seconds and displays the round-trip time. Because Panel’s pn.pane.HTML renders inside Shadow DOM using innerHTML (which doesn’t execute <script> tags), the ping JavaScript is injected via a Bokeh Div model with a CustomJS callback on the DocumentReady event.
Signal loading¶
File picker change
→ CallbackManager.plot_new_file()
→ AppState.load_file_data()
→ data_loading.get_filedata() — reads HDF5 with time-window query
→ CallbackManager.update_plot()
→ Fast path: plotting.update_plot_data() — update CDS + ranges in place
→ Full rebuild: plotting.make_plot() — LTTB downsample + create Bokeh figures
Annotation lifecycle¶
Box-select on plot
→ _on_selection_change() — converts indices to timestamps
→ state.selection_bounds = (start, end)
→ CallbackManager.update_selection() — enable/disable buttons
Click annotation button
→ CallbackManager.mark_annotation()
→ capture_new_annotation() — create DataFrame row
→ AppState.update_annotation_sources() — update overlay CDS
Click Export
→ CallbackManager.save()
→ data_loading.save_annotations() — write Excel, reload from disk