feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s

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.
This commit is contained in:
Martino Ferrari committed 2026-09-30 17:32:06 +02:00
1 parent f132a5ec92
commit 38ef459897
77 files changed
+5905 -688

No files matched your search

+33 -9
View File
@@ -4,6 +4,7 @@
#include <plot/data/array_series.hpp>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <utility>
@@ -23,23 +24,45 @@ void ArraySeries::commit() {
pyramid_.clear();
pyramid_.reserve(n);
// Bounds seed on the first *finite* sample and fold the rest: a non-finite
// sample is excluded from every aggregate, and a window with none aggregates
// to a zero-span Range (the library-wide NaN policy).
f32 lo = 0.0f, hi = 0.0f;
bool first = true;
bool finite = false;
for (const f32 v : y_) {
pyramid_.append(v);
if (first) {
lo = v;
hi = v;
first = false;
} else {
if (!std::isfinite(v)) continue;
if (finite) {
lo = std::min(lo, v);
hi = std::max(hi, v);
} else {
lo = v;
hi = v;
finite = true;
}
}
y_bounds_ = first ? Range{0.0, 0.0} : Range{f64(lo), f64(hi)};
y_has_finite_ = finite;
y_bounds_ = finite ? Range{f64(lo), f64(hi)} : Range{0.0, 0.0};
x_bounds_ = n == 0 ? Range{x0_, x0_} : Range{x0_, x0_ + f64(n - 1) * dx_};
}
void ArraySeries::push(f32 v) {
y_.push_back(v);
pyramid_.append(v);
// The incremental twin of commit()'s fold: skip non-finite, seed on the first
// finite sample, extend after that.
if (std::isfinite(v)) {
if (y_has_finite_) {
y_bounds_.min = std::min(y_bounds_.min, f64(v));
y_bounds_.max = std::max(y_bounds_.max, f64(v));
} else {
y_bounds_ = Range{f64(v), f64(v)};
y_has_finite_ = true;
}
}
x_bounds_ = Range{x0_, x0_ + f64(y_.size() - 1) * dx_};
}
u64 ArraySeries::size() const { return static_cast<u64>(y_.size()); }
f64 ArraySeries::x0() const { return x0_; }
@@ -52,8 +75,9 @@ Range ArraySeries::y_bounds() const { return y_bounds_; }
const MinMaxPyramid& ArraySeries::pyramid() const { return pyramid_; }
void ArraySeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const {
gather_impl(level, begin, end, out_xy);
void ArraySeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
GatherMode mode) const {
gather_impl(level, begin, end, out_xy, mode);
}
void ArraySeries::copy_raw(u64 begin, u64 end, f32* out) const {
+44 -19
View File
@@ -12,8 +12,12 @@
namespace plot::data {
namespace {
/// Samples covered by one level-l bucket.
constexpr u64 level_span(u32 l) { return u64{kBucket} << l; }
/// Samples covered by one level-l bucket. Clamped to kMaxPyramidLevel: the
/// shift past level 57 would overflow u64 (UB), and a bucket wider than any
/// u64 sample count is the same bucket as the widest one anyway.
constexpr u64 level_span(u32 l) {
return u64{kBucket} << (l > kMaxPyramidLevel ? kMaxPyramidLevel : l);
}
/// Level-l buckets needed to hold n samples (ceiling division).
constexpr u64 buckets_for(u64 n, u32 l) {
@@ -72,11 +76,13 @@ void cover_buckets(const MinMaxPyramid& p, u64 b0, u64 b1, Fn&& fn) {
}
/// Folds the decomposition of [b0, b1) into a min/max Range. Returns a
/// zero-span Range when there is nothing to fold.
/// zero-span Range when there is nothing to fold. Buckets with no finite
/// sample (count 0) are absent, not zero-valued, and are skipped.
Range fold_range(const MinMaxPyramid& p, u64 b0, u64 b1) {
Range r{};
bool first = true;
cover_buckets(p, b0, b1, [&](u32 l, u64 i) {
if (p.bucket_count(l, i) == 0) return;
const MinMax m = p.bucket(l, i);
if (first) {
r.min = f64(m.min);
@@ -96,10 +102,13 @@ void MinMaxPyramid::add_level() { levels_.emplace_back(); }
void MinMaxPyramid::reserve(u64 n) {
// Materialise every level n samples can need -- level 0 plus one per doubling
// of the bucket -- so a run of appends never grows the level vector. See the
// header for why the bucket deques themselves cannot be pre-sized.
// of the bucket, capped at kMaxPyramidLevel -- so a run of appends never grows
// the level vector. See the header for why the bucket deques themselves cannot
// be pre-sized.
u32 want = 1;
for (u64 span = level_span(0); span <= n && want < 63; span <<= 1) ++want;
for (u64 span = level_span(0); span <= n && want <= kMaxPyramidLevel; span <<= 1) {
++want;
}
if (levels_.size() < want) levels_.resize(want);
}
@@ -115,20 +124,25 @@ void MinMaxPyramid::push(u32 l, u64 k, const Entry& e) {
}
void MinMaxPyramid::append(f32 v) {
const f64 vd = f64(v);
// The new sample always lands in the in-progress level-0 bucket, which is the
// only bucket any append ever touches: a bucket becomes an entry exactly once,
// complete, and is never rewritten.
if (count_ % kBucket == 0) {
pending_.mm.min = v;
pending_.mm.max = v;
pending_.sum = vd;
pending_.sumsq = vd * vd;
} else {
pending_.mm.min = std::min(pending_.mm.min, v);
pending_.mm.max = std::max(pending_.mm.max, v);
if (count_ % kBucket == 0) pending_ = Entry{};
// Non-finite samples occupy their absolute position -- bucket boundaries are
// positional, so NaN or not the sample advances count_ and completes its
// bucket -- but they are excluded from every aggregate (see the header).
if (std::isfinite(v)) {
const f64 vd = f64(v);
if (pending_.count == 0) {
pending_.mm.min = v;
pending_.mm.max = v;
} else {
pending_.mm.min = std::min(pending_.mm.min, v);
pending_.mm.max = std::max(pending_.mm.max, v);
}
pending_.sum += vd;
pending_.sumsq += vd * vd;
++pending_.count;
}
++count_;
if (count_ % kBucket != 0) return; // the bucket is still partial: nothing to push
@@ -141,7 +155,8 @@ void MinMaxPyramid::append(f32 v) {
// Cascade: level l gains an entry exactly when kBucket << l divides the sample
// count, so the walk stops at the first level that gained nothing -- amortised
// O(1) per sample (1/kBucket + 1/(2*kBucket) + ... = 1/(kBucket/2) merges).
for (u32 l = 1; l < 63; ++l) {
// Capped at kMaxPyramidLevel: past it the span shift would overflow.
for (u32 l = 1; l <= kMaxPyramidLevel; ++l) {
const u64 span = level_span(l);
if (count_ % span != 0) break;
const u64 k = count_ / span - 1; // the level-l bucket that just completed
@@ -152,10 +167,14 @@ void MinMaxPyramid::append(f32 v) {
const Entry& a = at(l - 1, child);
const Entry& b = at(l - 1, child + 1);
Entry m;
m.mm.min = std::min(a.mm.min, b.mm.min);
m.mm.max = std::max(a.mm.max, b.mm.max);
// An empty child (count 0) has zero sums and a meaningless mm: it must not
// vote 0 into the merge, so a merge with it is just the other child.
m.mm = a.count == 0 ? b.mm : (b.count == 0 ? a.mm
: MinMax{std::min(a.mm.min, b.mm.min),
std::max(a.mm.max, b.mm.max)});
m.sum = a.sum + b.sum;
m.sumsq = a.sumsq + b.sumsq;
m.count = a.count + b.count;
push(l, k, m);
}
}
@@ -225,6 +244,12 @@ u64 MinMaxPyramid::level_size(u32 l) const noexcept {
MinMax MinMaxPyramid::bucket(u32 l, u64 i) const { return at(l, i).mm; }
f64 MinMaxPyramid::bucket_sum(u32 l, u64 i) const { return at(l, i).sum; }
f64 MinMaxPyramid::bucket_sumsq(u32 l, u64 i) const { return at(l, i).sumsq; }
u32 MinMaxPyramid::bucket_count(u32 l, u64 i) const { return at(l, i).count; }
Range MinMaxPyramid::reduce_buckets(u64 begin, u64 end) const {
const u64 e = std::min(end, size());
if (begin >= e) return Range{0.0, 0.0};
@@ -265,7 +290,7 @@ u32 MinMaxPyramid::level_for(u64 n, f64 samples_per_pixel) const {
const f64 ratio = samples_per_pixel / f64(kBucket);
if (n == 0 || !(ratio > 1.0)) return 0;
const f64 lg = std::floor(std::log2(ratio));
u32 l = lg >= f64(62) ? 62u : static_cast<u32>(lg);
u32 l = lg >= f64(kMaxPyramidLevel) ? kMaxPyramidLevel : static_cast<u32>(lg);
while (l > 0) {
const bool exists = l < level_count() && level_size(l) >= 2;
if (exists && buckets_for(n, l) >= 2) break;
+134 -28
View File
@@ -4,6 +4,7 @@
#include <plot/data/series.hpp>
#include <algorithm>
#include <cmath>
#include <cstddef>
namespace plot::data {
@@ -13,6 +14,9 @@ namespace {
/// gather path never allocates, and large enough to amortise the call.
constexpr u64 kGatherChunk = 256;
/// Ceiling division: first multiple of `d` at or after `v`, in `d` units.
constexpr u64 ceil_div(u64 v, u64 d) { return v / d + (v % d == 0 ? u64{0} : u64{1}); }
} // namespace
Series::QueryParts Series::split_query(u64 begin, u64 end) const {
@@ -75,6 +79,7 @@ Range Series::reduce(u64 begin, u64 end) const {
const u64 n = copy_edges(p, edge);
for (u64 i = 0; i < n; ++i) {
const f64 v = f64(edge[i]);
if (!std::isfinite(v)) continue; // non-finite samples are excluded (B7)
if (first) {
r.min = v;
r.max = v;
@@ -84,6 +89,8 @@ Range Series::reduce(u64 begin, u64 end) const {
r.max = std::max(r.max, v);
}
}
// No finite sample at all (an all-NaN window): a zero-span Range, per the
// library-wide NaN policy.
return first ? Range{0.0, 0.0} : r;
}
@@ -98,7 +105,10 @@ f64 Series::sum(u64 begin, u64 end) const {
}
f32 edge[2 * kBucket];
const u64 n = copy_edges(p, edge);
for (u64 i = 0; i < n; ++i) total += f64(edge[i]);
for (u64 i = 0; i < n; ++i) {
const f64 v = f64(edge[i]);
if (std::isfinite(v)) total += v; // non-finite samples are excluded (B7)
}
return total;
}
@@ -115,27 +125,73 @@ f64 Series::sumsq(u64 begin, u64 end) const {
const u64 n = copy_edges(p, edge);
for (u64 i = 0; i < n; ++i) {
const f64 v = f64(edge[i]);
total += v * v;
if (std::isfinite(v)) total += v * v; // non-finite samples are excluded (B7)
}
return total;
}
f64 Series::x_at(u64 i) const { return x0() + f64(i) * dx(); }
bool Series::neighbours(f64 x, u64& i0, u64& i1) const {
const u64 n = size();
if (n == 0) return false;
const f64 lo = x_at(0);
const f64 hi = x_at(n - 1);
if (!(x >= lo && x <= hi)) return false;
const f64 step = dx();
if (!(step > 0.0)) {
i0 = 0;
i1 = 0;
return true;
}
const f64 t = (x - lo) / step;
const u64 k = x >= hi ? n - 1 : static_cast<u64>(t); // floor, in range
// An exact hit collapses to one index, whatever the division above rounded
// to: compare against the samples themselves, not against t's fraction.
if (x_at(k) == x) {
i0 = k;
i1 = k;
return true;
}
if (k + 1 < n && x_at(k + 1) == x) {
i0 = k + 1;
i1 = k + 1;
return true;
}
i0 = k;
i1 = k + 1 < n ? k + 1 : k;
return true;
}
u64 Series::snap_index(f64 x) const {
const u64 n = size();
if (n == 0) return 0;
const f64 step = dx();
if (!(step > 0.0)) return 0;
const f64 k = std::round((x - x0()) / step);
// The first test also rejects NaN (every comparison with it is false), which
// would otherwise reach the u64 cast undefined.
if (!(k > 0.0)) return 0;
if (k >= f64(n)) return n - 1;
return static_cast<u64>(k);
}
void Series::gather_raw(u64 begin, u64 end, std::vector<f32>& out_xy) const {
const f64 x_0 = x0();
const f64 step = dx();
f32 raw[kGatherChunk];
for (u64 i = begin; i < end;) {
const u64 take = std::min<u64>(kGatherChunk, end - i);
copy_raw(i, i + take, raw);
for (u64 k = 0; k < take; ++k) {
out_xy.push_back(f32(x_0 + f64(i + k) * step));
out_xy.push_back(f32(f64(i + k) * step)); // x relative to x0(): see gather()
out_xy.push_back(raw[k]);
}
i += take;
}
}
void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const {
void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
GatherMode mode) const {
const u64 n = size();
const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n);
@@ -145,28 +201,37 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
gather_raw(b, e, out_xy);
return;
}
// Public levels clamp to the deepest representable one (R3): past
// kMaxPyramidLevel the bucket-span shift would overflow, and the widest
// bucket there is is already wider than any window.
level = std::min(level, kMaxPyramidLevel);
// Level >= 1: one envelope pair per *retained* bucket the range overlaps, x at
// the bucket's first sample (min) and at its last sample (max), which keeps x
// monotonic while covering the bucket's span. The pyramid's buckets are
// absolute, so map the window range onto its grid and clamp to the buckets that
// are both complete and wholly inside the window: a bucket that is only partly
// retained aggregates samples the window no longer has, and a partial trailing
// bucket is not stored at all.
// Level >= 1: one geometry element per *retained* bucket the range overlaps.
// The pyramid's buckets are absolute, so map the window range onto its grid
// and clamp to the buckets that are both complete and wholly inside the
// requested range: `first` is the first bucket whose span starts at or after
// `begin` (ceiling, so the bucket cannot reach left of the range) and `last`
// is one past the last bucket whose span ends at or before `end` (floor, so
// it cannot reach right of it). This is the B3 fix: a floor on `first` (or a
// bucket containing `end - 1`) emitted aggregates that covered samples
// outside [begin, end), so the envelope was wrong for any unaligned range.
// Buckets that are only partly *retained* are also excluded by construction:
// stored entries are complete aggregates of their own span, and a partial
// trailing bucket is never stored.
//
// Those two excluded buckets are still part of the range, and one of them holds
// the newest samples -- for a live trace, the part being watched. They are drawn
// from the raw samples instead: at most one bucket at each end, about a pixel
// each at the level level_for() picks, so the envelope path stays O(buckets)
// and the geometry still spans the requested range exactly.
const f64 x_0 = x0();
// The two excluded spans at the edges -- [b, lead) and [trail, e) -- are part
// of the range, and one of them holds the newest samples -- for a live trace,
// the part being watched. They are drawn from the raw samples instead: at
// most one bucket at each end, about a pixel each at the level level_for()
// picks, so the envelope path stays O(buckets) and the geometry still spans
// the requested range exactly, for every mode.
const f64 step = dx();
const u64 span = u64{kBucket} << level;
const u64 origin = pyramid().window_base();
const u64 lbase = pyramid().level_base(level);
const u64 lend = lbase + pyramid().level_size(level);
const u64 first = std::max((origin + b) / span, lbase);
const u64 last = std::min((origin + e - 1) / span + 1, lend);
const u64 first = std::max(ceil_div(origin + b, span), lbase);
const u64 last = std::min((origin + e) / span, lend);
if (first >= last) {
// No complete bucket of this level is inside the range -- a window squeezed
// into one bucket -- so the raw samples are the only exact geometry there is.
@@ -175,15 +240,56 @@ void Series::gather_impl(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy
}
const u64 lead = first * span - origin; // window index the first emitted bucket starts at
if (b < lead) gather_raw(b, std::min(lead, e), out_xy);
for (u64 bi = first; bi < last; ++bi) {
const MinMax m = pyramid().bucket(level, bi);
const u64 first_sample = bi * span - origin; // >= 0: stored buckets start inside the window
const u64 last_sample = std::min((bi + 1) * span, origin + e) - 1 - origin;
out_xy.push_back(f32(x_0 + f64(first_sample) * step));
out_xy.push_back(m.min);
out_xy.push_back(f32(x_0 + f64(last_sample) * step));
out_xy.push_back(m.max);
switch (mode) {
case GatherMode::Envelope: {
// One (min, max) pair per bucket, x at the bucket's first sample (min)
// and at its last sample (max), which keeps x monotonic while covering
// the bucket's span. A bucket with no finite sample is absent, not
// zero-valued, and emits nothing.
for (u64 bi = first; bi < last; ++bi) {
if (pyramid().bucket_count(level, bi) == 0) continue;
const MinMax m = pyramid().bucket(level, bi);
const u64 first_sample = bi * span - origin; // >= 0: stored buckets start inside the window
const u64 last_sample = std::min((bi + 1) * span, origin + e) - 1 - origin;
out_xy.push_back(f32(f64(first_sample) * step)); // x relative to x0()
out_xy.push_back(m.min);
out_xy.push_back(f32(f64(last_sample) * step));
out_xy.push_back(m.max);
}
break;
}
case GatherMode::Nearest: {
// One raw sample per bucket, picked the way pyramid().snap() would pick
// it: the sample nearest the bucket's centre, which on the fixed grid is
// the centre sample itself. x is at the sample; the value is raw, so a
// bucket whose centre sample is non-finite contributes that NaN, exactly
// as level 0 would have drawn it.
for (u64 bi = first; bi < last; ++bi) {
const u64 centre = bi * span + span / 2 - origin; // inside [b, e): the bucket is wholly inside
f32 v = 0.0f;
copy_raw(centre, centre + 1, &v);
out_xy.push_back(f32(f64(centre) * step)); // x relative to x0()
out_xy.push_back(v);
}
break;
}
case GatherMode::Mean: {
// One point per bucket, y = the bucket's finite samples' mean (the exact
// sum the bucket stores, divided by its finite count) and x at the
// bucket's centre. A bucket with no finite sample has no mean and emits
// nothing.
for (u64 bi = first; bi < last; ++bi) {
const u32 count = pyramid().bucket_count(level, bi);
if (count == 0) continue;
const u64 centre = bi * span + span / 2 - origin;
out_xy.push_back(f32(f64(centre) * step)); // x relative to x0()
out_xy.push_back(f32(pyramid().bucket_sum(level, bi) / f64(count)));
}
break;
}
}
const u64 trail = last * span - origin; // one past the last emitted bucket's span
if (trail < e) gather_raw(trail, e, out_xy);
}
+17 -4
View File
@@ -18,8 +18,11 @@ constexpr u64 kReadChunk = 256;
StreamSeries::StreamSeries(u64 capacity, f64 x0, f64 dx)
: ring_(capacity), x0_(x0), dx_(dx) {
// Size the pyramid's level table for a full window up front: the ring never
// holds more than capacity() samples, so appending never grows it.
pyramid_.reserve(capacity);
// holds more than capacity() samples, so appending never grows it. The ring
// rounded the request up to a power of two, and the window it will hold is
// the *rounded* capacity -- reserving the raw argument would under-size a
// non-power-of-two request exactly when the stream saturates (R4).
pyramid_.reserve(ring_.capacity());
}
void StreamSeries::push(f32 y) { ring_.push(y); }
@@ -60,6 +63,15 @@ void StreamSeries::acquire() {
y_bounds_ = reduce(0, size());
}
ArraySeries StreamSeries::freeze() const {
// The window of the last acquire(), copied out once: the frozen series owns
// its samples, so nothing the producer does afterwards can move it.
const u64 n = size();
std::vector<f32> y(static_cast<usize>(n));
if (n > 0) copy_raw(0, n, y.data());
return ArraySeries(std::move(y), x0(), dx());
}
u64 StreamSeries::size() const { return pyramid_.size(); }
f64 StreamSeries::x0() const { return x0_ + f64(pyramid_.window_base()) * dx_; }
@@ -75,8 +87,9 @@ Range StreamSeries::y_bounds() const { return y_bounds_; }
const MinMaxPyramid& StreamSeries::pyramid() const { return pyramid_; }
void StreamSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const {
gather_impl(level, begin, end, out_xy);
void StreamSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
GatherMode mode) const {
gather_impl(level, begin, end, out_xy, mode);
}
void StreamSeries::copy_raw(u64 begin, u64 end, f32* out) const {
+201
View File
@@ -0,0 +1,201 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/data/xy_series.hpp>
#include <algorithm>
#include <cassert>
#include <cmath>
#include <cstddef>
#include <utility>
namespace plot::data {
namespace {
/// Ceiling division: the first level-`l` bucket whose whole span starts at or
/// after `v` samples (in bucket units).
constexpr u64 ceil_div(u64 v, u64 d) { return v / d + (v % d == 0 ? u64{0} : u64{1}); }
} // namespace
XYSeries::XYSeries(std::vector<f64> x, std::vector<f32> y) { reset(std::move(x), std::move(y)); }
void XYSeries::reset(std::vector<f64> x, std::vector<f32> y) {
// The binary searches below (and every O(log n) consumer of them) are only
// correct on sorted input; see the header for the precondition.
assert(x.size() == y.size());
assert(std::is_sorted(x.begin(), x.end()) && "XYSeries requires x sorted ascending");
x_ = std::move(x);
y_ = std::move(y);
commit();
}
void XYSeries::commit() {
const u64 n = static_cast<u64>(y_.size());
pyramid_.clear();
pyramid_.reserve(n);
// Same NaN-aware fold as ArraySeries::commit(): non-finite y is excluded
// from the cached bounds, and a window with no finite sample aggregates to a
// zero-span Range. (The pyramid's append() applies the same policy per
// bucket.) The x bounds come straight from the sorted array's ends.
f32 lo = 0.0f, hi = 0.0f;
bool finite = false;
for (const f32 v : y_) {
pyramid_.append(v);
if (!std::isfinite(v)) continue;
if (finite) {
lo = std::min(lo, v);
hi = std::max(hi, v);
} else {
lo = v;
hi = v;
finite = true;
}
}
y_has_finite_ = finite;
y_bounds_ = finite ? Range{f64(lo), f64(hi)} : Range{0.0, 0.0};
x_bounds_ = n == 0 ? Range{0.0, 0.0} : Range{x_.front(), x_.back()};
}
u64 XYSeries::size() const { return static_cast<u64>(y_.size()); }
f64 XYSeries::x0() const { return x_.empty() ? 0.0 : x_.front(); }
f64 XYSeries::dx() const { return 0.0; }
Range XYSeries::x_bounds() const { return x_bounds_; }
Range XYSeries::y_bounds() const { return y_bounds_; }
const MinMaxPyramid& XYSeries::pyramid() const { return pyramid_; }
f64 XYSeries::x_at(u64 i) const { return x_[static_cast<usize>(i)]; }
bool XYSeries::neighbours(f64 x, u64& i0, u64& i1) const {
const u64 n = size();
if (n == 0) return false;
// Also rejects NaN: every comparison with it is false.
if (!(x >= x_.front() && x <= x_.back())) return false;
// Last sample at or left of x, first at or right of it. Both searches are in
// range: x >= front puts a lower_bound hit at index >= 1's predecessor at 0,
// and x <= back caps upper_bound's predecessor at n - 1.
const auto up = std::upper_bound(x_.begin(), x_.end(), x);
const auto lo = std::lower_bound(x_.begin(), x_.end(), x);
i0 = static_cast<u64>(up - x_.begin()) - 1;
i1 = static_cast<u64>(lo - x_.begin());
return true;
}
u64 XYSeries::snap_index(f64 x) const {
const u64 n = size();
if (n == 0) return 0;
if (!(x > x_.front())) return 0; // at or left of the first sample; NaN too
if (x >= x_.back()) return n - 1;
const auto it = std::lower_bound(x_.begin(), x_.end(), x);
const u64 hi = static_cast<u64>(it - x_.begin());
const u64 lo = hi - 1; // hi >= 1: x is strictly past x_.front()
return (x - x_[static_cast<usize>(lo)]) <= (x_[static_cast<usize>(hi)] - x)
? lo
: hi; // ties go to the lower index
}
void XYSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
GatherMode mode) const {
const u64 n = size();
const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n);
if (b >= e) return;
// x emitted relative to x0(): see Series::gather(). The x array is f64, so
// the subtraction happens wide and only the small, exact offset narrows.
const f64 origin = x0();
if (level == 0) {
for (u64 i = b; i < e; ++i) {
out_xy.push_back(f32(x_[static_cast<usize>(i)] - origin));
out_xy.push_back(y_[static_cast<usize>(i)]);
}
return;
}
level = std::min(level, kMaxPyramidLevel);
// The bucket grid is the sample index, exactly as for a uniform series, and
// an XYSeries never slides its window, so the pyramid's base is 0 and the
// window range needs no origin shift. Bucket selection is the B3-correct
// ceil/floor: `first` is the first bucket whose samples all sit at or after
// `begin`, `last` one past the last bucket wholly before `end`, and the two
// edge spans around them are read from the raw arrays -- so no emitted
// geometry comes from outside [begin, end), and the emitted x values are the
// array's own coordinates relative to x0().
const u64 span = u64{kBucket} << level;
const u64 lend = pyramid().level_size(level);
const u64 first = std::min(ceil_div(b, span), lend);
const u64 last = std::min(e / span, lend);
if (first >= last) {
for (u64 i = b; i < e; ++i) {
out_xy.push_back(f32(x_[static_cast<usize>(i)] - origin));
out_xy.push_back(y_[static_cast<usize>(i)]);
}
return;
}
const u64 lead = first * span;
for (u64 i = b; i < lead; ++i) {
out_xy.push_back(f32(x_[static_cast<usize>(i)] - origin));
out_xy.push_back(y_[static_cast<usize>(i)]);
}
switch (mode) {
case GatherMode::Envelope: {
for (u64 bi = first; bi < last; ++bi) {
if (pyramid().bucket_count(level, bi) == 0) continue;
const MinMax m = pyramid().bucket(level, bi);
const u64 lo = bi * span; // the bucket's first sample
const u64 hi = std::min((bi + 1) * span, e) - 1; // its last sample inside the range
out_xy.push_back(f32(x_[static_cast<usize>(lo)] - origin));
out_xy.push_back(m.min);
out_xy.push_back(f32(x_[static_cast<usize>(hi)] - origin));
out_xy.push_back(m.max);
}
break;
}
case GatherMode::Nearest: {
for (u64 bi = first; bi < last; ++bi) {
const u64 centre = bi * span + span / 2; // inside the bucket, hence inside [b, e)
out_xy.push_back(f32(x_[static_cast<usize>(centre)] - origin));
out_xy.push_back(y_[static_cast<usize>(centre)]);
}
break;
}
case GatherMode::Mean: {
for (u64 bi = first; bi < last; ++bi) {
const u32 count = pyramid().bucket_count(level, bi);
if (count == 0) continue;
const u64 centre = bi * span + span / 2;
out_xy.push_back(f32(x_[static_cast<usize>(centre)] - origin));
out_xy.push_back(f32(pyramid().bucket_sum(level, bi) / f64(count)));
}
break;
}
}
const u64 trail = last * span;
for (u64 i = trail; i < e; ++i) {
out_xy.push_back(f32(x_[static_cast<usize>(i)] - origin));
out_xy.push_back(y_[static_cast<usize>(i)]);
}
}
void XYSeries::copy_raw(u64 begin, u64 end, f32* out) const {
const u64 want = end > begin ? end - begin : 0;
if (want == 0) return;
const u64 n = size();
const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n);
std::copy(y_.begin() + static_cast<std::ptrdiff_t>(b),
y_.begin() + static_cast<std::ptrdiff_t>(e), out);
std::fill(out + static_cast<std::ptrdiff_t>(e - b),
out + static_cast<std::ptrdiff_t>(want), 0.0f);
}
} // namespace plot::data
+6
View File
@@ -317,9 +317,15 @@ bool Texture::create_r8(u32 width, u32 height, Status* st) noexcept {
void Texture::upload_rgba(u32 width, u32 height, const void* pixels) const noexcept {
glBindTexture(GL_TEXTURE_2D, id_);
// RGBA rows are 4-byte aligned whatever the width, but the *caller's* upload
// state is not ours to move: save what the host had and put it back, the same
// contract `upload_r8`'s callers honour for the single-channel path.
GLint host_alignment = 4;
glGetIntegerv(GL_UNPACK_ALIGNMENT, &host_alignment);
glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
glTexImage2D(GL_TEXTURE_2D, 0, static_cast<GLint>(GL_RGBA8), static_cast<GLsizei>(width),
static_cast<GLsizei>(height), 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
glPixelStorei(GL_UNPACK_ALIGNMENT, host_alignment);
}
void Texture::upload_r8(u32 width, u32 height, const void* pixels) const noexcept {
+296 -40
View File
@@ -39,6 +39,21 @@ constexpr f32 kCursorHitRadiusLogical = 7.0f;
/// A box-zoom selection narrower than this on either side is a click. Logical for
/// the same reason: the gesture is the same hand movement on any display.
constexpr f32 kMinBoxLogical = 4.0f;
/// Share of an axis' span one horizontal wheel notch (`dx`) pans, in axis space:
/// a notch is a small, deliberate step, a twentieth of the view.
constexpr f64 kPanPerNotch = 0.05;
/// Share of an axis' span one arrow-key press pans, in axis space -- twice the
/// wheel's step, because a key press is a coarser statement than a detent. A held
/// key repeats (the adapters' shared policy), so a hold pans continuously.
constexpr f64 kPanPerPress = 0.10;
/// The double-click window: two presses of the same button within this many
/// seconds -- and within `kDoubleClickPxLogical` of each other -- are a double-click
/// reset, not two gestures. Measured on `PointerEvent::time_s`, the host's clock.
constexpr f64 kDoubleClickSeconds = 0.35;
/// How close together the two presses of a double-click must land, in **logical**
/// pixels: the same hand-distance rule as the other tolerances here, converted
/// through `Style::device()` at the plot's scale.
constexpr f32 kDoubleClickPxLogical = 4.0f;
/// The narrowest *interior* a region's body keeps, in **logical** pixels: the edge bands are
/// cut back until this much of the middle is left, so a region too narrow for the full grab
/// radius on each side is still something a pointer can take hold of and *move*. Without it
@@ -136,7 +151,13 @@ void Controller::on_pointer(const PointerEvent& raw) {
plot_->layout(); // keep the chrome geometry the gestures map through current
if (e.pressed && e.down) {
begin_drag(e);
if (double_click(e)) {
// The second press of a double-click re-fits every axis and begins no drag:
// a reset is a click-shaped gesture, not the start of one.
fit_all();
} else {
begin_drag(e);
}
} else if (e.released) {
end_drag(e);
} else if (e.down && mode_ != DragMode::None) {
@@ -158,31 +179,59 @@ void Controller::on_wheel(const WheelEvent& raw) {
// like a zero-notch zoom -- a factor of exactly one -- and the zoom body would
// still run, calling the range setters with the unchanged range. Those clear
// `auto_scale` unconditionally, so a single garbage wheel would silently disarm
// auto-fit on every axis. Return before the event can mutate anything.
if (!std::isfinite(raw.dy)) return;
// auto-fit on every axis. `dx` is refused by the same rule: it pans when `dy` is
// zero, and a garbage pan is no more welcome than a garbage zoom. Return before
// the event can mutate anything.
if (!std::isfinite(raw.dy) || !std::isfinite(raw.dx)) return;
const WheelEvent e = sanitized(raw, has_pointer_, pointer_x_, pointer_y_);
plot_->layout();
const f64 factor = zoom_factor(static_cast<f64>(e.dy));
if (!(factor > 0.0)) return;
const bool shift = has_mod(e.mods, Mod::Shift);
const bool ctrl = has_mod(e.mods, Mod::Ctrl);
// Shift alone restricts the zoom to x, ctrl alone to y; neither (or both)
// zooms both axes. The data coordinate under the pointer stays put.
const bool zoom_x = !(ctrl && !shift);
const bool zoom_y = !(shift && !ctrl);
// A zero-notch wheel -- `dy == 0` and `dx == 0`, which a trackpad or a driver
// happily forwards -- must change nothing at all. Letting it run would call the
// range setters with every range unchanged, and those clear `auto_scale`: one
// empty scroll would silently disarm auto-fit on every axis. There is nothing to
// apply, so there is nothing to lay out or republish either.
if (e.dy == 0.0f && e.dx == 0.0f) return;
if (zoom_x) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
zoom_x_axis(i, x_zoom_anchor(i, e.x, e.y), factor);
if (e.dy != 0.0f) {
const f64 factor = zoom_factor(static_cast<f64>(e.dy));
// The zoom is refused, and the whole event with it, when its factor could not
// be applied honestly: a factor of exactly one changes no range but would
// still clear `auto_scale` on the way through the setters, a non-finite one
// (a huge downward `dy` explodes `exp`) would hand a non-finite span to the
// setters, and a non-positive one is not a zoom at all. "Leaves the plot
// exactly as it was" is the rule for an unusable amount.
if (!std::isfinite(factor) || !(factor > 0.0) || factor == 1.0) return;
const bool shift = has_mod(e.mods, Mod::Shift);
const bool ctrl = has_mod(e.mods, Mod::Ctrl);
// Shift alone restricts the zoom to x, ctrl alone to y; neither (or both)
// zooms both axes. The data coordinate under the pointer stays put.
const bool zoom_x = !(ctrl && !shift);
const bool zoom_y = !(shift && !ctrl);
if (zoom_x) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
zoom_x_axis(i, x_zoom_anchor(i, e.x, e.y), factor);
}
}
}
if (zoom_y) {
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast<f64>(e.y)), factor);
if (zoom_y) {
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast<f64>(e.y)), factor);
}
}
}
// A horizontal wheel pans x only: `dx` notches, 5% of the x span each, in axis
// space (so a log axis scrolls multiplicatively, exactly as its transform maps).
// A positive `dx` scrolls right -- the view moves toward larger x, the same
// reading `WheelEvent`'s comment gives the field. On a live axis the range
// setter reads the shifted window as a span and an offset behind the edge, so
// scrolling right runs to the live edge and stops there, exactly as a drag pan
// does.
if (e.dx != 0.0f) shift_x_axes(static_cast<f64>(e.dx) * kPanPerNotch);
// The ranges moved under a pointer that did not, so the geometry under it changed: refresh
// the layout -- the region rects the hit test reads were measured for the range this wheel
// has just replaced -- and republish what is hot. A frame drawn right after a scroll then
@@ -197,12 +246,79 @@ void Controller::on_wheel(const WheelEvent& raw) {
publish_hot();
}
void Controller::on_gesture(const GestureEvent& raw) {
// The same refusal rule as the wheel: a frame carrying something that is not
// a number is not a gesture, and a frame that could not change anything --
// a unit scale and no movement -- must not reach the range setters, which
// clear `auto_scale` for no visible change. `scale <= 0` is not a zoom.
if (!std::isfinite(raw.x) || !std::isfinite(raw.y) || !std::isfinite(raw.scale) ||
!std::isfinite(raw.dx) || !std::isfinite(raw.dy)) {
return;
}
if (raw.scale <= 0.0f) return;
if (raw.scale == 1.0f && raw.dx == 0.0f && raw.dy == 0.0f) return;
plot_->layout();
const f64 factor = 1.0 / static_cast<f64>(raw.scale); // pinch out = zoom in
if (std::isfinite(factor) && factor > 0.0 && factor != 1.0) {
// A pinch is a two-handed instrument, so it has no modifier semantics: it
// zooms both axes about the focal point, exactly as an unmodified wheel
// notch pair would -- same setters, same live-axis behaviour.
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
zoom_x_axis(i, x_zoom_anchor(i, raw.x, raw.y), factor);
}
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
zoom_y_axis(i, plot_->y_transform(i).to_data_y(static_cast<f64>(raw.y)), factor);
}
}
// The focal point's movement is a two-finger pan: the same path a one-finger
// drag takes, deltas included, so a pinch-and-drag moves the data under the
// fingers on every axis the drag would move.
if (raw.dx != 0.0f || raw.dy != 0.0f) {
PointerEvent pan_e;
pan_e.x = raw.x;
pan_e.y = raw.y;
pan_e.dx = raw.dx;
pan_e.dy = raw.dy;
pan(pan_e);
}
plot_->layout();
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
publish_hot();
}
void Controller::on_key(const KeyEvent& e) {
if (!e.down) return; // every action below is press-only
// Escape cancels the active drag (see cancel_drag). A key with no drag open --
// and any other key -- changes nothing and returns below without touching the
// layout, the crosshair or the published handles.
if (e.key == kKeyEscape) {
if (mode_ != DragMode::None) cancel_drag();
return;
}
// The arrow keys pan by axis class: Left/Right every x axis, Up/Down every y
// axis, 10% of that axis' own span per press (and per repeat, which is why the
// adapters forward key repeat). An arrow says where to *look*: Left walks the
// window toward smaller x, Up toward larger y -- the screen-y inversion a drag's
// dy carries does not apply, because a drag says where the data goes and a key
// says where the viewer goes.
switch (e.key) {
case kKeyLeft: shift_x_axes(-kPanPerPress); break;
case kKeyRight: shift_x_axes(kPanPerPress); break;
case kKeyUp: shift_y_axes(kPanPerPress); break;
case kKeyDown: shift_y_axes(-kPanPerPress); break;
default:
break;
}
const bool pans = e.key >= kKeyLeft && e.key <= kKeyDown;
const bool zooms = e.key == kKeyZoomIn || e.key == kKeyZoomOut;
const bool fits = e.key == kKeyFit || e.key == kKeyFitUpper;
if (!zooms && !fits) return; // a key this controller has no gesture for
if (!pans && !zooms && !fits) return; // a key this controller has no gesture for
if (zooms) {
const f64 notches = e.key == kKeyZoomIn ? kKeyZoomNotches : -kKeyZoomNotches;
@@ -219,7 +335,7 @@ void Controller::on_key(const KeyEvent& e) {
const f64 hi = to_space(a.range.max, a.log);
zoom_y_axis(i, from_space(0.5 * (lo + hi), a.log), factor);
}
} else {
} else if (fits) {
plot_->auto_fit_x();
for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i);
}
@@ -363,13 +479,33 @@ void Controller::begin_drag(const PointerEvent& e) {
drag_cursor_ = kInvalid;
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
// What Escape will restore if this gesture is cancelled: the axes' state *now*,
// before the drag moves anything (see capture_ranges).
capture_ranges();
// Shift first, and only on the left: it is a modifier gesture of its own, and a
// selection box is what it means even when it starts over a handle.
if (e.button == Button::Left && has_mod(e.mods, Mod::Shift)) {
mode_ = DragMode::BoxZoom;
selection_ = Rect{e.x, e.y, 0.0f, 0.0f};
return;
// The modifier box zoom, on the left only: a selection box is what a modified
// left press means even when it starts over a handle. The mask is resolved once,
// here, from the modifier table -- Shift is the plain both-axis selection it has
// always been, Ctrl frames the x range only, Alt the y range only, and any other
// combo falls back to both -- and `apply_box_zoom()` reads it on release, per
// axis and for the click threshold alike.
if (e.button == Button::Left) {
const bool shift = has_mod(e.mods, Mod::Shift);
const bool ctrl = has_mod(e.mods, Mod::Ctrl);
const bool alt = has_mod(e.mods, Mod::Alt);
if (shift || ctrl || alt) {
mode_ = DragMode::BoxZoom;
// The masks: Ctrl alone frames the x range only, Alt alone the y range only,
// and every other combo (Shift included) frames both -- the field says whether
// the release *applies* that axis, so the axis named by the mask is the one
// that stays true.
const bool ctrl_only = ctrl && !shift && !alt;
const bool alt_only = alt && !shift && !ctrl;
box_zoom_x_ = !alt_only;
box_zoom_y_ = !ctrl_only;
selection_ = Rect{e.x, e.y, 0.0f, 0.0f};
return;
}
}
if (e.button == Button::Middle) {
const PlotLayout& lay = std::as_const(*plot_).layout();
@@ -472,10 +608,113 @@ void Controller::end_drag(const PointerEvent& e) {
drag_cursor_ = kInvalid;
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
// The capture is the cancelled gesture's undo record, and this gesture was not
// cancelled: keeping it would let a later Escape restore a state that ended long
// ago (and `capture_ranges()` only runs at a press).
saved_x_.clear();
saved_y_.clear();
}
// --- gestures -------------------------------------------------------------
void Controller::capture_ranges() {
saved_x_.clear();
saved_y_.clear();
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
const Axis& a = plot_->x_axis(i);
saved_x_.push_back(SavedX{a.range, a.offset, a.auto_scale});
}
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const Axis& a = plot_->y_axis(i);
saved_y_.push_back(SavedY{a.range, a.auto_scale});
}
}
void Controller::cancel_drag() {
// The ranges the axes held at `begin_drag`, through the plot's own setters: an
// `Off` axis gets its range and its auto-scale flag back exactly, and a live one
// gets its span and offset restated the way the setters read them (the offset is
// written back verbatim after the range, since the live edge of *this* moment --
// not of the press -- is what the setter would otherwise derive it from). A
// cancelled gesture is as if it never ran: nothing is applied, no auto-scale is
// disarmed that was not already, and no box is framed.
for (u32 i = 0; i < plot_->x_axis_count() && i < saved_x_.size(); ++i) {
const SavedX& saved = saved_x_[i];
plot_->set_x_range(i, saved.range);
plot_->set_x_offset(i, saved.offset);
plot_->set_x_auto_scale(i, saved.auto_scale);
}
for (u32 i = 0; i < plot_->y_axis_count() && i < saved_y_.size(); ++i) {
const SavedY& saved = saved_y_[i];
plot_->set_y_range(i, saved.range);
plot_->set_y_auto_scale(i, saved.auto_scale);
}
// The gesture itself: no mode, no selection, nothing grabbed -- and the highlight
// the renderer draws follows, through the same tail every event that changes the
// geometry under the pointer runs.
mode_ = DragMode::None;
selection_ = Rect{};
drag_cursor_ = kInvalid;
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
plot_->layout();
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
publish_hot();
}
void Controller::fit_all() {
plot_->auto_fit_x();
for (u32 i = 0; i < plot_->y_axis_count(); ++i) plot_->auto_fit_y(i);
// A fit moved the geometry under a stationary pointer, exactly as a wheel notch
// does -- same tail, same reason (see on_wheel).
plot_->layout();
if (has_pointer_) update_crosshair(pointer_x_, pointer_y_);
publish_hot();
}
bool Controller::double_click(const PointerEvent& e) {
const f32 radius = plot_->style().device(kDoubleClickPxLogical);
const f32 dx = e.x - click_x_;
const f32 dy = e.y - click_y_;
// Both presses must carry a host timestamp for the window to mean anything: a
// host that leaves `time_s` at 0 never produces a double-click (the field is
// optional, and two presses "0 s apart" would otherwise fire on any host).
const bool in_time = e.time_s > 0.0 && click_time_s_ > 0.0 &&
e.time_s - click_time_s_ <= kDoubleClickSeconds;
const bool in_space = dx * dx + dy * dy <= radius * radius;
const bool is_double =
has_click_ && click_button_ == static_cast<int>(e.button) && in_time && in_space;
// This press is what a third one would be measured against -- unless the two
// just made a double-click, in which case the record clears: a third press starts
// a fresh gesture rather than re-firing the reset.
has_click_ = !is_double;
click_button_ = static_cast<int>(e.button);
click_time_s_ = e.time_s;
click_x_ = e.x;
click_y_ = e.y;
return is_double;
}
void Controller::shift_x_axes(f64 fraction) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
const Axis& a = plot_->x_axis(i);
const f64 lo = to_space(a.range.min, a.log);
const f64 hi = to_space(a.range.max, a.log);
const f64 d = fraction * (hi - lo); // positive scrolls toward larger x
plot_->set_x_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)});
}
}
void Controller::shift_y_axes(f64 fraction) {
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const Axis& a = plot_->y_axis(i);
const f64 lo = to_space(a.range.min, a.log);
const f64 hi = to_space(a.range.max, a.log);
const f64 d = fraction * (hi - lo); // positive scrolls toward larger y
plot_->set_y_range(i, Range{from_space(lo + d, a.log), from_space(hi + d, a.log)});
}
}
void Controller::pan(const PointerEvent& e) {
// The transforms map a range onto layout().axes, so the pixel-to-data scale is
// the range span over the axes rect -- not over the whole viewport. Using the
@@ -510,12 +749,17 @@ void Controller::box_zoom_update(const PointerEvent& e) {
void Controller::apply_box_zoom() {
const Rect sel = selection_;
// A selection under a few *logical* pixels on either side is a click: no zoom.
// The selection is measured in device pixels like everything the controller is
// handed, so the threshold is converted at the plot's own scale -- through the one
// place that conversion lives, so a change to its fallback is picked up here too.
// A selection under a few *logical* pixels on the axes the mask will zoom is a
// click: no zoom. The threshold is masked with the gesture -- a Ctrl drag frames
// the x range from a selection however short it is vertically, and vice versa --
// which is what makes a thin horizontal band a legal x-only box rather than a
// click no matter what the y says. The selection is measured in device pixels
// like everything the controller is handed, so the threshold is converted at the
// plot's own scale -- through the one place that conversion lives, so a change to
// its fallback is picked up here too.
const f32 min_box = plot_->style().device(kMinBoxLogical);
if (sel.w < min_box || sel.h < min_box) return;
if (box_zoom_x_ && sel.w < min_box) return;
if (box_zoom_y_ && sel.h < min_box) return;
// Clamped to the axes rect the transforms are built from, so a drag that left
// the plot area cannot ask for data outside what the box covers on screen.
@@ -528,17 +772,24 @@ void Controller::apply_box_zoom() {
const f64 r = std::clamp(static_cast<f64>(sel.right()), ax, ar);
const f64 t = std::clamp(static_cast<f64>(sel.y), ay, ab);
const f64 b = std::clamp(static_cast<f64>(sel.bottom()), ay, ab);
if (!(r > l) || !(b > t)) return;
if (box_zoom_x_ && !(r > l)) return;
if (box_zoom_y_ && !(b > t)) return;
// The ranges come straight from the transforms, so the box frames exactly the
// data under it; the selection's bottom edge is the range minimum on y.
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
const Transform tx = plot_->x_transform(i);
plot_->set_x_range(i, Range{tx.to_data_x(l), tx.to_data_x(r)});
// data under it; the selection's bottom edge is the range minimum on y. Masked
// axes are left exactly as they were -- the release applies only what the press
// promised.
if (box_zoom_x_) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
const Transform tx = plot_->x_transform(i);
plot_->set_x_range(i, Range{tx.to_data_x(l), tx.to_data_x(r)});
}
}
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const Transform ty = plot_->y_transform(i);
plot_->set_y_range(i, Range{ty.to_data_y(b), ty.to_data_y(t)});
if (box_zoom_y_) {
for (u32 i = 0; i < plot_->y_axis_count(); ++i) {
const Transform ty = plot_->y_transform(i);
plot_->set_y_range(i, Range{ty.to_data_y(b), ty.to_data_y(t)});
}
}
}
@@ -621,7 +872,12 @@ void Controller::axis_zoom(const PointerEvent& e) {
if (!(scale > 0.0) || !std::isfinite(scale)) return;
const f64 logical_dy = static_cast<f64>(e.dy) / scale;
const f64 factor = std::exp(-logical_dy * kZoomPerPixel);
if (!(factor > 0.0)) return;
// The same degenerate factors the wheel refuses: a factor of exactly one (a
// motion that reports no dy -- running it would still clear `auto_scale` on its
// way through the range setters), a non-finite one (an enormous dy explodes
// `exp` into a span the setters cannot store), and a non-positive one, which is
// not a zoom at all.
if (!std::isfinite(factor) || !(factor > 0.0) || factor == 1.0) return;
if (mode_ == DragMode::AxisZoomX) {
for (u32 i = 0; i < plot_->x_axis_count(); ++i) {
zoom_x_axis(i, x_zoom_anchor(i, e.x, e.y), factor);
+47 -15
View File
@@ -22,6 +22,22 @@ namespace {
/// wants a taller floor gives the figure a taller viewport, or gives the rows weights.
constexpr f32 kMinRowHeightLogical = 24.0f;
/// A coordinate in the shared axis' **axis space**: `log10` when the figure's one
/// x axis -- the bottom row's, the row the shared strip belongs to -- is
/// logarithmic, the value itself otherwise. A following group's span and offset
/// live in this space exactly as a single `Live` axis' do (see `Axis::offset`), so
/// a log group scrolls decades and holds decades; a *linear* span there would
/// rescale every row's view as the live edge moved. The bottom row rules the
/// space because it owns the shared axis; a group whose members disagree about
/// `log` still gets one data-space range pushed into every member, computed in
/// that row's space.
f64 to_link_space(f64 v, bool log) noexcept {
return log ? Transform::log10_positive(v) : v;
}
f64 from_link_space(f64 v, bool log) noexcept {
return log ? std::pow(10.0, v) : v;
}
} // namespace
Figure::Figure(u32 rows) : plots_(rows) {}
@@ -151,9 +167,11 @@ Status Figure::set_x_link_range(u32 group, Range r) {
// Under a following group the argument is not a pair of positions: it states the
// span the window should have and, through its maximum, where the window sits
// behind the live edge -- the same reinterpretation `Plot::set_x_range()` makes
// on a `Live` axis, applied to the group's own edge. A span that is not a span is
// refused rather than stored, because a following group has nothing to place a
// window from otherwise.
// on a `Live` axis, applied to the group's own edge. Both the span and the
// offset are taken in the shared axis' **axis space** (log10 decades on a log
// group), which is what makes the window hold decades instead of a decaying
// linear width. A span that is not a span is refused rather than stored,
// because a following group has nothing to place a window from otherwise.
const f64 lo = std::min(r.min, r.max);
const f64 hi = std::max(r.min, r.max);
if (!(hi > lo)) {
@@ -161,8 +179,9 @@ Status Figure::set_x_link_range(u32 group, Range r) {
"a following group's range states the window's span, so its bounds "
"must differ"};
}
g.span = hi - lo;
g.offset = live.max - hi;
const bool log = !plots_.empty() && plots_.back().x_axis(0u).log;
g.span = to_link_space(hi, log) - to_link_space(lo, log);
g.offset = to_link_space(live.max, log) - to_link_space(hi, log);
g.has_range = true;
place_live_window(g);
push_link(g);
@@ -249,18 +268,26 @@ bool Figure::place_live_window(XLink& group) {
if (!live_edge(group, live)) return false;
// The span is the group's own -- what the user zoomed to, or what the last range
// it was given stated -- and the follow window only moves it: the span is what
// makes this a scroll rather than a rescale. A span that is not a span is nothing
// makes this a scroll rather than a rescale. Both it and the offset are in the
// shared axis' **axis space** (see `to_link_space`), so a log group holds its
// visible decades whatever the edge does. A span that is not a span is nothing
// to place a window from, so the group's range is left exactly as it is.
if (!std::isfinite(group.span) || !(group.span > 0.0)) return false;
if (!std::isfinite(group.offset)) group.offset = 0.0;
// The offset can outlive the window it names: a ring that dropped samples moves the
// oldest x forward under it, so it is clamped against what the members hold *now*.
// An offset past that would put the window's right edge behind the oldest sample
// left, i.e. over data that is gone.
group.offset = std::clamp(group.offset, 0.0, std::max(0.0, live.max - live.min));
// The offset can outlive the window it names: a ring that dropped samples moves
// the oldest x forward under it, so it is clamped against what the members hold
// *now* -- measured in the same space, or a log group could hold an offset larger
// than everything it retains. An offset past that would put the window's right
// edge behind the oldest sample left, i.e. over data that is gone.
const bool log = !plots_.empty() && plots_.back().x_axis(0u).log;
const f64 newest_space = to_link_space(live.max, log);
const f64 retained =
std::max(0.0, newest_space - to_link_space(live.min, log));
group.offset = std::clamp(group.offset, 0.0, retained);
group.live_newest = live.max;
group.has_live = true;
group.range = Range{live.max - group.offset - group.span, live.max - group.offset};
const f64 right = newest_space - group.offset;
group.range = Range{from_link_space(right - group.span, log), from_link_space(right, log)};
return true;
}
@@ -300,15 +327,20 @@ void Figure::sync_x_links() {
// A group that was changed from outside restates its scroll state the way a
// single `Live` axis does -- the new range's span, and the offset its maximum
// implies -- and against the group's own edge, not the edge of the row the
// change arrived on. Then the window is recomputed from the live edge, which is
// change arrived on. Both are read in the shared axis' **axis space** (the
// same space `set_x_link_range()` and `place_live_window()` state them in),
// so a member's pan or zoom on a log group states decades like everything
// else on it. Then the window is recomputed from the live edge, which is
// the whole of what following is.
if (adopted) {
Range live{};
if (live_edge(group, live)) {
const f64 span = std::max(0.0, group.range.max - group.range.min);
const bool log = !plots_.empty() && plots_.back().x_axis(0u).log;
const f64 span = std::max(0.0, to_link_space(group.range.max, log) -
to_link_space(group.range.min, log));
if (span > 0.0) {
group.span = span;
group.offset = live.max - group.range.max;
group.offset = to_link_space(live.max, log) - to_link_space(group.range.max, log);
}
}
}
+184 -85
View File
@@ -31,41 +31,69 @@ const Axis& default_axis() noexcept {
return axis;
}
/// An axis coordinate in the space the live follow arithmetic runs in: log10 when
/// the axis is logarithmic, the value itself otherwise -- the same space the
/// controller's `to_space`/`from_space` gestures run in, and the space `Axis::offset`
/// and a `Live` window's span are stated in. A log axis therefore *scrolls* decades
/// and holds decades under follow: a linear span on a log axis would rescale the
/// view on every frame (the defect this fixes).
f64 to_axis_space(f64 v, bool log) noexcept {
return log ? Transform::log10_positive(v) : v;
}
f64 from_axis_space(f64 v, bool log) noexcept {
return log ? std::pow(10.0, v) : v;
}
/// The sample range a data interval covers: `[begin, end)` over the samples whose
/// x lies inside `[lo, hi]`, both ends included -- exactly the samples a region's
/// statistics are over. The nearest-sample snap seeds the walk and the two
/// corrections after it move by the one or two samples a rounded index can be off
/// by, so which side of a bound a sample falls on is decided by the same doubles
/// the series carries rather than by a division that is exact only on a grid that
/// is. Still O(1): the seed is never more than half a sample from the answer.
/// statistics are over. The brackets come from `Series::neighbours()`, which names
/// the samples at or either side of a bound *by the series' own x coordinates*
/// (`x_at()`), so a series whose samples are not on a uniform grid -- an XY series
/// -- is covered exactly where its samples actually are, not where a `dx()` grid
/// would have put them. Still O(1): two neighbour lookups.
///
/// False when the interval holds no sample at all, which is a real case: an
/// interval between two adjacent samples on a sparse series, one entirely before or
/// after the window, a non-finite bound, and a series with no usable `dx()`.
/// after the window, and a non-finite bound.
bool covered_samples(const data::Series& series, f64 lo, f64 hi, u64& begin, u64& end) {
const u64 n = series.size();
const f64 x0 = series.x0();
const f64 dx = series.dx();
if (n == 0u || !(dx > 0.0) || !std::isfinite(lo) || !std::isfinite(hi) || !(lo <= hi)) {
return false;
if (n == 0u || !std::isfinite(lo) || !std::isfinite(hi) || !(lo <= hi)) return false;
// The first sample at or after `lo`: the right neighbour of the bound, unless the
// bound lands on a sample -- which is then itself the first one inside. A bound
// outside the window covers everything on its side: before the oldest sample the
// coverage starts at 0, after the newest there is nothing inside.
u64 i0 = 0;
u64 i1 = 0;
if (!series.neighbours(lo, i0, i1)) {
if (series.x_at(0u) > lo) {
i1 = 0u;
} else {
return false;
}
} else if (series.x_at(i0) >= lo) {
i1 = i0;
}
const auto x_of = [x0, dx](u64 i) { return x0 + static_cast<f64>(i) * dx; };
const u64 b = i1;
// The first sample at or after `lo`: walk left while the one before is also
// inside, then right while this one is not yet.
u64 b = series.pyramid().snap(lo, x0, dx);
while (b > 0u && x_of(b - 1u) >= lo) --b;
while (b < n && x_of(b) < lo) ++b;
// One past the last sample at or before `hi`, from the same lookup: the left
// neighbour of the bound, or the sample the bound landed on.
u64 j0 = 0;
u64 j1 = 0;
if (!series.neighbours(hi, j0, j1)) {
if (series.x_at(n - 1u) < hi) {
j0 = n - 1u;
} else {
return false;
}
} else if (series.x_at(j1) <= hi) {
j0 = j1;
}
const u64 last = j0;
// One past the last sample at or before `hi`, from the same seed.
const u64 seed = series.pyramid().snap(hi, x0, dx);
u64 e = seed == n - 1u ? n : seed + 1u;
while (e < n && x_of(e) <= hi) ++e;
while (e > 0u && x_of(e - 1u) > hi) --e;
if (e <= b) return false;
if (last < b) return false; // the interval falls between two adjacent samples
begin = b;
end = e;
end = last + 1u;
return true;
}
@@ -228,6 +256,12 @@ u32 Plot::add_trace(const TraceDesc& desc) {
trace.axis_x = desc.axis_x;
trace.axis_y = desc.axis_y;
trace.name = desc.name;
trace.mode = desc.mode;
trace.dash_on = desc.dash_on;
trace.dash_off = desc.dash_off;
trace.marker = desc.marker;
trace.marker_size_px = desc.marker_size_px;
trace.marker_every = desc.marker_every;
traces_.push_back(trace);
mark_measures_dirty();
return static_cast<u32>(traces_.size() - 1);
@@ -363,15 +397,27 @@ void Plot::set_x_range(u32 axis, Range r) {
// normalised; a span that is not a span is ignored outright, leaving the span
// and offset the axis holds. `auto_fit_x()` is how a window is framed either
// way.
//
// The offset is taken in **axis space** (log10 when the axis is logarithmic):
// "how far behind the live edge" is a distance the axis' own transform measures,
// so a log axis states it in decades and the window that follows from it holds
// decades, not a decaying linear width. The span itself is read back from the
// stored range by place_live_x_window(), in the same space.
const f64 lo = std::min(r.min, r.max);
const f64 hi = std::max(r.min, r.max);
if (!std::isfinite(lo) || !std::isfinite(hi) || !(hi > lo)) return;
a.range = Range{lo, hi};
a.offset = live.max - hi;
a.offset = to_axis_space(live.max, a.log) - to_axis_space(hi, a.log);
place_live_x_window(axis);
a.auto_scale = false; // as ever: a range a caller chose is not an auto-fit
return;
}
// A bound that is not a number is not a range: storing one would hand the
// transforms a NaN (every mapping of it is NaN) and clear `auto_scale`, the one
// way back -- so the call is refused outright and the axis keeps the range *and*
// the auto-scale state it had. The same rule `set_region()` applies to a region's
// bounds, and for the same reason.
if (!std::isfinite(r.min) || !std::isfinite(r.max)) return;
// Off, or Live with nothing streaming to follow: the range is stored verbatim,
// which is what every caller of this setter got before follow existed.
a.range = r;
@@ -380,6 +426,9 @@ void Plot::set_x_range(u32 axis, Range r) {
void Plot::set_y_range(u32 axis, Range r) {
if (axis >= y_axes_.size()) return;
// The x setter's refusal, on the y side: a non-finite bound would leave the axis
// with no range to draw and no auto-scale to recover with.
if (!std::isfinite(r.min) || !std::isfinite(r.max)) return;
y_axes_[axis].range = r;
y_axes_[axis].auto_scale = false;
}
@@ -421,7 +470,10 @@ XFollow Plot::x_follow(u32 axis) const {
void Plot::set_x_offset(u32 axis, f64 units_behind_live) {
if (axis >= x_axes_.size()) return;
// No such offset: a NaN would place a window that is not a window, and the last
// usable one is a better answer than that for a value no gesture can undo.
// usable one is a better answer than that for a value no gesture can undo. The
// units are **axis space** ones -- data units on a linear axis, decades on a log
// one -- matching how `set_x_range()` derives the offset and how
// `place_live_x_window()` spends it.
if (!std::isfinite(units_behind_live)) return;
x_axes_[axis].offset = std::max(0.0, units_behind_live);
// The upper end of the clamp is what the traces still retain, which only they
@@ -489,19 +541,30 @@ bool Plot::place_live_x_window(u32 axis) {
if (a.follow != XFollow::Live) return false;
Range live{};
if (!live_x_bounds(axis, live)) return false;
// The span is the axis' own -- what the user zoomed to, or what the last fit
// produced -- and the follow window only moves it: the span is what makes this a
// scroll rather than a rescale. A span that is not a span is nothing to place a
// window from, so the range is left exactly as it is.
const f64 span = a.range.span();
// The span and the offset are distances in **axis space** -- log10 when the axis
// is logarithmic -- the space the controller's gestures are computed in. The span
// is the axis' own -- what the user zoomed to, or what the last fit produced --
// and the follow window only moves it: the span is what makes this a scroll
// rather than a rescale. On a log axis a *linear* span would rescale the view
// once the newest sample moved: ten units of span at 1e0 is ten decades, at 1e9
// it is nothing -- while the log-space span holds the visible decades constant
// whatever the edge does. A span that is not a span is nothing to place a window
// from, so the range is left exactly as it is.
const f64 hi_space = to_axis_space(a.range.max, a.log);
const f64 lo_space = to_axis_space(a.range.min, a.log);
const f64 span = hi_space - lo_space;
if (!std::isfinite(span) || !(span > 0.0)) return false;
// The offset can outlive the window it names: a ring that dropped samples moves
// the oldest x forward under it, so it is clamped against what the traces hold
// *now* -- an offset past that would put the window's right edge behind the
// oldest sample left, i.e. over data that is gone.
const f64 retained = std::max(0.0, live.max - live.min);
// *now* -- an offset past that would put the window's right edge behind the oldest
// sample left, i.e. over data that is gone. In axis space, like the span: the
// retained window's length is measured by the same ruler that measures the
// offset, or a log axis could hold an offset larger than everything it retains.
const f64 newest_space = to_axis_space(live.max, a.log);
const f64 retained = std::max(0.0, newest_space - to_axis_space(live.min, a.log));
a.offset = std::clamp(a.offset, 0.0, retained);
a.range = Range{live.max - a.offset - span, live.max - a.offset};
const f64 right = newest_space - a.offset;
a.range = Range{from_axis_space(right - span, a.log), from_axis_space(right, a.log)};
return true;
}
@@ -669,11 +732,26 @@ const Measurement& Plot::measurement(u32 id) const {
slot.result = Measurement{};
if (series != nullptr && samples > 0 && slot.cursor_a < cursors_.size() &&
slot.cursor_b < cursors_.size()) {
const u64 a = series->pyramid().snap(cursors_[slot.cursor_a].x, series->x0(), series->dx());
const u64 b = series->pyramid().snap(cursors_[slot.cursor_b].x, series->x0(), series->dx());
// snap() clamps to [0, size-1], so [a, b+1) is inside the series whenever
// the cursor pair is not inverted. An inverted pair is not an interval.
if (a <= b) slot.result = Measure::evaluate(*series, a, b + 1);
const Cursor& ca = cursors_[slot.cursor_a];
const Cursor& cb = cursors_[slot.cursor_b];
// R8: a measurement is read from two *x* coordinates, and a horizontal ruler
// has no x -- it is the dead field of its orientation (see set_cursor_x()).
// Evaluating the pair anyway would silently measure from x=0 and report
// numbers about nothing, so the result stays invalid instead; the id the
// caller holds is real, and `valid == false` is its answer.
if (!ca.is_horizontal() && !cb.is_horizontal()) {
// The cursors land on samples through the series' own lookups
// (`snap_index` clamps like the old pyramid snap did, and `x_at()` is
// where the sample really is), so an XY series measures between the
// samples its own x array names rather than between indices a uniform
// `dx()` guesses.
const u64 a = series->snap_index(ca.x);
const u64 b = series->snap_index(cb.x);
// snap_index() clamps to [0, size-1], so [a, b+1) is inside the series
// whenever the cursor pair is not inverted. An inverted pair is not an
// interval.
if (a <= b) slot.result = Measure::evaluate(*series, a, b + 1);
}
}
slot.last_series = series;
slot.last_size = samples;
@@ -821,10 +899,22 @@ MeasureSpan Plot::measure_span(u32 id) const noexcept {
if (id >= measures_.size()) return MeasureSpan{};
const MeasureSlot& slot = measures_[id];
if (slot.cursor_a >= cursors_.size() || slot.cursor_b >= cursors_.size()) return MeasureSpan{};
// A band is drawn between two *x* coordinates, and a horizontal ruler has none
// (the same dead field `measurement()` refuses under R8): the span says invalid
// rather than naming a band one edge of which sits at x=0.
if (cursors_[slot.cursor_a].is_horizontal() || cursors_[slot.cursor_b].is_horizontal()) {
return MeasureSpan{};
}
return MeasureSpan{slot.cursor_a, slot.cursor_b, true};
}
const PlotLayout& Plot::layout() {
// Belt and braces for R1: `set_viewport()` normalises a hostile rect, but every
// `std::clamp` below needs `lo <= hi` to be defined, so the dimensions the chrome
// is clamped against are read non-negative here too -- layout() is on the path of
// every host, every frame, and must be undefined nowhere.
const f32 vw = viewport_.w > 0.0f ? viewport_.w : 0.0f;
const f32 vh = viewport_.h > 0.0f ? viewport_.h : 0.0f;
// Live x windows are placed first, so everything below that derives from a range
// -- the ticks, the grid, the transforms the controller hit-tests through, and the
// range a caller reads back -- describes the live edge of *this* frame rather than
@@ -883,10 +973,10 @@ const PlotLayout& Plot::layout() {
// left of the allowance -- which is what keeps axes.w and axes.h positive
// however long the labels and titles are. `viewport_` is what a huge label set
// is clamped to first, so neither strip can push the rect outside it.
y_width = std::clamp(y_width, 0.0f, viewport_.w);
x_height = std::clamp(x_height, 0.0f, viewport_.h);
const f32 y_budget = (1.0f - kMinAxesShare) * viewport_.w;
const f32 x_budget = (1.0f - kMinAxesShare) * viewport_.h;
y_width = std::clamp(y_width, 0.0f, vw);
x_height = std::clamp(x_height, 0.0f, vh);
const f32 y_budget = (1.0f - kMinAxesShare) * vw;
const f32 x_budget = (1.0f - kMinAxesShare) * vh;
// The figure's common gutter, when one was imposed: a floor on `y_width + y_title_w`
// so every row's axes starts at the same x. It is served by the *label* strip -- the
// title column is one text line per titled axis and cannot be widened to fill a gutter
@@ -903,8 +993,8 @@ const PlotLayout& Plot::layout() {
layout_.y_title_width = y_title_width;
layout_.x_title_height = x_title_height;
layout_.axes = Rect{viewport_.x + y_width + y_title_width, viewport_.y,
viewport_.w - (y_width + y_title_width),
viewport_.h - (x_height + x_title_height)};
vw - (y_width + y_title_width),
vh - (x_height + x_title_height)};
layout_.y_title_area = Rect{viewport_.x, layout_.axes.y, y_title_width, layout_.axes.h};
layout_.y_label_area =
Rect{layout_.y_title_area.right(), layout_.axes.y, y_width, layout_.axes.h};
@@ -917,11 +1007,11 @@ const PlotLayout& Plot::layout() {
if (text_ == nullptr || pass + 1 >= kMaxLayoutPasses) break;
// The same caps as above, so this compares like with like and the loop still
// reaches its fixed point on the second pass.
const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, viewport_.w), y_budget);
const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, vw), y_budget);
// Overridden, not merely unmeasured: the strip is not reserved, so the fixed
// point has to be zero for a row that gave its strip away.
const f32 need_x =
x_labels ? std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, viewport_.h), x_budget)
x_labels ? std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, vh), x_budget)
: 0.0f;
// What the strip *wants* is the label's own width or the figure's floor, whichever is
// greater -- the floor is a maximum over sources, settled by one `max` and not by
@@ -987,8 +1077,10 @@ const data::Series* Plot::snap_series(u32 axis_x) const {
f64 Plot::snapped_x(f64 x, u32 axis_x) const {
const data::Series* s = snap_series(axis_x);
if (s == nullptr) return x;
const u64 i = s->pyramid().snap(x, s->x0(), s->dx());
return s->x0() + static_cast<f64>(i) * s->dx();
// Through the series' own index arithmetic (`snap_index`/`x_at`), so a series
// whose samples are not on a uniform grid snaps to the sample that is really
// nearest, not to where a `dx()` grid would have put one.
return s->x_at(s->snap_index(x));
}
f64 Plot::snapped_x(const Cursor& c) const {
@@ -1211,52 +1303,59 @@ void Plot::build_regions() {
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
CrosshairSpan span;
const u64 n = series.size();
const f64 x0 = series.x0();
const f64 dx = series.dx();
// No samples, no grid, no finite origin: nothing to interpolate between, and `index`
// below would be a division by zero or a NaN -- whose floor is undefined -- rather than a
// sample index. `!(dx > 0.0)` is also the NaN test for `dx`.
if (n == 0u || !(dx > 0.0) || !std::isfinite(x0) || !std::isfinite(x)) return span;
// No samples and no finite query: nothing to interpolate between.
if (n == 0u || !std::isfinite(x)) return span;
// The window is closed at both ends -- the oldest and the newest sample are samples too --
// and a query outside it has no neighbours to interpolate between. Answering with the
// nearest sample's value instead is the clamped lie this refuses: the readout would report
// a number as if it were under the pointer when the series does not reach that far.
const f64 newest = x0 + static_cast<f64>(n - 1u) * dx;
if (x < x0 || x > newest) return span;
// The bracketing samples, straight from the series (`neighbours()`/`x_at()`):
// the window is closed at both ends -- the oldest and the newest sample are
// samples too -- and a query outside it has no neighbours to interpolate
// between. Answering with the nearest sample's value instead is the clamped lie
// this refuses: the readout would report a number as if it were under the
// pointer when the series does not reach that far. The series owns where its
// samples are, so an XY series is bracketed by its own x array exactly, where
// the old uniform-grid index arithmetic would have read samples that do not
// exist at the positions it assumed.
u64 i0 = 0;
u64 i1 = 0;
if (!series.neighbours(x, i0, i1)) return span;
// Index arithmetic, not a search: the grid is uniform, so the sample at or before `x` is
// `floor((x - x0) / dx)` and there is nothing to look up. The two bounds are checked
// *before* the cast, which is also what keeps it defined: `index` is a quotient of two
// caller-supplied doubles and can overflow to infinity for an extreme origin and spacing,
// and casting that to `u64` is undefined. `!(index > 0.0)` is the NaN test as well.
const f64 index = (x - x0) / dx;
if (!(index > 0.0)) {
span.begin = span.end = 0u; // the oldest sample, or a rounding step below it
// A query that landed **on** a sample is a span of one, whichever side the
// series' bracket reported: a sample at the query is the answer, and the two
// ends of the window are samples too -- a bracket of (0, 1) at a query sitting
// on sample 0 would read as an interpolation with no width. Decided on the
// samples' own coordinates, so it holds for any `neighbours()` implementation.
if (series.x_at(i0) == x) {
span.begin = span.end = i0;
span.t = 0.0;
span.valid = true;
return span;
}
if (index >= static_cast<f64>(n - 1u)) {
span.begin = span.end = n - 1u; // the newest sample, or a step past it
if (series.x_at(i1) == x) {
span.begin = span.end = i1;
span.t = 0.0;
span.valid = true;
return span;
}
// `index` is finite and inside `(0, n-1)`, so truncating it is a floor on a value a `u64`
// holds. Two rounding cases keep the span honest at a sample: a `t` that lands on 0 is the
// query sitting *on* sample `i`, and one that rounds up to 1.0 is it sitting on the sample
// after it -- neither is an interpolation with no width.
const u64 i = static_cast<u64>(index);
const f64 t = index - static_cast<f64>(i);
if (!(t > 0.0)) {
span.begin = span.end = i;
} else if (t >= 1.0) {
span.begin = span.end = i + 1u;
if (i0 == i1) {
// The series bracketed a single sample for a query that is not on it: nothing
// to interpolate with, and the sample is the honest answer.
span.begin = span.end = i0;
span.t = 0.0;
} else {
span.begin = i;
span.end = i + 1u;
span.t = t;
span.begin = i0;
span.end = i1;
// `t` from the samples' own x coordinates, so the interpolation is exact on
// any grid, uniform or not. A degenerate pair (two samples sharing an x)
// interpolates nothing and reads the earlier sample.
const f64 xa = series.x_at(i0);
const f64 xb = series.x_at(i1);
span.t = xb > xa ? (x - xa) / (xb - xa) : 0.0;
if (!(span.t > 0.0)) {
span.t = 0.0; // a rounding step below the earlier sample is the sample
} else if (span.t > 1.0) {
span.t = 1.0;
}
}
span.valid = true;
return span;
+40 -4
View File
@@ -167,15 +167,51 @@ void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, boo
const f64 minor_step = step / minor_ratio;
if (!std::isfinite(minor_step) || !(minor_step > 0.0)) return;
const f64 stride = include_minors ? minor_step : step;
// Minors subdivide the major step an exact integer number of times, so the
// major/minor split is an *integer-index* test: a tick is a major when its
// minor index, counted from `first_major`, is a multiple of the ratio. Both
// operands of the index are exact for the windows axes actually show --
// `v - first_major` is the difference of two near doubles (Sterbenz-exact), and
// rounding the small quotient lands on the index the grid defines -- so no float
// tolerance is needed and none is applied. The old `|v - round(v/step)*step| <=
// step*1e-9` test fought a ULP-sized residue that an epoch origin produces
// whenever the step is not a power of two (v/step carries a rounding error of a
// few ULP of 1.75e9, i.e. far more than step*1e-9) and misclassified its majors;
// the index here has no residue to fight.
const auto minor_index_is_major = [&](f64 v) {
const f64 index = std::round((v - first_major) / minor_step);
return std::fmod(index, minor_ratio) == 0.0;
};
// Mutable: once the minors are capped the walk steps the major stride (see the
// cap branch below).
f64 stride = include_minors ? minor_step : step;
const f64 first = include_minors ? std::ceil(lo / minor_step) * minor_step : first_major;
if (!std::isfinite(first)) return;
char label[kLabelBuf];
usize minors = 0;
for (f64 v = first; v <= hi + stride * 1e-9;) {
if (out.size() >= kMaxTicks) break;
const bool major = !include_minors ||
std::fabs(v - std::round(v / step) * step) <= step * 1e-9;
const bool major = !include_minors || minor_index_is_major(v);
if (!major && minors >= kMaxTicks) {
// The cap counts minors only, and the majors are never truncated: an axis
// dense enough to want more subdivisions than fit loses its *subdivisions*,
// not its labels, so the far end of the axis keeps its numbers. From here on
// the walk steps the major stride; the next major at or after `v` is
// re-seated onto the major grid explicitly, because continuing from an
// off-grid minor with the new stride would visit only off-grid points and
// silently drop every remaining label. `minors >= kMaxTicks` is also the
// guard that keeps a hostile request (px towering over min_px) from stepping
// a minor stride across the whole range after the cap; the majors-only walk
// is as long as the caller's own pixel count asked it to be.
stride = step;
const f64 major_index = std::ceil((v - first_major) / step - 1e-9);
f64 reseat = first_major + major_index * step;
if (reseat < v) reseat = first_major + (major_index + 1.0) * step;
v = reseat;
continue;
}
if (!major) ++minors;
Tick tick;
tick.value = v;
tick.major = major;
+9 -5
View File
@@ -93,7 +93,7 @@ void TextBatch::set_cell(Vec2 cell_uv) noexcept {
cell_declared_ = true;
}
void TextBatch::add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux) {
void TextBatch::add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux, Color color) {
if (!cell_declared_) {
// Nobody declared a cell: a caller staging raw glyphs, where the glyph's own
// rect is the only authority there is. `add_text` and the renderer both
@@ -120,14 +120,18 @@ void TextBatch::add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux)
v_.push_back(glyph.v0);
v_.push_back(unit_ux.x);
v_.push_back(unit_ux.y);
v_.push_back(color.r);
v_.push_back(color.g);
v_.push_back(color.b);
v_.push_back(color.a);
}
void TextBatch::add_glyph(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 axis) {
add_cell(pen_px, glyph, px, unit_axis(axis));
void TextBatch::add_glyph(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 axis, Color color) {
add_cell(pen_px, glyph, px, unit_axis(axis), color);
}
Vec2 TextBatch::add_text(const char* utf8, Vec2 pen_px, f32 px, const TextBackend& font,
Vec2 axis) {
Vec2 axis, Color color) {
if (utf8 == nullptr) {
return pen_px;
}
@@ -145,7 +149,7 @@ Vec2 TextBatch::add_text(const char* utf8, Vec2 pen_px, f32 px, const TextBacken
if (glyph == nullptr) {
continue; // no artwork for this codepoint: nothing to draw, nothing to advance
}
add_cell(pen_px, *glyph, px, ux);
add_cell(pen_px, *glyph, px, ux, color);
pen_px = pen_px + ux * (glyph->advance * px);
}
return pen_px;
+2 -2
View File
@@ -32,6 +32,8 @@ void LineProgram::set_dash(Vec2 on_off) const noexcept {
void LineProgram::set_color(Color color) const noexcept { program_.set_uniform("u_color", color); }
void LineProgram::set_aa(f32 px) const noexcept { program_.set_uniform("u_aa", px); }
bool TextProgram::create(Status* st) noexcept {
program_ = gl::Program::from_source(kTextVS, kTextFS, st);
if (program_.id() == 0) {
@@ -54,6 +56,4 @@ void TextProgram::set_texture_size(Vec2 texels) const noexcept {
program_.set_uniform("u_tex_size", texels);
}
void TextProgram::set_color(Color color) const noexcept { program_.set_uniform("u_color", color); }
} // namespace plot::render
+299 -91
View File
@@ -36,10 +36,11 @@ bool fail(Status* st, ErrorCode code, const char* message) noexcept {
/// `math::Transform::to_screen_*` applies before scaling. A non-positive value on
/// a log axis takes the transform's own clamp, so it lands off-scale rather than
/// as NaN.
Vec2 pre_affine(f64 x, f64 y, bool log_x, bool log_y) noexcept {
return Vec2{static_cast<f32>(log_x ? Transform::log10_positive(x) : x),
static_cast<f32>(log_y ? Transform::log10_positive(y) : y)};
}
/// One axis' pre-affine coordinate: the value itself on a linear axis, `log10`
/// of it on a log one -- exactly what `math::Transform::to_screen_*` applies
/// before scaling. A non-positive value on a log axis takes the transform's own
/// clamp, so it lands off-scale rather than as NaN.
f64 space(f64 v, bool log) noexcept { return log ? Transform::log10_positive(v) : v; }
/// Sample index for a (possibly fractional, possibly non-finite) position in a
/// series' index space, clamped to [0, n]: NaN and -inf to 0, +inf to n, so a
@@ -190,6 +191,31 @@ u32 Renderer::AxisFont::revision() const { return font_ != nullptr ? font_->revi
// ------------------------------------------------------------------ init
Renderer::~Renderer() {
// The teardown contract (see renderer.hpp): the handles go while the context
// that owns their names is still current -- which it is, for a renderer
// destroyed before its context.
release_gl();
}
void Renderer::release_gl() noexcept {
ready_ = false;
// Assigning fresh objects releases every GL name through the handles'
// destructors: both programs, both vertex arrays and their instance buffers,
// and the atlas texture. Safe to call more than once; every assignment is a
// no-op while the renderer is uninitialised.
atlas_ = render::TextAtlas{};
line_program_ = render::LineProgram{};
text_program_ = render::TextProgram{};
line_vao_ = render::LineVertexArray{};
text_vao_ = render::TextVertexArray{};
lines_.clear();
glyphs_under_.clear();
glyphs_over_.clear();
}
// ------------------------------------------------------------------ init
void Renderer::set_text_backend(const render::TextBackend* font) noexcept {
const render::TextBackend* next = font != nullptr ? font : &default_font_;
if (next == font_) {
@@ -251,7 +277,8 @@ void Renderer::begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept {
fb_h_ = fb_h;
viewport_ = viewport_px;
lines_.clear();
glyphs_.clear();
glyphs_under_.clear();
glyphs_over_.clear();
stats_ = FrameStats{};
gl_error_ = false;
@@ -307,7 +334,14 @@ void Renderer::begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept {
// The pipelines write straight alpha and rely on blending being on; setting it
// per frame costs nothing and makes the renderer independent of what the host
// left behind.
// left behind. What the host had is saved first (R6) so `end_frame()` can hand
// the function and the enable bit back instead of leaving the frame's choice
// behind for the host's own draws to inherit.
gl::glGetIntegerv(gl::GL_BLEND_SRC_RGB, &blend_src_rgb_);
gl::glGetIntegerv(gl::GL_BLEND_SRC_ALPHA, &blend_src_a_);
gl::glGetIntegerv(gl::GL_BLEND_DST_RGB, &blend_dst_rgb_);
gl::glGetIntegerv(gl::GL_BLEND_DST_ALPHA, &blend_dst_a_);
blend_was_on_ = gl::glIsEnabled(gl::GL_BLEND) != gl::GL_FALSE;
gl::glEnable(gl::GL_BLEND);
gl::glBlendFunc(gl::GL_SRC_ALPHA, gl::GL_ONE_MINUS_SRC_ALPHA);
}
@@ -366,8 +400,10 @@ bool Renderer::begin_frame_text() noexcept {
font_backend_.reset(*font_, style_.device(style_.axis_font_px));
// The cell every glyph staged this frame is drawn in, declared from the backend
// rather than left to whichever glyph is staged first: the instances carry rect
// origins, and the draw carries this size (see TextBackend::cell_uv).
glyphs_.set_cell(font_backend_.cell_uv());
// origins, and the draw carries this size (see TextBackend::cell_uv). Both
// batches draw from the same atlas at the same size, so both declare it.
glyphs_under_.set_cell(font_backend_.cell_uv());
glyphs_over_.set_cell(font_backend_.cell_uv());
// A backend whose revision moved -- a font swapped, or re-baked at another size
// -- is re-uploaded here, before anything is staged from it, so the frame draws
// its glyphs from the atlas they were baked into. The first sync happened in
@@ -417,9 +453,10 @@ void Renderer::draw(Plot& plot) {
// its tick rebuilds, and the grid build all run in here, and every helper below
// reads the strips, the axes rect and the transforms it pins.
draw_plot(plot, plot.layout());
// The frame's single text flush, after every line batch of the frame: text is
// texture-bound, so it is one draw call whatever it holds (see draw_plot).
flush_text();
// The frame's over-text flush, after every line batch and after the under batch
// has already gone out inside draw_plot: readout text, tick labels and titles --
// what must sit over everything (see draw_plot).
flush_text(glyphs_over_);
}
void Renderer::draw(Figure& figure) {
@@ -453,7 +490,7 @@ void Renderer::draw(Figure& figure) {
// overload -- the one layout of this frame, not a second derivation of it.
draw_plot(plot, static_cast<const Plot&>(plot).layout());
}
flush_text();
flush_text(glyphs_over_);
}
void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
@@ -504,9 +541,13 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
draw_measures(plot, layout);
draw_annotations(plot);
draw_legend(plot, layout);
// The under text batch goes out here, so the readout's panel below it can
// occlude what it covers: legend names, cursor labels, measure labels and
// annotation captions are all chrome the panel is allowed to sit on.
flush_text(glyphs_under_);
// The crosshair's readout last of the interior passes: it is what the pointer is on, so its
// panel is drawn over the legend and every other overlay rather than under them, and its text
// joins the frame's single flush with everything else.
// panel is drawn over the legend and every other overlay rather than under them, and its
// text joins the frame's over flush with the tick labels and titles.
draw_crosshair_readout(plot, layout);
clear_clip();
draw_tick_labels(plot, layout);
@@ -540,7 +581,15 @@ void Renderer::end_frame() noexcept {
gl::glBindBuffer(gl::GL_ARRAY_BUFFER, 0);
gl::glActiveTexture(gl::GL_TEXTURE0);
gl::glBindTexture(gl::GL_TEXTURE_2D, 0);
gl::glDisable(gl::GL_BLEND);
// The blend state is the host's, not the frame's: what `begin_frame()` saved
// comes back here, function and enable bit both (R6).
gl::glBlendFuncSeparate(static_cast<gl::GLenum>(blend_src_rgb_),
static_cast<gl::GLenum>(blend_dst_rgb_),
static_cast<gl::GLenum>(blend_src_a_),
static_cast<gl::GLenum>(blend_dst_a_));
if (!blend_was_on_) {
gl::glDisable(gl::GL_BLEND);
}
// The scissor, too: the frame engaged it (its baseline clip) and each plot narrowed and
// released it, so what is left behind is the test *off* -- the state the residue list
// promises -- rather than whichever rect the last pass was clipped to. The box stays
@@ -567,9 +616,9 @@ void Renderer::upload_vertices(std::span<const f32> interleaved) noexcept {
}
void Renderer::upload_text_vertices(std::span<const f32> interleaved) noexcept {
// Six floats per instance is the text pipeline's layout (see TextInstance):
// the cell's corner, its atlas rect and the basis that rotates it.
text_vao_.upload(interleaved.data(), static_cast<u32>(interleaved.size() / 6u));
// Ten floats per instance is the text pipeline's layout (see TextInstance):
// the cell's corner, its atlas rect, the basis that rotates it and the tint.
text_vao_.upload(interleaved.data(), static_cast<u32>(interleaved.size() / 10u));
}
void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash) noexcept {
@@ -585,6 +634,11 @@ void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash
// grid, a trace or a cursor drawn after it (see draw_crosshair_lines()).
line_program_.set_dash(dash);
line_program_.set_color(color);
// The AA fringe, too: the style's logical knob in device pixels, clamped so
// a 0 style still keeps a quarter pixel of ramp. 1.0 -- the default -- is
// the fringe the shaders used to hardcode, so the default frame is
// unchanged.
line_program_.set_aa(std::max(0.25f, style_.device(style_.line_aa_px)));
line_vao_.bind();
// One triangle strip per instance, four corners from gl_VertexID.
gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4,
@@ -595,12 +649,12 @@ void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash
lines_.clear(); // keeps the capacity, so the next group re-uses the store
}
void Renderer::flush_text() noexcept {
const u32 glyphs = glyphs_.glyph_count();
void Renderer::flush_text(render::TextBatch& batch) noexcept {
const u32 glyphs = batch.glyph_count();
// No atlas is no text: a backend that publishes none has nothing to sample, and
// drawing its instances would read whatever texture happened to be bound.
if (glyphs != 0 && ready_ && !atlas_.empty()) {
upload_text_vertices(glyphs_.data());
upload_text_vertices(batch.data());
text_program_.bind();
text_program_.set_viewport({viewport_.w, viewport_.h});
// The cell comes from the backend and the atlas it uploaded -- never from a
@@ -613,26 +667,35 @@ void Renderer::flush_text() noexcept {
text_program_.set_px_range({cell_uv.x * atlas_texels.x * font_backend_.scale(),
cell_uv.y * atlas_texels.y * font_backend_.scale()});
text_program_.set_texture_size(atlas_texels);
text_program_.set_color(style_.axis_text);
// The tint rides on the instance (`a_colour`), so one draw call carries runs
// of several colours -- there is no colour uniform to set.
atlas_.bind();
text_vao_.bind();
gl::glDrawArraysInstanced(gl::GL_TRIANGLE_STRIP, 0, 4,
static_cast<gl::GLsizei>(text_vao_.instance_count()));
++stats_.draw_calls;
}
glyphs_.clear();
batch.clear();
}
// ----------------------------------------------------------- coordinates
Renderer::Mapping Renderer::mapping(const Plot& plot, u32 axis_x, u32 axis_y) const noexcept {
Renderer::Mapping Renderer::mapping(const Plot& plot, u32 axis_x, u32 axis_y,
f64 origin_x) const noexcept {
const Transform tx = plot.x_transform(axis_x);
const Transform ty = plot.y_transform(axis_y);
// The plot's transforms already map data to framebuffer pixels; the shaders
// emit NDC relative to the GL viewport, so the viewport origin comes off here
// -- once per mapping, never per primitive.
//
// The staged x is f32(to_space(x) - to_space(origin_x)) (B1), so the origin is
// folded back into the offset here -- in f64, once, before the single
// narrowing -- and the pair reproduces `to_screen_x` exactly however large the
// absolute x is. y is staged absolute (in pre-affine space), so its offset is
// the transform's own.
const f64 origin_s = space(origin_x, tx.log_x);
return Mapping{Vec2{static_cast<f32>(tx.sx), static_cast<f32>(ty.sy)},
Vec2{static_cast<f32>(tx.tx) - viewport_.x,
Vec2{static_cast<f32>(tx.sx * origin_s + tx.tx - viewport_.x),
static_cast<f32>(ty.ty) - viewport_.y}};
}
@@ -666,25 +729,42 @@ void Renderer::fill_pixels(const Rect& rect, Color color) {
flush_lines(pixel_mapping(), color, rect.h);
}
void Renderer::stage_segment(f64 x0, f64 y0, f64 x1, f64 y1, bool log_x, bool log_y) {
lines_.add_segment(pre_affine(x0, y0, log_x, log_y), pre_affine(x1, y1, log_x, log_y));
void Renderer::stage_segment(f64 origin_x, f64 x0, f64 y0, f64 x1, f64 y1, bool log_x,
bool log_y) {
// Staged relative to `origin_x` in the pre-affine space (B1): the mapping folds
// the origin back in, in f64, so this f32 is O(visible pixels).
lines_.add_segment(Vec2{static_cast<f32>(space(x0, log_x) - space(origin_x, log_x)),
static_cast<f32>(space(y0, log_y))},
Vec2{static_cast<f32>(space(x1, log_x) - space(origin_x, log_x)),
static_cast<f32>(space(y1, log_y))});
}
void Renderer::stage_xy(const f32* xy, u32 n, bool log_x, bool log_y) {
void Renderer::stage_xy(const f32* xy, u32 n, f64 series_x0, f64 origin_x, bool log_x,
bool log_y) {
if (!log_x && !log_y) {
// The common case: gathered points are already interleaved `x,y`, which is
// the batch's own payload, so this is a straight copy.
lines_.add_polyline_xy(xy, n);
// The common case: reconstruct the absolute data x in f64 (`x0 + rel` --
// the gather contract's own reconstruction), stage it relative to the
// draw's origin, and the f32 is O(visible pixels), not O(epoch).
staged_.clear();
staged_.reserve(usize(n) * 2u);
const f64 origin_s = space(origin_x, log_x);
for (u32 i = 0; i < n; ++i) {
staged_.push_back(static_cast<f32>(xy[2u * i] + series_x0 - origin_s));
staged_.push_back(xy[2u * i + 1u]);
}
lines_.add_polyline_xy(staged_.data(), n);
return;
}
for (u32 i = 0; i + 1u < n; ++i) {
stage_segment(xy[2u * i], xy[2u * i + 1u], xy[2u * i + 2u], xy[2u * i + 3u], log_x, log_y);
stage_segment(origin_x, xy[2u * i] + series_x0, xy[2u * i + 1u], xy[2u * i + 2u] + series_x0,
xy[2u * i + 3u], log_x, log_y);
}
}
Vec2 Renderer::stage_text(Vec2 pos_px, const char* utf8) {
Vec2 Renderer::stage_text(render::TextBatch& batch, Vec2 pos_px, const char* utf8, Color color) {
// Text is staged in viewport pixels: the text program has no offset uniform.
return glyphs_.add_text(utf8, to_viewport(pos_px), font_backend_.scale(), *font_);
return batch.add_text(utf8, to_viewport(pos_px), font_backend_.scale(), *font_, {1.0f, 0.0f},
color);
}
f32 Renderer::clipped_text_width(const char* utf8, f32 budget_px, Vec2 axis) const {
@@ -692,11 +772,12 @@ f32 Renderer::clipped_text_width(const char* utf8, f32 budget_px, Vec2 axis) con
[](Vec2, const render::Glyph&) {});
}
f32 Renderer::stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px, Vec2 axis) {
f32 Renderer::stage_text_clipped(render::TextBatch& batch, Vec2 pos_px, const char* utf8,
f32 budget_px, Vec2 axis, Color color) {
const f32 scale = font_backend_.scale();
return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, *font_, scale, axis,
[this, scale, axis](Vec2 pen, const render::Glyph& glyph) {
glyphs_.add_glyph(pen, glyph, scale, axis);
[this, &batch, scale, axis, color](Vec2 pen, const render::Glyph& glyph) {
batch.add_glyph(pen, glyph, scale, axis, color);
});
}
@@ -704,9 +785,12 @@ f32 Renderer::stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px, V
void Renderer::draw_grid(const Plot& plot) {
const Grid& grid = plot.grid();
const Mapping map = mapping(plot, 0, 0);
// The grid is built from the primary axis pair, so it is mapped with their
// transforms even when a trace uses another pair.
// transforms even when a trace uses another pair. Its lines are staged
// relative to the x range's own minimum -- the natural origin for a whole-axis
// draw -- and the mapping folds it back in (B1).
const Range x_range = plot.x_range(0);
const Mapping map = mapping(plot, 0, 0, x_range.min);
const bool log_x = plot.x_axis(0).log;
const bool log_y = plot.y_axis(0).log;
@@ -725,7 +809,7 @@ void Renderer::draw_grid(const Plot& plot) {
}
for (const GridLine& line : grid.lines()) {
if (line.major != major) continue; // grid.build() already dropped non-finite ones
stage_segment(line.x0, line.y0, line.x1, line.y1, log_x, log_y);
stage_segment(x_range.min, line.x0, line.y0, line.x1, line.y1, log_x, log_y);
}
flush_lines(map, color, width);
};
@@ -745,18 +829,20 @@ void Renderer::draw_axis_lines(const Plot& plot) {
// An x axis draws along its edge of the axes rect, spanning its own range; the
// perpendicular extent is the primary y axis' range, which is exactly what
// maps to the rect's top and bottom.
// maps to the rect's top and bottom. Staged relative to each range's own
// minimum and folded back in by the mapping (B1).
for (u32 i = 0; i < plot.x_axis_count(); ++i) {
const Axis& axis = plot.x_axis(i);
const f64 y = axis.place == AxisPlace::Top ? y_range.max : y_range.min;
stage_segment(axis.range.min, y, axis.range.max, y, axis.log, log_y0);
flush_lines(mapping(plot, i, 0), style_.axis_line, style_.device(style_.line_width_px));
stage_segment(axis.range.min, axis.range.min, y, axis.range.max, y, axis.log, log_y0);
flush_lines(mapping(plot, i, 0, axis.range.min), style_.axis_line,
style_.device(style_.line_width_px));
}
for (u32 i = 0; i < plot.y_axis_count(); ++i) {
const Axis& axis = plot.y_axis(i);
const f64 x = axis.place == AxisPlace::Right ? x_range.max : x_range.min;
stage_segment(x, axis.range.min, x, axis.range.max, log_x0, axis.log);
flush_lines(mapping(plot, 0, i), style_.axis_line, style_.device(style_.line_width_px));
stage_segment(x, x, axis.range.min, x, axis.range.max, log_x0, axis.log);
flush_lines(mapping(plot, 0, i, x), style_.axis_line, style_.device(style_.line_width_px));
}
}
@@ -777,9 +863,10 @@ void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
for (const Tick& tick : plot.x_axis(i).ticks) {
if (!tick.major || tick.label.empty()) continue;
const f32 width = font_backend_.measure(tick.label.c_str());
stage_text({static_cast<f32>(transform.to_screen_x(tick.value)) - 0.5f * width,
stage_text(glyphs_over_,
{static_cast<f32>(transform.to_screen_x(tick.value)) - 0.5f * width,
layout.x_label_area.y + 0.5f * (layout.x_label_area.h - line_h)},
tick.label.c_str());
tick.label.c_str(), style_.axis_text);
}
}
}
@@ -796,8 +883,9 @@ void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
const f32 width = font_backend_.measure(tick.label.c_str());
const f32 centre = static_cast<f32>(transform.to_screen_y(tick.value));
const f32 top = std::clamp(centre - 0.5f * line_h, layout.y_label_area.y, top_limit);
stage_text({layout.y_label_area.right() - style_.device(style_.grid_label_pad) - width, top},
tick.label.c_str());
stage_text(glyphs_over_,
{layout.y_label_area.right() - style_.device(style_.grid_label_pad) - width, top},
tick.label.c_str(), style_.axis_text);
}
}
}
@@ -841,8 +929,8 @@ void Renderer::draw_titles(const Plot& plot, const PlotLayout& layout) {
const f32 width = clipped_text_width(axis.label.c_str(), budget, kTextRight);
// Centred on the strip: the tick labels have their own rows above it, so the
// title needs no alignment of its own.
stage_text_clipped({strip.x + 0.5f * (strip.w - width), y}, axis.label.c_str(), budget,
kTextRight);
stage_text_clipped(glyphs_over_, {strip.x + 0.5f * (strip.w - width), y},
axis.label.c_str(), budget, kTextRight, style_.axis_text);
++row;
}
@@ -872,8 +960,8 @@ void Renderer::draw_titles(const Plot& plot, const PlotLayout& layout) {
if (x + line_h > strip.right()) break;
const f32 budget = strip.h - 2.0f * pad;
const f32 length = clipped_text_width(axis.label.c_str(), budget, kTextUp);
stage_text_clipped({x, strip.bottom() - 0.5f * (strip.h - length)}, axis.label.c_str(), budget,
kTextUp);
stage_text_clipped(glyphs_over_, {x, strip.bottom() - 0.5f * (strip.h - length)},
axis.label.c_str(), budget, kTextUp, style_.axis_text);
++column;
}
}
@@ -915,7 +1003,7 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
: 0u;
gather_.clear();
series.gather(level, first, last, gather_);
series.gather(level, first, last, gather_, trace.mode);
if (gather_.size() < 4u) continue; // fewer than two points is no segment
stats_.visible_samples += visible;
@@ -925,14 +1013,58 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
++stats_.lod_traces;
}
stage_xy(gather_.data(), static_cast<u32>(gather_.size() / 2u),
plot.x_axis(trace.axis_x).log, plot.y_axis(trace.axis_y).log);
// Gathered x is relative to `series.x0()` (the gather contract); the staging
// reconstructs the absolute data x in f64 and emits `to_space(x_abs) -
// to_space(origin)` -- the visible x range's minimum is the natural origin
// for a trace -- so the f32 is O(visible pixels), not O(epoch) (B1). The
// mapping folds the origin back into the offset uniform, in f64.
const bool log_x = plot.x_axis(trace.axis_x).log;
const bool log_y = plot.y_axis(trace.axis_y).log;
const Mapping map = mapping(plot, trace.axis_x, trace.axis_y, x_range.min);
const u32 points = static_cast<u32>(gather_.size() / 2u);
// A trace's dash rides on the same per-draw uniform the crosshair uses, in
// device pixels; `dash_off <= 0` is the solid default.
const Vec2 dash = trace.dash_off > 0.0f
? Vec2{style_.device(trace.dash_on), style_.device(trace.dash_off)}
: Vec2{};
// A trace's width is a logical size like every other stroke here, so it is
// converted too: left raw, a 1.5 px trace would be drawn 1.5 device px against a
// 3 device px axis line at scale 2 -- half the logical weight, which inverts the
// trace/axis hierarchy on a high-DPI panel.
flush_lines(mapping(plot, trace.axis_x, trace.axis_y), trace.color,
style_.device(trace.width));
// trace/axis hierarchy on a high-DPI panel. `width == 0` is scatter: the
// markers below are the whole trace.
if (trace.width > 0.0f) {
stage_xy(gather_.data(), points, series.x0(), x_range.min, log_x, log_y);
flush_lines(map, trace.color, style_.device(trace.width), dash);
}
// Markers, staged as line segments through this same batch in *pixel* space
// (so their size is device pixels whatever the data scale is). The stride is
// the trace's own `marker_every` or -- when it is 0, the auto mode -- about
// one marker per pixel column: ceil(samples per pixel), so markers only
// appear where the samples are sparse enough to need them.
if (trace.marker != Marker::None) {
const u32 stride =
trace.marker_every != 0
? trace.marker_every
: static_cast<u32>(std::ceil(samples_per_pixel > 1.0 ? samples_per_pixel : 1.0));
const f32 half = style_.device(trace.marker_size_px);
const f64 origin_s = space(x_range.min, log_x);
u32 staged = 0;
for (u32 p = 0; p < points; p += stride) {
const f64 x_abs = static_cast<f64>(gather_[2u * p]) + series.x0();
const f32 rx = static_cast<f32>(space(x_abs, log_x) - origin_s);
const f32 ry = static_cast<f32>(space(gather_[2u * p + 1u], log_y));
const Vec2 centre{rx * map.scale.x + map.offset.x, ry * map.scale.y + map.offset.y};
stage_marker(trace.marker, centre, half);
++staged;
}
if (staged != 0) {
flush_lines(pixel_mapping(), trace.color, style_.device(1.0f));
stats_.staged_markers += staged;
}
}
}
}
@@ -985,16 +1117,19 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
if (horizontal) {
const f64 y_drawn = plot.y_transform(cursor.axis_y).to_data_y(static_cast<f64>(drawn));
stage_segment(x_range.min, y_drawn, x_range.max, y_drawn, log_x,
stage_segment(x_range.min, x_range.min, y_drawn, x_range.max, y_drawn, log_x,
plot.y_axis(cursor.axis_y).log);
flush_lines(mapping(plot, 0, cursor.axis_y, x_range.min),
on ? style_.cursor : cursor.color,
style_.device(on ? style_.handle_width_px : style_.line_width_px));
} else {
const f64 x_drawn = plot.x_transform(cursor.axis_x).to_data_x(static_cast<f64>(drawn));
stage_segment(x_drawn, y_range.min, x_drawn, y_range.max, plot.x_axis(cursor.axis_x).log,
log_y);
stage_segment(x_drawn, x_drawn, y_range.min, x_drawn, y_range.max,
plot.x_axis(cursor.axis_x).log, log_y);
flush_lines(mapping(plot, cursor.axis_x, 0, x_drawn),
on ? style_.cursor : cursor.color,
style_.device(on ? style_.handle_width_px : style_.line_width_px));
}
flush_lines(horizontal ? mapping(plot, 0, cursor.axis_y) : mapping(plot, cursor.axis_x, 0),
on ? style_.cursor : cursor.color,
style_.device(on ? style_.handle_width_px : style_.line_width_px));
draw_cursor_label(plot, layout, cursor, drawn);
}
}
@@ -1012,15 +1147,17 @@ void Renderer::draw_cursor_label(const Plot& plot, const PlotLayout& layout, con
char value[32];
plot.y_axis(cursor.axis_y).format_value(cursor.y, value, sizeof value);
if (cursor.label.empty()) {
stage_text(pen, value);
stage_text(glyphs_under_, pen, value, style_.axis_text);
return;
}
stage_text(pen, cursor.label.c_str());
stage_text({pen.x + font_backend_.measure(cursor.label.c_str()) + gap, pen.y}, value);
stage_text(glyphs_under_, pen, cursor.label.c_str(), style_.axis_text);
stage_text(glyphs_under_, {pen.x + font_backend_.measure(cursor.label.c_str()) + gap, pen.y},
value, style_.axis_text);
return;
}
if (!cursor.label.empty()) {
stage_text({at + gap, layout.axes.y + style_.device(2.0f)}, cursor.label.c_str());
stage_text(glyphs_under_, {at + gap, layout.axes.y + style_.device(2.0f)},
cursor.label.c_str(), style_.axis_text);
}
}
@@ -1051,7 +1188,8 @@ void Renderer::draw_regions(const Plot& plot, const PlotLayout& layout) {
hot_right ? region.right_handle.w : edge_w);
if (!config.label.empty() && region.label.w > 0.0f) {
stage_text({region.label.x, region.label.y}, config.label.c_str());
stage_text(glyphs_under_, {region.label.x, region.label.y}, config.label.c_str(),
style_.axis_text);
}
}
}
@@ -1087,17 +1225,19 @@ void Renderer::draw_measures(const Plot& plot, const PlotLayout& layout) {
// The shaded band: one vertical stroke per pixel column of the interval.
// The line pipeline has no filled primitive, and the band is bounded by the
// plot's width, so a per-column fill stays O(pixels) and needs no second
// pipeline. Strokes are staged in data space like everything else, so the
// band follows a pan or a zoom through the uniform alone.
// pipeline. Strokes are staged in data space relative to the axis' visible
// minimum and folded back by the mapping (B1), so the band follows a pan or a
// zoom through the uniform alone.
const f64 origin = plot.x_range(a.axis_x).min;
for (f32 px = x0; px < x1; px += 1.0f) {
const f64 x = transform.to_data_x(static_cast<f64>(px) + 0.5);
stage_segment(x, y_range.min, x, y_range.max, plot.x_axis(a.axis_x).log, log_y);
stage_segment(origin, x, y_range.min, x, y_range.max, plot.x_axis(a.axis_x).log, log_y);
}
// One device pixel, not a logical one: the band is a *fill* made of one
// stroke per framebuffer column (`px += 1.0f` above), so its width is the
// column pitch itself. Scaling it would overlap the columns it does not
// match and leave gaps in the ones it does.
flush_lines(mapping(plot, a.axis_x, 0), style_.measure_fill, 1.0f);
flush_lines(mapping(plot, a.axis_x, 0, origin), style_.measure_fill, 1.0f);
}
char dx[kNumberChars];
@@ -1118,14 +1258,15 @@ void Renderer::draw_measures(const Plot& plot, const PlotLayout& layout) {
// cursor labels, and a wide readout clamped to the left edge would overprint
// them. The y axis title is not a consideration here -- it lives in its own
// strip outside the axes.
stage_text({left, axes.y + line_h + style_.device(2.0f)}, readout);
stage_text(glyphs_under_, {left, axes.y + line_h + style_.device(2.0f)}, readout,
style_.axis_text);
}
}
void Renderer::draw_annotations(const Plot& plot) {
const f32 line_h = font_backend_.height();
// The clip every other overlay is drawn under, read here because the caption is staged
// into the frame's single text batch -- flushed after `clear_clip()` -- so the scissor that
// into the under-text batch -- flushed after `clear_clip()` -- so the scissor that
// keeps the rest of this pass inside the axes does not reach it.
const Rect& axes = plot.layout().axes;
for (u32 i = 0; i < plot.annotation_count(); ++i) {
@@ -1141,13 +1282,19 @@ void Renderer::draw_annotations(const Plot& plot) {
flush_lines(pixel_mapping(), annotation.color, style_.device(1.0f));
// A caption whose anchor is outside the axes is skipped with its cross, which the clip
// has already removed: a caption with no cross is a label for a point the frame does not
// show, and -- because the text batch is drawn *after* the clip is released -- it would
// otherwise paint outside the axes it is measured in, into the neighbouring row of a
// `Figure` whose rows tile the figure. Splitting the frame's one text draw call to clip
// it instead would cost a draw call per row and mis-clip the tick labels, which are
// staged outside `axes` on purpose and share the batch after this one.
// show, and -- because the under-batch text is drawn *after* the clip is released -- it
// would otherwise paint outside the axes it is measured in, into the neighbouring row of a
// `Figure` whose rows tile the figure. The caption box is also clamped inside `axes`
// (R6), and skipped entirely when it cannot fit the rect at all -- an annotation pinned
// inside a sliver of axes keeps its cross but not a caption that would overhang it.
if (!annotation.text.empty() && axes.contains(px, py)) {
stage_text({px + style_.device(5.0f), py - 0.5f * line_h}, annotation.text.c_str());
const f32 width = font_backend_.measure(annotation.text.c_str());
const f32 pad = style_.device(5.0f);
if (width <= axes.w && line_h <= axes.h) {
const f32 x = std::clamp(px + pad, axes.x, axes.right() - width);
const f32 y = std::clamp(py - 0.5f * line_h, axes.y, axes.bottom() - line_h);
stage_text(glyphs_under_, {x, y}, annotation.text.c_str(), annotation.color);
}
}
}
}
@@ -1166,11 +1313,52 @@ void Renderer::draw_legend(const Plot& plot, const PlotLayout& layout) {
// the marker itself, so both branches read their string from one place -- the
// layout measured it, this stages it.
if (row.ellipsis) {
stage_text({row.name.x, row.name.y}, kLegendEllipsis);
stage_text(glyphs_under_, {row.name.x, row.name.y}, kLegendEllipsis, style_.axis_text);
continue;
}
draw_swatch(plot, row.trace, row.swatch);
stage_text({row.name.x, row.name.y}, plot.trace(row.trace).name.c_str());
stage_text(glyphs_under_, {row.name.x, row.name.y}, plot.trace(row.trace).name.c_str(),
style_.axis_text);
}
}
void Renderer::stage_marker(plot::Marker marker, Vec2 centre_px, f32 size_px) {
// Marker shapes as line segments, in viewport pixels: the trace's flush maps
// them with `pixel_mapping()`, so the size is device pixels whatever the data
// scale is. Cross: the two diagonals. Circle: an 8-gon. Square: the four sides.
switch (marker) {
case plot::Marker::Cross:
lines_.add_segment({centre_px.x - size_px, centre_px.y - size_px},
{centre_px.x + size_px, centre_px.y + size_px});
lines_.add_segment({centre_px.x - size_px, centre_px.y + size_px},
{centre_px.x + size_px, centre_px.y - size_px});
break;
case plot::Marker::Circle: {
// An octagon inscribed in the size box: 8 vertices, 8 segments.
Vec2 prev{};
for (u32 k = 0; k <= 8u; ++k) {
const f32 angle = static_cast<f32>(k) * (3.14159265f / 4.0f);
const Vec2 v{centre_px.x + size_px * std::cos(angle),
centre_px.y + size_px * std::sin(angle)};
if (k != 0) {
lines_.add_segment(prev, v);
}
prev = v;
}
break;
}
case plot::Marker::Square:
lines_.add_segment({centre_px.x - size_px, centre_px.y - size_px},
{centre_px.x + size_px, centre_px.y - size_px});
lines_.add_segment({centre_px.x + size_px, centre_px.y - size_px},
{centre_px.x + size_px, centre_px.y + size_px});
lines_.add_segment({centre_px.x + size_px, centre_px.y + size_px},
{centre_px.x - size_px, centre_px.y + size_px});
lines_.add_segment({centre_px.x - size_px, centre_px.y + size_px},
{centre_px.x - size_px, centre_px.y - size_px});
break;
case plot::Marker::None:
break;
}
}
@@ -1201,8 +1389,19 @@ void Renderer::draw_swatch(const Plot& plot, u32 trace, const Rect& box) {
// Opaque whatever the trace's own alpha is: a swatch drawn at a trace's 15% would be the least
// legible thing in the frame, and the swatch is the one element that says which colour an entry or
// a readout row *is*. One rule, used by the legend and by the crosshair's readout alike.
Color swatch = plot.trace(trace).color;
const Trace& source = plot.trace(trace);
Color swatch = source.color;
swatch.a = 1.0f;
if (source.dash_off > 0.0f && box.w > 0.0f && box.h > 0.0f) {
// A dashed trace's swatch *samples the dash*: a solid fill would describe the
// one thing the trace is not. One dashed stroke across the box's midline, in
// the trace's own pattern (converted to device pixels, as the trace itself is).
const f32 mid = box.y + 0.5f * box.h;
lines_.add_segment({box.x, mid}, {box.right(), mid});
flush_lines(pixel_mapping(), swatch, std::min(box.h, style_.device(source.width)),
Vec2{style_.device(source.dash_on), style_.device(source.dash_off)});
return;
}
fill_pixels(box, swatch);
}
@@ -1221,19 +1420,24 @@ void Renderer::draw_crosshair_lines(const Plot& plot, const PlotLayout& layout)
//
// The dash rides on the same segment: `v_along` is measured from the axes edge the line is
// staged from, so no geometry is added and the phase does not depend on the transform.
const Mapping data = mapping(plot, 0u, 0u);
const Mapping data = mapping(plot, 0u, 0u, plot.x_range(0u).min);
if (crosshair.vertical.w > 0.0f) {
const f64 x = plot.x_transform(0u).to_data_x(
static_cast<f64>(crosshair.vertical.x + 0.5f * crosshair.vertical.w));
const Range y_range = plot.y_range(0u);
stage_segment(x, y_range.min, x, y_range.max, plot.x_axis(0u).log, plot.y_axis(0u).log);
// Both lines share the horizontal's origin (the visible x minimum), so the
// staged values and the one mapping agree (B1): the vertical is staged as
// to_space(x) - to_space(min), not relative to itself.
stage_segment(plot.x_range(0u).min, x, y_range.min, x, y_range.max, plot.x_axis(0u).log,
plot.y_axis(0u).log);
flush_lines(data, style_.crosshair, stroke, crosshair.dash);
}
if (crosshair.horizontal.h > 0.0f) {
const f64 y = plot.y_transform(0u).to_data_y(
static_cast<f64>(crosshair.horizontal.y + 0.5f * crosshair.horizontal.h));
const Range x_range = plot.x_range(0u);
stage_segment(x_range.min, y, x_range.max, y, plot.x_axis(0u).log, plot.y_axis(0u).log);
stage_segment(x_range.min, x_range.min, y, x_range.max, y, plot.x_axis(0u).log,
plot.y_axis(0u).log);
flush_lines(data, style_.crosshair, stroke, crosshair.dash);
}
@@ -1265,10 +1469,14 @@ void Renderer::draw_crosshair_readout(const Plot& plot, const PlotLayout& layout
for (const CrosshairRow& row : crosshair.rows) {
if (!row.listed) continue;
draw_swatch(plot, row.trace, row.swatch);
stage_text({row.label_box.x, row.label_box.y}, row.label.c_str());
stage_text(glyphs_over_, {row.label_box.x, row.label_box.y}, row.label.c_str(),
style_.axis_text);
// The value is a number, or `kCrosshairNoValue` for a trace with no sample at this x (the plot
// put it there): either way there is a run to stage, so a row is never a blank cell.
stage_text({row.value_box.x, row.value_box.y}, row.value.c_str());
// put it there): either way there is a run to stage, so a row is never a blank cell. It is
// drawn in the row trace's own colour, resolved here from the plot -- the one tint the text
// pipeline carries per instance, so labels and values of one draw call can differ.
stage_text(glyphs_over_, {row.value_box.x, row.value_box.y}, row.value.c_str(),
plot.trace(row.trace).color);
}
}
+17 -9
View File
@@ -19,6 +19,7 @@ uniform vec2 u_scale; // data -> pixels
uniform vec2 u_offset; // pixels
uniform vec2 u_viewport; // render target size, pixels
uniform float u_width; // stroke width, pixels
uniform float u_aa; // AA fringe half-width, pixels
out float v_dist; // signed distance to the segment axis, pixels
// Distance travelled from a_p0, pixels -- and the dash's phase anchor. Both of the
@@ -33,7 +34,7 @@ void main() {
vec2 p1 = a_p1 * u_scale + u_offset;
vec2 d = p1 - p0;
float len = length(d);
float half_w = u_width * 0.5 + 1.0; // + one pixel of AA fringe
float half_w = u_width * 0.5 + u_aa; // + the AA fringe
// A degenerate segment has no direction: pin the normal, and give the quad
// area by stretching its two ends apart along x. Collapsing all four corners
// onto p0 would rasterise nothing, and a flat trace staged as p == p (the
@@ -64,6 +65,7 @@ in float v_dist; // signed distance to the segment axis, pixels
in float v_along; // distance travelled from a_p0, pixels
uniform float u_width; // stroke width, pixels
uniform float u_aa; // AA fringe half-width, pixels
uniform vec4 u_color;
uniform vec2 u_dash; // dash pattern in pixels: x = ink, y = gap; y <= 0 is solid
@@ -85,12 +87,15 @@ void main() {
}
float half_w = u_width * 0.5;
float d = abs(v_dist);
if (d > half_w + 1.0) {
if (d > half_w + u_aa) {
discard; // outside the fringe; nothing to blend
}
// Ramp over the last pixel of the half width so a thin stroke keeps its
// weight instead of aliasing into a dashed line.
float alpha = 1.0 - smoothstep(half_w - 0.5, half_w + 0.5, d);
// Ramp over the fringe so a thin stroke keeps its weight instead of aliasing
// into a dashed line: the ramp is centred on `half_w` and spans `u_aa`, so
// `u_aa == 1.0` -- the style default -- reproduces the historical single-pixel
// ramp exactly. `u_aa` is the style's `line_aa_px` in device pixels, clamped
// at 0.25 by the renderer.
float alpha = 1.0 - smoothstep(half_w - 0.5 * u_aa, half_w + 0.5 * u_aa, d);
o_color = vec4(u_color.rgb, u_color.a * alpha);
}
)";
@@ -99,12 +104,14 @@ const char* const kTextVS = R"(#version 330 core
layout(location = 0) in vec2 a_pos; // per-instance glyph cell top-left, pixels
layout(location = 1) in vec2 a_uv; // per-instance glyph rect top-left, normalised
layout(location = 2) in vec2 a_axis; // per-instance cell +x axis, unit, pixels
layout(location = 3) in vec4 a_colour; // per-instance tint, straight alpha
uniform vec2 u_viewport; // render target size, pixels
uniform vec2 u_px_range; // glyph cell on screen, pixels (width, height)
uniform vec2 u_glyph_uv; // glyph cell in the atlas, normalised (width, height)
out vec2 v_uv; // normalised atlas coordinate of this fragment
out vec2 v_uv; // normalised atlas coordinate of this fragment
out vec4 v_colour; // the instance's tint
void main() {
// The cell's two axes: the run direction the instance carries and its
@@ -123,15 +130,16 @@ void main() {
// The same handedness as the cell, so the glyph's rect lands on it the right
// way round: u runs along the basis, v down the glyph.
v_uv = a_uv + corner * u_glyph_uv;
v_colour = a_colour;
}
)";
const char* const kTextFS = R"(#version 330 core
in vec2 v_uv; // normalised atlas coordinate of this fragment
in vec2 v_uv; // normalised atlas coordinate of this fragment
in vec4 v_colour; // the instance's tint
uniform sampler2D u_atlas; // GL_R8 glyph atlas, GL_NEAREST
uniform vec2 u_tex_size; // atlas size in texels
uniform vec4 u_color;
out vec4 o_color;
@@ -141,7 +149,7 @@ void main() {
// glyph cell; rounding to a centre removes the tie by construction.
vec2 texel = clamp(floor(v_uv * u_tex_size), vec2(0.0), u_tex_size - vec2(1.0));
float coverage = texture(u_atlas, (texel + vec2(0.5)) / u_tex_size).r;
o_color = vec4(u_color.rgb, u_color.a * coverage);
o_color = vec4(v_colour.rgb, v_colour.a * coverage);
}
)";
+7 -3
View File
@@ -72,15 +72,18 @@ bool TextVertexArray::create(Status* st, UploadMode mode) noexcept {
}
vao_.bind();
buffer_.bind();
// Three attributes now: the cell's corner, its atlas rect and the basis that
// rotates it (see TextInstance). All of them advance once per instance, like
// the line pipeline's, because the corners come from gl_VertexID.
// Four attributes now: the cell's corner, its atlas rect, the basis that
// rotates it and the run's tint (see TextInstance). All of them advance once
// per instance, like the line pipeline's, because the corners come from
// gl_VertexID.
vao_.attrib(0u, 2, sizeof(TextInstance), offsetof(TextInstance, x));
vao_.attrib(1u, 2, sizeof(TextInstance), offsetof(TextInstance, u));
vao_.attrib(2u, 2, sizeof(TextInstance), offsetof(TextInstance, ax));
vao_.attrib(3u, 4, sizeof(TextInstance), offsetof(TextInstance, r));
gl::glVertexAttribDivisor(0u, kPerInstance);
gl::glVertexAttribDivisor(1u, kPerInstance);
gl::glVertexAttribDivisor(2u, kPerInstance);
gl::glVertexAttribDivisor(3u, kPerInstance);
gl::glBindVertexArray(0);
epoch_ = buffer_.epoch();
return true;
@@ -99,6 +102,7 @@ void TextVertexArray::upload(const f32* interleaved, u32 glyphs) noexcept {
vao_.attrib(0u, 2, sizeof(TextInstance), base + offsetof(TextInstance, x));
vao_.attrib(1u, 2, sizeof(TextInstance), base + offsetof(TextInstance, u));
vao_.attrib(2u, 2, sizeof(TextInstance), base + offsetof(TextInstance, ax));
vao_.attrib(3u, 4, sizeof(TextInstance), base + offsetof(TextInstance, r));
epoch_ = buffer_.epoch();
}
instances_ = glyphs;