From 5458ca9bae0398556c5154afcd86cdbd296cf12d Mon Sep 17 00:00:00 2001
From: Marcel
Date: Fri, 29 May 2026 16:23:47 +0200
Subject: [PATCH 01/39] =?UTF-8?q?feat(stammbaum):=20add=20clampZoom=20with?=
=?UTF-8?q?=20resolved=200.25=E2=80=933.0=20zoom=20bounds=20(#692)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.8
---
.../src/lib/person/genealogy/panZoom.test.ts | 26 +++++++++++++++++++
frontend/src/lib/person/genealogy/panZoom.ts | 19 ++++++++++++++
2 files changed, 45 insertions(+)
create mode 100644 frontend/src/lib/person/genealogy/panZoom.test.ts
create mode 100644 frontend/src/lib/person/genealogy/panZoom.ts
diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts
new file mode 100644
index 00000000..509acdd8
--- /dev/null
+++ b/frontend/src/lib/person/genealogy/panZoom.test.ts
@@ -0,0 +1,26 @@
+import { describe, it, expect } from 'vitest';
+import { clampZoom, MIN_ZOOM, MAX_ZOOM } from './panZoom';
+
+describe('clampZoom', () => {
+ it('returns the value unchanged when within range', () => {
+ expect(clampZoom(1)).toBe(1);
+ expect(clampZoom(0.5)).toBe(0.5);
+ expect(clampZoom(2.75)).toBe(2.75);
+ });
+
+ it('clamps below MIN_ZOOM up to MIN_ZOOM', () => {
+ expect(clampZoom(0.1)).toBe(MIN_ZOOM);
+ expect(clampZoom(0)).toBe(MIN_ZOOM);
+ expect(clampZoom(-5)).toBe(MIN_ZOOM);
+ });
+
+ it('clamps above MAX_ZOOM down to MAX_ZOOM', () => {
+ expect(clampZoom(5)).toBe(MAX_ZOOM);
+ expect(clampZoom(3.0001)).toBe(MAX_ZOOM);
+ });
+
+ it('exposes the resolved zoom bounds', () => {
+ expect(MIN_ZOOM).toBe(0.25);
+ expect(MAX_ZOOM).toBe(3.0);
+ });
+});
diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts
new file mode 100644
index 00000000..7b107440
--- /dev/null
+++ b/frontend/src/lib/person/genealogy/panZoom.ts
@@ -0,0 +1,19 @@
+/**
+ * Pan/zoom geometry for the Stammbaum canvas (#692).
+ *
+ * The Stammbaum renders zoom by deriving the SVG `viewBox` rather than applying
+ * a CSS transform (see `StammbaumTree.svelte`). This module is the single source
+ * of truth for the zoom bounds, the view-state shape, and every pure geometry
+ * helper used by the gesture action, the URL serialiser, and the page. Keeping
+ * the math here (and free of DOM access) makes it unit-testable in the node
+ * project. See ADR-026 for why this is custom rather than a third-party library.
+ */
+
+/** Resolved zoom bounds (OQ-001). */
+export const MIN_ZOOM = 0.25;
+export const MAX_ZOOM = 3.0;
+
+/** Clamp a zoom factor into the supported range. */
+export function clampZoom(z: number): number {
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
+}
--
2.49.1
From a7d0e96613710a99c84d56e4d52b7eb2dd9e7127 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Fri, 29 May 2026 16:25:11 +0200
Subject: [PATCH 02/39] feat(stammbaum): parse + sanitise URL pan/zoom params
(#692)
Degrade Infinity/NaN/overflow per axis and clamp zoom into bounds so a crafted
?cx/?cy/?z shared link cannot blank the SVG (Nora's review).
Co-Authored-By: Claude Opus 4.8
---
.../src/lib/person/genealogy/panZoom.test.ts | 37 +++++++++++++++++-
frontend/src/lib/person/genealogy/panZoom.ts | 39 +++++++++++++++++++
2 files changed, 75 insertions(+), 1 deletion(-)
diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts
index 509acdd8..fed80448 100644
--- a/frontend/src/lib/person/genealogy/panZoom.test.ts
+++ b/frontend/src/lib/person/genealogy/panZoom.test.ts
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest';
-import { clampZoom, MIN_ZOOM, MAX_ZOOM } from './panZoom';
+import {
+ clampZoom,
+ parsePanZoomParams,
+ DEFAULT_VIEW,
+ DEFAULT_ZOOM,
+ MIN_ZOOM,
+ MAX_ZOOM
+} from './panZoom';
describe('clampZoom', () => {
it('returns the value unchanged when within range', () => {
@@ -24,3 +31,31 @@ describe('clampZoom', () => {
expect(MAX_ZOOM).toBe(3.0);
});
});
+
+describe('parsePanZoomParams', () => {
+ it('parses well-formed cx/cy/z params', () => {
+ expect(parsePanZoomParams({ cx: '120', cy: '-40', z: '1.5' })).toEqual({
+ x: 120,
+ y: -40,
+ z: 1.5
+ });
+ });
+
+ it('falls back to DEFAULT_VIEW when params are absent', () => {
+ expect(parsePanZoomParams({})).toEqual(DEFAULT_VIEW);
+ expect(DEFAULT_VIEW).toEqual({ x: 0, y: 0, z: DEFAULT_ZOOM });
+ });
+
+ it('rejects Infinity and NaN, degrading each axis to its default (Nora #692)', () => {
+ expect(parsePanZoomParams({ z: 'Infinity' }).z).toBe(DEFAULT_ZOOM);
+ expect(parsePanZoomParams({ z: 'NaN' }).z).toBe(DEFAULT_ZOOM);
+ expect(parsePanZoomParams({ cx: 'NaN', cy: 'Infinity' })).toEqual(DEFAULT_VIEW);
+ expect(parsePanZoomParams({ cx: '1e500' }).x).toBe(0);
+ });
+
+ it('clamps an out-of-range zoom into the supported bounds', () => {
+ expect(parsePanZoomParams({ z: '99' }).z).toBe(MAX_ZOOM);
+ expect(parsePanZoomParams({ z: '0.01' }).z).toBe(MIN_ZOOM);
+ expect(parsePanZoomParams({ z: '-3' }).z).toBe(MIN_ZOOM);
+ });
+});
diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts
index 7b107440..1ec304ed 100644
--- a/frontend/src/lib/person/genealogy/panZoom.ts
+++ b/frontend/src/lib/person/genealogy/panZoom.ts
@@ -12,8 +12,47 @@
/** Resolved zoom bounds (OQ-001). */
export const MIN_ZOOM = 0.25;
export const MAX_ZOOM = 3.0;
+export const DEFAULT_ZOOM = 1;
+
+/**
+ * The canvas view state. `x`/`y` are pan offsets applied to the viewBox centre
+ * (SVG user units); `z` is the zoom factor. The default `{0, 0, 1}` frames the
+ * whole tree (fit-to-screen) because the base viewBox already encloses the
+ * layout bounding box at z=1.
+ */
+export type PanZoomState = { x: number; y: number; z: number };
+
+/** Fit-to-screen / initial view (US-PAN-004). */
+export const DEFAULT_VIEW: PanZoomState = { x: 0, y: 0, z: DEFAULT_ZOOM };
/** Clamp a zoom factor into the supported range. */
export function clampZoom(z: number): number {
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
}
+
+/** Parse a raw value to a finite number, or return `fallback` for NaN/Infinity/absent. */
+function finiteOr(raw: string | null | undefined, fallback: number): number {
+ if (raw == null) return fallback;
+ const n = Number(raw);
+ return Number.isFinite(n) ? n : fallback;
+}
+
+/**
+ * Parse URL-supplied pan/zoom params into a safe {@link PanZoomState} (OQ-003).
+ *
+ * Every axis is sanitised independently: `Infinity`, `NaN`, overflow (`1e500`),
+ * and absent values degrade to the default for that axis, and the zoom is
+ * clamped into [MIN_ZOOM, MAX_ZOOM]. This guards against a crafted shared link
+ * (`?z=Infinity`, `?cx=NaN`) rendering the SVG blank — see Nora's review (#692).
+ */
+export function parsePanZoomParams(raw: {
+ cx?: string | null;
+ cy?: string | null;
+ z?: string | null;
+}): PanZoomState {
+ return {
+ x: finiteOr(raw.cx, DEFAULT_VIEW.x),
+ y: finiteOr(raw.cy, DEFAULT_VIEW.y),
+ z: clampZoom(finiteOr(raw.z, DEFAULT_ZOOM))
+ };
+}
--
2.49.1
From 369a0213e5a4321175f7bee5b40461cd975ff541 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Fri, 29 May 2026 16:26:07 +0200
Subject: [PATCH 03/39] feat(stammbaum): serialise pan/zoom state to URL params
(#692)
Co-Authored-By: Claude Opus 4.8
---
.../src/lib/person/genealogy/panZoom.test.ts | 16 ++++++++++++++++
frontend/src/lib/person/genealogy/panZoom.ts | 5 +++++
2 files changed, 21 insertions(+)
diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts
index fed80448..4e1a8fd6 100644
--- a/frontend/src/lib/person/genealogy/panZoom.test.ts
+++ b/frontend/src/lib/person/genealogy/panZoom.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import {
clampZoom,
parsePanZoomParams,
+ serializePanZoomParams,
DEFAULT_VIEW,
DEFAULT_ZOOM,
MIN_ZOOM,
@@ -59,3 +60,18 @@ describe('parsePanZoomParams', () => {
expect(parsePanZoomParams({ z: '-3' }).z).toBe(MIN_ZOOM);
});
});
+
+describe('serializePanZoomParams', () => {
+ it('produces string cx/cy/z keys', () => {
+ expect(serializePanZoomParams({ x: 120, y: -40, z: 1.5 })).toEqual({
+ cx: '120',
+ cy: '-40',
+ z: '1.5'
+ });
+ });
+
+ it('round-trips through parsePanZoomParams', () => {
+ const state = { x: 87.5, y: -12.25, z: 2.4 };
+ expect(parsePanZoomParams(serializePanZoomParams(state))).toEqual(state);
+ });
+});
diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts
index 1ec304ed..714764f5 100644
--- a/frontend/src/lib/person/genealogy/panZoom.ts
+++ b/frontend/src/lib/person/genealogy/panZoom.ts
@@ -56,3 +56,8 @@ export function parsePanZoomParams(raw: {
z: clampZoom(finiteOr(raw.z, DEFAULT_ZOOM))
};
}
+
+/** Serialise a view state into URL query params (the inverse of {@link parsePanZoomParams}). */
+export function serializePanZoomParams(state: PanZoomState): { cx: string; cy: string; z: string } {
+ return { cx: String(state.x), cy: String(state.y), z: String(state.z) };
+}
--
2.49.1
From 0170f7969044d9f781feb4965656a6531beafa22 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Fri, 29 May 2026 16:27:14 +0200
Subject: [PATCH 04/39] feat(stammbaum): convert pointer pixel delta to SVG
units (#692)
Co-Authored-By: Claude Opus 4.8
---
.../src/lib/person/genealogy/panZoom.test.ts | 16 +++++++++++++++
frontend/src/lib/person/genealogy/panZoom.ts | 20 +++++++++++++++++++
2 files changed, 36 insertions(+)
diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts
index 4e1a8fd6..42282a65 100644
--- a/frontend/src/lib/person/genealogy/panZoom.test.ts
+++ b/frontend/src/lib/person/genealogy/panZoom.test.ts
@@ -3,6 +3,7 @@ import {
clampZoom,
parsePanZoomParams,
serializePanZoomParams,
+ screenDeltaToSvg,
DEFAULT_VIEW,
DEFAULT_ZOOM,
MIN_ZOOM,
@@ -75,3 +76,18 @@ describe('serializePanZoomParams', () => {
expect(parsePanZoomParams(serializePanZoomParams(state))).toEqual(state);
});
});
+
+describe('screenDeltaToSvg', () => {
+ it('scales a pixel delta by the viewBox-to-element ratio per axis', () => {
+ // viewBox is 2x the element in width, 2x in height → 1px == 2 SVG units.
+ expect(screenDeltaToSvg(10, 5, 1000, 800, 500, 400)).toEqual({ dx: 20, dy: 10 });
+ });
+
+ it('is identity when the viewBox matches the element pixel size', () => {
+ expect(screenDeltaToSvg(7, -3, 600, 600, 600, 600)).toEqual({ dx: 7, dy: -3 });
+ });
+
+ it('returns zero when the element has no measured size', () => {
+ expect(screenDeltaToSvg(10, 10, 1000, 800, 0, 0)).toEqual({ dx: 0, dy: 0 });
+ });
+});
diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts
index 714764f5..07a934d7 100644
--- a/frontend/src/lib/person/genealogy/panZoom.ts
+++ b/frontend/src/lib/person/genealogy/panZoom.ts
@@ -61,3 +61,23 @@ export function parsePanZoomParams(raw: {
export function serializePanZoomParams(state: PanZoomState): { cx: string; cy: string; z: string } {
return { cx: String(state.x), cy: String(state.y), z: String(state.z) };
}
+
+/**
+ * Convert a pointer delta in CSS pixels into SVG user units, using the current
+ * viewBox-to-element ratio per axis. This is the distance the pointer traversed
+ * expressed in the tree's coordinate space; the gesture handler subtracts it
+ * from the pan offset so the canvas follows the finger (US-PAN-001).
+ */
+export function screenDeltaToSvg(
+ dxPx: number,
+ dyPx: number,
+ viewBoxW: number,
+ viewBoxH: number,
+ elPxW: number,
+ elPxH: number
+): { dx: number; dy: number } {
+ return {
+ dx: elPxW > 0 ? dxPx * (viewBoxW / elPxW) : 0,
+ dy: elPxH > 0 ? dyPx * (viewBoxH / elPxH) : 0
+ };
+}
--
2.49.1
From 5d752fcc0f9c8bcfb9275149eb6bcfa802b9e143 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Fri, 29 May 2026 16:28:41 +0200
Subject: [PATCH 05/39] feat(stammbaum): centroid-anchored zoom (zoomAtPoint)
(#692)
Co-Authored-By: Claude Opus 4.8
---
.../src/lib/person/genealogy/panZoom.test.ts | 29 +++++++++++++++++++
frontend/src/lib/person/genealogy/panZoom.ts | 25 ++++++++++++++++
2 files changed, 54 insertions(+)
diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts
index 42282a65..52782c3b 100644
--- a/frontend/src/lib/person/genealogy/panZoom.test.ts
+++ b/frontend/src/lib/person/genealogy/panZoom.test.ts
@@ -4,6 +4,7 @@ import {
parsePanZoomParams,
serializePanZoomParams,
screenDeltaToSvg,
+ zoomAtPoint,
DEFAULT_VIEW,
DEFAULT_ZOOM,
MIN_ZOOM,
@@ -91,3 +92,31 @@ describe('screenDeltaToSvg', () => {
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 });
+ });
+});
diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts
index 07a934d7..5ef7ef4a 100644
--- a/frontend/src/lib/person/genealogy/panZoom.ts
+++ b/frontend/src/lib/person/genealogy/panZoom.ts
@@ -81,3 +81,28 @@ export function screenDeltaToSvg(
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
+ };
+}
--
2.49.1
From 197b668f20e9d32e437e9b31326b1a7308ef59b4 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Fri, 29 May 2026 16:29:55 +0200
Subject: [PATCH 06/39] feat(stammbaum): recentre-on-node with legible
auto-zoom (#692)
Co-Authored-By: Claude Opus 4.8
---
.../src/lib/person/genealogy/panZoom.test.ts | 25 +++++++++++++++++++
frontend/src/lib/person/genealogy/panZoom.ts | 23 +++++++++++++++++
2 files changed, 48 insertions(+)
diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts
index 52782c3b..bea6f2d9 100644
--- a/frontend/src/lib/person/genealogy/panZoom.test.ts
+++ b/frontend/src/lib/person/genealogy/panZoom.test.ts
@@ -5,8 +5,10 @@ import {
serializePanZoomParams,
screenDeltaToSvg,
zoomAtPoint,
+ recentreOn,
DEFAULT_VIEW,
DEFAULT_ZOOM,
+ LEGIBLE_ZOOM,
MIN_ZOOM,
MAX_ZOOM
} from './panZoom';
@@ -120,3 +122,26 @@ describe('zoomAtPoint', () => {
expect(next).toEqual({ x: 30, y: 10, z: MAX_ZOOM });
});
});
+
+describe('recentreOn', () => {
+ const node = { x: 300, y: 200 };
+ const base = { x: 100, y: 100 };
+
+ it('pans so the node sits at the viewBox centre, keeping the current zoom', () => {
+ expect(recentreOn(node, base, { x: 0, y: 0, z: 1 }, false)).toEqual({ x: 200, y: 100, z: 1 });
+ });
+
+ it('auto-zooms up to LEGIBLE_ZOOM when zoomed out (OQ-005)', () => {
+ const next = recentreOn(node, base, { x: 0, y: 0, z: 0.4 }, true);
+ expect(next.z).toBe(LEGIBLE_ZOOM);
+ expect({ x: next.x, y: next.y }).toEqual({ x: 200, y: 100 });
+ });
+
+ it('does not reduce an already-legible zoom when auto-zooming', () => {
+ expect(recentreOn(node, base, { x: 0, y: 0, z: 2 }, true).z).toBe(2);
+ });
+
+ it('leaves zoom untouched when auto-zoom is off', () => {
+ expect(recentreOn(node, base, { x: 0, y: 0, z: 0.4 }, false).z).toBe(0.4);
+ });
+});
diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts
index 5ef7ef4a..7777a615 100644
--- a/frontend/src/lib/person/genealogy/panZoom.ts
+++ b/frontend/src/lib/person/genealogy/panZoom.ts
@@ -14,6 +14,9 @@ export const MIN_ZOOM = 0.25;
export const MAX_ZOOM = 3.0;
export const DEFAULT_ZOOM = 1;
+/** Minimum zoom a recentre will snap up to so the focal node's text is legible (OQ-005). */
+export const LEGIBLE_ZOOM = 1;
+
/**
* The canvas view state. `x`/`y` are pan offsets applied to the viewBox centre
* (SVG user units); `z` is the zoom factor. The default `{0, 0, 1}` frames the
@@ -106,3 +109,23 @@ export function zoomAtPoint(
z
};
}
+
+/**
+ * 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:
+ * `pan = nodeCentre - baseCentre`. When `autoZoom` is set, a zoomed-out view is
+ * snapped up to {@link LEGIBLE_ZOOM} so the focal node's text is readable
+ * (OQ-005); an already-legible zoom is preserved.
+ */
+export function recentreOn(
+ nodeCentre: { x: number; y: number },
+ baseCentre: { x: number; y: number },
+ state: PanZoomState,
+ autoZoom: boolean
+): PanZoomState {
+ return {
+ x: nodeCentre.x - baseCentre.x,
+ y: nodeCentre.y - baseCentre.y,
+ z: autoZoom ? clampZoom(Math.max(state.z, LEGIBLE_ZOOM)) : state.z
+ };
+}
--
2.49.1
From 0422af89800c51690a2ccf06fd71bc6f1ca52915 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Fri, 29 May 2026 16:35:49 +0200
Subject: [PATCH 07/39] feat(stammbaum): drive viewBox from PanZoomState (pan +
zoom) (#692)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace the scalar zoom prop with a {x,y,z} PanZoomState. The viewBox centre
is offset by the pan and width/height scaled by zoom; the default {0,0,1}
frames the whole tree (fit-to-screen). Page header buttons now step view.z
through clampZoom over the resolved 0.25–3.0 range.
Co-Authored-By: Claude Opus 4.8
---
.../lib/person/genealogy/StammbaumTree.svelte | 23 ++++--
.../genealogy/StammbaumTree.svelte.test.ts | 73 ++++++++++++-------
frontend/src/lib/person/genealogy/panZoom.ts | 3 +
frontend/src/routes/stammbaum/+page.svelte | 14 +++-
4 files changed, 77 insertions(+), 36 deletions(-)
diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte b/frontend/src/lib/person/genealogy/StammbaumTree.svelte
index 8ab4af76..3fa83409 100644
--- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte
+++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte
@@ -8,6 +8,7 @@ import {
ROW_GAP,
type Layout
} from '$lib/person/genealogy/layout/buildLayout';
+import type { PanZoomState } from '$lib/person/genealogy/panZoom';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
@@ -16,7 +17,7 @@ interface Props {
nodes: PersonNodeDTO[];
edges: RelationshipDTO[];
selectedId: string | null;
- zoom: number;
+ panZoom: PanZoomState;
onSelect: (id: string) => void;
/**
* Force-show or force-hide the generation gutter. When undefined, falls
@@ -27,7 +28,7 @@ interface Props {
showGutter?: boolean;
}
-let { nodes, edges, selectedId, zoom, onSelect, showGutter }: Props = $props();
+let { nodes, edges, selectedId, panZoom, onSelect, showGutter }: Props = $props();
const layout = $derived.by(() => buildLayout(nodes, edges));
@@ -79,12 +80,20 @@ const gutterRows = $derived.by(() => {
return rows;
});
+// Base viewBox geometry at z=1, no pan — the whole tree framed (#692). Pan
+// offsets shift the centre; zoom scales width/height inversely. The default
+// {x:0,y:0,z:1} therefore fits the tree to the element (fit-to-screen).
+const baseDims = $derived({ w: layout.viewW + gutterWidth, h: layout.viewH });
+const baseCentre = $derived({
+ x: layout.viewX - gutterWidth + baseDims.w / 2,
+ y: layout.viewY + layout.viewH / 2
+});
+
const viewBox = $derived.by(() => {
- const totalW = layout.viewW + gutterWidth;
- const w = totalW / zoom;
- const h = layout.viewH / zoom;
- const cx = layout.viewX - gutterWidth + totalW / 2;
- const cy = layout.viewY + layout.viewH / 2;
+ const w = baseDims.w / panZoom.z;
+ const h = baseDims.h / panZoom.z;
+ const cx = baseCentre.x + panZoom.x;
+ const cy = baseCentre.y + panZoom.y;
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
});
diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts
index 57c2bd48..a0dacba6 100644
--- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts
+++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts
@@ -36,12 +36,35 @@ function rectsCentroid(svg: SVGElement): { x: number; y: number } {
}
describe('StammbaumTree viewBox', () => {
+ it('offsets the viewBox origin by the pan state (#692)', async () => {
+ render(StammbaumTree, {
+ nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
+ edges: [],
+ selectedId: null,
+ panZoom: { x: 100, y: 40, z: 1 },
+ showGutter: false,
+ onSelect: () => {}
+ });
+
+ const svg = document.querySelector('svg')!;
+ const [x, y, w, h] = parseViewBox(svg);
+
+ // Same dimensions as the unpanned default (z=1)…
+ expect(w).toBe(1200);
+ expect(h).toBe(800);
+
+ // …but the origin is shifted by the pan offset.
+ const unpannedX = -(1200 / 2 - 160 / 2); // single 160-wide node centred
+ expect(x).toBeCloseTo(unpannedX + 100, 6);
+ expect(y).toBeCloseTo(-(800 / 2 - 56 / 2) + 40, 6);
+ });
+
it('uses the minimum size and centers a single node', async () => {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -114,7 +137,7 @@ describe('StammbaumTree viewBox', () => {
}
],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -174,7 +197,7 @@ describe('StammbaumTree viewBox', () => {
}
],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -277,7 +300,7 @@ describe('StammbaumTree viewBox', () => {
}
],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -335,7 +358,7 @@ describe('StammbaumTree viewBox', () => {
}
],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -368,7 +391,7 @@ describe('StammbaumTree viewBox', () => {
}
],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -393,7 +416,7 @@ describe('StammbaumTree node rendering branches', () => {
],
edges: [],
selectedId: ID_A,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -409,7 +432,7 @@ describe('StammbaumTree node rendering branches', () => {
],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -422,7 +445,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true, deathYear: 1950 }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -434,7 +457,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -447,7 +470,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect
});
@@ -462,7 +485,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect
});
@@ -478,7 +501,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect
});
@@ -493,7 +516,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect
});
@@ -520,7 +543,7 @@ describe('StammbaumTree node rendering branches', () => {
}
],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -547,7 +570,7 @@ describe('StammbaumTree node rendering branches', () => {
}
],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -575,7 +598,7 @@ describe('StammbaumTree node rendering branches', () => {
}
],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -588,7 +611,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -607,7 +630,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -636,7 +659,7 @@ describe('StammbaumTree node rendering branches', () => {
],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -653,7 +676,7 @@ describe('StammbaumTree node rendering branches', () => {
],
edges: [],
selectedId: ID_A,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -674,7 +697,7 @@ describe('StammbaumTree node rendering branches', () => {
],
edges: [],
selectedId: ID_A,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -696,7 +719,7 @@ describe('StammbaumTree generation gutter (#689)', () => {
],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {},
showGutter: true
});
@@ -713,7 +736,7 @@ describe('StammbaumTree generation gutter (#689)', () => {
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {},
showGutter: true
});
@@ -730,7 +753,7 @@ describe('StammbaumTree generation gutter (#689)', () => {
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
edges: [],
selectedId: null,
- zoom: 1,
+ panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {},
showGutter: false
});
diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts
index 7777a615..9110bb10 100644
--- a/frontend/src/lib/person/genealogy/panZoom.ts
+++ b/frontend/src/lib/person/genealogy/panZoom.ts
@@ -17,6 +17,9 @@ export const DEFAULT_ZOOM = 1;
/** Minimum zoom a recentre will snap up to so the focal node's text is legible (OQ-005). */
export const LEGIBLE_ZOOM = 1;
+/** Fixed zoom increment per keyboard `+`/`-` press and per control-button click (OQ-002). */
+export const ZOOM_STEP_KB = 0.1;
+
/**
* The canvas view state. `x`/`y` are pan offsets applied to the viewBox centre
* (SVG user units); `z` is the zoom factor. The default `{0, 0, 1}` frames the
diff --git a/frontend/src/routes/stammbaum/+page.svelte b/frontend/src/routes/stammbaum/+page.svelte
index f2c774dc..2c3ee9db 100644
--- a/frontend/src/routes/stammbaum/+page.svelte
+++ b/frontend/src/routes/stammbaum/+page.svelte
@@ -3,6 +3,12 @@ import { m } from '$lib/paraglide/messages.js';
import { page } from '$app/state';
import StammbaumTree from '$lib/person/genealogy/StammbaumTree.svelte';
import StammbaumSidePanel from '$lib/person/genealogy/StammbaumSidePanel.svelte';
+import {
+ type PanZoomState,
+ DEFAULT_VIEW,
+ clampZoom,
+ ZOOM_STEP_KB
+} from '$lib/person/genealogy/panZoom';
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
@@ -23,12 +29,12 @@ let selectedId = $state(
const selectedNode = $derived(data.nodes.find((n) => n.id === selectedId) ?? null);
-let zoom = $state(1);
+let view = $state(DEFAULT_VIEW);
function zoomIn() {
- zoom = Math.min(2, zoom + 0.1);
+ view = { ...view, z: clampZoom(view.z + ZOOM_STEP_KB) };
}
function zoomOut() {
- zoom = Math.max(0.4, zoom - 0.1);
+ view = { ...view, z: clampZoom(view.z - ZOOM_STEP_KB) };
}
@@ -97,7 +103,7 @@ function zoomOut() {
nodes={data.nodes}
edges={data.edges}
selectedId={selectedId}
- zoom={zoom}
+ panZoom={view}
onSelect={(id) => (selectedId = id)}
/>
--
2.49.1
From da1984b916b6b098743b2818b6fb1509e0c03804 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Fri, 29 May 2026 16:39:55 +0200
Subject: [PATCH 08/39] feat(stammbaum): keyboard pan/zoom on the canvas (#692)
+/- zoom by the fixed step and arrow keys pan by a tenth of the visible
extent, emitted via onPanZoom. Provides the keyboard-only alternative path
required by NFR-A11Y-002. Nodes keep their own Enter/Space selection.
Co-Authored-By: Claude Opus 4.8
---
.../lib/person/genealogy/StammbaumTree.svelte | 53 ++++++++++++++++++-
.../genealogy/StammbaumTree.svelte.test.ts | 39 ++++++++++++++
frontend/src/routes/stammbaum/+page.svelte | 1 +
3 files changed, 91 insertions(+), 2 deletions(-)
diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte b/frontend/src/lib/person/genealogy/StammbaumTree.svelte
index 3fa83409..db74a366 100644
--- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte
+++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte
@@ -8,7 +8,7 @@ import {
ROW_GAP,
type Layout
} from '$lib/person/genealogy/layout/buildLayout';
-import type { PanZoomState } from '$lib/person/genealogy/panZoom';
+import { type PanZoomState, clampZoom, ZOOM_STEP_KB } from '$lib/person/genealogy/panZoom';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
@@ -18,6 +18,8 @@ interface Props {
edges: RelationshipDTO[];
selectedId: string | null;
panZoom: PanZoomState;
+ /** Emitted when the keyboard, a gesture, or a recentre changes the view. */
+ onPanZoom?: (state: PanZoomState) => void;
onSelect: (id: string) => void;
/**
* Force-show or force-hide the generation gutter. When undefined, falls
@@ -28,7 +30,15 @@ interface Props {
showGutter?: boolean;
}
-let { nodes, edges, selectedId, panZoom, onSelect, showGutter }: Props = $props();
+let {
+ nodes,
+ edges,
+ selectedId,
+ panZoom,
+ onPanZoom = () => {},
+ onSelect,
+ showGutter
+}: Props = $props();
const layout = $derived.by(() => buildLayout(nodes, edges));
@@ -112,6 +122,38 @@ function handleNodeKey(event: KeyboardEvent, id: string) {
}
}
+// Canvas-level keyboard: `+`/`-` zoom by the fixed step (OQ-002), arrows pan by
+// a tenth of the visible extent. Nodes keep their own Enter/Space selection.
+function handleCanvasKey(event: KeyboardEvent) {
+ const stepX = (baseDims.w / panZoom.z) * 0.1;
+ const stepY = (baseDims.h / panZoom.z) * 0.1;
+ switch (event.key) {
+ case '+':
+ case '=':
+ onPanZoom({ ...panZoom, z: clampZoom(panZoom.z + ZOOM_STEP_KB) });
+ break;
+ case '-':
+ case '_':
+ onPanZoom({ ...panZoom, z: clampZoom(panZoom.z - ZOOM_STEP_KB) });
+ break;
+ case 'ArrowLeft':
+ onPanZoom({ ...panZoom, x: panZoom.x - stepX });
+ break;
+ case 'ArrowRight':
+ onPanZoom({ ...panZoom, x: panZoom.x + stepX });
+ break;
+ case 'ArrowUp':
+ onPanZoom({ ...panZoom, y: panZoom.y - stepY });
+ break;
+ case 'ArrowDown':
+ onPanZoom({ ...panZoom, y: panZoom.y + stepY });
+ break;
+ default:
+ return;
+ }
+ event.preventDefault();
+}
+
const parentEdges = $derived(edges.filter((e) => e.relationType === 'PARENT_OF'));
const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF'));
@@ -182,11 +224,18 @@ const parentLinks = $derived.by(() => {
});
+
+
+
{/if}
-
+
+
+
+
+
{#if error}
diff --git a/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.spec.ts b/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.spec.ts
index 4dda616b..142ccce2 100644
--- a/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.spec.ts
+++ b/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.spec.ts
@@ -11,7 +11,7 @@ const makeNode = () => ({
id: 'person-1',
displayName: 'Alice Müller',
birthYear: 1900,
- deathYear: null,
+ deathYear: undefined,
familyMember: true
});
@@ -50,6 +50,23 @@ describe('StammbaumSidePanel', () => {
await expect.element(page.getByText('Alice Müller')).toBeInTheDocument();
});
+ it('hides the centre control when onCentre is not provided', async () => {
+ render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false });
+ await expect
+ .element(page.getByRole('button', { name: 'Auf diese Person zentrieren' }))
+ .not.toBeInTheDocument();
+ });
+
+ it('calls onCentre when the centre control is clicked (US-PAN-005)', async () => {
+ const onCentre = vi.fn();
+ render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), onCentre, canWrite: false });
+ const btn = [...document.querySelectorAll('button')].find(
+ (b) => b.getAttribute('aria-label') === 'Auf diese Person zentrieren'
+ );
+ btn!.click();
+ expect(onCentre).toHaveBeenCalledOnce();
+ });
+
it('shows empty-relationships message when no direct relationships are loaded', async () => {
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false });
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
diff --git a/frontend/src/routes/stammbaum/+page.svelte b/frontend/src/routes/stammbaum/+page.svelte
index 084f60db..5e166e43 100644
--- a/frontend/src/routes/stammbaum/+page.svelte
+++ b/frontend/src/routes/stammbaum/+page.svelte
@@ -1,5 +1,5 @@
+
+{#if visible}
+
+
+ {m.stammbaum_affordance_hint()}
+
+
+
+{/if}
diff --git a/frontend/src/lib/person/genealogy/StammbaumAffordance.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumAffordance.svelte.test.ts
new file mode 100644
index 00000000..d8cfff1c
--- /dev/null
+++ b/frontend/src/lib/person/genealogy/StammbaumAffordance.svelte.test.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import StammbaumAffordance from './StammbaumAffordance.svelte';
+
+const STORAGE_KEY = 'stammbaumAffordanceDismissedAt';
+
+describe('StammbaumAffordance (#692)', () => {
+ beforeEach(() => localStorage.clear());
+
+ it('shows the hint on a touch device that has not dismissed it', async () => {
+ render(StammbaumAffordance, { touch: true });
+ await vi.waitFor(() => expect(document.querySelector('[role="status"]')).not.toBeNull());
+ expect(document.body.textContent).toContain('Ziehen');
+ });
+
+ it('does not show on non-touch devices (OQ-008)', async () => {
+ render(StammbaumAffordance, { touch: false });
+ expect(document.querySelector('[role="status"]')).toBeNull();
+ });
+
+ it('hides and records dismissal when the close button is clicked', async () => {
+ render(StammbaumAffordance, { touch: true });
+ const dismiss = [...document.querySelectorAll('button')][0];
+ dismiss.click();
+ await vi.waitFor(() => expect(document.querySelector('[role="status"]')).toBeNull());
+ expect(localStorage.getItem(STORAGE_KEY)).toBeTruthy();
+ });
+
+ it('does not reappear within the 30-day window (NFR-USE-001)', async () => {
+ localStorage.setItem(STORAGE_KEY, String(Date.now()));
+ render(StammbaumAffordance, { touch: true });
+ expect(document.querySelector('[role="status"]')).toBeNull();
+ });
+});
diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte b/frontend/src/lib/person/genealogy/StammbaumTree.svelte
index 9dd3c29f..0fc9e0be 100644
--- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte
+++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte
@@ -29,6 +29,8 @@ interface Props {
onPanZoom?: (state: PanZoomState) => void;
/** When set to a node id, the canvas recentres on that node (US-PAN-005). */
centreOnId?: string | null;
+ /** Fired on the first pointer interaction with the canvas (affordance dismiss). */
+ onActivity?: () => void;
onSelect: (id: string) => void;
/**
* Force-show or force-hide the generation gutter. When undefined, falls
@@ -46,6 +48,7 @@ let {
panZoom,
onPanZoom = () => {},
centreOnId = null,
+ onActivity,
onSelect,
showGutter
}: Props = $props();
@@ -295,7 +298,8 @@ const parentLinks = $derived.by(() => {
baseCentreX: baseCentre.x,
baseCentreY: baseCentre.y,
reducedMotion,
- onPanZoom
+ onPanZoom,
+ onGestureStart: onActivity
}}
class="block h-full w-full"
>
diff --git a/frontend/src/routes/stammbaum/+page.svelte b/frontend/src/routes/stammbaum/+page.svelte
index 5e166e43..a7964372 100644
--- a/frontend/src/routes/stammbaum/+page.svelte
+++ b/frontend/src/routes/stammbaum/+page.svelte
@@ -7,6 +7,7 @@ import StammbaumTree from '$lib/person/genealogy/StammbaumTree.svelte';
import StammbaumSidePanel from '$lib/person/genealogy/StammbaumSidePanel.svelte';
import StammbaumBottomSheet from '$lib/person/genealogy/StammbaumBottomSheet.svelte';
import StammbaumControls from '$lib/person/genealogy/StammbaumControls.svelte';
+import StammbaumAffordance from '$lib/person/genealogy/StammbaumAffordance.svelte';
import {
type PanZoomState,
DEFAULT_VIEW,
@@ -36,6 +37,7 @@ let selectedId = $state(
const selectedNode = $derived(data.nodes.find((n) => n.id === selectedId) ?? null);
let view = $state(data.initialView);
+let canvasActivity = $state(false);
function zoomIn() {
view = { ...view, z: clampZoom(view.z + ZOOM_STEP_KB) };
}
@@ -124,8 +126,10 @@ $effect(() => {
panZoom={view}
centreOnId={centreOnId}
onPanZoom={(v) => (view = v)}
+ onActivity={() => (canvasActivity = true)}
onSelect={(id) => (selectedId = id)}
/>
+
{#if selectedNode}
--
2.49.1
From 80f5e0b14790d2594ab5c35645dae9653ca2ba16 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Fri, 29 May 2026 17:15:36 +0200
Subject: [PATCH 20/39] test(stammbaum): mobile visual + structural e2e at
320/414/768 (#692)
VISUAL-gated screenshots of the first-load affordance + control cluster at
each width and the bottom-sheet-open state at 414px, plus always-on structural
assertions. New snapshots; the #361 desktop baselines are untouched. Baselines
regenerate in CI via --update-snapshots.
Co-Authored-By: Claude Opus 4.8
---
frontend/e2e/stammbaum-mobile.visual.spec.ts | 67 ++++++++++++++++++++
1 file changed, 67 insertions(+)
create mode 100644 frontend/e2e/stammbaum-mobile.visual.spec.ts
diff --git a/frontend/e2e/stammbaum-mobile.visual.spec.ts b/frontend/e2e/stammbaum-mobile.visual.spec.ts
new file mode 100644
index 00000000..6de8c48b
--- /dev/null
+++ b/frontend/e2e/stammbaum-mobile.visual.spec.ts
@@ -0,0 +1,67 @@
+import { test, expect } from '@playwright/test';
+
+// Visual + structural coverage for the #692 mobile read path (pan/zoom/fit,
+// first-load affordance, bottom-sheet person panel).
+//
+// Snapshot assertions are gated on VISUAL=1 because they need pre-captured
+// baselines — regenerate in CI with `playwright test --update-snapshots` after
+// intentional UI changes. Structural assertions run unconditionally. The whole
+// suite is also subject to the project-wide Chromium-in-CI gate (#363); it
+// captures new snapshots rather than replacing the #361 desktop baselines.
+const VISUAL = process.env.VISUAL === '1';
+
+const WIDTHS = [320, 414, 768] as const;
+
+test.describe('Stammbaum — mobile read path (#692)', () => {
+ // Touch emulation so the canvas reports pointer:coarse and the first-load
+ // affordance appears; reduced-motion is already forced project-wide.
+ test.use({ hasTouch: true, isMobile: true });
+
+ for (const width of WIDTHS) {
+ test(`affordance + controls render at ${width}px`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 720 });
+ await page.addInitScript(() => localStorage.removeItem('stammbaumAffordanceDismissedAt'));
+ await page.goto('/stammbaum');
+ await expect(page.getByRole('heading', { name: 'Stammbaum' })).toBeVisible();
+
+ const empty = page.getByRole('heading', { name: 'Noch keine Familienmitglieder' });
+ if (await empty.isVisible().catch(() => false)) {
+ test.skip(true, 'no seeded family tree in this environment');
+ }
+
+ // Bottom-right control cluster with the fit-to-screen affordance.
+ await expect(page.getByTestId('fit-to-screen')).toBeVisible();
+ // First-load interactive hint (touch only).
+ await expect(page.getByRole('status')).toBeVisible();
+
+ if (VISUAL) {
+ await expect(page).toHaveScreenshot(`stammbaum-affordance-${width}.png`, {
+ animations: 'disabled'
+ });
+ }
+ });
+ }
+
+ test('bottom sheet opens on node tap at 414px and preserves the canvas', async ({ page }) => {
+ await page.setViewportSize({ width: 414, height: 720 });
+ await page.goto('/stammbaum');
+ await expect(page.getByRole('heading', { name: 'Stammbaum' })).toBeVisible();
+
+ const node = page.locator('svg[aria-label="Stammbaum"] g[role="button"]').first();
+ if ((await node.count()) === 0) test.skip(true, 'no seeded nodes to tap');
+
+ await node.tap();
+ const sheet = page.getByRole('dialog');
+ await expect(sheet).toBeVisible();
+
+ if (VISUAL) {
+ await expect(page).toHaveScreenshot('stammbaum-bottom-sheet-414.png', {
+ animations: 'disabled'
+ });
+ }
+
+ // Dismiss via the backdrop and confirm the sheet closes (state survives).
+ await page.getByRole('button', { name: 'Schließen' }).first().click();
+ await expect(sheet).toBeHidden();
+ });
+});
--
2.49.1
From ba053b3c23d894a8d66be5056c8708b9ba24d737 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Fri, 29 May 2026 17:17:10 +0200
Subject: [PATCH 21/39] docs(stammbaum): ADR-026 custom viewBox pan/zoom +
glossary terms (#692)
Record the reversal of OQ-007 (build custom over the existing viewBox rather
than adopt the panzoom library) and add pan/zoom view-state + fit-to-screen
glossary entries.
Co-Authored-By: Claude Opus 4.8
---
docs/GLOSSARY.md | 4 ++
.../026-stammbaum-custom-viewbox-pan-zoom.md | 56 +++++++++++++++++++
2 files changed, 60 insertions(+)
create mode 100644 docs/adr/026-stammbaum-custom-viewbox-pan-zoom.md
diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md
index 6a6f840d..dcfc8ad6 100644
--- a/docs/GLOSSARY.md
+++ b/docs/GLOSSARY.md
@@ -125,6 +125,10 @@ _Not to be confused with [parented](#parented-layout)_ — loose is the absence
**canonical fixture** (Stammbaum) — `frontend/src/lib/person/genealogy/__fixtures__/stammbaum.json`, a pinned `/api/network` snapshot used by `buildLayout.test.ts` for structural-property assertions against real data. Captured locally via `frontend/scripts/capture-network-fixture.mjs` with explicit credentials and a localhost backend; never invoked from CI. Sanity-gated by `validateFixture.ts` (≥ 50 nodes / ≥ 5 generations / ≥ 1 SPOUSE_OF edge / ≥ 1 multi-spouse person).
+**pan/zoom view state** `[#692]` — the `{ x, y, z }` triple (`PanZoomState` in `frontend/src/lib/person/genealogy/panZoom.ts`) describing the Stammbaum canvas position: `x`/`y` are pan offsets applied to the SVG viewBox centre, `z` is the zoom factor (clamped 0.25–3.0). Mirrored into the shareable URL as `?cx&cy&z` and seeded server-side from those params. See [ADR-026](adr/026-stammbaum-custom-viewbox-pan-zoom.md).
+
+**fit-to-screen** `[user-facing, #692]` — the Stammbaum control (`⤢`) and initial state that frames the whole tree in the viewport. Because the base viewBox already encloses the layout at `z=1`, fit-to-screen is simply the default view `{x:0, y:0, z:1}`.
+
---
## Other Domain Terms
diff --git a/docs/adr/026-stammbaum-custom-viewbox-pan-zoom.md b/docs/adr/026-stammbaum-custom-viewbox-pan-zoom.md
new file mode 100644
index 00000000..65b2ae9d
--- /dev/null
+++ b/docs/adr/026-stammbaum-custom-viewbox-pan-zoom.md
@@ -0,0 +1,56 @@
+# ADR-026 — Stammbaum pan/zoom is a custom viewBox layer, not a third-party library
+
+**Date:** 2026-05-29
+**Status:** Accepted
+**Issue:** #692 (mobile read path — pan, zoom, fit-to-view); supersedes OQ-007
+**Milestone:** Stammbaum mobile read path
+
+---
+
+## Context
+
+#692 makes `/stammbaum` usable on phones: drag-to-pan, pinch/keyboard/wheel zoom,
+fit-to-screen, recentre-on-person, a shareable URL view state, and an edge-fade
+affordance. During issue grooming, **OQ-007 was resolved to adopt the `panzoom`
+library** (timmywil v4.x) on the team's recommendation, pinned per NFR-MAINT-001.
+
+That recommendation predated a load-bearing implementation detail: `StammbaumTree.svelte`
+already renders zoom by **deriving the SVG `viewBox`** (`w = baseW / z`, centred on the
+layout bounding box, `preserveAspectRatio="xMidYMid meet"`) — not by applying a CSS
+`transform`. The `panzoom` library operates by writing `transform` to a DOM node. Adopting
+it would mean:
+
+- abandoning the proven viewBox derivation and the in-SVG generation gutter (#689), which
+ lives in SVG user-space coordinates and would have to be reconciled with a CSS-transformed
+ parent;
+- re-deriving fit-to-screen, recentre, and the `?cx&cy&z` URL state against the library's
+ transform coordinate system;
+- a client-only lazy import to keep the SSR-rendered tree from touching `window` at module
+ load; and
+- ~8 KB of bundle for behaviour we can express in a few pure functions.
+
+## Decision
+
+**Build pan/zoom as a thin custom layer over the existing viewBox**, with no third-party
+dependency. This reverses OQ-007.
+
+- All geometry is pure and unit-tested in `frontend/src/lib/person/genealogy/panZoom.ts`:
+ `clampZoom`, `parsePanZoomParams`/`serializePanZoomParams`, `screenDeltaToSvg`,
+ `zoomAtPoint` (centroid-anchored), `clampPan` (edge-clamp), `recentreOn`, `lerpView`.
+- Pan offsets shift the viewBox centre; zoom scales its width/height. The default
+ `{x:0, y:0, z:1}` already frames the whole tree, so **fit-to-screen is a reset to the
+ default** — no bounding-box recomputation.
+- DOM event wiring lives in the `panZoomGestures` action (pointer/wheel/pinch + inertia,
+ reduced-motion aware) and a keyboard handler on the SVG; both delegate to the pure module.
+
+## Consequences
+
+- **NFR-MAINT-001 (library pinning + feature-flag fallback) is moot** — no library is
+ adopted. The "swap-out point" is `panZoom.ts` + `panZoomGestures.ts`.
+- Text stays vector-crisp at any zoom (SVG-native scaling), satisfying US-PAN-002 AC5.
+- The #689 gutter and the #361 seeded-rank invariant are untouched by the pan/zoom layer.
+- Geometry is testable in the fast node project; only the DOM glue needs the browser project.
+- Trade-off: we own the inertia/pinch code (~a few hundred lines across the action) rather
+ than delegating it. This is acceptable given the testability and zero-dependency wins.
+
+The issue body's OQ-007 row is updated to point at this ADR.
--
2.49.1
From 7e859252a34be41de694aac7d4d6845da3992c1c Mon Sep 17 00:00:00 2001
From: Marcel
Date: Fri, 29 May 2026 17:48:42 +0200
Subject: [PATCH 22/39] =?UTF-8?q?docs(stammbaum):=20renumber=20pan/zoom=20?=
=?UTF-8?q?ADR=20026=E2=86=92027=20(collision=20with=20#361)=20(#692)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The #361 layout ADR already owns 026; renumber the custom-viewBox pan/zoom ADR
to 027 and update the glossary + panZoom.ts references (Elicit review).
Co-Authored-By: Claude Opus 4.8
---
docs/GLOSSARY.md | 2 +-
...box-pan-zoom.md => 027-stammbaum-custom-viewbox-pan-zoom.md} | 2 +-
frontend/src/lib/person/genealogy/panZoom.ts | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
rename docs/adr/{026-stammbaum-custom-viewbox-pan-zoom.md => 027-stammbaum-custom-viewbox-pan-zoom.md} (97%)
diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md
index dcfc8ad6..a3e1b359 100644
--- a/docs/GLOSSARY.md
+++ b/docs/GLOSSARY.md
@@ -125,7 +125,7 @@ _Not to be confused with [parented](#parented-layout)_ — loose is the absence
**canonical fixture** (Stammbaum) — `frontend/src/lib/person/genealogy/__fixtures__/stammbaum.json`, a pinned `/api/network` snapshot used by `buildLayout.test.ts` for structural-property assertions against real data. Captured locally via `frontend/scripts/capture-network-fixture.mjs` with explicit credentials and a localhost backend; never invoked from CI. Sanity-gated by `validateFixture.ts` (≥ 50 nodes / ≥ 5 generations / ≥ 1 SPOUSE_OF edge / ≥ 1 multi-spouse person).
-**pan/zoom view state** `[#692]` — the `{ x, y, z }` triple (`PanZoomState` in `frontend/src/lib/person/genealogy/panZoom.ts`) describing the Stammbaum canvas position: `x`/`y` are pan offsets applied to the SVG viewBox centre, `z` is the zoom factor (clamped 0.25–3.0). Mirrored into the shareable URL as `?cx&cy&z` and seeded server-side from those params. See [ADR-026](adr/026-stammbaum-custom-viewbox-pan-zoom.md).
+**pan/zoom view state** `[#692]` — the `{ x, y, z }` triple (`PanZoomState` in `frontend/src/lib/person/genealogy/panZoom.ts`) describing the Stammbaum canvas position: `x`/`y` are pan offsets applied to the SVG viewBox centre, `z` is the zoom factor (clamped 0.25–3.0). Mirrored into the shareable URL as `?cx&cy&z` and seeded server-side from those params. See [ADR-027](adr/027-stammbaum-custom-viewbox-pan-zoom.md).
**fit-to-screen** `[user-facing, #692]` — the Stammbaum control (`⤢`) and initial state that frames the whole tree in the viewport. Because the base viewBox already encloses the layout at `z=1`, fit-to-screen is simply the default view `{x:0, y:0, z:1}`.
diff --git a/docs/adr/026-stammbaum-custom-viewbox-pan-zoom.md b/docs/adr/027-stammbaum-custom-viewbox-pan-zoom.md
similarity index 97%
rename from docs/adr/026-stammbaum-custom-viewbox-pan-zoom.md
rename to docs/adr/027-stammbaum-custom-viewbox-pan-zoom.md
index 65b2ae9d..9baf44f3 100644
--- a/docs/adr/026-stammbaum-custom-viewbox-pan-zoom.md
+++ b/docs/adr/027-stammbaum-custom-viewbox-pan-zoom.md
@@ -1,4 +1,4 @@
-# ADR-026 — Stammbaum pan/zoom is a custom viewBox layer, not a third-party library
+# ADR-027 — Stammbaum pan/zoom is a custom viewBox layer, not a third-party library
**Date:** 2026-05-29
**Status:** Accepted
diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts
index 9b83cd27..21296590 100644
--- a/frontend/src/lib/person/genealogy/panZoom.ts
+++ b/frontend/src/lib/person/genealogy/panZoom.ts
@@ -6,7 +6,7 @@
* of truth for the zoom bounds, the view-state shape, and every pure geometry
* helper used by the gesture action, the URL serialiser, and the page. Keeping
* the math here (and free of DOM access) makes it unit-testable in the node
- * project. See ADR-026 for why this is custom rather than a third-party library.
+ * project. See ADR-027 for why this is custom rather than a third-party library.
*/
/** Resolved zoom bounds (OQ-001). */
--
2.49.1
From 578bebbd8b6b82a772c4cc4aea674ae0d363bf72 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Fri, 29 May 2026 17:56:22 +0200
Subject: [PATCH 23/39] =?UTF-8?q?fix(stammbaum):=20URL=20pan/zoom=20sync?=
=?UTF-8?q?=20never=20fired=20=E2=80=94=20gate=20replaceState=20on=20route?=
=?UTF-8?q?r-ready=20(#692)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
replaceState throws 'before the router is initialized' during hydration, which
killed the sync $effect on its first tick so the URL never updated on pan/zoom.
Gate the write behind a flag flipped after the first post-mount tick() (router
started) plus a defensive try/catch. Verified live: zoom now updates ?z=.
The prior component test mocked replaceState and masked this.
Co-Authored-By: Claude Opus 4.8
---
frontend/src/routes/stammbaum/+page.svelte | 27 +++++++++++++++++-----
1 file changed, 21 insertions(+), 6 deletions(-)
diff --git a/frontend/src/routes/stammbaum/+page.svelte b/frontend/src/routes/stammbaum/+page.svelte
index a7964372..1232827c 100644
--- a/frontend/src/routes/stammbaum/+page.svelte
+++ b/frontend/src/routes/stammbaum/+page.svelte
@@ -1,5 +1,5 @@
--
2.49.1
From bb2a89da58581e8a114ff871482697ab594850dd Mon Sep 17 00:00:00 2001
From: Marcel
Date: Fri, 29 May 2026 18:00:17 +0200
Subject: [PATCH 24/39] feat(stammbaum): land a fresh visit at readable z=3,
keep fit-to-screen at z=1 (#692)
A fresh visit (no URL state) now opens at INITIAL_VIEW (z=3) so node tiles and
generation labels are legible on arrival; the fit-to-screen control still zooms
out to the whole tree (DEFAULT_VIEW, z=1). Shared links with ?z still win.
Co-Authored-By: Claude Opus 4.8
---
frontend/src/lib/person/genealogy/panZoom.ts | 10 ++++++++-
frontend/src/routes/stammbaum/+page.server.ts | 21 +++++++++++--------
.../src/routes/stammbaum/page.server.test.ts | 6 +++---
3 files changed, 24 insertions(+), 13 deletions(-)
diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts
index 21296590..0d0d5500 100644
--- a/frontend/src/lib/person/genealogy/panZoom.ts
+++ b/frontend/src/lib/person/genealogy/panZoom.ts
@@ -28,9 +28,17 @@ export const ZOOM_STEP_KB = 0.1;
*/
export type PanZoomState = { x: number; y: number; z: number };
-/** Fit-to-screen / initial view (US-PAN-004). */
+/** Fit-to-screen target — frames the whole tree at z=1 (US-PAN-004). */
export const DEFAULT_VIEW: PanZoomState = { x: 0, y: 0, z: DEFAULT_ZOOM };
+/**
+ * Landing zoom for a fresh visit (no URL state). Higher than fit so node tiles
+ * and generation labels are legible on arrival; the fit-to-screen control
+ * (DEFAULT_VIEW, z=1) zooms back out to the whole tree.
+ */
+export const INITIAL_ZOOM = 3;
+export const INITIAL_VIEW: PanZoomState = { x: 0, y: 0, z: INITIAL_ZOOM };
+
/** Clamp a zoom factor into the supported range. */
export function clampZoom(z: number): number {
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
diff --git a/frontend/src/routes/stammbaum/+page.server.ts b/frontend/src/routes/stammbaum/+page.server.ts
index f9d5a2a5..8c4fbff6 100644
--- a/frontend/src/routes/stammbaum/+page.server.ts
+++ b/frontend/src/routes/stammbaum/+page.server.ts
@@ -1,7 +1,7 @@
import { error, redirect } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
-import { parsePanZoomParams } from '$lib/person/genealogy/panZoom';
+import { parsePanZoomParams, INITIAL_VIEW } from '$lib/person/genealogy/panZoom';
export async function load({ fetch, url }) {
const api = createApiClient(fetch);
@@ -13,14 +13,17 @@ export async function load({ fetch, url }) {
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
}
- // Sanitise the shareable pan/zoom params server-side so a crafted link
- // (?z=Infinity, ?cx=NaN) degrades to a safe view before reaching layout
- // geometry (Nora #692).
- const initialView = parsePanZoomParams({
- cx: url.searchParams.get('cx'),
- cy: url.searchParams.get('cy'),
- z: url.searchParams.get('z')
- });
+ // A fresh visit (no shared pan/zoom state) lands at the readable INITIAL_VIEW
+ // (z=3). When a link carries a zoom param we honour it, sanitising server-side
+ // so a crafted link (?z=Infinity, ?cx=NaN) degrades to a safe view before
+ // reaching layout geometry (Nora #692).
+ const initialView = url.searchParams.has('z')
+ ? parsePanZoomParams({
+ cx: url.searchParams.get('cx'),
+ cy: url.searchParams.get('cy'),
+ z: url.searchParams.get('z')
+ })
+ : INITIAL_VIEW;
const network = result.data!;
return { nodes: network.nodes ?? [], edges: network.edges ?? [], initialView };
diff --git a/frontend/src/routes/stammbaum/page.server.test.ts b/frontend/src/routes/stammbaum/page.server.test.ts
index f755ad14..786ede67 100644
--- a/frontend/src/routes/stammbaum/page.server.test.ts
+++ b/frontend/src/routes/stammbaum/page.server.test.ts
@@ -6,7 +6,7 @@ vi.mock('$lib/shared/api.server', () => ({
}));
import { createApiClient } from '$lib/shared/api.server';
-import { DEFAULT_VIEW, MAX_ZOOM } from '$lib/person/genealogy/panZoom';
+import { DEFAULT_VIEW, INITIAL_VIEW, MAX_ZOOM } from '$lib/person/genealogy/panZoom';
beforeEach(() => vi.clearAllMocks());
@@ -26,11 +26,11 @@ function loadEvent(query = '') {
}
describe('/stammbaum +page.server load — initialView', () => {
- it('returns DEFAULT_VIEW when no pan/zoom params are present', async () => {
+ it('returns the readable INITIAL_VIEW (z=3) for a fresh visit with no params', async () => {
mockNetwork();
const { load } = await import('./+page.server');
const result = await load(loadEvent() as never);
- expect(result.initialView).toEqual(DEFAULT_VIEW);
+ expect(result.initialView).toEqual(INITIAL_VIEW);
});
it('parses and returns valid ?cx&cy&z params', async () => {
--
2.49.1
From a458d3508b53d77b5aac9d27bde055fcbb6dbe68 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Fri, 29 May 2026 18:39:22 +0200
Subject: [PATCH 25/39] feat(stammbaum): pinned generation-label rail on all
viewports (#692)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Generation labels are no longer drawn in-SVG (where they panned/zoomed off
screen and were desktop-only). A new StammbaumGenerationRail overlays the canvas
left edge, mapping each generation row's centre through the SVG's live
getScreenCTM so chips stay pinned horizontally and track their row vertically at
any pan/zoom — on phones too. The desktop stripe underlay stays (gated on the
gutter breakpoint); the #689 label tests are rewritten against the rail.
Verified live: labels stay at left=4px while the canvas pans.
Co-Authored-By: Claude Opus 4.8
---
.../genealogy/StammbaumGenerationRail.svelte | 60 +++
.../StammbaumGenerationRail.svelte.test.ts | 32 ++
.../lib/person/genealogy/StammbaumTree.svelte | 413 +++++++++---------
.../genealogy/StammbaumTree.svelte.test.ts | 48 +-
4 files changed, 322 insertions(+), 231 deletions(-)
create mode 100644 frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte
create mode 100644 frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte.test.ts
diff --git a/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte b/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte
new file mode 100644
index 00000000..a5b294c2
--- /dev/null
+++ b/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte
@@ -0,0 +1,60 @@
+
+
+
+
+ {#each chips as chip (chip.rank)}
+ {#if chip.visible}
+
+ G{chip.label}
+
+ {/if}
+ {/each}
+
diff --git a/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte.test.ts
new file mode 100644
index 00000000..6a3b03a3
--- /dev/null
+++ b/frontend/src/lib/person/genealogy/StammbaumGenerationRail.svelte.test.ts
@@ -0,0 +1,32 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import StammbaumGenerationRail from './StammbaumGenerationRail.svelte';
+
+const rows = [
+ { rank: 0, label: 0, centerY: 100 },
+ { rank: 1, label: 1, centerY: 300 },
+ { rank: 2, label: 3, centerY: 500 }
+];
+
+describe('StammbaumGenerationRail (#692)', () => {
+ it('renders one labelled chip per generation row', async () => {
+ render(StammbaumGenerationRail, { svg: null, rows, panZoom: { x: 0, y: 0, z: 1 } });
+
+ await vi.waitFor(() => {
+ const labels = Array.from(document.querySelectorAll('[role="text"]')).map((el) => ({
+ aria: el.getAttribute('aria-label'),
+ text: el.textContent?.trim()
+ }));
+ expect(labels).toEqual([
+ { aria: 'Generation 0', text: 'G0' },
+ { aria: 'Generation 1', text: 'G1' },
+ { aria: 'Generation 3', text: 'G3' }
+ ]);
+ });
+ });
+
+ it('renders nothing when there are no labelled rows', async () => {
+ render(StammbaumGenerationRail, { svg: null, rows: [], panZoom: { x: 0, y: 0, z: 1 } });
+ await vi.waitFor(() => expect(document.querySelectorAll('[role="text"]')).toHaveLength(0));
+ });
+});
diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte b/frontend/src/lib/person/genealogy/StammbaumTree.svelte
index 0fc9e0be..72009b4a 100644
--- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte
+++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte
@@ -16,6 +16,7 @@ import {
ZOOM_STEP_KB
} from '$lib/person/genealogy/panZoom';
import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures';
+import StammbaumGenerationRail from '$lib/person/genealogy/StammbaumGenerationRail.svelte';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
@@ -97,8 +98,9 @@ $effect(() => {
});
type GutterRow = { rank: number; y: number; label: number | null };
+// Computed on all viewports (not gated on the desktop gutter) so the pinned
+// generation rail can show labels on phones too (#692).
const gutterRows = $derived.by(() => {
- if (gutterWidth === 0) return [];
const byId = new SvelteMap(nodes.map((n) => [n.id, n]));
const rows: GutterRow[] = [];
const sortedRanks = [...layout.generations.keys()].sort((a, b) => a - b);
@@ -128,6 +130,15 @@ const baseCentre = $derived({
y: layout.viewY + layout.viewH / 2
});
+// Labelled generation rows for the pinned rail, with each row's centre in SVG
+// coordinates (the rail maps these through the live screen transform).
+let svgEl = $state(null);
+const railRows = $derived(
+ gutterRows
+ .filter((r): r is GutterRow & { label: number } => r.label != null)
+ .map((r) => ({ rank: r.rank, label: r.label, centerY: r.y + NODE_H / 2 }))
+);
+
const viewBox = $derived.by(() => {
const w = baseDims.w / panZoom.z;
const h = baseDims.h / panZoom.z;
@@ -278,243 +289,227 @@ const parentLinks = $derived.by(() => {
});
-
-
-
-
-
- {#each gutterRows as row, i (`stripe-${row.rank}`)}
-
- {/each}
-
-
- {#each gutterRows as row (`label-${row.rank}`)}
- {#if row.label != null}
-
-
- G{row.label}
-
-
+
+