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 ``;
+ }).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 @@
+