From a458d3508b53d77b5aac9d27bde055fcbb6dbe68 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 18:39:22 +0200 Subject: [PATCH] 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} - - + +
+ + + + + + {#if gutterVisible} + {#each gutterRows as row, i (`stripe-${row.rank}`)} + + {/each} {/if} - {/each} - - {#each parentLinks.shared as group (group.key)} - {@const aCenter = nodeCenter(group.parentA)} - {@const bCenter = nodeCenter(group.parentB)} - {@const childCenters = group.childIds + {#each parentLinks.shared as group (group.key)} + {@const aCenter = nodeCenter(group.parentA)} + {@const bCenter = nodeCenter(group.parentB)} + {@const childCenters = group.childIds .map((id) => nodeCenter(id)) .filter((c): c is { x: number; y: number } => c !== null)} - {#if aCenter && bCenter && childCenters.length > 0} - {@const midX = (aCenter.x + bCenter.x) / 2} - {@const parentBottomY = aCenter.y + NODE_H / 2} - {@const childTopY = childCenters[0].y - NODE_H / 2} - {@const barY = (parentBottomY + childTopY) / 2} - {@const xs = childCenters.map((c) => c.x)} - {@const minX = Math.min(midX, ...xs)} - {@const maxX = Math.max(midX, ...xs)} - - {#if minX !== maxX} + {#if aCenter && bCenter && childCenters.length > 0} + {@const midX = (aCenter.x + bCenter.x) / 2} + {@const parentBottomY = aCenter.y + NODE_H / 2} + {@const childTopY = childCenters[0].y - NODE_H / 2} + {@const barY = (parentBottomY + childTopY) / 2} + {@const xs = childCenters.map((c) => c.x)} + {@const minX = Math.min(midX, ...xs)} + {@const maxX = Math.max(midX, ...xs)} + {#if minX !== maxX} + + {/if} + {#each childCenters as cc, i (group.childIds[i])} + + {/each} {/if} - {#each childCenters as cc, i (group.childIds[i])} + {/each} + + + {#each parentLinks.single as link (link.key)} + {@const parentCenter = nodeCenter(link.parentId)} + {@const childCenter = nodeCenter(link.childId)} + {#if parentCenter && childCenter} + {@const parentBottomY = parentCenter.y + NODE_H / 2} + {@const childTopY = childCenter.y - NODE_H / 2} + {@const barY = (parentBottomY + childTopY) / 2} + {#if parentCenter.x !== childCenter.x} + + {/if} + - {/each} - {/if} - {/each} + {/if} + {/each} - - {#each parentLinks.single as link (link.key)} - {@const parentCenter = nodeCenter(link.parentId)} - {@const childCenter = nodeCenter(link.childId)} - {#if parentCenter && childCenter} - {@const parentBottomY = parentCenter.y + NODE_H / 2} - {@const childTopY = childCenter.y - NODE_H / 2} - {@const barY = (parentBottomY + childTopY) / 2} - - {#if parentCenter.x !== childCenter.x} + + {#each spouseEdges as e (e.id)} + {@const aCenter = nodeCenter(e.personId)} + {@const bCenter = nodeCenter(e.relatedPersonId)} + {#if aCenter && bCenter} + {/if} - - {/if} - {/each} + {/each} - - {#each spouseEdges as e (e.id)} - {@const aCenter = nodeCenter(e.personId)} - {@const bCenter = nodeCenter(e.relatedPersonId)} - {#if aCenter && bCenter} - - - {/if} - {/each} - - - {#each nodes as node (node.id)} - {@const pos = layout.positions.get(node.id)} - {#if pos} - {@const isSelected = selectedId === node.id} - {@const isFocused = focusedId === node.id} - onSelect(node.id)} - onkeydown={(e) => handleNodeKey(e, node.id)} - onfocus={() => (focusedId = node.id)} - onblur={() => (focusedId = null)} - class="cursor-pointer focus:outline-none" - > - {#if isFocused} - - {/if} - - {#if isSelected} - - {/if} - onSelect(node.id)} + onkeydown={(e) => handleNodeKey(e, node.id)} + onfocus={() => (focusedId = node.id)} + onblur={() => (focusedId = null)} + class="cursor-pointer focus:outline-none" > - {node.displayName} - - {#if node.birthYear || node.deathYear} + {#if isFocused} + + {/if} + + {#if isSelected} + + {/if} - {node.birthYear ?? '?'}–{node.deathYear ?? ''} + {node.displayName} - {/if} - - {/if} - {/each} - + {#if node.birthYear || node.deathYear} + + {node.birthYear ?? '?'}–{node.deathYear ?? ''} + + {/if} + + {/if} + {/each} + + + +
diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts index 6584eb1c..9b67f862 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts @@ -831,10 +831,13 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => { }); }); -describe('StammbaumTree generation gutter (#689)', () => { - it('renders a G{n} label per occupied generation row when at least one node carries generation', async () => { - // showGutter overrides the matchMedia detection so the test never - // depends on the vitest-browser iframe viewport width. +describe('StammbaumTree generation rail (#689, #692)', () => { + const railLabels = () => + Array.from(document.querySelectorAll('[role="text"]')).map((el) => + el.getAttribute('aria-label') + ); + + it('renders a G{n} label per occupied generation row on the pinned rail', async () => { render(StammbaumTree, { nodes: [ { id: ID_A, displayName: 'Walter', familyMember: true, generation: 2 }, @@ -843,35 +846,37 @@ describe('StammbaumTree generation gutter (#689)', () => { edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, - onSelect: () => {}, - showGutter: true + onSelect: () => {} }); - const labels = Array.from(document.querySelectorAll('g[role="text"]')).map((g) => - g.getAttribute('aria-label') - ); - expect(labels).toContain('Generation 2'); - expect(labels).toContain('Generation 3'); + await vi.waitFor(() => { + const labels = railLabels(); + expect(labels).toContain('Generation 2'); + expect(labels).toContain('Generation 3'); + }); }); - it('wraps the visible G3 text inside an aria-labelled group so screen readers announce "Generation"', async () => { + it('labels the chip so screen readers announce "Generation" and shows the G{n} glyph', async () => { render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }], edges: [], selectedId: null, panZoom: { x: 0, y: 0, z: 1 }, - onSelect: () => {}, - showGutter: true + onSelect: () => {} }); - const g3 = Array.from(document.querySelectorAll('g[role="text"]')).find( - (g) => g.getAttribute('aria-label') === 'Generation 3' - ); - expect(g3).toBeDefined(); - expect(g3!.querySelector('text')!.textContent).toMatch(/G\s*3/); + await vi.waitFor(() => { + const g3 = Array.from(document.querySelectorAll('[role="text"]')).find( + (el) => el.getAttribute('aria-label') === 'Generation 3' + ); + expect(g3).toBeDefined(); + expect(g3!.textContent).toMatch(/G\s*3/); + }); }); - it('omits the gutter when showGutter is false (mobile breakpoint case)', async () => { + it('keeps showing generation labels on the pinned rail even on mobile (showGutter false)', async () => { + // The rail is viewport-independent (the #692 point); only the desktop + // stripe underlay is gated on the gutter breakpoint. render(StammbaumTree, { nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }], edges: [], @@ -881,7 +886,6 @@ describe('StammbaumTree generation gutter (#689)', () => { showGutter: false }); - const labelGroups = Array.from(document.querySelectorAll('g[role="text"]')); - expect(labelGroups).toHaveLength(0); + await vi.waitFor(() => expect(railLabels()).toContain('Generation 3')); }); });