FEAT-4: pedigree data module - depth-limited ancestor traversal, lazy expand, cycle guard, rd3t conversion (15 tests)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
gerbil-manager-web/src/api/litters.ts
Normal file
7
gerbil-manager-web/src/api/litters.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/** Typed API functions for the Würfe (litters) resource. */
|
||||
import { api, resources } from './client'
|
||||
import type { Litter } from './types'
|
||||
|
||||
export function getLitter(id: string): Promise<Litter> {
|
||||
return api.get<Litter>(`${resources.litters}/${id}`)
|
||||
}
|
||||
22
gerbil-manager-web/src/api/pedigree.ts
Normal file
22
gerbil-manager-web/src/api/pedigree.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* FEAT-4: Stammbaum-bezogene API-Aufrufe.
|
||||
*
|
||||
* Der Inzuchtkoeffizient wird serverseitig über die VOLLE Ahnentiefe berechnet
|
||||
* (Board-Entscheidung; UI zeigt 4 Generationen, Koeffizient zählt alle).
|
||||
* Endpunkt entsteht in DATA-2-Folgearbeit — bis dahin antwortet er mit 404 und
|
||||
* die UI zeigt einen „wird berechnet …“-Zustand.
|
||||
*/
|
||||
import { api, resources } from './client'
|
||||
|
||||
export interface InbreedingCoefficient {
|
||||
/** Koeffizient nach Wright, 0..1. */
|
||||
coefficient: number
|
||||
}
|
||||
|
||||
export async function getInbreedingCoefficient(gerbilId: string): Promise<number> {
|
||||
const raw = await api.get<InbreedingCoefficient | number>(
|
||||
`${resources.gerbils}/${gerbilId}/inbreeding-coefficient`,
|
||||
)
|
||||
// Defensiv: Endpunkt-Form ist noch nicht final (nacktes number vs. Objekt).
|
||||
return typeof raw === 'number' ? raw : raw.coefficient
|
||||
}
|
||||
302
gerbil-manager-web/src/pedigree/__tests__/build.test.ts
Normal file
302
gerbil-manager-web/src/pedigree/__tests__/build.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* FEAT-4: Tests für den Ahnenbaum-Builder (build.ts).
|
||||
*
|
||||
* Reine Daten-Tests (keine DOM/React): In-Memory-PedigreeSource mit
|
||||
* Aufruf-Zählern statt API.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Gerbil, Litter } from '../../api/types'
|
||||
import {
|
||||
ancestorsAt,
|
||||
buildPedigree,
|
||||
collectNodes,
|
||||
expandPedigree,
|
||||
toRawNodeDatum,
|
||||
} from '../build'
|
||||
import type { AnimalNode, PedigreeNode, PedigreeSource } from '../types'
|
||||
|
||||
/* ── Testdaten-Helfer ─────────────────────────────────────────── */
|
||||
|
||||
function makeGerbil(id: string, name: string, litterId: string | null = null): Gerbil {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
gender: 'unknown',
|
||||
status: 'Active',
|
||||
dateOfBirth: null,
|
||||
dateOfDeath: null,
|
||||
causeOfDeath: null,
|
||||
goHomeDate: null,
|
||||
litterId,
|
||||
enclosureId: null,
|
||||
colorVarietyId: null,
|
||||
originContactId: null,
|
||||
receiverContactId: null,
|
||||
genotype: null,
|
||||
notes: null,
|
||||
}
|
||||
}
|
||||
|
||||
function makeLitter(id: string, fatherId: string | null, motherId: string | null): Litter {
|
||||
return { id, name: `Wurf ${id}`, date: '2025-01-01', totalBorn: null, fatherId, motherId }
|
||||
}
|
||||
|
||||
interface CountingSource extends PedigreeSource {
|
||||
gerbilCalls: Map<string, number>
|
||||
litterCalls: Map<string, number>
|
||||
}
|
||||
|
||||
function makeSource(gerbils: Gerbil[], litters: Litter[]): CountingSource {
|
||||
const gerbilMap = new Map(gerbils.map((g) => [g.id, g]))
|
||||
const litterMap = new Map(litters.map((l) => [l.id, l]))
|
||||
const gerbilCalls = new Map<string, number>()
|
||||
const litterCalls = new Map<string, number>()
|
||||
return {
|
||||
gerbilCalls,
|
||||
litterCalls,
|
||||
getGerbil: (id) => {
|
||||
gerbilCalls.set(id, (gerbilCalls.get(id) ?? 0) + 1)
|
||||
return Promise.resolve(gerbilMap.get(id) ?? null)
|
||||
},
|
||||
getLitter: (id) => {
|
||||
litterCalls.set(id, (litterCalls.get(id) ?? 0) + 1)
|
||||
return Promise.resolve(litterMap.get(id) ?? null)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard-Familie:
|
||||
* kind ← (vater, mutter); vater ← (opa, oma); mutter ohne Wurf.
|
||||
* opa hat einen Wurf (uropas), der NICHT vorab geladen werden soll,
|
||||
* wenn die Tiefe es begrenzt.
|
||||
*/
|
||||
function familySource(): CountingSource {
|
||||
return makeSource(
|
||||
[
|
||||
makeGerbil('kind', 'Kind', 'wurf-kind'),
|
||||
makeGerbil('vater', 'Vater', 'wurf-vater'),
|
||||
makeGerbil('mutter', 'Mutter', null),
|
||||
makeGerbil('opa', 'Opa', 'wurf-opa'),
|
||||
makeGerbil('oma', 'Oma', null),
|
||||
makeGerbil('uropa', 'Uropa', null),
|
||||
],
|
||||
[
|
||||
makeLitter('wurf-kind', 'vater', 'mutter'),
|
||||
makeLitter('wurf-vater', 'opa', 'oma'),
|
||||
makeLitter('wurf-opa', 'uropa', null),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
function animal(node: PedigreeNode | undefined): AnimalNode {
|
||||
if (!node || node.kind !== 'animal') throw new Error(`Tier-Knoten erwartet, war: ${node?.kind}`)
|
||||
return node
|
||||
}
|
||||
|
||||
/* ── buildPedigree ────────────────────────────────────────────── */
|
||||
|
||||
describe('buildPedigree', () => {
|
||||
it('baut den Baum mit Pfaden, Vater-vor-Mutter und Platzhaltern für Gründertiere', async () => {
|
||||
const root = await buildPedigree(familySource(), 'kind')
|
||||
expect(root).not.toBeNull()
|
||||
expect(root!.gerbil.name).toBe('Kind')
|
||||
expect(root!.path).toBe('')
|
||||
|
||||
const vater = animal(root!.father)
|
||||
const mutter = animal(root!.mother)
|
||||
expect(vater.gerbil.name).toBe('Vater')
|
||||
expect(vater.path).toBe('v')
|
||||
expect(mutter.gerbil.name).toBe('Mutter')
|
||||
expect(mutter.path).toBe('m')
|
||||
|
||||
// Mutter ohne Wurf: beide Eltern als „unbekannt“-Platzhalter (Symmetrie).
|
||||
expect(mutter.father).toEqual({ kind: 'unknown', path: 'mv' })
|
||||
expect(mutter.mother).toEqual({ kind: 'unknown', path: 'mm' })
|
||||
expect(mutter.expandable).toBe(false)
|
||||
})
|
||||
|
||||
it('liefert null, wenn das Wurzeltier nicht existiert', async () => {
|
||||
expect(await buildPedigree(familySource(), 'gibt-es-nicht')).toBeNull()
|
||||
})
|
||||
|
||||
it('begrenzt die Tiefe und markiert die Ladegrenze als erweiterbar', async () => {
|
||||
const root = await buildPedigree(familySource(), 'kind', 1)
|
||||
const vater = animal(root!.father)
|
||||
const mutter = animal(root!.mother)
|
||||
|
||||
// Generation 1 ist die Grenze: keine Eltern geladen.
|
||||
expect(vater.father).toBeUndefined()
|
||||
expect(vater.mother).toBeUndefined()
|
||||
// Vater hat einen Wurf → nachladbar; Mutter ohne Wurf → nicht.
|
||||
expect(vater.expandable).toBe(true)
|
||||
expect(mutter.expandable).toBe(false)
|
||||
})
|
||||
|
||||
it('Standardtiefe 4: lädt genau 4 Generationen', async () => {
|
||||
const root = await buildPedigree(familySource(), 'kind')
|
||||
// Uropa liegt in Generation 3 (kind → vater → opa → uropa) und hat keinen
|
||||
// Wurf → seine Eltern sind als Platzhalter geladen (Generation 4 = Grenze).
|
||||
const uropa = animal(animal(animal(root!.father).father).father)
|
||||
expect(uropa.gerbil.name).toBe('Uropa')
|
||||
expect(uropa.father).toEqual({ kind: 'unknown', path: 'vvvv' })
|
||||
})
|
||||
|
||||
it('setzt Platzhalter für fehlenden Eltern-Eintrag im Wurf und für 404-Tiere', async () => {
|
||||
const source = makeSource(
|
||||
[makeGerbil('a', 'A', 'wurf-a')],
|
||||
// Vater-Id zeigt ins Leere (gelöschtes Tier), Mutter fehlt im Wurf.
|
||||
[makeLitter('wurf-a', 'geloescht', null)],
|
||||
)
|
||||
const root = await buildPedigree(source, 'a')
|
||||
expect(root!.father).toEqual({ kind: 'unknown', path: 'v' })
|
||||
expect(root!.mother).toEqual({ kind: 'unknown', path: 'm' })
|
||||
})
|
||||
|
||||
it('behandelt einen 404-Wurf wie unbekannte Eltern', async () => {
|
||||
const source = makeSource([makeGerbil('a', 'A', 'wurf-weg')], [])
|
||||
const root = await buildPedigree(source, 'a')
|
||||
expect(root!.father).toEqual({ kind: 'unknown', path: 'v' })
|
||||
expect(root!.mother).toEqual({ kind: 'unknown', path: 'm' })
|
||||
})
|
||||
|
||||
it('beendet Zyklen (Tier als eigener Vorfahre) ohne Endlosschleife', async () => {
|
||||
const source = makeSource(
|
||||
[makeGerbil('a', 'A', 'wurf-a'), makeGerbil('b', 'B', 'wurf-b')],
|
||||
[makeLitter('wurf-a', 'b', null), makeLitter('wurf-b', 'a', null)],
|
||||
)
|
||||
const root = await buildPedigree(source, 'a')
|
||||
const b = animal(root!.father)
|
||||
const aAgain = animal(b.father)
|
||||
expect(aAgain.gerbil.id).toBe('a')
|
||||
// Zyklus: keine Eltern, nicht erweiterbar (trotz vorhandenem Wurf).
|
||||
expect(aAgain.father).toBeUndefined()
|
||||
expect(aAgain.expandable).toBe(false)
|
||||
})
|
||||
|
||||
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(
|
||||
[
|
||||
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(animal(root!.father).father).gerbil.id).toBe('opa')
|
||||
expect(animal(animal(root!.mother).father).gerbil.id).toBe('opa')
|
||||
})
|
||||
})
|
||||
|
||||
/* ── expandPedigree ───────────────────────────────────────────── */
|
||||
|
||||
describe('expandPedigree', () => {
|
||||
it('lädt an der Grenze eine weitere Generation nach und teilt unveränderte Äste', async () => {
|
||||
const source = familySource()
|
||||
const root = await buildPedigree(source, 'kind', 1)
|
||||
const vorher = animal(root!.father)
|
||||
expect(vorher.father).toBeUndefined()
|
||||
|
||||
const next = await expandPedigree(source, root!, 'v')
|
||||
// Neuer Baum: erweiterter Ast hat jetzt Eltern …
|
||||
const vater = animal(next.father)
|
||||
expect(animal(vater.father).gerbil.name).toBe('Opa')
|
||||
expect(animal(vater.mother).gerbil.name).toBe('Oma')
|
||||
// … Wurzel ist ein neues Objekt, der unberührte Mutter-Ast wird geteilt.
|
||||
expect(next).not.toBe(root)
|
||||
expect(next.mother).toBe(root!.mother)
|
||||
})
|
||||
|
||||
it('erweiterte Knoten an der neuen Grenze sind wiederum erweiterbar', async () => {
|
||||
const source = familySource()
|
||||
const root = await buildPedigree(source, 'kind', 1)
|
||||
const next = await expandPedigree(source, root!, 'v')
|
||||
// Opa (Generation 2, neue Grenze) hat einen Wurf → erweiterbar.
|
||||
expect(animal(animal(next.father).father).expandable).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/* ── Caching-Vertrag der Quelle ───────────────────────────────── */
|
||||
|
||||
describe('Quellen-Aufrufe', () => {
|
||||
it('fragt jedes Tier und jeden Wurf höchstens einmal pro Aufbau an', async () => {
|
||||
const source = familySource()
|
||||
await buildPedigree(source, 'kind')
|
||||
for (const [id, count] of source.gerbilCalls) {
|
||||
expect(count, `Tier ${id} mehrfach geladen`).toBe(1)
|
||||
}
|
||||
for (const [id, count] of source.litterCalls) {
|
||||
expect(count, `Wurf ${id} mehrfach geladen`).toBe(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/* ── toRawNodeDatum / collectNodes ────────────────────────────── */
|
||||
|
||||
describe('toRawNodeDatum', () => {
|
||||
it('konvertiert in die react-d3-tree-Struktur (Name, Pfad, Kinder [Vater, Mutter])', async () => {
|
||||
const root = await buildPedigree(familySource(), 'kind', 1)
|
||||
const datum = toRawNodeDatum(root!, 'unbekannt')
|
||||
|
||||
expect(datum.name).toBe('Kind')
|
||||
expect(datum.attributes).toMatchObject({ path: '', kind: 'animal', expandable: false })
|
||||
expect(datum.children).toHaveLength(2)
|
||||
expect(datum.children![0].name).toBe('Vater')
|
||||
expect(datum.children![0].attributes).toMatchObject({ path: 'v', expandable: true })
|
||||
expect(datum.children![1].name).toBe('Mutter')
|
||||
// Grenz-Knoten: keine children-Eigenschaft (nicht geladen ≠ leer).
|
||||
expect(datum.children![0].children).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rendert Platzhalter mit dem übergebenen Label', async () => {
|
||||
const root = await buildPedigree(familySource(), 'mutter')
|
||||
const datum = toRawNodeDatum(root!, 'unbekannt')
|
||||
expect(datum.children![0]).toEqual({
|
||||
name: 'unbekannt',
|
||||
attributes: { path: 'v', kind: 'unknown' },
|
||||
})
|
||||
})
|
||||
|
||||
it('collectNodes indiziert jeden Knoten unter seinem Pfad', async () => {
|
||||
const root = await buildPedigree(familySource(), 'kind')
|
||||
const nodes = collectNodes(root!)
|
||||
expect(nodes.get('')).toBe(root)
|
||||
expect(animal(nodes.get('vv')).gerbil.name).toBe('Opa')
|
||||
expect(nodes.get('mv')?.kind).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
/* ── ancestorsAt (Druckansicht) ───────────────────────────────── */
|
||||
|
||||
describe('ancestorsAt', () => {
|
||||
it('liefert die Plätze einer Generation in Ahnentafel-Ordnung mit Lücken', async () => {
|
||||
const root = await buildPedigree(familySource(), 'kind')
|
||||
|
||||
expect(ancestorsAt(root!, 0)).toEqual([root])
|
||||
|
||||
const gen1 = ancestorsAt(root!, 1)
|
||||
expect(gen1.map((n) => (n?.kind === 'animal' ? n.gerbil.name : n?.kind))).toEqual([
|
||||
'Vater',
|
||||
'Mutter',
|
||||
])
|
||||
|
||||
const gen2 = ancestorsAt(root!, 2)
|
||||
expect(gen2).toHaveLength(4)
|
||||
expect(animal(gen2[0]!).gerbil.name).toBe('Opa')
|
||||
expect(animal(gen2[1]!).gerbil.name).toBe('Oma')
|
||||
expect(gen2[2]?.kind).toBe('unknown') // Mutter-Seite: Platzhalter
|
||||
expect(gen2[3]?.kind).toBe('unknown')
|
||||
|
||||
const gen3 = ancestorsAt(root!, 3)
|
||||
expect(gen3).toHaveLength(8)
|
||||
expect(animal(gen3[0]!).gerbil.name).toBe('Uropa')
|
||||
expect(gen3[1]?.kind).toBe('unknown') // Opas Wurf ohne Mutter
|
||||
// Hinter Oma (ohne Wurf, Platzhalter-Eltern) …
|
||||
expect(gen3[2]?.kind).toBe('unknown')
|
||||
// … hinter Platzhaltern: leere Plätze.
|
||||
expect(gen3.slice(4)).toEqual([null, null, null, null])
|
||||
})
|
||||
})
|
||||
182
gerbil-manager-web/src/pedigree/build.ts
Normal file
182
gerbil-manager-web/src/pedigree/build.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* FEAT-4: Aufbau und Erweiterung des Ahnenbaums + Konvertierung nach
|
||||
* react-d3-tree.
|
||||
*
|
||||
* Clientseitige Traversierung über die vorhandenen Endpunkte:
|
||||
* Tier → sein Wurf (litterId) → Vater/Mutter → rekursiv, tiefenbegrenzt
|
||||
* (Standard 4 Generationen, Board-Entscheidung). Tiefere Generationen werden
|
||||
* auf Wunsch nachgeladen (expandPedigree).
|
||||
*
|
||||
* Zyklenschutz: Steht ein Tier bereits auf dem Pfad zur Wurzel (defekte Daten:
|
||||
* „eigener Vorfahre“), wird die Rekursion dort beendet und der Knoten nicht
|
||||
* erweiterbar markiert.
|
||||
*/
|
||||
import type { RawNodeDatum } from 'react-d3-tree'
|
||||
import type { Gerbil } from '../api/types'
|
||||
import type { AnimalNode, PedigreeNode, PedigreePath, PedigreeSource, UnknownNode } from './types'
|
||||
|
||||
/** Standard-Generationstiefe der Erstanzeige (Board: UI 4 Generationen). */
|
||||
export const DEFAULT_GENERATIONS = 4
|
||||
|
||||
function unknown(path: PedigreePath): UnknownNode {
|
||||
return { kind: 'unknown', path }
|
||||
}
|
||||
|
||||
async function parentNode(
|
||||
source: PedigreeSource,
|
||||
id: string | null | undefined,
|
||||
path: PedigreePath,
|
||||
generationsLeft: number,
|
||||
ancestors: ReadonlySet<string>,
|
||||
): Promise<PedigreeNode> {
|
||||
if (!id) return unknown(path)
|
||||
const gerbil = await source.getGerbil(id)
|
||||
if (!gerbil) return unknown(path)
|
||||
return buildAnimal(source, gerbil, path, generationsLeft, ancestors)
|
||||
}
|
||||
|
||||
async function buildAnimal(
|
||||
source: PedigreeSource,
|
||||
gerbil: Gerbil,
|
||||
path: PedigreePath,
|
||||
generationsLeft: number,
|
||||
ancestors: ReadonlySet<string>,
|
||||
): Promise<AnimalNode> {
|
||||
const isCycle = ancestors.has(gerbil.id)
|
||||
if (isCycle || generationsLeft <= 0) {
|
||||
// Ladegrenze (oder Zyklus): Eltern nicht laden. Erweiterbar nur, wenn es
|
||||
// einen Wurf gibt und kein Zyklus vorliegt.
|
||||
return { kind: 'animal', path, gerbil, expandable: !isCycle && gerbil.litterId != null }
|
||||
}
|
||||
|
||||
if (!gerbil.litterId) {
|
||||
// Gründertier ohne erfassten Wurf: Eltern unbekannt. Platzhalter wahren
|
||||
// die Symmetrie der Ahnentafel.
|
||||
return {
|
||||
kind: 'animal',
|
||||
path,
|
||||
gerbil,
|
||||
father: unknown(path + 'v'),
|
||||
mother: unknown(path + 'm'),
|
||||
expandable: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Wurf laden; bei 404 (gelöschter Wurf) zählen die Eltern als unbekannt.
|
||||
const litter = await source.getLitter(gerbil.litterId)
|
||||
const nextAncestors = new Set(ancestors)
|
||||
nextAncestors.add(gerbil.id)
|
||||
const [father, mother] = await Promise.all([
|
||||
parentNode(source, litter?.fatherId, path + 'v', generationsLeft - 1, nextAncestors),
|
||||
parentNode(source, litter?.motherId, path + 'm', generationsLeft - 1, nextAncestors),
|
||||
])
|
||||
return { kind: 'animal', path, gerbil, father, mother, expandable: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut den Ahnenbaum für ein Wurzeltier. `null`, wenn das Tier selbst nicht
|
||||
* existiert (404).
|
||||
*/
|
||||
export async function buildPedigree(
|
||||
source: PedigreeSource,
|
||||
rootId: string,
|
||||
generations: number = DEFAULT_GENERATIONS,
|
||||
): Promise<AnimalNode | null> {
|
||||
const root = await source.getGerbil(rootId)
|
||||
if (!root) return null
|
||||
return buildAnimal(source, root, '', generations, new Set())
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt für den Knoten an `path` weitere `generations` Vorfahren-Generationen
|
||||
* nach und liefert eine NEUE Wurzel (unveränderte Äste werden strukturell
|
||||
* geteilt — geeignet für React-State).
|
||||
*/
|
||||
export async function expandPedigree(
|
||||
source: PedigreeSource,
|
||||
root: AnimalNode,
|
||||
path: PedigreePath,
|
||||
generations = 1,
|
||||
): Promise<AnimalNode> {
|
||||
const next = await expandAt(source, root, path, new Set(), generations)
|
||||
return next as AnimalNode
|
||||
}
|
||||
|
||||
async function expandAt(
|
||||
source: PedigreeSource,
|
||||
node: PedigreeNode,
|
||||
target: PedigreePath,
|
||||
ancestors: ReadonlySet<string>,
|
||||
generations: number,
|
||||
): Promise<PedigreeNode> {
|
||||
if (node.kind === 'unknown') return node
|
||||
if (node.path === target) {
|
||||
return buildAnimal(source, node.gerbil, node.path, generations, ancestors)
|
||||
}
|
||||
if (!target.startsWith(node.path)) return node
|
||||
|
||||
const branch = target.charAt(node.path.length) // 'v' | 'm'
|
||||
const nextAncestors = new Set(ancestors)
|
||||
nextAncestors.add(node.gerbil.id)
|
||||
if (branch === 'v' && node.father) {
|
||||
return { ...node, father: await expandAt(source, node.father, target, nextAncestors, generations) }
|
||||
}
|
||||
if (branch === 'm' && node.mother) {
|
||||
return { ...node, mother: await expandAt(source, node.mother, target, nextAncestors, generations) }
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
/* ── Konvertierung für react-d3-tree ──────────────────────────── */
|
||||
|
||||
/**
|
||||
* Konvertiert den Ahnenbaum in die react-d3-tree-Struktur. Kinder eines
|
||||
* Knotens sind [Vater, Mutter] (Vater oben — klassische Ahnentafel-Ordnung).
|
||||
* Die Karten-Renderfunktion findet den vollen Knoten über `attributes.path`
|
||||
* (siehe collectNodes); RawNodeDatum.attributes erlaubt nur flache Primitive.
|
||||
*/
|
||||
export function toRawNodeDatum(node: PedigreeNode, unknownLabel: string): RawNodeDatum {
|
||||
if (node.kind === 'unknown') {
|
||||
return { name: unknownLabel, attributes: { path: node.path, kind: 'unknown' } }
|
||||
}
|
||||
const datum: RawNodeDatum = {
|
||||
name: node.gerbil.name,
|
||||
attributes: { path: node.path, kind: 'animal', expandable: node.expandable },
|
||||
}
|
||||
if (node.father && node.mother) {
|
||||
datum.children = [
|
||||
toRawNodeDatum(node.father, unknownLabel),
|
||||
toRawNodeDatum(node.mother, unknownLabel),
|
||||
]
|
||||
}
|
||||
return datum
|
||||
}
|
||||
|
||||
/** Index aller Knoten nach Position (für die Karten-Renderfunktion). */
|
||||
export function collectNodes(root: PedigreeNode): Map<PedigreePath, PedigreeNode> {
|
||||
const map = new Map<PedigreePath, PedigreeNode>()
|
||||
const walk = (node: PedigreeNode): void => {
|
||||
map.set(node.path, node)
|
||||
if (node.kind === 'animal') {
|
||||
if (node.father) walk(node.father)
|
||||
if (node.mother) walk(node.mother)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Plätze einer Generation in Ahnentafel-Ordnung (Vater-Linie zuerst).
|
||||
* Länge = 2^generation; `null` = Platz nicht belegt/geladen (Druckansicht
|
||||
* rendert dafür leere Zellen).
|
||||
*/
|
||||
export function ancestorsAt(root: PedigreeNode, generation: number): (PedigreeNode | null)[] {
|
||||
let slots: (PedigreeNode | null)[] = [root]
|
||||
for (let g = 0; g < generation; g++) {
|
||||
slots = slots.flatMap((node) =>
|
||||
node && node.kind === 'animal' ? [node.father ?? null, node.mother ?? null] : [null, null],
|
||||
)
|
||||
}
|
||||
return slots
|
||||
}
|
||||
53
gerbil-manager-web/src/pedigree/chipColors.ts
Normal file
53
gerbil-manager-web/src/pedigree/chipColors.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* FEAT-4: Anzeigefarben der Farbschlag-Chips auf den Stammbaum-Karten.
|
||||
*
|
||||
* Reine Präsentation (angenäherte Fellfarben) — KEINE Genetik. Schlüssel sind
|
||||
* die Basisnamen aus dem GEN-1-Katalog (src/genetics/catalog.ts BASE_COLORS);
|
||||
* Modifikatoren („… Schecke“, „… Rex“) werden über den Basisnamen-Präfix
|
||||
* aufgelöst. Unbekannte Namen → null, die Karte nutzt dann den neutralen Chip.
|
||||
*/
|
||||
export interface ChipColor {
|
||||
readonly bg: string
|
||||
readonly fg: string
|
||||
}
|
||||
|
||||
const CHIP_COLORS: Record<string, ChipColor> = {
|
||||
// Colourpoint / C-Serie
|
||||
'Pink Eyed White (PEW)': { bg: '#f7f3ec', fg: '#8a7d6b' },
|
||||
Hermelin: { bg: '#f3ede2', fg: '#8a7d6b' },
|
||||
Himalaya: { bg: '#efe6d8', fg: '#8a7d6b' },
|
||||
Zobel: { bg: '#6b4f3a', fg: '#ffffff' },
|
||||
// Schimmel
|
||||
Schwarzschimmel: { bg: '#7a7470', fg: '#ffffff' },
|
||||
Rotaugenschimmel: { bg: '#c0a99a', fg: '#3b3026' },
|
||||
// Schwarzäugige Vollfarben
|
||||
Agouti: { bg: '#b3854d', fg: '#ffffff' },
|
||||
Schwarz: { bg: '#2f2a26', fg: '#ffffff' },
|
||||
Silberagouti: { bg: '#b9b3a8', fg: '#3b3026' },
|
||||
Anthrazit: { bg: '#5a5550', fg: '#ffffff' },
|
||||
Algierfuchs: { bg: '#c98a3d', fg: '#ffffff' },
|
||||
Blau: { bg: '#8a93a5', fg: '#ffffff' },
|
||||
// Rotäugige Vollfarben
|
||||
Gold: { bg: '#d9a441', fg: '#3b3026' },
|
||||
Platin: { bg: '#c8c2bd', fg: '#3b3026' },
|
||||
Goldfuchs: { bg: '#e0b15e', fg: '#3b3026' },
|
||||
Rotfuchs: { bg: '#b56a3c', fg: '#ffffff' },
|
||||
'dd Gold': { bg: '#e3c98f', fg: '#3b3026' },
|
||||
'dd Platin': { bg: '#d9d4cf', fg: '#3b3026' },
|
||||
}
|
||||
|
||||
/** Chipfarbe für einen Farbschlag-Namen (inkl. Modifikator-Suffixen). */
|
||||
export function chipColorFor(farbschlag: string): ChipColor | null {
|
||||
const exact = CHIP_COLORS[farbschlag]
|
||||
if (exact) return exact
|
||||
// Modifikatoren abgetrennt: längster passender Basisname gewinnt.
|
||||
let best: ChipColor | null = null
|
||||
let bestLength = 0
|
||||
for (const [name, color] of Object.entries(CHIP_COLORS)) {
|
||||
if (farbschlag.startsWith(name + ' ') && name.length > bestLength) {
|
||||
best = color
|
||||
bestLength = name.length
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
38
gerbil-manager-web/src/pedigree/source.ts
Normal file
38
gerbil-manager-web/src/pedigree/source.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* FEAT-4: API-gestützte PedigreeSource mit Cache.
|
||||
*
|
||||
* Der Cache lebt so lange wie die Stammbaum-Seite gemountet ist und bleibt
|
||||
* beim Umwurzeln (Tipp auf eine Karte) erhalten — gemeinsame Vorfahren werden
|
||||
* nur einmal geladen, der Wechsel fühlt sich dadurch unmittelbar an.
|
||||
*
|
||||
* 404 → null („unbekannt“); andere Fehler werden NICHT gecacht (erneuter
|
||||
* Versuch möglich) und an den Aufrufer durchgereicht.
|
||||
*/
|
||||
import { ApiError } from '../api/client'
|
||||
import { getGerbil } from '../api/gerbils'
|
||||
import { getLitter } from '../api/litters'
|
||||
import type { Gerbil, Litter } from '../api/types'
|
||||
import type { PedigreeSource } from './types'
|
||||
|
||||
function cached<T>(load: (id: string) => Promise<T>): (id: string) => Promise<T | null> {
|
||||
const cache = new Map<string, Promise<T | null>>()
|
||||
return (id: string) => {
|
||||
let entry = cache.get(id)
|
||||
if (!entry) {
|
||||
entry = load(id).catch((err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 404) return null
|
||||
cache.delete(id)
|
||||
throw err
|
||||
})
|
||||
cache.set(id, entry)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
}
|
||||
|
||||
export function createApiPedigreeSource(): PedigreeSource {
|
||||
return {
|
||||
getGerbil: cached<Gerbil>(getGerbil),
|
||||
getLitter: cached<Litter>(getLitter),
|
||||
}
|
||||
}
|
||||
50
gerbil-manager-web/src/pedigree/types.ts
Normal file
50
gerbil-manager-web/src/pedigree/types.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* FEAT-4: Datenmodell des Ahnenbaums (Stammbaum).
|
||||
*
|
||||
* Reines TypeScript-Modul ohne React/DOM — testbar und sprachneutral
|
||||
* (deutsche Texte kommen ausschließlich aus de.ts, in der UI-Schicht).
|
||||
*/
|
||||
import type { Gerbil, Litter } from '../api/types'
|
||||
|
||||
/**
|
||||
* Datenquelle für die Traversierung. In der App von einer gecachten
|
||||
* API-Implementierung erfüllt (source.ts); in Tests von In-Memory-Maps.
|
||||
* `null` = nicht vorhanden (z. B. 404) → wird als „unbekannt“ gerendert.
|
||||
*/
|
||||
export interface PedigreeSource {
|
||||
getGerbil(id: string): Promise<Gerbil | null>
|
||||
getLitter(id: string): Promise<Litter | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Position eines Knotens im Ahnenbaum: '' = Wurzeltier, danach pro Generation
|
||||
* ein Buchstabe — 'v' = Vater, 'm' = Mutter. Beispiel: 'vm' = Großmutter
|
||||
* väterlicherseits. Der Pfad ist der eindeutige Schlüssel einer POSITION;
|
||||
* dasselbe Tier kann (bei Inzucht) an mehreren Positionen stehen.
|
||||
*/
|
||||
export type PedigreePath = string
|
||||
|
||||
export type PedigreeNode = AnimalNode | UnknownNode
|
||||
|
||||
export interface AnimalNode {
|
||||
readonly kind: 'animal'
|
||||
readonly path: PedigreePath
|
||||
readonly gerbil: Gerbil
|
||||
/**
|
||||
* Eltern: entweder BEIDE gesetzt (geladen; ggf. „unbekannt“-Platzhalter)
|
||||
* oder BEIDE undefined (Ladegrenze der aktuellen Generationstiefe).
|
||||
*/
|
||||
readonly father?: PedigreeNode
|
||||
readonly mother?: PedigreeNode
|
||||
/** True: Tier hat einen Wurf, dessen Eltern noch nachgeladen werden können. */
|
||||
readonly expandable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Platzhalter für einen unbekannten Vorfahren. Hält die Geometrie der
|
||||
* Ahnentafel symmetrisch (Zertifikats-Optik), hat selbst keine Eltern.
|
||||
*/
|
||||
export interface UnknownNode {
|
||||
readonly kind: 'unknown'
|
||||
readonly path: PedigreePath
|
||||
}
|
||||
Reference in New Issue
Block a user