Files
GerbilManager/gerbil-manager-web/src/pedigree/build.ts

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
}