Upload 3 files
Browse files- LICENSE +21 -0
- README.md +89 -6
- index.html +75 -18
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Hugdrigo (Rodrigo Pantoja Navajas)
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,12 +1,95 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
|
|
|
| 7 |
pinned: false
|
| 8 |
license: mit
|
| 9 |
-
short_description:
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: DispatchForge
|
| 3 |
+
emoji: 🚚
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: static
|
| 7 |
+
app_file: index.html
|
| 8 |
pinned: false
|
| 9 |
license: mit
|
| 10 |
+
short_description: Fleet routing and delivery simulation.
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# DispatchForge
|
| 14 |
+
|
| 15 |
+
### Delivery Operations Studio | Hugdrigo Portfolio Project #108
|
| 16 |
+
|
| 17 |
+
**DispatchForge** is a fully offline, interactive, browser-based demo that creates capacity-feasible delivery routes for a **fictional** last-mile dispatch network. It compares a sequential allocation against a deterministic routing heuristic, visualizes routes on a synthetic grid, evaluates simulated delivery time windows, and stress-tests demand and travel-speed assumptions.
|
| 18 |
+
|
| 19 |
+
**Short description:** Fleet routing and delivery simulation.
|
| 20 |
+
|
| 21 |
+
> **Synthetic demonstration only.** All nine starting delivery locations, grid coordinates, packages, time windows, travel assumptions and fleet resources are fabricated. This is not a map, GPS navigator, real route planner, traffic forecast, or production dispatch system. No actual street network, driving restrictions, driver hours, road conditions or legally mandated vehicle limits are represented. Do not use the output to dispatch real vehicles.
|
| 22 |
+
|
| 23 |
+
## Publish on Hugging Face
|
| 24 |
+
|
| 25 |
+
1. Open [Create a Space](https://huggingface.co/new-space).
|
| 26 |
+
2. Use **Owner:** `Hugdrigo`; **Space name:** `dispatchforge-last-mile-routing`; **SDK:** `Static`; **Template:** `Blank`; **Visibility:** `Public`; **License:** `MIT`.
|
| 27 |
+
3. Extract the supplied ZIP. Upload precisely **`README.md`**, **`index.html`**, and **`LICENSE`** to the **root** of the new repository, not a subfolder.
|
| 28 |
+
4. Click **Commit changes**, then open **App** to check the deployment and test its controls. Static Spaces need no paid hardware or backend process for this app.
|
| 29 |
+
|
| 30 |
+
### YAML metadata compliance
|
| 31 |
+
|
| 32 |
+
The front matter above is parsed as Space metadata. Both colors are from the only supported color names (`red`, `yellow`, `green`, `blue`, `indigo`, `purple`, `pink`, `gray`):
|
| 33 |
+
|
| 34 |
+
- `colorFrom: blue`
|
| 35 |
+
- `colorTo: green`
|
| 36 |
+
- `sdk: static`
|
| 37 |
+
- `app_file: index.html`
|
| 38 |
+
|
| 39 |
+
Do not substitute CSS hex colors, `teal`, or `cyan` into Space color keys. The app's own CSS colors may use other values.
|
| 40 |
+
|
| 41 |
+
## App modules
|
| 42 |
+
|
| 43 |
+
| Module | Available functions |
|
| 44 |
+
|---|---|
|
| 45 |
+
| Overview | KPI dashboard, schematic multivehicle route map, route legend, vehicle details, capacity bars and operational priorities |
|
| 46 |
+
| Route planner | Fleet-size, capacity and speed controls; naive sequential-vs-heuristic comparison; calculated route table and CSV export |
|
| 47 |
+
| Delivery stops | Add/edit up to 30 active stops (80 stored total), filter/search, preserve archived records and restore them, CSV export |
|
| 48 |
+
| Scenarios | Four presets (standard, demand peak, traffic disruption, fleet reinforcement), independent sliders, scenario comparison, deterministic 240-draw uncertainty experiment |
|
| 49 |
+
| Methodology | Routing rules, equations, data handling, assumptions and explicit limitations |
|
| 50 |
+
|
| 51 |
+
Additional features: dark/light mode, browser-local persistence where allowed, defensive JSON import/export, CSV formula escaping, mobile layout, offline use with no API calls, no external scripts, no JavaScript build dependencies and no cookies or analytics.
|
| 52 |
+
|
| 53 |
+
## Model details
|
| 54 |
+
|
| 55 |
+
- **Synthetic coordinates:** Each fictional stop lies on a 0–100 × 0–100 city grid; depot is at `(48,49)`. Grid-unit distance is Euclidean, multiplied by `0.42` to produce **illustrative** kilometers.
|
| 56 |
+
- **Travel time:** `travel_minutes = distance_km / speed_km_per_hour × 60 × traffic_multiplier`.
|
| 57 |
+
- **Service window:** Vehicles depart at 08:00. A driver may wait for a stop to open. Any arrival after the closing time is counted as a late stop. Every stop has a fixed fictional service duration.
|
| 58 |
+
- **Capacity:** All packages assigned to a vehicle must total no more than the vehicle's declared package capacity. No volumetric, weight, multiple-compartment or driver-work-time capacity is modeled.
|
| 59 |
+
- **Assignment:** Sort active stops by priority and window closing time, try every feasible insertion on every vehicle, choose the lowest change in `distance + 14 × count_late + 0.58 × late_minutes` (distance-equivalent heuristic score), then apply bounded 2-opt route-sequence changes. This is **not** an exact global optimum solver.
|
| 60 |
+
- **Sequential comparator:** Assign stops in saved-record order to the first vehicle that has sufficient remaining package capacity, without attempting route optimization.
|
| 61 |
+
- **On-time percentage:** `(on_time_stops / assigned_stops) × 100`. If no stops are assigned, the displayed rate is zero.
|
| 62 |
+
- **Fleet utilization:** `(assigned_packages / (fleet_vehicles × capacity_per_vehicle)) × 100`.
|
| 63 |
+
- **Distance includes return:** Every nonempty vehicle route includes a return leg to the synthetic depot; idle vehicles have zero distance.
|
| 64 |
+
- **Scenario changes:** Demand multiplies every stop's packages (rounded to nearest positive integer); traffic changes modeled travel time; additional vehicles change availability, within a maximum of 8.
|
| 65 |
+
- **Uncertainty experiment:** 240 deterministic pseudo-random draws per scenario, with assumed demand ±10% and travel-time multiplier ±15%. P10, P50 and P90 describe only these hypothetical draws; **not** calibrated forecast or confidence intervals. The same settings and seed return identical output.
|
| 66 |
+
|
| 67 |
+
## Data and integrity
|
| 68 |
+
|
| 69 |
+
- All starting stops are **fabricated**. Use the form to experiment with your own fictional data.
|
| 70 |
+
- The JSON backup stores `{version: 1, stops: [...], settings: {vehicles,capacity,speed}, dark}`.
|
| 71 |
+
- Restore checks the expected version, numeric bounds, valid time windows, unique IDs, allowed categories and priorities, maximum record counts (80 saved, 30 active), and size (up to 500 KiB), then asks for confirmation before replacing the local registry.
|
| 72 |
+
- Archived records remain in the backup and can be restored. Archiving is not deletion.
|
| 73 |
+
- Names are HTML-escaped before rendering. CSV cells that begin with spreadsheet formula prefixes are quoted and prefixed with a safe apostrophe to mitigate formula injection. This is a browser demonstration, not a security certification.
|
| 74 |
+
- Storage: browser `localStorage` if permitted; browser storage may be cleared by user settings or private modes. Export backups for portability. No data leaves the browser unless the user downloads or shares it.
|
| 75 |
+
- Time windows refer to a fictional single-day operating schedule and are not tied to live dates, holidays, timezones or calendar APIs.
|
| 76 |
+
|
| 77 |
+
## Verification checklist
|
| 78 |
+
|
| 79 |
+
1. Open Overview: 9 active demo stops and 3 vehicles should render, with a visual network map.
|
| 80 |
+
2. Open Route planner, move all three controls and confirm KPI/route changes.
|
| 81 |
+
3. Open Delivery stops: search, filter, add a stop, edit it, archive it, then restore it.
|
| 82 |
+
4. Open Scenarios: change presets and sliders, confirm the map and results update.
|
| 83 |
+
5. Export route CSV, stop CSV and JSON backup. Import the backup and accept the confirmation; reject invalid JSON without changing data.
|
| 84 |
+
6. Switch to dark mode and try narrow mobile sizes; only data tables may scroll horizontally.
|
| 85 |
+
7. Check the README YAML colors, `static` SDK, exact `app_file`, all three ZIP entries and no external runtime dependencies.
|
| 86 |
+
|
| 87 |
+
## Constraints and transparency
|
| 88 |
+
|
| 89 |
+
This simulator does **not** promise real-world delivery optimization, financial savings, real-time traffic, exact route optimality, geocoding, real emissions, customer privacy compliance, or probabilistic service-level forecasts. Its value is in demonstrating routing heuristics, visualization, browser app engineering, data validation and scenario reasoning with reproducible synthetic inputs.
|
| 90 |
+
|
| 91 |
+
## License
|
| 92 |
+
|
| 93 |
+
MIT. See `LICENSE`.
|
| 94 |
+
|
| 95 |
+
**Portfolio:** Hugdrigo | **Project:** 108 | **Space:** `Hugdrigo/dispatchforge-last-mile-routing`
|
index.html
CHANGED
|
@@ -1,19 +1,76 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<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>
|
| 3 |
+
<style>
|
| 4 |
+
: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}
|
| 5 |
+
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}
|
| 6 |
+
*{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}
|
| 7 |
+
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)}
|
| 8 |
+
@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}}
|
| 9 |
+
@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}}
|
| 10 |
+
@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}}
|
| 11 |
+
@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}}
|
| 12 |
+
</style></head>
|
| 13 |
+
<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>
|
| 14 |
+
<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>
|
| 15 |
+
<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>
|
| 16 |
+
<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>
|
| 17 |
+
<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>
|
| 18 |
+
<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>
|
| 19 |
+
<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>
|
| 20 |
+
<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>
|
| 21 |
+
<script>
|
| 22 |
+
'use strict';
|
| 23 |
+
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'];
|
| 24 |
+
const DATA=[
|
| 25 |
+
{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},
|
| 26 |
+
{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},
|
| 27 |
+
{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},
|
| 28 |
+
{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},
|
| 29 |
+
{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},
|
| 30 |
+
{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},
|
| 31 |
+
{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},
|
| 32 |
+
{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},
|
| 33 |
+
{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}
|
| 34 |
+
];
|
| 35 |
+
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}};
|
| 36 |
+
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')}`};
|
| 37 |
+
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'};
|
| 38 |
+
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'};
|
| 39 |
+
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}}
|
| 40 |
+
let state=loadState();let activePage='overview',draftId=null,preset='Standard',scenario=clone(SCENARIOS.Standard);document.body.classList.toggle('dark',state.dark);
|
| 41 |
+
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)}
|
| 42 |
+
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}}
|
| 43 |
+
function effective(stops,cond){return stops.filter(s=>!s.archived).map(s=>({...s,packages:Math.max(1,Math.round(s.packages*cond.demand))}))}
|
| 44 |
+
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)}}
|
| 45 |
+
function createRoutes(cond){return Array.from({length:cond.vehicles},(_,i)=>({id:i+1,stops:[],load:0}))}
|
| 46 |
+
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)}
|
| 47 |
+
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}
|
| 48 |
+
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)}
|
| 49 |
+
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}}
|
| 50 |
+
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)}}
|
| 51 |
+
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>`}
|
| 52 |
+
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>`})}}
|
| 53 |
+
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>`}
|
| 54 |
+
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>'}
|
| 55 |
+
const CONFIG=[['vehicles','Fleet vehicles','vehicles',1,8],['capacity','Packages per vehicle','packages',20,120],['speed','Average speed','km/h',15,90]];
|
| 56 |
+
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('')}
|
| 57 |
+
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>`}
|
| 58 |
+
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.'}
|
| 59 |
+
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'})}
|
| 60 |
+
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`}
|
| 61 |
+
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('')}
|
| 62 |
+
const SC_CTRL=[['demand','Demand multiplier','%',60,180],['traffic','Travel-time multiplier','%',70,200],['extra','Additional vehicles','',0,4]];
|
| 63 |
+
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('')}
|
| 64 |
+
function rng(seed=1082026){return()=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/4294967296}}
|
| 65 |
+
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)}}
|
| 66 |
+
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)}
|
| 67 |
+
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'})}
|
| 68 |
+
function refreshAll(){overview();planner(true);stopRows();renderScenario(true)}
|
| 69 |
+
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)}
|
| 70 |
+
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'}
|
| 71 |
+
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)}
|
| 72 |
+
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)}
|
| 73 |
+
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.')}});
|
| 74 |
+
$('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.');});
|
| 75 |
+
refreshAll();setPage('overview');window.__dispatchTest={distance,evaluate,baseline,optimize,plan,monte,validState,verifyStop,routeCSV,stopCSV,conditions,effective,getState:()=>clone(state)};
|
| 76 |
+
</script></body></html>
|