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.