Skip to content

DS-view pointer arrows for every live pointer + hover polish - #6

Merged
zojize merged 29 commits into
mainfrom
feature/ds-view-pointer-arrows
Apr 22, 2026
Merged

zojize merged 29 commits into
mainfrom
feature/ds-view-pointer-arrows

Conversation

@zojize

@zojize zojize commented Apr 20, 2026

Copy link
Copy Markdown
Owner

Summary

  • Extends usePointerGraph to emit extraEdges / extraDanglingEdges for every live pointer — primitive pointer variables, pointer fields whose target isn't itself a struct, nested pointers in arrays. Every pointer in the DS view now gets a rendered arrow.
  • Tweaks placement so primitive-pointer cards auto-settle just left of their target (two passes handle short chains; fallback to free placement).
  • Hovering a pointer card or any pointer arrow in the DS view publishes the pointer into the hover store, so MemoryMap shows the source/target highlight + arrow with primary emphasis (previously only hovering a pointer byte in ByteMap did this).
  • Primitive-pointer cards pick up the same blue-500/15 + ring hover treatment as struct field-row highlights, so arrow-hover visibly picks out the source.
  • Clips the MemoryMap pointer-arrow overlay to the stack/heap scroll containers via data-overlay-clip-region + clip-path: inset(...) — arches no longer extend above the sticky headers or below the bottom margin. Horizontal overflow preserved for cross-column arrows.
  • Minor style tidy-ups: drops border-l-transparent on memory cards, renames Stack & Globals → Stack, moves the arrows toggle after LE/BE in the toolbar.
  • Bumps 14 dev-dep minors/patches (eslint, vitest, vite, unocss, typescript, vue-tsc, knip, etc.).

Test plan

  • Run any template that has primitive pointers (e.g. singly-insertBack) — confirm every live pointer has an arrow in DS view.
  • Hover a pointer card in DS view — MemoryMap Allocations/Bytes lights up with the primary purple source + orange target + arrow.
  • Hover a primitive-pointer arrow in DS view — same effect; the source card itself picks up the blue tint + ring.
  • Toggle arrows + switch between Bytes ↔ Allocations — arrows stay within the column content band (no bleed into headers / bottom margin).
  • Existing struct→struct pointer placement still looks correct (no regressions from the extra placement pass).

zojize and others added 7 commits April 19, 2026 19:54
The default border color already matches the background when no accent
is applied; the explicit transparent left border caused a subtle color
mismatch with the rest of the 1px border.
The column doesn't currently separate globals from the stack; the
shorter label is less misleading and matches the heap header's style.
Groups the view-affecting toggles (arrows, LE/BE) together on the right
side of the toolbar, separated from the Allocations/Bytes tabs by a
flexible spacer.
-     "@antfu/eslint-config": "^8.2.0",
-     "@types/node": "^25.6.0",
-     "@unocss/eslint-config": "^66.6.8",
-     "@unocss/eslint-plugin": "^66.6.8",
-     "@unocss/reset": "^66.6.8",
-     "@vitejs/plugin-vue": "^6.0.6",
-     "@vitest/coverage-v8": "^4.1.4",
-     "eslint": "^10.2.1",
-     "knip": "^6.4.1",
-     "typescript": "^6.0.3",
-     "unocss": "^66.6.8",
-     "vite": "^8.0.8",
-     "vitest": "^4.1.4",
-     "vue-tsc": "^3.2.7"

All checks pass: typecheck, lint, knip, 177 unit tests, build (10.30s).
Extends usePointerGraph beyond struct->struct edges: a second pass
walks every live pointer scalar (standalone pointer variables, pointer
fields whose target is not a struct, nested pointers inside arrays)
and emits `extraEdges` / `extraDanglingEdges`. DataStructureView
renders them as additional direction='dynamic' arrows that use
nearest-border endpoints.

Placement: after tree layout and non-pointer standalone placement, a
small pass tries to place primitive-pointer cards just left of their
target using placeRelative (direction='left', tighter 28px gap). Two
passes cover short chains (p1 -> p2 -> target); anything still
unresolved falls through to placeNew.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The overlay SVG was positioned inside the view-host's full box, so
bezier arches (control point is always above-and-left of the midpoint)
could extend above the STACK/HEAP sticky headers and below the
column's bottom padding. Both views now mark their scroll containers
with `data-overlay-clip-region`; ArrowOverlay unions their bounding
rects and applies a `clip-path: inset(top 0 bottom 0)` to the SVG so
the vertical extents stay inside the content band. Horizontal overflow
is preserved for arrows that cross columns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hovering a pointer card or any pointer arrow in the DS view now mirrors
the effect of hovering the pointer's byte in ByteMap: it publishes a
PointerArrow into the hover store so MemoryMap renders the source/target
highlight + arrow with primary emphasis.

