Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion docs/configuration/viewport.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ order: 6
| `zoomActivationKeyCode` | `string \| null` | `null` | Key that forces zoom-on-wheel, overriding `panOnScroll`. |
| `zoomOnDoubleClick` | `boolean \| 'step' \| 'toggle'` | `true` | `true`/`'step'` = d3's stepped zoom, `'toggle'` = jump-to-level and back, `false` = disabled. See [Double-click zoom](#double-click-zoom). |
| `dblClickZoomLevel` | `number` | `1.5` | Level `'toggle'` mode animates to. Clamped to `[minZoom, maxZoom]`; ignored in `'step'` mode. |
| `dblClickZoomOutLevel` | `number \| 'fit' \| 'min'` | `'min'` | Where `'toggle'` zooms out to when there is no remembered viewport. `'fit'` frames the whole graph; a number is a fixed level about the cursor. See [Double-click zoom](#double-click-zoom). |
| `zoomLevels` | `false \| object` | `{ far: 0.4, medium: 0.75 }` | Contextual zoom thresholds. Sets `data-zoom-level` attribute. See [Contextual zoom](../canvas/contextual-zoom.md). |
| `autoPanSpeed` | `number` | `15` | Auto-pan speed multiplier. |
| `autoPanOnConnect` | `boolean` | `true` | Auto-pan when drawing connections near canvas edge. |
Expand All @@ -41,9 +42,29 @@ flowCanvas({

If you reach the level some other way — the wheel, `setViewport()` — there is no remembered viewport to go back to, so a double-click there zooms out to `minZoom` about the cursor instead of doing nothing. Panning or zooming by hand discards the remembered viewport, so a later toggle-out never jumps to a view you have since left.

### Where the toggle zooms out to

That fallback is what `dblClickZoomOutLevel` sets. A remembered viewport always wins over it — the option only decides what happens when there is nothing to restore.

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

| Value | Second double-click goes to |
|-------|-----------------------------|
| `'min'` (default) | `minZoom`, about the cursor. |
| `'fit'` | The viewport that frames every visible node — what `fitView()` computes. On a canvas with nothing to fit (no nodes, or none measured yet), falls back to `'min'` rather than going dead. |
| `number` | That level, about the cursor. Clamped to `[minZoom, maxZoom]`. |

`'fit'` suits a canvas people read rather than survey — a workflow, a schema — where the gesture reads as "closer" and then "show me all of it", and where `minZoom` is an arbitrary floor that frames nothing in particular.

Two things to know about `'toggle'`:

- `dblClickZoomLevel` must sit above `minZoom`, otherwise there is no room to zoom back out into. If it does not (because clamping pushed it onto `minZoom`), AlpineFlow keeps d3's stepped handler rather than installing a gesture that would stall.
- `dblClickZoomLevel` must sit above the level it zooms back out to (`minZoom`, or a numeric `dblClickZoomOutLevel`), otherwise there is no room to zoom back out into. If it does not, AlpineFlow keeps d3's stepped handler rather than installing a gesture that would stall.
- Like `'step'`, it stays live under `zoomable: false` — that flag gates pointer-gesture zooming (wheel, pinch), never double-click, so a canvas that disables wheel zoom to run its own (e.g. pinch-only via `ctrl`+wheel) keeps the double-click gesture in either mode. Disable double-click zoom itself with `zoomOnDoubleClick: false`.

**`false`** — no double-click zoom at all.
Expand Down
75 changes: 75 additions & 0 deletions src/core/pan-zoom-dblclick.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,5 +213,80 @@ describe('double-click mode wiring', () => {
expect(changes).toBe(0);
expect(zoomOf(container).k).toBe(1);
});

/**
* `dblClickZoomOutLevel` only governs the branch with no remembered viewport —
* reached here by starting above the level, which is exactly how a canvas comes
* back from a restored or fitted viewport.
*/
describe('zoom-out fallback', () => {
it("puts the whole graph back on screen under 'fit'", async () => {
const fitViewport = { x: 24, y: -12, zoom: 0.6 };
create({
onTransformChange: () => {},
...toggleOpts,
dblClickZoomOutLevel: 'fit',
getFitViewport: () => fitViewport,
});
stubRect(container);

// Above the level with nothing remembered: a programmatic setViewport leaves
// no memory behind, same as arriving on a restored viewport.
instance!.setViewport({ x: 200, y: 100, zoom: 1.8 });

dblclickAt(container, 100, 50);
await settle();

const settled = zoomOf(container);
expect(settled.k).toBeCloseTo(fitViewport.zoom, 5);
expect(settled.x).toBeCloseTo(fitViewport.x, 5);
expect(settled.y).toBeCloseTo(fitViewport.y, 5);
});

it('does not measure the graph while the gesture is still zooming in', async () => {
let measured = 0;
create({
onTransformChange: () => {},
...toggleOpts,
dblClickZoomOutLevel: 'fit',
getFitViewport: () => { measured++; return { x: 0, y: 0, zoom: 0.6 }; },
});
stubRect(container);

dblclickAt(container, 100, 50); // from identity: zooms in to the level
await settle();

expect(zoomOf(container).k).toBeCloseTo(1.5, 5);
expect(measured).toBe(0);
});

it('falls back to d3 when a fixed out-level leaves the toggle no headroom', () => {
// Same rule as a level clamped onto minZoom: the second double-click would
// stall, so keep the stepped handler that still zooms both ways.
create({
onTransformChange: () => {},
zoomOnDoubleClick: 'toggle',
minZoom: 0.5,
maxZoom: 2,
dblClickZoomLevel: 1.5,
dblClickZoomOutLevel: 1.5,
});

expect(hasD3DblClickZoom(container)).toBe(true);
});

it('attaches the toggle when the fixed out-level sits below the level', () => {
create({
onTransformChange: () => {},
zoomOnDoubleClick: 'toggle',
minZoom: 0.5,
maxZoom: 2,
dblClickZoomLevel: 1.5,
dblClickZoomOutLevel: 1,
});

expect(hasD3DblClickZoom(container)).toBe(false);
});
});
});
});
95 changes: 95 additions & 0 deletions src/core/pan-zoom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,4 +204,99 @@ describe('resolveDblClickZoom — double-click is a toggle, not zoom-in-only', (
expect(next.zoom).toBe(2);
});
});

