"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 { Input } from "@/components/ui/input"; 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); // eslint-disable-next-line @typescript-eslint/no-explicit-any const chartRef = useRef(null); const [query, setQuery] = useState(""); 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, t] = 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 } } }), api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }), ]); if (cancelled) return; const ppl = p.data ?? []; const home = t.data?.home_person_id ?? null; const homeId = home && ppl.some((x) => x.id === home) ? home : null; setPeople(ppl); setRels(r.data ?? []); setEvents(e.data ?? []); // Open on the tree's default/home person when set, else the first person. setFocusId((cur) => cur ?? homeId ?? 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 () => { // Only link to people that still exist — a soft-deleted person leaves // dangling relationship rows, and family-chart breaks on an id with no // matching datum. Filter them out so a deletion never blanks the tree. const alive = new Set(people.map((pp) => pp.id)); const keep = (ids: string[]) => ids.filter((id) => alive.has(id)); 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: keep(partnersOf(pp.id)), parents: keep(parentsOf(pp.id)), children: keep(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"]]); if (mode === "portrait") chart.setOrientationVertical(); else chart.setOrientationHorizontal(); // Show enough generations that a recenter reveals grandparents + children. chart.setAncestryDepth?.(3); chart.setProgenyDepth?.(2); // Default card click recenters the whole hourglass; sync focus for the // "Open profile" link after every (re)build. chart.setAfterUpdate?.(() => { const md = chart.getMainDatum?.(); const id = md?.id ?? md?.data?.id; if (id) setFocusId(id); }); chartRef.current = chart; 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]); // Jump the tree (or fan) to a person and rebuild the hourglass around them. const goTo = useCallback( (id: string) => { setFocusId(id); setQuery(""); if (mode !== "fan" && chartRef.current) { chartRef.current.updateMainId?.(id); chartRef.current.updateTree?.(); } }, [mode], ); const matches = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return []; return people .filter((p) => (p.primary_name ?? "").toLowerCase().includes(q)) .slice(0, 8); }, [query, people]); const ModeButton = ({ m, label }: { m: Mode; label: string }) => ( ); return (

Tree

setQuery(e.target.value)} placeholder="Find a person…" className="w-56" /> {matches.length > 0 && (
    {matches.map((p) => (
  • ))}
)}
{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."}

); }