0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
A small electric delivery van leaves a depot, drops twelve parcels around the neighbourhood and comes home — except one battery charge does not cover the lot, so the round has to be cut into separate return trips. It opens mid-solve: the first guess just hops to whichever parcel is nearest, then 2-opt flips stretches of the route looking for the flip that shortens it most, and you watch the crossed legs pull apart while the kilometre count drops. Hit show first guess to see the tangled starting route ghosted behind the tidy one, then drag battery range down and watch the plan re-cut itself into more, shorter trips, each drawn in its own colour. Click the map to add a parcel or click one to cancel it — and push the range low enough and drops fall outside the dashed reach ring, which the planner flags rather than quietly skipping.
Source — the code you see is the code that just ran 1399 lines 53.3 KB index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Delivery Route Planner</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
/* ---------- base ---------- */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
line-height: 1.5;
overflow-x: hidden;
-webkit-text-size-adjust: 100%;
font-size: 15px;
}
.wrap { max-width: 1180px; margin: 0 auto; padding: 18px 14px 32px; }
h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -0.01em; }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.sr {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
/* ---------- header ---------- */
.head {
display: flex; flex-wrap: wrap; gap: 12px 16px;
align-items: flex-start; justify-content: space-between;
padding-bottom: 14px; margin-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.head h1 { font-size: 1.32rem; }
.lede { margin: 5px 0 0; color: var(--muted); max-width: 64ch; font-size: 0.95rem; }
.status {
display: inline-flex; align-items: center; gap: 8px;
font-family: var(--font-mono); font-size: 0.74rem; letter-spacing: 0.02em;
padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px;
background: var(--surface); color: var(--muted); white-space: nowrap;
flex-shrink: 0;
}
.status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
.status[data-state="load"] .dot { background: var(--accent); animation: pulse 1.1s ease-in-out infinite; }
.status[data-state="ok"] .dot { background: var(--run); }
.status[data-state="ok"] { color: var(--run); border-color: var(--run); background: var(--run-soft); }
.status[data-state="warn"] .dot { background: var(--accent); }
.status[data-state="warn"] { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } }
/* ---------- layout ---------- */
.grid { display: grid; gap: 14px; grid-template-columns: 306px minmax(0, 1fr); align-items: start; }
.col { display: grid; gap: 14px; min-width: 0; }
@media (max-width: 900px) {
.grid { grid-template-columns: minmax(0, 1fr); }
.controls { order: 2; }
.col { order: 1; }
}
.panel {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 14px; min-width: 0;
}
.panel > h2, .map-head h2 {
font-family: var(--font-mono); font-size: 0.7rem; text-transform: uppercase;
letter-spacing: 0.09em; color: var(--muted); font-weight: 600; margin-bottom: 12px;
}
/* ---------- controls ---------- */
.presets { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
button {
font: inherit; color: var(--ink); background: var(--surface);
border: 1px solid var(--line); border-radius: 7px;
padding: 7px 11px; min-height: 34px; cursor: pointer;
font-family: var(--font-mono); font-size: 0.75rem;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
button:hover { border-color: var(--accent); color: var(--accent); }
button[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.ctl { margin-bottom: 15px; }
.ctl:last-child { margin-bottom: 0; }
.ctl-top { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin-bottom: 4px; }
.ctl-top label { font-size: 0.88rem; }
.ctl-val {
font-family: var(--font-mono); font-variant-numeric: tabular-nums;
font-size: 0.88rem; font-weight: 600; color: var(--accent); white-space: nowrap;
}
.ctl-hint { font-family: var(--font-mono); font-size: 0.68rem; color: var(--muted); margin-top: 3px; }
.ctl-hint.bad { color: var(--danger); }
input[type=range] {
-webkit-appearance: none; appearance: none;
width: 100%; height: 24px; background: transparent; margin: 0; display: block; cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track { height: 4px; background: var(--line); border-radius: 2px; }
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; width: 16px; height: 16px; margin-top: -6px; border-radius: 50%;
background: var(--accent); border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line);
}
input[type=range]::-moz-range-track { height: 4px; background: var(--line); border-radius: 2px; }
input[type=range]::-moz-range-thumb {
width: 16px; height: 16px; border-radius: 50%;
background: var(--accent); border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line);
}
input[type=range]:focus-visible { outline: none; }
input[type=range]:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--accent); }
input[type=range]:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--accent); }
.ends { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 0.66rem; color: var(--muted); }
.btnrow { display: flex; gap: 6px; flex-wrap: wrap; }
.tip {
margin: 14px 0 0; padding: 9px 10px; border: 1px dashed var(--line); border-radius: 8px;
background: var(--bg); color: var(--muted); font-size: 0.8rem;
}
.tip b { color: var(--ink); font-weight: 600; }
/* ---------- stats ---------- */
.stats {
display: grid; grid-template-columns: repeat(auto-fit, minmax(136px, 1fr)); gap: 1px;
background: var(--line); border: 1px solid var(--line); border-radius: var(--radius);
overflow: hidden; margin: 0;
}
.stat { background: var(--surface); padding: 11px 12px; min-width: 0; }
.stat dt {
font-family: var(--font-mono); font-size: 0.63rem; text-transform: uppercase;
letter-spacing: 0.07em; color: var(--muted); margin-bottom: 3px;
}
.stat dd {
margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums;
font-size: 1.18rem; font-weight: 600; letter-spacing: -0.02em;
}
.stat dd small { font-size: 0.62rem; font-weight: 400; color: var(--muted); display: block; letter-spacing: 0.03em; margin-top: 2px; }
.stat dd .u { font-size: 0.7rem; font-weight: 500; color: var(--muted); margin-left: 2px; }
.stat[data-tone="good"] dd .n { color: var(--run); }
.stat[data-tone="warn"] dd .n { color: var(--accent); }
/* ---------- map ---------- */
.map-head { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.map-head h2 { margin: 0; }
.pass {
font-family: var(--font-mono); font-size: 0.68rem; color: var(--muted);
font-variant-numeric: tabular-nums; white-space: nowrap;
}
.pass b { color: var(--accent); font-weight: 600; }
.map-box { position: relative; }
canvas {
display: block; width: 100%; height: 300px;
border: 1px solid var(--line); border-radius: 8px; touch-action: manipulation; cursor: crosshair;
}
canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.readout {
margin-top: 9px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 7px;
background: var(--bg); font-family: var(--font-mono); font-size: 0.72rem;
font-variant-numeric: tabular-nums; color: var(--muted);
min-height: 34px; display: flex; align-items: center; gap: 4px 12px; flex-wrap: wrap;
}
.readout b { color: var(--ink); font-weight: 600; }
.readout .hl { color: var(--accent); font-weight: 600; }
/* ---------- trips ---------- */
.trips { margin-top: 12px; display: grid; gap: 8px; }
.trip { border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; background: var(--bg); }
.trip-top {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
font-family: var(--font-mono); font-size: 0.72rem; font-variant-numeric: tabular-nums;
}
.trip-top .sw { width: 10px; height: 10px; border-radius: 3px; flex: none; }
.trip-top .nm { font-weight: 600; letter-spacing: 0.04em; }
.trip-top .sp { flex: 1 1 auto; }
.trip-top .dim { color: var(--muted); }
.bar { margin-top: 6px; height: 5px; border-radius: 3px; background: var(--line); overflow: hidden; }
.bar i { display: block; height: 100%; border-radius: 3px; transition: width 0.3s ease; }
.warnrow {
border: 1px solid var(--danger); border-radius: 8px; padding: 8px 10px;
color: var(--danger); font-size: 0.8rem; display: none;
}
.warnrow.show { display: block; }
.warnrow b { font-family: var(--font-mono); font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em; }
.warnrow p { margin: 2px 0 0; color: var(--muted); }
.note { margin: 11px 0 0; font-family: var(--font-mono); font-size: 0.66rem; color: var(--muted); line-height: 1.65; }
/* ---------- footer ---------- */
.foot {
margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--line);
font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted);
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important; animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
@media (max-width: 560px) {
.pass { white-space: normal; }
}
@media (max-width: 420px) {
.wrap { padding: 14px 11px 26px; }
.head h1 { font-size: 1.15rem; }
.stat dd { font-size: 1.05rem; }
}
</style>
</head>
<body>
<div class="wrap">
<header class="head">
<div>
<h1>Delivery Route Planner</h1>
<p class="lede">A small electric delivery van leaves the depot, drops parcels around the neighbourhood, and comes home. This finds the shortest way round — and breaks the run into separate trips when one battery charge won't cover the lot.</p>
</div>
<div class="status" id="status" data-state="load">
<span class="dot" aria-hidden="true"></span>
<span id="statusText">planning…</span>
</div>
</header>
<main class="grid">
<section class="panel controls" aria-labelledby="ctlH">
<h2 id="ctlH">Set-up</h2>
<div class="presets" role="group" aria-label="Example neighbourhoods">
<button type="button" data-preset="spread" aria-pressed="true">suburban spread</button>
<button type="button" data-preset="two" aria-pressed="false">two estates</button>
<button type="button" data-preset="block" aria-pressed="false">dense block</button>
</div>
<div class="ctl">
<div class="ctl-top">
<label for="rng">Battery range</label>
<span class="ctl-val" id="v-rng">14.0 km</span>
</div>
<input type="range" id="rng" min="40" max="400" step="5" value="140"
aria-describedby="h-rng">
<div class="ends" aria-hidden="true"><span>4 km</span><span>40 km</span></div>
<div class="ctl-hint" id="h-rng">one charge, out and back — reaches 7.0 km from the depot</div>
</div>
<div class="ctl">
<div class="ctl-top">
<span>Parcels on the round</span>
<span class="ctl-val" id="v-drops">12</span>
</div>
<div class="btnrow" style="margin-top:4px">
<button type="button" id="btn-opt">re-plan</button>
<button type="button" id="btn-shuffle">shuffle</button>
<button type="button" id="btn-reset">reset</button>
</div>
</div>
<div class="ctl">
<div class="btnrow">
<button type="button" id="btn-ghost" aria-pressed="false">show first guess</button>
<button type="button" id="btn-reach" aria-pressed="true">show range ring</button>
</div>
</div>
<p class="tip">
<b>Click the map</b> to drop a parcel anywhere, or click an existing drop to cancel it.
The route re-plans itself every time.
</p>
</section>
<div class="col">
<dl class="stats">
<div class="stat" id="st-dist">
<dt>Total distance</dt>
<dd><span class="n" id="s-dist">—</span><span class="u">km</span><small>every trip, out and back</small></dd>
</div>
<div class="stat" id="st-trips">
<dt>Trips needed</dt>
<dd><span class="n" id="s-trips">—</span><small id="s-trips-sub">one battery charge each</small></dd>
</div>
<div class="stat" id="st-imp">
<dt>Shorter than first guess</dt>
<dd><span class="n" id="s-imp">—</span><span class="u" id="s-imp-u">%</span><small id="s-imp-sub">vs. nearest drop each time</small></dd>
</div>
<div class="stat" id="st-long">
<dt>Longest trip</dt>
<dd><span class="n" id="s-long">—</span><span class="u">km</span><small id="s-long-sub">of the 14.0 km range</small></dd>
</div>
</dl>
<section class="panel" aria-labelledby="mapH">
<div class="map-head">
<h2 id="mapH">Route map · 1 square = 1 km</h2>
<span class="pass mono" id="pass">nearest drop each time…</span>
</div>
<div class="map-box">
<canvas id="map" tabindex="0"
aria-label="Map of the depot and parcel drops. Move the crosshair with the arrow keys and press Enter to add or remove a drop there.">
<p id="cvAlt">Route map. The depot sits in the middle of the map; parcel drops are scattered around it.</p>
</canvas>
</div>
<div class="readout" id="readout">Click anywhere on the map to add a drop — or click a drop to cancel it.</div>
<div class="trips" id="trips"></div>
<div class="warnrow" id="warn" role="status">
<b id="warnTitle">out of range</b>
<p id="warnBody"></p>
</div>
<p class="note" id="note"></p>
<p class="sr" id="live" role="status" aria-live="polite"></p>
</section>
</div>
</main>
<footer class="foot">
<span>demo · sloprun.dev</span>
<span id="perf">—</span>
</footer>
</div>
<script>
(function () {
"use strict";
var $ = function (id) { return document.getElementById(id); };
var now = function () {
return (window.performance && performance.now) ? performance.now() : Date.now();
};
/* ------------------------------------------------------------------ *
* Map geometry. Everything internal is in "map units".
* 10 map units = 1 km, so the visible grid squares are 1 km across.
* ------------------------------------------------------------------ */
var MW = 100, MH = 68; // map size in units
var DEPOT = { x: 50, y: 35 };
var MAXD = 26; // most drops we will plan for
var HIT = 4.2; // click tolerance, in units
var MONO = 'ui-monospace, "SF Mono", Menlo, Consolas, monospace';
var PRESETS = {
spread: [[76,55],[26,14],[32,42],[49.5,23],[48,49],[86.5,22.5],[69,11],[12,8],[66.5,29.5],[18.5,58.5],[14.5,33],[76.5,38.5]],
two: [[14,16],[26,12],[20,28],[32,26],[12,34],[28,38],[38,18],
[66,40],[78,34],[86,46],[74,52],[64,58],[84,60],[92,36]],
block: [[30,18],[42,16],[54,20],[66,18],[28,30],[40,28],[52,32],[64,30],
[30,44],[42,46],[54,42],[66,48],[34,56],[46,58],[58,56],[72,58]]
};
/* ------------------------------------------------------------------ *
* State
* ------------------------------------------------------------------ */
var drops = []; // [{x,y}] in map units
var RU = 140; // battery range, in map units (= 14.0 km)
var S = null; // current solution bundle
var _best = null, _prev = null; // scratch arrays for the split DP
var phase = "idle"; // idle | build | opt | done
var raf = null, nextT = 0, buildK = 0, flash = null, optT0 = 0, solveMs = 0;
var ghostOn = false, reachOn = true, scan = null, recut = false;
var hover = -1, kb = false, cur = { x: 50, y: 16 };
/* ------------------------------------------------------------------ *
* Distance helpers
* ------------------------------------------------------------------ */
function dist(a, b) { return Math.sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y)); }
function km(u) { return u / 10; }
function f2(u) { return km(u).toFixed(2); }
function f1(u) { return km(u).toFixed(1); }
/* ------------------------------------------------------------------ *
* prep — split drops into reachable / out-of-range, build distance table
* ------------------------------------------------------------------ */
function prep() {
var pts = [], far = [], i, j;
for (i = 0; i < drops.length; i++) {
if (2 * dist(drops[i], DEPOT) <= RU + 1e-9) pts.push(drops[i]);
else far.push(drops[i]);
}
var n = pts.length;
var D = new Float64Array(n * n), Dd = new Float64Array(n);
for (i = 0; i < n; i++) {
Dd[i] = dist(pts[i], DEPOT);
for (j = i + 1; j < n; j++) {
var d = dist(pts[i], pts[j]);
D[i * n + j] = d; D[j * n + i] = d;
}
}
S = {
pts: pts, far: far, D: D, Ddep: Dd, n: n,
order: [], trips: [], of: [], cost: 0,
naiveCost: 0, naiveTrips: [], moves: 0
};
_best = new Float64Array(n + 1);
_prev = new Int32Array(n + 1);
}
/* nearest-drop-each-time construction — the "first guess" */
function nearestFirst() {
var n = S.n, used = new Uint8Array(n), ord = [], cur2 = -1, k, i;
for (k = 0; k < n; k++) {
var b = -1, bd = Infinity;
for (i = 0; i < n; i++) {
if (used[i]) continue;
var d = cur2 < 0 ? S.Ddep[i] : S.D[cur2 * n + i];
if (d < bd) { bd = d; b = i; }
}
used[b] = 1; ord.push(b); cur2 = b;
}
return ord;
}
/* ------------------------------------------------------------------ *
* split — cut a visiting order into battery-sized round trips.
* Exact dynamic program: best[j] = cheapest way to serve the first j
* drops of the order, where every trip starts and ends at the depot
* and never exceeds the battery range.
* ------------------------------------------------------------------ */
function splitCost(ord) {
var n = ord.length, D = S.D, Dd = S.Ddep, b = _best, j, i;
if (!n) return 0;
b[0] = 0;
for (j = 1; j <= n; j++) {
var bestJ = Infinity, inner = 0, last = Dd[ord[j - 1]];
for (i = j - 1; i >= 0; i--) {
if (i < j - 1) inner += D[ord[i] * n + ord[i + 1]];
var len = Dd[ord[i]] + inner + last;
if (len <= RU + 1e-9) {
var c = b[i] + len;
if (c < bestJ) bestJ = c;
}
}
b[j] = bestJ;
}
return b[n];
}
function splitTrips(ord) {
var n = ord.length, D = S.D, Dd = S.Ddep, b = _best, p = _prev, j, i;
if (!n) return { cost: 0, trips: [] };
b[0] = 0; p[0] = -1;
for (j = 1; j <= n; j++) {
var bestJ = Infinity, from = -1, inner = 0, last = Dd[ord[j - 1]];
for (i = j - 1; i >= 0; i--) {
if (i < j - 1) inner += D[ord[i] * n + ord[i + 1]];
var len = Dd[ord[i]] + inner + last;
if (len <= RU + 1e-9) {
var c = b[i] + len;
if (c < bestJ) { bestJ = c; from = i; }
}
}
b[j] = bestJ; p[j] = from;
}
var trips = [], j2 = n, guard = 0;
while (j2 > 0 && guard++ < n + 2) {
var i2 = p[j2];
if (i2 < 0) { i2 = j2 - 1; } /* defensive: never loop forever */
trips.push(ord.slice(i2, j2));
j2 = i2;
}
trips.reverse();
return { cost: b[n], trips: trips };
}
/* The "first guess" a person would make: hop to the nearest parcel, and
turn for home the moment the next one would not fit on the charge. */
function greedySplit(ord) {
var trips = [], cur = [], cost = 0, k;
for (k = 0; k < ord.length; k++) {
var trial = cur.concat([ord[k]]);
if (tripLen(trial) <= RU + 1e-9) { cur = trial; }
else {
if (cur.length) { trips.push(cur); cost += tripLen(cur); }
cur = [ord[k]];
}
}
if (cur.length) { trips.push(cur); cost += tripLen(cur); }
return { cost: cost, trips: trips };
}
function tripLen(t) {
if (!t.length) return 0;
var n = S.n, L = S.Ddep[t[0]] + S.Ddep[t[t.length - 1]];
for (var i = 0; i + 1 < t.length; i++) L += S.D[t[i] * n + t[i + 1]];
return L;
}
/* ------------------------------------------------------------------ *
* 2-opt: reverse the stretch between two legs, keep it if shorter.
* The score is the *split* distance, so a swap only counts as an
* improvement when the whole multi-trip plan gets shorter.
* ------------------------------------------------------------------ */
function rev(a, i, j) {
while (i < j) { var t = a[i]; a[i] = a[j]; a[j] = t; i++; j--; }
}
function bestMove() {
var ord = S.order, n = ord.length, bi = -1, bj = -1, bc = S.cost - 1e-7, i, j;
for (i = 0; i < n - 1; i++) {
for (j = i + 1; j < n; j++) {
rev(ord, i, j);
var c = splitCost(ord);
if (c < bc) { bc = c; bi = i; bj = j; }
rev(ord, i, j);
}
}
return bi < 0 ? null : { i: bi, j: bj, cost: bc };
}
function indexTrips() {
S.of = new Array(S.n);
for (var k = 0; k < S.trips.length; k++) {
for (var m = 0; m < S.trips[k].length; m++) S.of[S.trips[k][m]] = { t: k, seq: m + 1 };
}
}
/* ------------------------------------------------------------------ *
* Colours — re-read from the tokens whenever the theme moves
* ------------------------------------------------------------------ */
var LIGHTP = ["#C05600", "#2C6BC7", "#7A4BC4", "#B5379E", "#0B6C75", "#7E6100"];
var DARKP = ["#FFB454", "#6EA8FF", "#B48CFF", "#FF8BD6", "#4FD3DE", "#D9BB5A"];
var C = {};
function cssv(v) {
return getComputedStyle(document.documentElement).getPropertyValue(v).trim();
}
function rgb(h) {
h = (h || "").replace("#", "");
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
var n = parseInt(h, 16);
if (isNaN(n)) return [128, 128, 128];
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function rgba(h, a) { var c = rgb(h); return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
function colors() {
C.bg = cssv("--bg") || "#F6F6F3";
C.ink = cssv("--ink") || "#1A1D21";
C.muted = cssv("--muted") || "#5A6068";
C.line = cssv("--line") || "#E3E2DC";
C.accent = cssv("--accent") || "#C05600";
C.danger = cssv("--danger") || "#C93C37";
var c = rgb(C.bg);
C.dark = (0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]) / 255 < 0.45;
C.trip = C.dark ? DARKP : LIGHTP;
}
function tripColor(k) { return C.trip[k % C.trip.length]; }
/* ------------------------------------------------------------------ *
* Canvas
* ------------------------------------------------------------------ */
var cv = $("map"), ctx = cv.getContext("2d");
var dpr = 1, VW = 0, VH = 0, sc = 1, ox = 0, oy = 0;
function layout() {
var w = Math.max(240, cv.parentNode.clientWidth || 320);
var h = Math.round(Math.min(430, Math.max(210, w * 0.64)));
var d = Math.min(2, window.devicePixelRatio || 1);
/* Writing cv.style.height resizes our own observed box, so a no-op resize
must bail out here or the ResizeObserver re-enters on every frame. */
if (w === VW && h === VH && d === dpr) return false;
dpr = d;
cv.style.height = h + "px";
cv.width = Math.round(w * dpr);
cv.height = Math.round(h * dpr);
VW = w; VH = h;
var pad = 15;
sc = Math.min((w - 2 * pad) / MW, (h - 2 * pad) / MH);
ox = (w - MW * sc) / 2; oy = (h - MH * sc) / 2;
return true;
}
function tx(x) { return ox + x * sc; }
function ty(y) { return oy + y * sc; }
function inv(px, py) { return { x: (px - ox) / sc, y: (py - oy) / sc }; }
function drawGrid() {
var x, y, L = tx(0), R = tx(MW), T = ty(0), B = ty(MH);
ctx.lineWidth = 1;
for (x = 0; x <= MW; x += 5) {
ctx.strokeStyle = (x % 10 === 0) ? rgba(C.line, 0.95) : rgba(C.line, 0.45);
var px = Math.round(tx(x)) + 0.5;
ctx.beginPath(); ctx.moveTo(px, T); ctx.lineTo(px, B); ctx.stroke();
}
for (y = 0; y <= MH; y += 5) {
ctx.strokeStyle = (y % 10 === 0) ? rgba(C.line, 0.95) : rgba(C.line, 0.45);
var py = Math.round(ty(y)) + 0.5;
ctx.beginPath(); ctx.moveTo(L, py); ctx.lineTo(R, py); ctx.stroke();
}
}
/* How far the far corner of the map is from the depot. Past this the reach
ring is off the map entirely, so a circle would draw nothing at all. */
var CORNER = (function () {
var c = [[0, 0], [MW, 0], [0, MH], [MW, MH]], m = 0;
for (var i = 0; i < c.length; i++) m = Math.max(m, dist({ x: c[i][0], y: c[i][1] }, DEPOT));
return m;
})();
function drawRing() {
if (!reachOn) return;
var reach = RU / 2;
ctx.save();
if (reach >= CORNER) {
/* The ring is wider than the whole map — say so instead of drawing
an invisible circle, so the toggle never looks dead. */
var lx = tx(0) + 8, ly = ty(0) + 7;
ctx.setLineDash([3, 3]);
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(C.muted, 0.6);
ctx.beginPath(); ctx.arc(lx + 5, ly + 6, 5, 0, 6.2832); ctx.stroke();
ctx.setLineDash([]);
ctx.font = "600 10px " + MONO;
ctx.textAlign = "left"; ctx.textBaseline = "middle";
ctx.fillStyle = rgba(C.muted, 0.95);
ctx.fillText("reach " + f1(reach) + " km — the whole map is in range", lx + 15, ly + 6);
} else {
ctx.setLineDash([4, 5]);
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(C.muted, 0.5);
ctx.beginPath(); ctx.arc(tx(DEPOT.x), ty(DEPOT.y), reach * sc, 0, 6.2832); ctx.stroke();
}
ctx.restore();
}
function arrow(ax, ay, bx, by, col) {
var dx = bx - ax, dy = by - ay, L = Math.sqrt(dx * dx + dy * dy);
if (L < 30) return;
var mx = ax + dx * 0.56, my = ay + dy * 0.56, an = Math.atan2(dy, dx), s = 4.6;
ctx.beginPath();
ctx.moveTo(mx + Math.cos(an) * s, my + Math.sin(an) * s);
ctx.lineTo(mx + Math.cos(an + 2.5) * s, my + Math.sin(an + 2.5) * s);
ctx.lineTo(mx + Math.cos(an - 2.5) * s, my + Math.sin(an - 2.5) * s);
ctx.closePath();
ctx.fillStyle = col; ctx.fill();
}
function path(idxs, w, col, dash, arrows) {
if (!idxs.length) return;
var i, ax = tx(DEPOT.x), ay = ty(DEPOT.y);
ctx.save();
if (dash) ctx.setLineDash(dash);
ctx.lineWidth = w; ctx.lineJoin = "round"; ctx.lineCap = "round";
ctx.strokeStyle = col;
ctx.beginPath(); ctx.moveTo(ax, ay);
for (i = 0; i < idxs.length; i++) ctx.lineTo(tx(S.pts[idxs[i]].x), ty(S.pts[idxs[i]].y));
ctx.lineTo(ax, ay);
ctx.stroke();
ctx.restore();
if (!arrows) return;
var px = ax, py = ay;
for (i = 0; i < idxs.length; i++) {
var qx = tx(S.pts[idxs[i]].x), qy = ty(S.pts[idxs[i]].y);
arrow(px, py, qx, qy, col); px = qx; py = qy;
}
arrow(px, py, ax, ay, col);
}
function drawRoutes() {
for (var k = 0; k < S.trips.length; k++) {
path(S.trips[k], 2.3, tripColor(k), null, true);
}
}
function drawGhost() {
if (!ghostOn || !S.naiveTrips.length) return;
for (var k = 0; k < S.naiveTrips.length; k++) {
path(S.naiveTrips[k], 1.4, rgba(C.muted, 0.55), [3, 4], false);
}
}
function drawBuild() {
var ord = S.order.slice(0, buildK);
if (!ord.length) return;
ctx.save();
ctx.setLineDash([5, 4]);
ctx.lineWidth = 1.8; ctx.lineJoin = "round"; ctx.lineCap = "round";
ctx.strokeStyle = rgba(C.accent, 0.85);
ctx.beginPath(); ctx.moveTo(tx(DEPOT.x), ty(DEPOT.y));
for (var i = 0; i < ord.length; i++) ctx.lineTo(tx(S.pts[ord[i]].x), ty(S.pts[ord[i]].y));
ctx.stroke();
ctx.restore();
}
function drawProbe() {
if (phase !== "opt" || !scan || scan.i >= S.n - 1) return;
var i = scan.i, j = Math.min(scan.j, S.order.length - 1), k, p;
ctx.save();
ctx.setLineDash([2, 3]);
ctx.lineWidth = 1.3;
ctx.strokeStyle = rgba(C.ink, C.dark ? 0.55 : 0.42);
ctx.beginPath();
for (k = i; k <= j; k++) {
p = S.pts[S.order[k]];
if (k === i) ctx.moveTo(tx(p.x), ty(p.y)); else ctx.lineTo(tx(p.x), ty(p.y));
}
ctx.stroke();
ctx.setLineDash([]);
ctx.lineWidth = 1.4;
ctx.strokeStyle = rgba(C.ink, 0.65);
for (k = 0; k < 2; k++) {
p = S.pts[S.order[k === 0 ? i : j]];
ctx.beginPath(); ctx.arc(tx(p.x), ty(p.y), dropR() + 3.5, 0, 6.2832); ctx.stroke();
}
ctx.restore();
}
function drawFlash(ts) {
if (!flash) return;
var age = ts - flash.t;
if (age > 340) { flash = null; return; }
var a = 0.34 * (1 - age / 340), i;
ctx.save();
ctx.lineWidth = 9; ctx.lineJoin = "round"; ctx.lineCap = "round";
ctx.strokeStyle = rgba(C.accent, a);
ctx.beginPath();
for (i = flash.i; i <= flash.j && i < S.order.length; i++) {
var p = S.pts[S.order[i]];
if (i === flash.i) ctx.moveTo(tx(p.x), ty(p.y)); else ctx.lineTo(tx(p.x), ty(p.y));
}
ctx.stroke();
ctx.restore();
}
function dropR() { return Math.max(5, Math.min(8.5, sc * 1.3)); }
function drawPoints() {
var r = dropR(), i;
/* out of range */
for (i = 0; i < S.far.length; i++) {
var fx = tx(S.far[i].x), fy = ty(S.far[i].y);
ctx.save();
ctx.setLineDash([2.5, 2.5]);
ctx.lineWidth = 1.6; ctx.strokeStyle = C.danger;
ctx.beginPath(); ctx.arc(fx, fy, r, 0, 6.2832); ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(fx - r * 0.4, fy - r * 0.4); ctx.lineTo(fx + r * 0.4, fy + r * 0.4);
ctx.moveTo(fx + r * 0.4, fy - r * 0.4); ctx.lineTo(fx - r * 0.4, fy + r * 0.4);
ctx.lineWidth = 1.5; ctx.strokeStyle = C.danger; ctx.stroke();
}
/* in range */
for (i = 0; i < S.n; i++) {
var p = S.pts[i], px = tx(p.x), py = ty(p.y);
var meta = (phase === "build") ? null : (S.of ? S.of[i] : null);
var col, seq = null;
if (phase === "build") {
var pos = S.order.indexOf(i);
col = (pos >= 0 && pos < buildK) ? C.accent : rgba(C.muted, 0.55);
} else if (meta) {
col = tripColor(meta.t); seq = meta.seq;
} else {
col = rgba(C.muted, 0.55);
}
ctx.beginPath(); ctx.arc(px, py, r, 0, 6.2832);
ctx.fillStyle = col; ctx.fill();
ctx.lineWidth = 2; ctx.strokeStyle = C.bg; ctx.stroke();
if (seq !== null && r >= 6.3) {
ctx.fillStyle = C.bg;
ctx.font = "600 " + Math.round(r * 1.05) + "px " + MONO;
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText(String(seq), px, py + 0.5);
}
if (hover === i) {
ctx.beginPath(); ctx.arc(px, py, r + 4.5, 0, 6.2832);
ctx.lineWidth = 1.6; ctx.strokeStyle = C.accent; ctx.stroke();
}
}
}
function drawDepot() {
var d = Math.max(7, Math.min(11, sc * 1.7));
var px = tx(DEPOT.x), py = ty(DEPOT.y);
ctx.beginPath();
if (ctx.roundRect) { ctx.roundRect(px - d, py - d, d * 2, d * 2, 3); }
else { ctx.rect(px - d, py - d, d * 2, d * 2); }
ctx.fillStyle = C.accent; ctx.fill();
ctx.lineWidth = 2; ctx.strokeStyle = C.bg; ctx.stroke();
ctx.beginPath(); ctx.arc(px, py, d * 0.3, 0, 6.2832);
ctx.fillStyle = C.bg; ctx.fill();
ctx.font = "600 9px " + MONO;
ctx.textAlign = "center"; ctx.textBaseline = "top";
/* halo so the label stays readable where route legs run under it */
ctx.lineWidth = 3; ctx.lineJoin = "round"; ctx.strokeStyle = C.bg;
ctx.strokeText("DEPOT", px, py + d + 4);
ctx.fillStyle = C.muted;
ctx.fillText("DEPOT", px, py + d + 4);
}
function drawCursor() {
if (!kb) return;
var px = tx(cur.x), py = ty(cur.y);
ctx.save();
ctx.setLineDash([3, 4]); ctx.lineWidth = 1; ctx.strokeStyle = rgba(C.accent, 0.75);
ctx.beginPath();
ctx.moveTo(tx(0), py); ctx.lineTo(tx(MW), py);
ctx.moveTo(px, ty(0)); ctx.lineTo(px, ty(MH));
ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath(); ctx.arc(px, py, 8, 0, 6.2832);
ctx.lineWidth = 1.8; ctx.strokeStyle = C.accent; ctx.stroke();
ctx.restore();
}
function draw(ts) {
ts = ts || now();
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, VW, VH);
ctx.fillStyle = C.bg;
ctx.fillRect(0, 0, VW, VH);
drawGrid();
drawRing();
if (!S) return;
if (phase === "build") { drawBuild(); }
else { drawGhost(); drawRoutes(); drawProbe(); drawFlash(ts); }
drawPoints();
drawDepot();
drawCursor();
}
/* ------------------------------------------------------------------ *
* Readouts
* ------------------------------------------------------------------ */
function setStatus(state, text) {
$("status").setAttribute("data-state", state);
$("statusText").textContent = text;
}
function paintStats() {
var n = S.n, trips = S.trips.length;
$("s-dist").textContent = n ? f2(S.cost) : "—";
$("s-trips").textContent = n ? String(trips) : "—";
$("s-trips-sub").textContent = !n ? "nothing to deliver"
: trips === 1 ? "it all fits one charge"
: "split by battery range";
var imp = (S.naiveCost > 0) ? (S.naiveCost - S.cost) / S.naiveCost * 100 : 0;
var impEl = $("s-imp"), impU = $("s-imp-u"), impSub = $("s-imp-sub"), impBox = $("st-imp");
if (!n || S.naiveCost <= 0) {
impEl.textContent = "—"; impU.textContent = ""; impSub.textContent = "no route yet";
impBox.removeAttribute("data-tone");
} else if (phase === "build" || (phase === "opt" && imp < 0.05)) {
/* still mid-solve — do not claim the first guess was already best */
impEl.textContent = "—"; impU.textContent = ""; impSub.textContent = "still untangling…";
impBox.removeAttribute("data-tone");
} else if (imp >= 0.05) {
impEl.textContent = imp.toFixed(1); impU.textContent = "%";
impSub.textContent = f2(S.naiveCost - S.cost) + " km saved";
impBox.setAttribute("data-tone", "good");
} else {
impEl.textContent = "0.0"; impU.textContent = "%";
impSub.textContent = "first guess was already best";
impBox.removeAttribute("data-tone");
}
var longest = 0;
for (var k = 0; k < S.trips.length; k++) longest = Math.max(longest, tripLen(S.trips[k]));
$("s-long").textContent = n ? f2(longest) : "—";
$("s-long-sub").textContent = "of the " + f1(RU) + " km range";
$("st-long").setAttribute("data-tone", (n && longest > RU * 0.9) ? "warn" : "");
$("v-drops").textContent = String(drops.length);
}
function paintTrips() {
paintWarn();
var el = $("trips"), html = "", k;
if (!S.n) {
el.innerHTML = '<div class="trip"><div class="trip-top"><span class="dim">' +
(drops.length ? "every drop is out of range — widen the battery range"
: "no drops yet — click the map to add one") +
"</span></div></div>";
return;
}
for (k = 0; k < S.trips.length; k++) {
var t = S.trips[k], L = tripLen(t), pct = Math.max(2, Math.min(100, L / RU * 100));
var col = tripColor(k);
html += '<div class="trip">' +
'<div class="trip-top">' +
'<span class="sw" style="background:' + col + '"></span>' +
'<span class="nm" style="color:' + col + '">TRIP ' + (k + 1) + "</span>" +
'<span class="dim">' + t.length + (t.length === 1 ? " parcel" : " parcels") + "</span>" +
'<span class="sp"></span>' +
"<span>" + f2(L) + " km</span>" +
'<span class="dim">' + Math.round(L / RU * 100) + "% of charge</span>" +
"</div>" +
'<div class="bar"><i style="width:' + pct.toFixed(1) + "%;background:" + col + '"></i></div>' +
"</div>";
}
el.innerHTML = html;
}
/* The out-of-range notice has to be kept in step even when nothing at all is
reachable — that is exactly the case it exists for. */
function paintWarn() {
var w = $("warn");
if (S.far.length) {
var need = 0;
for (var i = 0; i < S.far.length; i++) need = Math.max(need, 2 * dist(S.far[i], DEPOT));
$("warnTitle").textContent = S.far.length + (S.far.length === 1 ? " drop out of range" : " drops out of range");
$("warnBody").textContent =
"The van cannot get there and back on one charge. A range of " + f1(Math.ceil(need)) +
" km or more would cover " + (S.far.length === 1 ? "it" : "them all") + ".";
w.classList.add("show");
} else {
w.classList.remove("show");
}
}
function describe() {
if (!S.n) return "Route map. No drops are currently in range of the depot.";
var s = "Route map. " + S.trips.length + (S.trips.length === 1 ? " trip" : " trips") +
" from the depot covering " + S.n + (S.n === 1 ? " drop, " : " drops, ") + f2(S.cost) + " km in total. ";
for (var k = 0; k < S.trips.length; k++) {
var c = S.trips[k].length;
s += "Trip " + (k + 1) + ": " + c + (c === 1 ? " drop, " : " drops, ") +
f2(tripLen(S.trips[k])) + " km. ";
}
if (S.far.length) s += S.far.length + (S.far.length === 1 ? " drop is" : " drops are") + " out of range.";
return s;
}
var CV_HINT = " Move the crosshair with the arrow keys and press Enter to add or remove a drop there.";
function setAlt() {
var d = describe();
$("cvAlt").textContent = d;
cv.setAttribute("aria-label", d + CV_HINT);
}
function paintAll() {
indexTrips();
paintStats();
paintTrips();
setAlt();
}
function readout(msg) { $("readout").innerHTML = msg; }
var DEFAULT_READ = "Click anywhere on the map to add a drop — or click a drop to cancel it.";
/* ------------------------------------------------------------------ *
* Solve + animate
* ------------------------------------------------------------------ */
function reduced() {
return !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}
function stop() {
if (raf) { cancelAnimationFrame(raf); raf = null; }
flash = null; scan = null; recut = false;
}
function fresh() {
var t0 = now();
prep();
S.order = nearestFirst();
var r = greedySplit(S.order);
S.naiveCost = r.cost; S.naiveTrips = r.trips;
S.cost = r.cost; S.trips = r.trips; S.moves = 0;
solveMs = now() - t0;
}
function optimizeSync(budgetMs) {
var t0 = now(), guard = 0;
if (S.n) S.cost = splitCost(S.order); /* optimal trip cuts for this order */
while (guard++ < 500) {
if (now() - t0 > (budgetMs || 18)) break;
var m = bestMove();
if (!m) break;
rev(S.order, m.i, m.j);
S.cost = m.cost; S.moves++;
}
var r = splitTrips(S.order);
S.cost = r.cost; S.trips = r.trips;
solveMs += now() - t0;
}
function finishRun() {
phase = "done";
paintAll();
if (S.far.length) {
setStatus("warn", S.far.length + " out of range");
} else if (S.n) {
setStatus("ok", "route ready");
} else {
setStatus("warn", "no drops");
}
var imp = (S.naiveCost > 0) ? (S.naiveCost - S.cost) / S.naiveCost * 100 : 0;
$("pass").innerHTML = !S.n ? "waiting for drops"
: (S.moves ? "2-opt settled after <b>" + S.moves + "</b> swap" + (S.moves === 1 ? "" : "s")
: "no crossings left to undo");
$("perf").textContent = drops.length + " drops · " + S.trips.length + " trips · " +
S.moves + " swaps · " + solveMs.toFixed(1) + " ms";
$("live").textContent = !S.n ? "No drops in range."
: "Route planned: " + S.trips.length + (S.trips.length === 1 ? " trip, " : " trips, ") + f2(S.cost) + " km total, " +
imp.toFixed(1) + "% shorter than the first guess.";
draw();
}
function tick(ts) {
raf = requestAnimationFrame(tick);
if (phase === "build") {
if (ts >= nextT) {
buildK++;
nextT = ts + Math.max(26, 480 / Math.max(1, S.n));
if (buildK >= S.n) startOpt(ts);
}
draw(ts);
return;
}
if (phase === "opt") {
if (ts < nextT) { draw(ts); return; }
if (recut) { doRecut(ts); draw(ts); return; }
if (!scan) newPass();
var t0 = now();
var complete = scanStep();
solveMs += now() - t0;
if (!complete) {
$("pass").innerHTML = "2-opt · testing pair <b>" + scan.tested + "</b>/" + scan.total +
(scan.bi >= 0 ? " · best so far −" + f2(S.cost - scan.bc) + " km" : "");
} else if (scan.bi >= 0) {
rev(S.order, scan.bi, scan.bj);
S.cost = scan.bc; S.moves++;
S.trips = splitTrips(S.order).trips;
flash = { i: scan.bi, j: scan.bj, t: ts };
indexTrips(); paintStats(); paintTrips();
$("pass").innerHTML = "flipped a stretch · swap <b>" + S.moves + "</b> · −" +
f2(S.naiveCost - S.cost) + " km so far";
scan = null;
nextT = ts + 300;
} else {
finishRun();
}
draw(ts);
return;
}
draw(ts);
if (!flash) { cancelAnimationFrame(raf); raf = null; }
}
function newPass() {
var n = S.n, total = n * (n - 1) / 2;
scan = {
i: 0, j: 1, bi: -1, bj: -1, bc: S.cost - 1e-7,
tested: 0, total: total, per: Math.max(1, Math.ceil(total / 18))
};
}
/* Try every pair of positions, reverse the stretch between them, keep the
single best flip of the pass. A few candidates per frame, so the search
itself is visible. */
function scanStep() {
var ord = S.order, n = ord.length, k = 0;
while (k++ < scan.per && scan.i < n - 1) {
rev(ord, scan.i, scan.j);
var c = splitCost(ord);
if (c < scan.bc) { scan.bc = c; scan.bi = scan.i; scan.bj = scan.j; }
rev(ord, scan.i, scan.j);
scan.tested++;
scan.j++;
if (scan.j >= n) { scan.i++; scan.j = scan.i + 1; }
}
return scan.i >= n - 1;
}
/* The first thing the planner does: keep the order, but cut the trips in the
best possible places instead of just flying home when the charge runs low. */
function doRecut(ts) {
recut = false;
var t0 = now();
var dp = S.n ? splitCost(S.order) : 0;
solveMs += now() - t0;
if (dp < S.cost - 1e-7) {
S.cost = dp;
S.trips = splitTrips(S.order).trips;
indexTrips(); paintStats(); paintTrips();
$("pass").innerHTML = "better places to turn for home · −" +
f2(S.naiveCost - S.cost) + " km";
nextT = ts + 420;
} else {
S.cost = dp;
$("pass").innerHTML = "2-opt · looking for crossings…";
nextT = ts + 120;
}
}
function startOpt(ts) {
phase = "opt";
recut = true; scan = null;
nextT = ts + 260;
indexTrips(); paintStats(); paintTrips();
setStatus("load", "untangling…");
$("pass").innerHTML = "first guess done · " + f2(S.naiveCost) + " km over " +
S.trips.length + (S.trips.length === 1 ? " trip" : " trips");
}
/* mode: "full" = animate the first guess too, "opt" = jump straight to
untangling, "sync" = solve instantly (slider drags, reduced motion) */
function run(mode) {
stop();
fresh();
if (hover >= S.n) hover = -1;
if (mode === "sync" || reduced() || S.n < 4) {
optimizeSync(mode === "sync" ? 14 : 40);
finishRun();
return;
}
hover = -1;
if (mode === "full") {
phase = "build"; buildK = 0; nextT = 0;
setStatus("load", "nearest drop each time…");
$("pass").innerHTML = "first guess · always hop to the nearest drop";
paintStats(); paintTrips(); setAlt();
} else {
phase = "opt"; recut = true; scan = null; nextT = now() + 90;
indexTrips(); paintStats(); paintTrips();
setStatus("load", "re-planning…");
$("pass").innerHTML = "first guess · " + f2(S.naiveCost) + " km";
}
raf = requestAnimationFrame(tick);
}
/* ------------------------------------------------------------------ *
* Editing the map
* ------------------------------------------------------------------ */
function nearestDrop(x, y) {
var b = -1, bd = HIT;
for (var i = 0; i < drops.length; i++) {
var d = Math.sqrt((drops[i].x - x) * (drops[i].x - x) + (drops[i].y - y) * (drops[i].y - y));
if (d < bd) { bd = d; b = i; }
}
return b;
}
function toggleAt(x, y) {
x = Math.max(2, Math.min(MW - 2, x));
y = Math.max(2, Math.min(MH - 2, y));
var hit = nearestDrop(x, y);
if (hit >= 0) {
drops.splice(hit, 1);
readout('<span class="hl">drop cancelled</span> · ' + drops.length + " left");
run("opt");
return;
}
if (dist({ x: x, y: y }, DEPOT) < 4.5) {
readout("that spot is the depot — drop somewhere else");
return;
}
if (drops.length >= MAXD) {
readout('<span class="hl">' + MAXD + " drops is the limit</span> · cancel one first");
return;
}
drops.push({ x: Math.round(x * 2) / 2, y: Math.round(y * 2) / 2 });
readout('<span class="hl">drop added</span> · ' + drops.length + " on the round");
run("opt");
}
cv.addEventListener("click", function (e) {
var r = cv.getBoundingClientRect();
var p = inv(e.clientX - r.left, e.clientY - r.top);
kb = false;
toggleAt(p.x, p.y);
});
cv.addEventListener("mousemove", function (e) {
var r = cv.getBoundingClientRect();
var p = inv(e.clientX - r.left, e.clientY - r.top);
var h = -1, i;
for (i = 0; i < S.pts.length; i++) {
if (dist(S.pts[i], p) < HIT) { h = i; break; }
}
if (h !== hover) {
hover = h;
if (h >= 0) {
var meta = S.of && S.of[h];
readout("<b>drop " + (meta ? "#" + meta.seq + " on trip " + (meta.t + 1) : "—") + "</b> · " +
f2(S.Ddep[h]) + " km from the depot · <span class=\"hl\">click to cancel it</span>");
} else {
readout(DEFAULT_READ);
}
draw();
}
cv.style.cursor = h >= 0 ? "pointer" : "crosshair";
});
cv.addEventListener("mouseleave", function () {
if (hover !== -1) { hover = -1; readout(DEFAULT_READ); draw(); }
});
cv.addEventListener("keydown", function (e) {
var k = e.key, step = e.shiftKey ? 1 : 4, moved = true;
if (k === "ArrowLeft") cur.x -= step;
else if (k === "ArrowRight") cur.x += step;
else if (k === "ArrowUp") cur.y -= step;
else if (k === "ArrowDown") cur.y += step;
else if (k === "Enter" || k === " " || k === "Spacebar") { moved = false; }
else return;
e.preventDefault();
kb = true;
cur.x = Math.max(2, Math.min(MW - 2, cur.x));
cur.y = Math.max(2, Math.min(MH - 2, cur.y));
if (moved) {
var h = nearestDrop(cur.x, cur.y);
var away = "<b>" + f2(dist(cur, DEPOT)) + " km</b> from the depot";
readout(h >= 0
? "crosshair on a drop · " + away + ' · <span class="hl">Enter cancels it</span>'
: "crosshair · " + away + ' · <span class="hl">Enter drops a parcel here</span>');
draw();
} else {
toggleAt(cur.x, cur.y);
kb = true;
draw();
}
});
cv.addEventListener("focus", function () {
kb = true; /* show the crosshair straight away, not only after a key press */
readout("arrow keys move the crosshair · Enter adds or cancels a drop");
draw();
});
cv.addEventListener("blur", function () { kb = false; readout(DEFAULT_READ); draw(); });
/* ------------------------------------------------------------------ *
* Controls
* ------------------------------------------------------------------ */
function labelRange() {
$("v-rng").textContent = f1(RU) + " km";
$("h-rng").textContent = "one charge, out and back — reaches " + f1(RU / 2) + " km from the depot";
$("rng").setAttribute("aria-valuetext", f1(RU) + " km per charge");
}
function setPreset(name) {
var list = PRESETS[name] || PRESETS.spread;
drops = list.map(function (p) { return { x: p[0], y: p[1] }; });
var btns = document.querySelectorAll("[data-preset]");
for (var i = 0; i < btns.length; i++) {
btns[i].setAttribute("aria-pressed", btns[i].getAttribute("data-preset") === name ? "true" : "false");
}
}
function clearPreset() {
var btns = document.querySelectorAll("[data-preset]");
for (var i = 0; i < btns.length; i++) btns[i].setAttribute("aria-pressed", "false");
}
(function () {
var btns = document.querySelectorAll("[data-preset]");
for (var i = 0; i < btns.length; i++) {
btns[i].addEventListener("click", function () {
setPreset(this.getAttribute("data-preset"));
readout(DEFAULT_READ);
run("full");
});
}
})();
/* A drag fires many input events, so each one only gets a small slice of
2-opt. When the drag stops, finish the job so the route you are left
looking at is the same one "re-plan" would give you. */
var settleT = null;
$("rng").addEventListener("input", function () {
RU = +this.value;
labelRange();
run("sync");
if (settleT) clearTimeout(settleT);
settleT = setTimeout(function () {
settleT = null;
if (phase !== "done" || !S || !S.n) return;
optimizeSync(60);
finishRun();
}, 180);
});
$("btn-opt").addEventListener("click", function () { readout(DEFAULT_READ); run("full"); });
var seed = 20260813;
function rnd() { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; }
$("btn-shuffle").addEventListener("click", function () {
seed = (seed ^ (Date.now() & 0xffffff)) >>> 0;
var want = 9 + Math.floor(rnd() * 7), out = [], tries = 0;
while (out.length < want && tries++ < 900) {
var x = 7 + rnd() * (MW - 14), y = 7 + rnd() * (MH - 14), ok = true;
if (dist({ x: x, y: y }, DEPOT) < 8) continue;
for (var i = 0; i < out.length; i++) {
if (dist({ x: x, y: y }, out[i]) < 8.5) { ok = false; break; }
}
if (ok) out.push({ x: Math.round(x * 2) / 2, y: Math.round(y * 2) / 2 });
}
drops = out;
clearPreset();
readout(DEFAULT_READ);
run("full");
});
$("btn-reset").addEventListener("click", function () {
RU = 140; $("rng").value = "140"; labelRange();
ghostOn = false; $("btn-ghost").setAttribute("aria-pressed", "false");
reachOn = true; $("btn-reach").setAttribute("aria-pressed", "true");
setPreset("spread");
readout(DEFAULT_READ);
run("full");
});
$("btn-ghost").addEventListener("click", function () {
ghostOn = !ghostOn;
this.setAttribute("aria-pressed", ghostOn ? "true" : "false");
readout(ghostOn
? (S && S.n && S.naiveCost - S.cost < 1e-6
? 'the first guess was already the best route — the <span class="hl">dashed grey</span> line sits exactly under it'
: 'the <span class="hl">dashed grey</span> line is the first-guess route, before untangling')
: DEFAULT_READ);
draw();
});
$("btn-reach").addEventListener("click", function () {
reachOn = !reachOn;
this.setAttribute("aria-pressed", reachOn ? "true" : "false");
readout(reachOn
? (RU / 2 >= CORNER
? "reach is " + f1(RU / 2) + " km — wider than this map, so nothing here is out of reach"
: "the dashed ring is how far the van can get and still come home")
: DEFAULT_READ);
draw();
});
/* ------------------------------------------------------------------ *
* Theme + resize
* ------------------------------------------------------------------ */
function themeChanged() {
colors();
if (S) paintTrips();
draw();
}
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
document.documentElement.setAttribute("data-theme", d.theme);
themeChanged();
}
});
var mq = window.matchMedia("(prefers-color-scheme: dark)");
if (mq.addEventListener) mq.addEventListener("change", themeChanged);
else if (mq.addListener) mq.addListener(themeChanged);
function onResize() { if (layout()) draw(); }
if (window.ResizeObserver) {
var ro = new ResizeObserver(function () { onResize(); });
ro.observe(cv.parentNode);
} else {
window.addEventListener("resize", onResize);
}
/* ------------------------------------------------------------------ *
* Go
* ------------------------------------------------------------------ */
$("note").textContent =
"First guess: always hop to the nearest parcel you have not done yet, and turn for home when " +
"the next one would not fit on the charge. Then the planner re-cuts the trips in the best " +
"possible places, and runs 2-opt: try every pair of positions, flip the stretch of drops " +
"between them, keep the flip that shortens the round the most, repeat until nothing helps. " +
"Crossed legs are always worth flipping, which is why the tangles come out. No trip is ever " +
"allowed to exceed one charge. Distances here are straight lines across the grid, not road miles.";
colors();
layout();
setPreset("spread");
labelRange();
run("full");
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.