feat(stammbaum): mobile read path — pan, zoom, fit-to-view (#692) #694
@@ -9,6 +9,7 @@ import {
|
|||||||
type Layout
|
type Layout
|
||||||
} from '$lib/person/genealogy/layout/buildLayout';
|
} from '$lib/person/genealogy/layout/buildLayout';
|
||||||
import { type PanZoomState, clampZoom, ZOOM_STEP_KB } from '$lib/person/genealogy/panZoom';
|
import { type PanZoomState, clampZoom, ZOOM_STEP_KB } from '$lib/person/genealogy/panZoom';
|
||||||
|
import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures';
|
||||||
|
|
||||||
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
@@ -67,6 +68,22 @@ $effect(() => {
|
|||||||
const gutterVisible = $derived(showGutter ?? isMdOrUp);
|
const gutterVisible = $derived(showGutter ?? isMdOrUp);
|
||||||
const gutterWidth = $derived(gutterVisible ? GUTTER_WIDTH_DESKTOP : 0);
|
const gutterWidth = $derived(gutterVisible ? GUTTER_WIDTH_DESKTOP : 0);
|
||||||
|
|
||||||
|
// Reduced-motion preference disables pan inertia and animated transitions
|
||||||
|
// (REQ-PAN-005). Seeded synchronously like the gutter state above.
|
||||||
|
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||||
|
let reducedMotion = $state(
|
||||||
|
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||||
|
? window.matchMedia(REDUCED_MOTION_QUERY).matches
|
||||||
|
: false
|
||||||
|
);
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||||
|
const mq = window.matchMedia(REDUCED_MOTION_QUERY);
|
||||||
|
const handler = (e: MediaQueryListEvent) => (reducedMotion = e.matches);
|
||||||
|
mq.addEventListener('change', handler);
|
||||||
|
return () => mq.removeEventListener('change', handler);
|
||||||
|
});
|
||||||
|
|
||||||
type GutterRow = { rank: number; y: number; label: number | null };
|
type GutterRow = { rank: number; y: number; label: number | null };
|
||||||
const gutterRows = $derived.by<GutterRow[]>(() => {
|
const gutterRows = $derived.by<GutterRow[]>(() => {
|
||||||
if (gutterWidth === 0) return [];
|
if (gutterWidth === 0) return [];
|
||||||
@@ -236,6 +253,15 @@ const parentLinks = $derived.by<ParentLinks>(() => {
|
|||||||
aria-label="Stammbaum"
|
aria-label="Stammbaum"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onkeydown={handleCanvasKey}
|
onkeydown={handleCanvasKey}
|
||||||
|
use:panZoomGestures={{
|
||||||
|
state: panZoom,
|
||||||
|
baseW: baseDims.w,
|
||||||
|
baseH: baseDims.h,
|
||||||
|
baseCentreX: baseCentre.x,
|
||||||
|
baseCentreY: baseCentre.y,
|
||||||
|
reducedMotion,
|
||||||
|
onPanZoom
|
||||||
|
}}
|
||||||
class="block h-full w-full"
|
class="block h-full w-full"
|
||||||
>
|
>
|
||||||
<!-- Gutter stripe underlay (#689) — decorative full-row bands alternating
|
<!-- Gutter stripe underlay (#689) — decorative full-row bands alternating
|
||||||
|
|||||||
@@ -549,6 +549,35 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
|
|||||||
expect(onPanZoom.mock.calls[0][0].y).toBeGreaterThan(0);
|
expect(onPanZoom.mock.calls[0][0].y).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('pans on a pointer drag and suppresses the trailing node click (US-PAN-001)', async () => {
|
||||||
|
const onPanZoom = vi.fn();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
render(StammbaumTree, {
|
||||||
|
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
||||||
|
edges: [],
|
||||||
|
selectedId: null,
|
||||||
|
// Zoomed in so panning is permitted (clampPan allows movement at z>1).
|
||||||
|
panZoom: { x: 0, y: 0, z: 2 },
|
||||||
|
onPanZoom,
|
||||||
|
onSelect
|
||||||
|
});
|
||||||
|
const svg = document.querySelector('svg')! as SVGSVGElement;
|
||||||
|
const node = document.querySelector('g[role="button"]') as SVGGElement;
|
||||||
|
|
||||||
|
const opts = (x: number) => ({ pointerId: 1, clientX: x, clientY: 100, bubbles: true });
|
||||||
|
svg.dispatchEvent(new PointerEvent('pointerdown', opts(100)));
|
||||||
|
svg.dispatchEvent(new PointerEvent('pointermove', opts(160)));
|
||||||
|
svg.dispatchEvent(new PointerEvent('pointerup', opts(160)));
|
||||||
|
|
||||||
|
expect(onPanZoom).toHaveBeenCalled();
|
||||||
|
// Dragging right reveals content to the left → pan x decreases.
|
||||||
|
expect(onPanZoom.mock.calls.at(-1)![0].x).toBeLessThan(0);
|
||||||
|
|
||||||
|
// The synthetic click after a real drag must not select the node.
|
||||||
|
node.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
expect(onSelect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('does not call onSelect for other keys', async () => {
|
it('does not call onSelect for other keys', async () => {
|
||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
render(StammbaumTree, {
|
render(StammbaumTree, {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
screenDeltaToSvg,
|
screenDeltaToSvg,
|
||||||
zoomAtPoint,
|
zoomAtPoint,
|
||||||
recentreOn,
|
recentreOn,
|
||||||
|
clampPan,
|
||||||
DEFAULT_VIEW,
|
DEFAULT_VIEW,
|
||||||
DEFAULT_ZOOM,
|
DEFAULT_ZOOM,
|
||||||
LEGIBLE_ZOOM,
|
LEGIBLE_ZOOM,
|
||||||
@@ -145,3 +146,23 @@ describe('recentreOn', () => {
|
|||||||
expect(recentreOn(node, base, { x: 0, y: 0, z: 0.4 }, false).z).toBe(0.4);
|
expect(recentreOn(node, base, { x: 0, y: 0, z: 0.4 }, false).z).toBe(0.4);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('clampPan', () => {
|
||||||
|
// Base frame is 1000 x 800.
|
||||||
|
it('forbids panning when the whole tree fits (z <= 1)', () => {
|
||||||
|
expect(clampPan({ x: 200, y: -100, z: 1 }, 1000, 800)).toEqual({ x: 0, y: 0, z: 1 });
|
||||||
|
expect(clampPan({ x: 50, y: 50, z: 0.5 }, 1000, 800)).toEqual({ x: 0, y: 0, z: 0.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows panning up to the edge when zoomed in (no infinite scroll)', () => {
|
||||||
|
// At z=2 the viewBox is 500 wide → limit = (1000 - 500) / 2 = 250.
|
||||||
|
expect(clampPan({ x: 1000, y: 0, z: 2 }, 1000, 800).x).toBe(250);
|
||||||
|
expect(clampPan({ x: -1000, y: 0, z: 2 }, 1000, 800).x).toBe(-250);
|
||||||
|
// Vertical limit at z=2: (800 - 400) / 2 = 200.
|
||||||
|
expect(clampPan({ x: 0, y: 999, z: 2 }, 1000, 800).y).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an in-range pan untouched', () => {
|
||||||
|
expect(clampPan({ x: 100, y: -50, z: 2 }, 1000, 800)).toEqual({ x: 100, y: -50, z: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -113,6 +113,20 @@ export function zoomAtPoint(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp the pan offset so the canvas cannot be dragged off the edge (US-PAN-001
|
||||||
|
* AC4 — no infinite scroll). The pannable range on each axis is half the
|
||||||
|
* difference between the base frame and the (smaller) zoomed viewBox; when the
|
||||||
|
* whole tree fits (z ≤ 1) the range collapses to zero, so the view stays centred.
|
||||||
|
*/
|
||||||
|
export function clampPan(state: PanZoomState, baseW: number, baseH: number): PanZoomState {
|
||||||
|
const clampAxis = (pan: number, base: number) => {
|
||||||
|
const limit = Math.max(0, (base - base / state.z) / 2);
|
||||||
|
return Math.min(limit, Math.max(-limit, pan)) || 0; // normalise -0 → 0
|
||||||
|
};
|
||||||
|
return { x: clampAxis(state.x, baseW), y: clampAxis(state.y, baseH), z: state.z };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pan so a node sits at the viewBox centre (US-PAN-005). Because the viewBox
|
* Pan so a node sits at the viewBox centre (US-PAN-005). Because the viewBox
|
||||||
* centre is `baseCentre + pan` independent of zoom, centring is a pure pan:
|
* centre is `baseCentre + pan` independent of zoom, centring is a pure pan:
|
||||||
|
|||||||
244
frontend/src/lib/person/genealogy/panZoomGestures.ts
Normal file
244
frontend/src/lib/person/genealogy/panZoomGestures.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import type { Action } from 'svelte/action';
|
||||||
|
import {
|
||||||
|
clampPan,
|
||||||
|
clampZoom,
|
||||||
|
screenDeltaToSvg,
|
||||||
|
zoomAtPoint,
|
||||||
|
ZOOM_STEP_KB,
|
||||||
|
type PanZoomState
|
||||||
|
} from '$lib/person/genealogy/panZoom';
|
||||||
|
|
||||||
|
export interface PanZoomGesturesParams {
|
||||||
|
/** The authoritative view state (re-synced at the start of each gesture). */
|
||||||
|
state: PanZoomState;
|
||||||
|
/** Base viewBox geometry at z=1 (includes the gutter) — see StammbaumTree. */
|
||||||
|
baseW: number;
|
||||||
|
baseH: number;
|
||||||
|
baseCentreX: number;
|
||||||
|
baseCentreY: number;
|
||||||
|
/** When true, inertia is skipped and motion stops on release (REQ-PAN-005). */
|
||||||
|
reducedMotion: boolean;
|
||||||
|
onPanZoom: (state: PanZoomState) => void;
|
||||||
|
/** Fired on the first pointer of a gesture (used to dismiss the affordance). */
|
||||||
|
onGestureStart?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pointer movement (px) below which a drag is treated as a tap, not a pan. */
|
||||||
|
const DRAG_THRESHOLD_PX = 4;
|
||||||
|
const INERTIA_DECAY = 0.92;
|
||||||
|
const INERTIA_MIN_SPEED = 0.02; // svg units per ms
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Touch/mouse/wheel pan & zoom for the Stammbaum canvas (#692). Thin DOM glue:
|
||||||
|
* all geometry is delegated to the pure helpers in `panZoom.ts`. One-finger
|
||||||
|
* drag and left-button drag pan; two-finger pinch and Ctrl+wheel zoom around the
|
||||||
|
* gesture centroid; plain wheel pans. Pan is edge-clamped and a real drag
|
||||||
|
* suppresses the trailing node click. Inertia decays after release unless the
|
||||||
|
* user prefers reduced motion.
|
||||||
|
*/
|
||||||
|
export const panZoomGestures: Action<SVGSVGElement, PanZoomGesturesParams> = (node, params) => {
|
||||||
|
let p = params;
|
||||||
|
let current = p.state;
|
||||||
|
|
||||||
|
const pointers = new Map<number, { x: number; y: number }>();
|
||||||
|
let dragging = false;
|
||||||
|
let moved = false;
|
||||||
|
let lastX = 0;
|
||||||
|
let lastY = 0;
|
||||||
|
let lastTime = 0;
|
||||||
|
let velX = 0;
|
||||||
|
let velY = 0;
|
||||||
|
let pinchStartDist = 0;
|
||||||
|
let pinchStartZoom = 1;
|
||||||
|
let inertiaFrame = 0;
|
||||||
|
let suppressClick = false;
|
||||||
|
|
||||||
|
const emit = (next: PanZoomState) => {
|
||||||
|
current = clampPan(next, p.baseW, p.baseH);
|
||||||
|
p.onPanZoom(current);
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewBoxW = () => p.baseW / current.z;
|
||||||
|
const viewBoxH = () => p.baseH / current.z;
|
||||||
|
|
||||||
|
// Convert a client point to its anchor offset from the base viewBox centre.
|
||||||
|
const anchorOffset = (clientX: number, clientY: number) => {
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
const w = viewBoxW();
|
||||||
|
const h = viewBoxH();
|
||||||
|
const fracX = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5;
|
||||||
|
const fracY = rect.height > 0 ? (clientY - rect.top) / rect.height : 0.5;
|
||||||
|
const svgX = p.baseCentreX + current.x - w / 2 + fracX * w;
|
||||||
|
const svgY = p.baseCentreY + current.y - h / 2 + fracY * h;
|
||||||
|
return { x: svgX - p.baseCentreX, y: svgY - p.baseCentreY };
|
||||||
|
};
|
||||||
|
|
||||||
|
const distance = (a: { x: number; y: number }, b: { x: number; y: number }) =>
|
||||||
|
Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
|
||||||
|
const cancelInertia = () => {
|
||||||
|
if (inertiaFrame) cancelAnimationFrame(inertiaFrame);
|
||||||
|
inertiaFrame = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runInertia = () => {
|
||||||
|
if (p.reducedMotion) return;
|
||||||
|
if (Math.hypot(velX, velY) < INERTIA_MIN_SPEED) return;
|
||||||
|
const step = () => {
|
||||||
|
const before = current;
|
||||||
|
emit({ ...current, x: current.x - velX * 16, y: current.y - velY * 16 });
|
||||||
|
velX *= INERTIA_DECAY;
|
||||||
|
velY *= INERTIA_DECAY;
|
||||||
|
const stalled = current.x === before.x && current.y === before.y;
|
||||||
|
if (!stalled && Math.hypot(velX, velY) >= INERTIA_MIN_SPEED) {
|
||||||
|
inertiaFrame = requestAnimationFrame(step);
|
||||||
|
} else {
|
||||||
|
inertiaFrame = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
inertiaFrame = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
|
cancelInertia();
|
||||||
|
try {
|
||||||
|
node.setPointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
/* pointer not capturable (e.g. synthetic event) — drag still works */
|
||||||
|
}
|
||||||
|
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
p.onGestureStart?.();
|
||||||
|
|
||||||
|
if (pointers.size === 1) {
|
||||||
|
current = p.state; // re-sync from the authoritative state
|
||||||
|
dragging = true;
|
||||||
|
moved = false;
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
lastTime = performance.now();
|
||||||
|
velX = 0;
|
||||||
|
velY = 0;
|
||||||
|
} else if (pointers.size === 2) {
|
||||||
|
const [a, b] = [...pointers.values()];
|
||||||
|
pinchStartDist = distance(a, b) || 1;
|
||||||
|
pinchStartZoom = current.z;
|
||||||
|
dragging = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: PointerEvent) => {
|
||||||
|
if (!pointers.has(e.pointerId)) return;
|
||||||
|
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
|
||||||
|
if (pointers.size >= 2) {
|
||||||
|
const [a, b] = [...pointers.values()];
|
||||||
|
const dist = distance(a, b) || 1;
|
||||||
|
const centroid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||||
|
const anchor = anchorOffset(centroid.x, centroid.y);
|
||||||
|
const targetZoom = clampZoom(pinchStartZoom * (dist / pinchStartDist));
|
||||||
|
emit(zoomAtPoint(current, targetZoom, anchor.x, anchor.y));
|
||||||
|
moved = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dragging) return;
|
||||||
|
const dxPx = e.clientX - lastX;
|
||||||
|
const dyPx = e.clientY - lastY;
|
||||||
|
if (!moved && Math.hypot(dxPx, dyPx) >= DRAG_THRESHOLD_PX) moved = true;
|
||||||
|
|
||||||
|
const { dx, dy } = screenDeltaToSvg(
|
||||||
|
dxPx,
|
||||||
|
dyPx,
|
||||||
|
viewBoxW(),
|
||||||
|
viewBoxH(),
|
||||||
|
node.clientWidth,
|
||||||
|
node.clientHeight
|
||||||
|
);
|
||||||
|
const now = performance.now();
|
||||||
|
const dt = Math.max(1, now - lastTime);
|
||||||
|
velX = dx / dt;
|
||||||
|
velY = dy / dt;
|
||||||
|
lastTime = now;
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
emit({ ...current, x: current.x - dx, y: current.y - dy });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = (e: PointerEvent) => {
|
||||||
|
pointers.delete(e.pointerId);
|
||||||
|
try {
|
||||||
|
if (node.hasPointerCapture?.(e.pointerId)) node.releasePointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
/* nothing to release */
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pointers.size === 0) {
|
||||||
|
if (dragging && moved) {
|
||||||
|
suppressClick = true;
|
||||||
|
runInertia();
|
||||||
|
}
|
||||||
|
dragging = false;
|
||||||
|
} else if (pointers.size === 1) {
|
||||||
|
// Dropped from pinch to a single pointer — resume a clean drag.
|
||||||
|
const [only] = [...pointers.entries()];
|
||||||
|
dragging = true;
|
||||||
|
moved = true;
|
||||||
|
lastX = only[1].x;
|
||||||
|
lastY = only[1].y;
|
||||||
|
lastTime = performance.now();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// A drag ends with a synthetic click on the node underneath; swallow it so a
|
||||||
|
// pan does not also select a person (US-PAN-001).
|
||||||
|
const onClickCapture = (e: MouseEvent) => {
|
||||||
|
if (suppressClick) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
suppressClick = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.ctrlKey) {
|
||||||
|
const factor = e.deltaY < 0 ? 1 + ZOOM_STEP_KB : 1 / (1 + ZOOM_STEP_KB);
|
||||||
|
const anchor = anchorOffset(e.clientX, e.clientY);
|
||||||
|
emit(zoomAtPoint(current, clampZoom(current.z * factor), anchor.x, anchor.y));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { dx, dy } = screenDeltaToSvg(
|
||||||
|
e.deltaX,
|
||||||
|
e.deltaY,
|
||||||
|
viewBoxW(),
|
||||||
|
viewBoxH(),
|
||||||
|
node.clientWidth,
|
||||||
|
node.clientHeight
|
||||||
|
);
|
||||||
|
emit({ ...current, x: current.x + dx, y: current.y + dy });
|
||||||
|
};
|
||||||
|
|
||||||
|
node.style.touchAction = 'none';
|
||||||
|
node.addEventListener('pointerdown', onPointerDown);
|
||||||
|
node.addEventListener('pointermove', onPointerMove);
|
||||||
|
node.addEventListener('pointerup', onPointerUp);
|
||||||
|
node.addEventListener('pointercancel', onPointerUp);
|
||||||
|
node.addEventListener('click', onClickCapture, true);
|
||||||
|
node.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(next: PanZoomGesturesParams) {
|
||||||
|
p = next;
|
||||||
|
if (!dragging && pointers.size === 0 && !inertiaFrame) current = next.state;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
cancelInertia();
|
||||||
|
node.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
node.removeEventListener('pointermove', onPointerMove);
|
||||||
|
node.removeEventListener('pointerup', onPointerUp);
|
||||||
|
node.removeEventListener('pointercancel', onPointerUp);
|
||||||
|
node.removeEventListener('click', onClickCapture, true);
|
||||||
|
node.removeEventListener('wheel', onWheel);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -98,7 +98,7 @@ function zoomOut() {
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-1 overflow-hidden">
|
<div class="flex flex-1 overflow-hidden">
|
||||||
<div class="flex-1 overflow-auto bg-muted/20">
|
<div class="flex-1 overflow-hidden bg-muted/20">
|
||||||
<StammbaumTree
|
<StammbaumTree
|
||||||
nodes={data.nodes}
|
nodes={data.nodes}
|
||||||
edges={data.edges}
|
edges={data.edges}
|
||||||
|
|||||||
Reference in New Issue
Block a user