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:
@@ -1,26 +1,269 @@
|
|||||||
/**
|
/**
|
||||||
* FEAT-4: Stammbaum (Ahnentafel) — interaktiver Stammbaum-Viewer.
|
* FEAT-4: Stammbaum (Ahnentafel) — interaktiver Stammbaum-Viewer.
|
||||||
*
|
*
|
||||||
* Erster Ausbauschritt: Route + Grundgerüst (Tier laden, Titel, Rücksprung).
|
* - react-d3-tree, horizontal (klassische Ahnentafel: Tier links, Vorfahren
|
||||||
* Der eigentliche Viewer (react-d3-tree, Druckansicht, Inzuchtkoeffizient)
|
* rechts; Vater oben, Mutter unten), Pinch-Zoom/Pan via d3-zoom plus
|
||||||
* folgt in den nächsten FEAT-4-Commits.
|
* 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 { 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 { 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() {
|
export default function StammbaumPage() {
|
||||||
const t = de.pages.stammbaum
|
const t = de.pages.stammbaum
|
||||||
const { id = '' } = useParams()
|
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>
|
useEffect(() => {
|
||||||
if (gerbil.error || !gerbil.data) {
|
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 (
|
return (
|
||||||
<section className="page">
|
<section className="page">
|
||||||
<p className="muted">{gerbil.error ?? t.notFound}</p>
|
<p className="muted">{tree.error ?? t.notFound}</p>
|
||||||
<Link to="/rennmaeuse" className="btn">
|
<Link to="/rennmaeuse" className="btn">
|
||||||
{de.pages.gerbils.detail.back}
|
{de.pages.gerbils.detail.back}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -28,17 +271,237 @@ export default function StammbaumPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const g = gerbil.data
|
|
||||||
return (
|
return (
|
||||||
<section className="page">
|
<>
|
||||||
|
<section className="page stammbaum-screen">
|
||||||
<header className="page-head">
|
<header className="page-head">
|
||||||
<h2>{t.titleFor(g.name)}</h2>
|
<h2>{t.titleFor(root.gerbil.name)}</h2>
|
||||||
<div className="head-actions">
|
<div className="head-actions">
|
||||||
<Link to={`/rennmaeuse/${g.id}`} className="btn">
|
<Link to={`/rennmaeuse/${root.gerbil.id}`} className="btn">
|
||||||
{t.backToAnimal}
|
{t.backToAnimal}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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-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>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
302
gerbil-manager-web/src/pages/stammbaum.css
Normal file
302
gerbil-manager-web/src/pages/stammbaum.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user