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.
|
||||
*
|
||||
* 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<TreeState>({ forId: null, root: null, error: null })
|
||||
|
||||
if (gerbil.loading) return <p className="muted">{de.common.loading}</p>
|
||||
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<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 (
|
||||
<section className="page">
|
||||
<p className="muted">{gerbil.error ?? t.notFound}</p>
|
||||
<p className="muted">{tree.error ?? t.notFound}</p>
|
||||
<Link to="/rennmaeuse" className="btn">
|
||||
{de.pages.gerbils.detail.back}
|
||||
</Link>
|
||||
@@ -28,17 +271,237 @@ export default function StammbaumPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const g = gerbil.data
|
||||
return (
|
||||
<section className="page">
|
||||
<header className="page-head">
|
||||
<h2>{t.titleFor(g.name)}</h2>
|
||||
<div className="head-actions">
|
||||
<Link to={`/rennmaeuse/${g.id}`} className="btn">
|
||||
{t.backToAnimal}
|
||||
</Link>
|
||||
<>
|
||||
<section className="page stammbaum-screen">
|
||||
<header className="page-head">
|
||||
<h2>{t.titleFor(root.gerbil.name)}</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>
|
||||
</header>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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