Files
GerbilManager/gerbil-manager-web/src/pages/StammbaumPage.tsx

568 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { listLitters } from '../api/litters'
import { listColorVarieties } from '../api/lookups'
import { getInbreedingCoefficient } from '../api/pedigree'
import type { Gender, Gerbil, Litter } from '../api/types'
import { useApi } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels'
import GerbilIcon from '../components/GerbilIcon'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } 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<TreeState>({ 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
/* ── Würfe des Wurzeltiers (STAMMBAUM-LITTERS): aktualisiert bei Umwurzeln ── */
const rootLitters = useApi(
() => listLitters({ filter: `fatherId=${id}|motherId=${id}`, orderBy: 'date desc', pageSize: 50 }),
[id],
)
const rootLitterItems = rootLitters.data?.items ?? []
/* ── 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 links, vertikal zentriert —
so sind die Eltern auch auf dem Smartphone sofort sichtbar. ── */
const defaultView = useMemo<View | null>(() => {
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 (
<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">{tree.error ?? t.notFound}</p>
<Link to="/rennmaeuse" className="btn">
{de.pages.gerbils.detail.back}
</Link>
</section>
)
}
return (
<>
<section className="page stammbaum-screen">
<header className="page-head">
<h2>{t.titleFor(root.gerbil.name || de.pages.gerbils.nameless)}</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>
<div className="stammbaum-layout">
{rootLitterItems.length > 0 && (
<LittersPanel litters={rootLitterItems} t={t} />
)}
<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>
</div>
<ul className="stammbaum-hints">
<li>{t.tapHint}</li>
<li>{t.hintName}</li>
<li>{t.hintExpand}</li>
</ul>
</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 dob = g.dateOfBirth ? formatDate(g.dateOfBirth) : 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">
<GerbilIcon size="1.6rem" />
</div>
<div className="pedigree-card__body">
<div className="pedigree-card__name">
<SexIcon gender={g.gender} />
<Link
to={`/rennmaeuse/${g.id}`}
className="pedigree-card__nametext"
onClick={(e) => e.stopPropagation()}
>
{g.name || de.pages.gerbils.nameless}
</Link>
</div>
{farbschlag && (
<span
className="pedigree-chip"
style={chip ? { background: chip.bg, color: chip.fg } : undefined}
title={farbschlag}
>
{g.genotype || farbschlag}
</span>
)}
{dob && <span className="pedigree-card__year">* {dob}</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>
)
}
/* ── Würfe-Panel (STAMMBAUM-LITTERS) ─────────────────────────────── */
function LittersPanel({
litters,
t,
}: {
litters: Litter[]
t: { littersTitle: string; littersJunge: string }
}) {
return (
<aside className="stammbaum-litters-panel" aria-label={t.littersTitle}>
<div className="stammbaum-litters-panel__title">{t.littersTitle}</div>
<ul className="stammbaum-litters-panel__list">
{litters.map((l) => (
<li key={l.id}>
<Link to={`/wuerfe/${l.id}`} className="stammbaum-litters-panel__link">
<span className="stammbaum-litters-panel__name">{l.name}</span>
{l.totalBorn != null && (
<span className="stammbaum-litters-panel__born">
{l.totalBorn} {t.littersJunge}
</span>
)}
</Link>
</li>
))}
</ul>
</aside>
)
}
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 || de.pages.gerbils.nameless)}</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 || de.pages.gerbils.nameless}
</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">
{toDisplayString(fromDisplayString(g.genotype))}
</div>
)}
</div>
)
}