Commit Graph
102 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 525759f9a9 fix(render): filter the glyph atlas, snap chrome hairlines to pixels
Text was point-sampled (`GL_NEAREST` plus `floor(v_uv * u_tex_size)`), which
at a non-integer scale *deletes* texels instead of blending them. The library's
own default -- a 12 px style over the 13 px embedded bake -- dropped one of
every 8 columns and one of every 17 rows of every glyph, so whole strokes went
missing and which ones depended on where the unsnapped pen fell. `kTextFS` now
does a 2x2 bilinear fetch clamped to the instance's own cell: the clamp keeps
the no-bleed guarantee the sampler mode protects (the atlas is a tight strip of
cells with no padding), and at scale 1 with a whole pen the weights are exactly
0, so it stays as crisp as the point sample.

Grid and axis lines were staged in data space and mapped in the vertex shader,
so a 1 px line's centre landed on an arbitrary fraction -- x = 100.0 lights two
columns at half coverage, x = 100.5 lights one crisply, and a frame mixed both.
`u_snap` rounds an axis-aligned segment's ink *leading edge* onto a pixel
boundary, which reduces to integer+0.5 for odd widths and to an integer for
even ones with no case analysis. It is on for the chrome that frames a plot and
off for anything drawn at a data position, since the rounding moves a line by
up to half a pixel. Diagonals are untouched.

`text_pixel`'s threshold drops to 160 because filtered glyphs legitimately peak
at 0.844 coverage at 12/13; the 180 threshold found text only while the sampler
was also deleting a column and a row of it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-02 00:22:08 +02:00
Martino Ferrari 98cfc5805b Fixing minor issues in multifigure plots
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
2026-10-01 15:32:05 +02:00
Martino Ferrari 38ef459897 feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
Breaking: Series::gather emits x relative to x0() and takes a GatherMode;
consumers reconstruct absolute x in f64 before mapping (series.hpp documents
the contract).

Bugs, each pinned by a fail-before regression test:
- render: f32 staging quantised epoch-time x into 128 px columns; every
  data-space draw now stages to_space(x) - to_space(origin) with the origin
  folded into the offset uniform in f64 (epoch/zero-origin twins rasterise
  identically)
- ticks: major test was ULP-fragile and blanked epoch axes; majors are an
  integer-index test and are never truncated by the minor cap
- gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor
  bucket edges + raw lead/trail spans
- live follow: log x axes held a linear span and zoomed every frame; span and
  offset now live in axis space (plot + both figure link-group paths)
- input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x,
  factor == 1 / inf refused in wheel and drag zoom
- render: the single text flush painted legend names over the crosshair
  readout panel; two batches, under flushed before the panel
- data: NaN dropped from min/max unless it seeded the fold; non-finite
  samples are now excluded from every aggregate and from cached bounds

