Files
plotter/README.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

15 KiB

plot

An embeddable OpenGL time-series plotting library for real-time, high-sample-rate data. The core (plot::core, C++20, OpenGL 3.3 core) has no third-party dependency at all; the host integrations that own a window and an event loop are separate, opt-in targets built on top of it.

Hosts, and what a frame costs

host CMake target source
GLFW plot::glfw adapters/glfw/
Dear ImGui plot::imgui (build-tree only) adapters/imgui/
Qt6 plot::qt6 adapters/qt6/
GTK4 plot::gtk4 adapters/gtk4/

The GTK4 adapter exists for, and is exercised by, a real consumer: udpscope, an oscilloscope-style viewer for a UDPStreamer source, at https://git.ferrarinas.it/martino/udpscope. It embeds the core through plot::gtk4, and the library's interaction layer — cursors, measurement marks, the shading of a span a host has declared invalid — has been shaped by what that host needed. The notes in src/, docs/ and tests/ that say "in udpscope's case" are those places.

The visible x range is mapped onto sample indices through the series' own x (Series::neighbours(), a binary search over the real stamps), never through the (x - x0()) / dx() grid: dx() is a nominal spacing, and a series whose x is a streamed timestamp would otherwise have its draw window land inside the visible range and lose the two segments that enter and leave the frame — the empty left and right margins of a zoomed-in view. Per-frame rendering cost is O(visible pixels + samples produced since the last frame), not O(samples): a trace denser than one sample per pixel is drawn from a min/max LOD pyramid rather than from raw samples. The plot_multiplot_demo run stages 20,901 GPU segments to draw ~4.5 million visible samples across six traces — the frame is bounded by the pixels it lights, not by the sample count behind them. That figure is from a linux-release build under xvfb-run on llvmpipe (Mesa's software rasteriser); no run touched a GPU, so read it as an upper bound and a record of the software path.

