Files
plotter/include/plot/render/style.hpp
T
Martino Ferrari c960decaea feat(plot): the readout marker snaps to the sample it quotes, and the value mode is a choice
The mark was placed at the *pointer's* x with the quoted sample's y, so with the
nearest-sample readout it sat off the datum horizontally -- and further off the deeper
the zoom. `CrosshairRow` now carries the data `x` the value belongs to and the marker is
placed from it, so in `NearestSample` the mark lands on the sample the panel is quoting.
`Style::crosshair_marker_px` (9) and `crosshair_marker_width` (1.5) replace the
renderer's hard-coded 3 px arm and 1 px stroke: a snapped mark lands among a trace's own
sample dots (`marker_px`, 3) and at that size would read as one more sample rather than
as the answer to the question the hover asks.

`CrosshairValue` makes the previous behaviour reachable rather than deleted. The two
modes answer different questions -- `NearestSample` "which datum is under the pointer",
`Interpolated` "what is the signal at this instant" -- and only the caller knows which it
is asking, so the plot offers both with the sample as the default.
`crosshair_interpolate()` is restored for it.

`Plot::crosshair_point()` is the lookup with both coordinates, public because a host that
draws instruments of its own needs the same snap and a second rule for it would drift
from this one; `crosshair_value()` delegates to it.
2026-10-05 14:36:47 +02:00

