Skip to content

Signal viewer: stable y-scaling, width-relative detail, multi-panel layouts — plus a written visual language#18

Merged
RaulSimpetru merged 4 commits into
mainfrom
feat/viewer-scaling-and-panels
Jul 25, 2026
Merged

Signal viewer: stable y-scaling, width-relative detail, multi-panel layouts — plus a written visual language#18
RaulSimpetru merged 4 commits into
mainfrom
feat/viewer-scaling-and-panels

Conversation

@RaulSimpetru

Copy link
Copy Markdown
Member

Three things that grew out of each other: making the y-axis stop jittering, making the
"how much to draw" control mean something, and making one stream viewable through several
panels. The last one exposed how much of the visual language lived only in the code, so it
is now written down and partly enforced.

Scaling

  • Auto no longer hands the axis to ImPlot's per-frame auto_fit. The range snaps out
    instantly to contain a new peak (never clipped) but contracts over ~5 s, so a variable
    signal in a small window stops zoom-jittering. Per-channel mode gets the same easing — its
    lane ranges no longer breathe — and its range scan is vectorised (~15× cheaper at 256 ch).
  • Per-Ch is now a scaling basis, not a mode that greys out Auto/Manual. Per-channel
    Manual freezes each lane's range so a weakening channel stays visible against its captured
    reference; Rescale re-fits and locks; Gain is live in per-channel Manual, inert in Auto.
  • Range fitting rejects artifacts by duration, not amplitude ("Artifact", default
    20 ms), because a 10 ms artifact and a 10 ms real event are indistinguishable by amplitude
    alone. Mode-aware, per-channel then unioned, ~11 ms at 256 ch. 0 ms restores plain min/max.

Detail

The fixed "Point cap" slider (100–10000 pts) becomes "Detail": draw density relative
to the plot width, shown as a percentage of full. The old cap fought the width-derived target
(min(n_pixels, width × 3)), so the top half of its range was inert on a typical plot and the
default under-resolved wide ones. n_pixels survives as an optional hard-cap override
(default: no cap).

Multi-panel

  • Several viewers can share one stream. State and ImGui ids key off a new widget_id
    (defaulting to stream_name) rather than the stream, so one panel per electrode grid works,
    each keeping its own channels, scale, filter and pause. Previously every SignalViewer("emg")
    resolved to one state and rendered identically. This also restores design-principle 8 —
    state keyed by widget identity, not by the data it happens to show.
  • Panel chrome collapses (title, controls, channel bar, footer) behind the header toggle,
    leaving a tiled panel almost all plot.
  • The channel-grid selector tiles grids near-square (6 → 3×2) instead of one tall stack.
  • Heatmap.ui(vrange=…) — an explicit colour range. Several heatmaps must share one to be
    comparable; with per-instance autoscaling a quiet electrode array and a loud one render
    identically.

Visual language, and two bugs it caught

docs/concepts/visual-language.md writes down what _theme.py / widgets/common.py already
did — type stack, the tint+underline selection cue, panel headers, plot styling, units, icon
meanings, pop-out vs collapse, widget identity. It sits beside design-principles.md, which
stays the code contract.

Writing it down made parts of it checkable, and tests/test_visual_language.py (AST-based, no
allowlist) found real bugs:

  • RawSignalViewer rendered as stock ImPlot — chart border, opaque background, heavy grid
    — for want of one ensure_implot_style() call.
  • 19 hardcoded colours across 8 widget files could not follow the theme. Several were wrong
    on the light theme specifically: a near-white session label, a white-on-white channel-grid
    hover outline, and a prediction readout that flashed toward white — into the card it sits
    on. They now read the theme (muted() / primary() / hairline()) or a named token.
    Colours deliberately fixed in both themes (console surface, status pill) are named in
    common.py rather than inlined.
  • ProcessLauncher used its own red/green instead of the shared DANGER / SUCCESS.
  • The panel chrome toggle didn't swap its glyph, unlike every other toggle here
    (PLAYPAUSE, autoscroll, pop-out), so it read as a one-shot action.

Rules needing judgement — is this the right cue, does this comparison need a shared range —
are deliberately left to review.

CLAUDE.md points agents at both contracts before they touch a widget.

Notes

  • 696 tests pass, ruff clean. Both enforcement rules were verified to fail when a violation is
    reintroduced.
  • tests/test_stream_lsl.py::test_stream_reconnect_swaps_buffers_atomically is flaky under
    full-suite load (LSL multicast contention) and passes in isolation — pre-existing.
  • Known gap: initial_channels seeds a viewer's channels but does not restrict them, so
    in a tiled per-grid layout "All" still enables every channel and [Edit…] lists every grid.
    A real channel_scope parameter is the fix; left for a follow-up.

…youts

A signal-viewer pass covering three things: how the axis behaves, how much
gets drawn, and how several panels compose.

Scaling
- Auto no longer hands the axis to ImPlot's per-frame auto_fit. The range
  snaps out instantly to contain a new peak (never clipped) but contracts
  over ~5 s, so a variable signal in a small window stops zoom-jittering.
  Per-channel mode gets the same easing — its lane ranges no longer breathe —
  and its range scan is vectorised (~15x cheaper at 256 channels).
- Per-Ch is now a scaling *basis*, not a mode that greys out Auto/Manual.
  Per-channel Manual freezes each lane's range so a weakening channel stays
  visible against its captured reference; Rescale re-fits and locks; Gain is
  live in per-channel Manual and inert in per-channel Auto.
