lvwerra's picture
lvwerra HF Staff
Species tree: drop viruses + retitle (25 species, 571,789 sequences)
a54539b
// =========================================================================
// §7, Species tree (Carbon-derived phylogeny)
//
// Renders the precomputed species_tree.json (built once by
// scripts/build_species_tree.py) as:
// - an SVG dendrogram spine on the left, with rounded Bezier elbows
// so the branches feel organic rather than CAD-like
// - a column of right-aligned data tracks (italic name, kingdom chip,
// log-scaled sequence count bar, NCBI agreement glyph)
// The two halves share the same row height (ROW_H px) so each leaf in
// the SVG lines up exactly with its row in the data tracks.
//
// User can toggle:
// - linkage = ward | upgma → swaps the precomputed layout
// - scope = kingdom | sister → swaps the NCBI agreement metric
// Hovering a row pops a tooltip listing the top-3 nearest neighbours
// in embedding space + their cosine distances.
// =========================================================================
(function initDemoSpeciesTree() {
const root = document.getElementById("demoSpeciesTree");
if (!root) return;
const ROW_H = 22; // px, must match .tree-row height in CSS
// SVG-internal padding kept at 0: vertical alignment with the rows
// grid is handled entirely by the CSS padding on .tree-spine /
// .tree-rows (both = 12px top). Doubling it would shift the spine
// down by 12px relative to the row labels.
const SPINE_PAD_TOP = 0;
const SPINE_PAD_BOTTOM = 0;
const ROWS_PAD_TOP = 12; // must match .tree-rows padding
const ROWS_PAD_BOTTOM = 28;
const SPINE_LABEL_INSET = 4; // tiny gap between spine tip and labels
const KINGDOM_COLOR = {
vertebrates: "#1f1f1d",
invertebrates: "#7a6242",
plants: "#317f3f",
fungi: "#a9762f",
bacteria: "#b00020",
};
const els = {
spine: document.getElementById("dtree-spine"),
svg: document.getElementById("dtree-svg"),
rows: document.getElementById("dtree-rows"),
info: document.getElementById("dtree-info"),
score: document.getElementById("dtree-score"),
scorePct: document.getElementById("dtree-score-pct"),
scoreSx: document.getElementById("dtree-score-suffix"),
nSp: document.getElementById("dtree-n"),
nSeq: document.getElementById("dtree-nseq"),
tooltip: document.getElementById("dtree-tooltip"),
frame: root.querySelector(".tree-frame"),
pillsLink: document.getElementById("dtree-link-pills"),
pillsScope: document.getElementById("dtree-scope-pills"),
};
let tree = null;
let state = { linkage: "ward", scope: "kingdom" };
let agreement = {}; // species -> 'match' | 'mismatch' | 'solo'
let nnTop = {}; // species -> [{name, dist, sameKingdom, sameClade}, ...]
// -------- nearest-neighbour computation --------
function buildNN() {
const sp = tree.species;
const D = tree.distance_matrix;
const kingdom = Object.fromEntries(sp.map((s, i) => [s, tree.kingdom[i]]));
const clade = Object.fromEntries(sp.map((s, i) => [s, tree.expected_clade[i]]));
const cladeSize = {};
sp.forEach(s => { const c = clade[s]; cladeSize[c] = (cladeSize[c] || 0) + 1; });
nnTop = {};
agreement = {};
for (let i = 0; i < sp.length; i++) {
// sort other species by distance ascending
const ranked = [];
for (let j = 0; j < sp.length; j++) {
if (j === i) continue;
ranked.push({
name: sp[j], dist: D[i][j],
sameKingdom: kingdom[sp[j]] === kingdom[sp[i]],
sameClade: clade[sp[j]] === clade[sp[i]],
});
}
ranked.sort((a, b) => a.dist - b.dist);
nnTop[sp[i]] = ranked.slice(0, 3);
// agreement state for the active scope
const nn = ranked[0];
if (state.scope === "kingdom") {
agreement[sp[i]] = nn.sameKingdom ? "match" : "mismatch";
} else {
if ((cladeSize[clade[sp[i]]] || 0) <= 1) agreement[sp[i]] = "solo";
else agreement[sp[i]] = nn.sameClade ? "match" : "mismatch";
}
}
}
// -------- score chip --------
// Three pieces of typography: headline % (Carbon green), raw ratio
// (m of total, muted), uppercase caption naming the comparison.
function updateScore() {
let m = 0, total = 0;
Object.values(agreement).forEach(v => {
if (v === "solo") return;
total += 1;
if (v === "match") m += 1;
});
const pct = total ? Math.round(100 * m / total) : 0;
if (els.scorePct) els.scorePct.textContent = `${pct}%`;
els.score.textContent = `${m} of ${total}`;
els.scoreSx.textContent = `match · ncbi ${state.scope}`;
}
// -------- SVG dendrogram spine --------
// scipy gives icoord/dcoord for each merge:
// icoord = [xL, xL, xR, xR] (in "leaf-index space": leaves at 5,15,25,...)
// dcoord = [yChildL, yMerge, yMerge, yChildR] (in distance space)
// We need to render this with leaf-index → vertical row position
// and distance → horizontal x position (with root on the LEFT, tips
// on the RIGHT, so labels sit next to the leaves).
function renderSpine() {
const layout = tree[state.linkage === "ward" ? "layout_ward" : "layout_upgma"];
const leafOrder = layout.leaf_order;
const ic = layout.icoord;
const dc = layout.dcoord;
const N = leafOrder.length;
const innerH = N * ROW_H;
const totalH = innerH + SPINE_PAD_TOP + SPINE_PAD_BOTTOM;
const w = els.spine.clientWidth || 320;
els.svg.style.height = totalH + "px";
els.svg.setAttribute("viewBox", `0 0 ${w} ${totalH}`);
// distance domain
let dmax = 0;
dc.forEach(arr => arr.forEach(v => { if (v > dmax) dmax = v; }));
if (dmax === 0) dmax = 1;
// In mobile (<=720px) the right-hand .tree-rows block stacks BELOW
// the spine instead of beside it, so the spine renders inline labels
// (kingdom chip + species name) at each tip, otherwise the user sees
// an unlabelled dendrogram followed by a list, which doesn't connect.
const isMobile = window.matchMedia("(max-width: 720px)").matches;
const padL = 4; // a hair from the SVG left edge (= root)
const padR = isMobile ? 130 : SPINE_LABEL_INSET; // room for inline labels in mobile
const innerW = w - padL - padR;
const xOfDist = d => padL + (1 - d / dmax) * innerW;
const yOfLeafIdx = idx => SPINE_PAD_TOP + (idx + 0.5) * ROW_H;
const yOfICoord = ix => yOfLeafIdx((ix - 5) / 10);
// Classic dendrogram elbows: top arm horizontal → vertical → bottom
// arm horizontal, sharp 90° corners. Single <path> for perf.
let d = "";
for (let i = 0; i < ic.length; i++) {
const xs = ic[i], ys = dc[i];
const yTop = yOfICoord(xs[0]);
const yBot = yOfICoord(xs[3]);
const xMerge = xOfDist(ys[1]);
const xTopArm = xOfDist(ys[0]);
const xBotArm = xOfDist(ys[3]);
d += ` M ${xTopArm} ${yTop}`
+ ` L ${xMerge} ${yTop}`
+ ` L ${xMerge} ${yBot}`
+ ` L ${xBotArm} ${yBot}`;
}
// Pastilles at each leaf tip, coloured by kingdom, that visually
// connect the muted-grey tree spine to the kingdom-coloured tracks
// on the right. They also act as a discrete "row marker" so the eye
// can follow horizontally even where the spine background tint is
// very pale (chicken / frog / zebrafish in the vertebrate band).
const kingdom = Object.fromEntries(tree.species.map((s, i) => [s, tree.kingdom[i]]));
let tips = "";
for (let i = 0; i < leafOrder.length; i++) {
const sp = leafOrder[i];
const cy = yOfLeafIdx(i);
const cx = w - padR;
const k = kingdom[sp];
const fill = (
k === "vertebrates" ? "#1f1f1d" :
k === "invertebrates" ? "#7a6242" :
k === "plants" ? "#317f3f" :
k === "fungi" ? "#a9762f" :
k === "bacteria" ? "#b00020" : "#888"
);
tips += `<circle cx="${cx}" cy="${cy}" r="2.4" fill="${fill}" stroke="#fff" stroke-width="0.8"/>`;
if (isMobile) {
// chip + species label rendered inline at the tip, only visible
// in mobile (desktop hides them via CSS, see .leaf-svg-label).
tips += `<rect class="leaf-svg-chip" x="${cx + 6}" y="${cy - 4}" width="8" height="8" fill="${fill}"/>`;
tips += `<text class="leaf-svg-label" x="${cx + 18}" y="${cy + 3.5}" fill="${fill}">${sp.replace(/_/g, " ")}</text>`;
}
}
els.svg.innerHTML =
`<path d="${d}" fill="none" stroke="#bbb8ad" stroke-width="1"
stroke-linecap="square" stroke-linejoin="miter"
shape-rendering="crispEdges" />` + tips;
return leafOrder;
}
// -------- rows --------
function renderRows(leafOrder) {
const counts = Object.fromEntries(tree.species.map((s, i) => [s, tree.counts[i]]));
const kingdom = Object.fromEntries(tree.species.map((s, i) => [s, tree.kingdom[i]]));
const maxLog = Math.log10(Math.max(...tree.counts) + 1);
let html = "";
for (let i = 0; i < leafOrder.length; i++) {
const sp = leafOrder[i];
const k = kingdom[sp];
const c = counts[sp];
const logFrac = Math.log10(c + 1) / maxLog;
const a = agreement[sp] || "solo";
const glyph = a === "match" ? "✓" : a === "mismatch" ? "✗" : "·";
html +=
`<div class="tree-row" data-species="${sp}" data-kingdom="${k}">` +
`<div class="tree-chip" style="background:${KINGDOM_COLOR[k]}"></div>` +
`<div class="tree-name">${sp.replace(/_/g, " ")}</div>` +
`<div class="tree-bar">` +
`<div class="bar-track">` +
`<div class="bar-fill" style="width:${(logFrac * 100).toFixed(1)}%"></div>` +
`</div>` +
`<div class="bar-num">${c.toLocaleString("en-US")}</div>` +
`</div>` +
`<div class="tree-ncbi" data-state="${a}">${glyph}</div>` +
`</div>`;
}
els.rows.innerHTML = html;
bindRowHover();
}
// -------- hover tooltip --------
function bindRowHover() {
els.rows.querySelectorAll(".tree-row").forEach(rowEl => {
rowEl.addEventListener("mouseenter", () => {
const sp = rowEl.dataset.species;
const top = nnTop[sp] || [];
const expected = state.scope === "kingdom"
? "same kingdom" : "same ncbi sister clade";
let tt =
`<div class="tt-title">${sp.replace(/_/g, " ")} · top neighbours</div>`;
top.forEach((nb, i) => {
const isExpected = state.scope === "kingdom" ? nb.sameKingdom : nb.sameClade;
const cls = isExpected ? "tt-name expected" : "tt-name";
const glyph = isExpected ? "✓" : "·";
tt +=
`<div class="tt-pair">` +
`<span class="tt-glyph">${glyph}</span>` +
`<span class="${cls}">${nb.name.replace(/_/g, " ")}</span>` +
`<span class="tt-dist">${nb.dist.toFixed(4)}</span>` +
`</div>`;
});
tt += `<div class="tt-pair" style="margin-top:6px;color:#888">` +
`<span class="tt-glyph"></span>` +
`<span style="font-size:9px">match = ${expected}</span></div>`;
els.tooltip.innerHTML = tt;
els.tooltip.classList.add("show");
});
rowEl.addEventListener("mousemove", (ev) => {
const fr = els.frame.getBoundingClientRect();
const tt = els.tooltip;
const x = ev.clientX - fr.left + 12;
const y = ev.clientY - fr.top + 12;
// keep on-screen
const ttw = tt.offsetWidth, tth = tt.offsetHeight;
const fitX = (x + ttw > fr.width) ? (ev.clientX - fr.left - ttw - 12) : x;
const fitY = (y + tth > fr.height) ? (ev.clientY - fr.top - tth - 12) : y;
tt.style.left = fitX + "px";
tt.style.top = fitY + "px";
});
rowEl.addEventListener("mouseleave", () => {
els.tooltip.classList.remove("show");
});
});
}
// -------- toggles --------
function bindToggles() {
els.pillsLink.querySelectorAll(".pill").forEach(p => {
p.addEventListener("click", () => {
if (p.classList.contains("active")) return;
els.pillsLink.querySelectorAll(".pill").forEach(b => b.classList.remove("active"));
p.classList.add("active");
state.linkage = p.dataset.link;
rerender();
});
});
els.pillsScope.querySelectorAll(".pill").forEach(p => {
p.addEventListener("click", () => {
if (p.classList.contains("active")) return;
els.pillsScope.querySelectorAll(".pill").forEach(b => b.classList.remove("active"));
p.classList.add("active");
state.scope = p.dataset.scope;
// scope only changes agreement, not layout, but it's cheap to redo all
buildNN();
const layout = tree[state.linkage === "ward" ? "layout_ward" : "layout_upgma"];
renderRows(layout.leaf_order);
updateScore();
});
});
}
function rerender() {
buildNN();
const order = renderSpine();
renderRows(order);
updateScore();
}
// -------- bootstrap --------
fetch("/species_tree")
.then(r => r.json())
.then(t => {
tree = t;
els.nSp.textContent = tree.species.length;
els.nSeq.textContent = tree.n_total_points.toLocaleString("en-US");
bindToggles();
rerender();
// The SVG width depends on the laid-out grid → recompute on resize.
const ro = new ResizeObserver(() => {
const order = renderSpine();
renderRows(order); // re-bind row hover after rebuild
});
ro.observe(els.spine);
// matchMedia covers the exact breakpoint transition where the
// spine width may not actually change (e.g. desktop window shrunk
// to 720px → still occupies the same column width but switches
// role from "tree" to "tree + inline labels").
const mq = window.matchMedia("(max-width: 720px)");
const onMQ = () => { const order = renderSpine(); renderRows(order); };
if (mq.addEventListener) mq.addEventListener("change", onMQ);
else mq.addListener(onMQ); // Safari < 14 fallback
})
.catch(err => {
els.info.textContent = "failed to load species tree: " + (err.message || err);
});
})();