Tree: clicking ×N flies to the person's other copy (not just flashes)

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 <justin@jpaul.me>
This commit is contained in:
2026-06-11 08:47:44 -04:00
parent f7666ad30b
commit ed263cf9a7
+69 -17
View File
@@ -36,6 +36,12 @@ export default function TreePage() {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const chartRef = useRef<any>(null); const chartRef = useRef<any>(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<any>(null);
// Per-person cursor so repeated clicks on a ×N badge cycle through the copies.
const dupCycle = useRef<Map<string, number>>(new Map());
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [people, setPeople] = useState<Person[]>([]); const [people, setPeople] = useState<Person[]>([]);
@@ -189,6 +195,7 @@ export default function TreePage() {
}; };
}); });
const f3 = await import("family-chart"); const f3 = await import("family-chart");
handlersRef.current = f3.handlers;
if (cancelled || !containerRef.current) return; if (cancelled || !containerRef.current) return;
try { try {
containerRef.current.innerHTML = ""; containerRef.current.innerHTML = "";
@@ -252,31 +259,76 @@ export default function TreePage() {
[mode], [mode],
); );
// Click the "×N" duplicate badge on a card to flash every copy of that person // Click the "×N" duplicate badge to FLY to the person's other copy in the
// in the current view. They're the same record drawn in two places (a shared // view (cycling through them on repeat clicks) and flash it on arrival. The
// ancestor, or an intermarriage). Delegated on the container so it survives // same record is drawn in two places (a shared ancestor, or an intermarriage),
// chart rebuilds; capture-phase + stopPropagation so it doesn't also recenter. // 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(() => { useEffect(() => {
const el = containerRef.current; const el = containerRef.current;
if (!el) return; if (!el) return;
type Bound = Element & { __data__?: { data?: { id?: string } } }; // eslint-disable-next-line @typescript-eslint/no-explicit-any
const idOf = (node: Element | null) => (node as Bound | null)?.__data__?.data?.id; 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) { function onClick(e: MouseEvent) {
const tag = (e.target as HTMLElement).closest?.(".f3-card-duplicate-tag"); const tag = (e.target as HTMLElement).closest?.(".f3-card-duplicate-tag");
if (!tag) return; if (!tag) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const id = idOf(tag.closest(".card_cont")); const clicked = tag.closest(".card_cont");
const id = idOf(clicked);
if (!id) return; if (!id) return;
el!.querySelectorAll(".card_cont").forEach((cont) => { const copies = Array.from(el!.querySelectorAll(".card_cont")).filter((c) => idOf(c) === id);
if (idOf(cont) !== id) return; if (copies.length < 2) {
const card = cont.querySelector(".card") as HTMLElement | null; flash(clicked);
if (!card) return; return;
card.classList.remove("f3-card-flash"); }
void card.offsetWidth; // restart the animation on repeat clicks // Advance from wherever we last landed (or the clicked card), skipping the
card.classList.add("f3-card-flash"); // clicked copy, so each click moves to the next other location.
window.setTimeout(() => card.classList.remove("f3-card-flash"), 1900); 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); el.addEventListener("click", onClick, true);
return () => el.removeEventListener("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 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 two places because they connect through more than one line (a shared
ancestor, or an intermarriage).{" "} ancestor, or an intermarriage).{" "}
<span className="text-[var(--muted)]">Click the ×N to flash the other copies.</span> <span className="text-[var(--muted)]">Click the ×N to fly to the other copies (click again to cycle).</span>
</li> </li>
<li className="flex flex-wrap items-center gap-x-2 gap-y-1"> <li className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">