"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, useSearchParams } 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 searchParams = useSearchParams(); const treeId = params.id; // The focused person can arrive in the URL (?focus=…) — e.g. coming back from // a person page. Captured once at mount so syncing focus→URL doesn't refetch. const initialFocus = useRef(searchParams.get("focus")); 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"); const [renderNote, setRenderNote] = useState(null); 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 ?? []); // Honor an explicit ?focus first (came from a person page / a shared // link), then the tree's default/home person, then the first person. const fromUrl = initialFocus.current && ppl.some((x) => x.id === initialFocus.current) ? initialFocus.current : null; setFocusId((cur) => cur ?? fromUrl ?? 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 () => { // Sanitize the graph before handing it to family-chart, which recurses // through parents and will blow the stack (blank tree) on a cycle — e.g. a // person edited into being their own ancestor. 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); // Build an acyclic set of parent edges: skip any edge that would make a // person their own ancestor. Children are derived from the kept edges so // parent/child stays consistent. const parentsMap = new Map(); const childrenMap = new Map(); let dropped = 0; 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)) { // Edge "pp has parent par" loops if pp is already an ancestor of par. if (isAncestorOf(pp.id, par)) { dropped++; 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"]]); 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 }); setRenderNote( dropped > 0 ? `Skipped ${dropped} conflicting parent link${dropped === 1 ? "" : "s"} (a person can't be their own ancestor). Open the people involved to fix the relationship.` : null, ); } catch (err) { // Never leave a blank canvas — show a message and let them fix via the // Family view / person pages. console.error("tree render failed", err); if (containerRef.current) containerRef.current.innerHTML = ""; setRenderNote( "The tree couldn't be drawn — a relationship may be conflicting. Use the Family view to open the affected people and check their parents/children.", ); } })(); 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], ); // Mirror the focused person into the URL (?focus=…) so navigating away and // back — or sharing the link — keeps the tree centered where you left it. // `replace` (not push) so each recenter doesn't pile up in browser history. useEffect(() => { if (!focusId || searchParams.get("focus") === focusId) return; const sp = new URLSearchParams(searchParams.toString()); sp.set("focus", focusId); router.replace(`/trees/${treeId}/tree?${sp.toString()}`, { scroll: false }); }, [focusId, searchParams, router, treeId]); 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.

} {renderNote && mode !== "fan" && (

{renderNote}

)} {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."}

); }