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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-29 16:39:55 +02:00
parent 0422af8980
commit da1984b916
3 changed files with 91 additions and 2 deletions

View File

@@ -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<Layout>(() => 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<ParentLinks>(() => {
});
</script>
<!-- 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
viewBox={viewBox}
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="Stammbaum"
tabindex="0"
onkeydown={handleCanvasKey}
class="block h-full w-full"
>
<!-- Gutter stripe underlay (#689) — decorative full-row bands alternating