Merge pull request 'Tree: clicking ×N flies to the person's other copy' (#249) from tree-fly-to-duplicate into main
build-frontend / build (push) Successful in 1m31s
build-frontend / build (push) Successful in 1m31s
This commit was merged in pull request #249.
This commit is contained in:
@@ -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;
|
||||
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"));
|
||||
if (!id) return;
|
||||
el!.querySelectorAll(".card_cont").forEach((cont) => {
|
||||
if (idOf(cont) !== id) return;
|
||||
const card = cont.querySelector(".card") as HTMLElement | null;
|
||||
// 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 clicked = tag.closest(".card_cont");
|
||||
const id = idOf(clicked);
|
||||
if (!id) return;
|
||||
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">
|
||||
|
||||
Reference in New Issue
Block a user