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:
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user