From 8d36f38be6863207971515246b3cf8caca69d36b Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sat, 16 May 2026 22:17:10 +0300 Subject: [PATCH] fix: restore UI to phase 5.4 (terrain, scale bar, zoom controls) --- src/web/app.css | 262 +++++++++++++++++++++++++++++++++++++++++++- src/web/app.js | 266 +++++++++++++++++++++++++++++++++++++++++---- src/web/index.html | 39 +++++++ 3 files changed, 546 insertions(+), 21 deletions(-) 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 @@ Линейка +