docs: spec and plan for DPI-consistent sizing, grid classes, and live follow with zoom and panning

This commit is contained in:
Martino Ferrari committed 2026-09-28 11:56:26 +02:00
1 parent 2d51b978d7
commit 1f03631e98
2 files changed
+204

No files matched your search

@@ -209,3 +209,55 @@ These are inherited from the predecessor spec and are non-negotiable; every task
- **Adwaita Mono's licence** is OFL 1.1 with reserved font names; mitigated by a neutral backend name, a committed licence, and stated provenance.
- **`Figure` touches the renderer's frame contract.** Mitigated by leaving `draw(Plot&)` untouched and adding `draw(Figure&)` alongside it, so the four shipped adapters keep working unchanged.
- **Snapshot/`revision()` staleness** could render a stale atlas. Mitigated by making `revision()` monotone and testing a backend swap mid-run.
---
## 10. Consistent physical sizing: the UI scale
**This reverses a decision recorded in the predecessor spec.** That document settled on style metrics being *device* pixels and accepted the consequence that labels are relatively smaller on a high-DPI host. That is backwards for a library whose output should look the same everywhere: a 12 px axis label must be the same physical size on a 1x panel and a 2x panel, and a 1 px grid must not be a hairline on one and a slab on the other.
`Style` gains `f32 ui_scale = 1.0f` — device pixels per logical unit. Every visual metric in `Style` is henceforth a **logical** unit (`axis_font_px`, line widths, grid widths, paddings, swatch and marker sizes, tick minimum spacing), converted to device pixels by one helper per consumer: `PlotLayout` when it sizes label strips, title strips, the legend and region chrome, and the renderer when it converts widths and font px. `Plot` scales the font px it hands to the text pipeline, so a logical 12 px label is drawn at `12 * ui_scale` device px.
Data-space geometry is untouched: only chrome scales. The pixel-space convention is otherwise unchanged — `Rect` is still device pixels with a top-left origin, so the renderer, the scissor clear and every adapter's event mapping stay as they are.
The host supplies the value and every adapter already knows it: GLFW from `glfwGetFramebufferSize` / `glfwGetWindowSize`, Qt from `devicePixelRatioF()`, GTK from the two sizes the `::resize` signal reports, ImGui from `io.DisplayFramebufferScale`. `Renderer::draw()` propagates `style().ui_scale` onto the plot before laying out, so layout and drawing cannot disagree about the scale.
Invariant, and the test that pins it: with `ui_scale = 2.0`, every chrome element's **device** extent doubles while its **logical** extent is unchanged — the same axis label occupies twice the pixels but expresses the same size, and `measure`-derived strip widths scale with it.
## 11. Grid lines as two styled classes
`Style` carried separate colours for major and minor grid lines but neither width nor visibility, so a dense plot could not switch the minor grid off, and a fine grid could not be drawn thinner than a coarse one. Each class gains its own width and visibility, all in logical units scaled by §10:
```cpp
Color grid_major = Color::hex(0x2A3038);
Color grid_minor = Color::hex(0x1C2126);
f32 grid_major_width = 1.0f; // logical px
f32 grid_minor_width = 1.0f;
bool grid_major_visible = true;
bool grid_minor_visible = true;
```
The grid emits majors exactly at the major ticks and minors exactly at the minor ticks — one source, the tick set — so a major grid line coincides with a labelled tick by construction rather than by coincidence. Visibility is checked before staging, so a disabled class costs nothing to draw rather than being drawn transparent.
## 12. Live data: relative zoom and panning that keeps following
The streaming case is the library's headline use, and today any interaction destroys it: a wheel zoom or a drag calls `set_x_range`, which clears `auto_scale`, so a live plot stops following the newest sample and never resumes. That is wrong for a scrolling display, and it is why panning feels useless there. The fix is an explicit follow state on the x axis instead of an implicit consequence of `auto_scale`:
```cpp
enum class XFollow { Off, Live };
// on Plot:
void set_x_follow(u32 axis, XFollow);
XFollow x_follow(u32 axis) const;
void set_x_offset(u32 axis, f64 units_behind_live); // >= 0, clamped to the retained window
f64 x_offset(u32 axis) const;
```
With `Live`, the visible window is `[newest - offset - span, newest - offset]`, recomputed from the series' newest x every frame, so new samples keep arriving at the right edge and the plot never stops following.
- **Relative zoom.** A wheel zoom **multiplies** the span rather than adding to it, so successive notches compound and the zoom is scale-free. The anchor is the data coordinate under the pointer when the pointer is inside `axes`, and the live edge otherwise. Following is preserved either way, and the offset is adjusted so the anchored coordinate does not move on screen.
- **Mouse panning.** A left-drag changes `offset` by the data delta and leaves the span alone — this is the panning a live plot needs, and what "add mouse panning" means here. Dragging toward the live edge stops at `offset == 0`; you cannot pan past the newest sample. Dragging away moves back through the retained window, so the limit is what the `StreamSeries` still holds.
- **Returning to live.** `auto_fit` under `Live` sets `offset = 0` and fits the span to the retained window. It does not leave `Live`.
- **Interactions never silently disable following.** Pan and zoom keep `XFollow::Live` armed; only an explicit `set_x_follow(axis, Off)` turns it off. This is the behavioural change: today's interaction path disarms auto-follow as a side effect.
- **Static plots are unaffected.** `XFollow::Off` is the default, and a plot whose series is not streaming behaves exactly as it does today, including `auto_scale` on the y axis.
The y axis keeps its existing `auto_scale`; this section governs the x view of a streaming series.