diff --git a/frontend/app/trees/[id]/page.tsx b/frontend/app/trees/[id]/page.tsx index ba31b7f..f607b20 100644 --- a/frontend/app/trees/[id]/page.tsx +++ b/frontend/app/trees/[id]/page.tsx @@ -34,6 +34,7 @@ export default function FamilyViewPage() { const [ready, setReady] = useState(false); const [focusId, setFocusId] = useState(null); const [search, setSearch] = useState(""); + const [results, setResults] = useState(null); // server fuzzy search const [firstName, setFirstName] = useState(""); // Inline add-relative form: which anchor + kind is open, and the typed name. // `key` keeps each empty slot's inline form independent (a person has 2 @@ -65,6 +66,22 @@ export default function FamilyViewPage() { load(); }, [load]); + // Debounced server-side fuzzy search (pg_trgm) across the whole tree. + useEffect(() => { + const q = search.trim(); + if (!q) { + setResults(null); + return; + } + const t = setTimeout(async () => { + const { data } = await api.GET("/api/v1/trees/{tree_id}/persons", { + params: { path: { tree_id: treeId }, query: { q } }, + }); + setResults(data ?? []); + }, 250); + return () => clearTimeout(t); + }, [search, treeId]); + const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]); const parentsOf = (id: string) => rels.filter((r) => r.type === "parent_child" && r.person_to_id === id).map((r) => r.person_from_id); @@ -265,10 +282,9 @@ export default function FamilyViewPage() { const sorted = [...people].sort((a, b) => (a.primary_name ?? "").localeCompare(b.primary_name ?? ""), ); - const matches = search - ? sorted.filter((p) => (p.primary_name ?? "").toLowerCase().includes(search.toLowerCase())) - : sorted; - const shown = matches.slice(0, 200); // cap DOM nodes; refine search to narrow + // Server fuzzy results when searching; otherwise the loaded set. + const directory = results ?? sorted; + const shown = directory.slice(0, 200); // cap DOM nodes; refine search to narrow return (
@@ -358,9 +374,9 @@ export default function FamilyViewPage() { )) )}
- {matches.length > shown.length && ( + {directory.length > shown.length && (
- Showing {shown.length} of {matches.length} — refine your search to narrow. + Showing {shown.length} of {directory.length} — refine your search to narrow.
)} diff --git a/frontend/app/trees/[id]/tree/page.tsx b/frontend/app/trees/[id]/tree/page.tsx index 01d0034..7140fcb 100644 --- a/frontend/app/trees/[id]/tree/page.tsx +++ b/frontend/app/trees/[id]/tree/page.tsx @@ -3,14 +3,19 @@ // 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 { useEffect, useRef, useState } from "react"; +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); @@ -23,11 +28,16 @@ export default function TreePage() { 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 } }, @@ -40,31 +50,56 @@ export default function TreePage() { 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 } } }), ]); - const people = p.data ?? []; - const rels: Relationship[] = r.data ?? []; - const events: Event[] = e.data ?? []; - if (people.length === 0) { - if (!cancelled) setStatus("empty"); - return; - } - - const parentsOf = (id: string) => - rels.filter((x) => x.type === "parent_child" && x.person_to_id === id).map((x) => x.person_from_id); - const childrenOf = (id: string) => - rels.filter((x) => x.type === "parent_child" && x.person_from_id === id).map((x) => x.person_to_id); - const partnersOf = (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)); - - const birthYear = new Map(); - for (const ev of events) { - if (ev.person_id && ev.event_type === "birth" && !birthYear.has(ev.person_id)) { - const y = ev.date_start ? ev.date_start.slice(0, 4) : ev.date_value ?? ""; - if (y) birthYear.set(ev.person_id, y); - } + 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 { @@ -72,56 +107,98 @@ export default function TreePage() { data: { "first name": fn || "Unnamed", "last name": ln, - birthday: birthYear.get(pp.id) ?? "", + birthday: years.get(pp.id) ?? "", gender: pp.gender === "female" ? "F" : "M", }, - rels: { - spouses: partnersOf(pp.id), - parents: parentsOf(pp.id), - children: childrenOf(pp.id), - }, + rels: { spouses: partnersOf(pp.id), parents: parentsOf(pp.id), children: childrenOf(pp.id) }, }; }); - + const f3 = await import("family-chart"); if (cancelled || !containerRef.current) return; - try { - const f3 = await import("family-chart"); - containerRef.current.innerHTML = ""; - const chart = f3.createChart(containerRef.current, data); - chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]); - chart.updateTree({ initial: true }); - if (!cancelled) setStatus("ready"); - } catch { - if (!cancelled) setStatus("error"); - } - })().catch(() => { - if (!cancelled) setStatus("error"); - }); - + 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; }; - }, [router, treeId]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [status, mode, people, rels, events]); + + const ModeButton = ({ m, label }: { m: Mode; label: string }) => ( + + ); return (
-
+

Tree

- - Drag to pan · scroll to zoom · click a person to recenter - +
+
+ + + +
+ {focusId && ( + + Open {nameOf(focusId)} → + + )} +
+ {status === "empty" && ( -

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

+

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."} +

); } diff --git a/frontend/components/fan-chart.tsx b/frontend/components/fan-chart.tsx new file mode 100644 index 0000000..46c6fd4 --- /dev/null +++ b/frontend/components/fan-chart.tsx @@ -0,0 +1,128 @@ +"use client"; + +// Radial fan chart of a focus person's ancestors (family-chart has no fan). +// Each generation is a ring; slot p in generation g descends from slot floor(p/2) +// in g-1. Click a wedge to refocus. + +type Props = { + focusId: string; + parentsOf: (id: string) => string[]; + nameOf: (id: string) => string; + yearOf: (id: string) => string; + onSelect: (id: string) => void; + generations?: number; +}; + +const SIZE = 720; +const CENTER = SIZE / 2; +const FOCUS_R = 46; +const SPAN = Math.PI * 1.6; // 288° fan + +function polar(r: number, a: number): [number, number] { + // a = 0 points up, increasing clockwise. + return [CENTER + r * Math.sin(a), CENTER - r * Math.cos(a)]; +} + +function sector(r0: number, r1: number, a0: number, a1: number): string { + const [x0, y0] = polar(r1, a0); + const [x1, y1] = polar(r1, a1); + const [x2, y2] = polar(r0, a1); + const [x3, y3] = polar(r0, a0); + const large = a1 - a0 > Math.PI ? 1 : 0; + return `M${x0} ${y0} A${r1} ${r1} 0 ${large} 1 ${x1} ${y1} L${x2} ${y2} A${r0} ${r0} 0 ${large} 0 ${x3} ${y3} Z`; +} + +function clip(s: string, n: number): string { + return s.length > n ? s.slice(0, n - 1) + "…" : s; +} + +export function FanChart({ + focusId, + parentsOf, + nameOf, + yearOf, + onSelect, + generations = 4, +}: Props) { + const gens: (string | null)[][] = [[focusId]]; + for (let g = 1; g <= generations; g++) { + const row: (string | null)[] = []; + for (const slot of gens[g - 1]) { + const ps = slot ? parentsOf(slot) : []; + row.push(ps[0] ?? null, ps[1] ?? null); + } + gens.push(row); + } + + const ringT = (CENTER - 60 - FOCUS_R) / generations; + const start = -SPAN / 2; + const wedges: React.ReactNode[] = []; + + for (let g = 1; g <= generations; g++) { + const row = gens[g]; + const w = SPAN / row.length; + const r0 = FOCUS_R + (g - 1) * ringT; + const r1 = FOCUS_R + g * ringT; + row.forEach((id, i) => { + const a0 = start + i * w; + const a1 = start + (i + 1) * w; + const mid = (a0 + a1) / 2; + const [tx, ty] = polar((r0 + r1) / 2, mid); + let deg = (mid * 180) / Math.PI; + if (deg > 90 || deg < -90) deg += 180; // keep text upright + wedges.push( + id && onSelect(id)} + style={{ cursor: id ? "pointer" : "default" }} + > + + {id && ( + = 3 ? 9 : 11, fill: "var(--foreground)" }} + > + {clip(nameOf(id), g >= 3 ? 12 : 18)} + + )} + , + ); + }); + } + + const [fx, fy] = [CENTER, CENTER]; + return ( +
+ + {wedges} + + + {clip(nameOf(focusId), 12)} + + + {yearOf(focusId)} + + +
+ ); +} diff --git a/frontend/lib/api/schema.d.ts b/frontend/lib/api/schema.d.ts index de5ba09..787e9ea 100644 --- a/frontend/lib/api/schema.d.ts +++ b/frontend/lib/api/schema.d.ts @@ -1412,6 +1412,7 @@ export interface operations { parameters: { query?: { deleted?: boolean; + q?: string | null; }; header?: never; path: { diff --git a/frontend/openapi.json b/frontend/openapi.json index 800df5a..358ff5f 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -564,6 +564,22 @@ "default": false, "title": "Deleted" } + }, + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Q" + } } ], "responses": {