From ff7d301fc4f1c1cc350ec685c42166454cfc52b8 Mon Sep 17 00:00:00 2001 From: Stream Date: Mon, 4 May 2026 11:10:01 +0300 Subject: [PATCH] auto-sync: 2026-05-04 11:10:01 --- tasks/enduro-trails/prototype/static/app.js | 803 ++++++++++++++++++-- 1 file changed, 719 insertions(+), 84 deletions(-) diff --git a/tasks/enduro-trails/prototype/static/app.js b/tasks/enduro-trails/prototype/static/app.js index eec0c57..9396f27 100644 --- a/tasks/enduro-trails/prototype/static/app.js +++ b/tasks/enduro-trails/prototype/static/app.js @@ -1,3 +1,25 @@ +// ─── Утилиты ────────────────────────────────────────────────────────────────── + +function formatDuration(seconds) { + const totalMin = Math.round(seconds / 60); + if (totalMin < 60) return totalMin + ' мин'; + const days = Math.floor(totalMin / 1440); + const hours = Math.floor((totalMin % 1440) / 60); + const mins = totalMin % 60; + if (days > 0) { + if (mins === 0) return `${days} дн ${hours} ч`; + return `${days} дн ${hours} ч ${mins} мин`; + } + if (mins === 0) return `${hours} ч`; + return `${hours} ч ${mins} мин`; +} + +function formatDist(m) { + if (!m) return '—'; + if (m >= 1000) return (m / 1000).toFixed(1) + ' км'; + return Math.round(m) + ' м'; +} + // ─── Компас ─────────────────────────────────────────────────────────────────── let compassLocked = false; @@ -84,6 +106,648 @@ function toggleLayer(group) { }); } + +// ─── Роутинг — состояние ────────────────────────────────────────────────────── +const ROUTE_COLORS = ['#0066ff', '#00aa44', '#9933cc', '#ff8800', '#888888']; + +let routeMode = false; +let routeWaypoints = []; // [{lon, lat}, ...] +let routeResults = []; // массив маршрутов из API +let activeRouteIdx = 0; +let waypointMarkers = []; // MapLibre маркеры точек +let addingWaypoint = false; // режим добавления промежуточной точки +let buildDebounceTimer = null; + +function getBasePath() { + return window.location.pathname.replace(/\/[^/]*$/, '') || ''; +} + +// ─── Режим маршрута ─────────────────────────────────────────────────────────── +function toggleRouteMode() { + routeMode = !routeMode; + const btn = document.getElementById('btn-route'); + const panel = document.getElementById('route-panel'); + if (routeMode) { + btn.classList.add('active'); + panel.style.display = 'block'; + clearRoute(); + window._map.getCanvas().style.cursor = 'crosshair'; + } else { + btn.classList.remove('active'); + panel.style.display = 'none'; + clearRoute(); + window._map.getCanvas().style.cursor = ''; + } +} + +function clearRoute() { + // Убираем маркеры точек + waypointMarkers.forEach(m => m.remove()); + waypointMarkers = []; + routeWaypoints = []; + routeResults = []; + activeRouteIdx = 0; + addingWaypoint = false; + + // Убираем слои маршрутов + const map = window._map; + if (map) { + for (let i = 0; i < 5; i++) { + if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i); + if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline'); + } + if (map.getSource('route')) map.removeSource('route'); + for (let i = 0; i < 5; i++) { + if (map.getSource('route-' + i)) map.removeSource('route-' + i); + } + } + + document.getElementById('route-status').textContent = 'Кликни точку старта'; + document.getElementById('route-actions').style.display = 'none'; + document.getElementById('route-cards').innerHTML = ''; + document.getElementById('waypoints-list').innerHTML = ''; + document.getElementById('btn-add-waypoint').style.display = ''; + + if (routeMode && map) map.getCanvas().style.cursor = 'crosshair'; +} + +// ─── Добавление промежуточной точки ────────────────────────────────────────── +function startAddWaypoint() { + if (routeWaypoints.length >= 10) return; + addingWaypoint = true; + window._map.getCanvas().style.cursor = 'crosshair'; + document.getElementById('route-status').textContent = 'Кликни на карте для добавления точки'; +} + +// ─── Маркеры точек ──────────────────────────────────────────────────────────── +function createWaypointMarkerEl(index, total) { + const el = document.createElement('div'); + el.className = 'route-waypoint-marker'; + let bg, text, color = '#fff'; + if (index === 0) { + bg = '#00aa44'; text = 'A'; + el.style.cssText = `width:22px;height:22px;background:${bg};border:2px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:${color};box-shadow:0 1px 4px rgba(0,0,0,0.3);cursor:grab;`; + } else if (index === total - 1) { + bg = '#cc0000'; text = 'B'; + el.style.cssText = `width:22px;height:22px;background:${bg};border:2px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:${color};box-shadow:0 1px 4px rgba(0,0,0,0.3);cursor:grab;`; + } else { + text = String(index); + el.style.cssText = `width:18px;height:18px;background:#fff;border:2px solid #0066ff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:#0066ff;box-shadow:0 1px 4px rgba(0,0,0,0.3);cursor:grab;`; + } + el.textContent = text; + return el; +} + +function rebuildWaypointMarkers() { + waypointMarkers.forEach(m => m.remove()); + waypointMarkers = []; + const map = window._map; + routeWaypoints.forEach((wp, i) => { + const el = createWaypointMarkerEl(i, routeWaypoints.length); + const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: true }) + .setLngLat([wp.lon, wp.lat]) + .addTo(map); + + // Захватываем индекс в замыкании + (function(idx) { + marker.on('dragend', () => { + const lngLat = marker.getLngLat(); + routeWaypoints[idx] = { lon: lngLat.lng, lat: lngLat.lat }; + renderWaypointsList(); + debounceBuildRoute(); + }); + })(i); + + waypointMarkers.push(marker); + }); +} + +function renderWaypointsList() { + const list = document.getElementById('waypoints-list'); + if (!routeWaypoints.length) { list.innerHTML = ''; return; } + + list.innerHTML = routeWaypoints.map((wp, i) => { + let labelClass, labelText; + if (i === 0) { labelClass = 'start'; labelText = 'A'; } + else if (i === routeWaypoints.length - 1) { labelClass = 'end'; labelText = 'B'; } + else { labelClass = 'mid'; labelText = String(i); } + + return `
+ ${labelText} + ${wp.lat.toFixed(4)}, ${wp.lon.toFixed(4)} + +
`; + }).join(''); + + // Показываем/скрываем кнопку добавления + document.getElementById('btn-add-waypoint').style.display = + routeWaypoints.length >= 10 ? 'none' : ''; +} + +// ─── Drag-and-drop порядка точек ────────────────────────────────────────────── +let dragWpIdx = null; + +function onWpDragStart(e, idx) { + dragWpIdx = idx; + e.dataTransfer.effectAllowed = 'move'; +} + +function onWpDragOver(e, idx) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + document.querySelectorAll('.waypoint-row').forEach((r, i) => { + r.classList.toggle('drag-over', i === idx); + }); +} + +function onWpDragLeave(e) { + e.currentTarget.classList.remove('drag-over'); +} + +function onWpDrop(e, idx) { + e.preventDefault(); + document.querySelectorAll('.waypoint-row').forEach(r => r.classList.remove('drag-over')); + if (dragWpIdx === null || dragWpIdx === idx) return; + const moved = routeWaypoints.splice(dragWpIdx, 1)[0]; + routeWaypoints.splice(idx, 0, moved); + dragWpIdx = null; + rebuildWaypointMarkers(); + renderWaypointsList(); + if (routeWaypoints.length >= 2) debounceBuildRoute(); +} + +function removeWaypoint(idx) { + routeWaypoints.splice(idx, 1); + rebuildWaypointMarkers(); + renderWaypointsList(); + if (routeWaypoints.length >= 2) { + debounceBuildRoute(); + } else { + // Убираем маршруты с карты + const map = window._map; + for (let i = 0; i < 5; i++) { + if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i); + if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline'); + if (map.getSource('route-' + i)) map.removeSource('route-' + i); + } + routeResults = []; + document.getElementById('route-cards').innerHTML = ''; + document.getElementById('route-actions').style.display = + routeWaypoints.length >= 2 ? 'block' : 'none'; + document.getElementById('route-status').textContent = + routeWaypoints.length === 0 ? 'Кликни точку старта' : + routeWaypoints.length === 1 ? 'Кликни точку финиша' : ''; + } +} + + +// ─── Построение маршрута ────────────────────────────────────────────────────── +function debounceBuildRoute() { + clearTimeout(buildDebounceTimer); + buildDebounceTimer = setTimeout(buildRoute, 300); +} + +async function buildRoute() { + if (routeWaypoints.length < 2) return; + const map = window._map; + const basePath = getBasePath(); + + document.getElementById('route-status').textContent = '⏳ Строю маршрут...'; + const btn = document.getElementById('btn-build-route'); + if (btn) btn.textContent = '⏳ Строю...'; + + try { + const resp = await fetch(basePath + '/api/route', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ waypoints: routeWaypoints, alternatives: 5 }), + }); + if (!resp.ok) throw new Error('Маршрут не найден'); + const data = await resp.json(); + routeResults = data.routes || []; + if (!routeResults.length) throw new Error('Маршрут не найден'); + + // Убираем старые слои + for (let i = 0; i < 5; i++) { + if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i); + if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline'); + if (map.getSource('route-' + i)) map.removeSource('route-' + i); + } + + // Рисуем все маршруты + routeResults.forEach((route, i) => { + const color = ROUTE_COLORS[i] || '#888888'; + const isActive = i === activeRouteIdx; + map.addSource('route-' + i, { + type: 'geojson', + data: { type: 'Feature', geometry: route.geometry, properties: {} } + }); + // Обводка (белая) для активного + map.addLayer({ + id: 'route-line-' + i + '-outline', + type: 'line', + source: 'route-' + i, + paint: { + 'line-color': '#ffffff', + 'line-width': isActive ? 7 : 4, + 'line-opacity': isActive ? 0.6 : 0, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' } + }); + map.addLayer({ + id: 'route-line-' + i, + type: 'line', + source: 'route-' + i, + paint: { + 'line-color': color, + 'line-width': isActive ? 5 : 3, + 'line-opacity': isActive ? 0.95 : 0.5, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' } + }); + + // Клик на линию маршрута + map.on('click', 'route-line-' + i, (e) => { + e.stopPropagation ? e.stopPropagation() : null; + selectRoute(i); + }); + map.on('mouseenter', 'route-line-' + i, () => { + map.getCanvas().style.cursor = 'pointer'; + highlightRoute(i); + }); + map.on('mouseleave', 'route-line-' + i, () => { + map.getCanvas().style.cursor = routeMode ? 'crosshair' : ''; + unhighlightRoute(i); + }); + }); + + activeRouteIdx = 0; + renderRouteCards(routeResults); + document.getElementById('route-status').textContent = `✅ ${routeResults.length} маршрут(ов)`; + document.getElementById('route-actions').style.display = 'block'; + + } catch(e) { + document.getElementById('route-status').textContent = '❌ ' + e.message; + } + + if (btn) btn.textContent = '🗺️ Построить маршрут'; +} + +// ─── Выбор и подсветка маршрутов ───────────────────────────────────────────── +function selectRoute(idx) { + activeRouteIdx = idx; + const map = window._map; + routeResults.forEach((_, i) => { + const isActive = i === idx; + if (map.getLayer('route-line-' + i)) { + map.setPaintProperty('route-line-' + i, 'line-width', isActive ? 5 : 3); + 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) { + 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(/\/[^/]*$/, '') || ''; @@ -112,6 +776,7 @@ async function initMap() { initRouteClicks(map); initRulerClicks(map); initSearch(); + renderMarkers(); }); map.on('error', (e) => { @@ -159,6 +824,8 @@ async function initMap() { ['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 = ` @@ -170,11 +837,16 @@ async function initMap() { `; popup.setLngLat(e.lngLat).setHTML(html).addTo(map); }); - map.on('mouseenter', layerId, () => { map.getCanvas().style.cursor = 'pointer'; }); - map.on('mouseleave', layerId, () => { map.getCanvas().style.cursor = ''; }); + 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 = ` @@ -182,10 +854,15 @@ async function initMap() { `; popup.setLngLat(e.lngLat).setHTML(html).addTo(map); }); - map.on('mouseenter', 'poi-circles', () => { map.getCanvas().style.cursor = 'pointer'; }); - map.on('mouseleave', 'poi-circles', () => { map.getCanvas().style.cursor = ''; }); + 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'], }); @@ -206,91 +883,51 @@ async function checkDataAvailability() { } } -// ─── Роутинг ────────────────────────────────────────────────────────────────── -let routeMode = false; -let routeStart = null; -let routeEnd = null; -let routeMarkers = []; - -function toggleRouteMode() { - routeMode = !routeMode; - const btn = document.getElementById('btn-route'); - const panel = document.getElementById('route-panel'); - if (routeMode) { - btn.classList.add('active'); - panel.style.display = 'block'; - clearRoute(); - window._map.getCanvas().style.cursor = 'crosshair'; - } else { - btn.classList.remove('active'); - panel.style.display = 'none'; - clearRoute(); - window._map.getCanvas().style.cursor = ''; - } -} - -function clearRoute() { - routeStart = null; - routeEnd = null; - routeMarkers.forEach(m => m.remove()); - routeMarkers = []; - const map = window._map; - if (map.getLayer('route-line')) map.removeLayer('route-line'); - if (map.getSource('route')) map.removeSource('route'); - document.getElementById('route-status').textContent = 'Кликни точку старта'; - document.getElementById('route-info').style.display = 'none'; - if (routeMode) map.getCanvas().style.cursor = 'crosshair'; -} - -async function buildRoute() { - const map = window._map; - document.getElementById('route-status').textContent = '⏳ Строю маршрут...'; - const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; - try { - const resp = await fetch( - `${basePath}/api/route?from_lon=${routeStart[0]}&from_lat=${routeStart[1]}&to_lon=${routeEnd[0]}&to_lat=${routeEnd[1]}` - ); - if (!resp.ok) throw new Error('Маршрут не найден'); - const data = await resp.json(); - if (map.getSource('route')) { - map.getSource('route').setData(data); - } else { - map.addSource('route', { type: 'geojson', data }); - map.addLayer({ - id: 'route-line', - type: 'line', - source: 'route', - paint: { 'line-color': '#0066ff', 'line-width': 4, 'line-opacity': 0.85 }, - layout: { 'line-cap': 'round', 'line-join': 'round' } - }); - } - const p = data.properties; - document.getElementById('route-distance').textContent = `${p.distance_km} км`; - document.getElementById('route-duration').textContent = `~${p.duration_min} мин`; - document.getElementById('route-status').textContent = '✅ Готово'; - document.getElementById('route-info').style.display = 'block'; - } catch(e) { - document.getElementById('route-status').textContent = '❌ ' + e.message; - } -} - +// ─── Клики на карте (маршрут + метки) ──────────────────────────────────────── function initRouteClicks(map) { map.on('click', (e) => { + // Режим добавления метки + if (markerMode) { + addMarker(e.lngLat); + toggleMarkerMode(); + return; + } + if (!routeMode) return; + const { lng, lat } = e.lngLat; - if (!routeStart) { - routeStart = [lng, lat]; - const el = document.createElement('div'); - el.style.cssText = 'width:14px;height:14px;background:#00aa00;border:2px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,0.3)'; - routeMarkers.push(new maplibregl.Marker({element: el}).setLngLat([lng, lat]).addTo(map)); + + // Режим добавления промежуточной точки + 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 (!routeEnd) { - routeEnd = [lng, lat]; - const el = document.createElement('div'); - el.style.cssText = 'width:14px;height:14px;background:#cc0000;border:2px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,0.3)'; - routeMarkers.push(new maplibregl.Marker({element: el}).setLngLat([lng, lat]).addTo(map)); + } else if (routeWaypoints.length === 1) { + routeWaypoints.push({ lon: lng, lat: lat }); + rebuildWaypointMarkers(); + renderWaypointsList(); + updateRouteActionsVisibility(); buildRoute(); } + // Если уже 2+ точек — клик ничего не делает (используй "+ Точка") }); } @@ -410,14 +1047,12 @@ function addRulerPoint(lngLat) { const label = rulerPoints.length === 1 ? '0 м' : rulerTotal >= 1 ? rulerTotal.toFixed(1) + ' км' : Math.round(rulerTotal * 1000) + ' м'; - // Кружок — anchor: center, строго 10×10 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); - // Плашка — отдельный маркер, anchor: center, offset вверх на 20px 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}`;