"use client"; // Vendored from family-chart/dist/styles (the package blocks the CSS subpath export). import "./chart.css"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { api } from "@/lib/api/client"; import type { components } from "@/lib/api/schema"; import { Button } from "@/components/ui/button"; import { FanChart } from "@/components/fan-chart"; type Person = components["schemas"]["PersonRead"]; type Relationship = components["schemas"]["RelationshipRead"]; type Event = components["schemas"]["EventRead"]; type Mode = "landscape" | "portrait" | "fan"; 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]]; } export default function TreePage() { const router = useRouter(); const params = useParams<{ id: string }>(); const treeId = params.id; const containerRef = useRef(null); const [people, setPeople] = useState([]); const [rels, setRels] = useState([]); const [events, setEvents] = useState([]); const [status, setStatus] = useState<"loading" | "empty" | "ready" | "error">("loading"); const [focusId, setFocusId] = useState(null); const [mode, setMode] = useState("landscape"); useEffect(() => { let cancelled = false; (async () => { const p = await api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } }, }); if (p.response.status === 401) { router.push("/login"); return; } const [r, e] = await Promise.all([ api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }), api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }), ]); if (cancelled) return; const ppl = p.data ?? []; setPeople(ppl); setRels(r.data ?? []); setEvents(e.data ?? []); setFocusId((cur) => cur ?? ppl[0]?.id ?? null); setStatus(ppl.length ? "ready" : "empty"); })().catch(() => !cancelled && setStatus("error")); return () => { cancelled = true; }; }, [router, treeId]); const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]); 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 childrenOf = useCallback( (id: string) => rels.filter((x) => x.type === "parent_child" && x.person_from_id === id).map((x) => x.person_to_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 nameOf = useCallback((id: string) => byId.get(id)?.primary_name ?? "Unknown", [byId]); const yearOf = useCallback((id: string) => years.get(id) ?? "", [years]); // family-chart for landscape/portrait. Intentionally not keyed on focusId — // card clicks recenter via updateMainId without rebuilding the chart. useEffect(() => { if (status !== "ready" || mode === "fan" || !containerRef.current) return; let cancelled = false; (async () => { 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: partnersOf(pp.id), parents: parentsOf(pp.id), children: childrenOf(pp.id) }, }; }); const f3 = await import("family-chart"); if (cancelled || !containerRef.current) return; containerRef.current.innerHTML = ""; const chart = f3.createChart(containerRef.current, data); chart .setCardHtml() .setCardDisplay([["first name", "last name"], ["birthday"]]) .setOnCardClick((_e: unknown, d: { data?: { id?: string } }) => { const id = d?.data?.id; if (id) { setFocusId(id); chart.updateMainId(id); chart.updateTree(); } }); if (mode === "portrait") chart.setOrientationVertical(); else chart.setOrientationHorizontal(); if (focusId) chart.updateMainId(focusId); chart.updateTree({ initial: true }); })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [status, mode, people, rels, events]); const ModeButton = ({ m, label }: { m: Mode; label: string }) => ( ); return (

Tree

{focusId && ( Open {nameOf(focusId)} → )}
{status === "empty" && (

No people yet — add some under People, or import a GEDCOM.

)} {status === "error" &&

Could not render the tree.

} {status === "ready" && mode === "fan" && focusId ? (
) : (
)}

{mode === "fan" ? "Click an ancestor to recenter the fan." : "Drag to pan · scroll to zoom · click a person to recenter."}

); }