diff --git a/src/web/app.css b/src/web/app.css
index b09e447..d9de6a9 100644
--- a/src/web/app.css
+++ b/src/web/app.css
@@ -247,7 +247,8 @@ body.has-map-mode #sheet-backdrop.visible { pointer-events: none; }
#waypoints-list { display: flex; flex-direction: column; margin-bottom: 10px; }
.wl-item {
display: flex; align-items: center; gap: 8px;
- padding: 6px 0;
+ padding: 8px 4px;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
border-bottom: 1px solid var(--border);
position: relative;
}
@@ -769,3 +770,262 @@ body.has-map-mode #sheet-backdrop.visible { pointer-events: none; }
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
+
+/* ═══════════════════════════════════════════
+ Terrain Layer (Phase 5.4)
+ ═══════════════════════════════════════════ */
+
+/* Terrain toggle button active state */
+#terrain-toggle.active {
+ color: var(--accent, #4CAF50);
+ background: rgba(76, 175, 80, 0.15);
+}
+
+/* Terrain popup */
+.terrain-popup {
+ position: fixed;
+ z-index: 500;
+ background: var(--surface, #1e1e1e);
+ border: 1px solid var(--border, rgba(255,255,255,0.12));
+ border-radius: 12px;
+ padding: 12px 14px;
+ min-width: 160px;
+ box-shadow: 0 4px 20px rgba(0,0,0,0.4);
+ user-select: none;
+}
+
+.terrain-popup-title {
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text2, rgba(255,255,255,0.5));
+ margin-bottom: 10px;
+}
+
+.terrain-checkbox {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 4px;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ cursor: pointer;
+ font-size: 15px;
+ color: var(--text, #fff);
+ border-radius: 6px;
+}
+
+.terrain-checkbox span {
+ font-size: 15px;
+ line-height: 1.3;
+}
+
+.terrain-checkbox:hover {
+ color: var(--accent, #4CAF50);
+}
+
+.terrain-checkbox input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ accent-color: var(--accent, #4CAF50);
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+/* Light theme overrides */
+.theme-light .terrain-popup {
+ background: var(--surface, #fff);
+ border-color: var(--border, rgba(0,0,0,0.12));
+ box-shadow: 0 4px 20px rgba(0,0,0,0.15);
+}
+
+.theme-light .terrain-popup-title {
+ color: var(--text2, rgba(0,0,0,0.5));
+}
+
+.theme-light .terrain-checkbox {
+ color: var(--text, #111);
+}
+
+
+/* Terrain hillshade hint & disabled state */
+.terrain-hint {
+ display: block;
+ font-size: 11px;
+ color: var(--accent, #4CAF50);
+ font-style: italic;
+ padding: 4px 0 2px 28px;
+ line-height: 1.2;
+}
+.terrain-checkbox.disabled {
+ opacity: 0.45;
+ pointer-events: none;
+ cursor: not-allowed;
+}
+.terrain-checkbox.disabled input[type="checkbox"] {
+ cursor: not-allowed;
+}
+
+/* ── Scale + Zoom bar (one line, top-right) ───────── */
+#scale-zoom-bar {
+ position: absolute;
+ top: calc(max(env(safe-area-inset-top, 0px), 8px) + 4px);
+ right: 12px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ z-index: 10;
+ pointer-events: none;
+}
+
+.szb-scale {
+ height: 16px;
+ border: 1.5px solid rgba(255,255,255,0.8);
+ border-top: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 40px;
+}
+
+.szb-label {
+ font-size: 10px;
+ font-weight: 500;
+ color: rgba(255,255,255,0.9);
+ text-shadow: 0 0 3px rgba(0,0,0,0.8), 0 1px 2px rgba(0,0,0,0.6);
+ white-space: nowrap;
+ padding: 0 4px;
+}
+
+.szb-zoom {
+ font-size: 11px;
+ font-weight: 600;
+ color: rgba(255,255,255,0.85);
+ text-shadow: 0 0 3px rgba(0,0,0,0.8), 0 1px 2px rgba(0,0,0,0.6);
+ white-space: nowrap;
+}
+
+
+/* ── Search panel ───────────────────────────── */
+#search-panel {
+ position: fixed;
+ bottom: calc(68px + env(safe-area-inset-bottom, 0px));
+ left: 0; right: 0;
+ background: var(--surface);
+ border-top: 1px solid var(--border);
+ z-index: 350;
+ padding: 12px 16px;
+ box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
+}
+.search-panel-inner {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+#standalone-search-input {
+ flex: 1;
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 10px 14px;
+ font-size: 15px;
+ color: var(--text1);
+ outline: none;
+}
+#standalone-search-input:focus {
+ border-color: var(--accent);
+}
+#search-close-btn {
+ background: none;
+ border: none;
+ color: var(--text3);
+ font-size: 20px;
+ cursor: pointer;
+ padding: 4px 8px;
+}
+#standalone-search-results {
+ max-height: 240px;
+ overflow-y: auto;
+ margin-top: 8px;
+}
+#standalone-search-results .search-result-item {
+ padding: 10px 12px;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+#standalone-search-results .search-result-item:hover,
+#standalone-search-results .search-result-item:active {
+ background: var(--surface2);
+}
+#standalone-search-results .search-result-name {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text1);
+}
+#standalone-search-results .search-result-sub {
+ font-size: 12px;
+ color: var(--text3);
+ margin-top: 2px;
+}
+
+
+/* ─── Zoom controls ──────────────────────────────────────────────────────── */
+#zoom-controls {
+ position: fixed;
+ left: 12px;
+ top: 12px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ z-index: 400;
+}
+#zoom-controls .map-btn {
+ width: 40px;
+ height: 40px;
+ font-size: 20px;
+ font-weight: 700;
+ line-height: 1;
+}
+#zoom-level {
+ background: var(--surface, #1e1e1e);
+ color: var(--text, #fff);
+ border-radius: 6px;
+ padding: 4px 8px;
+ font-size: 13px;
+ font-weight: 600;
+ min-width: 32px;
+ text-align: center;
+ border: 1px solid rgba(255,255,255,0.1);
+}
+
+/* ─── Scale bar ──────────────────────────────────────────────────────────── */
+#scale-bar {
+ position: fixed;
+ bottom: calc(80px + env(safe-area-inset-bottom, 0px) + 16px);
+ left: 12px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 3px;
+ z-index: 400;
+ pointer-events: none;
+}
+#scale-line {
+ height: 4px;
+ width: 100px;
+ background: #fff;
+ border: 1px solid rgba(0,0,0,0.6);
+ border-top: none;
+ border-left: 2px solid #fff;
+ border-right: 2px solid #fff;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.5);
+}
+#scale-label {
+ font-size: 11px;
+ color: #fff;
+ text-shadow: 0 1px 3px rgba(0,0,0,0.9), 0 0 4px rgba(0,0,0,0.7);
+ font-weight: 700;
+ letter-spacing: 0.3px;
+}
diff --git a/src/web/app.js b/src/web/app.js
index 27975da..792d64a 100644
--- a/src/web/app.js
+++ b/src/web/app.js
@@ -99,9 +99,10 @@ function switchMapStyle() {
fetch(styleUrl, { method: 'HEAD' }).then(r => {
if (r.ok) {
map.setStyle(styleUrl);
- // Restore position after style loads
- map.once('style.load', () => {
+ // Restore position and overlays after style loads
+ map.once('idle', () => {
map.jumpTo({ center, zoom, bearing, pitch });
+ rebuildMapOverlays();
});
} else {
console.log('Map style not available:', styleUrl);
@@ -120,6 +121,10 @@ function onMapStyleLoad() {
}
function rebuildMapOverlays() {
+ // Re-apply terrain and trails after style change
+ restoreTerrainState();
+ restoreTrailsState();
+
// Re-apply recon circle if active
if (reconMode && reconCenter) {
doRecon(reconCenter[0], reconCenter[1]);
@@ -1283,9 +1288,7 @@ function selectMarkerType(idx, lat, lng) {
closeMarkerDialog();
const markers = loadMarkers();
const icon = MARKER_ICONS[idx] || MARKER_ICONS[0];
- const name = prompt('Название метки (Enter = автоимя):');
- if (name === null) return;
- const autoName = name.trim() || `Метка ${markers.length + 1}`;
+ const autoName = `Метка ${markers.length + 1}`;
const marker = { id: Date.now(), name: autoName, icon, lat, lon: lng };
markers.push(marker);
saveMarkers(markers);
@@ -1320,6 +1323,17 @@ function drawNamedMarker(markerData) {
}
function renderMarkers() {
+ // Clear existing marker objects to prevent duplicates
+ Object.keys(namedMarkerObjects).forEach(id => {
+ const obj = namedMarkerObjects[id];
+ if (obj) {
+ const popup = obj.getPopup();
+ if (popup) popup.remove();
+ obj.remove();
+ }
+ });
+ namedMarkerObjects = {};
+ // Re-draw from localStorage
const markers = loadMarkers();
markers.forEach(m => drawNamedMarker(m));
}
@@ -1379,8 +1393,95 @@ async function initMap() {
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');
+ // Custom scale + zoom indicator (one line, top-right)
+ const scaleZoomBar = document.createElement('div');
+ scaleZoomBar.id = 'scale-zoom-bar';
+ scaleZoomBar.innerHTML = '
30 km
z7
';
+ document.getElementById('map').appendChild(scaleZoomBar);
+
+ function updateScaleZoom() {
+ const zoom = Math.round(map.getZoom());
+ const lat = map.getCenter().lat;
+ const metersPerPixel = 156543.03392 * Math.cos(lat * Math.PI / 180) / Math.pow(2, map.getZoom());
+
+ const targetPx = 80;
+ const rawMeters = metersPerPixel * targetPx;
+
+ let distance, unit, niceMeters;
+ if (rawMeters >= 1000) {
+ const km = rawMeters / 1000;
+ distance = km >= 100 ? Math.round(km / 50) * 50 :
+ km >= 10 ? Math.round(km / 5) * 5 :
+ km >= 1 ? Math.round(km) : Math.round(km * 10) / 10;
+ unit = 'km';
+ niceMeters = distance * 1000;
+ } else {
+ distance = rawMeters >= 100 ? Math.round(rawMeters / 50) * 50 :
+ rawMeters >= 10 ? Math.round(rawMeters / 5) * 5 :
+ Math.round(rawMeters);
+ unit = 'm';
+ niceMeters = distance;
+ }
+
+ const actualPx = Math.round(niceMeters / metersPerPixel);
+ const clampedPx = Math.max(40, Math.min(150, actualPx));
+
+ const scaleEl = scaleZoomBar.querySelector('.szb-scale');
+ const labelEl = scaleZoomBar.querySelector('.szb-label');
+ const zoomEl = scaleZoomBar.querySelector('.szb-zoom');
+
+ scaleEl.style.width = clampedPx + 'px';
+ labelEl.textContent = distance + ' ' + unit;
+ zoomEl.textContent = 'z' + zoom;
+ }
+
+ // ─── Scale bar & Zoom level ───────────────────────────────────────────────
+
+ function updateScaleBar() {
+ const zoom = map.getZoom();
+ const lat = map.getCenter().lat;
+ // Метров на пиксель при текущем зуме и широте
+ const metersPerPixel = 156543.03392 * Math.cos(lat * Math.PI / 180) / Math.pow(2, zoom);
+ // Целевая ширина линейки ~100px
+ const targetWidth = 100;
+ const meters = metersPerPixel * targetWidth;
+
+ let label, width;
+ if (meters >= 1000) {
+ const km = Math.round(meters / 1000);
+ label = km + ' км';
+ width = km * 1000 / metersPerPixel;
+ } else {
+ const m = Math.round(meters / 50) * 50 || 50;
+ label = m + ' м';
+ width = m / metersPerPixel;
+ }
+
+ const scaleLine = document.getElementById('scale-line');
+ const scaleLabel = document.getElementById('scale-label');
+ if (scaleLine) scaleLine.style.width = Math.round(width) + 'px';
+ if (scaleLabel) scaleLabel.textContent = label;
+ }
+
+ function updateZoomLevel() {
+ const el = document.getElementById('zoom-level');
+ if (el) el.textContent = Math.round(map.getZoom());
+ }
+
+ updateScaleZoom();
+ updateScaleBar();
+ updateZoomLevel();
+ map.on('zoom', () => {
+ updateScaleZoom();
+ updateScaleBar();
+ updateZoomLevel();
+ if (typeof updateHillshadeAvailability === 'function') updateHillshadeAvailability();
+ });
+ map.on('move', () => {
+ updateScaleZoom();
+ updateScaleBar();
+ });
map.on('load', () => {
checkDataAvailability();
@@ -2637,16 +2738,25 @@ function toggleTerrainPopup() {
const isVisible = popup.style.display !== 'none';
popup.style.display = isVisible ? 'none' : 'block';
- btn.classList.toggle('active', !isVisible);
- // Close on outside click
+ // Position popup to the left of the button
if (!isVisible) {
+ const rect = btn.getBoundingClientRect();
+ popup.style.right = (window.innerWidth - rect.left + 8) + 'px';
+ // Position: align bottom of popup with bottom of button, ensure fits in viewport
+ const popupHeight = popup.offsetHeight;
+ const desiredTop = rect.bottom - popupHeight;
+ const minTop = 8;
+ popup.style.top = Math.max(minTop, desiredTop) + 'px';
+ updateHillshadeAvailability();
setTimeout(() => {
document.addEventListener('click', closeTerrainOnOutside);
}, 10);
} else {
document.removeEventListener('click', closeTerrainOnOutside);
}
+
+ btn.classList.toggle('active', !isVisible);
}
function closeTerrainOnOutside(e) {
@@ -2663,20 +2773,66 @@ function onTerrainCheckbox() {
const map = window._map;
if (!map) return;
- const hypsoChecked = document.getElementById('terrain-hypso-cb').checked;
const hillshadeChecked = document.getElementById('terrain-hillshade-cb').checked;
+ const triChecked = document.getElementById('terrain-tri-cb').checked;
// Save state
- localStorage.setItem('terrain-hypso', hypsoChecked ? '1' : '0');
localStorage.setItem('terrain-hillshade', hillshadeChecked ? '1' : '0');
+ localStorage.setItem('terrain-tri', triChecked ? '1' : '0');
// Update button active state
const btn = document.getElementById('terrain-toggle');
- btn.classList.toggle('active', hypsoChecked || hillshadeChecked);
+ btn.classList.toggle('active', hillshadeChecked || triChecked);
// Apply layers
- applyTerrainLayer('terrain-hypso', TERRAIN_BASE_URL + '/hypso/{z}/{x}/{y}.png', hypsoChecked, 0.55, 5, 15);
applyTerrainLayer('terrain-hillshade', TERRAIN_BASE_URL + '/hillshade/{z}/{x}/{y}.png', hillshadeChecked, 0.40, 10, 15);
+ applyTerrainLayer('terrain-tri', TERRAIN_BASE_URL + '/tri/{z}/{x}/{y}.png', triChecked, 0.70, 5, 15);
+}
+
+
+function onTrailsCheckbox() {
+ const map = window._map;
+ if (!map) return;
+
+ const trackChecked = document.getElementById('trails-track-cb').checked;
+ const pathChecked = document.getElementById('trails-path-cb').checked;
+
+ // Save state
+ localStorage.setItem('trails-track', trackChecked ? '1' : '0');
+ localStorage.setItem('trails-path', pathChecked ? '1' : '0');
+
+ // Toggle layer visibility
+ if (map.getLayer('trails-track')) {
+ map.setLayoutProperty('trails-track', 'visibility', trackChecked ? 'visible' : 'none');
+ }
+ if (map.getLayer('trails-path-bridleway')) {
+ map.setLayoutProperty('trails-path-bridleway', 'visibility', pathChecked ? 'visible' : 'none');
+ }
+}
+
+function restoreTrailsState() {
+ const trackState = localStorage.getItem('trails-track');
+ const pathState = localStorage.getItem('trails-path');
+
+ // Default: both checked (visible)
+ const trackOn = trackState === null || trackState === '1';
+ const pathOn = pathState === null || pathState === '1';
+
+ const trackCb = document.getElementById('trails-track-cb');
+ const pathCb = document.getElementById('trails-path-cb');
+
+ if (trackCb) trackCb.checked = trackOn;
+ if (pathCb) pathCb.checked = pathOn;
+
+ const map = window._map;
+ if (map) {
+ if (map.getLayer('trails-track')) {
+ map.setLayoutProperty('trails-track', 'visibility', trackOn ? 'visible' : 'none');
+ }
+ if (map.getLayer('trails-path-bridleway')) {
+ map.setLayoutProperty('trails-path-bridleway', 'visibility', pathOn ? 'visible' : 'none');
+ }
+ }
}
function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) {
@@ -2693,7 +2849,6 @@ function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) {
tiles: [tileUrl],
tileSize: 256,
scheme: 'tms',
- bounds: [35, 45, 55, 62],
minzoom: minzoom,
maxzoom: maxzoom
});
@@ -2709,7 +2864,8 @@ function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) {
type: 'raster',
source: sourceId,
paint: {
- 'raster-opacity': opacity
+ 'raster-opacity': opacity,
+ 'raster-resampling': 'linear'
},
minzoom: minzoom,
maxzoom: maxzoom
@@ -2743,22 +2899,22 @@ function updateHillshadeAvailability() {
}
function restoreTerrainState() {
- const hypso = localStorage.getItem('terrain-hypso') === '1';
const hillshade = localStorage.getItem('terrain-hillshade') === '1';
+ const tri = localStorage.getItem('terrain-tri') === '1';
- const hypsoCb = document.getElementById('terrain-hypso-cb');
const hillshadeCb = document.getElementById('terrain-hillshade-cb');
+ const triCb = document.getElementById('terrain-tri-cb');
- if (hypsoCb) hypsoCb.checked = hypso;
if (hillshadeCb) hillshadeCb.checked = hillshade;
+ if (triCb) triCb.checked = tri;
- if (hypso || hillshade) {
+ if (hillshade || tri) {
onTerrainCheckbox();
}
// Update button active state
const btn = document.getElementById('terrain-toggle');
- if (btn) btn.classList.toggle('active', hypso || hillshade);
+ if (btn) btn.classList.toggle('active', hillshade || tri);
}
// Hook into map load and zoom changes
@@ -2771,8 +2927,8 @@ function restoreTerrainState() {
setTimeout(restoreTerrainState, 100);
});
// Initial state
- updateHillshadeAvailability();
restoreTerrainState();
+ restoreTrailsState();
} else {
// Map not ready yet, wait
const interval = setInterval(() => {
@@ -2784,7 +2940,77 @@ function restoreTerrainState() {
});
updateHillshadeAvailability();
restoreTerrainState();
+ restoreTrailsState();
}
}, 500);
}
})();
+
+// ─── Standalone Search Mode ──────────────────────────────────────
+let searchModeActive = false;
+let standaloneSearchTimeout = null;
+
+function toggleSearchMode() {
+ searchModeActive = !searchModeActive;
+ const panel = document.getElementById('search-panel');
+ const btn = document.getElementById('tb-search');
+
+ if (searchModeActive) {
+ panel.style.display = 'block';
+ btn.classList.add('active');
+ const input = document.getElementById('standalone-search-input');
+ input.value = '';
+ document.getElementById('standalone-search-results').innerHTML = '';
+ setTimeout(() => input.focus(), 100);
+
+ input.oninput = () => {
+ clearTimeout(standaloneSearchTimeout);
+ const q = input.value.trim();
+ if (q.length < 2) {
+ document.getElementById('standalone-search-results').innerHTML = '';
+ return;
+ }
+ standaloneSearchTimeout = setTimeout(() => doStandaloneSearch(q), 400);
+ };
+
+ input.onkeydown = (e) => {
+ if (e.key === 'Escape') toggleSearchMode();
+ };
+ } else {
+ panel.style.display = 'none';
+ btn.classList.remove('active');
+ }
+}
+
+async function doStandaloneSearch(query) {
+ const resultsEl = document.getElementById('standalone-search-results');
+ resultsEl.innerHTML = 'Поиск...
';
+
+ 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);
+ const data = await resp.json();
+
+ if (!data.length) {
+ resultsEl.innerHTML = 'Ничего не найдено
';
+ return;
+ }
+
+ resultsEl.innerHTML = data.map(item => {
+ const parts = item.display_name.split(',');
+ const name = parts[0].trim();
+ const sub = parts.slice(1, 3).join(',').trim();
+ return `
+
${name}
+ ${sub ? `
${sub}
` : ''}
+
`;
+ }).join('');
+ } catch(e) {
+ resultsEl.innerHTML = 'Ошибка поиска
';
+ }
+}
+
+function standaloneSelectResult(lat, lon, name) {
+ toggleSearchMode();
+ window._map.flyTo({ center: [parseFloat(lon), parseFloat(lat)], zoom: 13, duration: 800 });
+}
diff --git a/src/web/index.html b/src/web/index.html
index b96248c..90c91ac 100644
--- a/src/web/index.html
+++ b/src/web/index.html
@@ -32,6 +32,29 @@
⚠️ База данных недоступна
+
+
+
+
+
+
@@ -221,6 +256,10 @@
Линейка
+
+
+ Поиск
+
Метка