fix(stammbaum): address re-review nits — opaque rail, stale docs, rail clarity (#692)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m38s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s

- Rail chip background opaque (was /85) so G{n} labels stay AA-legible over
  tree content (Leonie).
- Rail effect: replace the reactKey hack with an inputsFinite guard that both
  tracks deps and guards NaN; name the fallback-stack magics; correct the stale
  'xMidYMid' comment (the CTM mapping is preserveAspectRatio-agnostic) (Felix/Markus).
- GLOSSARY zoom range 0.25–3.0 → 0.25–10; ADR-027 preserveAspectRatio note
  xMidYMid → xMinYMin (Elicit traceability).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-29 20:21:13 +02:00
parent 11dc25ef31
commit 95d35c20b2
3 changed files with 28 additions and 14 deletions

View File

@@ -22,21 +22,33 @@ type Chip = { rank: number; label: number; top: number; visible: boolean };
let chips = $state<Chip[]>([]);
let height = $state(0);
// Map each generation-row centre from SVG user space to a screen-y via the
// live CTM (exact under preserveAspectRatio="xMidYMid meet" — no manual
// letterbox math). Runs in an effect so it reads the CTM after the viewBox DOM
// update is flushed. `panZoom` and `height` are the recompute triggers.
// Fallback stacked positions when no screen transform is available (e.g. an
// unsized test container): list the chips top-down rather than hiding them.
const FALLBACK_TOP = 24;
const FALLBACK_GAP = 28;
// Off-screen cull margin (px) so a chip just past an edge isn't popped abruptly.
const CULL_MARGIN = 16;
// Map each generation-row centre from SVG user space to a screen-y via the live
// CTM. This reads whatever transform the browser actually computed, so it is
// independent of `preserveAspectRatio` (no manual letterbox math). Runs in an
// effect — not a $derived — so the CTM is read AFTER the viewBox DOM update is
// flushed. NB: this reads layout (getScreenCTM + getBoundingClientRect) once per
// pan/zoom/inertia frame; fine for a handful of chips, revisit if rows grow large.
$effect(() => {
// reactKey makes the effect re-run on pan, zoom and resize; the CTM reflects
// the parent's viewBox, which Svelte cannot track on its own.
const reactKey = panZoom.x + panZoom.y + panZoom.z + height;
const ctm = svg && Number.isFinite(reactKey) ? svg.getScreenCTM() : null;
// Reading pan/zoom + height registers them as effect dependencies (so it
// re-runs on pan, zoom and resize — the CTM reflects the parent's viewBox,
// which Svelte cannot track on its own) and doubles as a NaN guard.
const inputsFinite = Number.isFinite(panZoom.x + panZoom.y + panZoom.z + height);
const ctm = svg && inputsFinite ? svg.getScreenCTM() : null;
const top0 = svg ? svg.getBoundingClientRect().top : 0;
// Always emit one chip per labelled row so the labels exist regardless of
// transform availability; the CTM only positions them (fallback: stacked).
chips = rows.map((row, i) => {
const top = ctm ? new DOMPoint(0, row.centerY).matrixTransform(ctm).y - top0 : 24 + i * 28;
const visible = !ctm || height <= 0 || (top >= -16 && top <= height + 16);
const top = ctm
? new DOMPoint(0, row.centerY).matrixTransform(ctm).y - top0
: FALLBACK_TOP + i * FALLBACK_GAP;
const visible = !ctm || height <= 0 || (top >= -CULL_MARGIN && top <= height + CULL_MARGIN);
return { rank: row.rank, label: row.label, top, visible };
});
});
@@ -50,7 +62,7 @@ $effect(() => {
<div
role="text"
aria-label={`Generation ${chip.label}`}
class="absolute left-1 -translate-y-1/2 rounded-sm border border-line bg-surface/85 px-1.5 py-0.5 font-serif text-xs text-ink-3 shadow-sm"
class="absolute left-1 -translate-y-1/2 rounded-sm border border-line bg-surface px-1.5 py-0.5 font-serif text-xs text-ink-3 shadow-sm"
style="top: {chip.top}px"
>
G{chip.label}