ASCEND / app.mjs
mmrech's picture
v2.3.0: UN Data Commons cross-check extended to every indicator (spending, OOP share, life expectancy, population, derived SAO; four documented gaps), report in docs/
6055ba8 verified
Raw History Blame Contribute Delete
44.8 kB
import {createGlobe} from './globe.mjs';
import {DEFAULTS,STRATEGIES,evaluate,landscape,recommendation,normalizeState} from './model.mjs';
import {RAMP,rampColor,scaleFraction} from './globe-math.mjs';
import {observationUrl,parseObservations,compareSeries,UNDC_REST} from './undc.mjs';
const $=id=>document.getElementById(id);
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmt=(n,d=0)=>Number.isFinite(n)?new Intl.NumberFormat('en-US',{maximumFractionDigits:d}).format(n):'Not available';
const compact=n=>Number.isFinite(n)?new Intl.NumberFormat('en-US',{notation:'compact',maximumFractionDigits:1}).format(n):'Not available';
const money=n=>`R$${n>=1e6?fmt(n/1e6,2)+'m':fmt(n)}`;
const shortNames={current:'Current service',theatre:'Surgical capacity',referral:'Referral programme',combined:'Combined package'};
const colors=Object.fromEntries(STRATEGIES.map(s=>[s.id,s.color]));
const S=(tag,attrs={},text='')=>{const e=document.createElementNS('http://www.w3.org/2000/svg',tag);for(const[k,v]of Object.entries(attrs))e.setAttribute(k,v);if(text)e.textContent=text;return e;};
const draw=(svg,tag,attrs,text)=>{const e=S(tag,attrs,text);svg.append(e);return e;};
const REGION_COLORS={'East Asia & Pacific':'#4284b8','Europe & Central Asia':'#8876a7','Latin America & Caribbean':'#147d70','Middle East, North Africa, Afghanistan & Pakistan':'#bd7842','North America':'#2b4a50','South Asia':'#c9a227','Sub-Saharan Africa':'#a0443c'};
const regionColor=r=>REGION_COLORS[r]||'#5c706f';
const DEFAULT_VIEW={country:'BRA',year:'latest',metric:'sao',globeMetric:'sao',region:'all'};
let globe=null,overlayOn=false,overlaySurface='motorized',globeMetric=DEFAULT_VIEW.globeMetric,globeAutoRotate=!matchMedia('(prefers-reduced-motion: reduce)').matches;
let live={},liveBusy=false;
let data,params={...DEFAULTS},activeTab='world',country=DEFAULT_VIEW.country,year=DEFAULT_VIEW.year,metric=DEFAULT_VIEW.metric,region=DEFAULT_VIEW.region,tableSort={key:'name',dir:1},evidenceFilter='all',livePapers=null,liveSearchMeta=null,toastTimer,searchController;
function toast(message){$('toast').textContent=message;$('toast').classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('toast').classList.remove('show'),3500);}
function urlState(){const p=new URLSearchParams({view:activeTab,country,year:String(year),metric,globeMetric,region,category:evidenceFilter,q:$('paperSearch').value.trim()});if(overlayOn)p.set('overlay',overlaySurface);for(const k of ['budget','capacity','referralGain','arrivals','unitCost'])p.set(k,String(params[k]));return '#'+p;}
function saveState(push=false){try{history[push?'pushState':'replaceState'](null,'',urlState());}catch{}}
function readState(){const p=new URLSearchParams(location.hash.slice(1));activeTab=['world','evidence','landscape'].includes(p.get('view'))?p.get('view'):'world';country=data?.countries.some(c=>c.id===p.get('country'))?p.get('country'):DEFAULT_VIEW.country;const y=p.get('year');year=y==='latest'||y===null?'latest':(Number.isInteger(Number(y))&&Number(y)>=data.yearRange[0]&&Number(y)<=data.yearRange[1]?Number(y):'latest');metric=metricKeys().includes(p.get('metric'))?p.get('metric'):DEFAULT_VIEW.metric;globeMetric=metricKeys().includes(p.get('globeMetric'))?p.get('globeMetric'):DEFAULT_VIEW.globeMetric;region=regions().includes(p.get('region'))?p.get('region'):'all';overlayOn=['motorized','walking'].includes(p.get('overlay'));if(overlayOn)overlaySurface=p.get('overlay');evidenceFilter=['capacity','access','financing'].includes(p.get('category'))?p.get('category'):'all';$('paperSearch').value=p.get('q')||'';document.querySelectorAll('[data-evidence]').forEach(b=>b.classList.toggle('active',b.dataset.evidence===evidenceFilter));let input={};for(const k of ['budget','capacity','referralGain','arrivals','unitCost'])if(p.has(k)&&Number.isFinite(Number(p.get(k))))input[k]=Number(p.get(k));params=normalizeState(input);}
function setTab(tab,push=true){activeTab=tab;document.body.dataset.view=tab;globe?.setActive(tab==='world');document.querySelectorAll('.view').forEach(v=>{v.hidden=v.id!==tab;v.classList.toggle('active',v.id===tab);});document.querySelectorAll('[data-tab]').forEach(b=>{b.classList.toggle('active',b.dataset.tab===tab);if(b.dataset.tab===tab)b.setAttribute('aria-current','page');else b.removeAttribute('aria-current');});$('viewStatus').innerHTML=tab==='landscape'?'<span class="badge amber">ILLUSTRATIVE SCENARIO</span><span>12-month planning model · not a policy forecast</span>':tab==='world'?`<span class="badge green">PUBLIC OBSERVATIONS</span><span>${data.countries.length} economies · ${year==='latest'?'latest available observation per economy':'fixed year '+year} · missing values preserved</span>${Object.keys(live).length?`<span class="badge live">LIVE · UN DATA COMMONS</span><span>${Object.keys(live).map(k=>def(k).short).join(', ')} retrieved ${new Date(Math.max(...Object.values(live).map(l=>l.retrievedAt))).toISOString().slice(11,16)} UTC</span><button class="text-btn" id="liveOff">Back to snapshot</button>`:''}`:'<span class="badge green">RETRIEVED SOURCES</span><span>Discovery leads · not approved model parameters</span>';$('saveChart').hidden=tab!=='landscape';if(tab==='world')renderWorld();if(tab==='evidence')renderEvidence();if(tab==='landscape')renderModel();const off=$('liveOff');if(off)off.onclick=()=>{live={};setTab(activeTab,false);toast('Snapshot values restored.');};saveState(push);}
function bindControls(){for(const k of ['budget','capacity','referralGain','arrivals','unitCost'])$(k).value=params[k];$('budgetValue').textContent=money(params.budget);$('capacityValue').textContent=fmt(params.capacity);$('referralValue').textContent='+'+fmt(params.referralGain*100)+' pp';}
function renderModel(){bindControls();const results=evaluate(params),winner=recommendation(results);renderTerrain();renderFlow(winner);renderComparison(results,winner);const ties=winner.tiedIds.filter(id=>id!==winner.id);$('decisionCallout').innerHTML=`<div><div class="section-label">AT THESE ASSUMPTIONS</div><h3>${esc(shortNames[winner.id])}${ties.length?' · tied':''}</h3><p>${ties.length?'Same outcome and spending as '+ties.map(id=>esc(shortNames[id])).join(', ')+'. ':''}${winner.id==='current'?'No modeled throughput gain from the additional investment.':'Highest modeled throughput; not a policy recommendation.'}</p></div><div class="decision-value"><strong>+${fmt(winner.incremental,1)}</strong><small>expected procedures / year</small></div>`;
const capBinds=winner.bottleneck.includes('capacity'),refBinds=winner.bottleneck.includes('Referral');$('nextQuestion').textContent=capBinds?'How much staffed capacity is actually usable?':refBinds?'How much can referral throughput really improve?':'Which resource would constrain the next expansion?';$('nextReason').textContent=capBinds?'Measure staffed theatre time and cancellations. A registered facility or bed count cannot answer this question.':refBinds?'Measure referral completion and losses along the pathway. A promising study from another setting is not a local effect estimate.':'Audit operating funds, demand and implementation delays before treating this scenario as a real investment case.';}
function renderTerrain(){const svg=$('terrain');if(!svg.clientWidth)return;const W=Math.max(300,svg.clientWidth),H=svg.clientHeight||430,m={l:48,r:22,t:24,b:58},pw=W-m.l-m.r,ph=H-m.t-m.b,cols=43,rows=31;svg.replaceChildren();svg.setAttribute('viewBox',`0 0 ${W} ${H}`);draw(svg,'title',{id:'terrainTitle'},'Decision landscape: strategy with greatest modeled additional completions');draw(svg,'desc',{id:'terrainDesc'},'Horizontal axis usable monthly surgical capacity, 40 to 180 slots. Vertical axis assumed referral gain, 0 to 40 percentage points. Use the labeled sliders to select an exact point. Colors indicate strategies, not probabilities.');const cells=landscape(params,cols,rows),cw=pw/cols,ch=ph/rows;
for(const c of cells)draw(svg,'rect',{x:m.l+c.col*cw,y:m.t+(rows-1-c.row)*ch,width:cw+.2,height:ch+.2,fill:c.color,opacity:c.winnerId==='current'?'.3':'.84'});
for(let i=1;i<cols;i++)for(let j=0;j<rows;j++){const c=cells[j*cols+i],prev=cells[j*cols+i-1];if(c.winnerId!==prev.winnerId)draw(svg,'line',{x1:m.l+i*cw,y1:m.t+(rows-1-j)*ch,x2:m.l+i*cw,y2:m.t+(rows-j)*ch,stroke:'#102b31','stroke-width':1,opacity:'.65'});}
for(let j=1;j<rows;j++)for(let i=0;i<cols;i++){const c=cells[j*cols+i],prev=cells[(j-1)*cols+i];if(c.winnerId!==prev.winnerId)draw(svg,'line',{x1:m.l+i*cw,y1:m.t+(rows-j)*ch,x2:m.l+(i+1)*cw,y2:m.t+(rows-j)*ch,stroke:'#102b31','stroke-width':1,opacity:'.65'});}
const x=v=>m.l+(v-40)/140*pw,y=v=>m.t+ph-v/.4*ph;
for(const t of [40,80,120,160,180]){draw(svg,'line',{x1:x(t),x2:x(t),y1:m.t+ph,y2:m.t+ph+5,stroke:'#9fb7af'});draw(svg,'text',{x:x(t),y:m.t+ph+20,fill:'#c6d6cf','font-size':10,'text-anchor':'middle'},String(t));}
for(const t of [0,.1,.2,.3,.4]){draw(svg,'text',{x:m.l-10,y:y(t)+4,fill:'#c6d6cf','font-size':10,'text-anchor':'end'},'+'+Math.round(t*100));}
draw(svg,'text',{x:m.l+pw/2,y:H-11,fill:'#d2e5da','font-size':11,'text-anchor':'middle'},'Usable surgical capacity · slots / month');draw(svg,'text',{transform:`translate(15 ${m.t+ph/2}) rotate(-90)`,fill:'#d2e5da','font-size':10,'text-anchor':'middle'},'Referral improvement · percentage points');
// Labels describe regions, never data-derived causal mechanisms.
for(const s of STRATEGIES){const subset=cells.filter(c=>c.winnerId===s.id);if(subset.length<45)continue;let candidates=subset.filter(c=>c.col>3&&c.col<cols-4&&c.row>3&&c.row<rows-4);if(!candidates.length)continue;const med=candidates[Math.floor(candidates.length*.58)],lx=x(med.x),ly=y(med.y);let label=W<520?({current:'No gain',theatre:'Capacity',referral:'Referral',combined:'Combined'})[s.id]:shortNames[s.id];draw(svg,'text',{x:Math.max(m.l+44,Math.min(W-m.r-55,lx)),y:ly,fill:s.id==='current'?'#c6d6cf':'#173d3b','font-size':W<520?11:12,'font-weight':600,'text-anchor':'middle','paint-order':'stroke',stroke:s.id==='current'?'#243c44':s.color,'stroke-width':3},label);}
const px=x(params.capacity),py=y(params.referralGain);draw(svg,'line',{x1:px,x2:px,y1:m.t,y2:m.t+ph,stroke:'#fff','stroke-dasharray':'4 5',opacity:'.7'});draw(svg,'line',{x1:m.l,x2:m.l+pw,y1:py,y2:py,stroke:'#fff','stroke-dasharray':'4 5',opacity:'.7'});draw(svg,'circle',{cx:px,cy:py,r:9,fill:'#153438',stroke:'#fff','stroke-width':2});draw(svg,'circle',{cx:px,cy:py,r:2.5,fill:'#fff'});
svg.onclick=e=>{const r=svg.getBoundingClientRect(),xx=e.clientX-r.left,yy=e.clientY-r.top;if(xx<m.l||xx>W-m.r||yy<m.t||yy>H-m.b)return;params.capacity=Math.round(40+(xx-m.l)/pw*140);params.referralGain=Math.round((1-(yy-m.t)/ph)*40)/100;renderModel();saveState();};}
function renderFlow(winner){const m=winner.monthly[0];const stages=[['Eligible arrivals',params.arrivals,'Demand'],['Referral throughput',m.referralThroughput,'Referral flow'],['Staffed slots',m.capacity,'Staffed capacity'],['Funded slots',m.fundedSlots,'Operating funds']];$('flow').innerHTML=`<div class="flow-stages">${stages.map(([label,v,b])=>`<div class="flow-stage ${winner.bindingConstraints.includes(b)?'binding':''}"><small>${label}</small><strong>${fmt(v,1)}</strong><div class="gate"><i style="width:${Math.min(100,v/Math.max(...stages.map(s=>s[1]))*100)}%"></i></div><span class="binding-label">${winner.bindingConstraints.includes(b)?'BINDING GATE':''}</span></div>`).join('')}</div><div class="flow-note"><b>${fmt(m.completed,1)} expected completions / month</b> under the selected ${esc(shortNames[winner.id].toLowerCase())}.<br>Unresolved at 12 months: ${fmt(winner.queue,1)} cases.</div>`;}
function renderComparison(results,winner){const max=Math.max(...results.map(r=>r.incremental),1);$('comparison').innerHTML=results.map(r=>`<div class="compare-row"><div class="compare-name">${esc(shortNames[r.id])}${winner.tiedIds.includes(r.id)?' *':''}<small>${r.feasible?money(r.cost)+' committed':esc(r.reason)}</small></div><div class="compare-track"><div class="compare-bar" style="width:${r.incremental/max*100}%;background:${r.color}"></div></div><div class="compare-number">${r.feasible?'+'+fmt(r.incremental,1):'—'}</div></div>`).join('')+`<p class="fineprint">* Highest-ranked under these assumptions. End-of-horizon unresolved cases: ${fmt(winner.queue)} versus ${fmt(results[0].queue)} under current service.</p>`;}
/* ---------- World lens: all economies ---------- */
function metricKeys(){return Object.keys(data?.indicators||{}).filter(k=>k!=='population');}
function regions(){return ['all',...new Set((data?.countries||[]).map(c=>c.region))];}
function def(key){return data.indicators[key]||{short:key,unit:'',decimals:1,scale:'linear'};}
/** Observation for an economy and indicator under the current year mode: {value, year} or null. */
function obs(c,key,y=year){const s=live[key]?.series?.[c.id]||c.series?.[key];if(!s||!s.length)return null;if(y==='latest'){const [yy,v]=s[s.length-1];return {value:v,year:yy};}const hit=s.find(p=>p[0]===Number(y));return hit?{value:hit[1],year:hit[0]}:null;}
function value(c,key,y=year){return obs(c,key,y)?.value??null;}
function fmtValue(key,v){const d=def(key);if(!Number.isFinite(v))return 'Not available';if(key==='population')return compact(v);if(d.scale==='log'&&v>=10000)return fmt(v,0);return fmt(v,d.decimals??1);}
function yearLabel(){return year==='latest'?'latest available':String(year);}
function visibleCountries(){return data.countries.filter(c=>region==='all'||c.region===region);}
function metricOptions(selected,includePopulation=false){return data.indicatorGroups.map(g=>`<optgroup label="${esc(g.label)}">${g.indicators.filter(k=>includePopulation||k!=='population').map(k=>`<option value="${k}" ${k===selected?'selected':''}>${esc(def(k).short)}</option>`).join('')}</optgroup>`).join('');}
function targetProgress(key,v){const d=def(key);if(d.target===undefined||d.target===null||!Number.isFinite(v))return '';const better=d.better;let frac;if(d.target===0)frac=Math.max(0,Math.min(1,1-v/100));else frac=Math.max(0,Math.min(1,v/d.target));const met=d.target===0?v<=0.5:(better==='higher'?v>=d.target:v<=d.target);return `<div class="target" title="${esc(d.targetLabel)}"><i style="width:${Math.round(frac*100)}%" class="${met?'met':''}"></i></div><small class="target-note">${esc(d.targetLabel)}${met?' · met':''}</small>`;}
function renderWorld(){if(!data)return;$('year').value=String(year);$('worldMetric').value=metric;$('regionFilter').value=region;syncGlobe();renderDetail();renderScatter();renderTable();}
function renderDetail(){const c=data.countries.find(c=>c.id===country)||data.countries[0];const pop=obs(c,'population','latest');
$('countryDetail').innerHTML=`<div class="section-label">SELECTED SYSTEM · ${esc(yearLabel().toUpperCase())}</div><h2>${esc(c.name)}</h2><p class="fineprint">${esc(c.region)} · ${esc(c.income)}${pop?` · ${compact(pop.value)} people (${pop.year})`:''}</p>${data.indicatorGroups.map(g=>`<div class="detail-group"><div class="section-label">${esc(g.label.toUpperCase())}</div>${g.indicators.filter(k=>k!=='population').map(k=>{const o=obs(c,k);return `<div class="indicator ${k===globeMetric?'current':''}"><span>${esc(def(k).short)}</span><strong>${o?fmtValue(k,o.value):'—'}</strong><small>${esc(def(k).unit)} · ${o?o.year:'no observation'+(year!=='latest'&&c.series[k]?.length?' in '+year+' · latest '+c.series[k][c.series[k].length-1][0]:'')}</small>${o?targetProgress(k,o.value):''}${crossLine(c,k)}</div>`;}).join('')}</div>`).join('')}${undcKeys().length?`<div class="live-box"><div class="section-label">SOURCE TRIANGULATION</div><p>Latest UN System Data Commons values are stored beside World Bank/WHO series for ${Object.values(data.indicators).filter(d=>d.undc?.variable||d.undc?.derived).length} indicators (${undcKeys().length} refreshable live). <button class="secondary" id="liveCountry" ${liveBusy?'disabled':''}>Live check · UN Data Commons ↗</button></p><p id="liveCountryStatus" class="fineprint"></p></div>`:''}<p class="fineprint">Country selection changes context only. It does not calibrate the illustrative scenario.</p>`;
const lb=$('liveCountry');if(lb)lb.onclick=()=>liveCheckCountry(c);}
function undcKeys(){return Object.keys(data.indicators).filter(k=>data.indicators[k].undc?.variable);}
function crossLine(c,k){const u=def(k).undc;if(!u)return '';if(u.status==='no-counterpart')return `<small class="cross none" title="${esc(u.note||'')}">No UN Data Commons counterpart</small>`;if(!u.variable&&!u.derived)return '';const l=live[k]?.series?.[c.id];const snap=c.series[k]||[];const cc=c.crosscheck?.[k];const tag=u.derived?'UN Data Commons (derived from WHO head-counts)':'UN Data Commons';
if(l){const r=compareSeries(snap,l);return `<small class="cross live">UN Data Commons live: ${fmtValue(k,l[l.length-1][1])} (${l[l.length-1][0]}) · ${l.length} obs · ${r.status==='agree'?'agrees with snapshot at '+r.year:r.status==='differ'?'differs from snapshot at '+r.year+' ('+fmtValue(k,r.snapshot)+')':'no common year'}</small>`;}
if(!cc)return `<small class="cross">${tag}: no series for this economy</small>`;const [[yy,vv],n]=cc;const sl=snap[snap.length-1];let verdict='';
if(sl){if(yy>sl[0])verdict=' · newer than snapshot ('+sl[0]+')';else if(yy===sl[0])verdict=Math.abs(vv-sl[1])/Math.max(Math.abs(sl[1]),1e-9)<=.05?' · agrees':' · differs ('+fmtValue(k,sl[1])+' in snapshot)';else verdict=' · snapshot newer ('+sl[0]+')';}
return `<small class="cross">${tag}: ${fmtValue(k,vv)} (${yy}) · ${n} obs${verdict}</small>`;}
async function liveCheckCountry(c){const st=$('liveCountryStatus');liveBusy=true;$('liveCountry').disabled=true;st.textContent='Querying the UN System Data Commons V2 observation endpoint…';
try{const keys=undcKeys();const results=await Promise.all(keys.map(async k=>{const u=def(k).undc;const r=await fetch(observationUrl(u.variable,{iso3:[c.id],date:''}),{signal:AbortSignal.timeout(20000)});if(!r.ok)throw Error('HTTP '+r.status);const j=await r.json();return [k,parseObservations(j,u.variable,u.factor)[c.id]||null];}));
const now=Date.now();for(const [k,ser] of results){if(!ser)continue;live[k]=live[k]||{series:{},retrievedAt:now,scope:'country'};live[k].series[c.id]=ser;live[k].retrievedAt=now;}
liveBusy=false;renderWorld();setTab(activeTab,false);$('liveCountryStatus').textContent=`Live retrieval ${new Date(now).toISOString().slice(11,19)} UTC for ${c.name}: ${results.filter(r=>r[1]).length} of ${keys.length} series returned. Values marked "live" come from the UN System Data Commons, not from the snapshot.`;toast('Live UN Data Commons values loaded for '+c.name+'.');}
catch(e){liveBusy=false;const b=$('liveCountry');if(b)b.disabled=false;st.textContent='Live retrieval failed ('+e.message+'). Snapshot values are unchanged.';}}
async function liveRecolour(){const k=globeMetric,u=def(k).undc;if(!u?.variable)return;const btn=$('liveGlobe');btn.disabled=true;btn.textContent='Retrieving…';
try{const r=await fetch(observationUrl(u.variable,{date:''}),{signal:AbortSignal.timeout(30000)});if(!r.ok)throw Error('HTTP '+r.status);const j=await r.json();const series=parseObservations(j,u.variable,u.factor);const n=Object.keys(series).length;if(!n)throw Error('no country series in response');
live[k]={series,retrievedAt:Date.now(),scope:'all'};renderWorld();setTab(activeTab,false);toast(`${def(k).short}: ${n} economies recoloured from the UN System Data Commons (live).`);}
catch(e){toast('Live retrieval failed: '+e.message+'. Snapshot values are unchanged.');}
finally{const b=$('liveGlobe');if(b){b.disabled=false;b.textContent=live[globeMetric]?.scope==='all'?'Live values shown · refresh ↗':'Recolour from UN Data Commons (live) ↗';}}}
function renderScatter(){const rows=visibleCountries().map(c=>({c,x:obs(c,'spending'),y:obs(c,metric),p:value(c,'population','latest')})),valid=rows.filter(r=>r.x&&r.x.value>0&&r.y&&Number.isFinite(r.y.value)&&(def(metric).scale!=='log'||r.y.value>0));const d=def(metric);
const W=Math.max(310,$('worldChart').clientWidth),H=W<500?380:440,m={l:56,r:24,t:26,b:60},pw=W-m.l-m.r,ph=H-m.t-m.b;
const svg=S('svg',{viewBox:`0 0 ${W} ${H}`,class:'world-svg',role:'img','aria-label':`Health expenditure per capita versus ${d.short} (${yearLabel()}); each circle is one economy; correlation is not causation.`});$('worldChart').replaceChildren(svg);
if(!valid.length){draw(svg,'text',{x:W/2,y:H/2,'text-anchor':'middle',fill:'#657a72','font-size':12},'No economies have both observations for this selection.');$('worldMissing').textContent='';return;}
const xs=valid.map(r=>r.x.value),ys=valid.map(r=>r.y.value);
const xmin=Math.log10(Math.min(...xs))-.08,xmax=Math.log10(Math.max(...xs))+.08;
let ymin,ymax;if(d.scale==='log'){ymin=Math.log10(Math.min(...ys))-.05;ymax=Math.log10(Math.max(...ys))+.05;}else{const lo=Math.min(...ys),hi=Math.max(...ys),pad=(hi-lo||1)*.06;ymin=Math.max(d.unit.startsWith('%')?0:-Infinity,lo-pad);ymax=hi+pad;if(d.target&&d.target>0)ymax=Math.max(ymax,d.target*1.05);}
const x=v=>m.l+(Math.log10(v)-xmin)/(xmax-xmin||1)*pw,y=v=>m.t+ph-((d.scale==='log'?Math.log10(v):v)-ymin)/(ymax-ymin||1)*ph;
const yticks=d.scale==='log'?logTicks(ymin,ymax):niceTicks(ymin,ymax,6);
for(const t of yticks){draw(svg,'line',{x1:m.l,x2:W-m.r,y1:y(t),y2:y(t),stroke:'#dce3d8','stroke-width':.7});draw(svg,'text',{x:m.l-8,y:y(t)+3,'text-anchor':'end',fill:'#657a72','font-size':10},fmtTick(t));}
for(const t of logTicks(xmin,xmax)){draw(svg,'line',{x1:x(t),x2:x(t),y1:m.t,y2:m.t+ph,stroke:'#eef1ea','stroke-width':.7});draw(svg,'text',{x:x(t),y:H-m.b+20,'text-anchor':'middle',fill:'#657a72','font-size':10},'$'+fmtTick(t));}
if(Number.isFinite(d.target)&&d.target>0){draw(svg,'line',{x1:m.l,x2:W-m.r,y1:y(d.target),y2:y(d.target),stroke:'#bd7842','stroke-dasharray':'5 4','stroke-width':1});draw(svg,'text',{x:W-m.r,y:y(d.target)-5,'text-anchor':'end',fill:'#9a5a28','font-size':9},'LCoGS 2030 target');}
draw(svg,'text',{x:m.l,y:13,fill:'#4b675e','font-size':10},`${d.short} · ${d.unit}${d.scale==='log'?' (log scale)':''}`);draw(svg,'text',{x:m.l+pw/2,y:H-10,'text-anchor':'middle',fill:'#4b675e','font-size':10},'Current health expenditure per person · US$ (log scale)');
const popMax=Math.max(...valid.map(r=>r.p||0),1);
const ordered=valid.sort((a,b)=>(b.p||0)-(a.p||0)).sort((a,b)=>(a.c.id===country?1:0)-(b.c.id===country?1:0));
for(const r of ordered){const cx=x(r.x.value),cy=y(r.y.value),selected=r.c.id===country,rad=selected?9:3+Math.sqrt((r.p||0)/popMax)*9;const g=S('g',{role:'button',tabindex:0,'aria-label':`${r.c.name}: ${fmt(r.x.value)} dollars per person (${r.x.year}), ${fmtValue(metric,r.y.value)} ${d.unit} (${r.y.year}). Select economy.`});svg.append(g);const col=regionColor(r.c.region);draw(g,'circle',{cx,cy,r:Math.max(rad,7),fill:'transparent'});draw(g,'circle',{cx,cy,r:rad,fill:selected?'#147d70':col,'fill-opacity':selected?1:.55,stroke:selected?'#153438':col,'stroke-width':selected?2:.8});draw(g,'title',{},`${r.c.name} · ${r.c.region}\n$${fmt(r.x.value)} per person (${r.x.year}) · ${fmtValue(metric,r.y.value)} ${d.unit} (${r.y.year})`);if(selected){const lx=cx>W-110?cx-14:cx+14,anchor=cx>W-110?'end':'start';draw(g,'text',{x:lx,y:cy+4,'text-anchor':anchor,'font-size':12,fill:'#125a4e','font-weight':700,'paint-order':'stroke',stroke:'#fff','stroke-width':3},r.c.name);}const select=()=>{country=r.c.id;renderWorld();saveState();};g.onclick=select;g.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();select();}};}
const missing=rows.length-valid.length;
$('scatterDeck').textContent=`Each circle is one economy, sized by population and coloured by World Bank region. ${year==='latest'?'Latest available observations; years differ between economies and axes.':'Observations for '+year+' only.'} The selected economy is labelled; hover or focus any circle for its values and years.`;
$('worldMissing').textContent=`${valid.length} of ${rows.length} ${region==='all'?'economies':'economies in '+region} plotted. ${missing?missing+' lack one or both observations'+(year==='latest'?'':' in '+year)+'.':'All have both observations.'} No interpolation or carry-forward.${year!=='latest'&&valid.length<rows.length*.3?' This indicator is reported irregularly; choose “Latest available” to see each economy’s most recent observation with its year.':''}`;
$('regionLegend').innerHTML=regions().filter(r=>r!=='all').map(r=>[r,regionColor(r)]).map(([r,col])=>`<button class="${region===r?'active':''}" data-region="${esc(r)}"><i class="swatch" style="background:${col}"></i>${esc(r)}</button>`).join('')+`<button class="${region==='all'?'active':''}" data-region="all">All regions</button>`;$('regionLegend').querySelectorAll('button').forEach(b=>b.onclick=()=>{region=b.dataset.region;renderWorld();saveState();});}
function niceTicks(lo,hi,n){const span=hi-lo||1,raw=span/n,mag=10**Math.floor(Math.log10(raw)),step=[1,2,2.5,5,10].map(s=>s*mag).find(s=>span/s<=n)||mag*10;const out=[];for(let t=Math.ceil(lo/step)*step;t<=hi+1e-9;t+=step)out.push(+t.toFixed(10));return out;}
function logTicks(lo,hi){const out=[];for(let e=Math.floor(lo);e<=Math.ceil(hi);e++)for(const k of [1,2,5]){const v=k*10**e;if(Math.log10(v)>=lo&&Math.log10(v)<=hi)out.push(v);}return out.length>8?out.filter(v=>String(v)[0]==='1'):out;}
function fmtTick(v){return v>=1e6?compact(v):fmt(v,v<1?2:v<10?1:0);}
function renderTable(){const keys=['name','region','income',metric,'spending','physicians','life','uhc'].filter((k,i,a)=>a.indexOf(k)===i);const q=$('tableFilter').value.trim().toLowerCase();let rows=visibleCountries().filter(c=>!q||c.name.toLowerCase().includes(q)||c.id.toLowerCase().includes(q)||c.region.toLowerCase().includes(q)||c.income.toLowerCase().includes(q));
const get=(c,k)=>['name','region','income'].includes(k)?c[k]:value(c,k);
rows.sort((a,b)=>{const va=get(a,tableSort.key),vb=get(b,tableSort.key);if(va===null||va===undefined)return 1;if(vb===null||vb===undefined)return -1;return (typeof va==='string'?va.localeCompare(vb):va-vb)*tableSort.dir;});
const head=keys.map(k=>`<th><button class="sort ${tableSort.key===k?'active':''}" data-sort="${k}">${esc(['name','region','income'].includes(k)?{name:'Economy',region:'Region',income:'Income group'}[k]:def(k).short+(def(k).unit?' ('+def(k).unit+')':''))}${tableSort.key===k?(tableSort.dir>0?' ↑':' ↓'):''}</button></th>`).join('');
$('observationTable').innerHTML=`<thead><tr>${head}</tr></thead><tbody>${rows.map(c=>`<tr class="${c.id===country?'selected':''}" data-country="${c.id}">${keys.map(k=>{if(['name','region','income'].includes(k))return `<td>${esc(c[k])}</td>`;const o=obs(c,k);return `<td class="num">${o?fmtValue(k,o.value):'<span class="na">—</span>'}${o&&year==='latest'?`<small>${o.year}</small>`:''}</td>`;}).join('')}</tr>`).join('')}</tbody>`;
$('observationTable').querySelectorAll('.sort').forEach(b=>b.onclick=()=>{const k=b.dataset.sort;tableSort=tableSort.key===k?{key:k,dir:-tableSort.dir}:{key:k,dir:['name','region','income'].includes(k)?1:-1};renderTable();});
$('observationTable').querySelectorAll('tbody tr').forEach(tr=>tr.onclick=()=>{country=tr.dataset.country;renderWorld();saveState();});
$('tableNote').textContent=`${rows.length} economies shown · ${yearLabel()} · click a column to sort, a row to select. Small years next to values mark the observation year in latest-available mode.`;}
function selectSystem(id){if(!data.countries.some(c=>c.id===id))return;country=id;renderWorld();saveState();}
function syncGlobe(){
const d=def(globeMetric),c=data.countries.find(c=>c.id===country),o=c?obs(c,globeMetric):null;
$('globeMetric').value=globeMetric;$('globeCountry').value=country;
$('globeSelected').textContent=`${c?.name||country} · ${o?fmtValue(globeMetric,o.value)+' ('+o.year+')':'no observation'}`;
const spec={getValue:cc=>value(cc,globeMetric),metricLabel:d.short+' ('+d.unit+')',year:yearLabel(),scale:d.scale,format:v=>fmtValue(globeMetric,v)};
if(!globe){globe=createGlobe($('worldGlobe'),{countries:data.countries,onSelect:selectSystem,onHover:showTip,selectedCountry:country,...spec});globe.setAutoRotate(globeAutoRotate);window.ASCEND_GLOBE=globe;}
const stats=globe.setMetric(spec)||{};globe.setSelection(country);globe.setActive(activeTab==='world');syncOverlay();
const min=Number($('worldGlobe').dataset.metricMin),max=Number($('worldGlobe').dataset.metricMax),observed=Number($('worldGlobe').dataset.metricObserved||stats.observed||0);
$('globeLegend').innerHTML=`<span>${esc(d.short)} · ${esc(yearLabel())}</span><div class="globe-scale" style="background:linear-gradient(90deg,${RAMP.join(',')})"></div><div class="globe-scale-values"><span>${Number.isFinite(min)?fmtValue(globeMetric,min):'—'}</span>${d.scale==='log'&&Number.isFinite(min)&&min>0?`<span>${fmtValue(globeMetric,Math.sqrt(min*max))}</span>`:''}<span>${Number.isFinite(max)?fmtValue(globeMetric,max):'—'}</span></div><span>${esc(d.unit)} · ${d.scale==='log'?'log':'linear'} scale, 2nd–98th percentile${d.better?' · '+(d.better==='higher'?'higher is better':'lower is better'):''}</span>`;
$('globeCoverage').textContent=`${observed} of ${data.countries.length} economies observed · ${data.countries.length-observed} without observation (grey) · gold ring = selected system`;
$('globeRotate').textContent=globeAutoRotate?'Pause rotation':'Start rotation';$('globeRotate').setAttribute('aria-pressed',String(globeAutoRotate));
}
const OVERLAY_STOPS=[[0,'#fff7d6'],[10,'#f9e3a1'],[30,'#f0b25a'],[60,'#d97a35'],[120,'#a9452c'],[240,'#6b2424'],[480,'#3a1414']];
function hasAccessLayer(){return Boolean(data.indicators?.access2h);}
function syncOverlay(){const box=$('globeLayers');const mapped=Boolean(def(globeMetric).undc?.variable);box.hidden=!hasAccessLayer()&&!mapped;$('overlayToggle').parentElement.hidden=!hasAccessLayer();$('overlaySurface').hidden=!hasAccessLayer();$('overlayLegend').hidden=!hasAccessLayer();
const lg=$('liveGlobe');lg.hidden=!mapped;lg.disabled=false;lg.textContent=live[globeMetric]?.scope==='all'?'Live values shown · refresh ↗':'Recolour from UN Data Commons (live) ↗';lg.onclick=liveRecolour;
if(!hasAccessLayer()){globe?.setOverlay(null);return;}$('overlayToggle').checked=overlayOn;$('overlaySurface').value=overlaySurface;$('overlaySurface').disabled=!overlayOn;
$('overlayLegend').innerHTML=overlayOn?`<i style="background:linear-gradient(90deg,${OVERLAY_STOPS.map(s=>s[1]).join(',')})"></i><b>${OVERLAY_STOPS.map(s=>s[0]).join(' · ')}+ min</b> travel to nearest facility · MAP 2019 · log scale`:'';
if(!overlayOn){globe?.setOverlay(null);return;}
const surface=overlaySurface;overlayData(surface).then(url=>{if(!overlayOn||surface!==overlaySurface)return;return globe?.setOverlay(url);}).then(ok=>{if(overlayOn&&ok===false){toast('Travel-time surface image is unavailable here.');}});}
const overlayUrls=new Map();
async function overlayData(surface){if(overlayUrls.has(surface))return overlayUrls.get(surface);let url=null;try{const r=await fetch(`assets/traveltime-${surface}.b64.txt`);if(r.ok){const txt=(await r.text()).trim();if(txt.length>1000)url='data:image/png;base64,'+txt;}}catch{}if(!url){try{const r=await fetch(`assets/traveltime-${surface}.png`,{method:'HEAD'});if(r.ok)url=`assets/traveltime-${surface}.png`;}catch{}}overlayUrls.set(surface,url);return url;}
function showTip(c,info){const tip=$('globeTip');if(!c){tip.hidden=true;return;}const rect=$('worldGlobe').getBoundingClientRect();tip.hidden=false;tip.innerHTML=`<strong>${esc(c.name)}</strong><span>${esc(def(globeMetric).short)}: ${esc(info.display||'no observation')}</span>`;const o=obs(c,globeMetric);if(o)tip.innerHTML+=`<small>${o.year} · ${esc(def(globeMetric).unit)}</small>`;if(overlayOn&&globeMetric!=='access2h'){const k=overlaySurface==='walking'?'access2h_walk':'access2h',oa=obs(c,k,'latest');if(oa)tip.innerHTML+=`<small>${fmtValue(k,oa.value)}% within 2 h (${overlaySurface==='walking'?'walking':'motorised'}, modelled)</small>`;}tip.style.left=Math.min(rect.width-tip.offsetWidth-8,Math.max(8,info.x-rect.left+14))+'px';tip.style.top=Math.max(8,info.y-rect.top-tip.offsetHeight-12)+'px';}
function wireGlobe(){
$('globeCountry').innerHTML=data.countries.map(c=>`<option value="${esc(c.id)}">${esc(c.name)}</option>`).join('');$('globeCountry').onchange=()=>selectSystem($('globeCountry').value);
$('globeMetric').innerHTML=metricOptions(globeMetric);$('globeMetric').onchange=()=>{globeMetric=$('globeMetric').value;renderWorld();saveState();};
$('worldMetric').innerHTML=metricOptions(metric);$('regionFilter').innerHTML=regions().map(r=>`<option value="${esc(r)}">${r==='all'?'All regions':esc(r)}</option>`).join('');
$('rotateWest').onclick=()=>{globeAutoRotate=false;globe.setAutoRotate(false);globe.rotateBy(-20,0);syncGlobe();};$('rotateEast').onclick=()=>{globeAutoRotate=false;globe.setAutoRotate(false);globe.rotateBy(20,0);syncGlobe();};
$('globeZoomIn').onclick=()=>globe?.zoomBy(.86);$('globeZoomOut').onclick=()=>globe?.zoomBy(1.16);$('globeReset').onclick=()=>{globe?.reset();};
$('globeRotate').onclick=()=>{globeAutoRotate=!globeAutoRotate;globe?.setAutoRotate(globeAutoRotate);syncGlobe();};
$('worldGlobe').addEventListener('pointerdown',()=>{globeAutoRotate=false;globe?.setAutoRotate(false);$('globeRotate').textContent='Start rotation';$('globeRotate').setAttribute('aria-pressed','false');});
$('saveGlobe').onclick=()=>{const url=globe?.capture();if(!url){toast('Globe image export is unavailable on this device.');return;}const a=document.createElement('a');a.href=url;a.download=`ASCEND-globe-${globeMetric}-${year}.png`;a.click();toast('Globe image saved. Indicator and year are shown in this view.');};
$('tableFilter').oninput=()=>renderTable();
$('overlayToggle').onchange=()=>{overlayOn=$('overlayToggle').checked;syncOverlay();saveState();};$('overlaySurface').onchange=()=>{overlaySurface=$('overlaySurface').value;syncOverlay();saveState();};
}
function plain(s){return String(s??'').replace(/<[^>]*>/g,' ').replace(/\s+/g,' ').trim();}
function topic(p){const t=(p.title+' '+(p.abstract||'')).toLowerCase();return ['capacity','access','financing'].filter(k=>({capacity:/capacity|workforce|surgeon|infrastructure|hospital/,access:/access|referral|delay|equity|inequal/,financing:/financ|cost|fund|expend|insurance|economic/})[k].test(t));}
function papers(){return livePapers||data.evidence?.publications||[];}
function renderEvidence(){if(!data)return;const q=$('paperSearch').value.trim().toLowerCase();const ps=papers().filter(p=>(evidenceFilter==='all'||topic(p).includes(evidenceFilter))&&(!q||`${p.title} ${p.abstract||''} ${p.authors||''}`.toLowerCase().includes(q)));$('evidenceCards').innerHTML=ps.length?ps.map(p=>`<article class="paper-card"><div class="paper-meta">${esc(p.year||'YEAR UNAVAILABLE')} · ${esc(p.pmid?'PUBMED '+p.pmid:'EUROPE PMC')}</div><h3>${esc(p.title)}</h3><div class="authors">${esc(Array.isArray(p.authors)?p.authors.join(', '):p.authors||'Author information unavailable')}</div><p>${topic(p).length?topic(p).map(t=>({capacity:'Capacity',access:'Access & referral',financing:'Financing'})[t]).join(' · '):'Related research'} <span class="badge amber">DISCOVERY LEAD</span></p>${p.abstract?`<details><summary>Read abstract</summary><p>${esc(plain(p.abstract))}</p></details>`:'<p>Abstract unavailable in this snapshot.</p>'}<a class="source-link" href="${esc(safeURL(p.url))}" target="_blank" rel="noopener">Open source ↗</a></article>`).join(''):'<p class="empty">No matching sources in this snapshot. Try a broader term or search Europe PMC.</p>';
const studies=data.trials?.studies||[];$('trials').innerHTML=studies.length?studies.map(t=>`<article class="trial-card"><div class="section-label">${esc(t.nctId)} · ${esc((t.status||'Status unavailable').replaceAll('_',' '))}</div><h3>${esc(t.title)}</h3><p>${esc(t.studyType||'')} ${esc(Array.isArray(t.phase)?t.phase.join(', '):t.phase||'')}</p><p>${esc((t.countries||[]).slice(0,4).join(' · ')||'Sites not listed')}${t.countries?.length>4?' + more':''}</p><a href="${esc(safeURL(t.url))}" target="_blank" rel="noopener">View registration ↗</a></article>`).join(''):'<p class="empty">Trial registry snapshot unavailable. This is a data-access gap, not an absence of studies.</p>';
const registry=data.apiRegistry||[];$('sourceRegistry').innerHTML=registry.map(s=>`<article class="source-card"><div class="section-label">${esc(s.status||'RETRIEVED SNAPSHOT')}</div><h3>${esc(s.name||s.id)}</h3><p>${esc(s.description||s.purpose||s.note||'Public-source data; inspect provenance in the export.')}</p><a href="${esc(safeURL(s.documentationUrl||s.url||s.endpoint))}" target="_blank" rel="noopener">Source & documentation ↗</a></article>`).join('');$('snapshotNote').textContent=`Snapshot retrieved ${String(data.accessedAt||data.retrievedAt||data.generatedAt||'Date unavailable').slice(0,10)}. Sources can revise historical observations. Data are cached; the app is not a continuously updating monitoring service.`;}
function safeURL(u){try{const v=new URL(u);return ['https:','http:'].includes(v.protocol)?v.href:'https://europepmc.org';}catch{return'https://europepmc.org';}}
async function searchPapers(){const q=$('paperSearch').value.trim();if(!q){toast('Enter a literature search term first.');return;}searchController?.abort();searchController=new AbortController();const timer=setTimeout(()=>searchController.abort(),20000);$('liveSearch').disabled=true;$('searchStatus').textContent='Retrieving matching publications from Europe PMC…';try{const url='https://www.ebi.ac.uk/europepmc/webservices/rest/search?'+new URLSearchParams({query:q,format:'json',resultType:'core',pageSize:'12'});const r=await fetch(url,{signal:searchController.signal});if(!r.ok)throw Error('HTTP '+r.status);const j=await r.json();liveSearchMeta={query:q,url,retrievedAt:new Date().toISOString()};livePapers=(j.resultList?.result||[]).map(p=>({title:p.title,year:p.pubYear,authors:p.authorString,abstract:p.abstractText,pmid:p.pmid|| (p.source==='MED'?p.id:null),doi:p.doi,url:p.doi?'https://doi.org/'+p.doi:'https://europepmc.org/article/'+p.source+'/'+p.id}));evidenceFilter='all';document.querySelectorAll('[data-evidence]').forEach(b=>b.classList.toggle('active',b.dataset.evidence==='all'));$('searchStatus').textContent=`Retrieved ${livePapers.length} of ${fmt(j.hitCount)} matches. Search relevance is not evidence quality.`;renderEvidence();saveState();}catch(e){$('searchStatus').textContent='Live retrieval unavailable. The saved evidence snapshot is still available; no missing data were replaced.';livePapers=null;renderEvidence();}finally{clearTimeout(timer);$('liveSearch').disabled=false;}}
function download(name,content,type){const a=document.createElement('a');const u=URL.createObjectURL(new Blob([content],{type}));a.href=u;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(u),1000);}
function exportDecision(){const r=evaluate(params);download('ASCEND-decision.json',JSON.stringify({exportedAt:new Date().toISOString(),prototypeVersion:data.version,warning:'Illustrative scenario, not calibrated or a causal estimate.',view:{tab:activeTab,country,year,metric,globeMetric,globeCamera:globe?{longitude:$('worldGlobe').dataset.cameraLongitude,latitude:$('worldGlobe').dataset.cameraLatitude,distance:$('worldGlobe').dataset.cameraDistance}:null,evidenceFilter,query:$('paperSearch').value.trim()},assumptions:params,results:r,ranking:recommendation(r),dataSnapshot:data,sources:data.sources,liveSearchResults:livePapers,liveSearchMeta},null,2),'application/json');toast('Decision, assumptions, data and sources exported.');}
function exportSVG(){const svg=$('terrain').cloneNode(true);svg.setAttribute('xmlns','http://www.w3.org/2000/svg');svg.style.background='#102b31';svg.style.fontFamily='Arial, sans-serif';const W=$('terrain').clientWidth,H=$('terrain').clientHeight;svg.setAttribute('width',W);svg.setAttribute('height',H+48);svg.setAttribute('viewBox',`0 0 ${W} ${H+48}`);draw(svg,'rect',{x:0,y:H,width:W,height:48,fill:'#102b31'});draw(svg,'text',{x:15,y:H+20,fill:'#d2e5da','font-size':10},`ASCEND · ILLUSTRATIVE scenario · budget ${money(params.budget)} · not a causal estimate`);draw(svg,'text',{x:15,y:H+37,fill:'#d2e5da','font-size':9},'Legend: gray=current; green=capacity; violet=referral; amber=combined.');download('ASCEND-decision-landscape.svg',new XMLSerializer().serializeToString(svg),'image/svg+xml');toast('Labeled SVG exported.');}
function wire(){wireGlobe();document.querySelector('.skip').onclick=e=>{e.preventDefault();$('main').focus();$('main').scrollIntoView();};document.querySelectorAll('[data-tab]').forEach(b=>b.onclick=()=>setTab(b.dataset.tab));for(const k of ['budget','capacity','referralGain','arrivals','unitCost'])$(k).addEventListener('input',()=>{const n=Number($(k).value);if(!Number.isFinite(n)||$(k).value==='')return;params=normalizeState({...params,[k]:n});renderModel();saveState();});$('year').innerHTML='<option value="latest">Latest available</option>'+Array.from({length:data.yearRange[1]-data.yearRange[0]+1},(_,i)=>data.yearRange[1]-i).map(y=>`<option value="${y}">${y}</option>`).join('');$('year').onchange=()=>{year=$('year').value==='latest'?'latest':Number($('year').value);renderWorld();saveState();};$('regionFilter').onchange=()=>{region=$('regionFilter').value;renderWorld();saveState();};$('worldMetric').onchange=()=>{metric=$('worldMetric').value;renderWorld();saveState();};$('reset').onclick=()=>{params={...DEFAULTS};country=DEFAULT_VIEW.country;year=DEFAULT_VIEW.year;metric=DEFAULT_VIEW.metric;globeMetric=DEFAULT_VIEW.globeMetric;region='all';overlayOn=false;overlaySurface='motorized';live={};$('tableFilter').value='';livePapers=null;liveSearchMeta=null;$('paperSearch').value='';evidenceFilter='all';document.querySelectorAll('[data-evidence]').forEach(b=>b.classList.toggle('active',b.dataset.evidence==='all'));$('searchStatus').textContent='';setTab(activeTab,false);toast('Default view restored.');};$('export').onclick=exportDecision;$('saveChart').onclick=exportSVG;$('share').onclick=async()=>{saveState();try{await navigator.clipboard.writeText(location.href);toast('View link copied. Country, year, indicator and scenario settings are included.');}catch{download('ASCEND-view-link.txt',location.href,'text/plain');toast('View link saved.');}};$('storyPreset').onclick=()=>{params={...DEFAULTS,capacity:145,referralGain:.25};renderModel();saveState();toast('Referral bottleneck selected. Try increasing surgical capacity: it adds no benefit here.');};$('guided').onclick=e=>{e.preventDefault();setTab('landscape');$('storyPreset').click();$('landscape').scrollIntoView({behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'auto':'smooth'});};$('toEvidence').onclick=()=>{setTab('evidence');$('evidence').scrollIntoView({behavior:'smooth'});};$('methods').onclick=$('methodsFooter').onclick=()=>$('methodDialog').showModal();$('closeMethods').onclick=()=>$('methodDialog').close();$('methodDialog').addEventListener('click',e=>{if(e.target===$('methodDialog')){const r=$('methodDialog').getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)$('methodDialog').close();}});$('paperSearch').oninput=()=>{renderEvidence();saveState();};$('paperSearch').onkeydown=e=>{if(e.key==='Enter')searchPapers();};$('liveSearch').onclick=searchPapers;document.querySelectorAll('[data-evidence]').forEach(b=>b.onclick=()=>{evidenceFilter=b.dataset.evidence;document.querySelectorAll('[data-evidence]').forEach(x=>x.classList.toggle('active',x===b));renderEvidence();saveState();});window.addEventListener('popstate',()=>{readState();setTab(activeTab,false);});window.addEventListener('hashchange',()=>{readState();setTab(activeTab,false);});const ro=new ResizeObserver(()=>{if(activeTab==='landscape')renderTerrain();else if(activeTab==='world')renderWorld();});ro.observe($('terrainWrap'));ro.observe($('worldChart'));}
async function init(){try{data=window.ASCEND_DATA||await fetch('./data.json').then(r=>{if(!r.ok)throw Error('Data snapshot could not be loaded');return r.json();});readState();$('terrainLegend').innerHTML=STRATEGIES.map(s=>`<span><i class="swatch" style="background:${s.color}"></i>${esc(shortNames[s.id])}</span>`).join('');wire();setTab(activeTab,false);$('footerNote').textContent+=` Snapshot ${String(data.accessedAt).slice(0,10)} · v${data.version}.`;window.ASCEND_READY=true;}catch(e){document.querySelector('main').innerHTML='<div class="fatal"><h1>Data snapshot unavailable</h1><p>The app could not load its source data. Use the supplied standalone HTML or serve the project folder. No synthetic data have been substituted.</p><p>'+esc(e.message)+'</p></div>';console.error(e);}}
init();