fix(plot): single-rounding clock labels, log-axis fits that refuse non-positive data

This commit is contained in:
Martino Ferrari committed 2026-09-27 22:28:56 +02:00
1 parent 93fc06f050
commit 606e40a9f1
7 files changed
+258 -73

No files matched your search

+49 -48
View File
@@ -32,28 +32,48 @@ Axis axis_from(const AxisDesc& desc) {
return axis;
}
/// Adds the margin an auto-fitted range needs. A zero-span union (a single-sample
/// series) is widened instead of padded, because a degenerate range has no span to
/// take 2% of and would leave the transform's inverse ambiguous.
Range fit_range(Range r, bool log) {
if (!std::isfinite(r.min) || !std::isfinite(r.max)) return Range{0.0, 1.0};
f64 lo = std::min(r.min, r.max);
f64 hi = std::max(r.min, r.max);
/// Fits `axis`' range to the union of its traces' bounds, returning false when the
/// union cannot be fitted and the range is then left untouched.
///
/// A linear axis takes 2% of the span on both ends; a zero-span union (a
/// single-sample series) is widened instead of padded, because a degenerate range
/// has no span to take 2% of and would leave the transform's inverse ambiguous.
///
/// A log axis needs strictly positive data, and this is where a log axis could
/// otherwise be left broken: an all-negative union has no positive value to show,
/// and padding it would produce `min > max` once the lower edge was clamped to a
/// positive bound. Instead the positive part of the union is used -- `[hi*1e-3,
/// hi]` when the union straddles or starts below zero, since zero has no place on
/// a log axis -- the pad is multiplicative (2% of the span in log space), and a
/// union with no positive value at all is refused outright. The caller keeps its
/// range and is free to retry, which is what lets a stream that starts at zero
/// begin fitting by itself once it goes positive.
bool fit_axis_range(Axis& axis, Range bounds) {
const f64 lo = std::min(bounds.min, bounds.max);
const f64 hi = std::max(bounds.min, bounds.max);
if (!std::isfinite(lo) || !std::isfinite(hi)) return false;
if (log && lo > 0.0) {
if (hi > lo) {
const f64 factor = std::pow(hi / lo, 0.02); // 2% of the span, in log space
return Range{lo / factor, hi * factor};
if (axis.log) {
if (!(hi > 0.0)) return false; // no positive sample: not auto-fittable
const f64 lower = lo > 0.0 ? lo : hi * 1e-3;
if (!(lower > 0.0) || lower > hi) return false;
if (lower == hi) {
axis.range = Range{lower / 10.0, hi * 10.0}; // one decade either side
return true;
}
return Range{lo / 10.0, hi * 10.0}; // one decade either side
const f64 factor = std::pow(hi / lower, 0.02); // 2% of the span, in log space
axis.range = Range{lower / factor, hi * factor};
return true;
}
if (hi - lo > 0.0) {
if (hi > lo) {
const f64 pad = (hi - lo) * 0.02;
return Range{lo - pad, hi + pad};
axis.range = Range{lo - pad, hi + pad};
return true;
}
const f64 pad = std::fabs(lo) * 1e-3 + 0.5;
return Range{lo - pad, hi + pad};
axis.range = Range{lo - pad, hi + pad};
return true;
}
} // namespace
@@ -170,52 +190,33 @@ Transform Plot::y_transform(u32 axis) const {
static_cast<f64>(r.y), false, a.log);
}
bool Plot::fit_x_axis(u32 axis) {
Range union_range{};
bool Plot::trace_bounds_union(bool x_side, u32 axis, Range& out) const {
bool any = false;
for (const Trace& t : traces_) {
if (t.axis_x != axis || !t.visible()) continue; // null or empty series: skip
const Range bounds = t.series->x_bounds();
if ((x_side ? t.axis_x : t.axis_y) != axis || !t.visible()) continue; // null series: skip
const Range bounds = x_side ? t.series->x_bounds() : t.series->y_bounds();
if (!std::isfinite(bounds.min) || !std::isfinite(bounds.max)) continue;
if (!any) {
union_range = bounds;
out = bounds;
any = true;
} else {
union_range.min = std::min(union_range.min, bounds.min);
union_range.max = std::max(union_range.max, bounds.max);
out.min = std::min(out.min, bounds.min);
out.max = std::max(out.max, bounds.max);
}
}
if (!any) return false; // nothing bound to this axis: leave the caller's range
return any;
}
Axis& a = x_axes_[axis];
Range fitted = fit_range(union_range, a.log);
if (a.log && fitted.min <= 0.0) fitted.min = fitted.max > 0.0 ? fitted.max * 1e-3 : 1.0;
a.range = fitted;
return true;
bool Plot::fit_x_axis(u32 axis) {
Range union_range{};
if (!trace_bounds_union(/*x_side=*/true, axis, union_range)) return false;
return fit_axis_range(x_axes_[axis], union_range);
}
bool Plot::fit_y_axis(u32 axis) {
Range union_range{};
bool any = false;
for (const Trace& t : traces_) {
if (t.axis_y != axis || !t.visible()) continue;
const Range bounds = t.series->y_bounds();
if (!std::isfinite(bounds.min) || !std::isfinite(bounds.max)) continue;
if (!any) {
union_range = bounds;
any = true;
} else {
union_range.min = std::min(union_range.min, bounds.min);
union_range.max = std::max(union_range.max, bounds.max);
}
}
if (!any) return false;
Axis& a = y_axes_[axis];
Range fitted = fit_range(union_range, a.log);
if (a.log && fitted.min <= 0.0) fitted.min = fitted.max > 0.0 ? fitted.max * 1e-3 : 1.0;
a.range = fitted;
return true;
if (!trace_bounds_union(/*x_side=*/false, axis, union_range)) return false;
return fit_axis_range(y_axes_[axis], union_range);
}
void Plot::auto_fit_x() {