feat(render): independent major and minor grid classes tied to the tick set

This commit is contained in:
Martino Ferrari committed 2026-09-28 12:42:22 +02:00
1 parent 69495a3799
commit eaae07d8e0
7 files changed
+635 -16

No files matched your search

+209
View File
@@ -56,6 +56,41 @@ bool has_horizontal(const plot::Grid& g, f64 y, bool major) {
return false;
}
/// The same question in *screen* space: the renderer places a line by mapping its
/// `x0`/`y0` through the axis' transform, so this is the coincidence the pixels show.
bool has_vertical_screen(const plot::Grid& g, const plot::Transform& tx, f64 sx, bool major) {
for (const plot::GridLine& l : g.lines()) {
if (l.x0 == l.x1 && l.major == major && tx.to_screen_x(l.x0) == sx) return true;
}
return false;
}
bool has_horizontal_screen(const plot::Grid& g, const plot::Transform& ty, f64 sy, bool major) {
for (const plot::GridLine& l : g.lines()) {
if (l.y0 == l.y1 && l.major == major && ty.to_screen_y(l.y0) == sy) return true;
}
return false;
}
/// How many of `g`'s lines carry one class: `vertical` picks the direction, `major`
/// the class.
u32 count_lines(const plot::Grid& g, bool vertical, bool major) {
u32 n = 0;
for (const plot::GridLine& l : g.lines()) {
if (((l.x0 == l.x1) == vertical) && l.major == major) ++n;
}
return n;
}
/// Whether `ticks` holds `value` -- the "and every tick has a line" half of the
/// coincidence check, asked of the list the grid was built from.
bool tick_at(const std::vector<plot::Tick>& ticks, f64 value) {
for (const plot::Tick& t : ticks) {
if (t.value == value) return true;
}
return false;
}
} // namespace
PLOT_TEST(layout_without_text_backend_uses_default_label_areas) {
@@ -574,6 +609,180 @@ PLOT_TEST(grid_lines_are_tick_aligned_data_space_segments) {
PLOT_CHECK(minor > 0); // minor grid is generated too
}
/// The grid's positions have one source -- the tick set -- and this pins that, in
/// both directions and in both spaces: a line with no tick and a tick with no line
/// are each a defect, and one direction alone is satisfied by a grid that drops
/// ticks or by one that invents them. A labelled major tick and a major grid line
/// therefore coincide *by construction* (spec §11), not by two derivations that
/// happen to agree on the ranges this case tries.
///
/// Screen space is checked beside data space because the renderer places a line by
/// mapping its endpoints through the axis' transform (`Renderer::draw_grid`), so
/// screen coincidence is what the pixels show. A log axis is among the ranges: that
/// transform is the one that is not the identity.
PLOT_TEST(major_grid_lines_coincide_with_major_ticks) {
/// Both directions, both spaces, one line per tick -- for one laid-out plot.
const auto check_pair = [](const plot::Plot& p, u32 ax, u32 ay, const char* what) {
const plot::Grid& g = p.grid();
const plot::Transform tx = p.x_transform(ax);
const plot::Transform ty = p.y_transform(ay);
const std::vector<plot::Tick>& xt = p.x_axis(ax).ticks;
const std::vector<plot::Tick>& yt = p.y_axis(ay).ticks;
PLOT_CHECK(!xt.empty());
PLOT_CHECK(!yt.empty());
// Exactly one line per tick of the tick set the grid was built from: none
// dropped, none invented, and both classes carried through.
PLOT_CHECK(count_lines(g, true, true) + count_lines(g, true, false) == xt.size());
PLOT_CHECK(count_lines(g, false, true) + count_lines(g, false, false) == yt.size());
PLOT_CHECK(count_lines(g, true, false) > 0u); // the range really has minors
PLOT_CHECK(count_lines(g, false, false) > 0u);
// Every tick -> a line of its own class, and never of the other class.
for (const plot::Tick& t : xt) {
PLOT_CHECK(has_vertical(g, t.value, t.major));
PLOT_CHECK(!has_vertical(g, t.value, !t.major));
PLOT_CHECK(has_vertical_screen(g, tx, tx.to_screen_x(t.value), t.major));
}
for (const plot::Tick& t : yt) {
PLOT_CHECK(has_horizontal(g, t.value, t.major));
PLOT_CHECK(!has_horizontal(g, t.value, !t.major));
PLOT_CHECK(has_horizontal_screen(g, ty, ty.to_screen_y(t.value), t.major));
}
// Every line -> a tick of its own class.
for (const plot::GridLine& l : g.lines()) {
if (l.x0 == l.x1) {
PLOT_CHECK(tick_at(xt, l.x0));
} else {
PLOT_CHECK(tick_at(yt, l.y0));
}
}
std::printf("[grid] %-22s x %u ticks (%u major) -> %u vertical lines; y %u ticks (%u major)"
" -> %u horizontal, %s\n",
what, static_cast<unsigned>(xt.size()), count_lines(g, true, true),
count_lines(g, true, true) + count_lines(g, true, false),
static_cast<unsigned>(yt.size()), count_lines(g, false, true),
count_lines(g, false, true) + count_lines(g, false, false),
(tx.log_x || ty.log_y) ? "log" : "linear");
};
// Several ranges and axes widths, so the check is not one nice-number step's
// accident.
struct Scene {
const char* what;
f64 lo, hi;
f32 w, h;
};
const Scene scenes[] = {
{"[0, 10] 800x400", 0.0, 10.0, 800.0f, 400.0f},
{"[0, 100] 800x400", 0.0, 100.0, 800.0f, 400.0f},
{"[-5, 5] 640x360", -5.0, 5.0, 640.0f, 360.0f},
{"[0, 3.3] 200x120", 0.0, 3.3, 200.0f, 120.0f}, // a narrow axes: few ticks
};
for (const Scene& s : scenes) {
plot::Plot p;
p.set_viewport({0.0f, 0.0f, s.w, s.h});
const u32 ax = p.add_x_axis({"t", {s.lo, s.hi}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p.add_y_axis({"v", {s.lo, s.hi}, false, plot::AxisPlace::Left, false});
(void)p.layout();
check_pair(p, ax, ay, s.what);
}
// A log pair: the same data-space coincidence, and the same once mapped through the
// log10 pre-affine -- where an equal data step is *not* an equal screen step, so a
// grid built on any second derivation would part company here first.
{
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p.add_x_axis({"t", {1.0, 1000.0}, false, plot::AxisPlace::Bottom, true});
const u32 ay = p.add_y_axis({"v", {1.0, 100.0}, false, plot::AxisPlace::Left, true});
(void)p.layout();
PLOT_CHECK(p.x_axis(ax).log);
PLOT_CHECK(p.y_axis(ay).log);
PLOT_CHECK(p.x_transform(ax).log_x);
PLOT_CHECK(p.y_transform(ay).log_y);
check_pair(p, ax, ay, "log [1, 1000]");
}
// A tick set with no minors in it -- `generate_ticks(..., include_minors = false)`,
// the labelled set a caller draws numbers for -- makes a grid with no minors: the
// grid owns no subdivision of its own. Both directions still hold, and the minor
// class is empty on *both* sides, which is asserted rather than skipped: an empty
// class on one side only is exactly the drift this case exists to catch.
{
std::vector<plot::Tick> xt;
std::vector<plot::Tick> yt;
plot::generate_ticks({0.0, 10.0}, 400.0f, 60.0f, xt, false);
plot::generate_ticks({0.0, 1.0}, 300.0f, 60.0f, yt, false);
PLOT_CHECK(!xt.empty());
PLOT_CHECK(!yt.empty());
for (const plot::Tick& t : xt) PLOT_CHECK(t.major);
for (const plot::Tick& t : yt) PLOT_CHECK(t.major);
plot::Grid g;
g.build(xt, {0.0, 10.0}, yt, {0.0, 1.0});
PLOT_CHECK(g.size() == xt.size() + yt.size());
for (const plot::Tick& t : xt) {
PLOT_CHECK(has_vertical(g, t.value, true));
PLOT_CHECK(!has_vertical(g, t.value, false));
}
for (const plot::Tick& t : yt) {
PLOT_CHECK(has_horizontal(g, t.value, true));
PLOT_CHECK(!has_horizontal(g, t.value, false));
}
PLOT_CHECK(count_lines(g, true, false) == 0u);
PLOT_CHECK(count_lines(g, false, false) == 0u);
std::printf("[grid] majors-only tick set %u x ticks, %u y ticks -> %zu lines, 0 minors\n",
static_cast<unsigned>(xt.size()), static_cast<unsigned>(yt.size()), g.size());
}
// Two axis pairs. `Plot::build_grid()` builds the grid from the *primary* pair (x
// axis 0, y axis 0) and `Renderer::draw_grid()` maps it with that pair's transforms
// -- the comment there says so -- so a second pair's ticks get no lines at all.
// Pinned rather than assumed: the coincidence above is with the primary pair's
// ticks, and a value only the second axis ticks at has no line, in data space or on
// screen. (A trace on the second pair is still drawn in the data area; it simply
// has no grid of its own.)
{
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax0 = p.add_x_axis({"a", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const u32 ax1 = p.add_x_axis({"b", {0.0, 1.0}, false, plot::AxisPlace::Top, false});
const u32 ay0 = p.add_y_axis({"v", {0.0, 100.0}, false, plot::AxisPlace::Left, false});
const u32 ay1 = p.add_y_axis({"w", {0.0, 1.0}, false, plot::AxisPlace::Right, false});
(void)p.layout();
check_pair(p, ax0, ay0, "primary pair only");
PLOT_CHECK(p.x_axis_count() == 2u);
PLOT_CHECK(p.y_axis_count() == 2u);
const plot::Grid& g = p.grid();
const plot::Transform tx1 = p.x_transform(ax1);
const plot::Transform ty1 = p.y_transform(ay1);
u32 x_only_secondary = 0u;
u32 y_only_secondary = 0u;
for (const plot::Tick& t : p.x_axis(ax1).ticks) {
if (tick_at(p.x_axis(ax0).ticks, t.value)) continue; // 0 is on both ranges
++x_only_secondary;
PLOT_CHECK(!has_vertical(g, t.value, t.major));
PLOT_CHECK(!has_vertical_screen(g, tx1, tx1.to_screen_x(t.value), t.major));
}
for (const plot::Tick& t : p.y_axis(ay1).ticks) {
if (tick_at(p.y_axis(ay0).ticks, t.value)) continue;
++y_only_secondary;
PLOT_CHECK(!has_horizontal(g, t.value, t.major));
PLOT_CHECK(!has_horizontal_screen(g, ty1, ty1.to_screen_y(t.value), t.major));
}
// The case is not vacuous: the second pair really does tick where the first does
// not, so "no line there" is a statement about the build and not about the ranges.
PLOT_CHECK(x_only_secondary > 0u);
PLOT_CHECK(y_only_secondary > 0u);
std::printf("[grid] primary pair only %u secondary x ticks with no line, %u secondary y"
" ticks with no line; the grid is the primary pair's %zu lines\n",
static_cast<unsigned>(x_only_secondary),
static_cast<unsigned>(y_only_secondary), g.size());
}
}
// ------------------------------------------------------------------- legend
namespace {