Files
plotter/examples/glfw_demo.cpp
T
Martino Ferrari ed3a8ce645 licence: MIT LICENSE and SPDX identifiers in every first-party file
The repository had no licence at all, so every source was all-rights-reserved
by default. Add the verbatim MIT text at LICENSE (Copyright (c) 2026 Martino
Ferrari) and a two-line SPDX header to each of the 113 first-party sources --
48 include/plot, 26 src, 10 adapters, 5 examples, 21 tests, 3 tools -- so the
grant travels with each file rather than relying on the root document alone.

Scripts keep their shebang on line 1; the SPDX lines follow it. Genuinely
vendored code is untouched: third_party/stb_truetype.h carries no header of
ours. src/render/embedded_mono_font.cpp is generated, so tools/fonts/gen_atlas.py
emit() now writes the two lines into its header comment and the file was
regenerated -- gen_atlas.py --check passes and the regeneration adds only those
comment lines to the previously committed file.

README's provenance section now states MIT and links LICENSE.

No code, test or public API change.
2026-09-28 22:13:40 +02:00

782 lines
38 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The reference application: the whole library running as a real program, fed by
// a 1 MS/s producer thread through the GLFW adapter.
//
// What it demonstrates, and what the other three adapters copy:
//
// - a `StreamSeries` at the retention limit (1 << 20 samples, 1.048576 s at this
// rate) pushed from a background thread and acquired exactly once per frame on
// the render thread;
// - a signal whose decimation correctness is visible: a 437 Hz sine plus a small
// noise term plus a narrow spike every 100 ms. A bucket that kept only its
// first sample would drop the spike; a bucket that keeps its extrema cannot;
// - a plot the user can drive: grid, both axes titled and labelled (the time
// axis with `format_time` clock labels, the value axis with SI ones), one
// trace, two cursors and the measurement readout the plot layer draws itself
// (shaded band plus a `dx/dy/rms/mean` line) -- no drawing code here;
// - the headless smoke mode the adapter implements once
// (PLOT_DEMO_EXIT_AFTER_MS / PLOT_DEMO_SCREENSHOT), so CI can run this under
// `xvfb-run` and check the frame it wrote;
// - `PLOT_DEMO_FOLLOW=1`: the same run with the time axis armed for live following
// (`XFollow::Live`), driving three wheel notches and a pan through the host's own
// controller and printing, at each step, the range's width, its distance behind
// the newest sample and the follow state -- the interaction spec §12 is about,
// measured on a real stream rather than in a unit test. It is a *check*, not a
// readout: the run exits non-zero if a measured property fails to hold, or if the
// sequence did not get far enough to measure all four.
//
// The producer is joined before the stream it writes into is destroyed, and
// before the host closes the GL context (main's destruction order does that).
#include <glfw/glfw_host.hpp>
#include <plot/data/stream_series.hpp>
#include <plot/plot/ticks.hpp>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <numbers>
#include <thread>
#include <utility>
#include <vector>
namespace {
using plot::f32;
using plot::f64;
using plot::u32;
using plot::u64;
/// One sample per microsecond: 1 MS/s.
constexpr f64 kSampleRate = 1.0e6;
constexpr f64 kDx = 1.0 / kSampleRate;
/// The retained window: 1 << 20 samples = 1.048576 s. A power of two, so the
/// ring never has to round it up.
constexpr u64 kCapacity = 1u << 20;
/// Producer wakeup: about a millisecond of samples per push batch.
constexpr u64 kBatch = 1u << 10;
/// Cap on one push batch, so a process that was stopped and resumed catches up
/// over several iterations instead of allocating a batch for the whole gap.
constexpr u64 kMaxBatch = 1u << 16;
/// The signal: a few hundred Hz sine, a small noise term, a narrow spike.
constexpr f64 kSineHz = 437.0;
constexpr f64 kNoiseAmp = 0.02;
constexpr f64 kSpikePeriodS = 0.1;
constexpr f64 kSpikeWidthS = 2.0e-6;
constexpr f64 kSpikeAmp = 3.0;
// The trace colour the render smoke test's blue-dominance predicate expects, so
// the same predicate reads this demo's screenshot.
constexpr plot::Color kTraceColor = plot::Color::hex(0x40C0FF);
constexpr plot::Color kCursorAColor = plot::Color::hex(0xFFB020);
constexpr plot::Color kCursorBColor = plot::Color::hex(0x35D0A8);
/// The region the direct-manipulation script drags: a fill and an edge no other element
/// of the frame produces -- the trace is blue, the chrome grey, cursor A the accent and
/// cursor B a teal -- so a pixel count of one of them can only be about the region. The
/// two are the render smoke test's own colours, so its predicates read this screenshot
/// too.
constexpr plot::Color kRegionFill = plot::Color::hex(0x33FF00, 0.35f);
constexpr plot::Color kRegionEdge = plot::Color::hex(0xFF00FF);
/// The pan the region script takes, in framebuffer pixels, and the window it freezes the
/// view on, in seconds: 20 ms of this 1 MS/s stream is ~20 000 samples, wider than the
/// axes, so the trace is drawn from the pyramid and the region's interval is hundreds of
/// pixels across.
constexpr plot::u32 kRegionPanPx = 100u;
constexpr plot::f64 kRegionWindowS = 0.020;
/// y(t) for sample index `i`: sine + deterministic noise + a two-sample spike.
///
/// The noise hash takes the low 32 bits of the index, so its pattern repeats
/// every ~4295 s at 1 MS/s -- far beyond any run of this demo, and it keeps the
/// sample path allocation-free and branch-light, unlike a PRNG.
f32 sample_at(u64 i) noexcept {
const f64 t = static_cast<f64>(i) * kDx;
f64 y = std::sin(2.0 * std::numbers::pi * kSineHz * t);
u32 h = static_cast<u32>(i) * 2654435761u;
h ^= h >> 15;
h *= 2246822519u;
h ^= h >> 13;
h *= 3266489917u;
h ^= h >> 16;
y += kNoiseAmp * (static_cast<f64>(h & 0xFFFFFFu) / 8388608.0 - 1.0);
const f64 phase = std::fmod(t, kSpikePeriodS);
if (phase < kSpikeWidthS) {
y += kSpikeAmp * (1.0 - phase / kSpikeWidthS);
}
return static_cast<f32>(y);
}
/// Feeds a `StreamSeries` from its own thread at 1 MS/s on average, paced by
/// absolute deadlines off a steady clock: every wakeup produces every sample
/// whose time has already come, so a late wakeup is caught up instead of pushing
/// the phase back, and the achieved rate cannot drift the way a fixed
/// `sleep_for()` per batch does.
class Producer {
public:
explicit Producer(plot::data::StreamSeries& series)
: series_(&series), thread_([this] { run(); }) {}
~Producer() { stop(); }
Producer(const Producer&) = delete;
Producer& operator=(const Producer&) = delete;
/// Sets the stop flag and joins the thread. Idempotent; the destructor calls
/// it, so the thread is never detached and never outlives the stream.
void stop() noexcept {
stop_.store(true, std::memory_order_relaxed);
if (thread_.joinable()) thread_.join();
}
/// Samples pushed so far. Written by the producer, read by the render thread.
u64 produced() const noexcept { return produced_.load(std::memory_order_relaxed); }
private:
void run() {
using clock = std::chrono::steady_clock;
const auto start = clock::now();
u64 next = 0;
while (!stop_.load(std::memory_order_relaxed)) {
const f64 elapsed = std::chrono::duration<f64>(clock::now() - start).count();
const u64 due = static_cast<u64>(elapsed / kDx) + 1; // samples whose time has come
if (due > next) {
u64 n = due - next;
if (n > kMaxBatch) n = kMaxBatch;
batch_.resize(static_cast<plot::usize>(n));
for (u64 k = 0; k < n; ++k) {
batch_[static_cast<plot::usize>(k)] = sample_at(next + k);
}
series_->push(batch_.data(), n);
next += n;
produced_.store(next, std::memory_order_relaxed);
}
// Sleep until one batch past the last produced sample: an absolute
// deadline, so the phase is fixed at `start` and never accumulates.
const f64 wake_s = static_cast<f64>(next + kBatch) * kDx;
std::this_thread::sleep_until(
start + std::chrono::duration_cast<clock::duration>(std::chrono::duration<f64>(wake_s)));
}
}
plot::data::StreamSeries* series_;
std::vector<f32> batch_;
std::atomic<u64> produced_{0};
std::atomic<bool> stop_{false};
std::thread thread_; ///< declared last: the lambda runs once everything is up
};
/// The live readout: the achieved push rate, the frame time and the frame count.
struct Meter {
f64 rate = 0.0; ///< samples/s, refreshed a few times a second
f64 frame_ms = 0.0; ///< exponential moving average of the frame time
f64 elapsed = 0.0; ///< seconds of frame callback time
f64 last_report = 0.0;
u64 last_produced = 0;
u64 frames = 0;
};
/// The scripted live-follow sequence `PLOT_DEMO_FOLLOW=1` runs.
///
/// The follow arithmetic is pinned by the library's tests; "the plot keeps showing
/// the newest sample while the user zooms and pans it" is an *interaction*, and this
/// is the program that has one -- a real 1 MS/s producer, the real host, the real
/// controller. So the sequence arms `XFollow::Live` once the stream holds data and
/// then takes four measurements, one per frame:
///
/// (a) steady state, with the retained window framed;
/// (b) after three wheel notches, rolled over the x label strip -- outside `axes`,
/// where the wheel's anchor is the live edge, so the zoom changes the width and
/// nothing else;
/// (c) after a 100 px left-drag, the real pan gesture, which moves the window back
/// from the live edge;
/// (d) after a further 50 ms of data has arrived.
///
/// Each measurement prints the range's width, how far its right edge sits behind the
/// newest sample, and whether following is still armed. Only the zoom may change the
/// width, only the pan may change the distance behind, and following must be armed
/// throughout. The verdict is carried out of the run in `failed`, so `main()` exits
/// non-zero on a failed check -- a check whose result only stdout knows is not a check
/// -- and also when the sequence never completed, since a run that measured nothing
/// has not passed.
struct FollowScript {
bool enabled = false;
/// Set by report(); read by main(). False until the checks have all held.
bool failed = false;
/// Set by report(): the sequence reached a verdict at all. A run that ended before
/// the fourth measurement has not passed, it just has not finished.
bool completed = false;
plot::u32 step = 0;
plot::f64 width[4]{};
plot::f64 behind[4]{};
bool live[4]{};
plot::f64 wait_from = 0.0;
static FollowScript from_env() {
FollowScript s;
const char* v = std::getenv("PLOT_DEMO_FOLLOW");
s.enabled = v != nullptr && v[0] != '0' && v[0] != '\0';
return s;
}
/// One frame per step: the gesture, then the measurement of what it did. The last
/// step waits for data to actually arrive, because the last measurement is about
/// what more samples do to a window the user has moved.
void advance(plot::glfw::Host& host, plot::data::StreamSeries& stream, plot::Plot& p,
plot::u32 axis) {
switch (step) {
case 0:
// Enough retained data for the numbers to be worth printing; the producer
// starts before the window opens, so this usually fires on the first frame.
if (stream.size() < 262144u) return;
p.set_x_follow(axis, plot::XFollow::Live);
p.auto_fit_x(); // the retained window, ending at the live edge
record(0, p, stream, axis);
break;
case 1:
notches(host, p);
record(1, p, stream, axis);
break;
case 2:
pan(host, p);
record(2, p, stream, axis);
wait_from = stream.x_bounds().max;
break;
case 3:
// 50 ms at this demo's 1 MS/s, i.e. ~50000 samples of new data before the
// fourth measurement.
if (stream.x_bounds().max - wait_from < 0.05) return;
record(3, p, stream, axis);
report();
break;
default:
return;
}
++step;
}
private:
/// Three notches of the wheel over the x label strip centre.
static void notches(plot::glfw::Host& host, plot::Plot& p) {
const plot::Rect strip = p.layout().x_label_area;
const plot::input::WheelEvent w{strip.x + 0.5f * strip.w, strip.y + 0.5f * strip.h,
0.0f, 1.0f, 0};
for (int i = 0; i < 3; ++i) host.controller().on_wheel(w);
}
/// A 100 px drag to the right across the axes, as press/move/release.
static void pan(plot::glfw::Host& host, plot::Plot& p) {
const plot::Rect axes = p.layout().axes;
const f32 x = axes.x + 0.5f * axes.w;
const f32 y = axes.y + 0.5f * axes.h;
host.controller().on_pointer(
{x, y, 0.0f, 0.0f, plot::input::Button::Left, 0, true, true, false});
host.controller().on_pointer(
{x + 100.0f, y, 100.0f, 0.0f, plot::input::Button::Left, 0, true, false, false});
host.controller().on_pointer(
{x + 100.0f, y, 0.0f, 0.0f, plot::input::Button::Left, 0, false, false, true});
}
void record(int i, plot::Plot& p, plot::data::StreamSeries& stream, plot::u32 axis) {
p.layout();
const plot::Range r = p.x_range(axis);
width[i] = r.span();
behind[i] = stream.x_bounds().max - r.max;
live[i] = p.x_follow(axis) == plot::XFollow::Live;
std::printf("[glfw_demo] follow %c: width %9.6f s behind %9.6f s follow %s newest %.6f s\n",
"abcd"[i], width[i], behind[i], live[i] ? "armed" : "OFF",
stream.x_bounds().max);
std::fflush(stdout);
}
void report() {
// Tolerances rather than `==`: the window is recomputed from a newest x that has
// moved between the frames the two numbers were taken on, so the last bits of
// the width and the distance behind may differ.
const auto same = [](plot::f64 a, plot::f64 b) {
return std::fabs(a - b) <= 1e-9 * std::fabs(a) + 1e-12;
};
const auto at_edge = [](plot::f64 distance) { return std::fabs(distance) < 1e-6; };
const bool zoom_only_width =
width[0] > width[1] * 1.01 && same(width[1], width[2]) && same(width[2], width[3]);
const bool pan_only_behind =
at_edge(behind[0]) && at_edge(behind[1]) && behind[2] > 1e-6 && same(behind[2], behind[3]);
const bool following = live[0] && live[1] && live[2] && live[3];
failed = !(zoom_only_width && pan_only_behind && following);
completed = true;
std::printf(
"[glfw_demo] follow checks: width changed only by the zoom: %s; distance behind only"
" by the pan: %s; following armed throughout: %s\n",
zoom_only_width ? "yes" : "NO", pan_only_behind ? "yes" : "NO",
following ? "yes" : "NO");
std::fflush(stdout);
}
};
/// The region's geometry as `Plot::layout()` measured it -- the rects the renderer paints
/// and the controller hit-tests, so what this reports is what the frame drew -- or null
/// when the layout holds no such region.
const plot::RegionLayout* region_layout(const plot::Plot& p, plot::u32 region) {
for (const plot::RegionLayout& r : std::as_const(p).layout().regions) {
if (r.region == region) return &r;
}
return nullptr;
}
/// The shaded interval a region occupies right now, or an empty rect when the layout
/// holds no such region.
plot::Rect region_fill(const plot::Plot& p, plot::u32 region) {
const plot::RegionLayout* r = region_layout(p, region);
return r != nullptr ? r->fill : plot::Rect{};
}
/// The scripted direct-manipulation sequence `PLOT_DEMO_REGIONS` runs: the region half of
/// spec §5, on a real host, in the shape the follow script above established.
///
/// A region is geometry in *data* coordinates while what a pointer grabs and a frame
/// paints is its *pixel* rect, so the claims worth making on a live stream are that the
/// rect the layout measured is the rect the data moved, and that it tracks the view
/// rather than the screen. Three frames measure exactly that:
///
/// (a) freeze the x view on a fixed window behind the live edge -- a user gesture does
/// the same by clearing auto scale, and it is what stops the live fit from sliding
/// the window under the region on every frame -- and place the region on a fraction
/// of what that window shows;
/// (b) pan 100 px with the press *outside* the region's interval, which is the press
/// that means "move the view", and check that the region's fill rect moved by those
/// 100 px and by nothing else, with its width untouched: the region is data-anchored,
/// so the pan moved the data *under* it and the region came along;
/// (c) leave the pointer on the region's left bound (`PLOT_DEMO_REGIONS=hot`) or off the
/// data area (any other value), which is the A/B the screenshot's emphasized handle
/// is counted from.
///
/// The verdict is carried in `failed`, so `main()` exits non-zero when a check said no,
/// and the run's screenshot is what the pixel counts are read from.
struct RegionScript {
bool enabled = false;
/// Leave a bound hovered as the final pointer state: `PLOT_DEMO_REGIONS=hot`. Any other
/// value leaves the pointer where nothing is hot, so running twice -- with and without
/// `hot` -- is a pixel-level A/B of the emphasis.
bool hover = false;
bool failed = false;
/// Set once the third measurement has been taken: a run that ended before it has not
/// passed, it just has not finished.
bool completed = false;
/// Set in the first step, where the script places the region's bounds itself: from then
/// on the frame callback must leave the region alone, or the per-frame placement of the
/// demo's overlay would undo the very gesture this script measures.
bool owns_geometry = false;
plot::u32 step = 0;
plot::Rect before{};
plot::Rect after{};
plot::RegionGrab grab = plot::RegionGrab::None;
static RegionScript from_env() {
RegionScript s;
const char* v = std::getenv("PLOT_DEMO_REGIONS");
s.enabled = v != nullptr && v[0] != '\0' && v[0] != '0';
s.hover = v != nullptr && std::strcmp(v, "hot") == 0;
return s;
}
void advance(plot::glfw::Host& host, plot::data::StreamSeries& stream, plot::Plot& p,
plot::u32 axis, plot::u32 region) {
switch (step) {
case 0: {
// The producer starts while the window is being built, so the first frame can
// already hold every sample this needs -- and the renderer installs the font the
// strips and the labels are measured with on its *first* draw. Measuring the
// region's rect against a layout computed before that would measure the default
// strips, so the script waits for a frame the renderer has drawn.
if (stream.size() < 262144u || p.text_backend() == nullptr) return;
const plot::Range live = stream.x_bounds();
p.set_x_range(axis, {live.max - kRegionWindowS, live.max});
const plot::Range w = p.x_range(axis);
p.set_region(region, w.min + 0.30 * w.span(), w.min + 0.55 * w.span());
owns_geometry = true;
p.layout();
const plot::RegionLayout* rl = region_layout(p, region);
if (rl == nullptr) {
failed = true;
break;
}
before = rl->fill;
std::printf("[glfw_demo] region %u: frozen x [%.6f, %.6f] s, fill x %.1f w %.1f h"
" %.1f, bounds at %.1f and %.1f\n",
region, w.min, w.max, before.x, before.w, before.h,
static_cast<plot::f64>(plot::handle_column(rl->left_handle)),
static_cast<plot::f64>(plot::handle_column(rl->right_handle)));
break;
}
case 1: {
// The rect the pan starts from, from the settled layout of this frame rather than
// from the frame the region was placed on: the strip the chrome reserves settles
// with the tick labels the frozen window produces.
p.layout();
before = region_fill(p, region);
const plot::RegionLayout* rl = region_layout(p, region);
if (rl == nullptr) {
failed = true;
break;
}
const plot::Rect axes = std::as_const(p).layout().axes;
// Left of the region and inside the axes: a press on no handle, so this is the
// pan and not a region grab.
const plot::f32 x = rl->fill.x - 40.0f;
const plot::f32 y = axes.y + 0.5f * axes.h;
const plot::f64 held = p.x_transform(axis).to_data_x(x);
host.controller().on_pointer(
{x, y, 0.0f, 0.0f, plot::input::Button::Left, 0u, true, true, false});
const bool panned = host.controller().drag_mode() == plot::input::DragMode::Pan;
host.controller().on_pointer({x + static_cast<plot::f32>(kRegionPanPx), y,
static_cast<plot::f32>(kRegionPanPx), 0.0f,
plot::input::Button::Left, 0u, true, false, false});
host.controller().on_pointer({x + static_cast<plot::f32>(kRegionPanPx), y, 0.0f, 0.0f,
plot::input::Button::Left, 0u, false, false, true});
p.layout();
after = region_fill(p, region);
// The invariant the pan has always held, which is what makes a data-anchored
// region track the view: the data under the press is under the pointer afterwards.
const plot::f64 under = p.x_transform(axis).to_data_x(x + static_cast<plot::f32>(kRegionPanPx));
const plot::f64 delta = static_cast<plot::f64>(after.x) - static_cast<plot::f64>(before.x);
const bool tracked = std::fabs(delta - static_cast<plot::f64>(kRegionPanPx)) <= 1.0;
const bool width_kept = std::fabs(static_cast<plot::f64>(after.w) -
static_cast<plot::f64>(before.w)) <= 0.5;
const bool data_kept = std::fabs(under - held) <= 1e-9;
failed = failed || !panned || !tracked || !width_kept || !data_kept;
std::printf("[glfw_demo] region pan %u px: drag mode %s, fill x %.1f -> %.1f (delta"
" %.1f, wanted %u), width %.1f -> %.1f, data under the press %.9f -> %.9f\n",
kRegionPanPx, panned ? "Pan" : "NOT PAN", before.x, after.x, delta,
kRegionPanPx, before.w, after.w, held, under);
break;
}
case 2: {
const plot::Rect axes = std::as_const(p).layout().axes;
if (hover) {
const plot::RegionLayout* rl = region_layout(p, region);
if (rl == nullptr) {
failed = true;
break;
}
const plot::f32 col = plot::handle_column(rl->left_handle);
host.controller().on_pointer({col, axes.y + 0.5f * axes.h, 0.0f, 0.0f,
plot::input::Button::Left, 0u, false, false, false});
grab = host.controller().region_grab(region);
failed = failed || grab != plot::RegionGrab::Left;
} else {
// Inside the window, outside the data area: no handle is hot, so a frame drawn
// from here is the baseline the emphasized one is compared against.
const plot::Rect strip = std::as_const(p).layout().x_label_area;
host.controller().on_pointer({strip.x + 0.5f * strip.w, strip.y + 0.5f * strip.h,
0.0f, 0.0f, plot::input::Button::Left, 0u, false, false,
false});
grab = host.controller().region_grab(region);
failed = failed || grab != plot::RegionGrab::None;
}
const plot::HotHandles hot = p.hot_handles();
std::printf("[glfw_demo] region final pointer: grab zone %u, hot region %llu, hot"
" zone %u, hovered cursor %llu\n",
static_cast<unsigned>(grab), static_cast<unsigned long long>(hot.region),
static_cast<unsigned>(hot.zone),
static_cast<unsigned long long>(hot.cursor));
completed = true;
break;
}
default:
return;
}
++step;
}
};
} // namespace
int main() {
plot::glfw::Host host;
plot::data::StreamSeries stream(kCapacity, 0.0, kDx);
Producer producer(stream);
plot::Plot& p = host.plot();
// Time axis: clock labels, which `TickFormat::Time` routes through
// `format_time`. The value axis keeps SI labels, which is `format_si`.
const u32 axis_x = p.add_x_axis(
{"time", {-0.5, 0.5}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
const u32 axis_y = p.add_y_axis(
{"signal", {-4.0, 4.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
// The trace is named, so the plot's legend -- on by default, per `Legend` -- lists it.
// An unnamed trace contributes no entry and an all-unnamed plot draws no panel, which is
// what keeps the legend free for a caller who never names one.
const u32 trace = p.add_trace({&stream, kTraceColor, 1.5f, axis_x, axis_y, "signal"});
// Snapped cursors sit exactly on a sample, so the readout measures samples
// rather than a rounded position between two of them.
const u32 cursor_a = p.add_cursor(0.0, kCursorAColor);
const u32 cursor_b = p.add_cursor(0.0, kCursorBColor);
p.set_cursor_snap(cursor_a, plot::SnapMode::NearestSample);
p.set_cursor_snap(cursor_b, plot::SnapMode::NearestSample);
const u32 measure = p.add_measure(cursor_a, cursor_b, trace);
Meter meter;
/// The scripted live-follow verification, off unless `PLOT_DEMO_FOLLOW` asks for
/// it. It is the same shape as the headless smoke mode above: a driven run whose
/// result is a few numbers on stdout.
FollowScript script = FollowScript::from_env();
/// The scripted region sequence, off unless `PLOT_DEMO_REGIONS` asks for it. Same shape
/// as the follow script: a driven run whose verdict is carried out in its `failed`.
RegionScript region_script = RegionScript::from_env();
/// `PLOT_DEMO_CROSSHAIR=1`: park a pointer at the centre of the data area every frame, so the
/// controller places the two-line crosshair (spec §17's thin, dashed hover instrument) and a
/// headless screenshot has its ink to measure. Off by default, like the two scripts above.
bool crosshair_demo = false;
if (const char* v = std::getenv("PLOT_DEMO_CROSSHAIR");
v != nullptr && v[0] != '\0' && v[0] != '0') {
crosshair_demo = true;
}
// The region the frame carries: a direct-manipulation primitive with the statistics
// readout that is the point of one, so the demo reaches it without a script as well as
// through one. Its bounds ride the live window while the user has not taken the overlay
// over (see `overlay_follows`), and `PLOT_DEMO_REGIONS` freezes the view and drives it
// instead.
plot::Region region_desc;
region_desc.snap = plot::RegionSnap::Free;
region_desc.fill = kRegionFill;
region_desc.edge = kRegionEdge;
region_desc.label = "roi";
const u32 region = p.add_region(region_desc);
// The cursors and the region ride the live window until the user takes the overlay over:
// the first drag on either, or a pan or zoom that clears the x axis' auto scale, stops
// them where the user put them. Under `Live` the follow path owns the x range whatever
// auto scale says, so a zoom is not the user taking the x view over -- only a handle
// drag, or following being switched off, is. While `PLOT_DEMO_REGIONS` is running its
// script owns the region's geometry instead (it measures one gesture against the next
// frame's), so the overlay is left alone for the length of that run.
bool overlay_follows = true;
const plot::glfw::FrameFn frame = [&](plot::Plot& graph, plot::f64 dt) {
stream.acquire(); // once per frame, on the render thread
if (script.enabled) script.advance(host, stream, graph, axis_x);
if (region_script.enabled) region_script.advance(host, stream, graph, axis_x, region);
// Re-frame the live stream while the user has not overridden the ranges, and
// while the axis is not following. `auto_fit_*` re-arms auto scale, so it may
// only run while auto scale is still on: calling it after a pan would undo the
// pan every frame -- and under `Live` a fit means "back to the live edge", so a
// per-frame one would undo the offset the user just panned to as well.
if (graph.x_follow(axis_x) == plot::XFollow::Off && graph.x_axis(axis_x).auto_scale) {
graph.auto_fit_x();
}
if (graph.y_axis(axis_y).auto_scale) graph.auto_fit_y(axis_y);
if (overlay_follows && !(region_script.enabled && region_script.owns_geometry)) {
const plot::Range xr = graph.x_range(axis_x);
const bool x_taken_over = graph.x_follow(axis_x) == plot::XFollow::Live
? false
: !graph.x_axis(axis_x).auto_scale;
const plot::input::DragMode mode = host.controller().drag_mode();
const bool dragging = mode == plot::input::DragMode::MoveCursor ||
mode == plot::input::DragMode::MoveRegion ||
mode == plot::input::DragMode::ResizeRegionLeft ||
mode == plot::input::DragMode::ResizeRegionRight;
if (dragging || x_taken_over) {
overlay_follows = false;
} else {
graph.set_cursor_x(cursor_a, xr.min + 0.35 * xr.span());
graph.set_cursor_x(cursor_b, xr.min + 0.65 * xr.span());
// The region sits on the left sixth of the window, not across its middle: it is a
// grabbable handle, so a region over the centre would turn the pan gesture the
// `PLOT_DEMO_FOLLOW` script makes (and the pan a user makes) into a region drag.
// The two cursors stay where they are -- neither is within a grab radius of the
// centre either, which is why the script's pan has always been a pan.
graph.set_region(region, xr.min + 0.06 * xr.span(), xr.min + 0.18 * xr.span());
}
}
if (crosshair_demo) {
// A pointer parked at the centre of the data area, injected through the controller so the
// *real* placement path runs: `update_crosshair()` reads both data coordinates off the
// pointer's pixel position and the plot draws the full cross there.
const plot::Rect a = std::as_const(graph).layout().axes;
plot::input::PointerEvent e;
e.x = a.x + 0.5f * a.w;
e.y = a.y + 0.45f * a.h;
e.mods = 0;
e.down = false;
host.controller().on_pointer(e);
}
meter.elapsed += dt;
meter.frames += 1;
meter.frame_ms += 0.1 * (dt * 1.0e3 - meter.frame_ms); // EMA
if (meter.elapsed - meter.last_report >= 0.25) {
const u64 produced = producer.produced();
meter.rate = static_cast<f64>(produced - meter.last_produced) /
(meter.elapsed - meter.last_report);
meter.last_produced = produced;
meter.last_report = meter.elapsed;
// The title carries the live readout: samples/second, frame time, and the
// samples per pixel that say which rasterisation path the trace took. The
// stats are the previous frame's, since this runs before draw().
const plot::Renderer* r = host.renderer();
const plot::FrameStats prev = r != nullptr ? r->stats() : plot::FrameStats{};
const plot::Rect axes = std::as_const(graph).layout().axes;
const f64 spp = axes.w > 0.0f ? static_cast<f64>(prev.visible_samples) /
static_cast<f64>(axes.w)
: 0.0;
char title[192];
std::snprintf(title, sizeof title,
"plot glfw demo | %.3f MS/s | %.2f ms/frame | %.0f samples/px | %s | %llu frames",
meter.rate * 1.0e-6, meter.frame_ms, spp,
prev.lod_traces > 0 ? "LOD" : (prev.raw_traces > 0 ? "raw" : "-"),
static_cast<unsigned long long>(meter.frames));
host.set_title(title);
std::printf("[glfw_demo] t=%.1fs %.3f MS/s %.2f ms/frame %.0f samples/px %s\n",
meter.elapsed, meter.rate * 1.0e-6, meter.frame_ms, spp,
prev.lod_traces > 0 ? "LOD" : (prev.raw_traces > 0 ? "raw" : "-"));
std::fflush(stdout);
}
};
// vsync on: this is a real application, and the headless mode below is what
// makes it verifiable without a display.
plot::glfw::RunConfig cfg;
cfg.title = "plot glfw demo";
cfg.vsync = true;
plot::Status st;
const bool ok = host.run(cfg, frame, &st);
producer.stop(); // no thread pushes into the stream after this point
if (!ok) {
std::fprintf(stderr, "plot_glfw_demo: %s: %s\n", plot::to_string(st.code), st.message);
return 1;
}
// The live-follow script's verdict, before the summary: a run that asked for the
// verification and did not get a passing one fails, whether a check said NO or the
// sequence never got far enough to say anything (a deadline too short to reach the
// fourth measurement -- a run that measured nothing has not passed).
if (region_script.enabled) {
if (region_script.failed) {
std::fprintf(stderr,
"plot_glfw_demo: the region checks failed (see the 'region' lines above)\n");
return 1;
}
if (!region_script.completed) {
std::fprintf(stderr,
"plot_glfw_demo: the region script did not complete (raise"
" PLOT_DEMO_EXIT_AFTER_MS)\n");
return 1;
}
}
if (script.enabled) {
if (script.failed) {
std::fprintf(stderr,
"plot_glfw_demo: the live-follow checks failed (see the 'follow checks'"
" line above)\n");
return 1;
}
if (!script.completed) {
std::fprintf(stderr,
"plot_glfw_demo: the live-follow script did not complete (raise"
" PLOT_DEMO_EXIT_AFTER_MS)\n");
return 1;
}
}
const plot::Renderer* r = host.renderer();
const plot::FrameStats stats = r != nullptr ? r->stats() : plot::FrameStats{};
const plot::Rect axes = std::as_const(p).layout().axes;
// The crosshair's geometry as the last frame laid it out (spec §17): the stroke and dash the
// layout resolved and the two stroke boxes -- what the screenshot's ink is read against.
{
const plot::CrosshairLayout& ch = std::as_const(p).layout().crosshair;
std::printf("[glfw_demo] crosshair active %d, stroke %.2f device px, dash %.2f/%.2f,"
" vertical box w %.2f at x %.1f, horizontal box h %.2f at y %.1f\n",
ch.active ? 1 : 0, static_cast<plot::f64>(ch.stroke),
static_cast<plot::f64>(ch.dash.x), static_cast<plot::f64>(ch.dash.y),
static_cast<plot::f64>(ch.vertical.w), static_cast<plot::f64>(ch.vertical.x),
static_cast<plot::f64>(ch.horizontal.h), static_cast<plot::f64>(ch.horizontal.y));
}
const f64 spp =
axes.w > 0.0f ? static_cast<f64>(stats.visible_samples) / static_cast<f64>(axes.w) : 0.0;
const f64 dense = r != nullptr ? static_cast<f64>(r->style().dense_threshold_spp) : 0.0;
const plot::Measurement m = p.measurement(measure);
const f64 mean_ms =
meter.frames > 0 ? meter.elapsed / static_cast<f64>(meter.frames) * 1.0e3 : 0.0;
const plot::Range yr = stream.y_bounds();
std::printf("\n[glfw_demo] ---- run summary ----\n");
// The rate is the last metered interval, not produced/elapsed: this thread's
// clock starts when the first frame runs, and the producer starts ~0.4 s
// earlier, still building the window, so a cumulative division would report a
// rate the producer never sustained.
std::printf("[glfw_demo] producer %llu samples pushed, last metered %.3f MS/s\n",
static_cast<unsigned long long>(producer.produced()), meter.rate * 1.0e-6);
std::printf("[glfw_demo] frames %llu in %.3f s, mean %.2f ms/frame (%.1f fps),"
" last metered %.2f ms/frame\n",
static_cast<unsigned long long>(meter.frames), meter.elapsed, mean_ms,
mean_ms > 0.0 ? 1.0e3 / mean_ms : 0.0, meter.frame_ms);
std::printf("[glfw_demo] retained %llu samples, x [%.6f, %.6f] s, y [%.3f, %.3f]\n",
static_cast<unsigned long long>(stream.size()), stream.x_bounds().min,
stream.x_bounds().max, yr.min, yr.max);
std::printf("[glfw_demo] pixel spaces viewport (framebuffer px) %.0fx%.0f, window->framebuffer"
" scale %.2fx%.2f, content scale %.2fx%.2f\n",
static_cast<double>(p.viewport().w), static_cast<double>(p.viewport().h),
static_cast<double>(host.framebuffer_scale_x()),
static_cast<double>(host.framebuffer_scale_y()),
static_cast<double>(host.content_scale_x()),
static_cast<double>(host.content_scale_y()));
std::printf("[glfw_demo] last frame %.1f samples/px (dense threshold %.1f) -> %s\n", spp,
dense, stats.lod_traces > 0 ? "LOD pyramid" : "raw samples");
std::printf("[glfw_demo] last frame visible_samples %llu, axes %.0fx%.0f, staged_segments %u"
", draw_calls %u, lod %u raw %u\n",
static_cast<unsigned long long>(stats.visible_samples),
static_cast<double>(axes.w), static_cast<double>(axes.h), stats.staged_segments,
stats.draw_calls, stats.lod_traces, stats.raw_traces);
std::printf("[glfw_demo] measurement %s dx %g s dy %g rms %g mean %g\n",
m.valid ? "valid:" : "INVALID", m.dx, m.dy, m.rms, m.mean);
// The region's statistics, in every run: this is the direct-manipulation primitive's
// readout, and the numbers the pixel evidence is read against. Exact -- the interval
// queries go through `Series::reduce/sum/sumsq`, never the pyramid's bucket forms.
if (const plot::RegionStats& rs = p.region_stats(region, trace); rs.valid) {
std::printf("[glfw_demo] region %u: x [%g, %g] s, n %llu, mean %g, rms %g, min %g, max"
" %g, integral %g\n",
region, rs.x0, rs.x1, static_cast<unsigned long long>(rs.count), rs.mean, rs.rms,
rs.min, rs.max, rs.integral);
}
if (const plot::RegionLayout* rl = region_layout(p, region); rl != nullptr) {
// The last frame's geometry, as the layout measured it: the rect the fill, the bound
// strokes and the pixel counts all have to agree with.
std::printf("[glfw_demo] region fill x %.1f y %.1f w %.1f h %.1f, bounds at %.1f and"
" %.1f, tab w %.1f, label w %.1f\n",
rl->fill.x, rl->fill.y, rl->fill.w, rl->fill.h,
static_cast<plot::f64>(plot::handle_column(rl->left_handle)),
static_cast<plot::f64>(plot::handle_column(rl->right_handle)),
rl->left_handle.w, rl->label.w);
}
if (region_script.enabled) {
std::printf("[glfw_demo] region %u: x [%g, %g] s, snap %s, %s; checks %s\n", region,
p.region(region).x0, p.region(region).x1,
p.region(region).snap == plot::RegionSnap::Free ? "free" : "nearest sample",
region_script.hover ? "bound hovered at exit" : "pointer off the data area at exit",
region_script.failed ? "FAILED" : "passed");
}
std::printf("[glfw_demo] upload path %s, gl error on the last frame %d\n",
r != nullptr && r->using_persistent_upload() ? "persistent-mapped ring" : "orphan",
r != nullptr && r->last_frame_had_gl_error() ? 1 : 0);
return 0; // ~producer (no-op after stop), ~stream, then ~host closes GL
}