GPS Route Optimizer
Browser-only fixed-start open-route optimization. This version uses an LKH-style variable-depth k-opt search with backtracking, 1-tree α-nearness candidates, randomized kicks, and multiple runs. No maps or routing APIs are used.
Optimized Route
| # | Latitude | Longitude |
|---|
<div id=”gps-route-optimizer” class=”gro”>
<style>
#gps-route-optimizer{–ink:#12263a;–muted:#5d7183;–line:#dce5ec;–brand:#0567b5;–good:#087a4b;font:15px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:var(–ink);max-width:1080px;margin:20px auto;background:#fff;border:1px solid var(–line);border-radius:16px;box-shadow:0 8px 28px #15324a12;padding:clamp(16px,3vw,32px);box-sizing:border-box}.gro *{box-sizing:border-box}.gro h2{margin:0 0 4px;font-size:25px}.gro .sub{margin:0 0 24px;color:var(–muted)}.gro label{display:block;font-weight:700;margin:17px 0 7px}.gro input,.gro textarea{width:100%;border:1px solid #b9c9d5;border-radius:9px;padding:11px 12px;font:inherit;color:var(–ink);background:#fff}.gro textarea{height:230px;resize:vertical}.gro input:focus,.gro textarea:focus{outline:3px solid #0567b530;border-color:var(–brand)}.gro .hint{font-size:12px;color:var(–muted);margin-top:5px}.gro .actions{display:flex;gap:10px;flex-wrap:wrap;margin:20px 0}.gro button{border:0;border-radius:9px;padding:11px 16px;font:700 14px/1 system-ui;cursor:pointer;background:var(–brand);color:#fff}.gro button:hover{filter:brightness(.94)}.gro button:disabled{opacity:.55;cursor:not-allowed}.gro button.secondary{background:#e8f1f8;color:#155177}.gro button.danger{background:#a92d32}.gro .panel{border:1px solid var(–line);border-radius:10px;padding:14px;margin-top:16px;background:#fbfdff}.gro .status{white-space:pre-wrap;min-height:24px;color:#29465b}.gro .error{border-color:#e7aaaa;background:#fff8f8;color:#8b1d24}.gro .ok{border-color:#add8c3;background:#f5fcf8;color:#17623e}.gro .hide{display:none}.gro .metrics{display:grid;grid-template-columns:repeat(4,minmax(140px,1fr));gap:10px;margin-top:14px}.gro .metric{border:1px solid var(–line);border-radius:9px;padding:10px;background:#fff}.gro .metric b{display:block;font-size:18px}.gro .metric span{font-size:12px;color:var(–muted)}.gro .table-wrap{overflow:auto;max-height:520px;margin-top:14px;border:1px solid var(–line);border-radius:9px}.gro table{border-collapse:collapse;width:100%;min-width:430px}.gro th,.gro td{text-align:left;padding:9px 12px;border-bottom:1px solid #edf1f4}.gro th{position:sticky;top:0;background:#f2f7fb}.gro .pager{display:flex;align-items:center;gap:10px;margin-top:12px}.gro .pager span{color:var(–muted);font-size:13px}@media(max-width:720px){.gro .metrics{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:560px){.gro button{flex:1}.gro textarea{height:190px}}
</style>
<h2>GPS Route Optimizer</h2>
<p class=”sub”>
Browser-only fixed-start open-route optimization. This version uses an LKH-style variable-depth k-opt search with backtracking, 1-tree α-nearness candidates, randomized kicks, and multiple runs. No maps or routing APIs are used.
</p>
<label for=”gro-start”>Start Point</label>
<input id=”gro-start” value=”33.5871849, 73.1193211″ inputmode=”text” aria-describedby=”gro-start-hint”>
<div id=”gro-start-hint” class=”hint”>
Latitude, longitude — e.g. 33.5871849, 73.1193211
</div>
<label for=”gro-locations”>Delivery Locations</label>
<textarea id=”gro-locations” spellcheck=”false” placeholder=”33.600123,73.110456 33.612345,73.125678 33.590111,73.140222″></textarea>
<div id=”gro-count” class=”hint”>0 valid delivery locations detected.</div>
<div class=”actions”>
<button id=”gro-run” type=”button”>OPTIMIZE ROUTE</button>
<button id=”gro-cancel” class=”danger hide” type=”button”>Cancel Optimization</button>
</div>
<div id=”gro-message” class=”panel hide” role=”alert”></div>
<div id=”gro-progress” class=”panel status” aria-live=”polite”>Ready. Add one coordinate per line.</div>
<section id=”gro-results” class=”hide”>
<div class=”metrics” id=”gro-metrics”></div>
<h2 style=”margin-top:26px”>Optimized Route</h2>
<div class=”table-wrap”>
<table>
<thead>
<tr>
<th>#</th>
<th>Latitude</th>
<th>Longitude</th>
</tr>
</thead>
<tbody id=”gro-route-body”></tbody>
</table>
</div>
<div class=”pager”>
<button id=”gro-prev” class=”secondary” type=”button”>Previous</button>
<span id=”gro-page”></span>
<button id=”gro-next” class=”secondary” type=”button”>Next</button>
</div>
<div class=”actions”>
<button id=”gro-copy” class=”secondary” type=”button”>Copy Optimized GPS</button>
<button id=”gro-csv” class=”secondary” type=”button”>Download CSV</button>
</div>
</section>
<script>
(()=>{
‘use strict’;
const root=document.currentScript.parentElement,
$=s=>root.querySelector(s);
const
startEl=$(‘#gro-start’),
locations=$(‘#gro-locations’),
count=$(‘#gro-count’),
run=$(‘#gro-run’),
cancel=$(‘#gro-cancel’),
msg=$(‘#gro-message’),
progress=$(‘#gro-progress’),
results=$(‘#gro-results’),
tbody=$(‘#gro-route-body’),
metrics=$(‘#gro-metrics’);
let worker=null,
routeData=null,
page=0;
const PAGE=250;
const MAX_POINTS=10000;
function coord(s){
const m=String(s).trim().match(/^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*(?:,|\s+)\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*$/);
if(!m)return null;
const a=+m[1];
const b=+m[2];
return Number.isFinite(a)&&
Number.isFinite(b)&&
a>=-90&&
a<=90&&
b>=-180&&
b<=180
?[a,b]:null;
}
function list(a){
return a.slice(0,25).join(‘, ‘)+
(a.length>25?’ … (‘+a.length+’ total)’:”);
}
function notice(text,kind){
msg.textContent=text;
msg.className=’panel ‘+kind;
}
function parse(show){
const lines=locations.value.replace(/\r/g,”).split(‘\n’);
const valid=[];
const validGps=[];
const invalid=[];
const duplicates=[];
const seen=new Set;
lines.forEach((line,i)=>{
if(!line.trim())return;
const p=coord(line);
if(!p){
invalid.push(i+1);
return;
}
const k=p[0].toFixed(8)+’,’+p[1].toFixed(8);
if(seen.has(k)){
duplicates.push(i+1);
}
else{
seen.add(k);
valid.push(p);
validGps.push(line.trim());
}
});
const extra=duplicates.length
?’ (‘+duplicates.length+’ duplicate’+(duplicates.length===1?”:’s’)+’ will be removed).’
:”;
count.textContent=
valid.length+
‘ valid delivery location’+
(valid.length===1?”:’s’)+
‘ detected’+
extra;
if(valid.length>MAX_POINTS)
count.textContent+=’ Maximum supported input is ‘+MAX_POINTS+’.’;
if(show&&(invalid.length||duplicates.length)){
const parts=[];
if(invalid.length)
parts.push(
‘Invalid coordinate line’+
(invalid.length===1?”:’s’)+
‘: ‘+list(invalid)+’.’
);
if(duplicates.length)
parts.push(
‘Duplicate line’+
(duplicates.length===1?”:’s’)+
‘ removed: ‘+list(duplicates)+’.’
);
notice(
parts.join(‘ ‘),
invalid.length?’error’:’ok’
);
}
return{
valid,
validGps,
invalid,
duplicates
};
}
locations.addEventListener(
‘input’,
()=>parse(false)
);
function setRunning(on){
run.disabled=on;
cancel.classList.toggle(‘hide’,!on);
}
function fmt(m){
return m<1000
?m.toFixed(0)+’ m’
:(m/1000).toFixed(2)+’ km’;
}
function render(){
if(!routeData)return;
const{
lat,
lon,
order,
start
}=routeData;
const from=page*PAGE;
const to=Math.min(
order.length,
from+PAGE
);
let html=
‘<tr>’+
‘<td>Start</td>’+
‘<td>’+start[0]+'</td>’+
‘<td>’+start[1]+'</td>’+
‘</tr>’;
for(let i=from;i<to;i++){
const n=order[i];
html+=
‘<tr>’+
‘<td>’+(i+1)+'</td>’+
‘<td>’+lat[n].toFixed(7)+'</td>’+
‘<td>’+lon[n].toFixed(7)+'</td>’+
‘</tr>’;
}
tbody.innerHTML=html;
$(‘#gro-page’).textContent=
‘Stops ‘+(from+1)+’–’+to+
‘ of ‘+order.length;
$(‘#gro-prev’).disabled=page===0;
$(‘#gro-next’).disabled=to>=order.length;
}
$(‘#gro-prev’).onclick=()=>{
if(page){
page–;
render();
}
};
$(‘#gro-next’).onclick=()=>{
if((page+1)*PAGE<routeData.order.length){
page++;
render();
}
};
run.onclick=()=>{
const start=coord(startEl.value);
const p=parse(true);
if(!start){
notice(
‘Start Point must be a valid latitude and longitude.’,
‘error’
);
return;
}
if(p.invalid.length){
notice(
‘Fix the invalid delivery line’+
(p.invalid.length===1?”:’s’)+
‘ before optimizing: ‘+
list(p.invalid)+’.’,
‘error’
);
return;
}
if(p.valid.length>MAX_POINTS){
notice(
‘The browser version is capped at ‘+
MAX_POINTS+
‘ delivery locations.’,
‘error’
);
return;
}
if(!p.valid.length){
notice(
‘Enter at least one valid delivery location.’,
‘error’
);
return;
}
notice(”,’hide’);
startJob(
start,
p.valid,
startEl.value.trim(),
p.validGps
);
};
function startJob(
start,
points,
startGps,
gps
){
setRunning(true);
results.classList.add(‘hide’);
progress.textContent=
‘Preparing ‘+points.length+
‘ delivery coordinates…’;
const lat=new Float64Array(points.length);
const lon=new Float64Array(points.length);
points.forEach((p,i)=>{
lat[i]=p[0];
lon[i]=p[1];
});
const source=
‘(‘+workerMain.toString()+’)()’;
const blob=new Blob(
[source],
{type:’text/javascript’}
);
worker=new Worker(
URL.createObjectURL(blob)
);
worker.onmessage=e=>{
const d=e.data;
if(d.type===’progress’){
progress.textContent=d.text;
}
else if(d.type===’error’){
notice(
d.message,
‘error’
);
finish();
}
else if(d.type===’done’){
routeData={
start,
startGps,
gps,
lat:new Float64Array(d.lat),
lon:new Float64Array(d.lon),
order:new Int32Array(d.order)
};
showDone(d);
autoCopyOptimizedGPS();
finish();
}
};
worker.onerror=()=>{
notice(
‘The optimizer worker stopped unexpectedly. Try a modern browser with sufficient memory.’,
‘error’
);
finish();
};
worker.postMessage(
{
start,
lat:lat.buffer,
lon:lon.buffer
},
[
lat.buffer,
lon.buffer
]
);
}
function finish(){
if(worker){
worker.terminate();
worker=null;
}
setRunning(false);
}
cancel.onclick=()=>{
if(worker){
worker.terminate();
worker=null;
}
progress.textContent=
‘Optimization cancelled. No route was exported.’;
setRunning(false);
};
function showDone(d){
metrics.innerHTML=[
[‘Locations’,d.count],
[‘Initial Distance’,fmt(d.initial)],
[‘Final Distance’,fmt(d.final)],
[‘Improvement’,d.improvement.toFixed(2)+’%’],
[‘Optimization Time’,
(d.ms/1000).toFixed(1)+’ s’
],
[‘LKH Runs’,d.runs],
[‘Accepted k-opt’,d.moves],
[‘2-opt Moves’,d.twoOptMoves||0],
[‘Max Move Depth’,d.maxDepth]
].map(x=>
‘<div class=”metric”>’+
‘<b>’+x[1]+'</b>’+
‘<span>’+x[0]+'</span>’+
‘</div>’
).join(”);
page=0;
render();
results.classList.remove(‘hide’);
progress.textContent=
‘Complete — ‘+
d.runs+
‘ LKH-style runs, ‘+
d.moves+
‘ accepted variable-depth moves.’;
}
function gpsText(){
const r=routeData;
const a=[
r.startGps
];
for(const n of r.order){
a.push(
r.gps[n]
);
}
return a.join(‘\n’)+’\n’;
}
async function autoCopyOptimizedGPS(){
try{
await navigator.clipboard.writeText(
gpsText()
);
progress.textContent=
‘Complete — optimized GPS copied to clipboard automatically.’;
}
catch(e){
progress.textContent=
‘Complete — route optimized. Click “Copy Optimized GPS” to copy the GPS.’;
}
}
$(‘#gro-copy’).onclick=async()=>{
try{
await navigator.clipboard.writeText(
gpsText()
);
progress.textContent=
‘Optimized GPS copied to your clipboard.’;
}
catch(e){
const t=document.createElement(‘textarea’);
t.value=gpsText();
document.body.append(t);
t.select();
document.execCommand(‘copy’);
t.remove();
progress.textContent=
‘Optimized GPS copied to your clipboard.’;
}
};
$(‘#gro-csv’).onclick=()=>{
const b=new Blob(
[gpsText()],
{type:’text/csv’}
);
const a=document.createElement(‘a’);
a.href=URL.createObjectURL(b);
a.download=’optimized-gps-route.csv’;
a.click();
setTimeout(
()=>URL.revokeObjectURL(a.href),
500
);
};
/*
Browser implementation of the core LKH ideas:
1. A fixed-start open path is transformed into a cycle by adding
a zero-cost duplicate of the start.
2. The candidate backbone is based on an exact minimum 1-tree
for the spherical/Haversine geometry. The MST edge ordering
is computed with unit-sphere chord distances, which preserve
the ordering of great-circle distances.
3. α-nearness is computed using:
alpha(i,j) = c(i,j) – maximum edge on the 1-tree path i..j
4. Search uses sequential alternating X/Y edge exchanges:
X1, Y1, X2, Y2, …, Xk, Yk
5. Search is recursive and backtracks through candidate choices.
6. Positive gain pruning is used before deeper continuation.
7. Randomized double-bridge style kicks provide new starting tours.
8. Multiple independent runs are used.
Important:
This is NOT a binary/line-for-line port of Helsgaun’s official
LKH-3 source code. It is a browser implementation of the main
Lin-Kernighan-Helsgaun search concepts and is intentionally not
labelled as the official LKH solver.
*/
function workerMain(){
‘use strict’;
const R=6371008.8;
const INF=1e100;
let lat;
let lon;
let N;
let M;
let root;
let dup;
let cands;
let moves=0;
let lastPost=0;
let rngState=1;
let deadline=0;
let maxDepth=5;
let searchAborts=0;
const rad=x=>
x*Math.PI/180;
function rng(){
rngState=
(
rngState*1664525+
1013904223
)>>>0;
return rngState/4294967296;
}
function hav(a,b){
if(
(a===root&&b===dup)||
(a===dup&&b===root)
)
return 0;
const p=rad(lat[a]);
const q=rad(lat[b]);
const u=rad(
lat[b]-lat[a]
);
const v=rad(
lon[b]-lon[a]
);
const h=
Math.sin(u/2)**2+
Math.cos(p)*
Math.cos(q)*
Math.sin(v/2)**2;
return 2*
R*
Math.atan2(
Math.sqrt(h),
Math.sqrt(
Math.max(
0,
1-h
)
)
);
}
function post(text,force){
const now=performance.now();
if(
force||
now-lastPost>300
){
lastPost=now;
self.postMessage({
type:’progress’,
text
});
}
}
function edgeKey(a,b){
return a<b
?a*M+b
:b*M+a;
}
function isFixed(a,b){
return(
(a===root&&b===dup)||
(a===dup&&b===root)
);
}
/*
Build a geographically local raw candidate graph.
This is intentionally only the candidate pool; α-values are
subsequently calculated against the exact 1-tree.
*/
function gridCandidates(K){
post(
‘Building geographic neighbor graph…’,
true
);
const count=M;
const meanLat=
lat.reduce(
(s,v)=>s+v,
0
)/count;
const scale=Math.max(
0.05,
Math.cos(rad(meanLat))
);
const xs=new Float64Array(count);
const ys=new Float64Array(count);
let xmin=INF;
let xmax=-INF;
let ymin=INF;
let ymax=-INF;
for(let i=0;i<count;i++){
xs[i]=lon[i]*scale;
ys[i]=lat[i];
if(xs[i]<xmin)xmin=xs[i];
if(xs[i]>xmax)xmax=xs[i];
if(ys[i]<ymin)ymin=ys[i];
if(ys[i]>ymax)ymax=ys[i];
}
const span=Math.max(
xmax-xmin,
ymax-ymin,
0.000001
);
const cell=Math.max(
span/Math.sqrt(count)*1.8,
0.00005
);
const grid=new Map();
const key=(x,y)=>
x+’,’+y;
const cx=new Int32Array(count);
const cy=new Int32Array(count);
for(let i=0;i<count;i++){
const x=Math.floor(
(xs[i]-xmin)/cell
);
const y=Math.floor(
(ys[i]-ymin)/cell
);
cx[i]=x;
cy[i]=y;
const k=key(x,y);
let a=grid.get(k);
if(!a){
a=[];
grid.set(k,a);
}
a.push(i);
}
const out=
Array.from(
{length:count},
()=>[]
);
const target=
Math.min(
Math.max(K*4,48),
96
);
for(let i=0;i<count;i++){
const pool=[];
const seen=new Set();
let radius=0;
while(
pool.length<target&&
radius<12
){
for(
let dx=-radius;
dx<=radius;
dx++
){
for(
let dy=-radius;
dy<=radius;
dy++
){
if(
Math.max(
Math.abs(dx),
Math.abs(dy)
)!==radius
)
continue;
const a=
grid.get(
key(
cx[i]+dx,
cy[i]+dy
)
);
if(!a)
continue;
for(const j of a){
if(
j!==i&&
!seen.has(j)
){
seen.add(j);
pool.push(j);
}
}
}
}
radius++;
}
if(pool.length<target){
const yy=lat[i];
const idx=[];
for(
let j=0;
j<count;
j++
)
if(j!==i)
idx.push(j);
idx.sort(
(a,b)=>
Math.abs(lat[a]-yy)-
Math.abs(lat[b]-yy)
);
for(
let z=0;
z<Math.min(
idx.length,
target*2
)&&
pool.length<target;
z++
){
if(
!seen.has(idx[z])
){
seen.add(idx[z]);
pool.push(idx[z]);
}
}
}
pool.sort(
(a,b)=>
hav(i,a)-
hav(i,b)
);
for(
let z=0;
z<Math.min(
K,
pool.length
);
z++
){
out[i].push([
pool[z],
hav(i,pool[z])
]);
}
if(
(i&255)===0
){
post(
‘Neighbor graph: ‘+
i+’/’+count
);
}
}
return out;
}
/*
Exact minimum spanning tree on the non-root nodes.
The edge ordering for Prim is calculated from squared chord
distance on the unit sphere. Since this is a strictly increasing
transform of angular/geographic distance, the resulting MST
edge ordering is the same as for Haversine distance.
The actual tree-edge costs stored for α-nearness are Haversine.
*/
/*
Build the exact minimum 1-tree and compute exact
unpenalized α-nearness values.
Node layout:
0 … N-1 = delivery nodes
N = real start/root
N+1 = duplicate start
The 1-tree is constructed as:
MST on every node except root
+
root -> duplicate-start, cost 0
+
root -> nearest delivery
For ordinary non-root edges:
alpha(i,j) =
c(i,j) – maximum-MST-edge-on-path(i,j)
For root edges:
alpha(root, nearest) = 0
alpha(root, j) =
c(root,j) – rootSecondCost
The α calculation is GLOBAL:
every possible non-fixed edge is examined.
There is no geographic RAW_K pre-filter.
This is an exact α-nearness calculation for the
unpenalized spherical/Haversine 1-tree.
*/
function buildOneTree(ALPHA_K,geoCands,GEO_K){
post(
‘Computing exact minimum 1-tree…’,
true
);
/*
The MST must contain EVERY node except the distinguished root.
That means:
deliveries 0 … N-1
duplicate start N+1
The real root N is excluded.
*/
const treeNodes=[];
for(
let v=0;
v<M;
v++
){
if(v!==root){
treeNodes.push(v);
}
}
const V=treeNodes.length;
if(V<1){
throw new Error(
‘1-tree requires at least one non-root node.’
);
}
/*
Unit-sphere coordinates.
Squared chord distance has the same ordering as
great-circle/Haversine distance, so Prim produces
the same MST for the spherical metric.
*/
const ux=
new Float64Array(M);
const uy=
new Float64Array(M);
const uz=
new Float64Array(M);
for(
const v of treeNodes
){
const p=rad(lat[v]);
const q=rad(lon[v]);
ux[v]=
Math.cos(p)*
Math.cos(q);
uy[v]=
Math.cos(p)*
Math.sin(q);
uz[v]=
Math.sin(p);
}
/*
Exact dense Prim MST on all non-root nodes.
*/
const parent=
new Int32Array(M);
const used=
new Uint8Array(M);
const best=
new Float64Array(M);
parent.fill(-1);
best.fill(INF);
const first=
treeNodes[0];
best[first]=0;
const adj=
Array.from(
{length:M},
()=>[]
);
const mstCost=
new Float64Array(M);
mstCost.fill(0);
for(
let it=0;
it<V;
it++
){
let v=-1;
let bestW=INF;
/*
Find the unused tree node with minimum
connection cost.
*/
for(
let q=0;
q<V;
q++
){
const j=
treeNodes[q];
if(
!used[j] &&
best[j]<bestW
){
bestW=best[j];
v=j;
}
}
if(v<0){
throw new Error(
‘Exact MST construction failed.’
);
}
used[v]=1;
/*
Add the selected MST edge.
*/
if(parent[v]!==-1){
const p=parent[v];
const w=
hav(v,p);
mstCost[v]=w;
adj[v].push([
p,
w
]);
adj[p].push([
v,
w
]);
}
/*
Relax every remaining vertex.
Squared chord distance is used ONLY for
MST ordering.
*/
const vx=ux[v];
const vy=uy[v];
const vz=uz[v];
for(
let q=0;
q<V;
q++
){
const j=
treeNodes[q];
if(used[j])
continue;
const dx=
vx-ux[j];
const dy=
vy-uy[j];
const dz=
vz-uz[j];
const w=
dx*dx+
dy*dy+
dz*dz;
if(w<best[j]){
best[j]=w;
parent[j]=v;
}
}
if(
(it&255)===0
){
post(
‘Exact 1-tree MST: ‘+
(it+1)+’/’+V
);
}
}
/*
Find the cheapest REAL root -> delivery edge.
IMPORTANT:
Do not include root itself.
Do not include duplicate start.
*/
let nearest=0;
let rootSecond=
hav(
root,
0
);
for(
let j=1;
j<N;
j++
){
const z=
hav(root,j);
if(z<rootSecond){
rootSecond=z;
nearest=j;
}
}
/*
We now have the exact 1-tree:
MST(treeNodes)
+
root -> duplicate
+
root -> nearest delivery
The root->duplicate edge is fixed and has cost 0.
*/
/*
—————————————————————-
Exact maximum-edge-on-MST-path calculator
—————————————————————-
For every source node, traverse the entire MST once.
For a given source i, when visiting j:
beta(i,j) =
maximum MST edge on path i -> j
Then:
alpha(i,j) =
c(i,j) – beta(i,j)
This is O(M^2), but it is genuinely global:
NO geographic candidate pool is used.
—————————————————————-
*/
/*
Reusable DFS stacks.
*/
const stackNode=
new Int32Array(V);
const stackParent=
new Int32Array(V);
const stackBeta=
new Float64Array(V);
/*
Small max-heap used to retain only the K
smallest alpha candidates for each source.
The heap root is the WORST candidate currently kept.
*/
function heapWorse(
aAlpha,
aCost,
bAlpha,
bCost
){
return(
aAlpha>bAlpha||
(
aAlpha===bAlpha &&
aCost>bCost
)
);
}
function heapPush(
nodes,
alphas,
costs,
size,
node,
alphaValue,
costValue,
capacity
){
if(size<capacity){
let i=size;
nodes[i]=node;
alphas[i]=alphaValue;
costs[i]=costValue;
size++;
while(i>0){
const p=
Math.floor((i-1)/2);
if(
!heapWorse(
alphas[p],
costs[p],
alphas[i],
costs[i]
)
)
break;
[
nodes[p],
nodes[i]
]=[
nodes[i],
nodes[p]
];
[
alphas[p],
alphas[i]
]=[
alphas[i],
alphas[p]
];
[
costs[p],
costs[i]
]=[
costs[i],
costs[p]
];
i=p;
}
return size;
}
/*
Heap is full.
Ignore the new edge if it is not
better than the current worst edge.
*/
if(
!heapWorse(
alphas[0],
costs[0],
alphaValue,
costValue
)
){
return size;
}
/*
Replace heap root.
*/
nodes[0]=node;
alphas[0]=alphaValue;
costs[0]=costValue;
let i=0;
while(true){
const l=i*2+1;
const r=i*2+2;
let w=i;
if(
l<size &&
heapWorse(
alphas[l],
costs[l],
alphas[w],
costs[w]
)
){
w=l;
}
if(
r<size &&
heapWorse(
alphas[r],
costs[r],
alphas[w],
costs[w]
)
){
w=r;
}
if(w===i)
break;
[
nodes[i],
nodes[w]
]=[
nodes[w],
nodes[i]
];
[
alphas[i],
alphas[w]
]=[
alphas[w],
alphas[i]
];
[
costs[i],
costs[w]
]=[
costs[w],
costs[i]
];
i=w;
}
return size;
}
/*
Sort the retained heap into increasing alpha order.
*/
function sortHeap(
nodes,
alphas,
costs,
size
){
const arr=[];
for(
let i=0;
i<size;
i++
){
arr.push([
nodes[i],
alphas[i],
costs[i]
]);
}
arr.sort(
(a,b)=>
a[1]-b[1]||
a[2]-b[2]
);
return arr;
}
/*
Capacity cannot exceed the number of possible
non-self, non-fixed candidates.
*/
const K=
Math.max(
1,
Math.min(
ALPHA_K,
M-2
)
);
/*
Merge α-nearness candidates with geographically
nearby candidates.
α candidates remain first.
Geographic candidates are then added if they
are not already present.
The duplicate-start node is never allowed as
an ordinary search candidate.
*/
function mergeHybrid(
source,
alphaList
){
const out=[];
const seen=new Set;
for(
const v of alphaList
){
if(
v===source||
v===dup
)
continue;
if(
!seen.has(v)
){
seen.add(v);
out.push(v);
}
}
const geoList=
geoCands[source]||[];
for(
const item of geoList
){
const v=
Array.isArray(item)
?item[0]
:item;
if(
v===source||
v===dup
)
continue;
if(
source===dup&&
v===root
)
continue;
if(
!seen.has(v)
){
seen.add(v);
out.push(v);
}
}
return out;
}
/*
Compute exact α candidates for every node.
*/
for(
let source=0;
source<M;
source++
){
/*
The root has special 1-tree α-values.
*/
if(source===root){
const heapNodes=
new Int32Array(K);
const heapAlpha=
new Float64Array(K);
const heapCosts=
new Float64Array(K);
let heapSize=0;
for(
const j of treeNodes
){
/*
The duplicate-start edge is fixed and is
not a candidate.
*/
if(j===dup)
continue;
const d=
hav(root,j);
const alphaValue=
j===nearest
?0
:Math.max(
0,
d-rootSecond
);
heapSize=
heapPush(
heapNodes,
heapAlpha,
heapCosts,
heapSize,
j,
alphaValue,
d,
K
);
}
const ordered=
sortHeap(
heapNodes,
heapAlpha,
heapCosts,
heapSize
);
cands[root]=
mergeHybrid(
root,
ordered.map(
x=>x[0]
)
);
continue;
}
/*
Non-root source.
Every other MST node is examined.
*/
const heapNodes=
new Int32Array(K);
const heapAlpha=
new Float64Array(K);
const heapCosts=
new Float64Array(K);
let heapSize=0;
/*
DFS from source through the COMPLETE MST.
Each visited node gets its exact
maximum edge cost along source -> node.
*/
let sp=0;
stackNode[sp]=source;
stackParent[sp]=-1;
stackBeta[sp]=0;
sp++;
while(sp>0){
sp–;
const v=
stackNode[sp];
const prev=
stackParent[sp];
const beta=
stackBeta[sp];
/*
Do not consider the source itself.
*/
if(v!==source){
const d=
hav(source,v);
const alphaValue=
Math.max(
0,
d-beta
);
heapSize=
heapPush(
heapNodes,
heapAlpha,
heapCosts,
heapSize,
v,
alphaValue,
d,
K
);
}
/*
Continue through MST neighbors.
*/
const neighbors=
adj[v];
for(
let ni=0;
ni<neighbors.length;
ni++
){
const u=
neighbors[ni][0];
const w=
neighbors[ni][1];
if(u===prev)
continue;
stackNode[sp]=u;
stackParent[sp]=v;
stackBeta[sp]=
Math.max(
beta,
w
);
sp++;
}
}
/*
Now handle the real root edge separately.
The fixed root->duplicate edge is excluded.
*/
if(source!==dup){
const dRoot=
hav(
source,
root
);
const rootAlpha=
Math.max(
0,
dRoot-rootSecond
);
/*
If root already appears in the K exact candidates,
heapPush will deduplicate naturally below.
*/
heapSize=
heapPush(
heapNodes,
heapAlpha,
heapCosts,
heapSize,
root,
rootAlpha,
dRoot,
K
);
}
/*
Convert exact top-K α values into the candidate set.
*/
const ordered=
sortHeap(
heapNodes,
heapAlpha,
heapCosts,
heapSize
);
cands[source]=
mergeHybrid(
source,
ordered.map(
x=>x[0]
)
);
if(
(source&255)===0
){
post(
‘Exact α-nearness: ‘+
source+’/’+M
);
}
}
/*
The fixed zero-cost root->duplicate edge is
not used as an ordinary search candidate.
It is structural only.
*/
cands[root]=
cands[root].filter(
v=>v!==dup
);
cands[dup]=
cands[dup].filter(
v=>v!==root
);
post(
‘Hybrid candidates ready: ‘+
ALPHA_K+
‘ α-nearness + up to ‘+
GEO_K+
‘ geographic-neighbor candidates/node.’,
true
);
}
function cycleLength(route){
let z=0;
for(
let i=0;
i<route.length-1;
i++
){
z+=hav(
route[i],
route[i+1]
);
}
z+=hav(
route[route.length-1],
route[0]
);
return z;
}
/*
Construct a randomized nearest-neighbor initial tour.
Node root is the real start.
Node dup is a second copy of start with zero closing cost.
*/
function initialTour(seed){
const used=
new Uint8Array(M);
const r=
new Int32Array(M);
r[0]=root;
used[root]=1;
let cur=root;
for(
let p=1;
p<M-1;
p++
){
let pool=
cands[cur].filter(
j=>
!used[j]&&
j!==dup
);
if(!pool.length){
pool=[];
for(
let j=1;
j<M-1;
j++
){
if(
!used[j]
){
pool.push(j);
}
}
}
pool.sort(
(a,b)=>
hav(cur,a)-
hav(cur,b)
);
const top=
Math.min(
pool.length,
seed
?Math.min(
6,
pool.length
)
:1
);
const pick=
pool[
Math.floor(
rng()*top
)
];
r[p]=pick;
used[pick]=1;
cur=pick;
}
r[M-1]=dup;
return r;
}
function posMap(route){
const p=
new Int32Array(M);
for(
let i=0;
i<M;
i++
){
p[route[i]]=i;
}
return p;
}
function tourNeighbors(
route,
v,
pos
){
const p=
pos[v];
const left=
route[
(p-1+M)%M
];
const right=
route[
(p+1)%M
];
return left===right
?[left]
:[left,right];
}
function edgeInTour(
route,
pos,
a,
b
){
return(
pos[b]===
((pos[a]+1)%M)
||
pos[a]===
((pos[b]+1)%M)
);
}
/*
Apply an alternating X/Y exchange and verify that it produces
one valid Hamiltonian cycle while retaining the fixed
root <-> duplicate-start edge.
*/
function validExchange(
route,
X,
Y
){
const adj=
Array.from(
{length:M},
()=>[]
);
/*
Remove X edges from the current tour.
*/
for(
let i=0;
i<M;
i++
){
const a=route[i];
const b=
route[
(i+1)%M
];
let removed=false;
for(
const e of X
){
if(
edgeKey(a,b)===
edgeKey(
e[0],
e[1]
)
){
removed=true;
break;
}
}
if(!removed){
adj[a].push(b);
adj[b].push(a);
}
}
/*
Add Y edges.
*/
const added=
new Set();
for(
const e of Y
){
const a=e[0];
const b=e[1];
if(
a===b||
isFixed(a,b)
)
return null;
const k=
edgeKey(a,b);
if(
added.has(k)
)
return null;
added.add(k);
adj[a].push(b);
adj[b].push(a);
}
/*
Every node must have degree two.
*/
for(
let i=0;
i<M;
i++
){
if(
adj[i].length!==2
)
return null;
}
/*
Traverse the resulting cycle.
The fixed root <-> duplicate-start edge must remain.
The returned representation is:
root -> deliveries -> duplicate
*/
const out=
new Int32Array(M);
const first=
adj[root].find(
v=>v!==dup
);
if(first==null)
return null;
out[0]=root;
let prev=root;
let cur=first;
for(
let i=1;
i<M;
i++
){
out[i]=cur;
const a=
adj[cur][0];
const b=
adj[cur][1];
const next=
a===prev
?b
:a;
prev=cur;
cur=next;
if(
i<M-1 &&
cur===root
)
return null;
}
if(
cur!==root ||
out[M-1]!==dup
)
return null;
return out;
}
/*
Return the position of the tour edge (a,b).
The result is the index i such that:
route[i] -> route[(i+1)%M]
is the edge.
*/
function tourEdgeIndex(
a,
b,
pos
){
const pa=pos[a];
const pb=pos[b];
if(
(pa+1)%M===pb
)
return pa;
if(
(pb+1)%M===pa
)
return pb;
return -1;
}
/*
Fast LK feasibility test.
X contains broken tour edges.
Y contains already-added non-tour edges.
close is the prospective closing edge:
(last,t1)
After removing X and adding Y + close,
the result must be ONE Hamiltonian cycle.
This avoids constructing an M-node adjacency graph for
every recursive test. Only the k affected tour segments
are examined.
*/
function feasibleSequentialClose(
route,
pos,
X,
Y,
close
){
if(
!close||
close[0]===close[1]||
isFixed(
close[0],
close[1]
)
)
return false;
const xSet=
new Set();
const breakIdx=[];
const delta=
new Map();
const bump=(
v,
d
)=>{
delta.set(
v,
(delta.get(v)||0)+d
);
};
/*
Remove X edges from the current tour.
Each removed edge creates one path component.
*/
for(
const e of X
){
const a=e[0];
const b=e[1];
const k=
edgeKey(a,b);
if(
xSet.has(k)||
isFixed(a,b)||
!edgeInTour(
route,
pos,
a,
b
)
)
return false;
const idx=
tourEdgeIndex(
a,
b,
pos
);
if(idx<0)
return false;
xSet.add(k);
breakIdx.push(idx);
bump(a,-1);
bump(b,-1);
}
if(!breakIdx.length)
return false;
/*
Add Y edges plus the final closing edge.
The intermediate Y edges must connect
different path components.
The FINAL closing edge is different:
by that point all path components should
already belong to one connected component,
and the final edge closes that component
into one Hamiltonian cycle.
*/
const added=
Y.concat([
close
]);
const addedSet=
new Set();
for(
const e of added
){
const a=e[0];
const b=e[1];
if(
a===b||
isFixed(a,b)
)
return false;
const k=
edgeKey(a,b);
if(
xSet.has(k)||
addedSet.has(k)
)
return false;
if(
edgeInTour(
route,
pos,
a,
b
)
)
return false;
addedSet.add(k);
bump(a,1);
bump(b,1);
}
/*
Every touched vertex must remain degree two.
*/
for(
const d of delta.values()
){
if(d!==0)
return false;
}
/*
Deleting k edges from a single Hamiltonian cycle
creates exactly k path components.
*/
breakIdx.sort(
(a,b)=>a-b
);
const k=
breakIdx.length;
const parent=
new Int32Array(k);
for(
let i=0;
i<k;
i++
)
parent[i]=i;
function find(x){
let r=x;
while(
parent[r]!==r
){
parent[r]=
parent[parent[r]];
r=parent[r];
}
return r;
}
function unite(a,b){
a=find(a);
b=find(b);
if(a!==b)
parent[b]=a;
}
/*
Return the component containing the tour
position p after the X edges are removed.
If the removed tour-edge positions are:
b1 < b2 < … < bk
then each remaining segment is one component.
*/
function componentAtPosition(p){
let lo=0;
let hi=k;
while(lo<hi){
const mid=
(lo+hi)>>1;
if(
breakIdx[mid]<p
){
lo=mid+1;
}
else{
hi=mid;
}
}
return(
lo-1+k
)%k;
}
/*
Process added edges in order.
Y1 … Y(k-1):
each must connect two DIFFERENT components.
Final close:
all components must already be connected,
so the final edge should connect the SAME
union-find component and close the cycle.
*/
for(
let i=0;
i<added.length;
i++
){
const e=added[i];
const ca=
componentAtPosition(
pos[e[0]]
);
const cb=
componentAtPosition(
pos[e[1]]
);
const ra=find(ca);
const rb=find(cb);
/*
Intermediate Y edge:
must merge two different components.
*/
if(
i<added.length-1
){
if(
ra===rb
)
return false;
unite(
ra,
rb
);
}
/*
Final closing edge:
all k path components should now have
been merged into one component.
The final edge closes that component.
*/
else{
if(
ra!==rb
)
return false;
}
}
/*
Final connectivity check:
there must be exactly one component.
*/
const base=find(0);
for(
let i=1;
i<k;
i++
){
if(
find(i)!==base
)
return false;
}
return true;
}
/*
Recursive variable-depth Lin-Kernighan search.
State:
X = broken tour edges
Y = added non-tour edges
At depth i:
X_i = (t_(2i-1), t_(2i))
Y_i = (t_(2i), t_(2i+1))
The search:
X1
Y1
X2
Y2
…
Xk
Yk
The algorithm:
1. keeps cumulative positive gain;
2. tries a legal close-up at every depth;
3. requires close-up feasibility when selecting X_i;
4. backtracks through candidate Y choices;
5. backtracks through alternate X choices;
6. records the best improvement instead of returning
at the first improvement.
*/
function extendLK(
route,
pos,
t1,
last,
depth,
gain,
X,
Y,
usedX,
usedY,
usedNodes,
depthLimit,
passDeadline,
bestState
){
if(
performance.now()>=passDeadline
)
return;
/*
Try the close-up:
Y_k = (t_(2k), t1)
This is what turns the current alternating chain
into an actual k-opt move.
*/
if(
depth>=2
){
const close=[
last,
t1
];
const closeKey=
edgeKey(
last,
t1
);
const closeCost=
hav(
last,
t1
);
if(
!usedX.has(closeKey) &&
!usedY.has(closeKey) &&
!isFixed(last,t1) &&
!edgeInTour(
route,
pos,
last,
t1
)
){
const finalGain=
gain-closeCost;
/*
Positive improvement and better than the best
exchange already found.
*/
if(
finalGain>1e-6 &&
finalGain>
bestState.gain+1e-6
){
const Yfinal=
Y.concat([
close
]);
/*
The close-up should already be feasible because
X_i was selected using the LK feasibility rule.
Validate again before accepting the move.
*/
if(
feasibleSequentialClose(
route,
pos,
X,
Y,
close
)
){
const candidate=
validExchange(
route,
X,
Yfinal
);
if(candidate){
bestState.gain=
finalGain;
bestState.route=
candidate;
}
}
}
}
}
/*
Variable depth ends here.
A depth limit is only a practical browser safeguard;
the move itself is still variable-depth.
*/
if(
depth>=depthLimit
)
return;
const options=
cands[last]||[];
/*
Try candidate Y edges in α-nearness order.
*/
for(
let ci=0;
ci<options.length;
ci++
){
if(
performance.now()>=passDeadline
)
return;
const t3=
options[ci];
const yKey=
edgeKey(
last,
t3
);
/*
Y edge requirements.
t3 must be a new node.
The edge must not already be in the tour.
X/Y must remain disjoint.
*/
if(
t3===t1||
usedNodes[t3]||
usedX.has(yKey)||
usedY.has(yKey)||
isFixed(last,t3)||
edgeInTour(
route,
pos,
last,
t3
)
)
continue;
const yCost=
hav(
last,
t3
);
const gainAfterY=
gain-yCost;
/*
Lin-Kernighan positive gain criterion.
Do not descend into a branch whose cumulative
gain has already become non-positive.
*/
if(
gainAfterY<=1e-6
)
continue;
/*
X_(i+1) must currently be a tour edge incident to t3.
There are at most two such edges in a tour.
*/
const neigh=
tourNeighbors(
route,
t3,
pos
);
for(
let ni=0;
ni<neigh.length;
ni++
){
if(
performance.now()>=passDeadline
)
return;
const t4=
neigh[ni];
if(
t4===t1||
t4===last||
usedNodes[t4]
)
continue;
const xKey=
edgeKey(
t3,
t4
);
if(
usedX.has(xKey)||
usedY.has(xKey)||
isFixed(t3,t4)
)
continue;
const xCost=
hav(
t3,
t4
);
const gainAfterX=
gainAfterY+xCost;
/*
Tentatively add Y_i and X_(i+1).
*/
Y.push([
last,
t3
]);
X.push([
t3,
t4
]);
usedY.add(yKey);
usedX.add(xKey);
usedNodes[t3]=1;
usedNodes[t4]=1;
/*
This is the important LK feasibility criterion:
when X_(i+1) is selected, joining t4 to t1 must
be capable of producing a tour.
Without this test the search is only a loose
variable-depth k-opt enumerator.
*/
/*
Do not require the partial exchange to already be
closable at this depth.
A deeper LK move may need additional Y/X exchanges
before a valid closing edge exists.
The complete move is still checked by
feasibleSequentialClose() and validExchange()
when we actually attempt the close.
*/
extendLK(
route,
pos,
t1,
t4,
depth+1,
gainAfterX,
X,
Y,
usedX,
usedY,
usedNodes,
depthLimit,
passDeadline,
bestState
);
/*
Backtrack.
*/
usedNodes[t4]=0;
usedNodes[t3]=0;
usedX.delete(xKey);
usedY.delete(yKey);
X.pop();
Y.pop();
}
}
}
/*
One complete LK improvement pass.
Unlike the previous version, this:
– examines every directed starting edge until the deadline;
– examines both choices for X1;
– fully backtracks;
– records the best improving move;
– applies only the best move found during the pass.
This is the core behavior expected from the LK search,
rather than a bounded collection of 2-opt-like trials.
*/
function improveByLK(
route,
passDeadline,
depthLimit
){
const pos=
posMap(route);
const baseLength=
cycleLength(route);
const bestState={
gain:0,
route:null
};
const X=[];
const Y=[];
const usedX=
new Set();
const usedY=
new Set();
const usedNodes=
new Uint8Array(M);
/*
Every directed tour edge is a possible X1 orientation.
This is important: checking only route[i] -> route[i+1]
misses the opposite t1/t2 orientation.
*/
for(
let t1=0;
t1<M;
t1++
){
if(
performance.now()>=passDeadline
){
searchAborts++;
break;
}
const neighbors=
tourNeighbors(
route,
t1,
pos
);
for(
let ni=0;
ni<neighbors.length;
ni++
){
if(
performance.now()>=passDeadline
){
searchAborts++;
break;
}
const t2=
neighbors[ni];
if(
isFixed(t1,t2)
)
continue;
/*
Reset recursive state for this starting orientation.
*/
X.length=0;
Y.length=0;
usedX.clear();
usedY.clear();
usedNodes.fill(0);
/*
X1 = (t1,t2)
*/
const x1Key=
edgeKey(
t1,
t2
);
X.push([
t1,
t2
]);
usedX.add(x1Key);
usedNodes[t1]=1;
usedNodes[t2]=1;
const x1Gain=
hav(
t1,
t2
);
/*
Recursive LK search.
It does NOT return at the first improvement.
It keeps searching and updates bestState.
*/
extendLK(
route,
pos,
t1,
t2,
1,
x1Gain,
X,
Y,
usedX,
usedY,
usedNodes,
depthLimit,
passDeadline,
bestState
);
}
if(
(t1&127)===0
){
post(
‘LK search: starting node ‘+
t1+’/’+M+
‘\nBest gain so far: ‘+
bestState.gain.toFixed(1)+’ m’
);
}
}
/*
Apply the best improving variable-depth move found.
*/
if(
bestState.route &&
bestState.gain>1e-6
){
route.set(
bestState.route
);
moves++;
return{
changed:true,
length:
baseLength-bestState.gain
};
}
return{
changed:false,
length:baseLength
};
}
/*
Exhaustive 2-opt polishing.
The route representation is:
root -> delivery -> delivery -> … -> delivery -> dup
The root and duplicate-start remain fixed at the two ends.
For every pair of non-adjacent tour edges:
A -> B
C -> D
2-opt replaces them with:
A -> C
B -> D
and reverses the route segment B … C.
The entire set of valid edge pairs is scanned.
The best improving move is applied.
The scan repeats until no improving 2-opt move remains
or the supplied deadline is reached.
*/
/*
Parallel Exhaustive 2-opt.
The coordinator remains in the existing optimizer worker.
For every complete 2-opt pass:
1. Divide all possible i values among child workers.
2. Each child evaluates its complete j range.
3. Each child returns its best improving move.
4. The coordinator selects the global best move.
5. The coordinator performs the route reversal.
6. A new parallel pass begins.
The route therefore remains synchronized while the expensive
O(N²) neighborhood search is distributed across CPU workers.
*/
async function exhaustiveTwoOptParallel(
route,
polishDeadline
){
const totalI=
Math.max(
0,
M-3
);
if(
totalI<=0
){
return{
moves:0,
passes:0,
timedOut:false,
workers:0
};
}
/*
hardwareConcurrency is the browser’s estimate of logical
CPU concurrency.
The existing workerMain() is itself already one Worker,
so use hardwareConcurrency – 1 child workers.
This keeps roughly the reported number of logical CPUs
available to the optimization rather than unnecessarily
oversubscribing the CPU.
*/
const reportedCPUs=
Number(
navigator.hardwareConcurrency
)||2;
const workerCount=
Math.max(
1,
Math.min(
reportedCPUs-1,
totalI
)
);
const source=
‘(‘+twoOptChildMain.toString()+’)()’;
const blob=
new Blob(
[source],
{
type:’text/javascript’
}
);
const workerURL=
URL.createObjectURL(blob);
const pool=[];
function createWorker(){
return new Worker(
workerURL
);
}
/*
Create all child workers.
*/
for(
let i=0;
i<workerCount;
i++
){
pool.push(
createWorker()
);
}
/*
Terminate the entire pool safely.
*/
function terminatePool(){
for(
const w of pool
){
try{
w.terminate();
}
catch(e){}
}
URL.revokeObjectURL(
workerURL
);
}
/*
Initialize one child worker.
*/
function initWorker(w){
return new Promise(
(resolve,reject)=>{
let done=false;
const cleanup=()=>{
w.removeEventListener(
‘message’,
onMessage
);
w.removeEventListener(
‘error’,
onError
);
};
const onMessage=e=>{
if(
e.data&&
e.data.type===’ready’
){
done=true;
cleanup();
resolve();
}
else if(
e.data&&
e.data.type===’error’
){
if(!done){
done=true;
cleanup();
reject(
new Error(
e.data.message
)
);
}
}
};
const onError=err=>{
if(!done){
done=true;
cleanup();
reject(err);
}
};
w.addEventListener(
‘message’,
onMessage
);
w.addEventListener(
‘error’,
onError
);
const latCopy=
lat.slice().buffer;
const lonCopy=
lon.slice().buffer;
w.postMessage(
{
type:’init’,
M,
lat:latCopy,
lon:lonCopy
},
[
latCopy,
lonCopy
]
);
}
);
}
/*
Send one scan job to one child worker.
*/
function scanWorker(
w,
routeCopy,
iStart,
iEnd
){
return new Promise(
(resolve,reject)=>{
let done=false;
const cleanup=()=>{
w.removeEventListener(
‘message’,
onMessage
);
w.removeEventListener(
‘error’,
onError
);
};
const onMessage=e=>{
const d=e.data;
if(
d&&
d.type===’scanResult’
){
done=true;
cleanup();
resolve(d);
}
else if(
d&&
d.type===’error’
){
if(!done){
done=true;
cleanup();
reject(
new Error(
d.message
)
);
}
}
};
const onError=err=>{
if(!done){
done=true;
cleanup();
reject(err);
}
};
w.addEventListener(
‘message’,
onMessage
);
w.addEventListener(
‘error’,
onError
);
w.postMessage(
{
type:’scan’,
route:
routeCopy,
iStart,
iEnd
},
[
routeCopy
]
);
}
);
}
let movesMade=0;
let passes=0;
try{
/*
Initialize all child workers.
*/
await Promise.all(
pool.map(
w=>initWorker(w)
)
);
post(
‘Exhaustive 2-opt using ‘+
workerCount+
‘ parallel workers…’,
true
);
/*
Continue until no improving move exists.
Because the caller passes Infinity, this is genuinely
unlimited from the time perspective.
*/
while(
performance.now()<
polishDeadline
){
passes++;
const jobs=[];
/*
Each worker gets a contiguous range of i values.
The i range covers:
0 … M-4
*/
for(
let w=0;
w<workerCount;
w++
){
const iStart=
Math.floor(
totalI*w/workerCount
);
const iEnd=
Math.floor(
totalI*(w+1)/workerCount
);
if(
iStart>=iEnd
)
continue;
/*
route.slice() creates an independent ArrayBuffer,
which can safely be transferred to the child worker.
*/
const routeCopy=
route.slice().buffer;
jobs.push(
scanWorker(
pool[w],
routeCopy,
iStart,
iEnd
)
);
}
const results=
await Promise.all(jobs);
/*
Select the best move found by ANY worker.
*/
let bestGain=0;
let bestI=-1;
let bestJ=-1;
for(
const d of results
){
if(
d.gain>
bestGain+
1e-12
||
(
Math.abs(
d.gain-bestGain
)<=1e-12&&
d.gain>1e-12&&
(
bestI<0||
d.i<bestI||
(
d.i===bestI&&
d.j<bestJ
)
)
)
){
bestGain=d.gain;
bestI=d.i;
bestJ=d.j;
}
}
/*
No improving move exists anywhere in the entire
2-opt neighborhood.
The route is therefore 2-opt locally optimal.
*/
if(
bestI<0||
bestJ<0||
bestGain<=1e-12
){
break;
}
/*
Apply the best 2-opt reversal.
*/
let left=
bestI+1;
let right=
bestJ;
while(
left<right
){
const tmp=
route[left];
route[left]=
route[right];
route[right]=
tmp;
left++;
right–;
}
movesMade++;
post(
‘2-opt pass ‘+
passes+
‘ — move ‘+
movesMade+
‘ — improvement ‘+
bestGain.toFixed(1)+
‘ m — ‘+
workerCount+
‘ workers’,
false
);
}
return{
moves:movesMade,
passes,
timedOut:
performance.now()>=polishDeadline,
workers:workerCount
};
}
finally{
terminatePool();
}
}
/*
Randomized double-bridge style kick.
The fixed root and duplicate-start remain at the boundaries.
*/
function kick(route){
const cuts=[];
while(
cuts.length<3
){
const x=
2+
Math.floor(
rng()*(M-4)
);
if(
!cuts.includes(x)
){
cuts.push(x);
}
}
cuts.sort(
(a,b)=>a-b
);
const[
a,
b,
c
]=cuts;
const head=
Array.from(
route.slice(0,a)
);
const s1=
Array.from(
route.slice(a,b)
);
const s2=
Array.from(
route.slice(b,c)
);
const tail=
Array.from(
route.slice(c)
);
const out=[
…head,
…s2,
…s1,
…tail
];
out[0]=root;
out[M-1]=dup;
return Int32Array.from(out);
}
/*
Parallel Exhaustive 2-opt child worker.
Each child worker receives:
– the coordinate arrays once during initialization
– a copy of the current route for each scan
– an i-range to examine
It returns the best 2-opt move found in its assigned range.
*/
function twoOptChildMain(){
‘use strict’;
let lat;
let lon;
let latRad;
let lonRad;
let cosLat;
let M=0;
const R=6371008.8;
function prepareCoordinates(){
latRad=
new Float64Array(M);
lonRad=
new Float64Array(M);
cosLat=
new Float64Array(M);
for(
let i=0;
i<M;
i++
){
const p=
lat[i]*
Math.PI/180;
const q=
lon[i]*
Math.PI/180;
latRad[i]=p;
lonRad[i]=q;
cosLat[i]=Math.cos(p);
}
}
function hav(a,b){
const p=latRad[a];
const q=latRad[b];
const dLat=
latRad[b]-p;
const dLon=
lonRad[b]-lonRad[a];
const h=
Math.sin(dLat/2)**2+
cosLat[a]*
cosLat[b]*
Math.sin(dLon/2)**2;
return 2*
R*
Math.atan2(
Math.sqrt(h),
Math.sqrt(
Math.max(
0,
1-h
)
)
);
}
function scan(
route,
iStart,
iEnd
){
let bestGain=1e-12;
let bestI=-1;
let bestJ=-1;
const limitI=
Math.min(
iEnd,
M-3
);
for(
let i=iStart;
i<limitI;
i++
){
const a=route[i];
const b=route[i+1];
const oldAB=
hav(a,b);
for(
let j=i+2;
j<=M-2;
j++
){
const c=route[j];
const d=route[j+1];
const oldCost=
oldAB+
hav(c,d);
const newCost=
hav(a,c)+
hav(b,d);
const gain=
oldCost-newCost;
/*
Deterministic tie-breaking:
prefer the smaller i, then smaller j.
*/
if(
gain>bestGain+
1e-12
||
(
Math.abs(
gain-bestGain
)<=1e-12&&
gain>1e-12&&
(
bestI<0||
i<bestI||
(
i===bestI&&
j<bestJ
)
)
)
){
bestGain=gain;
bestI=i;
bestJ=j;
}
}
}
return{
gain:
bestGain>1e-12
?bestGain
:0,
i:bestI,
j:bestJ
};
}
self.onmessage=async e=>{
try{
const d=e.data;
if(d.type===’init’){
lat=
new Float64Array(d.lat);
lon=
new Float64Array(d.lon);
M=d.M;
prepareCoordinates();
self.postMessage({
type:’ready’
});
return;
}
if(d.type===’scan’){
const route=
new Int32Array(d.route);
const result=
scan(
route,
d.iStart,
d.iEnd
);
self.postMessage({
type:’scanResult’,
gain:result.gain,
i:result.i,
j:result.j
});
}
}
catch(err){
self.postMessage({
type:’error’,
message:
err&&err.message
?err.message
:String(err)
});
}
};
}
self.onmessage=async e=>{
try{
lat=
new Float64Array(
e.data.lat
);
lon=
new Float64Array(
e.data.lon
);
N=
lat.length;
root=N;
dup=N+1;
M=N+2;
/*
Extend coordinates with the real start and the duplicate start.
*/
const extLat=
new Float64Array(M);
const extLon=
new Float64Array(M);
extLat.set(lat);
extLon.set(lon);
extLat[root]=
e.data.start[0];
extLon[root]=
e.data.start[1];
extLat[dup]=
e.data.start[0];
extLon[dup]=
e.data.start[1];
lat=extLat;
lon=extLon;
rngState=
(
Math.floor(
performance.now()
)^N
)>>>0;
/*
One delivery: no optimization search is necessary.
*/
if(
N===1
){
const route=
Int32Array.from([
root,
0,
dup
]);
const d=
hav(
root,
0
);
self.postMessage(
{
type:’done’,
lat:lat.buffer,
lon:lon.buffer,
order:new Int32Array([0]).buffer,
count:1,
initial:d,
final:d,
improvement:0,
ms:0,
runs:0,
moves:0,
maxDepth:0
},
[
lat.buffer,
lon.buffer
]
);
return;
}
const began=
performance.now();
/*
Exact α-nearness candidate budget.
The candidate set is now selected from globally computed
α-values, rather than from a geographically pre-filtered
RAW_K pool.
*/
/*
Hybrid candidate strategy:
– ALPHA_K = exact α-nearness candidates
– GEO_K = geographically nearby candidates
At 10,000 nodes we retain the existing 46 α candidates
and add up to 20 geographic-neighbor candidates.
Smaller datasets use slightly fewer α candidates so
the total candidate count remains manageable.
*/
const ALPHA_K =
N <= 199
? 128
: N >= 7000
? 46
: 64;
const GEO_K =
N <= 199
? 32
: 20;
cands =
Array.from(
{length:M},
()=>[]
);
/*
Build geographic candidates first.
gridCandidates() returns [node,distance] pairs.
*/
const geoCands =
gridCandidates(
GEO_K
);
/*
Now build exact α-nearness candidates and merge
them with the geographic candidates.
*/
buildOneTree(
ALPHA_K,
geoCands,
GEO_K
);
post(
‘Building initial tour…’,
true
);
/*
The browser budget is now much larger than the original
few-hundred-millisecond-per-trial design.
10,000 nodes:
5 runs
180 seconds total
3,000-6,999:
6 runs
150 seconds
Smaller:
8 runs
90 seconds
*/
const runs =
N <= 199
? 14
: N >= 7000
? 5
: N >= 3000
? 6
: 8;
maxDepth =
N <= 199
? 14
: N >= 7000
? 7
: N >= 3000
? 9
: 11;
const totalBudget =
N <= 199
? 360000
: N >= 7000
? 600000
: N >= 3000
? 450000
: 300000;
deadline=
began+
totalBudget;
let best=
initialTour(0);
let bestLen=
cycleLength(best);
const initialLen=
bestLen;
let totalMoves=0;
/*
Multiple independent randomized runs.
*/
for(
let runNo=0;
runNo<runs&&
performance.now()<deadline;
runNo++
){
let current=
runNo===0
?best.slice()
:kick(best);
let currentLen=
cycleLength(current);
const runEnd=
Math.min(
deadline,
began+
Math.floor(
totalBudget/runs
)*(runNo+1)
);
post(
‘LKH-style run ‘+
(runNo+1)+
‘ / ‘+
runs+
‘\nCurrent best: ‘+
(bestLen/1000).toFixed(2)+
‘ km’,
true
);
let pass=0;
/*
Repeated variable-depth passes within each run.
*/
while(
performance.now()<runEnd
){
const r=
improveByLK(
current,
Math.min(
runEnd,
performance.now()+
Math.max(
2500,
Math.floor(
totalBudget/runs/5
)
)
),
maxDepth
);
currentLen=
r.length;
if(
!r.changed
)
break;
pass++;
if(
currentLen<
bestLen-1e-6
){
best=
current.slice();
bestLen=
currentLen;
post(
‘Improved best: ‘+
(bestLen/1000).toFixed(2)+
‘ km — run ‘+
(runNo+1)+
‘ pass ‘+
pass,
true
);
}
}
totalMoves=
moves;
post(
‘Run ‘+
(runNo+1)+
‘ complete\nBest distance: ‘+
(bestLen/1000).toFixed(2)+
‘ km’,
true
);
}
/*
Final backtracking polish.
*/
if(
performance.now()<deadline
){
post(
‘Final backtracking polish…’,
true
);
let end=
Math.min(
deadline,
performance.now()+
Math.min(
30000,
totalBudget/5
)
);
let changed=true;
while(
changed&&
performance.now()<end
){
const r=
improveByLK(
best,
end,
maxDepth
);
bestLen=
r.length;
changed=
r.changed;
if(changed)
best=
best.slice();
}
}
/*
Exhaustive 2-opt polishing.
NO TIME LIMIT:
2-opt continues until it reaches a 2-opt local optimum.
The Web Worker can still be cancelled using the Cancel
Optimization button, so the main page remains usable.
*/
let twoOptMoves=0;
post(
‘Exhaustive 2-opt polishing — running until no improving 2-opt move remains…’,
true
);
const twoOpt=
await exhaustiveTwoOptParallel(
best,
Infinity
);
twoOptMoves=
twoOpt.moves;
bestLen=
cycleLength(best);
post(
‘2-opt complete — ‘+
twoOptMoves+
‘ improving moves. ‘+
‘Final distance: ‘+
(bestLen/1000).toFixed(2)+
‘ km’,
true
);
/*
Return only the original delivery node IDs.
*/
const order=
new Int32Array(N);
for(
let i=1;
i<M-1;
i++
){
order[i-1]=
best[i];
}
const ms=
performance.now()-
began;
self.postMessage(
{
type:’done’,
lat:e.data.lat,
lon:e.data.lon,
order:order.buffer,
count:N,
initial:initialLen,
final:bestLen,
improvement:
initialLen
?100*
(initialLen-bestLen)/
initialLen
:0,
ms,
runs,
moves:totalMoves,
twoOptMoves,
maxDepth
},
[
e.data.lat,
e.data.lon,
order.buffer
]
);
}
catch(err){
self.postMessage(
{
type:’error’,
message:
‘Optimization failed: ‘+
(
err&&err.message
?err.message
:String(err)
)
}
);
}
};
}
})();
</script>
</div>