Also extends the arrow-hover visual in the DS view so primitive-pointer
cards (not just struct field rows) pick up the same blue-500/15 + ring
hover treatment as the struct field-row highlight, so the user can see
which pointer the arrow stems from.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@netlify

netlify Bot commented Apr 20, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for viz-list ready!

Name Link
🔨 Latest commit 85fb5cd
🔍 Latest deploy log https://app.netlify.com/projects/viz-list/deploys/69e85d1055f9930008b74a12
😎 Deploy Preview https://deploy-preview-6--viz-list.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

zojize and others added 22 commits April 20, 2026 00:37
The post-drag bounds change was triggering autoPanToContent, which
'undid' the user's intentional placement. A one-shot suppressAutoPanOnce
flag set in onDragEnd and consumed in the next onUpdated keeps the
auto-pan path for real bounds changes (new allocations, re-layouts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Zoom (Figma/Miro pattern):
- Cmd/Ctrl+scroll zooms around the cursor; plain scroll still pans
- Trackpad pinch (wheel+ctrlKey) rides the same path
- Toolbar cluster adds -/%/+ plus a separator and the existing Auto-layout
  button; the % label clicks to reset to 100%

Zoom math: contentRef transform is `translate(pan) scale(z)` with origin
0,0. Pan stays in screen pixels so existing pointerdown/move logic stays
untouched. getDragDelta + onDragEnd descale by z so dropped items land at
the right content-space coord regardless of zoom. clampPan multiplies
bounds by z when comparing to screen viewport.

Placement revert: primitive-pointer cards no longer try to snap left of
their target. The extra placeRelative pass produced chaotic layouts when
multiple pointers shared a target. Arrows alone carry enough signal, so
pointer items fall through to the standard placeNew empty-space finder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
measureFieldY was mixing screen-pixel offsets (from
getBoundingClientRect, which includes the canvas's CSS scale) with
content-space placement coords. At zoom 2x the arrow's starting Y was
doubled relative to the field row, making every field-anchored arrow
droop or rise as the user zoomed. Divide the screen-pixel offset by the
current zoom before adding it to pos.y.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The first render after a zoom button click reads
getBoundingClientRect before Vue has patched the contentRef style with
the new scale, so arrows were drawn at the pre-scale anchor positions
and stayed stale until pan or another interaction forced a re-render.
A post-nextTick tick bump gives the render a second run against the
already-scaled DOM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The wrapper now uses `flex items-center`, so when the column is wider
than a row's intrinsic width (labelWidth + bpr*cellWidth) the rows sit
in the middle with equal gutters on either side. The overlay SVG is a
sibling of the wrapper (so it spans the full column height without being
virtualized), which means the byte grid coordinates no longer match.

OverlayGeometry gains an xOffset field. ByteMap computes it per column
as max(0, (width - rowWidth) / 2) and threads it through ByteMapOverlay
into allocationRects / allocationPath. Unit test pins the behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-introduces target-aware placement for primitive pointer cards, fixing
the prior attempt's cascade issues:

- Non-pointer standalone items place first via the usual free-slot
  search, so by step 3.5 every pointer's target has a committed position.
- Pointer items are topo-sorted so chained pointers (p1 -> p2 -> x)
  land target-first, dependency-before-dependent. DFS-based with cycle
  fall-through so every pointer still gets placed exactly once.
- Each pointer probes four target-adjacent slots (right / left / below /
  above), sorts by center-to-target distance, and takes the nearest
  free candidate via a new non-destructive `placement.tryPlaceAt`.
- A distance cap of ~2x the larger card dimension keeps crowded graphs
  from shoving pointers into corners just to hug the target; past that
  they fall through to the ordinary free-slot placement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`tryPlaceAt` always bumped placement.version, even when committing the
same slot a previous pass had already assigned. Combined with
DataStructureView's step-3.5 loop, which re-probed every pointer on
each onUpdated run, version bumped -> render -> onUpdated -> re-probe
-> version bump, freezing heavy templates like btree as soon as they
ran.

Two idempotency guards: tryPlaceAt no-ops when the key already sits at
the requested (x, y); the pointer-placement loop skips items that
already have a position (matching placeNew's existing idempotency).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Items placed early during a run-through don't know about structure that
appears later, so the final layout is often messy until the user hits
Auto-layout. A debounced watcher on memoryVersion now fires a partial
re-layout ~400ms after activity stops — typically at breakpoints or
between slow steps. Manual drags are preserved via a new
`clearPositions(keepUserDragged)` variant on the placement engine, so
idle relayouts never clobber positions the user set themselves.

The trigger only fires when the total item count changed since the
last layout, so resets, pans, and hovers don't kick off a reshuffle.
During a fast run (memoryVersion ticks faster than 400ms) the debounce
window never closes so playback isn't interrupted; the relayout lands
once play pauses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the 400ms debounced idle trigger and the legacy autoPanToContent
with a single step-driven path: when content bounds change and anything
overflows the viewport, re-run layout preserving user drags. If the
re-layout still overflows, stay with the current pan — panning can't
shrink content that doesn't fit, and an unexpected top-left realignment
disrupts the user's reference frame more than it helps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the dropped auto-pan with an auto-scale step: after a triggered
relayout, shrink the canvas zoom enough to fit the entire content
bounding box inside the viewport (capped at 1x so the resting layout
stays at natural size) and center it. If the content is still larger
than the viewport at MIN_ZOOM (0.25x), setZoom clamps there and we
accept partial visibility — better than scrolling around blind.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Triggered relayouts now reshape the canvas unconditionally — user drags
are committed for the immediate render (via setPosition) but aren't
tracked as sticky, so the next bounds-change reaction re-places them
from scratch alongside everything else.

- Removes userDragged Set, markUserDragged / isUserDragged /
  clearUserDragged from the placement engine.
- `clearPositions()` drops its `keepUserDragged` parameter.
- `invalidateStaleTreePositions` unconditionally removes everything
  the tree diff flagged.
- Renames the step-triggered variant to `autoRelayoutInPlace`.
- Updates PLACEMENT_ENGINE.md with the new lifecycle.

`suppressAutoPanOnce` still survives one drag-drop render so the
dropped position isn't teleported away in the same tick — feels
less jarring than a post-release snap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs in the onUpdated auto-relayout trigger could chase their tails
under the right conditions (tree wider than the viewport at zoom<1):

1. The overflow check mixed units — it compared content-space bounds
   against `-panOffset..-panOffset+viewport` as if zoom were 1, so at
   zoom 0.5 content that actually fit on screen still registered as
   overflowing, re-triggering the relayout.
2. prevBoundsKey was snapshotted BEFORE autoRelayoutInPlace, so the
   in-block relayout moved positions but the key still reflected the
   pre-relayout bounds. The next render saw "bounds changed" vs the
   stale key and re-entered the branch for the same structural state.

Switched the overflow check to screen-space (pan + p*z) and snapped
prevBoundsKey to the post-relayout bounds so subsequent ticks stay
quiet until real structure actually changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces 15 focused tests in visualization.spec.ts with a single
parameterized spec (templates.spec.ts) that, for each .cpp template,
boots the app, runs to the breakpoint at fastest speed, and diffs a
structured view-state snapshot against a per-template golden JSON.

The snapshot — exposed by index.vue via a dev-only window.__viz hook —
captures "what the three views should show":
- endianness
- pausedAt (line + node text)
- allocations (base/size/region/layout)
- liveBytes (per-alloc hex dump)
- pointerEdges (tree + extra + dangling)

These are the semantics that matter; pixel/layout/zoom details stay
out so snapshots don't churn on visual tweaks. Goldens live in
test/e2e/templates.spec.ts-snapshots/ and regenerate with
`bun run test:e2e -- --update-snapshots`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Goldens are pure JSON derived from a deterministic interpreter with no
platform-specific output, so Playwright's default darwin/linux split
would just force CI to regenerate the same bytes. A custom
snapshotPathTemplate strips the suffix; Darwin and Linux CI now share
a single checked-in golden per template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two targeted layout improvements that address messy DS-view placement
in deeply-branched trees and waste of arrow-size gutter space:

1. Tidy-tree Y allocation. `placeTreeChildren` now passes each branch's
   subtree leaf count × rowPitch as the sibling 'height' to
   placeRelative, instead of the child's physical card height. Deep
   branches reserve vertical space proportional to their descendant
   count, so a 3-deep-5-leaf subtree no longer lands in the root's row
   just because intermediate nodes each centered themselves by card
   height only. Forward edges only (skip direction='left' back-links)
   with a visit-guard cycle check.

2. Arrow-gutter pointer candidate. `tryPlacePointerNearTarget` now
   also probes the empty space between the target's tree parent and
   the target itself as a fifth candidate slot. Rewards large
   `@arrow-size` annotations with pointer cards that sit on the
   visualised arrow path rather than floating at the canvas edge.

Snapshots regenerated — payload shape didn't change (same
allocations/bytes/edges), so goldens are stable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously a pointer card placed in the first empty slot found early in
a run (often the top-left corner before the tree had grown) stayed put
forever, even after the target moved somewhere completely different.
Now, on every step, an already-placed pointer is re-evaluated against
fresh target-adjacent candidates; if the best free candidate is at
least RELOCATION_HYSTERESIS (40px) closer to the target than the
current slot, the card moves.

Hysteresis prevents oscillation when small target nudges would produce
nearly-equal candidate distances. Convergence: each render picks at
most one better slot, so a single bump-and-settle cycle is enough.

Factored the candidate-set generation out as pointerCandidatesAround
so initial placement (tryPlacePointerNearTarget) and relocation
(maybeRelocatePointerNearTarget) share identical geometry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the instant dark-mode swap with the animation from antfu.me —
toggleDark clicks now expand (going to light) or contract (going to
dark) a circular clip-path centered on the click, using the browser's
startViewTransition when available. Reduced-motion and unsupported
browsers fall back to the instant swap.

Implementation notes:
- Monaco's theme is swapped BEFORE startViewTransition so its canvas
  repaints in time for the new-state snapshot. Doing it via the
  existing watch() would fire on Vue's flush inside the transition
  callback, after the browser has already captured the new snapshot —
  that was causing a tail-end colour flash on light→dark.
- Awaiting rAF inside the transition callback deadlocks (the browser
  pauses rendering during the callback, so rAF never fires and the
  whole transition times out with 'Transition was aborted because of
  timeout in DOM update').
- CSS layers old/new pseudo-elements per theme direction so the
  correct one sits on top of the other, and disables the browser's
  default cross-fade so our clip-path animation is the only one that
  plays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n gap

The flash was visible because Vue's microtask scheduling added a gap
between transition.ready resolving and our .animate() attaching — for
~1 frame the pseudo-elements rendered in their static default state
(unclipped, both layers fully opaque, z-index deciding top), showing
the wrong state briefly before the clip animation started.

Adding `fill: 'both'` to the WAAPI options pins the animation's first
keyframe as soon as .animate() is called, so even if there's a paint
before the animation's time-based progression begins, the pseudo-
element already has the correct starting clip applied.

Also reverts the earlier Monaco pre-swap: that made Monaco flip
instantly (before the circle animation), which was a regression.
Monaco's theme now swaps inside the transition callback again via its
watcher, which works correctly — the flash was from the unclipped
start frame, not from Monaco's canvas timing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n art

Moves the help modal out of index.vue into its own component and
refreshes the content to match the current visualizer + editor UX:

- Each action description now renders the actual button mark inline
  (icon + styling via shared .icon-btn / .toolbar-group utility
  classes) so readers see exactly what to click, not a name they have
  to match back to the toolbar.
- Adds coverage for features added since the original write-up:
  breakpoint() pausing, Allocations/Bytes tabs, the 'arrows' toggle,
  LE/BE endianness switch, primitive-pointer arrows and their hover
  cross-highlight, canvas zoom (Cmd/Ctrl+scroll, pinch, zoom toolbar),
  the auto-compact + auto-scale-to-fit step behavior, and the
  @arrow-size gutter room for pointer cards.
- Drops the theme-toggle blurb (unrelated to visualizer/editor UX).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Struct allocations on the heap (and in stack cards that flowed through
MemoryCell) used to render array fields as a single summary row like
"keys [3]" — useful to know the shape, useless for seeing actual
contents. `getFieldValues` now flattens every array field into one
row per element (`keys[0]`, `keys[1]`, `children[0]`, etc.) so the
card mirrors the memory layout byte-for-byte. Nested arrays recurse
with subscript chaining.

Each flattened row carries its own address, which lets statement LHS /
RHS tinting propagate down to the specific element being written — a
step on `root->keys[1] = 20` now paints the `keys[1]` row blue
instead of the whole card.

MemoryCell gains the corresponding statementLhs/RhsAddresses props
and applies the same blue/green tint rules DSValue already used for
the DS view and stack rendering paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pointer-target auto-scroll was yanking the same column the user
was hovering in when a pointer and its target lived in the same
region — scrolling stack while the cursor was anchored to a stack
pointer, or heap-to-heap. That lost the hover context and usually the
arrow along with it.

Now mirrors the AllocationMap pattern: if hover.source is
'byte-stack' and the target is also on the stack, skip the scroll;
same for heap. Cross-region targets (the common case — stack pointer
to heap alloc) still auto-scroll as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@zojize
zojize merged commit 55868e5 into main Apr 22, 2026
6 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