feat(plot): an axis can write its labels relative to an origin, and time scales by prefix

A host zooming into a sub-second window cannot label it absolutely: Time
formatting uses three significant digits, so 12.345671s and 12.345672s are the
same string and a 1 ms step around a multi-second time is unlabelled. The axis
gains an `origin`: every value it writes is `v - origin`, and the ticks are
generated on the shifted grid, so the window reads 0, 500us, 1ms.
`origin_follows_range` re-takes the origin from the axis' own range on every
refresh -- the mode a *moving* window needs, since the edge is derived from the
newest sample at layout time and a value the host wrote a frame earlier is a
frame of scroll stale.

`format_time` now scales its sub-second values by prefix, the way `format_si`
does on a y axis: the unit that keeps the mantissa in [1, 1000). A fixed
millisecond unit printed 0.005ms where 5us is the reading, and 1e-05ms where
10ns is. An exact zero prints `0`, the same convention `format_si` states. The
origin subtraction's dust is snapped by a *relative* epsilon, so a tick on the
origin reads 0 rather than 1.78e-12ms.

Tested by `ticks_are_written_relative_to_an_axis_origin`,
`a_range_origin_puts_the_zero_tick_on_the_left_edge` and the extended
`format_time_shapes`; the suite is 359 cases, 0 failed.
This commit is contained in:
Martino Ferrari committed 2026-10-03 12:14:42 +02:00
1 parent 211c5b97bc
commit 2dcd1f1864
7 files changed
+197 -14

No files matched your search

+82
View File
@@ -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
}