diff --git a/tasks/enduro-trails/prototype/static/app.js b/tasks/enduro-trails/prototype/static/app.js index 491a9d4..bc5eda5 100644 --- a/tasks/enduro-trails/prototype/static/app.js +++ b/tasks/enduro-trails/prototype/static/app.js @@ -70,7 +70,7 @@ function locateMe() { (pos) => { const { longitude, latitude } = pos.coords; const map = window._map; - btn.textContent = '📍'; + btn.textContent = '🎯'; map.flyTo({ center: [longitude, latitude], zoom: 13, duration: 800 }); if (locationMarker) { locationMarker.setLngLat([longitude, latitude]); @@ -84,7 +84,7 @@ function locateMe() { } }, (err) => { - btn.textContent = '📍'; + btn.textContent = '🎯'; alert('Не удалось определить местоположение: ' + err.message); }, { enableHighAccuracy: true, timeout: 10000 } @@ -140,6 +140,8 @@ function toggleRouteMode() { const btn = document.getElementById('btn-route'); const panel = document.getElementById('route-panel'); if (routeMode) { + deactivateAllModes(); + routeMode = true; // deactivateAllModes выключил, включаем обратно btn.classList.add('active'); panel.style.display = 'block'; clearRoute(); @@ -417,686 +419,4 @@ function selectRoute(idx) { 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(/\/[^/]*$/, '') || ''; - 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) => { - // Режим добавления метки - 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) { - 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(); + map.setPaint \ No newline at end of file