Robustness: viewport normalisation (no clamp UB), non-finite range refusal,
pyramid level cap at 57 (no shift/div UB through public gather), rounded ring
capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT
save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation
captions clamped, button-aware GLFW row capture, cached figure row rects (no
per-event layout), ruler-pair measurement refused, span-nonfinite guards in
axis_ends.

Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample
index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and
markers (scatter at width 0) with auto or literal stride; GatherMode
Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text
colour (readout values in trace colour); wheel-dx x pan; arrow-key pan;
Escape cancels a drag and restores captured ranges; modifier-masked box zoom
(Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s;
ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path
and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION,
SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence
install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan).

Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files,
floor 80/90 held; ASAN preset suite green; all four demos run headless (the
GTK scripted-resize check needs a window manager and fails identically on the
parent commit); readme_main.cpp committed and compiles with the README
command; pkg-config and find_package consumption verified from a relocated
install prefix.
2026-09-30 17:32:06 +02:00
Martino Ferrari f132a5ec92 test(plot): pin the hot-handle re-index on region removal, refresh coverage
`remove_region()` now clears or decrements the published `hot_.region` with the
cache it re-indexes, so a frame after a removal highlights the region the handle
actually names. The new case covers both branches (a handle above the hole
follows it down, the removed region's handle is cleared with its zone), and the
coverage document is regenerated by the command that measured it: 95.2% of src/,
every file at or above 80%.
2026-09-28 22:42:56 +02:00
Martino Ferrari 1f5ce0d4a5 fix(plot,figure): re-index hot region, logical row floor, refuse live on a linked axis
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.
2026-09-28 22:36:37 +02:00
Martino Ferrari 2fbd1de7d4 fix(render,input): guard the zoom gain, clip annotation captions, no-throw dump
Three findings reachable by ordinary use:

* `Controller::axis_zoom()` divided by `Plot::ui_scale()` directly, though
  `Style::device()` documents a non-positive scale as host state that must be
  tolerated. With `ui_scale == 0` and a negative drag delta, `logical_dy` was
  `-inf`, the factor `+inf`, and the guard `!(factor > 0.0)` did not reject it:
  `zoom_x_axis`/`zoom_y_axis` computed `anchor + (bound - anchor) * inf`, and
  `set_y_range()` stored it verbatim and cleared `auto_scale` -- an axis left
  non-finite until an auto-fit. A negative scale silently inverted the zoom.
  A scale that is not positive and finite now means no zoom at all.

* `draw_annotations()` stages its caption into the frame's single text batch,
  which `flush_text()` draws *after* `clear_clip()`, so a caption staged while
  the axes clip was engaged was rasterised with the viewport clip. An
  annotation anchored outside the visible range kept a caption with no cross,
  and in a `Figure` -- whose rows tile the figure -- that caption painted into
  the neighbouring row. A caption whose anchor is outside `axes` is now skipped
  with its cross, rather than splitting the frame's one text draw call (which
  would cost a draw call per row and mis-clip the labels staged outside `axes`).

* `dump_frame_ppm()` was the one public entry point not `noexcept`, so a
  `std::bad_alloc` crossed the API boundary. Its buffers are now sized before
  anything is on disk and a failure is reported as `ErrorCode::OutOfMemory`,
  exactly as `TrueTypeFont::from_file()` reports it.

Tests: `ui_scale` 0 and -1 leave the axis range finite and unchanged with
`auto_scale` intact; an annotation anchored outside a bottom figure row's y
range leaves no caption ink in the row above, while an in-range one still
draws; a `static_assert` pins the dump's `noexcept`.
2026-09-28 22:36:34 +02:00
Martino Ferrari 9aff06acee fix(glfw): route a drag by the row it began in, and drop stale hover state
`controller_at()` routed every pointer event by the row the pixel was in,
ignoring the row a drag started in, so a press on one figure row followed by a
motion and release in another delivered the motion and release to a controller
whose `mode_ == None`. The owning controller kept `mode_`, `drag_region_`,
`drag_cursor_` and `drag_fixed_x_` set for ever: `publish_hot()` kept publishing
the grabbed handle, `grabbed_region()` reported a drag that had ended, and a
later gesture re-entering the row resumed the stale edit from the old anchor.

`feed_pointer_event()` now owns the routing: a press captures the row it landed
in and every motion and the release until the button comes up go to that row
(`pointer_target()`), cleared on release and when another figure is set. The
GLFW callbacks build the event and call it, so a windowless host or a test can
drive the routing too.

Two neighbours in the same seam:

* `sync_figure_crosshair()` called the four-argument overload unconditionally,
  so a documented vertical-only crosshair (`horizontal == false`) was promoted
  to a full cross at the stored y == 0, clamped to the axes edge and made
  permanent; the change detection also keyed on x alone, missing a y-only move
  to another row at the same x (two rows showing one horizontal line, spec 17).
  It now consults `crosshair().horizontal` and compares the hovered row's y.
* Only the row under the pointer republished its hot handles, so a row the
  pointer left kept drawing a hovered marker or bound as if it were still
  under the pointer, and answered `hovered_region()`/`grabbed_cursor()` for
  it. The run loop clears every other row's handles, keeping the captured
  row while a drag owns the button, and reads the frame's one layout once
  instead of recomputing every row five times.

