1(function () { 2 var initMaps = function () { 3 // Expects race_map_data to be localized via wp_localize_script 4 var mapsData = window.race_map_data || []; 5 6 if (!mapsData || mapsData.length === 0) return; 7 8 var nullIcon = L.divIcon({ className: 'hidden-marker', html: '', iconSize: [0, 0] }); 9 10 mapsData.forEach(function (data) { 11 var mapId = data.map_id; 12 var routes = data.routes; 13 var initialIndex = data.initial_index || 0; 14 15 var wrapper = document.getElementById('wrapper-' + mapId); 16 if (!wrapper) return; 17 18 var select = document.getElementById('select-' + mapId); 19 var downloadBtn = document.getElementById('download-' + mapId); 20 var titleDisplay = document.getElementById('title-display-' + mapId); 21 var descDisplay = document.getElementById('desc-display-' + mapId); 22 var autoStatsDisplay = document.getElementById('auto-stats-' + mapId); 23 var autoDirectionsDisplay = document.getElementById('auto-directions-' + mapId); 24 var infoOverlay = document.getElementById('info-overlay-' + mapId); 25 var toggleBtn = document.getElementById('toggle-overlay-btn-' + mapId); 26 var infoCloseBtn = document.getElementById('close-overlay-' + mapId); 27 var emojiToggleBtn = document.getElementById('toggle-emoji-btn-' + mapId); 28 var emojiOverlay = document.getElementById('emoji-overlay-' + mapId); 29 var toolsToggleBtn = document.getElementById('toggle-tools-' + mapId); 30 var toolsContainer = document.getElementById('tools-' + mapId); 31 var emojiCloseBtn = document.getElementById('close-emoji-' + mapId); 32 var emojiListDisplay = document.getElementById('emoji-list-' + mapId); 33 var playBtn = document.getElementById('play-' + mapId); 34 var distDisplay = document.getElementById('dist-' + mapId); 35 var eleCurrentDisplay = document.getElementById('ele-current-' + mapId); 36 var eleGainDisplay = document.getElementById('ele-gain-' + mapId); 37 var eleCanvas = document.getElementById('ele-' + mapId); 38 var eleCtx = eleCanvas ? eleCanvas.getContext('2d') : null; 39 var playIcon = playBtn ? playBtn.querySelector('.icon-play') : null; 40 var pauseIcon = playBtn ? playBtn.querySelector('.icon-pause') : null; 41 42 var lockBtn = document.getElementById('lock-' + mapId); 43 var unlockedIcon = lockBtn ? lockBtn.querySelector('.icon-unlocked') : null; 44 var lockedIcon = lockBtn ? lockBtn.querySelector('.icon-locked') : null; 45 46 var layerContainer = document.getElementById('layers-' + mapId); 47 var markerToggleBtn = document.getElementById('toggle-markers-' + mapId); 48 49 // Setup Photo Popup 50 var photoPopup = document.createElement('div'); 51 photoPopup.className = 'race-photo-popup hidden'; 52 photoPopup.innerHTML = '<div class="race-photo-popup-content"><span class="close-photo">×</span><img src="" alt="Løypebilde"><div class="race-photo-caption"></div></div>'; 53 wrapper.appendChild(photoPopup); 54 photoPopup.querySelector('.close-photo').addEventListener('click', function () { 55 photoPopup.classList.add('hidden'); 56 }); 57 photoPopup.addEventListener('click', function (e) { 58 if (e.target === photoPopup) photoPopup.classList.add('hidden'); 59 }); 60 61 var zoomInBtn = document.getElementById('zoom-in-' + mapId); 62 var zoomOutBtn = document.getElementById('zoom-out-' + mapId); 63 var zoomSlider = document.getElementById('zoom-slider-' + mapId); 64 var centerMapBtn = document.getElementById('center-map-' + mapId); 65 var fullscreenBtn = document.getElementById('fullscreen-' + mapId); 66 var locateMeBtn = document.getElementById('locate-me-' + mapId); 67 68 var mapboxStyle = (data.global && data.global.mapbox_style) ? String(data.global.mapbox_style).trim() : 'mapbox/outdoors-v12'; 69 mapboxStyle = mapboxStyle.replace(/^mapbox:\/\/styles\//i, '').replace(/^https?:\/\/api\.mapbox\.com\/styles\/v1\//i, ''); 70 if (!mapboxStyle) mapboxStyle = 'mapbox/outdoors-v12'; 71 72 var cartoKeyParam = (data.global && data.global.carto_key) ? '?key=' + encodeURIComponent(data.global.carto_key) : ''; 73 74 var baseLayers = { 75 "dark": L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' + cartoKeyParam, { 76 attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> contributors © <a href="https://carto.com/attributions">CARTO</a>', 77 maxZoom: 19 78 }), 79 "light": L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' + cartoKeyParam, { 80 attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> contributors © <a href="https://carto.com/attributions">CARTO</a>', 81 maxZoom: 19 82 }), 83 "osm": L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { 84 attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', 85 maxZoom: 19 86 }), 87 "sat": L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { 88 attribution: 'Tiles © Esri', 89 maxZoom: 19 90 }), 91 "mapbox": L.tileLayer('https://api.mapbox.com/styles/v1/' + mapboxStyle + '/tiles/512/{z}/{x}/{y}
91?access_token=' + (data.global && data.global.mapbox_key ? data.global.mapbox_key : ''), { 92 attribution: '© <a href="https://www.mapbox.com/about/maps/">Mapbox</a>', 93 maxZoom: 19, 94 tileSize: 512, 95 zoomOffset: -1 96 }), 97 "stadia": L.tileLayer('https://tiles.stadiamaps.com/tiles/outdoors/{z}/{x}/{y}{r}.png' + (data.global && data.global.stadia_key ? '?api_key=' + data.global.stadia_key : ''), { 98 attribution: '© <a href="https://stadiamaps.com/">Stadia Maps</a>', 99 maxZoom: 20 100 }), 101 "tf": L.tileLayer('https://{s}.tile.thunderforest.com/landscape/{z}/{x}/{y}.png?apikey=' + (data.global && data.global.thunderforest_key ? data.global.thunderforest_key : ''), { 102 attribution: '© <a href="https://www.thunderforest.com/">Thunderforest</a>', 103 maxZoom: 22 104 }) 105 }; 106 107 // Determine Default Layer 108 var isMobile = window.innerWidth < 768; 109 110 var enabledStyles = (data.global && data.global.enabled_map_styles) ? data.global.enabled_map_styles : ['dark', 'light', 'osm', 'sat', 'mapbox', 'stadia', 'tf']; 111 enabledStyles = enabledStyles.filter(function(style) { 112 if (style === 'mapbox') return !!(data.global && data.global.mapbox_key); 113 if (style === 'stadia') return !!(data.global && data.global.stadia_key); 114 if (style === 'tf') return !!(data.global && data.global.thunderforest_key); 115 return true; 116 }); 117 if (enabledStyles.length === 0) { 118 enabledStyles = ['dark']; 119 } 120 121 var savedDefault = (data.global && data.global.default_map_style) ? data.global.default_map_style : 'dark'; 122 if (enabledStyles.indexOf(savedDefault) === -1) { 123 savedDefault = enabledStyles[0]; 124 } 125 var savedDefaultMobile = (data.global && data.global.default_map_style_mobile) ? data.global.default_map_style_mobile : 'light'; 126 if (enabledStyles.indexOf(savedDefaultMobile) === -1) { 127 savedDefaultMobile = enabledStyles[0]; 128 } 129 130 var defaultLayer; 131 var initialStyleType; 132 if (isMobile) { 133 defaultLayer = baseLayers[savedDefaultMobile] || baseLayers['dark']; 134 initialStyleType = baseLayers[savedDefaultMobile] ? savedDefaultMobile : 'dark'; 135 } else { 136 defaultLayer = baseLayers[savedDefault] || baseLayers['dark']; 137 initialStyleType = baseLayers[savedDefault] ? savedDefault : 'dark'; 138 } 139 140 wrapper.setAttribute('data-map-style', initialStyleType); 141 wrapper.classList.add('map-style-' + initialStyleType); 142 143 // MinZoom: 3 restricts zooming out too far 144 var map = L.map(mapId, { 145 scrollWheelZoom: true, 146 layers: [defaultLayer], 147 zoomControl: false, 148 attributionControl: false, 149 minZoom: 3 150 }).setView([59.91, 10.75], 10); 151 152 // --- ZOOM CONTROLS --- 153 if (zoomInBtn) zoomInBtn.onclick = function () { map.zoomIn(); }; 154 if (zoomOutBtn) zoomOutBtn.onclick = function () { map.zoomOut(); }; 155 156 if (zoomSlider) { 157 // Init Slider range 158 map.on('zoomend', function () { 159 zoomSlider.value = map.getZoom(); 160 }); 161 zoomSlider.addEventListener('input', function () { 162 map.setZoom(this.value); 163 }); 164 // Set initial 165 zoomSlider.value = map.getZoom(); 166 } 167 168 var getFitBoundsOptions = function (routeObj) { 169 var isMobileView = window.innerWidth < 768; 170 var currentR = routeObj || (typeof getCurrentRoute === 'function' ? getCurrentRoute() : null); 171 172 var padTopLeft = isMobileView ? [35, 55] : [75, 75]; 173 var padBottomRight = isMobileView ? [35, 95] : [75, 125]; 174 175 if (data.padding && !isNaN(parseInt(data.padding, 10))) { 176 var p = parseInt(data.padding, 10); 177 padTopLeft = [p, p]; 178 padBottomRight = [p, p]; 179 } 180 181 // Determine max zoom level: 182 // 1. Shortcode max_zoom parameter 183 // 2. Route-specific max_zoom setting 184 // 3. Global fit_bounds_max_zoom setting 185 // 4. Default: 14 (ensures optimal framing and landscape context on short routes) 186 var maxZ = 14; 187 if (data.max_zoom && !isNaN(parseInt(data.max_zoom, 10))) { 188 maxZ = parseInt(data.max_zoom, 10); 189 } else if (currentR && currentR.max_zoom && !isNaN(parseInt(currentR.max_zoom, 10))) { 190 maxZ = parseInt(currentR.max_zoom, 10); 191 } else if (data.global && data.global.fit_bounds_max_zoom && !isNaN(parseInt(data.global.fit_bounds_max_zoom, 10))) { 192 maxZ = parseInt(data.global.fit_bounds_max_zoom, 10); 193 } 194 195 return { 196 paddingTopLeft: padTopLeft, 197 paddingBottomRight: padBottomRight, 198 maxZoom: maxZ 199 }; 200 }; 201 202 if (centerMapBtn) { 203 centerMapBtn.addEventListener('click', function () { 204 var fitOpts = getFitBoundsOptions(getCurrentRoute()); 205 if (currentRouteBounds && currentRouteBounds.isValid()) { 206 map.fitBounds(currentRouteBounds, fitOpts); 207 } else if (gpxLayer && gpxLayer.getBounds && gpxLayer.getBounds().isValid()) { 208 map.fitBounds(gpxLayer.getBounds(), fitOpts); 209 } 210 }); 211 } 212 213 // --- CUSTOM LAYER CONTROLLER LOGIC --- 214 if (layerContainer) { 215 var buttons = layerContainer.querySelectorAll('.race-layer-btn'); 216 217 // Set active class on init 218 buttons.forEach(function (btn) { 219 var type = btn.getAttribute('data-type'); 220 var activeType = isMobile ? savedDefaultMobile : savedDefault; 221 222 if (type === activeType) btn.classList.add('active'); 223 else btn.classList.remove('active'); 224 }); 225 226 buttons.forEach(function (btn) { 227 btn.addEventListener('click', function (e) { 228 e.preventDefault(); 229 var type = this.getAttribute('data-type'); 230 // Remove all base layers 231 for (var key in baseLayers) { 232 if (map.hasLayer(baseLayers[key])) { 233 map.removeLayer(baseLayers[key]); 234 } 235 } 236 // Add selected base layer 237 if (baseLayers[type]) { 238 map.addLayer(baseLayers[type]); 239 wrapper.setAttribute('data-map-style', type); 240 // Ensure only the selected style class exists 241 wrapper.className = wrapper.className.replace(/map-style-\w+/g, ''); 242 wrapper.classList.add('map-style-' + type); 243 } else { 244 map.addLayer(baseLayers['dark']); // Fallback to dark if type is not found 245 wrapper.setAttribute('data-map-style', 'dark'); 246 wrapper.className = wrapper.className.replace(/map-style-\w+/g, ''); 247 wrapper.classList.add('map-style-dark'); 248 } 249 buttons.forEach(b => b.classList.remove('active')); 250 this.classList.add('active'); 251 // closeFlyouts(); // This function is not defined in the provided context. 252 }); 253 }); 254 layerContainer.addEventListener('mousedown', function (e) { e.stopPropagation(); }); 255 layerContainer.addEventListener('dblclick', function (e) { e.stopPropagation(); }); 256 } 257 258 var gpxLayer = null; 259 var currentRouteBounds = null; 260 var gradientLayerGroup = L.layerGroup().addTo(map); 261 var fixedMarkerGroup = L.layerGroup().addTo(map); 262 var customMarkerGroup = L.layerGroup().addTo(map); 263 264 // --- MARKER TOGGLE LOGIC --- 265 if (markerToggleBtn) { 266 markerToggleBtn.addEventListener('click', function () { 267 if (map.hasLayer(customMarkerGroup)) { 268 map.removeLayer(customMarkerGroup); 269 this.innerText = 'Vis markører'; 270 } else { 271 map.addLayer(customMarkerGroup); 272 this.innerText = 'Skjul markører'; 273 } 274 }); 275 } 276 277 var userLocationMarker = null; 278 279 if (locateMeBtn) { 280 locateMeBtn.addEventListener('click', function () { 281 map.locate({ setView: false, maxZoom: 16 }); 282 }); 283 284 map.on('locationfound', function (e) { 285 if (userLocationMarker) { 286 map.removeLayer(userLocationMarker); 287 } 288 userLocationMarker = L.circleMarker(e.latlng, { 289 radius: 8, 290 fillColor: "#007AFF", 291 color: "#fff", 292 weight: 2, 293 opacity: 1, 294 fillOpacity: 1 295 }).addTo(map); 296 297 var bounds = L.latLngBounds([e.latlng]); 298 if (gpxLayer) { 299 bounds.extend(gpxLayer.getBounds()); 300 } 301 map.fitBounds(bounds, { padding: [50, 50] }); 302 }); 303 304 map.on('locationerror', function (e) { 305 alert("Kunne ikke hente posisjonen din. Sjekk at du har gitt nettleseren tillatelse til posisjonsdata."); 306 }); 307 } 308 309 if (fullscreenBtn) { 310 fullscreenBtn.addEventListener('click', function () { 311 var hasFullscreenAPI = document.fullscreenEnabled || document.webkitFullscreenEnabled; 312 if (!document.fullscreenElement && !document.webkitFullscreenElement && !wrapper.classList.contains('ios-fullscreen')) { 313 if (hasFullscreenAPI && wrapper.requestFullscreen) { 314 wrapper.requestFullscreen().catch(function () { 315 wrapper.classList.add('ios-fullscreen'); 316 handleFullscreenUI(true); 317 }); 318 } else if (hasFullscreenAPI && wrapper.webkitRequestFullscreen) { 319 wrapper.webkitRequestFullscreen(); 320 } else { 321 wrapper.classList.add('ios-fullscreen'); 322 handleFullscreenUI(true); 323 } 324 } else { 325 if (wrapper.classList.contains('ios-fullscreen')) { 326 wrapper.classList.remove('ios-fullscreen'); 327 handleFullscreenUI(false); 328 } else if (document.exitFullscreen) { 329 document.exitFullscreen(); 330 } else if (document.webkitExitFullscreen) { 331 document.webkitExitFullscreen(); 332 } 333 } 334 }); 335 336 document.addEventListener('fullscreenchange', handleFullscreenUI); 337 document.addEventListener('webkitfullscreenchange', handleFullscreenUI); 338 339 function handleFullscreenUI(forceState) { 340 var isFs = typeof forceState === 'boolean' ? forceState : !!(document.fullscreenElement || document.webkitFullscreenElement || wrapper.classList.contains('ios-fullscreen')); 341 var openIcon = fullscreenBtn.querySelector('.f
341s-icon-open'); 342 var closeIcon = fullscreenBtn.querySelector('.fs-icon-close'); 343 var fsText = fullscreenBtn.querySelector('.fs-text'); 344 345 if (isFs) { 346 if (openIcon) openIcon.style.display = 'none'; 347 if (closeIcon) closeIcon.style.display = 'block'; 348 if (fsText) fsText.innerText = 'Lukk fullskjerm'; 349 } else { 350 if (openIcon) openIcon.style.display = 'block'; 351 if (closeIcon) closeIcon.style.display = 'none'; 352 if (fsText) fsText.innerText = 'Fullskjerm'; 353 } 354 355 // Workaround to refresh map tiles in full screen 356 setTimeout(function () { 357 map.invalidateSize(); 358 }, 200); 359 } 360 } 361 362 var runnerMarker = null; 363 var allPoints = []; 364 var distArray = []; 365 var elevationData = []; 366 var allMarkerDefs = []; // Store definitions for dynamic rendering 367 368 var isPlaying = false; 369 var animationId = null; 370 var currentProgress = 0; 371 var savedProgress = 0; // For interactive hover 372 var isHovering = false; 373 var isFocusLocked = false; 374 var maxProgress = 1000; 375 var totalDistanceKm = 0; 376 var distScaleFactor = 1; 377 378 var sessionStartTime = null; 379 var sessionDurationMs = 0; 380 var sessionStartProgress = 0; 381 382 function easeInOutCubicMixed(t) { 383 var quad = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; 384 return 0.4 * t + 0.6 * quad; // 40% linear, 60% quadratic (snappier start and finish, less aggressive slowing) 385 } 386 387 function getPointAndIndexAtDistance(targetDist) { 388 if (allPoints.length === 0) return null; 389 if (targetDist <= 0) return { point: allPoints[0], index: 0, ratio: 0 }; 390 var lastIdx = allPoints.length - 1; 391 var totalDist = distArray[lastIdx]; 392 if (targetDist >= totalDist) return { point: allPoints[lastIdx], index: lastIdx, ratio: 0 }; 393 394 var low = 0; 395 var high = lastIdx; 396 while (low < high - 1) { 397 var mid = Math.floor((low + high) / 2); 398 if (distArray[mid] <= targetDist) { 399 low = mid; 400 } else { 401 high = mid; 402 } 403 } 404 var i = low; 405 var d1 = distArray[i]; 406 var d2 = distArray[i + 1]; 407 var ratio = 0; 408 if (d2 > d1) { 409 ratio = (targetDist - d1) / (d2 - d1); 410 } 411 var lat = allPoints[i].lat + ratio * (allPoints[i + 1].lat - allPoints[i].lat); 412 var lng = allPoints[i].lng + ratio * (allPoints[i + 1].lng - allPoints[i].lng); 413 return { 414 point: L.latLng(lat, lng), 415 index: i, 416 ratio: ratio 417 }; 418 } 419 420 function getElevationAtDistance(targetKm) { 421 if (!elevationData || elevationData.length === 0) return 0; 422 if (targetKm <= elevationData[0][0]) return elevationData[0][1]; 423 var lastIdx = elevationData.length - 1; 424 if (targetKm >= elevationData[lastIdx][0]) return elevationData[lastIdx][1]; 425 426 var low = 0; 427 var high = lastIdx; 428 while (low < high - 1) { 429 var mid = Math.floor((low + high) / 2); 430 if (elevationData[mid][0] <= targetKm) { 431 low = mid; 432 } else { 433 high = mid; 434 } 435 } 436 var i = low; 437 var k1 = elevationData[i][0]; 438 var k2 = elevationData[i + 1][0]; 439 var e1 = elevationData[i][1]; 440 var e2 = elevationData[i + 1][1]; 441 var ratio = 0; 442 if (k2 > k1) { 443 ratio = (targetKm - k1) / (k2 - k1); 444 } 445 return e1 + ratio * (e2 - e1); 446 } 447 448 449 function adjustColor(hex, saturationFactor) { 450 hex = hex.replace('#', '');
451 var r = parseInt(hex.substring(0, 2), 16) / 255; 452 var g = parseInt(hex.substring(2, 4), 16) / 255; 453 var b = parseInt(hex.substring(4, 6), 16) / 255; 454 var max = Math.max(r, g, b), min = Math.min(r, g, b); 455 var h = 0, s = 0, l = (max + min) / 2; 456 457 if (max !== min) { 458 var d = max - min; 459 s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 460 switch (max) { 461 case r: h = (g - b) / d + (g < b ? 6 : 0); break; 462 case g: h = (b - r) / d + 2; break; 463 case b: h = (r - g) / d + 4; break; 464 } 465 h /= 6; 466 } 467 468 s = Math.max(0, Math.min(1, s * saturationFactor)); 469 h = Math.round(h * 360); 470 s = Math.round(s * 100); 471 l = Math.round(l * 100); 472 473 return 'hsl(' + h + ', ' + s + '%, ' + l + '%)'; 474 } 475 476 if (lockBtn) { 477 lockBtn.addEventListener('click', function () { 478 isFocusLocked = !isFocusLocked; 479 if (isFocusLocked) { 480 lockBtn.classList.add('locked'); 481 if (unlockedIcon) unlockedIcon.style.display = 'none'; 482 if (lockedIcon) lockedIcon.style.display = 'block'; 483 // Immediately snap to track if locked 484 if (allPoints.length > 0) { 485 var pct = currentProgress / maxProgress; 486 var totalDist = distArray[allPoints.length - 1] || 0; 487 var targetDist = pct * totalDist; 488 var targetRes = getPointAndIndexAtDistance(targetDist); 489 if (targetRes) map.setView(targetRes.point, map.getZoom(), { animate: true }); 490 } 491 } else { 492 lockBtn.classList.remove('locked'); 493 if (unlockedIcon) unlockedIcon.style.display = 'block'; 494 if (lockedIcon) lockedIcon.style.display = 'none'; 495 } 496 }); 497 } 498 499 function togglePlay() { isPlaying ? pause() : play(); } 500 501 function play() { 502 if (currentProgress >= maxProgress) { 503 currentProgress = 0; 504 } 505 506 isPlaying = true; 507 if (playIcon) playIcon.style.display = 'none'; 508 if (pauseIcon) pauseIcon.style.display = 'block'; 509 510 sessionStartProgress = currentProgress; 511 var assumedSpeedKmh = 15; 512 var runningTimeHours = totalDistanceKm / assumedSpeedKmh; 513 var playbackTimeSeconds = runningTimeHours * 12; // 12 seconds of animation per 1 hour of running 514 playbackTimeSeconds = Math.max(3, Math.min(playbackTimeSeconds, 60)); 515 516 var remainingFraction = (maxProgress - sessionStartProgress) / maxProgress; 517 sessionDurationMs = Math.max(1000, playbackTimeSeconds * 1000 * remainingFraction); 518 sessionStartTime = performance.now(); 519 520 animate(); 521 } 522 523 function pause() { 524 isPlaying = false; 525 savedProgress = currentProgress; // Save the state when we pause 526 if (playIcon) playIcon.style.display = 'block'; 527 if (pauseIcon) pauseIcon.style.display = 'none'; 528 if (animationId) cancelAnimationFrame(animationId); 529 } 530 531 if (toggleBtn) { 532 toggleBtn.addEventListener('click', function () { 533 if (emojiOverlay && !emojiOverlay.classList.contains('hidden')) { 534 emojiOverlay.classList.add('collapsed'); emojiOverlay.classList.remove('expanded'); 535 setTimeout(function () { emojiOverlay.classList.add('hidden'); }, 300); 536 } 537 if (infoOverlay.classList.contains('hidden')) { 538 infoOverlay.classList.remove('hidden'); 539 setTimeout(function () { 540 infoOverlay.classList.remove('collapsed'); 541 infoOverlay.classList.add('expanded'); 542 }, 10); 543 } else { 544 infoOverlay.classList.add('collapsed'); 545 infoOverlay.classList.remove('expanded'); 546 setTimeout(function () { 547 infoOverlay.classList.add('hidden'); 548 }, 300); 549 } 550 }); 551 } 552 553 if (toolsToggleBtn && toolsContainer) { 554 var toolsIconOpen = toolsToggleBtn.querySelector('.icon-open'); 555 var toolsIconClose = toolsToggleBtn.querySelector('.icon-close'); 556 557 toolsToggleBtn.addEventListener('click', function () { 558 // is-hidden means the tools are off-screen 559 if (toolsContainer.classList.contains('is-hidden')) { 560 toolsContainer.classList.remove('is-hidden'); 561 if (toolsIconOpen) toolsIconOpen.style.display = 'none'; 562 if (toolsIconClose) toolsIconClose.style.display = 'block'; 563 } else { 564 toolsContainer.classList.add('is-hidden'); 565 if (toolsIconOpen) toolsIconOpen.style.display = 'block'; 566 if (toolsIconClose) toolsIconClose.style.display = 'none'; 567 } 568 }); 569 } 570 571 if (emojiToggleBtn && emojiOverlay) { 572 emojiToggleBtn.addEventListener('click', function () { 573 if (infoOverlay && !infoOverlay.classList.contains('hidden')) { 574 infoOverlay.classList.add('collapsed'); infoOverlay.classList.remove('expanded'); 575 setTimeout(function () { infoOverlay.classList.add('hidden'); }, 300); 576 } 577 if (emojiOverlay.classList.contains('hidden')) { 578 emojiOverlay.classList.remove('hidden'); 579 setTimeout(function () { 580 emojiOverlay.classList.remove('collapsed'); 581 emojiOverlay.classList.add('expanded'); 582 }, 10); 583 } else { 584 emojiOverlay.classList.add('collapsed'); 585 emojiOverlay.classList.remove('expanded'); 586 setTimeout(function () { 587 emojiOverlay.classList.add('hidden'); 588 }, 300); 589 } 590 }); 591 } 592 593 if (emojiCloseBtn && emojiOverlay) { 594 emojiCloseBtn.addEventListener('click', function () { 595 emojiOverlay.classList.add('collapsed'); 596 emojiOverlay.classList.remove('expanded'); 597 setTimeout(function () { 598 emojiOverlay.classList.add('hidden'); 599 }, 300); 600 }); 601 } 602 603 if (infoCloseBtn) { 604 infoCloseBtn.addEventListener('click', function () { 605 infoOverlay.classList.add('collapsed'); 606 infoOverlay.classList.remove('expanded'); 607 setTimeout(function () { 608 infoOverlay.classList.add('hidden'); 609 }, 300); 610 }); 611 } 612 613 function drawElevationProfile(currentPct, rangeVal) { 614 if (!eleCtx) return; 615 var w = eleCanvas.width; 616 var h = eleCanvas.height; 617 eleCtx.clearRect(0, 0, w, h); 618 619 var eleColor = (data.global && data.global.elevation_color) ? data.global.elevation_color : '#e23126'; 620 var primaryColor = (data.global && data.global.primary_color) ? data.global.primary_color : '#e23126'; 621 var eleOpacity = (data.global && data.global.elevation_opacity !== undefined) ? parseInt(data.global.elevation_opacity) / 100 : 0.3; 622 623 function hexToRgba(hex, alpha) { 624 hex = hex.replace('#', ''); 625 var r = 0, g = 0, b = 0; 626 if (hex.length === 3) { 627 r = parseInt(hex[0]+hex[0], 16); 628 g = parseInt(hex[1]+hex[1], 16); 629 b = parseInt(hex[2]+hex[2], 16); 630 } else if (hex.length === 6) { 631 r = parseInt(hex.slice(0, 2), 16); 632 g = parseInt(hex.slice(2, 4), 16); 633 b = parseInt(hex.slice(4, 6), 16); 634 } 635 return 'rgba(' + r + ', ' + g + ', ' + b + ', ' + alpha + ')'; 636 } 637 638 var cursorX = currentPct * w; 639 640 if (!elevationData || elevationData.length === 0) { 641 // Fallback sleek progress bar for tracks without elevation 642 var barH = 6; 643 var barY = (h / 2) - (barH / 2); 644
645 // Background 646 eleCtx.fillStyle = 'rgba(255, 255, 255, 0.1)'; 647 eleCtx.beginPath(); 648 eleCtx.roundRect ? eleCtx.roundRect(0, barY, w, barH, 3) : eleCtx.fillRect(0, barY, w, barH); 649 eleCtx.fill(); 650 651 // Fill Progress 652 eleCtx.fillStyle = primaryColor; 653 eleCtx.beginPath(); 654 eleCtx.roundRect ? eleCtx.roundRect(0, barY, cursorX, barH, 3) : eleCtx.fillRect(0, barY, cursorX, barH); 655 eleCtx.fill(); 656 657 // Scrubber handle 658 eleCtx.beginPath(); 659 eleCtx.arc(cursorX, h / 2, 7, 0, 2 * Math.PI); 660 eleCtx.fillStyle = '#fff'; 661 eleCtx.fill(); 662 eleCtx.strokeStyle = primaryColor; 663 eleCtx.lineWidth = 2; 664 eleCtx.stroke(); 665 return; 666 } 667 668 var minEle = 9999, maxEle = -9999; 669 for (var i = 0; i < elevationData.length; i++) { 670 if (elevationData[i][1] < minEle) minEle = elevationData[i][1]; 671 if (elevationData[i][1] > maxEle) maxEle = elevationData[i][1]; 672 } 673 674 var displayRange = rangeVal || (maxEle - minEle) || 100; 675 if (displayRange < 10) displayRange = 100; 676 var centerEle = (minEle + maxEle) / 2; 677 var visualMin = centerEle - (displayRange / 2); 678 679 eleCtx.beginPath(); 680 eleCtx.moveTo(0, h); 681 var gradient = eleCtx.createLinearGradient(0, 0, 0, h); 682 gradient.addColorStop(0, '#666'); 683 gradient.addColorStop(1, '#333'); 684 eleCtx.fillStyle = gradient; 685 686 for (var i = 0; i < elevationData.length; i++) { 687 var pt = elevationData[i]; 688 var x = (pt[0] / totalDistanceKm) * w; 689 var y = h - ((pt[1] - visualMin) / displayRange) * (h * 0.8) - (h * 0.1); 690 eleCtx.lineTo(x, y); 691 } 692 eleCtx.lineTo(w, h); 693 eleCtx.closePath(); 694 eleCtx.fill(); 695 696 // Highlight topography of passed area 697 eleCtx.beginPath(); 698 var first = true; 699 for (var i = 0; i < elevationData.length; i++) { 700 var pt = elevationData[i]; 701 var x = (pt[0] / totalDistanceKm) * w; 702 if (x > cursorX) { 703 if (!first && i > 0) { 704 var prevPt = elevationData[i - 1]; 705 var prevX = (prevPt[0] / totalDistanceKm) * w; 706 var prevY = h - ((prevPt[1] - visualMin) / displayRange) * (h * 0.8) - (h * 0.1); 707 var currY = h - ((pt[1] - visualMin) / displayRange) * (h * 0.8) - (h * 0.1); 708 var interpolatedY = prevY + (cursorX - prevX) * (currY - prevY) / (x - prevX); 709 eleCtx.lineTo(cursorX, interpolatedY); 710 } 711 break; 712 } 713 var y = h - ((pt[1] - visualMin) / displayRange) * (h * 0.8) - (h * 0.1); 714 if (first) { 715 eleCtx.moveTo(x, y); 716 first = false; 717 } else { 718 eleCtx.lineTo(x, y); 719 } 720 } 721 eleCtx.strokeStyle = eleColor; 722 eleCtx.lineWidth = 2.5; 723 eleCtx.lineCap = 'round'; 724 eleCtx.lineJoin = 'round'; 725 eleCtx.stroke(); 726 727 eleCtx.fillStyle = hexToRgba(eleColor, eleOpacity); 728 eleCtx.fillRect(0, 0, cursorX, h); 729 eleCtx.beginPath(); eleCtx.moveTo(cursorX, 0); eleCtx.lineTo(cursorX, h); 730 eleCtx.strokeStyle = eleColor; 731 eleCtx.lineWidth = 2; 732 eleCtx.stroke(); 733 } 734 735 function drawGradientPolyline(targetDist) { 736 gradientLayerGroup.clearLayers(); 737 if (allPoints.length < 2) return; 738 739 var totalDist = distArray[allPoints.length - 1] || 0; 740 var safeTarget = Math.max(0, Math.min(targetDist, totalDist)); 741 742 // Find segment index containing safeTarget 743 var low = 0; 744 var high = allPoints.length - 1; 745 while (low < high - 1) { 746 var mid = Math.floor((low + high) / 2); 747 if (distArray[mid] <= safeTarget) { 748 low = mid; 749 } else { 750 high = mid; 751 } 752 } 753 var safeLimit = low; // we draw full segments up to safeLimit 754 755 var fallbackColor = (data.global && data.global.primary_color) ? data.global.primary_color : '#e23126'; 756 var hasEle = (elevationData && elevationData.length > 0); 757 758 var currentSegment = []; 759 var currentColor = null; 760 var currentOpacity = null; 761 762 // Let's get the interpolated point at safeTarget 763 var targetRes = getPointAndIndexAtDistance(safeTarget); 764 var interpolatedPt = targetRes ? targetRes.point : allPoints[safeLimit]; 765 766 // Pre-project points to screen pixels to optimize performance. 767 var projectedPoints = []; 768 try { 769 for (var k = 0; k <= safeLimit + 1; k++) { 770 if (allPoints[k]) { 771 projectedPoints[k] = map.latLngToContainerPoint(allPoints[k]); 772 } 773 } 774 var projectedTarget = map.latLngToContainerPoint(interpolatedPt); 775 } catch (err) { 776 // Fallback 777 } 778 779 // Calculate cumulative screen distance backward from the runner (the interpolated point) 780 var accumPxArray = new Array(safeLimit + 2).fill(0); 781 try { 782 if (projectedPoints.length > 0 && projectedTarget) { 783 var accum = 0; 784 var lastPt = projectedTarget; 785 786 // Distance from runner to the start of the current segment (allPoints[safeLimit]) 787 if (projectedPoints[safeLimit]) { 788 var dx = lastPt.x - projectedPoints[safeLimit].x; 789 var dy = lastPt.y - projectedPoints[safeLimit].y; 790 accum += Math.sqrt(dx * dx + dy * dy); 791 } 792 accumPxArray[safeLimit] = accum; 793 794 lastPt = projectedPoints[safeLimit]; 795 for (var j = safeLimit - 1; j >= 0; j--) { 796 var currPt = projectedPoints[j]; 797 if (lastPt && currPt) { 798 var dx = lastPt.x - currPt.x; 799 var dy = lastPt.y - currPt.y; 800 accum += Math.sqrt(dx * dx + dy * dy); 801 } 802 accumPxArray[j] = accum; 803 lastPt = currPt; 804 } 805 } 806 } catch (err) { 807 // Fallback 808 } 809 810 var startFadePx = 200; 811 var endFadePx = 500; 812 813 // Function to draw a segment/polyline helper 814 var addSegmentToGroup = function(pts, color, opacity) { 815 if (pts.length > 0) { 816 L.polyline(pts, { 817 color: color, 818 weight: 5, 819 opacity: opacity, 820 fill: false, 821 className: 'race-route-polyline', 822 interactive: false 823 }).addTo(gradientLayerGroup); 824 } 825 }; 826 827 for (var i = 0; i < safeLimit; i++) { 828 var p1 = allPoints[i]; 829 var p2 = allPoints[i + 1]; 830 if (!p1 || !p2) continue; 831 832 var baseColor = fallbackColor; 833 834 if (hasEle) { 835 var d1 = distArray[i]; 836 var d2 = distArray[i + 1]; 837 var distDiff = d2 - d1; 838 839 var e1 = elevationData[i] ? elevationData[i][1] : 0; 840 var e2 = elevationData[i + 1] ? elevationData[i + 1][1] : 0; 841 var eleDiff = e2 - e1; 842 843 var slope = 0; 844 if (distDiff > 0) slope = (eleDiff / distDiff) * 100; 845 846 baseColor = '#3498db'; 847 if (slope > 2.2) baseColor = '#e23126'; 848 else if (slope < -2.2) baseColor = '#2ecc71'; 849 } 850 851 // Calculate desaturation and fade based on the segment's distance from the runner 852 var segmentDist = accumPxArray[i]; 853 var saturationFactor = 1.0; 854 var opacity = 0.9; 855 856 if (segmentDist > startFadePx) { 857 if (segmentDist >= endFadePx) { 858 saturationFactor = 0.7; 859 opacity = 0.63; 860 } else { 861 var t = (segmentDist - startFadePx) / (endFadePx - startFadePx); 862 saturationFactor = 1.0 - t * 0.3; 863 opacity = 0.9 - t * 0.27; 864 } 865 } 866 867 var color = adjustColor(baseColor, saturationFactor); 868 869 if (color !== currentColor || Math.abs(opacity - currentOpacity) > 0.01) { 870 if (currentSegment.length > 0) { 871 currentSegment.push(p1); 872 addSegmentToGroup(currentSegment, currentColor, currentOpacity); 873 } 874 currentSegment = [p1]; 875 currentColor = color; 876 currentOpacity = opacity; 877 } 878 currentSegment.push(p2); 879 } 880 881 // Now for the last segment which goes from allPoints[safeLimit] to interpolatedPt 882 if (safeLimit < allPoints.length - 1) { 883 var p1 = allPoints[safeLimit]; 884 var p2 = interpolatedPt; 885 886 var baseColor = fallbackColor; 887 if (hasEle) { 888 var d1 = distArray[safeLimit]; 889 var d2 = distArray[safeLimit + 1]; 890 var distDiff = d2 - d1; 891 892 var e1 = elevationData[safeLimit] ? elevationData[safeLimit][1] : 0; 893 var e2 = elevationData[safeLimit + 1] ? elevationData[safeLimit + 1][1] : 0; 894 var eleDiff = e2 - e1; 895 896 var slope = 0; 897 if (distDiff > 0) slope = (eleDiff / distDiff) * 100; 898 899 baseColor = '#3498db'; 900 if (slope > 2.2) baseColor = '#e23126'; 901 else if (slope < -2.2) baseColor = '#2ecc71'; 902 } 903 904 var segmentDist = accumPxArray[safeLimit]; 905 var saturationFactor = 1.0; 906 var opacity = 0.9; 907 if (segmentDist > startFadePx) { 908 if (segmentDist >= endFadePx) { 909 saturationFactor = 0.7; 910 opacity = 0.63; 911 } else { 912 var t = (segmentDist - startFadePx) / (endFadePx - startFadePx); 913 saturationFactor = 1.0 - t * 0.3; 914 opacity = 0.9 - t * 0.27; 915 } 916 } 917 var color = adjustColor(baseColor, saturationFactor); 918 919 if (color !== currentColor || Math.abs(opacity - currentOpacity) > 0.01) { 920 if (currentSegment.length > 0) { 921 currentSegment.push(p1); 922 addSegmentToGroup(currentSegment, currentColor, currentOpacity); 923 } 924 currentSegment = [p1]; 925 currentColor = color; 926 currentOpacity = opacity; 927 } 928 currentSegment.push(p2); 929 } 930 931 if (currentSegment.length > 0) { 932 addSegmentToGroup(currentSegment, currentColor, currentOpacity); 933 } 934 } 935 936 function updateMapFromProgress(val, rangeVal) { 937 if (allPoints.length === 0) return; 938 var pct = val / maxProgress; 939 var totalDist = distArray[allPoints.length - 1] || 0; 940 var targetDist = pct * totalDist; 941 942 var targetRes = getPointAndIndexAtDistance(targetDist);
943 if (runnerMarker && targetRes) { 944 runnerMarker.setLatLng(targetRes.point); 945 if (isFocusLocked) { 946 map.setView(targetRes.point, map.getZoom(), { animate: false }); 947 } 948 } 949 950 drawGradientPolyline(targetDist); 951 952 if (distDisplay) { 953 var d = targetDist * distScaleFactor; 954 distDisplay.innerText = (d < 1000 ? Math.round(d) + ' m' : (d / 1000).toFixed(2).replace('.', ',') + ' km'); 955 } 956 if (eleCurrentDisplay && elevationData && elevationData.length > 0) { 957 var targetKm = pct * totalDistanceKm; 958 var ele = getElevationAtDistance(targetKm); 959 eleCurrentDisplay.innerText = Math.round(ele) + ' moh'; 960 } 961 962 if (wrapper) { 963 var dVal = targetDist * distScaleFactor; 964 var kmNodes = wrapper.querySelectorAll('.race-km-marker'); 965 kmNodes.forEach(function (node) { 966 var kmVal = parseInt(node.textContent, 10); 967 if (!isNaN(kmVal) && (dVal / 1000) >= kmVal) { 968 node.classList.add('active'); 969 } else { 970 node.classList.remove('active'); 971 } 972 }); 973 } 974 975 drawElevationProfile(pct, rangeVal); 976 } 977 978 function animate() { 979 if (!isPlaying) return; 980 981 var now = performance.now(); 982 var elapsedMs = now - sessionStartTime; 983 var t = Math.min(1, elapsedMs / sessionDurationMs); 984 985 var easedT = easeInOutCubicMixed(t); 986 currentProgress = sessionStartProgress + easedT * (maxProgress - sessionStartProgress); 987 988 updateMapFromProgress(currentProgress, getCurrentRoute().elevation_range); 989 990 if (t < 1) { 991 animationId = requestAnimationFrame(animate); 992 } else { 993 currentProgress = maxProgress; 994 updateMapFromProgress(currentProgress, getCurrentRoute().elevation_range); 995 pause(); 996 } 997 } 998 999 if (eleCanvas) { 1000 var drag = function (e) { 1001 var rect = eleCanvas.getBoundingClientRect(); 1002 var x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left; 1003 var pct = Math.max(0, Math.min(1, x / rect.width)); 1004 currentProgress = Math.floor(pct * maxProgress); 1005 savedProgress = currentProgress; // Update saved state on click/drag 1006 updateMapFromProgress(currentProgress, getCurrentRoute().elevation_range); 1007 }; 1008 1009 var hover = function (e) { 1010 if (isPlaying) return; // Don't interrupt if playing 1011 var rect = eleCanvas.getBoundingClientRect(); 1012 var x = e.clientX - rect.left; 1013 var pct = Math.max(0, Math.min(1, x / rect.width)); 1014 var hoverProgress = Math.floor(pct * maxProgress); 1015 updateMapFromProgress(hoverProgress, getCurrentRoute().elevation_range); 1016 }; 1017 1018 eleCanvas.addEventListener('mousedown', function (e) { pause(); drag(e); }); 1019 eleCanvas.addEventListener('mousemove', function (e) { 1020 if (e.buttons === 1) { drag(e); } 1021 else { hover(e); } 1022 }); 1023 eleCanvas.addEventListener('mouseleave', function (e) { 1024 if (!isPlaying) { 1025 currentProgress = savedProgress; // Reset to saved state when mouse leaves 1026 updateMapFromProgress(currentProgress, getCurrentRoute().elevation_range); 1027 } 1028 }); 1029 1030 eleCanvas.addEventListener('touchstart', function (e) { pause(); drag(e); }, { passive: false }); 1031 eleCanvas.addEventListener('touchmove', function (e) { drag(e); }, { passive: false }); 1032 } 1033 1034 if (playBtn) playBtn.addEventListener('click', togglePlay); 1035 1036 wrapper.setAttribute('tabindex', '0'); 1037 document.addEventListener('keydown', function (e) { 1038 if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) return; 1039 if (e.code === 'Space' || e.key === ' ' || e.keyCode === 32) { 1040 if (wrapper.matches(':hover') || wrapper.contains(document.activeElement)) { 1041 e.preventDefault(); 1042 togglePlay(); 1043 } 1044 } 1045 }); 1046 1047 function getCurrentRoute() { 1048 var idx = select ? select.value : initialIndex; 1049 return routes[idx]; 1050 } 1051 1052 function getAllCoordinates(layer, arr) { 1053 if (layer instanceof L.Polyline) { 1054 var latlngs = layer.getLatLngs(); 1055 flattenCoordinates(latlngs, arr); 1056 } else if (layer.getLayers) {
1057 layer.getLayers().forEach(function (subLayer) { 1058 getAllCoordinates(subLayer, arr); 1059 }); 1060 } 1061 } 1062 1063 function flattenCoordinates(obj, arr) { 1064 if (Array.isArray(obj)) { 1065 if (obj.length > 0 && (obj[0].lat !== undefined || Array.isArray(obj[0]))) { 1066 obj.forEach(function (item) { flattenCoordinates(item, arr); }); 1067 } 1068 } else if (obj && obj.lat !== undefined) { 1069 arr.push(obj); 1070 } 1071 } 1072 1073 // --- CORE RENDERING FUNCTION (DYNAMIC) --- 1074 function renderSmartMarkers() { 1075 fixedMarkerGroup.clearLayers(); 1076 customMarkerGroup.clearLayers(); 1077 1078 var placedRects = []; 1079 var baseLineHeight = 25; 1080 var stepHeight = 35; 1081 var horizontalBuffer = 20; // Pixels buffer between markers 1082 var verticalBuffer = 10; // Pixels buffer for vertical detection 1083 1084 // Sort to ensure consistent rendering order (optional) 1085 // allMarkerDefs.sort((a,b) => a.latlng.lat - b.latlng.lat); 1086 1087 allMarkerDefs.forEach(function (def) { 1088 var pLatLng = L.latLng(def.latlng); 1089 1090 // Convert to screen pixels 1091 var point = map.latLngToContainerPoint(pLatLng); 1092 var currentLineHeight = baseLineHeight; 1093 1094 if (def.type === 'km') { 1095 // KM markers are short/ground level 1096 currentLineHeight = 0; 1097 } else { 1098 // Logic for Custom / Start / Finish markers 1099 var found = false; 1100 var testHeight = baseLineHeight; 1101 1102 while (!found) { 1103 var isBlocked = placedRects.some(function (rect) { 1104 // Check simple bounding box collision in screen space 1105 var xDist = Math.abs(rect.x - point.x); 1106 // Vertical overlap check: close in height? 1107 var yDist = Math.abs(rect.height - testHeight); 1108 1109 // If horizontally close AND vertically close -> Collision 1110 return (xDist < horizontalBuffer && yDist < verticalBuffer); 1111 }); 1112 1113 if (!isBlocked) { 1114 currentLineHeight = testHeight; 1115 found = true; 1116 } else { 1117 testHeight += stepHeight; 1118 } 1119 1120 // Safety cap 1121 if (testHeight > 300) { 1122 currentLineHeight = baseLineHeight; 1123 found = true; 1124 } 1125 } 1126 } 1127 1128 // Store this placement 1129 placedRects.push({ x: point.x, height: currentLineHeight }); 1130 1131 // Add to Map 1132 var targetGroup = (def.type === 'custom' || def.type === 'coord') ? customMarkerGroup : fixedMarkerGroup; 1133 1134 if (def.type === 'km') { 1135 var kmm = L.marker(def.latlng, { 1136 icon: L.divIcon({ 1137 className: 'race-km-marker', 1138 html: '<span style="display:inline-block; transform:translateY(1px);">' + def.text + '</span>', 1139 iconSize: [20, 20], 1140 iconAnchor: [10, 10] 1141 }), 1142 zIndexOffset: 2000 // KM markers above pins (which are ~1000) 1143 }).addTo(targetGroup); 1144 1145 var passedKm = parseInt(def.text, 10); 1146 var activeRoute = getCurrentRoute(); 1147 var totalRouteKm = (activeRoute && activeRoute.override_dist && parseFloat(activeRoute.override_dist) > 0) ? (parseFloat(activeRoute.override_dist) / 1000) : totalDistanceKm; 1148 var remainingKm = Math.max(0, totalRouteKm - passedKm).toFixed(1); 1149 1150 var remainingText = ''; 1151 if (parseFloat(remainingKm) <= 0.1) { 1152 remainingText = 'MÃ¥lstreken er rett rundt hjørnet!'; 1153 } else { 1154 remainingText = 'GjenstÃ¥r: <strong>' + remainingKm + ' km</strong> til mÃ¥l'; 1155 } 1156 1157 var popupContent = '<div style="text-align:center; font-family:var(--rm-font, sans-serif); padding:6px 10px; min-width:140px;">' + 1158 '<div style="font-size:13px; font-weight:bold; color:var(--rm-primary, #e23126); margin-bottom:4px;">ð ' + passedKm + ' km-merket</div>' + 1159 '<div style="font-size:12px; color:var(--rm-text, #333); margin-bottom:3px;">Løpt: <strong>' + passedKm + ' km</strong></div>' + 1160 '<div style="font-size:12px; color:var(--rm-text, #333);">
1160' + remainingText + '</div>' + 1161 '</div>'; 1162 kmm.bindPopup(popupContent, { 1163 closeButton: false, 1164 autoClose: true, 1165 className: 'race-km-dir-popup' 1166 }); 1167 } else if ((def.type === 'start' || def.type === 'finish') && def.style === 'arch') { 1168 createArchMarker(def.latlng, def.type === 'start' ? 'START' : 'MÃ L', targetGroup, def.text, def.photo_url); 1169 } else { 1170 createPinMarker(def.latlng, def.emoji, def.text, currentLineHeight, targetGroup, def.photo_url); 1171 } 1172 }); 1173 } 1174 1175 // Re-render on zoom end to fix overlaps 1176 map.on('zoomend', function () { 1177 renderSmartMarkers(); 1178 }); 1179 1180 function loadRoute(index, isInitialLoad) { 1181 var route = routes[index]; 1182 if (!route || !route.gpx) { 1183 console.error("Race Map: Missing GPX data for route index", index); 1184 return; 1185 } 1186 1187 pause(); 1188 currentProgress = 0; 1189 savedProgress = 0; 1190 wrapper.classList.add('loading'); 1191 1192 if (gpxLayer) { map.removeLayer(gpxLayer); gpxLayer = null; } 1193 if (runnerMarker) { map.removeLayer(runnerMarker); runnerMarker = null; } 1194 1195 // Clear old data 1196 allMarkerDefs = []; 1197 1198 if (titleDisplay) titleDisplay.innerText = route.title; 1199 if (descDisplay) descDisplay.innerHTML = route.description ? route.description.replace(/\\n
1199/g, '<br>') : ''; 1200 if (autoStatsDisplay) autoStatsDisplay.innerHTML = ''; 1201 if (autoDirectionsDisplay) autoDirectionsDisplay.innerHTML = ''; 1202 1203 if (infoOverlay) { 1204 infoOverlay.classList.add('hidden'); 1205 infoOverlay.classList.add('collapsed'); 1206 infoOverlay.classList.remove('expanded'); 1207 if (toggleBtn) { 1208 var hasDesc = route.description && route.description.trim().length > 0; 1209 var showAutoS = route.show_auto_stats !== 0 && route.show_auto_stats !== '0'; 1210 var showAutoD = route.show_auto_directions !== 0 && route.show_auto_directions !== '0'; 1211 if (!hasDesc && !route.title && !showAutoS && !showAutoD) { 1212 toggleBtn.style.display = 'none'; 1213 } else { 1214 toggleBtn.style.display = 'flex'; 1215 } 1216 } 1217 } 1218 1219 if (emojiToggleBtn && emojiOverlay && emojiListDisplay) { 1220 emojiOverlay.classList.add('hidden'); 1221 emojiOverlay.classList.add('collapsed'); 1222 emojiOverlay.classList.remove('expanded'); 1223 emojiListDisplay.innerHTML = ''; 1224 1225 if (route.coord_markers && route.coord_markers.length > 0) { 1226 emojiToggleBtn.style.display = 'flex'; 1227 route.coord_markers.forEach(function (cm) { 1228 if (cm.lat && cm.lng && cm.text) { 1229 var btn = document.createElement('button'); 1230 btn.className = 'race-emoji-listItem'; 1231 btn.style.cssText = 'display:flex; align-items:center; gap:8px; background:rgba(128,128,128,0.2); border:none; padding:6px 10px; border-radius:4px; color:inherit; cursor:pointer; text-align:left; transition:background 0.2s'; 1232 btn.onmouseover = function () { btn.style.background = 'var(--rm-primary)'; btn.style.color = '#fff'; }; 1233 btn.onmouseout = function () { btn.style.background = 'rgba(128,128,128,0.2)'; btn.style.color = 'inherit'; }; 1234 btn.innerHTML = '<span style="font-size:18px;">' + (cm.emoji || 'ð') + '</span><span>
1234' + cm.text + '</span>'; 1235 btn.onclick = function () { 1236 map.flyTo([cm.lat, cm.lng], 16, { animate: true, duration: 1 }); 1237 emojiCloseBtn.click(); // Auto-close to reveal pin 1238 }; 1239 emojiListDisplay.appendChild(btn); 1240 } 1241 }); 1242 } else { 1243 emojiToggleBtn.style.display = 'none'; 1244 } 1245 } 1246 1247 // Update GPX download button visibility 1248 if (downloadBtn) { 1249 if (route.allow_gpx == 1) { 1250 downloadBtn.href = route.gpx; 1251 downloadBtn.style.display = 'flex'; 1252 } else { 1253 downloadBtn.style.display = 'none'; 1254 } 1255 } 1256 1257 if (eleGainDisplay) eleGainDisplay.style.display = (route.show_elevation == 1) ? 'block' : 'none'; 1258 1259 // We treat ele_current default as visible (1) for older routes that don't have it set. 1260 // However, the admin side sets it if saved. If undefined, default to true. 1261 var showEleCurrent = route.show_ele_current !== undefined ? route.show_ele_current : 1; 1262 if (eleCurrentDisplay) eleCurrentDisplay.style.display = (showEleCurrent == 1) ? 'block' : 'none'; 1263 1264 new L.GPX(route.gpx, { 1265 async: true, 1266 marker_options: { 1267 startIcon: nullIcon, 1268 endIcon: nullIcon, 1269 shadowUrl: '', 1270 wptIcons: { '': nullIcon } 1271 }, 1272 polyline_options: { color: '#888', opacity: 0.6, weight: 5, fill: false, className: 'race-route-polyline' } 1273 }).addTo(map).on('loaded', function (e) { 1274 try { 1275 gpxLayer = e.target; 1276 1277 // --- MAGIC OFFSET (RIGHT SIDE DRIVE) --- 1278 var laneOffsetMeters = 1; // 1 meter right 1279 var applyOffset = function(latlngs) { 1280 if (!latlngs || latlngs.length < 2) return latlngs; 1281 if (Array.isArray(latlngs[0]) || !latlngs[0].lat) { 1282 return latlngs.map(applyOffset); 1283 } 1284 var offsetPath = []; 1285 var refLat = latlngs[0].lat * Math.PI / 180; 1286 var mPerLat = 111320; 1287 var mPerLng = 111320 * Math.cos(refLat); 1288 1289 // Multi-lap tracker 1290 var grid = {}; 1291 var pathScores = new Array(latlngs.length).fill(1); 1292 var getGridKey = function(lat, lng) { 1293 return Math.round(lat / 0.0001) + '_' + Math.round(lng / 0.0001); // ~11m prec 1294 }; 1295 for (var j = 0; j < latlngs.length; j++) { 1296 var key = getGridKey(latlngs[j].lat, latlngs[j].lng); 1297 if (!grid[key]) { 1298 grid[key] = { lastIndex: j, passes: 1 }; 1299 } else { 1300 if (j - grid[key].lastIndex > 40) { // Gap ~40 pts -> new pass 1301 grid[key].passes += 1; 1302 } 1303 grid[key].lastIndex = j; 1304 } 1305 pathScores[j] = grid[key].passes; 1306 } 1307 1308 // Smooth the lap multiplier 1309 var wSize = 25; 1310 var s1 = new Array(latlngs.length); 1311 for (var j = 0; j < latlngs.length; j++) { 1312 var sum = 0, count = 0; 1313 for (var k = Math.max(0, j - wSize); k <= Math.min(latlngs.length - 1, j + wSize); k++) { 1314 sum += pathScores[k]; count++; 1315 } 1316 s1[j] = sum / count; 1317 } 1318 var s2 = new Array(latlngs.length); 1319 for (var j = 0; j < latlngs.length; j++) { 1320 var sum = 0, count = 0; 1321 for (var k = Math.max(0, j - wSize); k <= Math.min(latlngs.length - 1, j + wSize); k++) { 1322 sum += s1[k]; count++; 1323 } 1324 s2[j] = sum / count; 1325 } 1326 1327 var getNormal = function(p1, p2) { 1328 var dx = (p2.lng - p1.lng) * mPerLng; 1329 var dy = (p2.lat - p1.lat) * mPerLat; 1330 var len = Math.sqrt(dx*dx + dy*dy); 1331 if (len < 0.0001 || isNaN(len)) return {x: 0, y: 0}; 1332 return { x: dy / len, y: -dx / len }; 1333 }; 1334 1335 var normals = []; 1336 for (var j = 0; j < latlngs.length - 1; j++) { 1337 normals.push(getNormal(latlngs[j], latlngs[j+1])); 1338 } 1339 1340 for (var j = 0; j < latlngs.length; j++) { 1341 var nx = 0, ny = 0; 1342 if (j === 0) { 1343 nx = normals[0].x; ny = normals[0].y; 1344 } else if (j === latlngs.length - 1) { 1345 nx = normals[normals.length - 1].x; ny = normals[normals.length - 1].y; 1346 } else { 1347 var n1 = normals[j-1]; 1348 var n2 = normals[j]; 1349 nx = n1.x + n2.x; 1350 ny = n1.y + n2.y; 1351 var nLen = Math.sqrt(nx*nx + ny*ny); 1352 if (nLen < 0.0001 || isNaN(nLen)) { 1353 nx = n1.x; ny = n1.y; 1354 } else { 1355 nx /= nLen;
1355 ny /= nLen; 1356 } 1357 } 1358 var dynamicOffset = laneOffsetMeters * s2[j]; // Shift extra per lap 1359 var offLng = latlngs[j].lng + (nx * dynamicOffset) / mPerLng; 1360 var offLat = latlngs[j].lat + (ny * dynamicOffset) / mPerLat; 1361 offsetPath.push(L.latLng(offLat, offLng)); 1362 } 1363 return offsetPath; 1364 }; 1365 var offsetLayerRec = function(layer) { 1366 if (layer instanceof L.Polyline) { 1367 layer.setLatLngs(applyOffset(layer.getLatLngs())); 1368 } else if (layer.getLayers) { 1369 layer.getLayers().forEach(offsetLayerRec); 1370 } 1371 }; 1372 offsetLayerRec(gpxLayer); 1373 // --- END MAGIC OFFSET --- 1374 1375 var bounds = gpxLayer.getBounds(); 1376 if (route.coord_markers && route.coord_markers.length > 0) { 1377 for (var cmi = 0; cmi < route.coord_markers.length; cmi++) { 1378 var bcm = route.coord_markers[cmi]; 1379 var bLat = parseFloat(bcm.lat); 1380 var bLng = parseFloat(bcm.lng); 1381 if (!isNaN(bLat) && !isNaN(bLng) && (Math.abs(bLat) > 0.0001 || Math.abs(bLng) > 0.0001)) { 1382 if (bounds && bounds.isValid()) { 1383 bounds.extend([bLat, bLng]); 1384 } 1385 } 1386 } 1387 } 1388 1389 if (bounds && bounds.isValid()) { 1390 currentRouteBounds = bounds; 1391 map.invalidateSize(); 1392 var fitOptions = getFitBoundsOptions(route); 1393 if (isInitialLoad) { 1394 map.fitBounds(bounds, fitOptions); 1395 } else { 1396 map.flyToBounds(bounds, Object.assign({ duration: 1.5, easeLinearity: 0.25 }, fitOptions)); 1397 } 1398 1399 // Robust fallback: re-run invalidateSize and fitBounds after container finishes layout / styles 1400 setTimeout(function () { 1401 map.invalidateSize(); 1402 if (isInitialLoad && bounds.isValid()) { 1403 map.fitBounds(bounds, getFitBoundsOptions(route)); 1404 } 1405 }, 250); 1406 } 1407 1408 if (zoomSlider) { 1409 zoomSlider.min = map.getMinZoom(); 1410 zoomSlider.max = map.getMaxZoom(); 1411 zoomSlider.value = map.getZoom(); 1412 } 1413 1414 elevationData = gpxLayer.get_elevation_data() || []; 1415 totalDistanceKm = (gpxLayer.get_distance() || 0) / 1000; 1416 var realMeters = gpxLayer.get_distance() || 0; 1417 1418 if (realMeters > 0) { 1419 distScaleFactor = route.override_dist ? (parseFloat(route.override_dist) / realMeters) : 1; 1420 } else { 1421 distScaleFactor = 1; 1422 } 1423 1424 allPoints = []; 1425 getAllCoordinates(gpxLayer, allPoints); 1426 1427 if (allPoints.length > 0) { 1428 distArray = [0]; 1429 for (var i = 1; i < allPoints.length; i++) { 1430 distArray.push(distArray[i - 1] + allPoints[i].distanceTo(allPoints[i - 1])); 1431 } 1432 1433 var runnerIcon = L.divIcon({ 1434 className: 'runner-icon', 1435 html: '<div style="width:14px;height:14px;background:#fff;border:3px solid #e23126;border-radius:50%;"></div>', 1436 iconSize: [14, 14], iconAnchor: [7, 7] 1437 });
1438 runnerMarker = L.marker(allPoints[0], { icon: runnerIcon, zIndexOffset: 1000 }).addTo(map); 1439 1440 // --- BUILD MARKER DEFINITIONS --- 1441 var startEmoji = route.start_emoji || 'â±ï¸'; 1442 var startText = route.start_text || 'Start'; 1443 var finishEmoji = route.finish_emoji || 'ð'; 1444 var finishText = route.finish_text || 'MÃ¥l'; 1445 1446 // Start 1447 allMarkerDefs.push({ latlng: allPoints[0], type: 'start', emoji: startEmoji, text: startText, photo_url: route.start_photo, style: route.start_finish_style || 'emoji' }); 1448 1449 // KM Markers 1450 var nextKm = 1; 1451 for (var i = 1; i < allPoints.length; i++) { 1452 var d = distArray[i] * distScaleFactor; 1453 var pd = distArray[i - 1] * distScaleFactor; 1454 if (d >= nextKm * 1000 && pd < nextKm * 1000) { 1455 var p1 = allPoints[Math.max(0, i - 1)]; 1456 var p2 = allPoints[Math.min(allPoints.length - 1, i + 1)]; 1457 var lat1 = p1.lat * Math.PI / 180; 1458 var lat2 = p2.lat * Math.PI / 180; 1459 var dlng = (p2.lng - p1.lng) * Math.PI / 180; 1460 var y = Math.sin(dlng) * Math.cos(lat2); 1461 var x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dlng); 1462 var brng = (Math.atan2(y, x) * 180 / Math.PI); 1463 1464 allMarkerDefs.push({ latlng: allPoints[i], type: 'km', text: nextKm, brng: brng }); 1465 nextKm++; 1466 } 1467 } 1468 1469 // Custom Distance Markers 1470 if (route.markers && route.markers.length > 0) { 1471 var nextM_idx = 0; 1472 for (var i = 1; i < allPoints.length; i++) { 1473 var d = distArray[i] * distScaleFactor; 1474 var pd = distArray[i - 1] * distScaleFactor; 1475 1476 while (nextM_idx < route.markers.length) { 1477 var m = route.markers[nextM_idx]; 1478 if (d >= m.dist && pd < m.dist) { 1479 allMarkerDefs.push({ latlng: allPoints[i], type: 'custom', emoji: m.emoji, text: m.text, photo_url: m.photo_url }); 1480 nextM_idx++; 1481 } else if (m.dist < pd) { 1482 nextM_idx++; 1483 } else { 1484 break; 1485 } 1486 } 1487 } 1488 } 1489 1490 // Coordinate Markers 1491 if (route.coord_markers && route.coord_markers.length > 0) { 1492 route.coord_markers.forEach(function (cm) { 1493 if (cm.lat && cm.lng) { 1494 allMarkerDefs.push({ 1495 latlng: [cm.lat, cm.lng], 1496 type: 'coord', 1497 emoji: cm.emoji, 1498 text: cm.text, 1499 photo_url: cm.photo_url 1500 }); 1501 } 1502 }); 1503 } 1504 1505 // Finish 1506 allMarkerDefs.push({ latlng: allPoints[allPoints.length - 1], type: 'finish', emoji: finishEmoji, text: finishText, photo_url: route.finish_photo, style: route.start_finish_style || 'emoji' }); 1507 1508 // --- GPX CLICK EVENT --- 1509 gpxLayer.on('click', function(ev) { 1510 var clickPoint = ev.latlng; 1511 1512 // Project the clicked point onto the closest line segment for maximum accuracy and granularity 1513 var clickPointPx = map.latLngToContainerPoint(clickPoint); 1514 var minPixelDistSq = Infinity; 1515 var bestJ = 0; 1516 var bestT = 0; 1517 1518 for (var j = 0; j < allPoints.length - 1; j++) { 1519 var v1 = map.latLngToContainerPoint(allPoints[j]); 1520 var v2 = map.latLngToContainerPoint(allPoints[j + 1]); 1521 1522 var dx = v2.x - v1.x; 1523 var dy = v2.y - v1.y; 1524 var len2 = dx * dx + dy * dy; 1525 1526 var t = 0; 1527 if (len2 > 0) { 1528 t = ((clickPointPx.x - v1.x) * dx + (clickPointPx.y - v1.y) * dy) / len2; 1529 t = Math.max(0, Math.min(1, t)); 1530 } 1531 1532 var px = v1.x + t * dx; 1533 var py = v1.y + t * dy; 1534 1535 var distSq = (clickPointPx.x - px) * (clickPointPx.x - px) + (clickPointPx.y - py) * (clickPointPx.y - py); 1536 if (distSq < minPixelDistSq) { 1537 minPixelDistSq = distSq; 1538 bestJ = j; 1539 bestT = t; 1540 } 1541 } 1542 1543 // Calculate exact distance using interpolation 1544 var segmentMeters = allPoints[bestJ].distanceTo(allPoints[bestJ + 1]); 1545 var exactMeters = distArray[bestJ] + bestT * segmentMeters; 1546 1547 var distFromStart = exactMeters * distScaleFactor; 1548 var totalDist = distArray[allPoints.length - 1] * distScaleFactor; 1549 var distToFinish = Math.max(0, totalDist - distFromStart); 1550 1551 // Format showing meters below 1000m, and km with 2 decimals for 1000m and above 1552 var formatDistance = function(val) { 1553 if (val < 1000) { 1554 return Math.round(val) + ' m'; 1555 } else { 1556 return (val / 1000).toFixed(2).replace('.', ',') + ' km'; 1557 } 1558 }; 1559 1560 var closestIdx = bestT < 0.5 ? bestJ : bestJ + 1; 1561 var partText = ''; 1562 var hasEle = (elevationData && elevationData.length > 0); 1563 if (hasEle) { 1564 var idx = closestIdx; 1565 if (idx >= allPoints.length - 1) idx = allPoints.length - 2; 1566 if (idx < 0) idx = 0; 1567 1568 var d1 = distArray[idx]; 1569 var d2 = distArray[idx + 1]; 1570 var distDiff = d2 - d1; 1571 1572 var e1 = elevationData[idx] ? elevationData[idx][1] : 0; 1573 var e2 = elevationData[idx + 1] ? elevationData[idx + 1][1] : 0; 1574 var eleDiff = e2 - e1; 1575 1576 var slope = 0; 1577 if (distDiff > 0) slope = (eleDiff / distDiff) * 100; 1578 1579 if (slope > 2.2) { 1580 partText = '<div style="margin-top:8px; display:flex; align-items:center; gap:6px;">' + 1581 '<span style="font-size:16px;">ð´</span> <b>Ãkt belastning</b>' + 1582 '</div>'; 1583 } else if (slope < -2.2) { 1584 partText = '<div style="margin-top:8px; display:flex; align-items:center; gap:6px;">' + 1585 '<span style="font-size:16px;">ð¢</span> <b>Energibesparende parti</b>' + 1586 '</div>'; 1587 } else { 1588 partText = '<div style="margin-top:8px; display:flex; align-items:center; gap:6px;">' + 1589 '<span style="font-size:16px;">ðµ</span> <b>Normal belastning</b>' + 1590 '</div>'; 1591 } 1592 } 1593 1594 var popupContent = '<div style="text-align:left; font-family:var(--rm-font, sans-serif); font-size:13px; padding: 4px; line-height:1.4;">' + 1595 '<div style="margin-bottom:6px;"><b>Fra start:</b> ' + formatDistance(distFromStart) + '</div>' + 1596 '<div style="margin-bottom:6px;"><b>Til mÃ¥l:</b> ' + formatDistance(distToFinish) + '</div>'; 1597 1598 if (hasEle) { 1599 popupContent += '<hr style="border:none; border-top:1px solid rgba(128,128,128,0.2); margin:8px 0;">' + 1600 partText + 1601 '<div style="font-size:11px; color:rgba(128,128,128,0.85); margin-top:8px; line-height:1.35;">' + 1602 'Fargene viser hvor krevende de ulike partiene oppleves. Grønt er lett, blÃ¥tt er jevnt, rødt er mer krevende â sÃ¥ du kan planlegge kreftene smart gjennom løypa.' + 1603 '</div>'; 1604 } 1605 1606 popupContent += '</div>'; 1607 1608 L.popup({ 1609 closeButton: true, 1610 autoClose: true, 1611 className: 'race-gpx-click-popup' 1612 }) 1613 .setLatLng(clickPoint) 1614 .setContent(popupContent) 1615 .openOn(map); 1616 }); 1617 1618 // Initial Render 1619 renderSmartMarkers(); 1620 currentProgress = maxProgress; // Show track as 100% finished initially 1621 savedProgress = currentProgress; 1622 if (lockBtn) lockBtn.style.display = 'flex'; // Reveal the lock button 1623 updateMapFromProgress(currentProgress, route.elevation_range); 1624 pause(); // Ensure it state is paused 1625 } else { 1626 console.error("Race Map: No points found in GPX"); 1627 } 1628 1629 if (route.show_elevation == 1 && eleGainDisplay) { 1630 try { 1631 var gain = gpxLayer.get_elevation_gain() || 0; 1632 eleGainDisplay.innerText = Math.round(gain) + ' Høydemeter'; 1633 } catch (err) { 1634 eleGainDisplay.innerText = '0 Høydemeter'; 1635 } 1636 } 1637 1638 // Update info overlay auto stats and turn-by-turn route description 1639 updateInfoOverlayContent(route, gpxLayer, allPoints, elevationData); 1640 } catch (fatalError) { 1641 console.error('Race Map Fatal Error during parsing:', fatalError); 1642 } finally { 1643 wrapper.classList.remove('loading'); 1644 resizeCanvas(); 1645 } 1646 }).on('error', function (e) { 1647 console.error('Race Map GPX Error:', e); 1648 wrapper.classList.remove('loading'); 1649 }); 1650 } 1651 1652 // --- PIN MARKER FUNCTION --- 1653 function createPinMarker(latlng, emoji, text, lineHeight, targetGroup, photoUrl) { 1654 if (!latlng) return; 1655 1656 var totalHeight = 30 + lineHeight; 1657 1658 // Defaults to camera emoji if photo exists and emoji is empty 1659 var displayEmoji = emoji; 1660 if (photoUrl && !displayEmoji) { 1661 displayEmoji = 'ð¸'; 1662 } 1663 1664 var html = '<div class="race-pin-wrapper" style="height:' + totalHeight + 'px;">' + 1665 ' <div class="race-pin-emoji"' + (photoUrl ? ' style="cursor:pointer;"' : '') + '>
1665' + displayEmoji + '</div>' + 1666 ' <div class="race-pin-line"></div>' + 1667 '</div>'; 1668 1669 var icon = L.divIcon({ 1670 className: 'race-marker-container', 1671 html: html, 1672 iconSize: [0, 0], // CSS handles size 1673 iconAnchor: [0, 0] // CSS handles positioning 1674 }); 1675 1676 // Z-INDEX: Short markers FRONT, Tall markers BACK 1677 var zIndex = 1000 - lineHeight; 1678 1679 var m = L.marker(latlng, { icon: icon, zIndexOffset: zIndex }).addTo(targetGroup); 1680 1681 if (photoUrl) { 1682 m.on('click', function () { 1683 var img = photoPopup.querySelector('img'); 1684 var caption = photoPopup.querySelector('.race-photo-caption'); 1685 img.src = photoUrl; 1686 if (text) { 1687 caption.innerText = text; 1688 caption.style.display = 'block'; 1689 } else { 1690 caption.style.display = 'none'; 1691 } 1692 photoPopup.classList.remove('hidden'); 1693 }); 1694 } 1695 1696 if (text) { 1697 m.bindTooltip(text, { 1698 direction: 'top', 1699 offset: [0, -totalHeight], // Offset relative to bottom anchor 1700 className: 'race-map-tooltip' 1701 }); 1702 } 1703 } 1704 1705 // --- ARCH MARKER FUNCTION --- 1706 function createArchMarker(latlng, label, targetGroup, text, photoUrl) { 1707 if (!latlng) return; 1708 1709 // Render beautiful SVG arch 1710 var html = '<div class="race-arch-wrapper" style="width:60px; height:40px; margin-left:-30px; margin-top:-40px; pointer-events:auto;' + (photoUrl ? ' cursor:pointer;' : '') + '">' + 1711 '<svg width="60" height="40" viewBox="0 0 60 40" xmlns="http://www.w3.org/2000/svg">' + 1712 ' <!-- Shadow -->' + 1713 ' <ellipse cx="30" cy="38" rx="20" ry="2" fill="rgba(0,0,0,0.3)"/>' + 1714 ' <!-- Arch Legs -->' + 1715 ' <path d="M 15,38 L 15,18 A 15,15 0 0,1 45,18 L 45,38" fill="none" stroke="var(--rm-primary, #e23126)" stroke-width="7" stroke-linecap="square"/>' + 1716 ' <path d="M 15,38 L 15,18 A 15,15 0 0,1 45,18 L 45,38" fill="none" stroke="#ffffff" stroke-width="2.5" stroke-linecap="square"/>' + 1717 ' <!-- Banner Sign -->' + 1718 ' <rect x="16" y="10" width="28" height="9" rx="1" fill="var(--rm-primary, #e23126)" stroke="#ffffff" stroke-width="1"/>' + 1719 ' <!-- Banner Text -->' + 1720 ' <text x="30" y="17" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="6" font-weight="bold" fill="#ffffff" text-anchor="middle" letter-spacing="0.5">' + label + '</text>' + 1721 '</svg>' + 1722 '</div>'; 1723 1724 var icon = L.divIcon({ 1725 className: 'race-marker-arch-container', 1726 html: html, 1727 iconSize: [0, 0], 1728 iconAnchor: [0, 0] 1729 }); 1730 1731 var m = L.marker(latlng, { icon: icon, zIndexOffset: 1200 }).addTo(targetGroup); 1732 1733 if (photoUrl) { 1734 m.on('click', function () { 1735 var img = photoPopup.querySelector('img'); 1736 var caption = photoPopup.querySelector('.race-photo-caption'); 1737 img.src = photoUrl; 1738 if (text) { 1739 caption.innerText = text; 1740 caption.style.display = 'block'; 1741 } else { 1742 caption.style.display = 'none'; 1743 } 1744 photoPopup.classList.remove('hidden'); 1745 }); 1746 } 1747 1748 if (text) { 1749 m.bindTooltip(text, { 1750 direction: 'top', 1751 offset: [0, -42], 1752 className: 'race-map-tooltip' 1753 }); 1754 } 1755 } 1756 1757 // --- AUTO INFO & TURN-BY-TURN ROUTE DESCRIPTION GENERATOR --- 1758 function updateInfoOverlayContent(route, gpxLayer, allPoints, elevationData) { 1759 var hasManualDesc = route.description && route.description.trim().length > 0; 1760 var showStats = route.show_auto_stats !== 0 && route.show_auto_stats !== '0'; 1761 var showDirections = route.show_auto_directions !== 0 && route.show_auto_directions !== '0'; 1762 1763 if (toggleBtn) { 1764 if (hasManualDesc || showStats || showDirections || route.title) { 1765 toggleBtn.style.display = 'flex'; 1766 } else { 1767 toggleBtn.style.display = 'none'; 1768 } 1769 } 1770 1771 // 1. Render Auto Stats 1772 if (autoStatsDisplay) { 1773 if (showStats && gpxLayer && allPoints && allPoints.length > 0) { 1774 var realMeters = gpxLayer.get_distance() || 0; 1775 var overrideVal = route.override_dist ? parseFloat(route.override_dist) : 0; 1776 var distMeters = overrideVal > 0 ? overrideVal : realMeters; 1777 var distKm = distMeters / 1000; 1778 var gainM = Math.round(gpxLayer.get_elevation_gain() || 0); 1779 1780 var maxEle = 0; 1781 if (elevationData && elevationData.length > 0) { 1782 for (var e = 0; e < elevationData.length; e++) { 1783 if (elevationData[e] && elevationData[e][1] > maxEle) { 1784 maxEle = Math.round(elevationData[e][1]); 1785 } 1786 } 1787 } else if (allPoints && allPoints[0] && allPoints[0].meta && allPoints[0].meta.ele) { 1788 for (var p = 0; p < allPoints.length; p++) { 1789 if (allPoints[p].meta && allPoints[p].meta.ele > maxEle) maxEle = Math.round(allPoints[p].meta.ele); 1790 } 1791 } 1792 1793 // Estimate time based on 5:30 min/km average pace 1794 var estMins = Math.round(distKm * 5.5); 1795 var estTimeStr = ''; 1796 if (estMins >= 60) { 1797 var hrs = Math.floor(estMins / 60); 1798 var mins = estMins % 60; 1799 estTimeStr = hrs + 't ' + (mins > 0 ? mins + 'm' : ''); 1800 } else { 1801 estTimeStr = estMins + ' min'; 1802 } 1803 1804 // Determine surface profile label 1805 var surfaceLabel = 'Gateløp (Asfalt / Fast dekke)'; 1806 if (route.surface_type === 'asphalt') { 1807 surfaceLabel = 'Gateløp (Fast dekke / Asfalt)'; 1808 } else if (route.surface_type === 'mixed') { 1809 surfaceLabel = 'Blandet underlag (Asfalt og grus)'; 1810 } else if (route.surface_type === 'trail') { 1811 surfaceLabel = 'Terrengløp (Kuperte skogsstier & utmark)'; 1812 } else if (route.surface_type === 'hill') { 1813 surfaceLabel = 'Krevende motbakkeløp'; 1814 } else { 1815 var gainPerKm = distKm > 0 ? (gainM / distKm) : 0; 1816 if (gainPerKm > 40) { 1817 surfaceLabel = 'Kupért terreng / motbakke'; 1818 } else if (gainPerKm > 18) { 1819 surfaceLabel = 'Kuperte veier & stier'; 1820 } else { 1821 surfaceLabel = 'Gateløp (Asfalt / Fast dekke)'; 1822 } 1823 } 1824 1825 var statsHtml = ''; 1826 if (hasManualDesc) { 1827 statsHtml += '<div class="race-info-divider"></div>'; 1828 } 1829 statsHtml += '<div class="race-auto-section-title">ð Løypefakta</div>'; 1830 statsHtml += '<div class="race-stats-grid">'; 1831 statsHtml += ' <div class="race-stat-card"><span class="race-stat-card-label">ð Distanse</span><span class="race-stat-card-value">' + distKm.toFixed(1) + ' km</span></div>'; 1832 statsHtml += ' <div class="race-stat-card"><span class="race-stat-card-label">â°ï¸ Stigning</span><span class="race-stat-card-value">+' + gainM + ' m</span></div>'; 1833 if (maxEle > 0) { 1834 statsHtml += ' <div class="race-stat-card"><span class="race-stat-card-label">
1834ð Max Høyde</span><span class="race-stat-card-value">' + maxEle + ' m.o.h.</span></div>'; 1835 } else { 1836 statsHtml += ' <div class="race-stat-card"><span class="race-stat-card-label">ð Est. Tempo</span><span class="race-stat-card-value">5:30 min/km</span></div>'; 1837 } 1838 statsHtml += ' <div class="race-stat-card"><span class="race-stat-card-label">â±ï¸ Est. Tid</span><span class="race-stat-card-value">ca. ' + estTimeStr + '</span></div>'; 1839 statsHtml += ' <div class="race-stat-card" style="grid-column: span 2;"><span class="race-stat-card-label">ð£ï¸ Underlag & Løypeprofil</span><span class="race-stat-card-value">' + surfaceLabel + '</span></div>'; 1840 statsHtml += '</div>'; 1841 1842 autoStatsDisplay.innerHTML = statsHtml; 1843 autoStatsDisplay.style.display = 'block'; 1844 } else { 1845 autoStatsDisplay.innerHTML = ''; 1846 autoStatsDisplay.style.display = 'none'; 1847 } 1848 } 1849 1850 // 2. Render Turn-by-Turn Directions 1851 if (autoDirectionsDisplay) { 1852 if (showDirections && allPoints && allPoints.length > 1) { 1853 var dirHtml = '<div class="race-info-divider"></div>'; 1854 dirHtml += '<div class="race-auto-section-title">ðºï¸ Løypebeskrivelse</div>'; 1855 dirHtml += '<div class="race-directions-list" id="directions-list-' + mapId + '">'; 1856 dirHtml += ' <div class="race-directions-loading"><div class="race-spinner-mini"></div><span>Henter gatenavn og analyserer løypa...</span></div>'; 1857 dirHtml += '</div>'; 1858 autoDirectionsDisplay.innerHTML = dirHtml; 1859 autoDirectionsDisplay.style.display = 'block'; 1860 1861 generateTurnByTurnDirections(route, gpxLayer, allPoints, mapId); 1862 } else { 1863 autoDirectionsDisplay.innerHTML = ''; 1864 autoDirectionsDisplay.style.display = 'none'; 1865 } 1866 } 1867 } 1868 1869 function generateTurnByTurnDirections(route, gpxLayer, allPoints, mapId) { 1870 var container = document.getElementById('directions-list-' + mapId); 1871 if (!container) return; 1872 1873 var realMeters = (gpxLayer && gpxLayer.get_distance) ? (gpxLayer.get_distance() || 0) : 0; 1874 var overrideVal = route.override_dist ? parseFloat(route.override_dist) : 0; 1875 var scaleFactor = (realMeters > 0 && overrideVal > 0) ? (overrideVal / realMeters) : 1; 1876 1877 var cacheKey = 'hib_route_desc_v5_' + (route.title || 'route').replace(/\W/g, '_') + '_' + allPoints.length + '_' + overrideVal; 1878 var cached = localStorage.getItem(cacheKey); 1879 if (cached) { 1880 try { 1881 var cachedSteps = JSON.parse(cached); 1882 renderTurnByTurnSteps(container, cachedSteps); 1883 return; 1884 } catch (e) {} 1885 } 1886 1887 function getBearing(p1, p2) { 1888 var lat1 = p1.lat * Math.PI / 180; 1889 var lat2 = p2.lat * Math.PI / 180; 1890 var dlng = (p2.lng - p1.lng) * Math.PI / 180; 1891 var y = Math.sin(dlng) * Math.cos(lat2); 1892 var x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dlng); 1893 return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360; 1894 } 1895 1896 function getAngleDiff(b1, b2) { 1897 return (b2 - b1 + 540) % 360 - 180; 1898 } 1899 1900 function detectLaps(pts, totalDistMeters) { 1901 if (!pts || pts.length < 20) return { numLaps: 1, lapDistMeters: totalDistMeters }; 1902 var startPt = pts[0]; 1903 var minStepPoints = Math.floor(pts.length * 0.15); 1904 var passes = 0; 1905 var lastPassIndex = 0; 1906 for (var i = minStepPoints; i < pts.length - 5; i++) { 1907 var d = pts[i].distanceTo(startPt); 1908 if (d < 45 && (i - lastPassIndex) > minStepPoints) { 1909 passes++; 1910 lastPassIndex = i; 1911 } 1912 } 1913 var numLaps = passes > 0 ? (passes + (pts[pts.length - 1].distanceTo(startPt) < 50 ? 1 : 0)) : 1; 1914 return { numLaps: numLaps, lapDistMeters: numLaps > 1 ? (totalDistMeters / numLaps) : totalDistMeters }; 1915 } 1916 1917 var samples = []; 1918 samples.push({ pt: allPoints[0], index: 0, accumDist: 0 }); 1919 1920 var accumDist = 0; 1921 var lastBearing = null; 1922 1923 for (var i = 1; i < allPoints.length; i++) { 1924 var stepDist = allPoints[i].distanceTo(allPoints[i - 1]); 1925 accumDist += stepDist; 1926 1927 var distFromLast = accumDist - samples[samples.length - 1].accumDist; 1928 var currentBearing = getBearing(allPoints[Math.max(0, i - 2)], allPoints[i]); 1929 1930 var isTurn = false;
1931 if (lastBearing !== null) { 1932 var diff = Math.abs(getAngleDiff(lastBearing, currentBearing)); 1933 if (diff > 35 && distFromLast >= 80) { 1934 isTurn = true; 1935 } 1936 } 1937 lastBearing = currentBearing; 1938 1939 if (isTurn || distFromLast >= 300 || i === allPoints.length - 1) { 1940 samples.push({ pt: allPoints[i], index: i, accumDist: accumDist, bearing: currentBearing }); 1941 } 1942 } 1943 1944 if (samples.length > 12) { 1945 var step = (samples.length - 1) / 11; 1946 var reduced = [samples[0]]; 1947 for (var k = 1; k < 11; k++) { 1948 reduced.push(samples[Math.round(k * step)]); 1949 } 1950 reduced.push(samples[samples.length - 1]); 1951 samples = reduced; 1952 } 1953 1954 var promises = samples.map(function (sample) { 1955 var url = 'https://nominatim.openstreetmap.org/reverse?format=json&lat=' + sample.pt.lat + '&lon=' + sample.pt.lng + '&zoom=18&addressdetails=1'; 1956 return fetch(url) 1957 .then(function (res) { return res.json(); }) 1958 .then(function (data) { 1959 var addr = data.address || {}; 1960 var road = addr.road || addr.pedestrian || addr.footway || addr.path || addr.cycleway || addr.suburb || addr.neighbourhood || addr.village || addr.city || ''; 1961 return { sample: sample, road: road.trim() }; 1962 }) 1963 .catch(function () { 1964 return { sample: sample, road: '' }; 1965 }); 1966 }); 1967 1968 Promise.all(promises).then(function (results) { 1969 var segments = []; 1970 var currentSegment = null; 1971 1972 for (var r = 0; r < results.length; r++) { 1973 var item = results[r]; 1974 var roadName = item.road; 1975 var sample = item.sample; 1976 1977 if (r === 0) { 1978 currentSegment = { 1979 road: roadName || 'Startpunkt', 1980 startDist: 0, 1981 endDist: sample.accumDist, 1982 bearing: sample.bearing || 0 1983 }; 1984 } else { 1985 var prevSample = results[r - 1].sample; 1986 if (roadName && currentSegment.road && roadName.toLowerCase() === currentSegment.road.toLowerCase()) { 1987 currentSegment.endDist = sample.accumDist; 1988 currentSegment.bearing = sample.bearing || currentSegment.bearing; 1989 } else { 1990 currentSegment.endDist = sample.accumDist; 1991 segments.push(currentSegment); 1992 currentSegment = { 1993 road: roadName || currentSegment.road || 'veien videre', 1994 startDist: sample.accumDist, 1995 endDist: sample.accumDist, 1996 bearing: sample.bearing || 0, 1997 prevBearing: prevSample.bearing || 0 1998 }; 1999 } 2000 } 2001 } 2002 if (currentSegment) { 2003 segments.push(currentSegment); 2004 } 2005 2006 var steps = []; 2007 var lapData = detectLaps(allPoints, (overrideVal > 0 ? overrideVal : realMeters)); 2008 if (lapData.numLaps > 1) { 2009 var lapKmStr = (lapData.lapDistMeters / 1000).toFixed(1); 2010 steps.push({ 2011 icon: 'ð', 2012 text: 'Løypa løpes i <strong>' + lapData.numLaps + ' runder</strong> (ca. ' + lapKmStr + ' km per runde).' 2013 }); 2014 } 2015 2016 var nextLapTrigger = 2; 2017 2018 for (var s = 0; s < segments.length; s++) { 2019 var seg = segments[s]; 2020 var lengthMeters = Math.round((seg.endDist - seg.startDist) * scaleFactor); 2021 var distText = lengthMeters >= 1000 ? (lengthMeters / 1000).toFixed(1) + ' km' : lengthMeters + ' meter'; 2022 2023 // Insert lap passage step when crossing a lap boundary 2024 if (lapData.numLaps > 1 && nextLapTrigger <= lapData.numLaps) { 2025 var lapThresholdMeters = (nextLapTrigger - 1) * lapData.lapDistMeters; 2026 if ((seg.endDist * scaleFactor) >= lapThresholdMeters && s > 0 && s < segments.length - 1) { 2027 steps.push({ 2028 icon: 'ð', 2029 text: '<strong>Passering start/mÃ¥lomrÃ¥det:</strong> Ut pÃ¥ runde ' + nextLapTrigger + ' av ' + lapData.numLaps + '!' 2030 }); 2031 nextLapTrigger++; 2032 } 2033 } 2034 2035 if (s === 0) { 2036 var startRoadText = (seg.road && seg.road !== 'Startpunkt' && seg.road !== 'veien videre') ? 'ved <strong>' + seg.road + '</strong>' : 'ved løypestart'; 2037 steps.push({ 2038 icon: 'ð', 2039 text: 'Startskuddet gÃ¥r ' + startRoadText + '! Du legger ut pÃ¥ traséen og følger veien i ' + distText + '.' 2040 }); 2041 } else if (s === segments.length - 1) { 2042 if (lengthMeters <= 25) { 2043 var finishLocation = (seg.road && seg.road !== 'veien videre' && seg.road !== 'Startpunkt') ? ' ved <strong>' + seg.road + '</strong>' : ''; 2044 steps.push({ 2045 icon: 'ð', 2046 text: 'MÃ¥lgang' + finishLocation + ' â løp over mÃ¥lstreken!' 2047 }); 2048 } else { 2049 var finishRoadText = (seg.road && seg.road !== 'veien videre' && seg.road !== 'Startpunkt') ? 'inn pÃ¥ <strong>' + seg.road + '</strong>' : 'siste etappe'; 2050 steps.push({ 2051 icon: 'ð', 2052 text: 'Løp ' + distText + ' ' + finishRoadText + ' og løp over mÃ¥lstreken!' 2053 }); 2054 } 2055 } else { 2056 var angleDiff = 0; 2057 if (seg.prevBearing && seg.bearing) { 2058 angleDiff = getAngleDiff(seg.prevBearing, seg.bearing); 2059 } 2060 2061 var turnAction = 'Fortsett'; 2062 var turnIcon = 'â¬ï¸'; 2063 if (angleDiff > 35 && angleDiff < 140) { 2064 turnAction = 'Sving til høyre'; 2065 turnIcon = 'â¡ï¸'; 2066 } else if (angleDiff < -35 && angleDiff > -140) { 2067 turnAction = 'Sving til venstre'; 2068 turnIcon = '⬠ï¸'; 2069 } else if (angleDiff >= 140 || angleDiff <= -140) { 2070 turnAction = 'Ta en U-sving / krapp sving'; 2071 turnIcon = 'ð'; 2072 } 2073 2074 var roadPhrase = seg.road && seg.road !== 'veien videre' ? ' inn pÃ¥ <strong>' + seg.road + '</strong>' : ''; 2075 steps.push({ 2076 icon: turnIcon, 2077 text: turnAction + roadPhrase + ', og følg i ' + distText + '.' 2078 }); 2079 } 2080 } 2081 2082 try { 2083 localStorage.setItem(cacheKey, JSON.stringify(steps)); 2084 } catch (e) {} 2085 2086 renderTurnByTurnSteps(container, steps); 2087 }); 2088 } 2089 2090 function renderTurnByTurnSteps(container, steps) { 2091 if (!container) return; 2092 if (!steps || steps.length === 0) { 2093 container.innerHTML = '<div style="font-size:12px; opacity:0.7;">Kunne ikke hente gatenavn for denne løypa.</div>'; 2094 return; 2095 } 2096 var html = ''; 2097 for (var i = 0; i < steps.length; i++) { 2098 html += '<div class="race-direction-item">'; 2099 html += ' <span class="race-direction-icon">' + steps[i].icon + '</span>'; 2100 html += ' <span class="race-direction-text">' + steps[i].text + '</span>'; 2101 html += '</div>'; 2102 } 2103 container.innerHTML = html; 2104 } 2105 2106 function resizeCanvas() {
2107 if (eleCanvas) { 2108 eleCanvas.width = eleCanvas.offsetWidth; 2109 eleCanvas.height = eleCanvas.offsetHeight; 2110 if (elevationData.length > 0) updateMapFromProgress(currentProgress, getCurrentRoute().elevation_range); 2111 } 2112 } 2113 window.addEventListener('resize', resizeCanvas); 2114 2115 if (select) { 2116 select.addEventListener('change', function () { loadRoute(this.value, false); }); 2117 } 2118 2119 loadRoute(initialIndex, true); 2120 2121 // --- RESIZE OBSERVER FOR OVERLAYS/TABS --- 2122 // Fixes issue where map is invisible or wrong size when inside a hidden container that becomes visible 2123 if (window.ResizeObserver) { 2124 var ro = new ResizeObserver(function (entries) { 2125 for (var entry of entries) { 2126 if (entry.contentRect.width > 0 && entry.contentRect.height > 0) { 2127 map.invalidateSize(); 2128 resizeCanvas(); 2129 if (currentRouteBounds && currentRouteBounds.isValid()) { 2130 // If map was stuck at world view (zoom <= 4) or uninitialized 2131 if (map.getZoom() <= 4) { 2132 map.fitBounds(currentRouteBounds, getFitBoundsOptions(getCurrentRoute())); 2133 } 2134 } 2135 } 2136 } 2137 }); 2138 ro.observe(wrapper); 2139 } 2140 }); 2141 }; 2142 2143 if (document.readyState === 'loading') { 2144 document.addEventListener('DOMContentLoaded', initMaps); 2145 } else { 2146 initMaps(); 2147 } 2148})();
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.