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">
|
<script lang="ts">
|
||||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import type { components } from '$lib/generated/api';
|
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 PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
@@ -17,10 +23,52 @@ interface Props {
|
|||||||
let { nodes, edges, selectedId, zoom, onSelect }: Props = $props();
|
let { nodes, edges, selectedId, zoom, onSelect }: Props = $props();
|
||||||
|
|
||||||
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
|
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 viewBox = $derived.by(() => {
|
||||||
const w = layout.viewW / zoom;
|
const totalW = layout.viewW + gutterWidth;
|
||||||
|
const w = totalW / zoom;
|
||||||
const h = layout.viewH / 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;
|
const cy = layout.viewY + layout.viewH / 2;
|
||||||
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
|
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
|
||||||
});
|
});
|
||||||
@@ -117,6 +165,44 @@ const parentLinks = $derived.by<ParentLinks>(() => {
|
|||||||
aria-label="Stammbaum"
|
aria-label="Stammbaum"
|
||||||
class="block h-full w-full"
|
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
|
<!-- Shared parent-pair → children: drop from spouse midpoint to a sibling
|
||||||
bar, then short verticals from the bar to each child top. -->
|
bar, then short verticals from the bar to each child top. -->
|
||||||
{#each parentLinks.shared as group (group.key)}
|
{#each parentLinks.shared as group (group.key)}
|
||||||
|
|||||||
@@ -648,3 +648,69 @@ describe('StammbaumTree node rendering branches', () => {
|
|||||||
expect(accentRects.length).toBe(1);
|
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. */
|
with axe (tracked in #480) before tweaking the palette. */
|
||||||
--timeline-bar-idle: rgba(161, 220, 216, 0.35);
|
--timeline-bar-idle: rgba(161, 220, 216, 0.35);
|
||||||
--timeline-bar-outside: var(--c-line);
|
--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 ─────────────────────────────────────────────────────────── */
|
/* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */
|
||||||
@@ -236,6 +242,10 @@
|
|||||||
clears WCAG 1.4.11 non-text contrast for large UI elements. */
|
clears WCAG 1.4.11 non-text contrast for large UI elements. */
|
||||||
--timeline-bar-idle: #3a6e8c;
|
--timeline-bar-idle: #3a6e8c;
|
||||||
--timeline-bar-outside: #1a2735;
|
--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. */
|
clears WCAG 1.4.11 non-text contrast for large UI elements. */
|
||||||
--timeline-bar-idle: #3a6e8c;
|
--timeline-bar-idle: #3a6e8c;
|
||||||
--timeline-bar-outside: #1a2735;
|
--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> ──── */
|
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
|
||||||
|
|||||||
Reference in New Issue
Block a user