1import { requestCurrentLocation, locationErrorMessage } from './current-location.mjs?v=288'; 2import { trackParts } from '../io/track-geometry.js?v=288'; 3import { GPX_FIELDS, clearImportedLeg, splitImportedLeg, routeHandlePoints, routeInsertionIndex } from './route-editing.js?v=288'; 4/** 5 * Waypoint Manager â handles the waypoint array, map markers, drag, and sidebar inputs. 6 */ 7 8import { eventBus } from '../core/event-bus.js?v=288'; 9import { store } from '../core/state.js?v=288'; 10import { config } from '../core/config.js?v=288'; 11import { uid, waypointLetter, escapeHtml } from '../core/utils.js?v=288'; 12import { initSearch, attachSearchToInput } from './search.js?v=288'; 13import { setUserPuckVisible } from '../map/map-manager.js?v=288'; 14 15let map = null; 16let markers = []; 17let routeHandles = []; 18let handleGeneration = 0; 19let waypoints = []; // { id, lngLat: [lng, lat], address: string } 20 21// âââ Public API âââ 22 23export function initWaypoints(_map) { 24 map = _map; 25 26 eventBus.on('route:calculated', ({ routeData }) => renderRouteHandles(routeData)); 27 eventBus.on('route:cleared', clearRouteHandles); 28 eventBus.on('waypoint:changed', clearRouteHandles); 29 eventBus.on('waypoint:gpxSplit', () => renderRouteHandles(store.get('_lastRouteData'))); 30 31 // Map click â add waypoint 32 map.on('click', onMapClick); 33 34 // Wire sidebar action buttons 35 const addStopBtn = document.getElementById('add-stop-btn'); 36 const reverseBtn = document.getElementById('reverse-btn'); 37 const sortBtn = document.getElementById('wp-sort-btn'); 38 const clearBtn = document.getElementById('clear-btn'); 39 const clearRouteBtn = document.getElementById('clear-route-btn'); 40 41 if (addStopBtn) addStopBtn.addEventListener('click', addStop); 42 if (reverseBtn) reverseBtn.addEventListener('click', reverseWaypoints); 43 if (sortBtn) sortBtn.addEventListener('click', reverseWaypoints); 44 if (clearBtn) clearBtn.addEventListener('click', confirmAndClear); 45 if (clearRouteBtn) clearRouteBtn.addEventListener('click', confirmAndClear); 46 47 // Initialize search (Google Places init), then replace the static A/B rows 48 // with dynamically-built ones so the quick-fill icons (Home / Current 49 // location) show from first load. 50 initSearch(); 51 rebuildInputsUI(); 52 53 // Re-render inputs when the saved Home becomes available (after auth) so the 54 // Home quick-fill icon appears. 55 eventBus.on('home:changed', () => rebuildInputsUI()); 56 57 // Listen for search result selections (avoids circular import with search.js) 58 eventBus.on('search:selected', ({ index, lngLat, address }) => { 59 // Grow waypoints array if the index doesn't exist yet (e.g. initial A/B inputs) 60 while (waypoints.length <= index) { 61 waypoints.push({ id: uid(), lngLat: null, address: '', profile: store.get('selectedProfile') || null }); 62 } 63 updateWaypoint(index, lngLat, address); 64 // Pan map to the selected location (no zoom change) 65 if (lngLat && map) { 66 map.panTo(lngLat); 67 } 68 }); 69} 70 71// âââ Komoot-style waypoint popup (shown on map click) âââ 72let _wpPopup = null; 73 74// Context-aware "Set as â¦" actions, shared by the map-click popup and the 75// trail context menu. Badges mirror the on-map markers: 76// start = green A · waypoint = lettered (B/C/Dâ¦) · destination = checkered flag 77export function waypointActionDescriptors() { 78 // No pins yet â "Set as start" (green A). After that â "Set as waypoint" 79 // (blank marker). The last point is always the checkered finish, handled 80 // automatically by renderMarkers â no forced "destination" choice. 81 if (!waypoints.some(wp => wp.lngLat)) { 82 return [{ act: 'start', label: 'Set as start', badge: `<span class="wp-pop-badge start">A</span>` }]; 83 } 84 return [{ act: 'via', label: 'Set as waypoint', badge: `<span class="wp-pop-badge blank"></span>` }]; 85} 86 87export function applyWaypointAction(act, lngLat) { 88 const lng = lngLat.lng !== undefined ? lngLat.lng : lngLat[0]; 89 const lat = lngLat.lat !== undefined ? lngLat.lat : lngLat[1]; 90 addWaypoint([lng, lat]); // always append â finish auto-moves to the new last point 91} 92 93export function showWaypointPopup(lngLat) { 94 closeWaypointPopup(); 95 const lng = lngLat.lng !== undefined ? lngLat.lng : lngLat[0]; 96 const lat = lngLat.lat !== undefined ? lngLat.lat : lngLat[1]; 97 const coordArr = [lng, lat]; 98 99 const actions = waypointActionDescriptors() 100 .map(d => `<button class="wp-pop-btn" data-act="${d.act}">${d.badge}${d.label}</button>`) 101 .join(''); 102 103 const html = ` 104 <div class="wp-pop"> 105 <div class="wp-pop-head">
106 <span class="wp-pop-title">Waypoint</span> 107 <button class="wp-pop-close" data-act="cancel" aria-label="Close"> 108 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg> 109 </button> 110 </div> 111 <div class="wp-pop-actions">${actions}</div> 112 </div>`; 113 114 _wpPopup = new mapboxgl.Popup({ offset: 14, closeButton: false, className: 'wp-popup', maxWidth: '300px' }) 115 .setLngLat(coordArr) 116 .setHTML(html) 117 .addTo(map); 118 119 _wpPopup.getElement().querySelectorAll('[data-act]').forEach((btn) => { 120 btn.addEventListener('click', () => { 121 applyWaypointAction(btn.dataset.act, coordArr); 122 closeWaypointPopup(); 123 }); 124 }); 125} 126 127export function closeWaypointPopup() { 128 if (_wpPopup) { _wpPopup.remove(); _wpPopup = null; } 129} 130 131export function addWaypoint(lngLat, address = '', { silent = false } = {}) { 132 const wp = { id: uid(), lngLat, address, profile: store.get('selectedProfile') || null }; 133 // Clear GPX coords on the previous last waypoint â that segment now needs routing 134 if (waypoints.length > 0 && waypoints[waypoints.length - 1].gpxCoords) { 135 delete waypoints[waypoints.length - 1].gpxCoords; 136 waypoints[waypoints.length - 1].profile = store.get('selectedProfile') || null; 137 } 138 waypoints.push(wp); 139 if (!silent) { 140 store.set('waypoints', [...waypoints]); 141 renderMarkers(); 142 rebuildInputsUI(); 143 if (!address) fillAddressIfEmpty(wp); 144 eventBus.emit('waypoint:added', { waypoint: wp, index: waypoints.length - 1 }); 145 eventBus.emit('waypoint:changed'); 146 } 147} 148 149/** 150 * Reverse-geocode a freshly-dropped waypoint (one with coords but no address) 151 * and write the resulting address into both the waypoint and its input field â 152 * without re-routing. Mirrors what dragging a marker already does. 153 */ 154function fillAddressIfEmpty(wp) { 155 if (!wp || !wp.lngLat || wp.address) return; 156 const location = wp.lngLat; 157 reverseGeocode(location).then((addr) => { 158 if (!addr || wp.address || wp.lngLat !== location) return; 159 const i = waypoints.indexOf(wp); 160 if (i < 0) return; 161 wp.address = addr; 162 store.set('waypoints', [...waypoints]); 163 const input = document.querySelector(`.waypoint-search[data-index="${i}"]`); 164 if (input && !input.value) input.value = addr; 165 }); 166} 167 168/** Flush after silent batch add â updates UI + triggers route calculation once. */ 169export function flushWaypoints() { 170 store.set('waypoints', [...waypoints]); 171 renderMarkers(); 172 rebuildInputsUI(); 173 eventBus.emit('waypoint:changed'); 174} 175 176/** 177 * Insert a waypoint at a specific index (used for adding points on the route line). 178 */ 179export function addWaypointAt(index, lngLat, address = '', { draggedTo } = {}) { 180 const routeProfile = store.get('selectedProfile') || 'lts'; 181 182 // Check if the "before" waypoint has GPX geometry we can split 183 const beforeWp = index > 0 ? waypoints[index - 1] : null; 184 185 let gpxSplit = false; 186 187 if (beforeWp?.gapToNext) return; 188 if (beforeWp && beforeWp.gpxCoords && beforeWp.gpxCoords.length > 1) { 189 const split = splitImportedLeg(beforeWp, lngLat); 190 if (!split) return; 191 const wp = { id: uid(), address, profile: 'free', ...split }; 192 193 waypoints.splice(index, 0, wp); 194 gpxSplit = true; 195 } else { 196 // Non-GPX insertion: use routing profile for the new and after waypoints 197 const wp = { id: uid(), lngLat, address, profile: routeProfile }; 198 199 if (beforeWp && beforeWp.gpxCoords) { 200 delete beforeWp.gpxCoords; 201 } 202 if (index < waypoints.length) { 203 waypoints[index].profile = routeProfile; 204 } 205 206 waypoints.splice(index, 0, wp); 207 } 208 209 if (draggedTo) { 210 invalidateMovedLegs(index); 211 waypoints[index].lngLat = draggedTo; 212 waypoints[index].address = ''; 213 gpxSplit = false; 214 } 215 216 store.set('waypoints', [...waypoints]); 217 renderMarkers(); 218 rebuildInputsUI(); 219 if (!address) fillAddressIfEmpty(waypoints[index]); 220 eventBus.emit('waypoint:added', { waypoint: waypoints[index], index }); 221 222 // GPX split preserves exact route geometry â skip recalculation to keep 223 // the enriched highway colors from the GPX import. 224 // Emit a separate event so undo-redo saves a snapshot without triggering recalc. 225 if (gpxSplit) { 226 eventBus.emit('waypoint:gpxSplit'); 227 } else { 228 eventBus.emit('waypoint:changed'); 229 } 230} 231 232export function removeWaypoint(index) { 233 if (index < 0 || index >= waypoints.length) return; 234 const previous = waypoints[index - 1], removed = waypoints[index], next = waypoints[index + 1]; 235 if (previous && next) { 236 previous.gapToNext = previous.gapToNext || removed.gapToNext || false; 237 if (previous.gpxCoords?.length > 1 && removed.gpxCoords?.length > 1) { 238 for (const key of GPX_FIELDS) { 239 if (previous[key] && removed[key]) previous[key] = [...previous[key].slice(0, -1), ...removed[key]]; 240 else delete previous[key]; 241 } 242 } else { 243 const imported = previous.gpxCoords || removed.gpxCoords; 244 clearImportedLeg(previous); 245 if (imported) next.profile = store.get('selectedProfile') || 'lts'; 246 } 247 } 248 waypoints.splice(index, 1); 249 store.set('waypoints', [...waypoints]); 250 renderMarkers(); 251 rebuildInputsUI();
252 eventBus.emit('waypoint:removed', { waypoint: removed, index }); 253 eventBus.emit('waypoint:changed'); 254} 255 256function invalidateMovedLegs(index) { 257 const wp = waypoints[index], previous = waypoints[index - 1], next = waypoints[index + 1]; 258 const profile = store.get('selectedProfile') || 'lts'; 259 if (previous?.gpxCoords) { clearImportedLeg(previous); wp.profile = profile; } 260 if (wp?.gpxCoords) { clearImportedLeg(wp); if (next) next.profile = profile; } 261} 262 263export function updateWaypoint(index, lngLat, address) { 264 if (index < 0 || index >= waypoints.length) return; 265 waypoints[index].lngLat = lngLat; 266 if (address !== undefined) waypoints[index].address = address; 267 268 invalidateMovedLegs(index); 269 270 store.set('waypoints', [...waypoints]); 271 renderMarkers(); 272 // Rebuild the rows so the row reflects its now-filled state: empty-state 273 // quick-fill icons disappear and the delete / reorder controls appear. 274 rebuildInputsUI(); 275 // Also update mobile search inputs 276 if (index === 0) { 277 const mobileA = document.getElementById('mobile-search-a'); 278 if (mobileA && address !== undefined) mobileA.value = address; 279 } else if (index === 1) { 280 const mobileB = document.getElementById('mobile-search-b'); 281 if (mobileB && address !== undefined) mobileB.value = address; 282 } 283 eventBus.emit('waypoint:updated', { index }); 284 eventBus.emit('waypoint:changed'); 285} 286 287function confirmAndClear() { 288 if (store.get('routeGeometry') || waypoints.length >= 2) { 289 if (!confirm('This will delete the entire route. Are you sure?')) return; 290 } 291 clearWaypoints(); 292} 293 294export function clearWaypoints() { 295 waypoints = []; 296 store.set('waypoints', []); 297 clearMarkers(); 298 rebuildInputsUI(); 299 eventBus.emit('waypoints:cleared'); 300 eventBus.emit('waypoint:changed'); 301} 302 303export function reverseWaypoints() { 304 if (waypoints.length < 2) return; 305 const original = waypoints.map(wp => ({ ...wp })); 306 waypoints.reverse(); 307 for (let i = 0; i < waypoints.length; i++) { 308 const oldIndex = original.length - 1 - i; 309 clearImportedLeg(waypoints[i]); 310 waypoints[i].profile = i ? original[oldIndex + 1].profile : original[oldIndex].profile; 311 const leg = original[oldIndex - 1]; 312 waypoints[i].gapToNext = leg?.gapToNext || false; 313 if (!leg && original[oldIndex].gpxTimes) waypoints[i].gpxTimes = [original[oldIndex].gpxTimes[0]]; 314 if (leg) for (const key of GPX_FIELDS) if (leg[key]) waypoints[i][key] = [...leg[key]].reverse(); 315 } 316 store.set('waypoints', [...waypoints]); 317 renderMarkers(); 318 rebuildInputsUI(); 319 eventBus.emit('waypoint:changed'); 320} 321 322/** Move a waypoint up (dir = -1) or down (dir = +1) in the order. */ 323export function moveWaypoint(index, dir) { 324 const j = index + dir; 325 if (index < 0 || index >= waypoints.length || j < 0 || j >= waypoints.length) return; 326 const rerouteEnds = []; 327 for (const end of new Set([index, index + 1, j, j + 1])) { 328 if (end > 0 && end < waypoints.length && waypoints[end - 1].gpxCoords) { 329 clearImportedLeg(waypoints[end - 1]); 330 rerouteEnds.push(end); 331 } 332 } 333 [waypoints[index], waypoints[j]] = [waypoints[j], waypoints[index]]; 334 for (const end of rerouteEnds) waypoints[end].profile = store.get('selectedProfile') || 'lts'; 335 store.set('waypoints', [...waypoints]); 336 renderMarkers(); 337 rebuildInputsUI(); 338 eventBus.emit('waypoint:changed'); 339} 340 341export function getWaypoints() { 342 return waypoints; 343} 344 345/** 346 * After GPX enrichment, store per-point road class data on each waypoint. 347 * This allows buildFreeRoute to recreate colored features when the route 348 * is recalculated (e.g. after dragging a waypoint). 349 */ 350export function enrichWaypointGPXData(pointClasses, pointSurfaces, pointInfra, pointLts, pointAccess) { 351 let offset = 0; 352 for (const wp of waypoints) { 353 if (!wp.gpxCoords || wp.gpxCoords.length === 0) continue; 354 const len = wp.gpxCoords.length; 355 if (wp.gapToNext) { offset += len - 1; continue; } 356 wp.gpxRoadClasses = pointClasses.slice(offset, offset + len); 357 wp.gpxSurfaces = pointSurfaces.slice(offset, offset + len); 358 wp.gpxBikeInfra = pointInfra.slice(offset, offset + len); 359 if (pointLts) wp.gpxLts = pointLts.slice(offset, offset + len); 360 if (pointAccess) wp.gpxBikePathAccess = pointAccess.slice(offset, offset + len); 361 // Next waypoint's gpxCoords overlaps by 1 point at the boundary 362 offset += len - 1; 363 } 364} 365 366export function clearWaypointProfiles() { 367 for (const wp of waypoints) { 368 wp.profile = null; 369 } 370 store.set('waypoints', [...waypoints]); 371} 372 373/** 374 * Restore waypoints from a snapshot (used by undo/redo). 375 * Skips emitting waypoint:changed to avoid recursive saves. 376 */ 377export function restoreWaypoints(snapshot) { 378 clearRouteHandles(); 379 waypoints = snapshot.map(wp => ({ 380 ...structuredClone(wp), 381 lngLat: wp.lngLat ? [...wp.lngLat] : null, 382 profile: wp.profile || null, 383 })); 384 store.set('waypoints', [...waypoints]); 385 renderMarkers(); 386 rebuildInputsUI(); 387} 388 389// âââ Markers âââ 390 391function clearMarkers() {
392 markers.forEach(m => m.remove()); 393 markers = []; 394 // Safety: if a marker is torn down while the puck is hidden (e.g. the 395 // re-render that follows a drag), mouseleave never fires on the now- 396 // removed element â restore the puck so it can't get stuck hidden. 397 setUserPuckVisible(true); 398} 399 400function renderMarkers() { 401 clearMarkers(); 402 if (!map) return; 403 404 const firstWp = waypoints[0]; 405 const lastWp = waypoints[waypoints.length - 1]; 406 const hasOverlappingEndpoints = waypoints.length > 1 407 && firstWp?.lngLat 408 && lastWp?.lngLat 409 && areSamePoint(firstWp.lngLat, lastWp.lngLat); 410 411 waypoints.forEach((wp, i) => { 412 if (!wp.lngLat) return; // Skip waypoints without coordinates (e.g. blank via stops) 413 414 if (hasOverlappingEndpoints && i === waypoints.length - 1) return; 415 416 const letter = waypointLetter(i); 417 const isFirst = i === 0; 418 const isLast = i === waypoints.length - 1 && waypoints.length > 1; 419 const el = createWaypointMarkerElement({ 420 letter, 421 isFirst, 422 isLast, 423 isCombined: hasOverlappingEndpoints && isFirst, 424 }); 425 426 const marker = new mapboxgl.Marker({ element: el, draggable: true }) 427 .setLngLat(wp.lngLat) 428 .addTo(map); 429 430 // Stack waypoint markers ABOVE the user-location puck (z-index:1) so the 431 // draggable marker is always the topmost hit target â Mapbox's drag 432 // handlers then receive the mousedown directly even when the puck 433 // overlaps. The element keeps Mapbox's inline transform; z-index is 434 // independent of it. 435 el.style.zIndex = '10'; 436 437 // Hide the user-location puck while the cursor is over (or dragging) 438 // this marker so it can never intercept the grab â mirrors Ride with 439 // GPS. mouseleave (not dragend) restores it, so it stays hidden for 440 // the whole interaction even if the cursor briefly leaves mid-drag. 441 el.addEventListener('mouseenter', () => setUserPuckVisible(false)); 442 el.addEventListener('mouseleave', () => setUserPuckVisible(true)); 443 marker.on('dragstart', () => setUserPuckVisible(false)); 444 445 marker.on('dragend', () => { 446 const newLngLat = marker.getLngLat(); 447 const coords = [newLngLat.lng, newLngLat.lat]; 448 setUserPuckVisible(true); 449 const index = waypoints.findIndex(point => point.id === wp.id); 450 if (index < 0) return; 451 // Commit immediately; the address lookup must never delay or reorder edits. 452 if (hasOverlappingEndpoints && isFirst) { 453 const last = waypoints.length - 1; 454 invalidateMovedLegs(last); 455 waypoints[last].lngLat = coords; 456 waypoints[last].address = ''; 457 } 458 updateWaypoint(index, coords, ''); 459 fillAddressIfEmpty(waypoints[index]); 460 }); 461 462 markers.push(marker); 463 }); 464} 465 466function clearRouteHandles() { 467 handleGeneration++; 468 routeHandles.forEach(marker => marker.remove()); 469 routeHandles = []; 470} 471 472function renderRouteHandles(routeData) { 473 clearRouteHandles(); 474 if (!map || store.get('viewMode') || !routeData?.coordinates) return; 475 const coords = routeData.coordinates; 476 const generation = handleGeneration; 477 const avoid = waypoints.filter(wp => wp.lngLat).map(wp => wp.lngLat); 478 const handles = trackParts(coords, routeData.trackBreaks).flatMap(part => 479 routeHandlePoints(part.coordinates, [...avoid, part.coordinates[0], part.coordinates.at(-1)]) 480 .map(handle => ({...handle, position:handle.position + part.start}))); 481 for (const point of handles) { 482 const index = routeInsertionIndex(coords, waypoints, point.coordinate, point.position); 483 const el = document.createElement('div'); 484 el.className = 'route-drag-handle'; 485 el.title = 'Drag to reshape route'; 486 el.setAttribute('aria-label', 'Drag to add a route control point'); 487 const marker = new mapboxgl.Marker({ element: el, draggable: true }).setLngLat(point.coordinate).addTo(map); 488 marker.on('dragend', () => { 489 if (generation !== handleGeneration) return; 490 const location = marker.getLngLat(); 491 addWaypointAt(index, point.coordinate, '', { draggedTo: [location.lng, location.lat] }); 492 }); 493 routeHandles.push(marker); 494 } 495} 496 497function createWaypointMarkerElement({ letter, isFirst, isLast, isCombined }) {
498 if (isCombined) return createCombinedStartFinishMarker(); 499 if (isFirst) return createStartMarker(); 500 if (isLast) return createFinishMarker(); 501 502 // Blank mid-route waypoint marker (no letter) 503 const el = document.createElement('div'); 504 el.className = 'route-marker route-marker--via'; 505 return el; 506} 507 508function createStartMarker() { 509 const el = document.createElement('div'); 510 el.className = 'route-marker route-marker--start'; 511 return el; 512} 513 514function createFinishMarker() { 515 const el = document.createElement('div'); 516 el.className = 'route-marker route-marker--finish'; 517 el.style.backgroundImage = `url("${buildFinishMarkerSvg()}")`; 518 el.style.backgroundSize = 'cover'; 519 el.style.backgroundPosition = 'center'; 520 return el; 521} 522 523function createCombinedStartFinishMarker() { 524 const el = document.createElement('div'); 525 el.className = 'route-marker route-marker--start-finish'; 526 el.style.backgroundImage = `url("${buildCombinedStartFinishSvg()}")`; 527 el.style.backgroundSize = 'cover'; 528 el.style.backgroundPosition = 'center'; 529 return el; 530} 531 532function areSamePoint(a, b) { 533 return Math.abs(a[0] - b[0]) < 0.00005 && Math.abs(a[1] - b[1]) < 0.00005; 534} 535 536function buildFinishMarkerSvg() { 537 const svg = ` 538 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> 539 <defs> 540 <pattern id="checker" width="8" height="8" patternUnits="userSpaceOnUse"> 541 <rect width="8" height="8" fill="#ffffff"/> 542 <rect width="4" height="4" fill="#111827"/> 543 <rect x="4" y="4" width="4" height="4" fill="#111827"/> 544 </pattern> 545 </defs> 546 <circle cx="10" cy="10" r="10" fill="url(#checker)"/> 547 </svg> 548 `; 549 return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; 550} 551 552function buildCombinedStartFinishSvg() { 553 const svg = ` 554 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> 555 <defs> 556 <pattern id="checker" width="8" height="8" patternUnits="userSpaceOnUse"> 557 <rect width="8" height="8" fill="#ffffff"/> 558 <rect width="4" height="4" fill="#111827"/> 559 <rect x="4" y="4" width="4" height="4" fill="#111827"/> 560 </pattern> 561 <clipPath id="circleClip"> 562 <circle cx="10" cy="10" r="10"/> 563 </clipPath> 564 </defs> 565 <g clip-path="url(#circleClip)"> 566 <rect width="20" height="20" fill="url(#checker)"/> 567 <polygon points="0,0 20,0 0,20" fill="#22c55e"/> 568 <line x1="0" y1="20" x2="20" y2="0" stroke="#ffffff" stroke-width="1.6"/> 569 </g> 570 </svg> 571 `; 572 return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; 573} 574 575// âââ Sidebar Inputs âââ 576 577function rebuildInputsUI() { 578 const container = document.getElementById('waypoint-inputs'); 579 if (!container) return; 580 581 // Determine how many rows to show (at least 2) 582 const count = Math.max(waypoints.length, 2); 583 584 // Home quick-fill is only offered when a home is set (signed in / synced). 585 const home = store.get('homeData'); 586 const hasHome = !!(home && home.lngLat); 587 588 container.innerHTML = ''; 589 590 for (let i = 0; i < count; i++) { 591 const wp = waypoints[i]; 592 const letter = waypointLetter(i); 593 const isFirst = i === 0; 594 const isLast = i === count - 1; 595 const isVia = !isFirst && !isLast; 596 const letterClass = isFirst ? 'start' : isLast ? 'end' : 'via'; 597 598 const row = document.createElement('div'); 599 row.className = 'waypoint-row'; 600 row.dataset.index = i; 601 602 // Build row HTML 603 let html = `<div class="waypoint-letter ${letterClass}">${letter}</div>`; 604 605 // Connector line (not on last row) 606 if (!isLast) { 607 html += `<div class="waypoint-connector"></div>`; 608 } 609 610 // Input wrapper 611 html += `<div class="input-wrapper">`; 612 html += `<input type="text" class="waypoint-search w-full" placeholder="${isFirst ? 'Start location...' : isLast && !isVia ? 'Destination...' : 'Via...'}" data-index="${i}" value="${escapeHtml(wp?.address || '')}">`; 613 // Quick-fill icons (Home + Current location) shown on empty fields so the 614 // user can see them at a glance â Home only when one is set / signed in. 615 if (!wp?.lngLat) { 616 html += `<div class="wp-quick-actions">`; 617 if (hasHome) { 618 html += `<button class="wp-quick-btn use-home-btn" data-index="${i}" title="Use home address" aria-label="Use home address"> 619 <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg> 620 </button>`; 621 } 622 html += `<button class="use-location-btn" data-index="${i}" title="Use my current location" aria-label="Use my current location"> 623 <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" x2="5" y1="12" y2="12"/><line x1="19" x2="22" y1="12" y2="12"/><line x1="12" x2="12" y1="2" y2="5"/><line x1="12" x2="12" y1="19" y2="22"/><circle cx="12" cy="12" r="7"/><circle cx="12" cy="12" r="3"/></svg> 624 </button>`; 625 html += `</div>`; 626 } 627 html += `<div class="search-results hidden"></div>`; 628 html += `</div>`; 629 630 // Reorder controls â only for real waypoints when there's more than one. 631 const isRealWp = i < waypoints.length && !!wp; 632 if (isRealWp && waypoints.length >= 2) {
633 const canUp = i > 0; 634 const canDown = i < waypoints.length - 1; 635 html += `<div class="waypoint-move"> 636 <button class="waypoint-move-btn" data-move="up" data-index="${i}" title="Move up" aria-label="Move up" ${canUp ? '' : 'disabled'}> 637 <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg> 638 </button> 639 <button class="waypoint-move-btn" data-move="down" data-index="${i}" title="Move down" aria-label="Move down" ${canDown ? '' : 'disabled'}> 640 <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg> 641 </button> 642 </div>`; 643 } 644 645 // Delete button on every real waypoint (lets you remove A, B, or any stop). 646 if (isRealWp) { 647 html += `<button class="waypoint-remove-btn" data-index="${i}" title="Remove waypoint" aria-label="Remove waypoint"> 648 <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg> 649 </button>`; 650 } 651 652 row.innerHTML = html; 653 container.appendChild(row); 654 } 655 656 // Wire move up/down buttons 657 container.querySelectorAll('.waypoint-move-btn').forEach(btn => { 658 btn.addEventListener('click', (e) => { 659 e.preventDefault(); 660 e.stopPropagation(); 661 if (btn.disabled) return; 662 const idx = parseInt(btn.dataset.index, 10); 663 moveWaypoint(idx, btn.dataset.move === 'up' ? -1 : 1); 664 }); 665 }); 666 667 // Wire remove buttons 668 container.querySelectorAll('.waypoint-remove-btn').forEach(btn => { 669 btn.addEventListener('click', (e) => { 670 const idx = parseInt(btn.dataset.index, 10); 671 removeWaypoint(idx); 672 }); 673 }); 674 675 // Wire location buttons 676 container.querySelectorAll('.use-location-btn').forEach(btn => { 677 btn.addEventListener('click', (e) => { 678 e.preventDefault(); 679 e.stopPropagation(); 680 const idx = parseInt(btn.dataset.index, 10); 681 useCurrentLocation(idx, btn); 682 }); 683 }); 684 685 // Wire home buttons 686 container.querySelectorAll('.use-home-btn').forEach(btn => { 687 btn.addEventListener('click', (e) => { 688 e.preventDefault(); 689 e.stopPropagation(); 690 useHome(parseInt(btn.dataset.index, 10)); 691 }); 692 }); 693 694 // Attach search to each input 695 container.querySelectorAll('.waypoint-search').forEach(input => { 696 const idx = parseInt(input.dataset.index, 10); 697 attachSearchToInput(input, idx); 698 }); 699 700 // Show/hide the inline clear button 701 updateClearButtonVisibility(); 702} 703 704function updateClearButtonVisibility() { 705 const clearRouteBtn = document.getElementById('clear-route-btn'); 706 if (!clearRouteBtn) return; 707 const hasAnyPoint = waypoints.some(wp => wp?.lngLat); 708 clearRouteBtn.style.display = hasAnyPoint ? '' : 'none'; 709} 710 711// âââ Add Stop âââ 712 713function addStop() { 714 // Append a blank waypoint after the last one 715 if (waypoints.length < 2) return; // Need at least A and B first 716 const wp = { id: uid(), lngLat: null, address: '' }; 717 waypoints.push(wp); 718 const newIndex = waypoints.length - 1; 719 store.set('waypoints', [...waypoints]); 720 renderMarkers(); 721 rebuildInputsUI(); 722 // Focus the new input 723 setTimeout(() => { 724 const input = document.querySelector(`.waypoint-search[data-index="${newIndex}"]`); 725 if (input) input.focus(); 726 }, 50); 727} 728 729// âââ Map Click Handler âââ 730 731function onMapClick(e) { 732 if (e.originalEvent?.target?.closest?.('.mapboxgl-marker, .route-marker, .route-drag-handle')) return; 733 // The shared menu hides editing actions in view mode. 734 735 // Skip if click hit interactive layers (cycling routes, etc.) 736 // Include dynamic per-segment route layers (route-seg-N-casing/fill) 737 const allLayers = map.getStyle().layers.map(l => l.id); 738 const routeSegLayers = allLayers.filter(id => id.startsWith('route-seg-')); 739 const interactiveLayers = [ 740 'cycling-routes-line', 'cycling-routes-casing', 'routes-hitarea', 741 ...routeSegLayers, 742 'gravel-roads-layer', 'gravel-markers-layer', 'gravel-photos-layer', 743 'quiet-roads-layer', 'quiet-markers-layer', 744 'mapillary-roads-layer', 'mapillary-markers-layer', 745 'closures-hitarea', 'closures-fill', 746 ]; 747 748 const features = map.queryRenderedFeatures(e.point, { layers: interactiveLayers.filter(l => { 749 try { return map.getLayer(l); } catch { return false; } 750 })}); 751 752 // If clicked on the user's own planner route line, open the same context 753 // menu as right-click ("Add control point" listed first when on the 754 // route) â left and right click behave identically on the route. Other 755 // interactive layers (cycling routes, gravel/quiet/mapillary/closures) 756 // still no-op so the map click doesn't fight their own handlers. 757 if (features.length > 0) { 758 const onOwnRoute = features.some(f => 759 f.layer.id.startsWith('route-seg-') 760 ); 761 if (onOwnRoute) { 762 import('../map/user-hazards.js?v=288').then(m => { 763 m.showMapContextMenu(e.lngLat, e.point); 764 }); 765 return; 766 } 767 if (features.some(f => 768 f.layer.id === 'routes-hitarea' || 769 f.layer.id.startsWith('gravel-') || f.layer.id.startsWith('quiet-') || 770 f.layer.id.startsWith('mapillary-') || f.layer.id.startsWith('closures-') 771 )) { 772 return; 773 } 774 } 775 776 // Both mouse buttons use the same actions and placement behaviour. 777 import('../map/user-hazards.js?v=288').then(m => m.showMapContextMenu(e.lngLat, e.point)); 778} 779
780// âââ Reverse Geocode âââ 781 782export async function reverseGeocode(lngLat) { 783 try { 784 const [lng, lat] = lngLat; 785 const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?access_token=${config.mapboxToken}&types=address,poi,place&limit=1`; 786 const resp = await fetch(url, {signal: AbortSignal.timeout(5000)}); 787 if (!resp.ok) throw new Error(`Geocode failed: ${resp.status}`); 788 const data = await resp.json(); 789 if (data.features && data.features.length > 0) { 790 return data.features[0].place_name || ''; 791 } 792 } catch (err) { 793 console.warn('[Waypoints] Reverse geocode failed:', err); 794 } 795 return `${lngLat[1].toFixed(5)}, ${lngLat[0].toFixed(5)}`; 796} 797 798// âââ Use Home âââ 799 800/** Fill a waypoint with the user's saved home address (from the shared store). */ 801function useHome(waypointIndex) { 802 const home = store.get('homeData'); 803 if (!home || !home.lngLat) return; 804 while (waypoints.length <= waypointIndex) { 805 waypoints.push({ id: uid(), lngLat: null, address: '', profile: store.get('selectedProfile') || null }); 806 } 807 updateWaypoint(waypointIndex, home.lngLat, home.address || 'Home'); 808 if (map) map.panTo(home.lngLat); 809} 810 811// âââ Use Current Location âââ 812 813export async function useCurrentLocation(waypointIndex, buttonEl) { 814 if (buttonEl.disabled) return; 815 document.getElementById('waypoint-location-error')?.remove(); 816 const originalHTML = buttonEl.innerHTML; 817 buttonEl.disabled = true; 818 buttonEl.setAttribute('aria-busy', 'true'); 819 buttonEl.innerHTML = '<i class="fa-solid fa-spinner fa-spin" aria-hidden="true"></i>'; 820 try { 821 const lngLat = await requestCurrentLocation(navigator.geolocation); 822 // A route edit or re-render supersedes the pending location request. 823 if (!buttonEl.isConnected) return; 824 while (waypoints.length <= waypointIndex) { 825 waypoints.push({ id: uid(), lngLat: null, address: '', profile: store.get('selectedProfile') || null }); 826 } 827 const fallback = `${lngLat[1].toFixed(5)}, ${lngLat[0].toFixed(5)}`; 828 updateWaypoint(waypointIndex, lngLat, fallback); 829 map?.panTo(lngLat); 830 const waypoint = waypoints[waypointIndex]; 831 // Routing can start immediately; resolving a street name must not hold it up. 832 reverseGeocode(lngLat).then(address => { 833 const index = waypoints.indexOf(waypoint); 834 if (index < 0 || waypoint.lngLat !== lngLat || waypoint.address !== fallback) return; 835 waypoint.address = address; 836 store.set('waypoints', [...waypoints]); 837 const input = document.querySelector(`.waypoint-search[data-index="${index}"]`); 838 if (input) input.value = address; 839 }); 840 } catch (error) { 841 if (!buttonEl.isConnected) return; 842 const message = document.createElement('p'); 843 message.id = 'waypoint-location-error'; 844 message.setAttribute('role', 'alert'); 845 message.textContent = locationErrorMessage(error); 846 document.getElementById('waypoint-inputs').after(message); 847 } finally { 848 buttonEl.disabled = false; 849 buttonEl.removeAttribute('aria-busy'); 850 buttonEl.innerHTML = originalHTML; 851 } 852}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.