All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m21s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m37s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
Extract the three SVG connector layers (+ the parent-link graph computation) into StammbaumConnectors.svelte and the node <g> into StammbaumNode.svelte (which now owns its own focus-ring state). StammbaumTree drops 546→308 lines and is now an orchestrator: layout, gutter/reduced-motion state, viewBox, gestures, rail, anchor. Rendered SVG is byte-identical, so the existing browser tests are unchanged. Verified live: 62 nodes + 58 connector lines render, node-tap selects. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
309 lines
11 KiB
Svelte
309 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { untrack, onMount } from 'svelte';
|
|
import { SvelteMap } from 'svelte/reactivity';
|
|
import type { components } from '$lib/generated/api';
|
|
import {
|
|
buildLayout,
|
|
NODE_W,
|
|
NODE_H,
|
|
ROW_GAP,
|
|
type Layout
|
|
} from '$lib/person/genealogy/layout/buildLayout';
|
|
import {
|
|
type PanZoomState,
|
|
clampZoom,
|
|
clampPan,
|
|
recentreOn,
|
|
cornerView,
|
|
ZOOM_STEP_KB
|
|
} from '$lib/person/genealogy/panZoom';
|
|
import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures';
|
|
import StammbaumGenerationRail from '$lib/person/genealogy/StammbaumGenerationRail.svelte';
|
|
import StammbaumConnectors from '$lib/person/genealogy/StammbaumConnectors.svelte';
|
|
import StammbaumNode from '$lib/person/genealogy/StammbaumNode.svelte';
|
|
|
|
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
|
|
|
interface Props {
|
|
nodes: PersonNodeDTO[];
|
|
edges: RelationshipDTO[];
|
|
selectedId: string | null;
|
|
panZoom: PanZoomState;
|
|
/** Emitted when the keyboard, a gesture, or a recentre changes the view. */
|
|
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;
|
|
/** When true, the initial view is anchored to the tree's top-left corner. */
|
|
anchorTopLeft?: boolean;
|
|
onSelect: (id: string) => void;
|
|
/**
|
|
* Force-show or force-hide the generation gutter. When undefined, falls
|
|
* back to a `window.matchMedia('(min-width: 768px)')` detection so the
|
|
* gutter only appears on md+ viewports. Tests pass an explicit boolean
|
|
* to avoid depending on the vitest-browser iframe viewport.
|
|
*/
|
|
showGutter?: boolean;
|
|
}
|
|
|
|
let {
|
|
nodes,
|
|
edges,
|
|
selectedId,
|
|
panZoom,
|
|
onPanZoom = () => {},
|
|
centreOnId = null,
|
|
onActivity,
|
|
anchorTopLeft = false,
|
|
onSelect,
|
|
showGutter
|
|
}: Props = $props();
|
|
|
|
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
|
|
|
|
// Stammbaum gutter (#689). 100 px column on the left of the canvas on md+
|
|
// viewports, carrying the G{n} label per generation row. Hidden entirely on
|
|
// phones (canvas is already overflow-scroll; 100 px of permanent chrome is
|
|
// too costly on a 320 px screen).
|
|
const GUTTER_WIDTH_DESKTOP = 100;
|
|
const GUTTER_MEDIA_QUERY = '(min-width: 768px)';
|
|
// Seed synchronously so the first paint already has the right gutter state —
|
|
// otherwise the test (and a brief flash on real CSR mount) would see the
|
|
// pre-effect false. SSR has no window; the gutter stays hidden until hydrate.
|
|
let isMdOrUp = $state(
|
|
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
|
? window.matchMedia(GUTTER_MEDIA_QUERY).matches
|
|
: false
|
|
);
|
|
$effect(() => {
|
|
if (showGutter !== undefined) return;
|
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
|
const mq = window.matchMedia(GUTTER_MEDIA_QUERY);
|
|
const handler = (e: MediaQueryListEvent) => (isMdOrUp = e.matches);
|
|
mq.addEventListener('change', handler);
|
|
return () => mq.removeEventListener('change', handler);
|
|
});
|
|
const gutterVisible = $derived(showGutter ?? isMdOrUp);
|
|
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 };
|
|
// 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<GutterRow[]>(() => {
|
|
const byId = new SvelteMap(nodes.map((n) => [n.id, n]));
|
|
const rows: GutterRow[] = [];
|
|
const sortedRanks = [...layout.generations.keys()].sort((a, b) => a - b);
|
|
for (const rank of sortedRanks) {
|
|
const ids = layout.generations.get(rank)!;
|
|
const firstPos = layout.positions.get(ids[0]);
|
|
if (!firstPos) continue;
|
|
let label: number | null = null;
|
|
for (const id of ids) {
|
|
const g = byId.get(id)?.generation;
|
|
if (g != null) {
|
|
label = g;
|
|
break;
|
|
}
|
|
}
|
|
rows.push({ rank, y: firstPos.y, label });
|
|
}
|
|
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
|
|
});
|
|
|
|
// 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<SVGSVGElement | null>(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 }))
|
|
);
|
|
|
|
// A fresh visit (no shared URL state) lands on the tree's content top-left
|
|
// rather than its centre (#692). Anchors to the first row / leftmost node (not
|
|
// the padded frame corner, which would leave empty space above row 1), with a
|
|
// small margin. Runs once after layout is available.
|
|
const ANCHOR_MARGIN = 24;
|
|
onMount(() => {
|
|
if (!anchorTopLeft) return;
|
|
let minX = Infinity;
|
|
let minY = Infinity;
|
|
for (const pos of layout.positions.values()) {
|
|
minX = Math.min(minX, pos.x);
|
|
minY = Math.min(minY, pos.y);
|
|
}
|
|
if (!Number.isFinite(minX)) return; // no nodes
|
|
const target = cornerView(
|
|
minX - ANCHOR_MARGIN,
|
|
minY - ANCHOR_MARGIN,
|
|
baseCentre.x,
|
|
baseCentre.y,
|
|
baseDims.w,
|
|
baseDims.h,
|
|
panZoom.z
|
|
);
|
|
onPanZoom(clampPan(target, baseDims.w, baseDims.h));
|
|
});
|
|
|
|
const viewBox = $derived.by(() => {
|
|
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}`;
|
|
});
|
|
|
|
// Permanent edge-fade affordance (#692, replaces US-PAN-006 AC3). When the tree
|
|
// is zoomed past fit, content is clipped at the viewport edges, so a 24px fade
|
|
// on all four edges cues that more tree exists off-screen. Zero JS beyond this
|
|
// reactive style; nothing fades at fit (z <= 1, whole tree visible).
|
|
const EDGE_FADE = 24;
|
|
const maskStyle = $derived(
|
|
panZoom.z > 1
|
|
? `-webkit-mask-image:linear-gradient(to right,transparent,#000 ${EDGE_FADE}px,#000 calc(100% - ${EDGE_FADE}px),transparent),linear-gradient(to bottom,transparent,#000 ${EDGE_FADE}px,#000 calc(100% - ${EDGE_FADE}px),transparent);` +
|
|
`mask-image:linear-gradient(to right,transparent,#000 ${EDGE_FADE}px,#000 calc(100% - ${EDGE_FADE}px),transparent),linear-gradient(to bottom,transparent,#000 ${EDGE_FADE}px,#000 calc(100% - ${EDGE_FADE}px),transparent);` +
|
|
`-webkit-mask-composite:source-in;mask-composite:intersect;`
|
|
: ''
|
|
);
|
|
|
|
function nodeCenter(id: string): { x: number; y: number } | null {
|
|
const p = layout.positions.get(id);
|
|
if (!p) return null;
|
|
return { x: p.x + NODE_W / 2, y: p.y + NODE_H / 2 };
|
|
}
|
|
|
|
// Recentre when the parent sets centreOnId (US-PAN-005). Only centreOnId is a
|
|
// tracked dependency — the current view is read untracked so a normal pan does
|
|
// not retrigger a recentre.
|
|
$effect(() => {
|
|
const id = centreOnId;
|
|
if (!id) return;
|
|
untrack(() => {
|
|
const c = nodeCenter(id);
|
|
if (c) onPanZoom(recentreOn(c, baseCentre, panZoom, true));
|
|
});
|
|
});
|
|
|
|
// 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();
|
|
}
|
|
</script>
|
|
|
|
<!-- Relative wrapper so the pinned generation rail can overlay the canvas. -->
|
|
<div class="relative h-full w-full">
|
|
<!-- The canvas is a custom interactive pan/zoom region: `tabindex` lets keyboard
|
|
users focus it and the keydown handler is the keyboard-only alternative to
|
|
touch/mouse gestures (NFR-A11Y-002). The visible focus outline is kept. -->
|
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
<svg
|
|
bind:this={svgEl}
|
|
viewBox={viewBox}
|
|
preserveAspectRatio="xMinYMin meet"
|
|
role="img"
|
|
aria-label="Stammbaum"
|
|
tabindex="0"
|
|
style={maskStyle}
|
|
onkeydown={handleCanvasKey}
|
|
use:panZoomGestures={{
|
|
state: panZoom,
|
|
baseW: baseDims.w,
|
|
baseH: baseDims.h,
|
|
baseCentreX: baseCentre.x,
|
|
baseCentreY: baseCentre.y,
|
|
reducedMotion,
|
|
onPanZoom,
|
|
onGestureStart: onActivity
|
|
}}
|
|
class="block h-full w-full"
|
|
>
|
|
<!-- Gutter stripe underlay (#689) — decorative full-row bands alternating
|
|
transparent / var(--c-gutter-stripe), desktop only. Generation labels
|
|
are no longer drawn in-SVG; the pinned rail below carries them. -->
|
|
{#if gutterVisible}
|
|
{#each gutterRows as row, i (`stripe-${row.rank}`)}
|
|
<rect
|
|
aria-hidden="true"
|
|
x={layout.viewX - gutterWidth}
|
|
y={row.y - ROW_GAP / 2}
|
|
width={layout.viewW + gutterWidth}
|
|
height={NODE_H + ROW_GAP}
|
|
fill={i % 2 === 0 ? 'transparent' : 'var(--c-gutter-stripe)'}
|
|
/>
|
|
{/each}
|
|
{/if}
|
|
|
|
<StammbaumConnectors edges={edges} positions={layout.positions} />
|
|
|
|
<!-- Nodes -->
|
|
{#each nodes as node (node.id)}
|
|
{@const pos = layout.positions.get(node.id)}
|
|
{#if pos}
|
|
<StammbaumNode
|
|
node={node}
|
|
pos={pos}
|
|
selected={selectedId === node.id}
|
|
onSelect={onSelect}
|
|
/>
|
|
{/if}
|
|
{/each}
|
|
</svg>
|
|
|
|
<StammbaumGenerationRail svg={svgEl} rows={railRows} panZoom={panZoom} />
|
|
</div>
|