Test: a press on row A's region, a motion into row B, a release in row B
asserts no controller keeps a drag mode, nothing reports a grabbed handle, and
a later press-and-motion that re-enters row A does not resume the old edit.
2026-09-28 22:36:30 +02:00
Martino Ferrari 81792dcd05 docs: correct three header comments that had drifted from the code 2026-09-28 22:17:55 +02:00
Martino Ferrari 040e40254d chore: remove a stray fragment committed by a shell redirect 2026-09-28 22:13:59 +02:00
Martino Ferrari ed3a8ce645 licence: MIT LICENSE and SPDX identifiers in every first-party file
The repository had no licence at all, so every source was all-rights-reserved
by default. Add the verbatim MIT text at LICENSE (Copyright (c) 2026 Martino
Ferrari) and a two-line SPDX header to each of the 113 first-party sources --
48 include/plot, 26 src, 10 adapters, 5 examples, 21 tests, 3 tools -- so the
grant travels with each file rather than relying on the root document alone.

Scripts keep their shebang on line 1; the SPDX lines follow it. Genuinely
vendored code is untouched: third_party/stb_truetype.h carries no header of
ours. src/render/embedded_mono_font.cpp is generated, so tools/fonts/gen_atlas.py
emit() now writes the two lines into its header comment and the file was
regenerated -- gen_atlas.py --check passes and the regeneration adds only those
comment lines to the previously committed file.

README's provenance section now states MIT and links LICENSE.

