diff --git a/gerbil-manager-web/src/pages/StammbaumPage.tsx b/gerbil-manager-web/src/pages/StammbaumPage.tsx index 36046ce..032ec3e 100644 --- a/gerbil-manager-web/src/pages/StammbaumPage.tsx +++ b/gerbil-manager-web/src/pages/StammbaumPage.tsx @@ -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({ forId: null, root: null, error: null }) - if (gerbil.loading) return

{de.common.loading}

- 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(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(() => { + 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 ( + + + {!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 (
-

{gerbil.error ?? t.notFound}

+

{tree.error ?? t.notFound}

{de.pages.gerbils.detail.back} @@ -28,17 +271,237 @@ export default function StammbaumPage() { ) } - const g = gerbil.data return ( -
-
-

{t.titleFor(g.name)}

-
- - {t.backToAnimal} - + <> +
+
+

{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}
} +
) } diff --git a/gerbil-manager-web/src/pages/stammbaum.css b/gerbil-manager-web/src/pages/stammbaum.css new file mode 100644 index 0000000..35bf1dd --- /dev/null +++ b/gerbil-manager-web/src/pages/stammbaum.css @@ -0,0 +1,302 @@ +/* FEAT-4: Stammbaum — Bildschirm- und Druckstile. + Bewusst eigene Datei statt index.css: index.css wird parallel von + FEAT-1/2/5 bearbeitet (geteilter Arbeitsbaum), und die Druckregeln + bleiben hier gekapselt. */ + +/* ── Werkzeugleiste ───────────────────────────────────────────── */ + +.stammbaum-toolbar { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; + margin: 0.75rem 0 0.5rem; +} + +.stammbaum-toolbar__spacer { + flex: 1; +} + +.stammbaum-zoombtn { + min-width: 44px; + padding: 0.5rem 0.6rem; + font-size: 1.1rem; + line-height: 1; +} + +.stammbaum-inzucht { + font-size: 0.85rem; + color: var(--color-text-muted); + white-space: nowrap; +} + +.stammbaum-inzucht b { + color: var(--color-text); +} + +.stammbaum-hint { + font-size: 0.8rem; + color: var(--color-text-muted); + margin: 0.4rem 0 0; +} + +/* ── Zeichenfläche ────────────────────────────────────────────── */ + +.stammbaum-canvas { + height: clamp(18rem, 62dvh, 46rem); + border: 1px solid var(--color-border); + border-radius: 0.6rem; + background: + radial-gradient(var(--color-border) 1px, transparent 1px) 0 0 / 22px 22px, + var(--color-surface); + overflow: hidden; + /* Pinch/Pan gehören d3-zoom, nicht dem Browser-Scrolling. */ + touch-action: none; +} + +.stammbaum-canvas .rd3t-tree-container { + width: 100%; + height: 100%; +} + +.stammbaum-canvas .rd3t-link { + stroke: var(--color-border); + stroke-width: 1.5; +} + +/* ── Ahnenkarten (foreignObject-HTML) ─────────────────────────── */ + +.pedigree-card { + box-sizing: border-box; + width: 100%; + height: 100%; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.55rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 0.6rem; + box-shadow: 0 1px 2px rgb(59 48 38 / 10%); + overflow: hidden; + cursor: pointer; + font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + color: var(--color-text); +} + +.pedigree-card:hover:not(.pedigree-card--root):not(.pedigree-card--unknown) { + background: var(--color-accent-soft); +} + +.pedigree-card--root { + border: 2px solid var(--color-accent); + cursor: default; +} + +.pedigree-card--unknown { + justify-content: center; + border-style: dashed; + background: transparent; + color: var(--color-text-muted); + font-style: italic; + font-size: 0.85rem; + cursor: default; +} + +.pedigree-card__photo { + flex: none; + width: 42px; + height: 42px; + border-radius: 50%; + background: var(--color-accent-soft); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.4rem; +} + +.pedigree-card__body { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.pedigree-card__name { + display: flex; + align-items: center; + gap: 0.3rem; + font-weight: 600; + font-size: 0.9rem; +} + +.pedigree-card__nametext { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pedigree-card__sex--male { + color: #3a6ea5; +} + +.pedigree-card__sex--female { + color: #b5527d; +} + +.pedigree-card__sex--unknown { + color: var(--color-text-muted); +} + +.pedigree-chip { + align-self: flex-start; + max-width: 100%; + font-size: 0.68rem; + padding: 0.1rem 0.45rem; + border-radius: 1rem; + background: var(--color-accent-soft); + color: var(--color-accent); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pedigree-card__year { + font-size: 0.72rem; + color: var(--color-text-muted); +} + +.pedigree-card__expand { + flex: none; + margin-left: auto; + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid var(--color-accent); + background: var(--color-accent-soft); + color: var(--color-accent); + font-size: 1.05rem; + line-height: 1; + cursor: pointer; +} + +.pedigree-card__expand:hover { + background: var(--color-accent); + color: #fff; +} + +/* ── Druckansicht (Ahnentafel als CSS-Grid, Hunde-Zertifikat-Optik) ── + Am Bildschirm unsichtbar; @media print blendet App-Chrome und die + interaktive Ansicht aus und zeigt stattdessen die Tafel. + (:has-Scoping, damit der Druck ANDERER Seiten unberührt bleibt.) */ + +.stammbaum-print { + display: none; +} + +@media print { + @page { + size: A4 landscape; + margin: 10mm; + } + + body:has(.stammbaum-print) .app-header, + body:has(.stammbaum-print) .app-nav, + body:has(.stammbaum-print) .stammbaum-screen { + display: none !important; + } + + .stammbaum-print { + display: flex; + flex-direction: column; + height: 100%; + color: #000; + } +} + +.stammbaum-print__head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 4mm; + border-bottom: 2pt solid #333; + padding-bottom: 2mm; + margin-bottom: 3mm; +} + +.stammbaum-print__head h1 { + font-size: 16pt; + margin: 0; +} + +.stammbaum-print__meta { + font-size: 9pt; + color: #444; + margin: 0; +} + +.stammbaum-print__grid { + flex: 1; + display: grid; + grid-template-columns: repeat(5, 1fr); + grid-template-rows: auto repeat(16, minmax(0, 1fr)); + gap: 1.5mm; + min-height: 0; +} + +.stammbaum-print__gen { + font-size: 8pt; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #555; + align-self: end; + padding-bottom: 0.5mm; +} + +.stammbaum-print__cell { + border: 0.5pt solid #999; + border-radius: 1mm; + padding: 1mm 2mm; + overflow: hidden; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.5mm; + font-size: 9pt; + break-inside: avoid; +} + +/* Hintere Generationen: kleinere Schrift, damit 16 Zellen passen. */ +.stammbaum-print__cell--g3 { + font-size: 8pt; +} + +.stammbaum-print__cell--g4 { + font-size: 7pt; + padding: 0.5mm 1mm; +} + +.stammbaum-print__cell--empty { + border-style: dashed; + border-color: #ccc; +} + +.stammbaum-print__cell--unknown { + color: #888; + font-style: italic; +} + +.stammbaum-print__name { + font-weight: 600; +} + +.stammbaum-print__sub { + font-size: 0.85em; + color: #444; +} + +.stammbaum-print__geno { + font-family: Consolas, 'Courier New', monospace; + font-size: 0.8em; + color: #555; +}