// Only the zoom-out fallback is configurable: it is the branch with no viewport to
// go back to. `minZoom` is a floor, which is the right answer for a canvas people
// survey and the wrong one for a canvas they read — there, "show me all of it"
// is the graph's own extent, not an arbitrary level.
describe('zoom-out fallback (dblClickZoomOutLevel)', () => {
const zoomedIn = { x: 40, y: 20, zoom: 2 };
const fitViewport = { x: 15, y: -8, zoom: 0.72 };

it("goes to the fitted viewport under 'fit', pan and all", () => {
const { next, remember } = resolveDblClickZoom(zoomedIn, { x: 100, y: 50 }, {
level: 1.5, minZoom: 0.5, remembered: null, zoomOut: 'fit', fit: () => fitViewport,
});

// The whole transform, not just the zoom: fitting is a pan as much as a scale,
// and zooming out about the cursor would leave the graph off-centre.
expect(next).toEqual(fitViewport);
expect(remember).toBeNull();
});

it('falls back to minZoom when there is nothing to fit', () => {
// An empty canvas, or one whose nodes have not been measured yet. minZoom is
// still a move, and a dead gesture is worse than a blunt one.
const { next } = resolveDblClickZoom(zoomedIn, { x: 100, y: 50 }, {
level: 1.5, minZoom: 0.5, remembered: null, zoomOut: 'fit', fit: () => null,
});

expect(next.zoom).toBe(0.5);
});

it('does not measure the graph on the double-click that zooms in', () => {
// The fit is a thunk for this reason: most double-clicks zoom in, and walking
// every node's bounds for a branch that will not run is work nobody asked for.
let measured = 0;
resolveDblClickZoom({ x: 0, y: 0, zoom: 1 }, { x: 100, y: 50 }, {
level: 1.5, minZoom: 0.5, remembered: null, zoomOut: 'fit', fit: () => { measured++; return fitViewport; },
});

expect(measured).toBe(0);
});

it('a remembered viewport still wins over the fallback', () => {
const remembered = { x: 1, y: 2, zoom: 0.9 };
const { next } = resolveDblClickZoom(zoomedIn, { x: 100, y: 50 }, {
level: 1.5, minZoom: 0.5, remembered, zoomOut: 'fit', fit: () => fitViewport,
});

expect(next).toEqual(remembered);
});

it('zooms out to a fixed number about the cursor', () => {
const pointer = { x: 100, y: 50 };
const { next } = resolveDblClickZoom(zoomedIn, pointer, {
level: 1.5, minZoom: 0.5, remembered: null, zoomOut: 0.8,
});

expect(next.zoom).toBe(0.8);
const before = { x: (pointer.x - zoomedIn.x) / zoomedIn.zoom, y: (pointer.y - zoomedIn.y) / zoomedIn.zoom };
const after = { x: (pointer.x - next.x) / next.zoom, y: (pointer.y - next.y) / next.zoom };
expect(after.x).toBeCloseTo(before.x, 10);
expect(after.y).toBeCloseTo(before.y, 10);
});

it('never zooms out below minZoom', () => {
const { next } = resolveDblClickZoom(zoomedIn, { x: 0, y: 0 }, {
level: 1.5, minZoom: 0.5, remembered: null, zoomOut: 0.1,
});

expect(next.zoom).toBe(0.5);
});

it('is honest when the fixed level leaves no room below the current zoom', () => {
// Same contract as the level === minZoom case above: hand back the very object,
// so a caller can tell "no move" from a transition.
const current = { x: 10, y: 20, zoom: 1.5 };
const { next, remember } = resolveDblClickZoom(current, { x: 100, y: 50 }, {
level: 1.5, minZoom: 0.5, remembered: null, zoomOut: 1.5,
});

expect(next).toBe(current);
expect(remember).toBeNull();
});

it("defaults to 'min', matching the behaviour before the option existed", () => {
const withOption = resolveDblClickZoom(zoomedIn, { x: 100, y: 50 }, {
level: 1.5, minZoom: 0.5, remembered: null, zoomOut: 'min',
});
const without = resolveDblClickZoom(zoomedIn, { x: 100, y: 50 }, {
level: 1.5, minZoom: 0.5, remembered: null,
});

expect(withOption.next).toEqual(without.next);
expect(without.next.zoom).toBe(0.5);
});
});
});
99 changes: 81 additions & 18 deletions src/core/pan-zoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,24 @@ export interface PanZoomOptions {
* zoom-in. Clamped to [minZoom, maxZoom]. Only consulted when
* `zoomOnDoubleClick: 'toggle'`. Default: 1.5 */
dblClickZoomLevel?: number;
/** Where a toggle double-click zooms out to when there is no remembered viewport
* to restore — the user reached this zoom by wheel or `setViewport`, or panned
* after zooming in and so dropped the memory.
*
* - `'min'` (default) — `minZoom`, about the cursor.
* - `'fit'` — the viewport that frames the whole graph, as `fitView()` computes
* it. Needs {@link PanZoomOptions.getFitViewport}; falls back to `'min'` when
* there is nothing to fit.
* - `number` — a fixed level, about the cursor. Clamped to [minZoom, maxZoom].
*
* Only consulted when `zoomOnDoubleClick: 'toggle'`; a remembered viewport always
* wins over it. Default: `'min'` */
dblClickZoomOutLevel?: number | 'fit' | 'min';
/** Viewport that frames the whole graph, or `null` when there is nothing to frame.
* Called only when a toggle double-click zooms out under
* `dblClickZoomOutLevel: 'fit'` — pan/zoom does not know about nodes, so the
* consumer supplies this. */
getFitViewport?: () => Viewport | null;
/** Key code that temporarily enables panning when held. Default: 'Space' */
panActivationKeyCode?: string | null;
/** Key code that forces zoom (overrides panOnScroll) when held with wheel. Default: 'Control'.
Expand Down Expand Up @@ -181,23 +199,38 @@ function scaleAbout(current: ViewportTransform, px: number, py: number, k: numbe
* so the next double-click can put it back exactly;
* - at or above `level` with a remembered viewport → restore it;
* - at or above `level` with nothing remembered (the user got here by wheel or
* `setViewport`) → zoom out to `minZoom` about the cursor, so the gesture always
* does something rather than stalling.
* `setViewport`, or panned after zooming in and so dropped the memory) → zoom out
* to `zoomOut`, so the gesture always does something rather than stalling.
*
* `zoomOut` is `'min'` by default — `minZoom` about the cursor. `'fit'` puts the
* whole graph back on screen instead, which is what the second double-click means
* on a canvas people read rather than survey; a number is a fixed level, again about
* the cursor. Only this fallback is configurable: with a remembered viewport, going
* back exactly where you came from beats any level anybody could name.
*
* `fit` is a thunk rather than a viewport because measuring every node's bounds is
* only worth doing on the branch that uses it, and most double-clicks zoom in.
*
* Pure so the decision can be tested without driving d3-zoom through a real DOM.
*
* Precondition: `level` must sit strictly above `minZoom`. A toggle needs somewhere
* to go — when the two coincide, "at or above the level" is also "already at the
* floor", so the zoom-out branch has no room and the gesture cannot move the
* viewport. That configuration is rejected at wiring time (createPanZoom keeps
* d3's native stepped handler instead of installing a toggle that would stall);
* should it reach here anyway, the current viewport is returned unchanged rather
* than a transform that merely looks new.
* Precondition: `level` must sit strictly above the level zooming out lands on. A
* toggle needs somewhere to go — when the two coincide, "at or above the level" is
* also "already at the floor", so the zoom-out branch has no room and the gesture
* cannot move the viewport. That configuration is rejected at wiring time
* (createPanZoom keeps d3's native stepped handler instead of installing a toggle
* that would stall); should it reach here anyway, the current viewport is returned
* unchanged rather than a transform that merely looks new.
*/
export function resolveDblClickZoom(
current: ViewportTransform,
pointer: { x: number; y: number },
opts: { level: number; minZoom: number; remembered: ViewportTransform | null },
opts: {
level: number;
minZoom: number;
remembered: ViewportTransform | null;
zoomOut?: number | 'fit' | 'min';
fit?: (() => ViewportTransform | null) | null;
},
): { next: ViewportTransform; remember: ViewportTransform | null } {
// Tolerance so floating-point drift at exactly `level` still counts as "at" it.
const atOrAboveLevel = current.zoom >= opts.level - 1e-3;
Expand All @@ -208,13 +241,28 @@ export function resolveDblClickZoom(
if (opts.remembered) {
return { next: opts.remembered, remember: null };
}
// Degenerate `level <= minZoom`: no headroom below, so zooming out would resolve

const zoomOut = opts.zoomOut ?? 'min';

if (zoomOut === 'fit') {
// A canvas with no measured nodes has no fit to go to; `minZoom` is still a
// move, so fall through to it rather than making the gesture dead on an empty
// canvas.
const fitted = opts.fit?.();
if (fitted) {
return { next: fitted, remember: null };
}
}

const outZoom = Math.max(opts.minZoom, zoomOut === 'fit' || zoomOut === 'min' ? opts.minZoom : zoomOut);

// Degenerate `level <= outZoom`: no headroom below, so zooming out would resolve
// to the identity of `current`. Say so explicitly instead of returning a no-op
// dressed up as a transition.
if (current.zoom <= opts.minZoom + 1e-3) {
if (current.zoom <= outZoom + 1e-3) {
return { next: current, remember: null };
}
return { next: scaleAbout(current, pointer.x, pointer.y, opts.minZoom), remember: null };
return { next: scaleAbout(current, pointer.x, pointer.y, outZoom), remember: null };
}

export function createPanZoom(
Expand Down Expand Up @@ -327,6 +375,11 @@ export function createPanZoom(
Math.min(maxZoom, options.dblClickZoomLevel ?? DEFAULT_DBLCLICK_ZOOM_LEVEL),
);

const dblClickZoomOutLevel: number | 'fit' | 'min' =
typeof options.dblClickZoomOutLevel === 'number'
? Math.max(minZoom, Math.min(maxZoom, options.dblClickZoomOutLevel))
: options.dblClickZoomOutLevel ?? 'min';

const dblClickHandler = (event: MouseEvent) => {
// Deliberately NOT gated on `zoomable`, mirroring createPanZoomFilter, which
// blocks wheel and pinch on `zoomable: false` but always lets dblclick through —
Expand All @@ -347,18 +400,28 @@ export function createPanZoom(
const { next, remember } = resolveDblClickZoom(
{ x: t.x, y: t.y, zoom: t.k },
{ x: event.clientX - rect.left, y: event.clientY - rect.top },
{ level: dblClickZoomLevel, minZoom, remembered: rememberedViewport },
{
level: dblClickZoomLevel,
minZoom,
remembered: rememberedViewport,
zoomOut: dblClickZoomOutLevel,
fit: options.getFitViewport ?? null,
},
);
rememberedViewport = remember;

sel.transition().duration(DBLCLICK_ZOOM_DURATION)
.call(zoomBehavior.transform, zoomIdentity.translate(next.x, next.y).scale(next.zoom));
};

// A toggle with `level <= minZoom` has no headroom to zoom back out into, so it
// would stall on the second double-click. Keep d3's stepped handler instead —
// it still zooms in and out — rather than installing a dead gesture.
const toggleAttached = dblClickMode === 'toggle' && dblClickZoomLevel > minZoom + 1e-3;
// A toggle whose level sits at or below the level it zooms back out to has no
// headroom, so it would stall on the second double-click. Keep d3's stepped
// handler instead — it still zooms in and out — rather than installing a dead
// gesture. `'fit'` is measured at gesture time and cannot be checked here, so it
// is held to the same floor as `'min'`; if the graph happens to fit above the
// level, the fallback inside resolveDblClickZoom keeps the gesture honest.
const dblClickZoomOutFloor = typeof dblClickZoomOutLevel === 'number' ? dblClickZoomOutLevel : minZoom;
const toggleAttached = dblClickMode === 'toggle' && dblClickZoomLevel > dblClickZoomOutFloor + 1e-3;

if (toggleAttached) {
sel.on('dblclick.zoom', null);
Expand Down
11 changes: 11 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1834,6 +1834,17 @@ export interface FlowCanvasConfig {
* have room to return to. Only consulted when `zoomOnDoubleClick: 'toggle'`.
* Default: 1.5 */
dblClickZoomLevel?: number;
/** Where a toggle double-click zooms out to when it has no remembered viewport to
* restore — the user reached this zoom by wheel or `setViewport`, or panned after
* zooming in, which drops the memory.
*
* - `'min'` (default) — `minZoom`, about the cursor.
* - `'fit'` — the whole graph back on screen, the viewport `fitView()` computes.
* - `number` — a fixed level, about the cursor. Clamped to [minZoom, maxZoom].
*
* A remembered viewport always wins over this. Only consulted when
* `zoomOnDoubleClick: 'toggle'`. Default: `'min'` */
dblClickZoomOutLevel?: number | 'fit' | 'min';

// ── Select on Drag ────────────────────────────────────────────────
/** Automatically select nodes when they start being dragged. Default: true */
Expand Down
Loading