Features

  • a trace whose series answers a clamped stamp for its oldest slots -- a ring whose published window is pinned while it slides -- is drawn from its longest increasing suffix, so an unreadable front cannot draw a segment from the newest sample back to the oldest (a line across the whole panel);
  • a following group's window stays on its members' data: while the members retain at least a window's worth, the scroll offset is bounded by retained - span, so a pan or a zoom-in near an edge cannot leave the window's own span over data the scope never had (a window wider than the data keeps the older placement -- right edge on the newest sample);
  • a trace whose gathered window is denser than the axis is reduced onto the pixel grid -- one snapped vertical segment per integer column, lit from that column's own extremes -- because an LOD envelope staged as a polyline puts three or four near-vertical hairlines per column at as many sub-pixel phases, and the beat between the bucket pitch and the pixel pitch is a crawling moiré; one segment per column leaves one phase, so there is nothing to beat (FrameStats::column_traces, and docs/data-and-performance.md for the four rules it keeps);
  • and such a column can be drawn graded by dwell instead of as one flat band (Style::intensity_grading, off by default, FrameStats::graded_traces): bright where the signal sits, faint where it only passes through, the way a digital-phosphor scope reads. It is what puts back the information the reduction throws away — a column's min and max are the same whether its samples fill the span or pile at its two ends — so the gather is asked at a level finer than the pixel grid and the distribution read from those sub-buckets. Below the density at which columns are reduced the same switch grades the polyline by each segment's slope (FrameStats::slope_traces), so the look does not change as a window narrows past the threshold;
  • a phosphor density a host can turn on for a plot (Plot::set_density(), an image rather than a set of retained traces): one grid per trace, in that trace's own colour, one f32 cell per device pixel of the axes rect, drawn in place of the traces -- a host may not also draw them over it -- into which each acquisition is deposited, 1.0 per cell a trace crosses, so brightness is how often it passed. It fades either by a time constant in stream seconds or by a per-acquisition factor, and the newest acquisition is deposited like every other, so it fades too. Memory is the screen's times the traces on screen (a 640x360 viewport's axes is 179 349 cells per trace, 717 kB), the deposit walks the axes' columns rather than the samples, and a frame whose grids did not move costs one draw call per trace (FrameStats::density_cells/density_upload_bytes); see docs/data-and-performance.md;
  • sample markers when a window is sparse enough that its samples are the shape (Style::marker_below_samples, 50 by default, marker_px across, in the trace's own colour);
  • grid with independent major/minor line classes (Style::grid_major_visible, grid_minor_visible), each with its own colour, width and (for the minors) dash — the subdivisions default to thinner, dashed and alpha-blended, so they read as texture under the labelled grid rather than as a second set of ticks;
  • x and y axes, with a vertical (bottom-to-top) y-axis title;
  • cursors in both orientations — vertical markers and horizontal rulers;
  • draggable regions and measurement bands with exact interval statistics;
  • a signal legend anchored to an axes corner;
  • a hover crosshair with a per-signal readout, quoting the nearest sample and marking it (CrosshairValue::NearestSample, the default) or interpolating between two (CrosshairValue::Interpolated);
  • a multi-plot Figure of stacked rows sharing one synchronised x axis;
  • live follow (XFollow::Live) with relative (multiplicative) zoom and mouse panning;
  • XY series for irregularly sampled data (plot/data/xy_series.hpp): sorted-ascending x with binary-searched crosshair, measurement and index snapping;
  • per-trace draw modes: dash patterns (Trace::dash_on/dash_off, logical px, 0 = solid) and point markers (Trace::marker: cross, circle, square) with a manual marker_every stride or an automatic one; width == 0 draws the markers alone (scatter);
  • three downsample policies per trace (Trace::mode, GatherMode): Envelope (min/max pairs — the default), Nearest (one raw sample per bucket) and Mean (one averaged point per bucket);
  • readout text colour: crosshair value rows render in their row's trace colour, labels in the axis text colour;
  • input parity across hosts: pinch-zoom and pan gestures on Qt6 and GTK4 drive the same controller paths as mouse and wheel (touch parity), with keyboard panning (arrow keys), Escape cancelling a drag or box-zoom, double-click re-fitting the axes, and modifier-masked box zoom (Shift = both axes, Ctrl = x only, Alt = y only), the area being chosen framed as the drag runs -- a low-alpha fill under a light border, widened along the axis the release will not zoom, so Ctrl's frame is the band it is about to apply rather than the sliver a straight horizontal drag sweeps;
  • DPI-consistent sizing: metrics are logical units converted once through Style::ui_scale;
  • an anti-aliased embedded monospaced font, plus an optional runtime TrueType backend (plot::font_ttf).

Build and install

Needs a C++20 compiler and CMake 3.24+. Presets live in CMakePresets.json (linux-debug, linux-release, linux-asan, linux-coverage).

cmake --preset linux-release
cmake --build build/release
cmake --install build/release --prefix /where/you/want

Install is on by default (PLOT_INSTALL=ON) and, via GNUInstallDirs, installs the plot_core archive under lib/, the public headers under include/plot/ — including the generated plot/version.hpp — a CMake package config under lib/cmake/plot/, a pkg-config file under lib/pkgconfig/plot.pc, and the licences under share/licenses/plot/. A consumer then needs only:

find_package(plot 0.1 REQUIRED)
target_link_libraries(my_app PRIVATE plot::core)

Versioning. The package config carries SameMinorVersion compatibility: at 0.x the major component promises nothing and the minor is what moves when the surface breaks, so find_package(plot 0.1 REQUIRED) accepts 0.1.x and refuses anything newer. A shared build (BUILD_SHARED_LIBS=ON) names its ABI on the file itself — libplot_core.so.0, the SOVERSION being the major component. What a header set is is in its generated <plot/version.hpp>: plot::version::major/minor/patch/string, or the PLOT_VERSION_* macros for a preprocessor test. The full layout is tabled in docs/building-and-testing.md.

Embedding

A host owns the window, the GL context and the event loop; plot owns none of them. This is the whole of a minimal GLFW program — the adapter opens the window, runs the loop and feeds the controller, and the callback is where live data would be pushed. It is committed at the repository root as readme_main.cpp:

#include <glfw/glfw_host.hpp>   // the optional GLFW adapter
#include <plot/plot.hpp>

#include <cmath>
#include <vector>

int main() {
  plot::glfw::Host host;
  plot::Plot& p = host.plot();

  std::vector<plot::f32> y(512);
  for (plot::usize i = 0; i < y.size(); ++i)
    y[i] = static_cast<plot::f32>(std::sin(0.05 * static_cast<double>(i)));
  plot::data::ArraySeries series(std::move(y), 0.0, 1.0);

  const plot::u32 x = p.add_x_axis({"x", {0.0, 511.0}, true});
  const plot::u32 sig = p.add_y_axis({"signal", {-1.0, 1.0}, true, plot::AxisPlace::Left});
  p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, x, sig, "sine"});

  plot::Status st;
  return host.run({800, 480, "plot"}, [](plot::Plot&, plot::f64) {}, &st) ? 0 : 1;
}

