auto-sync: 2026-05-05 00:00:01
This commit is contained in:
690
tasks/enduro-trails/prototype/static/app.js
vendored
690
tasks/enduro-trails/prototype/static/app.js
vendored
@@ -70,7 +70,7 @@ function locateMe() {
|
|||||||
(pos) => {
|
(pos) => {
|
||||||
const { longitude, latitude } = pos.coords;
|
const { longitude, latitude } = pos.coords;
|
||||||
const map = window._map;
|
const map = window._map;
|
||||||
btn.textContent = '📍';
|
btn.textContent = '🎯';
|
||||||
map.flyTo({ center: [longitude, latitude], zoom: 13, duration: 800 });
|
map.flyTo({ center: [longitude, latitude], zoom: 13, duration: 800 });
|
||||||
if (locationMarker) {
|
if (locationMarker) {
|
||||||
locationMarker.setLngLat([longitude, latitude]);
|
locationMarker.setLngLat([longitude, latitude]);
|
||||||
@@ -84,7 +84,7 @@ function locateMe() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
btn.textContent = '📍';
|
btn.textContent = '🎯';
|
||||||
alert('Не удалось определить местоположение: ' + err.message);
|
alert('Не удалось определить местоположение: ' + err.message);
|
||||||
},
|
},
|
||||||
{ enableHighAccuracy: true, timeout: 10000 }
|
{ enableHighAccuracy: true, timeout: 10000 }
|
||||||
@@ -140,6 +140,8 @@ function toggleRouteMode() {
|
|||||||
const btn = document.getElementById('btn-route');
|
const btn = document.getElementById('btn-route');
|
||||||
const panel = document.getElementById('route-panel');
|
const panel = document.getElementById('route-panel');
|
||||||
if (routeMode) {
|
if (routeMode) {
|
||||||
|
deactivateAllModes();
|
||||||
|
routeMode = true; // deactivateAllModes выключил, включаем обратно
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
panel.style.display = 'block';
|
panel.style.display = 'block';
|
||||||
clearRoute();
|
clearRoute();
|
||||||
@@ -417,686 +419,4 @@ function selectRoute(idx) {
|
|||||||
const isActive = i === idx;
|
const isActive = i === idx;
|
||||||
if (map.getLayer('route-line-' + i)) {
|
if (map.getLayer('route-line-' + i)) {
|
||||||
map.setPaintProperty('route-line-' + i, 'line-width', isActive ? 5 : 3);
|
map.setPaintProperty('route-line-' + i, 'line-width', isActive ? 5 : 3);
|
||||||
map.setPaintProperty('route-line-' + i, 'line-opacity', isActive ? 0.95 : 0.5);
|
map.setPaint
|
||||||
}
|
|
||||||
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 ${(s.track_lev12_m/1000).toFixed(1)} км ${s.track_lev12_pct}%</div>
|
|
||||||
<div class="route-stat-row">🔴 Lev3-5 ${(s.track_lev345_m/1000).toFixed(1)} км ${s.track_lev345_pct}%</div>
|
|
||||||
<div class="route-stat-row">🔴 Тропы ${(s.path_m/1000).toFixed(1)} км ${s.path_pct}%</div>
|
|
||||||
<div class="route-stat-row">⬜ Асфальт ${(s.asphalt_m/1000).toFixed(1)} км ${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,'&').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 ` <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();
|
|
||||||
Reference in New Issue
Block a user