impactlab-latam / app.js
Hugdrigo's picture
Upload 22 files
b9cdea1 verified
Raw History Blame Contribute Delete
31.1 kB
/* ImpactLab LATAM browser UI. The analytics engine is dependency-free. */
(function(){
'use strict';
const E=window.ImpactEngine,D=window.ImpactDemo;
const colors=['#189784','#638fd7','#d18b42','#9c6dc1','#bc5960','#4f8ea0'];
const $=id=>document.getElementById(id);
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmt=(x,dp=2)=>E.finite(x)?new Intl.NumberFormat('en-US',{maximumFractionDigits:dp,minimumFractionDigits:dp}).format(x):'—';
const signed=(x,dp=2)=>E.finite(x)?(x>0?'+':'')+fmt(x,dp):'—';
const countryName=c=>E.countries[c]||c;
const optsDefault={country:'CHL',controls:['ARG','PER','URY'],indicator:'SP.DYN.LE00.IN',start:2003,end:2024,breakYear:2016};
const state={rows:D.records.slice(),source:'demo',indicatorLoaded:null,opts:{...optsDefault,controls:[...optsDefault.controls]},activeTab:'overview',did:null,synth:null,notes:'',loading:false,updated:'',message:'',revision:0};
window.__impactState=state;
function nameShort(ind){return E.indicators[ind]?.name||ind;}
function optionMarkup(obj,sel){return Object.keys(obj).map(key=>`<option value="${esc(key)}" ${key===sel?'selected':''}>${esc(obj[key].name||obj[key])}</option>`).join('');}
function setDefaultForm(){
$('indicator').innerHTML=optionMarkup(E.indicators,state.opts.indicator);
$('treated').innerHTML=optionMarkup(E.countries,state.opts.country);
$('startYear').value=state.opts.start;$('endYear').value=state.opts.end;$('breakYear').value=state.opts.breakYear;
$('policyName').value='';$('policyUrl').value='';renderDonors();
}
function renderDonors(){
const treated=$('treated').value;
$('controls').innerHTML=Object.entries(E.countries).map(([code,name])=>`<label class="country-check"><input type="checkbox" value="${esc(code)}" ${code===treated?'disabled':''} ${state.opts.controls.includes(code)&&code!==treated?'checked':''}/> ${esc(name)}</label>`).join('');
}
function optionsFromForm(){
const controls=Array.from($('controls').querySelectorAll('input:checked')).map(el=>el.value);
return {country:$('treated').value,controls,indicator:$('indicator').value,start:Number($('startYear').value),end:Number($('endYear').value),breakYear:Number($('breakYear').value)};
}
function validatedPolicyUrl(){const input=$('policyUrl').value.trim();if(!input)return '';try{const u=new URL(input);return ['http:','https:'].includes(u.protocol)?u.href:'';}catch(e){return '';}}
function error(msg){$('errorPanel').textContent=msg||'';$('errorPanel').hidden=!msg;}
function statusMessage(){
const live=state.source==='worldbank',imported=state.source==='imported';const el=$('sourceBanner');el.dataset.live=String(live);el.dataset.imported=String(imported);
el.querySelector('.source-chip').textContent=live?'WORLD BANK LIVE':imported?'IMPORTED DATA':'SYNTHETIC DEMO';
$('sourceMessage').textContent=live?`Live World Bank records for ${nameShort(state.indicatorLoaded)}. Reporting years may lag.`:imported?'User-imported data. Provenance and completeness must be independently verified.':'All displayed values are fictional synthetic examples, not official country statistics.';
$('rowCount').textContent=`${new Intl.NumberFormat('en-US').format(state.rows.length)} records`;
}
function refresh(){
try{state.opts=optionsFromForm();E.checkOptions(state.opts);if(state.source==='worldbank'&&state.opts.indicator!==state.indicatorLoaded){throw Error('Select “Load World Bank data” to fetch the newly selected indicator.');}
state.did=E.estimateDid(state.rows,state.opts);state.synth=E.syntheticControl(state.rows,state.opts);error(state.did.ok?'':state.did.error);state.revision++;
}catch(ex){state.did=null;state.synth=null;error(ex.message);}
statusMessage();render();
}
function heading(num,title,subtitle,tag='RESEARCH MODULE'){return `<div class="view-heading"><div><div class="tiny-label">${num} · ${esc(tag)}</div><h2>${esc(title)}</h2><p>${esc(subtitle)}</p></div><span class="method-stamp">${state.source==='demo'?'DEMO DATA':state.source==='worldbank'?'OFFICIAL SOURCE':'IMPORTED DATA'}</span></div>`;}
function metric(label,value,detail,kind=''){return `<div class="metric ${kind}"><div class="label">${esc(label)}</div><div class="value">${esc(value)}</div><div class="detail">${esc(detail)}</div></div>`;}
function box(title,subtitle,body,extra=''){return `<article class="card ${extra}"><h3>${esc(title)}</h3>${subtitle?`<div class="caption">${esc(subtitle)}</div>`:''}${body}</article>`;}
function notice(text,warn=false){return `<div class="callout ${warn?'warning':''}">${esc(text)}</div>`;}
function noData(e='Not enough comparable observations for this method.'){
return `<div class="empty"><h3>Not enough comparable data</h3><p>${esc(e)}</p><p>Try a different outcome, more controls, a longer period, or synthetic demo.</p></div>`;
}
function chartLine(lines,yearBreak,opt={}){
const data=lines.flatMap(line=>line.points.filter(p=>E.finite(p.v)).map(p=>p.v));
const years=lines.flatMap(line=>line.points.map(p=>p.year));
if(!data.length||!years.length)return `<div class="empty">No chartable series</div>`;
let lo=Math.min(...data),hi=Math.max(...data);if(lo===hi){lo-=1;hi+=1;}
const spread=hi-lo;lo-=spread*.12;hi+=spread*.15;
const minY=Math.min(...years),maxY=Math.max(...years),width=800,height=312,L=57,R=16,T=16,B=39;
const x=year=>L+(year-minY)/(maxY-minY||1)*(width-L-R);
const y=v=>T+(hi-v)/(hi-lo)*(height-T-B);
let svg=`<svg class="graph" viewBox="0 0 ${width} ${height}" role="img" aria-label="Time-series chart" xmlns="http://www.w3.org/2000/svg">`;
for(let j=0;j<=4;j++){const value=lo+(hi-lo)*j/4;svg+=`<line x1="${L}" x2="${width-R}" y1="${y(value)}" y2="${y(value)}" stroke="#e6eef3"/><text x="${L-10}" y="${y(value)+4}" text-anchor="end">${esc(fmt(value,Math.abs(value)>1000?0:1))}</text>`;}
for(let j=0;j<=5;j++){const year=Math.round(minY+(maxY-minY)*j/5);svg+=`<text x="${x(year)}" y="${height-13}" text-anchor="middle">${year}</text>`;}
if(E.finite(yearBreak)&&yearBreak>=minY&&yearBreak<=maxY){const bx=x(yearBreak);svg+=`<line x1="${bx}" x2="${bx}" y1="${T}" y2="${height-B}" stroke="#d89c3c" stroke-dasharray="5 4" stroke-width="2"/><text x="${Math.min(bx+5,width-130)}" y="${T+13}" fill="#bc7e24">break ${yearBreak}</text>`;}
lines.forEach((line,i)=>{
const col=line.color||colors[i%colors.length];let points=[];
line.points.forEach((p,j)=>{
if(E.finite(p.v)){points.push([x(p.year),y(p.v)]);}else{if(points.length){svg+=makePath(points,col);points=[];}}
});if(points.length)svg+=makePath(points,col);
for(const p of line.points.filter(p=>E.finite(p.v))){svg+=`<circle cx="${x(p.year)}" cy="${y(p.v)}" r="2.6" fill="${col}"><title>${esc(line.name)} ${p.year}: ${esc(fmt(p.v,3))}</title></circle>`;}
});
svg+='</svg>';
svg+=`<div class="legend">${lines.map((line,i)=>`<span><i style="background:${line.color||colors[i%colors.length]}"></i>${esc(line.name)}</span>`).join('')}</div>`;
return svg;
}
function makePath(points,color){return `<polyline fill="none" stroke="${color}" stroke-width="3" stroke-linejoin="round" stroke-linecap="round" points="${points.map(p=>p.map(n=>n.toFixed(1)).join(',')).join(' ')}"/>`;}
function lineFromPoints(points,key,name,color){return {name,color,points:points.map(p=>({year:p.year,v:p[key]}))};}
function barList(entries,scale){
return `<div class="barstack">${entries.map(([name,value],i)=>`<div class="barrow"><span>${esc(name)}</span><div class="progress"><div style="width:${E.clamp((Math.abs(value)/scale)*100,0,100).toFixed(1)}%;background:${colors[i%colors.length]}"></div></div><strong>${esc(fmt(value,2))}</strong></div>`).join('')}</div>`;
}
function valueUnit(){return E.indicators[state.opts.indicator]?.unit||'outcome units';}
function pairTable(points,columns){return `<div class="table-scroll"><table class="data-table"><thead><tr>${columns.map(c=>`<th ${c.numeric?'class="numeric"':''}>${esc(c.label)}</th>`).join('')}</tr></thead><tbody>${points.map(p=>`<tr>${columns.map(c=>`<td ${c.numeric?'class="numeric"':''}>${esc(c.number?fmt(p[c.key],c.decimals??2):p[c.key])}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;}
function overview(){
const did=state.did,syn=state.synth;let html=heading('01','The research overview','Separate an observational pattern from a defensible policy claim.','DESIGN SNAPSHOT');
if(!did?.ok)return html+noData(did?.error);
const trend=E.pretrend(did);
html+=`<div class="metrics">${metric('DiD contrast',signed(did.effect),valueUnit(),'teal')}${metric('Temporal observations',String(did.preYears+did.postYears),`${did.preYears} pre + ${did.postYears} post`)}${metric('Pre-trend slope gap',signed(trend.slopeDifference,3),`${valueUnit()} per year`)}${metric('Synthetic model',syn?.ok?'Available':'Unavailable',syn?.ok?`${syn.weights.length} donor weights`:'Check data coverage',syn?.ok?'teal':'amber')}</div>`;
html+=`<div class="two-cols">${box('Outcome trajectory','Treated country vs. equal-weight mean of comparison economies',chartLine([lineFromPoints(did.points,'treated',countryName(state.opts.country),colors[0]),lineFromPoints(did.points,'controls','Comparison mean',colors[1])],state.opts.breakYear))}${box('Research interpretation','Methodology visible by design',`<div class="check-row"><span class="pass">●</span> <strong>${esc(countryName(state.opts.country))}</strong> compared with ${did.preYears+did.postYears} matched years.</div><div class="check-row"><span class="pass">●</span> ${state.opts.controls.length} comparison countries selected.</div><div class="check-row"><span class="${trend.ok?'pass':'fail'}">●</span> Pre-period slopes: ${esc(trend.ok?'calculated':'insufficient data')}.</div><div class="check-row"><span class="${syn?.ok?'pass':'fail'}">●</span> Convex synthetic comparison: ${esc(syn?.ok?'estimated':'unavailable')}.</div><hr style="border:none;border-top:1px solid #e8edf0;margin:14px 0"/><p class="hint"><b>Interpretation:</b> Neither a DiD estimate nor a synthetic comparison alone establishes a causal effect. Identification needs a real, independently documented intervention and scrutiny of confounders.</p>`)}</div>`;
html+=notice(`${state.source==='demo'?'DEMONSTRATION ONLY. ':'Observational study. '}The selected break year is a user-defined analytical assumption, not an independently validated policy event.`,true);
return html;
}
function trends(){
const d=state.did;if(!d?.ok)return heading('02','Outcome time series','Inspect levels, variation and reporting gaps.')+noData(d?.error);
return heading('02','Time-series explorer','Inspect trends before and after the proposed policy break.')+
box('Observed outcome','Only years with reported data are plotted; gaps are not silently imputed.',chartLine([lineFromPoints(d.points,'treated',countryName(state.opts.country),colors[0]),lineFromPoints(d.points,'controls','Control mean',colors[1])],state.opts.breakYear))+
`<div class="two-cols equal" style="margin-top:13px">${box('Year-by-year contrast','The control mean is the equal-weight average of all chosen donors',pairTable(d.points.map(p=>({...p,difference:E.finite(p.treated)&&E.finite(p.controls)?p.treated-p.controls:null})),[{key:'year',label:'Year'},{key:'treated',label:'Treated',numeric:true,number:true},{key:'controls',label:'Control mean',numeric:true,number:true},{key:'difference',label:'Difference',numeric:true,number:true}]))}${box('Series notes','Good comparative inference requires more than a visible chart.',`<ul class="stat-list"><li>Check whether both series had comparable trends before ${state.opts.breakYear}.</li><li>Country-specific shocks can invalidate comparisons.</li><li>Missing years remain missing; no extrapolation is applied.</li><li>Use the Data audit tab to see completeness.</li><li>Comparisons use ${esc(valueUnit())} of ${esc(nameShort(state.opts.indicator))}.</li></ul>`)}</div>`;
}
function didView(){
const d=state.did;if(!d?.ok)return heading('03','Difference-in-Differences','A transparent group-level pre/post contrast.')+noData(d?.error);
const p=E.pretrend(d),boot=E.bootstrapDid(d,400,20261008);
let out=heading('03','Difference-in-Differences','Two-group, two-period contrast, with paired-year averages and descriptive diagnostics.');
out+=`<div class="metrics">${metric('DiD contrast',signed(d.effect),valueUnit(),'teal')}${metric('Treated change',signed(d.treatedChange),`post minus pre`)}${metric('Control change',signed(d.controlChange),'post minus pre')}${metric('Year-resampling range',`${signed(boot.interval[0],1)} to ${signed(boot.interval[1],1)}`,'2.5–97.5% of 400 draws')}</div>`;
out+=`<div class="two-cols">${box('Comparison over time','Both series use exactly the same observed years.',chartLine([lineFromPoints(d.points,'treated',countryName(state.opts.country),colors[0]),lineFromPoints(d.points,'controls','Comparison mean',colors[1])],state.opts.breakYear))}${box('Pre-trend diagnostic','A slope gap is a diagnostic, not a formal parallel-trends guarantee.',`<div class="barstack">${barList([['Treated slope',p.treatedSlope||0],['Control slope',p.controlSlope||0]],Math.max(.001,Math.abs(p.treatedSlope||0),Math.abs(p.controlSlope||0)))}</div><p class="hint">Pre-treatment slope difference: <strong>${esc(signed(p.slopeDifference,3))} ${esc(valueUnit())}/year</strong>.</p><p class="hint">The interval resamples observed years. It is a descriptive stability check, not a cluster-robust statistical confidence interval and not proof of causal identification.</p>`)}</div>`;
out+=box('Estimator definition','No black-box treatment-effect claim',`<div class="report">DiD = (Treated_after − Treated_before) − (Controls_after − Controls_before)\n\n${esc(`${fmt(d.treatedPost)} − ${fmt(d.treatedPre)} − (${fmt(d.controlPost)} − ${fmt(d.controlPre)}) = ${signed(d.effect)} ${valueUnit()}`)}</div>`);
out+=notice('Parallel trends, contemporaneous confounding, interference, treatment timing and measurement changes must be assessed outside this dashboard. This is an exploratory contrast.',true);
return out;
}
function synthView(){
const d=state.synth;let out=heading('04','Synthetic control','A convex mixture of donors fitted only using pre-break observations.');if(!d?.ok)return out+noData(d?.error);
out+=`<div class="metrics">${metric('Average post-break gap',signed(d.postMeanGap),valueUnit(),'teal')}${metric('Pre-break RMSE',fmt(d.preRmse),valueUnit())}${metric('Post-break RMSE',fmt(d.postRmse),valueUnit())}${metric('RMSE ratio',fmt(d.fitRatio), 'post / pre; interpret cautiously')}</div>`;
out+=`<div class="two-cols">${box('Observed vs synthetic','Donor weights are nonnegative, sum to one, and use pre-break years only.',chartLine([lineFromPoints(d.points,'treated',countryName(state.opts.country),colors[0]),lineFromPoints(d.points,'synthetic','Synthetic control',colors[2])],state.opts.breakYear))}${box('Donor weights','Optimized using constrained least squares with mild regularization',barList(d.weights.map(w=>[countryName(w.country),w.weight]),1)+`<p class="hint">Weights represent pre-break statistical fit, not country similarity or causal validity. Sum = ${fmt(E.sum(d.weights.map(w=>w.weight)),4)}.</p>`)}</div>`;
out+=box('Gap series','Treated outcome minus weighted synthetic comparison',chartLine([lineFromPoints(d.points,'gap','Gap',colors[0])],state.opts.breakYear));
out+=notice('A low pre-break RMSE is necessary but not sufficient. The result is sensitive to the chosen donor pool and to unobserved changes.',true);return out;
}
function placebos(){
const d=state.did;if(!d?.ok)return heading('05','Placebo diagnostics','Explore alternative fake assignments and break years.')+noData(d?.error);
const units=E.unitPlacebos(state.rows,state.opts),times=E.timePlacebos(state.rows,state.opts);
let out=heading('05','Placebo diagnostics','Two diagnostics that help reveal how distinctive the observed contrast is.');
out+=`<div class="metrics">${metric('Unit assignments',String(units.length),'countries re-treated in turn')}${metric('Earlier break dates',String(times.length),'fake interventions')}${metric('Observed DiD',signed(d.effect),valueUnit(),'teal')}${metric('Placebo inference','Descriptive','no causal p-values','amber')}</div>`;
out+=`<div class="two-cols equal">${box('In-space unit placebos','Each selected country is treated in turn; others become donors',units.length?pairTable(units.map(u=>({...u,country:countryName(u.country),case:u.treated?'Observed':'Placebo'})),[{key:'country',label:'Country'},{key:'case',label:'Role'},{key:'postMeanGap',label:'Post gap',numeric:true,number:true},{key:'preRmse',label:'Pre RMSE',numeric:true,number:true}]):'<p class="hint">Choose more donor countries with complete coverage to enable this diagnostic.')}${box('Earlier-break DiD placebos','All fake breaks use observations before the actual break year',times.length?pairTable(times,[{key:'year',label:'Fake break'},{key:'effect',label:'DiD contrast',numeric:true,number:true}]):'<p class="hint">A longer pre-period is needed for fake breaks with three-year segments.')}</div>`;
out+=notice('These finite sets of placebos are sensitivity exercises, not valid randomization inference. Never interpret them as established statistical significance.',true);return out;
}
function quality(){
const o=state.opts,c=E.coverage(state.rows,o),d=state.did,s=state.synth;
const present=E.sum(c.map(x=>x.present)),expected=E.sum(c.map(x=>x.expected));
let out=heading('06','Data coverage & integrity','Missing observations remain missing; source and year lag stay visible.');
out+=`<div class="metrics">${metric('Observed coverage',`${fmt(100*present/Math.max(1,expected),1)}%`,'selected study panel','teal')}${metric('Expected observations',String(expected),'years × economies')}${metric('Available observations',String(present),'non-null values')}${metric('Methods usable',String(Number(!!d?.ok)+Number(!!s?.ok))+'/2','DiD and synthetic control')}</div>`;
out+=box('Panel completeness','Coverage uses selected outcome, countries and calendar years.',pairTable(c.map(r=>({...r,country:countryName(r.country),coverage:100*r.present/r.expected})),[{key:'country',label:'Economy'},{key:'present',label:'Present'},{key:'expected',label:'Expected'},{key:'coverage',label:'Coverage %',numeric:true,number:true,decimals:1},{key:'latest',label:'Latest year'}]));
out+=`<div class="two-cols equal" style="margin-top:13px">${box('Source boundaries','How to read the status at the top of the page',`<ul class="stat-list"><li><b>DEMO:</b> 100% synthetic, not factual observations.</li><li><b>WORLD BANK LIVE:</b> values loaded directly from the World Bank API.</li><li><b>IMPORTED:</b> no external provenance verified.</li><li>Live-data results depend on connectivity and reporting delays.</li></ul>`)}${box('Quality gates','Deterministic method checks',`<div class="check-row"><span class="${d?.ok?'pass':'fail'}">●</span> At least three matched years before and after for DiD</div><div class="check-row"><span class="${s?.ok?'pass':'fail'}">●</span> At least five shared pre and two post years for synthetic control</div><div class="check-row"><span class="${state.opts.controls.length>=2?'pass':'fail'}">●</span> At least two donor economies</div><div class="check-row"><span class="pass">●</span> No interpolation of missing observations</div>`)}</div>`;
return out;
}
function protocol(){
const o=state.opts,d=state.did,p=d?.ok?E.pretrend(d):null;
let out=heading('07','Research protocol','Record intervention provenance and assumptions before interpreting results.');
const policy=$('policyName').value.trim(),url=validatedPolicyUrl();
out+=`<div class="two-cols equal">${box('Design register','User-defined selections; no automatic policy-event verification',`<table class="data-table"><tbody><tr><td><b>Treated economy</b></td><td>${esc(countryName(o.country))}</td></tr><tr><td><b>Comparator set</b></td><td>${esc(o.controls.map(countryName).join(', '))}</td></tr><tr><td><b>Outcome</b></td><td>${esc(nameShort(o.indicator))}</td></tr><tr><td><b>Study window</b></td><td>${o.start}–${o.end}</td></tr><tr><td><b>Break year</b></td><td>${o.breakYear}</td></tr><tr><td><b>Policy description</b></td><td>${esc(policy||'Not documented')}</td></tr><tr><td><b>External evidence URL</b></td><td>${url?`<a href="${esc(url)}" target="_blank" rel="noopener noreferrer">Open source ↗</a>`:'Not provided'}</td></tr><tr><td><b>Data source</b></td><td>${esc(state.source)}</td></tr></tbody></table>`)}${box('Identification assumptions','These are not automatically established',`<ul class="stat-list"><li>Was the event actually implemented when specified?</li><li>Are comparison economies unaffected by the intervention?</li><li>Could spillovers contaminate donors?</li><li>Are pre-break trajectories sufficiently comparable?</li><li>Was there an unrelated shock around the break year?</li><li>Were definitions and measurement stable?</li><li>Is policy assignment plausibly exogenous?</li></ul>` )}</div>`;
out+=box('Researcher notes','Editable notes are kept in browser memory and included in exported sessions.',`<textarea class="policy-textarea" id="researchNotes" placeholder="Document design assumptions, confounders, follow-up sources and interpretation limits…">${esc(state.notes)}</textarea><div class="hint">Notes are not uploaded to our server; export a JSON session to save them before closing the page.</div>`);
out+=notice('Model output is never equivalent to a validated policy evaluation. Distinguish exploratory comparisons from causal identification.',true);return out;
}
function createReport(){
const o=state.opts,d=state.did,s=state.synth,trend=d?.ok?E.pretrend(d):null;
const dataLabel=state.source==='demo'?'SYNTHETIC DEMO, NOT REAL COUNTRY DATA':state.source==='worldbank'?'WORLD BANK INDICATORS API, direct browser fetch':'IMPORTED UNVERIFIED DATA';
const pre=d?.ok?d.preYears:'N/A',post=d?.ok?d.postYears:'N/A';
const url=validatedPolicyUrl();
return `# ImpactLab LATAM — Exploratory research record\n\nGenerated: ${new Date().toISOString()}\nData provenance: ${dataLabel}\nIndicator: ${nameShort(o.indicator)} (${o.indicator}, ${valueUnit()})\nTreated economy: ${countryName(o.country)} (${o.country})\nDonors: ${o.controls.map(x=>countryName(x)).join(', ')}\nYears: ${o.start}–${o.end}; hypothetical break: ${o.breakYear}\nIntervention description: ${$('policyName').value.trim()||'Not documented'}\nIntervention external source: ${url||'Not provided'}\n\n## Difference-in-Differences (descriptive)\n\nEstimate: ${d?.ok?fmt(d.effect,4):'Not estimable'} ${valueUnit()}\nMatched years: ${pre} before; ${post} after.\nPretrend slope difference: ${trend?.ok?fmt(trend.slopeDifference,4):'Unavailable'} ${valueUnit()}/year.\nYear-resampling descriptive interval: ${d?.ok?E.bootstrapDid(d,400,20261008).interval.map(x=>fmt(x,4)).join(' to '):'Unavailable'}\n\n## Synthetic control (descriptive)\n\nMean post-break gap: ${s?.ok?fmt(s.postMeanGap,4):'Not estimable'} ${valueUnit()}\nPre-break RMSE: ${s?.ok?fmt(s.preRmse,4):'Unavailable'}\nDonor weights: ${s?.ok?s.weights.map(x=>x.country+'='+fmt(x.weight,4)).join('; '):'Unavailable'}\n\n## Notes\n\n${state.notes||'No notes provided.'}\n\n## Required interpretation caveats\n\nEstimates are exploratory associations, not identified causal effects. Difference-in-Differences requires credible parallel trends, controls for contemporaneous shocks and justified policy assignment. Synthetic controls depend on donor selection and pre-break fit. Placebo exercises are descriptive, not formal p-values. Missing data are not imputed. The year-resampling interval is not a valid causal confidence interval.\n\n## Technical references\n- World Bank Indicators API v2: https://datahelpdesk.worldbank.org/knowledgebase/articles/889392\n- Study protocol and algorithms: METHODOLOGY.md (in source repository)\n- Open-source code: engine.js\n`;
}
function downloadText(name,text,type){
const blob=new Blob([text],{type});const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);
}
function exportRows(){
const o=state.opts;return state.rows.filter(r=>r.indicator===o.indicator&&[o.country,...o.controls].includes(r.country)&&r.year>=o.start&&r.year<=o.end);
}
function csvExport(){downloadText('impactlab_selected_panel.csv',E.toCSV(exportRows(),['country','year','indicator','value','source']),'text/csv;charset=utf-8');}
function sessionJson(){return JSON.stringify({version:'1.0.0',source:state.source,indicatorLoaded:state.indicatorLoaded,opts:state.opts,rows:state.rows,policyName:$('policyName').value,policyUrl:validatedPolicyUrl(),notes:state.notes,exportedAt:new Date().toISOString(),methodology:'Descriptive comparisons, no causal identification.'},null,2);}
function exportView(){
let out=heading('08','Export & reproducibility','Download data, the analysis protocol, and a full restorable research session.');
out+=`<div class="metrics">${metric('Export formats','3','CSV · Markdown · JSON')}${metric('Panel rows',String(exportRows().length),'selected outcome and economies')}${metric('Session records',String(state.rows.length),'restore all loaded observations')}${metric('License','MIT','Source code only')}</div>`;
out+=box('Download your study','Source-aware exports; synthetic data remain labeled.',`<div class="button-row"><button class="btn primary" id="exportCsv">↓ Panel CSV</button><button class="btn outlined" id="exportMd">↓ Research report .md</button><button class="btn subtle" id="exportJson">↓ Full session .json</button></div><p class="hint">Full JSON sessions include the selected outcome data, settings and research notes. Review contents before sharing publicly.</p>`);
out+=`<div class="two-cols equal" style="margin-top:13px">${box('Restore a prior session','Import only files that you trust. Imported data are never automatically treated as verified official statistics.',`<input class="file-input" id="importJson" type="file" accept=".json,application/json"/><p class="hint">A session must include a records array and a valid research design. Uploading does not communicate files to a server.</p>`)}${box('Method disclosure','Transparency is part of the research output',`<ul class="stat-list"><li>Outcome definitions and World Bank indicator codes</li><li>Equal-weight comparison DiD</li><li>Constrained synthetic-donor optimization</li><li>Pre-period diagnostics and placebos</li><li>Missing-data and source labelling</li><li>Explicit non-causal interpretation rule</li></ul>`)}</div>`;
out+=box('Report preview','This Markdown file records the key assumptions and limitations.',`<pre class="report" id="reportPreview">${esc(createReport())}</pre>`);return out;
}
const renderers={overview,trends,did:didView,synthetic:synthView,placebos,quality,protocol,export:exportView};
function render(){
document.querySelectorAll('.tab').forEach(btn=>btn.classList.toggle('active',btn.dataset.tab===state.activeTab));
$('view').innerHTML=renderers[state.activeTab]();
if(state.activeTab==='protocol')$('researchNotes').addEventListener('input',ev=>state.notes=ev.target.value);
if(state.activeTab==='export'){
$('exportCsv').onclick=csvExport;$('exportMd').onclick=()=>downloadText('impactlab_research_report.md',createReport(),'text/markdown;charset=utf-8');
$('exportJson').onclick=()=>downloadText('impactlab_reproducible_session.json',sessionJson(),'application/json;charset=utf-8');
$('importJson').onchange=importSession;
}
}
async function importSession(ev){
const f=ev.target.files?.[0];if(!f)return;
try{if(f.size>5_000_000)throw Error('JSON file exceeds 5 MB.');
const json=JSON.parse(await f.text());if(!json||!Array.isArray(json.rows)||!json.opts)throw Error('Not an ImpactLab session.');
const rows=E.validateRows(json.rows);if(!rows.length)throw Error('No usable data rows in this file.');
const opts=json.opts;E.checkOptions(opts);
if(!E.indicators[opts.indicator]||!E.countries[opts.country]||opts.controls.some(x=>!E.countries[x]))throw Error('Session contains an unsupported country or indicator.');
if(rows.length>15000)throw Error('Session exceeds 15,000 usable rows.');
state.rows=rows;state.source='imported';state.indicatorLoaded=null;state.opts={...opts,controls:[...opts.controls]};state.notes=String(json.notes||'').slice(0,5000);
setDefaultForm();$('policyName').value=String(json.policyName||'').slice(0,140);$('policyUrl').value=String(json.policyUrl||'').slice(0,200);
state.activeTab='overview';refresh();
}catch(e){error('Could not restore session: '+e.message);}
}
async function loadLive(){
if(state.loading)return;
const o=optionsFromForm();try{E.checkOptions(o);}catch(ex){error(ex.message);return;}
state.loading=true;$('liveBtn').textContent='Loading World Bank API…';$('liveBtn').disabled=true;error('');
const countries=Object.keys(E.countries).join(';'),years='1998:2025';
const endpoint=`https://api.worldbank.org/v2/country/${countries}/indicator/${encodeURIComponent(o.indicator)}?format=json&per_page=2500&date=${years}`;
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),18000);
try{
const resp=await fetch(endpoint,{signal:controller.signal,headers:{Accept:'application/json'}});
if(!resp.ok)throw Error(`World Bank API HTTP ${resp.status}`);
const body=await resp.json(),rows=E.validateRows(E.parseWorldBank(body,o.indicator));
if(rows.filter(r=>E.finite(r.value)).length<20)throw Error('Too few official observations. The API may be unavailable for this indicator.');
state.rows=rows;state.indicatorLoaded=o.indicator;state.source='worldbank';state.activeTab='overview';refresh();
}catch(ex){error('Live data could not be loaded. Previous data were preserved. '+(ex.name==='AbortError'?'Request timed out.':ex.message));}
finally{clearTimeout(timer);state.loading=false;$('liveBtn').disabled=false;$('liveBtn').textContent='↻ Load World Bank data';}
}
function resetDemo(){state.source='demo';state.rows=D.records.slice();state.indicatorLoaded=null;state.opts={...optsDefault,controls:[...optsDefault.controls]};state.activeTab='overview';state.notes='';setDefaultForm();refresh();}
$('tabs').addEventListener('click',ev=>{const b=ev.target.closest('button[data-tab]');if(!b)return;state.activeTab=b.dataset.tab;render();});
$('treated').addEventListener('change',()=>{state.opts.controls=Array.from($('controls').querySelectorAll('input:checked')).map(x=>x.value).filter(x=>x!==$('treated').value);renderDonors();});
$('controls').addEventListener('change',()=>{state.opts.controls=Array.from($('controls').querySelectorAll('input:checked')).map(x=>x.value);});
$('runBtn').addEventListener('click',refresh);
$('liveBtn').addEventListener('click',loadLive);
$('demoBtn').addEventListener('click',resetDemo);
setDefaultForm();refresh();
})();