PageSourceSearch

https://rundrm.no/wp-content/plugins/HIB%20Multi%20Race%20Map%20Pro/assets/js/frontend.js?ver=1790200085

js rundrm.no collected 2026-09-25 09:48:05 UTC 110,840 bytes, 2,148 lines download raw bytes

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">&times;</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: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> contributors &copy; <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: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> contributors &copy; <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: '&copy; <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 &copy; 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: '&copy; <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: '&copy; <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: '&copy; <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.