auto-sync: 2026-05-05 00:00:01

This commit is contained in:
Stream
2026-05-05 00:00:01 +03:00
parent cf83d4b609
commit 201430cde5

View File

@@ -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 = `
<div class="route-coverage-bar">
<div style="width:${s.track_lev12_pct}%;background:#FFD700" title="Lev1-2: ${(s.track_lev12_m/1000).toFixed(1)} км"></div>
<div style="width:${s.track_lev345_pct}%;background:#FF4400" title="Lev3-5: ${(s.track_lev345_m/1000).toFixed(1)} км"></div>
<div style="width:${s.path_pct}%;background:#cc0000" title="Тропы: ${(s.path_m/1000).toFixed(1)} км"></div>
<div style="width:${s.asphalt_pct}%;background:#aaaaaa" title="Асфальт: ${(s.asphalt_m/1000).toFixed(1)} км"></div>
</div>`;
summaryHtml = `<div class="route-card-summary">${s.dirt_total_pct}% грунт · ${s.asphalt_pct}% асфальт</div>`;
detailsHtml = `
<div class="route-card-details" id="route-details-${i}" style="display:none;">
<div class="route-stat-row">🟡 Lev1-2 &nbsp; ${(s.track_lev12_m/1000).toFixed(1)} км &nbsp; ${s.track_lev12_pct}%</div>
<div class="route-stat-row">🔴 Lev3-5 &nbsp; ${(s.track_lev345_m/1000).toFixed(1)} км &nbsp; ${s.track_lev345_pct}%</div>
<div class="route-stat-row">🔴 Тропы &nbsp; ${(s.path_m/1000).toFixed(1)} км &nbsp; ${s.path_pct}%</div>
<div class="route-stat-row">⬜ Асфальт &nbsp; ${(s.asphalt_m/1000).toFixed(1)} км &nbsp; ${s.asphalt_pct}%</div>
<div style="margin-top:6px;display:flex;gap:6px;">
<button onclick="event.stopPropagation();downloadGPX()"
style="flex:1;padding:4px;background:#f0f0f0;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:11px;">
📥 GPX
</button>
<button onclick="event.stopPropagation();selectRoute(${i})"
style="flex:1;padding:4px;background:#ff6600;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px;">
Выбрать
</button>
</div>
</div>
<button class="route-details-toggle" onclick="event.stopPropagation();toggleRouteDetails(${i})">Подробнее ▼</button>`;
} else {
detailsHtml = `
<div class="route-card-details" id="route-details-${i}" style="display:none;">
<div style="margin-top:6px;display:flex;gap:6px;">
<button onclick="event.stopPropagation();downloadGPX()"
style="flex:1;padding:4px;background:#f0f0f0;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:11px;">
📥 GPX
</button>
<button onclick="event.stopPropagation();selectRoute(${i})"
style="flex:1;padding:4px;background:#ff6600;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px;">
Выбрать
</button>
</div>
</div>
<button class="route-details-toggle" onclick="event.stopPropagation();toggleRouteDetails(${i})">Подробнее ▼</button>`;
}
return `<div class="route-card${isActive ? ' active' : ''}" id="route-card-${i}"
onclick="selectRoute(${i})"
onmouseenter="highlightRoute(${i})"
onmouseleave="unhighlightRoute(${i})">
<div class="route-card-header">
<span class="route-color-dot" style="background:${color}"></span>
<span class="route-card-title">Вариант ${i + 1}</span>
<span class="route-card-dist">${distKm} км</span>
<span class="route-card-time">${timeStr}</span>
</div>
${barHtml}
${summaryHtml}
${detailsHtml}
</div>`;
}).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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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 ` <wpt lat="${wp.lat}" lon="${wp.lon}"><name>${escapeXml(name)}</name></wpt>`;
});
// Добавить флажки из localStorage
const markers = loadMarkers();
markers.forEach(m => {
wpts.push(` <wpt lat="${m.lat}" lon="${m.lon}"><name>${escapeXml(m.name)}</name><sym>${escapeXml(m.icon)}</sym></wpt>`);
});
// Трек
const trkpts = route.geometry.coordinates.map(([lon, lat]) =>
` <trkpt lat="${lat}" lon="${lon}"/>`
).join('\n');
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Enduro Trails" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>Enduro route ${dateStr}</name>
<desc>${distKm} км · ${dirtPct}% грунт</desc>
<time>${now.toISOString()}</time>
</metadata>
${wpts.join('\n')}
<trk>
<name>Enduro route ${dateStr}</name>
<trkseg>
${trkpts}
</trkseg>
</trk>
</gpx>`;
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(`
<div class="popup-title">${escapeXml(markerData.name)}</div>
<div class="popup-row"><span class="popup-key">Координаты</span><span class="popup-val">${markerData.lat.toFixed(5)}, ${markerData.lon.toFixed(5)}</span></div>
<div style="margin-top:8px;display:flex;gap:6px;flex-wrap:wrap;">
<button onclick="useMarkerAsA(${markerData.id})" style="flex:1;padding:3px 6px;background:#00aa44;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px;">→ Точка A</button>
<button onclick="useMarkerAsB(${markerData.id})" style="flex:1;padding:3px 6px;background:#cc0000;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px;">→ Точка B</button>
<button onclick="removeMarker(${markerData.id})" style="flex:1;padding:3px 6px;background:#f0f0f0;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:11px;">🗑 Удалить</button>
</div>
`);
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 = `
<div class="popup-title">${props.name || 'Без названия'}</div>
<div class="popup-row"><span class="popup-key">Тип</span><span class="popup-val">${props.highway || '—'}</span></div>
<div class="popup-row"><span class="popup-key">Покрытие</span><span class="popup-val">${props.surface || '—'}</span></div>
<div class="popup-row"><span class="popup-key">Категория</span><span class="popup-val">${props.tracktype || '—'}</span></div>
<div class="popup-row"><span class="popup-key">Длина</span><span class="popup-val">${formatLength(props.length_m)}</span></div>
${props.mtb_scale ? `<div class="popup-row"><span class="popup-key">MTB scale</span><span class="popup-val">${props.mtb_scale}</span></div>` : ''}
`;
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 = `
<div class="popup-title">${props.name || 'Без названия'}</div>
<div class="popup-row"><span class="popup-key">Тип</span><span class="popup-val">${poiTypeLabel(props.poi_type)}</span></div>
`;
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 = '<div class="search-result-item" style="color:#888">Поиск...</div>';
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 = '<div class="search-result-item" style="color:#888">Ничего не найдено</div>';
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 `<div class="search-result-item" onclick="selectSearchResult(${item.lat}, ${item.lon}, '${name.replace(/'/g, "\\'")}')">
<div class="search-result-name">${name}</div>
<div class="search-result-detail">${detail}</div>
</div>`;
}).join('');
} catch(e) {
results.innerHTML = '<div class="search-result-item" style="color:#c00">Ошибка поиска</div>';
}
}
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 = `<span>${label}</span><span style="cursor:pointer;opacity:0.7;" onclick="event.stopPropagation();removeRulerPoint(${idx})">✕</span>`;
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