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 = `
+
+
🟡 Lev1-2 ${(s.track_lev12_m/1000).toFixed(1)} км ${s.track_lev12_pct}%
+
🔴 Lev3-5 ${(s.track_lev345_m/1000).toFixed(1)} км ${s.track_lev345_pct}%
+
🔴 Тропы ${(s.path_m/1000).toFixed(1)} км ${s.path_pct}%
+
⬜ Асфальт ${(s.asphalt_m/1000).toFixed(1)} км ${s.asphalt_pct}%
+
+
+
+
+
+ `;
+ } else {
+ detailsHtml = `
+
+
+
+
+
+
+ `;
+ }
+
+ return `
+
+ ${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 ``;
+ }).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();