183 lines
6.2 KiB
TypeScript
183 lines
6.2 KiB
TypeScript
/**
|
|
* 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
|
|
}
|