jzxiao's picture
Publish compression benchmark results and interactive leaderboard
bfe13e2 verified
Raw History Blame Contribute Delete
23.2 kB
const $ = (id) => document.getElementById(id);
const escape = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const number = (v) => v != null && Number.isFinite(Number(v)) ? Number(v) : null;
const mean = (values) => { const a = values.map(number).filter((n) => n !== null); return a.length ? a.reduce((s,n) => s+n,0)/a.length : null; };
const fmt = (v, digits=3) => number(v) === null ? 'Not reported' : Number(v).toLocaleString('en-US',{maximumFractionDigits:digits,minimumFractionDigits:digits});
const countFmt = (v) => Number(v).toLocaleString('en-US');
const runtimeName = (v) => ({cpp:'C++',java:'Java',python:'Python'}[v] || v);
const cols = [
{key:'compression_rate',label:'Average rate ↓',digits:4,visible:true},
{key:'overall_rate',label:'Overall rate ↓',digits:4,visible:false},
{key:'compression_time',label:'Encode ns/point ↓',digits:2,visible:true},
{key:'decompression_time',label:'Decode ns/point ↓',digits:2,visible:true},
{key:'compressed_bytes',label:'Compressed bytes',digits:0,visible:false},
{key:'original_bytes',label:'Original bytes',digits:0,visible:false},
];
let snapshot, records=[], methods=new Map(), datasets=new Map(), currentRows=[], activeTab='overall';
let sort={key:'compression_rate',direction:1};
let selectedMethod=null;
const state = () => ({runtime:$('runtime').value,dataset:$('dataset').value,dtype:$('dtype').value,precision:$('precision').value,family:$('family').value,mode:$('row-mode').value,search:$('search').value.trim().toLowerCase(),complete:$('complete-only').checked});
function scopeKey() { const s=state(); return s.dtype==='overall'?'overall':s.dtype==='int'?'integer':s.precision==='fixed'?'fixed_float':s.precision==='non_fixed'?'nonfixed_float':'float'; }
function normalizeRecord(r) {
return { ...r,configuration:r.algorithm_variant??r.algorithm??r.method,method:r.algorithm_variant??r.algorithm??r.method,runtime:r.runtime ?? r.implementation_language ?? snapshot.benchmark?.implementation_language ?? 'cpp',
family:r.family ?? r.method_family ?? r.method ?? methods.get(r.algorithm)?.family ?? r.algorithm,
compression_rate:number(r.compression_rate ?? r.average_compression_rate),compression_time:number(r.compression_time_ns_per_point ?? r.compression_time),decompression_time:number(r.decompression_time_ns_per_point ?? r.decompression_time),
original_bytes:number(r.original_size_bytes ?? r.original_bytes ?? r.originalSize),compressed_bytes:number(r.compressed_size_bytes ?? r.compressed_bytes ?? r.compressedSize) };
}
function filteredRecords(ignoreMethod=false) {
const s=state(), scope=scopeKey();
let result=records.filter((r) => r.runtime===s.runtime && r.scope===scope && (s.dataset==='all'||r.dataset===s.dataset));
if(s.mode==='family') {
const best=new Map();
for(const r of result){const key=`${r.dataset}\0${r.family}`;const prev=best.get(key);if(!prev||r.compression_rate<prev.compression_rate||(r.compression_rate===prev.compression_rate&&r.configuration.localeCompare(prev.configuration)<0))best.set(key,r);}
result=[...best.values()].map(r=>({...r,method:r.family}));
}
return ignoreMethod?result:result.filter(r=>(s.family==='all'||r.family===s.family)&&(!s.search||(s.mode==='family'?r.family:`${r.method} ${r.family}`).toLowerCase().includes(s.search)));
}
function aggregate(input) {
const groups=new Map();
for(const r of input) { if(!groups.has(r.method)) groups.set(r.method,[]); groups.get(r.method).push(r); }
const totalDatasets=new Set(filteredRecords(true).map(r=>r.dataset)).size;
const rows=[];
for(const [method,source] of groups) {
const hasBytes=source.every(r=>r.original_bytes!==null && r.original_bytes>0 && r.compressed_bytes!==null && r.compressed_bytes>0);
const original=hasBytes?source.reduce((s,r)=>s+r.original_bytes,0):null, compressed=hasBytes?source.reduce((s,r)=>s+r.compressed_bytes,0):null;
rows.push({method,family:source[0].family,runtime:source[0].runtime,compression_rate:mean(source.map(r=>r.compression_rate)),compression_time:mean(source.map(r=>r.compression_time)),decompression_time:mean(source.map(r=>r.decompression_time)),overall_rate:hasBytes?compressed/original:null,original_bytes:original,compressed_bytes:compressed,coverage:new Set(source.map(r=>r.dataset)).size,totalDatasets,source});
}
return rows.filter(r=>!state().complete||r.coverage===totalDatasets).sort((a,b)=>{
const av=number(a[sort.key]),bv=number(b[sort.key]);
if(av===null&&bv===null)return a.method.localeCompare(b.method);
if(av===null)return 1;if(bv===null)return -1;
return sort.direction*(av-bv)||a.method.localeCompare(b.method);
});
}
function renderTable() {
const visible=cols.filter(c=>c.visible);
$('leaderboard').querySelector('thead').innerHTML=`<tr><th>Position</th><th>Method / configuration</th>${visible.map(c=>`<th aria-sort="${sort.key===c.key?(sort.direction===1?'ascending':'descending'):'none'}"><button data-sort="${c.key}" title="Sort ${escape(c.label)}">${escape(c.label)}${sort.key===c.key?(sort.direction===1?' ▴':' ▾'):''}</button></th>`).join('')}<th>Dataset coverage</th></tr>`;
const minima=Object.fromEntries(visible.map(c=>[c.key,Math.min(...currentRows.map(r=>number(r[c.key])).filter(n=>n!==null))]));
$('leaderboard').querySelector('tbody').innerHTML=currentRows.length?currentRows.map((r,i)=>`<tr><td class="rank numeric">${i+1}</td><td><button class="method-button" data-method="${escape(r.method)}">${escape(r.method)}</button><span class="method-family">${escape(r.family)} · ${escape(runtimeName(r.runtime))}</span></td>${visible.map(c=>`<td class="numeric ${!c.key.endsWith('bytes')&&number(r[c.key])!==null&&r[c.key]===minima[c.key]?'best':''}">${fmt(r[c.key],c.digits)}</td>`).join('')}<td><span class="coverage ${r.coverage===r.totalDatasets?'full':''}">${r.coverage} / ${r.totalDatasets}</span></td></tr>`).join(''):`<tr><td colspan="${visible.length+3}" class="empty-cell">No measurements match this selection. Try another runtime, data type, or dataset.</td></tr>`;
for(const button of $('leaderboard').querySelectorAll('[data-sort]'))button.addEventListener('click',()=>{const key=button.dataset.sort;sort={key,direction:sort.key===key?-sort.direction:1};refresh();});
for(const button of $('leaderboard').querySelectorAll('[data-method]'))button.addEventListener('click',()=>showMethod(button.dataset.method,true));
$('row-count').textContent=`${currentRows.length} ${state().mode==='family'?'method families':'method configurations'} · sorted by ${cols.find(c=>c.key===sort.key)?.label??sort.key}`;
$('download').disabled=!currentRows.length;
}
function refresh() {
if(!snapshot)return;
$('precision').disabled=$('dtype').value!=='float';
if(scopeKey()==='float'&&!records.some(r=>r.scope==='float')) {
// The exporter must publish an explicit all-float scope; never combine
// preaggregated fixed/non-fixed metrics and call it an all-float result.
$('results-note').textContent='All-float measurements are not available in this snapshot. Select a precision category.';
} else $('results-note').textContent=state().mode==='family'?'Each family uses its lowest-rate measured configuration per dataset. Configurations may differ across datasets; use Exact configuration for fixed parameters.':'Each row retains one exact configuration. Lower compression rates and lower ns/point are better. Coverage counts datasets, not identical column sets.';
currentRows=aggregate(filteredRecords());
const s=state(),scope=scopeKey(),ds=s.dataset==='all'?'all measured datasets':s.dataset;
const names={overall:'all numeric columns',integer:'integer columns',float:'floating-point columns',fixed_float:'fixed-precision float columns',nonfixed_float:'non-fixed-precision float columns'};
$('selection-summary').textContent=`${runtimeName(s.runtime)} · ${names[scope]||scope} · ${ds} · ${currentRows.length} methods`;
$('view-title').textContent=activeTab==='dataset'?`${s.dataset==='all'?'Dataset':s.dataset} leaderboard`:activeTab==='tradeoffs'?'Storage and execution trade-offs':'Overall leaderboard';
renderTable();
if(activeTab==='tradeoffs')renderChart();
if(selectedMethod)showMethod(selectedMethod,false);
}
function setTab(tab) {
activeTab=['overall','dataset','tradeoffs','methods','paper','about'].includes(tab)?tab:'overall';
for(const b of document.querySelectorAll('[data-tab]')){b.classList.toggle('active',b.dataset.tab===activeTab);if(b.dataset.tab===activeTab)b.setAttribute('aria-current','page');else b.removeAttribute('aria-current');}
$('results-view').hidden=['methods','paper','about'].includes(activeTab);$('methods-view').hidden=activeTab!=='methods';$('paper-view').hidden=activeTab!=='paper';$('about-view').hidden=activeTab!=='about';$('chart-view').hidden=activeTab!=='tradeoffs';
if(activeTab==='dataset'&&$('dataset').value==='all'){const first=[...datasets.keys()].find(k=>records.some(r=>r.dataset===k&&r.runtime===$('runtime').value));if(first)$('dataset').value=first;}
if(activeTab==='overall')$('dataset').value='all';
refresh();
}
function showMethod(method,focus=false) {
const row=currentRows.find(r=>r.method===method), detail=$('method-detail');
if(!row){selectedMethod=null;detail.hidden=true;return;}selectedMethod=method;
const meta=methods.get(method)??methods.get(row.source[0].configuration), pipeline=meta?.operator_pipeline;
const source=row.source.slice().sort((a,b)=>a.dataset.localeCompare(b.dataset));
detail.innerHTML=`<div class="detail-head"><div><h2 id="detail-title">${escape(method)}</h2><p class="muted">${escape(row.family)} · ${escape(runtimeName(row.runtime))} · ${row.coverage} measured datasets</p></div><button id="close-detail" class="quiet-button">Close details</button></div><div class="detail-grid"><div><strong>${fmt(row.compression_rate,4)}</strong><span>average compression rate</span></div><div><strong>${fmt(row.compression_time,2)}</strong><span>encoding ns/point</span></div><div><strong>${fmt(row.decompression_time,2)}</strong><span>decoding ns/point</span></div><div><strong>${row.coverage} / ${row.totalDatasets}</strong><span>dataset coverage</span></div></div>${pipeline?`<p class="source-code">Interpretive operator mapping: ${escape(typeof pipeline==='string'?pipeline:JSON.stringify(pipeline))}</p>`:''}<p class="muted">Dataset metrics below come from the published result files. Independent round-trip evidence and hardware are ${snapshot.benchmark?.roundtrip_evidence?'documented in the manifest':'not reported for this snapshot'}.</p><div class="detail-table"><table><thead><tr><th>Dataset</th><th>Configuration</th><th>Rate ↓</th><th>Encode ns/point ↓</th><th>Decode ns/point ↓</th><th>Original bytes</th><th>Compressed bytes</th></tr></thead><tbody>${source.map(r=>`<tr><td>${escape(r.dataset)}</td><td>${escape(r.configuration)}</td><td class="numeric">${fmt(r.compression_rate,4)}</td><td class="numeric">${fmt(r.compression_time,2)}</td><td class="numeric">${fmt(r.decompression_time,2)}</td><td class="numeric">${fmt(r.original_bytes,0)}</td><td class="numeric">${fmt(r.compressed_bytes,0)}</td></tr>`).join('')}</tbody></table></div>`;
detail.hidden=false;$('close-detail').addEventListener('click',()=>{detail.hidden=true;selectedMethod=null;});
if(focus){detail.scrollIntoView({block:'nearest',behavior:'auto'});detail.focus({preventScroll:true});}
}
function renderChart() {
const metric=$('chart-metric').value;
const valid=currentRows.filter(r=>r.compression_rate>0&&r[metric]>0);
if(!valid.length){$('chart').innerHTML='<p class="chart-empty muted">No positive measurements to plot for this selection.</p>';return;}
const width=1100,height=410,pad={left:85,right:35,top:30,bottom:65};
const maxRate=Math.max(...valid.map(r=>r.compression_rate))*1.08;
const logs=valid.map(r=>Math.log10(r[metric]));let minLog=Math.floor(Math.min(...logs)),maxLog=Math.ceil(Math.max(...logs));if(maxLog===minLog)maxLog=minLog+1;
const x=r=>pad.left+r/maxRate*(width-pad.left-pad.right),y=n=>height-pad.bottom-(Math.log10(n)-minLog)/(maxLog-minLog)*(height-pad.top-pad.bottom);
let graph='';
for(let i=0;i<=5;i++){const rate=maxRate*i/5,px=x(rate);graph+=`<line class="gridline" x1="${px}" y1="${pad.top}" x2="${px}" y2="${height-pad.bottom}"/><text class="axis-label" x="${px}" y="${height-pad.bottom+25}" text-anchor="middle">${fmt(rate,2)}</text>`;}
for(let i=minLog;i<=maxLog;i++){const n=10**i,py=y(n);graph+=`<line class="gridline" x1="${pad.left}" y1="${py}" x2="${width-pad.right}" y2="${py}"/><text class="axis-label" x="${pad.left-12}" y="${py+4}" text-anchor="end">${n.toLocaleString('en-US',{maximumFractionDigits:3})}</text>`;}
for(const r of valid)graph+=`<circle class="point" tabindex="0" role="button" aria-label="${escape(r.method)}: compression rate ${fmt(r.compression_rate,4)}, ${metric==='compression_time'?'encoding':'decoding'} ${fmt(r[metric],2)} ns per point" data-method="${escape(r.method)}" cx="${x(r.compression_rate)}" cy="${y(r[metric])}" r="6"><title>${escape(r.method)} | rate ${fmt(r.compression_rate,4)} | ${fmt(r[metric],2)} ns/point | coverage ${r.coverage}/${r.totalDatasets}</title></circle>`;
graph+=`<text class="axis-label" x="${width/2}" y="${height-13}" text-anchor="middle">Average compression rate (compressed / original) ↓</text><text class="axis-label" transform="translate(20 ${height/2}) rotate(-90)" text-anchor="middle">${metric==='compression_time'?'Encoding':'Decoding'} ns/point (log scale) ↓</text>`;
$('chart').innerHTML=`<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Measured compression rates versus ${metric==='compression_time'?'encoding':'decoding'} cost">${graph}</svg>`;
for(const p of $('chart').querySelectorAll('[data-method]')){p.addEventListener('click',()=>showMethod(p.dataset.method,true));p.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();showMethod(p.dataset.method,true);}});}
}
function csv() {
const quote=v=>{let text=String(v??'');if(typeof v==='string'&&/^[=+@-]/.test(text))text="'"+text;return `"${text.replace(/"/g,'""')}"`;};
const columns=['position','method','runtime','scope','row_mode','selected_dataset','sort_metric','sort_direction','average_compression_rate','overall_compression_rate','encode_ns_per_point','decode_ns_per_point','dataset_coverage','datasets_in_scope','selected_configuration_by_dataset'];
const body=currentRows.map((r,i)=>[i+1,r.method,r.runtime,scopeKey(),state().mode,state().dataset,sort.key,sort.direction===1?'ascending':'descending',r.compression_rate,r.overall_rate,r.compression_time,r.decompression_time,r.coverage,r.totalDatasets,JSON.stringify(Object.fromEntries(r.source.map(s=>[s.dataset,s.configuration])))].map(quote).join(','));
const url=URL.createObjectURL(new Blob(['\ufeff'+[columns.join(','),...body].join('\r\n')],{type:'text/csv;charset=utf-8'}));const a=document.createElement('a');a.href=url;a.download=`THULab-compression-${state().runtime}-${scopeKey()}-${state().dataset}.csv`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
}
function renderAbout() {
const b=snapshot.benchmark||{},s=snapshot.source||{},c=snapshot.coverage||{};
const notes=[b.scope_note,b.aggregation_note,b.selection_note,...(b.notes||[]),...(b.measurement_boundaries||[])].filter(Boolean);
$('methodology').innerHTML=notes.map(n=>`<p class="notice">${escape(n)}</p>`).join('');
const definitions=b.metric_definitions||{};if(Object.keys(definitions).length)$('methodology').innerHTML+=`<dl class="definitions">${Object.entries(definitions).map(([k,v])=>`<div><dt>${escape(k.replaceAll('_',' '))}</dt><dd>${escape(typeof v==='string'?v:JSON.stringify(v))}</dd></div>`).join('')}</dl>`;
const exclusions=c.exclusions||c.excluded_datasets||snapshot.exclusions||[];
const info={ 'Snapshot exported':snapshot.exported_at||s.exported_at||'Not reported','Dataset scope generated':s.generated_at||'Not reported', 'Source checkout':s.git_commit||'Not reported','Source files':`${s.source_files?.length??0} metadata inputs and ${s.result_csv_files_used?.length??0} result CSVs (hashes in manifest)`,'Result entries across scopes':`${records.length} (scope slices can reuse a measurement)`,'Worktree source state':s.worktree_dirty_inputs?.length?'Includes current local changes; input file hashes identify the published snapshot.':'See manifest for source file hashes.','Runtime environment':b.hardware||'Hardware details are not recorded in this exported result snapshot.','Lossless evidence':b.roundtrip_evidence||'Lossless-design method filter from the source project; per-run round-trip verification is not recorded here.'};
$('provenance').innerHTML=`<table class="provenance-table"><tbody>${Object.entries(info).map(([k,v])=>`<tr><th>${escape(k)}</th><td class="source-code">${escape(v)}</td></tr>`).join('')}</tbody></table>${exclusions.length?`<h3>Excluded or unavailable results</h3><p class="source-code">${escape(typeof exclusions==='string'?exclusions:JSON.stringify(exclusions))}</p>`:''}`;
const groups=new Map();for(const r of records){if(!groups.has(r.family))groups.set(r.family,[]);groups.get(r.family).push(r.configuration);}
$('method-catalog').innerHTML=`<div class="method-groups">${[...groups].sort((a,b)=>a[0].localeCompare(b[0])).map(([family,names])=>`<section class="method-group"><h3>${escape(family)}</h3><ul>${[...new Set(names)].sort().map(n=>`<li>${escape(n)}</li>`).join('')}</ul></section>`).join('')}</div>`;
}
function init() {
if(snapshot.compact_records){const t=snapshot.record_tables;snapshot.dataset_results=snapshot.compact_records.map(r=>({dataset:t.datasets[r[0]],scope:t.scopes[r[1]],runtime:t.runtimes[r[2]],family:t.families[r[3]],method:t.configurations[r[4]],algorithm_variant:t.configurations[r[4]],compression_rate:r[5],compression_time_ns_per_point:r[6],decompression_time_ns_per_point:r[7],original_size_bytes:r[8],compressed_size_bytes:r[9]}));delete snapshot.compact_records;}
for(const m of snapshot.methods||[]) { const method=typeof m==='string'?{name:m}:m;methods.set(method.name??method.method,method);for(const c of method.configurations||[])methods.set(c.algorithm_variant,{...method,name:c.algorithm_variant,family:method.name}); }
for(const d of snapshot.datasets||[])datasets.set(d.key??d.dataset,d);
records=(snapshot.dataset_results||snapshot.records||[]).map(normalizeRecord).filter(r=>r.method&&r.dataset&&r.compression_rate>0);
for(const r of records)if(!methods.has(r.method))methods.set(r.method,{name:r.method,family:r.family});
const runtimes=[...new Set(records.map(r=>r.runtime))].sort((a,b)=>['cpp','java','python'].indexOf(a)-['cpp','java','python'].indexOf(b));
$('runtime').innerHTML=runtimes.map(r=>`<option value="${escape(r)}">${escape(runtimeName(r))}</option>`).join('');
$('dataset').innerHTML='<option value="all">All measured datasets</option>'+[...datasets.values()].sort((a,b)=>(a.key??a.dataset).localeCompare(b.key??b.dataset)).map(d=>`<option value="${escape(d.key??d.dataset)}">${escape(d.key??d.dataset)}</option>`).join('');
$('family').innerHTML='<option value="all">All method families</option>'+[...new Set(records.map(r=>r.family))].sort().map(f=>`<option value="${escape(f)}">${escape(f)}</option>`).join('');
$('dataset-count').textContent=countFmt(new Set(records.map(r=>r.dataset)).size);$('method-count').textContent=countFmt(new Set(records.map(r=>r.family)).size);$('runtime-count').textContent=runtimes.length;$('record-count').textContent=countFmt(snapshot.coverage?.runtime_configuration_count??new Set(records.map(r=>`${r.runtime}\0${r.method}`)).size);
const exported=snapshot.exported_at||snapshot.source?.exported_at||'not reported';$('snapshot').textContent=`Result snapshot exported · ${String(exported).replace('T',' ').slice(0,19)}${exported==='not reported'?'':' UTC'}`;$('footer-snapshot').textContent=`Source inputs documented in the manifest`;
$('column-options').innerHTML=cols.map(c=>`<label><input type="checkbox" data-column="${c.key}" ${c.visible?'checked':''}>${escape(c.label)}</label>`).join('');
for(const input of $('column-options').querySelectorAll('input'))input.addEventListener('change',()=>{cols.find(c=>c.key===input.dataset.column).visible=input.checked;renderTable();});
for(const id of ['runtime','dtype','precision','dataset','family','row-mode','complete-only'])$(id).addEventListener('change',refresh);$('search').addEventListener('input',refresh);$('chart-metric').addEventListener('change',renderChart);$('download').addEventListener('click',csv);
$('reset').addEventListener('click',()=>{$('runtime').value=runtimes.includes('cpp')?'cpp':runtimes[0];$('dtype').value='overall';$('precision').value='all';$('dataset').value='all';$('row-mode').value='family';$('search').value='';$('family').value='all';$('complete-only').checked=false;selectedMethod=null;$('method-detail').hidden=true;sort={key:'compression_rate',direction:1};setTab(activeTab);});
for(const tab of document.querySelectorAll('[data-tab]'))tab.addEventListener('click',()=>{history.replaceState(null,'',`#${tab.dataset.tab}`);setTab(tab.dataset.tab);});
const renderPaper=()=>{const rows=(snapshot.paper_table3?.rows||[]).filter(r=>r.scope===$('paper-scope').value);$('paper-table').querySelector('tbody').innerHTML=rows.map(r=>`<tr><td>${escape(r.method)}</td><td class="numeric">${fmt(r.rank,1)}</td><td class="numeric">${fmt(r.average_compression_rate,4)}</td><td class="numeric">${fmt(r.average_compression_time_ns_per_point,1)}</td><td class="numeric">${fmt(r.average_decompression_time_ns_per_point,1)}</td><td class="numeric">${fmt(r.median_balanced_score,4)}</td></tr>`).join('');};$('paper-scope').addEventListener('change',renderPaper);renderPaper();
renderAbout();setTab(location.hash.slice(1)||'overall');
document.documentElement.dataset.ready='true';
}
function setupTheme() {
let stored;try{stored=localStorage.getItem('thulab-theme');}catch{}
const query=new URLSearchParams(location.search).get('__theme');
const theme=stored||(['dark','light'].includes(query)?query:matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light');
document.documentElement.dataset.theme=theme;$('theme').textContent=theme==='dark'?'Light mode':'Dark mode';
$('theme').addEventListener('click',()=>{const next=document.documentElement.dataset.theme==='dark'?'light':'dark';document.documentElement.dataset.theme=next;$('theme').textContent=next==='dark'?'Light mode':'Dark mode';try{localStorage.setItem('thulab-theme',next);}catch{}});
}
setupTheme();
try { const response=await fetch('./ui-data.json');if(!response.ok)throw new Error(`Snapshot request returned ${response.status}`);snapshot=await response.json();init(); }
catch(e){$('load-error').hidden=false;$('load-error').textContent=`The result snapshot could not be loaded. Please reload the page or download the JSON from the repository. ${e.message}`;$('snapshot').textContent='Snapshot unavailable';$('leaderboard').querySelector('tbody').innerHTML='<tr><td class="empty-cell">Results unavailable.</td></tr>';}