Tree: clicking ×N flies to the person's other copy #249

Merged
justin merged 1 commits from tree-fly-to-duplicate into main 2026-06-11 08:48:00 -04:00
Showing only changes of commit ed263cf9a7 - Show all commits
+69 -17
View File
@@ -36,6 +36,12 @@ export default function TreePage() {
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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 [people, setPeople] = useState<Person[]>([]);
@@ -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).{" "}
<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 className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="inline-flex items-center gap-1">