- Range fitting rejects transients shorter than a budget ("Reject <", default
  20 ms) by *duration* rather than amplitude, so a brief movement artifact no
  longer blows up the scale and dwarfs the EMG. Mode-aware (rectify/rms_env
  pin the lower bound to 0), per-channel then unioned for the shared axis, and
  cheap enough for the per-frame path (~11 ms at 256 ch). 0 ms restores min/max.

Detail
- The fixed "Point cap" slider (100-10000 pts) becomes "Detail": draw density
  relative to the plot width, shown as a percentage of full. The old cap fought
  the width-derived target — min(n_pixels, width*3) — so the top half of its
  range was inert on a typical plot and the default under-resolved wide ones.
  n_pixels survives as an optional hard-cap override (default: no cap).

Layout
- Several viewers can share one stream. State and ImGui ids now key off a new
  widget_id (defaulting to stream_name) rather than the stream, so one panel
  per electrode grid works, each keeping its own channels, scale, filter and
  pause; previously every SignalViewer("emg") resolved to one state and
  rendered identically. title names a panel, show_controls opens it collapsed.
- The panel chrome — title, control menu, channel bar, footer — collapses
  behind the header toggle, leaving a tiled panel almost all plot. Collapsed,
  the toggle shrinks to a bare icon so there is always a way back.
- The channel-grid selector tiles several grids near-square (6 -> 3x2) instead
  of one tall stack, and opens sized to the tiling.
- Heatmap.ui gains vrange, an explicit colour range. Several heatmaps must
  share one to be comparable: with per-instance autoscaling a quiet electrode
  array and a loud one render identically.
…to it

The theme was a real, deliberate system — Instrument Serif / IBM Plex Mono,
the tint+underline "this is on" cue, borderless transparent plots, tab10 for
series identity — but it lived only in the code, so new controls drifted from
it. concepts/visual-language.md states it: type, colour, panel headers, state
cues, plot styling, unit formatting, pop-out vs collapse, and widget identity.
It sits beside design-principles.md, which stays the code contract.

Two controls that had drifted:
- The panel chrome toggle now carries push_selected/pop_selected when
  collapsed, like every other sticky toggle (Edit…, Manual). A bare icon over
  a plot read as an unrelated action rather than a live state.
- "Reject <" becomes "Artifact", with the slider formatting the threshold as
  "< 20 ms". A label should say what the control does, spelled out; a
  truncated operator symbol is jargon to whoever is running the session.

Two other suspected drifts did not survive checking, and are left alone: panel
titles already pass through panel_header's uppercase/muted/icon/ellipsis
treatment, and %/x/s are each correct for the quantity they label — that was a
missing written rule, now in the doc, not a defect.
Writing the visual language down made it checkable, and the check found real
bugs. Nineteen colours were typed as literals across eight widget files, so
they could not follow the active theme: the session-manager label and the raw
viewer's footer were near-white (washed out on the light theme), the
channel-grid hover outline was white-on-white and therefore invisible, and the
prediction readout flashed *toward white* — into the card it sits on.

Those now read the theme through new common.py helpers (muted, primary,
hairline) or an existing token. Colours deliberately fixed in both themes are
named rather than inlined: CONSOLE_BG / CONSOLE_TEXT for the terminal surface
(a terminal stays dark on a light desktop) and PILL_BG / ON_PILL_TEXT for the
status badge. ProcessLauncher's hand-rolled red/green become the shared
DANGER / SUCCESS, so Stop/Launch match status everywhere else.

Separately, RawSignalViewer called implot.begin_plot without
ensure_implot_style(), so it rendered with stock ImPlot chrome — border, opaque
background, heavy grid — instead of the app's plot style. That is the bug the
second rule was written to catch, and it had shipped.

tests/test_visual_language.py enforces both rules, with no allowlist. It is
AST-based, not textual: a docstring in _state.py mentions begin_plot and a grep
version flagged it. Both rules were verified to fail when a violation is
reintroduced. Rules that need judgement — is this the right cue, does this
comparison need a shared range — are deliberately left to review.

CLAUDE.md points agents at both contracts before they touch a widget, which is
what would have prevented the drift in the first place.
…oggle

The codebase consistently swaps a toggle's icon to show what clicking will do
— PLAY/PAUSE on transport, ANGLES_DOWN/ARROW_DOWN on log autoscroll, the
expand/collapse arrows on pop-out. The panel chrome toggle showed BARS in both
states, so it read as a one-shot action rather than a live toggle. It now folds
away under ANGLES_UP and comes back under BARS ("reveal a hidden menu").

Documents the rule and the established glyph meanings in visual-language.md,
since one-glyph-per-meaning was a real convention that had never been written
down — which is how the inconsistency got in.
@RaulSimpetru
RaulSimpetru merged commit d0fa7f3 into main Jul 25, 2026
4 checks passed
RaulSimpetru added a commit that referenced this pull request Jul 25, 2026
Cut 2.4.0 — the signal-viewer scaling/detail/multi-panel work (#18) and the
channel_scope follow-up (#19). New user-facing capability with no breaking
changes, hence a minor bump.

Highlights: the y-axis eases instead of jittering and ignores artifacts by
duration; "Point cap" becomes the width-relative "Detail" control; one stream
can be shown through several panels (widget_id) each owning its own channels
(channel_scope); Heatmap gained a shared colour range. The visual language that
had only ever lived in the code is now written down in
docs/concepts/visual-language.md and two of its rules are enforced by tests —
which caught RawSignalViewer rendering as stock ImPlot and 19 hardcoded colours
that could not follow the light theme.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant