| <!doctype html> |
| <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="PathwayLab: offline Markov cohort analytics, equity comparisons, and reproducible transition audits."><meta name="theme-color" content="#07101b"><title>PathwayLab | Cohort Transition Intelligence</title><style>:root{color-scheme:dark;--bg:#07101b;--surface:#101c2a;--surface2:#152435;--card:#112030;--line:#263a4c;--ink:#eff7fb;--muted:#a0b5c3;--mint:#3fddba;--mint2:#9df9e2;--blue:#709fff;--orange:#f7b572;--red:#fb8f9b;--shadow:0 14px 40px rgba(0,0,0,.17)}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;font:14px/1.52 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:radial-gradient(ellipse at 15% -6%,#12323a 0%,transparent 33%),var(--bg);color:var(--ink)}button,select,input{font:inherit}button{cursor:pointer}select,input{color:var(--ink)}.layout{display:grid;grid-template-columns:252px minmax(0,1fr);max-width:1690px;margin:auto;min-height:100vh}.sidebar{background:#0a1622;border-right:1px solid var(--line);padding:27px 18px 22px;position:sticky;top:0;height:100vh;display:flex;flex-direction:column;gap:20px}.logo{display:flex;gap:12px;align-items:center;font-weight:800;font-size:19px;letter-spacing:-.6px;padding:5px 8px}.logomark{width:38px;height:38px;background:linear-gradient(150deg,var(--mint),#30a7b3);color:#002526;display:grid;place-items:center;border-radius:12px;font-size:21px;box-shadow:0 0 23px #0ac4aa22}.brand-sub{font-size:10px;font-weight:700;color:var(--muted);letter-spacing:2.1px;padding-left:10px;margin-top:-9px}.nav{display:grid;gap:5px}.nav button{background:transparent;color:#acc4d2;text-align:left;border:1px solid transparent;padding:12px;border-radius:11px;display:flex;gap:10px;align-items:center;transition:background .15s}.nav button:hover{background:#1a2b38;color:white}.nav button.active{background:linear-gradient(90deg,#183c42,#162834);border-color:#287d78;color:#b7f8e8;font-weight:750}.nav .num{font-size:11px;border:1px solid #345064;border-radius:6px;padding:2px 6px;min-width:26px;text-align:center}.sidebar-foot{margin-top:auto;border:1px solid #2f4a50;border-radius:13px;background:#13252e;padding:14px;color:var(--muted);font-size:12px}.sidebar-foot b{display:block;color:var(--mint2);margin-bottom:4px}.main{min-width:0;padding:29px clamp(18px,3vw,45px) 40px}.top{display:flex;justify-content:space-between;align-items:center;gap:18px;flex-wrap:wrap;margin-bottom:28px}.eyebrow{font-size:11px;font-weight:800;letter-spacing:2.2px;text-transform:uppercase;color:var(--mint)}h1{font-size:clamp(26px,2.7vw,37px);letter-spacing:-1.3px;line-height:1.12;margin:5px 0 8px}h2{font-size:17px;letter-spacing:-.4px;margin:0 0 6px}h3{font-size:14px;margin:0 0 7px}.sub{color:var(--muted);max-width:800px;margin:0}.toolbar{display:flex;gap:10px;flex-wrap:wrap;align-items:end}.control{display:flex;flex-direction:column;gap:6px;font-size:11px;font-weight:800;letter-spacing:.35px;color:var(--muted)}select,.field{background:#152838;border:1px solid #325063;border-radius:9px;min-height:38px;padding:8px 10px;outline:none;max-width:100%;min-width:120px}select:focus,.field:focus{border-color:var(--mint)}.hero{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:18px;border:1px solid #2d514f;background:linear-gradient(110deg,#132e35,#132334 72%);border-radius:17px;padding:21px 23px;margin-bottom:19px}.hero p{color:var(--muted);margin:4px 0 0}.tag{display:inline-flex;align-items:center;gap:7px;background:#123b3c;border:1px solid #316d65;color:#a2f8e6;border-radius:30px;padding:6px 10px;font-weight:750;font-size:11px;white-space:nowrap}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:13px;margin-bottom:18px}.metric{border:1px solid var(--line);background:var(--card);border-radius:14px;padding:16px 18px;box-shadow:var(--shadow)}.metric .k{color:var(--muted);font-weight:700;font-size:11px;line-height:1.35}.metric .v{font-size:clamp(21px,2.2vw,31px);font-weight:850;letter-spacing:-1.25px;margin:9px 0 2px;white-space:nowrap}.metric .tip{font-size:11px;color:#849eac}.mint{color:var(--mint)}.orange{color:var(--orange)}.red{color:var(--red)}.blue{color:var(--blue)}.grid2{display:grid;grid-template-columns:minmax(0,1.48fr) minmax(260px,1fr);gap:16px}.grid-equal{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.panel{background:var(--surface);border:1px solid var(--line);border-radius:15px;padding:19px;min-width:0;box-shadow:var(--shadow);margin-bottom:16px}.panel-title{display:flex;justify-content:space-between;gap:14px;align-items:flex-start;margin-bottom:17px}.muted{color:var(--muted)}.tiny{font-size:11px;color:var(--muted)}.chart{width:100%;height:auto;display:block;overflow:visible}.legend{display:flex;flex-wrap:wrap;gap:14px;font-size:11px;color:var(--muted)}.dot{display:inline-block;width:9px;height:9px;border-radius:3px;margin-right:5px}.table-wrap{overflow:auto;max-width:100%}table{border-collapse:collapse;width:100%;font-size:12px;text-align:left}th{color:#9fb8c7;font-size:11px;letter-spacing:.2px;font-weight:800;background:#152636}th,td{padding:11px 10px;border-bottom:1px solid #263747;white-space:nowrap}td{color:#d1e0e9}tr:last-child td{border-bottom:0}.bar-track{background:#253748;height:8px;border-radius:12px;overflow:hidden;min-width:80px}.bar-fill{height:100%;background:var(--mint);border-radius:12px}.notice{font-size:12px;border:1px solid #4a4a37;background:#292919;padding:12px 15px;border-radius:11px;color:#eee2b4}.oknotice{border-color:#315d5b;background:#152d30;color:#acf3e3}.btn{display:inline-flex;align-items:center;gap:7px;justify-content:center;padding:10px 13px;color:#07221f;background:var(--mint);border:0;border-radius:9px;font-weight:850;font-size:12px;text-decoration:none}.btn:hover{filter:brightness(1.08)}.btn.secondary{background:#1e3341;color:#dcf0f4;border:1px solid #3a5667}.btn.danger{background:#5e2f37;color:white}.stack{display:grid;gap:11px}.hstack{display:flex;gap:9px;flex-wrap:wrap;align-items:center}.range{width:100%;accent-color:var(--mint)}.slider-head{display:flex;justify-content:space-between;gap:10px;margin:4px 0;color:var(--muted);font-size:12px}.nodeline{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #2d4050;padding:9px 0;gap:9px}.pill{font-size:10px;padding:4px 8px;border-radius:18px;background:#253b4b;color:#e6f6f9}.pill.good{background:#10453f;color:#bcfff0}.pill.bad{background:#563039;color:#ffbdc3}.viz-wrap{overflow:auto;width:100%;border:1px solid #283d4c;border-radius:11px;background:radial-gradient(#2b4750 0.75px,transparent .75px);background-size:18px 18px;min-height:240px}.viz{min-width:650px;max-width:100%;display:block}.svg-node{cursor:pointer}.svg-node:hover rect{stroke:white}.svg-node text{pointer-events:none}.data-drop{border:1px dashed #557283;background:#132533;padding:19px;text-align:center;border-radius:13px}.file-input{width:100%;max-width:370px;font-size:12px;color:var(--muted)}.file-input::file-selector-button{background:#284654;border:1px solid #578184;border-radius:7px;padding:8px;margin-right:12px;color:white;cursor:pointer}.file-list{display:flex;flex-wrap:wrap;gap:8px}.foot{color:#7796a6;margin-top:22px;font-size:11px}.pct{font-variant-numeric:tabular-nums}.stat-swatch{border-left:3px solid var(--mint);background:#142936;padding:13px;border-radius:9px}.download-grid{display:flex;flex-wrap:wrap;gap:9px}.screen-reader{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0)}#toast{position:fixed;bottom:18px;right:20px;z-index:1000;border-radius:12px;background:#193c3e;border:1px solid #4f9890;color:#ddfff5;padding:12px 16px;max-width:350px;box-shadow:0 18px 40px #0008;display:none}#toast.error{background:#4f2630;border-color:#ba707e;color:#ffe0e4} .sectiongap{margin-bottom:18px} .divider{height:1px;background:var(--line);margin:14px 0}code{color:#b0eee7;overflow-wrap:anywhere}@media(max-width:1080px){.layout{grid-template-columns:205px minmax(0,1fr)}.sidebar{padding:20px 12px}.main{padding:23px 18px}.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.grid2{grid-template-columns:1fr}.hero{grid-template-columns:1fr}}@media(max-width:680px){.layout{display:block}.sidebar{height:auto;position:static;padding:14px 14px 11px;border-right:0;border-bottom:1px solid var(--line);gap:10px}.logo{padding:0;font-size:18px}.logomark{width:31px;height:31px}.brand-sub,.sidebar-foot{display:none}.nav{display:flex;flex-wrap:nowrap;overflow-x:auto;gap:6px;padding-bottom:5px;scrollbar-width:thin}.nav button{font-size:11px;white-space:nowrap;padding:8px 10px;flex:0 0 auto}.nav .num{display:none}.main{padding:18px 12px}.top{margin-bottom:15px}.metrics{gap:8px}.metric{padding:12px}.metric .v{font-size:23px}.hero{padding:16px;gap:10px}.grid-equal{grid-template-columns:1fr}.panel{padding:14px}.viz{min-width:670px}.toolbar{width:100%}.control{min-width:100px;flex:1}.control select{min-width:0;width:100%}.hstack .btn{flex:1}h1{font-size:28px}th,td{padding:9px}.tag{white-space:normal}} |
| </style></head><body> |
| <div class="layout"><aside class="sidebar"><div class="logo"><span class="logomark">⤳</span><span>Pathway<span class="mint">Lab</span></span></div><div class="brand-sub">COHORT TRANSITION INTELLIGENCE</div><nav class="nav" id="navigation" aria-label="Application modules"></nav><div class="sidebar-foot"><b>RESEARCH STUDIO · #56</b>Local-first, transparent calculations<br>Synthetic sample data · No API keys</div></aside> |
| <main class="main"><header class="top"><div><div class="eyebrow">Interactive research workbench</div><div class="sub">Evidence-aware trajectories, group gaps and decision scenarios</div></div><div class="toolbar"><label class="control">DATASET<select id="study-select" aria-label="Choose demonstration study"></select></label><label class="control">COHORT<select id="group-select" aria-label="Choose cohort group"></select></label><label class="control">HORIZON<select id="horizon-select" aria-label="Choose projection horizon"><option value="4">4 periods</option><option value="8">8 periods</option><option value="12" selected>12 periods</option><option value="16">16 periods</option><option value="24">24 periods</option></select></label></div></header> |
| <div id="view" aria-live="polite"></div><footer class="foot">PathwayLab v1.0.0 · Open-source methodology · Illustrative models, not evidence of causality or guaranteed forecasts · All user files processed in the browser</footer></main></div> |
| <div id="toast" role="status" aria-live="polite"></div><script> |
| const Core=(()=>{ |
| 'use strict'; |
| const MAX_ROWS=1600,MAX_STATES=14,MAX_SEGMENTS=5; |
| const demos=[ |
| {id:'education',name:'University progression',description:'Synthetic student progression across access, enrollment and graduation.',initial:'applied',good:'graduated',bad:'withdrawn',states:[['applied','Applications','transient'],['enrolled','Enrolled','transient'],['active','Studying','transient'],['paused','Paused','transient'],['graduated','Graduated','absorbing'],['withdrawn','Withdrawn','absorbing']],groups:[{name:'Urban',n:1150,mat:[[15,72,0,0,0,13],[0,8,78,2,0,12],[0,0,72,10,12,6],[0,3,49,30,3,15]]},{name:'Rural',n:920,mat:[[23,57,0,0,0,20],[0,11,66,4,0,19],[0,0,63,15,11,11],[0,2,39,37,3,19]]}]}, |
| {id:'workforce',name:'Workforce onboarding',description:'Synthetic recruitment, onboarding and sustained employment.',initial:'screened',good:'retained',bad:'exited',states:[['screened','Screening','transient'],['hired','Hired','transient'],['training','Training','transient'],['productive','Active','transient'],['retained','Retained','absorbing'],['exited','Exited','absorbing']],groups:[{name:'Office',n:860,mat:[[20,68,0,0,0,12],[0,10,78,0,0,12],[0,0,38,46,5,11],[0,0,0,67,27,6]]},{name:'Remote',n:1040,mat:[[14,76,0,0,0,10],[0,5,81,0,0,14],[0,0,30,49,10,11],[0,0,0,61,32,7]]}]}, |
| {id:'services',name:'Public service pathways',description:'Synthetic requests moving through administrative service stages.',initial:'received',good:'resolved',bad:'closed',states:[['received','Received','transient'],['review','Review','transient'],['evidence','Evidence','transient'],['escalated','Escalated','transient'],['resolved','Resolved','absorbing'],['closed','Unresolved','absorbing']],groups:[{name:'Online',n:1340,mat:[[5,85,0,0,0,10],[0,24,15,7,44,10],[0,28,15,8,29,20],[0,10,3,30,37,20]]},{name:'In person',n:780,mat:[[4,88,0,0,0,8],[0,18,12,6,56,8],[0,30,12,6,40,12],[0,10,3,28,49,10]]}]} |
| ]; |
| function demo(id){const d=demos.find(x=>x.id===id)||demos[0];const states=d.states.map(([id,label,type])=>({id,label,type}));const rows=[];const cohorts={};for(const g of d.groups){cohorts[g.name]=g.n;for(let i=0;i<g.mat.length;i++)for(let j=0;j<states.length;j++)if(g.mat[i][j]>0)rows.push({segment:g.name,from:states[i].id,to:states[j].id,count:g.mat[i][j]});}return {id:d.id,name:d.name,description:d.description,initial:d.initial,good:d.good,bad:d.bad,states,rows,cohorts};} |
| const isSafeId=x=>typeof x==='string'&&/^[A-Za-z][A-Za-z0-9_-]{0,31}$/.test(x); |
| function validate(src){ |
| if(!src||typeof src!=='object'||!Array.isArray(src.states)||!Array.isArray(src.rows))throw Error('Expected an object with states and rows.'); |
| if(src.states.length<3||src.states.length>MAX_STATES)throw Error(`States must number 3 to ${MAX_STATES}.`); |
| const seen=new Set();for(const s of src.states){if(!isSafeId(s.id)||seen.has(s.id)||!['absorbing','transient'].includes(s.type)||typeof s.label!=='string'||!s.label.trim()||s.label.length>42)throw Error('Invalid, duplicate or mislabeled state.');seen.add(s.id);} |
| const state=Object.fromEntries(src.states.map(s=>[s.id,s])); const trans=src.states.filter(s=>s.type==='transient');const abs=src.states.filter(s=>s.type==='absorbing'); |
| if(trans.length<1||abs.length<2)throw Error('At least one transient and two absorbing states are required.'); |
| if(!state[src.initial]||state[src.initial].type!=='transient'||!state[src.good]||state[src.good].type!=='absorbing'||!state[src.bad]||state[src.bad].type!=='absorbing'||src.good===src.bad)throw Error('Choose a valid initial, favorable and adverse state.'); |
| if(src.rows.length<trans.length||src.rows.length>MAX_ROWS)throw Error('Invalid number of transitions.'); |
| const segments=new Set(), keys=new Set(), totals=new Map(); |
| for(const r of src.rows){if(typeof r.segment!=='string'||!r.segment.trim()||r.segment.length>35||!Object.hasOwn(state,r.from)||!Object.hasOwn(state,r.to)||state[r.from].type!=='transient'||!Number.isSafeInteger(r.count)||r.count<0||r.count>10000000)throw Error('Invalid segment, transition or count.'); |
| const k=[r.segment,r.from,r.to].join('\u0000');if(keys.has(k))throw Error('Duplicate segment/from/to transition.');keys.add(k);segments.add(r.segment);let sk=r.segment+'\u0000'+r.from;totals.set(sk,(totals.get(sk)||0)+r.count);} |
| if(segments.size<1||segments.size>MAX_SEGMENTS)throw Error('Too many or too few segments.'); |
| for(const g of segments)for(const s of trans)if(!totals.get(g+'\u0000'+s.id))throw Error(`No outgoing counts for ${s.id} in ${g}.`); |
| if(!src.cohorts||typeof src.cohorts!=='object')throw Error('Missing cohort sizes.');for(const g of segments)if(!Number.isSafeInteger(src.cohorts[g])||src.cohorts[g]<1||src.cohorts[g]>10000000)throw Error('Missing or invalid cohort size for '+g); |
| const clean={id:String(src.id||'imported').slice(0,40),name:String(src.name||'Imported pathways').slice(0,75),description:String(src.description||'Imported data').slice(0,160),initial:src.initial,good:src.good,bad:src.bad,states:src.states.map(s=>({id:s.id,label:s.label.trim(),type:s.type})),rows:src.rows.map(r=>({segment:r.segment,from:r.from,to:r.to,count:r.count})),cohorts:Object.fromEntries([...segments].map(g=>[g,src.cohorts[g]]))};return clean; |
| } |
| function groups(m){return Object.keys(m.cohorts);} |
| function matrix(m,g){const n=m.states.length,index=Object.fromEntries(m.states.map((s,i)=>[s.id,i]));const P=Array.from({length:n},()=>Array(n).fill(0));const totals=Array(n).fill(0);for(const r of m.rows)if(r.segment===g){P[index[r.from]][index[r.to]]+=r.count;totals[index[r.from]]+=r.count;}for(let i=0;i<n;i++)if(m.states[i].type==='absorbing')P[i][i]=1;else for(let j=0;j<n;j++)P[i][j]/=totals[i];return P;} |
| function adjust(P,m,from,to,pct){const a=P.map(x=>x.slice()),i=m.states.findIndex(s=>s.id===from),j=m.states.findIndex(s=>s.id===to);if(i<0||j<0||m.states[i].type!=='transient'||i===j&&a[i][i]>=1)throw Error('Invalid intervention.');let delta=Math.max(0,Math.min(.9,Number(pct)/100));const previous=a[i][j],next=Math.min(.999999,previous+delta);if(previous>=1)return a;const mult=(1-next)/(1-previous);a[i]=a[i].map((p,k)=>k===j?next:p*mult);return a;} |
| function project(m,P,horizon,initial=m.initial){const n=P.length;let v=Array(n).fill(0);v[m.states.findIndex(s=>s.id===initial)]=1;let history=[v.slice()];for(let t=0;t<horizon;t++){const z=Array(n).fill(0);for(let i=0;i<n;i++)for(let j=0;j<n;j++)z[j]+=v[i]*P[i][j];v=z;history.push(z.slice());}const index=Object.fromEntries(m.states.map((s,i)=>[s.id,i]));return {history,final:v,good:v[index[m.good]],bad:v[index[m.bad]],ongoing:v.reduce((a,x,i)=>a+(m.states[i].type==='transient'?x:0),0)};} |
| function solve(A,b){const n=b.length,a=A.map((r,i)=>[...r,b[i]]);for(let k=0;k<n;k++){let x=k;for(let i=k+1;i<n;i++)if(Math.abs(a[i][k])>Math.abs(a[x][k]))x=i;if(Math.abs(a[x][k])<1e-10)return null;[a[x],a[k]]=[a[k],a[x]];let div=a[k][k];for(let j=k;j<=n;j++)a[k][j]/=div;for(let i=0;i<n;i++)if(i!==k){const fac=a[i][k];for(let j=k;j<=n;j++)a[i][j]-=fac*a[k][j];}}return a.map(row=>row[n]);} |
| function expectedSteps(m,P){const ids=m.states.map((s,i)=>s.type==='transient'?i:-1).filter(i=>i>=0),A=ids.map(i=>ids.map((j,k)=>(i===j?1:0)-P[i][j]));const ans=solve(A,ids.map(()=>1));if(!ans)return null;const ix=ids.indexOf(m.states.findIndex(s=>s.id===m.initial));return ans[ix]>=0&&Number.isFinite(ans[ix])?ans[ix]:null;} |
| function rngFactory(seed=151){let x=seed>>>0;return ()=>{x=(1664525*x+1013904223)>>>0;return (x+.5)/4294967296;};} |
| function normal(rng){return Math.sqrt(-2*Math.log(rng()))*Math.cos(2*Math.PI*rng());} |
| function gamma(shape,rng){if(shape<1)return gamma(shape+1,rng)*Math.pow(rng(),1/shape);const d=shape-1/3,c=1/Math.sqrt(9*d);for(let j=0;j<1000;j++){const x=normal(rng),v=(1+c*x)**3;if(v>0&&(rng()<1-.0331*x**4||Math.log(rng())<.5*x*x+d*(1-v+Math.log(v))))return d*v;}return shape;} |
| function sampleMatrix(m,g,rng){const P=matrix(m,g),index=Object.fromEntries(m.states.map((s,i)=>[s.id,i]));for(const state of m.states){if(state.type==='absorbing')continue;const i=index[state.id],edges=m.rows.filter(r=>r.segment===g&&r.from===state.id&&r.count>0),draw=edges.map(r=>gamma(r.count+.5,rng)),total=draw.reduce((a,b)=>a+b,0);P[i]=Array(P.length).fill(0);edges.forEach((r,k)=>P[i][index[r.to]]=draw[k]/total);}return P;} |
| function quantile(sorted,p){if(!sorted.length)return NaN;const x=(sorted.length-1)*p,i=Math.floor(x),r=x-i;return sorted[i]*(1-r)+sorted[Math.min(i+1,sorted.length-1)]*r;} |
| function uncertainty(m,g,h,seed=1234,reps=160){const rand=rngFactory(seed);let draws=[];for(let k=0;k<reps;k++){let p=project(m,sampleMatrix(m,g,rand),h);draws.push(p.good);}draws.sort((a,b)=>a-b);return {low:quantile(draws,.1),median:quantile(draws,.5),high:quantile(draws,.9),reps,seed};} |
| function parseCSV(input){if(typeof input!=='string'||input.length>1_000_000)throw Error('CSV must be under 1 MB.');const data=[];let i=0,field='',row=[],quoted=false,closed=false;function next(){row.push(field);field='';if(row.some(v=>v!==''))data.push(row);row=[];if(data.length>MAX_ROWS+10)throw Error('CSV has too many lines.');} |
| while(i<input.length){const c=input[i++];if(quoted){if(c==='"'){if(input[i]==='"'){field+='"';i++;}else{quoted=false;closed=true;}}else field+=c;continue;}if(closed){if(c===','){row.push(field);field='';closed=false;}else if(c==='\n'||c==='\r'){next();closed=false;if(c==='\r'&&input[i]==='\n')i++;}else throw Error('Invalid character after quoted CSV field.');continue;}if(c==='"'){if(field!=='')throw Error('Invalid quote in CSV.');quoted=true;}else if(c===','){row.push(field);field='';}else if(c==='\n'||c==='\r'){next();if(c==='\r'&&input[i]==='\n')i++;}else field+=c;} |
| if(quoted)throw Error('Unclosed quoted field in CSV.');if(row.length||field)next();if(data.length<2)throw Error('CSV needs a header and records.');const width=data[0].length;if(data.some(row=>row.length!==width))throw Error('CSV has inconsistent number of columns.');return data;} |
| function csvImport(input){const data=parseCSV(input.replace(/^\uFEFF/,''));const want=['segment','from','to','count','from_type','to_type'];const head=data[0].map(x=>x.trim());if(head.join('|')!==want.join('|'))throw Error('CSV header must be segment,from,to,count,from_type,to_type.');let stateTypes=Object.create(null),segs=new Set();const rows=[];let first=null;for(const rec of data.slice(1)){let [segment,from,to,count,ft,tt]=rec.map(x=>x.trim());if(!segment||!from||!to||!/^\d+$/.test(count)||!['transient','absorbing'].includes(ft)||!['transient','absorbing'].includes(tt))throw Error('Invalid CSV transition row.');if(!isSafeId(from)||!isSafeId(to))throw Error('State identifiers must use letters, numbers, _ or -.');if(ft!=='transient')throw Error('Only transient states should appear as origin.');for(const [id,t] of [[from,ft],[to,tt]]){if(stateTypes[id]&&stateTypes[id]!==t)throw Error('Inconsistent state types.');stateTypes[id]=t;}if(!first)first=from;segs.add(segment);rows.push({segment,from,to,count:Number(count)});} |
| const states=Object.keys(stateTypes).map(id=>({id,label:id.replace(/[_-]/g,' ').replace(/\b\w/g,c=>c.toUpperCase()),type:stateTypes[id]}));const abs=states.filter(s=>s.type==='absorbing'),cohorts={};for(const segment of segs){const n=rows.filter(r=>r.segment===segment&&r.from===first).reduce((a,r)=>a+r.count,0);cohorts[segment]=n||1;}return validate({id:'csv-import',name:'Imported transition study',description:'Cohort sizes estimated from initial-state transition counts; replace with actual cohort sizes in JSON if known.',initial:first,good:abs[0]?.id,bad:abs[1]?.id,states,rows,cohorts});} |
| function csvQuote(x){let s=String(x??'');if(/^[=+@\-\t\r]/.test(s))s="'"+s;return '"'+s.replace(/"/g,'""')+'"';} |
| function exportCSV(m){return ['segment,from,to,count,from_type,to_type',...m.rows.map(r=>[r.segment,r.from,r.to,r.count,m.states.find(s=>s.id===r.from).type,m.states.find(s=>s.id===r.to).type].map(csvQuote).join(','))].join('\r\n');} |
| function format(n,d=1){return Number.isFinite(n)?n.toFixed(d):'N/A';} |
| return {demo,demos:demos.map(({id,name})=>({id,name})),validate,groups,matrix,adjust,project,expectedSteps,uncertainty,parseCSV,csvImport,exportCSV,format,rngFactory}; |
| })(); |
| </script><script>/* Browser UI, all processing local. */ |
| (()=>{'use strict'; |
| let model=Core.validate(Core.demo('education')),segment=Core.groups(model)[0],horizon=12,page='overview',selected=model.initial,from=model.initial,to=model.states[1].id,shift=12,loaded=false; |
| const pages=[['overview','Overview','◫'],['journey','Journey map','◇'],['groups','Cohort comparison','▤'],['intervention','Intervention studio','⚙'],['uncertainty','Uncertainty lab','◉'],['data','Data & methods','⬇']]; |
| const el=id=>document.getElementById(id);const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));const Pct=x=>Core.format(x*100,1)+'%';const nr=x=>Math.round(x).toLocaleString('en-US');const getLabel=id=>model.states.find(s=>s.id===id)?.label||id; |
| function current(){const P=Core.matrix(model,segment);return {P,p:Core.project(model,P,horizon),time:Core.expectedSteps(model,P)};} |
| function options(list,value){return list.map(x=>`<option value="${esc(x.id)}" ${x.id===value?'selected':''}>${esc(x.label)}</option>`).join('');} |
| function stateOptions(value,which){return options(model.states.filter(s=>which==='from'?s.type==='transient':true).map(s=>({id:s.id,label:s.label})),value);} |
| function lineChart(hist){const w=760,h=280,x=50,y=14,W=690,H=217;let traces=[{key:model.good,color:'#3fddba',label:getLabel(model.good)},{key:model.bad,color:'#fb8f9b',label:getLabel(model.bad)},{key:'transient',color:'#709fff',label:'In progress'}];let idx=Object.fromEntries(model.states.map((s,i)=>[s.id,i]));const val=(t,key)=>key==='transient'?hist[t].reduce((a,z,i)=>a+(model.states[i].type==='transient'?z:0),0):hist[t][idx[key]];return `<svg class="chart" viewBox="0 0 ${w} ${h}" role="img" aria-label="Probability of outcomes across periods">${[0,.25,.5,.75,1].map(v=>`<line x1="${x}" y1="${y+H*(1-v)}" x2="${x+W}" y2="${y+H*(1-v)}" stroke="#2d4555" stroke-dasharray="4 5"/><text x="${x-10}" y="${y+H*(1-v)+4}" fill="#99b1c1" font-size="11" text-anchor="end">${Math.round(v*100)}%</text>`).join('')}${traces.map(t=>`<polyline fill="none" stroke="${t.color}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" points="${hist.map((v,i)=>`${x+i/horizon*W},${y+(1-val(i,t.key))*H}`).join(' ')}"/>`).join('')}${[0,Math.round(horizon/2),horizon].map(t=>`<text x="${x+t/horizon*W}" y="${y+H+24}" fill="#9db4bf" font-size="11" text-anchor="middle">Period ${t}</text>`).join('')}</svg><div class="legend">${traces.map(t=>`<span><span class="dot" style="background:${t.color}"></span>${esc(t.label)}</span>`).join('')}</div>`;} |
| function barRows(p){const ix=model.states.map((s,i)=>({s,p:p.final[i]})).sort((a,b)=>b.p-a.p);return ix.map(({s,p})=>`<div class="nodeline"><div style="min-width:104px">${esc(s.label)}<div class="tiny">${s.type==='absorbing'?'Outcome':'Intermediate'}</div></div><div style="flex:1"><div class="bar-track"><div class="bar-fill" style="width:${(p*100).toFixed(3)}%;background:${s.id===model.good?'#3fddba':s.id===model.bad?'#fb8f9b':'#709fff'}"></div></div></div><b class="pct">${Pct(p)}</b></div>`).join('');} |
| function metric(name,val,sub,color=''){return `<div class="metric"><div class="k">${esc(name)}</div><div class="v ${color}">${esc(val)}</div><div class="tip">${esc(sub)}</div></div>`;} |
| function panel(title,desc,content){return `<section class="panel"><div class="panel-title"><div><h2>${esc(title)}</h2><div class="tiny">${esc(desc)}</div></div></div>${content}</section>`;} |
| function overview(){const {p,time}=current(),n=model.cohorts[segment];return `<div class="hero"><div><div class="eyebrow">01 / PATHWAY INTELLIGENCE</div><h1>${esc(model.name)}</h1><p>${esc(model.description)} All figures are synthetic or user imported.</p></div><span class="tag">● Offline & auditable</span></div><div class="metrics">${metric('Starting cohort',nr(n),'Synthetic starting population')}${metric('Favorable at horizon',Pct(p.good),getLabel(model.good),'mint')}${metric('Adverse at horizon',Pct(p.bad),getLabel(model.bad),'red')}${metric('Still in progress',Pct(p.ongoing),`After ${horizon} periods`,'blue')}</div><div class="grid2">${panel('Outcomes over time','Absorbing Markov chain • finite horizon',lineChart(p.history))}${panel('Status distribution',`Distribution after period ${horizon}`,barRows(p))}</div><div class="grid-equal">${panel('Decision context','What this model can and cannot say',`<div class="stat-swatch"><b>${time===null?'Expected absorption time unavailable':Core.format(time,1)+' expected periods to any final outcome'}</b><div class="tiny">Computed with the fundamental matrix when all intermediate states are transient.</div></div><div class="divider"></div><p class="muted">Transition rates are estimated from observed or synthetic counts. They are assumed constant over time. Interventions are hypothetical reallocations, not causal claims.</p>`)}${panel('Explore the full journey','Next steps to interrogate the results',`<div class="stack"><button class="btn secondary" data-page="journey">Explore transition map →</button><button class="btn secondary" data-page="groups">Compare cohorts →</button><button class="btn secondary" data-page="uncertainty">Quantify sampling uncertainty →</button></div>`)}</div>`;} |
| function graph(P){const trans=model.states.filter(s=>s.type==='transient'),abs=model.states.filter(s=>s.type==='absorbing');const coords={};const leftRows=Math.ceil(trans.length/2),height=Math.max(340,Math.max(leftRows,abs.length)*116+45);trans.forEach((s,k)=>{let col=Math.floor(k/leftRows);coords[s.id]={x:130+col*225,y:65+(k%leftRows)*112};});abs.forEach((s,k)=>coords[s.id]={x:610,y:65+k*112});let id=Object.fromEntries(model.states.map((s,i)=>[s.id,i]));let lines=[];for(const s of trans)for(const t of model.states){let prob=P[id[s.id]][id[t.id]];if(prob<=.075||s.id===t.id)continue;const a=coords[s.id],b=coords[t.id];if(!a||!b)continue;let x1=a.x+57,y1=a.y+16,x2=b.x-61,y2=b.y+16;if(x2<x1){y1=a.y+28;y2=b.y+28;x2=b.x;x1=a.x;}lines.push(`<path d="M${x1},${y1} Q${(x1+x2)/2},${(y1+y2)/2-12} ${x2},${y2}" fill="none" stroke="${s.id===selected?'#46d7b9':'#4b6775'}" stroke-width="${Math.max(1.3,prob*3.6)}" marker-end="url(#arrow)" opacity=".86"><title>${esc(s.label)} → ${esc(t.label)}: ${Pct(prob)}</title></path>`);} |
| return `<div class="viz-wrap"><svg class="viz" viewBox="0 0 780 ${height}" role="img" aria-label="Directed graph of state transitions"><defs><marker id="arrow" markerWidth="7" markerHeight="7" refX="6" refY="3" orient="auto"><path d="M0 0 L6 3 L0 6" stroke="none" fill="#618c94"/></marker></defs>${lines.join('')}${model.states.map(s=>{let a=coords[s.id]||{x:610,y:65};const color=s.id===model.good?'#216b5c':s.id===model.bad?'#803a45':s.type==='absorbing'?'#455d7e':'#213c51';return `<g class="svg-node" data-node="${esc(s.id)}" tabindex="0" role="button" aria-label="Inspect ${esc(s.label)}"><rect x="${a.x-60}" y="${a.y-6}" rx="13" width="132" height="60" fill="${color}" stroke="${s.id===selected?'#b7fff1':'#648391'}" stroke-width="${s.id===selected?2.6:1}"/><text x="${a.x+6}" y="${a.y+20}" text-anchor="middle" fill="#f4ffff" font-size="12" font-weight="700">${esc(s.label.length>18?s.label.slice(0,16)+'…':s.label)}</text><text x="${a.x+6}" y="${a.y+39}" text-anchor="middle" fill="#b8d4da" font-size="9">${s.type==='absorbing'?'FINAL OUTCOME':'IN PROGRESS'}</text></g>`}).join('')}</svg></div>`;} |
| function journey(){const {P}=current(),n=model.states.findIndex(s=>s.id===selected);return `<div class="hero"><div><div class="eyebrow">02 / PROCESS DISCOVERY</div><h1>Journey map</h1><p>Explore state transitions. Select a node to inspect its observed transition probabilities.</p></div><span class="tag">${model.states.length} states</span></div><div class="grid2">${panel('Transition network','Lines show flows over 7.5% • arrows point to next states',graph(P))}${panel('Selected node',getLabel(selected),`<div class="stat-swatch"><h2>${esc(getLabel(selected))}</h2><div class="tiny">${model.states[n].type==='absorbing'?'Absorbing outcome: this state is final':'Transient state: may continue to other stages'}</div></div><div class="divider"></div>${model.states[n].type==='absorbing'?'<p class="muted">Final states retain all probability once reached.</p>':model.states.map((s,i)=>({s,p:P[n][i]})).filter(o=>o.p>0).sort((a,b)=>b.p-a.p).map(({s,p})=>`<div class="nodeline"><span>${esc(s.label)}</span><b>${Pct(p)}</b></div>`).join('')}`)}</div>${panel('Transition probability table',`All origin states for ${segment}`,`<div class="table-wrap"><table><thead><tr><th>Origin</th>${model.states.map(s=>`<th>${esc(s.label)}</th>`).join('')}</tr></thead><tbody>${model.states.map((s,i)=>`<tr><td>${esc(s.label)}</td>${P[i].map(v=>`<td>${Pct(v)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`)}`;} |
| function comparison(){const all=Core.groups(model).map(g=>{const P=Core.matrix(model,g),p=Core.project(model,P,horizon),unc=Core.uncertainty(model,g,horizon,151,90);return {g,p,unc,n:model.cohorts[g],time:Core.expectedSteps(model,P)};});const vals=all.map(a=>a.p.good),gap=(Math.max(...vals)-Math.min(...vals))*100;return `<div class="hero"><div><div class="eyebrow">03 / EQUITY ANALYTICS</div><h1>Cohort comparison</h1><p>Compare outcomes at the same horizon using segment-specific transition matrices.</p></div><span class="tag">${all.length} comparison groups</span></div><div class="metrics">${metric('Group count',String(all.length),'Comparable cohorts')}${metric('Largest favorable gap',Core.format(gap,1)+' pp','Max difference across groups','orange')}${metric('Best favorable rate',Pct(Math.max(...vals)),'At selected horizon','mint')}${metric('Lowest favorable rate',Pct(Math.min(...vals)),'At selected horizon','red')}</div>${panel('Group-level results','Percentages apply to modeled transitions, not sampled individuals',`<div class="table-wrap"><table><thead><tr><th>Group</th><th>Starting N</th><th>Favorable</th><th>Adverse</th><th>In progress</th><th>Expected periods</th></tr></thead><tbody>${all.map(a=>`<tr><td><b>${esc(a.g)}</b></td><td>${nr(a.n)}</td><td class="mint">${Pct(a.p.good)}</td><td class="red">${Pct(a.p.bad)}</td><td>${Pct(a.p.ongoing)}</td><td>${a.time===null?'N/A':Core.format(a.time,1)}</td></tr>`).join('')}</tbody></table></div>`)}${panel('Favorable outcome by cohort','Model-based horizon probabilities',all.map(a=>`<div class="nodeline"><div style="min-width:95px">${esc(a.g)}</div><div style="flex:1"><div class="bar-track"><div class="bar-fill" style="width:${a.p.good*100}%"></div></div></div><b>${Pct(a.p.good)}</b></div>`).join(''))}<div class="notice">Differences between cohorts may reflect different underlying circumstances, selection effects or unequal transition conditions. This dashboard does not establish discrimination or causation.</div>`;} |
| function intervention(){const P=Core.matrix(model,segment),base=Core.project(model,P,horizon);if(!model.states.some(s=>s.id===from&&s.type==='transient'))from=model.initial;if(!model.states.some(s=>s.id===to))to=model.good;const adj=Core.adjust(P,model,from,to,shift),alt=Core.project(model,adj,horizon),diff=(alt.good-base.good)*100;return `<div class="hero"><div><div class="eyebrow">04 / WHAT-IF PLANNING</div><h1>Intervention studio</h1><p>Reallocate a transition probability, hold other model assumptions fixed, and compare outcomes.</p></div><span class="tag">Hypothetical • not causal</span></div><div class="grid2">${panel('Change one transition','The increase is taken proportionally from other outgoing transitions',`<div class="stack"><label class="control">Origin state<select data-control="from">${stateOptions(from,'from')}</select></label><label class="control">Destination state<select data-control="to">${stateOptions(to,'to')}</select></label><div><div class="slider-head"><b>Increase selected probability</b><b class="mint">+${shift} pp</b></div><input class="range" type="range" data-control="shift" min="0" max="35" step="1" value="${shift}" aria-label="Increase transition probability"></div><div class="stat-swatch">${esc(getLabel(from))} → ${esc(getLabel(to))}<div class="tiny">Before: ${Pct(P[model.states.findIndex(s=>s.id===from)][model.states.findIndex(s=>s.id===to)])} · After: ${Pct(adj[model.states.findIndex(s=>s.id===from)][model.states.findIndex(s=>s.id===to)])}</div></div><button class="btn secondary" data-action="reset-intervention">Reset experiment</button></div>`)}${panel('Modeled scenario impact',`Outcomes after period ${horizon}`,`${metric('Favorable effect',`${diff>=0?'+':''}${Core.format(diff,1)} pp`,'Change relative to baseline',diff>=0?'mint':'red')}<div class="divider"></div><div class="nodeline"><span>Baseline favorable</span><b>${Pct(base.good)}</b></div><div class="nodeline"><span>Hypothetical favorable</span><b class="mint">${Pct(alt.good)}</b></div><div class="nodeline"><span>Baseline adverse</span><b>${Pct(base.bad)}</b></div><div class="nodeline"><span>Hypothetical adverse</span><b>${Pct(alt.bad)}</b></div>` )}</div>${panel('Scenario trajectory','Both curves start from the same initial state',`<div class="grid-equal"><div><h3>Before intervention</h3>${lineChart(base.history)}</div><div><h3>After intervention</h3>${lineChart(alt.history)}</div></div>`)}<div class="notice">This is an algebraic change to a transition row. It does not estimate the effect of a real-world intervention, its cost, implementation feasibility or unintended consequences.</div>`;} |
| function uncertainty(){const {P,p}=current(),u=Core.uncertainty(model,segment,horizon,2026,200),rowCounts={};model.states.filter(s=>s.type==='transient').forEach(s=>rowCounts[s.id]=model.rows.filter(r=>r.segment===segment&&r.from===s.id).reduce((a,b)=>a+b.count,0));return `<div class="hero"><div><div class="eyebrow">05 / UNCERTAINTY & ASSUMPTIONS</div><h1>Uncertainty laboratory</h1><p>Explore plausible transition-rate variation given finite row counts and fixed network structure.</p></div><span class="tag">Seed 2026 • 200 draws</span></div><div class="metrics">${metric('Point estimate',Pct(p.good),'Favorable at horizon','mint')}${metric('Lower 10th percentile',Pct(u.low),'Approximate model-based interval')}${metric('Upper 90th percentile',Pct(u.high),'Approximate model-based interval')}${metric('Estimated interval width',Core.format((u.high-u.low)*100,1)+' pp','Not a validation guarantee','orange')}</div><div class="grid2">${panel('Modeled uncertainty','80% central interval from 200 Dirichlet draws',`<div style="padding:20px 10px"><div class="bar-track" style="height:20px;position:relative"><div style="position:absolute;left:${u.low*100}%;width:${(u.high-u.low)*100}%;height:100%;background:#3fddba"></div><div style="position:absolute;left:${p.good*100}%;height:100%;width:3px;background:#fff"></div></div><div class="hstack" style="justify-content:space-between;margin-top:12px"><span>${Pct(u.low)}</span><span>Point: ${Pct(p.good)}</span><span>${Pct(u.high)}</span></div></div><p class="tiny">Independent Dirichlet draws on observed outgoing transitions, Jeffreys-style pseudocount 0.5, structural zeros preserved. Conditional on the Markov model.</p>`)}${panel('Transition data coverage','Counts contributing to estimated probabilities',Object.entries(rowCounts).map(([id,n])=>`<div class="nodeline"><span>${esc(getLabel(id))}</span><b>${nr(n)} transitions</b></div>`).join(''))}</div><div class="notice">The interval reflects model-based sampling uncertainty only. It excludes measurement error, missing populations, policy feedback, time-varying rates and structural misspecification. Synthetic counts do not convey empirical evidence.</div>`;} |
| function data(){return `<div class="hero"><div><div class="eyebrow">06 / DATA ENGINEERING</div><h1>Data & methodology</h1><p>Import your own transition matrix as CSV or a fully configured study as JSON. Processing stays in this browser.</p></div><span class="tag">No backend • no API keys</span></div><div class="grid2">${panel('Import study','CSV transition counts or complete JSON',`<div class="data-drop"><b>Load a CSV or JSON file</b><p class="tiny">Maximum 1 MB. CSV requires 6 named columns; JSON can specify cohort sizes.</p><input class="file-input" type="file" id="data-file" accept=".csv,.json,text/csv,application/json" aria-label="Import CSV or JSON"><div class="tiny" style="margin-top:8px">Previous data remain unchanged if validation fails.</div></div><div class="divider"></div><div class="hstack"><button class="btn secondary" data-action="load-demo">Restore university example</button><button class="btn secondary" data-download="template">Download CSV template</button></div>`)}${panel('Dataset specification','Import validation rules',`<div class="stack"><div class="nodeline"><span>Study</span><b>${esc(model.name)}</b></div><div class="nodeline"><span>States</span><b>${model.states.length}</b></div><div class="nodeline"><span>Transitions</span><b>${model.rows.length}</b></div><div class="nodeline"><span>Groups</span><b>${Core.groups(model).length}</b></div><div class="nodeline"><span>Source</span><b>${loaded?'User import':'Synthetic example'}</b></div></div><p class="tiny">CSV header: <code>segment,from,to,count,from_type,to_type</code>. Initial state is the first origin, and cohort size is inferred from its outgoing counts; use JSON to set exact cohort sizes or labels.</p>` )}</div>${panel('Download evidence & reproducibility','Files generated locally from the current data and controls',`<div class="download-grid"><button class="btn" data-download="json">Full audit JSON</button><button class="btn secondary" data-download="csv">Transitions CSV</button><button class="btn secondary" data-download="projection">Projection CSV</button><button class="btn secondary" data-download="markdown">Executive report.md</button><button class="btn secondary" data-download="cohorts">Cohort summary CSV</button><button class="btn secondary" data-download="template">CSV template</button></div>`)}${panel('Statistical foundations','Methods and scope',`<div class="stack"><div><b>Transition model</b><p class="muted">Time-homogeneous, first-order Markov chain. Rows are normalized observed counts; absorbing outcomes remain absorbing.</p></div><div><b>Projection</b><p class="muted">Multiply the starting state distribution by the transition matrix for each period. The three reported categories are favorable, adverse, and still in progress.</p></div><div><b>Expected duration</b><p class="muted">For transient-to-absorbing chains with a nonsingular fundamental matrix, expected time to any final outcome is computed via (I − Q)⁻¹1.</p></div><div><b>Import limitations</b><p class="muted">At most 14 states, 5 groups, 1,600 transition records and 1 MB of input. CSV data should never be used to identify individuals.</p></div></div>`)}<div class="notice">Use aggregated, consented or adequately de-identified data only. A public Static Space publishes its source code and built-in demo datasets. File imports are processed in the visitor's browser, not uploaded to this app's server.</div>`;} |
| function download(which){const {P,p,time}=current();const outcome={project:'PathwayLab #56',generated_at:new Date().toISOString(),study:model.name,segment,horizon,cohort_size:model.cohorts[segment],rates:{favorable:p.good,adverse:p.bad,in_progress:p.ongoing},expected_periods_to_absorption:time,model,formula:'First-order time-homogeneous absorbing Markov chain',limitations:['Synthetic examples are illustrative','Transition stability and cohort comparability are assumptions','No causal effect identification']};const Q=Core.exportCSV;const csv=(head,rows)=>[head.join(','),...rows.map(r=>r.map(v=>{let s=String(v??'');if(/^[=+@\-\t\r]/.test(s))s="'"+s;return '"'+s.replace(/"/g,'""')+'"';}).join(','))].join('\r\n');let str,mime,filename;if(which==='json'){str=JSON.stringify(outcome,null,2);mime='application/json';filename='pathwaylab-audit.json';}else if(which==='csv'){str=Q(model);mime='text/csv';filename='pathwaylab-transitions.csv';}else if(which==='projection'){str=csv(['period','favorable','adverse','in_progress'],p.history.map((v,i)=>{const f=v[model.states.findIndex(s=>s.id===model.good)],b=v[model.states.findIndex(s=>s.id===model.bad)];return [i,f,b,Math.max(0,1-f-b)];}));mime='text/csv';filename='pathwaylab-projection.csv';}else if(which==='cohorts'){str=csv(['segment','starting_cohort','favorable','adverse','in_progress'],Core.groups(model).map(g=>{const o=Core.project(model,Core.matrix(model,g),horizon);return [g,model.cohorts[g],o.good,o.bad,o.ongoing];}));mime='text/csv';filename='pathwaylab-cohorts.csv';}else if(which==='template'){str=Core.exportCSV(Core.demo('education'));mime='text/csv';filename='pathwaylab-transition-template.csv';}else{str=`# PathwayLab | Audit report\n\nStudy: ${model.name}\nSegment: ${segment}\nPeriods: ${horizon}\nStarting cohort: ${model.cohorts[segment]}\n\n## Outcomes\n\n- Favorable: ${Pct(p.good)}\n- Adverse: ${Pct(p.bad)}\n- In progress: ${Pct(p.ongoing)}\n- Expected absorption time: ${time===null?'Not identified':Core.format(time,2)+' periods'}\n\n## Method\n\nFirst-order, time-homogeneous absorbing Markov chain using row-normalized transition counts. Outcomes at a fixed horizon are probabilities, not observed totals.\n\n## Cautions\n\n${loaded?'User-provided dataset. Verify provenance and sampling before interpreting.':'Synthetic demonstration. No empirical claim.'} No causal attribution, forecast guarantee or quality certification.\n`;mime='text/markdown';filename='pathwaylab-report.md';}const blob=new Blob([str],{type:mime+';charset=utf-8'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=filename;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1200);toast('Downloaded '+filename);} |
| let toastTimer;function toast(s,err=false){const t=el('toast');t.textContent=s;t.className=err?'error':'';t.style.display='block';clearTimeout(toastTimer);toastTimer=setTimeout(()=>t.style.display='none',4800);} |
| function render(){const parts={overview,journey,groups:comparison,intervention,uncertainty,data};el('view').innerHTML=parts[page]();el('navigation').innerHTML=pages.map(([id,label,emoji],i)=>`<button data-page="${id}" class="${page===id?'active':''}" aria-current="${page===id?'page':'false'}"><span class="num">${String(i+1).padStart(2,'0')}</span><span>${emoji} ${label}</span></button>`).join('');el('study-select').innerHTML=Core.demos.map(s=>`<option value="${esc(s.id)}" ${s.id===model.id?'selected':''}>${esc(s.name)}</option>`).join('')+(loaded?'<option value="imported" selected>Imported study</option>':'');el('group-select').innerHTML=Core.groups(model).map(g=>`<option value="${esc(g)}" ${g===segment?'selected':''}>${esc(g)}</option>`).join('');el('horizon-select').value=String(horizon);el('view').setAttribute('data-page',page);if(page==='overview')el('navigation').scrollLeft=0;} |
| function loadModel(m,isImport){model=Core.validate(m);segment=Core.groups(model)[0];selected=model.initial;from=model.initial;to=model.states.find(s=>s.id!==from)?.id||model.good;shift=12;page='overview';loaded=isImport;render();} |
| document.addEventListener('click',ev=>{const b=ev.target.closest('[data-page],[data-action],[data-download],[data-node]');if(!b)return;try{if(b.dataset.page){page=b.dataset.page;render();window.scrollTo({top:0,behavior:'smooth'});}else if(b.dataset.download)download(b.dataset.download);else if(b.dataset.node){selected=b.dataset.node;render();}else if(b.dataset.action==='reset-intervention'){from=model.initial;to=model.states[1]?.id||model.good;shift=0;render();}else if(b.dataset.action==='load-demo'){loadModel(Core.demo('education'),false);toast('University example restored');}}catch(e){toast(e.message,true);}}); |
| document.addEventListener('keydown',ev=>{if((ev.key==='Enter'||ev.key===' ')&&ev.target.matches('[data-node]')){ev.preventDefault();selected=ev.target.dataset.node;render();}}); |
| document.addEventListener('change',async ev=>{const target=ev.target;try{if(target.id==='study-select'){if(target.value!=='imported'){loadModel(Core.demo(target.value),false);}}else if(target.id==='group-select'){segment=target.value;render();}else if(target.id==='horizon-select'){horizon=Number(target.value);render();}else if(target.dataset.control){if(target.dataset.control==='from')from=target.value;else if(target.dataset.control==='to')to=target.value;else shift=Number(target.value);render();}else if(target.id==='data-file'){const file=target.files?.[0];if(!file)return;if(file.size>1_000_000)throw Error('File exceeds 1 MB.');const txt=await file.text();const imported=file.name.toLowerCase().endsWith('.json')?Core.validate(JSON.parse(txt)):Core.csvImport(txt);loadModel(imported,true);toast('Dataset imported successfully');}}catch(e){toast('Import or input error: '+e.message,true);target.value='';}}); |
| |
| render(); |
| })(); |
| </script></body></html> |