Breaking: Series::gather emits x relative to x0() and takes a GatherMode; consumers reconstruct absolute x in f64 before mapping (series.hpp documents the contract). Bugs, each pinned by a fail-before regression test: - render: f32 staging quantised epoch-time x into 128 px columns; every data-space draw now stages to_space(x) - to_space(origin) with the origin folded into the offset uniform in f64 (epoch/zero-origin twins rasterise identically) - ticks: major test was ULP-fragile and blanked epoch axes; majors are an integer-index test and are never truncated by the minor cap - gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor bucket edges + raw lead/trail spans - live follow: log x axes held a linear span and zoomed every frame; span and offset now live in axis space (plot + both figure link-group paths) - input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x, factor == 1 / inf refused in wheel and drag zoom - render: the single text flush painted legend names over the crosshair readout panel; two batches, under flushed before the panel - data: NaN dropped from min/max unless it seeded the fold; non-finite samples are now excluded from every aggregate and from cached bounds Robustness: viewport normalisation (no clamp UB), non-finite range refusal, pyramid level cap at 57 (no shift/div UB through public gather), rounded ring capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation captions clamped, button-aware GLFW row capture, cached figure row rects (no per-event layout), ruler-pair measurement refused, span-nonfinite guards in axis_ends. Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and markers (scatter at width 0) with auto or literal stride; GatherMode Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text colour (readout values in trace colour); wheel-dx x pan; arrow-key pan; Escape cancels a drag and restores captured ranges; modifier-masked box zoom (Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s; ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION, SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan). Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files, floor 80/90 held; ASAN preset suite green; all four demos run headless (the GTK scripted-resize check needs a window manager and fails identically on the parent commit); readme_main.cpp committed and compiles with the README command; pkg-config and find_package consumption verified from a relocated install prefix.
146 lines
8.2 KiB
C++
146 lines
8.2 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);
|
|
Color grid_major = Color::hex(0x2A3038);
|
|
Color grid_minor = Color::hex(0x1C2126);
|
|
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 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.
|
|
f32 grid_major_width = 1.0f; ///< logical width of a major (labelled-tick) line.
|
|
f32 grid_minor_width = 1.0f; ///< logical width of a minor (subdivision) line.
|
|
bool grid_major_visible = true; ///< stage the major grid at all.
|
|
bool grid_minor_visible = true; ///< stage the minor grid at all.
|
|
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
|