diff --git a/tasks/enduro-trails/prototype/static/app.css b/tasks/enduro-trails/prototype/static/app.css index f5c6d20..98b76ba 100644 --- a/tasks/enduro-trails/prototype/static/app.css +++ b/tasks/enduro-trails/prototype/static/app.css @@ -464,3 +464,36 @@ body { user-select: none; line-height: 1; } + +/* ─── Фаза 4: Разведка ───────────────────────────────────────────────────── */ +.recon-radius-btn { + flex: 1; + padding: 4px 0; + border: 1px solid #ccc; + border-radius: 4px; + cursor: pointer; + font-size: 11px; + background: #f0f0f0; + transition: background 0.15s; +} +.recon-radius-btn:hover { background: #e0e0e0; } +.recon-radius-btn.active { background: #ff6600; color: #fff; border-color: #ff6600; font-weight: 600; } + +/* ─── Фаза 4: Красивый маршрут ─────────────────────────────────────────── */ +.scenic-dist-btn { + flex: 1; + padding: 4px 0; + border: 1px solid #ccc; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + background: #f0f0f0; + transition: background 0.15s; +} +.scenic-dist-btn:hover { background: #e0e0e0; } +.scenic-dist-btn.active { background: #ff6600; color: #fff; border-color: #ff6600; font-weight: 600; } + +.scenic-poi-item { + display: flex; align-items: center; gap: 6px; + font-size: 12px; color: #444; padding: 2px 0; +} diff --git a/tasks/enduro-trails/prototype/static/app.js b/tasks/enduro-trails/prototype/static/app.js index bc5eda5..f57ed70 100644 --- a/tasks/enduro-trails/prototype/static/app.js +++ b/tasks/enduro-trails/prototype/static/app.js @@ -9,6 +9,18 @@ function deactivateAllModes() { if (typeof scenicMode !== 'undefined' && scenicMode) toggleScenicMode(); } +// ─── Общее: деактивация всех режимов ──────────────────────────────────────────── + +function deactivateAllModes() { + if (routeMode) { routeMode = false; document.getElementById('btn-route').classList.remove('active'); document.getElementById('route-panel').style.display = 'none'; clearRoute(); } + if (rulerMode) toggleRuler(); + if (markerMode) toggleMarkerMode(); + if (reconMode) toggleReconMode(); + if (linkMode) toggleLinkMode(); + if (scenicMode) toggleScenicMode(); + if (window._map) window._map.getCanvas().style.cursor = ''; +} + // ─── Утилиты ────────────────────────────────────────────────────────────────── function formatDuration(seconds) { @@ -141,7 +153,7 @@ function toggleRouteMode() { const panel = document.getElementById('route-panel'); if (routeMode) { deactivateAllModes(); - routeMode = true; // deactivateAllModes выключил, включаем обратно + routeMode = true; btn.classList.add('active'); panel.style.display = 'block'; clearRoute(); @@ -419,4 +431,715 @@ function selectRoute(idx) { const isActive = i === idx; if (map.getLayer('route-line-' + i)) { map.setPaintProperty('route-line-' + i, 'line-width', isActive ? 5 : 3); - map.setPaint \ No newline at end of file + map.setPaintProperty('route-line-' + i, 'line-opacity', isActive ? 0.95 : 0.5); + } + if (map.getLayer('route-line-' + i + '-outline')) { + map.setPaintProperty('route-line-' + i + '-outline', 'line-width', isActive ? 7 : 4); + map.setPaintProperty('route-line-' + i + '-outline', 'line-opacity', isActive ? 0.6 : 0); + } + }); + // Обновляем CSS карточек + document.querySelectorAll('.route-card').forEach((card, i) => { + card.classList.toggle('active', i === idx); + }); +} + +function highlightRoute(idx) { + const map = window._map; + if (map.getLayer('route-line-' + idx)) { + map.setPaintProperty('route-line-' + idx, 'line-width', 7); + map.setPaintProperty('route-line-' + idx, 'line-opacity', 1); + } +} + +function unhighlightRoute(idx) { + const isActive = idx === activeRouteIdx; + const map = window._map; + if (map.getLayer('route-line-' + idx)) { + map.setPaintProperty('route-line-' + idx, 'line-width', isActive ? 5 : 3); + map.setPaintProperty('route-line-' + idx, 'line-opacity', isActive ? 0.95 : 0.5); + } +} + +// ─── Карточки маршрутов ─────────────────────────────────────────────────────── +function renderRouteCards(routes) { + const container = document.getElementById('route-cards'); + container.innerHTML = routes.map((route, i) => { + const color = ROUTE_COLORS[i] || '#888888'; + const distKm = (route.distance_m / 1000).toFixed(1); + const timeStr = formatDuration(route.duration_s); + const isActive = i === activeRouteIdx; + + let barHtml = ''; + let summaryHtml = ''; + let detailsHtml = ''; + + if (route.stats) { + const s = route.stats; + barHtml = ` +
+
+
+
+
+
`; + summaryHtml = `
${s.dirt_total_pct}% грунт · ${s.asphalt_pct}% асфальт
`; + detailsHtml = ` + + `; + } else { + detailsHtml = ` + + `; + } + + return `
+
+ + Вариант ${i + 1} + ${distKm} км + ${timeStr} +
+ ${barHtml} + ${summaryHtml} + ${detailsHtml} +
`; + }).join(''); +} + +function toggleRouteDetails(idx) { + const details = document.getElementById('route-details-' + idx); + const btn = details ? details.nextElementSibling : null; + if (!details) return; + const isOpen = details.style.display !== 'none'; + details.style.display = isOpen ? 'none' : 'block'; + if (btn) btn.textContent = isOpen ? 'Подробнее ▼' : 'Свернуть ▲'; +} + + +// ─── GPX экспорт ───────────────────────────────────────────────────────────── +function escapeXml(str) { + return (str || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +function downloadGPX() { + const route = routeResults[activeRouteIdx]; + if (!route) return; + + const now = new Date(); + const dateStr = now.toISOString().slice(0, 10); + const timeStr = now.toISOString().replace(/[-:]/g, '').slice(0, 15); + const filename = `enduro-${timeStr}.gpx`; + + const distKm = (route.distance_m / 1000).toFixed(1); + const dirtPct = route.stats ? route.stats.dirt_total_pct : '?'; + + // Waypoints: точки маршрута + const wpts = routeWaypoints.map((wp, i) => { + const name = i === 0 ? 'Старт' : i === routeWaypoints.length - 1 ? 'Финиш' : `Точка ${i}`; + return ` ${escapeXml(name)}`; + }); + + // Добавить флажки из localStorage + const markers = loadMarkers(); + markers.forEach(m => { + wpts.push(` ${escapeXml(m.name)}${escapeXml(m.icon)}`); + }); + + // Трек + const trkpts = route.geometry.coordinates.map(([lon, lat]) => + ` ` + ).join('\n'); + + const gpx = ` + + + Enduro route ${dateStr} + ${distKm} км · ${dirtPct}% грунт + + +${wpts.join('\n')} + + Enduro route ${dateStr} + +${trkpts} + + +`; + + const blob = new Blob([gpx], { type: 'application/gpx+xml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +// ─── Флажки / именованные метки ────────────────────────────────────────────── +const MARKER_ICONS = ['🚩', '⛺', '🔧', '⛽', '💧', '📍']; +const MARKERS_KEY = 'enduro_markers'; +let markerMode = false; +let namedMarkerObjects = {}; // id -> MapLibre Marker + +function loadMarkers() { + try { + return JSON.parse(localStorage.getItem(MARKERS_KEY) || '[]'); + } catch(e) { + return []; + } +} + +function saveMarkers(markers) { + try { + localStorage.setItem(MARKERS_KEY, JSON.stringify(markers)); + } catch(e) { + console.warn('localStorage недоступен'); + } +} + +function toggleMarkerMode() { + markerMode = !markerMode; + const btn = document.getElementById('btn-markers'); + if (markerMode) { + deactivateAllModes(); + markerMode = true; + btn.classList.add('active'); + window._map.getCanvas().style.cursor = 'crosshair'; + } else { + btn.classList.remove('active'); + window._map.getCanvas().style.cursor = ''; + } +} + +function addMarker(lngLat) { + const markers = loadMarkers(); + if (markers.length >= 50) { + alert('Достигнут лимит 50 меток'); + return; + } + + // Простой диалог выбора иконки и имени + const iconChoice = promptIconChoice(); + if (iconChoice === null) return; // отмена + + const rawName = prompt('Название метки (Enter = автоимя):'); + if (rawName === null) return; // отмена + const autoName = rawName.trim() || `Метка ${markers.length + 1}`; + + const marker = { + id: Date.now(), + name: autoName, + icon: iconChoice, + lat: lngLat.lat, + lon: lngLat.lng + }; + markers.push(marker); + saveMarkers(markers); + drawNamedMarker(marker); +} + +function promptIconChoice() { + const msg = 'Выберите иконку:\n' + MARKER_ICONS.map((ic, i) => `${i+1}. ${ic}`).join('\n') + '\n\nВведите номер (1-6) или Enter для 🚩:'; + const input = prompt(msg); + if (input === null) return null; + const idx = parseInt(input, 10) - 1; + if (idx >= 0 && idx < MARKER_ICONS.length) return MARKER_ICONS[idx]; + return MARKER_ICONS[0]; +} + +function drawNamedMarker(markerData) { + const map = window._map; + const el = document.createElement('div'); + el.className = 'named-marker-el'; + el.textContent = markerData.icon; + el.title = markerData.name; + + const popup = new maplibregl.Popup({ offset: 25, closeButton: true }) + .setHTML(` + + +
+ + + +
+ `); + + const mlMarker = new maplibregl.Marker({ element: el, anchor: 'bottom' }) + .setLngLat([markerData.lon, markerData.lat]) + .setPopup(popup) + .addTo(map); + + el.addEventListener('click', () => mlMarker.togglePopup()); + namedMarkerObjects[markerData.id] = mlMarker; +} + +function renderMarkers() { + const markers = loadMarkers(); + markers.forEach(m => drawNamedMarker(m)); +} + +function removeMarker(id) { + if (namedMarkerObjects[id]) { + namedMarkerObjects[id].remove(); + delete namedMarkerObjects[id]; + } + const markers = loadMarkers().filter(m => m.id !== id); + saveMarkers(markers); +} + +function useMarkerAsA(id) { + const markers = loadMarkers(); + const m = markers.find(x => x.id === id); + if (!m) return; + if (!routeMode) { + toggleRouteMode(); + } + if (routeWaypoints.length === 0) { + routeWaypoints.push({ lon: m.lon, lat: m.lat }); + } else { + routeWaypoints[0] = { lon: m.lon, lat: m.lat }; + } + rebuildWaypointMarkers(); + renderWaypointsList(); + updateRouteActionsVisibility(); + if (routeWaypoints.length >= 2) debounceBuildRoute(); + // Закрыть попап + if (namedMarkerObjects[id]) namedMarkerObjects[id].getPopup().remove(); +} + +function useMarkerAsB(id) { + const markers = loadMarkers(); + const m = markers.find(x => x.id === id); + if (!m) return; + if (!routeMode) { + toggleRouteMode(); + } + if (routeWaypoints.length === 0) { + routeWaypoints.push({ lon: m.lon, lat: m.lat }); + routeWaypoints.push({ lon: m.lon, lat: m.lat }); + } else if (routeWaypoints.length === 1) { + routeWaypoints.push({ lon: m.lon, lat: m.lat }); + } else { + routeWaypoints[routeWaypoints.length - 1] = { lon: m.lon, lat: m.lat }; + } + rebuildWaypointMarkers(); + renderWaypointsList(); + updateRouteActionsVisibility(); + if (routeWaypoints.length >= 2) debounceBuildRoute(); + if (namedMarkerObjects[id]) namedMarkerObjects[id].getPopup().remove(); +} + +function clearAllMarkers() { + if (!confirm('Удалить все метки?')) return; + Object.values(namedMarkerObjects).forEach(m => m.remove()); + namedMarkerObjects = {}; + saveMarkers([]); +} + +function updateRouteActionsVisibility() { + document.getElementById('route-actions').style.display = + routeWaypoints.length >= 2 ? 'block' : 'none'; + document.getElementById('route-status').textContent = + routeWaypoints.length === 0 ? 'Кликни точку старта' : + routeWaypoints.length === 1 ? 'Кликни точку финиша' : ''; +} + + +// ─── Map init ───────────────────────────────────────────────────────────────── +async function initMap() { + const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; + const tileBase = window.location.origin + basePath; + const style = await fetch(basePath + '/style.json').then(r => r.json()); + style.sources['trails-tiles'].tiles = [`${tileBase}/api/tiles/{z}/{x}/{y}.mvt`]; + + const map = new maplibregl.Map({ + container: 'map', + style: style, + center: [40.5, 55.5], + zoom: 7, + minZoom: 4, + maxZoom: 18, + hash: true, + }); + window._map = map; + + map.addControl(new maplibregl.NavigationControl(), 'top-left'); + map.addControl(new maplibregl.ScaleControl({ unit: 'metric' }), 'bottom-right'); + map.addControl(new maplibregl.FullscreenControl(), 'top-left'); + + map.on('load', () => { + document.getElementById('loading').classList.remove('visible'); + checkDataAvailability(); + initRouteClicks(map); + initRulerClicks(map); + initSearch(); + renderMarkers(); + }); + + map.on('error', (e) => { + console.error('Map error:', e.error?.message || e); + document.getElementById('loading').classList.remove('visible'); + }); + + setTimeout(() => { + document.getElementById('loading').classList.remove('visible'); + }, 15000); + + map.on('zoom', () => { + document.getElementById('zoom-val').textContent = map.getZoom().toFixed(1); + }); + + map.on('mousemove', (e) => { + const { lng, lat } = e.lngLat; + document.getElementById('coords-val').textContent = + `${lat.toFixed(4)}, ${lng.toFixed(4)}`; + }); + + const popup = new maplibregl.Popup({ + closeButton: true, + closeOnClick: false, + maxWidth: '300px', + }); + + function formatLength(m) { + if (!m) return '—'; + if (m >= 1000) return (m / 1000).toFixed(1) + ' км'; + return Math.round(m) + ' м'; + } + + function poiTypeLabel(t) { + const labels = { + 'natural=peak': '⛰ Вершина', + 'natural=water': '💧 Вода', + 'tourism=viewpoint': '👁 Смотровая', + 'historic=ruins': '🏙 Руины', + 'natural=cave_entrance': '🕳 Пещера', + 'ford=yes': '🌊 Брод', + }; + return labels[t] || t; + } + + ['trails-track', 'trails-path-bridleway', 'trails-asphalt'].forEach(layerId => { + map.on('click', layerId, (e) => { + // Не показываем попап трека если активен режим маршрута или линейки + if (routeMode || rulerMode || markerMode) return; + const props = e.features[0].properties; + const html = ` + + + + + + ${props.mtb_scale ? `` : ''} + `; + popup.setLngLat(e.lngLat).setHTML(html).addTo(map); + }); + map.on('mouseenter', layerId, () => { + if (!routeMode && !rulerMode && !markerMode) map.getCanvas().style.cursor = 'pointer'; + }); + map.on('mouseleave', layerId, () => { + if (!routeMode && !rulerMode && !markerMode) map.getCanvas().style.cursor = ''; + }); + }); + + map.on('click', 'poi-circles', (e) => { + if (routeMode || rulerMode || markerMode) return; + const props = e.features[0].properties; + const html = ` + + + `; + popup.setLngLat(e.lngLat).setHTML(html).addTo(map); + }); + map.on('mouseenter', 'poi-circles', () => { + if (!routeMode && !rulerMode && !markerMode) map.getCanvas().style.cursor = 'pointer'; + }); + map.on('mouseleave', 'poi-circles', () => { + if (!routeMode && !rulerMode && !markerMode) map.getCanvas().style.cursor = ''; + }); + + map.on('click', (e) => { + if (routeMode || rulerMode || markerMode) return; + const features = map.queryRenderedFeatures(e.point, { + layers: ['trails-track', 'trails-path-bridleway', 'trails-asphalt', 'poi-circles'], + }); + if (!features.length) popup.remove(); + }); +} + +async function checkDataAvailability() { + try { + const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; + const resp = await fetch(basePath + '/api/health'); + const data = await resp.json(); + if (!data.db_exists) { + document.getElementById('no-data-warning').classList.add('visible'); + } + } catch (e) { + console.warn('Health check failed:', e); + } +} + +// ─── Клики на карте (маршрут + метки) ──────────────────────────────────────── +function initRouteClicks(map) { + map.on('click', (e) => { + const { lng, lat } = e.lngLat; + + // Режим Разведки + if (reconMode) { + doRecon(lng, lat); + return; + } + + // Режим Связки + if (linkMode) { + addLinkPoint(lng, lat); + return; + } + + // Режим Красивого маршрута + if (scenicMode) { + scenicStart = { lon: lng, lat: lat }; + document.getElementById('scenic-status').textContent = `📍 Старт: ${lat.toFixed(4)}, ${lng.toFixed(4)}`; + if (scenicStartMarker) scenicStartMarker.remove(); + const el = document.createElement('div'); + el.style.cssText = 'width:16px;height:16px;background:#ff6600;border:2px solid #fff;border-radius:50%;box-shadow:0 1px 4px rgba(0,0,0,0.3);'; + scenicStartMarker = new maplibregl.Marker({ element: el, anchor: 'center' }).setLngLat([lng, lat]).addTo(map); + return; + } + + // Режим добавления метки + if (markerMode) { + addMarker(e.lngLat); + toggleMarkerMode(); + return; + } + + if (!routeMode) return; + + const { lng, lat } = e.lngLat; + + // Режим добавления промежуточной точки + if (addingWaypoint) { + addingWaypoint = false; + map.getCanvas().style.cursor = 'crosshair'; + // Вставляем перед последней точкой (B) + if (routeWaypoints.length >= 2) { + routeWaypoints.splice(routeWaypoints.length - 1, 0, { lon: lng, lat: lat }); + } else { + routeWaypoints.push({ lon: lng, lat: lat }); + } + rebuildWaypointMarkers(); + renderWaypointsList(); + updateRouteActionsVisibility(); + if (routeWaypoints.length >= 2) debounceBuildRoute(); + return; + } + + // Обычный режим: A → B + if (routeWaypoints.length === 0) { + routeWaypoints.push({ lon: lng, lat: lat }); + rebuildWaypointMarkers(); + renderWaypointsList(); + document.getElementById('route-status').textContent = 'Кликни точку финиша'; + } else if (routeWaypoints.length === 1) { + routeWaypoints.push({ lon: lng, lat: lat }); + rebuildWaypointMarkers(); + renderWaypointsList(); + updateRouteActionsVisibility(); + buildRoute(); + } + // Если уже 2+ точек — клик ничего не делает (используй "+ Точка") + }); +} + +// ─── Поиск (Nominatim) ──────────────────────────────────────────────────────── +let searchTimeout = null; + +function initSearch() { + const input = document.getElementById('search-input'); + const results = document.getElementById('search-results'); + input.addEventListener('input', () => { + clearTimeout(searchTimeout); + const q = input.value.trim(); + if (q.length < 2) { results.style.display = 'none'; return; } + searchTimeout = setTimeout(() => doSearch(q), 400); + }); + input.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { results.style.display = 'none'; input.blur(); } + }); + document.addEventListener('click', (e) => { + if (!e.target.closest('#search-box')) results.style.display = 'none'; + }); +} + +async function doSearch(query) { + const results = document.getElementById('search-results'); + results.innerHTML = '
Поиск...
'; + results.style.display = 'block'; + try { + const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=6&countrycodes=ru&accept-language=ru`; + const resp = await fetch(url, { headers: { 'Accept-Language': 'ru' } }); + const data = await resp.json(); + if (!data.length) { + results.innerHTML = '
Ничего не найдено
'; + return; + } + results.innerHTML = data.map((item) => { + const name = item.display_name.split(',')[0]; + const detail = item.display_name.split(',').slice(1, 3).join(',').trim(); + return `
+
${name}
+
${detail}
+
`; + }).join(''); + } catch(e) { + results.innerHTML = '
Ошибка поиска
'; + } +} + +function selectSearchResult(lat, lon, name) { + window._map.flyTo({ center: [lon, lat], zoom: 13, duration: 800 }); + document.getElementById('search-results').style.display = 'none'; + document.getElementById('search-input').value = name; +} + +// ─── Линейка ────────────────────────────────────────────────────────────────── +let rulerMode = false; +let rulerPoints = []; +let rulerMarkers = []; +let rulerTotal = 0; + +function toggleRuler() { + rulerMode = !rulerMode; + const btn = document.getElementById('btn-ruler'); + if (rulerMode) { + deactivateAllModes(); + rulerMode = true; + btn.classList.add('active'); + window._map.getCanvas().style.cursor = 'crosshair'; + clearRuler(); + } else { + btn.classList.remove('active'); + window._map.getCanvas().style.cursor = ''; + clearRuler(); + } +} + +function clearRuler() { + rulerPoints = []; + rulerTotal = 0; + rulerMarkers.forEach(m => m.remove()); + rulerMarkers = []; + const map = window._map; + if (map.getLayer('ruler-line')) map.removeLayer('ruler-line'); + if (map.getSource('ruler')) map.removeSource('ruler'); +} + +function haversineKm(a, b) { + const R = 6371; + const dLat = (b[1] - a[1]) * Math.PI / 180; + const dLon = (b[0] - a[0]) * Math.PI / 180; + const s = Math.sin(dLat/2)**2 + Math.cos(a[1]*Math.PI/180) * Math.cos(b[1]*Math.PI/180) * Math.sin(dLon/2)**2; + return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1-s)); +} + +function updateRulerLine() { + const map = window._map; + const geojson = { type: 'Feature', geometry: { type: 'LineString', coordinates: rulerPoints } }; + if (map.getSource('ruler')) { + map.getSource('ruler').setData(geojson); + } else { + map.addSource('ruler', { type: 'geojson', data: geojson }); + map.addLayer({ + id: 'ruler-line', type: 'line', source: 'ruler', + paint: { 'line-color': '#0088ff', 'line-width': 2, 'line-dasharray': [4, 2], 'line-opacity': 0.9 } + }); + } +} + +function addRulerPoint(lngLat) { + const map = window._map; + const pt = [lngLat.lng, lngLat.lat]; + const idx = rulerPoints.length; + rulerPoints.push(pt); + + if (rulerPoints.length > 1) { + rulerTotal += haversineKm(rulerPoints[rulerPoints.length - 2], pt); + } + + const label = rulerPoints.length === 1 ? '0 м' : + rulerTotal >= 1 ? rulerTotal.toFixed(1) + ' км' : Math.round(rulerTotal * 1000) + ' м'; + + const dot = document.createElement('div'); + dot.style.cssText = 'width:10px;height:10px;background:#0088ff;border:2px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,0.3);'; + const dotMarker = new maplibregl.Marker({ element: dot, anchor: 'center' }) + .setLngLat([lngLat.lng, lngLat.lat]) + .addTo(map); + + const labelEl = document.createElement('div'); + labelEl.style.cssText = 'display:inline-flex;align-items:center;gap:3px;background:rgba(0,0,0,0.75);color:#fff;font-size:11px;font-weight:600;padding:2px 6px;border-radius:3px;white-space:nowrap;'; + labelEl.innerHTML = `${label}`; + const labelMarker = new maplibregl.Marker({ element: labelEl, anchor: 'center', offset: [0, -20] }) + .setLngLat([lngLat.lng, lngLat.lat]) + .addTo(map); + + rulerMarkers.push(dotMarker, labelMarker); + updateRulerLine(); +} + +function removeRulerPoint(idx) { + if (idx < 0 || idx >= rulerPoints.length) return; + rulerPoints.splice(idx, 1); + rulerMarkers.forEach(m => m.remove()); + rulerMarkers = []; + rulerTotal = 0; + const pts = [...rulerPoints]; + rulerPoints = []; + pts.forEach(pt => addRulerPoint({ lng: pt[0], lat: pt[1] })); +} + +function initRulerClicks(map) { + map.on('click', (e) => { + if (!rulerMode) return; + addRulerPoint(e.lngLat); + }); + map.on('dblclick', (e) => { + if (!rulerMode) return; + e.preventDefault(); + toggleRuler(); + }); +} + +initMap();