Subdivisions used to be drawn exactly like the labelled lines -- same width, same opacity -- so a plot dense enough to need them read as two competing sets of ticks. The minor class is now three things the style states explicitly: - grid_minor_width 1.0 -> 0.75, a sub-pixel logical width. At 1x that is expressed as reduced coverage through the anti-aliased ramp; on a HiDPI display the line is literally narrower. - grid_minor_dash_on/_off (new, 3/3): dashed by default, off == 0 draws it solid, the same convention as the crosshair's and a trace's dash. The dash is a fragment-shader discard, so no geometry is added -- the vertices are the line either way. - grid_minor carries alpha (0x1C2126 at 50%), and the line shader applies u_color.a, so it blends into the background instead of sitting on it. Together that is about a fifth of the ink a major line leaves along its length, which is what gives the eye one grid to read instead of two. The major class is untouched: it stays the structure. Tests: a new pixel test (the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major) asserts a major column is inked top-to-bottom while a minor one alternates, and that the minor's ink sits closer to the background than the major's. Three existing tests counted the minor class as "most of eight rows" of its exact colour; two now take their floor from the style (minor_line_ink: width x alpha x duty), and the width test states the dash and alpha away because widths are its subject. 361 cases, 0 failed.
175 lines
9.4 KiB
Markdown
175 lines
9.4 KiB
Markdown
# 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` | `0x2A3038` | 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 |
|
||
| `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 |
|
||
| `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 roughly a fifth of a major line's ink — 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.
|
||
|
||
## 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.
|