Tree: Legend by the pan/zoom hint + clickable ×N duplicate badges #248

Merged
justin merged 1 commits from tree-legend-and-duplicate-flash into main 2026-06-11 08:32:54 -04:00
2 changed files with 87 additions and 6 deletions
+17 -1
View File
@@ -381,9 +381,25 @@
color: rgb(255, 251, 220);
background-color: rgba(255, 251, 220, 0);
border-radius: 50%;
padding: 2px;
padding: 2px 4px;
font-weight: 600;
cursor: pointer;
transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out;
}
.f3 .f3-card-duplicate-tag:hover {
background-color: rgba(255, 251, 220, 0.9);
color: #000;
}
/* Click the ×N badge → every copy of that person flashes (see tree/page.tsx). */
@keyframes f3-card-flash {
0%, 100% { outline-color: rgba(160, 106, 66, 0); }
30%, 70% { outline-color: rgba(160, 106, 66, 1); }
}
.f3 .f3-card-flash .card-inner {
outline: 4px solid rgba(160, 106, 66, 1);
animation: f3-card-flash 0.55s ease-in-out 3;
}
.f3 .f3-card-duplicate-hover div.card-inner {
transform: translate(0, -2px);
+70 -5
View File
@@ -252,6 +252,36 @@ 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.
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;
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);
});
}
el.addEventListener("click", onClick, true);
return () => el.removeEventListener("click", onClick, true);
}, []);
// Mirror the focused person into the URL (?focus=…) so navigating away and
// back — or sharing the link — keeps the tree centered where you left it.
// `replace` (not push) so each recenter doesn't pile up in browser history.
@@ -402,11 +432,46 @@ export default function TreePage() {
/>
)}
<p className="text-sm text-[var(--muted)]">
{mode === "fan"
? "Click an ancestor to recenter the fan."
: "Drag to pan · scroll to zoom · click a person to recenter."}
</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[var(--muted)]">
<span>
{mode === "fan"
? "Click an ancestor to recenter the fan."
: "Drag to pan · scroll to zoom · click a person to recenter."}
</span>
{mode !== "fan" && (
<div className="group relative">
<button
type="button"
className="underline decoration-dotted underline-offset-2 hover:text-bronze focus-visible:text-bronze focus-visible:outline-none"
>
Legend
</button>
<div className="invisible absolute bottom-full left-0 z-30 mb-2 w-80 rounded-lg border border-[var(--border)] bg-[var(--surface)] p-3 text-xs text-[var(--foreground)] opacity-0 shadow-lg transition-opacity group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100">
<ul className="space-y-2">
<li>
<span className="font-semibold text-bronze">×N</span> on a card this
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>
</li>
<li className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="inline-flex items-center gap-1">
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: "rgb(120,159,172)" }} /> male
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: "rgb(196,138,146)" }} /> female
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: "lightgray" }} /> sex not set
</span>
</li>
<li>Drag to pan, scroll to zoom, and click any card to recenter the tree on that person.</li>
</ul>
</div>
</div>
)}
</div>
</div>
);
}