Add a radial fan-chart view of the pedigree

Add a Fan / Tree toggle in the pedigree header. The fan places the dog in a
centre disc with each generation as a ring fanning outward, so up to nine
generations fit in one compact circle instead of a tree that doubles in
width every generation. Inner rings are labelled; tap any wedge for that
dog's details. Pedigree-collapse dogs keep their stable hue, and tapping one
lights up every wedge of that dog. The existing zoom applies to the fan too.

Render dispatch is factored into renderPedigree(); the collapse-highlight
now works on any element (tree card or fan wedge) carrying a data-dogkey.
This commit is contained in:
Alexander Heldt
2026-07-26 11:13:16 +00:00
parent a2c9aa9716
commit 69e312175b
4 changed files with 222 additions and 13 deletions
+176 -13
View File
@@ -2499,6 +2499,27 @@
pedFoldAll.textContent = collapsed ? "Expand all" : "Collapse all";
}
pedFoldAll.addEventListener("click", () => pedSetAll(pedFoldAll.textContent[0] === "C"));
// ---- view toggle: top-down tree vs radial fan ----
const pedViewBtn = document.getElementById("ped-view");
const pedCaption = document.getElementById("pedigree-caption");
const pedViewKey = () => `puppy-tracker:${currentUser.id}:pedigree-view:v1`;
let pedView = "tree";
try { const v = localStorage.getItem(pedViewKey()); if (v === "fan" || v === "tree") pedView = v; } catch { /* ignore */ }
// The fan has no per-branch folding, so hide that control in fan mode; the
// toggle always offers the *other* view.
function updatePedViewControls() {
pedViewBtn.textContent = pedView === "fan" ? "Tree view" : "Fan view";
pedFoldAll.hidden = pedView === "fan";
if (pedView !== "fan" && pedCaption) { pedCaption.hidden = true; }
}
pedViewBtn.addEventListener("click", () => {
pedView = pedView === "fan" ? "tree" : "fan";
try { localStorage.setItem(pedViewKey(), pedView); } catch { /* ignore */ }
renderPedigree(pedNodes);
});
// Trackpad/desktop: ctrl or ⌘ + wheel zooms instead of scrolling the page.
pedTree.addEventListener("wheel", (e) => {
if (!e.ctrlKey && !e.metaKey) return;
@@ -2573,7 +2594,7 @@
const cached = loadPedCache(q);
if (cached && cached.nodes) {
renderSubject(cached.subject);
renderTree(cached.nodes);
renderPedigree(cached.nodes);
}
if (!navigator.onLine) {
setPedStatus(cached ? "Offline — showing the last saved pedigree." : "Pedigree needs an internet connection.",
@@ -2606,7 +2627,7 @@
const data = await res.json();
if (data.status === "choose") { renderChoose(data.matches || []); return; }
renderSubject(data.subject);
renderTree(data.nodes || {});
renderPedigree(data.nodes || {});
if (data.status === "done") {
savePedCache(q, data.subject, data.nodes || {});
setPedDone();
@@ -2625,7 +2646,7 @@
if (res.status === 401) { handleLoggedOut(); return; }
if (!res.ok) { setPedStatus("Lost track of the pedigree crawl.", "error"); return; }
const data = await res.json();
renderTree(data.nodes || {});
renderPedigree(data.nodes || {});
if (data.status === "done") { savePedCache(q, subject, data.nodes || {}); setPedDone(); return; }
if (data.status === "error") { setPedStatus(data.error || "Pedigree crawl failed.", "error"); return; }
setPedStatus(`Tracing ancestry… ${pedCountText()}`, "busy");
@@ -2725,17 +2746,22 @@
}
let pedRepeat = {}; // dogKey -> occurrence count, for the current tree
function renderTree(nodes) {
// Render the ancestry in whichever view is active (top-down tree or radial
// fan). Shared prep — cache the nodes and count pedigree collapse — lives here.
function renderPedigree(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;
if (pedRepeatNote) pedRepeatNote.hidden = !Object.values(pedRepeat).some((c) => c > 1);
pedTree.textContent = "";
if (pedView === "fan") renderFan(nodes); else renderTree(nodes);
updatePedViewControls();
}
function renderTree(nodes) {
const root = buildPedNode(nodes, 1, 0);
if (!root) return;
const ul = document.createElement("ul");
@@ -2745,15 +2771,152 @@
if (pedFoldAll) pedFoldAll.textContent = "Collapse all"; // fresh tree starts partly open
}
// Highlight (or unhighlight) every card that is the same dog as `key`.
// ---- radial fan chart ----
// The dog sits in a centre disc; each generation is a ring fanning outward.
// A position p is at generation g = floor(log2 p); within that ring it takes
// the wedge (idx=p-2^g) of 2^g equal slices, which nests each dog's parents
// directly outside it. Labels only fit on the inner rings; deeper wedges are
// colour only, with details on tap. Repeated dogs (pedigree collapse) carry
// their stable hue so tapping one lights up every wedge of that dog.
const FAN_MAX_GEN = 9;
const SVGNS = "http://www.w3.org/2000/svg";
const fanPolar = (r, deg) => {
const a = (deg - 90) * Math.PI / 180;
return [r * Math.cos(a), r * Math.sin(a)];
};
const fanNum = (n) => Math.round(n * 100) / 100;
function renderFan(nodes) {
let maxGen = 0;
for (const k in nodes) { const g = Math.floor(Math.log2(Number(k))); if (g > maxGen) maxGen = g; }
maxGen = Math.min(maxGen, FAN_MAX_GEN);
const r0 = 46;
const radii = [r0];
for (let g = 1; g <= maxGen; g++) radii[g] = radii[g - 1] + Math.max(24, 50 - g * 3);
const R = radii[maxGen] || r0;
const pad = 4;
const box = (R + pad) * 2;
const svg = document.createElementNS(SVGNS, "svg");
svg.setAttribute("class", "ped-fan");
svg.setAttribute("viewBox", `${-R - pad} ${-R - pad} ${box} ${box}`);
svg.setAttribute("width", box);
svg.setAttribute("height", box);
for (let g = 1; g <= maxGen; g++) {
const count = 2 ** g, degPer = 360 / count, ri = radii[g - 1], ro = radii[g];
for (let idx = 0; idx < count; idx++) {
const n = nodes[String(count + idx)];
if (!n) continue;
const a0 = idx * degPer, a1 = a0 + degPer, large = (a1 - a0) > 180 ? 1 : 0;
const [x1, y1] = fanPolar(ri, a0), [x2, y2] = fanPolar(ro, a0);
const [x3, y3] = fanPolar(ro, a1), [x4, y4] = fanPolar(ri, a1);
const path = document.createElementNS(SVGNS, "path");
path.setAttribute("d",
`M${fanNum(x2)} ${fanNum(y2)}A${fanNum(ro)} ${fanNum(ro)} 0 ${large} 1 ${fanNum(x3)} ${fanNum(y3)}` +
`L${fanNum(x4)} ${fanNum(y4)}A${fanNum(ri)} ${fanNum(ri)} 0 ${large} 0 ${fanNum(x1)} ${fanNum(y1)}Z`);
path.setAttribute("class", "ped-wedge");
path.style.setProperty("--gen", g);
const key = dogKey(n);
if (key && pedRepeat[key] > 1) {
path.dataset.dogkey = key;
path.style.setProperty("--repeat-hue", hueFor(key));
path.classList.add("ped-wedge-repeat");
}
const title = document.createElementNS(SVGNS, "title");
title.textContent = fanTitle(n, key);
path.append(title);
path.addEventListener("click", () => selectFan(count + idx));
svg.append(path);
if (degPer >= 20) fanLabel(svg, n, (a0 + a1) / 2, ri, ro, degPer);
}
}
// centre disc = the dog
const c = document.createElementNS(SVGNS, "circle");
c.setAttribute("r", r0);
c.setAttribute("class", "ped-fan-center");
c.addEventListener("click", () => selectFan(1));
svg.append(c);
fanCenterLabel(svg, nodes["1"], r0);
pedTree.append(svg);
if (pedCaption) {
pedCaption.hidden = false;
pedCaption.textContent = "Tap a wedge for its dog. Zoom to read the outer rings.";
}
}
function fanTitle(n, key) {
let t = n.name || "(unnamed)";
if (n.reg) t += " — " + n.reg;
if (key && pedRepeat[key] > 1) t += " (×" + pedRepeat[key] + ")";
return t;
}
function fanLabel(svg, n, midA, ri, ro, degPer) {
let rot = midA - 90;
if (rot > 90 && rot < 270) rot -= 180; // keep upright
const [px, py] = fanPolar((ri + ro) / 2, midA);
const t = document.createElementNS(SVGNS, "text");
t.setAttribute("class", "ped-wedge-label");
t.setAttribute("transform", `translate(${fanNum(px)} ${fanNum(py)}) rotate(${fanNum(rot)})`);
const room = Math.floor((ro - ri) / 6.2); // chars that fit along the ring
t.textContent = fanTrunc(n.name || (n.reg || "?"), Math.max(6, room));
svg.append(t);
}
function fanCenterLabel(svg, n, r0) {
if (!n) return;
const words = (n.name || "Dog").split(" ");
const lines = [];
let line = "";
for (const w of words) {
if ((line + " " + w).trim().length > 12) { if (line) lines.push(line); line = w; }
else line = (line ? line + " " : "") + w;
}
if (line) lines.push(line);
const shown = lines.slice(0, 3);
const t = document.createElementNS(SVGNS, "text");
t.setAttribute("class", "ped-fan-center-label");
t.setAttribute("text-anchor", "middle");
const lh = 12, y0 = -(shown.length - 1) * lh / 2;
shown.forEach((ln, i) => {
const ts = document.createElementNS(SVGNS, "tspan");
ts.setAttribute("x", "0");
ts.setAttribute("y", fanNum(y0 + i * lh));
ts.textContent = ln;
t.append(ts);
});
svg.append(t);
}
function fanTrunc(s, max) { return s.length > max ? s.slice(0, max - 1) + "…" : s; }
function selectFan(pos) {
const n = pedNodes[String(pos)];
if (!n || !pedCaption) return;
const key = dogKey(n);
const bits = [];
if (n.reg) bits.push(n.reg);
if (n.titles) bits.push(n.titles);
if (key && pedRepeat[key] > 1) bits.push("appears ×" + pedRepeat[key]);
pedCaption.hidden = false;
pedCaption.textContent = (n.name || "(unnamed)") + (bits.length ? " — " + bits.join(" · ") : "");
togglePedHighlight(key && pedRepeat[key] > 1 ? key : ""); // clears if not a repeat
}
// Highlight (or unhighlight) every element (tree card or fan wedge) that is the
// same dog as `key`.
function togglePedHighlight(key) {
const cards = pedTree.querySelectorAll(".ped-card[data-dogkey]");
const els = pedTree.querySelectorAll("[data-dogkey]");
let lit = false;
cards.forEach((c) => {
els.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"); });
els.forEach((c) => c.classList.remove("ped-lit"));
if (!lit) els.forEach((c) => { if (c.dataset.dogkey === key) c.classList.add("ped-lit"); });
}
function buildPedNode(nodes, pos, depth) {