feat(stammbaum): render generation gutter on the family tree (#689)
The gutter sits 100 px to the left of the tree canvas on md+ viewports
(hidden entirely below md to preserve scrollable area on phones — see
spec's deliberate dual-audience trade-off). Per occupied generation
row it draws:
- A full-width decorative stripe rect alternating transparent and
var(--c-gutter-stripe). aria-hidden because it carries no meaning.
- The label `G{n}` at the left edge, sourced from the un-shifted
node.generation value (never the post-normalise rank), wrapped in
`<g role="text" aria-label="Generation N">` so screen readers
announce the full word instead of "G three".
CSS adds --c-gutter-stripe in both the light root and the dark mode
blocks (8% / 14% mint over canvas — decorative contrast carve-out).
Browser tests cover label rendering, the ARIA wrapper, and the
viewport-below-md absent-gutter path via a matchMedia stub. Existing
StammbaumTree structural-invariant tests still pass since none of
them assert anything inside the gutter region.
Refs #689
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { buildLayout, NODE_W, NODE_H, type Layout } from '$lib/person/genealogy/layout/buildLayout';
|
||||
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'];
|
||||
@@ -17,10 +23,52 @@ interface Props {
|
||||
let { nodes, edges, selectedId, zoom, onSelect }: 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)';
|
||||
let isMdOrUp = $state(false);
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||
const mq = window.matchMedia(GUTTER_MEDIA_QUERY);
|
||||
isMdOrUp = mq.matches;
|
||||
const handler = (e: MediaQueryListEvent) => (isMdOrUp = e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
});
|
||||
const gutterWidth = $derived(isMdOrUp ? 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 w = layout.viewW / zoom;
|
||||
const totalW = layout.viewW + gutterWidth;
|
||||
const w = totalW / zoom;
|
||||
const h = layout.viewH / zoom;
|
||||
const cx = layout.viewX + layout.viewW / 2;
|
||||
const cx = layout.viewX - gutterWidth + totalW / 2;
|
||||
const cy = layout.viewY + layout.viewH / 2;
|
||||
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
|
||||
});
|
||||
@@ -117,6 +165,44 @@ const parentLinks = $derived.by<ParentLinks>(() => {
|
||||
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)}
|
||||
|
||||
@@ -648,3 +648,69 @@ describe('StammbaumTree node rendering branches', () => {
|
||||
expect(accentRects.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StammbaumTree generation gutter (#689)', () => {
|
||||
it('renders a G{n} label per occupied generation row when at least one node carries generation', async () => {
|
||||
render(StammbaumTree, {
|
||||
nodes: [
|
||||
{ id: ID_A, displayName: 'Walter', familyMember: true, generation: 2 },
|
||||
{ id: ID_B, displayName: 'Herbert', familyMember: true, generation: 3 }
|
||||
],
|
||||
edges: [],
|
||||
selectedId: null,
|
||||
zoom: 1,
|
||||
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');
|
||||
});
|
||||
|
||||
it('wraps the visible G3 text inside an aria-labelled group so screen readers announce "Generation"', async () => {
|
||||
render(StammbaumTree, {
|
||||
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
|
||||
edges: [],
|
||||
selectedId: null,
|
||||
zoom: 1,
|
||||
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/);
|
||||
});
|
||||
|
||||
it('omits the gutter when matchMedia (min-width: 768px) is false', async () => {
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
|
||||
try {
|
||||
render(StammbaumTree, {
|
||||
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
|
||||
edges: [],
|
||||
selectedId: null,
|
||||
zoom: 1,
|
||||
onSelect: () => {}
|
||||
});
|
||||
|
||||
const labelGroups = Array.from(document.querySelectorAll('g[role="text"]'));
|
||||
expect(labelGroups).toHaveLength(0);
|
||||
} finally {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,6 +160,12 @@
|
||||
with axe (tracked in #480) before tweaking the palette. */
|
||||
--timeline-bar-idle: rgba(161, 220, 216, 0.35);
|
||||
--timeline-bar-outside: var(--c-line);
|
||||
|
||||
/* Stammbaum gutter stripe (issue #689) — decorative full-row underlay
|
||||
alternating with transparent. Mint-tinted on canvas to mark generation
|
||||
rows without competing with node fills. 8% on light surface ≈ #ECF6F4
|
||||
(~1.04:1 vs canvas — decorative carve-out). */
|
||||
--c-gutter-stripe: rgba(161, 220, 216, 0.08);
|
||||
}
|
||||
|
||||
/* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */
|
||||
@@ -236,6 +242,10 @@
|
||||
clears WCAG 1.4.11 non-text contrast for large UI elements. */
|
||||
--timeline-bar-idle: #3a6e8c;
|
||||
--timeline-bar-outside: #1a2735;
|
||||
|
||||
/* Stammbaum gutter stripe (issue #689) — 14% mint on dark canvas for
|
||||
visibility parity with the 8% light-mode token. Decorative carve-out. */
|
||||
--c-gutter-stripe: rgba(161, 220, 216, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,6 +318,9 @@
|
||||
clears WCAG 1.4.11 non-text contrast for large UI elements. */
|
||||
--timeline-bar-idle: #3a6e8c;
|
||||
--timeline-bar-outside: #1a2735;
|
||||
|
||||
/* Stammbaum gutter stripe (issue #689) — KEEP IN SYNC with the @media block. */
|
||||
--c-gutter-stripe: rgba(161, 220, 216, 0.14);
|
||||
}
|
||||
|
||||
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
|
||||
|
||||
Reference in New Issue
Block a user