Files
provenance/frontend/app/trees/[id]/tree/page.tsx
T
justin c5a2a7f0d4 Preserve focused person across tree/people/detail navigation
The Tree view, People (Family) view, and person detail page each tracked
the "current person" independently, so moving between them reset you to the
home person. The detail page's "← Back to tree" link also pointed at the
People view (not the Tree) and carried no person, so it always landed on the
default person.

Make the focused person a URL-encoded concept that travels across views:

- Tree and People views read ?focus=<id> on load and mirror the focused
  person back into the URL via router.replace (no history spam), so leaving
  and returning keeps you centered where you were. Bookmarks/shared links
  also resolve to the right person.
- "Open person" links carry ?from=tree | ?from=people.
- The detail page's back link is now origin-aware: "← Back to Tree" →
  /tree?focus=<id> or "← Back to People" → /?focus=<id>, returning you in
  place instead of to the home person.
- Add a "View in tree →" link on the detail page — the previously missing
  direct jump from a person to the tree re-rooted on them.
- person→person relationship links (and create-relative redirect) pass
  `from` through so click-chains keep their anchor.

Also gitignore *.tsbuildinfo (Next build artifact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-08 14:48:32 -04:00

346 lines
14 KiB
TypeScript

"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<string | null>(searchParams.get("focus"));
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chartRef = useRef<any>(null);
const [query, setQuery] = useState("");
const [people, setPeople] = useState<Person[]>([]);
const [rels, setRels] = useState<Relationship[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [status, setStatus] = useState<"loading" | "empty" | "ready" | "error">("loading");
const [focusId, setFocusId] = useState<string | null>(null);
const [mode, setMode] = useState<Mode>("landscape");
const [renderNote, setRenderNote] = useState<string | null>(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<string, string>();
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<string, string[]>();
const childrenMap = new Map<string, string[]>();
let dropped = 0;
const isAncestorOf = (ancestor: string, of: string): boolean => {
const stack = [...(parentsMap.get(of) ?? [])];
const seen = new Set<string>();
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 }) => (
<button
onClick={() => setMode(m)}
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
mode === m ? "bg-bronze text-paper" : "text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
{label}
</button>
);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold">Tree</h1>
<div className="relative">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Find a person…"
className="w-56"
/>
{matches.length > 0 && (
<ul className="absolute z-20 mt-1 w-72 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
{matches.map((p) => (
<li key={p.id}>
<button
onClick={() => goTo(p.id)}
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-[var(--muted-bg,rgba(0,0,0,0.04))]"
>
<span>{p.primary_name ?? "Unnamed"}</span>
{yearOf(p.id) && (
<span className="text-xs text-[var(--muted)]">{yearOf(p.id)}</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center rounded-lg border border-[var(--border)] p-0.5">
<ModeButton m="landscape" label="Landscape" />
<ModeButton m="portrait" label="Portrait" />
<ModeButton m="fan" label="Fan" />
</div>
{focusId && (
<Link
href={`/trees/${treeId}/persons/${focusId}?from=tree`}
className="text-sm text-bronze hover:underline"
>
Open {nameOf(focusId)}
</Link>
)}
</div>
</div>
{status === "empty" && (
<p className="text-[var(--muted)]">No people yet add some under People, or import a GEDCOM.</p>
)}
{status === "error" && <p className="text-[var(--muted)]">Could not render the tree.</p>}
{renderNote && mode !== "fan" && (
<p className="rounded-md border border-bronze/40 bg-bronze/[0.06] px-3 py-2 text-sm text-bronze">
{renderNote}
</p>
)}
{status === "ready" && mode === "fan" && focusId ? (
<div className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-4">
<FanChart
focusId={focusId}
parentsOf={parentsOf}
nameOf={nameOf}
yearOf={yearOf}
onSelect={setFocusId}
/>
</div>
) : (
<div
ref={containerRef}
className="f3 rounded-xl border border-[var(--border)]"
style={{ width: "100%", height: "74vh", background: "var(--surface)" }}
/>
)}
<p className="text-sm text-[var(--muted)]">
{mode === "fan"
? "Click an ancestor to recenter the fan."
: "Drag to pan · scroll to zoom · click a person to recenter."}
</p>
</div>
);
}