Hugdrigo's picture
Upload 19 files
3939ca3 verified
Raw History Blame Contribute Delete
22.4 kB
import {COUNTRIES,INDICATORS,IDS,COUNTRY_IDS,finite,escapeHTML,formatValue,latest,subset,coverage,status,timeline,compareCountries,crossCountryGap,paired,pearson,change,toCSV,validateSession,normalizeRows} from './core.js';
import {makeWorldBankURL,requestWorldBank} from './worldbank.js';
import {createDemo} from './demo-data.js';
const $=id=>document.getElementById(id);
const COLOR=['#007e77','#3562af','#df8c43','#8b6cc2','#d75872','#4a9d9c','#b78d27','#3f8294'];
const STATE={rows:createDemo(),mode:'demo',url:null,loadedAt:null,loadedCountries:[],activeView:'overview'};
const saved=['ARG','BOL','BRA','CHL','COL','PRY'];
function populate(){
$('countries').innerHTML=COUNTRY_IDS.map(c=>`<option value="${c}" ${saved.includes(c)?'selected':''}>${escapeHTML(COUNTRIES[c])}</option>`).join('');
$('indicator').innerHTML=IDS.map(id=>`<option value="${id}">${escapeHTML(INDICATORS[id].short)}</option>`).join('');
$('compare-indicator').innerHTML=IDS.map(id=>`<option value="${id}" ${id==='IT.NET.USER.ZS'?'selected':''}>${escapeHTML(INDICATORS[id].short)}</option>`).join('');
$('gap-year').value='2020';
}
function chosen(){return [...$('countries').selectedOptions].map(o=>o.value);}
function filters(){const countries=chosen(),start=Number($('start').value),end=Number($('end').value),ind=$('indicator').value,other=$('compare-indicator').value;
if(!countries.length)throw new Error('Choose at least one country.');if(countries.length>8)throw new Error('Choose at most eight countries.');if(!Number.isInteger(start)||!Number.isInteger(end)||start<1990||end>2026||start>end)throw new Error('Choose a valid year range between 1990 and 2026.');
return {countries,start,end,ind,other};}
function setAlert(text,type='demo'){const box=$('alert');box.textContent=text;box.className='alert '+type;}
function withFilters(fn){try{return fn(filters());}catch(e){setAlert(e.message,'error');return null;}}
function esc(n){return escapeHTML(n);}
function num(n,d=1){return n===null||n===undefined?'N/A':Number(n).toLocaleString('en-US',{maximumFractionDigits:d});}
function smallTitle(t){return esc(t);}
function empty(msg){return `<div class="empty">${esc(msg)}</div>`;}
function updateTop(f){
const visible=STATE.rows.filter(r=>f.countries.includes(r.iso3)&&r.year>=f.start&&r.year<=f.end);
const st=status(visible,f.countries,IDS,f.start,f.end);
$('metric-countries').textContent=f.countries.length;
$('metric-obs').textContent=num(st.count,0);
$('metric-coverage').textContent=(100*st.ratio).toFixed(1)+'%';
$('metric-lag').textContent=st.meanLag===null?'N/A':st.meanLag.toFixed(1)+'y';
$('mode-pill').className='mode-pill '+STATE.mode;$('mode-pill').textContent=STATE.mode==='live'?'WORLD BANK LIVE':STATE.mode==='imported'?'IMPORTED DATA':'SYNTHETIC DEMO';
$('status-title').textContent=STATE.mode==='live'?'Official data retrieved from World Bank API':STATE.mode==='imported'?'User-imported research session':'Synthetic research preview';
$('status-sub').textContent=STATE.mode==='live'?`Retrieved ${STATE.loadedAt||'this session'} · ${st.count} valid observations`:(STATE.mode==='imported'?'User-supplied values; original provenance not verified.':'Generated values for demonstrating application functionality.');
$('footer-source').textContent=STATE.mode==='live'?'World Bank WDI source 2 · last loaded '+STATE.loadedAt:STATE.mode==='imported'?'USER-IMPORTED OBSERVATIONS':'SYNTHETIC DEMO, NOT FOR CITATION';
if(STATE.mode==='demo')setAlert('SYNTHETIC DEMO: These figures are invented for interface testing. Click “Load World Bank data” to retrieve official observations.','demo');
else if(STATE.mode==='imported')setAlert('IMPORTED SESSION: User-provided data. Source and accuracy have not been independently verified.','imported');
else {const missing=f.countries.filter(c=>!STATE.loadedCountries.includes(c));setAlert(missing.length?'WORLD BANK DATA: New selected countries were not in the last request. Click “Load World Bank data” again.':`OFFICIAL WORLD BANK DATA: ${st.count} non-missing records. Each observation retains its original year. No imputation.`,missing.length?'error':'live');}
}
function renderOverview(f){
const rank=compareCountries(STATE.rows,f.countries,f.ind,f.start,f.end);
$('overview-meta').textContent=INDICATORS[f.ind].short+' · '+f.start+'–'+f.end;
const max=rank.length?Math.max(...rank.map(x=>Math.max(0,x.point.value)),0):0;
$('rank-bars').innerHTML=rank.length?rank.map((x,i)=>`<div class="rank-row"><div class="rank-label">${esc(COUNTRIES[x.country])}</div><div class="track"><div class="fill" style="width:${max?Math.max(0,x.point.value/max*100):0}%;background:${COLOR[i%COLOR.length]}"></div></div><div class="rank-value">${esc(formatValue(x.point.value,f.ind))}<small>${x.point.year}</small></div></div>`).join(''):empty('No observations found. Try a different indicator or wider year range.');
$('rank-table').innerHTML=rank.length?`<div class="scrolltable"><table><thead><tr><th>Rank</th><th>Economy</th><th>Indicator</th><th class="num">Reported value</th><th class="num">Year</th><th class="num">Lag</th></tr></thead><tbody>${rank.map((x,i)=>`<tr><td>${i+1}</td><td>${esc(COUNTRIES[x.country])}</td><td>${esc(INDICATORS[f.ind].short)}</td><td class="num">${esc(formatValue(x.point.value,f.ind))}</td><td class="num">${x.point.year}</td><td class="num">${f.end-x.point.year}</td></tr>`).join('')}</tbody></table></div>`:empty('No comparable country observations in this interval.');
}
function bounds(nums){let min=Math.min(...nums),max=Math.max(...nums);if(!Number.isFinite(min)||!Number.isFinite(max))return [0,1];if(max===min){const delta=Math.max(1,Math.abs(max)*.05);return[min-delta,max+delta];}const p=(max-min)*.08;return[min-p,max+p];}
function renderTrends(f){
$('trend-title').textContent=INDICATORS[f.ind].label;
const series=timeline(STATE.rows,f.countries,f.ind,f.start,f.end),vals=series.flatMap(s=>s.points.map(p=>p.value));
if(!vals.length){$('line-chart').innerHTML=empty('No observations to plot for this indicator.');$('legend').innerHTML='';$('trend-notes').innerHTML='';return;}
const w=800,h=330,pad={l:74,r:30,t:25,b:45},[ymin,ymax]=bounds(vals),xx=yr=>pad.l+(yr-f.start)/Math.max(1,f.end-f.start)*(w-pad.l-pad.r),yy=v=>h-pad.b-(v-ymin)/(ymax-ymin)*(h-pad.t-pad.b);
let svg=`<svg class="plot-svg" viewBox="0 0 ${w} ${h}" role="img" aria-label="Time series for selected education indicator">`;
for(let i=0;i<6;i++){const v=ymin+i*(ymax-ymin)/5,y=yy(v);svg+=`<line class="grid" x1="${pad.l}" y1="${y}" x2="${w-pad.r}" y2="${y}"/><text class="chart-tick" x="${pad.l-10}" y="${y+4}" text-anchor="end">${esc(num(v,1))}</text>`;}
const step=Math.max(1,Math.ceil((f.end-f.start)/7));for(let y=f.start;y<=f.end;y+=step)svg+=`<text class="chart-tick" x="${xx(y)}" y="${h-17}" text-anchor="middle">${y}</text>`;
series.forEach((s,j)=>{const color=COLOR[j%COLOR.length];for(let i=1;i<s.points.length;i++){
const a=s.points[i-1],b=s.points[i];if(b.year-a.year!==1)continue;
svg+=`<line x1="${xx(a.year)}" y1="${yy(a.value)}" x2="${xx(b.year)}" y2="${yy(b.value)}" stroke="${color}" stroke-width="2.6" stroke-linecap="round"/>`;
}for(const p of s.points)svg+=`<circle cx="${xx(p.year)}" cy="${yy(p.value)}" r="3.2" fill="${color}"><title>${esc(COUNTRIES[p.iso3])} ${p.year}: ${esc(formatValue(p.value,f.ind))}</title></circle>`;});svg+='</svg>';
$('line-chart').innerHTML=svg;$('legend').innerHTML=series.map((s,i)=>`<span><i style="background:${COLOR[i%COLOR.length]}"></i>${esc(COUNTRIES[s.iso3])} (${s.points.length})</span>`).join('');
const lines=series.map(s=>{if(s.points.length<2)return `<tr><td>${esc(COUNTRIES[s.iso3])}</td><td colspan="3">Insufficient years</td></tr>`;const ch=change(s.points[0],s.points[s.points.length-1]);return `<tr><td>${esc(COUNTRIES[s.iso3])}</td><td>${s.points[0].year} → ${s.points[s.points.length-1].year}</td><td class="num">${ch?esc((ch.amount>0?'+':'')+num(ch.amount,2)): 'N/A'}</td><td class="num">${s.points.length}</td></tr>`;});
$('trend-notes').innerHTML=`<h3>Observed change, without interpolation</h3><div class="scrolltable"><table><thead><tr><th>Economy</th><th>First → last</th><th class="num">Change (indicator units)</th><th class="num">Reported years</th></tr></thead><tbody>${lines.join('')}</tbody></table></div><p>${esc(INDICATORS[f.ind].tip)} Change uses earliest and latest available observations, not necessarily the requested boundary years.</p>`;
}
function renderGaps(f){
const selected=f.countries;$('gap-a').innerHTML=selected.map(c=>`<option value="${c}">${esc(COUNTRIES[c])}</option>`).join('');$('gap-b').innerHTML=selected.map(c=>`<option value="${c}">${esc(COUNTRIES[c])}</option>`).join('');
const priorA=$('gap-a').dataset.last,priorB=$('gap-b').dataset.last;$('gap-a').value=selected.includes(priorA)?priorA:selected[0];$('gap-b').value=selected.includes(priorB)?priorB:(selected[1]||selected[0]);
const yr=Number($('gap-year').value);if(yr<f.start||yr>f.end){$('gap-year').value=String(f.end);}
renderGapResult(f);
const firsts=timeline(STATE.rows,selected,f.ind,f.start,f.end);
$('progress-table').innerHTML=`<div class="chart-title"><h3>Progress diagnostics</h3><small>First and last reported points; no missing years filled.</small></div><div class="scrolltable"><table><thead><tr><th>Economy</th><th class="num">First reported</th><th class="num">Last reported</th><th class="num">Change</th><th class="num">Available years</th></tr></thead><tbody>${firsts.map(s=>{if(!s.points.length)return `<tr><td>${esc(COUNTRIES[s.iso3])}</td><td colspan="4">Missing</td></tr>`;const a=s.points[0],b=s.points[s.points.length-1],d=change(a,b);return `<tr><td>${esc(COUNTRIES[s.iso3])}</td><td class="num">${esc(formatValue(a.value,f.ind))} (${a.year})</td><td class="num">${esc(formatValue(b.value,f.ind))} (${b.year})</td><td class="num">${d?(d.amount>0?'+':'')+esc(num(d.amount,2)):'N/A'}</td><td class="num">${s.points.length}</td></tr>`;}).join('')}</tbody></table></div>`;
}
function renderGapResult(f){const a=$('gap-a').value,b=$('gap-b').value,yr=Number($('gap-year').value);$('gap-a').dataset.last=a;$('gap-b').dataset.last=b;
if(!a||!b||a===b||!Number.isInteger(yr)||yr<f.start||yr>f.end){$('gap-result').innerHTML=empty('Choose two different economies and a year within the selected range.');return;}
const z=crossCountryGap(STATE.rows,a,b,f.ind,f.start,f.end,yr);$('gap-result').innerHTML=z?`<div class="numberline"><strong>${z.delta>=0?'+':''}${esc(num(z.delta,2))}</strong> ${esc(INDICATORS[f.ind].unit)} · ${esc(COUNTRIES[a])} minus ${esc(COUNTRIES[b])} in ${yr}</div><p class="muted">${esc(COUNTRIES[a])}: ${esc(formatValue(z.a,f.ind))} · ${esc(COUNTRIES[b])}: ${esc(formatValue(z.b,f.ind))}. This is a descriptive cross-country difference, not an estimate of within-country equity.</p>`:empty('At least one of these countries has no observation for this exact year. Choose another year; values are never filled in.');}
function renderRelations(f){const a=f.ind,b=f.other;if(a===b){$('scatter-chart').innerHTML=empty('Choose a different association variable.');$('relationship-meta').textContent='Comparing a measure with itself is not useful.';return;}
const pairs=paired(STATE.rows,a,b,f.countries,f.start,f.end);if(pairs.length<3){$('scatter-chart').innerHTML=empty('Too few matched country-year observations. Try a wider period or a series with better coverage.');$('relationship-meta').textContent=`Only ${pairs.length} valid matched pairs. At least three are required.`;return;}
const w=800,h=355,p={l:85,r:25,t:25,b:72},[xmin,xmax]=bounds(pairs.map(x=>x[a])),[ymin,ymax]=bounds(pairs.map(x=>x[b]));const xx=x=>p.l+(x-xmin)/(xmax-xmin)*(w-p.l-p.r),yy=y=>h-p.b-(y-ymin)/(ymax-ymin)*(h-p.t-p.b);
let svg=`<svg class="plot-svg" viewBox="0 0 ${w} ${h}" role="img" aria-label="Scatterplot of paired observations">`;
for(let i=0;i<5;i++){const v=ymin+i*(ymax-ymin)/4,y=yy(v);svg+=`<line class="grid" x1="${p.l}" y1="${y}" x2="${w-p.r}" y2="${y}"/><text class="chart-tick" x="${p.l-9}" y="${y+4}" text-anchor="end">${esc(num(v,1))}</text>`;}
for(let i=0;i<5;i++){const v=xmin+i*(xmax-xmin)/4,x=xx(v);svg+=`<text class="chart-tick" x="${x}" y="${h-49}" text-anchor="middle">${esc(num(v,1))}</text>`;}
svg+=`<text x="${w/2}" y="${h-10}" text-anchor="middle" font-size="11" fill="#355766">${esc(INDICATORS[a].short)} (${esc(INDICATORS[a].unit)})</text>`;
svg+=`<text x="17" y="${h/2}" transform="rotate(-90 17 ${h/2})" text-anchor="middle" font-size="11" fill="#355766">${esc(INDICATORS[b].short)} (${esc(INDICATORS[b].unit)})</text>`;
for(const o of pairs){const ci=f.countries.indexOf(o.iso3);svg+=`<circle cx="${xx(o[a])}" cy="${yy(o[b])}" r="4.5" fill="${COLOR[ci%COLOR.length]}" fill-opacity=".68"><title>${esc(COUNTRIES[o.iso3])} ${o.year}: ${esc(num(o[a],2))} / ${esc(num(o[b],2))}</title></circle>`;}svg+='</svg>';
const r=pearson(pairs,a,b);$('scatter-chart').innerHTML=svg;
$('relationship-meta').innerHTML=`<strong>Paired observations:</strong> ${pairs.length} (country-year pairs) · <strong>Pearson r:</strong> ${r===null?'N/A':num(r,3)} · <strong>Selected countries:</strong> ${f.countries.length}.<br/>This is an exploratory descriptive correlation. The same country appears in multiple years, observations are not independent, reporting may be uneven, and no statistical significance or causal claim is implied.`;
}
function renderQuality(f){const years=f.end-f.start+1;const sample=STATE.rows.filter(r=>f.countries.includes(r.iso3)&&r.year>=f.start&&r.year<=f.end),cols=IDS;
const matrix=f.countries.map(c=>{const cells=cols.map(ind=>{const points=sample.filter(r=>r.iso3===c&&r.indicator===ind);const ratio=points.length/years,shade=ratio===0?'background:#fae8e5':`background:rgba(9,151,134,${(.09+.65*ratio).toFixed(2)})`;return `<td class="matrix-square ${!ratio?'missing':''}" style="${shade}" title="${esc(COUNTRIES[c])} ${esc(INDICATORS[ind].short)}: ${points.length}/${years} years">${num(ratio*100,0)}%</td>`;}).join('');return `<tr><td>${esc(COUNTRIES[c])}</td>${cells}</tr>`;}).join('');
$('coverage-matrix').innerHTML=`<div class="scrolltable"><table class="matrix"><thead><tr><th>Economy</th>${cols.map(id=>`<th title="${esc(INDICATORS[id].label)}">${esc(INDICATORS[id].short)}</th>`).join('')}</tr></thead><tbody>${matrix}</tbody></table></div><p class="hint">Color darkness approximates the share of selected years with a reported value. Coverage is not a quality certificate.</p>`;
const st=status(sample,f.countries,IDS,f.start,f.end);$('source-audit').innerHTML=`<h3>Audit trail and caveats</h3><p><b>Data mode:</b> ${STATE.mode==='live'?'World Bank WDI API, source 2':STATE.mode==='demo'?'SYNTHETIC DEMO (not official)':'User imported, source unverified'}.<br/><b>Period:</b> ${f.start}–${f.end}. <b>Coverage:</b> ${st.count}/${st.eligible} possible country-year-indicator observations. <b>Reporting lag:</b> ${st.meanLag===null?'N/A':st.meanLag.toFixed(1)+' years'} averaged over available series.</p><p>UNESCO UIS supplies many education series reproduced in World Development Indicators. Gross enrollment and primary completion definitions should not be interpreted as graduation rates, and gross ratios can exceed 100%. Missing values stay missing. Cross-country coverage and vintage differ. GDP per capita and Internet use are contextual, not direct school outcomes.</p><p><b>API:</b> ${STATE.url?`<a href="${esc(STATE.url)}" target="_blank" rel="noopener noreferrer">Exact query link</a>`:'Not queried live in this session.'} · <a href="https://datahelpdesk.worldbank.org/knowledgebase/articles/889392" target="_blank" rel="noopener noreferrer">World Bank API documentation</a> · <a href="https://databank.worldbank.org/metadataglossary/world-development-indicators/" target="_blank" rel="noopener noreferrer">Indicator metadata glossary</a></p>`;
}
function makeBrief(f){const s=subset(STATE.rows,f.countries,f.ind,f.start,f.end),rank=compareCountries(STATE.rows,f.countries,f.ind,f.start,f.end),st=status(STATE.rows,f.countries,IDS,f.start,f.end),pairs=paired(STATE.rows,f.ind,f.other,f.countries,f.start,f.end);const r=f.ind===f.other?null:pearson(pairs,f.ind,f.other);const mode=STATE.mode==='live'?'OFFICIAL WORLD BANK WDI OBSERVATIONS':STATE.mode==='demo'?'SYNTHETIC DEMO, NOT CITABLE':'USER-IMPORTED DATA, SOURCE UNVERIFIED';
const lines=[`# Education Data Lab: Comparative Research Brief`,``,`**Data status:** ${mode}`,`**Period:** ${f.start}–${f.end}`,`**Countries:** ${f.countries.map(c=>COUNTRIES[c]).join(', ')}`,`**Primary measure:** ${INDICATORS[f.ind].label} (${INDICATORS[f.ind].unit})`,`**Association measure:** ${INDICATORS[f.other].label}`,`**Generated:** ${new Date().toISOString()}`,``,`## Observations`,`- ${s.length} non-missing observations for the primary measure; ${st.count}/${st.eligible} country-year-indicator cells observed across the 8 tracked series.`,`- Latest reported values may have different reporting years and must not be described as same-year comparisons.`];
if(rank.length){const top=rank[0];lines.push(`- Highest latest *reported* value within these selected economies: ${COUNTRIES[top.country]} (${formatValue(top.point.value,f.ind)}, ${top.point.year}). This is not a causal ranking or necessarily the latest calendar year.`);}else lines.push('- No latest reported observation is available for this measure in the selected window.');
for(const item of rank.slice(0,8))lines.push(`- ${COUNTRIES[item.country]}: ${formatValue(item.point.value,f.ind)} (${item.point.year})`);
lines.push('','## Exploratory association');if(r!==null)lines.push(`- Pearson r for ${pairs.length} matched country-year pairs: ${r.toFixed(3)}. Descriptive only; repeated country-years are not independent and **no causal or significance inference** follows.`);else lines.push('- Insufficient or unsuitable paired observations for correlation.');
lines.push('','## Methodological safeguards','- Missing years are not interpolated, carried forward or imputed.','- Gross education indicators may exceed 100%; they are not net participation rates.','- Data vintages, coverage, definitions and reporting frequency differ across countries.','- This brief is an exploratory descriptive analysis, not an impact evaluation or recommendation for resource allocation.','',`## Provenance`,`- Mode: ${mode}`,`- Query: ${STATE.url||'No live query; generated demo or imported file.'}`,'- World Bank Indicators API v2, WDI source 2: https://datahelpdesk.worldbank.org/knowledgebase/articles/889392','- Definitions: https://databank.worldbank.org/metadataglossary/world-development-indicators/','');return lines.join('\n');}
function renderBrief(f){const txt=makeBrief(f);$('brief-text').innerHTML=txt.split('\n').map(line=>{
if(line.startsWith('# '))return `<h3>${esc(line.slice(2))}</h3>`;
if(line.startsWith('## '))return `<h3>${esc(line.slice(3))}</h3>`;
if(line.startsWith('- '))return `<p>• ${esc(line.slice(2))}</p>`;
return line?`<p>${esc(line)}</p>`:'';}).join('');}
function render(){withFilters(f=>{updateTop(f);renderOverview(f);renderTrends(f);renderGaps(f);renderRelations(f);renderQuality(f);renderBrief(f);});}
function rowsForSelection(){const f=filters();return STATE.rows.filter(r=>f.countries.includes(r.iso3)&&r.year>=f.start&&r.year<=f.end);}
function download(text,name,type){const blob=new Blob([text],{type});const u=URL.createObjectURL(blob);const a=document.createElement('a');a.href=u;a.download=name;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(u),10000);}
function sendExport(type){withFilters(f=>{const suf=STATE.mode==='demo'?'SYNTHETIC_DEMO':STATE.mode==='live'?'WORLD_BANK':'USER_IMPORTED';const pre='education-data-lab_'+suf+'_'+new Date().toISOString().slice(0,10);
if(type==='csv')download(toCSV(rowsForSelection()),pre+'.csv','text/csv;charset=utf-8');
else if(type==='md')download(makeBrief(f),pre+'_research-brief.md','text/markdown;charset=utf-8');
else download(JSON.stringify({format:'edlab-session-v1',mode:STATE.mode,sourceURL:STATE.url,exportedAt:new Date().toISOString(),filters:f,rows:rowsForSelection(),disclaimer:'Demo values are synthetic; imported files are unverified; live source is World Bank WDI.'},null,2),pre+'.json','application/json');});}
function bind(){
for(const id of ['countries','indicator','start','end','compare-indicator'])$(id).addEventListener('change',render);
document.querySelectorAll('.tab').forEach(btn=>btn.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('active',t===btn));document.querySelectorAll('.view').forEach(v=>v.classList.toggle('active',v.id==='view-'+btn.dataset.view));STATE.activeView=btn.dataset.view;}));
for(const id of ['gap-a','gap-b','gap-year'])$(id).addEventListener('change',()=>withFilters(renderGapResult));
$('load-demo').addEventListener('click',()=>{STATE.rows=createDemo();STATE.mode='demo';STATE.url=null;STATE.loadedAt=null;STATE.loadedCountries=[];render();});
$('load-live').addEventListener('click',async()=>{const f=withFilters(x=>x);if(!f)return;const btn=$('load-live');btn.disabled=true;btn.textContent='Loading official data…';setAlert('Fetching official observations from the World Bank. Please wait.','live');
try{const {rows,url}=await requestWorldBank(f.countries,IDS,f.start,f.end);STATE.rows=rows;STATE.mode='live';STATE.url=url;STATE.loadedAt=new Date().toLocaleString();STATE.loadedCountries=[...f.countries];render();}
catch(e){setAlert('Live data could not be loaded: '+e.message+'. Previously shown data were not replaced. You can use the clearly labeled demo meanwhile.','error');}
finally{btn.disabled=false;btn.textContent='↗ Load World Bank data';}
});
$('download-csv').addEventListener('click',()=>sendExport('csv'));$('export-md').addEventListener('click',()=>sendExport('md'));$('export-json').addEventListener('click',()=>sendExport('json'));
$('import-json').addEventListener('click',()=>$('import-file').click());$('import-file').addEventListener('change',async(e)=>{const file=e.target.files?.[0];if(!file)return;try{if(file.size>6e6)throw new Error('Maximum file size is 6 MB');const json=JSON.parse(await file.text());const parsed=validateSession(json);STATE.rows=parsed.rows;STATE.mode='imported';STATE.url=null;STATE.loadedCountries=[];STATE.loadedAt=new Date().toLocaleString();render();}catch(error){setAlert('Import failed: '+error.message,'error');}finally{e.target.value='';}});
}
populate();bind();render();