"use client"; // Vendored family-chart styles (the package blocks the CSS subpath export). import "../app/trees/[id]/tree/chart.css"; import { useCallback, useEffect, useMemo, useRef } from "react"; import type { components } from "@/lib/api/schema"; type Person = components["schemas"]["PersonRead"]; type Relationship = components["schemas"]["RelationshipRead"]; type Event = components["schemas"]["EventRead"]; function splitName(name: string | null | undefined): [string, string] { const t = (name ?? "").trim().split(/\s+/).filter(Boolean); if (t.length <= 1) return [name ?? "", ""]; return [t.slice(0, -1).join(" "), t[t.length - 1]]; } /** * Read-only family-chart hourglass for the public surface. Same renderer the * member tree view uses (incl. the cycle-sanitisation that keeps a bad graph * from blowing the stack), fed by already-redacted public data. Clicking a card * recenters; `onOpen` links out to the person's public page. */ export function PublicTreeChart({ people, rels, events, focusId, onFocus, onOpen, }: { people: Person[]; rels: Relationship[]; events: Event[]; focusId: string | null; onFocus: (id: string) => void; onOpen?: (id: string) => void; }) { const containerRef = useRef(null); // eslint-disable-next-line @typescript-eslint/no-explicit-any const chartRef = useRef(null); const parentsOf = useCallback( (id: string) => rels.filter((x) => x.type === "parent_child" && x.person_to_id === id).map((x) => x.person_from_id), [rels], ); const partnersOf = useCallback( (id: string) => rels .filter((x) => x.type === "partnership" && (x.person_from_id === id || x.person_to_id === id)) .map((x) => (x.person_from_id === id ? x.person_to_id : x.person_from_id)), [rels], ); const years = useMemo(() => { const m = new Map(); for (const ev of events) { if (ev.person_id && ev.event_type === "birth" && !m.has(ev.person_id)) { const y = ev.date_start ? ev.date_start.slice(0, 4) : ev.date_value ?? ""; if (y) m.set(ev.person_id, y); } } return m; }, [events]); const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]); const nameOf = useCallback((id: string) => byId.get(id)?.primary_name ?? "person", [byId]); // Build the chart when the data changes (not on focus — recenter handles that). useEffect(() => { if (people.length === 0 || !containerRef.current) return; let cancelled = false; (async () => { const alive = new Set(people.map((pp) => pp.id)); const ok = (ids: string[], self: string) => [...new Set(ids)].filter((id) => alive.has(id) && id !== self); const parentsMap = new Map(); const childrenMap = new Map(); const isAncestorOf = (ancestor: string, of: string): boolean => { const stack = [...(parentsMap.get(of) ?? [])]; const seen = new Set(); while (stack.length) { const n = stack.pop()!; if (n === ancestor) return true; if (seen.has(n)) continue; seen.add(n); for (const p of parentsMap.get(n) ?? []) stack.push(p); } return false; }; for (const pp of people) { const accepted: string[] = []; for (const par of ok(parentsOf(pp.id), pp.id)) { if (isAncestorOf(pp.id, par)) continue; accepted.push(par); parentsMap.set(pp.id, accepted); childrenMap.set(par, [...(childrenMap.get(par) ?? []), pp.id]); } parentsMap.set(pp.id, accepted); } const data = people.map((pp) => { const [fn, ln] = splitName(pp.primary_name); return { id: pp.id, data: { "first name": fn || "Unnamed", "last name": ln, birthday: years.get(pp.id) ?? "", gender: pp.gender === "female" ? "F" : "M", }, rels: { spouses: ok(partnersOf(pp.id), pp.id), parents: parentsMap.get(pp.id) ?? [], children: childrenMap.get(pp.id) ?? [], }, }; }); const f3 = await import("family-chart"); if (cancelled || !containerRef.current) return; try { containerRef.current.innerHTML = ""; const chart = f3.createChart(containerRef.current, data); chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]); chart.setOrientationHorizontal(); chart.setAncestryDepth?.(3); chart.setProgenyDepth?.(2); chart.setAfterUpdate?.(() => { const md = chart.getMainDatum?.(); const id = md?.id ?? md?.data?.id; if (id) onFocus(id); }); chartRef.current = chart; if (focusId) chart.updateMainId(focusId); chart.updateTree({ initial: true }); } catch (err) { console.error("public tree render failed", err); if (containerRef.current) containerRef.current.innerHTML = ""; } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [people, rels, events]); // Recenter when the focus changes without rebuilding the chart. useEffect(() => { if (focusId && chartRef.current) { chartRef.current.updateMainId?.(focusId); chartRef.current.updateTree?.(); } }, [focusId]); return (

Drag to pan · scroll to zoom · click a person to recenter.

{focusId && onOpen && ( )}
); }