Files
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

191 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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()`:
```cpp
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.