auto-sync: 2026-05-04 11:10:01
This commit is contained in:
803
tasks/enduro-trails/prototype/static/app.js
vendored
803
tasks/enduro-trails/prototype/static/app.js
vendored
@@ -1,3 +1,25 @@
|
||||
// ─── Утилиты ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const totalMin = Math.round(seconds / 60);
|
||||
if (totalMin < 60) return totalMin + ' мин';
|
||||
const days = Math.floor(totalMin / 1440);
|
||||
const hours = Math.floor((totalMin % 1440) / 60);
|
||||
const mins = totalMin % 60;
|
||||
if (days > 0) {
|
||||
if (mins === 0) return `${days} дн ${hours} ч`;
|
||||
return `${days} дн ${hours} ч ${mins} мин`;
|
||||
}
|
||||
if (mins === 0) return `${hours} ч`;
|
||||
return `${hours} ч ${mins} мин`;
|
||||
}
|
||||
|
||||
function formatDist(m) {
|
||||
if (!m) return '—';
|
||||
if (m >= 1000) return (m / 1000).toFixed(1) + ' км';
|
||||
return Math.round(m) + ' м';
|
||||
}
|
||||
|
||||
// ─── Компас ───────────────────────────────────────────────────────────────────
|
||||
let compassLocked = false;
|
||||
|
||||
@@ -84,6 +106,648 @@ function toggleLayer(group) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ─── Роутинг — состояние ──────────────────────────────────────────────────────
|
||||
const ROUTE_COLORS = ['#0066ff', '#00aa44', '#9933cc', '#ff8800', '#888888'];
|
||||
|
||||
let routeMode = false;
|
||||
let routeWaypoints = []; // [{lon, lat}, ...]
|
||||
let routeResults = []; // массив маршрутов из API
|
||||
let activeRouteIdx = 0;
|
||||
let waypointMarkers = []; // MapLibre маркеры точек
|
||||
let addingWaypoint = false; // режим добавления промежуточной точки
|
||||
let buildDebounceTimer = null;
|
||||
|
||||
function getBasePath() {
|
||||
return window.location.pathname.replace(/\/[^/]*$/, '') || '';
|
||||
}
|
||||
|
||||
// ─── Режим маршрута ───────────────────────────────────────────────────────────
|
||||
function toggleRouteMode() {
|
||||
routeMode = !routeMode;
|
||||
const btn = document.getElementById('btn-route');
|
||||
const panel = document.getElementById('route-panel');
|
||||
if (routeMode) {
|
||||
btn.classList.add('active');
|
||||
panel.style.display = 'block';
|
||||
clearRoute();
|
||||
window._map.getCanvas().style.cursor = 'crosshair';
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
panel.style.display = 'none';
|
||||
clearRoute();
|
||||
window._map.getCanvas().style.cursor = '';
|
||||
}
|
||||
}
|
||||
|
||||
function clearRoute() {
|
||||
// Убираем маркеры точек
|
||||
waypointMarkers.forEach(m => m.remove());
|
||||
waypointMarkers = [];
|
||||
routeWaypoints = [];
|
||||
routeResults = [];
|
||||
activeRouteIdx = 0;
|
||||
addingWaypoint = false;
|
||||
|
||||
// Убираем слои маршрутов
|
||||
const map = window._map;
|
||||
if (map) {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i);
|
||||
if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline');
|
||||
}
|
||||
if (map.getSource('route')) map.removeSource('route');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (map.getSource('route-' + i)) map.removeSource('route-' + i);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('route-status').textContent = 'Кликни точку старта';
|
||||
document.getElementById('route-actions').style.display = 'none';
|
||||
document.getElementById('route-cards').innerHTML = '';
|
||||
document.getElementById('waypoints-list').innerHTML = '';
|
||||
document.getElementById('btn-add-waypoint').style.display = '';
|
||||
|
||||
if (routeMode && map) map.getCanvas().style.cursor = 'crosshair';
|
||||
}
|
||||
|
||||
// ─── Добавление промежуточной точки ──────────────────────────────────────────
|
||||
function startAddWaypoint() {
|
||||
if (routeWaypoints.length >= 10) return;
|
||||
addingWaypoint = true;
|
||||
window._map.getCanvas().style.cursor = 'crosshair';
|
||||
document.getElementById('route-status').textContent = 'Кликни на карте для добавления точки';
|
||||
}
|
||||
|
||||
// ─── Маркеры точек ────────────────────────────────────────────────────────────
|
||||
function createWaypointMarkerEl(index, total) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'route-waypoint-marker';
|
||||
let bg, text, color = '#fff';
|
||||
if (index === 0) {
|
||||
bg = '#00aa44'; text = 'A';
|
||||
el.style.cssText = `width:22px;height:22px;background:${bg};border:2px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:${color};box-shadow:0 1px 4px rgba(0,0,0,0.3);cursor:grab;`;
|
||||
} else if (index === total - 1) {
|
||||
bg = '#cc0000'; text = 'B';
|
||||
el.style.cssText = `width:22px;height:22px;background:${bg};border:2px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:${color};box-shadow:0 1px 4px rgba(0,0,0,0.3);cursor:grab;`;
|
||||
} else {
|
||||
text = String(index);
|
||||
el.style.cssText = `width:18px;height:18px;background:#fff;border:2px solid #0066ff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:#0066ff;box-shadow:0 1px 4px rgba(0,0,0,0.3);cursor:grab;`;
|
||||
}
|
||||
el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
function rebuildWaypointMarkers() {
|
||||
waypointMarkers.forEach(m => m.remove());
|
||||
waypointMarkers = [];
|
||||
const map = window._map;
|
||||
routeWaypoints.forEach((wp, i) => {
|
||||
const el = createWaypointMarkerEl(i, routeWaypoints.length);
|
||||
const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: true })
|
||||
.setLngLat([wp.lon, wp.lat])
|
||||
.addTo(map);
|
||||
|
||||
// Захватываем индекс в замыкании
|
||||
(function(idx) {
|
||||
marker.on('dragend', () => {
|
||||
const lngLat = marker.getLngLat();
|
||||
routeWaypoints[idx] = { lon: lngLat.lng, lat: lngLat.lat };
|
||||
renderWaypointsList();
|
||||
debounceBuildRoute();
|
||||
});
|
||||
})(i);
|
||||
|
||||
waypointMarkers.push(marker);
|
||||
});
|
||||
}
|
||||
|
||||
function renderWaypointsList() {
|
||||
const list = document.getElementById('waypoints-list');
|
||||
if (!routeWaypoints.length) { list.innerHTML = ''; return; }
|
||||
|
||||
list.innerHTML = routeWaypoints.map((wp, i) => {
|
||||
let labelClass, labelText;
|
||||
if (i === 0) { labelClass = 'start'; labelText = 'A'; }
|
||||
else if (i === routeWaypoints.length - 1) { labelClass = 'end'; labelText = 'B'; }
|
||||
else { labelClass = 'mid'; labelText = String(i); }
|
||||
|
||||
return `<div class="waypoint-row" draggable="true"
|
||||
ondragstart="onWpDragStart(event,${i})"
|
||||
ondragover="onWpDragOver(event,${i})"
|
||||
ondrop="onWpDrop(event,${i})"
|
||||
ondragleave="onWpDragLeave(event)">
|
||||
<span class="waypoint-label ${labelClass}">${labelText}</span>
|
||||
<span class="waypoint-coords">${wp.lat.toFixed(4)}, ${wp.lon.toFixed(4)}</span>
|
||||
<button class="waypoint-remove" onclick="removeWaypoint(${i})" title="Удалить точку">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Показываем/скрываем кнопку добавления
|
||||
document.getElementById('btn-add-waypoint').style.display =
|
||||
routeWaypoints.length >= 10 ? 'none' : '';
|
||||
}
|
||||
|
||||
// ─── Drag-and-drop порядка точек ──────────────────────────────────────────────
|
||||
let dragWpIdx = null;
|
||||
|
||||
function onWpDragStart(e, idx) {
|
||||
dragWpIdx = idx;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
function onWpDragOver(e, idx) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
document.querySelectorAll('.waypoint-row').forEach((r, i) => {
|
||||
r.classList.toggle('drag-over', i === idx);
|
||||
});
|
||||
}
|
||||
|
||||
function onWpDragLeave(e) {
|
||||
e.currentTarget.classList.remove('drag-over');
|
||||
}
|
||||
|
||||
function onWpDrop(e, idx) {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('.waypoint-row').forEach(r => r.classList.remove('drag-over'));
|
||||
if (dragWpIdx === null || dragWpIdx === idx) return;
|
||||
const moved = routeWaypoints.splice(dragWpIdx, 1)[0];
|
||||
routeWaypoints.splice(idx, 0, moved);
|
||||
dragWpIdx = null;
|
||||
rebuildWaypointMarkers();
|
||||
renderWaypointsList();
|
||||
if (routeWaypoints.length >= 2) debounceBuildRoute();
|
||||
}
|
||||
|
||||
function removeWaypoint(idx) {
|
||||
routeWaypoints.splice(idx, 1);
|
||||
rebuildWaypointMarkers();
|
||||
renderWaypointsList();
|
||||
if (routeWaypoints.length >= 2) {
|
||||
debounceBuildRoute();
|
||||
} else {
|
||||
// Убираем маршруты с карты
|
||||
const map = window._map;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i);
|
||||
if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline');
|
||||
if (map.getSource('route-' + i)) map.removeSource('route-' + i);
|
||||
}
|
||||
routeResults = [];
|
||||
document.getElementById('route-cards').innerHTML = '';
|
||||
document.getElementById('route-actions').style.display =
|
||||
routeWaypoints.length >= 2 ? 'block' : 'none';
|
||||
document.getElementById('route-status').textContent =
|
||||
routeWaypoints.length === 0 ? 'Кликни точку старта' :
|
||||
routeWaypoints.length === 1 ? 'Кликни точку финиша' : '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─── Построение маршрута ──────────────────────────────────────────────────────
|
||||
function debounceBuildRoute() {
|
||||
clearTimeout(buildDebounceTimer);
|
||||
buildDebounceTimer = setTimeout(buildRoute, 300);
|
||||
}
|
||||
|
||||
async function buildRoute() {
|
||||
if (routeWaypoints.length < 2) return;
|
||||
const map = window._map;
|
||||
const basePath = getBasePath();
|
||||
|
||||
document.getElementById('route-status').textContent = '⏳ Строю маршрут...';
|
||||
const btn = document.getElementById('btn-build-route');
|
||||
if (btn) btn.textContent = '⏳ Строю...';
|
||||
|
||||
try {
|
||||
const resp = await fetch(basePath + '/api/route', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ waypoints: routeWaypoints, alternatives: 5 }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Маршрут не найден');
|
||||
const data = await resp.json();
|
||||
routeResults = data.routes || [];
|
||||
if (!routeResults.length) throw new Error('Маршрут не найден');
|
||||
|
||||
// Убираем старые слои
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (map.getLayer('route-line-' + i)) map.removeLayer('route-line-' + i);
|
||||
if (map.getLayer('route-line-' + i + '-outline')) map.removeLayer('route-line-' + i + '-outline');
|
||||
if (map.getSource('route-' + i)) map.removeSource('route-' + i);
|
||||
}
|
||||
|
||||
// Рисуем все маршруты
|
||||
routeResults.forEach((route, i) => {
|
||||
const color = ROUTE_COLORS[i] || '#888888';
|
||||
const isActive = i === activeRouteIdx;
|
||||
map.addSource('route-' + i, {
|
||||
type: 'geojson',
|
||||
data: { type: 'Feature', geometry: route.geometry, properties: {} }
|
||||
});
|
||||
// Обводка (белая) для активного
|
||||
map.addLayer({
|
||||
id: 'route-line-' + i + '-outline',
|
||||
type: 'line',
|
||||
source: 'route-' + i,
|
||||
paint: {
|
||||
'line-color': '#ffffff',
|
||||
'line-width': isActive ? 7 : 4,
|
||||
'line-opacity': isActive ? 0.6 : 0,
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' }
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'route-line-' + i,
|
||||
type: 'line',
|
||||
source: 'route-' + i,
|
||||
paint: {
|
||||
'line-color': color,
|
||||
'line-width': isActive ? 5 : 3,
|
||||
'line-opacity': isActive ? 0.95 : 0.5,
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' }
|
||||
});
|
||||
|
||||
// Клик на линию маршрута
|
||||
map.on('click', 'route-line-' + i, (e) => {
|
||||
e.stopPropagation ? e.stopPropagation() : null;
|
||||
selectRoute(i);
|
||||
});
|
||||
map.on('mouseenter', 'route-line-' + i, () => {
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
highlightRoute(i);
|
||||
});
|
||||
map.on('mouseleave', 'route-line-' + i, () => {
|
||||
map.getCanvas().style.cursor = routeMode ? 'crosshair' : '';
|
||||
unhighlightRoute(i);
|
||||
});
|
||||
});
|
||||
|
||||
activeRouteIdx = 0;
|
||||
renderRouteCards(routeResults);
|
||||
document.getElementById('route-status').textContent = `✅ ${routeResults.length} маршрут(ов)`;
|
||||
document.getElementById('route-actions').style.display = 'block';
|
||||
|
||||
} catch(e) {
|
||||
document.getElementById('route-status').textContent = '❌ ' + e.message;
|
||||
}
|
||||
|
||||
if (btn) btn.textContent = '🗺️ Построить маршрут';
|
||||
}
|
||||
|
||||
// ─── Выбор и подсветка маршрутов ─────────────────────────────────────────────
|
||||
function selectRoute(idx) {
|
||||
activeRouteIdx = idx;
|
||||
const map = window._map;
|
||||
routeResults.forEach((_, i) => {
|
||||
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 ${(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(/\/[^/]*$/, '') || '';
|
||||
@@ -112,6 +776,7 @@ async function initMap() {
|
||||
initRouteClicks(map);
|
||||
initRulerClicks(map);
|
||||
initSearch();
|
||||
renderMarkers();
|
||||
});
|
||||
|
||||
map.on('error', (e) => {
|
||||
@@ -159,6 +824,8 @@ async function initMap() {
|
||||
|
||||
['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>
|
||||
@@ -170,11 +837,16 @@ async function initMap() {
|
||||
`;
|
||||
popup.setLngLat(e.lngLat).setHTML(html).addTo(map);
|
||||
});
|
||||
map.on('mouseenter', layerId, () => { map.getCanvas().style.cursor = 'pointer'; });
|
||||
map.on('mouseleave', layerId, () => { map.getCanvas().style.cursor = ''; });
|
||||
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>
|
||||
@@ -182,10 +854,15 @@ async function initMap() {
|
||||
`;
|
||||
popup.setLngLat(e.lngLat).setHTML(html).addTo(map);
|
||||
});
|
||||
map.on('mouseenter', 'poi-circles', () => { map.getCanvas().style.cursor = 'pointer'; });
|
||||
map.on('mouseleave', 'poi-circles', () => { map.getCanvas().style.cursor = ''; });
|
||||
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'],
|
||||
});
|
||||
@@ -206,91 +883,51 @@ async function checkDataAvailability() {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Роутинг ──────────────────────────────────────────────────────────────────
|
||||
let routeMode = false;
|
||||
let routeStart = null;
|
||||
let routeEnd = null;
|
||||
let routeMarkers = [];
|
||||
|
||||
function toggleRouteMode() {
|
||||
routeMode = !routeMode;
|
||||
const btn = document.getElementById('btn-route');
|
||||
const panel = document.getElementById('route-panel');
|
||||
if (routeMode) {
|
||||
btn.classList.add('active');
|
||||
panel.style.display = 'block';
|
||||
clearRoute();
|
||||
window._map.getCanvas().style.cursor = 'crosshair';
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
panel.style.display = 'none';
|
||||
clearRoute();
|
||||
window._map.getCanvas().style.cursor = '';
|
||||
}
|
||||
}
|
||||
|
||||
function clearRoute() {
|
||||
routeStart = null;
|
||||
routeEnd = null;
|
||||
routeMarkers.forEach(m => m.remove());
|
||||
routeMarkers = [];
|
||||
const map = window._map;
|
||||
if (map.getLayer('route-line')) map.removeLayer('route-line');
|
||||
if (map.getSource('route')) map.removeSource('route');
|
||||
document.getElementById('route-status').textContent = 'Кликни точку старта';
|
||||
document.getElementById('route-info').style.display = 'none';
|
||||
if (routeMode) map.getCanvas().style.cursor = 'crosshair';
|
||||
}
|
||||
|
||||
async function buildRoute() {
|
||||
const map = window._map;
|
||||
document.getElementById('route-status').textContent = '⏳ Строю маршрут...';
|
||||
const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${basePath}/api/route?from_lon=${routeStart[0]}&from_lat=${routeStart[1]}&to_lon=${routeEnd[0]}&to_lat=${routeEnd[1]}`
|
||||
);
|
||||
if (!resp.ok) throw new Error('Маршрут не найден');
|
||||
const data = await resp.json();
|
||||
if (map.getSource('route')) {
|
||||
map.getSource('route').setData(data);
|
||||
} else {
|
||||
map.addSource('route', { type: 'geojson', data });
|
||||
map.addLayer({
|
||||
id: 'route-line',
|
||||
type: 'line',
|
||||
source: 'route',
|
||||
paint: { 'line-color': '#0066ff', 'line-width': 4, 'line-opacity': 0.85 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' }
|
||||
});
|
||||
}
|
||||
const p = data.properties;
|
||||
document.getElementById('route-distance').textContent = `${p.distance_km} км`;
|
||||
document.getElementById('route-duration').textContent = `~${p.duration_min} мин`;
|
||||
document.getElementById('route-status').textContent = '✅ Готово';
|
||||
document.getElementById('route-info').style.display = 'block';
|
||||
} catch(e) {
|
||||
document.getElementById('route-status').textContent = '❌ ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Клики на карте (маршрут + метки) ────────────────────────────────────────
|
||||
function initRouteClicks(map) {
|
||||
map.on('click', (e) => {
|
||||
// Режим добавления метки
|
||||
if (markerMode) {
|
||||
addMarker(e.lngLat);
|
||||
toggleMarkerMode();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!routeMode) return;
|
||||
|
||||
const { lng, lat } = e.lngLat;
|
||||
if (!routeStart) {
|
||||
routeStart = [lng, lat];
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'width:14px;height:14px;background:#00aa00;border:2px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,0.3)';
|
||||
routeMarkers.push(new maplibregl.Marker({element: el}).setLngLat([lng, lat]).addTo(map));
|
||||
|
||||
// Режим добавления промежуточной точки
|
||||
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 (!routeEnd) {
|
||||
routeEnd = [lng, lat];
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'width:14px;height:14px;background:#cc0000;border:2px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,0.3)';
|
||||
routeMarkers.push(new maplibregl.Marker({element: el}).setLngLat([lng, lat]).addTo(map));
|
||||
} else if (routeWaypoints.length === 1) {
|
||||
routeWaypoints.push({ lon: lng, lat: lat });
|
||||
rebuildWaypointMarkers();
|
||||
renderWaypointsList();
|
||||
updateRouteActionsVisibility();
|
||||
buildRoute();
|
||||
}
|
||||
// Если уже 2+ точек — клик ничего не делает (используй "+ Точка")
|
||||
});
|
||||
}
|
||||
|
||||
@@ -410,14 +1047,12 @@ function addRulerPoint(lngLat) {
|
||||
const label = rulerPoints.length === 1 ? '0 м' :
|
||||
rulerTotal >= 1 ? rulerTotal.toFixed(1) + ' км' : Math.round(rulerTotal * 1000) + ' м';
|
||||
|
||||
// Кружок — anchor: center, строго 10×10
|
||||
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);
|
||||
|
||||
// Плашка — отдельный маркер, anchor: center, offset вверх на 20px
|
||||
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>`;
|
||||
|
||||
Reference in New Issue
Block a user