Files
plotter/examples/glfw_demo.cpp
T

779 lines
38 KiB
C++

// 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
}