const state = {data: null, cache: new Map(), dimensionality: new Map()};
const $ = id => document.getElementById(id);
const species = () => state.data.species;
const speciesBySlug = slug => species().find(x => x.slug === slug);
const displaySource = id => ({
EggNOG_OG: 'EggNOG OG', GO_EggNOG: 'GO (EggNOG)', GO_ESM2_150M_MF: 'GO (ESM2-150M)',
GO_ESMC_600M_ProteinFunction: 'GO (ESMC-600M)', ESM_localization: 'ESM localization',
GO_union: 'GO union', Pfam_HMMER: 'Pfam / HMMER', EC_EggNOG: 'EC (EggNOG)',
KEGG_KO_EggNOG: 'KEGG KO (EggNOG)', KEGG_KO_KofamScan: 'KEGG KO (KofamScan)',
KEGG_KO_union: 'KEGG KO union'
}[id] || id);
function esc(value){
return String(value ?? '').replaceAll('&','&').replaceAll('<','<')
.replaceAll('>','>').replaceAll('"','"').replaceAll("'",''');
}
function table(target, rows, columns){
if(!rows || !rows.length){$(target).innerHTML='
No rows available.
';return;}
const cols=columns || Object.keys(rows[0]);
$(target).innerHTML=`${cols.map(c=>`| ${esc(c)} | `).join('')}
`+
rows.map(row=>`${cols.map(c=>`| ${esc(row[c])} | `).join('')}
`).join('')+'';
}
function records(payload){return (payload.data||payload.rows||[]).map(row=>Object.fromEntries(payload.columns.map((c,i)=>[c,row[i]])));}
async function json(path){
if(!state.cache.has(path)) state.cache.set(path, fetch(path).then(r=>{if(!r.ok)throw Error(path);return r.json()}));
return state.cache.get(path);
}
function fillSelect(id, values, value){
$(id).innerHTML=values.map(v=>``).join('');
if(value!==undefined) $(id).value=value;
}
function setSpeciesSelect(id, initial='species01'){
fillSelect(id, species().map(x=>x.slug), initial);
$(id).querySelectorAll('option').forEach(o=>{const s=speciesBySlug(o.value);o.textContent=s.label});
}
function contrastEntries(slug){
const seen=new Map();
for(const x of state.data.deIndex[slug]||[]) if(!seen.has(x.contrast_id))seen.set(x.contrast_id,x);
return [...seen.values()].sort((a,b)=>a.contrast_id.localeCompare(b.contrast_id));
}
function setupContrastSelect(speciesId, contrastId){
const entries=contrastEntries($(speciesId).value);
fillSelect(contrastId, entries.map(x=>x.contrast_id), entries[0]?.contrast_id);
}
function csvDownload(filename, rows, columns){
const body=[columns, ...rows.map(r=>columns.map(c=>r[c] ?? ''))]
.map(row=>row.map(v=>`"${String(v).replaceAll('"','""')}"`).join(',')).join('\n');
const url=URL.createObjectURL(new Blob([body],{type:'text/csv;charset=utf-8'}));
const a=document.createElement('a');a.href=url;a.download=filename;a.click();URL.revokeObjectURL(url);
}
function fmt(n){return Number(n).toLocaleString('en-US')}
function pFmt(n){n=Number(n);return Number.isFinite(n)?(n<.001?n.toExponential(2):n.toPrecision(3)):'NA'}
function plotLogP(rows, column){
const positive=rows.map(r=>Number(r[column])).filter(x=>Number.isFinite(x)&&x>0);
const observedMax=positive.length?Math.max(...positive.map(x=>-Math.log10(x))):0;
const floor=positive.length?Math.min(...positive)/10:1e-12;
return {
value:n=>{n=Number(n);return Number.isFinite(n)&&n>0?-Math.log10(n):-Math.log10(floor)},
max:Math.max(observedMax,-Math.log10(floor)),
floored:n=>{n=Number(n);return !Number.isFinite(n)||n<=0}
};
}
function plotPText(value, label, floored){
const n=Number(value);
if(!Number.isFinite(n))return `${label}: unavailable`;
if(n<=0)return `${label}: 0 (reported as 0; plotted at finite floor)`;
return `${label}: ${pFmt(n)}${floored?' (finite floor used)':''}`;
}
function contrastDirectionText(entry){
if(!entry)return '';
return `Positive fold change (up) indicates higher expression in ${entry.group_a} relative to ${entry.group_b}; negative fold change (down) indicates higher expression in ${entry.group_b} relative to ${entry.group_a}.`;
}
function renderOverview(){
table('assembly-table', state.data.assemblySummary, Object.keys(state.data.assemblySummary[0]));
const render=()=>{
const slug=$('overview-species').value, s=speciesBySlug(slug);
$('annotation-title').textContent=`${s.label} | Universe: ${fmt(s.genes)} genes, ${fmt(s.proteins)} predicted proteins.`;
table('annotation-table', state.data.annotationExamples[s.label]);
};
setSpeciesSelect('overview-species');$('overview-species').onchange=render;render();
}
async function renderExpression(){
const slug=$('dim-species').value, method=$('dim-method').value, genes=$('dim-genes').value;
const path=`data/dimensionality/${slug}.json`, all=await json(path), p=all[`${method}_${genes}`];
const axes=p.axes, dimensionLabels=['Dimension 1','Dimension 2','Dimension 3'], rows=p.rows, symbols=['circle','diamond','square','x','cross','triangle-up'];
const temps=[...new Set(rows.map(r=>String(r.temperature)))], symbolMap=Object.fromEntries(temps.map((x,i)=>[x,symbols[i%symbols.length]]));
const stages=[...new Set(rows.map(r=>String(r.development_stage)))];
const stageColors=['#176b91','#e27d43','#58a66b','#8b63b6','#c94f66','#d3a42b'];
const traces=stages.map((stage,i)=>{
const x=rows.filter(r=>String(r.development_stage)===stage);
return {type:'scatter3d',mode:'markers',name:stage,x:x.map(r=>r[axes[0]]),y:x.map(r=>r[axes[1]]),z:x.map(r=>r[axes[2]]),
text:x.map(r=>r.sample_id),customdata:x.map(r=>[r.temperature,r.development_stage,r.replicate]),
marker:{size:8,color:stageColors[i%stageColors.length],symbol:x.map(r=>symbolMap[String(r.temperature)])},
hovertemplate:'%{text}
Temperature: %{customdata[0]}
Development stage: %{customdata[1]}
Replicate: %{customdata[2]}'};
});
const temperatureLegend=temps.map(temp=>({type:'scatter3d',mode:'markers',name:`Temperature: ${temp}`,x:[null],y:[null],z:[null],
marker:{size:8,color:'#6b7f8f',symbol:symbolMap[temp]},legendgroup:'temperature',showlegend:true,hoverinfo:'skip'}));
Plotly.newPlot('dim-plot',[...traces,...temperatureLegend],{template:'plotly_white',paper_bgcolor:'#fff',plot_bgcolor:'#f7fbfd',font:{color:'#123b5d'},height:650,
scene:{xaxis:{title:{text:dimensionLabels[0]}},yaxis:{title:{text:dimensionLabels[1]}},zaxis:{title:{text:dimensionLabels[2]}}},title:`${speciesBySlug(slug).label} · ${method}`},{responsive:true});
const explained=p.explained_variance?` Explained variance: ${p.explained_variance.map((x,i)=>`${dimensionLabels[i]} ${(x*100).toFixed(1)}%`).join(', ')}.`:'';
$('dim-status').textContent=`${speciesBySlug(slug).label}: ${rows.length} samples projected from ${genes==='all'?'all':fmt(p.selected_gene_count)} variable genes after log2(TMM + 1). The matrix contains ${fmt(p.expressed_gene_count)} non-constant genes.${explained}`;
table('dim-table', rows, ['sample_id','temperature','development_stage','replicate',...axes]);
}
async function loadDe(){
const slug=$('de-species').value, contrast=$('de-contrast').value, method=$('de-method').value;
const entry=(state.data.deIndex[slug]||[]).find(x=>x.contrast_id===contrast&&x.method===method);
return entry?{entry,rows:records(await json(`data/${entry.file}`))}:{entry:null,rows:[]};
}
function deFiltered(rows, method){
const p=+$('de-pvalue').value, q=+$('de-adjusted').value, fold=+$('de-fold').value;
return rows.filter(r=>Number(r[method==='DESeq2'?'pvalue':'PValue'])<=p&&Number(r[method==='DESeq2'?'padj':'FDR'])<=q&&Math.abs(Number(r[method==='DESeq2'?'log2FoldChange':'logFC']))>=fold);
}
async function renderDE(){
const method=$('de-method').value, loaded=await loadDe(), rows=deFiltered(loaded.rows,method), entry=loaded.entry;
if(!entry){$('de-summary').textContent='No differential-expression result is available for this selection.';return;}
const foldCol=method==='DESeq2'?'log2FoldChange':'logFC', pCol=$('de-y').value==='pvalue'?(method==='DESeq2'?'pvalue':'PValue'):(method==='DESeq2'?'padj':'FDR');
const yLabel=$('de-y').value==='pvalue'?'p-value':(method==='DESeq2'?'padj':'FDR');
const yTitle=`-log10(${yLabel})`, logP=plotLogP(rows,pCol);
const traces=['down','up'].map(direction=>{const x=rows.filter(r=>r.direction===direction);return {type:'scatter',mode:'markers',name:direction,x:x.map(r=>r[foldCol]),y:x.map(r=>logP.value(r[pCol])),customdata:x.map(r=>[r.gene_id,plotPText(r[pCol],yLabel,logP.floored(r[pCol]))]),hovertemplate:'%{customdata[0]}
Fold change: %{x:.3f}
'+yTitle+': %{y:.2f}
%{customdata[1]}',marker:{size:6}}});
const xTitle=method==='DESeq2'?'log2 fold change':'log fold change';
Plotly.newPlot('de-plot',traces,{template:'plotly_white',paper_bgcolor:'#fff',plot_bgcolor:'#f7fbfd',font:{color:'#123b5d'},height:620,title:`Volcano plot: ${method}`,xaxis:{title:{text:xTitle}},yaxis:{title:{text:yTitle},range:[0,Math.max(logP.max*1.08,1)]},shapes:[{type:'line',x0:0,x1:0,y0:0,y1:1,yref:'paper',line:{dash:'dot',color:'#7890a0'}}]},{responsive:true});
$('de-summary').innerHTML=`${method} compared ${esc(entry.group_a)} (n = ${entry.n_group_a}) with ${esc(entry.group_b)} (n = ${entry.n_group_b}). Of the ${fmt(entry.n_genes_input)} genes in the expression matrix, ${fmt(entry.n_genes_tested)} were retained for statistical testing. Using p-value ≤ ${$('de-pvalue').value}, adjusted p-value / FDR ≤ ${$('de-adjusted').value}, and absolute fold change ≥ ${$('de-fold').value}, ${fmt(rows.length)} DEGs remain. ${contrastDirectionText(entry)}`;
const cols=method==='DESeq2'?['gene_id','baseMean','log2FoldChange','lfcSE','stat','pvalue','padj','direction']:['gene_id','logFC','logCPM','F','PValue','FDR','direction'];
table('de-table',rows.slice(0,500),cols);$('de-download').onclick=()=>csvDownload(`${slugSafe($('de-species').value)}_de_filtered.csv`,rows,cols);
}
function slugSafe(x){return x.toLowerCase().replaceAll(' ','_')}
function activeEnrichmentSource(){return sourceFor($('en-source').value,$('en-hierarchy').value)}
function sourceFor(base,h){
if(h==='Direct terms')return base;
if(h==='GO ancestors')return `${base}__GO_ANCESTORS`;
if(h==='EC level 1 class')return 'EC_EggNOG__EC_LEVEL1';
const prefix={'KEGG pathways':'KEGG_PATHWAY','KEGG level 1':'KEGG_LEVEL1','KEGG level 2':'KEGG_LEVEL2'}[h];
const suffix={KEGG_KO_EggNOG:'EggNOG',KEGG_KO_KofamScan:'KofamScan',KEGG_KO_union:'consensus'}[base];
return prefix&&suffix?`${prefix}_${suffix}`:base;
}
async function loadEnrichment(){
const slug=$('en-species').value, contrast=$('en-contrast').value, method=$('en-method').value;
const index=(state.data.enrichmentIndex[slug]||[]).filter(x=>x.contrast_id===contrast&&x.method===method);
if(!index.length)return {index,rows:[]};
const file=index[0].file;return {index,rows:records(await json(`data/${file}`))};
}
async function renderEnrichment(){
const loaded=await loadEnrichment(), source=activeEnrichmentSource(), maxP=+$('en-pvalue').value,maxQ=+$('en-adjusted').value;
const info=loaded.index.find(x=>x.source_id===source), rows=loaded.rows.filter(r=>r.source_id===source&&Number(r.pvalue)<=maxP&&Number(r.padj)<=maxQ);
if(!info){$('en-summary').textContent='No enrichment snapshot is available for this hierarchy selection.';return;}
const deEntry=(state.data.deIndex[$('en-species').value]||[]).find(x=>x.contrast_id===$('en-contrast').value&&x.method===$('en-method').value);
const byTerm={};for(const r of rows){(byTerm[r.term]??=[]).push(r)}
const terms=Object.values(byTerm).sort((a,b)=>Math.min(...a.map(x=>+x.padj))-Math.min(...b.map(x=>+x.padj))).slice(0,20);
const agg=terms.map(group=>{const total=group.reduce((s,r)=>s+Number(r.overlap||0),0);const fe=group.reduce((s,r)=>s+Number(r.fold_enrichment||0)*Number(r.overlap||0),0)/(total||1);return group.map(r=>({...r,term_description:r.name||r.term,segment_fold_enrichment:fe*Number(r.overlap||0)/(total||1),fold_enrichment_total:fe,total_overlap:total}))}).flat().sort((a,b)=>Number(a.fold_enrichment_total)-Number(b.fold_enrichment_total)||a.term_description.localeCompare(b.term_description));
const categoryOrder=[...new Set(agg.map(r=>r.term_description))];
Plotly.newPlot('en-plot',[{type:'bar',orientation:'h',x:agg.map(r=>r.segment_fold_enrichment),y:agg.map(r=>r.term_description),customdata:agg.map(r=>[r.direction,r.overlap,r.fold_enrichment_total,r.padj]),name:'direction',marker:{color:agg.map(r=>r.direction==='up'?'#d55e48':'#247ba0')},hovertemplate:'%{y}
%{customdata[0]} genes: %{customdata[1]}
Fold enrichment: %{customdata[2]:.2f}
Adjusted p-value: %{customdata[3]:.3g}'}],{template:'plotly_white',paper_bgcolor:'#fff',plot_bgcolor:'#f7fbfd',font:{color:'#123b5d'},height:Math.max(520,350+terms.length*22),barmode:'stack',title:`${displaySource($('en-source').value)} · ${$('en-hierarchy').value}`,xaxis:{title:{text:'Fold enrichment'},range:[0,Math.max(...agg.map(r=>r.fold_enrichment_total),1)]},yaxis:{automargin:true,categoryorder:'array',categoryarray:categoryOrder}} ,{responsive:true});
const nEnriched=new Set(rows.map(r=>r.term)).size;
$('en-summary').innerHTML=`The frozen over-representation analysis tested ${fmt(info.universe_terms)} unique terms or categories; ${fmt(nEnriched)} terms pass the displayed ORA filters. Bars show fold enrichment. For this contrast, positive fold change (up) indicates higher expression in ${esc(deEntry?.group_a||'the first group')} relative to ${esc(deEntry?.group_b||'the second group')}; negative fold change (down) indicates higher expression in ${esc(deEntry?.group_b||'the second group')} relative to ${esc(deEntry?.group_a||'the first group')}. When both directions contribute to a term, the bar is stacked proportionally; these directions describe expression differences and do not by themselves imply pathway activation or inhibition.`;
table('en-table',rows.sort((a,b)=>Number(a.padj)-Number(b.padj)).slice(0,500),['source_id','direction','term','name','overlap','foreground_size','term_size','background_size','fold_enrichment','pvalue','padj','genes']);
$('en-download').onclick=()=>csvDownload(`${slugSafe($('en-species').value)}_enrichment_filtered.csv`,rows,['source_id','direction','term','name','overlap','foreground_size','term_size','background_size','fold_enrichment','pvalue','padj','genes']);
}
function syncDESpecies(){setupContrastSelect('de-species','de-contrast')}
function syncEnSpecies(){setupContrastSelect('en-species','en-contrast')}
function init(){
setSpeciesSelect('dim-species');setSpeciesSelect('de-species');setSpeciesSelect('en-species');
renderOverview();syncDESpecies();syncEnSpecies();
$('de-species').onchange=()=>{syncDESpecies();renderDE();};$('en-species').onchange=()=>{syncEnSpecies();syncEnSource();renderEnrichment()};
$('dim-run').onclick=renderExpression;renderExpression();
renderDE();
$('de-run').onclick=renderDE;['de-method','de-contrast','de-pvalue','de-adjusted','de-fold','de-y'].forEach(id=>$(id).onchange=()=>{});
$('en-run').onclick=renderEnrichment;
$('en-source').onchange=syncEnHierarchy;
syncEnSource();renderEnrichment();
document.querySelectorAll('.tab').forEach(b=>b.onclick=()=>{document.querySelectorAll('.tab,.panel').forEach(x=>x.classList.remove('active'));b.classList.add('active');$(b.dataset.tab).classList.add('active');});
fetch('assets/inria_footer_logo.txt').then(r=>r.text()).then(x=>{$('inria-logo').innerHTML=`
`});
}
function syncEnSource(){const ids=state.data.sourceOrder;fillSelect('en-source',ids.map(displaySource));[...$('en-source').options].forEach((o,i)=>o.value=ids[i]);$('en-source').value=ids.includes('KEGG_KO_union')?'KEGG_KO_union':ids[0];syncEnHierarchy()}
function syncEnHierarchy(){const base=$('en-source').value, options=state.data.hierarchies[base]||['Direct terms'];const preferred=options.includes('KEGG pathways')?'KEGG pathways':options[0];fillSelect('en-hierarchy',options,preferred);}
fetch('data/app-data.json').then(r=>r.json()).then(d=>{state.data=d;init()}).catch(e=>{document.body.innerHTML+='Unable to load the frozen data snapshot.
';console.error(e)});