Skip to content

feat(viewer): add event annotations from chart tooltips#929

Merged
thinkingfish merged 38 commits into
iopsystems:mainfrom
thinkingfish:feat/tooltip-add-event
May 16, 2026
Merged

feat(viewer): add event annotations from chart tooltips#929
thinkingfish merged 38 commits into
iopsystems:mainfrom
thinkingfish:feat/tooltip-add-event

Conversation

@thinkingfish
Copy link
Copy Markdown
Member

Summary

Lets a viewer user create one-off event annotations against the loaded recording from a chart, in Notebook/Report. Events render as subtle vertical hairlines with a persistent description bubble, persist to the parquet footer on Save as Report, reload on subsequent open, and survive a page refresh while unsaved.

  • Schema: chart_id: Option<String> added to dashboard::events::Event; events: Vec<Event> added to report-save::ReportPayload, written to the KEY_EVENTS footer key (single parquet + both sides of an A/B tarball).
  • Add: freeze a chart tooltip in Notebook → "+ Add Event" → popover form (editable RFC3339 timestamp pre-filled from the click x, description, kind, source/node/instance, "show only on this chart").
  • Display: subtle dashed hairline (opacity 0.7) + a persistent HTML description tag rendered in the band above the plot grid (off-canvas, so it never collides with the hover tooltip), x-aligned per event and repositioned on render/zoom/resize.
  • Inspect / delete: click a bubble → read-only "more info" popup of every populated field, with a Delete action (inline confirm) shown only in Notebook.
  • Persistence: events persist to a file-scoped localStorage key the same way Notebook/Report state does, so unsaved adds/deletes survive a refresh; a persisted working set is authoritative over the footer seed.
  • Event editing is gated to Notebook only; markers render read-only everywhere else.
  • Drive-by fix: half-width charts collapse to a single column on narrow screens again (regression from the viewer: Lit chart spike + CSS tokens + charts.css extraction #913 chart-CSS extraction — the @media override was stranded in style.css after the base rule moved to charts.css).

Server viewer and WASM static-site viewer both covered (shared symlinked assets).

Test plan

  • cargo test -p dashboard -p report-save — Event/ReportPayload round-trip with & without chart_id/events; KEY_EVENTS written when events present, skipped when empty, written to both A/B sides
  • node --test tests/events_store.test.mjs tests/event_markers.test.mjs — store filter/seed/replace/remove + markLine builder
  • cargo clippy --all-targets -- -D warnings
  • bash tests/viewer_smoke.sh
  • Manual: Notebook add → marker appears; Save as Report → reload → marker persists; refresh before save → unsaved marker survives; compare-mode A/B; non-Notebook surfaces are read-only

🤖 Generated with Claude Code

Adds `events: Vec<Event>` to `ReportPayload` (defaults empty, so existing
callers are wire-compatible) and writes a KEY_EVENTS footer entry whenever
the payload carries events. Both trim and embed paths handle it;
save_combined_ab_tarball passes None for now (Task 3 will wire it up).
- Set tooltip enterable:true while frozen so link clicks land on the
  link instead of passing through to the canvas (which previously
  toggled freeze and unfroze the tooltip).
- Restructure footer to render the link as a block element under the
  state row instead of inline-left.
ECharts caches the tooltip wrapper's pointer-events:none and doesn't
reapply when enterable toggles mid-display, so the link clicks were
still passing through. Walk up to the wrapper and patch directly.

Add a horizontal rule above the link via border-top + padding so it
visually separates from the FROZEN state row.
…amp field

- Marker color is now dark teal (#0d8b8b) per design ask; lines also
  emphasize-on-hover with width/opacity bump.
- Inline marker label is hidden until cursor enters the line, then
  shows the description next to it.
- Remove the Timestamp input from the Add Event form (the chart's
  x-axis already shows the frozen position); submit reads the
  captured timestamp directly from prefill instead.
- Color: dark orange (#cc6600).
- Label position 'end' + rotate:0 + align:center pins the description
  above the top of the vertical line (which sits at the plot grid's
  top edge), so the text stays horizontal and legible.
- Pill background uses the same dark-orange so the hover label reads
  as a label tag rather than free-floating text.
The pre-mount POPOVER_H_EST is a guess; after mount, measure the real
height and reseat the top edge if needed.
- Marker color now #a85d23 (less saturated, slightly darker than the
  previous #cc6600).
- onSubmit now unfreezes the tooltip + clears the axis-pointer line on
  the originating chart, so the transient interaction state goes away
  once the event lands as a permanent marker. Cancel/ESC paths leave
  the freeze intact, in case the user wants to retry.
… freeze)

Brought back the Timestamp input so a user can fine-tune the exact
moment after freezing. Pre-populated from the captured frozen-axis
position as RFC3339; submit re-parses and validates.
- EventsStore.remove({timestamp, description}) drops the matching
  entry by value (ECharts' internal data clones lose object identity,
  so reference-equality wouldn't be reliable).
- New openEventDelete popover anchored at the click point with a
  destructive Delete button.
- Chart's echart.on('click') intercepts markLine clicks (xAxis-defined
  is the discriminator vs scatter's yAxis OOB markLine), opens the
  popover, and sets a one-shot _suppressFreezeClick flag so the
  zr-click handler doesn't ALSO toggle freeze on the same event.
… axis-pointer on Add

- Click an event marker now freezes the data tooltip at that x and
  shows '<description>  x' in the footer, instead of opening a separate
  delete popover. The x is a delete affordance that pops the
  confirmation popover. Empty-grid freezes still get the original
  FROZEN/+Add Event footer.
- buildFreezeFooterContent (new in base.js) is the single source of
  truth for the three footer states (not frozen / frozen-empty /
  frozen-marker). chart.js's freeze toggle reuses it so the rebuild
  doesn't drift.
- Add submit + Delete confirm now share _teardownFreezeAndAxisPointer
  which fires both synchronously AND on the next frame to clear the
  axis-pointer hairline that previously lingered after the popover
  unmounted and exposed the chart again.
…reens again

Regression from iopsystems#913 (chart-CSS extraction): the base
'.group .charts { grid-template-columns: repeat(2, 1fr); }' moved into
charts.css, but the '@media (max-width: 1199px)' single-column
override stayed in style.css. Since charts.css loads after style.css
and both selectors have equal specificity, the unconditional 2-col
rule wins at every viewport width — narrow-screen collapse stopped
working.

Move the override next to its base rule in charts.css. Leave the
sibling overrides for .sysinfo-grid / .cgroup-pair-charts in style.css
since those selectors only exist there.
Two fixes:

1. Marker-click freeze actually freezes now. Previously showTip ran
   BEFORE _toggleTooltipFreeze set triggerOn:'none', so the just-shown
   tooltip got auto-hidden by the next mousemove. The hardcoded y:60
   also fell outside the plot grid for tall charts, silently failing
   axis-trigger showTip. Reorder + compute pixelY from the chart's
   grid rect.

2. Add Event / x Delete affordances now only render in Notebook.
   isEventEditingAllowed() is the single gate (route.startsWith
   ('/notebook')); buildFreezeFooterContent reads it before emitting
   either link, and chart.js's open-form / open-delete handlers
   no-op outside Notebook for defense in depth. Click-to-freeze and
   marker-hover labels still work everywhere — only event mutation
   is gated.
…ltip

Marker click no longer freezes the regular axis-trigger tooltip with
all series values. Instead it suppresses the regular tooltip and pops
a small standalone bubble next to the click position showing just the
event description (+ x delete in Notebook).

- New openEventBubble in event_form.js: positioned-once on oncreate
  (measure + clamp), dismisses on outside-click / ESC / x click, fires
  onClose so the chart can restore the suppressed regular tooltip.
- chart.js: marker-click handler now calls _suppressRegularTooltip()
  (setOption triggerOn:'none' + hideTip + axisPointer leave), opens
  the bubble, restores the regular tooltip on bubble close. The
  delete x triggers the existing openEventDelete confirmation popover.
- Drop now-dead marker-frozen plumbing: _frozenEvent state,
  tooltip-delete-event delegated handler, _openDeleteEventConfirm
  method, eventCtx branch in buildFreezeFooterContent / footer color.
  Footer is back to two states: not frozen / frozen-with-Add-Event.
…rker

Anchor the bubble at the top of the plot grid at the marker's x
(translated from canvas to viewport coords), and position the bubble
above that anchor by default. The description + x now appear right
where the inline hover label does, instead of next to the click point.
…vas bubble + read-only info popup

- Hairline: thin dashed, opacity 0.45, silent (non-interactive),
  no on-canvas label.
- Description shows by default as a persistent HTML tag in a layer
  above the plot grid (off the canvas data area, so it never overlaps
  the hover tooltip). One tag per visible event, x-aligned to its
  hairline; repositioned on render / zoom / resize. Tags whose event
  scrolls out of the zoom window are dropped.
- Click a tag → openEventInfo: read-only popup of every populated
  field (Timestamp/Description/Kind/Source/Node/Instance) with a
  Delete action behind an inline confirm, shown only in Notebook.
- Removed the transient bubble + separate confirm popover, the
  marker-click freeze/tooltip-suppression plumbing, and the
  _frozenEvent footer state. Freeze footer is back to two states.
- markLine silent:true, label hidden — tests updated accordingly.
…t box

- Tag top is now the plot-grid top (per-tag, in JS) with a CSS
  translateY(-100%) + 4px gap, so it sits right above the hairline's
  top end instead of floating at the chart-container top.
- width:max-content (capped at max-width) so the pointer/click zone
  matches the visible bubble rather than a full-width block — fixes
  the flaky cursor that lingered as a pointer across empty space.
Events now persist to a file-scoped localStorage key (rezolus_events_
<suffix>) the same way Notebook/Report state does, so in-memory adds
and deletes that haven't been Saved-as-Report survive a page reload.

- events_store: new replaceAll() for the restore path.
- selection.js owns persistence (events_store stays pure/testable):
  EVENTS_STORAGE_KEY scoped in setStorageScope; persistEvents on every
  store change; restoreEvents on scope set. A restored working set is
  authoritative (eventsRestoredFromStorage) so seedEventsFromMetadata
  becomes a no-op — persisted edits win over the footer, mirroring how
  Notebook localStorage overrides embedded report state. Persistence is
  suspended during restore/seed so only genuine user edits get written.
- Both bootstraps call seedEventsFromMetadata AFTER setStorageScope so
  the restore-vs-seed precedence is order-independent.
- Clear-All on the Notebook store also purges the events key.
- Also: trim event-tag padding back to 3px 9px for a leaner look.
Add isEventDisplayAllowed() (route /notebook or /report). Gate both
_applyEventMarkers (no event hairlines; base chart-style markLine such
as scatter's OOB separator still preserved) and _renderEventBubbles
(tears down the bubble layer and bails) on it. Raw metric sections no
longer show event hairlines or bubbles. Editing stays Notebook-only
via the existing isEventEditingAllowed().
Render nothing instead of throwing on attrs.section.route when the
route doesn't map to a loaded section (mid-load / failed fetch). The
throw was desyncing mithril's vdom and cascading into removeChild
NotFoundErrors / a white screen.
…eport

uploadParquet (in-app Load Parquet / re-load) always fetched the
overview section and routed to /overview, ignoring report mode. A
trimmed Save-as-Report parquet has no section data and an empty
section list, so this fired a doomed overview fetch and landed on a
broken page. Mirror the cold-boot path: check ViewerApi.getMode(),
and for a report go straight to /report without the overview fetch.
@thinkingfish thinkingfish marked this pull request as ready for review May 15, 2026 19:49
@thinkingfish thinkingfish merged commit 114a0de into iopsystems:main May 16, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant