Files
plotter/docs/styling.md
T
Martino Ferrari 1b8b55d3ee feat: a phosphor density is the trace, one grid per trace in its own colour
A user looked at the display and reported three things at once: "there is clearly a gain offset
between latest acquisition and persistent image", "persistent instead of being brighter + opaque is
just gray/white", and "last acquisition does not seem to be affected, it looks like persistent image
+ last acquisition (so for example last acquisition never fade in time mode)". What they add up to is
that this was not a phosphor yet: it was a **density with a solid trace drawn over it**.

Four changes, and the first is the one the others follow from:

- **The current acquisition is deposited like every other, and no solid trace is drawn over the
  image.** `Renderer::draw_traces()` returns at once while a density is on: the trace ink *is* the
  image, so the newest acquisition is a sweep inside it and fades with it. Drawing the polyline as
  well put a solid, unfading stroke over its own faint ghost -- the reported "persistent image + last
  acquisition", where the last acquisition never faded in time mode.
- **One grid per trace, in that trace's own colour.** `Plot::density_grid(trace)` is one screen-sized
  `f32` grid per trace, deposited from that trace and drawn in `Trace::color`, because a density that
  had one hue for the plot could not say *which* trace put the ink there -- two signals agreeing in a
  cell would be one blended pixel. The display's memory is therefore `traces * columns * rows * 4`
  bytes rather than one plot's worth (a trace with no series, an unbound slot, holds no grid), which
  is the one cost this change adds: four signals in one 800x480 row are four grids, 6 MB.
- **The ceiling is the trace's own brightness, not the fade's steady state.** `full_scale` now
  defaults to **one sweep**: a cell with one acquisition's ink is drawn in its trace's colour at that
  colour's own alpha, and a signal crossing the same place every acquisition settles *at* the colour
  its trace would have drawn (the ceiling clamps) rather than a fraction of a brighter steady state --
  the gain offset the user reported. `Style::density`'s near-white 0.85 tint is gone with it, because
  there is no one tint any more. A caller that wants the fade's ramp on screen instead still sets
  `full_scale` to the sum the fade settles at and accepts the offset; that is a legitimate choice, just
  not the one a phosphor makes.

The reading is a **framebuffer** one, because neither `ink`/`peak` nor a tint can make a colour or a
fade: `a_phosphor_density_is_the_trace_in_its_own_colour` deposits one sweep, checks the saturated
cell is *exactly* the trace's colour (`full=224`, the trace's `0xE0` green, against `bg=25` -- the old
monochrome tint read ~190 red), then fades it one time constant and checks the same pixel is dimmer
(`faded=115`) and still the trace's hue -- which a stroke over the density could not do. It moves the
trace and draws without depositing to prove no polyline is drawn (`moved` equals the no-density frame
at the moved row), and reads a steady state against a quarter of it (`steady=224`, `quarter=138`), in
the signal's colour throughout. `a_density_holds_one_grid_per_trace` reads two traces' grids apart
(`267 472 cells each, ink 584/584`, in two rows). The grid's own cases are unchanged where they should
be (`a_density_halves_per_acquisition_and_keeps_the_newest_brightest` `peak_steady=2.000`,
`a_density_fades_by_its_time_constant` `exp(-dt/tau)`).

Suite: **397 cases, 0 failed**, 4/4 ctest entries.
2026-10-16 20:21:38 +02:00

11 KiB
Raw Blame History

Styling

Every colour and metric the renderer draws with lives in render::Style (include/plot/render/style.hpp). A caller sets fields on Renderer::style(); the next frame picks them up, because nothing here is baked into a shader.

Set colours and metrics on the renderer, not on the plot: Renderer::draw() copies its Style into the plot and hands it the ui_scale before it lays out, so the strips, tick densities and strokes the plot measures with are the values the renderer draws with. A plot drawn without a renderer lays out with whatever Plot::set_style() was given.

The unit rule: logical versus device

A metric a user perceives as a size — a font, a stroke width, a padding, a marker, a swatch, the minimum spacing between ticks — is written in logical units, so a 12 px label is the same physical size on a 1x and a 2x panel. The host states its display with ui_scale (device pixels per logical unit); every consumer converts exactly once, through device():

