diff --git a/src/app.js b/src/app.js index 22a3e7d..ed81050 100644 --- a/src/app.js +++ b/src/app.js @@ -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) { diff --git a/src/changelog.json b/src/changelog.json index e282661..debea2d 100644 --- a/src/changelog.json +++ b/src/changelog.json @@ -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": "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" }, diff --git a/src/index.html b/src/index.html index eeafed2..ee46acf 100644 --- a/src/index.html +++ b/src/index.html @@ -281,6 +281,7 @@
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.
+