Compare commits
11
Commits
e337c6a60c
...
faef19ba60
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faef19ba60 | ||
|
|
7dbe4e7183 | ||
|
|
fba8d985cf | ||
|
|
c4c0a49466 | ||
|
|
01e410bd74 | ||
|
|
f3fb500068 | ||
|
|
2dcd1f1864 | ||
|
|
211c5b97bc | ||
|
|
125cc1f64a | ||
|
|
523446d08f | ||
|
|
133cd680ce |
No files matched your search
@@ -14,6 +14,11 @@ built on top of it.
|
||||
| Qt6 | `plot::qt6` | `adapters/qt6/` |
|
||||
| GTK4 | `plot::gtk4` | `adapters/gtk4/` |
|
||||
|
||||
The visible x range is mapped onto sample indices through the series' own x
|
||||
(`Series::neighbours()`, a binary search over the real stamps), never through the
|
||||
`(x - x0()) / dx()` grid: `dx()` is a *nominal* spacing, and a series whose x is a streamed
|
||||
timestamp would otherwise have its draw window land inside the visible range and lose the two
|
||||
segments that enter and leave the frame — the empty left and right margins of a zoomed-in view.
|
||||
Per-frame rendering cost is O(visible pixels + samples produced since the last frame),
|
||||
not O(samples): a trace denser than one sample per pixel is drawn from a min/max LOD
|
||||
pyramid rather than from raw samples. The `plot_multiplot_demo` run stages **20,901 GPU
|
||||
@@ -24,8 +29,16 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
|
||||
|
||||
## Features
|
||||
|
||||
- a following group's window stays on its members' data: while the members retain at least a
|
||||
window's worth, the scroll offset is bounded by `retained - span`, so a pan or a zoom-in near an
|
||||
edge cannot leave the window's own span over data the scope never had (a window *wider* than the
|
||||
data keeps the older placement -- right edge on the newest sample);
|
||||
- sample **markers** when a window is sparse enough that its samples are the shape
|
||||
(`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour);
|
||||
- grid with independent major/minor line classes (`Style::grid_major_visible`,
|
||||
`grid_minor_visible`);
|
||||
`grid_minor_visible`), each with its own colour, width and (for the minors) dash — the
|
||||
subdivisions default to thinner, dashed and alpha-blended, so they read as texture under the
|
||||
labelled grid rather than as a second set of ticks;
|
||||
- x and y axes, with a vertical (bottom-to-top) y-axis title;
|
||||
- cursors in both orientations — vertical markers and horizontal rulers;
|
||||
- draggable regions and measurement bands with exact interval statistics;
|
||||
|
||||
+30
-3
@@ -36,8 +36,8 @@ Colours (neither logical nor device — nothing to scale):
|
||||
| field | default | meaning |
|
||||
|---|---|---|
|
||||
| `background` | `0x101418` | frame clear colour and legend/readout panel base |
|
||||
| `grid_major` | `0x2A3038` | major (labelled-tick) grid lines |
|
||||
| `grid_minor` | `0x1C2126` | minor (subdivision) grid lines |
|
||||
| `grid_major` | `0x39414C` | major (labelled-tick) grid lines |
|
||||
| `grid_minor` | `0x1C2126` at 50% alpha | minor (subdivision) grid lines |
|
||||
| `axis_line` | `0x8A94A6` | axis lines |
|
||||
| `axis_text` | `0xC8D0DC` | tick labels and axis titles |
|
||||
| `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight |
|
||||
@@ -56,8 +56,12 @@ Logical metrics (scaled by `device()`):
|
||||
|---|---|---|
|
||||
| `axis_font_px` | `12.0` | logical em the axis text is drawn at |
|
||||
| `grid_label_pad` | `4.0` | gap between a tick label and the axes, and the inset inside a title strip |
|
||||
| `marker_below_samples` | `50` | a dot per raw sample when the window holds at most this many (`0` = never) |
|
||||
| `marker_px` | `3.0` | logical size of that dot |
|
||||
| `grid_major_width` | `1.0` | width of a major grid line |
|
||||
| `grid_minor_width` | `1.0` | width of a minor grid line |
|
||||
| `grid_minor_width` | `0.75` | width of a minor grid line (sub-pixel on purpose) |
|
||||
| `grid_minor_dash_on` | `3.0` | minor grid dash: ink length in logical pixels |
|
||||
| `grid_minor_dash_off` | `3.0` | minor grid dash: gap; `0` draws the minor grid solid |
|
||||
| `grid_major_visible` | `true` | stage the major grid at all |
|
||||
| `grid_minor_visible` | `true` | stage the minor grid at all |
|
||||
| `line_width_px` | `1.5` | width of the strokes framing the data (axis lines, cursor markers) |
|
||||
@@ -91,6 +95,29 @@ transparent. `Grid` therefore carries both classes' geometry whatever the style
|
||||
caller that draws the grid itself applies the same two switches. `Grid::vertical` and
|
||||
`Grid::horizontal` (both `true`) control which directions emit lines at all.
|
||||
|
||||
The labelled class is the **structure** and the subdivisions are **texture**, and the minor
|
||||
grid's three defaults are what makes that read: it is thinner than the major hairline
|
||||
(`grid_minor_width`, a sub-pixel width that at 1x is expressed as coverage and on a HiDPI
|
||||
display as a narrower line), its colour carries an **alpha** so it blends into the
|
||||
background instead of sitting on it, and it is **dashed** (`grid_minor_dash_on`/`_off`) so an
|
||||
unlabelled line at an unlabelled position is never mistaken for a labelled one. Together
|
||||
those put a minor line at an order of magnitude less ink than a major one — at full opacity and the
|
||||
same width a dense minor grid reads as a second set of ticks. The dash is a fragment-shader
|
||||
discard, not extra geometry: the vertices are the line either way. Set
|
||||
`grid_minor_dash_off = 0` for the old solid subdivision.
|
||||
|
||||
## A cursor's stroke
|
||||
|
||||
`set_cursor_color()` gives a cursor its own colour and `set_cursor_stroke()` its own width and
|
||||
dash (`Cursor::width`, `Cursor::dash`). Per cursor rather than per plot, because a host uses
|
||||
cursors for two different jobs: the instruments that frame a reading — a marker at an edge, a pull
|
||||
handle — and the *readout marks* a computation places on the data (a measured level, a peak, a
|
||||
pulse's edges). Those want different weights. `width <= 0` means the style's default for that
|
||||
cursor (`handle_width_px` while it is hovered or grabbed, `line_width_px` otherwise), and a hover
|
||||
can only make the stroke *thicker*: the cue that the pointer has hold of something must not be the
|
||||
thing that hides it. `dash.off <= 0` (the default) is solid — the same convention as a trace's,
|
||||
the crosshair's and the grid's.
|
||||
|
||||
## The crosshair's dash
|
||||
|
||||
`crosshair_dash_on`/`crosshair_dash_off` are the fragment shader's discard pattern, so no
|
||||
|
||||
@@ -52,6 +52,23 @@ struct Axis {
|
||||
AxisPlace place = AxisPlace::Bottom;
|
||||
bool log = false;
|
||||
TickFormat format = TickFormat::Si;
|
||||
/// The value the labels are written *relative to*: every value on this axis is formatted as
|
||||
/// `v - origin`, and the ticks are generated on that shifted grid. A host uses it to read a
|
||||
/// zoomed-in window against its own left edge -- the labels then say `0, 0.5ms, 1ms` with the
|
||||
/// window's start printed once beside the axis, instead of `12.345671s, 12.345672s` (which
|
||||
/// three significant digits cannot even distinguish). 0 by default, which is the plain
|
||||
/// absolute axis; the *positions* of the ticks are unchanged by it in the sense that only
|
||||
/// their printed value shifts.
|
||||
f64 origin = 0.0;
|
||||
|
||||
/// When set, `origin` is re-taken from the axis' own range on every `refresh()` -- the labels
|
||||
/// then count from whatever the left edge currently is, and the tick on that edge reads zero
|
||||
/// exactly, without a host having to predict where the follow will place the window (which it
|
||||
/// cannot: the window moves with the samples, and a value written a frame earlier is a frame
|
||||
/// of scroll stale). The window-start label a host draws beside the axis is then
|
||||
/// `origin` *after* the frame, which is the value these labels were built with.
|
||||
bool origin_follows_range = false;
|
||||
|
||||
/// Whether this axis follows the newest sample. Only meaningful on an x axis --
|
||||
/// a y axis has no live edge -- and `Off` on both, so nothing changes for a
|
||||
/// caller who never asks for it. See XFollow.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include <plot/core/color.hpp>
|
||||
#include <plot/core/types.hpp>
|
||||
#include <plot/math/vec2.hpp>
|
||||
|
||||
namespace plot {
|
||||
|
||||
@@ -86,6 +87,25 @@ struct Cursor {
|
||||
u32 axis_x = 0; ///< the x axis `x` is in. Dead for `Horizontal`.
|
||||
u32 axis_y = 0; ///< the y axis `y` is in. Dead for `Vertical`.
|
||||
bool visible = true;
|
||||
/// Whether the pointer may hit this cursor at all: `false` for a *readout* -- a marker a
|
||||
/// host keeps to show something it computed, which the user must not be able to drag. The
|
||||
/// controller's `cursor_at()` skips it, so it is neither hovered nor grabbed, and the
|
||||
/// hit radius it would have taken is left to whatever is behind it. Drawn exactly as a
|
||||
/// visible one is: this is about the gesture, not the ink.
|
||||
bool selectable = true;
|
||||
/// The stroke's logical width; `<= 0` means the style's default for this cursor
|
||||
/// (`Style::handle_width_px` while it is hovered or grabbed, `Style::line_width_px`
|
||||
/// otherwise). Per cursor, like the colour, because a host uses cursors for two different
|
||||
/// jobs: the instruments that frame a reading (a marker at an edge, a pull handle) and the
|
||||
/// *readout marks* a computation places on the data (a measured level, a peak). Those want
|
||||
/// different weights, and a hover can only make the stroke thicker, never thinner -- the cue
|
||||
/// that the pointer has hold of something must not be the thing that hides it.
|
||||
f32 width = 0.0f;
|
||||
/// The dash, logical pixels: ink length and gap. `off <= 0` (the default) is a solid stroke --
|
||||
/// the same convention as a trace's, the crosshair's and the grid's. A dashed readout mark is
|
||||
/// distinguishable by *form* from the solid chrome around it, which is what an instrument that
|
||||
/// reports a number should be.
|
||||
Vec2 dash{};
|
||||
|
||||
/// True when this is a horizontal ruler (spans the axes' width, reads its `y`). The one
|
||||
/// place the orientation becomes a branch, so every consumer takes the same decision and
|
||||
|
||||
@@ -229,6 +229,13 @@ class Plot {
|
||||
/// Measures the interval between two cursors over the series of `trace`.
|
||||
u32 add_measure(u32 cursor_a, u32 cursor_b, u32 trace = 0);
|
||||
u32 add_annotation(const Annotation& annotation);
|
||||
/// Moves an annotation that is already in the plot, keeping its id -- what a host whose
|
||||
/// annotation is a *readout* needs (the point where a cursor crosses a trace moves with
|
||||
/// the cursor). `Plot` has no `remove_annotation`, so the same parking discipline as the
|
||||
/// cursors applies: set `visible = false` rather than dropping it, and re-place where it
|
||||
/// belongs. An id that names no annotation is ignored, not an error, exactly as every
|
||||
/// other setter here treats a stale id.
|
||||
void set_annotation(u32 id, const Annotation& annotation);
|
||||
/// Adds a region. Its bounds are ordered and, when the region asks to snap, pulled
|
||||
/// onto the sample grid as it is added -- exactly as `add_cursor()` places a
|
||||
/// snapping cursor -- so a region handed to a plot that already holds samples is on
|
||||
@@ -472,6 +479,34 @@ class Plot {
|
||||
///
|
||||
/// Ignored for a vertical cursor, whose snap is `SnapMode` -- see `set_cursor_snap()`.
|
||||
void set_cursor_h_snap(u32 id, HRulerSnap mode);
|
||||
/// Recolours a cursor that already exists. A *readout* cursor -- one a host keeps as a
|
||||
/// marker for something it computed, rather than an instrument the user placed -- has to
|
||||
/// be told apart from its neighbours, and its colour is how it says which readout it
|
||||
/// belongs to. An id that names no cursor is ignored, like every other setter here.
|
||||
void set_cursor_color(u32 id, Color color);
|
||||
|
||||
/// A cursor's stroke: its logical width (`<= 0` for the style's default) and its dash
|
||||
/// (`dash.off <= 0` for solid). The instrument's own, like its colour -- see `Cursor::width`.
|
||||
void set_cursor_stroke(u32 id, f32 width, Vec2 dash);
|
||||
/// Makes a cursor unselectable (see `Cursor::selectable`): the pointer cannot hover or
|
||||
/// grab it, which is what a host wants for a marker it draws as a *readout*. An id that
|
||||
/// names no cursor is ignored, like every other setter here.
|
||||
void set_cursor_selectable(u32 id, bool selectable);
|
||||
/// Sets the value an axis' labels are written relative to (see `Axis::origin`). The ticks
|
||||
/// are generated on that shifted grid too, so a host that zooms into a window can label it
|
||||
/// from its own left edge. An id that names no axis of that orientation is ignored, like the
|
||||
/// other setters here -- x and y ids are separate sequences, so the orientation is part of
|
||||
/// the call.
|
||||
void set_x_axis_origin(u32 axis, f64 origin);
|
||||
void set_y_axis_origin(u32 axis, f64 origin);
|
||||
/// The origin the matching setter stored (0 for an axis that never had one).
|
||||
f64 x_axis_origin(u32 axis) const;
|
||||
f64 y_axis_origin(u32 axis) const;
|
||||
/// Makes an axis' label origin follow its own range (see `Axis::origin_follows_range`): the
|
||||
/// labels then count from the left edge the frame actually has, which is the only way a
|
||||
/// host can label a *moving* window from its start without being a frame behind.
|
||||
void set_x_axis_origin_from_range(u32 axis, bool on);
|
||||
void set_y_axis_origin_from_range(u32 axis, bool on);
|
||||
|
||||
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the
|
||||
/// measure was added, or when the series underneath it changed: a different
|
||||
|
||||
@@ -48,7 +48,10 @@ void format_si(f64 v, char* buf, usize n, const char* unit = nullptr);
|
||||
/// field is derived from that one number, so no field can ever reach its radix:
|
||||
/// 119.5 s is `2:00`, not `1:60`, and 3599.5 s is `1:00:00`.
|
||||
///
|
||||
/// Shapes, by magnitude: below 1 s `500ms`; below 60 s `1.5s`; below 1 h `1:30`;
|
||||
/// Shapes, by magnitude: below 1 s the prefixed unit that keeps the mantissa in
|
||||
/// `[1, 1000)` -- `500ms`, `5ms`, `500us`, `5us`, `5ns`, `5ps` (the micro sign is
|
||||
/// UTF-8 U+00B5, as in `format_si`) -- with an exact zero printed as `0` rather
|
||||
/// than as a zero of some unit; below 60 s `1.5s`; below 1 h `1:30`;
|
||||
/// at or above 24 h, as a decimal day count `2.5d`. Between 1 h and 24 h the
|
||||
/// shape is `1:01:01` -- and because a value that rounds up to 24 h takes the day
|
||||
/// shape, the hour field of that shape is always below 24. The 60 s and 1 h
|
||||
@@ -87,6 +90,6 @@ inline constexpr usize kMaxTicks = 256;
|
||||
/// single-sample series), a non-positive `px` or `min_px`, and a span whose step
|
||||
/// overflows to infinity.
|
||||
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out,
|
||||
bool include_minors = false);
|
||||
bool include_minors = false, f64 origin = 0.0);
|
||||
|
||||
} // namespace plot
|
||||
@@ -38,8 +38,19 @@ struct Style {
|
||||
// --- colours: neither logical nor device, so nothing to scale ------------
|
||||
|
||||
Color background = Color::hex(0x101418);
|
||||
Color grid_major = Color::hex(0x2A3038);
|
||||
Color grid_minor = Color::hex(0x1C2126);
|
||||
/// The labelled grid. Brighter than the subdivisions and still well under `axis_line`
|
||||
/// (0x8A94A6): the grid is structure the eye can read, the axis is the frame, and the
|
||||
/// hierarchy between the three is what keeps a dense plot legible. It is already fully
|
||||
/// opaque, so "more prominent" means a brighter colour and nothing else.
|
||||
Color grid_major = Color::hex(0x39414C);
|
||||
/// The minor grid's colour, **with an alpha** so it blends into the background rather than
|
||||
/// sitting on it: a subdivision is a reading aid, and at full opacity a dense minor grid
|
||||
/// reads as a second set of ticks competing with the labelled one. The line shader applies
|
||||
/// `u_color.a` (see `src/render/shaders.cpp`), so this one blends and the major -- opaque at
|
||||
/// 0x39414C -- stays the structure the eye picks out. Together with `grid_minor_width` and
|
||||
/// the dash below, the defaults put a minor line at an order of magnitude less ink than a
|
||||
/// major one.
|
||||
Color grid_minor = Color::hex(0x1C2126, 0.5f);
|
||||
Color axis_line = Color::hex(0x8A94A6);
|
||||
Color axis_text = Color::hex(0xC8D0DC);
|
||||
Color cursor = Color::hex(0xFFB020);
|
||||
@@ -96,10 +107,31 @@ struct Style {
|
||||
/// skips it before the first segment is staged, so a dense plot's minor grid
|
||||
/// costs nothing rather than being drawn transparent (spec §11). The colours
|
||||
/// above are already per class.
|
||||
/// When a trace's window holds at most this many raw samples, each one is also drawn as a dot
|
||||
/// `marker_px` logical pixels across, in the trace's own colour. `0` draws none. Zoomed in far
|
||||
/// enough to see individual samples -- a handful of them across a whole panel -- the polyline
|
||||
/// *between* them stops being the shape, and the samples themselves are what a reader is looking
|
||||
/// at; a marker makes each one a place rather than a bend in a line.
|
||||
f32 marker_below_samples = 50.0f;
|
||||
f32 marker_px = 3.0f;
|
||||
f32 grid_major_width = 1.0f; ///< logical width of a major (labelled-tick) line.
|
||||
f32 grid_minor_width = 1.0f; ///< logical width of a minor (subdivision) line.
|
||||
/// Logical width of a minor (subdivision) line: **thinner than the major hairline**, and
|
||||
/// sub-pixel on purpose. At 1x that is expressed as *coverage* -- the anti-aliased ramp
|
||||
/// (`line_aa_px`) lights one column at roughly three quarters of the ink -- and on a HiDPI
|
||||
/// display as a line that is literally narrower. The point is hierarchy: the labelled grid is
|
||||
/// structure, the subdivisions are texture.
|
||||
f32 grid_minor_width = 0.75f;
|
||||
bool grid_major_visible = true; ///< stage the major grid at all.
|
||||
bool grid_minor_visible = true; ///< stage the minor grid at all.
|
||||
/// The minor grid's dash, in logical pixels: ink length and gap. **`off == 0` draws it
|
||||
/// solid** -- the same convention as the crosshair's and a trace's dash -- which is what a
|
||||
/// caller that wants the old solid subdivision states. Dashed is the default because an
|
||||
/// unlabelled line at an unlabelled position should never be mistaken for a labelled one, and
|
||||
/// because a broken line carries less weight over data than a continuous one. The dash is a
|
||||
/// fragment discard, never extra geometry, so a dense minor grid costs no more vertices for
|
||||
/// being dashed.
|
||||
f32 grid_minor_dash_on = 3.0f;
|
||||
f32 grid_minor_dash_off = 3.0f;
|
||||
f32 line_width_px = 1.5f; ///< logical width of the strokes framing the data
|
||||
///< (axis lines, cursor markers).
|
||||
/// Logical half-width of the anti-aliased fringe the line shader blends at
|
||||
|
||||
@@ -925,7 +925,9 @@ u64 Controller::cursor_at(f32 px, f32 py) const {
|
||||
f32 best_d = 0.0f;
|
||||
for (u32 i = 0; i < plot_->cursor_count(); ++i) {
|
||||
const Cursor& cursor = plot_->cursor(i);
|
||||
if (!cursor.visible) continue;
|
||||
// A readout is drawn but not offered to the pointer: skipping it here is what makes it
|
||||
// undraggable, rather than fighting the drag a frame at a time.
|
||||
if (!cursor.visible || !cursor.selectable) continue;
|
||||
// One hit test for both orientations, because a cursor *is* one line across the axes:
|
||||
// what the pointer has to be near is that line's own coordinate -- the column of a
|
||||
// vertical marker, the row of a horizontal ruler -- measured with the same radius
|
||||
|
||||
+17
-3
@@ -3,18 +3,32 @@
|
||||
|
||||
#include <plot/plot/axis.hpp>
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace plot {
|
||||
|
||||
void Axis::format_value(f64 v, char* buf, usize n) const {
|
||||
// The one place a value on this axis becomes text, so the origin is applied here and
|
||||
// everything that goes through it -- tick labels, and the crosshair's readout -- reads in
|
||||
// the same frame of reference as the axis itself.
|
||||
// The subtraction leaves a residue when the value and the origin are the same distant number
|
||||
// (a tick exactly on the origin then formatted as `1.78e-12ms` instead of `0ms`). Snapping it
|
||||
// is a *relative* test, so a genuinely small value on a large axis is not swallowed -- what
|
||||
// this removes is arithmetic dust, not data.
|
||||
f64 shown = v - origin;
|
||||
if (std::fabs(shown) < 1.0e-12 * std::max(1.0, std::fabs(v))) shown = 0.0;
|
||||
if (format == TickFormat::Time) {
|
||||
format_time(v, buf, n);
|
||||
format_time(shown, buf, n);
|
||||
return;
|
||||
}
|
||||
format_si(v, buf, n);
|
||||
format_si(shown, buf, n);
|
||||
}
|
||||
|
||||
void Axis::refresh(f32 px, f32 min_px, bool include_minors) {
|
||||
generate_ticks(range, px, min_px, ticks, include_minors);
|
||||
// The range first, so a following origin is this frame's edge and not the last one's: the
|
||||
// zero tick then sits exactly on the window start, which is the whole point of asking.
|
||||
if (origin_follows_range) origin = range.min;
|
||||
generate_ticks(range, px, min_px, ticks, include_minors, origin);
|
||||
if (format != TickFormat::Time) return;
|
||||
// generate_ticks labels with format_si; a time axis relabels its majors here,
|
||||
// because the tick generator has no business knowing about a clock -- through the one
|
||||
|
||||
+14
-3
@@ -4,6 +4,7 @@
|
||||
#include <plot/plot/figure.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
|
||||
@@ -277,13 +278,23 @@ bool Figure::place_live_window(XLink& group) {
|
||||
// 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.
|
||||
// than everything it retains.
|
||||
//
|
||||
// The bound is about the whole *window*, not just its right edge -- but only when the window
|
||||
// *can* sit on the data. `retained >= span` is that case, and there the offset may not exceed
|
||||
// `retained - span`: with the loose bound a scroll could put the window's right edge exactly on
|
||||
// the oldest sample, which leaves the entire span to its left over data the scope never had --
|
||||
// an empty margin as wide as the view, reached by a pan or by a zoom-in near that edge. When the
|
||||
// window is *wider* than what the members retain (a ring still filling, or one just trimmed)
|
||||
// nothing can avoid a margin, and the honest placement is the old one: the window's right edge
|
||||
// on the newest sample, the shortfall on the left, which is what a scope that just started
|
||||
// should look like.
|
||||
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);
|
||||
const f64 reach = retained >= group.span ? retained - group.span : retained;
|
||||
group.offset = std::clamp(group.offset, 0.0, std::max(0.0, reach));
|
||||
group.live_newest = live.max;
|
||||
group.has_live = true;
|
||||
const f64 right = newest_space - group.offset;
|
||||
|
||||
@@ -306,6 +306,55 @@ u32 Plot::add_annotation(const Annotation& annotation) {
|
||||
return static_cast<u32>(annotations_.size() - 1);
|
||||
}
|
||||
|
||||
void Plot::set_cursor_color(u32 id, Color color) {
|
||||
if (id >= cursors_.size()) return;
|
||||
cursors_[id].color = color;
|
||||
}
|
||||
|
||||
void Plot::set_cursor_stroke(u32 id, f32 width, Vec2 dash) {
|
||||
if (id >= cursors_.size()) return;
|
||||
cursors_[id].width = width;
|
||||
cursors_[id].dash = dash;
|
||||
}
|
||||
|
||||
void Plot::set_cursor_selectable(u32 id, bool selectable) {
|
||||
if (id >= cursors_.size()) return;
|
||||
cursors_[id].selectable = selectable;
|
||||
}
|
||||
|
||||
void Plot::set_x_axis_origin(u32 axis, f64 origin) {
|
||||
if (axis >= x_axes_.size()) return;
|
||||
x_axes_[axis].origin = origin;
|
||||
}
|
||||
|
||||
void Plot::set_y_axis_origin(u32 axis, f64 origin) {
|
||||
if (axis >= y_axes_.size()) return;
|
||||
y_axes_[axis].origin = origin;
|
||||
}
|
||||
|
||||
void Plot::set_x_axis_origin_from_range(u32 axis, bool on) {
|
||||
if (axis >= x_axes_.size()) return;
|
||||
x_axes_[axis].origin_follows_range = on;
|
||||
}
|
||||
|
||||
void Plot::set_y_axis_origin_from_range(u32 axis, bool on) {
|
||||
if (axis >= y_axes_.size()) return;
|
||||
y_axes_[axis].origin_follows_range = on;
|
||||
}
|
||||
|
||||
f64 Plot::x_axis_origin(u32 axis) const {
|
||||
return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0;
|
||||
}
|
||||
|
||||
f64 Plot::y_axis_origin(u32 axis) const {
|
||||
return axis < y_axes_.size() ? y_axes_[axis].origin : 0.0;
|
||||
}
|
||||
|
||||
void Plot::set_annotation(u32 id, const Annotation& annotation) {
|
||||
if (id >= annotations_.size()) return;
|
||||
annotations_[id] = annotation;
|
||||
}
|
||||
|
||||
u32 Plot::add_region(const Region& region) {
|
||||
// A region whose bounds are not numbers is not an interval this plot can hold, and a
|
||||
// `Free` one stored that way could never be dragged back (every motion re-derives both
|
||||
|
||||
+33
-9
@@ -99,14 +99,34 @@ void format_time(f64 seconds, char* buf, usize n) {
|
||||
const f64 t = std::fabs(seconds);
|
||||
|
||||
if (t < 1.0) {
|
||||
// Sub-second: milliseconds. The 999.5 cut keeps %.3g out of scientific
|
||||
// notation (it would print "1e+03ms") right below the one-second boundary.
|
||||
const f64 ms = t * 1000.0;
|
||||
if (ms < 999.5) {
|
||||
std::snprintf(buf, n, "%s%.3gms", sign, ms);
|
||||
} else {
|
||||
std::snprintf(buf, n, "%s%.3gs", sign, t);
|
||||
// Sub-second: the prefix that keeps the mantissa in [1, 1000) -- the same rule the SI
|
||||
// formatter uses on a y axis, and the reading a zoomed-in window needs. A fixed
|
||||
// millisecond unit printed `0.005ms` where `5us` is what the axis is showing, and
|
||||
// `1e-05ms` where `10ns` is. The micro sign is the UTF-8 sequence the embedded font has a
|
||||
// glyph for (see `format_si`), not a Latin-1 byte.
|
||||
// Exactly zero carries no unit, the same convention `format_si` states: every prefixed
|
||||
// form of it is "0" with a unit the value does not have.
|
||||
if (t == 0.0) {
|
||||
std::snprintf(buf, n, "0");
|
||||
return;
|
||||
}
|
||||
static const struct {
|
||||
f64 scale;
|
||||
const char* unit;
|
||||
} kUnits[] = {
|
||||
{1.0e-3, "ms"},
|
||||
{1.0e-6, "\u00b5s"},
|
||||
{1.0e-9, "ns"},
|
||||
{1.0e-12, "ps"},
|
||||
};
|
||||
for (const auto& u : kUnits) {
|
||||
if (t < u.scale) continue;
|
||||
std::snprintf(buf, n, "%s%.3g%s", sign, t / u.scale, u.unit);
|
||||
return;
|
||||
}
|
||||
// Below a picosecond nothing on a time axis reads in prefixed units; plain seconds keep
|
||||
// the fallback's own precision rather than inventing one.
|
||||
std::snprintf(buf, n, "%s%.3gs", sign, t);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -131,7 +151,8 @@ void format_time(f64 seconds, char* buf, usize n) {
|
||||
}
|
||||
}
|
||||
|
||||
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, bool include_minors) {
|
||||
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, bool include_minors,
|
||||
f64 origin) {
|
||||
out.clear();
|
||||
|
||||
const f64 lo = range.min;
|
||||
@@ -157,7 +178,10 @@ void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, boo
|
||||
: 10.0);
|
||||
if (!std::isfinite(step) || !(step > 0.0)) return; // span so large the step overflowed
|
||||
|
||||
const f64 first_major = std::ceil(lo / step) * step;
|
||||
// The major walk starts on the *origin's* grid: with an origin the ticks a host reads are
|
||||
// round numbers relative to it (0, 0.5ms, 1ms of the window it zoomed into), which is the
|
||||
// whole point of having one. `origin = 0` is the plain absolute grid.
|
||||
const f64 first_major = origin + std::ceil((lo - origin) / step) * step;
|
||||
if (!std::isfinite(first_major)) return;
|
||||
|
||||
// Minor ticks subdivide the major step: by 4 when the step's leading digit is
|
||||
|
||||
+63
-26
@@ -42,15 +42,6 @@ bool fail(Status* st, ErrorCode code, const char* message) noexcept {
|
||||
/// 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
|
||||
/// range the axis cannot map still yields a valid window.
|
||||
u64 clamp_index(f64 v, u64 n) noexcept {
|
||||
if (!(v > 0.0)) return 0;
|
||||
if (v >= static_cast<f64>(n)) return n;
|
||||
return static_cast<u64>(v);
|
||||
}
|
||||
|
||||
/// One ASCII/UTF-8 line of the measurement readout. `format_si` leaves room for
|
||||
/// three significant digits plus a prefix and sign.
|
||||
constexpr usize kNumberChars = 24;
|
||||
@@ -807,7 +798,7 @@ void Renderer::draw_grid(const Plot& plot) {
|
||||
// to draw rather than being drawn transparent. Majors first, minors over them, as
|
||||
// the single group order always did. `LineBatch::clear()` keeps its capacity, so
|
||||
// the second pass re-uses the first one's storage.
|
||||
const auto draw_class = [&](bool major, bool visible, Color color, f32 width) {
|
||||
const auto draw_class = [&](bool major, bool visible, Color color, f32 width, Vec2 dash) {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
@@ -817,12 +808,21 @@ void Renderer::draw_grid(const Plot& plot) {
|
||||
}
|
||||
// Snapped: a grid line is chrome at a round *data* value, and a hairline that
|
||||
// straddles two columns reads as a grey smear and shimmers as the view pans.
|
||||
flush_lines(map, color, width, Vec2{}, true);
|
||||
flush_lines(map, color, width, dash, true);
|
||||
};
|
||||
draw_class(true, style_.grid_major_visible, style_.grid_major,
|
||||
style_.device(style_.grid_major_width));
|
||||
style_.device(style_.grid_major_width), Vec2{});
|
||||
// The minor grid's dash rides the same per-draw uniform the crosshair and a dashed trace use,
|
||||
// and `off == 0` is the solid default. Each line starts its own pattern (the dash distance is
|
||||
// per-segment), and every segment here is built from the range's minimum, so the dashes of the
|
||||
// vertical lines line up with each other -- the grid reads as one texture rather than as a
|
||||
// jumble of unrelated dashes.
|
||||
const Vec2 minor_dash = style_.grid_minor_dash_off > 0.0f
|
||||
? Vec2{style_.device(style_.grid_minor_dash_on),
|
||||
style_.device(style_.grid_minor_dash_off)}
|
||||
: Vec2{};
|
||||
draw_class(false, style_.grid_minor_visible, style_.grid_minor,
|
||||
style_.device(style_.grid_minor_width));
|
||||
style_.device(style_.grid_minor_width), minor_dash);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- axis chrome
|
||||
@@ -994,15 +994,26 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
||||
const u64 n = series.size();
|
||||
if (!(dx > 0.0) || n == 0) continue;
|
||||
|
||||
// Map the visible x range onto sample indices. One sample of slack on each
|
||||
// side covers a partially visible end bucket; the range is clamped to the
|
||||
// series, so a zoomed-out view never asks for more than exists.
|
||||
// The visible x range onto sample indices, through the series' *own* x.
|
||||
//
|
||||
// Not the `(x - x0()) / dx()` grid: `dx()` is documented as a nominal spacing (the LOD
|
||||
// level, a coarse snap) and for a series whose x is a streamed timestamp it is only the
|
||||
// mean of the retained window. Walking that grid lands the window inside the visible range
|
||||
// -- and the first and last *segments*, the ones that enter and leave the frame, are then
|
||||
// not drawn at all, which is the empty margin at both edges of a zoomed-in view. A series
|
||||
// with a uniform x answers the same question through the same call, so this is the one
|
||||
// path. One sample of slack on each side keeps a partially visible end bucket whole, and
|
||||
// the result is clamped to the series: a zoomed-out view never asks for more than exists,
|
||||
// and a window that reaches past the data leaves the margin genuinely -- correctly -- empty.
|
||||
const Range x_range = plot.x_range(trace.axis_x);
|
||||
f64 lo = (x_range.min - series.x0()) / dx;
|
||||
f64 hi = (x_range.max - series.x0()) / dx;
|
||||
if (hi < lo) std::swap(lo, hi);
|
||||
const u64 first = clamp_index(std::floor(lo) - 1.0, n);
|
||||
const u64 last = clamp_index(std::ceil(hi) + 1.0, n);
|
||||
// `dx` is still read above for the samples-per-pixel estimate -- a density, where a nominal
|
||||
// spacing is exactly right -- and never for the window itself.
|
||||
u64 first = 0;
|
||||
u64 last = n;
|
||||
u64 i0 = 0;
|
||||
u64 i1 = 0;
|
||||
if (series.neighbours(x_range.min, i0, i1)) first = i0 > 0 ? i0 - 1 : 0;
|
||||
if (series.neighbours(x_range.max, i0, i1)) last = std::min(n, i1 + 1);
|
||||
if (last <= first) continue;
|
||||
|
||||
// This is the whole performance contract: the window, never the series.
|
||||
@@ -1017,6 +1028,29 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
||||
if (gather_.size() < 4u) continue; // fewer than two points is no segment
|
||||
|
||||
stats_.visible_samples += visible;
|
||||
// Markers for a window sparse enough that its samples are the shape: one dot per raw sample,
|
||||
// in the trace's own colour. Level 0 only -- above it the geometry is *buckets*, which are not
|
||||
// points a reader can find. Drawn after the polyline they sit on, so a dot is never half
|
||||
// covered by its own line, and inside the axes only (a marker is a place, and a place outside
|
||||
// the frame is not one).
|
||||
if (level == 0u && style_.marker_below_samples > 0.0f && visible > 0u &&
|
||||
visible <= static_cast<u64>(style_.marker_below_samples)) {
|
||||
const auto& xt = plot.x_transform(trace.axis_x);
|
||||
const auto& yt = plot.y_transform(trace.axis_y);
|
||||
const f32 half = 0.5f * style_.device(style_.marker_px);
|
||||
for (u32 k = 0; k + 1 < gather_.size(); k += 2) {
|
||||
const f64 x_abs = series.x0() + static_cast<f64>(gather_[k]);
|
||||
const f64 y_abs = static_cast<f64>(gather_[k + 1]);
|
||||
if (!std::isfinite(x_abs) || !std::isfinite(y_abs)) continue;
|
||||
const f32 sx = xt.to_screen_x(x_abs);
|
||||
const f32 sy = yt.to_screen_y(y_abs);
|
||||
if (!(sx >= layout.axes.x && sx <= layout.axes.right() && sy >= layout.axes.y &&
|
||||
sy <= layout.axes.bottom())) {
|
||||
continue;
|
||||
}
|
||||
fill_pixels(Rect{sx - half, sy - half, 2.0f * half, 2.0f * half}, trace.color);
|
||||
}
|
||||
}
|
||||
if (level == 0u) {
|
||||
++stats_.raw_traces;
|
||||
} else {
|
||||
@@ -1106,7 +1140,12 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
|
||||
// belongs to the plot, and `to_screen_y()` of a range maximum can undershoot `axes.y` by
|
||||
// a floating-point ulp, which a bare `<` would mistake for "outside". Only the ones
|
||||
// beyond that margin are skipped.
|
||||
const f32 half = 0.5f * style_.device(on ? style_.handle_width_px : style_.line_width_px);
|
||||
// The cursor's own width when it has one, else the style's; a hover or a grab can only make
|
||||
// it thicker (the cue that the pointer has hold of something).
|
||||
const f32 own = cursor.width > 0.0f ? style_.device(cursor.width)
|
||||
: style_.device(style_.line_width_px);
|
||||
const f32 width = on ? std::max(own, style_.device(style_.handle_width_px)) : own;
|
||||
const f32 half = 0.5f * width;
|
||||
if (horizontal ? (at < layout.axes.y - half || at > layout.axes.bottom() + half)
|
||||
: (at < layout.axes.x - half || at > layout.axes.right() + half)) {
|
||||
continue;
|
||||
@@ -1130,15 +1169,13 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
|
||||
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));
|
||||
on ? style_.cursor : cursor.color, width, cursor.dash);
|
||||
} else {
|
||||
const f64 x_drawn = plot.x_transform(cursor.axis_x).to_data_x(static_cast<f64>(drawn));
|
||||
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));
|
||||
on ? style_.cursor : cursor.color, width, cursor.dash);
|
||||
}
|
||||
draw_cursor_label(plot, layout, cursor, drawn);
|
||||
}
|
||||
|
||||
@@ -1788,6 +1788,47 @@ PLOT_TEST(a_middle_drag_on_a_figures_bottom_row_zooms_the_shared_x_axis) {
|
||||
// pointer) rather than on the live edge, and the window walked back off the newest sample
|
||||
// by a fifth of its width. These two cases pin the fix from the two ends: the gesture keeps
|
||||
// the edge, and the hint that makes it possible exists exactly while a group follows.
|
||||
/// A following group's window stays **on the data**: the offset bound is `retained - span`, not
|
||||
/// `retained`. With the loose bound, scrolling back put the window's *right* edge on the oldest
|
||||
/// retained sample, which leaves the window's whole span to its left over data the scope never
|
||||
/// had -- an empty margin as wide as the view, reached by a pan or by a zoom-in near that edge.
|
||||
/// That is the "sometimes zooming in, the left or right margin becomes empty" report, and it is
|
||||
/// why the bound has to be about the window and not about one edge of it.
|
||||
PLOT_TEST(a_following_group_keeps_its_whole_window_on_the_data) {
|
||||
plot::data::StreamSeries series(1u << 12, 0.0, 1.0 / 1000.0);
|
||||
plot::f32 samples[512];
|
||||
for (u32 i = 0; i < 512u; ++i) samples[i] = static_cast<plot::f32>(i % 7u);
|
||||
series.push(samples, 512u);
|
||||
series.acquire();
|
||||
|
||||
plot::Figure figure(1u);
|
||||
figure.set_viewport({0.0f, 0.0f, 800.0f, 600.0f});
|
||||
figure.plot(0u).add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
|
||||
figure.plot(0u).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||
figure.plot(0u).add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u, "s"});
|
||||
const u32 group = figure.add_x_link();
|
||||
PLOT_CHECK(figure.link_x(0u, group));
|
||||
PLOT_CHECK(figure.set_x_link_range(group, {0.0, 0.25}));
|
||||
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live));
|
||||
figure.layout();
|
||||
|
||||
const plot::Range data = series.x_bounds();
|
||||
const f64 span = figure.x_link_range(group).span();
|
||||
PLOT_CHECK(span > 0.0);
|
||||
// Ask to scroll far past the oldest sample, the way a drag or a zoom-in near the left edge does.
|
||||
PLOT_CHECK(figure.set_x_link_range(group, {data.min - 10.0 * span, data.min - 9.0 * span}));
|
||||
figure.layout();
|
||||
|
||||
const plot::Range after = figure.x_link_range(group);
|
||||
std::printf("[follow-bound] data=[%.4f..%.4f] span=%.4f after=[%.4f..%.4f]\n", data.min,
|
||||
data.max, span, after.min, after.max);
|
||||
// The window is still a window, and its *left* edge is on the data: that is the assertion the
|
||||
// loose bound failed, where `after.min` came out a whole span below `data.min`.
|
||||
PLOT_CHECK_NEAR(after.span(), span, 1e-9 * span);
|
||||
PLOT_CHECK(after.min >= data.min - 1e-9);
|
||||
PLOT_CHECK(after.max <= data.max + 1e-9);
|
||||
}
|
||||
|
||||
PLOT_TEST(a_wheel_on_a_following_figures_axis_strip_keeps_the_live_edge) {
|
||||
constexpr u32 kFigureRows = 3u;
|
||||
constexpr f64 kWheelDx = 1.0 / 1000.0;
|
||||
@@ -2309,3 +2350,35 @@ PLOT_TEST(double_click_needs_both_timestamps_and_the_window) {
|
||||
f.controller.on_pointer({410.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
|
||||
true, 20.1});
|
||||
}
|
||||
|
||||
// A readout cursor is drawn but not offered to the pointer: the host that keeps it is showing
|
||||
// a computed value, and a drag on it would be a drag on a number rather than on an instrument.
|
||||
// The hit test is where that is enforced -- re-placing the marker every frame would only fight
|
||||
// the drag, which is the shape this replaces.
|
||||
PLOT_TEST(an_unselectable_cursor_is_neither_hovered_nor_grabbed) {
|
||||
plot::Plot p;
|
||||
p.set_viewport({0, 0, 800, 400});
|
||||
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||
const u32 readout = p.add_cursor(50.0, plot::Color::hex(0x00FF00));
|
||||
p.set_cursor_selectable(readout, false);
|
||||
PLOT_CHECK(p.cursor(readout).visible); // drawn all the same
|
||||
PLOT_CHECK(!p.cursor(readout).selectable);
|
||||
// A normal cursor right beside it stays grabbable, so the flag is a property of one cursor
|
||||
// and not a switch on the plot.
|
||||
const u32 normal = p.add_cursor(50.0, plot::Color::hex(0xFF0000));
|
||||
|
||||
plot::input::Controller c(p);
|
||||
c.set_viewport({0, 0, 800, 400});
|
||||
const f32 sx = screen_x_of(p, ax, 50.0);
|
||||
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, 0, false, false, false});
|
||||
PLOT_CHECK(c.hovered_cursor() == normal); // the readout is not in the running
|
||||
|
||||
// Press and move: the pointer grabbed the *normal* cursor (the two are at the same x, so
|
||||
// the readout would otherwise be the nearer hit), and the readout's own x never moves.
|
||||
const f64 before = p.cursor(readout).x;
|
||||
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, 0, true, true, false});
|
||||
PLOT_CHECK(c.grabbed_cursor() == normal);
|
||||
c.on_pointer({sx + 40, 200, 40, 0, plot::input::Button::Left, 0, true, false, false});
|
||||
PLOT_CHECK_NEAR(p.cursor(readout).x, before, 1e-12);
|
||||
PLOT_CHECK(c.grabbed_cursor() == normal);
|
||||
}
|
||||
+12
-3
@@ -844,20 +844,29 @@ PLOT_TEST(a_following_log_group_holds_decades) {
|
||||
|
||||
// A member panned while the group follows: the adopted scroll state is the
|
||||
// adopted range's span *in decades* -- two here (edge/1000 to edge/10 is a
|
||||
// factor of 100) -- and the offset one decade behind the edge, both of which
|
||||
// survive the next frames' advances.
|
||||
// factor of 100) -- and the offset as far behind the edge as the retained data
|
||||
// allows, both of which survive the next frames' advances.
|
||||
//
|
||||
// The pan *asks* for a full decade back, and the group may not take it: the offset is bounded by
|
||||
// `retained - span`, so the window stops with its left edge on the oldest sample. Asserting
|
||||
// `max == edge/10` here was asserting the *old* bound, which put a whole span of the window over
|
||||
// data the members never held -- the empty-left-margin defect this bound exists for. The
|
||||
// log-space assertions above are this test's subject and are unchanged.
|
||||
const plot::f64 edge = left.x_bounds().max;
|
||||
figure.plot(1u).set_x_range(0u, {edge / 1000.0, edge / 10.0});
|
||||
figure.layout();
|
||||
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 2.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, edge / 10.0, 1e-8);
|
||||
const plot::f64 oldest = std::min(left.x_bounds().min, right.x_bounds().min);
|
||||
PLOT_CHECK(figure.plot(0u).x_range(0u).min >= oldest - 1e-6);
|
||||
PLOT_CHECK(figure.plot(0u).x_range(0u).max <= edge + 1e-6);
|
||||
for (int step = 0; step < 3; ++step) {
|
||||
left.push(0.5f);
|
||||
left.acquire();
|
||||
figure.layout();
|
||||
PLOT_CHECK_NEAR(decades_of(figure.plot(0u)), 2.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(decades_of(figure.plot(1u)), 2.0, 1e-9);
|
||||
PLOT_CHECK(figure.plot(0u).x_range(0u).min >= oldest - 1e-6);
|
||||
}
|
||||
PLOT_CHECK(figure.x_link_follow(group) == plot::XFollow::Live);
|
||||
}
|
||||
|
||||
@@ -2584,3 +2584,53 @@ PLOT_TEST(xy_series_measurement_uses_the_true_nearest_samples) {
|
||||
PLOT_CHECK_NEAR(stats.min, 10.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(stats.max, 10.0, 1e-9);
|
||||
}
|
||||
|
||||
// A host whose annotation is a readout -- the point where a cursor crosses a trace --
|
||||
// moves it every frame. The id has to survive that, and a stale id must be ignored
|
||||
// rather than read or written out of bounds, exactly as the cursors' setters behave.
|
||||
// A readout cursor says which readout it belongs to with its colour; the setter keeps the
|
||||
// id, and a stale one changes nothing.
|
||||
PLOT_TEST(a_cursor_can_be_recoloured_by_id) {
|
||||
plot::Plot p;
|
||||
const u32 id = p.add_cursor(2.0, plot::Color::hex(0x111111));
|
||||
PLOT_CHECK(p.cursor(id).color.b == plot::Color::hex(0x111111).b);
|
||||
|
||||
p.set_cursor_color(id, plot::Color::hex(0x9BE8A0));
|
||||
PLOT_CHECK(p.cursor(id).color.g == plot::Color::hex(0x9BE8A0).g);
|
||||
PLOT_CHECK(p.cursor_count() == 1u); // recoloured, not added
|
||||
PLOT_CHECK_NEAR(p.cursor(id).x, 2.0, 1e-12);
|
||||
|
||||
p.set_cursor_color(42u, plot::Color::hex(0xFFFFFF));
|
||||
PLOT_CHECK(p.cursor(id).color.g == plot::Color::hex(0x9BE8A0).g);
|
||||
}
|
||||
|
||||
PLOT_TEST(an_annotation_can_be_moved_by_id) {
|
||||
plot::Plot p;
|
||||
const plot::Annotation pin{1.0, 2.0, "first", plot::Color::hex(0xE8E8E8), 0u, 0u, true};
|
||||
const u32 id = p.add_annotation(pin);
|
||||
PLOT_CHECK(p.annotation(id).x == 1.0);
|
||||
PLOT_CHECK(p.annotation(id).text == std::string("first"));
|
||||
|
||||
plot::Annotation moved = pin;
|
||||
moved.x = 3.5;
|
||||
moved.y = -1.25;
|
||||
moved.text = "moved";
|
||||
p.set_annotation(id, moved);
|
||||
PLOT_CHECK_NEAR(p.annotation(id).x, 3.5, 1e-12);
|
||||
PLOT_CHECK_NEAR(p.annotation(id).y, -1.25, 1e-12);
|
||||
PLOT_CHECK(p.annotation(id).text == std::string("moved"));
|
||||
PLOT_CHECK(p.annotation_count() == 1u); // moved, not added
|
||||
|
||||
// Parked rather than removed, like a cursor: the id stays valid and reads back hidden.
|
||||
plot::Annotation parked = moved;
|
||||
parked.visible = false;
|
||||
p.set_annotation(id, parked);
|
||||
PLOT_CHECK(!p.annotation(id).visible);
|
||||
PLOT_CHECK(p.annotation_count() == 1u);
|
||||
|
||||
// A stale id changes nothing (and reads a default, as the accessors promise).
|
||||
plot::Annotation other = pin;
|
||||
other.x = 99.0;
|
||||
p.set_annotation(42u, other);
|
||||
PLOT_CHECK_NEAR(p.annotation(id).x, 3.5, 1e-12);
|
||||
}
|
||||
+548
-7
@@ -695,6 +695,125 @@ PLOT_TEST(renders_visible_trace_across_viewport) {
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
/// A series whose `dx()` understates its spacing.
|
||||
///
|
||||
/// That is every ring-backed series: x is a streamed timestamp, so `dx()` is only a nominal
|
||||
/// grid (an LOD level, a coarse snap) while `x_at()`/`neighbours()` answer from the real time
|
||||
/// ring. The samples here sit ten apart and `dx()` claims one, with the plotted window
|
||||
/// `[12, 18]` falling strictly *between* two of them -- no sample is inside the window at all,
|
||||
/// so the only way to light its margins is the segment that crosses them.
|
||||
class LyingGridSeries final : public plot::data::Series {
|
||||
public:
|
||||
LyingGridSeries() {
|
||||
pyramid_.reserve(kN);
|
||||
for (u64 i = 0; i < kN; ++i) pyramid_.append(kY);
|
||||
}
|
||||
|
||||
u64 size() const override { return kN; }
|
||||
f64 x0() const override { return 0.0; }
|
||||
f64 dx() const override { return 1.0; } // the lie: the real spacing is x_at()'s ten
|
||||
plot::Range x_bounds() const override { return {0.0, x_at(kN - 1u)}; }
|
||||
plot::Range y_bounds() const override { return {kY, kY}; }
|
||||
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
||||
f64 x_at(u64 i) const override { return 10.0 * static_cast<f64>(i); }
|
||||
bool neighbours(f64 x, u64& i0, u64& i1) const override {
|
||||
if (x < 0.0 || x > x_at(kN - 1u)) return false;
|
||||
i0 = static_cast<u64>(x / 10.0);
|
||||
i1 = i0;
|
||||
if (x_at(i0) != x && i0 + 1u < kN) i1 = i0 + 1u;
|
||||
return true;
|
||||
}
|
||||
void gather(u32, u64 begin, u64 end, std::vector<f32>& out,
|
||||
plot::data::GatherMode) const override {
|
||||
++gather_calls_;
|
||||
for (u64 i = begin; i < end; ++i) {
|
||||
out.push_back(static_cast<f32>(x_at(i) - x0()));
|
||||
out.push_back(kY);
|
||||
}
|
||||
}
|
||||
|
||||
u64 gather_calls() const noexcept { return gather_calls_; }
|
||||
|
||||
protected:
|
||||
void copy_raw(u64 begin, u64 end, f32* out) const override {
|
||||
for (u64 i = begin; i < end; ++i) out[i - begin] = kY;
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr u64 kN = 5u;
|
||||
static constexpr f32 kY = 1.0f;
|
||||
plot::data::MinMaxPyramid pyramid_;
|
||||
mutable u64 gather_calls_ = 0;
|
||||
};
|
||||
|
||||
/// The window falls between two samples: the trace must still be drawn to both margins.
|
||||
///
|
||||
/// Found by zooming in: the renderer mapped the visible x range onto sample indices with
|
||||
/// `(x - x0()) / dx()`, and on a series whose `dx()` is nominal that lands *inside* the visible
|
||||
/// range -- here it lands past the end of a five-sample series entirely, so `last <= first` and
|
||||
/// the frame held no trace at all. The segment entering the window from the sample before it and
|
||||
/// leaving to the one after are what draw the margins, and a series answers where they are
|
||||
/// through `neighbours()`, over its own x.
|
||||
PLOT_TEST(a_sparse_window_between_samples_still_inks_both_margins) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
|
||||
LyingGridSeries s;
|
||||
plot::Plot p;
|
||||
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
// `auto_scale` off on both: the window has to be the one the test names, or the axis takes
|
||||
// the series' own bounds and the window falls outside the frame -- a test of nothing.
|
||||
const plot::u32 ax = p.add_x_axis({"t", {12.0, 18.0}, false, plot::AxisPlace::Bottom, false});
|
||||
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 3.0}, false, plot::AxisPlace::Left, false});
|
||||
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
||||
|
||||
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
dump_if_requested(r, "margins.ppm");
|
||||
|
||||
const plot::Rect a = p.layout().axes;
|
||||
// The window's own evidence: the samples that bracket it are sampled even though neither is
|
||||
// inside it, and the reverted grid walk staged *none* (`(12 - 0) / 1` is past the end of a
|
||||
// five-sample series, so `last <= first` and the frame held no trace at all).
|
||||
const plot::Range drawn_x = p.x_range(ax);
|
||||
const plot::u32 covered = covered_columns(ctx, a, 24);
|
||||
std::printf("[margins] window=[%.3f..%.3f] axes=[%.1f %.1f %.1f %.1f] visible=%llu "
|
||||
"raw=%llu lod=%llu segments=%llu gathers=%llu trace_visible=%d covered=%u/24\n",
|
||||
drawn_x.min, drawn_x.max, a.x, a.y, a.w, a.h,
|
||||
static_cast<unsigned long long>(r.stats().visible_samples),
|
||||
static_cast<unsigned long long>(r.stats().raw_traces),
|
||||
static_cast<unsigned long long>(r.stats().lod_traces),
|
||||
static_cast<unsigned long long>(r.stats().staged_segments),
|
||||
static_cast<unsigned long long>(s.gather_calls()), int(p.trace(0).visible()),
|
||||
covered);
|
||||
PLOT_CHECK(r.stats().visible_samples >= 2u);
|
||||
PLOT_CHECK(covered == 24u);
|
||||
PLOT_CHECK(covered_columns(ctx, a, 24) == 24); // every column, margins included
|
||||
|
||||
// The margins by name, on the line's own row: the reported symptom was these two being blank
|
||||
// while the middle drew. `y = 1` in the window `[-1, 3]` is the middle row of the band.
|
||||
const plot::u32 row = static_cast<plot::u32>(a.y + a.h / 2.0f);
|
||||
for (const plot::u32 x : {static_cast<plot::u32>(a.x) + 1u,
|
||||
static_cast<plot::u32>(a.x + a.w) - 2u}) {
|
||||
bool inked = false;
|
||||
for (plot::u32 dy = 0; dy < 4u && !inked; ++dy) {
|
||||
plot::u8 px[4] = {};
|
||||
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (row + dy), px));
|
||||
inked = trace_pixel(px);
|
||||
}
|
||||
PLOT_CHECK(inked);
|
||||
}
|
||||
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
PLOT_TEST(dense_and_sparse_paths_agree_on_extent) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
@@ -1031,6 +1150,326 @@ PLOT_TEST(draws_cursors_measure_band_and_annotations) {
|
||||
// No trace and no minor grid, so the only thing drawn inside the rect is the major
|
||||
// grid and the one colour it is drawn in; the rect is inset so the axis lines on
|
||||
// its own edges, which are a different colour, stay out of the scan.
|
||||
/// The minor grid is a *subdivision*, and the style says so three ways: thinner, dashed and
|
||||
/// blended into the background. Found by reading a frame whose minor grid read as a second set
|
||||
/// of ticks -- at full opacity and the same width as the labelled grid there was no hierarchy
|
||||
/// to see.
|
||||
///
|
||||
/// The frame holds no trace, so inside `axes` the only elements are the two grid classes and
|
||||
/// the background. A row off the horizontal lines then classifies every column by inspection:
|
||||
/// background, an opaque major line (`pixel_is`, the same exactness `grid_hairlines_land_on_whole_pixels`
|
||||
/// leans on), or the minor's blend -- which at these defaults is the only non-background pixel
|
||||
/// in the frame that is neither. Width is checked as *coverage* rather than as a pixel count,
|
||||
/// because at 1x a sub-pixel logical width is exactly a dimmer stroke: the ink is one column
|
||||
/// either way, so `grid_minor_width` can only show up in how brightly that column is lit. On a
|
||||
/// HiDPI display the same width is literally narrower.
|
||||
PLOT_TEST(the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
PLOT_CHECK(r.style().grid_minor_visible);
|
||||
PLOT_CHECK(r.style().grid_minor_dash_off > 0.0f); // the default is a dash, not a solid line
|
||||
|
||||
plot::Plot p;
|
||||
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
p.add_x_axis({"seconds", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
|
||||
p.add_y_axis({"volts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||
|
||||
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
|
||||
const plot::Rect& a = p.layout().axes;
|
||||
const plot::Color bg = r.style().background;
|
||||
const plot::Color major = r.style().grid_major;
|
||||
// Distance from the background, per channel, so "dimmer" means "closer to the background"
|
||||
// whatever the two colours are.
|
||||
const auto bg_distance = [&](const plot::u8 px[4]) {
|
||||
const auto d = [](plot::u8 v, plot::f32 want) {
|
||||
return std::abs(static_cast<int>(v) - static_cast<int>(want * 255.0f + 0.5f));
|
||||
};
|
||||
return d(px[0], bg.r) + d(px[1], bg.g) + d(px[2], bg.b);
|
||||
};
|
||||
|
||||
// The row: the first one that holds all three kinds of pixel, so it is neither a horizontal
|
||||
// grid line (everything major ink) nor a band the layout reserved for something else.
|
||||
const plot::u32 first_row = static_cast<plot::u32>(a.y) + 3u;
|
||||
const plot::u32 last_row = static_cast<plot::u32>(a.y + a.h) - 4u;
|
||||
std::vector<plot::u32> major_cols;
|
||||
std::vector<plot::u32> minor_cols;
|
||||
plot::u32 scan_row = 0;
|
||||
for (plot::u32 row = first_row; row < last_row; ++row) {
|
||||
major_cols.clear();
|
||||
minor_cols.clear();
|
||||
plot::u32 background = 0;
|
||||
for (plot::u32 cx = static_cast<plot::u32>(a.x) + 3u;
|
||||
cx + 3u < static_cast<plot::u32>(a.x + a.w); ++cx) {
|
||||
plot::u8 px[4] = {};
|
||||
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - row, px));
|
||||
if (pixel_is(px, bg)) {
|
||||
++background;
|
||||
} else if (pixel_is(px, major)) {
|
||||
major_cols.push_back(cx);
|
||||
} else {
|
||||
minor_cols.push_back(cx);
|
||||
}
|
||||
}
|
||||
if (background > 0u && !major_cols.empty() && !minor_cols.empty()) {
|
||||
scan_row = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
PLOT_CHECK(scan_row != 0u); // a row carrying both grid classes and the background
|
||||
PLOT_CHECK(major_cols.size() >= 2u);
|
||||
PLOT_CHECK(minor_cols.size() >= 2u);
|
||||
|
||||
// Solid against dashed, down the two columns: a major line is ink at every row, a minor one
|
||||
// alternates -- which is the whole of "dashed" a pixel test can see. The duty cycle is
|
||||
// checked as a range rather than as the exact 3/3 the style asks for, because the dash is a
|
||||
// fragment discard and its ends land where they land.
|
||||
const auto column_ink = [&](plot::u32 cx, plot::u32& ink, plot::u32& gaps) {
|
||||
ink = 0;
|
||||
gaps = 0;
|
||||
for (plot::u32 row = first_row; row < last_row; ++row) {
|
||||
plot::u8 px[4] = {};
|
||||
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - row, px));
|
||||
if (pixel_is(px, bg)) {
|
||||
++gaps;
|
||||
} else {
|
||||
++ink;
|
||||
}
|
||||
}
|
||||
};
|
||||
plot::u32 ink = 0;
|
||||
plot::u32 gaps = 0;
|
||||
column_ink(major_cols.front(), ink, gaps);
|
||||
PLOT_CHECK(gaps == 0u); // the labelled grid is continuous
|
||||
const plot::u32 major_ink = ink;
|
||||
|
||||
column_ink(minor_cols.front(), ink, gaps);
|
||||
PLOT_CHECK(ink > 0u && gaps > 0u); // dashed, not solid
|
||||
PLOT_CHECK(ink * 5u > major_ink && gaps * 5u > major_ink); // roughly half on, half off
|
||||
|
||||
// Dimmer: the minor's ink sits closer to the background than the major's. This is also the
|
||||
// "thinner" half of the contract at 1x -- see the note above the test.
|
||||
const auto ink_distance = [&](plot::u32 cx) {
|
||||
int best = 0;
|
||||
for (plot::u32 row = first_row; row < last_row; ++row) {
|
||||
plot::u8 px[4] = {};
|
||||
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - row, px));
|
||||
if (pixel_is(px, bg)) continue;
|
||||
best = std::max(best, bg_distance(px));
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const int major_bright = ink_distance(major_cols.front());
|
||||
const int minor_bright = ink_distance(minor_cols.front());
|
||||
PLOT_CHECK(minor_bright > 0);
|
||||
PLOT_CHECK(minor_bright < major_bright);
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
/// A cursor's stroke is its own: a host that places *readout marks* on the data -- a measured
|
||||
/// level, a peak, a pulse's edges -- wants them lighter and broken, so they read as annotations
|
||||
/// over the data rather than as more chrome around it. Two cursors in one frame, one default and
|
||||
/// one given a thin dashed stroke, so the comparison is in the same pixels: the dashed column
|
||||
/// alternates ink and gap down its length while the solid one does not, and its ink sits closer
|
||||
/// to the background, which at 1x is what a sub-pixel logical width *is*.
|
||||
PLOT_TEST(a_cursor_stroke_can_be_thinner_and_dashed) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
|
||||
plot::Plot p;
|
||||
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
p.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
|
||||
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||
// Green: no other element of this frame is green-dominant (the grid is grey-dark and the axis
|
||||
// line is chalk), so the predicate below can only find these two cursors.
|
||||
const plot::Color ink = plot::Color::hex(0x00FF00);
|
||||
p.add_cursor(3.0, ink);
|
||||
const plot::u32 thin = p.add_cursor(7.0, ink);
|
||||
p.set_cursor_snap(thin, plot::SnapMode::Free);
|
||||
p.set_cursor_stroke(thin, 0.75f, plot::Vec2{3.0f, 3.0f});
|
||||
|
||||
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
|
||||
const plot::Rect& a = p.layout().axes;
|
||||
const plot::Color bg = r.style().background;
|
||||
const auto px_at = [&](plot::u32 cx, plot::u32 cy, plot::u8 px[4]) {
|
||||
return ctx.read_pixel(cx, ctx.height - 1u - cy, px);
|
||||
};
|
||||
const auto bg_distance = [&](const plot::u8 px[4]) {
|
||||
const auto d = [](plot::u8 v, plot::f32 want) {
|
||||
return std::abs(static_cast<int>(v) - static_cast<int>(want * 255.0f + 0.5f));
|
||||
};
|
||||
return d(px[0], bg.r) + d(px[1], bg.g) + d(px[2], bg.b);
|
||||
};
|
||||
// The two cursor columns, as the *centre of each run* of green columns: a stroke with an
|
||||
// anti-aliased edge covers two or three columns, so taking the first two hits found one cursor
|
||||
// twice (238 and 239) and never looked at the other -- which is how this test first reported
|
||||
// "neither is dashed" with both columns' ink identical.
|
||||
plot::u32 cols[2] = {0u, 0u};
|
||||
plot::u32 run_from[2] = {0u, 0u};
|
||||
plot::u32 run_to[2] = {0u, 0u};
|
||||
int found = 0;
|
||||
bool in_run = false;
|
||||
plot::u32 run_start = 0;
|
||||
const auto close_run = [&](plot::u32 end_column) {
|
||||
if (found >= 2) return;
|
||||
cols[found] = (run_start + end_column) / 2u;
|
||||
run_from[found] = run_start;
|
||||
run_to[found] = end_column;
|
||||
++found;
|
||||
};
|
||||
for (plot::u32 cx = static_cast<plot::u32>(a.x); cx < static_cast<plot::u32>(a.x + a.w); ++cx) {
|
||||
bool green = false;
|
||||
for (plot::u32 row = static_cast<plot::u32>(a.y) + 4u;
|
||||
row < static_cast<plot::u32>(a.y + a.h) - 4u && !green; ++row) {
|
||||
plot::u8 px[4] = {};
|
||||
PLOT_CHECK(px_at(cx, row, px));
|
||||
green = green_pixel(px);
|
||||
}
|
||||
if (green && !in_run) {
|
||||
in_run = true;
|
||||
run_start = cx;
|
||||
} else if (!green && in_run) {
|
||||
in_run = false;
|
||||
close_run(cx - 1u);
|
||||
}
|
||||
}
|
||||
if (in_run) close_run(static_cast<plot::u32>(a.x + a.w) - 1u);
|
||||
PLOT_CHECK(found == 2);
|
||||
plot::u32 ink_rows[2] = {0u, 0u};
|
||||
plot::u32 gap_rows[2] = {0u, 0u};
|
||||
// The ink each stroke lays down, summed over its whole run of columns: a cursor is a *data*
|
||||
// position and so is deliberately not snapped, so which columns (and how brightly) a stroke
|
||||
// covers depends on where its coordinate falls between pixels. Comparing one column's peak made
|
||||
// the *solid* stroke look lighter than the thin one, because its ink happened to split across
|
||||
// two columns. What is asked here is the thing the style states: less ink per unit length.
|
||||
long long total_ink[2] = {0, 0};
|
||||
for (int c = 0; c < 2; ++c) {
|
||||
for (plot::u32 cx = run_from[c]; cx <= run_to[c]; ++cx) {
|
||||
for (plot::u32 row = static_cast<plot::u32>(a.y) + 4u;
|
||||
row < static_cast<plot::u32>(a.y + a.h) - 4u; ++row) {
|
||||
plot::u8 px[4] = {};
|
||||
PLOT_CHECK(px_at(cx, row, px));
|
||||
if (green_pixel(px)) {
|
||||
if (cx == cols[c]) ++ink_rows[c];
|
||||
total_ink[c] += bg_distance(px);
|
||||
} else if (pixel_is(px, bg) && cx == cols[c]) {
|
||||
++gap_rows[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
std::printf("[cursor-stroke] solid x=%u [%u..%u] ink=%u gaps=%u total=%lld; "
|
||||
"thin x=%u [%u..%u] ink=%u gaps=%u total=%lld\n",
|
||||
cols[0], run_from[0], run_to[0], ink_rows[0], gap_rows[0], total_ink[0], cols[1],
|
||||
run_from[1], run_to[1], ink_rows[1], gap_rows[1], total_ink[1]);
|
||||
PLOT_CHECK(gap_rows[0] == 0u); // the default stroke is continuous
|
||||
PLOT_CHECK(ink_rows[1] > 0u && gap_rows[1] > 0u); // the custom one is dashed
|
||||
PLOT_CHECK(total_ink[1] < total_ink[0]); // and lays down less ink
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
/// A window sparse enough that its samples *are* the shape gets a marker on each one. Ten samples
|
||||
/// across a panel are places a reader counts, and the polyline between them is not the shape any
|
||||
/// more; a hundred are a curve and the markers would be noise, which is what the threshold is for.
|
||||
/// The test reads the vertical run of trace-coloured pixels per column: a marker is `marker_px`
|
||||
/// across, so at a sample's own column the run is taller than the line, and at a midpoint it is
|
||||
/// the line alone. The dense case proves the second half -- same series shape, no column taller
|
||||
/// than the stroke.
|
||||
PLOT_TEST(a_sparse_window_marks_its_samples) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
PLOT_CHECK(r.style().marker_below_samples >= 50.0f); // the default is the spec's 50
|
||||
|
||||
const auto run_at = [&](plot::Plot& p, const plot::Rect& a) {
|
||||
const plot::u32 first_row = static_cast<plot::u32>(a.y);
|
||||
const plot::u32 rows = static_cast<plot::u32>(a.h);
|
||||
std::vector<plot::u32> tall;
|
||||
for (plot::u32 cx = static_cast<plot::u32>(a.x);
|
||||
cx < static_cast<plot::u32>(a.x + a.w); ++cx) {
|
||||
plot::u32 best = 0;
|
||||
plot::u32 run = 0;
|
||||
for (plot::u32 yy = 0; yy < rows; ++yy) {
|
||||
plot::u8 px[4] = {};
|
||||
PLOT_CHECK(ctx.read_pixel(cx, ctx.height - 1u - (first_row + yy), px));
|
||||
if (green_pixel(px)) {
|
||||
++run;
|
||||
best = std::max(best, run);
|
||||
} else {
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
if (best >= 3u) tall.push_back(cx);
|
||||
}
|
||||
(void)p;
|
||||
return tall;
|
||||
};
|
||||
// The dots *group*: a marker is `marker_px` wide, so it spans several adjacent columns. What
|
||||
// identifies one is a run of tall columns, exactly as a cursor's stroke spans two -- the first
|
||||
// version of this test counted columns and read three markers per sample.
|
||||
const auto groups = [](const std::vector<plot::u32>& cols) {
|
||||
std::vector<plot::u32> centres;
|
||||
for (std::size_t i = 0; i < cols.size();) {
|
||||
std::size_t j = i;
|
||||
while (j + 1 < cols.size() && cols[j + 1] == cols[j] + 1u) ++j;
|
||||
centres.push_back((cols[i] + cols[j]) / 2u);
|
||||
i = j + 1;
|
||||
}
|
||||
return centres;
|
||||
};
|
||||
|
||||
// Ten samples, a flat line: every sample is a place, and none of them is a bend.
|
||||
const auto sparse = [&](plot::u32 samples, const char* what) {
|
||||
std::vector<plot::f32> y(samples, 0.25f);
|
||||
const plot::data::ArraySeries series(y, 0.0, 10.0 / static_cast<plot::f64>(samples));
|
||||
plot::Plot p;
|
||||
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
const plot::u32 ax = p.add_x_axis({"t", {0.0, 10.0}, false, plot::AxisPlace::Bottom, false});
|
||||
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||
p.add_trace({&series, plot::Color::hex(0x00FF00), 1.5f, ax, ay});
|
||||
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
const std::vector<plot::u32> tall = run_at(p, p.layout().axes);
|
||||
const std::vector<plot::u32> centres = groups(tall);
|
||||
std::printf("[markers] %s: %zu columns, %zu groups, visible=%llu\n", what, tall.size(),
|
||||
centres.size(), static_cast<unsigned long long>(r.stats().visible_samples));
|
||||
return centres;
|
||||
};
|
||||
|
||||
const std::vector<plot::u32> ten = sparse(10u, "10 samples");
|
||||
PLOT_CHECK(ten.size() == 10u); // one marker per sample
|
||||
// ... and at the samples: ten of them across the panel, evenly spaced. This is the assertion
|
||||
// that says the dots are *the samples* and not something the style happened to paint.
|
||||
const double step = static_cast<double>(ten.back() - ten.front()) / 9.0;
|
||||
for (std::size_t i = 1; i < ten.size(); ++i) {
|
||||
PLOT_CHECK_NEAR(static_cast<double>(ten[i] - ten[i - 1]), step, 2.0);
|
||||
}
|
||||
// A hundred samples: the same shape, past the threshold, and no group is a dot.
|
||||
const std::vector<plot::u32> hundred = sparse(100u, "100 samples");
|
||||
PLOT_CHECK(hundred.size() <= 5u);
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
PLOT_TEST(grid_hairlines_land_on_whole_pixels) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
@@ -3177,6 +3616,20 @@ plot::f32 window_ink(const GridFrame& f, const plot::Rect& window, plot::Color b
|
||||
return ink;
|
||||
}
|
||||
|
||||
/// The ink one minor line is expected to leave in a `kWindowRows`-tall window, **from the
|
||||
/// style**: its width in device pixels, its colour's alpha (the line shader multiplies it in)
|
||||
/// and the dash's duty cycle. Written out rather than as a literal, because the minor class'
|
||||
/// defaults are deliberately a fraction of a solid opaque line -- a threshold saying "most of
|
||||
/// eight rows" stopped describing the class the moment it gained a dash, while it is the style
|
||||
/// that says how much ink a class is *meant* to leave.
|
||||
plot::f32 minor_line_ink(const plot::render::Style& style) {
|
||||
const plot::f32 duty =
|
||||
style.grid_minor_dash_off > 0.0f
|
||||
? style.grid_minor_dash_on / (style.grid_minor_dash_on + style.grid_minor_dash_off)
|
||||
: 1.0f;
|
||||
return kWindowRows * style.device(style.grid_minor_width) * style.grid_minor.a * duty;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
/// Spec §11's per-class switch, on the minor class: a class the style switches off is
|
||||
@@ -3206,10 +3659,12 @@ PLOT_TEST(disabling_the_minor_grid_removes_its_colour) {
|
||||
const GridWindows win = grid_windows(both);
|
||||
const plot::f32 major_before = window_ink(both, win.major, bg, major_ink);
|
||||
const plot::f32 minor_before = window_ink(both, win.minor, bg, minor_ink);
|
||||
// A line, not one stray fringe pixel: an 8-row window over a 1 px stroke holds about
|
||||
// 8 px of ink per class, and this asks for most of it.
|
||||
// A line, not one stray fringe pixel: an 8-row window over a stroke holds most of the ink
|
||||
// that stroke is meant to leave. For the major class that is the window's own height; for the
|
||||
// minor class it is what its width, alpha and dash ask for, so the threshold follows the
|
||||
// style instead of a literal the class is no longer drawn to.
|
||||
PLOT_CHECK(major_before > 0.75f * kWindowRows);
|
||||
PLOT_CHECK(minor_before > 0.75f * kWindowRows);
|
||||
PLOT_CHECK(minor_before > 0.75f * minor_line_ink(r.style()));
|
||||
|
||||
r.style().grid_minor_visible = false;
|
||||
const GridFrame without = draw_grid_scene(ctx, r, 1.0f);
|
||||
@@ -3256,7 +3711,7 @@ PLOT_TEST(disabling_the_major_grid_leaves_the_minor_grid) {
|
||||
const plot::f32 major_before = window_ink(both, win.major, bg, r.style().grid_major);
|
||||
const plot::f32 minor_before = window_ink(both, win.minor, bg, r.style().grid_minor);
|
||||
PLOT_CHECK(major_before > 0.75f * kWindowRows);
|
||||
PLOT_CHECK(minor_before > 0.75f * kWindowRows);
|
||||
PLOT_CHECK(minor_before > 0.75f * minor_line_ink(r.style()));
|
||||
|
||||
r.style().grid_major_visible = false;
|
||||
const GridFrame without = draw_grid_scene(ctx, r, 1.0f);
|
||||
@@ -3345,6 +3800,12 @@ PLOT_TEST(grid_widths_are_per_class_and_scale_with_dpi) {
|
||||
r.style().grid_major_width = 2.0f;
|
||||
r.style().grid_minor_width = 1.0f;
|
||||
|
||||
// Widths are this test's subject, so the minor class' dash and alpha are stated away first:
|
||||
// they would multiply every count and the ratio below, and their own defaults are the subject
|
||||
// of `the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major`.
|
||||
r.style().grid_minor_dash_off = 0.0f;
|
||||
r.style().grid_minor = plot::Color::hex(0x1C2126);
|
||||
|
||||
const GridFrame one = draw_grid_scene(ctx, r, 1.0f);
|
||||
const GridWindows win = grid_windows(one);
|
||||
const plot::f32 major_1x = window_ink(one, win.major, bg, r.style().grid_major);
|
||||
@@ -5217,9 +5678,10 @@ plot::i32 densest_column(const std::vector<plot::u8>& frame, plot::u32 fb_w, plo
|
||||
return best;
|
||||
}
|
||||
|
||||
/// The grid *majors* (0x2A3038 over the 0x101418 background), which no other element of these
|
||||
/// frames reaches: the minors are 0x1C2126 (too dark for the bound), the crosshair is near-white,
|
||||
/// a trace is saturated and the cursor is the orange accent.
|
||||
/// The grid *majors* (0x39414C over the 0x101418 background), which no other element of these
|
||||
/// frames reaches: the minors are that same colour at 50% alpha over a 0.75-covered dashed
|
||||
/// stroke, so their brightest pixel is about 15 levels off the background and stays under this
|
||||
/// bound; the crosshair is near-white, a trace is saturated and the cursor is the orange accent.
|
||||
bool grid_pixel(const plot::u8 px[4]) {
|
||||
const plot::u8 lo = std::min(px[0], std::min(px[1], px[2]));
|
||||
const plot::u8 hi = std::max(px[0], std::max(px[1], px[2]));
|
||||
@@ -5971,3 +6433,82 @@ PLOT_TEST(release_gl_shuts_down_and_the_renderer_can_reinit) {
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
// The other orientation, in a figure: a horizontal ruler is per row (its `y` is on that
|
||||
// row's y axis, so two rows' rulers are two different values), and every row's has to be
|
||||
// drawn in its own band. The vertical case above covers the shared-time half; this is the
|
||||
// half where each row owns its instrument, which is what an app's "two rulers per plot"
|
||||
// needs -- and which the app found undrawn.
|
||||
PLOT_TEST(figure_rows_draw_their_own_horizontal_rulers) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
|
||||
const std::vector<plot::f32> samples = wave(101u, 0.0f);
|
||||
plot::data::ArraySeries series(samples, 0.0, 1.0);
|
||||
|
||||
// A strongly red ruler and a strongly green one, so a pixel that is red- or
|
||||
// green-dominant inside a row's axes can only be that row's ink: the trace is blue and
|
||||
// the chrome grey (all three channels equal).
|
||||
const plot::Color red = plot::Color::hex(0xFF2020);
|
||||
const plot::Color green = plot::Color::hex(0x20FF20);
|
||||
const plot::f32 y_scales[3] = {671.0e6f, 90.0f, 2.51f};
|
||||
|
||||
// The harness's no-atlas backend: a ruler always writes its value as text, and this case
|
||||
// is about the *stroke*, so the text is asked for and staged as nothing. It goes on the
|
||||
// *renderer*, not on the plots: `draw(Figure&)` replaces each row's backend with the
|
||||
// renderer's own on the first frame, so a plot-level backend would be swapped out under a
|
||||
// batch that had already declared that backend's (absent) cell.
|
||||
OnePxPerStringBackend no_text;
|
||||
r.set_text_backend(&no_text);
|
||||
|
||||
plot::Figure figure(3u);
|
||||
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
for (plot::u32 row = 0; row < 3u; ++row) {
|
||||
plot::Plot& p = figure.plot(row);
|
||||
const plot::u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||
const plot::u32 ay =
|
||||
p.add_y_axis({"v", {0.0, y_scales[row]}, false, plot::AxisPlace::Left, false});
|
||||
p.add_trace({&series, kRowBlue, 1.5f, ax, ay});
|
||||
// A quarter and three quarters up this row's own range: inside its axes whatever the
|
||||
// scale is, which is the point of a ruler being per row.
|
||||
p.add_h_ruler(0.25 * y_scales[row], red);
|
||||
p.add_h_ruler(0.75 * y_scales[row], green);
|
||||
}
|
||||
|
||||
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
r.draw(figure);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
dump_if_requested(r, "figure_h_rulers.ppm");
|
||||
|
||||
const std::vector<plot::u8> frame = read_frame(ctx);
|
||||
// Rows of the frame that hold a fully covered red or green line, and which row's band
|
||||
// each falls in. The predicate demands full coverage exactly as the vertical case does.
|
||||
const auto lines_in = [&](const plot::Rect& area, bool want_red) {
|
||||
plot::u32 found = 0;
|
||||
for (plot::u32 y = static_cast<plot::u32>(area.y);
|
||||
y < static_cast<plot::u32>(area.bottom()); ++y) {
|
||||
plot::u32 run = 0;
|
||||
for (plot::u32 x = static_cast<plot::u32>(area.x);
|
||||
x < static_cast<plot::u32>(area.right()); ++x) {
|
||||
const plot::u8* px = &frame[(plot::usize(y) * ctx.width + x) * 4u];
|
||||
const bool ok = want_red ? (px[0] >= 200 && px[1] <= 80 && px[2] <= 80)
|
||||
: (px[1] >= 200 && px[0] <= 80 && px[2] <= 80);
|
||||
if (ok) ++run;
|
||||
}
|
||||
if (run > (area.w * 3u) / 4u) ++found;
|
||||
}
|
||||
return found;
|
||||
};
|
||||
const plot::FigureLayout layout = figure.layout();
|
||||
for (plot::u32 row = 0; row < 3u; ++row) {
|
||||
const plot::Rect& axes = figure.plot(row).layout().axes;
|
||||
PLOT_CHECK(lines_in(axes, true) >= 1u); // this row's own A ruler
|
||||
PLOT_CHECK(lines_in(axes, false) >= 1u); // and its B ruler
|
||||
}
|
||||
(void)layout;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "harness.hpp"
|
||||
#include <plot/plot.hpp>
|
||||
#include <plot/plot/ticks.hpp>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
@@ -235,6 +236,11 @@ PLOT_TEST(si_labels_are_utf8_and_never_overrun) {
|
||||
PLOT_TEST(format_time_shapes) {
|
||||
char b[32];
|
||||
plot::format_time(0.5, b, sizeof b); PLOT_CHECK(std::string(b) == "500ms");
|
||||
plot::format_time(0.005, b, sizeof b); PLOT_CHECK(std::string(b) == "5ms");
|
||||
plot::format_time(0.0005, b, sizeof b); PLOT_CHECK(std::string(b) == "500\u00b5s");
|
||||
plot::format_time(5.0e-6, b, sizeof b); PLOT_CHECK(std::string(b) == "5\u00b5s");
|
||||
plot::format_time(5.0e-9, b, sizeof b); PLOT_CHECK(std::string(b) == "5ns");
|
||||
plot::format_time(5.0e-12, b, sizeof b); PLOT_CHECK(std::string(b) == "5ps");
|
||||
plot::format_time(1.5, b, sizeof b); PLOT_CHECK(std::string(b) == "1.5s");
|
||||
plot::format_time(90.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1:30");
|
||||
plot::format_time(3661.0, b, sizeof b); PLOT_CHECK(std::string(b) == "1:01:01");
|
||||
@@ -268,3 +274,79 @@ PLOT_TEST(format_time_never_lets_a_field_reach_its_radix) {
|
||||
check_no_field_reaches_its_radix(label);
|
||||
}
|
||||
}
|
||||
|
||||
// An axis origin shifts what the labels *say* and which grid the ticks sit on: a window
|
||||
// zoomed into 12.345 s must read `0, 0.5ms, 1ms` against its own left edge, because three
|
||||
// significant digits cannot tell 12.345671 s from 12.345672 s. A host prints the origin once
|
||||
// beside the axis and the user adds.
|
||||
PLOT_TEST(ticks_are_written_relative_to_an_axis_origin) {
|
||||
// The same window twice: absolute, and relative to its own start.
|
||||
const plot::Range window{12.345, 12.346};
|
||||
std::vector<plot::Tick> absolute;
|
||||
plot::generate_ticks(window, 400.0f, 80.0f, absolute, /*include_minors=*/false, 0.0);
|
||||
PLOT_CHECK(!absolute.empty());
|
||||
PLOT_CHECK(absolute.front().value >= window.min);
|
||||
|
||||
std::vector<plot::Tick> relative;
|
||||
plot::generate_ticks(window, 400.0f, 80.0f, relative, /*include_minors=*/false, window.min);
|
||||
PLOT_CHECK(!relative.empty());
|
||||
// The walk starts on the origin's grid: the first major is the origin itself, and every
|
||||
// major is an exact multiple of the step away from it.
|
||||
PLOT_CHECK_NEAR(relative.front().value, window.min, 1e-12);
|
||||
PLOT_CHECK_NEAR(relative[1].value - relative[0].value,
|
||||
(absolute[1].value - absolute[0].value), 1e-12);
|
||||
for (const plot::Tick& t : relative) {
|
||||
PLOT_CHECK(t.value >= window.min - 1e-12);
|
||||
PLOT_CHECK(t.value <= window.max + 1e-12);
|
||||
}
|
||||
|
||||
// And through an axis: the *labels* are the relative values, the positions the absolute
|
||||
// ones. That is the whole contract -- `format_value()` is where the origin is applied.
|
||||
plot::Plot p;
|
||||
p.set_viewport({0, 0, 800, 400});
|
||||
const u32 ax = p.add_x_axis({"time", window, false, plot::AxisPlace::Bottom, false,
|
||||
plot::TickFormat::Time});
|
||||
p.x_axis(ax);
|
||||
PLOT_CHECK_NEAR(p.x_axis_origin(ax), 0.0, 1e-12);
|
||||
p.set_x_axis_origin(ax, window.min);
|
||||
PLOT_CHECK_NEAR(p.x_axis_origin(ax), window.min, 1e-12);
|
||||
char buf[64];
|
||||
p.x_axis(ax).format_value(window.min, buf, sizeof buf);
|
||||
PLOT_CHECK(std::string(buf) == "0"); // the window's start reads zero
|
||||
p.x_axis(ax).format_value(window.min + 0.0005, buf, sizeof buf);
|
||||
PLOT_CHECK(std::string(buf) == "500\u00b5s"); // half a millisecond reads in microseconds
|
||||
// An id that names no axis is ignored; the axis that exists is untouched by it.
|
||||
p.set_x_axis_origin(7u, 1234.0);
|
||||
PLOT_CHECK_NEAR(p.x_axis_origin(ax), window.min, 1e-12);
|
||||
}
|
||||
|
||||
// The moving-window case, which is why the mode exists: the origin is re-taken from the range
|
||||
// at refresh, so the tick on the left edge reads exactly zero however far the window has
|
||||
// scrolled since the host last looked. A host cannot predict the edge (the follow places it
|
||||
// from the newest sample), and a value it wrote a frame earlier is a frame of scroll stale --
|
||||
// at 1 MS/s that is more than a millisecond-scale window shows.
|
||||
PLOT_TEST(a_range_origin_puts_the_zero_tick_on_the_left_edge) {
|
||||
plot::Plot p;
|
||||
p.set_viewport({0, 0, 800, 400});
|
||||
const plot::Range window{12.345, 12.347};
|
||||
const u32 ax = p.add_x_axis({"time", window, false, plot::AxisPlace::Bottom, false,
|
||||
plot::TickFormat::Time});
|
||||
p.set_x_axis_origin_from_range(ax, true);
|
||||
|
||||
// `layout()` is the library's own path to the ticks (it ends in `refresh_ticks()`), so this
|
||||
// is the order a frame really runs in.
|
||||
p.layout();
|
||||
PLOT_CHECK(!p.x_axis(ax).ticks.empty());
|
||||
const plot::Tick& first = p.x_axis(ax).ticks.front();
|
||||
PLOT_CHECK(first.major);
|
||||
PLOT_CHECK_NEAR(f64(first.value), window.min, 1e-12); // exactly the left edge ...
|
||||
PLOT_CHECK(first.label == "0"); // ... and it reads zero
|
||||
|
||||
// The window slides: the origin follows it, so the zero tick is on the *new* edge.
|
||||
const plot::Range moved{12.6, 12.602};
|
||||
p.set_x_range(ax, moved);
|
||||
p.layout();
|
||||
PLOT_CHECK_NEAR(f64(p.x_axis(ax).ticks.front().value), moved.min, 1e-12);
|
||||
PLOT_CHECK(p.x_axis(ax).ticks.front().label == "0");
|
||||
PLOT_CHECK_NEAR(p.x_axis_origin(ax), moved.min, 1e-12); // and the host reads it back
|
||||
}
|
||||
Reference in new issue
Block a user