/* 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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=>``).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])=>``).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 `
`;
return svg;
}
function makePath(points,color){return ``;}
function lineFromPoints(points,key,name,color){return {name,color,points:points.map(p=>({year:p.year,v:p[key]}))};}
function barList(entries,scale){
return `
${entries.map(([name,value],i)=>`
${esc(name)}
${esc(fmt(value,2))}
`).join('')}
`;
}
function valueUnit(){return E.indicators[state.opts.indicator]?.unit||'outcome units';}
function pairTable(points,columns){return `
`;}
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+=`
${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')}
`;
html+=`
${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',`
●${esc(countryName(state.opts.country))} compared with ${did.preYears+did.postYears} matched years.
● ${state.opts.controls.length} comparison countries selected.
Interpretation: Neither a DiD estimate nor a synthetic comparison alone establishes a causal effect. Identification needs a real, independently documented intervention and scrutiny of confounders.
`)}
`;
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))+
`
${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.',`
Check whether both series had comparable trends before ${state.opts.breakYear}.
Country-specific shocks can invalidate comparisons.
Missing years remain missing; no extrapolation is applied.
Use the Data audit tab to see completeness.
Comparisons use ${esc(valueUnit())} of ${esc(nameShort(state.opts.indicator))}.
`)}
`;
}
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+=`
${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')}
`;
out+=`
${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.',`
The interval resamples observed years. It is a descriptive stability check, not a cluster-robust statistical confidence interval and not proof of causal identification.
`);
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+=`
${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)+`
Weights represent pre-break statistical fit, not country similarity or causal validity. Sum = ${fmt(E.sum(d.weights.map(w=>w.weight)),4)}.
`)}
`;
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+=`
${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}]):'
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}]):'
A longer pre-period is needed for fake breaks with three-year segments.')}
`;
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+=`
${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')}
`;
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+=`
${box('Source boundaries','How to read the status at the top of the page',`
DEMO: 100% synthetic, not factual observations.
WORLD BANK LIVE: values loaded directly from the World Bank API.
IMPORTED: no external provenance verified.
Live-data results depend on connectivity and reporting delays.
`)}${box('Identification assumptions','These are not automatically established',`
Was the event actually implemented when specified?
Are comparison economies unaffected by the intervention?
Could spillovers contaminate donors?
Are pre-break trajectories sufficiently comparable?
Was there an unrelated shock around the break year?
Were definitions and measurement stable?
Is policy assignment plausibly exogenous?
` )}
`;
out+=box('Researcher notes','Editable notes are kept in browser memory and included in exported sessions.',`
Notes are not uploaded to our server; export a JSON session to save them before closing the page.
`);
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+=`
${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')}
`;
out+=box('Download your study','Source-aware exports; synthetic data remain labeled.',`
Full JSON sessions include the selected outcome data, settings and research notes. Review contents before sharing publicly.
`);
out+=`
${box('Restore a prior session','Import only files that you trust. Imported data are never automatically treated as verified official statistics.',`
A session must include a records array and a valid research design. Uploading does not communicate files to a server.
`)}${box('Method disclosure','Transparency is part of the research output',`
Outcome definitions and World Bank indicator codes
Equal-weight comparison DiD
Constrained synthetic-donor optimization
Pre-period diagnostics and placebos
Missing-data and source labelling
Explicit non-causal interpretation rule
`)}
`;
out+=box('Report preview','This Markdown file records the key assumptions and limitations.',`
${esc(createReport())}
`);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();
})();