Compare commits
4 Commits
c1dd6d299f
...
b170085311
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b170085311 | ||
|
|
d5a7974f3a | ||
|
|
53660eadc9 | ||
|
|
f4b631e1bc |
@@ -568,12 +568,15 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
|
|||||||
const opts = (x: number) => ({ pointerId: 1, clientX: x, clientY: 100, bubbles: true });
|
const opts = (x: number) => ({ pointerId: 1, clientX: x, clientY: 100, bubbles: true });
|
||||||
svg.dispatchEvent(new PointerEvent('pointerdown', opts(100)));
|
svg.dispatchEvent(new PointerEvent('pointerdown', opts(100)));
|
||||||
svg.dispatchEvent(new PointerEvent('pointermove', opts(160)));
|
svg.dispatchEvent(new PointerEvent('pointermove', opts(160)));
|
||||||
svg.dispatchEvent(new PointerEvent('pointerup', opts(160)));
|
|
||||||
|
|
||||||
|
// Assert on the move's emission *before* releasing: inertia starts on
|
||||||
|
// pointerup and could otherwise perturb the last recorded call.
|
||||||
expect(onPanZoom).toHaveBeenCalled();
|
expect(onPanZoom).toHaveBeenCalled();
|
||||||
// Dragging right reveals content to the left → pan x decreases.
|
// Dragging right reveals content to the left → pan x decreases.
|
||||||
expect(onPanZoom.mock.calls.at(-1)![0].x).toBeLessThan(0);
|
expect(onPanZoom.mock.calls.at(-1)![0].x).toBeLessThan(0);
|
||||||
|
|
||||||
|
svg.dispatchEvent(new PointerEvent('pointerup', opts(160)));
|
||||||
|
|
||||||
// The synthetic click after a real drag must not select the node.
|
// The synthetic click after a real drag must not select the node.
|
||||||
node.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
node.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
expect(onSelect).not.toHaveBeenCalled();
|
expect(onSelect).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
serializePanZoomParams,
|
serializePanZoomParams,
|
||||||
screenDeltaToSvg,
|
screenDeltaToSvg,
|
||||||
zoomAtPoint,
|
zoomAtPoint,
|
||||||
|
pinchZoom,
|
||||||
|
stepInertia,
|
||||||
recentreOn,
|
recentreOn,
|
||||||
clampPan,
|
clampPan,
|
||||||
lerpView,
|
lerpView,
|
||||||
@@ -133,6 +135,35 @@ describe('zoomAtPoint', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('pinchZoom', () => {
|
||||||
|
it('scales zoom by the finger-distance ratio around the centroid', () => {
|
||||||
|
// Fingers spread 100→200 → 2× zoom; centroid at canvas centre → no pan.
|
||||||
|
expect(pinchZoom({ x: 0, y: 0, z: 1 }, 1, 100, 200, 0, 0)).toEqual({ x: 0, y: 0, z: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zooms out when fingers pinch together', () => {
|
||||||
|
expect(pinchZoom({ x: 0, y: 0, z: 2 }, 2, 200, 100, 0, 0).z).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps the scaled zoom into bounds', () => {
|
||||||
|
expect(pinchZoom({ x: 0, y: 0, z: 2 }, 2, 100, 1000, 0, 0).z).toBe(MAX_ZOOM);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a zero start distance as no zoom change', () => {
|
||||||
|
expect(pinchZoom({ x: 5, y: 5, z: 1.5 }, 1.5, 0, 200, 0, 0).z).toBe(1.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stepInertia', () => {
|
||||||
|
it('advances the pan by velocity × frame duration in the drag direction', () => {
|
||||||
|
expect(stepInertia({ x: 100, y: 50, z: 1 }, 0.5, 0.25, 16)).toEqual({ x: 92, y: 46, z: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves zoom untouched', () => {
|
||||||
|
expect(stepInertia({ x: 0, y: 0, z: 2.5 }, 1, 1, 16).z).toBe(2.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('recentreOn', () => {
|
describe('recentreOn', () => {
|
||||||
const node = { x: 300, y: 200 };
|
const node = { x: 300, y: 200 };
|
||||||
const base = { x: 100, y: 100 };
|
const base = { x: 100, y: 100 };
|
||||||
|
|||||||
@@ -130,6 +130,43 @@ export function zoomAtPoint(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Assumed milliseconds per animation frame, used to scale inertia velocity. */
|
||||||
|
export const FRAME_MS = 16;
|
||||||
|
/** Per-frame velocity decay for pan inertia (OQ-004). */
|
||||||
|
export const INERTIA_DECAY = 0.92;
|
||||||
|
/** Inertia stops once the velocity (svg units per ms) drops below this. */
|
||||||
|
export const INERTIA_MIN_SPEED = 0.02;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinch zoom around the gesture centroid (US-PAN-002/003). The new zoom is the
|
||||||
|
* start zoom scaled by the finger-distance ratio (clamped); the anchor offset
|
||||||
|
* keeps the centroid fixed via {@link zoomAtPoint}.
|
||||||
|
*/
|
||||||
|
export function pinchZoom(
|
||||||
|
state: PanZoomState,
|
||||||
|
startZoom: number,
|
||||||
|
startDist: number,
|
||||||
|
currentDist: number,
|
||||||
|
anchorX: number,
|
||||||
|
anchorY: number
|
||||||
|
): PanZoomState {
|
||||||
|
const ratio = startDist > 0 ? currentDist / startDist : 1;
|
||||||
|
return zoomAtPoint(state, clampZoom(startZoom * ratio), anchorX, anchorY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance the pan by one inertia frame: continue the release velocity (svg units
|
||||||
|
* per ms) in the drag direction, scaled by the frame duration. Zoom is untouched.
|
||||||
|
*/
|
||||||
|
export function stepInertia(
|
||||||
|
state: PanZoomState,
|
||||||
|
velX: number,
|
||||||
|
velY: number,
|
||||||
|
frameMs: number = FRAME_MS
|
||||||
|
): PanZoomState {
|
||||||
|
return { x: state.x - velX * frameMs, y: state.y - velY * frameMs, z: state.z };
|
||||||
|
}
|
||||||
|
|
||||||
/** Linearly interpolate between two view states (drives fit/recentre tweening). */
|
/** Linearly interpolate between two view states (drives fit/recentre tweening). */
|
||||||
export function lerpView(from: PanZoomState, to: PanZoomState, t: number): PanZoomState {
|
export function lerpView(from: PanZoomState, to: PanZoomState, t: number): PanZoomState {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import {
|
|||||||
clampZoom,
|
clampZoom,
|
||||||
screenDeltaToSvg,
|
screenDeltaToSvg,
|
||||||
zoomAtPoint,
|
zoomAtPoint,
|
||||||
|
pinchZoom,
|
||||||
|
stepInertia,
|
||||||
|
FRAME_MS,
|
||||||
|
INERTIA_DECAY,
|
||||||
|
INERTIA_MIN_SPEED,
|
||||||
ZOOM_STEP_KB,
|
ZOOM_STEP_KB,
|
||||||
type PanZoomState
|
type PanZoomState
|
||||||
} from '$lib/person/genealogy/panZoom';
|
} from '$lib/person/genealogy/panZoom';
|
||||||
@@ -25,8 +30,6 @@ export interface PanZoomGesturesParams {
|
|||||||
|
|
||||||
/** Pointer movement (px) below which a drag is treated as a tap, not a pan. */
|
/** Pointer movement (px) below which a drag is treated as a tap, not a pan. */
|
||||||
const DRAG_THRESHOLD_PX = 4;
|
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:
|
* Touch/mouse/wheel pan & zoom for the Stammbaum canvas (#692). Thin DOM glue:
|
||||||
@@ -86,7 +89,7 @@ export const panZoomGestures: Action<SVGSVGElement, PanZoomGesturesParams> = (no
|
|||||||
if (Math.hypot(velX, velY) < INERTIA_MIN_SPEED) return;
|
if (Math.hypot(velX, velY) < INERTIA_MIN_SPEED) return;
|
||||||
const step = () => {
|
const step = () => {
|
||||||
const before = current;
|
const before = current;
|
||||||
emit({ ...current, x: current.x - velX * 16, y: current.y - velY * 16 });
|
emit(stepInertia(current, velX, velY, FRAME_MS));
|
||||||
velX *= INERTIA_DECAY;
|
velX *= INERTIA_DECAY;
|
||||||
velY *= INERTIA_DECAY;
|
velY *= INERTIA_DECAY;
|
||||||
const stalled = current.x === before.x && current.y === before.y;
|
const stalled = current.x === before.x && current.y === before.y;
|
||||||
@@ -101,11 +104,11 @@ export const panZoomGestures: Action<SVGSVGElement, PanZoomGesturesParams> = (no
|
|||||||
|
|
||||||
const onPointerDown = (e: PointerEvent) => {
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
cancelInertia();
|
cancelInertia();
|
||||||
try {
|
// NB: do NOT capture the pointer here — capturing on pointerdown makes the
|
||||||
node.setPointerCapture(e.pointerId);
|
// browser dispatch the trailing `click` at this element instead of the
|
||||||
} catch {
|
// node under the pointer, which silently breaks node selection (a tap must
|
||||||
/* pointer not capturable (e.g. synthetic event) — drag still works */
|
// still reach the node's onclick). Capture is deferred to the first move
|
||||||
}
|
// that crosses the drag threshold (see onPointerMove).
|
||||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
p.onGestureStart?.();
|
p.onGestureStart?.();
|
||||||
|
|
||||||
@@ -135,8 +138,7 @@ export const panZoomGestures: Action<SVGSVGElement, PanZoomGesturesParams> = (no
|
|||||||
const dist = distance(a, b) || 1;
|
const dist = distance(a, b) || 1;
|
||||||
const centroid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
const centroid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||||
const anchor = anchorOffset(centroid.x, centroid.y);
|
const anchor = anchorOffset(centroid.x, centroid.y);
|
||||||
const targetZoom = clampZoom(pinchStartZoom * (dist / pinchStartDist));
|
emit(pinchZoom(current, pinchStartZoom, pinchStartDist, dist, anchor.x, anchor.y));
|
||||||
emit(zoomAtPoint(current, targetZoom, anchor.x, anchor.y));
|
|
||||||
moved = true;
|
moved = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -144,7 +146,17 @@ export const panZoomGestures: Action<SVGSVGElement, PanZoomGesturesParams> = (no
|
|||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
const dxPx = e.clientX - lastX;
|
const dxPx = e.clientX - lastX;
|
||||||
const dyPx = e.clientY - lastY;
|
const dyPx = e.clientY - lastY;
|
||||||
if (!moved && Math.hypot(dxPx, dyPx) >= DRAG_THRESHOLD_PX) moved = true;
|
if (!moved && Math.hypot(dxPx, dyPx) >= DRAG_THRESHOLD_PX) {
|
||||||
|
// A real drag has started — now capture so we keep receiving move/up
|
||||||
|
// even if the pointer leaves the canvas. (Deferred from pointerdown so
|
||||||
|
// taps still select nodes.)
|
||||||
|
moved = true;
|
||||||
|
try {
|
||||||
|
node.setPointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
/* pointer not capturable (e.g. synthetic event) — drag still works */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { dx, dy } = screenDeltaToSvg(
|
const { dx, dy } = screenDeltaToSvg(
|
||||||
dxPx,
|
dxPx,
|
||||||
|
|||||||
@@ -57,4 +57,19 @@ describe('trapFocus action', () => {
|
|||||||
// No trap after destroy → focus stays on the last button.
|
// No trap after destroy → focus stays on the last button.
|
||||||
expect(document.activeElement).toBe(buttons[1]);
|
expect(document.activeElement).toBe(buttons[1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('restores focus to the previously-focused element on destroy (WCAG 2.4.3)', () => {
|
||||||
|
const opener = document.createElement('button');
|
||||||
|
document.body.appendChild(opener);
|
||||||
|
nodes.push(opener);
|
||||||
|
opener.focus();
|
||||||
|
expect(document.activeElement).toBe(opener);
|
||||||
|
|
||||||
|
const { node } = makeContainer(['one', 'two']);
|
||||||
|
const handle = trapFocus(node);
|
||||||
|
expect(document.activeElement).not.toBe(opener);
|
||||||
|
|
||||||
|
handle.destroy();
|
||||||
|
expect(document.activeElement).toBe(opener);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const FOCUSABLE_SELECTOR = [
|
|||||||
].join(',');
|
].join(',');
|
||||||
|
|
||||||
export function trapFocus(node: HTMLElement) {
|
export function trapFocus(node: HTMLElement) {
|
||||||
|
// Remember what had focus so it can be restored when the overlay closes
|
||||||
|
// (WCAG 2.4.3 — don't strand keyboard/AT users at the top of the page).
|
||||||
|
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||||
const focusable = () => Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
|
const focusable = () => Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
|
||||||
|
|
||||||
function onKeydown(event: KeyboardEvent) {
|
function onKeydown(event: KeyboardEvent) {
|
||||||
@@ -38,6 +41,7 @@ export function trapFocus(node: HTMLElement) {
|
|||||||
return {
|
return {
|
||||||
destroy() {
|
destroy() {
|
||||||
node.removeEventListener('keydown', onKeydown);
|
node.removeEventListener('keydown', onKeydown);
|
||||||
|
previouslyFocused?.focus?.();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user