568 lines
19 KiB
TypeScript
568 lines
19 KiB
TypeScript
/**
|
||
* 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>
|
||
)
|
||
}
|