193 lines
12 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/color.hpp>
namespace plot::render {
/// Every colour and metric the renderer draws with. Defaults are the dark theme
/// the plan's render smoke test asserts against; a caller swaps a field and the
/// next frame picks it up, so nothing here is baked into a shader.
///
/// ## Units: every metric below is **logical**, and `device()` converts it
///
/// 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 panel and a 2x one
/// and a 1 px grid is the same hairline on both. The host states what its display
/// is with `ui_scale` (device pixels per logical unit) and every consumer converts
/// exactly once, through `device()`: `Plot::layout()` when it sizes the label
/// strips, the title strips and the legend's chrome, the renderer when it converts
/// widths and font px.
///
/// The one exception is `dense_threshold_spp`, which is a **density** rather than a
/// size: samples per *rasterised* pixel. It is compared against a samples-per-pixel
/// ratio computed from the framebuffer's own width, so scaling it would make the
/// chosen LOD level depend on the display and not on the data. It is used raw.
///
/// The same rule classifies the metrics that live outside this struct, which are
/// logical too and converted by the same call:
///
/// * `Legend`'s pad, swatch, gap and margin;
/// * `Trace`'s (and `TraceDesc`'s) `width` -- a stroke like `line_width_px`, and
/// the one a plot draws most of, so leaving it raw would invert the
/// trace/axis stroke hierarchy at any scale above 1;
/// * the renderer's marker arm lengths and text offsets.
struct Style {
// --- colours: neither logical nor device, so nothing to scale ------------
Color background = Color::hex(0x101418);
/// The labelled grid. Brighter than the subdivisions and still well under `axis_line`
/// (0x8A94A6): the grid is structure the eye can read, the axis is the frame, and the
/// hierarchy between the three is what keeps a dense plot legible. It is already fully
/// opaque, so "more prominent" means a brighter colour and nothing else.
Color grid_major = Color::hex(0x39414C);
/// The minor grid's colour, **with an alpha** so it blends into the background rather than
/// sitting on it: a subdivision is a reading aid, and at full opacity a dense minor grid
/// reads as a second set of ticks competing with the labelled one. The line shader applies
/// `u_color.a` (see `src/render/shaders.cpp`), so this one blends and the major -- opaque at
/// 0x39414C -- stays the structure the eye picks out. Together with `grid_minor_width` and
/// the dash below, the defaults put a minor line at an order of magnitude less ink than a
/// major one.
Color grid_minor = Color::hex(0x1C2126, 0.5f);
Color axis_line = Color::hex(0x8A94A6);
Color axis_text = Color::hex(0xC8D0DC);
Color cursor = Color::hex(0xFFB020);
Color measure_fill = Color::hex(0xFFB020, 0.15f);
/// The crosshair's colour: its two lines and the readout panel's border (spec §15). Markers are
/// deliberately *not* this colour -- they carry each trace's own, because a marker says *which*
/// trace and the line says *where*.
///
/// **Hue-neutral by design**, and that is the whole choice: a saturated colour here would be
/// confusable with a trace, and the library's own default trace colour is a saturated cyan
/// (`Trace`'s default, 0x40C0FF) that a hue-based reading cannot tell from a cyan crosshair --
/// only lightness would separate them, which no reader can rely on. A near-white can never be a
/// saturated trace of *any* hue, and it is still plainly not `cursor`'s orange, which is what
/// the spec asks for.
///
/// The stroke is thin and dashed by default -- see `crosshair_width`, `crosshair_dash_on` and
/// `crosshair_dash_off` below for the values: thinner than a trace so the instrument reads as
/// chrome rather than data, and dashed so it is distinguishable by *form* as well as by
/// lightness.
Color crosshair = Color::hex(0xE6EBF2);
/// The crosshair's stroke and dash, in logical pixels (spec §17). The crosshair is a
/// *passive* hover instrument drawn over the data, so its default **recedes**: a
/// thinner stroke than a trace's (`Trace::width`'s 1.5) so it reads as chrome rather
/// than as data, and a dash so it is distinguishable from the grid and the traces at
/// a glance.
///
/// `crosshair_dash_on` is the ink length and `crosshair_dash_off` the gap; **`off == 0`
/// means solid**, which is the value every *other* line in the library is drawn at, so
/// the one pipeline serves them unchanged (the dash is a fragment-shader discard on
/// `v_along`, so no geometry is added). A `Crosshair` may override all three for one
/// plot without touching this global style.
f32 crosshair_width = 0.75f;
f32 crosshair_dash_on = 4.0f;
f32 crosshair_dash_off = 4.0f;
/// The full extent, in logical pixels, of the little cross drawn at each trace's readout
/// point (`CrosshairRow::marker`), in that trace's own colour.
///
/// **Deliberately larger than `marker_px`**, which is what a trace's own samples are dotted
/// with at a deep zoom. In the default `CrosshairValue::NearestSample` the readout's mark is
/// *snapped to a sample*, so it lands among those dots -- at the same size it would read as
/// one more sample instead of as "this is the one the panel is quoting". The lines are chrome
/// and recede; this mark is the answer to the question the hover asks, and it is the one
/// crosshair element that should not.
///
/// Its stroke is `crosshair_marker_width`, thicker than the lines' for the same reason. `0`
/// across draws no mark at all, which is how a caller that wants the panel without the marks
/// says so.
f32 crosshair_marker_px = 9.0f;
f32 crosshair_marker_width = 1.5f;
// --- the scale ----------------------------------------------------------
/// Device pixels per logical unit. 1.0 is the 1x display every default here was
/// chosen on. Supplied by the host (`Renderer::style()` is what an adapter
/// writes), and handed to the plot by `Renderer::draw()` before it lays out, so
/// layout and drawing cannot disagree about it.
f32 ui_scale = 1.0f;
// --- logical metrics: a size a user perceives, scaled by `device()` ------
f32 axis_font_px = 12.0f; ///< logical em the axis text is drawn at.
f32 grid_label_pad = 4.0f; ///< logical gap between a tick label and the axes,
///< and the inset inside a title strip.
/// The two grid classes, each with its own width and its own switch. The grid's
/// *positions* come from the tick set alone -- `Grid::build()` tags every segment
/// with the class of the tick it came from -- so these four decide only how a
/// class is drawn. 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 (spec §11). The colours
/// above are already per class.
/// When a trace's window holds at most this many raw samples, each one is also drawn as a dot
/// `marker_px` logical pixels across, in the trace's own colour. `0` draws none. Zoomed in far
/// enough to see individual samples -- a handful of them across a whole panel -- the polyline
/// *between* them stops being the shape, and the samples themselves are what a reader is looking
/// at; a marker makes each one a place rather than a bend in a line.
f32 marker_below_samples = 50.0f;
f32 marker_px = 3.0f;
f32 grid_major_width = 1.0f; ///< logical width of a major (labelled-tick) line.
/// Logical width of a minor (subdivision) line: **thinner than the major hairline**, and
/// sub-pixel on purpose. At 1x that is expressed as *coverage* -- the anti-aliased ramp
/// (`line_aa_px`) lights one column at roughly three quarters of the ink -- and on a HiDPI
/// display as a line that is literally narrower. The point is hierarchy: the labelled grid is
/// structure, the subdivisions are texture.
f32 grid_minor_width = 0.75f;
bool grid_major_visible = true; ///< stage the major grid at all.
bool grid_minor_visible = true; ///< stage the minor grid at all.
/// The minor grid's dash, in logical pixels: ink length and gap. **`off == 0` draws it
/// solid** -- the same convention as the crosshair's and a trace's dash -- which is what a
/// caller that wants the old solid subdivision states. Dashed is the default because an
/// unlabelled line at an unlabelled position should never be mistaken for a labelled one, and
/// because a broken line carries less weight over data than a continuous one. The dash is a
/// fragment discard, never extra geometry, so a dense minor grid costs no more vertices for
/// being dashed.
f32 grid_minor_dash_on = 3.0f;
f32 grid_minor_dash_off = 3.0f;
f32 line_width_px = 1.5f; ///< logical width of the strokes framing the data
///< (axis lines, cursor markers).
/// Logical half-width of the anti-aliased fringe the line shader blends at
/// every stroke edge (the smoothstep ramp runs over `|dist| in [w - aa,
/// w + aa]`). Clamped to at least 0.25 device px at use: below that the
/// ramp is thinner than a fragment and edges alias again. 1.0 is the value
/// every default was chosen on.
f32 line_aa_px = 1.0f;
/// Logical width of a *draggable handle* drawn highlighted: the tab a hovered or
/// grabbed cursor marker is widened to, and the tab a region's hot bound is drawn
/// as -- the latter sized by `Plot::layout()` with this same metric, so the two
/// kinds of handle are one width. Wider than `line_width_px` on purpose: the cue
/// for "this marker will follow your drag" has to be visible on a marker whose
/// own colour may already be the accent.
f32 handle_width_px = 5.0f;
f32 tick_min_px = 60.0f; ///< logical minimum spacing between major ticks, so
///< a 2x display gets the same tick *count*.
// --- device metric: a density, never scaled ------------------------------
f32 dense_threshold_spp = 1.5f; ///< spec §4.3: samples per **device** pixel at or
///< above which a trace is gathered from the LOD
///< pyramid instead of from its raw samples. A
///< density, not a size: it is deliberately not
///< scaled by `ui_scale`, or the chosen level
///< would move with the display's DPI. It selects
///< the gather level, never a primitive: both
///< sides draw through the one instanced
///< anti-aliased quad path.
/// `logical` in device pixels: the single conversion point for every logical
/// metric above, so the arithmetic appears once per consumer instead of a `*
/// ui_scale` scattered over the renderer and the layout.
///
/// A scale that is not positive is not a scale -- a host that has not measured
/// its display, a field nobody filled in -- and converts 1:1 rather than
/// collapsing every strip, font and stroke to nothing.
f32 device(f32 logical) const noexcept {
return ui_scale > 0.0f ? logical * ui_scale : logical;
}
};
} // namespace plot::render