Flag pedigree collapse with repeat badges

When a dog fills more than one position in the pedigree (pedigree collapse),
mark each copy with a ×N badge and a stable per-dog colour, and let tapping
any copy highlight every place that dog appears. Identity is the registration
number (falling back to name), so the same ancestor at different positions is
treated as one. The summary line also reports how many ancestors recur.
This commit is contained in:
Alexander Heldt
2026-07-26 10:39:51 +00:00
parent 8157e95066
commit 01d64b682b
4 changed files with 83 additions and 5 deletions
+60 -5
View File
@@ -2458,6 +2458,7 @@
const pedTree = document.getElementById("pedigree-tree");
const pedBtn = document.getElementById("pedigree-btn");
const pedRefresh = document.getElementById("pedigree-refresh");
const pedRepeatNote = document.getElementById("pedigree-repeat-note");
const PED_OPEN_DEPTH = 3; // show 3 generations expanded by default; deeper collapses
let pedPollTimer = null;
@@ -2580,20 +2581,24 @@
// fills many positions); "generations back" is the depth of the deepest
// position (floor(log2(pos)), since sire = 2·pos and dam = 2·pos+1).
function pedCounts() {
const seen = new Set();
const counts = new Map();
let maxPos = 1;
for (const k in pedNodes) {
const n = pedNodes[k];
const key = n.reg || n.name;
if (key) seen.add(key);
if (key) counts.set(key, (counts.get(key) || 0) + 1);
const p = Number(k);
if (p > maxPos) maxPos = p;
}
return { distinct: seen.size, gens: Math.floor(Math.log2(maxPos)) };
let repeated = 0;
counts.forEach((c) => { if (c > 1) repeated++; });
return { distinct: counts.size, repeated, gens: Math.floor(Math.log2(maxPos)) };
}
function pedCountText() {
const { distinct: a, gens: g } = pedCounts();
return `${a} ancestor${a === 1 ? "" : "s"} back ${g} generation${g === 1 ? "" : "s"}`;
const { distinct: a, repeated: r, gens: g } = pedCounts();
let s = `${a} ancestor${a === 1 ? "" : "s"} back ${g} generation${g === 1 ? "" : "s"}`;
if (r > 0) s += `, ${r} appearing more than once`;
return s;
}
function setPedDone() { setPedStatus(`Traced ${pedCountText()}.`, "done"); }
function setPedStatus(text, kind) {
@@ -2649,9 +2654,29 @@
// 2n+1. We render it top-down like a family tree — the dog on top, parents
// branching below — as a nested <ul>/<li> so CSS can draw the connectors.
// Built recursively (sire left, dam right) and collapsed below PED_OPEN_DEPTH.
// A dog's identity for spotting pedigree collapse: its registration number,
// or its name when it has none. Ancestors sharing a key are the same dog.
function dogKey(n) { return n ? (n.reg || n.name || "") : ""; }
// Stable hue per repeated dog so its badge/highlight colour is consistent
// everywhere it appears.
function hueFor(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h % 360;
}
let pedRepeat = {}; // dogKey -> occurrence count, for the current tree
function renderTree(nodes) {
pedNodes = nodes;
pedTree.textContent = "";
// Count how many positions each dog fills, to flag pedigree collapse.
pedRepeat = {};
for (const k in nodes) {
const id = dogKey(nodes[k]);
if (id) pedRepeat[id] = (pedRepeat[id] || 0) + 1;
}
const anyRepeat = Object.values(pedRepeat).some((c) => c > 1);
if (pedRepeatNote) pedRepeatNote.hidden = !anyRepeat;
const root = buildPedNode(nodes, 1, 0);
if (!root) return;
const ul = document.createElement("ul");
@@ -2660,6 +2685,17 @@
pedTree.append(ul);
}
// Highlight (or unhighlight) every card that is the same dog as `key`.
function togglePedHighlight(key) {
const cards = pedTree.querySelectorAll(".ped-card[data-dogkey]");
let lit = false;
cards.forEach((c) => {
if (c.dataset.dogkey === key && c.classList.contains("ped-lit")) lit = true;
});
cards.forEach((c) => c.classList.remove("ped-lit"));
if (!lit) cards.forEach((c) => { if (c.dataset.dogkey === key) c.classList.add("ped-lit"); });
}
function buildPedNode(nodes, pos, depth) {
const n = nodes[String(pos)];
const hasSire = !!nodes[String(pos * 2)];
@@ -2687,6 +2723,25 @@
r.textContent = n.reg;
card.append(r);
}
// Mark dogs that fill more than one position (pedigree collapse). A ×N badge
// shows how many times, a stable colour ties the copies together, and tapping
// the card lights up every place this dog appears.
const key = dogKey(n);
if (key && pedRepeat[key] > 1) {
card.classList.add("ped-repeat");
card.dataset.dogkey = key;
card.style.setProperty("--repeat-hue", hueFor(key));
const badge = document.createElement("span");
badge.className = "ped-repeat-badge";
badge.textContent = "×" + pedRepeat[key];
badge.title = "Appears " + pedRepeat[key] + " times in this pedigree — tap to highlight them all";
card.append(badge);
card.addEventListener("click", (e) => {
if (e.target.closest(".ped-toggle")) return; // let the expander do its job
togglePedHighlight(key);
});
}
li.append(card);
if (hasSire || hasDam) {