It compiles and links against a built tree (see docs/getting-started.md for the same program written against plot::core directly, with no adapter). The one non-obvious flag is -I build/debug/include: that is where CMake generates plot/version.hpp, which the umbrella pulls in:

g++ -std=c++20 -I include -I adapters -I build/debug/include readme_main.cpp \
    build/debug/adapters/glfw/libplot_glfw.a build/debug/libplot_core.a \
    $(pkg-config --libs glfw3) -ldl -lpthread -o plot_readme

Documentation

The user guide lives under docs/; docs/superpowers/ is the design record, not user documentation.

  • Getting started — prerequisites, build/install/consume, and a minimal GLFW program against plot::core.
  • Architecture — the layers (core/math → gl → data → render → plot → input), what each owns, and the dependency rules.
  • Embedding into a host — the four adapters, the ui_scale/DPI rule per host, and the headless demo invocations.
  • Data and performance — the sample stores, the single-producer/single-consumer contract, the LOD pyramid, and the measured frame times.
  • Interaction — cursors, regions, the measurement band, the legend, the crosshair, the gesture table, and live follow.
  • Styling — every Style field with its default and unit, the grid classes, and installing a text backend.
  • Building and testing — presets, PLOT_BUILD_* options, the suite, the headless demo modes, and the coverage command.
  • Coverage — the coverage method and the measured per-file table.

Testing and coverage

cmake --preset linux-debug
cmake --build build/debug
ctest --test-dir build/debug --output-on-failure   # 396 cases, 0 failed

tools/coverage.sh                                  # configure+build+run+capture+floor check

The linux-debug suite runs 396 cases; the linux-coverage preset builds 379 of them, because its adapters are off and 17 cases need an adapter target (11 in test_imgui_widget.cpp, 6 in test_glfw_host.cpp). Coverage is enforced by tools/coverage.sh against tools/coverage_floor.py: 80% per file under src/ and 90% aggregate. The measured aggregate and per-file table live in docs/coverage.md, which the command regenerates — it is the single source for those numbers, so this page carries none.

Licence and third-party provenance

  • This library is MIT licensed: the full text is in LICENSE, and every first-party file (C++, Python and shell) carries its SPDX identifier in the file header — // SPDX-License-Identifier: MIT — so the grant travels with each source file, not just the repository root.
  • Adwaita Mono (the embedded font, render::EmbeddedMonoFont) is under SIL OFL 1.1, committed verbatim at licences/OFL-1.1.txt. The backend class is named neutrally (EmbeddedMonoFont, not "Adwaita Mono") because OFL reserves the font name for modified redistributions. The baked atlas is generated at authoring time by tools/fonts/gen_atlas.py and committed as data, so plot_core rasterises nothing at build or run time.
  • stb_truetype v1.26 (public domain, Sean Barrett / RAD Game Tools) is vendored at third_party/stb_truetype.h and compiled only by the optional plot_font_ttf target; its include directory is PRIVATE, so no consumer and no other target can reach it. PLOT_BUILD_TTF_FONT defaults on exactly when the vendored header is present.
  • plot_core is third-party-free. It links only ${CMAKE_DL_LIBS}, and no public header under include/plot/ includes a third-party header. The header self-containment and umbrella checks in tests/CMakeLists.txt fail the build if either rule breaks.

Limitations

  • Timings are llvmpipe under Xvfb (tools/coverage.sh, the demo summaries). No measurement here used a GPU; there are no hardware numbers.
  • macOS and Windows are untested. Development and CI are Linux/EGL + X11 only.
  • Fractional-scale compositing and touchpad scroll calibration are unverified — the ui_scale sources are correct per host, but no run exercised a real fractional DPI or a calibrated scroll device. The new gesture paths (Qt6 and GTK4 pinch, the keyboard and wheel paths behind the parity list above) are tested at the toolkit-free seams and headless; no run has touched real touch hardware.
  • Figure row rects are one frame stale during a resize. The hosts cache Figure::layout()'s rows and refresh them once per frame, so an event arriving mid-resize routes against the previous frame's rects (see docs/embedding.md).
  • Untrusted font files are out of scope for the runtime plot::font_ttf backend: stb_truetype does no range checking, so from_file() must only be handed files the host trusts.
  • render ↔ plot is not a strict include chain. Renderer includes plot/plot.hpp because drawing is being handed a Plot; the plot layer names only render's value types (Style, TextBackend), never Renderer. The cycle and its direction are documented in docs/architecture.md.