Skip to content

Commit 3bb2a3c

Browse files
misialqlizgehret
andauthored
NEW: add an interactive TFA visualization (#364)
<!-- PR guidance and examples: https://github.com/rachis-org/governance/blob/main/pr_template_guidelines.md --> <!-- rachis Generative AI Policy: https://github.com/rachis-org/governance/blob/main/ai_policy.md --> # Description <!-- REQUIRED: Briefly describe this PR, or link the issue it closes. --> This PR adds a visualization allowing to interactively explore the relationships between taxonomy and the chosen functional profiles contained in the TFA table. # AI Disclosure <!-- REQUIRED: Check exactly one option. --> - [ ] NO AI USED. - [x] AI USED. # AI Usage Details <!-- REQUIRED if 'AI USED' is checked. This section can be deleted if 'NO AI USED' is checked. --> GPT Sol 6.1 was used to design the visualization and iterate over the code. I have reviewed and tested the code using some real world data and user feedback (thanks @fanniekerff2!) --------- Co-authored-by: Liz Gehret <54517601+lizgehret@users.noreply.github.com>
1 parent 635d2f9 commit 3bb2a3c

26 files changed

Lines changed: 2343 additions & 100 deletions
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
"use strict";
2+
3+
// Browser controls and Vega plots; taxonomy aggregation lives in taxonomy.js.
4+
// data.json stores nonzero loads as [pair index, sample index, load] triplets.
5+
let tfa;
6+
const showUnclassified = document.getElementById("show-unclassified");
7+
const levelSelect = document.getElementById("level");
8+
const taxonSearch = document.getElementById("taxon-search");
9+
const selectedTaxonomy = document.getElementById("selected-taxonomy");
10+
const taxonSelect = document.getElementById("taxon");
11+
const functionSelect = document.getElementById("function");
12+
const groupSelect = document.getElementById("group");
13+
const metricSelect = document.getElementById("metric");
14+
const limitSelect = document.getElementById("limit");
15+
const clearSelections = document.getElementById("clear-selections");
16+
const status = document.getElementById("status");
17+
const schema = "https://vega.github.io/schema/vega-lite/v5.json";
18+
let activeViews = [];
19+
let renderQueue = Promise.resolve();
20+
21+
function addOptions(select, labels, firstLabel) {
22+
const first = document.createElement("option");
23+
first.value = "";
24+
first.textContent = firstLabel;
25+
select.appendChild(first);
26+
for (const label of labels) {
27+
const option = document.createElement("option");
28+
option.value = label;
29+
option.textContent = label;
30+
select.appendChild(option);
31+
}
32+
}
33+
34+
// Keep the original data in tfa and the selected taxonomy level in current.
35+
let current;
36+
let loadsByPair = [];
37+
let deepestLevel = 1;
38+
const controls = [showUnclassified, levelSelect, taxonSearch, taxonSelect,
39+
functionSelect, groupSelect, metricSelect, limitSelect, clearSelections];
40+
for (const control of controls) control.disabled = true;
41+
42+
/** Load data and populate controls before drawing the first plots. */
43+
async function initializeExplorer() {
44+
const response = await fetch("data.json");
45+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
46+
tfa = await response.json();
47+
const functions = [...new Set(tfa.pairs.map(pair => pair.function))].sort();
48+
deepestLevel = tfa.pairs.reduce((depth, pair) => Math.max(depth, pair.lineage.length), 1);
49+
for (let level = 1; level <= deepestLevel; level++) {
50+
levelSelect.add(new Option(`Level ${level}`, String(level)));
51+
}
52+
addOptions(functionSelect, functions, "All functions");
53+
addOptions(groupSelect, Object.keys(tfa.groups), "All samples");
54+
for (const control of controls) control.disabled = false;
55+
resetSelections();
56+
await renderQueue;
57+
}
58+
59+
/** Restore the same control values and plots used on first load. */
60+
function resetSelections() {
61+
levelSelect.value = String(deepestLevel);
62+
taxonSearch.value = "";
63+
functionSelect.value = "";
64+
groupSelect.value = "";
65+
metricSelect.value = "sum";
66+
limitSelect.value = "20";
67+
showUnclassified.checked = false;
68+
changeLevel();
69+
update();
70+
}
71+
72+
function pairVisible(pair) {
73+
return showUnclassified.checked || !isUnclassifiedTaxon(pair, Number(levelSelect.value));
74+
}
75+
76+
function pairLabel(pair) {
77+
return pair.taxon_label || latestTaxonLabel(pair.lineage || [], pair.taxon_id);
78+
}
79+
80+
/** Rebuild the taxon list from search and function filters, keeping valid selections. */
81+
function refreshTaxa() {
82+
const previous = taxonSelect.value;
83+
const query = taxonSearch.value.trim().toLocaleLowerCase();
84+
const taxa = [...new Set(current.pairs
85+
.filter(pairVisible)
86+
.filter(pair => !functionSelect.value || pair.function === functionSelect.value)
87+
.map(pair => pair.taxon))]
88+
.filter(taxon => taxon.toLocaleLowerCase().includes(query)).sort();
89+
taxonSelect.replaceChildren();
90+
addOptions(taxonSelect, taxa, query ? "Select a matching taxon" : "All taxa");
91+
const labelByTaxon = new Map(current.pairs.map(pair => [pair.taxon, pairLabel(pair)]));
92+
const terminal = taxon => labelByTaxon.get(taxon);
93+
const counts = new Map();
94+
for (const taxon of taxa) counts.set(terminal(taxon), (counts.get(terminal(taxon)) || 0) + 1);
95+
for (const option of taxonSelect.options) {
96+
if (!option.value) continue;
97+
const label = terminal(option.value);
98+
const parent = option.value.split(";").slice(0, -1).join("; ").trim();
99+
option.textContent = counts.get(label) > 1 ? `${label} [${parent}]` : label;
100+
option.title = option.value;
101+
}
102+
taxonSelect.value = taxa.includes(previous) ? previous : "";
103+
showSelectedTaxonomy();
104+
}
105+
106+
function showSelectedTaxonomy() {
107+
taxonSelect.title = taxonSelect.value || "All taxa";
108+
selectedTaxonomy.textContent = taxonSelect.value
109+
? `Selected lineage: ${taxonSelect.value}`
110+
: taxonSearch.value.trim() ? "Search filters the taxon list. Select a taxon to filter the plots." : "";
111+
}
112+
113+
/** Collapse to the chosen level and index sparse loads for repeated filtering. */
114+
function changeLevel() {
115+
current = collapseTaxonomy(tfa, Number(levelSelect.value));
116+
loadsByPair = current.pairs.map(() => []);
117+
for (const load of current.loads) loadsByPair[load[0]].push(load);
118+
taxonSelect.value = "";
119+
refreshTaxa();
120+
}
121+
122+
/** Find pair indices matching the current taxon, function, and visibility choices. */
123+
function matchedPairs() {
124+
const indices = [];
125+
current.pairs.forEach((pair, index) => {
126+
if (pairVisible(pair) && (!taxonSelect.value || pair.taxon === taxonSelect.value) &&
127+
(!functionSelect.value || pair.function === functionSelect.value)) {
128+
indices.push(index);
129+
}
130+
});
131+
return indices;
132+
}
133+
134+
/** Sum selected pairs per sample, retaining zero loads and metadata groups. */
135+
function sampleValues(pairIndices) {
136+
const values = new Float64Array(tfa.samples.length);
137+
for (const pairIndex of pairIndices) {
138+
for (const [, sampleIndex, load] of loadsByPair[pairIndex]) {
139+
values[sampleIndex] += load;
140+
}
141+
}
142+
const grouping = tfa.groups[groupSelect.value];
143+
return tfa.samples.map((sample, index) => ({
144+
sample,
145+
group: grouping ? grouping[index] : "All samples",
146+
load: values[index]
147+
}));
148+
}
149+
150+
function plotWidth(id) {
151+
const gutter = {heatmap: 170, boxplot: 90, samples: 180}[id];
152+
return Math.max(240, document.getElementById(id).parentElement.clientWidth - gutter);
153+
}
154+
155+
/** Rank links by the chosen metric and build a Vega-Lite heatmap specification. */
156+
function heatmapSpec(indices) {
157+
const field = {sum: "total", mean: "mean", median: "median"}[metricSelect.value];
158+
const label = {sum: "Sum", mean: "Mean", median: "Median"}[metricSelect.value];
159+
const top = indices
160+
.map(index => current.pairs[index])
161+
.sort((a, b) => b[field] - a[field])
162+
.slice(0, Number(limitSelect.value));
163+
const taxaOrder = [...new Set(top.map(pair => pair.taxon))];
164+
const functionOrder = [...new Set(top.map(pair => pair.function))];
165+
// Full lineages identify rows; short labels must not merge taxa with identical names.
166+
const axisLabels = Object.fromEntries(top.map(pair => [pair.taxon, pairLabel(pair)]));
167+
return {
168+
$schema: schema,
169+
data: {values: top},
170+
width: Math.min(plotWidth("heatmap"), Math.max(300, functionOrder.length * 52)),
171+
height: {step: 29},
172+
mark: {type: "rect", cursor: "pointer", stroke: "white", strokeWidth: 1},
173+
encoding: {
174+
x: {
175+
field: "function", type: "nominal", sort: functionOrder, title: "Function",
176+
axis: {labels: functionOrder.length <= 30}
177+
},
178+
y: {
179+
field: "taxon", type: "nominal", sort: taxaOrder, title: "Taxon",
180+
axis: {labelLimit: 0, labelExpr: `${JSON.stringify(axisLabels)}[datum.label]`}
181+
},
182+
color: {field, type: "quantitative", title: `${label} load`, scale: {scheme: "blues", zero: true}},
183+
tooltip: [
184+
{field: "taxon", type: "nominal", title: "Taxon"},
185+
{field: "taxon_id", type: "nominal", title: "Taxon ID"},
186+
{field: "function", type: "nominal", title: "Function"},
187+
{field, type: "quantitative", title: `${label} load`, format: ".4g"}
188+
]
189+
},
190+
config: {view: {stroke: null}}
191+
};
192+
}
193+
194+
function sampleGroupColor(samples) {
195+
// Use every sample, including categories absent from the top 100 bars.
196+
return {
197+
field: "group", type: "nominal", legend: null,
198+
scale: {domain: [...new Set(samples.map(sample => sample.group))].sort(), scheme: "tableau10"}
199+
};
200+
}
201+
202+
function boxplotSpec(samples) {
203+
return {
204+
$schema: schema,
205+
data: {values: samples},
206+
width: plotWidth("boxplot"),
207+
height: 300,
208+
mark: {type: "boxplot", extent: 1.5},
209+
encoding: {
210+
x: {field: "group", type: "nominal", sort: sampleGroupColor(samples).scale.domain, title: groupSelect.value || "Samples"},
211+
y: {field: "load", type: "quantitative", title: "TFA load", scale: {zero: true}},
212+
color: sampleGroupColor(samples)
213+
},
214+
config: {view: {stroke: null}}
215+
};
216+
}
217+
218+
function sampleSpec(samples) {
219+
const top = [...samples].sort((a, b) => b.load - a.load).slice(0, 100);
220+
return {
221+
$schema: schema,
222+
data: {values: top},
223+
width: plotWidth("samples"),
224+
height: 300,
225+
mark: {type: "bar"},
226+
encoding: {
227+
x: {field: "sample", type: "nominal", sort: "-y", title: "Sample", axis: {labels: top.length <= 30}},
228+
y: {field: "load", type: "quantitative", title: "TFA load", scale: {zero: true}},
229+
color: sampleGroupColor(samples),
230+
tooltip: [
231+
{field: "sample", type: "nominal", title: "Sample"},
232+
{field: "group", type: "nominal", title: groupSelect.value || "Group"},
233+
{field: "load", type: "quantitative", title: "TFA load", format: ".4g"}
234+
]
235+
},
236+
config: {view: {stroke: null}}
237+
};
238+
}
239+
240+
/** Dispose of old Vega views and redraw all three plots for the current selection. */
241+
async function draw() {
242+
for (const view of activeViews) view.finalize();
243+
activeViews = [];
244+
const indices = matchedPairs();
245+
const samples = sampleValues(indices);
246+
const method = metricSelect.value;
247+
status.textContent = `${indices.length} matching taxon–function pairs · ${samples.length} samples`;
248+
document.getElementById("heatmap-caption").textContent =
249+
`Each square is one pair at the selected taxonomy level; color shows its ${{sum: "sum", mean: "mean", median: "median"}[method]} load across all samples, including zeros. Links are ranked by this metric. Taxa are summed within each sample before this summary. Click a square to inspect that pair.`;
250+
document.getElementById("group-caption").textContent = groupSelect.value
251+
? `Each box compares sample loads for one ${groupSelect.value} category. Zeros are included.`
252+
: "One box summarizes all samples. Add categorical metadata to compare groups.";
253+
254+
if (!indices.length || !samples.length) {
255+
for (const id of ["heatmap-plot", "boxplot", "samples"]) {
256+
document.getElementById(id).textContent = "No values to plot for this selection.";
257+
}
258+
return;
259+
}
260+
if (typeof vegaEmbed !== "function") {
261+
status.textContent += " · Vega could not load; an internet connection is needed to display the plots.";
262+
return;
263+
}
264+
try {
265+
const heatmapContainer = document.getElementById("heatmap");
266+
heatmapContainer.scrollTop = 0;
267+
heatmapContainer.scrollLeft = 0;
268+
// Use fresh targets so a previous Vega view cannot hide the next render.
269+
for (const id of ["heatmap-plot", "boxplot", "samples"]) {
270+
document.getElementById(id).replaceChildren();
271+
}
272+
const options = {actions: false, renderer: "svg"};
273+
const [heatmap, boxplot, samplePlot] = await Promise.all([
274+
vegaEmbed("#heatmap-plot", heatmapSpec(indices), options),
275+
vegaEmbed("#boxplot", boxplotSpec(samples), options),
276+
vegaEmbed("#samples", sampleSpec(samples), options)
277+
]);
278+
activeViews = [heatmap.view, boxplot.view, samplePlot.view];
279+
heatmap.view.addEventListener("click", (_, item) => {
280+
const pair = item && item.datum;
281+
if (pair && pair.taxon && pair.function) {
282+
taxonSearch.value = "";
283+
functionSelect.value = pair.function;
284+
refreshTaxa();
285+
taxonSelect.value = pair.taxon;
286+
update();
287+
}
288+
});
289+
} catch (error) {
290+
status.textContent = `Could not draw the plots: ${error.message}`;
291+
}
292+
}
293+
294+
function update() {
295+
if (!tfa) return;
296+
showSelectedTaxonomy();
297+
// Serialize asynchronous redraws so an older render cannot overwrite a newer one.
298+
renderQueue = renderQueue.then(draw).catch(error => {
299+
status.textContent = `Could not draw the plots: ${error.message}`;
300+
});
301+
}
302+
303+
clearSelections.addEventListener("click", resetSelections);
304+
// Rebuild the taxon choices whenever a control changes which taxa are available.
305+
showUnclassified.addEventListener("change", () => {
306+
refreshTaxa();
307+
update();
308+
});
309+
levelSelect.addEventListener("change", () => {
310+
changeLevel();
311+
update();
312+
});
313+
taxonSearch.addEventListener("input", () => {
314+
refreshTaxa();
315+
update();
316+
});
317+
functionSelect.addEventListener("change", () => {
318+
refreshTaxa();
319+
update();
320+
});
321+
for (const select of [taxonSelect, groupSelect, metricSelect, limitSelect]) {
322+
select.addEventListener("change", update);
323+
}
324+
let resizeTimer;
325+
window.addEventListener("resize", () => {
326+
clearTimeout(resizeTimer);
327+
resizeTimer = setTimeout(update, 150);
328+
});
329+
const initialization = initializeExplorer().catch(error => {
330+
status.textContent = `Could not load TFA data: ${error.message}`;
331+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<title>Explore taxonomic functional attribution</title>
7+
<link rel="stylesheet" href="style.css">
8+
<script src="https://cdn.jsdelivr.net/npm/vega@5/build/vega.min.js" defer></script>
9+
<script src="https://cdn.jsdelivr.net/npm/vega-lite@5/build/vega-lite.min.js" defer></script>
10+
<script src="https://cdn.jsdelivr.net/npm/vega-embed@6/build/vega-embed.min.js" defer></script>
11+
<script src="taxonomy.js" defer></script>
12+
<script src="explore.js" defer></script>
13+
</head>
14+
<body>
15+
<main>
16+
<section class="controls" aria-label="Explorer controls">
17+
<label>Taxonomy level <select id="level"></select></label>
18+
<label class="taxon-control">Search taxa <input id="taxon-search" type="search" placeholder="Type a name or lineage…" aria-controls="taxon" autocomplete="off"></label>
19+
<label class="taxon-control">Taxon <select id="taxon" aria-describedby="selected-taxonomy"></select></label>
20+
<label>Function <select id="function"></select></label>
21+
<label>Group samples by <select id="group"></select></label>
22+
<label>Heatmap metric <select id="metric"><option value="sum">Sum</option><option value="mean">Mean</option><option value="median">Median</option></select></label>
23+
<label>Heatmap links <select id="limit"><option value="20">Top 20</option><option value="50">Top 50</option><option value="100">Top 100</option><option value="200">Top 200</option><option value="500">Top 500</option><option value="1000">Top 1000</option><option value="Infinity">All links</option></select></label>
24+
<label class="checkbox-control"><input id="show-unclassified" type="checkbox"> Show unclassified taxa</label>
25+
<button id="clear-selections" type="button">Clear selections</button>
26+
</section>
27+
28+
<p id="selected-taxonomy" aria-live="polite"></p>
29+
<p id="status" role="status">Loading data…</p>
30+
31+
<section class="card">
32+
<h2>Taxon–function links</h2>
33+
<p id="heatmap-caption"></p>
34+
<div id="heatmap" class="plot"><div id="heatmap-plot"></div></div>
35+
</section>
36+
37+
<section class="grid">
38+
<div class="card">
39+
<h2>Sample distributions by group</h2>
40+
<p id="group-caption"></p>
41+
<div id="boxplot" class="plot"></div>
42+
</div>
43+
<div class="card">
44+
<h2>Individual samples</h2>
45+
<p>The 100 samples with the highest loads are shown; the group comparison uses every sample. Colors match the groups labeled in the panel on the left.</p>
46+
<div id="samples" class="plot"></div>
47+
</div>
48+
</section>
49+
<footer>
50+
<p><strong>TFA (Taxonomic Functional Attribution) load</strong> estimates how much of a function is attributed to a taxon in a sample. For each contig assigned to that taxon, the function copy count is multiplied by the contig's abundance in that sample. These contributions are summed across the taxon's contigs. Unclassified taxa are hidden by default; toggle the "Show unclassified taxa" checkbox to include them.</p>
51+
<p><strong>Heatmap:</strong> Each cell shows the sum, mean, or median load for one taxon–function pair across all samples, including zeros. At a selected taxonomy level, taxa sharing a lineage are summed within each sample before this summary is calculated. Labels use the last classified rank when deeper ranks are missing.</p>
52+
<p><strong>Bottom plots:</strong> Each sample contributes one value: the sum of loads for all taxon–function pairs matching the current filters. Selecting one function and all taxa therefore sums that function's load across the displayed taxa within each sample. Each box shows the distribution of these per-sample values within a metadata group; each bar shows one sample's value. Zero loads are included. Metadata only groups samples in these plots; it does not change the loads or the heatmap summaries. Samples with missing metadata form a separate group.</p>
53+
<p><strong>Box plots (Tukey):</strong> The line inside each box marks the median. The box spans the 25th to 75th percentiles (Q1–Q3), and the interquartile range is IQR = Q3 − Q1. Whiskers extend to the smallest and largest observed sample values within the range Q1 − 1.5 × IQR to Q3 + 1.5 × IQR. Values beyond the whiskers are shown as individual outlier points.</p>
54+
</footer>
55+
</main>
56+
</body>
57+
</html>

0 commit comments

Comments
 (0)