Hugdrigo's picture
Upload 3 files
0a186d4 verified
Raw History Blame Contribute Delete
42.6 kB
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#0b1425"><title>QueueCanvas | Hugdrigo</title>
<style>
:root{color-scheme:dark;--bg:#080e1a;--nav:#0e1728;--panel:#121e31;--panel2:#17263a;--line:#27394e;--text:#eef5ff;--muted:#93a9c1;--accent:#9ed5ff;--green:#7ce2b1;--yellow:#f3c77e;--red:#ff929e;--shadow:0 24px 56px #0003}
body.light{color-scheme:light;--bg:#f3f7fc;--nav:#eaf2fa;--panel:#fff;--panel2:#eef3fa;--line:#dce4ef;--text:#14263b;--muted:#61758a;--shadow:0 18px 40px #1c466212}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",Arial;min-width:280px}body:before{content:"";position:fixed;pointer-events:none;z-index:-1;inset:0;background:radial-gradient(circle at 89% 0%,color-mix(in srgb,var(--accent) 9%,transparent),transparent 35%)}button,input,select{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:2px}a{color:inherit}strong{font-weight:720}
.app{display:flex;min-height:100vh}.sidebar{flex:0 0 238px;position:sticky;top:0;height:100vh;padding:26px 17px;display:flex;flex-direction:column;background:var(--nav);border-right:1px solid var(--line)}.brand{display:flex;align-items:center;gap:12px;padding:5px 12px 28px}.brandmark{height:39px;width:39px;border:1px solid color-mix(in srgb,var(--accent) 55%,transparent);background:color-mix(in srgb,var(--accent) 12%,transparent);color:var(--accent);border-radius:13px;display:grid;place-items:center;font-weight:900;font-size:19px;box-shadow:0 0 20px color-mix(in srgb,var(--accent) 10%,transparent)}.brand strong{display:block;letter-spacing:.5px;font-size:15px}.brand small{display:block;color:var(--muted);font-size:10px}.nav-label{font-size:10px;letter-spacing:2px;font-weight:800;color:var(--muted);margin:8px 12px 13px}.navbtn{display:flex;align-items:center;gap:12px;width:100%;border:1px solid transparent;border-radius:11px;background:transparent;color:var(--muted);padding:12px 13px;margin-bottom:5px;text-align:left;font-weight:650}.navbtn.active{color:var(--text);background:color-mix(in srgb,var(--accent) 11%,var(--panel));border-color:color-mix(in srgb,var(--accent) 25%,transparent)}.navbtn:hover{background:var(--panel2);color:var(--text)}.nav-ico{font-size:16px;width:22px;text-align:center}.sidebottom{margin-top:auto;padding:16px 12px 4px;border-top:1px solid var(--line)}.sidebottom small{color:var(--muted);font-size:11px}.sidebottom b{display:block;margin:8px 0;color:var(--accent);font-size:12px}.dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--green);margin-right:6px;box-shadow:0 0 12px var(--green)}
.main{min-width:0;flex:1;padding:29px clamp(18px,3.2vw,55px) 40px;max-width:1750px;margin:auto}.topbar{display:flex;justify-content:space-between;align-items:center;gap:15px;margin-bottom:26px}.crumb{font-size:11px;text-transform:uppercase;color:var(--muted);letter-spacing:1.65px;font-weight:800}.crumb strong{color:var(--accent)}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:flex-end}.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:10px 13px;border:1px solid var(--line);border-radius:9px;color:var(--text);background:var(--panel);font-weight:700;font-size:12px;white-space:nowrap}.btn:hover{border-color:var(--accent)}.btn.primary{background:var(--accent);color:#081625;border-color:var(--accent)}.btn.warn{color:var(--red)}.btn.tiny{font-size:11px;padding:6px 9px}.hero{display:flex;gap:24px;justify-content:space-between;align-items:center;margin-bottom:23px}.eyebrow{font-weight:800;letter-spacing:1.7px;color:var(--accent);font-size:10px;text-transform:uppercase}.hero h1{font-size:clamp(28px,3.6vw,47px);letter-spacing:-1.6px;line-height:1.08;margin:8px 0 11px;font-weight:820}.hero p{margin:0;color:var(--muted);max-width:760px}.hero-art{height:104px;width:156px;flex:0 0 156px;border:1px solid color-mix(in srgb,var(--accent) 30%,var(--line));border-radius:22px;background:linear-gradient(145deg,color-mix(in srgb,var(--accent) 16%,var(--panel)),var(--panel));position:relative;overflow:hidden}.hero-art:before{content:"";position:absolute;width:135px;height:135px;border-radius:45px;border:2px solid var(--accent);transform:rotate(35deg);top:24px;left:50px;opacity:.35}.hero-art:after{content:"";position:absolute;width:75px;height:75px;border-radius:22px;border:1px solid var(--accent);transform:rotate(35deg);top:11px;left:20px;opacity:.6}.hero-art span{position:absolute;top:40px;left:63px;font-size:18px;color:var(--accent);font-weight:900;z-index:2}.statusbar{display:flex;gap:10px;flex-wrap:wrap;margin:0 0 23px}.tag{border:1px solid var(--line);border-radius:18px;padding:6px 11px;font-weight:750;color:var(--muted);font-size:10px;letter-spacing:.5px}.tag.accent{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 27%,var(--line))}
.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:19px}.card{min-width:0;border:1px solid var(--line);background:var(--panel);border-radius:15px;box-shadow:var(--shadow)}.metric{padding:20px}.metric .small{font-size:11px;letter-spacing:.1px;color:var(--muted);font-weight:650}.metric strong{font-size:clamp(20px,2.2vw,30px);line-height:1.35;display:block;letter-spacing:-.7px;margin:5px 0 2px;overflow-wrap:anywhere}.metric .foot{color:var(--muted);font-size:10px}.metric i{display:inline-block;height:4px;width:29px;background:var(--accent);border-radius:4px;margin-top:13px}.metric:nth-child(2) i{background:var(--green)}.metric:nth-child(3) i{background:var(--yellow)}.metric:nth-child(4) i{background:#9ca6f8}
.columns{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(275px,.9fr);gap:18px;align-items:start}.stack{display:grid;gap:18px;align-content:start}.panel{padding:22px}.panel h2{font-size:15px;letter-spacing:-.1px;margin:0;font-weight:800}.panel .sub{font-size:11px;color:var(--muted);margin-top:5px}.panel-head{display:flex;gap:15px;justify-content:space-between;align-items:flex-start;margin-bottom:19px}.badge{font-size:10px;font-weight:760;color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 25%,var(--line));background:color-mix(in srgb,var(--accent) 8%,transparent);padding:5px 9px;border-radius:50px;white-space:nowrap}.barrow{display:grid;grid-template-columns:minmax(94px,1fr) 3.5fr minmax(72px,1fr);align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--line)}.barrow:last-child{border:0}.barrow .nm{font-weight:650;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.barrow .val{text-align:right;font-variant-numeric:tabular-nums;font-size:11px;font-weight:800}.track{height:9px;border-radius:10px;background:var(--panel2);overflow:hidden}.fill{height:100%;border-radius:10px;background:linear-gradient(90deg,var(--accent),color-mix(in srgb,var(--accent) 45%,#fff))}.insight{padding:13px;border:1px solid color-mix(in srgb,var(--accent) 25%,var(--line));background:color-mix(in srgb,var(--accent) 7%,transparent);border-radius:10px;margin-top:14px;color:var(--text);font-size:12px}.insight strong{color:var(--accent)}.stat-pair{display:flex;justify-content:space-between;gap:12px;border-bottom:1px solid var(--line);padding:11px 0;font-size:12px}.stat-pair span{color:var(--muted)}.stat-pair:last-child{border:none}.muted{color:var(--muted)}.note{color:var(--muted);font-size:11px;margin-top:13px}
.preset-list{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:9px;margin-bottom:19px}.preset{background:var(--panel2);border:1px solid var(--line);border-radius:11px;padding:12px;text-align:left;color:var(--text)}.preset.selected{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 10%,var(--panel2))}.preset b{display:block;font-size:12px}.preset small{display:block;color:var(--muted);font-size:10px;margin-top:5px}.control{padding:13px 0;border-bottom:1px solid var(--line)}.control:last-child{border:none}.controlhead{display:flex;justify-content:space-between;font-size:12px;font-weight:680;margin-bottom:11px}.controlhead output{color:var(--accent);font-weight:800}.control input{width:100%;accent-color:var(--accent)}.spread{display:flex;gap:12px;justify-content:space-between;align-items:center;flex-wrap:wrap}.section-title{margin:0 0 16px}.section-title h2{font-size:20px;margin:0 0 5px;letter-spacing:-.4px}.section-title p{color:var(--muted);margin:0;font-size:12px}
.toolbar{display:flex;justify-content:space-between;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:18px}.search{border:1px solid var(--line);background:var(--panel2);border-radius:9px;color:var(--text);padding:10px 12px;min-width:180px;max-width:100%;flex:1}.tablewrap{overflow:auto;width:100%;border-radius:10px;border:1px solid var(--line)}table{border-collapse:collapse;width:100%;min-width:680px;font-size:11px}thead{background:var(--panel2);color:var(--muted)}th,td{text-align:left;padding:13px 13px;border-bottom:1px solid var(--line);vertical-align:middle}th{font-size:10px;letter-spacing:.4px;font-weight:800;white-space:nowrap}td strong{font-size:12px}tr:last-child td{border-bottom:0}tbody tr:hover{background:color-mix(in srgb,var(--accent) 5%,transparent)}.pill{padding:5px 8px;border-radius:20px;background:color-mix(in srgb,var(--accent) 9%,transparent);color:var(--accent);font-size:10px;font-weight:750}.pill.bad{color:var(--red);background:color-mix(in srgb,var(--red) 9%,transparent)}.rowact{display:flex;gap:6px}.empty{text-align:center;padding:36px;color:var(--muted)}
.methods{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.method-card{border:1px solid var(--line);border-radius:13px;padding:19px;background:var(--panel)}.method-card .number{font-size:11px;font-weight:900;color:var(--accent);letter-spacing:1px}.method-card p{font-size:12px;color:var(--muted);margin:9px 0 0}.disclaimer{border:1px solid color-mix(in srgb,var(--yellow) 45%,var(--line));background:color-mix(in srgb,var(--yellow) 5%,var(--panel));border-radius:12px;padding:17px;margin-top:16px;font-size:12px;line-height:1.6}.disclaimer strong{color:var(--yellow)}.footer{display:flex;gap:20px;justify-content:space-between;align-items:center;flex-wrap:wrap;color:var(--muted);font-size:10px;margin-top:25px;border-top:1px solid var(--line);padding-top:19px}
.modal{position:fixed;inset:0;z-index:10;display:none;align-items:center;justify-content:center;padding:16px;background:#020817bb;backdrop-filter:blur(8px)}.modal.show{display:flex}.dialog{width:min(620px,100%);max-height:91vh;overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:17px;padding:22px;box-shadow:0 26px 85px #0009}.fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px}.field label{display:block;color:var(--muted);font-size:11px;font-weight:720;margin-bottom:5px}.field input{width:100%;min-width:0;border-radius:9px;border:1px solid var(--line);background:var(--panel2);color:var(--text);padding:11px}.field:first-child{grid-column:1/-1}.modalbottom{display:flex;justify-content:flex-end;gap:9px;margin-top:20px}.toast{position:fixed;right:20px;bottom:20px;max-width:min(400px,calc(100% - 40px));z-index:15;background:var(--panel2);color:var(--text);border:1px solid var(--accent);padding:12px 17px;border-radius:10px;box-shadow:var(--shadow);display:none;font-size:12px}.toast.show{display:block}.lab-cols{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:18px}.chartsvg{width:100%;height:180px;display:block;overflow:visible}.small-list{padding:0;margin:0;list-style:none}.small-list li{padding:12px 0;border-bottom:1px solid var(--line);font-size:12px}.small-list li:last-child{border:0}.small-list b{display:block}.small-list span{color:var(--muted)}
@media(max-width:1110px){.sidebar{flex-basis:206px}.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.columns{grid-template-columns:1fr}.hero-art{display:none}}
@media(max-width:780px){.app{display:block}.sidebar{position:relative;width:100%;height:auto;padding:10px 14px 9px;border-right:0;border-bottom:1px solid var(--line)}.brand{padding:7px 3px 9px}.brandmark{width:32px;height:32px;border-radius:9px}.nav-label,.sidebottom{display:none}.navbox{display:flex;gap:5px;overflow-x:auto;scrollbar-width:none}.navbtn{width:auto;white-space:nowrap;padding:9px;margin:0;font-size:11px}.nav-ico{width:auto}.main{padding:17px 16px 30px}.topbar{margin-bottom:24px}.hero{margin-bottom:18px}.preset-list{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:520px){.crumb{font-size:9px}.topbar{align-items:start}.actions{gap:6px}.actions .btn{font-size:10px;padding:8px}.hero h1{font-size:30px}.hero p{font-size:12px}.metrics{gap:9px}.metric{padding:14px}.metric strong{font-size:22px}.metric .small{font-size:10px}.metric .foot{font-size:9px}.panel{padding:15px}.columns,.stack{gap:12px}.barrow{grid-template-columns:minmax(72px,1fr) 2.0fr minmax(55px,.7fr);gap:7px}.lab-cols,.methods{grid-template-columns:1fr}.fields{grid-template-columns:1fr}.footer{gap:8px}.tag{font-size:9px;padding:5px 8px}}
</style></head><body><div class="app"><aside class="sidebar"><div class="brand"><div class="brandmark">H</div><div><strong>HUGDRIGO</strong><small>RESEARCH & INTELLIGENCE LABS</small></div></div><div class="nav-label">WORKSPACE</div><nav class="navbox" id="navbox" aria-label="Application navigation"><button class="navbtn active" data-page="overview"><span class="nav-ico">◈</span>Overview</button><button class="navbtn" data-page="lab"><span class="nav-ico">◇</span>Scenario Lab</button><button class="navbtn" data-page="records"><span class="nav-ico">▤</span>Records</button><button class="navbtn" data-page="method"><span class="nav-ico">◉</span>Methodology</button></nav><div class="sidebottom"><small>PROJECT SERIES</small><b>#139 / HUGDRIGO STUDIO</b><small><span class="dot"></span>Static • Offline-capable • Synthetic data</small></div></aside><main class="main"><header class="topbar"><div class="crumb">HUGDRIGO <span aria-hidden="true">/</span> PROJECT <strong>#139</strong></div><div class="actions"><button class="btn" id="themeBtn" title="Toggle theme">☼ Theme</button><button class="btn" id="jsonBtn">↓ Backup</button><button class="btn primary" id="csvBtn">↧ Export CSV</button></div></header><section class="hero"><div><div class="eyebrow">SERVICE OPERATIONS · QUEUES</div><h1>QueueCanvas<span style="color:var(--accent)">.</span></h1><p>Analyze call-center waiting time, agent utilization, and arrival surges with Erlang-C assumptions.</p></div><div class="hero-art" aria-hidden="true"><span>#139</span></div></section><div class="statusbar"><span class="tag accent">LIVE WHAT-IF MODEL</span><span class="tag">SYNTHETIC DATA</span><span class="tag">NO EXTERNAL API</span><span class="tag">PROJECT #139</span></div><section id="page" aria-live="polite"></section><footer class="footer"><span>© 2026 Hugdrigo · MIT licensed · All values are illustrative</span><span>Privacy: all edits are kept only in this browser</span></footer></main></div><div class="modal" id="modal" role="dialog" aria-modal="true" aria-label="Record editor"><div class="dialog"><div class="spread"><h2 id="editTitle">Edit record</h2><button id="closeModal" class="btn tiny" aria-label="Close editor">✕</button></div><form id="editForm"><div class="fields" id="fields"></div><div class="modalbottom"><button type="button" id="cancelModal" class="btn">Cancel</button><button type="submit" class="btn primary">Save record</button></div></form></div></div><div class="toast" id="toast" role="status"></div><input type="file" id="importFile" accept="application/json,.json" hidden><script>
const CFG={"number":139,"name":"QueueCanvas","slug":"queuecanvas-contact-center","model":"queue","eyebrow":"SERVICE OPERATIONS · QUEUES","subtitle":"Erlang-C Staffing Intelligence Studio","description":"Analyze call-center waiting time, agent utilization, and arrival surges with Erlang-C assumptions.","short":"Contact center queue simulator.","accent":"#9ed5ff","fields":[{"key":"name","label":"Name","kind":"text","min":0,"max":0,"step":1},{"key":"agents","label":"Available agents","kind":"number","min":1,"max":160,"step":1},{"key":"callsHour","label":"Calls per hour","kind":"number","min":1,"max":900,"step":1},{"key":"handleMinutes","label":"Average handle minutes","kind":"number","min":1,"max":45,"step":1}],"rows":[{"name":"Customer Care A","agents":24,"callsHour":140,"handleMinutes":7.5,"id":"R01"},{"name":"Claims Desk","agents":18,"callsHour":92,"handleMinutes":8,"id":"R02"},{"name":"Premium Support","agents":12,"callsHour":42,"handleMinutes":11,"id":"R03"},{"name":"International Help","agents":28,"callsHour":125,"handleMinutes":9,"id":"R04"},{"name":"Technical Triage","agents":33,"callsHour":135,"handleMinutes":10,"id":"R05"},{"name":"Billing Desk","agents":16,"callsHour":70,"handleMinutes":8.5,"id":"R06"},{"name":"Emergency Line","agents":42,"callsHour":180,"handleMinutes":11,"id":"R07"}],"controls":[{"key":"traffic","label":"Arrival rate change","min":-60,"max":100,"step":5,"value":0,"unit":"%"},{"key":"extraAgents","label":"Extra agents","min":-10,"max":25,"step":1,"value":0,"unit":"agents"},{"key":"productivity","label":"Handle-time improvement","min":0,"max":80,"step":5,"value":15,"unit":"%"}],"presets":[{"name":"Baseline","desc":"Current staffing","values":[0,0,15]},{"name":"Traffic surge","desc":"Higher arrival","values":[70,0,0]},{"name":"Staff up","desc":"Additional coverage","values":[20,15,30]},{"name":"Staff shortage","desc":"Capacity reduction","values":[20,-8,0]}],"metrics":[["Sum of wait minutes","num"],["Hourly call volume","num"],["Mean utilization","pct"]],"methodology":["Uses the Erlang-C M/M/c steady-state waiting-time formula when offered load is below agent capacity.","For unstable overloaded queues, reported wait is capped at 60 minutes as a warning proxy; abandonment is not modeled.","Poisson arrivals, exponential service, identical agents, no callback, and no patience distribution are assumed."],"insight":"Assess queue sensitivity to call volume, staffing and handle-time improvements."};
'use strict';
const BYID=id=>document.getElementById(id);
const esc=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const num=x=>Number.isFinite(+x)?+x:0;
const clamp=(v,lo,hi)=>Math.max(lo,Math.min(hi,v));
const fmt=(v,t)=>{v=Number.isFinite(v)?v:0;return t==='money'?'$'+v.toLocaleString('en-US',{maximumFractionDigits:0}):t==='moneyK'?'$'+v.toLocaleString('en-US',{maximumFractionDigits:0})+'k':t==='pct'?v.toFixed(1)+'%':v.toLocaleString('en-US',{maximumFractionDigits:1});};
const base=()=>CFG.rows.map(x=>({...x,archived:false}));
const defaultControls=()=>Object.fromEntries(CFG.controls.map(c=>[c.key,c.value]));
const STORAGE_KEY='hugdrigo_studio_'+CFG.number+'_v1';
let state={records:base(), controls:defaultControls(), page:'overview', search:'', preset:0, theme:'dark'};
let editId=null;
function validRecord(raw){if(!raw||typeof raw!=='object'||Array.isArray(raw))return null;const r={id:typeof raw.id==='string'&&/^[\w-]{1,45}$/.test(raw.id)?raw.id:'R'+Math.random().toString(36).slice(2,9),archived:raw.archived===true};for(const f of CFG.fields){if(f.kind==='text'){if(typeof raw[f.key]!=='string'||(!raw[f.key].trim()&&f.key!=='deps')||raw[f.key].length>160)return null;r[f.key]=raw[f.key].trim().slice(0,140);}else{const v=raw[f.key];if(typeof v!=='number'||!Number.isFinite(v)||v<f.min||v>f.max)return null;r[f.key]=v;}}if(CFG.model==='coldtrace'&&r.minTemp>=r.maxTemp)return null;if(CFG.model==='talent'&&r.skill>r.target)return null;if(CFG.model==='survey'&&(r.positive>r.respondents||r.respondents>r.population))return null;return r;}
function validRecords(records){if(!Array.isArray(records)||records.length>150||records.length<1)return null;const out=records.map(validRecord);if(out.some(x=>!x))return null;const ids=new Set(out.map(x=>x.id));if(ids.size!==out.length)return null;if(CFG.model==='lineage'&&!validGraph(out))return null;return out;}
function validGraph(rows){const ids=new Set(rows.map(x=>x.assetId));if(ids.size!==rows.length)return false;const map=new Map(rows.map(x=>[x.assetId,x.deps.split(';').map(x=>x.trim()).filter(Boolean)]));for(let [id,deps] of map){if(deps.some(d=>!map.has(d)||d===id))return false;}let visiting=new Set(),visited=new Set();const visit=id=>{if(visiting.has(id))return false;if(visited.has(id))return true;visiting.add(id);for(const d of map.get(id))if(!visit(d))return false;visiting.delete(id);visited.add(id);return true;};return [...ids].every(visit);}
function load(){try{const s=JSON.parse(localStorage.getItem(STORAGE_KEY)||'null');if(s){const r=validRecords(s.records);if(r)state.records=r;if(s.controls&&typeof s.controls==='object'){for(const c of CFG.controls){const v=s.controls[c.key];if(typeof v==='number'&&Number.isFinite(v))state.controls[c.key]=clamp(v,c.min,c.max);}}if(s.theme==='light')state.theme='light';}}catch(e){}document.body.classList.toggle('light',state.theme==='light');}
function save(){try{localStorage.setItem(STORAGE_KEY,JSON.stringify({project:CFG.number,records:state.records,controls:state.controls,theme:state.theme}));}catch(e){toast('Browser storage not available; exports still work.');}}
let toastTimer;function toast(txt){const el=BYID('toast');el.textContent=txt;el.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>el.classList.remove('show'),3800);}
function model(rows,p){
const kind=CFG.model;
const active=rows.filter(r=>!r.archived);
let out=active.map(r=>{let primary=0,secondary=0,risk=0,score=0,note='';
switch(kind){
case 'aid': {
const travelFactor=clamp(1-(r.roadHours*(1+p.disruption/100))/72,.08,1);
const accessible=Math.floor(r.stock*(p.access/100)*travelFactor+0.500000001);
const demand=Math.ceil(r.households*r.kitsPerHousehold*(1+p.needSurge/100));
primary=Math.min(accessible,demand);secondary=Math.max(0,demand-primary);
risk=demand?100*secondary/demand:0;score=r.severity*(secondary+1)/Math.max(1,r.roadHours);
note=`${primary} kits deliverable of ${demand} needed; road access factor ${travelFactor.toFixed(2)}.`;break;
}
case 'cash': {
const effectiveDso=Math.max(0,r.dso+p.delay-p.followup*.28);
const ontime=clamp((p.horizon+10-effectiveDso)/25,0,1);
const collect=r.amount*(1-r.defaultRate/100)*ontime;
const factoring=r.amount*p.factor/100*(1-p.fee/100);
primary=collect+factoring*(1-ontime);secondary=Math.max(0,r.amount-primary);
risk=r.amount?100*secondary/r.amount:0;score=primary/r.amount*100;
note=`Collection likelihood proxy ${Math.round(ontime*100)}%; effective DSO ${effectiveDso.toFixed(0)} days.`;break;
}
case 'talent': {
const seats=Math.ceil(r.employees*p.uptake/100);
const gap=Math.max(0,r.target-r.skill);
const trainedGain=clamp(gap*(p.efficacy/100)*(1-r.attrition/200),0,gap);
primary=seats*trainedGain*r.valuePerPoint;
secondary=seats*r.trainingCost;
risk=clamp(100*(gap-trainedGain*Math.min(1,seats/Math.max(1,r.employees)))/Math.max(1,r.target),0,100);
score=primary/Math.max(1,secondary);
note=`Potential value $${primary.toFixed(0)} vs. $${secondary.toFixed(0)} training cost, before capacity selection.`;break;
}
case 'carbon': {
const fuel=(r.fuelLiters*2.68*(1-p.fuelCut/100))/1000;
const electricity=r.mwh*p.gridKg/1000;
const freight=(r.tonKm*.095*(1-p.routeCut/100))/1000;
primary=fuel+electricity+freight;
secondary=(r.fuelLiters*2.68+r.mwh*p.gridKg+r.tonKm*.095)/1000-primary;
risk=clamp(primary/(Math.max(1,r.revenueK)*10)*100,0,100);
score=secondary;
note=`Fuel ${fuel.toFixed(1)}, electricity ${electricity.toFixed(1)}, freight ${freight.toFixed(1)} tCO₂e.`;break;
}
case 'queue': {
const arrivals=r.callsHour*(1+p.traffic/100),agents=Math.max(1,Math.round(r.agents+p.extraAgents));
const serviceRate=60/(r.handleMinutes*(1-p.productivity/200));
const offered=arrivals/serviceRate,occupancy=offered/agents;
let probWait=1,waitMinutes=60; // overload is treated as unstable and capped
if(occupancy<1){
let term=1,total=1;
for(let i=1;i<agents;i++){term*=offered/i;total+=term;}
const last=term*offered/agents;
const waitMass=last/(1-occupancy);
probWait=waitMass/(total+waitMass);
waitMinutes=probWait/(agents*serviceRate-arrivals)*60;
}
primary=waitMinutes;secondary=arrivals;
risk=clamp(occupancy*100,0,500);score=100-Math.min(risk,100);
note=`Erlang-C wait ${waitMinutes.toFixed(2)} min; utilization ${(occupancy*100).toFixed(1)}%; ${agents} agents.`;break;
}
case 'soil': {
const available=r.moisture+p.rain+p.irrigation;
const need=r.waterNeed*(1+p.heat/100);
const deficit=clamp((need-available)/Math.max(1,need),0,1);
const nutrients=Math.min(1,(r.nitrogen+p.nBoost)/Math.max(1,r.nTarget));
const yieldFactor=clamp((1-deficit*.67)*(.72+.28*nutrients),0,1);
primary=r.hectares*r.potentialYield*yieldFactor;
secondary=Math.max(0,r.nitrogen+p.nBoost-r.nTarget)*r.hectares;
risk=100*deficit;score=yieldFactor*100;
note=`Moisture deficit ${(100*deficit).toFixed(1)}%; modeled yield ${(r.potentialYield*yieldFactor).toFixed(2)} t/ha.`;break;
}
case 'supplier': {
const lead=r.leadWeeks+p.delay;
const effectiveDemand=r.weeklyDemand*lead*(1+p.demand/100);
const effectiveSupply=r.stock+r.alternative*(p.backup/100)*lead;
const deficit=Math.max(0,effectiveDemand-effectiveSupply);
primary=deficit;secondary=deficit*r.unitCost;
risk=clamp(100*deficit/Math.max(1,effectiveDemand)+p.reliabilityShock*(1-r.reliability/100)*.25,0,100);
score=100-risk;
note=`Coverage ${effectiveDemand?Math.min(100,100*effectiveSupply/effectiveDemand).toFixed(1):'100'}%; ${lead.toFixed(1)}-week lead time.`;break;
}
case 'ethics': {
const base=r.impact*.25+r.privacy*.2+(100-r.oversight)*.2+(100-r.explain)*.2+r.incidents*.15;
const mitigation=clamp((p.audit*.35+p.training*.30+p.controls*.35)/100,0,1);
risk=clamp(base*(1-mitigation*.6),0,100);
primary=risk;secondary=100-risk;score=100-risk;
note=`Baseline ${base.toFixed(1)}/100; residual ${risk.toFixed(1)}/100. Not a compliance certification.`;break;
}
case 'battery': {
const delivered=r.capacity*r.cycles*365*(r.efficiency/100)*(1-p.degradation/100);
const gross=delivered*Math.max(0,(r.sellPrice+p.spread/100)-(r.buyPrice+p.purchaseShift/100));
const fees=delivered*p.fees/100;
primary=gross-fees-r.annualOp;
secondary=primary>0?r.capex/primary:0;
risk=clamp(primary<=0?100:secondary/15*100,0,100);
score=primary/Math.max(1,r.capex);
note=`Delivered ${delivered.toFixed(0)} kWh/year; payback ${primary>0?secondary.toFixed(1)+' y':'not reached'}.`;
break;
}
case 'trade': {
const goods=r.quantity*r.fob*(1+p.fx/100);
const shipping=r.freight*(1+p.freight/100);
const duty=goods*clamp(r.duty+p.tariff,0,100)/100;
const insurance=goods*r.insurance/100;
primary=goods+shipping+duty+insurance;
secondary=primary/r.quantity;
risk=clamp((r.leadDays/120*40)+((r.duty+p.tariff)/100*60),0,100);
score=100-risk;
note=`Goods $${goods.toFixed(0)}; freight $${shipping.toFixed(0)}; duty $${duty.toFixed(0)} (VAT excluded).`;
break;
}
}
return {r,primary,secondary,risk,score,note};
});
// Training allocation is portfolio-wide: rank by benefit/cost and fund courses under the cap.
if(kind==='talent'){
let remaining=p.budget;
for(const x of [...out].sort((a,b)=>b.score-a.score)){
const seats=Math.ceil(x.r.employees*p.uptake/100);
const selected=Math.min(seats,Math.floor(remaining/Math.max(1,x.r.trainingCost)));
const fraction=seats?selected/seats:0;
x.primary*=fraction;x.secondary=selected*x.r.trainingCost;
x.risk=clamp(100*Math.max(0,x.r.target-x.r.skill-(x.r.target-x.r.skill)*(p.efficacy/100)*fraction)/Math.max(1,x.r.target),0,100);
x.note=`${selected}/${seats} seats funded; training cost $${x.secondary.toFixed(0)}.`;
remaining-=x.secondary;
}
}
return out;
}
function summary(out){const total=k=>out.reduce((a,x)=>a+x[k],0);return [total('primary'),total('secondary'),out.length?total('risk')/out.length:0,out.length];}
function viewStats(out){const s=summary(out),md=CFG.metrics.concat([['Active records','num']]);return `<div class="metrics">${md.map((d,i)=>`<div class="card metric"><div class="small">${esc(d[0])}</div><strong>${fmt(s[i],d[1])}</strong><div class="foot">${i===3?'Editable synthetic observations':i===2?'Scenario-adjusted diagnostic':'Derived from active records'}</div><i></i></div>`).join('')}</div>`;}
function bars(out){const records=[...out].sort((a,b)=>b.primary-a.primary).slice(0,7);const max=Math.max(1,...records.map(x=>Math.abs(x.primary)));return records.map(x=>`<div class="barrow"><span class="nm" title="${esc(x.r.name)}">${esc(x.r.name)}</span><div class="track"><div class="fill" style="width:${clamp(Math.abs(x.primary)/max*100,1,100).toFixed(2)}%"></div></div><span class="val">${fmt(x.primary,CFG.metrics[0][1])}</span></div>`).join('')||'<div class="empty">No active records.</div>';}
function spark(out){const y=out.slice(0,15).map(x=>x.primary),n=y.length;if(n<2)return '';const min=Math.min(...y),max=Math.max(...y),range=max-min||1;const point=y.map((v,i)=>[(i/(n-1)*620+4).toFixed(1),(145-(v-min)/range*112).toFixed(1)]);const poly=point.map(p=>p.join(',')).join(' ');return `<svg class="chartsvg" viewBox="0 0 630 180" role="img" aria-label="Calculated record value trend"><defs><linearGradient id="area" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="var(--accent)" stop-opacity=".31"/><stop offset="100%" stop-color="var(--accent)" stop-opacity="0"/></linearGradient></defs><path d="M ${point[0][0]} 160 L ${point.map(p=>p.join(' ')).join(' L ')} L ${point[n-1][0]} 160 Z" fill="url(#area)"/><polyline points="${poly}" stroke="var(--accent)" stroke-width="3" fill="none" stroke-linejoin="round" stroke-linecap="round"/>${point.map(p=>`<circle cx="${p[0]}" cy="${p[1]}" r="3.5" fill="var(--accent)"/>`).join('')}<line x1="0" y1="160" x2="630" y2="160" stroke="var(--line)" /></svg>`;}
function presets(){return `<div class="preset-list">${CFG.presets.map((x,i)=>`<button class="preset ${state.preset===i?'selected':''}" data-preset="${i}"><b>${esc(x.name)}</b><small>${esc(x.desc)}</small></button>`).join('')}</div>`;}
function sliderHTML(){return CFG.controls.map(c=>`<div class="control"><div class="controlhead"><label for="ctrl-${c.key}">${esc(c.label)}</label><output id="out-${c.key}">${fmt(state.controls[c.key])}${esc(c.unit)}</output></div><input aria-label="${esc(c.label)}" id="ctrl-${c.key}" type="range" data-ctrl="${c.key}" min="${c.min}" max="${c.max}" step="${c.step}" value="${state.controls[c.key]}"></div>`).join('');}
function overview(out){const highest=[...out].sort((a,b)=>b.risk-a.risk)[0];return `${viewStats(out)}<div class="columns"><section class="card panel"><div class="panel-head"><div><h2>Scenario-adjusted results</h2><div class="sub">Ranked by ${esc(CFG.metrics[0][0].toLowerCase())}</div></div><span class="badge">${esc(CFG.presets[state.preset]?.name||'Custom')} case</span></div>${bars(out)}</section><div class="stack"><section class="card panel"><div class="panel-head"><div><h2>Portfolio signal</h2><div class="sub">Calculated directly in your browser</div></div><span class="badge">LIVE MODEL</span></div>${spark(out)}<div class="stat-pair"><span>Records analyzed</span><b>${out.length}</b></div><div class="stat-pair"><span>Highest diagnostic risk</span><b>${out.length?fmt(Math.max(...out.map(x=>x.risk))):'0'}</b></div><div class="stat-pair"><span>Current scenario</span><b>${esc(CFG.presets[state.preset]?.name||'Custom')}</b></div></section><section class="card panel"><h2>Analyst interpretation</h2><p class="sub">${esc(CFG.insight)}</p><div class="insight"><strong>Focus:</strong> ${highest?esc(highest.r.name)+' · '+esc(highest.note):'Add an active record to begin.'}</div><p class="note">Everything here is a what-if simulation with fictitious inputs.</p></section></div></div>`;}
function lab(out){return `<div class="section-title"><h2>Scenario laboratory</h2><p>Choose a scenario preset or adjust parameters. Results update immediately.</p></div><section class="card panel"><div class="panel-head"><div><h2>Explore different assumptions</h2><div class="sub">Four model-specific scenarios</div></div><span class="badge">INTERACTIVE</span></div>${presets()}</section><div class="lab-cols" style="margin-top:17px"><section class="card panel"><div class="panel-head"><div><h2>Scenario controls</h2><div class="sub">Only illustrative settings, no external APIs</div></div><button class="btn tiny" id="resetCtrl">↻ Reset</button></div>${sliderHTML()}</section><section class="card panel"><div class="panel-head"><div><h2>Results under this scenario</h2><div class="sub">Live recomputation</div></div><span class="badge">${out.length} records</span></div>${bars(out.slice(0,8))}</section></div><section class="card panel" style="margin-top:17px"><h2>Model trace</h2><p class="sub">Top diagnostic examples for the current assumptions</p><ul class="small-list">${[...out].sort((a,b)=>b.risk-a.risk).slice(0,5).map(x=>`<li><b>${esc(x.r.name)}</b><span>${esc(x.note)}</span></li>`).join('')}</ul></section>`;}
function fieldCell(x,f){const val=x.r[f.key];return `<td>${f.kind==='number'?fmt(num(val)):esc(val)}</td>`;}
function recordsView(){const all=state.records.filter(r=>Object.values(r).some(v=>typeof v==='string'&&v.toLowerCase().includes(state.search.toLowerCase())));return `<div class="section-title"><h2>Records library</h2><p>Edit assumptions, archive or restore records, and export all work without an account.</p></div><section class="card panel"><div class="toolbar"><input id="search" class="search" type="search" placeholder="Search records..." aria-label="Search records" value="${esc(state.search)}"><div class="actions"><button class="btn" id="importBtn">↑ Import JSON</button><button class="btn" id="resetBtn">↻ Restore demo</button><button class="btn primary" id="addBtn">+ New record</button></div></div><div class="tablewrap"><table><thead><tr><th>STATUS</th>${CFG.fields.slice(0,5).map(f=>`<th>${esc(f.label)}</th>`).join('')}<th>ACTIONS</th></tr></thead><tbody>${all.map(r=>`<tr><td><span class="pill ${r.archived?'bad':''}">${r.archived?'Archived':'Active'}</span></td>${CFG.fields.slice(0,5).map(f=>fieldCell({r},f)).join('')}<td><div class="rowact"><button class="btn tiny" data-edit="${esc(r.id)}">Edit</button><button class="btn tiny ${r.archived?'':'warn'}" data-archive="${esc(r.id)}">${r.archived?'Restore':'Archive'}</button></div></td></tr>`).join('')||'<tr><td colspan="8" class="empty">No matching records.</td></tr>'}</tbody></table></div><p class="note">CSV spreadsheets guard against formula injection. JSON restores validate all fields before replacing records.</p></section>`;}
function methodology(){return `<div class="section-title"><h2>Methodology & data</h2><p>Transparent assumptions, limits, and privacy.</p></div><div class="methods">${CFG.methodology.map((x,i)=>`<article class="method-card"><div class="number">METHOD ${String(i+1).padStart(2,'0')}</div><p>${esc(x)}</p></article>`).join('')}<article class="method-card"><div class="number">LOCAL-FIRST ARCHITECTURE</div><p>Native HTML, CSS, and JavaScript. Records are stored in this browser, and CSV/JSON exports run offline without an API key.</p></article></div><div class="disclaimer"><strong>Limitations.</strong> All observations, rates, names, metrics and outcomes are synthetic, illustrative, and for training and prototyping. This project is not an operational, scientific, regulatory, medical, legal, or investment decision system. Validate actual decisions against primary data and relevant experts.</div><section class="card panel" style="margin-top:17px"><h2>Data controls</h2><p class="sub">Export a JSON backup before clearing your browser. Import validates numeric ranges and rejects invalid record shapes. Reset restores the demo dataset.</p><div class="actions" style="justify-content:flex-start;margin-top:15px"><button id="methodJSON" class="btn primary">Download backup</button><button id="methodImport" class="btn">Import JSON</button><button id="methodCSV" class="btn">Export CSV</button></div></section>`;}
function paint(){let all=model(state.records,state.controls);const target=BYID('page');if(state.page==='overview')target.innerHTML=overview(all);else if(state.page==='lab')target.innerHTML=lab(all);else if(state.page==='records')target.innerHTML=recordsView();else target.innerHTML=methodology();document.querySelectorAll('.navbtn').forEach(b=>b.classList.toggle('active',b.dataset.page===state.page));}
function download(filename,content,type){const blob=new Blob([content],{type}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=filename;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),800);}
function backup(){download(CFG.slug+'-backup.json',JSON.stringify({project:CFG.number,version:1,records:state.records,controls:state.controls},null,2),'application/json');toast('JSON backup downloaded.');}
function csvSafe(value){let s=String(value??'');if(/^[\s]*[=+@\-\t\r]/.test(s))s="'"+s;return '"'+s.replaceAll('"','""')+'"';}
function csv(){const out=model(state.records,state.controls),head=['id','archived',...CFG.fields.map(f=>f.key),'metric_primary','metric_secondary','diagnostic_risk'];const lines=[head.join(','),...out.map(x=>[csvSafe(x.r.id),'false',...CFG.fields.map(f=>f.kind==='text'?csvSafe(x.r[f.key]):x.r[f.key]),x.primary.toFixed(5),x.secondary.toFixed(5),x.risk.toFixed(5)].join(','))];download(CFG.slug+'-results.csv',lines.join('\r\n'),'text/csv;charset=utf-8');toast('Calculated CSV exported.');}
function openEdit(id){editId=id||null;const r=id?state.records.find(x=>x.id===id):null;BYID('editTitle').textContent=r?'Edit '+r.name:'Create record';BYID('fields').innerHTML=CFG.fields.map(f=>`<div class="field"><label for="f-${esc(f.key)}">${esc(f.label)}</label><input id="f-${esc(f.key)}" name="${esc(f.key)}" type="${f.kind==='text'?'text':'number'}" ${f.kind==='number'?`min="${f.min}" max="${f.max}" step="any"`:'maxlength="140"'} value="${esc(r?r[f.key]:f.kind==='text'?'':Math.max(f.min,Math.min(f.max,1)))}" ${f.key==='deps'?'':'required'}></div>`).join('');BYID('modal').classList.add('show');BYID('fields').querySelector('input')?.focus();}
function closeEdit(){BYID('modal').classList.remove('show');}
function editSubmit(e){e.preventDefault();const fd=new FormData(BYID('editForm')),existing=state.records.find(r=>r.id===editId);const raw={id:editId||'R'+Date.now().toString(36)+'_'+Math.random().toString(36).slice(2,5),archived:existing?.archived||false};for(const f of CFG.fields){let v=fd.get(f.key);raw[f.key]=f.kind==='text'?String(v||'').trim():Number(v);}const next=validRecord(raw);if(!next){toast('Invalid record. Review required fields and numeric limits.');return;}const candidate=existing?state.records.map(r=>r.id===editId?next:r):[...state.records,next];if(CFG.model==='lineage'&&!validGraph(candidate)){toast('Invalid dependency graph, unknown asset code or circular reference.');return;}if(candidate.length>150){toast('Maximum 150 records.');return;}state.records=candidate;closeEdit();save();paint();toast(existing?'Record updated.':'New record added.');}
async function importJSON(file){if(!file)return;if(file.size>1024*1024){toast('JSON is too large (max 1 MB).');return;}try{const text=await file.text(),parsed=JSON.parse(text);if(parsed?.project!==CFG.number){toast('This backup belongs to another project.');return;}const rows=validRecords(parsed.records);if(!rows){toast('Backup rejected: invalid records or graph.');return;}let next=defaultControls();if(parsed.controls&&typeof parsed.controls==='object')for(const c of CFG.controls){const v=parsed.controls[c.key];if(typeof v!=='number'||!Number.isFinite(v)||v<c.min||v>c.max){toast('Backup rejected: invalid scenario values.');return;}next[c.key]=v;}state.records=rows;state.controls=next;state.preset=-1;save();paint();toast('Validated backup successfully imported.');}catch(e){toast('Invalid JSON: original records were preserved.');}}
BYID('navbox').addEventListener('click',e=>{const b=e.target.closest('[data-page]');if(b){state.page=b.dataset.page;paint();window.scrollTo({top:0,behavior:'instant'});}});
BYID('page').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.preset!==undefined){const i=+b.dataset.preset;state.preset=i;CFG.controls.forEach((c,j)=>state.controls[c.key]=CFG.presets[i].values[j]);save();paint();}if(b.id==='resetCtrl'){state.controls=defaultControls();state.preset=0;save();paint();}if(b.id==='addBtn')openEdit(null);if(b.dataset.edit)openEdit(b.dataset.edit);if(b.dataset.archive){state.records=state.records.map(x=>x.id===b.dataset.archive?{...x,archived:!x.archived}:x);save();paint();toast('Record status updated.');}if(b.id==='importBtn'||b.id==='methodImport')BYID('importFile').click();if(b.id==='resetBtn'){if(confirm('Restore all original sample records? Export JSON first to preserve your edits.')){state.records=base();state.controls=defaultControls();state.preset=0;state.search='';save();paint();toast('Demo records restored.');}}if(b.id==='methodJSON')backup();if(b.id==='methodCSV')csv();});
BYID('page').addEventListener('input',e=>{if(e.target.dataset.ctrl){const k=e.target.dataset.ctrl;state.controls[k]=+e.target.value;state.preset=-1;save();paint();}if(e.target.id==='search'){const p=e.target.selectionStart??e.target.value.length;state.search=e.target.value;paint();BYID('search')?.focus();BYID('search')?.setSelectionRange(p,p);}});
BYID('jsonBtn').addEventListener('click',backup);BYID('csvBtn').addEventListener('click',csv);BYID('themeBtn').addEventListener('click',()=>{state.theme=state.theme==='dark'?'light':'dark';document.body.classList.toggle('light',state.theme==='light');save();});BYID('closeModal').addEventListener('click',closeEdit);BYID('cancelModal').addEventListener('click',closeEdit);BYID('modal').addEventListener('click',e=>{if(e.target===BYID('modal'))closeEdit();});document.addEventListener('keydown',e=>{if(e.key==='Escape')closeEdit();});BYID('editForm').addEventListener('submit',editSubmit);
BYID('importFile').addEventListener('change',async e=>{const f=e.target.files?.[0];await importJSON(f);e.target.value='';});
load();paint();
</script></body></html>