Once the dot starts stacking to disambiguate multiple marriages on multi-spouse rows it carries meaning, so it's no longer decorative — WCAG 1.4.11 (3:1) applies. r=6 (12 px diameter) covers the contrast gap; the existing brand-navy fill against the gutter and surface backgrounds satisfies the ratio without a hue change. Impl-ref table in stammbaum-tree-spec.html updated to match (r=6 / 12 px dia / Informational), with the WCAG reference noted. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
398 lines
12 KiB
Svelte
398 lines
12 KiB
Svelte
<script lang="ts">
|
||
import { SvelteMap, SvelteSet } 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';
|
||
|
||
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||
|
||
interface Props {
|
||
nodes: PersonNodeDTO[];
|
||
edges: RelationshipDTO[];
|
||
selectedId: string | null;
|
||
zoom: number;
|
||
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, zoom, 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);
|
||
|
||
type GutterRow = { rank: number; y: number; label: number | null };
|
||
const gutterRows = $derived.by<GutterRow[]>(() => {
|
||
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);
|
||
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;
|
||
});
|
||
|
||
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;
|
||
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
|
||
});
|
||
|
||
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 };
|
||
}
|
||
|
||
let focusedId = $state<string | null>(null);
|
||
|
||
function handleNodeKey(event: KeyboardEvent, id: string) {
|
||
if (event.key === 'Enter' || event.key === ' ') {
|
||
event.preventDefault();
|
||
onSelect(id);
|
||
}
|
||
}
|
||
|
||
const parentEdges = $derived(edges.filter((e) => e.relationType === 'PARENT_OF'));
|
||
const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF'));
|
||
|
||
function pairKey(a: string, b: string): string {
|
||
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
||
}
|
||
|
||
type ParentLinks = {
|
||
// One entry per spouse-pair-with-children: drives the drop + sibling-bar
|
||
// + per-child vertical pattern in the SVG.
|
||
shared: { key: string; parentA: string; parentB: string; childIds: string[] }[];
|
||
// One entry per remaining parent → child edge (single parents, or the
|
||
// "second" parent edge when only one parent is in the spouse pair).
|
||
single: { key: string; parentId: string; childId: string }[];
|
||
};
|
||
|
||
const parentLinks = $derived.by<ParentLinks>(() => {
|
||
const spousePairs = new SvelteSet<string>();
|
||
for (const e of spouseEdges) {
|
||
spousePairs.add(pairKey(e.personId, e.relatedPersonId));
|
||
}
|
||
|
||
const childToParents = new SvelteMap<string, string[]>();
|
||
for (const e of parentEdges) {
|
||
const list = childToParents.get(e.relatedPersonId) ?? [];
|
||
list.push(e.personId);
|
||
childToParents.set(e.relatedPersonId, list);
|
||
}
|
||
|
||
const sharedMap = new SvelteMap<
|
||
string,
|
||
{ parentA: string; parentB: string; childIds: string[] }
|
||
>();
|
||
const single: ParentLinks['single'] = [];
|
||
for (const [childId, parents] of childToParents) {
|
||
const consumed = new SvelteSet<string>();
|
||
for (let i = 0; i < parents.length; i++) {
|
||
if (consumed.has(parents[i])) continue;
|
||
for (let j = i + 1; j < parents.length; j++) {
|
||
if (consumed.has(parents[j])) continue;
|
||
if (spousePairs.has(pairKey(parents[i], parents[j]))) {
|
||
const groupKey = pairKey(parents[i], parents[j]);
|
||
const existing = sharedMap.get(groupKey);
|
||
if (existing) {
|
||
existing.childIds.push(childId);
|
||
} else {
|
||
sharedMap.set(groupKey, {
|
||
parentA: parents[i],
|
||
parentB: parents[j],
|
||
childIds: [childId]
|
||
});
|
||
}
|
||
consumed.add(parents[i]);
|
||
consumed.add(parents[j]);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
for (const parentId of parents) {
|
||
if (consumed.has(parentId)) continue;
|
||
single.push({ key: `${parentId}->${childId}`, parentId, childId });
|
||
}
|
||
}
|
||
|
||
const shared: ParentLinks['shared'] = [];
|
||
for (const [key, group] of sharedMap) shared.push({ key, ...group });
|
||
return { shared, single };
|
||
});
|
||
</script>
|
||
|
||
<svg
|
||
viewBox={viewBox}
|
||
preserveAspectRatio="xMidYMid meet"
|
||
role="img"
|
||
aria-label="Stammbaum"
|
||
class="block h-full w-full"
|
||
>
|
||
<!-- Gutter stripe underlay (#689) — decorative full-row bands alternating
|
||
transparent / var(--c-gutter-stripe). aria-hidden because they carry
|
||
no meaning; the row's generation is announced by the label group below. -->
|
||
{#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}
|
||
|
||
<!-- Gutter labels (#689) — `G{node.generation}` per occupied row at the
|
||
un-shifted source-truth value. Wrapped in <g role="text"> so screen
|
||
readers announce "Generation three" instead of "G three". -->
|
||
{#each gutterRows as row (`label-${row.rank}`)}
|
||
{#if row.label != null}
|
||
<g role="text" aria-label={`Generation ${row.label}`}>
|
||
<text
|
||
x={layout.viewX - gutterWidth + 12}
|
||
y={row.y + NODE_H / 2}
|
||
text-anchor="start"
|
||
dominant-baseline="middle"
|
||
font-family="var(--font-sans)"
|
||
font-size="12"
|
||
font-weight="700"
|
||
letter-spacing="0.08em"
|
||
fill="var(--c-ink-2)"
|
||
style:text-transform="uppercase"
|
||
>
|
||
G{row.label}
|
||
</text>
|
||
</g>
|
||
{/if}
|
||
{/each}
|
||
|
||
<!-- Shared parent-pair → children: drop from spouse midpoint to a sibling
|
||
bar, then short verticals from the bar to each child top. -->
|
||
{#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)}
|
||
<line
|
||
x1={midX}
|
||
y1={parentBottomY}
|
||
x2={midX}
|
||
y2={barY}
|
||
stroke="var(--c-primary)"
|
||
stroke-width="1.5"
|
||
/>
|
||
{#if minX !== maxX}
|
||
<line
|
||
x1={minX}
|
||
y1={barY}
|
||
x2={maxX}
|
||
y2={barY}
|
||
stroke="var(--c-primary)"
|
||
stroke-width="1.5"
|
||
/>
|
||
{/if}
|
||
{#each childCenters as cc, i (group.childIds[i])}
|
||
<line
|
||
x1={cc.x}
|
||
y1={barY}
|
||
x2={cc.x}
|
||
y2={childTopY}
|
||
stroke="var(--c-primary)"
|
||
stroke-width="1.5"
|
||
/>
|
||
{/each}
|
||
{/if}
|
||
{/each}
|
||
|
||
<!-- Single-parent → child connectors: parent bottom → bar → child top. -->
|
||
{#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}
|
||
<line
|
||
x1={parentCenter.x}
|
||
y1={parentBottomY}
|
||
x2={parentCenter.x}
|
||
y2={barY}
|
||
stroke="var(--c-primary)"
|
||
stroke-width="1.5"
|
||
/>
|
||
{#if parentCenter.x !== childCenter.x}
|
||
<line
|
||
x1={parentCenter.x}
|
||
y1={barY}
|
||
x2={childCenter.x}
|
||
y2={barY}
|
||
stroke="var(--c-primary)"
|
||
stroke-width="1.5"
|
||
/>
|
||
{/if}
|
||
<line
|
||
x1={childCenter.x}
|
||
y1={barY}
|
||
x2={childCenter.x}
|
||
y2={childTopY}
|
||
stroke="var(--c-primary)"
|
||
stroke-width="1.5"
|
||
/>
|
||
{/if}
|
||
{/each}
|
||
|
||
<!-- Spouse connectors -->
|
||
{#each spouseEdges as e (e.id)}
|
||
{@const aCenter = nodeCenter(e.personId)}
|
||
{@const bCenter = nodeCenter(e.relatedPersonId)}
|
||
{#if aCenter && bCenter}
|
||
<line
|
||
x1={aCenter.x}
|
||
y1={aCenter.y}
|
||
x2={bCenter.x}
|
||
y2={bCenter.y}
|
||
stroke="var(--c-primary)"
|
||
stroke-width="1.5"
|
||
stroke-dasharray={e.toYear ? '4 4' : undefined}
|
||
/>
|
||
<circle
|
||
cx={(aCenter.x + bCenter.x) / 2}
|
||
cy={(aCenter.y + bCenter.y) / 2}
|
||
r="6"
|
||
fill="var(--c-primary)"
|
||
/>
|
||
{/if}
|
||
{/each}
|
||
|
||
<!-- Nodes -->
|
||
{#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}
|
||
<g
|
||
role="button"
|
||
tabindex="0"
|
||
aria-label="{node.displayName}{node.birthYear || node.deathYear
|
||
? `, ${node.birthYear ?? '?'}–${node.deathYear ?? ''}`
|
||
: ''}"
|
||
aria-expanded={isSelected}
|
||
transform="translate({pos.x}, {pos.y})"
|
||
onclick={() => onSelect(node.id)}
|
||
onkeydown={(e) => handleNodeKey(e, node.id)}
|
||
onfocus={() => (focusedId = node.id)}
|
||
onblur={() => (focusedId = null)}
|
||
class="cursor-pointer focus:outline-none"
|
||
>
|
||
{#if isFocused}
|
||
<rect
|
||
x="-3"
|
||
y="-3"
|
||
width={NODE_W + 6}
|
||
height={NODE_H + 6}
|
||
rx="6"
|
||
fill="none"
|
||
stroke="var(--c-focus-ring)"
|
||
stroke-width="2"
|
||
/>
|
||
{/if}
|
||
<rect
|
||
width={NODE_W}
|
||
height={NODE_H}
|
||
rx="4"
|
||
fill={isSelected ? 'var(--c-primary)' : 'var(--c-surface)'}
|
||
stroke="var(--c-primary)"
|
||
stroke-width="1.5"
|
||
/>
|
||
{#if isSelected}
|
||
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
|
||
{/if}
|
||
<text
|
||
x={NODE_W / 2}
|
||
y={NODE_H / 2 - 6}
|
||
text-anchor="middle"
|
||
font-family="serif"
|
||
font-size="16"
|
||
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
|
||
>
|
||
{node.displayName}
|
||
</text>
|
||
{#if node.birthYear || node.deathYear}
|
||
<text
|
||
x={NODE_W / 2}
|
||
y={NODE_H / 2 + 12}
|
||
text-anchor="middle"
|
||
font-family="sans-serif"
|
||
font-size="12"
|
||
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
|
||
opacity={isSelected ? 0.75 : 1}
|
||
>
|
||
{node.birthYear ?? '?'}–{node.deathYear ?? ''}
|
||
</text>
|
||
{/if}
|
||
</g>
|
||
{/if}
|
||
{/each}
|
||
</svg>
|