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.textContent = collapsed ? "Expand all" : "Collapse all";
} }
pedFoldAll.addEventListener("click", () => pedSetAll(pedFoldAll.textContent[0] === "C")); 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. // Trackpad/desktop: ctrl or ⌘ + wheel zooms instead of scrolling the page.
pedTree.addEventListener("wheel", (e) => { pedTree.addEventListener("wheel", (e) => {
if (!e.ctrlKey && !e.metaKey) return; if (!e.ctrlKey && !e.metaKey) return;
@@ -2573,7 +2594,7 @@
const cached = loadPedCache(q); const cached = loadPedCache(q);
if (cached && cached.nodes) { if (cached && cached.nodes) {
renderSubject(cached.subject); renderSubject(cached.subject);
renderTree(cached.nodes); renderPedigree(cached.nodes);
} }
if (!navigator.onLine) { if (!navigator.onLine) {
setPedStatus(cached ? "Offline — showing the last saved pedigree." : "Pedigree needs an internet connection.", setPedStatus(cached ? "Offline — showing the last saved pedigree." : "Pedigree needs an internet connection.",
@@ -2606,7 +2627,7 @@
const data = await res.json(); const data = await res.json();
if (data.status === "choose") { renderChoose(data.matches || []); return; } if (data.status === "choose") { renderChoose(data.matches || []); return; }
renderSubject(data.subject); renderSubject(data.subject);
renderTree(data.nodes || {}); renderPedigree(data.nodes || {});
if (data.status === "done") { if (data.status === "done") {
savePedCache(q, data.subject, data.nodes || {}); savePedCache(q, data.subject, data.nodes || {});
setPedDone(); setPedDone();
@@ -2625,7 +2646,7 @@
if (res.status === 401) { handleLoggedOut(); return; } if (res.status === 401) { handleLoggedOut(); return; }
if (!res.ok) { setPedStatus("Lost track of the pedigree crawl.", "error"); return; } if (!res.ok) { setPedStatus("Lost track of the pedigree crawl.", "error"); return; }
const data = await res.json(); 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 === "done") { savePedCache(q, subject, data.nodes || {}); setPedDone(); return; }
if (data.status === "error") { setPedStatus(data.error || "Pedigree crawl failed.", "error"); return; } if (data.status === "error") { setPedStatus(data.error || "Pedigree crawl failed.", "error"); return; }
setPedStatus(`Tracing ancestry… ${pedCountText()}`, "busy"); setPedStatus(`Tracing ancestry… ${pedCountText()}`, "busy");
@@ -2725,17 +2746,22 @@
} }
let pedRepeat = {}; // dogKey -> occurrence count, for the current tree 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; pedNodes = nodes;
pedTree.textContent = "";
// Count how many positions each dog fills, to flag pedigree collapse.
pedRepeat = {}; pedRepeat = {};
for (const k in nodes) { for (const k in nodes) {
const id = dogKey(nodes[k]); const id = dogKey(nodes[k]);
if (id) pedRepeat[id] = (pedRepeat[id] || 0) + 1; if (id) pedRepeat[id] = (pedRepeat[id] || 0) + 1;
} }
const anyRepeat = Object.values(pedRepeat).some((c) => c > 1); if (pedRepeatNote) pedRepeatNote.hidden = !Object.values(pedRepeat).some((c) => c > 1);
if (pedRepeatNote) pedRepeatNote.hidden = !anyRepeat; pedTree.textContent = "";
if (pedView === "fan") renderFan(nodes); else renderTree(nodes);
updatePedViewControls();
}
function renderTree(nodes) {
const root = buildPedNode(nodes, 1, 0); const root = buildPedNode(nodes, 1, 0);
if (!root) return; if (!root) return;
const ul = document.createElement("ul"); const ul = document.createElement("ul");
@@ -2745,15 +2771,152 @@
if (pedFoldAll) pedFoldAll.textContent = "Collapse all"; // fresh tree starts partly open 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) { function togglePedHighlight(key) {
const cards = pedTree.querySelectorAll(".ped-card[data-dogkey]"); const els = pedTree.querySelectorAll("[data-dogkey]");
let lit = false; let lit = false;
cards.forEach((c) => { els.forEach((c) => {
if (c.dataset.dogkey === key && c.classList.contains("ped-lit")) lit = true; if (c.dataset.dogkey === key && c.classList.contains("ped-lit")) lit = true;
}); });
cards.forEach((c) => c.classList.remove("ped-lit")); els.forEach((c) => c.classList.remove("ped-lit"));
if (!lit) cards.forEach((c) => { if (c.dataset.dogkey === key) c.classList.add("ped-lit"); }); if (!lit) els.forEach((c) => { if (c.dataset.dogkey === key) c.classList.add("ped-lit"); });
} }
function buildPedNode(nodes, pos, depth) { function buildPedNode(nodes, pos, depth) {
+1
View File
@@ -1,4 +1,5 @@
[ [
{ "date": "2026-07-26", "text": "Added a fan-chart view of the pedigree (toggle it in the header): your dog at the centre with each generation fanning outward as a ring, so many generations fit at once without the tree sprawling sideways — tap a wedge for that dog, and repeated ancestors keep their colour" },
{ "date": "2026-07-26", "text": "Added a Collapse all / Expand all toggle to the pedigree, to fold the whole tree down to your dog or open every branch at once" }, { "date": "2026-07-26", "text": "Added a Collapse all / Expand all toggle to the pedigree, to fold the whole tree down to your dog or open every branch at once" },
{ "date": "2026-07-26", "text": "The pedigree is now zoomable — use the +/ buttons, ⌘/Ctrl-scroll, or pinch on a phone — to fit a wide tree on screen or zoom in for detail; your zoom level is remembered" }, { "date": "2026-07-26", "text": "The pedigree is now zoomable — use the +/ buttons, ⌘/Ctrl-scroll, or pinch on a phone — to fit a wide tree on screen or zoom in for detail; your zoom level is remembered" },
{ "date": "2026-07-26", "text": "In the pedigree, a dog that fills more than one spot (pedigree collapse, common in a breed's older lines) now carries a ×N badge — tap it to highlight every place that dog appears in the tree" }, { "date": "2026-07-26", "text": "In the pedigree, a dog that fills more than one spot (pedigree collapse, common in a breed's older lines) now carries a ×N badge — tap it to highlight every place that dog appears in the tree" },
+2
View File
@@ -281,6 +281,7 @@
<button type="button" id="pedigree-back" class="ghost icon-btn" aria-label="Back to tracker" title="Back"></button> <button type="button" id="pedigree-back" class="ghost icon-btn" aria-label="Back to tracker" title="Back"></button>
<h1>🌳 Pedigree</h1> <h1>🌳 Pedigree</h1>
<div class="ped-controls"> <div class="ped-controls">
<button type="button" id="ped-view" class="ghost">Fan view</button>
<button type="button" id="ped-foldall" class="ghost">Collapse all</button> <button type="button" id="ped-foldall" class="ghost">Collapse all</button>
<div class="ped-zoom" role="group" aria-label="Zoom"> <div class="ped-zoom" role="group" aria-label="Zoom">
<button type="button" id="ped-zoom-out" class="ghost icon-btn" aria-label="Zoom out" title="Zoom out"></button> <button type="button" id="ped-zoom-out" class="ghost icon-btn" aria-label="Zoom out" title="Zoom out"></button>
@@ -301,6 +302,7 @@
<div id="pedigree-subject" class="pedigree-subject" hidden></div> <div id="pedigree-subject" class="pedigree-subject" hidden></div>
<p id="pedigree-repeat-note" class="muted-note pedigree-repeat-note" hidden>Some ancestors appear in more than one place further back (pedigree collapse). Expand the tree to reveal their ×N badges, then tap one to highlight every spot that dog appears.</p> <p id="pedigree-repeat-note" class="muted-note pedigree-repeat-note" hidden>Some ancestors appear in more than one place further back (pedigree collapse). Expand the tree to reveal their ×N badges, then tap one to highlight every spot that dog appears.</p>
<div id="pedigree-tree" class="pedigree-tree"></div> <div id="pedigree-tree" class="pedigree-tree"></div>
<p id="pedigree-caption" class="pedigree-caption" hidden></p>
</main> </main>
</div><!-- /#pedigree-screen --> </div><!-- /#pedigree-screen -->
+43
View File
@@ -1285,3 +1285,46 @@ section.collapsed > :not(h2) { display: none; }
cursor: pointer; cursor: pointer;
} }
.ped-toggle:hover { border-color: var(--accent); color: var(--accent); } .ped-toggle:hover { border-color: var(--accent); color: var(--accent); }
/* ---- radial fan chart ---- */
.ped-fan { display: block; margin: 0 auto; max-width: none; }
.ped-wedge {
cursor: pointer;
stroke: var(--border);
stroke-width: 1;
/* outer rings tint gradually darker for depth */
fill: color-mix(in srgb, var(--accent-soft) calc(var(--gen, 1) * 7%), var(--surface));
transition: filter 0.1s ease;
}
.ped-wedge:hover { filter: brightness(0.95); }
/* a dog that appears more than once is filled with its stable hue */
.ped-wedge-repeat { fill: hsl(var(--repeat-hue, 0), 60%, 80%); stroke: hsl(var(--repeat-hue, 0), 45%, 60%); }
.ped-wedge.ped-lit {
fill: hsl(var(--repeat-hue, 0), 72%, 62%);
stroke: hsl(var(--repeat-hue, 0), 72%, 38%);
stroke-width: 2;
}
.ped-wedge-label {
font-size: 8px;
fill: var(--text);
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
}
.ped-fan-center { fill: var(--accent); stroke: none; cursor: pointer; }
.ped-fan-center-label {
fill: #fff;
font-size: 9px;
font-weight: 600;
dominant-baseline: central;
pointer-events: none;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .ped-wedge-label { fill: var(--text); }
}
.pedigree-caption {
margin: 10px 0 0;
font-size: 0.85rem;
color: var(--muted);
text-align: center;
}