No code, test or public API change.
2026-09-28 22:13:40 +02:00
Martino Ferrari 059227d080 docs: README with build, embedding and verification entry points 2026-09-28 22:00:57 +02:00
Martino Ferrari 19c8d4368d docs(coverage): regenerate the measured table from the command that measures it 2026-09-28 22:00:57 +02:00
Martino Ferrari b7d0f901cc docs: a user guide covering getting started, architecture, embedding, data, interaction, styling and testing 2026-09-28 21:50:19 +02:00
Martino Ferrari d03f9ce51a test(gl): cover the loader's extension scanner and the handle and vertex-array failure paths 2026-09-28 21:40:37 +02:00
Martino Ferrari e9985eeeaf test(coverage): a reproducible coverage preset, report and floor check 2026-09-28 21:23:22 +02:00
Martino Ferrari 41b79313a7 docs: plan for the user documentation and a coverage floor 2026-09-28 21:11:43 +02:00
Martino Ferrari 2307aa1158 test(input): pin the dragged nearest-tick ruler's snap on drop 2026-09-28 20:54:58 +02:00
Martino Ferrari d2ba67921e fix(input): cursors follow the pointer while dragging and snap on drop 2026-09-28 20:46:14 +02:00
Martino Ferrari ea70cf4fca feat(plot): the figure crosshair marks the hovered row, and the stroke is thin, dashed and configurable 2026-09-28 20:33:35 +02:00
Martino Ferrari f8a364e963 feat(plot): the figure crosshair marks the hovered row, and the stroke is thin, dashed and configurable
The figure sync learns which row the pointer is in and that row's local y, so the
shared x goes to every row and the horizontal line only to the hovered one -- a
crosshair that finally marks a row where its y means something. The stroke gains
Style::crosshair_width/_dash_on/_dash_off (logical; 0.75 / 4 / 4), a per-Crosshair
override, and a fragment-shader dash on v_along (off == 0 is solid), anchored to the
axes edge each line is staged from so the phase does not crawl.
2026-09-28 20:29:09 +02:00
Martino Ferrari eee7c34dd5 feat(plot): the figure crosshair marks the hovered row, and the stroke is thin, dashed and configurable 2026-09-28 20:17:01 +02:00
Martino Ferrari cae672bc99 fix(plot): a figure gives every row the same y gutter so the axes line up 2026-09-28 20:11:17 +02:00
Martino Ferrari 663b57c679 docs: spec and plan for figure y-gutter alignment, crosshair line and style, and cursor dragging 2026-09-28 19:57:19 +02:00
Martino Ferrari a14f333dbe fix(plot,render,glfw): a hue-neutral crosshair, an em-dash placeholder, one layout per figure event, and a usable readout switch 2026-09-28 19:38:30 +02:00
Martino Ferrari 10d5129fbf refactor(render): share the readout panel and swatch drawing with the legend 2026-09-28 19:25:33 +02:00
Martino Ferrari 29824b6919 fix(plot,glfw): layer the crosshair under the handles, sync it across a figure, and harden the lookup 2026-09-28 19:22:29 +02:00
Martino Ferrari 8e27e22ebe test(plot): pin the crosshair's lookup, readout, placement, sync and clip 2026-09-28 19:07:22 +02:00
Martino Ferrari fd0c471430 feat(plot): crosshair with an interpolated per-signal hover readout 2026-09-28 18:57:33 +02:00
Martino Ferrari a153b73ead fix(input): clamp a dragged ruler on an ordered y range and share the orientation test 2026-09-28 17:58:42 +02:00
Martino Ferrari 93ad774281 fix(render): keep a cursor parked on an axis bound inside and visible 2026-09-28 17:49:18 +02:00
Martino Ferrari eb7371d7a5 test(plot): pin the horizontal ruler's paint, edge behaviour and orientation rules 2026-09-28 17:34:43 +02:00
Martino Ferrari 63f9655ae1 feat(plot): horizontal rulers alongside the vertical cursors 2026-09-28 17:18:43 +02:00
Martino Ferrari 2d819c4228 fix(render): clip grid, traces, regions and cursors to the plot area 2026-09-28 17:00:02 +02:00
Martino Ferrari d853db62ae fix(text): carry a glyph's negative bearing and harden the bake's extents 2026-09-28 16:32:18 +02:00
Martino Ferrari b0f9fbbb35 docs: spec and plan for data clipping, horizontal rulers and the crosshair readout 2026-09-28 16:32:18 +02:00
Martino Ferrari 1b153b2ec3 fix(text): refuse an atlas that would overflow its own arithmetic, and clamp the cell before narrowing it 2026-09-28 16:13:56 +02:00
Martino Ferrari 14d664a246 feat(text): optional vendored stb_truetype backend for runtime external fonts 2026-09-28 16:07:27 +02:00
Martino Ferrari daff60e3f0 fix(input,examples): a linked figure's wheel anchor uses the group's live edge, and every demo verdict reaches its exit status
Review fix pass 1 on the task 7 work; four findings, all closed.

