/** * FEAT-4: Stammbaum (Ahnentafel) — interaktiver Stammbaum-Viewer. * * - 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 { 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 { 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() /* ── Daten: Ahnenbaum (Cache überlebt das Umwurzeln) ── */ const [source] = useState(createApiPedigreeSource) const [tree, setTree] = useState({ forId: null, root: null, error: null }) 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(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 links, vertikal zentriert — so sind die Eltern auch auf dem Smartphone sofort sichtbar. ── */ const defaultView = useMemo(() => { if (!size) return null const zoom = size.w < 520 ? 0.8 : 1 return { zoom, translate: { x: (CARD_W / 2) * zoom + 24, 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 ( {!node || node.kind === 'unknown' ? (
{t.unknown}
) : ( navigate(`/rennmaeuse/${node.gerbil.id}/stammbaum`)} onExpand={() => handleExpand(path)} /> )}
) }, [nodesByPath, farbschlagOf, navigate, handleExpand, t], ) /* ── Zustände: Laden / Fehler ── */ if (loading) return

{de.common.loading}

if (!root || !datum) { return (

{tree.error ?? t.notFound}

{de.pages.gerbils.detail.back}
) } return ( <>

{t.titleFor(root.gerbil.name)}

{t.backToAnimal}
{t.inbreeding.label}: {inbreedingText}
{view && ( )}

{t.tapHint}

{/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */} ) } /* ── 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 (
{/* Foto-Platzhalter — echte Fotos kommen mit FEAT-6. */}
{g.name}
{farbschlag && ( {farbschlag} )} {year && * {year}}
{node.expandable && ( )}
) } function SexIcon({ gender }: { gender: Gender }) { const symbol = gender === 'male' ? '♂' : gender === 'female' ? '♀' : '?' return ( {symbol} ) } /* ── 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 (

{t.titleFor(root.gerbil.name)}

{t.inbreeding.label}: {inbreedingText} · {t.printView.createdOn} {today}

{generations.map((_, g) => (
{t.printView.generations[g]}
))} {generations.flatMap((slots, g) => { const span = 2 ** (DEFAULT_GENERATIONS - g) return slots.map((node, i) => ( )) })}
) } 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
if (node.kind === 'unknown') { return (
{t.unknown}
) } const g = node.gerbil const sexSymbol = g.gender === 'male' ? '♂ ' : g.gender === 'female' ? '♀ ' : '' return (
{sexSymbol} {g.name}
{g.dateOfBirth && (
{t.printView.born} {formatDate(g.dateOfBirth)}
)} {farbschlag &&
{farbschlag}
} {g.genotype && gen <= 2 &&
{g.genotype}
}
) }