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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user