PageSourceSearch

https://www.vicbug.app/v2/routing/waypoints.js?v=288

js vicbug.app collected 2026-10-02 00:26:33 UTC 33,149 bytes, 852 lines download raw bytes

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.