fix(stammbaum): Geschwister-Verpaarung erkennen und im Diagramm zusammenführen
Vollgeschwister-Verpaarung (Vater & Mutter aus demselben Wurf) ist in der
Gerbil-Zucht häufig. Bisher zeigten Diagramm und Import sie nicht korrekt.
Import (merge_and_resolve.py):
- Wurf-Dedup gehärtet: asymmetrischer Merge nur bei kompatiblen Eltern-Namen;
bei Date=None nur mit positivem Namens-Match (verhindert blindes Verschmelzen
unverwandter datumloser Stubs). Falsch-Merges 112 -> 70.
- Parent-Resolver robust: Gender ist Präferenz statt hartem Filter, sodass
vertauschte Eltern (z. B. weibliches Tier in der Vater-Position) trotzdem
auflösen. Aufgelöste Eltern 200/206 -> 240/260.
- Neue Rollen-Normalisierung: weist jede Maus rollenrichtig nach Geschlecht zu,
entfernt Selbst-Verpaarungen und unmögliche Doppelrollen.
Selbst-Verpaarungen 16 -> 0, Gender-Rollen-Fehler 30 -> 0.
- Reine Helfer (litter_compatible/assign_parent_roles/names_*) auf Modulebene
extrahiert und in test_merge_resolve.py (26 Tests) abgesichert.
Frontend (pedigree):
- buildAnimal markiert den Mutter-Knoten, wenn beide Eltern dieselbe litterId
teilen (Vollgeschwister). toRawNodeDatum führt dessen Vorfahren-Ast zu einem
Verweis-Knoten zusammen ("Geschwister von <Vater>, Eltern siehe oben"); die
Vaterlinie zeigt die gemeinsamen Großeltern einmal.
- Druck-Ahnentafel bleibt vollständig (ignoriert die Marke).
- Build-Tests (+3) und e2e-Test (Inzucht-Kind-Fixture) ergänzt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -168,6 +168,13 @@ export default function StammbaumPage() {
|
||||
/* ── react-d3-tree-Daten ── */
|
||||
const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root])
|
||||
const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t])
|
||||
const hasSiblingPairing = useMemo(
|
||||
() =>
|
||||
nodesByPath
|
||||
? [...nodesByPath.values()].some((n) => n.kind === 'animal' && n.siblingPairing != null)
|
||||
: false,
|
||||
[nodesByPath],
|
||||
)
|
||||
|
||||
/* ── Zeichenfläche vermessen (Erst-Zentrierung + Einpassen) ── */
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null)
|
||||
@@ -246,6 +253,28 @@ export default function StammbaumPage() {
|
||||
const renderNode = useCallback(
|
||||
({ nodeDatum }: CustomNodeElementProps) => {
|
||||
const path = String(nodeDatum.attributes?.path ?? '')
|
||||
// Verweis-Knoten der Geschwister-Verpaarung: rein aus den Attributen
|
||||
// gerendert (kein Eintrag in nodesByPath).
|
||||
if (nodeDatum.attributes?.kind === 'sibling-ref') {
|
||||
const refName = String(nodeDatum.attributes.refName ?? '') || de.pages.gerbils.nameless
|
||||
return (
|
||||
<g>
|
||||
<foreignObject width={CARD_W} height={CARD_H} x={-CARD_W / 2} y={-CARD_H / 2}>
|
||||
<div className="pedigree-card pedigree-card--sibling">
|
||||
<span className="pedigree-card--sibling__icon" aria-hidden="true">
|
||||
⟲
|
||||
</span>
|
||||
<div className="pedigree-card--sibling__body">
|
||||
<span className="pedigree-card--sibling__label">
|
||||
{t.siblingPairing.refLabel(refName)}
|
||||
</span>
|
||||
<span className="pedigree-card--sibling__hint">{t.siblingPairing.refHint}</span>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
const node = nodesByPath?.get(path)
|
||||
return (
|
||||
<g>
|
||||
@@ -353,6 +382,7 @@ export default function StammbaumPage() {
|
||||
<li>{t.tapHint}</li>
|
||||
<li>{t.hintName}</li>
|
||||
<li>{t.hintExpand}</li>
|
||||
{hasSiblingPairing && <li>{t.hintSiblings}</li>}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -429,7 +459,7 @@ function PedigreeCard({
|
||||
)}
|
||||
{dob && <span className="pedigree-card__year">* {dob}</span>}
|
||||
</div>
|
||||
{node.expandable && (
|
||||
{node.expandable && !node.siblingPairing && (
|
||||
<button
|
||||
type="button"
|
||||
className="pedigree-card__expand"
|
||||
|
||||
@@ -231,6 +231,44 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Verweis-Knoten der Geschwister-Verpaarung: Vater & Mutter aus demselben
|
||||
Wurf — der Vorfahren-Ast verweist auf die Vaterlinie statt ihn zu doppeln. */
|
||||
.pedigree-card--sibling {
|
||||
border-style: dashed;
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-soft);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pedigree-card--sibling__icon {
|
||||
flex: none;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.pedigree-card--sibling__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pedigree-card--sibling__label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pedigree-card--sibling__hint {
|
||||
font-size: 0.72rem;
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.pedigree-card__photo {
|
||||
flex: none;
|
||||
width: 42px;
|
||||
|
||||
@@ -183,6 +183,46 @@ describe('buildPedigree', () => {
|
||||
expect(aAgain.expandable).toBe(false)
|
||||
})
|
||||
|
||||
it('markiert Geschwister-Verpaarung: Vater & Mutter aus demselben Wurf', async () => {
|
||||
// Vater und Mutter sind Vollgeschwister (gleiche litterId 'w-grand').
|
||||
const source = makeSource(
|
||||
[
|
||||
makeGerbil('kind', 'Kind', 'w-kind'),
|
||||
makeGerbil('vater', 'Vater', 'w-grand'),
|
||||
makeGerbil('mutter', 'Mutter', 'w-grand'),
|
||||
makeGerbil('opa', 'Opa', null),
|
||||
makeGerbil('oma', 'Oma', null),
|
||||
],
|
||||
[makeLitter('w-kind', 'vater', 'mutter'), makeLitter('w-grand', 'opa', 'oma')],
|
||||
)
|
||||
const root = await buildPedigree(source, 'kind')
|
||||
const vater = animal(root!.father)
|
||||
const mutter = animal(root!.mother)
|
||||
|
||||
// Mutter trägt den Verweis auf die Vaterlinie; Vater selbst nicht.
|
||||
expect(mutter.siblingPairing).toEqual({ siblingName: 'Vater', siblingPath: 'v' })
|
||||
expect(vater.siblingPairing).toBeUndefined()
|
||||
|
||||
// Echte Vorfahren bleiben im Modell (Druck-Ahnentafel nutzt sie weiter).
|
||||
expect(animal(mutter.father).gerbil.name).toBe('Opa')
|
||||
expect(animal(mutter.mother).gerbil.name).toBe('Oma')
|
||||
})
|
||||
|
||||
it('verlangt für Geschwister-Verpaarung dieselbe litterId (Halbgeschwister zählen nicht)', async () => {
|
||||
// Gemeinsamer Vater (Opa), aber verschiedene Würfe → keine Verpaarung-Marke.
|
||||
const source = makeSource(
|
||||
[
|
||||
makeGerbil('kind', 'Kind', 'w1'),
|
||||
makeGerbil('vater', 'Vater', 'w2'),
|
||||
makeGerbil('mutter', 'Mutter', 'w3'),
|
||||
makeGerbil('opa', 'Opa', null),
|
||||
],
|
||||
[makeLitter('w1', 'vater', 'mutter'), makeLitter('w2', 'opa', null), makeLitter('w3', 'opa', null)],
|
||||
)
|
||||
const root = await buildPedigree(source, 'kind')
|
||||
expect(animal(root!.mother).siblingPairing).toBeUndefined()
|
||||
})
|
||||
|
||||
it('dasselbe Tier darf an mehreren Positionen stehen (Inzucht ist kein Zyklus)', async () => {
|
||||
// Vater und Mutter haben denselben Vater (Opa) — Ahnenschwund, kein Zyklus.
|
||||
const source = makeSource(
|
||||
@@ -260,6 +300,36 @@ describe('toRawNodeDatum', () => {
|
||||
expect(datum.children![0].children).toBeUndefined()
|
||||
})
|
||||
|
||||
it('führt den Vorfahren-Ast einer Geschwister-Verpaarung zu einem Verweis zusammen', async () => {
|
||||
const source = makeSource(
|
||||
[
|
||||
makeGerbil('kind', 'Kind', 'w-kind'),
|
||||
makeGerbil('vater', 'Vater', 'w-grand'),
|
||||
makeGerbil('mutter', 'Mutter', 'w-grand'),
|
||||
makeGerbil('opa', 'Opa', null),
|
||||
makeGerbil('oma', 'Oma', null),
|
||||
],
|
||||
[makeLitter('w-kind', 'vater', 'mutter'), makeLitter('w-grand', 'opa', 'oma')],
|
||||
)
|
||||
const root = await buildPedigree(source, 'kind')
|
||||
const datum = toRawNodeDatum(root!, 'unbekannt')
|
||||
|
||||
// Vater behält seinen vollen Vorfahren-Ast …
|
||||
const vater = datum.children![0]
|
||||
expect(vater.name).toBe('Vater')
|
||||
expect(vater.children!.map((c) => c.name)).toEqual(['Opa', 'Oma'])
|
||||
|
||||
// … Mutter wird zu EINEM Verweis-Knoten zusammengeführt.
|
||||
const mutter = datum.children![1]
|
||||
expect(mutter.name).toBe('Mutter')
|
||||
expect(mutter.children).toHaveLength(1)
|
||||
expect(mutter.children![0].attributes).toMatchObject({
|
||||
kind: 'sibling-ref',
|
||||
refName: 'Vater',
|
||||
refPath: 'v',
|
||||
})
|
||||
})
|
||||
|
||||
it('rendert Platzhalter mit dem übergebenen Label', async () => {
|
||||
const root = await buildPedigree(familySource(), 'mutter')
|
||||
const datum = toRawNodeDatum(root!, 'unbekannt')
|
||||
|
||||
@@ -70,6 +70,25 @@ async function buildAnimal(
|
||||
parentNode(source, litter?.fatherId, path + 'v', generationsLeft - 1, nextAncestors),
|
||||
parentNode(source, litter?.motherId, path + 'm', generationsLeft - 1, nextAncestors),
|
||||
])
|
||||
|
||||
// Geschwister-Verpaarung: Stammen Vater und Mutter aus DEMSELBEN Wurf
|
||||
// (gleiche litterId), sind sie Vollgeschwister. Die Vorfahren-Linie ist dann
|
||||
// auf beiden Seiten identisch — wir markieren die Mutter, damit das Diagramm
|
||||
// ihren Ast zu einem Verweis auf die Vaterlinie zusammenführt. Die echten
|
||||
// Vorfahren bleiben im Modell erhalten (Druck-Ahnentafel nutzt sie weiter).
|
||||
if (
|
||||
father.kind === 'animal' &&
|
||||
mother.kind === 'animal' &&
|
||||
father.gerbil.litterId &&
|
||||
father.gerbil.litterId === mother.gerbil.litterId
|
||||
) {
|
||||
const markedMother: AnimalNode = {
|
||||
...mother,
|
||||
siblingPairing: { siblingName: father.gerbil.name, siblingPath: father.path },
|
||||
}
|
||||
return { kind: 'animal', path, gerbil, father, mother: markedMother, expandable: false }
|
||||
}
|
||||
|
||||
return { kind: 'animal', path, gerbil, father, mother, expandable: false }
|
||||
}
|
||||
|
||||
@@ -143,6 +162,24 @@ export function toRawNodeDatum(node: PedigreeNode, unknownLabel: string): RawNod
|
||||
name: node.gerbil.name,
|
||||
attributes: { path: node.path, kind: 'animal', expandable: node.expandable },
|
||||
}
|
||||
// Geschwister-Verpaarung: Vorfahren-Ast zu EINEM Verweis-Knoten
|
||||
// zusammenführen (gemeinsame Vorfahren stehen bereits an der Vaterlinie).
|
||||
if (node.siblingPairing) {
|
||||
datum.children = [
|
||||
{
|
||||
name: node.siblingPairing.siblingName,
|
||||
attributes: {
|
||||
// Synthetischer Pfad (nicht in collectNodes) — die Karte rendert
|
||||
// ausschließlich aus diesen Attributen.
|
||||
path: node.path + '§',
|
||||
kind: 'sibling-ref',
|
||||
refName: node.siblingPairing.siblingName,
|
||||
refPath: node.siblingPairing.siblingPath,
|
||||
},
|
||||
},
|
||||
]
|
||||
return datum
|
||||
}
|
||||
if (node.father && node.mother) {
|
||||
datum.children = [
|
||||
toRawNodeDatum(node.father, unknownLabel),
|
||||
|
||||
@@ -38,6 +38,15 @@ export interface AnimalNode {
|
||||
readonly mother?: PedigreeNode
|
||||
/** True: Tier hat einen Wurf, dessen Eltern noch nachgeladen werden können. */
|
||||
readonly expandable: boolean
|
||||
/**
|
||||
* Geschwister-Verpaarung: gesetzt, wenn dieses Tier und sein Geschwister
|
||||
* (an `siblingPath`) aus DEMSELBEN Wurf stammen und beide Eltern des
|
||||
* Wurzeltiers sind (Vollgeschwister-Verpaarung — in der Zucht häufig).
|
||||
* Im interaktiven Diagramm wird der Vorfahren-Ast dieses Knotens dann zu
|
||||
* einem Verweis kollabiert (gemeinsame Vorfahren siehe `siblingPath`); die
|
||||
* echten Vorfahren bleiben im Modell (Druck-Ahnentafel zeigt sie weiterhin).
|
||||
*/
|
||||
readonly siblingPairing?: { readonly siblingName: string; readonly siblingPath: PedigreePath }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -411,6 +411,12 @@ export const de = {
|
||||
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
|
||||
hintName: 'Namenslink: Tierakte öffnen',
|
||||
hintExpand: '+: weitere Vorfahren nachladen',
|
||||
hintSiblings: '⟲: Eltern sind Geschwister — gemeinsame Vorfahren siehe Vaterlinie',
|
||||
/** Verweis-Knoten bei Geschwister-Verpaarung (Vater & Mutter aus demselben Wurf). */
|
||||
siblingPairing: {
|
||||
refLabel: (name: string) => `Geschwister von ${name}`,
|
||||
refHint: 'Eltern siehe oben',
|
||||
},
|
||||
/** Würfe-Panel links (STAMMBAUM-LITTERS). */
|
||||
littersTitle: 'Würfe',
|
||||
littersJunge: 'Junge',
|
||||
|
||||
Reference in New Issue
Block a user