690a6da659
Two small tree-view aids prompted by "why do some people show ×2". - Legend: a hover/focus "Legend" link next to the "drag to pan…" hint, explaining the ×N badge (a person drawn N times in the view because they connect through more than one line — a shared ancestor or an intermarriage), the gender card colors, and the pan/zoom/recenter controls. - The ×N badge is now clearly clickable (cursor + hover state); clicking it flashes every copy of that person in the current view (a bronze outline pulse), so you can spot where else they appear. Implemented by delegating on the chart container and matching the d3-bound person id across cards; capture-phase + stopPropagation so a badge click flashes instead of recentering. Frontend only. Honest follow-up: flashing finds copies that are on-screen; a true "fly to an off-screen copy" needs d3-zoom transform work (the chart pans by transform, not scroll) — a later enhancement. Signed-off-by: Justin Paul <justin@jpaul.me>
478 lines
20 KiB
TypeScript
478 lines
20 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";
|
||
import { DepthControl } from "@/components/depth-control";
|
||
|
||
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);
|
||
// The tree's default/home person — lets us offer a "recenter on default" jump.
|
||
const [homeId, setHomeId] = useState<string | null>(null);
|
||
const [mode, setMode] = useState<Mode>("landscape");
|
||
const [renderNote, setRenderNote] = useState<string | null>(null);
|
||
// How many generations to show around the focus, each independently settable
|
||
// (or "all"). ALL_DEPTH is just a number bigger than any real lineage; the
|
||
// chart only renders people that exist, so a high cap costs nothing.
|
||
const [ancDepth, setAncDepth] = useState(3); // ancestors (backwards)
|
||
const [progDepth, setProgDepth] = useState(2); // descendants (forwards)
|
||
const [ancAll, setAncAll] = useState(false);
|
||
const [progAll, setProgAll] = useState(false);
|
||
const ALL_DEPTH = 100;
|
||
const effAnc = ancAll ? ALL_DEPTH : ancDepth;
|
||
const effProg = progAll ? ALL_DEPTH : progDepth;
|
||
|
||
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 ?? []);
|
||
setHomeId(homeId);
|
||
// 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();
|
||
// Generations to show around the focus (configurable; see depth controls).
|
||
chart.setAncestryDepth?.(effAnc);
|
||
chart.setProgenyDepth?.(effProg);
|
||
// 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]);
|
||
|
||
// Apply depth changes to the already-built chart without a full rebuild
|
||
// (landscape/portrait only; the fan reads its own `generations` prop).
|
||
useEffect(() => {
|
||
if (mode === "fan" || !chartRef.current) return;
|
||
chartRef.current.setAncestryDepth?.(effAnc);
|
||
chartRef.current.setProgenyDepth?.(effProg);
|
||
chartRef.current.updateTree?.();
|
||
}, [effAnc, effProg, mode]);
|
||
|
||
// 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],
|
||
);
|
||
|
||
// Click the "×N" duplicate badge on a card to flash every copy of that person
|
||
// in the current view. They're the same record drawn in two places (a shared
|
||
// ancestor, or an intermarriage). Delegated on the container so it survives
|
||
// chart rebuilds; capture-phase + stopPropagation so it doesn't also recenter.
|
||
useEffect(() => {
|
||
const el = containerRef.current;
|
||
if (!el) return;
|
||
type Bound = Element & { __data__?: { data?: { id?: string } } };
|
||
const idOf = (node: Element | null) => (node as Bound | null)?.__data__?.data?.id;
|
||
function onClick(e: MouseEvent) {
|
||
const tag = (e.target as HTMLElement).closest?.(".f3-card-duplicate-tag");
|
||
if (!tag) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const id = idOf(tag.closest(".card_cont"));
|
||
if (!id) return;
|
||
el!.querySelectorAll(".card_cont").forEach((cont) => {
|
||
if (idOf(cont) !== id) return;
|
||
const card = cont.querySelector(".card") as HTMLElement | null;
|
||
if (!card) return;
|
||
card.classList.remove("f3-card-flash");
|
||
void card.offsetWidth; // restart the animation on repeat clicks
|
||
card.classList.add("f3-card-flash");
|
||
window.setTimeout(() => card.classList.remove("f3-card-flash"), 1900);
|
||
});
|
||
}
|
||
el.addEventListener("click", onClick, true);
|
||
return () => el.removeEventListener("click", onClick, true);
|
||
}, []);
|
||
|
||
// 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>
|
||
{homeId && focusId !== homeId && (
|
||
<button
|
||
onClick={() => goTo(homeId)}
|
||
className="text-sm text-bronze hover:underline"
|
||
title={`Recenter on the tree's default person (${nameOf(homeId)})`}
|
||
>
|
||
↩ Back to default person
|
||
<span className="text-[var(--muted)]"> · {nameOf(homeId)}</span>
|
||
</button>
|
||
)}
|
||
</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 === "ready" && (
|
||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-lg border border-[var(--border)] bg-[var(--surface)] px-4 py-2.5">
|
||
<span className="text-sm font-medium">Generations</span>
|
||
<DepthControl
|
||
label="Ancestors"
|
||
icon="↑"
|
||
value={ancDepth}
|
||
all={ancAll}
|
||
onValue={(v) => {
|
||
setAncAll(false);
|
||
setAncDepth(v);
|
||
}}
|
||
onAll={setAncAll}
|
||
/>
|
||
<DepthControl
|
||
label="Descendants"
|
||
icon="↓"
|
||
value={progDepth}
|
||
all={progAll}
|
||
onValue={(v) => {
|
||
setProgAll(false);
|
||
setProgDepth(v);
|
||
}}
|
||
onAll={setProgAll}
|
||
disabled={mode === "fan"}
|
||
/>
|
||
{mode === "fan" && (
|
||
<span className="text-xs text-[var(--muted)]">Fan shows ancestors only.</span>
|
||
)}
|
||
</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}
|
||
generations={Math.min(effAnc, 8)}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div
|
||
ref={containerRef}
|
||
className="f3 rounded-xl border border-[var(--border)]"
|
||
style={{ width: "100%", height: "74vh", background: "var(--surface)" }}
|
||
/>
|
||
)}
|
||
|
||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[var(--muted)]">
|
||
<span>
|
||
{mode === "fan"
|
||
? "Click an ancestor to recenter the fan."
|
||
: "Drag to pan · scroll to zoom · click a person to recenter."}
|
||
</span>
|
||
{mode !== "fan" && (
|
||
<div className="group relative">
|
||
<button
|
||
type="button"
|
||
className="underline decoration-dotted underline-offset-2 hover:text-bronze focus-visible:text-bronze focus-visible:outline-none"
|
||
>
|
||
Legend
|
||
</button>
|
||
<div className="invisible absolute bottom-full left-0 z-30 mb-2 w-80 rounded-lg border border-[var(--border)] bg-[var(--surface)] p-3 text-xs text-[var(--foreground)] opacity-0 shadow-lg transition-opacity group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100">
|
||
<ul className="space-y-2">
|
||
<li>
|
||
<span className="font-semibold text-bronze">×N</span> on a card — this
|
||
person appears N times in the current view. The same record is drawn in
|
||
two places because they connect through more than one line (a shared
|
||
ancestor, or an intermarriage).{" "}
|
||
<span className="text-[var(--muted)]">Click the ×N to flash the other copies.</span>
|
||
</li>
|
||
<li className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||
<span className="inline-flex items-center gap-1">
|
||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: "rgb(120,159,172)" }} /> male
|
||
</span>
|
||
<span className="inline-flex items-center gap-1">
|
||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: "rgb(196,138,146)" }} /> female
|
||
</span>
|
||
<span className="inline-flex items-center gap-1">
|
||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: "lightgray" }} /> sex not set
|
||
</span>
|
||
</li>
|
||
<li>Drag to pan, scroll to zoom, and click any card to recenter the tree on that person.</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|