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:
1 parent
211c5b97bc
commit
2dcd1f1864
7 files changed
+197
-14
No files matched your search
@@ -52,6 +52,23 @@ struct Axis {
|
|||||||
AxisPlace place = AxisPlace::Bottom;
|
AxisPlace place = AxisPlace::Bottom;
|
||||||
bool log = false;
|
bool log = false;
|
||||||
TickFormat format = TickFormat::Si;
|
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 --
|
/// 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
|
/// a y axis has no live edge -- and `Off` on both, so nothing changes for a
|
||||||
/// caller who never asks for it. See XFollow.
|
/// caller who never asks for it. See XFollow.
|
||||||
|
|||||||
@@ -488,6 +488,21 @@ class Plot {
|
|||||||
/// grab it, which is what a host wants for a marker it draws as a *readout*. An id that
|
/// 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.
|
/// names no cursor is ignored, like every other setter here.
|
||||||
void set_cursor_selectable(u32 id, bool selectable);
|
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
|
/// 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
|
/// 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:
|
/// 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`.
|
/// 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
|
/// 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 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
|
/// 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
|
/// single-sample series), a non-positive `px` or `min_px`, and a span whose step
|
||||||
/// overflows to infinity.
|
/// overflows to infinity.
|
||||||
void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out,
|
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
|
} // namespace plot
|
||||||
+17
-3
@@ -3,18 +3,32 @@
|
|||||||
|
|
||||||
#include <plot/plot/axis.hpp>
|
#include <plot/plot/axis.hpp>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace plot {
|
namespace plot {
|
||||||
|
|
||||||
void Axis::format_value(f64 v, char* buf, usize n) const {
|
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) {
|
if (format == TickFormat::Time) {
|
||||||
format_time(v, buf, n);
|
format_time(shown, buf, n);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
format_si(v, buf, n);
|
format_si(shown, buf, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Axis::refresh(f32 px, f32 min_px, bool include_minors) {
|
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;
|
if (format != TickFormat::Time) return;
|
||||||
// generate_ticks labels with format_si; a time axis relabels its majors here,
|
// 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
|
// because the tick generator has no business knowing about a clock -- through the one
|
||||||
|
|||||||
@@ -316,6 +316,34 @@ void Plot::set_cursor_selectable(u32 id, bool selectable) {
|
|||||||
cursors_[id].selectable = selectable;
|
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) {
|
void Plot::set_annotation(u32 id, const Annotation& annotation) {
|
||||||
if (id >= annotations_.size()) return;
|
if (id >= annotations_.size()) return;
|
||||||
annotations_[id] = annotation;
|
annotations_[id] = annotation;
|
||||||
|
|||||||
+33
-9
@@ -99,14 +99,34 @@ void format_time(f64 seconds, char* buf, usize n) {
|
|||||||
const f64 t = std::fabs(seconds);
|
const f64 t = std::fabs(seconds);
|
||||||
|
|
||||||
if (t < 1.0) {
|
if (t < 1.0) {
|
||||||
// Sub-second: milliseconds. The 999.5 cut keeps %.3g out of scientific
|
// Sub-second: the prefix that keeps the mantissa in [1, 1000) -- the same rule the SI
|
||||||
// notation (it would print "1e+03ms") right below the one-second boundary.
|
// formatter uses on a y axis, and the reading a zoomed-in window needs. A fixed
|
||||||
const f64 ms = t * 1000.0;
|
// millisecond unit printed `0.005ms` where `5us` is what the axis is showing, and
|
||||||
if (ms < 999.5) {
|
// `1e-05ms` where `10ns` is. The micro sign is the UTF-8 sequence the embedded font has a
|
||||||
std::snprintf(buf, n, "%s%.3gms", sign, ms);
|
// glyph for (see `format_si`), not a Latin-1 byte.
|
||||||
} else {
|
// Exactly zero carries no unit, the same convention `format_si` states: every prefixed
|
||||||
std::snprintf(buf, n, "%s%.3gs", sign, t);
|
// 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;
|
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();
|
out.clear();
|
||||||
|
|
||||||
const f64 lo = range.min;
|
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);
|
: 10.0);
|
||||||
if (!std::isfinite(step) || !(step > 0.0)) return; // span so large the step overflowed
|
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;
|
if (!std::isfinite(first_major)) return;
|
||||||
|
|
||||||
// Minor ticks subdivide the major step: by 4 when the step's leading digit is
|
// Minor ticks subdivide the major step: by 4 when the step's leading digit is
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#include "harness.hpp"
|
#include "harness.hpp"
|
||||||
|
#include <plot/plot.hpp>
|
||||||
#include <plot/plot/ticks.hpp>
|
#include <plot/plot/ticks.hpp>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
@@ -235,6 +236,11 @@ PLOT_TEST(si_labels_are_utf8_and_never_overrun) {
|
|||||||
PLOT_TEST(format_time_shapes) {
|
PLOT_TEST(format_time_shapes) {
|
||||||
char b[32];
|
char b[32];
|
||||||
plot::format_time(0.5, b, sizeof b); PLOT_CHECK(std::string(b) == "500ms");
|
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(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(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");
|
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);
|
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