| <!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="#11263d"><meta name="description" content="DispatchForge: an offline, synthetic last-mile delivery routing and fleet capacity simulator."><title>DispatchForge | Delivery Operations Studio</title> |
| <style> |
| :root{color-scheme:light;--bg:#f2f6fa;--paper:#fff;--soft:#f7f9fd;--ink:#18314a;--muted:#74889d;--line:#dce5ef;--nav:#10273d;--accent:#2469bb;--mint:#138d84;--amber:#be823d;--bad:#bb5662;--shadow:0 12px 38px rgba(28,55,92,.045);--tint:#e9f3fb} |
| body.dark{color-scheme:dark;--bg:#0b1928;--paper:#142b3d;--soft:#1c3749;--ink:#e5f1f8;--muted:#abc1d2;--line:#315164;--nav:#081320;--accent:#9ac6ff;--mint:#7bdbd0;--amber:#f7c27c;--bad:#ff9e9f;--shadow:none;--tint:#233d53} |
| *{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}button,input,select{font:inherit}button{cursor:pointer}button:disabled{opacity:.5;cursor:not-allowed}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #6db1ef;outline-offset:2px}button,input,select{max-width:100%}.shell{display:grid;grid-template-columns:252px minmax(0,1fr);min-height:100vh}.sidebar{background:var(--nav);color:#adc1d1;padding:27px 19px;display:flex;flex-direction:column;height:100vh;position:sticky;top:0}.brand{display:flex;gap:12px;align-items:center;margin:0 3px 39px}.logo{display:grid;place-items:center;flex-shrink:0;width:45px;height:45px;border-radius:13px;background:linear-gradient(145deg,#8ae2c2,#78a9ec);color:#14364c;font-size:25px;font-weight:900;box-shadow:0 9px 25px #47bb9740}.brand strong{display:block;color:white;font-size:20px;letter-spacing:-.8px;line-height:1.2}.brand span{display:block;font-size:9px;font-weight:850;letter-spacing:1.9px;color:#81a5be}.navcap{font-weight:850;font-size:10px;color:#7997ac;letter-spacing:1.6px;padding:0 12px;margin-bottom:9px}.nav{display:grid;gap:6px}.nav button{color:#b3cad9;border:0;width:100%;padding:12px 13px;border-radius:10px;background:transparent;display:flex;gap:12px;text-align:left;align-items:center;font-size:12px;font-weight:750}.nav button:hover,.nav button.sel{background:#2b4960;color:white}.nav button.sel{box-shadow:inset 3px 0 #6fdbbd}.nav .glyph{font-size:17px;width:21px;text-align:center}.sidecard{margin-top:auto;border:1px solid #32536a;border-radius:14px;background:#19384e;padding:15px;font-size:11px}.sidecard b{display:block;color:white}.sidecard p{margin:5px 0 0;color:#a9c5d8}.online{display:inline-block;width:8px;height:8px;background:#75e5ac;border-radius:50%;margin-right:7px} |
| main{width:100%;min-width:0;max-width:1700px;padding:25px clamp(16px,3vw,46px) 45px}.topline{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-bottom:28px}.crumb{font-size:11px;color:var(--muted);font-weight:700}.crumb b{color:var(--ink)}.actions{display:flex;gap:8px;flex-wrap:wrap}.btn{min-height:36px;background:var(--paper);border:1px solid var(--line);border-radius:9px;padding:8px 13px;color:var(--ink);display:inline-flex;align-items:center;justify-content:center;gap:7px;font-size:11px;font-weight:800;white-space:nowrap}.btn:hover{filter:brightness(.95)}.btn.primary{background:#286dbb;color:#fff;border-color:#286dbb}.btn.soft{background:var(--tint);color:var(--accent);border-color:transparent}.btn.warn{color:var(--bad)}.hero{display:flex;justify-content:space-between;align-items:flex-start;gap:15px;margin-bottom:24px}.eyebrow{color:var(--mint);font-size:10px;font-weight:900;letter-spacing:1.55px;text-transform:uppercase}.hero h1{font-size:clamp(26px,3vw,38px);margin:4px 0 5px;letter-spacing:-1.3px;line-height:1.18}.hero p{max-width:700px;color:var(--muted);margin:0}.pill{border-radius:99px;padding:7px 12px;color:var(--mint);background:#e4f5ef;font-weight:850;white-space:nowrap;font-size:10px}.dark .pill{background:#1e4942}.grid4{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:15px;margin-bottom:16px}.card{background:var(--paper);border:1px solid var(--line);border-radius:15px;min-width:0;box-shadow:var(--shadow)}.metric{padding:19px}.metricTop{display:flex;align-items:center;justify-content:space-between;gap:7px;font-size:11px;color:var(--muted);font-weight:800}.metricIcon{display:grid;place-items:center;height:32px;width:32px;border-radius:9px;background:var(--tint);color:var(--accent);font-size:17px}.value{font-size:clamp(22px,2.2vw,29px);font-weight:860;letter-spacing:-1px;margin:12px 0 4px;overflow-wrap:anywhere}.foot{color:var(--muted);font-size:10px}.layout{display:grid;grid-template-columns:minmax(0,1.57fr) minmax(300px,1fr);gap:16px;margin-bottom:16px}.layout.half{grid-template-columns:repeat(2,minmax(0,1fr))}.panel{padding:22px}.paneHeader{display:flex;justify-content:space-between;align-items:flex-start;gap:9px;margin-bottom:16px}.paneHeader h2{font-size:14px;letter-spacing:-.2px;margin:0}.paneHeader p{font-size:11px;color:var(--muted);margin:3px 0 0}.minor{font-size:10px;color:var(--muted)}.chart{width:100%;overflow:hidden}.chart svg{display:block;width:100%;height:auto}.legend{display:flex;gap:12px;flex-wrap:wrap;font-size:10px;color:var(--muted);margin-top:11px}.legend .markdot{display:inline-block;width:9px;height:9px;border-radius:3px;margin-right:5px}.listline{padding:12px 0;display:flex;justify-content:space-between;align-items:center;gap:10px;border-bottom:1px solid var(--line)}.listline:last-child{border-bottom:0}.listline b{font-size:11px;display:block}.listline small{font-size:10px;color:var(--muted);display:block;margin-top:3px}.listline .num{text-align:right;font-weight:850;font-size:12px}.tag{font-size:9px;font-weight:850;padding:4px 7px;border-radius:6px;background:var(--tint);color:var(--accent);white-space:nowrap}.tag.good{background:#e3f6ef;color:#237b64}.tag.warn{background:#fff0de;color:#ae7533}.tag.bad{background:#fff0ef;color:#b45461}.dark .tag.good{background:#235044;color:#9af8c0}.dark .tag.warn{background:#493c28;color:#ffcd84}.dark .tag.bad{background:#4b303e;color:#ffc0bd}.notice{background:var(--soft);border:1px solid var(--line);border-radius:10px;padding:13px 14px;color:var(--muted);font-size:11px}.notice strong{color:var(--ink)}.stripe{height:6px;border-radius:10px;background:var(--soft);overflow:hidden}.stripe span{height:100%;border-radius:10px;background:#309c95;display:block}.divider{border-top:1px solid var(--line);margin:17px 0}.barRow{display:grid;grid-template-columns:minmax(0,1fr) 1.3fr auto;align-items:center;gap:9px;margin:14px 0;font-size:11px}.barTrack{height:8px;border-radius:5px;background:var(--soft);overflow:hidden}.barTrack span{display:block;height:100%;background:#3677c1;border-radius:5px}.mono{font-variant-numeric:tabular-nums}.section{display:none}.section.active{display:block}.presets{display:flex;flex-wrap:wrap;gap:8px;margin:7px 0 17px}.btn.chosen{border-color:#4789c2;background:var(--tint);color:var(--accent)}.sliderRow{padding:10px 0}.sliderLabel{display:flex;justify-content:space-between;gap:10px;margin-bottom:8px;font-size:11px;font-weight:750}.sliderLabel output{color:var(--accent);font-weight:900;font-variant-numeric:tabular-nums}input[type=range]{width:100%;accent-color:#277fc0;cursor:pointer}.controlGrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:25px}.twoK{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.statbox{border:1px solid var(--line);background:var(--soft);border-radius:12px;padding:13px}.statbox b{display:block;font-size:19px;margin-top:5px}.statbox span{font-size:10px;color:var(--muted)}.tablewrap{overflow:auto;width:100%}table{border-collapse:collapse;text-align:left;min-width:660px;width:100%;font-size:11px}th{text-transform:uppercase;letter-spacing:.7px;color:var(--muted);font-size:9px;white-space:nowrap}th,td{border-bottom:1px solid var(--line);padding:12px 11px;vertical-align:middle}td b{display:block}td small{display:block;color:var(--muted);margin-top:2px}td .btn{font-size:10px;min-height:30px;padding:6px 9px}.tabletools{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 15px}.field{background:var(--paper);border:1px solid var(--line);border-radius:8px;color:var(--ink);min-height:36px;padding:8px 10px;min-width:0}.tabletools input{flex:1;min-width:155px}.tabletools select{min-width:120px}.formGrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.formGrid label{display:grid;gap:5px;font-size:11px;font-weight:750;color:var(--muted)}.formGrid label.full{grid-column:1/-1}.formGrid .field{width:100%}.formActions{display:flex;gap:9px;margin-top:19px;flex-wrap:wrap}.steps{counter-reset:step}.step{padding:14px 0;border-bottom:1px solid var(--line);display:grid;grid-template-columns:36px minmax(0,1fr);gap:10px}.step:last-child{border-bottom:0}.step .n{height:28px;width:28px;background:var(--tint);color:var(--accent);display:grid;place-items:center;border-radius:8px;font-weight:900;font-size:11px}.step b{font-size:12px}.step p{font-size:11px;margin:4px 0 0;color:var(--muted)}.empty{padding:24px;text-align:center;color:var(--muted);font-size:12px}.hidden{display:none!important}#toast{position:fixed;z-index:20;bottom:24px;right:24px;background:#132e41;color:white;border-radius:11px;padding:12px 16px;box-shadow:0 12px 38px #1116;max-width:340px;font-size:12px;display:none}#toast.show{display:block}.subtitle{font-size:11px;color:var(--muted);margin-top:8px}.cols3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.bigNum{font-size:24px;letter-spacing:-1px;font-weight:900}.printline{display:flex;flex-wrap:wrap;gap:9px}.chart text{font-family:system-ui,-apple-system,'Segoe UI',sans-serif}.callout{background:var(--tint);border-left:3px solid #4a91ca;padding:13px 15px;border-radius:8px;font-size:11px;color:var(--ink)} |
| @media(max-width:1200px){.sidebar{width:212px}.shell{grid-template-columns:212px minmax(0,1fr)}.grid4{grid-template-columns:repeat(2,minmax(0,1fr))}.layout{grid-template-columns:1fr}.layout.half{grid-template-columns:1fr 1fr}} |
| @media(max-width:820px){.shell{display:block}.sidebar{width:100%;height:auto;position:static;padding:13px 15px}.brand{margin:0 0 13px}.logo{width:37px;height:37px;font-size:20px}.brand strong{font-size:18px}.navcap,.sidecard{display:none}.nav{grid-template-columns:repeat(5,minmax(0,1fr));gap:4px}.nav button{padding:9px 3px;flex-direction:column;gap:2px;justify-content:center;text-align:center;font-size:9px}.nav .glyph{font-size:18px}main{padding:17px 16px 30px}.topline{margin-bottom:20px}.hero{margin-bottom:19px}.layout.half{grid-template-columns:1fr}} |
| @media(max-width:580px){.topline{align-items:flex-start}.crumb{display:none}.actions{width:100%;gap:6px}.actions .btn{flex:1;font-size:10px;padding:8px}.hero h1{font-size:29px}.hero .pill{display:none}.grid4{gap:9px}.metric{padding:14px}.value{font-size:21px}.metricTop{font-size:10px}.panel{padding:16px}.paneHeader{display:block}.controlGrid,.formGrid{grid-template-columns:1fr}.cols3{grid-template-columns:1fr 1fr}.cols3>:last-child{grid-column:1/-1}.twoK{grid-template-columns:1fr 1fr}.barRow{grid-template-columns:minmax(0,1fr) minmax(70px,.8fr) auto}.nav button{font-size:8px}.nav .glyph{font-size:17px}} |
| @media(max-width:370px){.nav{gap:1px}.nav button{font-size:7px}.actions .btn{min-width:0;padding:7px 5px}.metricTop{font-size:9px}.value{font-size:19px}.cols3{grid-template-columns:1fr}.cols3>:last-child{grid-column:auto}} |
| </style></head> |
| <body><div class="shell"><aside class="sidebar"><div class="brand"><div class="logo">D<span style="font-size:16px">↗</span></div><div><strong>DispatchForge</strong><span>HUGDRIGO OPERATIONS</span></div></div><div class="navcap">WORKSPACE</div><nav class="nav" aria-label="Application sections"><button data-page="overview" class="sel"><span class="glyph">◫</span>Overview</button><button data-page="planner"><span class="glyph">⌁</span>Route planner</button><button data-page="stops"><span class="glyph">◉</span>Delivery stops</button><button data-page="scenarios"><span class="glyph">◈</span>Scenarios</button><button data-page="methods"><span class="glyph">▤</span>Methodology</button></nav><div class="sidecard"><b><span class="online"></span>LOCAL SIMULATION</b><p>Synthetic city grid. No real locations, traffic APIs, accounts, or paid services.</p><p>Portfolio Project 108 · MIT</p></div></aside> |
| <main><header class="topline"><div class="crumb">Hugdrigo / Operations Lab / <b>DispatchForge</b></div><div class="actions"><button class="btn" id="themeBtn" type="button">☾ Theme</button><button class="btn" id="exportBackup" type="button">↧ JSON backup</button><button class="btn" id="importBtn" type="button">↥ Restore</button><input id="restoreFile" type="file" accept=".json,application/json" class="hidden" aria-label="Restore a DispatchForge backup"><button class="btn primary" id="exportPlan" type="button">↧ Route CSV</button></div></header> |
| <section id="overview" class="section active"><div class="hero"><div><span class="eyebrow">COMMAND OVERVIEW · SYNTHETIC DATA</span><h1>Delivery operations, clarified.</h1><p>Plan feasible fleet routes, monitor time-window performance and surface capacity gaps across a fictional city service area.</p></div><span class="pill">● Scenario ready</span></div><div class="grid4" id="overviewKpis"></div><div class="layout"><div class="card panel"><div class="paneHeader"><div><h2>Dispatch network</h2><p>Schematic grid coordinates, not geographic roads or real travel times</p></div><span class="tag">Optimized routes</span></div><div id="mapOverview" class="chart"></div><div id="mapLegend" class="legend"></div></div><div class="card panel"><div class="paneHeader"><div><h2>Fleet dispatch summary</h2><p>Available vehicles and modeled delivery performance</p></div></div><div id="fleetOverview"></div><div class="divider"></div><div class="notice"><strong>Planning signal</strong><div id="overviewInsight" style="margin-top:6px"></div></div></div></div><div class="layout half"><div class="card panel"><div class="paneHeader"><div><h2>Route capacity utilization</h2><p>Packages assigned against each vehicle's capacity</p></div></div><div id="utilizationBars"></div></div><div class="card panel"><div class="paneHeader"><div><h2>Operational priorities</h2><p>Late stops and demand that cannot be assigned</p></div></div><div id="priorityList"></div></div></div></section> |
| <section id="planner" class="section"><div class="hero"><div><span class="eyebrow">ROUTE OPTIMIZATION LAB</span><h1>Plan the next dispatch.</h1><p>Adjust dispatch capacity and service constraints. Routes are rebuilt using deterministic insertion and local improvement.</p></div><span class="pill">Capacity constrained</span></div><div class="layout"><div class="card panel"><div class="paneHeader"><div><h2>Dispatch controls</h2><p>All changes are local. They do not change the underlying delivery stops.</p></div></div><div id="controlPanel" class="controlGrid"></div><div class="divider"></div><div class="printline"><button class="btn soft" id="restoreSettings">↺ Restore standard settings</button><button class="btn" id="exportStops">↧ Export stops CSV</button></div></div><div class="card panel"><div class="paneHeader"><div><h2>Plan comparison</h2><p>Naive sequential allocation versus heuristic routing</p></div></div><div id="comparison"></div><div class="subtitle">Different methods can trade distance against time-window performance. A shorter route does not always mean better service.</div></div></div><div class="grid4" id="plannerKpis"></div><div class="card panel"><div class="paneHeader"><div><h2>Calculated delivery runs</h2><p>Travel and service times use a simple Euclidean grid proxy</p></div></div><div id="routeTable" class="tablewrap"></div></div></section> |
| <section id="stops" class="section"><div class="hero"><div><span class="eyebrow">DELIVERY REGISTRY</span><h1>Manage your delivery stops.</h1><p>Edit synthetic demand, geographic grid points, service windows and priorities. Archived stops can always be restored.</p></div><button class="btn primary" id="newStop">+ Add stop</button></div><div class="card panel" style="margin-bottom:16px"><div class="paneHeader"><div><h2 id="editHeading">Add a delivery stop</h2><p>Coordinates range from 0 to 100 on a fictitious city grid.</p></div><button type="button" class="btn" id="cancelEdit">Clear form</button></div><form id="stopForm" novalidate><div class="formGrid"><label class="full">Stop name<input class="field" id="stopName" name="name" maxlength="60" required placeholder="Example: Riverside depot client"></label><label>Zone<select class="field" id="stopZone"><option>Central</option><option>North</option><option>South</option><option>East</option><option>West</option></select></label><label>Priority<select class="field" id="stopPriority"><option value="High">High</option><option value="Medium" selected>Medium</option><option value="Low">Low</option></select></label><label>Packages<input class="field" id="stopPackages" type="number" min="1" max="80" step="1" value="12" required></label><label>Service time, minutes<input class="field" id="stopService" type="number" min="1" max="45" step="1" value="8" required></label><label>Grid X (0 to 100)<input class="field" id="stopX" type="number" min="0" max="100" step="1" value="40" required></label><label>Grid Y (0 to 100)<input class="field" id="stopY" type="number" min="0" max="100" step="1" value="60" required></label><label>Window opens<input class="field" id="stopOpen" type="time" value="08:00" required></label><label>Window closes<input class="field" id="stopClose" type="time" value="17:00" required></label></div><div class="formActions"><button class="btn primary" type="submit" id="saveStop">Save stop</button><span class="minor" id="formNotice" role="status" aria-live="polite">All fields required. Times must be in a valid order.</span></div></form></div><div class="card panel"><div class="paneHeader"><div><h2>Delivery registry</h2><p id="stopCount">Synthetic stops with non-destructive archiving</p></div></div><div class="tabletools"><input class="field" id="searchStops" placeholder="Search stop or zone…" aria-label="Search delivery stops"><select class="field" id="zoneFilter" aria-label="Filter by zone"><option value="all">All zones</option><option>Central</option><option>North</option><option>South</option><option>East</option><option>West</option></select><select class="field" id="statusFilter" aria-label="Filter by status"><option value="active">Active</option><option value="all">All</option><option value="archived">Archived</option></select></div><div class="tablewrap"><table><thead><tr><th>Delivery stop</th><th>Priority</th><th>Packages</th><th>Service window</th><th>Grid point</th><th>Actions</th></tr></thead><tbody id="stopRows"></tbody></table></div></div></section> |
| <section id="scenarios" class="section"><div class="hero"><div><span class="eyebrow">DELIVERY STRESS TESTING</span><h1>Explore operating conditions.</h1><p>Model demand surges, slow travel and extra fleet capacity. All outcomes use synthetic assumptions.</p></div><span class="pill">Four presets</span></div><div class="card panel" style="margin-bottom:16px"><div class="paneHeader"><div><h2>Scenario presets</h2><p>Switch conditions without modifying your saved standard settings.</p></div></div><div id="presetButtons" class="presets"></div><div class="controlGrid" id="scenarioControls"></div></div><div class="grid4" id="scenarioKpis"></div><div class="layout half"><div class="card panel"><div class="paneHeader"><div><h2>Baseline versus scenario</h2><p>Operational metrics under the selected settings</p></div></div><div id="scenarioComparison"></div></div><div class="card panel"><div class="paneHeader"><div><h2>Uncertainty experiment</h2><p>240 reproducible demand and traffic draws</p></div></div><div id="monteResults"></div></div></div><div class="card panel"><div class="paneHeader"><div><h2>Scenario network</h2><p>Illustrative spatial view of calculated routes</p></div></div><div class="chart" id="mapScenario"></div></div></section> |
| <section id="methods" class="section"><div class="hero"><div><span class="eyebrow">METHODOLOGY & RESPONSIBLE USE</span><h1>Understand every calculation.</h1><p>Plain-language assumptions, algorithm limits, data handling, and instructions to interpret the outputs responsibly.</p></div></div><div class="layout half"><div class="card panel"><div class="paneHeader"><div><h2>Routing logic</h2><p>Transparent deterministic heuristic, not an exact optimizer</p></div></div><div class="steps"><div class="step"><span class="n">01</span><div><b>Validate active stops</b><p>Use only stops with valid package counts, synthetic coordinates and service windows.</p></div></div><div class="step"><span class="n">02</span><div><b>Assign by constrained insertion</b><p>Choose the best feasible vehicle position with capacity constraints and a lateness-penalized score.</p></div></div><div class="step"><span class="n">03</span><div><b>Improve route sequence</b><p>Use bounded 2-opt reversals on each vehicle route while preserving capacity.</p></div></div><div class="step"><span class="n">04</span><div><b>Evaluate time windows</b><p>Travel at assumed speed, wait for opening times and count stops that arrive after closing.</p></div></div><div class="step"><span class="n">05</span><div><b>Stress-test assumptions</b><p>Run 240 deterministic synthetic draws with modest demand and traffic variation. These are not calibrated confidence intervals.</p></div></div></div></div><div class="card panel"><div class="paneHeader"><div><h2>Data integrity & limits</h2><p>What this tool does and does not provide</p></div></div><div class="notice"><strong>Fictional map:</strong> Grid coordinates are arbitrary. Distance is straight-line Euclidean distance multiplied by a configurable constant, not road distance. Traffic, fuel, live GPS and actual vehicle routes are not retrieved.</div><div class="divider"></div><div class="notice"><strong>Offline privacy:</strong> Input remains in this browser's local storage unless you export it. No backend, API keys, analytics, remote calls, or paid models are used.</div><div class="divider"></div><div class="notice"><strong>Planning limitations:</strong> The heuristic may miss a globally optimal solution. Loading is represented by package count only. Driver shifts, holidays, legal hours, street restrictions and truck-specific routing are not modeled.</div><div class="divider"></div><div class="callout"><strong>Operational rule:</strong> Never use this demo as a sole source for dispatching real vehicles. Validate locations, roads, schedules and real fleet constraints in a production-grade routing platform.</div></div></div><div class="card panel"><div class="paneHeader"><div><h2>Calculation definitions</h2><p>These simple equations are the source of the metrics.</p></div></div><div class="cols3"><div class="statbox"><span>Leg distance</span><b>√(Δx² + Δy²) × 0.42</b><span>Illustrative kilometer proxy</span></div><div class="statbox"><span>Vehicle utilization</span><b>Assigned ÷ Capacity</b><span>As a percentage per vehicle</span></div><div class="statbox"><span>Service compliance</span><b>On time ÷ Served</b><span>Arrival at or before close</span></div></div><p class="subtitle">Routes depart at 08:00. Travel duration includes scenario slowdown. Service starts at the later of arrival and the opening time; arriving after closing is classified late. A return leg to the depot is included in distance. Maximum modeled stop count is 30, not a live production network.</p></div></section> |
| <footer class="minor" style="margin-top:22px">DispatchForge · Project #108 · Hugdrigo · Synthetic offline simulation only · Built for Hugging Face Static Spaces</footer></main></div><div id="toast" role="status" aria-live="polite"></div> |
| <script> |
| 'use strict'; |
| const STORAGE='hugdrigo_dispatchforge_108_v1', DEPOT={x:48,y:49}, SCALE=.42, COLORS=['#347fc7','#119b95','#c28b43','#926bc0','#d86f82','#6388ad','#87a046','#a57263'], ZONES=['Central','North','South','East','West'], PRIORITIES=['High','Medium','Low']; |
| const DATA=[ |
| {id:'st1',name:'Northgate Medical',zone:'North',x:37,y:13,packages:18,service:10,open:'08:00',close:'11:00',priority:'High',archived:false}, |
| {id:'st2',name:'Riverside Pharmacy',zone:'East',x:81,y:24,packages:15,service:8,open:'09:00',close:'12:30',priority:'High',archived:false}, |
| {id:'st3',name:'Central Office Hub',zone:'Central',x:55,y:43,packages:20,service:9,open:'08:00',close:'16:00',priority:'Medium',archived:false}, |
| {id:'st4',name:'Westside Market',zone:'West',x:14,y:46,packages:14,service:7,open:'10:00',close:'14:00',priority:'Medium',archived:false}, |
| {id:'st5',name:'Lakeside Studios',zone:'East',x:90,y:67,packages:12,service:11,open:'09:00',close:'17:00',priority:'Low',archived:false}, |
| {id:'st6',name:'South Distribution',zone:'South',x:52,y:88,packages:19,service:14,open:'11:00',close:'17:30',priority:'Medium',archived:false}, |
| {id:'st7',name:'Cedar Heights',zone:'North',x:21,y:27,packages:10,service:7,open:'08:30',close:'15:00',priority:'High',archived:false}, |
| {id:'st8',name:'Innovation Campus',zone:'South',x:76,y:81,packages:16,service:12,open:'10:00',close:'17:00',priority:'Medium',archived:false}, |
| {id:'st9',name:'Harbor Business Park',zone:'West',x:12,y:75,packages:11,service:9,open:'10:00',close:'18:00',priority:'Low',archived:false} |
| ]; |
| const STANDARD={vehicles:3,capacity:55,speed:34}, SCENARIOS={Standard:{demand:100,traffic:100,extra:0},'Demand peak':{demand:140,traffic:105,extra:0},'Traffic disruption':{demand:105,traffic:175,extra:0},'Fleet reinforcement':{demand:120,traffic:110,extra:2}}; |
| const $=id=>document.getElementById(id);const clone=x=>JSON.parse(JSON.stringify(x));const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));const cap=(x,a,b)=>Math.min(b,Math.max(a,x));const integer=(x,a,b)=>Number.isInteger(x)&&x>=a&&x<=b;const fmt=(v,d=1)=>Number(v).toLocaleString('en-US',{maximumFractionDigits:d,minimumFractionDigits:d});const hm=m=>{const n=Math.round(m);return `${String(Math.floor(n/60)).padStart(2,'0')}:${String(n%60).padStart(2,'0')}`}; |
| function validTime(s){return typeof s==='string'&&/^([01]\d|2[0-3]):[0-5]\d$/.test(s)}function mins(s){return +s.slice(0,2)*60 + +s.slice(3,5)}function verifyStop(s){return s&&typeof s==='object'&&typeof s.id==='string'&&/^[A-Za-z0-9_-]{1,48}$/.test(s.id)&&typeof s.name==='string'&&s.name.trim().length>0&&s.name.length<=60&&ZONES.includes(s.zone)&&PRIORITIES.includes(s.priority)&&integer(s.x,0,100)&&integer(s.y,0,100)&&integer(s.packages,1,80)&&integer(s.service,1,45)&&validTime(s.open)&&validTime(s.close)&&mins(s.open)<mins(s.close)&&typeof s.archived==='boolean'}; |
| function validState(s){return !!s&&s.version===1&&Array.isArray(s.stops)&&s.stops.length<=80&&s.stops.filter(x=>!x.archived).length<=30&&s.stops.every(verifyStop)&&new Set(s.stops.map(x=>x.id)).size===s.stops.length&&s.settings&&integer(s.settings.vehicles,1,8)&&integer(s.settings.capacity,20,120)&&integer(s.settings.speed,15,90)&&typeof s.dark==='boolean'}; |
| function loadState(){try{const raw=localStorage.getItem(STORAGE);if(raw){const p=JSON.parse(raw);if(validState(p))return p}}catch(e){}return{version:1,stops:clone(DATA),settings:clone(STANDARD),dark:false}} |
| let state=loadState();let activePage='overview',draftId=null,preset='Standard',scenario=clone(SCENARIOS.Standard);document.body.classList.toggle('dark',state.dark); |
| function persist(){try{localStorage.setItem(STORAGE,JSON.stringify(state))}catch(e){toast('Storage blocked. Export JSON to preserve changes.')}}function toast(s){const e=$('toast');e.textContent=s;e.classList.add('show');clearTimeout(toast.id);toast.id=setTimeout(()=>e.classList.remove('show'),3500)} |
| function active(){return state.stops.filter(x=>!x.archived)};function distance(a,b){return Math.hypot(a.x-b.x,a.y-b.y)*SCALE}function conditions(sc=SCENARIOS.Standard,settings=state.settings){return{vehicles:cap(settings.vehicles+(sc.extra||0),1,8),capacity:settings.capacity,speed:settings.speed,traffic:sc.traffic/100,demand:sc.demand/100}} |
| function effective(stops,cond){return stops.filter(s=>!s.archived).map(s=>({...s,packages:Math.max(1,Math.round(s.packages*cond.demand))}))} |
| function evaluate(seq,cond){let pos=DEPOT,clock=480,km=0,late=0,onTime=0,maxLate=0,detail=[];for(const s of seq){const leg=distance(pos,s);km+=leg;clock+=leg/cond.speed*60*cond.traffic;const arrival=clock,delay=Math.max(0,arrival-mins(s.close));if(delay>1e-9){late++;maxLate=Math.max(delay,maxLate)}else onTime++;clock=Math.max(clock,mins(s.open))+s.service;detail.push({id:s.id,arrival,delay,serviceEnd:clock});pos=s}const back=distance(pos,DEPOT);km+=back;clock+=back/cond.speed*60*cond.traffic;return{km,late,onTime,maxLate,finish:clock,details:detail,score:km+late*14+detail.reduce((z,d)=>z+d.delay*.58,0)}} |
| function createRoutes(cond){return Array.from({length:cond.vehicles},(_,i)=>({id:i+1,stops:[],load:0}))} |
| function baseline(stops,cond){let routes=createRoutes(cond),unassigned=[];for(const s of stops){const r=routes.find(r=>r.load+s.packages<=cond.capacity);if(!r)unassigned.push(s);else{r.stops.push(s);r.load+=s.packages}}return summarize(routes,unassigned,cond)} |
| function optimize(stops,cond){let routes=createRoutes(cond),unassigned=[];const sorted=[...stops].sort((a,b)=>PRIORITIES.indexOf(a.priority)-PRIORITIES.indexOf(b.priority)||mins(a.close)-mins(b.close)||a.id.localeCompare(b.id));for(const s of sorted){let best=null;for(const r of routes){if(r.load+s.packages>cond.capacity)continue;const old=evaluate(r.stops,cond).score;for(let i=0;i<=r.stops.length;i++){const next=[...r.stops.slice(0,i),s,...r.stops.slice(i)];const delta=evaluate(next,cond).score-old;const key=delta+1e-4*r.id;if(best===null||key<best.key)best={r,seq:next,key}}}if(!best){unassigned.push(s);continue}best.r.stops=best.seq;best.r.load+=s.packages} |
| for(const r of routes){let changed=true,rounds=0;while(changed&&rounds++<5){changed=false;let current=evaluate(r.stops,cond).score;for(let i=0;i<r.stops.length-1;i++){for(let j=i+1;j<r.stops.length;j++){const next=r.stops.slice();next.splice(i,j-i+1,...next.slice(i,j+1).reverse());const score=evaluate(next,cond).score;if(score<current-1e-7){r.stops=next;current=score;changed=true}}}}}return summarize(routes,unassigned,cond)} |
| function summarize(routes,unassigned,cond){const prepared=routes.map(r=>({...r,...evaluate(r.stops,cond)}));const km=prepared.reduce((t,r)=>t+r.km,0),packages=prepared.reduce((t,r)=>t+r.load,0),served=prepared.reduce((t,r)=>t+r.stops.length,0),ontime=prepared.reduce((t,r)=>t+r.onTime,0),late=prepared.reduce((t,r)=>t+r.late,0);return{routes:prepared,unassigned,km,packages,served,ontime,late,totalStops:served+unassigned.length,util:100*packages/(cond.vehicles*cond.capacity),sla:served?100*ontime/served:0,finish:Math.max(480,...prepared.map(r=>r.finish)),condition:cond}} |
| function plan(sc=SCENARIOS.Standard,settings=state.settings,stops=active()){const cond=conditions(sc,settings),items=effective(stops,cond);return{optimized:optimize(items,cond),naive:baseline(items,cond)}} |
| function metric(name,value,foot,icon){return `<div class="card metric"><div class="metricTop"><span>${esc(name)}</span><span class="metricIcon">${esc(icon)}</span></div><div class="value mono">${esc(value)}</div><div class="foot">${esc(foot)}</div></div>`} |
| function routeColor(i){return COLORS[i%COLORS.length]};function svgMap(result){const W=760,H=438,project=p=>[32+p.x*6.96,30+p.y*3.72],grid=[];for(let x=0;x<=100;x+=10){const n=project({x,y:0})[0];grid.push(`<line x1="${n}" y1="21" x2="${n}" y2="414" stroke="var(--line)" stroke-dasharray="3 6" opacity=".78"/>`)}for(let y=0;y<=100;y+=10){const n=project({x:0,y})[1];grid.push(`<line x1="20" y1="${n}" x2="744" y2="${n}" stroke="var(--line)" stroke-dasharray="3 6" opacity=".78"/>`)}const blocks=[[90,60,65,30],[174,178,45,40],[408,70,50,44],[540,165,74,36],[270,287,68,40],[605,309,72,43]].map(([x,y,w,h])=>`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="var(--tint)" opacity=".6"/>`).join('');let paths='',dots='';const depot=project(DEPOT),all=new Map();for(const r of result.routes){const coords=[depot,...r.stops.map(project),depot].map(x=>x.join(',')).join(' ');if(r.stops.length){paths+=`<polyline points="${coords}" fill="none" stroke="${routeColor(r.id-1)}" stroke-width="3.7" stroke-linecap="round" stroke-linejoin="round" opacity=".90"/>`;r.stops.forEach((s,i)=>{const [x,y]=project(s);all.set(s.id,true);dots+=`<g><circle cx="${x}" cy="${y}" r="10.4" fill="${routeColor(r.id-1)}" stroke="var(--paper)" stroke-width="2.8"/><text x="${x}" y="${y+3.6}" font-size="10" font-weight="850" fill="white" text-anchor="middle">${i+1}</text><title>${esc(s.name)} · Vehicle ${r.id} · ${s.packages} packages</title></g>`})}} |
| for(const s of result.unassigned){const [x,y]=project(s);dots+=`<g><circle cx="${x}" cy="${y}" r="11" fill="var(--paper)" stroke="#ce6474" stroke-width="3"/><text x="${x}" y="${y+4}" fill="#ce6474" font-size="13" font-weight="900" text-anchor="middle">!</text><title>${esc(s.name)}: unassigned due to capacity</title></g>`}return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Schematic route network: vehicle paths, numbered stops, depot and unassigned deliveries"><rect x="1" y="1" width="758" height="436" rx="10" fill="var(--soft)" stroke="var(--line)"/>${blocks}<g>${grid.join('')}</g>${paths}${dots}<g><rect x="${depot[0]-11}" y="${depot[1]-11}" width="22" height="22" rx="5" fill="#172f44" stroke="#fff" stroke-width="2.5"/><text x="${depot[0]}" y="${depot[1]+4}" fill="white" text-anchor="middle" font-weight="900" font-size="13">D</text></g><text x="29" y="20" fill="var(--muted)" font-size="10" font-weight="800">SCHEMATIC SERVICE GRID / NOT TO SCALE</text><text x="742" y="427" text-anchor="end" fill="var(--muted)" font-size="9">DEPARTURE 08:00 · DEPOT D</text></svg>`} |
| function overview(){const r=plan().optimized;$('overviewKpis').innerHTML=metric('Assigned packages',fmt(r.packages,0),'Modeled packages on routes','▣')+metric('Route distance',fmt(r.km)+' km','Euclidean distance proxy','⌁')+metric('On-time deliveries',fmt(r.sla)+'%','Among stops that were assigned','◷')+metric('Unassigned stops',String(r.unassigned.length),'Due to fleet capacity only','⚑');$('mapOverview').innerHTML=svgMap(r);$('mapLegend').innerHTML=r.routes.map(x=>`<span><i class="markdot" style="background:${routeColor(x.id-1)}"></i>Vehicle ${x.id}${!x.stops.length?' (idle)':''}</span>`).join('')+`<span><i class="markdot" style="background:#172f44"></i>Depot</span><span><i class="markdot" style="background:#ce6474"></i>Unassigned</span>`;$('fleetOverview').innerHTML=r.routes.map(x=>`<div class="listline"><div><b>Vehicle ${x.id}</b><small>${x.stops.length} stops · ${x.load}/${r.condition.capacity} packages</small></div><div class="num mono">${fmt(x.km)} km<small>${fmt(x.stops.length?100*x.onTime/x.stops.length:0,0)}% on time</small></div></div>`).join('');$('overviewInsight').textContent=r.unassigned.length?`${r.unassigned.length} stop(s) could not be assigned within the modeled total package capacity. Review fleet size or capacity in Route planner.`:r.late?`${r.late} scheduled stop(s) arrive after their fictional delivery windows. Try more vehicles or a lower traffic factor.`:'All active stops are assigned and modeled as on time under the standard assumptions.';$('utilizationBars').innerHTML=r.routes.map(x=>`<div class="barRow"><b>Vehicle ${x.id}</b><div class="barTrack"><span style="width:${cap(x.load/r.condition.capacity*100,0,100).toFixed(1)}%;background:${routeColor(x.id-1)}"></span></div><span class="mono">${fmt(x.load/r.condition.capacity*100,0)}%</span></div>`).join('');let p=[];for(const r0 of r.routes)for(const d of r0.details)if(d.delay>0){const s=r0.stops.find(s=>s.id===d.id);p.push({name:s.name,what:`Late by ${fmt(d.delay,0)} minutes · Vehicle ${r0.id}`})}for(const s of r.unassigned)p.push({name:s.name,what:`${s.packages} packages · insufficient available capacity`});$('priorityList').innerHTML=p.length?p.slice(0,6).map(x=>`<div class="listline"><div><b>${esc(x.name)}</b><small>${esc(x.what)}</small></div><span class="tag bad">Attention</span></div>`).join(''):'<div class="notice"><strong>Clear dispatch:</strong> No lateness or unassigned stops in this synthetic scenario.</div>'} |
| const CONFIG=[['vehicles','Fleet vehicles','vehicles',1,8],['capacity','Packages per vehicle','packages',20,120],['speed','Average speed','km/h',15,90]]; |
| function renderControls(){const e=$('controlPanel');e.innerHTML=CONFIG.map(([key,name,unit,min,max])=>`<div class="sliderRow"><div class="sliderLabel"><label for="ctrl_${key}">${esc(name)}</label><output id="ctrlOut_${key}">${state.settings[key]} ${unit}</output></div><input type="range" id="ctrl_${key}" data-control="${key}" min="${min}" max="${max}" step="1" value="${state.settings[key]}"></div>`).join('')} |
| function planner(rebuild=false){if(rebuild)renderControls();const p=plan(),r=p.optimized,b=p.naive;$('plannerKpis').innerHTML=metric('Optimized distance',fmt(r.km)+' km',`Sequential: ${fmt(b.km)} km`,'⌁')+metric('Assigned stops',`${r.served}/${r.totalStops}`,'Feasible under vehicle capacity','◉')+metric('On-time rate',fmt(r.sla)+'%',`Sequential: ${fmt(b.sla)}%`,'◷')+metric('Latest return',hm(r.finish),'Routes depart at 08:00','↶');$('comparison').innerHTML=`<div class="twoK"><div class="statbox"><span>Sequential allocation</span><b>${fmt(b.km)} km</b><span>${fmt(b.sla)}% on time · ${b.unassigned.length} unassigned</span></div><div class="statbox"><span>Constrained heuristic</span><b>${fmt(r.km)} km</b><span>${fmt(r.sla)}% on time · ${r.unassigned.length} unassigned</span></div></div><div class="divider"></div><div class="listline"><div><b>Distance change</b><small>Heuristic versus sequential</small></div><div class="num mono">${b.km?fmt(100*(r.km/b.km-1))+'%':'0%'}</div></div><div class="listline"><div><b>On-time change</b><small>Percentage-point difference</small></div><div class="num mono">${fmt(r.sla-b.sla)} pp</div></div>`;$('routeTable').innerHTML=`<table><thead><tr><th>Vehicle</th><th>Assigned stops</th><th>Load</th><th>Distance</th><th>On time</th><th>Return</th></tr></thead><tbody>${r.routes.map(x=>`<tr><td><b>Vehicle ${x.id}</b></td><td>${x.stops.length?x.stops.map(s=>esc(s.name)).join(' → '):'<span class="minor">Idle</span>'}</td><td class="mono">${x.load}/${r.condition.capacity}</td><td class="mono">${fmt(x.km)} km</td><td><span class="tag ${x.late?'warn':'good'}">${x.onTime}/${x.stops.length}</span></td><td class="mono">${hm(x.finish)}</td></tr>`).join('')}</tbody></table>`} |
| function clearForm(){draftId=null;$('stopForm').reset();$('stopName').value='';$('stopPackages').value=12;$('stopService').value=8;$('stopX').value=40;$('stopY').value=60;$('stopOpen').value='08:00';$('stopClose').value='17:00';$('editHeading').textContent='Add a delivery stop';$('saveStop').textContent='Save stop';$('formNotice').textContent='All fields required. Times must be in a valid order.'} |
| function editStop(id){const s=state.stops.find(x=>x.id===id);if(!s)return;draftId=id;for(const k of ['name','zone','priority','packages','service','x','y','open','close'])$('stop'+k[0].toUpperCase()+k.slice(1)).value=s[k];$('editHeading').textContent='Edit stop';$('saveStop').textContent='Save changes';setPage('stops');$('stopForm').scrollIntoView({behavior:'smooth',block:'center'})} |
| function stopRows(){const query=$('searchStops').value.toLowerCase().trim(),zone=$('zoneFilter').value,status=$('statusFilter').value;const items=state.stops.filter(x=>(status==='all'||(status==='active'?!x.archived:x.archived))&&(zone==='all'||x.zone===zone)&&(!query||(x.name+' '+x.zone).toLowerCase().includes(query)));$('stopRows').innerHTML=items.map(s=>`<tr><td><b>${esc(s.name)}</b><small>${esc(s.zone)} · ${s.archived?'Archived':'Active'}</small></td><td><span class="tag ${s.priority==='High'?'warn':''}">${esc(s.priority)}</span></td><td class="mono">${s.packages}</td><td class="mono">${esc(s.open)}–${esc(s.close)}</td><td class="mono">${s.x}, ${s.y}</td><td><div class="printline"><button class="btn" data-edit="${esc(s.id)}">Edit</button><button class="btn ${s.archived?'soft':'warn'}" data-archive="${esc(s.id)}">${s.archived?'Restore':'Archive'}</button></div></td></tr>`).join('')||'<tr><td colspan="6" class="empty">No matching stops.</td></tr>';$('stopCount').textContent=`${state.stops.filter(x=>!x.archived).length} active · ${state.stops.filter(x=>x.archived).length} archived · ${items.length} shown`} |
| function renderPresets(){const btns=Object.keys(SCENARIOS);$('presetButtons').innerHTML=btns.map(k=>`<button class="btn ${preset===k?'chosen':''}" data-preset="${esc(k)}">${esc(k)}</button>`).join('')} |
| const SC_CTRL=[['demand','Demand multiplier','%',60,180],['traffic','Travel-time multiplier','%',70,200],['extra','Additional vehicles','',0,4]]; |
| function renderScenarioInputs(){renderPresets();$('scenarioControls').innerHTML=SC_CTRL.map(([k,label,unit,min,max])=>`<div class="sliderRow"><div class="sliderLabel"><label for="sc_${k}">${esc(label)}</label><output id="scOut_${k}">${scenario[k]}${unit}</output></div><input id="sc_${k}" data-scenario="${k}" type="range" min="${min}" max="${max}" step="${k==='extra'?'1':'5'}" value="${scenario[k]}"></div>`).join('')} |
| function rng(seed=1082026){return()=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/4294967296}} |
| function monte(sc,iterations=240){const rnd=rng(),sla=[],distanceTotals=[],unserved=[];for(let i=0;i<iterations;i++){const demandJitter=.90+rnd()*.2,trafficJitter=.85+rnd()*.30;const test={...sc,demand:cap(sc.demand*demandJitter,50,190),traffic:cap(sc.traffic*trafficJitter,50,220)};const p=plan(test).optimized;sla.push(p.sla);distanceTotals.push(p.km);unserved.push(p.unassigned.length)}const summarizeA=a=>{a.sort((x,y)=>x-y);return[a[Math.floor((iterations-1)*.1)],a[Math.floor((iterations-1)*.5)],a[Math.floor((iterations-1)*.9)]]};return{sla:summarizeA(sla),km:summarizeA(distanceTotals),unassigned:summarizeA(unserved)}} |
| function renderScenario(full=false){if(full)renderScenarioInputs();const base=plan(SCENARIOS.Standard).optimized,r=plan(scenario).optimized;$('scenarioKpis').innerHTML=metric('Route distance',fmt(r.km)+' km',`Standard: ${fmt(base.km)} km`,'⌁')+metric('On-time rate',fmt(r.sla)+'%',`Standard: ${fmt(base.sla)}%`,'◷')+metric('Unassigned stops',String(r.unassigned.length),`Standard: ${base.unassigned.length}`,'⚑')+metric('Fleet usage',fmt(r.util)+'%',`${r.condition.vehicles} available vehicles`,'▤');$('scenarioComparison').innerHTML=[['Total route distance',fmt(base.km)+' km',fmt(r.km)+' km'],['On-time delivery rate',fmt(base.sla)+'%',fmt(r.sla)+'%'],['Assigned packages',String(base.packages),String(r.packages)],['Unassigned stops',String(base.unassigned.length),String(r.unassigned.length)],['Fleet size',String(base.condition.vehicles),String(r.condition.vehicles)]].map(([name,a,b])=>`<div class="listline"><div><b>${esc(name)}</b><small>Standard → scenario</small></div><div class="num mono">${esc(a)} → ${esc(b)}</div></div>`).join('');const m=monte(scenario);$('monteResults').innerHTML=[['On-time rate',m.sla,'%'],['Route distance',m.km,' km'],['Unassigned stops',m.unassigned,'']].map(([name,q,unit])=>`<div class="listline"><div><b>${esc(name)}</b><small>P10 / P50 / P90</small></div><div class="num mono">${q.map(x=>fmt(x,unit===''?0:1)+unit).join(' / ')}</div></div>`).join('')+'<p class="subtitle">Draws perturb demand by ±10% and travel multiplier by ±15%; quantiles reflect only these fictional variations.</p>';$('mapScenario').innerHTML=svgMap(r)} |
| function setPage(name){if(!$(''+name)||!['overview','planner','stops','scenarios','methods'].includes(name))return;activePage=name;document.querySelectorAll('.section').forEach(x=>x.classList.toggle('active',x.id===name));document.querySelectorAll('[data-page]').forEach(x=>x.classList.toggle('sel',x.dataset.page===name));if(name==='planner')planner(true);if(name==='scenarios')renderScenario(true);if(name==='overview')overview();if(name==='stops')stopRows();window.scrollTo({top:0,behavior:'instant'})} |
| function refreshAll(){overview();planner(true);stopRows();renderScenario(true)} |
| function download(filename,text,mime){const blob=new Blob([text],{type:mime}),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),2000)} |
| function csvValue(v){let s=String(v??'');if(/^[\s]*[=+\-@\t\r]/.test(s))s="'"+s;return '"'+s.replaceAll('"','""')+'"'}function csv(rows){return '\ufeff'+rows.map(r=>r.map(csvValue).join(',')).join('\r\n')+'\r\n'} |
| function stopCSV(){const rows=[['ID','Name','Zone','Packages','ServiceMinutes','GridX','GridY','Opening','Closing','Priority','Status'],...state.stops.map(s=>[s.id,s.name,s.zone,s.packages,s.service,s.x,s.y,s.open,s.close,s.priority,s.archived?'Archived':'Active'])];return csv(rows)}function routeCSV(){const r=plan().optimized,rows=[['Vehicle','Sequence','Stop','Zone','Packages','ArrivalHHMM','DelayMinutes','RouteKm','VehicleLoad']];for(const v of r.routes){v.stops.forEach((s,i)=>{let d=v.details.find(x=>x.id===s.id);rows.push([v.id,i+1,s.name,s.zone,s.packages,hm(d.arrival),d.delay.toFixed(1),v.km.toFixed(2),v.load])})}for(const s of r.unassigned)rows.push(['Unassigned','',s.name,s.zone,s.packages,'','',0,'']);return csv(rows)} |
| function fileImport(file){if(!file)return;if(file.size>512000){toast('Backup exceeds 500 KB. No data changed.');return}const reader=new FileReader();reader.onload=()=>{let obj;try{obj=JSON.parse(String(reader.result))}catch(e){toast('Invalid JSON. No data changed.');return}if(!validState(obj)){toast('Invalid backup schema or values. No data changed.');return}if(!window.confirm(`Restore ${obj.stops.length} saved delivery stops? This will replace the current local registry. Export a backup first if you need it.`))return;state=clone(obj);document.body.classList.toggle('dark',state.dark);persist();clearForm();refreshAll();toast('Backup restored and validated.')};reader.onerror=()=>toast('Could not read backup.');reader.readAsText(file)} |
| document.querySelectorAll('[data-page]').forEach(x=>x.addEventListener('click',()=>setPage(x.dataset.page)));$('themeBtn').addEventListener('click',()=>{state.dark=!state.dark;document.body.classList.toggle('dark',state.dark);persist()});$('exportBackup').addEventListener('click',()=>download('dispatchforge-backup.json',JSON.stringify(state,null,2),'application/json'));$('exportPlan').addEventListener('click',()=>download('dispatchforge-routes.csv',routeCSV(),'text/csv;charset=utf-8'));$('exportStops').addEventListener('click',()=>download('dispatchforge-stops.csv',stopCSV(),'text/csv;charset=utf-8'));$('importBtn').addEventListener('click',()=>$('restoreFile').click());$('restoreFile').addEventListener('change',e=>{fileImport(e.target.files[0]);e.target.value=''});$('restoreSettings').addEventListener('click',()=>{state.settings=clone(STANDARD);persist();planner(true);overview();renderScenario();toast('Standard fleet settings restored.')});$('controlPanel').addEventListener('input',e=>{const k=e.target.dataset.control;if(!k)return;const value=Number(e.target.value);if(!CONFIG.some(x=>x[0]===k)||!integer(value,1,120))return;state.settings[k]=value;$('ctrlOut_'+k).textContent=value+' '+CONFIG.find(x=>x[0]===k)[2];persist();planner();overview();renderScenario()});$('presetButtons').addEventListener('click',e=>{const b=e.target.closest('[data-preset]');if(!b||!SCENARIOS[b.dataset.preset])return;preset=b.dataset.preset;scenario=clone(SCENARIOS[preset]);renderScenario(true)});$('scenarioControls').addEventListener('input',e=>{const k=e.target.dataset.scenario;if(!SC_CTRL.some(x=>x[0]===k))return;scenario[k]=+e.target.value;preset='Custom';$('scOut_'+k).textContent=scenario[k]+(k==='extra'?'':'%');renderPresets();renderScenario()});$('searchStops').addEventListener('input',stopRows);$('zoneFilter').addEventListener('change',stopRows);$('statusFilter').addEventListener('change',stopRows);$('cancelEdit').addEventListener('click',clearForm);$('newStop').addEventListener('click',()=>{clearForm();$('stopForm').scrollIntoView({behavior:'smooth',block:'center'});$('stopName').focus()});$('stopRows').addEventListener('click',e=>{const ed=e.target.closest('[data-edit]'),ar=e.target.closest('[data-archive]');if(ed)editStop(ed.dataset.edit);if(ar){const s=state.stops.find(s=>s.id===ar.dataset.archive);if(!s)return;if(s.archived&&active().length>=30){toast('Maximum 30 active stops. Archive another before restoring.');return}s.archived=!s.archived;persist();refreshAll();toast(s.archived?'Stop archived, not deleted.':'Stop restored.')}}); |
| $('stopForm').addEventListener('submit',e=>{e.preventDefault();let s={id:draftId||('stop'+Date.now().toString(36)+Math.floor(Math.random()*1e5).toString(36)),name:$('stopName').value.trim(),zone:$('stopZone').value,priority:$('stopPriority').value,packages:Number($('stopPackages').value),service:Number($('stopService').value),x:Number($('stopX').value),y:Number($('stopY').value),open:$('stopOpen').value,close:$('stopClose').value,archived:false};if(draftId){const old=state.stops.find(x=>x.id===draftId);if(old)s.archived=old.archived}if(!verifyStop(s)){$('formNotice').textContent='Invalid data. Check quantities, grid coordinates and service times.';return}if(!draftId&&state.stops.length>=80){$('formNotice').textContent='Maximum 80 saved stops reached.';return}if(!draftId&&active().length>=30){$('formNotice').textContent='Maximum 30 active stops. Archive another stop first.';return}if(draftId){const i=state.stops.findIndex(x=>x.id===draftId);if(i<0)return;state.stops[i]=s}else state.stops.push(s);persist();const wasEdit=Boolean(draftId);clearForm();refreshAll();toast(wasEdit?'Stop updated successfully.':'New stop added.');}); |
| refreshAll();setPage('overview');window.__dispatchTest={distance,evaluate,baseline,optimize,plan,monte,validState,verifyStop,routeCSV,stopCSV,conditions,effective,getState:()=>clone(state)}; |
| </script></body></html> |
|
|