Hugdrigo's picture
Upload 3 files
ffa63d1 verified
Raw History Blame Contribute Delete
49.9 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="#08111e"><meta name="description" content="RouteForge: reproducible fleet and delivery route optimization in your browser.">
<title>RouteForge | Fleet Optimization Studio</title>
<style>
:root{--bg:#07111d;--side:#0b1625;--panel:#0e1c2e;--panel2:#112338;--border:#24354a;--muted:#93a7bb;--text:#ebf4fa;--mint:#65e7b1;--cyan:#54c5ed;--orange:#ffb977;--red:#ff7f94;--purple:#b2a4ff;--shadow:0 18px 55px #0005}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:radial-gradient(ellipse at 80% 0%,#102d3e 0%,transparent 42%),var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}button,input,select{font:inherit}button{cursor:pointer}a{color:var(--mint)}button:focus-visible,input:focus-visible,select:focus-visible,.navlink:focus-visible{outline:2px solid var(--mint);outline-offset:3px}
.app-shell{display:grid;grid-template-columns:253px minmax(0,1fr);min-height:100vh}.sidebar{border-right:1px solid var(--border);background:linear-gradient(165deg,#0d1b2b,#0a1727);padding:26px 15px 20px;position:sticky;top:0;height:100vh;display:flex;flex-direction:column;z-index:40}.brand{display:flex;align-items:center;gap:11px;padding:3px 9px 28px}.brand-mark{display:grid;place-items:center;border:1px solid #508f87;background:#163e40;width:43px;height:43px;border-radius:14px;color:var(--mint);font-size:24px;font-weight:900}.brand-title{font-weight:830;font-size:20px;letter-spacing:-.7px}.brand-sub{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:1.9px}.sidebar-label{font-size:10px;letter-spacing:2px;color:#6c859b;text-transform:uppercase;font-weight:800;padding:5px 12px 13px}.navlink{width:100%;border:1px solid transparent;border-radius:10px;padding:13px 12px;display:flex;gap:12px;align-items:center;text-align:left;background:transparent;color:#a8bbcf;font-weight:670;margin:2px 0}.navlink .nav-icon{font-size:18px;width:22px;text-align:center}.navlink:hover{background:#1a2e40}.navlink.active{background:#173d3f;border-color:#356e64;color:#c1ffe3}.side-bottom{margin-top:auto;border-top:1px solid var(--border);padding:17px 11px 0;font-size:11px;line-height:1.8;color:var(--muted)}.side-bottom b{color:var(--mint)}.main{min-width:0;padding:25px clamp(20px,3vw,45px) 50px;max-width:1680px;width:100%;margin:auto}.topbar{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:26px}.crumb{color:#a3bacd;font-size:12px;letter-spacing:.45px}.crumb strong{color:var(--text)}.top-actions{display:flex;gap:10px;align-items:center}.status{display:inline-flex;align-items:center;gap:7px;border:1px solid #26695c;background:#123b34;color:#8cf9c7;font-size:11px;padding:8px 12px;border-radius:30px;font-weight:750}.dot{width:7px;height:7px;border-radius:50%;background:var(--mint);box-shadow:0 0 0 3px #65e7b122}.btn{border:1px solid #3b5668;background:#183148;color:#def7ff;padding:10px 15px;border-radius:10px;font-weight:750;white-space:nowrap}.btn:hover{filter:brightness(1.12)}.btn.primary{background:var(--mint);border-color:var(--mint);color:#09231d}.btn.ghost{background:transparent}.btn.small{font-size:12px;padding:8px 11px}.btn:disabled{opacity:.4;cursor:default}.hero{display:flex;justify-content:space-between;gap:22px;align-items:flex-end;margin-bottom:23px}.eyebrow{font-size:11px;font-weight:800;letter-spacing:2px;color:var(--mint);text-transform:uppercase;margin-bottom:8px}.hero h1{margin:0;font-size:clamp(26px,3vw,40px);letter-spacing:-1.6px;line-height:1.14}.subhead{max-width:820px;color:var(--muted);margin:12px 0 0}.hero-side{text-align:right;color:var(--muted);font-size:12px;min-width:190px}.hero-side strong{display:block;color:var(--text);font-size:14px}.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:18px}.stat,.panel,.note{border:1px solid var(--border);background:linear-gradient(160deg,#112235,#0b1a2b);box-shadow:var(--shadow);border-radius:15px}.stat{padding:17px 18px;min-height:125px;position:relative;overflow:hidden}.stat:before{content:'';position:absolute;inset:0 auto 0 0;width:3px;background:var(--mint)}.stat:nth-child(2):before{background:var(--cyan)}.stat:nth-child(3):before{background:var(--purple)}.stat:nth-child(4):before{background:var(--orange)}.stat .lab{color:#a4b9c9;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1.15px}.stat .big{font-size:31px;line-height:1.25;font-weight:830;letter-spacing:-1.2px;margin:10px 0 3px}.stat small{color:var(--muted);font-size:11px}.two{display:grid;grid-template-columns:1.6fr 1fr;gap:18px}.two.equal{grid-template-columns:1fr 1fr}.panel{padding:22px;min-width:0;margin-bottom:17px}.panel h2{font-size:15px;margin:0 0 4px;letter-spacing:-.25px}.panel p.hint{margin:0 0 17px;color:var(--muted);font-size:12px}.panel-head{display:flex;justify-content:space-between;align-items:start;gap:12px;margin-bottom:15px}.panel-head h2{font-size:16px}.badge{display:inline-block;border-radius:7px;border:1px solid #2c6459;color:#96e7bf;padding:4px 8px;background:#12342e;font-size:10px;font-weight:750}.badge.warning{border-color:#7f4f34;color:#ffc994;background:#402b23}.legend{display:flex;gap:12px;flex-wrap:wrap;font-size:11px;color:var(--muted);align-items:center}.legend-dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:4px}.map-outer{position:relative;overflow:hidden;border:1px solid #2b4657;background:radial-gradient(circle at 78% 20%,#123b40 0%,transparent 38%),#091c2a;border-radius:12px;padding:6px;min-height:270px}.map-outer svg{width:100%;height:auto;display:block;max-height:435px}.map-overlay{position:absolute;bottom:14px;left:15px;background:#0b1a2ce8;border:1px solid #365367;color:#d7e7f4;font-size:11px;padding:8px 11px;border-radius:9px;max-width:65%}.axis-text{fill:#6e8699;font:11px system-ui}.location-label{font:11px system-ui;fill:#b8d5db;paint-order:stroke;stroke:#0a1b2a;stroke-width:3px;stroke-linejoin:round}.map-node{cursor:pointer}.map-node:focus-visible{outline:none;filter:drop-shadow(0 0 4px #fff)}.route-card{display:flex;gap:12px;padding:12px;border:1px solid var(--border);border-radius:11px;margin-bottom:9px;background:#0b1b2b}.route-card.selected{border-color:var(--mint);background:#133239}.rnum{width:32px;height:32px;flex:none;border-radius:9px;display:grid;place-items:center;background:#21475a;font-weight:800}.route-card strong{font-size:12px}.route-card small{color:var(--muted);font-size:11px}.route-card button{border:0;background:transparent;color:inherit;text-align:left;flex:1;min-width:0;padding:0}.barrow{display:grid;grid-template-columns:90px 1fr 55px;gap:10px;align-items:center;margin:12px 0;font-size:11px;color:#c4d5e3}.track{height:9px;background:#22394d;border-radius:20px;overflow:hidden}.fill{height:100%;border-radius:20px}.metric-row{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #24364a;padding:12px 0;font-size:12px;gap:12px}.metric-row:last-child{border-bottom:0}.metric-row span{color:var(--muted)}.metric-row strong{font-variant-numeric:tabular-nums}.empty{border:1px dashed #446477;background:#102538;color:#bbd5e5;padding:21px;border-radius:12px;margin:15px 0}.empty strong{color:#ffd1a0}.control-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:17px}.control{background:#10263a;border:1px solid #263c52;border-radius:12px;padding:14px;min-width:0}.control label{display:flex;justify-content:space-between;gap:12px;font-size:12px;font-weight:700;margin-bottom:10px}.control label span{font-variant-numeric:tabular-nums;color:var(--mint)}input[type=range]{width:100%;accent-color:var(--mint);cursor:pointer}.range-foot{display:flex;justify-content:space-between;color:#7f97a9;font-size:10px}.section{display:none}.section.active{display:block}.table-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:10px}table{width:100%;border-collapse:collapse;text-align:left;min-width:480px;font-size:12px}th{color:#9fb9ce;background:#193047;text-transform:uppercase;font-size:10px;letter-spacing:.7px}td,th{padding:11px 13px;border-bottom:1px solid #2b3f51}tr:last-child td{border-bottom:0}td strong{color:#e8f5fa}td.num{text-align:right;font-variant-numeric:tabular-nums}.note{padding:17px 19px;margin:0 0 17px;color:#c1d5e2;font-size:12px}.note strong{color:var(--mint)}.note.warn{border-color:#72502f;background:#2c2524}.note.warn strong{color:#ffce9d}.download-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}.download-item{border:1px solid #2d495a;padding:14px;border-radius:11px;background:#12263a;color:#e1f8f6;text-align:left}.download-item:hover{border-color:var(--mint)}.download-item span{display:block;color:var(--muted);font-size:11px;margin-top:4px}.upload{border:1.5px dashed #577586;padding:20px;border-radius:12px;background:#102639}.upload input{width:100%;margin:10px 0 0;font-size:12px;max-width:100%}.upload small{display:block;color:#91acc2;margin-top:8px}.flash{position:fixed;right:26px;bottom:26px;z-index:100;background:#163b40;color:#c5ffe9;border:1px solid #427e70;border-radius:10px;padding:13px 18px;max-width:380px;box-shadow:var(--shadow)}.flash.error{background:#3d222a;color:#ffd4dc;border-color:#945063}.hero-pill{background:#1c3645;border:1px solid #3b596c;border-radius:30px;color:#a3c9d5;padding:6px 11px;font-size:11px;font-weight:700}.footer{color:#748fa3;font-size:11px;text-align:center;margin-top:27px}.mobile-menu{display:none}.subtle{color:var(--muted);font-size:12px}.policy-list{display:grid;grid-template-columns:repeat(3,1fr);gap:11px}.policy{padding:14px;border:1px solid var(--border);border-radius:10px;background:#12283b}.policy strong{font-size:12px}.policy p{margin:5px 0 0;color:var(--muted);font-size:11px}
@media(max-width:1100px){.app-shell{grid-template-columns:215px minmax(0,1fr)}.main{padding:22px 18px}.stats{grid-template-columns:repeat(2,minmax(0,1fr))}.two{grid-template-columns:1fr}.policy-list{grid-template-columns:1fr 1fr}}
@media(max-width:720px){.app-shell{display:block}.sidebar{position:fixed;transform:translateX(-110%);transition:transform .22s;height:100dvh;width:250px;box-shadow:0 0 30px #0008}.sidebar.open{transform:translateX(0)}.mobile-menu{display:inline-flex}.main{padding:16px 13px 35px}.topbar{margin-bottom:17px}.top-actions .status{display:none}.hero{display:block}.hero-side{text-align:left;margin-top:10px}.hero h1{font-size:30px}.stats{gap:9px}.stat{padding:13px;min-height:106px}.stat .big{font-size:24px}.panel{padding:15px;margin-bottom:11px}.control-grid,.two.equal,.download-grid,.policy-list{grid-template-columns:1fr}.legend{gap:6px}.barrow{grid-template-columns:70px 1fr 45px}.map-overlay{max-width:85%}.top-actions .btn{padding:9px 10px}.btn{font-size:12px}.hero-pill{display:inline-block}.footer{margin-top:15px}}
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}.sidebar{transition:none}}
</style></head>
<body><div class="app-shell">
<aside class="sidebar" id="sidebar">
<div class="brand"><div class="brand-mark">↗</div><div><div class="brand-title">RouteForge</div><div class="brand-sub">Logistics intelligence</div></div></div>
<div class="sidebar-label">Decision modules</div>
<nav aria-label="Main navigation">
<button class="navlink active" data-page="overview"><span class="nav-icon">◫</span>Command center</button>
<button class="navlink" data-page="network"><span class="nav-icon">◎</span>Network explorer</button>
<button class="navlink" data-page="optimizer"><span class="nav-icon">⌁</span>Fleet optimizer</button>
<button class="navlink" data-page="stress"><span class="nav-icon">▤</span>Scenario laboratory</button>
<button class="navlink" data-page="equity"><span class="nav-icon">◇</span>Coverage & equity</button>
<button class="navlink" data-page="data"><span class="nav-icon">▣</span>Data & methodology</button>
</nav>
<div class="side-bottom"><b>STATIC / LOCAL-FIRST</b><br>Exact capacity-constrained routing<br>Up to 9 delivery locations<br>No external APIs or data transfer<br><span style="color:#5e9f94">Research demonstration · v1.0</span></div>
</aside>
<main class="main">
<div class="topbar"><div style="display:flex;align-items:center;gap:10px"><button class="btn ghost mobile-menu" id="menuBtn" aria-label="Open navigation">☰</button><div class="crumb">PORTFOLIO / PROJECT 46 / <strong id="crumbName">COMMAND CENTER</strong></div></div><div class="top-actions"><span class="status"><span class="dot"></span> ENGINE READY</span><button class="btn small" id="resetBtn">Reset</button><button class="btn primary small" id="optBtn">↻ Optimize fleet</button></div></div>
<div class="hero"><div><div class="eyebrow">PROJECT 046 · DECISION INTELLIGENCE</div><h1 id="pageTitle">Fleet decisions, optimized.</h1><p class="subhead" id="pageSubtitle">Transparent, exact optimization of delivery routes and vehicle allocation, with capacity limits, time budgets and costs.</p></div><div class="hero-side"><span class="hero-pill">REPRODUCIBLE SIMULATION</span><strong id="scenarioLabel">Metro distribution</strong>All locations &amp; costs are synthetic</div></div>
<div id="notice" aria-live="polite"></div>
<section id="overview" class="section active"><div id="overviewBody"></div></section>
<section id="network" class="section"><div id="networkBody"></div></section>
<section id="optimizer" class="section"><div id="optimizerBody"></div></section>
<section id="stress" class="section"><div id="stressBody"></div></section>
<section id="equity" class="section"><div id="equityBody"></div></section>
<section id="data" class="section"><div id="dataBody"></div></section>
<div class="footer">ROUTEFORGE · PROJECT 46 · BROWSER-ONLY OPTIMIZATION · SYNTHETIC DEMONSTRATIONS · MIT LICENSE</div>
</main></div><div id="toast" role="status" aria-live="polite"></div>
<script>
/* RouteForge analytics core. No external dependencies. */
const RF = (() => {
const depot={id:'DEPOT',name:'Central depot',x:14,y:52,demand:0,service:0,priority:'Depot'};
const scenarios={
urban:{name:'Metro distribution',subtitle:'Dense urban delivery demand',depot:{...depot},stops:[
['A01','North Market',30,17,9,14,'Essential'],['A02','Civic Hub',49,28,6,12,'Standard'],
['A03','River Park',76,17,8,13,'Essential'],['A04','Health Centre',68,52,11,15,'Essential'],
['A05','South Station',38,77,7,11,'Standard'],['A06','Tech Quarter',85,74,8,12,'Standard'],
['A07','Old Town',19,35,5,13,'Essential'],['A08','East Point',88,39,6,10,'Standard']
]},
highland:{name:'Highland supply routes',subtitle:'Low-density long-distance distribution',depot:{id:'DEPOT',name:'Mountain base',x:10,y:78},stops:[
['H01','Hilltop Clinic',21,29,8,20,'Essential'],['H02','Ridge School',35,15,7,17,'Essential'],
['H03','West Village',12,47,6,18,'Standard'],['H04','East Village',64,20,5,18,'Standard'],
['H05','Upper Valley',79,42,8,23,'Essential'],['H06','Reservoir',54,70,7,15,'Essential'],
['H07','Transit Hub',84,82,9,12,'Standard'],['H08','Community Centre',34,51,6,16,'Standard']
]},
service:{name:'Field service visits',subtitle:'Technician visits and service windows',depot:{id:'DEPOT',name:'Service dispatch',x:18,y:48},stops:[
['S01','Network Tower',33,17,6,24,'Essential'],['S02','Municipal Office',61,19,5,20,'Standard'],
['S03','Library Site',89,32,4,22,'Standard'],['S04','Power Substation',75,65,9,24,'Essential'],
['S05','Logistics Hall',35,79,8,19,'Standard'],['S06','Water Station',13,82,7,20,'Essential'],
['S07','Hospital Router',46,43,5,27,'Essential'],['S08','Remote Sensor',90,86,6,18,'Standard']
]}
};
for (const v of Object.values(scenarios)) v.stops=v.stops.map(a=>Object.fromEntries(['id','name','x','y','demand','service','priority'].map((k,i)=>[k,a[i]])));
function options(o={}) {return {fleet:4,capacity:22,maxMinutes:220,traffic:1.1,speed:31,kmUnit:0.39,kmCost:1.4,vehicleCost:36,hourCost:18,...o};}
const km=(a,b,opts)=>Math.hypot(a.x-b.x,a.y-b.y)*opts.kmUnit;
const round=(x,d=1)=>Number(x.toFixed(d));
function validate(stops, dep=depot){
if (!Array.isArray(stops)||stops.length<2||stops.length>9) throw Error('Provide 2–9 delivery stops.');
let ids=new Set();
for(const s of [dep,...stops]){
if(!s||typeof s.id!=='string'||!s.id.trim()||ids.has(s.id)) throw Error('Each location needs a unique, nonempty ID.');
ids.add(s.id);
if(typeof s.name!=='string'||!s.name.trim()||s.name.length>100)throw Error('Location names must contain 1–100 characters.');
if(!Number.isFinite(s.x)||!Number.isFinite(s.y)||s.x<0||s.x>100||s.y<0||s.y>100)throw Error('Coordinates must be between 0 and 100.');
}
for(const s of stops){
if(!Number.isInteger(s.demand)||s.demand<1||s.demand>40)throw Error('Demand must be a whole number between 1 and 40.');
if(!Number.isFinite(s.service)||s.service<0||s.service>60)throw Error('Service time must be 0–60 minutes.');
if(!['Essential','Standard'].includes(s.priority))throw Error('Priority must be Essential or Standard.');
}
return true;
}
function validateOptions(o){
if(!Number.isInteger(o.fleet)||o.fleet<1||o.fleet>8||!Number.isFinite(o.capacity)||o.capacity<1||o.capacity>200||!Number.isFinite(o.maxMinutes)||o.maxMinutes<20||o.maxMinutes>720||!Number.isFinite(o.traffic)||o.traffic<0.6||o.traffic>3||!Number.isFinite(o.speed)||o.speed<5||o.speed>110||!Number.isFinite(o.kmUnit)||o.kmUnit<=0||o.kmUnit>3||!Number.isFinite(o.kmCost)||o.kmCost<0||!Number.isFinite(o.vehicleCost)||o.vehicleCost<0||!Number.isFinite(o.hourCost)||o.hourCost<0)throw Error('Planner options are out of the permitted range.');
}
function enrichRoute(ids,stops,dep,o){
const members=ids.map(i=>stops[i]);let distance=0,last=dep;
for(let s of members){distance+=km(last,s,o);last=s;} distance+=km(last,dep,o);
const service=members.reduce((a,s)=>a+s.service,0), load=members.reduce((a,s)=>a+s.demand,0);
const minutes=distance/o.speed*60*o.traffic+service;
return {ids,members:members.map(s=>s.id),distance,minutes,service,load,priority:members.filter(s=>s.priority==='Essential').length,cost:distance*o.kmCost+minutes/60*o.hourCost+o.vehicleCost};
}
function optimize(stops,dep=depot,o=options()){
validate(stops,dep);validateOptions(o);
const n=stops.length,full=(1<<n)-1,size=1<<n;
// Held-Karp: shortest closed tour for every subset. Complexity O(n^2 2^n).
const dp=Array.from({length:size},()=>Array(n).fill(Infinity));
const parent=Array.from({length:size},()=>Array(n).fill(-1));
for(let j=0;j<n;j++)dp[1<<j][j]=km(dep,stops[j],o);
for(let mask=1;mask<size;mask++)for(let j=0;j<n;j++){
if(!(mask&(1<<j)))continue;
let prev=mask^(1<<j);if(!prev)continue;
for(let k=0;k<n;k++)if(prev&(1<<k)){
const v=dp[prev][k]+km(stops[k],stops[j],o);
if(v<dp[mask][j]-1e-10){dp[mask][j]=v;parent[mask][j]=k;}
}
}
let bestRoutes=Array(size).fill(null);
for(let mask=1;mask<size;mask++){
let sum=0,svc=0;for(let i=0;i<n;i++)if(mask&(1<<i)){sum+=stops[i].demand;svc+=stops[i].service;}
if(sum>o.capacity)continue;
let end=-1,v=Infinity;
for(let j=0;j<n;j++)if(mask&(1<<j)){
const d=dp[mask][j]+km(stops[j],dep,o);
if(d<v-1e-10){v=d;end=j;}
}
const minutes=v/o.speed*60*o.traffic+svc;
if(minutes>o.maxMinutes+1e-8)continue;
const ids=[];let cur=end,m=mask;
while(cur>=0){ids.push(cur);const p=parent[m][cur];m^=(1<<cur);cur=p;}
ids.reverse();bestRoutes[mask]=enrichRoute(ids,stops,dep,o);
}
// Exact optimal partition with at most fleet vehicles; avoids double counting by anchoring lowest bit.
const opt=Array.from({length:o.fleet+1},()=>Array(size).fill(Infinity));
const choice=Array.from({length:o.fleet+1},()=>Array(size).fill(0));opt[0][0]=0;
for(let v=1;v<=o.fleet;v++)for(let mask=1;mask<size;mask++){
const anchor=mask&-mask;
for(let sub=mask;sub;sub=(sub-1)&mask){
if(!(sub&anchor)||!bestRoutes[sub])continue;
const cand=opt[v-1][mask^sub]+bestRoutes[sub].cost;
if(cand<opt[v][mask]-1e-10){opt[v][mask]=cand;choice[v][mask]=sub;}
}
}
let best=Infinity,bestFleet=0;
for(let v=1;v<=o.fleet;v++)if(opt[v][full]<best-1e-10){best=opt[v][full];bestFleet=v;}
if(!Number.isFinite(best))return {feasible:false,reason:'Fleet, capacity or route duration are insufficient to serve all stops.',options:o,stops:stops.length,required:stops.reduce((s,x)=>s+x.demand,0)};
let mask=full,v=bestFleet,routes=[];
while(mask){let sub=choice[v][mask];if(!sub)throw Error('Internal partition reconstruction failed.');routes.push(bestRoutes[sub]);mask^=sub;v--;}
routes.sort((a,b)=>a.members[0].localeCompare(b.members[0]));
return aggregate(routes,stops,o,'Exact optimizer');
}
function aggregate(routes,stops,o,method){
const totalDistance=routes.reduce((a,r)=>a+r.distance,0),totalMinutes=routes.reduce((a,r)=>a+r.minutes,0);
const load=stops.reduce((a,s)=>a+s.demand,0),ess=stops.filter(s=>s.priority==='Essential').length;
return {feasible:true,method,routes,vehicles:routes.length,cost:routes.reduce((a,r)=>a+r.cost,0),totalDistance,totalMinutes,load,stops:stops.length,essential:ess,essentialShare:stops.length?ess/stops.length:0,capacityUse:load/(routes.length*o.capacity),options:o};
}
function baseline(stops,dep=depot,o=options()){
validate(stops,dep);validateOptions(o);
// Nearest feasible insertion, ordered by depot proximity, no global search.
let remaining=new Set(stops.map((_,i)=>i)),routes=[];
while(remaining.size&&routes.length<o.fleet){
const ids=[];let active=dep;
for(;;){
let nearest=-1,dist=Infinity;
for(const i of remaining){
const candidate=enrichRoute([...ids,i],stops,dep,o);
if(candidate.load>o.capacity||candidate.minutes>o.maxMinutes+1e-8)continue;
const d=km(active,stops[i],o);
if(d<dist-1e-10){dist=d;nearest=i;}
}
if(nearest<0)break;
ids.push(nearest);remaining.delete(nearest);active=stops[nearest];
}
if(!ids.length)break;
routes.push(enrichRoute(ids,stops,dep,o));
}
const result=aggregate(routes,stops,o,'Nearest-neighbor heuristic');
return {...result,feasible:remaining.size===0,unserved:Array.from(remaining).map(i=>stops[i].id),served:stops.length-remaining.size};
}
// RFC 4180 style CSV parser with quoted commas, embedded newlines and escaped quotes.
function parseCSV(input){
if(typeof input!=='string'||input.length>2e6)throw Error('CSV is missing or too large (2 MB maximum).');
let rows=[],row=[],cell='',quoted=false,afterQuote=false;
input=input.replace(/^\uFEFF/,'');
for(let i=0;i<input.length;i++){
const c=input[i];
if(quoted){if(c==='"'){if(input[i+1]==='"'){cell+='"';i++;}else{quoted=false;afterQuote=true;}}else cell+=c;}
else if(c==='"'){if(afterQuote||cell.trim()!=='')throw Error('Unexpected CSV quote.');quoted=true;}
else if(c===','){row.push(cell.trim());cell='';afterQuote=false;}
else if(c==='\n'||c==='\r'){
if(c==='\r'&&input[i+1]==='\n')i++;
row.push(cell.trim());if(row.some(v=>v!==''))rows.push(row);row=[];cell='';afterQuote=false;
}else{if(afterQuote&&!/\s/.test(c))throw Error('Unexpected text after CSV quote.');cell+=c;}
}
if(quoted)throw Error('Unclosed CSV quote.');
row.push(cell.trim());if(row.some(v=>v!==''))rows.push(row);
if(rows.length<3)throw Error('CSV needs a header and at least two stops.');
const required=['id','name','x','y','demand','service','priority'];
const names=rows.shift().map(s=>s.toLowerCase());
if(names.length!==required.length||names.some((s,i)=>s!==required[i]))throw Error('Expected columns: '+required.join(', '));
const stops=rows.map((values,i)=>{
if(values.length!==required.length)throw Error('Invalid field count on data row '+(i+2));
let [id,name,x,y,demand,service,priority]=values;
if([x,y,demand,service].some(v=>v.trim()===''||!Number.isFinite(Number(v))))throw Error('Invalid numeric value on row '+(i+2));
return {id,name,x:Number(x),y:Number(y),demand:Number(demand),service:Number(service),priority};
});
validate(stops);return stops;
}
function csvEscape(value){let s=String(value??'');if(/^[\s]*[=+@\-\t\r]/.test(s))s="'"+s;return '"'+s.replace(/"/g,'""')+'"';}
function asCSV(rows){return rows.map(r=>r.map(csvEscape).join(',')).join('\r\n')+'\r\n';}
function stress(stops,dep,o){
const factors=[0.85,1,1.2,1.45];return factors.map(f=>{const p={...o,traffic:Math.min(3,Math.max(.6,round(o.traffic*f,3)))};const r=optimize(stops,dep,p);return {factor:f,traffic:p.traffic,feasible:r.feasible,vehicles:r.vehicles??null,cost:r.feasible?r.cost:null,minutes:r.feasible?r.totalMinutes:null};});
}
function template(){return asCSV([['id','name','x','y','demand','service','priority'],['X01','Example Clinic',30,20,7,15,'Essential'],['X02','Regional Hub',60,70,9,12,'Standard']]);}
return {depot,scenarios,options,km,round,validate,validateOptions,optimize,baseline,parseCSV,asCSV,csvEscape,stress,template};
})();
(()=>{'use strict';
const $=id=>document.getElementById(id);
const esc=s=>String(s??'').replace(/[&<>"']/g,x=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[x]));
const money=n=>'$'+Number(n).toLocaleString('en-US',{minimumFractionDigits:0,maximumFractionDigits:0});
const fmt=(n,d=1)=>Number(n).toLocaleString('en-US',{minimumFractionDigits:d,maximumFractionDigits:d});
const palette=['#65e7b1','#56c1ef','#b8a4ff','#ffbe7c','#fb849c','#8ad8bf','#ffc875','#90b3ff'];
const titles={overview:['Fleet decisions, optimized.','Transparent, exact optimization of delivery routes and vehicle allocation, with capacity limits, time budgets and costs.'],network:['Explore every connection.','A local coordinate network, route geometries and stop-level information. Locations are illustrative, not real addresses.'],optimizer:['Choose the right fleet.','Exact route partitioning, reproducible constraints and a nearest-neighbor benchmark.'],stress:['Test the unexpected.','Re-optimize under traffic disruptions to see when cost grows or the available fleet becomes infeasible.'],equity:['Make coverage transparent.','Break down essential versus standard destinations, route workloads and operational constraints.'],data:['Bring your own dataset.','Import validation, auditable assumptions and reproducible JSON/CSV reports. Everything stays in your browser.']};
let state={key:'service',scenario:RF.scenarios.service,stops:RF.scenarios.service.stops.map(s=>({...s})),dep:{...RF.scenarios.service.depot},o:RF.options(),result:null,baseline:null,selected:0,page:'overview',uploadName:null};
function recalc(){state.result=RF.optimize(state.stops,state.dep,state.o);state.baseline=RF.baseline(state.stops,state.dep,state.o);state.selected=0;renderAll();}
function toast(msg,error=false){const c=$('toast');c.innerHTML='<div class="flash'+(error?' error':'')+'">'+esc(msg)+'</div>';clearTimeout(toast.t);toast.t=setTimeout(()=>c.innerHTML='',4200);}
function setScenario(k){if(!RF.scenarios[k])return;const sc=RF.scenarios[k];state.key=k;state.scenario=sc;state.stops=sc.stops.map(s=>({...s}));state.dep={...sc.depot};state.uploadName=null;recalc();toast('Loaded '+sc.name+'.');}
function page(name){state.page=name;document.querySelectorAll('.section').forEach(e=>e.classList.toggle('active',e.id===name));document.querySelectorAll('.navlink').forEach(e=>e.classList.toggle('active',e.dataset.page===name));$('sidebar').classList.remove('open');$('crumbName').textContent=name==='data'?'DATA & METHODOLOGY':name.toUpperCase();$('pageTitle').textContent=titles[name][0];$('pageSubtitle').textContent=titles[name][1];window.scrollTo({top:0,behavior:'instant'});renderAll();}
function stat(label,value,sub){return '<div class="stat"><div class="lab">'+esc(label)+'</div><div class="big">'+value+'</div><small>'+esc(sub)+'</small></div>';}
function stats(){const r=state.result,b=state.baseline;return '<div class="stats">'+(r.feasible?
stat('Optimized cost',money(r.cost),'Cost model · USD, synthetic')+stat('Fleet required',r.vehicles+' / '+state.o.fleet,'Vehicles assigned / available')+stat('Total driving',fmt(r.totalDistance)+' km','Road distance approximated')+stat('Delivery coverage','100%','All '+r.stops+' stops assigned'):
stat('Optimization status','No plan','Current constraints infeasible')+stat('Available vehicles',state.o.fleet,'Increase fleet or capacity')+stat('Demand',r.required,'Total cargo units required')+stat('Covered stops','0 / '+state.stops.length,'Full coverage required for a plan'))+'</div>';
}
function resultNotice(){if(!state.result.feasible)return '<div class="note warn"><strong>No feasible fleet plan.</strong> '+esc(state.result.reason)+' Increase capacity, vehicles or maximum route duration in Fleet optimizer. No stops have been silently excluded.</div>';return '<div class="note"><strong>Decision-ready baseline.</strong> Exact minimum of the documented travel, driver-hour and vehicle costs, for the selected fleet constraints. '+(state.baseline.feasible?'Nearest-neighbor alternative: '+money(state.baseline.cost)+'.':'The simple greedy heuristic cannot serve all stops under these limits.')+'</div>';}
function routeCards(routes=state.result.routes||[]){return routes.map((r,i)=>'<div class="route-card '+(i===state.selected?'selected':'')+'"><div class="rnum" style="color:'+palette[i]+'">'+(i+1)+'</div><button data-route="'+i+'"><strong>Vehicle '+(i+1)+' · '+r.members.length+' stops</strong><br><small>'+esc(r.members.join(' → '))+'</small><br><small>'+fmt(r.distance)+' km · '+fmt(r.minutes,0)+' min · '+r.load+' cargo units</small></button><div style="font-size:13px;font-weight:800">'+money(r.cost)+'</div></div>').join('')||'<p class="subtle">No feasible route under the current limits.</p>';}
function coords(s){return {x:45+s.x*6.8,y:33+s.y*3.5};}
function mapSvg(){const r=state.result,dep=state.dep,stops=state.stops;
const lines=Array.from({length:12},(_,i)=>'<line x1="'+(45+i*68)+'" x2="'+(45+i*68)+'" y1="20" y2="392" stroke="#284155" stroke-width=".7" opacity=".45"/>').join('')+Array.from({length:9},(_,i)=>'<line x1="42" x2="745" y1="'+(33+i*43.75)+'" y2="'+(33+i*43.75)+'" stroke="#284155" stroke-width=".7" opacity=".45"/>').join('');
const roads='<path d="M45 260 Q260 25 710 145 M60 340 Q240 335 610 40 M80 60 Q410 440 733 245" fill="none" stroke="#416074" opacity=".24" stroke-width="3" stroke-dasharray="5 9"/>';
const routes=r.feasible?r.routes.map((route,i)=>{const points=[dep,...route.ids.map(j=>stops[j]),dep].map(s=>{const p=coords(s);return p.x+','+p.y}).join(' ');return '<polyline points="'+points+'" fill="none" stroke="'+palette[i]+'" stroke-width="'+(i===state.selected?4.6:2.5)+'" stroke-linecap="round" stroke-linejoin="round" opacity="'+(i===state.selected?'1':'.44')+'"/>';}).join(''):'';
const nodes=stops.map((s,i)=>{const p=coords(s);const own=r.feasible?r.routes.findIndex(z=>z.members.includes(s.id)):-1;const fill=own<0?'#6e9cab':palette[own];const hl=own===state.selected;return '<g data-stop="'+i+'" class="map-node" role="button" tabindex="0" aria-label="Inspect '+esc(s.name)+'"><rect x="'+(p.x-14)+'" y="'+(p.y-25)+'" width="65" height="44" fill="transparent"/><circle cx="'+p.x+'" cy="'+p.y+'" r="'+(hl?11:8)+'" fill="'+fill+'" stroke="#0c1a25" stroke-width="3"/><text x="'+(p.x+12)+'" y="'+(p.y-11)+'" class="location-label">'+esc(s.id)+'</text></g>';}).join('');
const p=coords(dep);return '<div class="map-outer"><svg viewBox="0 0 780 410" role="img" aria-label="Illustrative delivery route map showing depot and stops"><rect width="780" height="410" fill="transparent"/>'+lines+roads+routes+nodes+'<circle cx="'+p.x+'" cy="'+p.y+'" r="13" fill="#f7d488" stroke="#3c3428" stroke-width="3"/><text x="'+(p.x+18)+'" y="'+(p.y+5)+'" class="location-label">DEPOT</text></svg><div class="map-overlay" id="mapInfo">Illustrative coordinate grid · 1 unit = '+fmt(state.o.kmUnit,2)+' km</div></div>';}
function legend(){return '<div class="legend"><span><i class="legend-dot" style="background:#f7d488"></i>Depot</span><span><i class="legend-dot" style="background:'+palette[0]+'"></i>Active vehicle</span><span>● Stops are clickable</span><span>Not a geographic map</span></div>';}
function routeChart(){const routes=state.result.routes||[];return routes.length?routes.map((r,i)=>'<div class="barrow"><span>Vehicle '+(i+1)+'</span><div class="track"><div class="fill" style="width:'+Math.min(100,r.minutes/state.o.maxMinutes*100)+'%;background:'+palette[i]+'"></div></div><strong>'+fmt(r.minutes,0)+' min</strong></div>').join(''):'<div class="empty">There is no feasible plan to chart.</div>';}
function overview(){const r=state.result,b=state.baseline;$('overviewBody').innerHTML=stats()+resultNotice()+'<div class="two"><div class="panel"><div class="panel-head"><div><h2>Optimized route network</h2><p class="hint">Click stops or a vehicle to inspect the route</p></div><span class="badge">Exact allocation</span></div>'+mapSvg()+legend()+'</div><div class="panel"><div class="panel-head"><div><h2>Fleet dispatch</h2><p class="hint">Costed route plan by vehicle</p></div></div>'+routeCards()+'</div></div><div class="two equal"><div class="panel"><h2>Travel time by vehicle</h2><p class="hint">Total drive and service time relative to the route limit</p>'+routeChart()+'</div><div class="panel"><h2>Decision comparison</h2><p class="hint">Exact optimizer versus a greedy benchmark</p>'+metric('Optimized plan',r.feasible?money(r.cost):'Infeasible')+metric('Nearest-neighbor plan',b.feasible?money(b.cost):'Infeasible')+metric('Estimated cost reduction',r.feasible&&b.feasible?fmt(Math.max(0,(b.cost-r.cost)/b.cost*100),1)+'%':'Not comparable')+metric('Optimization method','Exact dynamic programming')+'</div></div>';}
function metric(a,b){return '<div class="metric-row"><span>'+esc(a)+'</span><strong>'+esc(b)+'</strong></div>';}
function network(){const r=state.result;const rows=state.stops.map((s,i)=>'<tr><td><strong>'+esc(s.id)+'</strong></td><td>'+esc(s.name)+'</td><td>'+esc(s.priority)+'</td><td class="num">'+s.demand+'</td><td class="num">'+s.service+' min</td><td class="num">'+(r.feasible?'Vehicle '+(r.routes.findIndex(z=>z.members.includes(s.id))+1):'–')+'</td></tr>').join('');$('networkBody').innerHTML=stats()+'<div class="panel"><div class="panel-head"><div><h2>Route geometry and service locations</h2><p class="hint">Synthetic coordinates support transparent distance calculations</p></div><span class="badge">'+state.stops.length+' service points</span></div>'+mapSvg()+legend()+'</div><div class="panel"><h2>Stop manifest</h2><p class="hint">Priority affects reporting, not the cost objective. Every stop must be served if feasible.</p><div class="table-wrap"><table><thead><tr><th>Stop ID</th><th>Destination</th><th>Priority</th><th>Demand</th><th>Service</th><th>Assigned</th></tr></thead><tbody>'+rows+'</tbody></table></div></div>';}
function slider(key,label,min,max,step,unit=''){const val=state.o[key];return '<div class="control"><label for="p-'+key+'">'+esc(label)+'<span id="v-'+key+'">'+fmt(val,Number.isInteger(step)?0:2)+unit+'</span></label><input type="range" id="p-'+key+'" data-setting="'+key+'" min="'+min+'" max="'+max+'" step="'+step+'" value="'+val+'"><div class="range-foot"><span>'+min+unit+'</span><span>'+max+unit+'</span></div></div>';}
function optimizer(){const r=state.result,b=state.baseline;$('optimizerBody').innerHTML=stats()+resultNotice()+'<div class="two equal"><div class="panel"><div class="panel-head"><div><h2>Fleet constraints</h2><p class="hint">Move a control; results recalculate immediately</p></div><span class="badge">Exact solver</span></div><div class="control-grid">'+slider('fleet','Available vehicles',1,8,1)+slider('capacity','Capacity per vehicle',10,55,1,' units')+slider('maxMinutes','Maximum route duration',60,420,10,' min')+slider('traffic','Traffic multiplier',0.7,2.5,0.05,'×')+'</div><br><h2>Cost assumptions</h2><p class="hint">Editable synthetic rates, in USD</p><div class="control-grid">'+slider('vehicleCost','Vehicle dispatch cost',0,100,5,' USD')+slider('kmCost','Distance cost per km',0,5,0.1,' USD')+slider('hourCost','Driver hourly cost',0,60,1,' USD')+slider('speed','Average speed',10,70,1,' km/h')+'</div></div><div class="panel"><h2>Optimized allocations</h2><p class="hint">Every route begins and ends at the depot</p>'+routeCards()+'<hr style="border:0;border-top:1px solid var(--border);margin:15px 0">'+metric('Full service',r.feasible?'Feasible':'Not feasible')+metric('Optimized operating cost',r.feasible?money(r.cost):'–')+metric('Greedy comparison',b.feasible?money(b.cost):'Not feasible')+metric('Total cargo demand',state.stops.reduce((a,s)=>a+s.demand,0)+' units')+'</div></div><div class="note"><strong>Optimization model.</strong> Each stop is visited exactly once, every route starts and ends at the depot, total cargo and route time stay within limits, and the fleet count cannot exceed the available vehicles. The solver minimizes synthetic dispatch + driver time + travel costs for up to nine stops.</div>';}
function stressPage(){const a=RF.stress(state.stops,state.dep,state.o),r=state.result;
$('stressBody').innerHTML=stats()+'<div class="note"><strong>Controlled what-if comparison.</strong> Traffic changes while the demand, depot, fleet capacity and cost assumptions remain fixed. The solver is rerun from scratch for every traffic level.</div><div class="panel"><h2>Traffic sensitivity</h2><p class="hint">Cost impact and feasibility under four congestion scenarios</p><div class="table-wrap"><table><thead><tr><th>Scenario</th><th>Traffic multiplier</th><th>Feasibility</th><th>Vehicles</th><th>Operating cost</th><th>Change vs base</th></tr></thead><tbody>'+a.map(x=>'<tr><td><strong>'+Math.round(x.factor*100)+'% of current</strong></td><td>'+fmt(x.traffic,2)+'×</td><td>'+(x.feasible?'<span class="badge">Feasible</span>':'<span class="badge warning">Infeasible</span>')+'</td><td>'+(x.vehicles??'–')+'</td><td>'+((x.cost!==null)?money(x.cost):'–')+'</td><td>'+((x.feasible&&r.feasible)?fmt((x.cost/r.cost-1)*100,1)+'%':'–')+'</td></tr>').join('')+'</tbody></table></div></div><div class="two equal"><div class="panel"><h2>Cost under traffic stress</h2><p class="hint">Bars show projected synthetic operating cost</p>'+a.map(x=>'<div class="barrow"><span>'+Math.round(x.factor*100)+'%</span><div class="track"><div class="fill" style="background:'+(x.feasible?'#65e7b1':'#ff7f94')+';width:'+(x.feasible?Math.max(2,x.cost/Math.max(...a.map(q=>q.cost||0))*100):3)+'%"></div></div><strong>'+(x.feasible?money(x.cost):'N/A')+'</strong></div>').join('')+'</div><div class="panel"><h2>Operational interpretation</h2>'+metric('Reference traffic',fmt(state.o.traffic,2)+'×')+metric('Fleet limit',String(state.o.fleet)) +metric('Maximum route duration',state.o.maxMinutes+' min')+metric('Scenarios with complete service',a.filter(x=>x.feasible).length+' / '+a.length)+'<p class="subtle">A scenario marked infeasible cannot serve all stops under its tested constraints. It is not assigned a misleading partial-service cost.</p></div></div>';}
function equity(){const r=state.result,ess=state.stops.filter(s=>s.priority==='Essential'),std=state.stops.filter(s=>s.priority==='Standard');$('equityBody').innerHTML=stats()+'<div class="two equal"><div class="panel"><h2>Service coverage by priority</h2><p class="hint">All stops are mandatory. No priority group is silently excluded.</p>'+metric('Essential destinations',ess.length+' stops / '+ess.reduce((a,s)=>a+s.demand,0)+' units')+metric('Standard destinations',std.length+' stops / '+std.reduce((a,s)=>a+s.demand,0)+' units')+metric('Essential group fully covered',r.feasible?'Yes':'No complete plan')+metric('Standard group fully covered',r.feasible?'Yes':'No complete plan')+'</div><div class="panel"><h2>Workload equity</h2><p class="hint">Variation in route times across deployed vehicles</p>'+metric('Vehicle utilization',r.feasible?fmt(r.capacityUse*100,0)+'% of available capacity':'–')+metric('Longest deployed route',r.feasible?fmt(Math.max(...r.routes.map(x=>x.minutes)),0)+' min':'–')+metric('Shortest deployed route',r.feasible?fmt(Math.min(...r.routes.map(x=>x.minutes)),0)+' min':'–')+metric('Duration range',r.feasible?fmt(Math.max(...r.routes.map(x=>x.minutes))-Math.min(...r.routes.map(x=>x.minutes)),0)+' min':'–')+'</div></div><div class="panel"><h2>Route-level constraint audit</h2><p class="hint">No route exceeds the configured load or duration ceilings</p><div class="table-wrap"><table><thead><tr><th>Vehicle</th><th>Stops</th><th>Cargo load</th><th>Load utilization</th><th>Route duration</th><th>Time utilization</th></tr></thead><tbody>'+(r.feasible?r.routes.map((x,i)=>'<tr><td>Vehicle '+(i+1)+'</td><td>'+x.ids.length+'</td><td>'+x.load+' / '+state.o.capacity+'</td><td>'+fmt(x.load/state.o.capacity*100,0)+'%</td><td>'+fmt(x.minutes,0)+' min</td><td>'+fmt(x.minutes/state.o.maxMinutes*100,0)+'%</td></tr>').join(''):'<tr><td colspan="6">No complete plan under these limits.</td></tr>')+'</tbody></table></div></div><div class="note"><strong>Interpretation.</strong> The labels “Essential” and “Standard” help audit delivery coverage. They are not a legal or demographic fairness model; this planner minimizes operational costs while requiring all stops to be served.</div>';}
function dataPage(){$('dataBody').innerHTML='<div class="two equal"><div class="panel"><div class="panel-head"><div><h2>Demo and data loading</h2><p class="hint">Import up to nine stops; no data leaves the browser</p></div><span class="badge">Offline-ready</span></div><div class="control"><label for="scenario">Demonstration scenario</label><select id="scenario" style="width:100%;background:#0c1b2b;color:#d3e7f2;border:1px solid #365064;padding:11px;border-radius:8px">'+Object.entries(RF.scenarios).map(([k,s])=>'<option value="'+k+'" '+(k===state.key?'selected':'')+'>'+esc(s.name)+'</option>').join('')+'</select></div><br><div class="upload"><strong>Import delivery stops (CSV)</strong><small>Columns: id,name,x,y,demand,service,priority</small><input type="file" id="csvFile" accept=".csv,text/csv" aria-label="Select a CSV of delivery stops"><small>2–9 stops; coordinates 0–100; demand 1–40; service 0–60 min; priority Essential / Standard. Depot stays fixed. 2 MB file limit.</small></div><p class="subtle">'+(state.uploadName?'Current data source: '+esc(state.uploadName):'Active source: '+esc(state.scenario.name)+' (synthetic)')+'</p></div><div class="panel"><h2>Exports & reproducibility</h2><p class="hint">Download an auditable snapshot of the current assumptions and results</p><div class="download-grid"><button class="download-item" data-export="json">↓ Audit report JSON<span>Solver, routes, constraints, scenario</span></button><button class="download-item" data-export="csv">↓ Route report CSV<span>Fleet allocations and costs</span></button><button class="download-item" data-export="md">↓ Methods summary<span>Readable Markdown report</span></button><button class="download-item" data-export="template">↓ Import CSV template<span>Valid example with header</span></button><button class="download-item" data-export="stops">↓ Current stops CSV<span>Export the delivery manifest</span></button><button class="download-item" data-export="stress">↓ Stress table CSV<span>Scenario comparisons</span></button></div></div></div><div class="panel"><h2>Methodology & safeguards</h2><div class="policy-list"><div class="policy"><strong>Exact combinatorial solver</strong><p>Held–Karp solves each subset tour, then subset partitioning selects an optimal fleet allocation. Limited to nine stops to protect responsiveness.</p></div><div class="policy"><strong>Transparent assumptions</strong><p>Euclidean coordinates scaled into kilometers; common speed and traffic; vehicle dispatch, distance and driver time costs. No real roads or live traffic.</p></div><div class="policy"><strong>Client-side processing</strong><p>CSV parsing, route computation and report creation happen inside this tab. No external API calls, local storage or tracking scripts.</p></div></div></div><div class="note warn"><strong>Important limitations.</strong> The map is an illustrative coordinate network and cannot be used for navigation. Route duration includes driving and per-stop service, but not time windows, driver breaks or regulatory requirements. Cost estimates are synthetic and should not guide real dispatch without validated inputs.</div>';}
function renderAll(){ $('scenarioLabel').textContent=state.key==='custom'?'Imported dataset':state.scenario.name; $('notice').innerHTML=!state.result.feasible?resultNotice():'';overview();network();optimizer();stressPage();equity();dataPage(); }
function download(name,text,mime){const blob=new Blob([text],{type:mime}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1500);toast('Downloaded '+name);}
function exportFile(type){const r=state.result,base=state.baseline,s=state.stops,o=state.o,date='demo';const meta={application:'RouteForge v1.0',dataset:state.uploadName||state.scenario.name,synthetic:state.key!=='custom',method:'Held-Karp subsets + exact fleet partition',depot:state.dep,options:o,stops:s,optimization:r,greedyBaseline:base,trafficScenarios:RF.stress(s,state.dep,o),limitations:['Coordinates are illustrative, not real roads','No time windows, regulatory constraints or live traffic','Costs are hypothetical']};
switch(type){case'json':download('routeforge-audit.json',JSON.stringify(meta,null,2),'application/json');break;
case'csv':download('routeforge-routes.csv',RF.asCSV([['vehicle','stop_ids','distance_km','duration_min','cargo_units','cost_usd'],...(r.routes||[]).map((x,i)=>[i+1,x.members.join(' / '),RF.round(x.distance,2),RF.round(x.minutes,2),x.load,RF.round(x.cost,2)])]),'text/csv');break;
case'md':download('routeforge-methods.md',`# RouteForge audit\n\nDataset: ${state.uploadName||state.scenario.name}\n\nFeasible: ${r.feasible}\n\nFleet capacity: ${o.capacity} units; vehicles available: ${o.fleet}; route limit: ${o.maxMinutes} min; traffic: ${o.traffic}x.\n\nMethod: exact Held–Karp subset tours and fleet subset partitioning. Objective: dispatch cost + km cost + driver time.\n\n${r.feasible?'Estimated synthetic operating cost: '+money(r.cost)+'; vehicles: '+r.vehicles+'.':'No full-coverage feasible route under the chosen limits.'}\n\nLimitations: synthetic Euclidean coordinates; no actual road network or regulatory checks; no causal or real-world guarantee.\n`,'text/markdown');break;
case'template':download('routeforge-import-template.csv',RF.template(),'text/csv');break;
case'stops':download('routeforge-stops.csv',RF.asCSV([['id','name','x','y','demand','service','priority'],...s.map(x=>[x.id,x.name,x.x,x.y,x.demand,x.service,x.priority])]),'text/csv');break;
case'stress':download('routeforge-stress.csv',RF.asCSV([['traffic_factor','traffic_multiplier','feasible','vehicles','cost_usd'],...RF.stress(s,state.dep,o).map(x=>[x.factor,x.traffic,x.feasible,x.vehicles??'',x.cost===null?'':RF.round(x.cost,2)])]),'text/csv');break;
}}
document.body.addEventListener('click',e=>{
const nav=e.target.closest('[data-page]');if(nav){page(nav.dataset.page);return;}
const route=e.target.closest('[data-route]');if(route){state.selected=Number(route.dataset.route);renderAll();return;}
const stop=e.target.closest('[data-stop]');if(stop){const item=state.stops[Number(stop.dataset.stop)];document.querySelectorAll('#mapInfo').forEach(el=>{el.textContent=item.id+' · '+item.name+' · '+item.demand+' units · '+item.priority});return;}
const dl=e.target.closest('[data-export]');if(dl){exportFile(dl.dataset.export);return;}
});
document.body.addEventListener('keydown',e=>{const n=e.target.closest('[data-stop]');if(n&&(e.key==='Enter'||e.key===' ')){e.preventDefault();n.dispatchEvent(new MouseEvent('click',{bubbles:true}));}});
document.body.addEventListener('input',e=>{const el=e.target;if(!el.matches('[data-setting]'))return;const k=el.dataset.setting;const v=document.getElementById('v-'+k);if(v){const step=Number(el.step);v.textContent=fmt(Number(el.value),Number.isInteger(step)?0:2)+(k==='capacity'?' units':k==='maxMinutes'?' min':k==='traffic'?'×':['vehicleCost','kmCost','hourCost'].includes(k)?' USD':k==='speed'?' km/h':'');}});
document.body.addEventListener('change',async e=>{
if(e.target.matches('[data-setting]')){state.o={...state.o,[e.target.dataset.setting]:Number(e.target.value)};recalc();return;}
if(e.target.id==='scenario'){setScenario(e.target.value);return;}
if(e.target.id==='csvFile'){
const f=e.target.files?.[0];if(!f)return;
try{if(f.size>2e6)throw Error('CSV exceeds the 2 MB limit.');const text=await f.text();const data=RF.parseCSV(text);
state.stops=data;state.key='custom';state.uploadName=f.name;recalc();toast('Imported '+data.length+' stops.');}
catch(err){toast('Import rejected: '+err.message,true);}finally{e.target.value='';}
}
});
$('menuBtn').addEventListener('click',()=>$('sidebar').classList.toggle('open'));
$('optBtn').addEventListener('click',()=>{recalc();toast(state.result.feasible?'Exact route plan recalculated.':'No complete plan under current constraints.',!state.result.feasible);});
$('resetBtn').addEventListener('click',()=>{state.o=RF.options();setScenario('service');toast('Default scenario and controls restored.');});
recalc();
})();
</script>
</body></html>