auto-sync: 2026-05-04 00:20:02
This commit is contained in:
@@ -274,6 +274,69 @@ body {
|
||||
animation: location-pulse 1.5s ease-out infinite;
|
||||
}
|
||||
|
||||
/* ─── Поиск (Nominatim) ─────────────────────────────────────────────── */
|
||||
#search-box {
|
||||
position: relative;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
#search-input {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
width: 220px;
|
||||
outline: none;
|
||||
background: rgba(255,255,255,0.95);
|
||||
}
|
||||
|
||||
#search-input:focus {
|
||||
border-color: #e07b00;
|
||||
box-shadow: 0 0 0 2px rgba(224,123,0,0.15);
|
||||
}
|
||||
|
||||
#search-results {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
width: 300px;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
z-index: 100;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.search-result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.search-result-item:hover {
|
||||
background: #fff8f0;
|
||||
}
|
||||
|
||||
.search-result-name {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.search-result-detail {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@keyframes location-pulse {
|
||||
0% { transform: translate(-50%, -50%) scale(0.5); opacity: 1; }
|
||||
100% { transform: translate(-50%, -50%) scale(1.5); opacity: 0; }
|
||||
|
||||
176
tasks/enduro-trails/prototype/static/app.js
vendored
176
tasks/enduro-trails/prototype/static/app.js
vendored
@@ -112,6 +112,8 @@ async function initMap() {
|
||||
document.getElementById('loading').classList.remove('visible');
|
||||
checkDataAvailability();
|
||||
initRouteClicks(map);
|
||||
initSearch();
|
||||
initRulerClicks(map);
|
||||
});
|
||||
|
||||
map.on('error', (e) => {
|
||||
@@ -310,4 +312,178 @@ function initRouteClicks(map) {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Поиск (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, i) => {
|
||||
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) {
|
||||
const map = window._map;
|
||||
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');
|
||||
btn.title = 'Линейка активна (кликай точки, двойной клик — завершить)';
|
||||
window._map.getCanvas().style.cursor = 'crosshair';
|
||||
clearRuler();
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
btn.title = 'Измерить расстояние';
|
||||
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, isLast) {
|
||||
const map = window._map;
|
||||
const pt = [lngLat.lng, lngLat.lat];
|
||||
rulerPoints.push(pt);
|
||||
|
||||
// Считаем расстояние от предыдущей точки
|
||||
let segDist = 0;
|
||||
if (rulerPoints.length > 1) {
|
||||
segDist = haversineKm(rulerPoints[rulerPoints.length - 2], pt);
|
||||
rulerTotal += segDist;
|
||||
}
|
||||
|
||||
// Маркер с подписью
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'background:#0088ff;border:2px solid #fff;border-radius:50%;width:10px;height:10px;box-shadow:0 0 4px rgba(0,0,0,0.3)';
|
||||
|
||||
// Подпись с накопленным расстоянием
|
||||
const label = rulerPoints.length === 1 ? '0' :
|
||||
rulerTotal >= 1 ? rulerTotal.toFixed(1) + ' км' : Math.round(rulerTotal * 1000) + ' м';
|
||||
|
||||
const popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false })
|
||||
.setHTML(`<span style="font-size:12px;font-weight:600;color:#0088ff">${label}</span>`);
|
||||
|
||||
const marker = new maplibregl.Marker({ element: el })
|
||||
.setLngLat([lngLat.lng, lngLat.lat])
|
||||
.setPopup(popup)
|
||||
.addTo(map);
|
||||
marker.togglePopup();
|
||||
rulerMarkers.push(marker);
|
||||
|
||||
updateRulerLine();
|
||||
}
|
||||
|
||||
function initRulerClicks(map) {
|
||||
map.on('click', (e) => {
|
||||
if (!rulerMode) return;
|
||||
addRulerPoint(e.lngLat, false);
|
||||
});
|
||||
|
||||
map.on('dblclick', (e) => {
|
||||
if (!rulerMode) return;
|
||||
e.preventDefault();
|
||||
// Двойной клик = завершить измерение
|
||||
toggleRuler();
|
||||
});
|
||||
}
|
||||
|
||||
initMap();
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
🗺 Подложка
|
||||
</button>
|
||||
</div>
|
||||
<div id="search-box">
|
||||
<input type="text" id="search-input" placeholder="🔍 Поиск места..." autocomplete="off" />
|
||||
<div id="search-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="map-container">
|
||||
@@ -104,6 +108,7 @@
|
||||
<button id="btn-compass" class="map-ctrl-btn" title="Свободное вращение" onclick="toggleCompass()">🧭</button>
|
||||
<button id="btn-route" class="map-ctrl-btn" title="Построить маршрут" onclick="toggleRouteMode()">🗺️</button>
|
||||
<button id="btn-locate" class="map-ctrl-btn" title="Моё местоположение" onclick="locateMe()">📍</button>
|
||||
<button id="btn-ruler" class="map-ctrl-btn" title="Измерить расстояние" onclick="toggleRuler()">📏</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user