Skip to content

feat(pan-zoom): configurable zoom-out for the double-click toggle - #83

Open
webard wants to merge 1 commit into
getartisanflow:devfrom
webard:feat/dblclick-zoom-out-level
Open

feat(pan-zoom): configurable zoom-out for the double-click toggle#83
webard wants to merge 1 commit into
getartisanflow:devfrom
webard:feat/dblclick-zoom-out-level

Conversation

@webard

@webard webard commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

What

dblClickZoomOutLevel — where a zoomOnDoubleClick: 'toggle' double-click zooms out to when it has no remembered viewport to restore.

flowCanvas({
  zoomOnDoubleClick: 'toggle',
  dblClickZoomLevel: 1,        // double-click → 100% about the cursor
  dblClickZoomOutLevel: 'fit', // double-click again → the whole graph
})

Why

The toggle's headline behaviour — second double-click restores the exact viewport the first one left — is the right answer whenever there is a viewport to restore. The branch with nothing remembered is reached more often than it sounds: the user got to this zoom by wheel or setViewport(), or panned after zooming in, which drops the memory by design (rememberedViewport = null on any user-sourced 'start').

That branch went to minZoom, hard-coded. The only lever on it was minZoom itself — the same floor the wheel and pinch answer to — so "zoom out to something sensible on double-click" and "let the wheel go down to 0.5" could not both be had.

Where this came from: a workflow editor at dblClickZoomLevel: 1. The gesture reads as closer, then show me all of it — and "all of it" is the graph's own extent, not an arbitrary floor that frames nothing in particular.

Shape

  • 'min' (default) — minZoom about the cursor, byte-for-byte the old behaviour.
  • 'fit' — the viewport fitView() computes for every visible node. Falls back to 'min' when there is nothing to fit (empty canvas, nothing measured yet), so the gesture never goes dead.
  • number — a fixed level about the cursor, clamped to [minZoom, maxZoom].

A remembered viewport still wins over all three; the option only names the fallback.

pan-zoom knows nothing about nodes, so 'fit' asks for the viewport through a new getFitViewport?: () => Viewport | null option, wired in _initPanZoom from getNodesBounds() + getViewportForBounds() — the same pair fitView() uses, with the same DEFAULT_FIT_PADDING. It is passed to resolveDblClickZoom as a thunk: most double-clicks zoom in, and walking every node's bounds for a branch that will not run is work nobody asked for.

The no-headroom guard now measures against whatever the toggle zooms out to rather than always minZoom, so dblClickZoomLevel: 1.5 with dblClickZoomOutLevel: 1.5 keeps d3's stepped handler instead of installing a gesture that stalls on its second half — same rule as the existing level <= minZoom case. 'fit' is measured at gesture time and can't be checked statically, so it is held to the 'min' floor and the runtime fallback covers the rest.

Tests

src/core/pan-zoom.test.ts — eight cases on the pure resolveDblClickZoom: 'fit' returns the fitted viewport pan and all; the fallback to minZoom when there is nothing to fit; the thunk is not called on a zoom-in; a remembered viewport still wins; a numeric level zooms out about the cursor and clamps to minZoom; an out-level with no room below returns the current viewport by reference (the existing "honest no-op" contract); and the default matches the pre-option behaviour exactly.

src/core/pan-zoom-dblclick.test.ts — four in the wiring suite, two driving a real DOM double-click through the d3 transition: 'fit' settles on the supplied viewport, no measuring while the gesture zooms in, and the attach/skip decision for a numeric out-level.

npm run test — 187 files, 3044 tests, all passing. Five of the new cases fail against dev without the source change.

Not included

dist/ is not rebuilt — source, tests and docs only. No version bump, no CHANGELOG.md entry, no new dependencies.

Independent of #82; both branch off dev and touch different files.

In `zoomOnDoubleClick: 'toggle'`, the second double-click restores the exact
viewport the first one left — unless there is nothing to restore, which happens
more often than it sounds: the user reached this zoom by wheel or
`setViewport()`, or panned after zooming in, which drops the memory by design.
That branch went to `minZoom`, hard-coded, and the only way to move it was to
raise `minZoom` itself — the same floor the wheel and pinch answer to.

`dblClickZoomOutLevel` names that fallback alone; a remembered viewport still
wins over it.

- `'min'` (default) — `minZoom` about the cursor, exactly as before.
- `'fit'` — the viewport that frames every visible node, the one `fitView()`
  computes. On a canvas people read rather than survey, the gesture means
  "closer", then "show me all of it", and `minZoom` frames nothing in
  particular. Falls back to `'min'` when there is nothing to fit, so an empty
  canvas keeps a live gesture.
- a number — a fixed level about the cursor, clamped to [minZoom, maxZoom].

`pan-zoom` knows nothing about nodes, so `'fit'` asks for the viewport through
a new `getFitViewport` option, wired in `_initPanZoom` from `getNodesBounds()`
+ `getViewportForBounds()`. It is a thunk: most double-clicks zoom in, and
walking every node's bounds for a branch that will not run is work nobody asked
for.

The no-headroom guard now measures against whatever the toggle zooms out to
rather than always `minZoom`, so a numeric out-level at or above the zoom-in
level falls back to d3's stepped handler instead of installing a gesture that
would stall on its second half.
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