f32 device(f32 logical) const noexcept {
  return ui_scale > 0.0f ? logical * ui_scale : logical;   // non-positive scale converts 1:1
}

Plot::layout() uses it to size the label strips, title strips and legend chrome; the renderer uses it to convert widths and font px (the font is reset at device(axis_font_px)).

The one exception is dense_threshold_spp, which is a density (samples per rasterised pixel), compared against a ratio computed from the framebuffer's own width. Scaling it would make the chosen LOD level depend on the display rather than on the data, so it is used raw.

Style fields

Colours (neither logical nor device — nothing to scale):

field default meaning
background 0x101418 frame clear colour and legend/readout panel base
grid_major 0x39414C major (labelled-tick) grid lines
grid_minor 0x1C2126 at 50% alpha minor (subdivision) grid lines
axis_line 0x8A94A6 axis lines
axis_text 0xC8D0DC tick labels and axis titles
cursor 0xFFB020 cursor markers; also the hovered/grab highlight
measure_fill 0xFFB020 at α 0.15 the measurement band
invalid_fill 0x8A94A6 at α 0.18 a span the host declared invalid (Plot::set_invalid_spans())
invalid_repaired_fill 0x8A94A6 at α 0.08 the same span once the host has marked it repaired
crosshair 0xE6EBF2 crosshair lines and the readout panel's border (hue-neutral by design; trace markers keep each trace's own colour)

The scale:

field default unit
ui_scale 1.0 device pixels per logical unit; supplied by the host

Logical metrics (scaled by device()):

field default meaning
axis_font_px 12.0 logical em the axis text is drawn at
grid_label_pad 4.0 gap between a tick label and the axes, and the inset inside a title strip
marker_below_samples 50 a dot per raw sample when the window holds at most this many (0 = never)
marker_px 3.0 logical size of that dot
grid_major_width 1.0 width of a major grid line
grid_minor_width 0.75 width of a minor grid line (sub-pixel on purpose)
grid_minor_dash_on 3.0 minor grid dash: ink length in logical pixels
grid_minor_dash_off 3.0 minor grid dash: gap; 0 draws the minor grid solid
grid_major_visible true stage the major grid at all
grid_minor_visible true stage the minor grid at all
line_width_px 1.5 width of the strokes framing the data (axis lines, cursor markers)
handle_width_px 5.0 width of a highlighted draggable handle (a hovered/grabbed cursor tab, a region's hot bound)
tick_min_px 60.0 minimum spacing between major ticks

Crosshair stroke and dash (logical):

field default meaning
crosshair_width 0.75 the crosshair's stroke width (deliberately thinner than a trace's 1.5)
crosshair_dash_on 4.0 ink length of the dash
crosshair_dash_off 4.0 gap length of the dash; 0 means solid

Device metric (never scaled):

field default meaning
dense_threshold_spp 1.5 samples per device pixel at or above which a trace is gathered from the LOD pyramid instead of from raw samples

The grid

The grid's positions come from the tick set alone: Grid::build() emits one GridLine per tick, spanning the perpendicular axis' full range, tagged major (a labelled tick) or not. Style decides only how each class is drawn — its width and visibility — and the two colours above.

A class switched off stages no geometry at all: the renderer skips it before the first segment is staged, so a dense plot's minor grid costs nothing rather than being drawn transparent. Grid therefore carries both classes' geometry whatever the style says; a caller that draws the grid itself applies the same two switches. Grid::vertical and Grid::horizontal (both true) control which directions emit lines at all.

The labelled class is the structure and the subdivisions are texture, and the minor grid's three defaults are what makes that read: it is thinner than the major hairline (grid_minor_width, a sub-pixel width that at 1x is expressed as coverage and on a HiDPI display as a narrower line), its colour carries an alpha so it blends into the background instead of sitting on it, and it is dashed (grid_minor_dash_on/_off) so an unlabelled line at an unlabelled position is never mistaken for a labelled one. Together those put a minor line at an order of magnitude less ink than a major one — at full opacity and the same width a dense minor grid reads as a second set of ticks. The dash is a fragment-shader discard, not extra geometry: the vertices are the line either way. Set grid_minor_dash_off = 0 for the old solid subdivision.

