From 0a46b5347ac48bf875f2349b4fcce8eec91189be Mon Sep 17 00:00:00 2001 From: Stream Date: Mon, 4 May 2026 00:20:02 +0300 Subject: [PATCH] auto-sync: 2026-05-04 00:20:02 --- tasks/enduro-trails/prototype/static/app.css | 63 +++++++ tasks/enduro-trails/prototype/static/app.js | 176 ++++++++++++++++++ .../enduro-trails/prototype/static/index.html | 5 + 3 files changed, 244 insertions(+) diff --git a/tasks/enduro-trails/prototype/static/app.css b/tasks/enduro-trails/prototype/static/app.css index 09d5c08..ed09758 100644 --- a/tasks/enduro-trails/prototype/static/app.css +++ b/tasks/enduro-trails/prototype/static/app.css @@ -274,6 +274,69 @@ body { animation: location-pulse 1.5s ease-out infinite; } +/* ─── Поиск (Nominatim) ─────────────────────────────────────────────── */ +#search-box { + position: relative; + margin-left: 12px; +} + +#search-input { + padding: 6px 12px; + border: 1px solid #ccc; + border-radius: 20px; + font-size: 13px; + width: 220px; + outline: none; + background: rgba(255,255,255,0.95); +} + +#search-input:focus { + border-color: #e07b00; + box-shadow: 0 0 0 2px rgba(224,123,0,0.15); +} + +#search-results { + display: none; + position: absolute; + top: calc(100% + 4px); + left: 0; + width: 300px; + background: white; + border: 1px solid #ddd; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + z-index: 100; + max-height: 280px; + overflow-y: auto; +} + +.search-result-item { + padding: 8px 12px; + cursor: pointer; + font-size: 13px; + border-bottom: 1px solid #f0f0f0; + line-height: 1.4; +} + +.search-result-item:last-child { + border-bottom: none; +} + +.search-result-item:hover { + background: #fff8f0; +} + +.search-result-name { + font-weight: 600; + color: #333; +} + +.search-result-detail { + font-size: 11px; + color: #888; + margin-top: 2px; +} + @keyframes location-pulse { 0% { transform: translate(-50%, -50%) scale(0.5); opacity: 1; } 100% { transform: translate(-50%, -50%) scale(1.5); opacity: 0; } diff --git a/tasks/enduro-trails/prototype/static/app.js b/tasks/enduro-trails/prototype/static/app.js index 13840cf..7e5e83c 100644 --- a/tasks/enduro-trails/prototype/static/app.js +++ b/tasks/enduro-trails/prototype/static/app.js @@ -112,6 +112,8 @@ async function initMap() { document.getElementById('loading').classList.remove('visible'); checkDataAvailability(); initRouteClicks(map); + initSearch(); + initRulerClicks(map); }); map.on('error', (e) => { @@ -310,4 +312,178 @@ function initRouteClicks(map) { }); } +// ─── Поиск (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, i) => { + 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) { + const map = window._map; + 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'); + btn.title = 'Линейка активна (кликай точки, двойной клик — завершить)'; + window._map.getCanvas().style.cursor = 'crosshair'; + clearRuler(); + } else { + btn.classList.remove('active'); + btn.title = 'Измерить расстояние'; + 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, isLast) { + const map = window._map; + const pt = [lngLat.lng, lngLat.lat]; + rulerPoints.push(pt); + + // Считаем расстояние от предыдущей точки + let segDist = 0; + if (rulerPoints.length > 1) { + segDist = haversineKm(rulerPoints[rulerPoints.length - 2], pt); + rulerTotal += segDist; + } + + // Маркер с подписью + const el = document.createElement('div'); + el.style.cssText = 'background:#0088ff;border:2px solid #fff;border-radius:50%;width:10px;height:10px;box-shadow:0 0 4px rgba(0,0,0,0.3)'; + + // Подпись с накопленным расстоянием + const label = rulerPoints.length === 1 ? '0' : + rulerTotal >= 1 ? rulerTotal.toFixed(1) + ' км' : Math.round(rulerTotal * 1000) + ' м'; + + const popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false }) + .setHTML(`${label}`); + + const marker = new maplibregl.Marker({ element: el }) + .setLngLat([lngLat.lng, lngLat.lat]) + .setPopup(popup) + .addTo(map); + marker.togglePopup(); + rulerMarkers.push(marker); + + updateRulerLine(); +} + +function initRulerClicks(map) { + map.on('click', (e) => { + if (!rulerMode) return; + addRulerPoint(e.lngLat, false); + }); + + map.on('dblclick', (e) => { + if (!rulerMode) return; + e.preventDefault(); + // Двойной клик = завершить измерение + toggleRuler(); + }); +} + initMap(); diff --git a/tasks/enduro-trails/prototype/static/index.html b/tasks/enduro-trails/prototype/static/index.html index 082c77c..1778d59 100644 --- a/tasks/enduro-trails/prototype/static/index.html +++ b/tasks/enduro-trails/prototype/static/index.html @@ -32,6 +32,10 @@ 🗺 Подложка +
@@ -104,6 +108,7 @@ +