feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
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:
1 parent
f132a5ec92
commit
38ef459897
77 files changed
+5905
-688
No files matched your search
+299
-91
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user