-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.html
More file actions
404 lines (393 loc) · 22.1 KB
/
Copy pathindex.html
File metadata and controls
404 lines (393 loc) · 22.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>STR1DE — cover every street</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css"/>
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Spline+Sans+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{--bg:#0d0f0a;--panel:#15180f;--paper:#eae6d5;--line:#2c3120;--accent:#c6f04a;--accent2:#ff6b3d;--muted:#7c8163}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{font-family:"Spline Sans Mono",monospace;background:var(--bg);color:var(--paper);overflow:hidden}
#app{display:grid;grid-template-columns:370px 1fr;height:100vh}
@media(max-width:760px){#app{grid-template-columns:1fr;grid-template-rows:auto 1fr}aside{max-height:46vh}}
aside{background:var(--panel);border-right:1px solid var(--line);display:flex;flex-direction:column;overflow-y:auto}
.brand{padding:24px 24px 16px;border-bottom:1px solid var(--line)}
.brand h1{font-family:"Archivo Black",sans-serif;font-size:34px;letter-spacing:-1px;line-height:.9}
.brand h1 span{color:var(--accent)}
.brand p{font-size:11px;color:var(--muted);margin-top:8px;letter-spacing:.5px;line-height:1.5}
form{padding:20px 24px;display:flex;flex-direction:column;gap:16px}
label{display:block;font-size:10px;letter-spacing:1.5px;text-transform:uppercase;color:var(--muted);margin-bottom:7px}
input[type=text],select{width:100%;background:var(--bg);border:1px solid var(--line);color:var(--paper);padding:11px 12px;font-family:inherit;font-size:13px;border-radius:2px}
input[type=text]:focus,select:focus{outline:none;border-color:var(--accent)}
.row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.slider-val{color:var(--accent);float:right;letter-spacing:0}
input[type=range]{width:100%;accent-color:var(--accent)}
input[type=range]:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
.toggle-line{display:flex;align-items:center;gap:10px;cursor:pointer}
.toggle-line input{accent-color:var(--accent);width:16px;height:16px}
.toggle-line span{font-size:12px}
button.go{background:var(--accent);color:#111;border:none;padding:14px;font-family:"Archivo Black",sans-serif;font-size:14px;letter-spacing:1px;cursor:pointer;border-radius:2px;text-transform:uppercase;transition:transform .08s}
button.go:hover{transform:translateY(-1px)}button.go:disabled{opacity:.5;cursor:wait;transform:none}
button.go:focus-visible{outline:2px solid var(--paper);outline-offset:3px}
.hint{font-size:10px;color:var(--muted);line-height:1.5;border-left:2px solid var(--accent2);padding-left:10px}
.attrib{font-size:9px;color:var(--muted);line-height:1.6;padding:0 24px 18px;opacity:.75}
.attrib a{color:var(--muted);text-decoration:underline}
.stats{margin-top:auto;border-top:1px solid var(--line);padding:18px 24px;display:none}
.stats.show{display:block}
.stats h2{font-size:10px;letter-spacing:2px;color:var(--muted);text-transform:uppercase;margin-bottom:12px;display:flex;justify-content:space-between;align-items:center}
.dlall{background:var(--accent2);color:#fff;border:none;font-size:9px;letter-spacing:1px;padding:5px 9px;border-radius:2px;cursor:pointer;text-transform:uppercase;font-family:inherit}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px 14px}
.metric .n{font-family:"Archivo Black",sans-serif;font-size:22px;color:var(--accent)}
.metric .l{font-size:9px;color:var(--muted);letter-spacing:1px;text-transform:uppercase}
.runlist{margin-top:14px;max-height:170px;overflow-y:auto;display:flex;flex-direction:column;gap:2px}
.run{display:flex;align-items:center;gap:8px;font-size:11px;padding:5px 6px;border-radius:2px;cursor:pointer}
.run:hover{background:var(--bg)}
.sw{width:18px;height:4px;border-radius:2px;flex:0 0 auto}
.run .mi{margin-left:auto;color:var(--muted)}
.run .gpx{color:var(--accent);text-decoration:none;font-size:10px;border:1px solid var(--line);padding:1px 5px;border-radius:2px}
.run .gpx:hover{border-color:var(--accent)}
.ztag{font-size:9px;color:var(--muted);border:1px solid var(--line);padding:1px 4px;border-radius:2px}
#map{height:100vh}.leaflet-container{background:#0d0f0a}
@media(max-width:760px){#map{height:54vh}}
.badge{position:absolute;top:16px;right:16px;z-index:500;background:var(--panel);border:1px solid var(--line);color:var(--accent2);font-size:10px;letter-spacing:1px;padding:6px 11px;border-radius:2px;text-transform:uppercase}
.start-pin{background:var(--accent);width:14px;height:14px;border-radius:50%;border:3px solid #111;box-shadow:0 0 0 2px var(--accent)}
.spin{display:inline-block;width:12px;height:12px;border:2px solid #111;border-top-color:transparent;border-radius:50%;animation:s .6s linear infinite;vertical-align:-1px;margin-right:6px}
@keyframes s{to{transform:rotate(360deg)}}
@media(prefers-reduced-motion:reduce){.spin{animation:none}}
</style>
</head>
<body>
<div id="app">
<aside>
<div class="brand">
<h1>STR<span>1</span>DE</h1>
<p>COVER EVERY STREET. REPEAT NONE.<br>Runs computed in your browser.</p>
</div>
<form id="f" onsubmit="return false">
<div>
<label>Start address or place</label>
<input type="text" id="addr" placeholder="123 Main St, Brooklyn NY" value="Cooper Square, Manhattan"/>
</div>
<div>
<label>Max run length <span class="slider-val" id="mlv">5.0 mi</span></label>
<input type="range" id="ml" min="1" max="13" step="0.5" value="5"/>
</div>
<div>
<label>Coverage radius <span class="slider-val" id="rv">1.2 mi</span></label>
<input type="range" id="rad" min="0.4" max="3" step="0.1" value="1.2"/>
</div>
<div class="row">
<div><label>Surface</label>
<select id="net">
<option value="walk">Walk / paths</option>
<option value="drive">Roads only</option>
<option value="bike">Bike network</option>
</select></div>
<div><label>Zones</label>
<select id="clu">
<option value="false">One big loop</option>
<option value="true">Cluster by area</option>
</select></div>
</div>
<div>
<label>Route quality</label>
<select id="quality">
<option value="fast" selected>Fast — quicker solve</option>
<option value="best">Best — shortest routes</option>
</select>
</div>
<label class="toggle-line"><input type="checkbox" id="ele"/><span>Include elevation (Open-Elevation; slower)</span></label>
<label class="toggle-line"><input type="checkbox" id="demo"/><span>Demo mode (offline sample)</span></label>
<button class="go" id="go">Plan my runs</button>
<p class="hint" id="hint">Enter a place, pick a max distance, and STR1DE builds a set of runs that cover every street with no repeats. Everything runs locally — your location never leaves the browser.</p>
</form>
<div class="stats" id="stats">
<h2>Coverage plan <button class="dlall" id="dlall">Download all GPX</button></h2>
<div class="grid2">
<div class="metric"><div class="n" id="m-runs">–</div><div class="l">Runs</div></div>
<div class="metric"><div class="n" id="m-cov">–</div><div class="l">Miles run</div></div>
<div class="metric"><div class="n" id="m-bare">–</div><div class="l">Street miles</div></div>
<div class="metric"><div class="n" id="m-over">–</div><div class="l">Repeat overhead</div></div>
<div class="metric"><div class="n" id="m-climb">–</div><div class="l">Total climb</div></div>
</div>
<div class="runlist" id="runlist"></div>
</div>
<p class="attrib">
Map data © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors,
ODbL. Geocoding by <a href="https://nominatim.openstreetmap.org/">Nominatim</a>, streets via the
<a href="https://overpass-api.de/">Overpass API</a>, tiles by <a href="https://carto.com/attributions">CARTO</a>.
These are volunteer-run services — STR1DE caches results locally and rate-limits itself.
</p>
</aside>
<div style="position:relative">
<div class="badge" id="badge">ready</div>
<div id="map"></div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<script src="./coverage_core.js"></script>
<script src="./net_cache.js"></script>
<script>
const M_PER_MI = 1609.344;
const map = L.map('map',{zoomControl:true}).setView([40.73,-73.99],13);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',{subdomains:'abcd',maxZoom:19,
attribution:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'}).addTo(map);
let layers=[], startMarker=null, current=null;
const ml=document.getElementById('ml'),mlv=document.getElementById('mlv');
const rad=document.getElementById('rad'),rv=document.getElementById('rv');
ml.oninput=()=>mlv.textContent=(+ml.value).toFixed(1)+' mi';
rad.oninput=()=>rv.textContent=(+rad.value).toFixed(1)+' mi';
const badge=t=>document.getElementById('badge').textContent=t;
/* The hint doubles as the error panel. Capture its resting state at load so an
* error can be cleared — otherwise a failed plan leaves red text sitting there
* through every later success. */
const hintEl=document.getElementById('hint');
const HINT_DEFAULT=hintEl.textContent;
function showError(msg){ hintEl.textContent=msg; hintEl.style.borderColor='var(--accent2)'; }
function clearError(){ hintEl.textContent=HINT_DEFAULT; hintEl.style.borderColor=''; }
/* ---- Overpass network filters ---- */
const FILTERS={
walk:'["highway"]["highway"!~"motorway|motorway_link|trunk|trunk_link|construction|proposed|raceway"]["foot"!~"no"]',
drive:'["highway"~"motorway|trunk|primary|secondary|tertiary|unclassified|residential|living_street|service"]',
bike:'["highway"]["bicycle"!~"no"]["highway"!~"motorway|motorway_link|footway|steps|construction|proposed"]'
};
/* ---- courtesy layer for the public OSM services ----
* Overpass and Nominatim are volunteer-run. Every response is cached locally so
* a repeat plan costs no request, and a cooldown caps how fast the button can
* spend one. Note that a browser cannot set User-Agent (it is a forbidden
* header, and a custom one would force a CORS preflight — two requests instead
* of one), so the app identifies itself by the Referer the browser sends
* automatically, which is what the Nominatim policy accepts. */
const NC=window.NetCache;
const COOLDOWN_MS=NC.DEFAULT_COOLDOWN_MS;
/* localStorage throws in private mode and when storage is disabled; degrade to
* an in-memory cache rather than losing the app. */
function safeStore(){
try{ const p='stride:probe'; localStorage.setItem(p,'1'); localStorage.removeItem(p); return localStorage; }
catch(e){ const m=new Map(); return {getItem:k=>m.has(k)?m.get(k):null,setItem:(k,v)=>{m.set(k,v);},removeItem:k=>{m.delete(k);}}; }
}
const cache=NC.makeCache(safeStore());
/* Read from storage, not a variable, so reloading the page cannot reset the
* cooldown and let someone hammer Overpass with F5. */
const lastFetchAt=()=>cache.getLastFetchAt();
/* One geocode per explicit submit — never per keystroke. Nominatim's policy
* forbids client-side autocomplete outright. */
async function geocode(q){
const hit=cache.getGeocode(q);
if(hit) return hit;
const u='https://nominatim.openstreetmap.org/search?format=json&limit=1&q='+encodeURIComponent(q);
const r=await fetch(u,{headers:{'Accept':'application/json'}});
if(!r.ok) throw new Error('Geocoder is busy (Nominatim '+r.status+'). Wait a moment and retry.');
const j=await r.json();
if(!j.length) throw new Error('Address not found. Try adding a city or state.');
const ll=[parseFloat(j[0].lat),parseFloat(j[0].lon)];
cache.putGeocode(q,ll);
return ll;
}
/* Public Overpass instances, tried STRICTLY IN ORDER and never raced. Racing
* them would double the load on volunteer infrastructure to save one user a few
* seconds — exactly the trade this app exists not to make. A mirror is only
* contacted after the one before it has already failed. */
const OVERPASS_ENDPOINTS=[
'https://overpass-api.de/api/interpreter',
'https://overpass.kumi.systems/api/interpreter'
];
/* Overloaded public servers are the single most likely failure, so say what to
* do about it rather than just reporting a number. */
function overpassMessage(status){
if(status===429) return 'OpenStreetMap is rate-limiting this connection. Wait a minute before planning again.';
if(status===502||status===503||status===504)
return 'OpenStreetMap’s public servers are overloaded — every mirror timed out. Try again in a minute, '+
'or pull the coverage radius down: a smaller area is a far cheaper query and usually gets through.';
if(status===400) return 'OpenStreetMap rejected the query. That’s a bug in STR1DE, not something you did.';
return 'OpenStreetMap returned an error (Overpass '+status+'). Try again in a moment.';
}
async function overpass(lat,lon,radiusM,net,onRetry){
const area={lat,lon,radiusM,net};
const hit=cache.getOverpass(area);
if(hit) return {data:hit,cached:true};
/* The cooldown is enforced here, at the network boundary, not on the button —
* so a cached area can always be replanned instantly. */
const left=NC.cooldownRemaining(Date.now(),lastFetchAt(),COOLDOWN_MS);
if(left>0) throw new Error('Cooling down — '+Math.ceil(left/1000)+'s before the next Overpass request.');
const flt=FILTERS[net]||FILTERS.walk;
/* `out skel` drops tags and metadata. graphFromOverpass reads only
* id/lat/lon/nodes, and the highway filtering already happened server-side, so
* every tag byte was downloaded and discarded: 7.3 MB -> 4.1 MB raw on the
* default area (0.81 -> 0.57 MB gzipped) and half the JSON to parse, for a
* byte-identical plan. Deliberately NOT `qt`: quadtile ordering reorders
* elements, which changes the Euler circuit's start and the run boundaries. */
const q=`[out:json][timeout:60];(way${flt}(around:${radiusM},${lat},${lon}););(._;>;);out skel;`;
cache.noteFetch(Date.now()); // cooldown starts at the attempt, so a 429 backs off too
let lastMsg=null;
for(let i=0;i<OVERPASS_ENDPOINTS.length;i++){
if(i){ onRetry&&onRetry(); await new Promise(s=>setTimeout(s,1500)); }
let r;
try{
r=await fetch(OVERPASS_ENDPOINTS[i],{method:'POST',body:'data='+encodeURIComponent(q)});
}catch(e){
lastMsg='Couldn’t reach OpenStreetMap. Check your connection and try again.';
continue; // network-level failure: the mirror is worth a try
}
if(r.ok){
const data=await r.json();
/* A 200 is not proof of a usable answer. Regional instances happily return
* an empty result for anywhere outside their extract (measured: one mirror
* answers Manhattan with 0 elements in 3.6s), which would surface as
* "no streets found" instead of a plan. Treat empty as a failure while
* another endpoint remains — a genuinely empty area then still reports
* empty once the last one agrees. */
if(data.elements&&data.elements.length===0&&i+1<OVERPASS_ENDPOINTS.length){
lastMsg='OpenStreetMap returned no data for that area.';
continue;
}
cache.putOverpass(area,data); // oversize areas simply are not cached
return {data,cached:false};
}
lastMsg=overpassMessage(r.status);
// a rejected query fails identically everywhere; don't spend a mirror on it
if(r.status>=400&&r.status<500&&r.status!==429) break;
}
throw new Error(lastMsg||'OpenStreetMap is unavailable. Try again in a moment.');
}
/* ---- worker ---- */
let worker=null;
function solveInWorker(payload){
return new Promise((res,rej)=>{
worker&&worker.terminate();
worker=new Worker('./worker.js');
worker.onmessage=e=>{
const d=e.data;
if(d.phase==='building') badge('building graph…');
else if(d.phase==='elevation') badge('fetching elevation…');
else if(d.phase==='elevation_failed') badge('elevation unavailable — continuing');
else if(d.phase==='solving') badge('planning runs…');
else if(d.phase==='done') res(d.result);
else if(d.error) rej(new Error(d.error));
};
worker.onerror=err=>rej(new Error(err.message||'worker failed'));
worker.postMessage(payload);
});
}
/* ---- GPX ---- */
function downloadGPX(run){
const gpx=window.CoverageCore.runToGPX(run,'STR1DE Run '+run.id);
const blob=new Blob([gpx],{type:'application/gpx+xml'});
const a=document.createElement('a');
a.href=URL.createObjectURL(blob); a.download='stride_run_'+run.id+'.gpx';
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href);
}
function downloadAll(){ if(!current) return; current.runs.forEach((r,i)=>setTimeout(()=>downloadGPX(r),i*250)); }
document.getElementById('dlall').onclick=downloadAll;
/* ---- render ---- */
function render(data,label){
current=data;
layers.forEach(l=>map.removeLayer(l)); layers=[];
if(startMarker) map.removeLayer(startMarker);
const all=[];
data.runs.forEach((r,i)=>{
const line=L.polyline(r.coords,{color:r.color||hsl(i,data.runs.length),weight:4,opacity:.9}).addTo(map);
layers.push(line); r.coords.forEach(p=>all.push(p));
});
if(data.center){startMarker=L.marker(data.center,{icon:L.divIcon({className:'',html:'<div class="start-pin"></div>',iconSize:[14,14]})}).addTo(map);}
if(all.length) map.fitBounds(L.latLngBounds(all).pad(0.1));
const s=data.stats;
document.getElementById('m-runs').textContent=s.n_runs;
document.getElementById('m-cov').textContent=s.covered_mi;
document.getElementById('m-bare').textContent=s.bare_mi;
document.getElementById('m-over').textContent='+'+s.overhead_pct+'%';
const FT=3.28084;
document.getElementById('m-climb').textContent = s.total_gain_m!=null ? Math.round(s.total_gain_m*FT).toLocaleString()+' ft' : '–';
const list=document.getElementById('runlist'); list.innerHTML='';
data.runs.forEach((r,i)=>{
const col=r.color||hsl(i,data.runs.length);
const d=document.createElement('div'); d.className='run';
d.innerHTML='<span class="sw" style="background:'+col+'"></span>Run '+r.id+
' <span class="ztag">Z'+r.zone+'</span><span class="mi">'+r.length_mi+' mi</span>'+
(r.elev_gain_m!=null?'<span class="mi">↑'+Math.round(r.elev_gain_m*3.28084).toLocaleString()+' ft</span>':'')+
'<a class="gpx" href="#">GPX</a>';
d.onmouseenter=()=>layers[i].setStyle({weight:8});
d.onmouseleave=()=>layers[i].setStyle({weight:4});
d.onclick=ev=>{ if(ev.target.classList.contains('gpx')){ev.preventDefault();downloadGPX(r);return;} map.fitBounds(layers[i].getBounds().pad(0.25)); };
list.appendChild(d);
});
document.getElementById('stats').classList.add('show');
badge(label);
}
function hsl(i,n){const h=(i/Math.max(n,1))*360;return 'hsl('+h+',70%,62%)';}
/* ---- fetch cooldown ----
* A hard floor on how often the button can spend an Overpass request. Cache
* hits and demo mode never touch the network, so neither is throttled. */
const goBtn=document.getElementById('go');
const GO_LABEL=goBtn.innerHTML;
let cooldownTimer=null, busy=false;
/* Whether the next click would spend an Overpass request. Both lookups are
* local peeks — no geocoding happens here, so this is safe to call on every
* keystroke and cooldown tick. */
function wouldUseNetwork(){
if(document.getElementById('demo').checked) return false;
const ll=cache.peekGeocode(document.getElementById('addr').value);
if(!ll) return true;
return !cache.hasOverpass({lat:ll[0],lon:ll[1],
radiusM:(+rad.value)*M_PER_MI,net:document.getElementById('net').value});
}
function refreshCooldown(){
if(busy) return;
const left=NC.cooldownRemaining(Date.now(),lastFetchAt(),COOLDOWN_MS);
if(left<=0){
if(cooldownTimer){ clearInterval(cooldownTimer); cooldownTimer=null; }
goBtn.disabled=false; goBtn.innerHTML=GO_LABEL; return;
}
if(wouldUseNetwork()){ goBtn.disabled=true; goBtn.innerHTML='Cooling down · '+Math.ceil(left/1000)+'s'; }
else { goBtn.disabled=false; goBtn.innerHTML=GO_LABEL; } // cached areas replan instantly
}
function startCooldown(){
if(cooldownTimer) clearInterval(cooldownTimer);
cooldownTimer=setInterval(refreshCooldown,250);
refreshCooldown();
}
/* Re-evaluate the button when the form changes: switching back to an area that
* is already cached should re-enable it mid-cooldown. Local reads only. */
['addr','rad','net','demo'].forEach(id=>{
const el=document.getElementById(id);
el.addEventListener('input',refreshCooldown);
el.addEventListener('change',refreshCooldown);
});
/* ---- main action ---- */
goBtn.onclick=async function(){
if(document.getElementById('demo').checked){
const r=await fetch('./demo.json'); const d=await r.json();
d.runs.forEach((run,i)=>run.color=run.color||hsl(i,d.runs.length));
render(d,'demo data'); return;
}
if(busy) return; // the cooldown itself is enforced in overpass(), so cached areas still replan
busy=true; goBtn.disabled=true; goBtn.innerHTML='<span class="spin"></span>Working…';
clearError();
try{
badge('finding address…');
const [lat,lon]=await geocode(document.getElementById('addr').value);
badge('fetching streets…');
const {data:osm,cached}=await overpass(lat,lon,(+rad.value)*M_PER_MI,
document.getElementById('net').value, ()=>badge('main server busy · trying mirror…'));
const result=await solveInWorker({
overpass:osm, maxMeters:(+ml.value)*M_PER_MI,
startLat:lat, startLon:lon, cluster:document.getElementById('clu').value==='true',
elevation:document.getElementById('ele').checked,
matchK:window.CoverageCore.MATCH_PRESETS[document.getElementById('quality').value]
});
result.center=[lat,lon];
result.runs.forEach((run,i)=>run.color=hsl(i,result.runs.length));
render(result,cached?'cached · openstreetmap':'live · openstreetmap');
}catch(e){
badge('error'); showError(e.message);
}finally{
busy=false; goBtn.innerHTML=GO_LABEL; goBtn.disabled=false;
if(NC.cooldownRemaining(Date.now(),lastFetchAt(),COOLDOWN_MS)>0) startCooldown();
}
};
</script>
</body>
</html>