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) => {
|
||||
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 ${(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();
|
||||
map.setPaint
|
||||
Reference in New Issue
Block a user