PageSourceSearch

https://www.vicbug.app/v2/app.js?v=284

js vicbug.app collected 2026-09-24 13:54:51 UTC 62,253 bytes, 1,571 lines download raw bytes

1import { initRouteLineNames } from './ui/route-line-names.js?v=284';
2import { initRouteRecovery } from './io/route-recovery.js?v=284';
3import { initMobileHandoff } from './io/mobile-handoff.js?v=284';
4import { routeViewportPadding } from './map/route-viewport.js?v=284';
5/**
6 * Route Builder v2 — Entry Point
7 *
8 * Wires all modules together. Each module is self-contained and communicates
9 * via the event bus and central state store.
10 */
11
12import { initRouteLegend } from './ui/route-legend.js?v=284';
13import { eventBus } from './core/event-bus.js?v=284';
14import { store } from './core/state.js?v=284';
15import { config } from './core/config.js?v=284';
16import { initMap, getMap } from './map/map-manager.js?v=284';
17import { initStyleToggle } from './map/map-styles.js?v=284';
18import { addMapLayers, initLayerToggles } from './map/map-layers.js?v=284';
19import { initNavbar } from './ui/navbar.js?v=284';
20import { initProfileModal } from './ui/profile-modal.js?v=284';
21import { initWaypoints, clearWaypointProfiles, getWaypoints, useCurrentLocation, updateWaypoint, reverseWaypoints, addWaypoint, removeWaypoint } from './routing/waypoints.js?v=284';
22import { initRouteEngine } from './routing/route-engine.js?v=284';
23import { fetchValhallaRouteComplete } from './routing/valhalla.js?v=284';
24import { initUndoRedo } from './routing/undo-redo.js?v=284';
25import { initPOI } from './poi/poi-manager.js?v=284';
26import { initPOIDetails }
26 from './poi/poi-details.js?v=284';
27import { initCustomPOIs } from './poi/custom-pois.js?v=284';
28import { initElevationChart, adjustSidebarForElevation } from './ui/elevation-chart.js?v=284';
29import { initGravelPhotoUpload } from './map/gravel-photos.js?v=284';
30import { initTrailPanel } from './panels/trail-panel.js?v=284';
31import { initGarmin, setupGarminListeners, checkGarminCallback } from './io/garmin.js?v=284';
32import { initWeather } from './map/weather-manager.js?v=284';
33import { initCollectiblePois } from './map/collectible-pois.js?v=284';
34import { initUserHazards } from './map/user-hazards.js?v=284';
35import { initEmergencyHazards } from './map/emergency-hazards.js?v=284';
36import { initGPXExport } from './io/gpx-export.js?v=284';
37import { initGPXImport } from './io/gpx-import.js?v=284';
38import { initRouteSave } from './io/route-save.js?v=284';
39import { initRoutePublish } from './io/route-publish.js?v=284';
40import { initRouteShare } from './io/route-share.js?v=284';
41import { initSavedRoutes, loadSavedRouteById } from './io/saved-routes.js?v=284';
42import { initRouteLibrary } from './io/route-library.js?v=284';
43import { initRouteView } from './io/route-view.js?v=284';
44import { initBookmarks } from './io/route-bookmarks.js?v=284';
45import { initMapSearch, attachSearchToInput } from './routing/search.js?v=284';
46import { initOrgApplyModal } from './ui/org-apply-modal.js?v=284';
47import { initOrgManageModal } from './ui/org-manage-modal.js?v=284';
48
49// ─── Initialize Firebase ───
50let db = null;
51let auth = null;
52let functions = null;
53
54try {
55  firebase.initializeApp(config.firebaseConfig);
56  db = firebase.firestore();
57  auth = firebase.auth();
58  functions = firebase.app().functions('australia-southeast1');
59  auth.setPersistence(firebase.auth.Auth.Persistence.LOCAL);
60} catch (e) {
61  console.warn('[v2] Firebase initialization failed:', e.message);
62}
63
64// Export for use by other modules
65export { db, auth, functions };
66
67// ─── Boot ───
68let _v2Booted = false;
69document.addEventListener('DOMContentLoaded', () => {
70  if (_v2Booted) return;
71  _v2Booted = true;
72  console.log('[v2] Route Builder v2 starting...');
73
74  // Expose store for navbar to read userOrgs
75  window.__v2Store = store;
76
77  // 1. Navbar + Profile modal + Org modals
78  initNavbar();
79  initProfileModal();
80  initOrgApplyModal();
81  initOrgManageModal();
82
83  // 2. Map
84  const map = initMap('map');
85
86  // 3. Style toggle (added after map creates)
87  initStyleToggle(map);
88
89  // 4. Sidebar toggle
90  initSidebar();
91
92  // 4b. Right sidebar (layers) toggle
93  initRightSidebar();
94
95  // 5. Legend accordion (open on desktop)
96  initRouteLegend();
97  initRouteLineNames(map);
98
99  // 5b. Sidebar accordions (Route Builder & Saved Routes)
100  initSidebarAccordions();
101
102  // 5c. Auth actions (sign in / sign out)
103  initAuthActions();
104
105  // 6. Auth state listener
106  if (auth) {
107    auth.onAuthStateChanged(async (user) => {
108      store.set('currentUser', user);
109      store.set('isAdmin', false);
110
111      if (user) {
112        try {
113          const userDoc = await db.collection('users').doc(user.uid).get();
114          if (userDoc.exists) {
115            const userData = userDoc.data();
116            const role = userData.role || '';
117            store.set('isAdmin', role === 'admin' || role === 'moderator');
118            store.set('canPublish', role === 'admin' || role === 'moderator' || userData.canPublish === true);
119          }
120        } catch (e) {
121          console.warn('[v2] Failed to check admin status:', e.message);
122        }
123        // Fetch user's org memberships
124        try {
125          const orgsSnap = await db.collection('organisations')
126            .where('ownerId', '==', user.uid)
127            .where('status', '==', 'approved')
128            .get();
129          const userOrgs = [];
130          orgsSnap.forEach(d => {
131            const data = d.data();
132            userOrgs.push({ orgId: d.id, role: 'owner', orgName: data.name, libraryGroupSlug: data.libraryGroupSlug });
133          });
134          // Also check member subcollections by querying invites by email
135          if (user.email) {
136            const allOrgsSnap = await db.collection('organisations').where('status', '==', 'approved').get();
137            for (const orgDoc of allOrgsSnap.docs) {
138              if (userOrgs.find(o => o.orgId === orgDoc.id)) continue;
139              try {
140                const memberDoc = await db.collection('organisations').doc(orgDoc.id)
141                  .collection('members').doc(user.uid).get();
142                if (memberDoc.exists) {
143                  const mData = memberDoc.data();
144                  const orgData = orgDoc.data();
145                  userOrgs.push({ orgId: orgDoc.id, role: mData.role, orgName: orgData.name, libraryGroupSlug: orgData.libraryGroupSlug });
146                }
147              } catch (e) { /* member check failed, skip */ }
148            }
149          }
150          store.set('userOrgs', userOrgs);
151        } catch (e) {
152          console.warn('[v2] Failed to fetch user orgs:', e.message);
153          store.set('userOrgs', []);
154        }
155
156        updateAuthUI(user);
157        // Initialize Garmin integration
158        initGarmin(user);
159      } else {
160        store.set('userOrgs', []);
161        updateAuthUI(null);
162        // Clean up Garmin when logged out
163        initGarmin(null);
164      }
165      eventBus.emit('auth:changed', { user, isAdmin: store.get('isAdmin') });
166
167      // Handle ?loadRoute= param — load a saved route into the builder after auth
168      if (user) {
169        const urlParams = new URLSearchParams(window.location.search);
170        const loadRouteParam = urlParams.get('loadRoute');
171        const libraryRouteId = urlParams.get('libraryRouteId');
172        if (loadRouteParam) {
173          // Store libraryRouteId so publish can offer "Update" instead of new publish
174          if (libraryRouteId) {
175            store.set('editingLibraryRouteId', libraryRouteId);
176          }
177          // Delay to ensure map + routing is initialized
178          setTimeout(() => {
179            loadSavedRouteById(loadRouteParam);
180            // Clean up URL
181            const url = new URL(window.location);
182            url.searchParams.delete('loadRoute');
183            url.searchParams.delete('libraryRouteId');
184            history.replaceState(null, '', url);
185          }, 1500);
186        }
187      }
188    });
189  }
190
191  // 7. Map ready — add layers, wire toggles, init routing
192  let layerTogglesWired = false;
193  let routingInitialized = false;
194  eventBus.on('map:ready', ({ map }) => {
195    console.log('[v2] Map ready — adding layers');
196    addMapLayers(map, db);
197    if (!layerTogglesWired) {
198      initLayerToggles(map);
199      initGravelPhotoUpload(db);
200      layerTogglesWired = true;
201    }
202    if (!routingInitialized) {
203      initWaypoints(map);
204      initRouteEngine(map);
205      initUndoRedo();
206      initPOI(map);
207      initPOIDetails();
208      initCustomPOIs(map);
209      initTrailPanel(map);
210      initElevationChart(map);
211      initGPXExport();
212      initGPXImport();
213      initRouteSave();
214      initRoutePublish();
215      initRouteShare();
216      initSavedRoutes();
217      initRouteLibrary();
218      initRouteView();
219      initBookmarks();
220      initWeather(map);
221      initCollectiblePois(map);
222      initUserHazards(map);
223      initEmergencyHazards(map);
224      initMapSearch(map);
225      initMobileSearchSheet();
226      initRouteAutoFit(map);
227      initRouteRecovery();
228      initMobileHandoff();
229      routingInitialized = true;
230      console.log('[v2] Routing modules initialized');
231    }
232  });
233
234  // 8. Map style changed — re-add layers (sources/layers are lost on style change)
235  //    Skip the initial style load (map:ready handles that)
236  let initialStyleDone = false;
237  eventBus.on('map:styleChanged', ({ style }) => {
238    console.log('[v2] Map style changed to:', style);
239    if (!initialStyleDone) {
240      initialStyleDone = true;
241      return; // map:ready will handle initial layer setup
242    }
243    const map = getMap();
244    if (map) addMapLayers(map, db);
245  });
246
247  // 9. Profile picker (Komoot-style dropdown) + gravel toggle
248  initProfilePicker();
249
250  // 9b. Floating map toolbar (undo/redo, hide route line, Map content + POIs)
251  initMapToolbar();
252
253  // 9c. Sidebar tabs (Builder / My Routes / Collections)
254  initSidebarTabs();
255
256  // 9d. Search Place modal (suggestions: Home / Current location / Saved places)
257  initSearchModal();
258
259  // 10. Garmin integration
260  setupGarminListeners();
261  checkGarminCallback();
262
263  // 11. Profile time comparison badges
264  initProfileComparison();
265
266  // 12. Minimized icon strip controls (profiles + POIs)
267  initMinimizedControls();
268
269  console.log('[v2] Route Builder v2 initialized');
270});
271
272function initRouteAutoFit(map) {
273  // Only fit when the emitter asks for it (loading a saved/library/GPX route).
274  // Interactive edits (adding/dragging waypoints, profile changes) must not
275  // move the camera — the user is zoomed in placing points.
276  eventBus.on('route:calculated', ({ routeData, fit }) => {
277    if (!fit) return;
278    const coords = routeData?.coordinates
279      || routeData?.geometry?.geometry?.coordinates
280      || store.get('routeGeometry')?.geometry?.coordinates;
281
282    if (!coords || coords.length < 2) return;
283
284    let minLng = Infinity;
285    let maxLng = -Infinity;
286    let minLat = Infinity;
287    let maxLat = -Infinity;
288
289    for (const coord of coords) {
290      const lng = coord?.[0];
291      const lat = coord?.[1];
292      if (typeof lng !== 'number' || typeof lat !== 'number') continue;
293      minLng = Math.min(minLng, lng);
294      maxLng = Math.max(maxLng, lng);
295      minLat = Math.min(minLat, lat);
296      maxLat = Math.max(maxLat, lat);
297    }
298
299    if (!Number.isFinite(minLng) || !Number.isFinite(maxLng) || !Number.isFinite(minLat) || !Number.isFinite(maxLat)) {
300      return;
301    }
302
303    map.fitBounds([[minLng, minLat], [maxLng, maxLat]], {
304      padding: routeViewportPadding(map),
305      maxZoom: 15,
306      duration: 900,
307    });
308  });
309}
310
311// ─── Profile Picker ───
312const BIKE_PROFILES = new Set(['lts', 'bike_offroad', 'bike', 'bike_fast', 'bike_shortest']);
313
314const PROFILE_DESCRIPTIONS = {
315  free: 'Straight lines between points — no routing. Use when other profiles won\'t route where you need.',
316  foot: 'Walking/hiking paths and pedestrian zones. Use when a section insists on avoiding a footpath you want.',
317  lts: 'Experimental Stress Free routing. Favours lower-stress links and avoids higher-stress roads where practical while keeping legal connections available.',
318  bike_offroad: 'Prioritises off-road bike paths and shared trails. Avoids busy roads even if it means a longer route. Best for casual and family rides.',
319  bike: 'Treats all bike infrastructure equally — paths, lanes, and sharrows. Happily uses residential streets and quiet roads for shorter routes.',
320  bike_fast: 'Favours on-road bike lanes and direct residential streets over winding shared paths. Prioritises safe, efficient roads while avoiding dangerous ones.',
321  bike_shortest: 'Shortest, most direct route — ignores any rules. Use when you just want the quickest way there.',
322  car: 'Full road network including highways. Use when a section stubbornly avoids a road you want.',
323};
324
325const MODE_DESCRIPTIONS = {
326  whole: 'Changing profile recalculates the entire route.',
327  next_sections: 'Changing profile only affects new waypoints. Existing segments keep their original profile.',
328};
329
330function initProfilePicker() {
331  const picker = document.getElementById('profile-picker');
332  const gravelRow = document.getElementById('gravel-toggle-row');
333  const gravelCheckbox = document.getElementById('gravel-on');
334  const ferryRow = document.getElementById('ferry-toggle-row');
335  const ferryCheckbox = document.getElementById('ferry-on');
336  const descText = document.getElementById('profile-desc-text');
337  if (!picker) return;
338
339  // ── Komoot-style profile dropdown ──
340  const trigger = document.getElementById('profile-trigger');
341  const popover = document.getElementById('profile-popover');
342  const triggerLabel = document.getElementById('profile-trigger-label');
343  const triggerIcon = document.getElementById('profile-trigger-icon');
344
345  // The Routing popover lives inside the sidebar, which has `zoom` + z-index:100 and
346  // therefore forms its own stacking context — trapping the popover below the
347  // elevation bar no matter its own z-index. It's position:fixed and positioned in
348  // viewport coordinates, so reparenting it to <body> (the root stacking context)
349  // doesn't change where it appears, but lets its z-index stack above the elevation bar.
350  if (popover && popover.parentElement !== document.body) {
351    document.body.appendChild(popover);
352  }
353
354  function syncTrigger() {
355    const active = picker.querySelector('.profile-btn.active');
356    if (!active) return;
357    const title = active.querySelector('.profile-row-title')?.textContent || '';
358    const icon = active.querySelector('.profile-row-icon')?.innerHTML || '';
359    if (triggerLabel) triggerLabel.textContent = title;
360    if (triggerIcon && icon) triggerIcon.innerHTML = icon;
361  }
362  function openPopover() {
363    if (!popover || !trigger) return;
364    const r = trigger.getBoundingClientRect();
365    const pw = 380, vw = window.innerWidth;
366    popover.style.top = (r.bottom + 8) + 'px';
367    popover.style.left = Math.min(Math.max(12, r.left), vw - pw - 12) + 'px';
368    popover.classList.remove('hidden');
369    trigger.setAttribute('aria-expanded', 'true');
370  }
371  function closePopover() {
372    if (!popover) return;
373    popover.classList.add('hidden');
374    trigger?.setAttribute('aria-expanded', 'false');
375  }
376  if (trigger) {
377    trigger.addEventListener('click', (e) => {
378      e.stopPropagation();
379      popover.classList.contains('hidden') ? openPopover() : closePopover();
380    });
381  }
382  const popoverClose = document.getElementById('profile-popover-close');
383  if (popoverClose) popoverClose.addEventListener('click', closePopover);
384  document.addEventListener('click', (e) => {
385    if (!popover || popover.classList.contains('hidden')) return;
386    if (!popover.contains(e.target) && !trigger.contains(e.target)) closePopover();
387  });
388
389  // Filters popover (Gravel / Ferries) — opened from the Waypoints footer filters button.
390  const filtersBtn = document.getElementById('wp-filters-btn');
391  const filtersPop = document.getElementById('filters-popover');
392
393  // Same stacking-context fix as the Routing popover: move it out of the zoomed
394  // sidebar (z-index:100) to <body> so its z-index can clear the elevation bar.
395  if (filtersPop && filtersPop.parentElement !== document.body) {
396    document.body.appendChild(filtersPop);
397  }
398  function openFilters() {
399    if (!filtersPop || !filtersBtn) return;
400    const r = filtersBtn.getBoundingClientRect();
401    const pw = 280; // matches CSS width
402    const vw = window.innerWidth;
403    const vh = window.innerHeight;
404    // Open below the button, extending right over the map; clamp to viewport.
405    let left = Math.min(Math.max(12, r.left), vw - pw - 12);
406    let top = r.bottom + 10;
407    // If it would run off the bottom, open above the button instead.
408    if (top + 200 > vh) top = Math.max(12, r.top - 200);
409    filtersPop.style.top = top + 'px';
410    filtersPop.style.left = left + 'px';
411    filtersPop.classList.remove('hidden');
412  }
413  function closeFilters() { filtersPop?.classList.add('hidden'); }
414  const filtersClose = document.getElementById('filters-popover-close');
415  if (filtersClose) filtersClose.addEventListener('click', closeFilters);
416  if (filtersBtn) {
417    filtersBtn.addEventListener('click', (e) => {
418      e.stopPropagation();
419      filtersPop.classList.contains('hidden') ? openFilters() : closeFilters();
420    });
421  }
422  document.addEventListener('click', (e) => {
423    if (!filtersPop || filtersPop.classList.contains('hidden')) return;
424    if (!filtersPop.contains(e.target) && !filtersBtn.contains(e.target)) closeFilters();
425  });
426
427  function updateDescription(profile) {
428    if (descText) descText.textContent = PROFILE_DESCRIPTIONS[profile] || '';
429  }
430
431  function updateToggleVisibility(profile) {
432    const isBike = BIKE_PROFILES.has(profile);
433    const isServerRouted = profile !== 'free';
434    if (gravelRow) gravelRow.classList.toggle('hidden', !isServerRouted);
435    if (ferryRow) ferryRow.classList.toggle('hidden', !isServerRouted);
436  }
437
438  // Button click handler
439  picker.addEventListener('click', (e) => {
440    const btn = e.target.closest('.profile-btn');
441    if (!btn) return;
442
443    const profile = btn.dataset.profile;
444    picker.querySelectorAll('.profile-btn').forEach(b => b.classList.remove('active'));
445    btn.classList.add('active');
446
447    const mode = store.get('profileMode') || 'next_sections';
448
449    store.set('selectedProfile', profile);
450    updateDescription(profile);
451    updateToggleVisibility(profile);
452    syncTrigger();
453    closePopover();
454
455    // Sync mini profile strip
456    document.querySelectorAll('#mini-profiles .mini-profile-btn').forEach(b => {
457      b.classList.toggle('active', b.dataset.profile === profile);
458    });
459
460    if (mode === 'whole') {
461      // Clear all per-waypoint profile overrides and recalculate everything
462      clearWaypointProfiles();
463      eventBus.emit('profile:changed');
464    }
465    // In next_sections mode: just update the active profile — no recalc.
466    // New waypoints will get stamped with this profile when placed.
467  });
468
469  // Gravel checkbox handler
470  if (gravelCheckbox) {
471    gravelCheckbox.addEventListener('change', () => {
472      store.set('gravelOn', gravelCheckbox.checked);
473      eventBus.emit('profile:changed');
474    });
475  }
476
477  // Ferry checkbox handler
478  if (ferryCheckbox) {
479    ferryCheckbox.addEventListener('change', () => {
480      store.set('ferryOn', ferryCheckbox.checked);
481      eventBus.emit('profile:changed');
482    });
483  }
484
485  // Initialize toggle visibility and description based on defaults
486  const defaultProfile = store.get('selectedProfile') || 'lts';
487  updateToggleVisibility(defaultProfile);
488  updateDescription(defaultProfile);
489  // Default profile mode → next_sections (changing profile only affects new waypoints)
490  if (!store.get('profileMode')) store.set('profileMode', 'next_sections');
491  syncTrigger();
492}
493
494// ─── Floating Map Toolbar (Komoot-style) ───
495function initMapToolbar() {
496  // Relocate the POI toggles out of the sidebar into the Map content popover.
497  // Moving the live node preserves all #show-* ids and their wiring.
498  const poiToggles = document.getElementById('poi-toggles');
499  const poiHost = document.getElementById('map-content-poi-host');
500  if (poiToggles && poiHost) poiHost.appendChild(poiToggles);
501
502  // Relocate the right Layers panel + Legend into the Map content popover.
503  // Moving the live nodes preserves all #toggle-* ids and their wiring.
504  const moveInto = (ids, hostId) => {
505    const host = document.getElementById(hostId);
506    if (!host) return;
507    ids.forEach((id) => { const el = document.getElementById(id); if (el) host.appendChild(el); });
508  };
509  moveInto(['toggle-bikepaths', 'toggle-cyclelanes', 'toggle-lts', 'toggle-gravel', 'toggle-quiet'], 'mc-host-route');
510  moveInto(['toggle-hazards', 'toggle-emergency', 'toggle-closures', 'toggle-railway'], 'mc-host-data');
511  moveInto(['toggle-wind', 'wind-time-controls', 'radar-controls'], 'mc-host-weather');
512
513  // Give each relocated layer toggle a real on/off switch element.
514  document.querySelectorAll('#map-content-popover .layer-toggle').forEach((btn) => {
515    if (!btn.querySelector('.mc-switch')) {
516      const sw = document.createElement('div');
517      sw.className = 'mc-switch';
518      btn.appendChild(sw);
519    }
520  });
521
522  // The right sidebar is now empty — hide it and its chevron.
523  const rightSidebar = document.getElementById('right-sidebar');
524  if (rightSidebar) rightSidebar.style.display = 'none';
525  const rightChevron = document.getElementById('right-sidebar-chevron');
526  if (rightChevron) rightChevron.style.display = 'none';
527  // Bottom bar can now use the full right inset.
528  const eb = document.getElementById('elevation-bar');
529  if (eb) eb.style.right = '12px';
530
531  // Collapsible group headers inside the Map content popover.
532  document.querySelectorAll('#map-content-popover .mc-group-header').forEach((h) => {
533    h.addEventListener('click', () => h.parentElement.classList.toggle('open'));
534  });
535
536  const routeTitle = document.getElementById('route-title');
537  routeTitle?.addEventListener('keydown', event => {
538    if (event.key === 'Enter') { event.preventDefault(); routeTitle.blur(); }
539  });
540  routeTitle?.addEventListener('blur', () => {
541    routeTitle.textContent = routeTitle.textContent.trim().slice(0, 160) || 'Untitled';
542    routeTitle.dispatchEvent(new Event('input', { bubbles: true }));
543  });
544  const syncRouteLayout = () => document.body.classList.toggle('has-planned-route', !!store.get('_lastRouteData')?.coordinates?.length);
545  eventBus.on('route:calculated', syncRouteLayout);
546  eventBus.on('route:cleared', () => document.body.classList.remove('has-planned-route'));
547  syncRouteLayout();
548
549  const undoBtn = document.getElementById('undo-btn');
550  const redoBtn = document.getElementById('redo-btn');
551  const tUndo = document.getElementById('toolbar-undo');
552  const tRedo = document.getElementById('toolbar-redo');
553  const tHide = document.getElementById('toolbar-hide-route');
554  const tMapContent = document.getElementById('toolbar-map-content');
555  const tPois = document.getElementById('toolbar-pois');
556
557  // Undo / Redo proxy the existing elevation-bar buttons (keeps all history logic).
558  if (tUndo && undoBtn) tUndo.addEventListener('click', () => undoBtn.click());
559  if (tRedo && redoBtn) tRedo.addEventListener('click', () => redoBtn.click());
560
561  // Mirror enabled state — the source buttons toggle display:none when unavailable.
562  function syncUndoRedo() {
563    if (tUndo && undoBtn) tUndo.disabled = undoBtn.style.display === 'none';
564    if (tRedo && redoBtn) tRedo.disabled = redoBtn.style.display === 'none';
565  }
566  if (undoBtn) new MutationObserver(syncUndoRedo).observe(undoBtn, { attributes: true, attributeFilter: ['style'] });
567  if (redoBtn) new MutationObserver(syncUndoRedo).observe(redoBtn, { attributes: true, attributeFilter: ['style'] });
568  syncUndoRedo();
569
570  // Hide / show the route line (all route-* map layers).
571  let routeHidden = false;
572  function applyRouteVisibility() {
573    const map = getMap();
574    if (!map) return;
575    const vis = routeHidden ? 'none' : 'visible';
576    (map.getStyle().layers || []).forEach((l) => {
577      if (l.id && l.id.startsWith('route-')) {
578        try { map.setLayoutProperty(l.id, 'visibility', vis); } catch (e) { /* layer gone */ }
579      }
580    });
581  }
582  if (tHide) tHide.addEventListener('click', () => {
583    routeHidden = !routeHidden;
584    applyRouteVisibility();
585    tHide.classList.toggle('active', routeHidden);
586    tHide.setAttribute('aria-pressed', String(routeHidden));
587  });
588  // Re-apply after a route re-renders (new segment layers default to visible).
589  eventBus.on('route:calculated', () => { if (routeHidden) setTimeout(applyRouteVisibility, 0); });
590
591  // Lift the bottom-right map controls above the stats bar when a route exists.
592  const mapCtrls = document.getElementById('map-controls');
593  eventBus.on('route:calculated', () => mapCtrls?.classList.add('above-bar'));
594  eventBus.on('route:cleared', () => mapCtrls?.classList.remove('above-bar'));
595
596  // Toolbar dropdowns (POIs + Map content) — each opens below its button,
597  // extends right over the map, and only one is open at a time.
598  function positionUnder(pop, btn, width) {
599    const r = btn.getBoundingClientRect();
600    const vw = window.innerWidth;
601    pop.style.top = (r.bottom + 10) + 'px';
602    pop.style.left = Math.min(Math.max(12, r.left), vw - width - 12) + 'px';
603  }
604  function makeDropdown(btn, pop, closeId, width) {
605    if (!btn || !pop) return null;
606    const close = () => { pop.classList.add('hidden'); btn.classList.remove('active'); btn.setAttribute('aria-expanded', 'false'); };
607    const open = () => { positionUnder(pop, btn, width); pop.classList.remove('hidden'); btn.classList.add('active'); btn.setAttribute('aria-expanded', 'true'); };
608    btn.addEventListener('click', (e) => {
609      e.stopPropagation();
610      const wasOpen = !pop.classList.contains('hidden');
611      closeAllDropdowns();
612      if (!wasOpen) open();
613    });
614    const closeBtn = document.getElementById(closeId);
615    if (closeBtn) closeBtn.addEventListener('click', close);
616    document.addEventListener('click', (e) => {
617      if (pop.classList.contains('hidden')) return;
618      if (!pop.contains(e.target) && !btn.contains(e.target)) close();
619    });
620    return { open, close, btn, pop };
621  }
622  const dropdowns = [];
623  function closeAllDropdowns() { dropdowns.forEach((d) => d.close()); }
624  const poisPop = document.getElementById('pois-popover');
625  const mcPop = document.getElementById('map-content-popover');
626  const tExport = document.getElementById('toolbar-export');
627  const exportPop = document.getElementById('export-popover');
628  const dPois = makeDropdown(tPois, poisPop, 'pois-popover-close', 460);
629  const dMc = makeDropdown(tMapContent, mcPop, 'map-content-close', 380);
630  const dExport = makeDropdown(tExport, exportPop, 'export-popover-close', 320);
631  if (dPois) dropdowns.push(dPois);
632  if (dMc) dropdowns.push(dMc);
633  if (dExport) dropdowns.push(dExport);
634
635  // ── Waypoints footer tools ──
636  // "Search place" opens the existing map location search.
637  const wpSearch = document.getElementById('wp-search-btn');
638  if (wpSearch) wpSearch.addEventListener('click', (e) => {
639    e.stopPropagation();
640    const box = document.getElementById('map-search-box');
641    const input = document.getElementById('map-search-input');
642    if (!box) return;
643    const willOpen = box.classList.contains('hidden');
644    box.classList.toggle('hidden', !willOpen);
645    document.getElementById('ctrl-search')?.classList.toggle('active', willOpen);
646    if (willOpen && input) setTimeout(() => input.focus(), 0);
647  });
648
649  // "Round trip" appends a return-to-start waypoint (and removes it when off).
650  const roundtrip = document.getElementById('roundtrip-on');
651  if (roundtrip) {
652    roundtrip.addEventListener('change', () => {
653      const wps = getWaypoints();
654      if (roundtrip.checked) {
655        const first = wps[0];
656        if (first && first.lngLat) addWaypoint(first.lngLat, first.address || 'Start');
657      } else {
658        const cur = getWaypoints();
659        const n = cur.length;
660        if (n > 2 && JSON.stringify(cur[n - 1].lngLat) === JSON.stringify(cur[0].lngLat)) {
661          removeWaypoint(n - 1);
662        }
663      }
664    });
665  }
666}
667
668// ─── Search Place modal (Komoot-style suggestions) ───
669function initSearchModal() {
670  const box = document.getElementById('map-search-box');
671  if (!box) return;
672  const input = document.getElementById('map-search-input');
673  const suggestions = document.getElementById('search-suggestions');
674  const results = document.getElementById('map-search-results');
675  const back = document.getElementById('search-modal-back');
676
677  function closeModal() {
678    box.classList.add('hidden');
679    document.getElementById('ctrl-search')?.classList.remove('active');
680    if (input) input.value = '';
681    if (results) { results.classList.add('hidden'); results.innerHTML = ''; }
682    if (suggestions) suggestions.style.display = '';
683  }
684  if (back) back.addEventListener('click', closeModal);
685
686  // Reset suggestions visible whenever the modal opens.
687  new MutationObserver(() => {
688    if (!box.classList.contains('hidden') && suggestions) suggestions.style.display = '';
689  }).observe(box, { attributes: true, attributeFilter: ['class'] });
690
691  // Show suggestions when empty, results when typing.
692  if (input) input.addEventListener('input', () => {
693    if (suggestions) suggestions.style.display = input.value.trim() ? 'none' : '';
694  });
695
696  function flyToAndClose(lngLat) {
697    const m = getMap();
698    if (m && lngLat) m.flyTo({ center: lngLat, zoom: 14, duration: 1200 });
699    closeModal();
700  }
701
702  // Current location
703  document.getElementById('sugg-current')?.addEventListener('click', () => {
704    if (!navigator.geolocation) return;
705    navigator.geolocation.getCurrentPosition(
706      (pos) => flyToAndClose([pos.coords.longitude, pos.coords.latitude]),
707      () => {},
708      { enableHighAccuracy: true, timeout: 10000 }
709    );
710  });
711
712  // Home — prefer the Firestore home synced from the iOS/Android app
713  // (users/{uid}/private/home), fall back to a locally-set address.
714  const HOME_KEY = 'vicbug_home';
715  let homeData = null;
716  function renderHome() {
717    const title = document.getElementById('sugg-home-title');
718    const sub = document.getElementById('sugg-home-sub');
719    if (homeData) { if (title) title.textContent = 'Home';
719 if (sub) sub.textContent = homeData.address || ''; }
720    else { if (title) title.textContent = 'Set home address'; if (sub) sub.textContent = ''; }
721  }
722  async function refreshHome() {
723    homeData = null;
724    try {
725      const user = auth?.currentUser;
726      if (user && db) {
727        const snap = await db.collection('users').doc(user.uid).collection('private').doc('home').get();
728        if (snap.exists) {
729          const d = snap.data() || {};
730          if (typeof d.homeLatitude === 'number' && typeof d.homeLongitude === 'number') {
731            homeData = { lngLat: [d.homeLongitude, d.homeLatitude], address: d.homeTitle || 'Home' };
732          }
733        }
734      }
735    } catch (e) { /* permission/offline — fall back */ }
736    if (!homeData) {
737      try { homeData = JSON.parse(localStorage.getItem(HOME_KEY) || 'null'); } catch { homeData = null; }
738    }
739    // Publish so the per-waypoint inputs can offer a "Home" quick-fill icon.
740    store.set('homeData', homeData);
741    eventBus.emit('home:changed', homeData);
742    renderHome();
743  }
744  refreshHome();
745  // Re-read once auth resolves (user may log in after init).
746  eventBus.on('auth:changed', () => refreshHome());
747
748  document.getElementById('sugg-home')?.addEventListener('click', async () => {
749    if (homeData && homeData.lngLat) { flyToAndClose(homeData.lngLat); return; }
750    const addr = window.prompt('Enter your home address:');
751    if (!addr) return;
752    try {
753      const r = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(addr)}.json?access_token=${config.mapboxToken}&country=au&limit=1`);
754      const d = await r.json();
755      const f = d.features?.[0];
756      if (f) {
757        homeData = { lngLat: f.center, address: f.place_name };
758        localStorage.setItem(HOME_KEY, JSON.stringify(homeData));
759        renderHome();
760      }
761    } catch (e) { /* ignore */ }
762  });
763}
764
765// ─── Sidebar tabs (Builder / My Routes / Collections) ───
766function initSidebarTabs() {
767  const tabs = document.querySelectorAll('#sidebar-tabs .sidebar-tab');
768  if (!tabs.length) return;
769  const ids = ['my-routes', 'route-library', 'route-builder'];
770  function activate(target) {
771    ids.forEach((id) => document.getElementById(id)?.classList.toggle('open', id === target));
772    tabs.forEach((t) => t.classList.toggle('active', t.dataset.tab === target));
773  }
774  tabs.forEach((t) => t.addEventListener('click', () => activate(t.dataset.tab)));
775  activate('route-builder');
776}
777
778// ─── Elevation Bar Position ───
779function updateElevationBarPosition() {
780  const bar = document.getElementById('elevation-bar');
781  if (!bar) return;
782  const isMobile = window.innerWidth <= 768;
783  if (isMobile) {
784    // On mobile, elevation bar is full-width bottom sheet (CSS handles positioning)
785    bar.style.left = '';
786    bar.style.right = '';
787  } else {
788    // Like Komoot: the bottom bar starts after the floating sidebar card
789    // (384px = 12 inset + 360 card + 12 gap). The Layers panel is gone
790    // (folded into Map content), so the bar extends to the 12px right inset.
791    const leftOpen = !document.getElementById('sidebar')?.classList.contains('minimized');
792    bar.style.left = leftOpen ? '434px' : '12px';
793    bar.style.right = '12px';
794  }
795}
796
797/** On mobile, auto-minimize the elevation bar when a sidebar expands */
798function minimizeElevationOnMobile() {
799  if (window.innerWidth > 768) return;
800  const bar = document.getElementById('elevation-bar');
801  if (!bar || bar.dataset.minimized === 'true') return;
802  const minBtn = document.getElementById('elevation-minimize-btn');
803  bar.style.height = '56px';
804  bar.dataset.minimized = 'true';
805  if (minBtn) {
806    const svg = minBtn.querySelector('svg');
807    if (svg) svg.style.transform = 'rotate(180deg)';
808  }
809  adjustSidebarForElevation();
810}
811
812// ─── Sidebar Toggle ───
813function initSidebar() {
814  const sidebar = document.getElementById('sidebar');
815  const chevron = document.getElementById('left-sidebar-chevron');
816  if (!sidebar || !chevron) return;
817
818  const isMobile = () => window.innerWidth <= 768;
819
820  // Auto-minimize on mobile
821  if (isMobile()) {
822    sidebar.classList.add('minimized');
823    store.set('sidebarMinimized', true);
824  }
825
826  function updateLeftChevron() {
827    const isMinimized = sidebar.classList.contains('minimized');
828    if (isMobile()) {
829      chevron.style.left = isMinimized ? '50px' : '360px';
830    } else {
831      chevron.style.left = isMinimized ? '0px' : '372px';
832    }
833    chevron.querySelector('svg').style.transform = isMinimized ? 'rotate(180deg)' : '';
834  }
835
836  function toggleLeft() {
837    sidebar.classList.toggle('minimized');
838    const isMinimized = sidebar.classList.contains('minimized');
839    store.set('sidebarMinimized', isMinimized);
840    eventBus.emit('sidebar:toggled', { minimized: isMinimized });
841    if (!isMinimized) minimizeElevationOnMobile();
842    updateLeftChevron();
843    updateElevationBarPosition();
844    adjustSidebarForElevation();
845  }
846
847  chevron.addEventListener('click', toggleLeft);
848
849  // On mobile, clicking an accordion icon in the icon strip should expand the sidebar
850  if (isMobile()) {
851    ['my-routes-toggle', 'route-library-toggle', 'route-builder-toggle'].forEach(id => {
852      const btn = document.getElementById(id);
853      if (!btn) return;
854      btn.addEventListener('click', (e) => {
855        if (sidebar.classList.contains('minimized')) {
856          e.stopImmediatePropagation(); // prevent the accordion toggle from also firing
857          sidebar.classList.remove('minimized');
858          store.set('sidebarMinimized', false);
859          eventBus.emit('sidebar:toggled', { minimized: false });
860          minimizeElevationOnMobile();
861          updateLeftChevron();
862          updateElevationBarPosition();
863          adjustSidebarForElevation();
864
865          // Open the corresponding accordion
866          const accordionId = id.replace('-toggle', '');
867          const accordion = document.getElementById(accordionId);
868          if (accordion) {
869            document.querySelectorAll('#sidebar .header-accordion').forEach(a => a.classList.remove('open'));
870            accordion.classList.add('open');
871          }
872        }
873      }, true); // use capture phase to run before the accordion toggle handler
874    });
875
876    // Initial chevron position
877    updateLeftChevron();
878  }
879
880  // Update chevron whenever sidebar is toggled from anywhere (e.g. mini view controls)
881  eventBus.on('sidebar:toggled', () => updateLeftChevron());
882
883  // Handle window resize
884  let resizeTimeout;
885  window.addEventListener('resize', () => {
886    if (isMobile()) {
887      sidebar.classList.add('minimized');
888      store.set('sidebarMinimized', true);
889      const rightSidebar = document.getElementById('right-sidebar');
890      if (rightSidebar) rightSidebar.classList.add('minimized');
891      updateLeftChevron();
892      updateElevationBarPosition();
893      adjustSidebarForElevation();
894    }
895
896    clearTimeout(resizeTimeout);
897    resizeTimeout = setTimeout(() => {
898      const map = getMap();
899      if (map) {
900        const center = map.getCenter();
901        const zoom = map.getZoom();
902        map.resize();
903        map.jumpTo({ center, zoom });
904      }
905    }, 150);
906  });
907}
908
909// ─── Right Sidebar (Layers) Toggle ───
910function initRightSidebar() {
911  const sidebar = document.getElementById('right-sidebar');
912  const chevron = document.getElementById('right-sidebar-chevron');
913  if (!sidebar || !chevron) return;
914
915  const isMobile = () => window.innerWidth <= 768;
916
917  // Auto-minimize on mobile
918  if (isMobile()) {
919    sidebar.classList.add('minimized');
920  }
921
922  function updateRightChevron() {
923    const isMinimized = sidebar.classList.contains('minimized');
924    if (isMobile()) {
925      chevron.style.right = isMinimized ? '50px' : '200px';
926    } else {
927      chevron.style.right = isMinimized ? '0px' : '200px';
928    }
929    chevron.querySelector('svg').style.transform = isMinimized ? 'rotate(180deg)' : '';
930  }
931
932  function toggleRight() {
933    sidebar.classList.toggle('minimized');
934    const isMinimized = sidebar.classList.contains('minimized');
935    if (!isMinimized) minimizeElevationOnMobile();
936    updateRightChevron();
937    updateElevationBarPosition();
938  }
939
940  chevron.addEventListener('click', toggleRight);
941
942  // On mobile, tapping the minimized right sidebar header expands it
943  if (isMobile()) {
944    const header = sidebar.querySelector('.right-sidebar-header');
945    if (header) {
946      header.addEventListener('click', () => {
947        if (sidebar.classList.contains('minimized')) {
948          sidebar.classList.remove('minimized');
949          minimizeElevationOnMobile();
950          updateRightChevron();
951          updateElevationBarPosition();
952        }
953      });
954    }
955
956    // Initial chevron position
957    updateRightChevron();
958  }
959
960  // Update right chevron position on resize
961  window.addEventListener('resize', () => {
962    if (isMobile() && sidebar.classList.contains('minimized')) {
963      updateRightChevron();
964    }
965  });
966}
967
968// ─── Legend Accordion ───
969// ─── Auth UI Update ───
970function updateAuthUI(user) {
971  const loginPrompt = document.getElementById('login-prompt');
972  const authStatus = document.getElementById('auth-status');
973  const userName = document.getElementById('user-name');
974  const userAvatar = document.getElementById('user-avatar');
975
976  // Navbar auth indicator
977  const navIndicator = document.getElementById('nav-user-indicator');
978  const navAvatar = document.getElementById('nav-user-avatar');
979  const navName = document.getElementById('nav-user-name');
980  const navSignIn = document.getElementById('nav-sign-in');
981
982  if (user) {
983    if (loginPrompt) loginPrompt.classList.add('hidden');
984    if (authStatus) authStatus.classList.remove('hidden');
985    if (userName) userName.textContent = user.displayName || user.email || 'User';
986    if (userAvatar) {
987      const initial = (user.displayName || user.email || '?')[0].toUpperCase();
988      userAvatar.textContent = initial;
989    }
990
991    // Navbar
992    if (navIndicator) navIndicator.classList.remove('hidden');
993    if (navSignIn) navSignIn.classList.add('hidden');
994    if (navAvatar) {
995      const initial = (user.displayName || user.email || '?')[0].toUpperCase();
996      navAvatar.textContent = initial;
997    }
998    if (navName) navName.textContent = user.displayName || user.email || '';
999
1000    // Mobile menu auth
1001    const mobileSignIn = document.getElementById('mobile-menu-sign-in');
1002    const mobileUser = document.getElementById('mobile-menu-user');
1003    const mobileAvatar = document.getElementById('mobile-menu-avatar');
1004    const mobileName = document.getElementById('mobile-menu-user-name');
1005    if (mobileSignIn) mobileSignIn.classList.add('hidden');
1006    if (mobileUser) mobileUser.classList.remove('hidden');
1007    if (mobileAvatar) mobileAvatar.textContent = (user.displayName || user.email || '?')[0].toUpperCase();
1008    if (mobileName) mobileName.textContent = user.displayName || user.email || 'User';
1009  } else {
1010    if (loginPrompt) loginPrompt.classList.remove('hidden');
1011    if (authStatus) authStatus.classList.add('hidden');
1012
1013    // Navbar
1014    if (navIndicator) navIndicator.classList.add('hidden');
1015    if (navSignIn) navSignIn.classList.remove('hidden');
1016
1017    // Mobile menu auth
1018    const mobileSignIn = document.getElementById('mobile-menu-sign-in');
1019    const mobileUser = document.getElementById('mobile-menu-user');
1020    if (mobileSignIn) mobileSignIn.classList.remove('hidden');
1021    if (mobileUser) mobileUser.classList.add('hidden');
1022  }
1023}
1024
1025// ─── Auth Actions (Sign in / Sign out) ───
1026function initAuthActions() {
1027  const signinModal = document.getElementById('signin-modal');
1028
1029  function openSigninModal() {
1030    if (!signinModal) return;
1031    // Reset to initial state
1032    document.getElementById('signin-email-form').style.display = 'none';
1033    document.getElementById('signin-signup-form').style.display = 'none';
1034    document.getElementById('signin-email-error').style.display = 'none';
1035    document.getElementById('signin-signup-error').style.display = 'none';
1036    signinModal.classList.remove('hidden');
1037  }
1038
1039  function closeSigninModal() {
1040    signinModal?.classList.add('hidden');
1041  }
1042
1043  // Close modal
1044  document.getElementById('signin-modal-close')?.addEventListener('click', closeSigninModal);
1045  signinModal?.addEventListener('click', (e) => { if (e.target === signinModal) closeSigninModal(); });
1046
1047  // Google sign-in
1048  document.getElementById('signin-google')?.addEventListener('click', async () => {
1049    if (!auth) return;
1050    try {
1051      const provider = new firebase.auth.GoogleAuthProvider();
1052      provider.setCustomParameters({ prompt: 'select_account' });
1053      await auth.signInWithPopup(provider);
1054      closeSigninModal();
1055    } catch (e) {
1056      if (e.code !== 'auth/popup-closed-by-user') {
1057        console.error('[v2] Google sign-in error:', e);
1058      }
1059    }
1060  });
1061
1062  // Apple sign-in
1063  document.getElementById('signin-apple')?.addEventListener('click', async () => {
1064    if (!auth) return;
1065    try {
1066      const provider = new firebase.auth.OAuthProvider('apple.com');
1067      provider.addScope('email');
1068      provider.addScope('name');
1069      await auth.signInWithPopup(provider);
1070      closeSigninModal();
1071    } catch (e) {
1072      if (e.code !== 'auth/popup-closed-by-user') {
1073        console.error('[v2] Apple sign-in error:', e);
1074      }
1075    }
1076  });
1077
1078  // Email toggle
1079  document.getElementById('signin-email-toggle')?.addEventListener('click', () => {
1080    document.getElementById('signin-email-form').style.display = 'block';
1081    document.getElementById('signin-signup-form').style.display = 'none';
1082  });
1083
1084  // Email sign-in
1085  document.getElementById('signin-email-submit')?.addEventListener('click', async () => {
1086    const email = document.getElementById('signin-email').value.trim();
1087    const password = document.getElementById('signin-password').value;
1088    const errorEl = document.getElementById('signin-email-error');
1089    errorEl.style.display = 'none';
1090
1091    if (!email || !password) {
1092      errorEl.textContent = 'Please enter email and password';
1093      errorEl.style.display = 'block';
1094      return;
1095    }
1096
1097    try {
1098      await auth.signInWithEmailAndPassword(email, password);
1099      closeSigninModal();
1100    } catch (e) {
1101      errorEl.textContent = e.code === 'auth/invalid-credential' ? 'Invalid email or password'
1102        : e.code === 'auth/user-not-found' ? 'No account found with this email'
1103        : e.message;
1104      errorEl.style.display = 'block';
1105    }
1106  });
1107
1108  // Forgot password
1109  document.getElementById('signin-email-forgot')?.addEventListener('click', async () => {
1110    const email = document.getElementById('signin-email').value.trim();
1111    const errorEl = document.getElementById('signin-email-error');
1112    if (!email) {
1113      errorEl.textContent = 'Enter your email first';
1114      errorEl.style.display = 'block';
1115      return;
1116    }
1117    try {
1118      await auth.sendPasswordResetEmail(email);
1119      errorEl.textContent = 'Password reset email sent!';
1120      errorEl.style.color = '#22c55e';
1121      errorEl.style.display = 'block';
1122      setTimeout(() => { errorEl.style.color = '#ef4444'; }, 3000);
1123    } catch (e) {
1124      errorEl.textContent = e.code === 'auth/user-not-found' ? 'No account found' : e.message;
1125      errorEl.style.display = 'block';
1126    }
1127  });
1128
1129  // Toggle to sign-up form
1130  document.getElementById('signin-email-signup-toggle')?.addEventListener('click', () => {
1131    document.getElementById('signin-email-form').style.display = 'none';
1132    document.getElementById('signin-signup-form').style.display = 'block';
1133  });
1134
1135  // Back to sign-in
1136  document.getElementById('signin-signup-back')?.addEventListener('click', () => {
1137    document.getElementById('signin-signup-form').style.display = 'none';
1138    document.getElementById('signin-email-form').style.display = 'block';
1139  });
1140
1141  // Email sign-up
1142  document.getElementById('signin-signup-submit')?.addEventListener('click', async () => {
1143    const name = document.getElementById('signup-name').value.trim();
1144    const email = document.getElementById('signup-email').value.trim();
1145    const password = document.getElementById('signup-password').value;
1146    const errorEl = document.getElementById('signin-signup-error');
1147    errorEl.style.display = 'none';
1148
1149    if (!name || !email || !password) {
1150      errorEl.textContent = 'Please fill in all fields';
1151      errorEl.style.display = 'block';
1152      return;
1153    }
1154    if (password.length < 6) {
1155      errorEl.textContent = 'Password must be at least 6 characters';
1156      errorEl.style.display = 'block';
1157      return;
1158    }
1159
1160    try {
1161      const cred = await auth.createUserWithEmailAndPassword(email, password);
1162      await cred.user.updateProfile({ displayName: name });
1163      closeSigninModal();
1164    } catch (e) {
1165      errorEl.textContent = e.code === 'auth/email-already-in-use' ? 'An account with this email already exists'
1166        : e.code === 'auth/weak-password' ? 'Password is too weak'
1167        : e.message;
1168      errorEl.style.display = 'block';
1169    }
1170  });
1171
1172  // Sign out
1173  async function doSignOut() {
1174    if (!auth) return;
1175    try { await auth.signOut(); } catch (e) { console.error('[v2] Sign-out error:', e); }
1176  }
1177
1178  // Navbar sign-in button
1179  document.getElementById('nav-sign-in')?.addEventListener('click', openSigninModal);
1180
1181  // Sidebar login prompt
1182  document.getElementById('login-prompt')?.addEventListener('click', openSigninModal);
1183
1184  // Sidebar sign-out link
1185  document.getElementById('signout-link')?.addEventListener('click', doSignOut);
1186
1187  // Navbar avatar click → open profile
1188  document.getElementById('nav-user-indicator')?.addEventListener('click', () => {
1189    eventBus.emit('profile:open');
1190  });
1191
1192  // Mobile menu auth
1193  document.getElementById('mobile-menu-sign-in')?.addEventListener('click', openSigninModal);
1194  document.getElementById('mobile-menu-sign-out')?.addEventListener('click', doSignOut);
1195
1196  // Listen for loginRequired events from other modules
1197  eventBus.on('auth:loginRequired', openSigninModal);
1198}
1199
1200// ─── Header Accordions (My Routes, Route Library & Route Builder) ───
1201function initSidebarAccordions() {
1202  const routeBuilder = document.getElementById('route-builder');
1203  const savedRoutes = document.getElementById('my-routes');
1204  const routeLibrary = document.getElementById('route-library');
1205  const routeBuilderToggle = document.getElementById('route-builder
1205-toggle');
1206  const savedRoutesToggle = document.getElementById('my-routes-toggle');
1207  const routeLibraryToggle = document.getElementById('route-library-toggle');
1208
1209  if (!routeBuilder || !savedRoutes) return;
1210
1211  // Default: Route Builder open
1212  routeBuilder.classList.add('open');
1213
1214  function closeAll() {
1215    routeBuilder.classList.remove('open');
1216    savedRoutes.classList.remove('open');
1217    routeLibrary?.classList.remove('open');
1218    routeBuilderToggle?.setAttribute('aria-expanded', 'false');
1219    savedRoutesToggle?.setAttribute('aria-expanded', 'false');
1220    routeLibraryToggle?.setAttribute('aria-expanded', 'false');
1221  }
1222
1223  function openPanel(panel) {
1224    closeAll();
1225    panel.classList.add('open');
1226    const toggle = panel.querySelector('.header-accordion-toggle');
1227    if (toggle) toggle.setAttribute('aria-expanded', 'true');
1228  }
1229
1230  routeBuilderToggle?.addEventListener('click', () => {
1231    if (routeBuilder.classList.contains('open')) return; // Already open, stay open
1232    openPanel(routeBuilder);
1233  });
1234
1235  savedRoutesToggle?.addEventListener('click', () => {
1236    if (savedRoutes.classList.contains('open')) { openPanel(routeBuilder); return; }
1237    openPanel(savedRoutes);
1238  });
1239
1240  routeLibraryToggle?.addEventListener('click', () => {
1241    if (store.get('viewMode')) return; // handled by route-view.js
1242    if (routeLibrary?.classList.contains('open')) { openPanel(routeBuilder); return; }
1243    openPanel(routeLibrary);
1244  });
1245
1246  // Library folder toggles (includes My Routes subfolders + Route Collections folders)
1247  const folderToggles = document.querySelectorAll('.library-folder-toggle');
1248  console.log('[v2] Found', folderToggles.length, 'library-folder-toggle buttons');
1249  folderToggles.forEach((btn, i) => {
1250    console.log('[v2] Wiring folder toggle', i, btn.textContent.trim());
1251    btn.addEventListener('click', (e) => {
1252      console.log('[v2] Folder toggle clicked:', btn.textContent.trim(), e);
1253      const folder = btn.closest('.library-folder');
1254      console.log('[v2] Found folder:', folder, 'current open:', folder?.classList.contains('open'));
1255      if (folder) folder.classList.toggle('open');
1256    });
1257  });
1258
1259  // Wire generic inner accordions (Description, Route waypoints, etc.)
1260  ['route-description', 'route-waypoints'].forEach(id => {
1261    const el = document.getElementById(id);
1262    const toggle = document.getElementById(id + '-toggle');
1263    if (el && toggle && !toggle._wired) {
1264      toggle.addEventListener('click', () => el.classList.toggle('open'));
1265      toggle._wired = true;
1266    }
1267  });
1268}
1269
1270// ─── Profile Time Comparison Badges ───
1271const COMPARE_PROFILES = ['lts', 'bike_offroad', 'bike', 'bike_fast'];
1272let comparisonAbort = null;
1273let comparisonGeneration = 0;
1274
1275function initProfileComparison() {
1276  eventBus.on('route:calculated', ({ routeData }) => {
1277    const currentProfile = store.get('selectedProfile') || 'lts';
1278    if (!COMPARE_PROFILES.includes(currentProfile)) {
1279      clearProfileBadges();
1280      return;
1281    }
1282    const waypoints = getWaypoints().filter(wp => wp.lngLat);
1283    if (waypoints.length < 2) return;
1284    updateProfileBadges(currentProfile, routeData.distance, waypoints);
1285  });
1286
1287  eventBus.on('route:cleared', clearProfileBadges);
1288}
1289
1290function clearProfileBadges() {
1291  document.querySelectorAll('.profile-time-badge').forEach(el => el.remove());
1292  document.querySelectorAll('.mini-profile-badge').forEach(el => el.remove());
1293}
1294
1295async function updateProfileBadges(currentProfile, currentDistance, waypoints) {
1296  if (comparisonAbort) comparisonAbort.abort();
1297  comparisonAbort = new AbortController();
1298  const signal = comparisonAbort.signal;
1299  const gen = ++comparisonGeneration;
1300
1301  const otherProfiles = COMPARE_PROFILES.filter(p => p !== currentProfile);
1302  const currentTimeMin = (currentDistance / 1000) / 20 * 60;
1303
1304  // Fire requests in parallel — route via BRouter (same engine as the main route)
1305  // so the comparison chips no longer depend on the legacy GraphHopper server.
1306  const results = await Promise.allSettled(
1307    otherProfiles.map(async (profile) => {
1308      const data = await fetchValhallaRouteComplete(
1309        waypoints, profile, signal,
1310        { gravelOn: store.get('gravelOn'), ferryOn: store.get('ferryOn') }
1311      );
1312      if (!data?.paths?.[0]) return null;
1313      return { profile, distance: data.paths[0].distance || 0 };
1314    })
1315  );
1316
1317  if (signal.aborted || gen !== comparisonGeneration) return;
1318
1319  clearProfileBadges();
1320
1321  for (const result of results) {
1322    if (result.status !== 'fulfilled' || !result.value) continue;
1323    const { profile, distance } = result.value;
1324    const otherTimeMin = (distance / 1000) / 20 * 60;
1325    const diffMin = Math.round(otherTimeMin - currentTimeMin);
1326
1327    if (diffMin === 0) continue;
1328
1329    const btn = document.querySelector(`#profile-picker .profile-btn[data-profile="${profile}"]`);
1330    if (!btn) continue;
1331
1332    const badge = document.createElement('span');
1333    badge.className = 'profile-time-badge';
1334    badge.textContent = (diffMin > 0 ? '+' : '') + diffMin;
1335    badge.style.background = diffMin < 0 ? '#22c55e' : '#ef4444';
1336    btn.appendChild(badge);
1337
1338    // Mirror badge to mini profile strip
1339    const miniBtn = document.querySelector(`#mini-profiles .mini-profile-btn[data-profile="${profile}"]`);
1340    if (miniBtn) {
1341      const miniBadge = document.createElement('span');
1342      miniBadge.className = 'mini-profile-badge';
1343      miniBadge.textContent = (diffMin > 0 ? '+' : '') + diffMin;
1344      miniBadge.style.background = diffMin < 0 ? '#22c55e' : '#ef4444';
1345      miniBtn.appendChild(miniBadge);
1346    }
1347  }
1348}
1349
1350// ─── Minimized Icon Strip Controls ───
1351function initMinimizedControls() {
1352  const miniProfiles = document.getElementById('mini-profiles');
1353  const miniPois = document.getElementById('mini-pois');
1354  if (!miniProfiles || !miniPois) return;
1355
1356  // Sync mini profile active state with main picker
1357  function syncMiniProfileActive() {
1358    const current = store.get('selectedProfile') || 'lts';
1359    miniProfiles.querySelectorAll('.mini-profile-btn').forEach(btn => {
1360      btn.classList.toggle('active', btn.dataset.profile === current);
1361    });
1362  }
1363
1364  // Sync mini POI active state with checkboxes
1365  function syncMiniPoiActive() {
1366    miniPois.querySelectorAll('.mini-poi-btn').forEach(btn => {
1367      const checkbox = document.getElementById(btn.dataset.poi);
1368      if (checkbox) btn.classList.toggle('active', checkbox.checked);
1369    });
1370  }
1371
1372  // Profile click: sync with main picker and trigger profile change
1373  miniProfiles.addEventListener('click', (e) => {
1374    const btn = e.target.closest('.mini-profile-btn');
1375    if (!btn) return;
1376    const profile = btn.dataset.profile;
1377
1378    // Update main picker
1379    const mainPicker = document.getElementById('profile-picker');
1380    if (mainPicker) {
1381      mainPicker.querySelectorAll('.profile-btn').forEach(b => b.classList.remove('active'));
1382      const mainBtn = mainPicker.querySelector(`.profile-btn[data-profile="${profile}"]`);
1383      if (mainBtn) mainBtn.classList.add('active');
1384    }
1385
1386    store.set('selectedProfile', profile);
1387    syncMiniProfileActive();
1388
1389    const mode = store.get('profileMode') || 'next_sections';
1390    if (mode === 'whole') {
1391      clearWaypointProfiles();
1392      eventBus.emit('profile:changed');
1393    }
1394  });
1395
1396  // POI click: toggle corresponding checkbox
1397  miniPois.addEventListener('click', (e) => {
1398    const btn = e.target.closest('.mini-poi-btn');
1399    if (!btn) return;
1400    const checkbox = document.getElementById(btn.dataset.poi);
1401    if (checkbox) {
1402      checkbox.checked = !checkbox.checked;
1403      checkbox.dispatchEvent(new Event('change'));
1404    }
1405    syncMiniPoiActive();
1406  });
1407
1408  // Keep in sync when main picker changes
1409  eventBus.on('profile:changed', syncMiniProfileActive);
1410  eventBus.on('route:calculated', () => {
1411    syncMiniProfileActive(); syncMiniPoiActive();
1412    document.getElementById('sidebar')?.classList.add('has-route');
1413    // Remove route loading spinner (if showing)
1414    document.getElementById('route-loading-overlay')?.remove();
1415    // Show description accordion when route exists
1416    const descAccordion = document.getElementById('route-description');
1417    if (descAccordion) descAccordion.classList.remove('hidden');
1418  });
1419  eventBus.on('route:cleared', () => {
1420    document.getElementById('sidebar')?.classList.remove('has-route');
1421    // Hide description accordion when route is cleared
1422    const descAccordion = document.getElementById('route-description');
1423    if (descAccordion) {
1424      descAccordion.classList.add('hidden');
1425      descAccordion.classList.remove('open');
1426    }
1427    const descText = document.getElementById('route-description-text');
1428    if (descText) descText.value = '';
1429  });
1430
1431  // Initial sync
1432  syncMiniProfileActive();
1433  syncMiniPoiActive();
1434
1435  // View-mode icon strip: expand sidebar and open the target view accordion
1436  const miniViewControls = document.getElementById('minimized-view-controls');
1437  if (miniViewControls) {
1438    miniViewControls.addEventListener('click', (e) => {
1439      const btn = e.target.closest('.mini-view-btn');
1440      if (!btn) return;
1441      const targetId = btn.dataset.target;
1442      const sidebar = document.getElementById('sidebar');
1443      // Just expand the sidebar — view mode content is already configured
1444      if (sidebar && sidebar.classList.contains('minimized')) {
1445        sidebar.classList.remove('minimized');
1446        store.set('sidebarMinimized', false);
1447        eventBus.emit('sidebar:toggled', { minimized: false });
1448        minimizeElevationOnMobile();
1449        updateElevationBarPosition();
1450        adjustSidebarForElevation();
1451      }
1452      // Open the target view accordion and scroll to it
1453      setTimeout(() => {
1454        const accordion = document.getElementById(targetId);
1455        if (accordion) {
1456          if (!accordion.classList.contains('open')) accordion.classList.add('open');
1457          accordion.scrollIntoView({ behavior: 'smooth', block: 'start' });
1458        }
1459      }, 150);
1460    });
1461  }
1462}
1463
1464// ─── Mobile Search Sheet ───
1465
1466function initMobileSearchSheet() {
1467  if (window.innerWidth > 768) return;
1468  if (store.get('viewMode')) return;
1469  // Also check URL — viewMode may not be set yet if enterViewMode is still loading
1470  if (new URL(window.location).searchParams.has('route')) return;
1471
1472  const sheet = document.getElementById('mobile-search-sheet');
1473  if (!sheet) return;
1474
1475  const inputA = document.getElementById('mobile-search-a');
1476  const inputB = document.getElementById('mobile-search-b');
1477  if (!inputA || !inputB) return;
1478
1479  // Wire search to waypoint indices 0 (A) and 1 (B)
1480  attachSearchToInput(inputA, 0);
1481  attachSearchToInput(inputB, 1);
1482
1483  // Wire location buttons
1484  const locationBtns = sheet.querySelectorAll('.use-location-btn');
1485  locationBtns.forEach(btn => {
1486    btn.addEventListener('click', (e) => {
1487      e.preventDefault();
1488      e.stopPropagation();
1489
1490      // Flip/swap button — swap A and B
1491      if (btn.dataset.flip === 'true') {
1492        const wp = getWaypoints();
1493        if (!wp[0]?.lngLat && !wp[1]?.lngLat) {
1494          alert('Set at least one location first');
1495          return;
1496        }
1497        // Ensure both waypoint slots exist so reverseWaypoints doesn't bail
1498        while (wp.length < 2) {
1499          wp.push({ id: Date.now().toString(36), lngLat: null, address: '', profile: null });
1500        }
1501        reverseWaypoints();
1502        const swapped = getWaypoints();
1503        inputA.value = swapped[0]?.address || '';
1504        inputB.value = swapped[1]?.address || '';
1505      } else {
1506        // GPS location button (A)
1507        useCurrentLocation(parseInt(btn.dataset.index, 10), btn);
1508      }
1509    });
1510  });
1511
1512  // Show the sheet
1513  sheet.style.display = 'block';
1514
1515  // Close button
1516  document.getElementById('mobile-search-close')?.addEventListener('click', () => {
1517    sheet.style.display = 'none';
1518  });
1519
1520  // Dismiss on backdrop tap (above the sheet)
1521  sheet.addEventListener('click', (e) => {
1522    if (e.target === sheet) sheet.style.display = 'none';
1523  });
1524
1525  // Dismiss when user taps the map
1526  const map = getMap();
1527  if (map) {
1528    map.on('click', () => {
1529      if (sheet.style.display !== 'none') sheet.style.display = 'none';
1530    });
1531  }
1532
1533  // When a search result is selected, update the mobile input and auto-focus next
1534  eventBus.on('search:selected', ({ index, lngLat, address }) => {
1535    if (index === 0) {
1536      inputA.value = address || '';
1537      // Hide A results dropdown
1538      inputA.parentElement?.querySelector('.search-results')?.classList.add('hidden');
1539      setTimeout(() => inputB.focus(), 100);
1540    } else if (index === 1) {
1541      inputB.value = address || '';
1542      // Hide B results dropdown
1543      inputB.parentElement?.querySelector('.search-results')?.classList.add('hidden');
1544    }
1545  });
1546
1547  // Dismiss when route is calculated and zoom to fit
1548  eventBus.on('route:calculated', ({ routeData }) => {
1549    sheet.style.display = 'none';
1550    try {
1551      // routeData.coordinates is the raw coord array; geometry is a GeoJSON Feature
1552      const coords = routeData?.coordinates
1553        || routeData?.geometry?.geometry?.coordinates
1554        || store.get('routeGeometry')?.geometry?.coordinates;
1555      if (coords && coords.length > 1) {
1556        const map = getMap();
1557        if (!map) return;
1558        let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity;
1559        for (const c of coords) {
1560          if (c[0] < minLng) minLng = c[0];
1561          if (c[0] > maxLng) maxLng = c[0];
1562          if (c[1] < minLat) minLat = c[1];
1563          if (c[1] > maxLat) maxLat = c[1];
1564        }
1565        setTimeout(() => {
1566          map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 80, maxZoom: 15, duration: 1000 });
1567        }, 200);
1568      }
1569    } catch (e) { console.warn('[MobileSearch] fitBounds failed:', e); }
1570  });
1571}

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.