From ed263cf9a728aaed90ab02dcaa78e3699aa5bbc8 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Thu, 11 Jun 2026 08:47:44 -0400 Subject: [PATCH] =?UTF-8?q?Tree:=20clicking=20=C3=97N=20flies=20to=20the?= =?UTF-8?q?=20person's=20other=20copy=20(not=20just=20flashes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a large tree the duplicate's other copy is usually off-screen, so flashing in place wasn't enough. Clicking the ×N badge now pans/zooms the view to center the other copy and flashes it on arrival; clicking again cycles through the remaining copies (for a person drawn 3+ times). Uses family-chart's exported handlers: cardToMiddle centers a datum (read from the target card_cont's bound x/y, falling back to its transform attr), keeping the current zoom level via getCurrentZoom. Verified against the lib: the svg's parent (f3Canvas) holds the zoom object, and cards are positioned by datum x/y — same coordinate space cardToMiddle expects. Falls back to an in-place flash if the zoom object isn't ready. Frontend only; supersedes the flash-only behavior. Signed-off-by: Justin Paul --- frontend/app/trees/[id]/tree/page.tsx | 86 +++++++++++++++++++++------ 1 file changed, 69 insertions(+), 17 deletions(-) diff --git a/frontend/app/trees/[id]/tree/page.tsx b/frontend/app/trees/[id]/tree/page.tsx index 083a4f7..2baeef1 100644 --- a/frontend/app/trees/[id]/tree/page.tsx +++ b/frontend/app/trees/[id]/tree/page.tsx @@ -36,6 +36,12 @@ export default function TreePage() { const containerRef = useRef(null); // eslint-disable-next-line @typescript-eslint/no-explicit-any const chartRef = useRef(null); + // family-chart's pan/zoom helpers (cardToMiddle, getCurrentZoom), captured at + // render — used to fly to a duplicate's other copy. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handlersRef = useRef(null); + // Per-person cursor so repeated clicks on a ×N badge cycle through the copies. + const dupCycle = useRef>(new Map()); const [query, setQuery] = useState(""); const [people, setPeople] = useState([]); @@ -189,6 +195,7 @@ export default function TreePage() { }; }); const f3 = await import("family-chart"); + handlersRef.current = f3.handlers; if (cancelled || !containerRef.current) return; try { containerRef.current.innerHTML = ""; @@ -252,31 +259,76 @@ export default function TreePage() { [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. + // Click the "×N" duplicate badge to FLY to the person's other copy in the + // view (cycling through them on repeat clicks) and flash it on arrival. The + // same record is drawn in two places (a shared ancestor, or an intermarriage), + // and on a big tree the other copy is usually off-screen. Delegated on the + // container so it survives chart rebuilds; capture-phase + stopPropagation so a + // badge click flies instead of recentering. 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; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = (n: Element | null) => (n as any)?.__data__; + const idOf = (n: Element | null) => data(n)?.data?.id as string | undefined; + const xyOf = (cont: Element): { x: number; y: number } | null => { + const d = data(cont); + if (d && typeof d.x === "number" && typeof d.y === "number") return { x: d.x, y: d.y }; + const m = /translate\(\s*([-\d.]+)[ ,]+([-\d.]+)/.exec(cont.getAttribute("transform") ?? ""); + return m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : null; + }; + const flash = (cont: Element | null) => { + 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); + }; + 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")); + const clicked = tag.closest(".card_cont"); + const id = idOf(clicked); 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); - }); + const copies = Array.from(el!.querySelectorAll(".card_cont")).filter((c) => idOf(c) === id); + if (copies.length < 2) { + flash(clicked); + return; + } + // Advance from wherever we last landed (or the clicked card), skipping the + // clicked copy, so each click moves to the next other location. + const start = dupCycle.current.get(id) ?? copies.indexOf(clicked as Element); + let next = (start + 1) % copies.length; + if (copies[next] === clicked) next = (next + 1) % copies.length; + dupCycle.current.set(id, next); + const target = copies[next]; + + const handlers = handlersRef.current; + const svg = el!.querySelector("svg.main_svg") as SVGSVGElement | null; + const xy = xyOf(target); + let flew = false; + if (handlers?.cardToMiddle && svg && xy) { + try { + const rect = svg.getBoundingClientRect(); + const scale = handlers.getCurrentZoom ? handlers.getCurrentZoom(svg).k : 1; + handlers.cardToMiddle({ + datum: xy, + svg, + svg_dim: { width: rect.width, height: rect.height }, + scale, + transition_time: 750, + }); + flew = true; + } catch { + /* zoom not ready — fall back to flashing in place */ + } + } + // Flash on arrival (after the fly), or immediately if we couldn't fly. + window.setTimeout(() => flash(target), flew ? 900 : 0); } el.addEventListener("click", onClick, true); return () => el.removeEventListener("click", onClick, true); @@ -453,7 +505,7 @@ export default function TreePage() { 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).{" "} - Click the ×N to flash the other copies. + Click the ×N to fly to the other copies (click again to cycle).