feat(stammbaum): mobile read path — pan, zoom, fit-to-view (#692) #694
@@ -4,6 +4,7 @@ import {
|
|||||||
parsePanZoomParams,
|
parsePanZoomParams,
|
||||||
serializePanZoomParams,
|
serializePanZoomParams,
|
||||||
screenDeltaToSvg,
|
screenDeltaToSvg,
|
||||||
|
zoomAtPoint,
|
||||||
DEFAULT_VIEW,
|
DEFAULT_VIEW,
|
||||||
DEFAULT_ZOOM,
|
DEFAULT_ZOOM,
|
||||||
MIN_ZOOM,
|
MIN_ZOOM,
|
||||||
@@ -91,3 +92,31 @@ describe('screenDeltaToSvg', () => {
|
|||||||
expect(screenDeltaToSvg(10, 10, 1000, 800, 0, 0)).toEqual({ dx: 0, dy: 0 });
|
expect(screenDeltaToSvg(10, 10, 1000, 800, 0, 0)).toEqual({ dx: 0, dy: 0 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('zoomAtPoint', () => {
|
||||||
|
// The anchor is expressed as an offset (in SVG units) from the base viewBox
|
||||||
|
// centre. The fraction of the anchor across the viewBox must not change.
|
||||||
|
const anchorScreenFraction = (state: { x: number; z: number }, anchorOffsetX: number) => {
|
||||||
|
const baseW = 1000;
|
||||||
|
const w = baseW / state.z;
|
||||||
|
const centreOffset = anchorOffsetX - state.x; // anchor relative to viewBox centre
|
||||||
|
return centreOffset / w + 0.5;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('keeps the canvas centre fixed when the anchor is the centre', () => {
|
||||||
|
const next = zoomAtPoint({ x: 0, y: 0, z: 1 }, 2, 0, 0);
|
||||||
|
expect(next).toEqual({ x: 0, y: 0, z: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an off-centre anchor at the same screen position across a zoom-in', () => {
|
||||||
|
const before = { x: 0, y: 0, z: 1 };
|
||||||
|
const after = zoomAtPoint(before, 2, 100, 50);
|
||||||
|
expect(after.z).toBe(2);
|
||||||
|
expect(anchorScreenFraction(after, 100)).toBeCloseTo(anchorScreenFraction(before, 100), 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps the target zoom and makes no move when already at the bound', () => {
|
||||||
|
const next = zoomAtPoint({ x: 30, y: 10, z: MAX_ZOOM }, 99, 200, 200);
|
||||||
|
expect(next).toEqual({ x: 30, y: 10, z: MAX_ZOOM });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -81,3 +81,28 @@ export function screenDeltaToSvg(
|
|||||||
dy: elPxH > 0 ? dyPx * (viewBoxH / elPxH) : 0
|
dy: elPxH > 0 ? dyPx * (viewBoxH / elPxH) : 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoom to `newZoom` while keeping a given anchor point fixed on screen
|
||||||
|
* (pinch-centroid zoom — US-PAN-002 AC1 / US-PAN-003 AC1).
|
||||||
|
*
|
||||||
|
* `anchorX`/`anchorY` are the anchor point expressed as an offset, in SVG units,
|
||||||
|
* from the base viewBox centre. Because the viewBox width scales as `1/z`, the
|
||||||
|
* ratio of old-to-new width is exactly `z / newZoom` independent of the base
|
||||||
|
* size, so the new pan offset that preserves the anchor's screen fraction is
|
||||||
|
* `anchor - (anchor - pan) * (z / newZoom)`.
|
||||||
|
*/
|
||||||
|
export function zoomAtPoint(
|
||||||
|
state: PanZoomState,
|
||||||
|
newZoom: number,
|
||||||
|
anchorX: number,
|
||||||
|
anchorY: number
|
||||||
|
): PanZoomState {
|
||||||
|
const z = clampZoom(newZoom);
|
||||||
|
const ratio = state.z / z;
|
||||||
|
return {
|
||||||
|
x: anchorX - (anchorX - state.x) * ratio,
|
||||||
|
y: anchorY - (anchorY - state.y) * ratio,
|
||||||
|
z
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user