A cursor's stroke

set_cursor_color() gives a cursor its own colour and set_cursor_stroke() its own width and dash (Cursor::width, Cursor::dash). Per cursor rather than per plot, because a host uses cursors for two different jobs: the instruments that frame a reading — a marker at an edge, a pull handle — and the readout marks a computation places on the data (a measured level, a peak, a pulse's edges). Those want different weights. width <= 0 means the style's default for that cursor (handle_width_px while it is hovered or grabbed, line_width_px otherwise), and a hover can only make the stroke thicker: the cue that the pointer has hold of something must not be the thing that hides it. dash.off <= 0 (the default) is solid — the same convention as a trace's, the crosshair's and the grid's.

The crosshair's dash

crosshair_dash_on/crosshair_dash_off are the fragment shader's discard pattern, so no geometry is added. dash_off == 0 draws a solid line — the value every other line in the library uses, so the one pipeline serves them unchanged. A single plot may override all three knobs without touching the global Style through CrosshairStyle (below).

Legend

Legend (include/plot/legend.hpp), set with Plot::set_legend(). All sizes are logical, converted through Style::device() when Plot::layout() measures the panel.

field default meaning
visible true draw the legend at all (free when nothing is named)
place LegendPlace::TopRight corner of axes the panel is anchored to
frame true paint the panel (background at partial alpha plus a 1-px border)
pad 6.0 inset from the panel edge to its first row, and the swatch↔name gutter (one knob for both)
swatch_w 14.0 swatch width
swatch_h 8.0 swatch height; the row height is max(swatch_h, line height)
row_gap 3.0 blank space between rows
margin 8.0 inset from the axes edge, and the budget the panel must fit in (so it drops entries rather than overhanging)

There is no "outside the plot" placement: the strips around axes belong to the tick labels and axis titles, and a legend that reserved space would resize the plot the moment a trace was named. A truncated legend ends with the constant kLegendEllipsis ("...").

CrosshairStyle

A per-Crosshair override of the three crosshair knobs, in logical pixels (include/plot/crosshair.hpp), set with Plot::set_crosshair_style(). Every field defaults to -1.0, which means "not overridden": the field falls back to the matching Style::crosshair_* metric, so a default-constructed CrosshairStyle is exactly the global style.

field default meaning
width -1.0 logical stroke width; < 0 takes Style::crosshair_width
dash_on -1.0 logical ink length; < 0 takes Style::crosshair_dash_on
dash_off -1.0 logical gap; < 0 takes Style::crosshair_dash_off, and 0 (once resolved) means solid

Traces

A trace's own width (TraceDesc::width, Trace::width) is logical too — a stroke like line_width_px, converted by the same device() call. Its default colour is 0x4FA3FF and its default width 1.5. Region fill/edge colours are the region's own (Region::fill 0xFFB020 at α 0.15, Region::edge 0xFFB020); a cursor's colour is per-cursor (0xFFB020 by default).

Installing a text backend

The font a plot measures with and the renderer draws with is a render::TextBackend (include/plot/render/text.hpp): measure(), height(), ascent(), em_px(), glyph(), cell_uv(), atlas_pixels(), atlas_width(), atlas_height() and revision().

  • Plot::set_text_backend(const render::TextBackend*) sets the backend a plot lays its labels out with. It is borrowed, not owned, and may be null (then the label areas fall back to PlotLayout's defaults and no string is measured).
  • Renderer::set_text_backend(const render::TextBackend*) sets the face the renderer draws with. It is also borrowed and must outlive the frames.

Note the interaction: Renderer::init() binds render::EmbeddedMonoFont, and every Renderer::draw() installs that binding on the plot it is given — replacing whatever a host set with Plot::set_text_backend() (a debug build names the one it dropped). So installing a backend on the plot only takes effect for a plot no renderer draws. To draw with your own face (say a plot::text::TrueTypeFont from the optional plot::font_ttf target), install it with Renderer::set_text_backend(); the swap takes effect on the next frame, which re-reads the backend's metrics at style().device(axis_font_px) and re-uploads its atlas when the backend's revision() moved. Passing nullptr restores the embedded face.