Three smaller findings at the plot/figure seam: * `Plot::remove_region()` re-indexed `region_stats_` but left `hot_.region` naming the old index, so a frame after a removal highlighted the wrong region's bound and an open drag edited a different region. The published handle (and its zone) is now cleared or decremented with the cache, and the contract documents that a host must not remove a region mid-drag. * `Figure`'s `kMinRowHeightPx` was applied as a *device*-pixel floor while chrome is logical, so at 2x the guarantee was half as strong exactly where the chrome is twice as large. It is now a logical constant converted through the row's own `ui_scale()`, 1:1 for a non-positive scale as `Style::device()` does. * `Figure::link_x()` refused a member whose axis 0 was `XFollow::Live`, but nothing refused `set_x_follow(0u, XFollow::Live)` *after* linking. The group then wrote its range into an axis that reinterpreted it against its own live edge, read it back as an external change, and tracked one member instead of the union. `Plot` now carries a linkage flag (`set_x_link_member()`), set by `link_x()` and cleared by `unlink_row()`, and `set_x_follow()` refuses `Live` on a linked axis. Tests: arming `Live` on a linked member leaves it `Off` and unlinking restores the ability to arm it; the row-height floor is 24 and 48 device pixels at 1x and 2x respectively.
108 lines
5.3 KiB
C++
108 lines
5.3 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#pragma once
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include <plot/core/types.hpp>
|
|
#include <plot/plot/ticks.hpp>
|
|
|
|
namespace plot {
|
|
|
|
/// Where an axis sits around the plot area.
|
|
enum class AxisPlace { Bottom, Top, Left, Right };
|
|
|
|
/// How an x axis' visible window relates to the newest sample of the streaming
|
|
/// series drawn on it.
|
|
///
|
|
/// `Off` is the default and means what the library has always meant: the axis'
|
|
/// range is exactly what a caller set or what an auto-fit produced, and nothing
|
|
/// recomputes it. `Live` makes the window a *view from the live edge*: every
|
|
/// `layout()` places it at `[newest - offset - span, newest - offset]`, so new
|
|
/// samples keep arriving at the right edge and no gesture can stop them (spec §12).
|
|
///
|
|
/// While an axis is `Live` the follow path owns its range: `auto_scale` is inert on
|
|
/// that axis, and a range handed to `Plot::set_x_range` is read as a *span* and an
|
|
/// *offset behind the live edge* rather than as absolute positions. See
|
|
/// `Plot::set_x_follow()`.
|
|
enum class XFollow { Off, Live };
|
|
|
|
/// Everything a caller can say about an axis at creation time. The six fields are
|
|
/// the five of the design spec plus the label format; the trailing default keeps
|
|
/// the spec's five-field aggregate initialisation working unchanged.
|
|
struct AxisDesc {
|
|
std::string label;
|
|
Range range{0.0, 1.0};
|
|
bool auto_scale = true;
|
|
AxisPlace place = AxisPlace::Bottom;
|
|
bool log = false;
|
|
TickFormat format = TickFormat::Si;
|
|
};
|
|
|
|
/// A live axis: its range and presentation flags, plus the tick list `refresh()`
|
|
/// regenerates for the current pixel size. `auto_scale`, `log` and `follow` are
|
|
/// public because the input controller and the renderer both read and write them,
|
|
/// and because a host that lays a plot out itself has to be able to state the
|
|
/// follow mode it wants without a renderer to set it.
|
|
struct Axis {
|
|
std::string label;
|
|
Range range{0.0, 1.0};
|
|
bool auto_scale = true;
|
|
AxisPlace place = AxisPlace::Bottom;
|
|
bool log = false;
|
|
TickFormat format = TickFormat::Si;
|
|
/// 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.
|
|
XFollow follow = XFollow::Off;
|
|
/// How far behind the live edge the visible window's right edge sits, in data
|
|
/// units, under `XFollow::Live`. Stored rather than implied by the range,
|
|
/// because the range is recomputed from the series' newest x on every frame:
|
|
/// this is the part of the view the user chose, so it is the part that survives
|
|
/// the recompute. Three things write it, all of them through one clamp: a pan or
|
|
/// a zoom through `Plot::set_x_range()`, which derives it from the range the
|
|
/// gesture asked for, `Plot::set_x_offset()` for a caller that states it
|
|
/// directly, and `Plot::layout()`'s placement, which re-clamps it against the
|
|
/// retained window every frame. `Plot::x_offset()` reads it back.
|
|
f64 offset = 0.0;
|
|
/// Set by an *owner* that is following this axis' window on the plot's behalf: a
|
|
/// `Figure` link group, while it follows, writes its own live edge here every frame.
|
|
/// A member's own `follow` must be `Off` (`Figure::link_x()` refuses a `Live` member
|
|
/// -- the group owns the shared window and its follow state), so without this the
|
|
/// input layer could not see that the axis is following: the controller's wheel
|
|
/// anchor would extrapolate a coordinate from the pointer instead of using the live
|
|
/// edge, and a zoom made from the axis strip would walk the window back off the edge.
|
|
///
|
|
/// `live_owner_newest` is the *owner's* edge, not this plot's: rows may be fed at
|
|
/// different rates, and a group's edge is the union of its members'. Cleared when the
|
|
/// group stops following or the row leaves it.
|
|
bool live_owner = false;
|
|
f64 live_owner_newest = 0.0;
|
|
/// Set while this plot is a member of a `Figure` x link group (`Figure::link_x()`
|
|
/// writes it, `unlink_row()` clears it). The group owns the shared x window *and*
|
|
/// its follow state -- `Figure::link_x()` refuses a `Live` member joiner -- so
|
|
/// `Plot::set_x_follow(Live)` on this axis is refused after linking too, rather
|
|
/// than letting a later call put the member's axis back into a state the group
|
|
/// cannot drive (see `Plot::set_x_follow()`).
|
|
bool link_member = false;
|
|
/// Ticks for the last `refresh()`, ascending, majors and (when asked) minors.
|
|
std::vector<Tick> ticks;
|
|
|
|
/// Regenerates `ticks` for an axis `px` pixels long, majors at least `min_px`
|
|
/// apart. `include_minors` additionally emits the grid subdivisions. Labels use
|
|
/// `format` -- `TickFormat::Time` relabels the majors with `format_time`, which
|
|
/// `generate_ticks` cannot know about.
|
|
void refresh(f32 px, f32 min_px, bool include_minors = true);
|
|
|
|
/// Writes `v` the way this axis' own tick labels are written: `format_time` on a
|
|
/// `Time` axis, `format_si` otherwise. The one rule for "how a value on this axis
|
|
/// reads", exposed so a consumer that has to caption a value the axis carries -- a
|
|
/// horizontal ruler's label -- writes it exactly as the tick beside it is written.
|
|
/// `buf` is NUL-terminated and never written past `n` bytes (both formatters
|
|
/// guarantee that).
|
|
void format_value(f64 v, char* buf, usize n) const;
|
|
};
|
|
|
|
} // namespace plot
|