auto-sync: 2026-05-04 00:50:01
This commit is contained in:
108
tasks/enduro-trails/prototype/static/app.js
vendored
108
tasks/enduro-trails/prototype/static/app.js
vendored
@@ -86,7 +86,6 @@ function toggleLayer(group) {
|
||||
|
||||
// ─── Map init ─────────────────────────────────────────────────────────────────
|
||||
async function initMap() {
|
||||
// Определяем base path для работы как на корне, так и под /enduro/
|
||||
const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
|
||||
const tileBase = window.location.origin + basePath;
|
||||
const style = await fetch(basePath + '/style.json').then(r => r.json());
|
||||
@@ -107,13 +106,12 @@ async function initMap() {
|
||||
map.addControl(new maplibregl.ScaleControl({ unit: 'metric' }), 'bottom-right');
|
||||
map.addControl(new maplibregl.FullscreenControl(), 'top-left');
|
||||
|
||||
// ─── Loading state ────────────────────────────────────────────────────────
|
||||
map.on('load', () => {
|
||||
document.getElementById('loading').classList.remove('visible');
|
||||
checkDataAvailability();
|
||||
initRouteClicks(map);
|
||||
initSearch();
|
||||
initRulerClicks(map);
|
||||
initSearch();
|
||||
});
|
||||
|
||||
map.on('error', (e) => {
|
||||
@@ -125,7 +123,6 @@ async function initMap() {
|
||||
document.getElementById('loading').classList.remove('visible');
|
||||
}, 15000);
|
||||
|
||||
// ─── Stats bar ────────────────────────────────────────────────────────────
|
||||
map.on('zoom', () => {
|
||||
document.getElementById('zoom-val').textContent = map.getZoom().toFixed(1);
|
||||
});
|
||||
@@ -136,7 +133,6 @@ async function initMap() {
|
||||
`${lat.toFixed(4)}, ${lng.toFixed(4)}`;
|
||||
});
|
||||
|
||||
// ─── Popups ───────────────────────────────────────────────────────────────
|
||||
const popup = new maplibregl.Popup({
|
||||
closeButton: true,
|
||||
closeOnClick: false,
|
||||
@@ -161,7 +157,6 @@ async function initMap() {
|
||||
return labels[t] || t;
|
||||
}
|
||||
|
||||
// Клик по грунтовкам
|
||||
['trails-track', 'trails-path-bridleway', 'trails-asphalt'].forEach(layerId => {
|
||||
map.on('click', layerId, (e) => {
|
||||
const props = e.features[0].properties;
|
||||
@@ -179,7 +174,6 @@ async function initMap() {
|
||||
map.on('mouseleave', layerId, () => { map.getCanvas().style.cursor = ''; });
|
||||
});
|
||||
|
||||
// Клик по POI
|
||||
map.on('click', 'poi-circles', (e) => {
|
||||
const props = e.features[0].properties;
|
||||
const html = `
|
||||
@@ -191,7 +185,6 @@ async function initMap() {
|
||||
map.on('mouseenter', 'poi-circles', () => { map.getCanvas().style.cursor = 'pointer'; });
|
||||
map.on('mouseleave', 'poi-circles', () => { map.getCanvas().style.cursor = ''; });
|
||||
|
||||
// Закрыть popup при клике на пустое место
|
||||
map.on('click', (e) => {
|
||||
const features = map.queryRenderedFeatures(e.point, {
|
||||
layers: ['trails-track', 'trails-path-bridleway', 'trails-asphalt', 'poi-circles'],
|
||||
@@ -202,6 +195,7 @@ async function initMap() {
|
||||
|
||||
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) {
|
||||
@@ -212,12 +206,11 @@ async function checkDataAvailability() {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Роутинг ──────────────────────────────────────────────────────────────────────────────
|
||||
// ─── Роутинг ──────────────────────────────────────────────────────────────────
|
||||
let routeMode = false;
|
||||
let routeStart = null;
|
||||
let routeEnd = null;
|
||||
let routeMarkers = [];
|
||||
let routeLayer = null;
|
||||
|
||||
function toggleRouteMode() {
|
||||
routeMode = !routeMode;
|
||||
@@ -252,7 +245,6 @@ function clearRoute() {
|
||||
async function buildRoute() {
|
||||
const map = window._map;
|
||||
document.getElementById('route-status').textContent = '⏳ Строю маршрут...';
|
||||
|
||||
const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
|
||||
try {
|
||||
const resp = await fetch(
|
||||
@@ -260,8 +252,6 @@ async function buildRoute() {
|
||||
);
|
||||
if (!resp.ok) throw new Error('Маршрут не найден');
|
||||
const data = await resp.json();
|
||||
|
||||
// Рисуем линию
|
||||
if (map.getSource('route')) {
|
||||
map.getSource('route').setData(data);
|
||||
} else {
|
||||
@@ -270,16 +260,10 @@ async function buildRoute() {
|
||||
id: 'route-line',
|
||||
type: 'line',
|
||||
source: 'route',
|
||||
paint: {
|
||||
'line-color': '#0066ff',
|
||||
'line-width': 4,
|
||||
'line-opacity': 0.85,
|
||||
},
|
||||
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} мин`;
|
||||
@@ -290,12 +274,10 @@ async function buildRoute() {
|
||||
}
|
||||
}
|
||||
|
||||
// Клик на карте в режиме роутинга
|
||||
function initRouteClicks(map) {
|
||||
map.on('click', (e) => {
|
||||
if (!routeMode) return;
|
||||
const { lng, lat } = e.lngLat;
|
||||
|
||||
if (!routeStart) {
|
||||
routeStart = [lng, lat];
|
||||
const el = document.createElement('div');
|
||||
@@ -312,34 +294,23 @@ function initRouteClicks(map) {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Поиск (Nominatim) ────────────────────────────────────────────────────────────────
|
||||
// ─── Поиск (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;
|
||||
}
|
||||
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();
|
||||
}
|
||||
if (e.key === 'Escape') { results.style.display = 'none'; input.blur(); }
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#search-box')) {
|
||||
results.style.display = 'none';
|
||||
}
|
||||
if (!e.target.closest('#search-box')) results.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -347,21 +318,18 @@ 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, i) => {
|
||||
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, "\\'")}')">
|
||||
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>`;
|
||||
@@ -372,13 +340,12 @@ async function doSearch(query) {
|
||||
}
|
||||
|
||||
function selectSearchResult(lat, lon, name) {
|
||||
const map = window._map;
|
||||
map.flyTo({ center: [lon, lat], zoom: 13, duration: 800 });
|
||||
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 = [];
|
||||
@@ -389,12 +356,10 @@ function toggleRuler() {
|
||||
const btn = document.getElementById('btn-ruler');
|
||||
if (rulerMode) {
|
||||
btn.classList.add('active');
|
||||
btn.title = 'Линейка активна (кликай точки, двойной клик — завершить)';
|
||||
window._map.getCanvas().style.cursor = 'crosshair';
|
||||
clearRuler();
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
btn.title = 'Измерить расстояние';
|
||||
window._map.getCanvas().style.cursor = '';
|
||||
clearRuler();
|
||||
}
|
||||
@@ -426,51 +391,41 @@ function updateRulerLine() {
|
||||
} 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,
|
||||
}
|
||||
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, isLast) {
|
||||
function addRulerPoint(lngLat) {
|
||||
const map = window._map;
|
||||
const pt = [lngLat.lng, lngLat.lat];
|
||||
const idx = rulerPoints.length;
|
||||
rulerPoints.push(pt);
|
||||
|
||||
// Считаем расстояние от предыдущей точки
|
||||
if (rulerPoints.length > 1) {
|
||||
const segDist = haversineKm(rulerPoints[rulerPoints.length - 2], pt);
|
||||
rulerTotal += segDist;
|
||||
rulerTotal += haversineKm(rulerPoints[rulerPoints.length - 2], pt);
|
||||
}
|
||||
|
||||
// Подпись с накопленным расстоянием
|
||||
const label = rulerPoints.length === 1 ? '0 м' :
|
||||
rulerTotal >= 1 ? rulerTotal.toFixed(1) + ' км' : Math.round(rulerTotal * 1000) + ' м';
|
||||
|
||||
// Кружок точно на координатах
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'position:relative;width:10px;height:10px;';
|
||||
el.innerHTML = `
|
||||
<div style="width:10px;height:10px;background:#0088ff;border:2px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,0.3)"></div>
|
||||
<div style="position:absolute;bottom:14px;left:50%;transform:translateX(-50%);display:inline-flex;align-items:center;gap:3px;background:rgba(0,0,0,0.7);color:#fff;font-size:11px;font-weight:600;padding:2px 5px;border-radius:3px;white-space:nowrap;pointer-events:auto">
|
||||
<span>${label}</span>
|
||||
<span style="cursor:pointer;opacity:0.8;font-size:10px" onclick="removeRulerPoint(${idx})">✕</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const marker = new maplibregl.Marker({ element: el, anchor: 'center' })
|
||||
// Кружок — 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);
|
||||
rulerMarkers.push(marker);
|
||||
|
||||
// Плашка — отдельный маркер, 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>`;
|
||||
const labelMarker = new maplibregl.Marker({ element: labelEl, anchor: 'center', offset: [0, -20] })
|
||||
.setLngLat([lngLat.lng, lngLat.lat])
|
||||
.addTo(map);
|
||||
|
||||
rulerMarkers.push(dotMarker, labelMarker);
|
||||
updateRulerLine();
|
||||
}
|
||||
|
||||
@@ -480,7 +435,6 @@ function removeRulerPoint(idx) {
|
||||
rulerMarkers.forEach(m => m.remove());
|
||||
rulerMarkers = [];
|
||||
rulerTotal = 0;
|
||||
// Пересоздаём все маркеры
|
||||
const pts = [...rulerPoints];
|
||||
rulerPoints = [];
|
||||
pts.forEach(pt => addRulerPoint({ lng: pt[0], lat: pt[1] }));
|
||||
@@ -489,13 +443,11 @@ function removeRulerPoint(idx) {
|
||||
function initRulerClicks(map) {
|
||||
map.on('click', (e) => {
|
||||
if (!rulerMode) return;
|
||||
addRulerPoint(e.lngLat, false);
|
||||
addRulerPoint(e.lngLat);
|
||||
});
|
||||
|
||||
map.on('dblclick', (e) => {
|
||||
if (!rulerMode) return;
|
||||
e.preventDefault();
|
||||
// Двойной клик = завершить измерение
|
||||
toggleRuler();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user