1. The Qt demo's region sat across the middle of the window, which is exactly
   where its own scripted input check pans: after the check's box zoom the press
   opened DragMode::MoveRegion, the x range never moved, and the check printed
   'input check pan FAIL' -- under an exit 0, because the verdict was printed and
   not returned. The region now rides the left sixth (as the GLFW demo's does),
   both checks pass, and the verdict is wired to the exit status. The same audit
   found two more printed-only verdicts -- the GTK demo's scripted-resize layout
   check and its screenshot write (and Qt's) -- now wired too. What stays
   printed-only is the screenshot line of the GLFW host and the ImGui widget,
   where the dump happens inside the adapter's frame loop.

2. A follow-ing link group was invisible to the input layer: a member's own axis
   must be XFollow::Off (link_x() refuses a Live member), so Controller's
   x_zoom_anchor() extrapolated a coordinate from the pointer instead of using
   the live edge and a wheel over a row's axis strip walked the window back by
   19% of its width (behind 0.000000 -> 0.285809 s). The group now publishes its
   own edge to every member (Axis::live_owner + Plot::set_x_live_owner, written
   by Figure::push_link, cleared when the group stops following or the row
   unlinks), and x_zoom_anchor() reads it. Public-header addition, called out.
   Measured after: behind 0.012992 s and the window's right edge bit-identical to
   the edge the group published at the gesture.

3. The multiplot summary printed x_link_count() -- a number of groups -- under
   'rows in it'. It now reports the members and the group count separately, and
   every row line carries its own group id.

4. Host::controller_at() promised to clamp a pixel in a row gap to the nearest
   row and only clamped below the last one, so a gap pixel went to row 0. It now
   selects by distance, and tests/test_glfw_host.cpp pins it with a 40 px gap --
   the first coverage of the host's figure routing, which needs no window.

New cases: tests/test_controller.cpp x2 (the anchor and the hint's lifecycle),
tests/test_glfw_host.cpp x3 (routing, the gap, the single-plot fallback): 229
cases, 0 failed, ASan clean. All five demos exit 0, the three self-checking
modes exit 0, and the amended assertions were confirmed failing against the
pre-fix behaviour.
2026-09-28 15:52:08 +02:00
Martino Ferrari a70c381a1e fix(examples): keep the glfw demo's region off the pan path, and print the multiplot's region geometry
Adding a grabbable region across the middle of the glfw demo turned the pan
gesture PLOT_DEMO_FOLLOW makes -- and a user's own centre drag -- into a region
drag, which that mode's self-check reported as 'distance behind only by the pan:
NO' and exit 1. The region now rides the left sixth of the live window, so a
press in the middle pans exactly as it always did, and the reason is written
where the bounds are placed.

The multiplot demo also prints its region's last-frame fill rect and bound
columns, so the exit frame's region pixels can be read against the geometry
Plot::layout() measured.

Report: .superpowers/sdd/p2-task-7-report.md
2026-09-28 15:36:25 +02:00
Martino Ferrari 942f88c568 feat(examples): a three-row synced Figure demo, on a GLFW host that draws one
The multi-plot example the phase exists for: three plots stacked in one
Figure, sharing one x link with following armed, each row carrying its own
named signals and its own legend, driven by six StreamSeries at 500 kS/s.
Two cursors and a region with an exact statistics readout reach the direct
manipulation of spec §5 in the same frame, and PLOT_DEMO_SYNC=1 is a
self-checking mode that exits non-zero unless all three rows hold one range
and the shared right edge keeps tracking the newest sample through a zoom, a
pan on another row and more arriving data.

The GLFW host gains the figure mode the demo needs: set_figure() makes the
run loop draw the Figure instead of its own plot and route each pointer,
wheel and key event to the row it lands in, through one Controller per row
(row_controller()). All of it is opt-in; the single-plot path is untouched.
2026-09-28 15:24:22 +02:00
Martino Ferrari 770da4c2b2 fix(plot): a figure row's strip shows labels and title independently, and the shared axis is the bottom row's strip 2026-09-28 15:07:39 +02:00
Martino Ferrari 8f1e60a105 feat(plot): Figure with stacked rows and a synchronised x axis 2026-09-28 14:42:17 +02:00
Martino Ferrari 7cc06f07f5 fix(input): refuse non-finite region bounds, keep narrow regions movable, republish hot handles 2026-09-28 14:08:11 +02:00
Martino Ferrari 4681d7015b feat(examples): drive a region through the glfw demo and verify its geometry 2026-09-28 13:54:59 +02:00
Martino Ferrari 4e0e0ae0b3 test(plot): region removal re-indexes the ids and the statistics cache 2026-09-28 13:53:26 +02:00
Martino Ferrari 5e610bc138 test(input): pin the region snap's once-on-release rule and the live-follow data anchoring 2026-09-28 13:50:52 +02:00
Martino Ferrari 354e79eea2 feat(input): draggable cursors, regions and measurement-band edges 2026-09-28 13:47:35 +02:00
Martino Ferrari f423cf6f99 test(input): region, cursor and band-edge direct-manipulation cases (spec §5) 2026-09-28 13:42:26 +02:00
Martino Ferrari e4d48cc6c3 wip(plot): region layout, hot handles and draggable handles (build restored) 2026-09-28 13:28:51 +02:00
Martino Ferrari 7d2349555d fix(plot): a degenerate range cannot strand a live axis, and the demo's follow check fails the run 2026-09-28 13:14:50 +02:00