FEAT-4: interactive Stammbaum viewer - foreignObject cards (photo slot, sex, Farbschlag chip, year), tap-to-re-root, lazy expand, zoom controls + CSS-grid print pedigree & Inzuchtkoeffizient slot

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 00:22:45 +02:00
parent 61cfab046a
commit 9c0a2fec56
2 changed files with 784 additions and 19 deletions

View File

@@ -1,26 +1,269 @@
/**
* FEAT-4: Stammbaum (Ahnentafel) — interaktiver Stammbaum-Viewer.
*
* Erster Ausbauschritt: Route + Grundgerüst (Tier laden, Titel, Rücksprung).
* Der eigentliche Viewer (react-d3-tree, Druckansicht, Inzuchtkoeffizient)
* folgt in den nächsten FEAT-4-Commits.
* - react-d3-tree, horizontal (klassische Ahnentafel: Tier links, Vorfahren
* rechts; Vater oben, Mutter unten), Pinch-Zoom/Pan via d3-zoom plus
* explizite +//Einpassen-Buttons.
* - Karten als foreignObject-HTML: Foto-Platzhalter (Fotos kommen mit FEAT-6),
* Name, ♂/♀, Farbschlag-Chip, Geburtsjahr. Tipp auf eine Karte wurzelt den
* Baum auf dieses Tier um; der Quellen-Cache bleibt erhalten, dadurch ist
* der Wechsel unmittelbar.
* - Tiefe: 4 Generationen beim Laden (Board-Entscheidung), tiefere werden pro
* Ast über den -Knopf nachgeladen. Die Tiefenbegrenzung steckt im
* Datenmodul (src/pedigree), nicht in react-d3-trees initialDepth — so sind
* nachgeladene Generationen sofort sichtbar und es wird nie blind vorab
* geladen.
* - Druck/PDF: separate CSS-Grid-Ahnentafel (.stammbaum-print), per
* @media print sichtbar; Browser „Als PDF speichern“ ist der Export.
*/
import { Link, useParams } from 'react-router-dom'
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import Tree from 'react-d3-tree'
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
import { de } from '../strings/de'
import { getGerbil } from '../api/gerbils'
import { ApiError } from '../api/client'
import { listColorVarieties } from '../api/lookups'
import { getInbreedingCoefficient } from '../api/pedigree'
import type { Gender, Gerbil } from '../api/types'
import { useApi } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag } from '../genetics'
import {
DEFAULT_GENERATIONS,
ancestorsAt,
buildPedigree,
collectNodes,
expandPedigree,
toRawNodeDatum,
} from '../pedigree/build'
import { chipColorFor } from '../pedigree/chipColors'
import { createApiPedigreeSource } from '../pedigree/source'
import type { AnimalNode, PedigreeNode } from '../pedigree/types'
import './stammbaum.css'
/* Kartengeometrie (px im Baum-Koordinatensystem). */
const CARD_W = 190
const CARD_H = 88
/* nodeSize bei horizontaler Orientierung: x = Abstand der Generationen,
y = vertikales Raster der Karten (siehe react-d3-tree generateTree). */
const NODE_X = 250
const NODE_Y = 100
const ZOOM_MIN = 0.2
const ZOOM_MAX = 2
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
function errorMessage(err: unknown): string {
if (err instanceof ApiError) return err.message
return de.api.errors.unknown
}
/** Tiefe und Blattzahl des sichtbaren Baums (für „Einpassen“). */
function datumExtent(datum: RawNodeDatum): { depth: number; leaves: number } {
if (!datum.children || datum.children.length === 0) return { depth: 0, leaves: 1 }
let depth = 0
let leaves = 0
for (const child of datum.children) {
const e = datumExtent(child)
depth = Math.max(depth, e.depth + 1)
leaves += e.leaves
}
return { depth, leaves }
}
interface View {
zoom: number
translate: Point
}
interface TreeState {
forId: string | null
root: AnimalNode | null
error: string | null
}
export default function StammbaumPage() {
const t = de.pages.stammbaum
const { id = '' } = useParams()
const navigate = useNavigate()
const gerbil = useApi(() => getGerbil(id), [id])
/* ── Daten: Ahnenbaum (Cache überlebt das Umwurzeln) ── */
const [source] = useState(createApiPedigreeSource)
const [tree, setTree] = useState<TreeState>({ forId: null, root: null, error: null })
if (gerbil.loading) return <p className="muted">{de.common.loading}</p>
if (gerbil.error || !gerbil.data) {
useEffect(() => {
let cancelled = false
buildPedigree(source, id)
.then((root) => {
if (!cancelled) setTree({ forId: id, root, error: root ? null : t.notFound })
})
.catch((err: unknown) => {
if (!cancelled) setTree({ forId: id, root: null, error: errorMessage(err) })
})
return () => {
cancelled = true
}
}, [id, source, t])
const loading = tree.forId !== id
const root = loading ? null : tree.root
const handleExpand = useCallback(
(path: string) => {
const current = tree.root
if (!current || tree.forId !== id) return
expandPedigree(source, current, path)
.then((next) =>
// Nur übernehmen, wenn inzwischen nicht umgewurzelt wurde.
setTree((s) => (s.forId === id && s.root === current ? { ...s, root: next } : s)),
)
.catch(() => {
/* Nachladen fehlgeschlagen: Baum unverändert lassen; erneuter Tipp versucht es wieder. */
})
},
[tree, source, id],
)
/* ── Farbschlag: Lookup-Name, sonst aus dem Genotyp (GEN-1) ── */
const colorVarieties = useApi(() => listColorVarieties(), [])
const colorNameById = useMemo(
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
[colorVarieties.data],
)
const farbschlagOf = useCallback(
(g: Gerbil): string | null => {
const byId = g.colorVarietyId ? colorNameById.get(g.colorVarietyId) : undefined
if (byId) return byId
if (g.genotype && g.genotype.trim()) {
try {
const name = genotypeToFarbschlag(fromDisplayString(g.genotype))
return name === UNKNOWN_FARBSCHLAG ? null : name
} catch {
return null
}
}
return null
},
[colorNameById],
)
/* ── Inzuchtkoeffizient (Endpunkt folgt serverseitig — bis dahin 404) ── */
const inbreeding = useApi(() => getInbreedingCoefficient(id), [id])
const inbreedingText =
!inbreeding.loading && !inbreeding.error && inbreeding.data != null
? `${(inbreeding.data * 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} %`
: t.inbreeding.unavailable
/* ── react-d3-tree-Daten ── */
const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root])
const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t])
/* ── Zeichenfläche vermessen (Erst-Zentrierung + Einpassen) ── */
const canvasRef = useRef<HTMLDivElement | null>(null)
const [size, setSize] = useState<{ w: number; h: number } | null>(null)
const hasRoot = root !== null
useEffect(() => {
const el = canvasRef.current
if (!el) return
const update = () => setSize({ w: el.clientWidth, h: el.clientHeight })
update()
const observer = new ResizeObserver(update)
observer.observe(el)
return () => observer.disconnect()
}, [hasRoot])
/* ── Ansicht (Zoom/Position): Wurzelkarte beim Laden zentriert ── */
const defaultView = useMemo<View | null>(() => {
if (!size) return null
const zoom = size.w < 520 ? 0.8 : 1
return {
zoom,
translate: { x: Math.min(size.w / 2, CARD_W * zoom + 48), y: size.h / 2 },
}
}, [size])
const [viewOverride, setViewOverride] = useState<(View & { forId: string }) | null>(null)
const view = viewOverride && viewOverride.forId === id ? viewOverride : defaultView
/* Letzter Live-Stand aus d3 (Nutzer-Pan/-Pinch), Basis der Zoom-Buttons. */
const liveView = useRef<(View & { forId: string }) | null>(null)
const handleTreeUpdate = useCallback(
(target: { node: unknown; zoom: number; translate: Point }) => {
liveView.current = { forId: id, zoom: target.zoom, translate: target.translate }
},
[id],
)
const setView = useCallback(
(next: View) => {
setViewOverride({ forId: id, ...next })
liveView.current = { forId: id, ...next }
},
[id],
)
/** Zoomt um `factor` um die Mitte der Zeichenfläche. */
const applyZoom = useCallback(
(factor: number) => {
if (!size || !view) return
const current = liveView.current && liveView.current.forId === id ? liveView.current : view
const zoom = clamp(current.zoom * factor, ZOOM_MIN, ZOOM_MAX)
const k = zoom / current.zoom
const c = { x: size.w / 2, y: size.h / 2 }
setView({
zoom,
translate: {
x: c.x - (c.x - current.translate.x) * k,
y: c.y - (c.y - current.translate.y) * k,
},
})
},
[size, view, id, setView],
)
/** Passt den ganzen Baum in die Zeichenfläche ein. */
const fitView = useCallback(() => {
if (!size || !datum) return
const extent = datumExtent(datum)
const width = extent.depth * NODE_X + CARD_W + 32
const height = extent.leaves * NODE_Y + 32
const zoom = clamp(Math.min(size.w / width, size.h / height), ZOOM_MIN, ZOOM_MAX)
setView({ zoom, translate: { x: (CARD_W / 2 + 16) * zoom, y: size.h / 2 } })
}, [size, datum, setView])
/* ── Ahnenkarten ── */
const renderNode = useCallback(
({ nodeDatum }: CustomNodeElementProps) => {
const path = String(nodeDatum.attributes?.path ?? '')
const node = nodesByPath?.get(path)
return (
<g>
<foreignObject width={CARD_W} height={CARD_H} x={-CARD_W / 2} y={-CARD_H / 2}>
{!node || node.kind === 'unknown' ? (
<div className="pedigree-card pedigree-card--unknown">{t.unknown}</div>
) : (
<PedigreeCard
node={node}
isRoot={path === ''}
farbschlag={farbschlagOf(node.gerbil)}
onOpen={() => navigate(`/rennmaeuse/${node.gerbil.id}/stammbaum`)}
onExpand={() => handleExpand(path)}
/>
)}
</foreignObject>
</g>
)
},
[nodesByPath, farbschlagOf, navigate, handleExpand, t],
)
/* ── Zustände: Laden / Fehler ── */
if (loading) return <p className="muted">{de.common.loading}</p>
if (!root || !datum) {
return (
<section className="page">
<p className="muted">{gerbil.error ?? t.notFound}</p>
<p className="muted">{tree.error ?? t.notFound}</p>
<Link to="/rennmaeuse" className="btn">
{de.pages.gerbils.detail.back}
</Link>
@@ -28,17 +271,237 @@ export default function StammbaumPage() {
)
}
const g = gerbil.data
return (
<section className="page">
<header className="page-head">
<h2>{t.titleFor(g.name)}</h2>
<div className="head-actions">
<Link to={`/rennmaeuse/${g.id}`} className="btn">
{t.backToAnimal}
</Link>
<>
<section className="page stammbaum-screen">
<header className="page-head">
<h2>{t.titleFor(root.gerbil.name)}</h2>
<div className="head-actions">
<Link to={`/rennmaeuse/${root.gerbil.id}`} className="btn">
{t.backToAnimal}
</Link>
</div>
</header>
<div className="stammbaum-toolbar">
<button
type="button"
className="btn stammbaum-zoombtn"
onClick={() => applyZoom(1.25)}
aria-label={t.zoomIn}
title={t.zoomIn}
>
</button>
<button
type="button"
className="btn stammbaum-zoombtn"
onClick={() => applyZoom(0.8)}
aria-label={t.zoomOut}
title={t.zoomOut}
>
</button>
<button type="button" className="btn" onClick={fitView}>
{t.zoomFit}
</button>
<span className="stammbaum-toolbar__spacer" />
<span className="stammbaum-inzucht">
{t.inbreeding.label}: <b>{inbreedingText}</b>
</span>
<button type="button" className="btn" onClick={() => window.print()}>
{t.print}
</button>
</div>
</header>
</section>
<div className="stammbaum-canvas" ref={canvasRef}>
{view && (
<Tree
key={id}
data={datum}
renderCustomNodeElement={renderNode}
orientation="horizontal"
pathFunc="step"
translate={view.translate}
zoom={view.zoom}
scaleExtent={{ min: ZOOM_MIN, max: ZOOM_MAX }}
zoomable
draggable
collapsible={false}
nodeSize={{ x: NODE_X, y: NODE_Y }}
separation={{ siblings: 1, nonSiblings: 1 }}
onUpdate={handleTreeUpdate}
/>
)}
</div>
<p className="stammbaum-hint">{t.tapHint}</p>
</section>
{/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */}
<PrintPedigree root={root} farbschlagOf={farbschlagOf} inbreedingText={inbreedingText} />
</>
)
}
/* ── Ahnenkarte (interaktive Ansicht) ───────────────────────────── */
function PedigreeCard({
node,
isRoot,
farbschlag,
onOpen,
onExpand,
}: {
node: AnimalNode
isRoot: boolean
farbschlag: string | null
onOpen: () => void
onExpand: () => void
}) {
const t = de.pages.stammbaum
const g = node.gerbil
const chip = farbschlag ? chipColorFor(farbschlag) : null
const year = g.dateOfBirth ? g.dateOfBirth.slice(0, 4) : null
return (
<div
className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'}
onClick={isRoot ? undefined : onOpen}
role={isRoot ? undefined : 'button'}
title={isRoot ? undefined : t.tapHint}
>
{/* Foto-Platzhalter — echte Fotos kommen mit FEAT-6. */}
<div className="pedigree-card__photo" aria-hidden="true">
🐭
</div>
<div className="pedigree-card__body">
<div className="pedigree-card__name">
<SexIcon gender={g.gender} />
<span className="pedigree-card__nametext">{g.name}</span>
</div>
{farbschlag && (
<span
className="pedigree-chip"
style={chip ? { background: chip.bg, color: chip.fg } : undefined}
>
{farbschlag}
</span>
)}
{year && <span className="pedigree-card__year">* {year}</span>}
</div>
{node.expandable && (
<button
type="button"
className="pedigree-card__expand"
aria-label={t.expand}
title={t.expand}
onClick={(e) => {
e.stopPropagation()
onExpand()
}}
>
</button>
)}
</div>
)
}
function SexIcon({ gender }: { gender: Gender }) {
const symbol = gender === 'male' ? '♂' : gender === 'female' ? '♀' : '?'
return (
<span
className={`pedigree-card__sex pedigree-card__sex--${gender}`}
title={genderLabel(gender)}
aria-label={genderLabel(gender)}
>
{symbol}
</span>
)
}
/* ── Druckansicht: Ahnentafel als CSS-Grid (Zertifikats-Layout) ── */
function PrintPedigree({
root,
farbschlagOf,
inbreedingText,
}: {
root: AnimalNode
farbschlagOf: (g: Gerbil) => string | null
inbreedingText: string
}) {
const t = de.pages.stammbaum
const generations = Array.from({ length: DEFAULT_GENERATIONS + 1 }, (_, g) =>
ancestorsAt(root, g),
)
const today = new Date().toLocaleDateString('de-DE')
return (
<div className="stammbaum-print">
<header className="stammbaum-print__head">
<h1>{t.titleFor(root.gerbil.name)}</h1>
<p className="stammbaum-print__meta">
{t.inbreeding.label}: {inbreedingText} · {t.printView.createdOn} {today}
</p>
</header>
<div className="stammbaum-print__grid">
{generations.map((_, g) => (
<div key={`gen-${g}`} className="stammbaum-print__gen" style={{ gridColumn: g + 1, gridRow: 1 }}>
{t.printView.generations[g]}
</div>
))}
{generations.flatMap((slots, g) => {
const span = 2 ** (DEFAULT_GENERATIONS - g)
return slots.map((node, i) => (
<PrintCell
key={`${g}:${i}`}
node={node}
gen={g}
farbschlag={node?.kind === 'animal' ? farbschlagOf(node.gerbil) : null}
style={{ gridColumn: g + 1, gridRow: `${i * span + 2} / span ${span}` }}
/>
))
})}
</div>
</div>
)
}
function PrintCell({
node,
gen,
farbschlag,
style,
}: {
node: PedigreeNode | null
gen: number
farbschlag: string | null
style: CSSProperties
}) {
const t = de.pages.stammbaum
const base = `stammbaum-print__cell stammbaum-print__cell--g${gen}`
if (!node) return <div className={`${base} stammbaum-print__cell--empty`} style={style} />
if (node.kind === 'unknown') {
return (
<div className={`${base} stammbaum-print__cell--unknown`} style={style}>
{t.unknown}
</div>
)
}
const g = node.gerbil
const sexSymbol = g.gender === 'male' ? '♂ ' : g.gender === 'female' ? '♀ ' : ''
return (
<div className={base} style={style}>
<div className="stammbaum-print__name">
{sexSymbol}
{g.name}
</div>
{g.dateOfBirth && (
<div className="stammbaum-print__sub">
{t.printView.born} {formatDate(g.dateOfBirth)}
</div>
)}
{farbschlag && <div className="stammbaum-print__sub">{farbschlag}</div>}
{g.genotype && gen <= 2 && <div className="stammbaum-print__geno">{g.genotype}</div>}
</div>
)
}