diff --git a/tasks/enduro-trails/prototype/static/app.css b/tasks/enduro-trails/prototype/static/app.css index a69f60c..5ca1033 100644 --- a/tasks/enduro-trails/prototype/static/app.css +++ b/tasks/enduro-trails/prototype/static/app.css @@ -282,4 +282,265 @@ html, body { -webkit-tap-highlight-color: transparent; animation: cardFadeIn 0.2s ease-out both; } -.route-card.active { border-color: var(--accent); box-shadow: 0 0 0 1px var \ No newline at end of file +.route-card.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.rc-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } +.rc-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } +.rc-title { flex: 1; font-size: 13px; font-weight: 700; color: var(--text); } +.rc-km { font-size: 14px; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; } +.rc-time { font-size: 12px; color: var(--text2); font-variant-numeric: tabular-nums; } +.rc-bar { height: 5px; border-radius: 3px; background: var(--surface3); overflow: hidden; margin-bottom: 8px; display: flex; } +.rc-bar-dirt { background: var(--gold); height: 100%; transition: width 0.4s; } +.rc-bar-asphalt { background: var(--text3); height: 100%; flex: 1; } +.rc-stats { display: flex; flex-wrap: wrap; gap: 5px; } + +/* Stat pills */ +.stat-pill { display: inline-flex; align-items: center; gap: 4px; border-radius: 20px; padding: 3px 9px; font-size: 11px; font-weight: 700; letter-spacing: 0.02em; } +.stat-pill.dirt { background: var(--gold-bg); color: var(--gold); } +.stat-pill.asphalt { background: var(--surface3); color: var(--text2); } +.stat-pill.path { background: var(--red-bg); color: var(--red); } + +/* ── Primary Button ───────────────────────────── */ +.btn-primary { width: 100%; height: 48px; background: var(--accent); color: #fff; border: none; border-radius: 14px; font-size: 15px; font-weight: 700; display: flex; align-items: center; justify-content: center; gap: 8px; cursor: pointer; transition: background 0.15s, transform 0.1s; letter-spacing: 0.02em; margin-top: 12px; } +.btn-primary svg { width: 18px; height: 18px; } +.btn-primary:active { background: var(--accent-h); transform: scale(0.98); } +.btn-primary:disabled { opacity: 0.5; pointer-events: none; } + +/* ── Segment Control ──────────────────────────── */ +.seg-control { display: flex; gap: 4px; background: var(--surface2); border: 1px solid var(--border); border-radius: 12px; padding: 4px; margin-bottom: 12px; } +.seg-btn { flex: 1; height: 34px; background: none; border: none; border-radius: 9px; font-size: 13px; font-weight: 600; color: var(--text2); cursor: pointer; transition: all 0.15s; } +.seg-btn.active { background: var(--accent); color: #fff; box-shadow: 0 2px 8px rgba(255,107,0,0.35); } +.seg-btn:not(.active):active { background: var(--surface3); } +.dist-custom { height: 34px; width: 70px; background: var(--surface2); border: 1px solid var(--border); border-radius: 9px; color: var(--text); font-size: 13px; font-weight: 600; text-align: center; outline: none; flex-shrink: 0; } +.dist-custom:focus { border-color: var(--accent); } + +/* ── Recon Results ────────────────────────────── */ +.recon-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 14px; } +.recon-stat { background: var(--surface2); border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; } +.rs-value { font-size: 22px; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; line-height: 1; margin-bottom: 3px; } +.rs-value.gold { color: var(--gold); } +.rs-value.red { color: var(--red); } +.rs-label { font-size: 11px; color: var(--text2); font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; } +.poi-row { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border); } +.poi-row:last-child { border-bottom: none; } +.poi-row-label { font-size: 13px; color: var(--text); display: flex; align-items: center; gap: 8px; } +.poi-row-count { font-size: 16px; font-weight: 800; color: var(--accent); font-variant-numeric: tabular-nums; } +.poi-icon { width: 28px; height: 28px; border-radius: 8px; background: var(--surface2); display: flex; align-items: center; justify-content: center; font-size: 14px; } + +/* ── Scenic POI ───────────────────────────────── */ +.scenic-poi-item { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text2); padding: 3px 0; } +.scenic-score-bar { height: 4px; border-radius: 2px; background: var(--surface3); overflow: hidden; margin: 6px 0; } +.scenic-score-fill { height: 100%; background: var(--gold); border-radius: 2px; } + +/* ── Link Points ──────────────────────────────── */ +.link-points { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; } +.link-pt { display: flex; align-items: center; gap: 8px; background: var(--surface2); border: 1.5px solid var(--border); border-radius: 10px; padding: 10px 12px; } +.link-pt-num { width: 24px; height: 24px; border-radius: 50%; background: var(--accent); color: #fff; font-size: 12px; font-weight: 800; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } +.link-pt-label { font-size: 13px; color: var(--text); flex: 1; } +.link-pt.empty .link-pt-num { background: var(--surface3); color: var(--text3); } +.link-pt.empty .link-pt-label { color: var(--text3); } +#link-status { font-size: 13px; color: var(--text2); padding: 4px 0 10px; } + +/* ── Scenic Config ───────────────────────────── */ +#scenic-status { font-size: 13px; color: var(--text2); padding: 6px 0; display: flex; align-items: center; gap: 6px; } +.dist-row { display: flex; gap: 4px; align-items: center; margin-bottom: 4px; } + +/* ── Marker Dialog ────────────────────────────── */ +#marker-dialog { position: fixed; inset: 0; z-index: 500; display: flex; align-items: flex-end; justify-content: center; padding-bottom: env(safe-area-inset-bottom, 0px); pointer-events: none; opacity: 0; transition: opacity 0.2s; } +#marker-dialog.open { pointer-events: auto; opacity: 1; } +.marker-dialog-inner { background: var(--surface); border-radius: 20px 20px 0 0; border-top: 1px solid var(--border); padding: 0 16px 20px; width: 100%; transform: translateY(30px); transition: transform 0.25s cubic-bezier(0.32, 0, 0.15, 1); } +#marker-dialog.open .marker-dialog-inner { transform: translateY(0); } +.marker-type-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 12px 0; } +.marker-type-btn { background: var(--surface2); border: 1.5px solid var(--border); border-radius: 12px; padding: 12px 8px; cursor: pointer; transition: all 0.15s; display: flex; flex-direction: column; align-items: center; gap: 5px; -webkit-tap-highlight-color: transparent; } +.marker-type-btn:active { border-color: var(--accent); background: var(--accent-bg); } +.marker-type-btn .mt-icon { font-size: 24px; } +.marker-type-btn .mt-label { font-size: 11px; font-weight: 600; color: var(--text2); text-transform: uppercase; letter-spacing: 0.06em; } + +/* ── No Data Warning ─────────────────────────── */ +#no-data-warning { display: none; position: fixed; bottom: 80px; left: 12px; right: 12px; background: var(--red-bg); border: 1px solid var(--red); border-radius: 12px; padding: 10px 14px; font-size: 13px; color: var(--red); z-index: 200; } +#no-data-warning.visible { display: block; } + +/* ── Skeleton Loading ────────────────────────── */ +.skeleton { + background: linear-gradient(90deg, var(--surface2) 0%, var(--surface3) 50%, var(--surface2) 100%); + background-size: 200% 100%; + animation: shimmer 1.4s infinite; + border-radius: 8px; +} +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} +.skeleton-card { + background: var(--surface2); + border: 1.5px solid var(--border); + border-radius: 14px; + padding: 14px; + margin-bottom: 8px; +} +.skeleton-line { + height: 14px; + margin-bottom: 8px; + border-radius: 4px; +} +.skeleton-line.w60 { width: 60%; } +.skeleton-line.w40 { width: 40%; } +.skeleton-line.w80 { width: 80%; } +.skeleton-line.h20 { height: 20px; } + +/* ── Ruler ───────────────────────────────────── */ +#ruler-info { + position: fixed; + top: calc(max(env(safe-area-inset-top,0px),12px) + 58px); + left: 12px; right: 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px 14px; + font-size: 13px; color: var(--text); + font-weight: 600; z-index: 200; + display: none; box-shadow: var(--shadow-sm); +} +#ruler-info.visible { display: flex; align-items: center; gap: 8px; } + +/* ── Waypoint Markers ─────────────────────────── */ +.route-waypoint-marker { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 800; color: #fff; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.5); border: 2px solid rgba(255,255,255,0.8); } +.named-marker-el { font-size: 22px; cursor: pointer; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5)); user-select: none; line-height: 1; } + +/* ═══════════════════════════════════════════════════ + TASK 5: Desktop Layout (≥768px) + ═══════════════════════════════════════════════════ */ +@media (min-width: 768px) { + #toolbar { + flex-direction: column; + width: 72px; height: auto; + right: auto; left: 0; + top: 0; bottom: 0; + border-right: 1px solid var(--border); + border-top: none; + padding: 80px 0 20px; + justify-content: flex-start; + gap: 4px; + } + .tb-btn { width: 64px; height: 56px; flex: none; } + .bottom-sheet { + left: 72px; right: auto; + width: 380px; max-width: 400px; + max-height: 100vh; + border-radius: 0 20px 0 0; + border-top: none; + border-right: 1px solid var(--border); + top: 0; bottom: 0; + transform: translateX(-120%); + } + .bottom-sheet.open { transform: translateX(0); } + .bottom-sheet.swiping { transition: none; } + #search-bar { left: 84px; right: 12px; max-width: 400px; } + #map-controls-r { right: 12px; bottom: 12px; } + #sheet-backdrop { display: none; } + #ruler-info { left: 84px; } + #search-results { left: 84px; right: 12px; max-width: 400px; } +} + +/* ═══════════════════════════════════════════════════ + TASK 6: Micro-animations + ═══════════════════════════════════════════════════ */ +@keyframes cardFadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +.route-card:nth-child(1) { animation-delay: 0ms; } +.route-card:nth-child(2) { animation-delay: 60ms; } +.route-card:nth-child(3) { animation-delay: 120ms; } +.route-card:nth-child(4) { animation-delay: 180ms; } +.route-card:nth-child(5) { animation-delay: 240ms; } + +/* Marker pop-in animation */ +@keyframes markerPopIn { + from { transform: scale(0); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} +.marker-anim { animation: markerPopIn 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28) both; } + +/* ── Misc ────────────────────────────────────── */ +.text-accent { color: var(--accent); } +.text-gold { color: var(--gold); } +.text-red { color: var(--red); } +.text-muted { color: var(--text2); } +.mt-8 { margin-top: 8px; } +.mt-12 { margin-top: 12px; } +.mb-8 { margin-bottom: 8px; } +.cursor-crosshair .maplibregl-canvas { cursor: crosshair !important; } + +/* ── My Location Marker ──────────────────────── */ +.my-location-marker { position: relative; width: 20px; height: 20px; } +.my-location-dot { + position: absolute; top: 50%; left: 50%; + width: 12px; height: 12px; + background: #4285f4; border: 2px solid #fff; + border-radius: 50%; + transform: translate(-50%, -50%); + box-shadow: 0 2px 6px rgba(66,133,244,0.6); +} +.my-location-pulse { + position: absolute; top: 50%; left: 50%; + width: 30px; height: 30px; + background: rgba(66,133,244,0.3); + border-radius: 50%; + transform: translate(-50%, -50%); + animation: pulse-ring 2s ease-out infinite; +} +@keyframes pulse-ring { + 0% { transform: translate(-50%, -50%) scale(0.5); opacity: 1; } + 100% { transform: translate(-50%, -50%) scale(2); opacity: 0; } +} + +/* ── MapLibre popup theme overrides ──────────── */ +.maplibregl-popup-content { + background: var(--surface) !important; + color: var(--text) !important; + border: 1px solid var(--border) !important; + border-radius: 12px !important; + padding: 12px !important; + font-size: 13px; + box-shadow: var(--shadow) !important; +} +.maplibregl-popup-tip { + border-top-color: var(--surface) !important; +} +.maplibregl-popup-close-button { + color: var(--text2) !important; + font-size: 18px !important; + right: 6px !important; top: 4px !important; +} +.popup-title { font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 4px; } +.popup-row { display: flex; justify-content: space-between; padding: 2px 0; font-size: 12px; } +.popup-key { color: var(--text2); } +.popup-val { color: var(--text); font-weight: 600; } + +/* Route card legacy styles (compat) */ +.route-card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } +.route-color-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } +.route-card-title { flex: 1; font-size: 13px; font-weight: 700; color: var(--text); } +.route-card-dist { font-size: 14px; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; } +.route-card-time { font-size: 12px; color: var(--text2); font-variant-numeric: tabular-nums; } +.route-coverage-bar { height: 5px; border-radius: 3px; background: var(--surface3); overflow: hidden; margin-bottom: 8px; display: flex; } +.route-coverage-bar > div { height: 100%; transition: width 0.4s; } +.route-card-summary { font-size: 12px; color: var(--text2); margin-bottom: 6px; } +.route-card-details { margin-top: 6px; border-top: 1px solid var(--border); padding-top: 6px; } +.route-stat-row { font-size: 12px; color: var(--text2); padding: 2px 0; } +.route-details-toggle { width: 100%; background: none; border: none; color: var(--accent); font-size: 12px; font-weight: 600; cursor: pointer; padding: 6px 0 0; text-align: left; } +.waypoint-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 4px; transition: border-color 0.15s; } +.waypoint-row.drag-over { border-color: var(--accent); } +.waypoint-label { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; font-size: 11px; font-weight: 700; color: #fff; flex-shrink: 0; } +.waypoint-label.start { background: var(--success); } +.waypoint-label.end { background: var(--red); } +.waypoint-label.mid { background: #0066ff; } +.waypoint-coords { flex: 1; font-size: 12px; color: var(--text2); font-variant-numeric: tabular-nums; } +.waypoint-remove { width: 24px; height: 24px; border: none; background: none; color: var(--text3); cursor: pointer; font-size: 14px; border-radius: 4px; display: flex; align-items: center; justify-content: center; } +.waypoint-remove:hover { background: var(--red-bg); color: var(--red); } +#btn-add-waypoint { width: 100%; height: 36px; background: var(--surface2); border: 1.5px dashed var(--border2); border-radius: 10px; color: var(--text2); font-size: 12px; font-weight: 600; cursor: pointer; margin-top: 4px; display: flex; align-items: center; justify-content: center; gap: 6px; transition: border-color 0.15s; } +#btn-add-waypoint:hover { border-color: var(--accent); color: var(--accent); } +#btn-build-route { width: 100%; height: 42px; background: var(--accent); color: #fff; border: none; border-radius: 10px; font-size: 14px; font-weight: 700; cursor: pointer; margin-top: 8px; transition: background 0.15s; } +#btn-build-route:active { background: var(--accent-h); } +.search-result-name { font-size: 14px; font-weight: 500; color: var(--text); } +.search-result-detail { font-size: 12px; color: var(--text2); margin-top: 2px; } diff --git a/tasks/enduro-trails/prototype/static/app.js b/tasks/enduro-trails/prototype/static/app.js index fe9450a..6cfc1d9 100644 --- a/tasks/enduro-trails/prototype/static/app.js +++ b/tasks/enduro-trails/prototype/static/app.js @@ -1,16 +1,158 @@ -// ─── Общее: деактивация всех режимов ──────────────────────────────────────────── +// ═══════════════════════════════════════════════════════════════════ +// Enduro Trails — Phase 5 Redesign +// Theme system (auto/light/dark + SunCalc), skeleton, swipe, animations +// ═══════════════════════════════════════════════════════════════════ -function deactivateAllModes() { - if (routeMode) { routeMode = false; document.getElementById('tb-route').classList.remove('active'); closeSheet('sheet-route'); clearRoute(); } - if (rulerMode) toggleRuler(); - if (markerMode) toggleMarkerMode(); - if (typeof reconMode !== 'undefined' && reconMode) toggleReconMode(); - if (typeof linkMode !== 'undefined' && linkMode) toggleLinkMode(); - if (typeof scenicMode !== 'undefined' && scenicMode) toggleScenicMode(); - if (window._map) window._map.getCanvas().style.cursor = ''; +// ─── Theme System ────────────────────────────────────────────────── +let themeMode = localStorage.getItem('enduro-theme-mode') || 'auto'; // 'auto' | 'light' | 'dark' +let userLat = null; +let userLon = null; +let themeAutoInterval = null; + +function isDarkTheme() { + return document.body.classList.contains('theme-dark'); } -// ─── Утилиты ────────────────────────────────────────────────────────────────── +function applyTheme() { + if (themeMode === 'light') { + document.body.className = 'theme-light'; + } else if (themeMode === 'dark') { + document.body.className = 'theme-dark'; + } else { + // auto: use SunCalc + applyAutoTheme(); + } + updateThemeButtonIcon(); + switchMapStyle(); +} + +function applyAutoTheme() { + if (themeMode !== 'auto') return; + const now = new Date(); + const lat = userLat || 55.75; + const lon = userLon || 37.62; + let isDay = true; + try { + if (typeof SunCalc !== 'undefined') { + const times = SunCalc.getTimes(now, lat, lon); + isDay = now >= times.sunrise && now < times.sunset; + } else { + // Fallback: assume day if 6am-8pm + const h = now.getHours(); + isDay = h >= 6 && h < 20; + } + } catch(e) { + const h = now.getHours(); + isDay = h >= 6 && h < 20; + } + document.body.className = isDay ? 'theme-light' : 'theme-dark'; + updateThemeButtonIcon(); +} + +function toggleTheme() { + // Cycle: auto → light → dark → auto + if (themeMode === 'auto') themeMode = 'light'; + else if (themeMode === 'light') themeMode = 'dark'; + else themeMode = 'auto'; + + localStorage.setItem('enduro-theme-mode', themeMode); + applyTheme(); +} + +function updateThemeButtonIcon() { + const sunIcon = document.getElementById('theme-icon-sun'); + const moonIcon = document.getElementById('theme-icon-moon'); + const label = document.getElementById('theme-label'); + if (!sunIcon || !moonIcon) return; + + const dark = isDarkTheme(); + + if (themeMode === 'auto') { + // Dynamic icon based on actual theme + sunIcon.style.display = dark ? 'none' : 'block'; + moonIcon.style.display = dark ? 'block' : 'none'; + if (label) label.textContent = 'Авто'; + } else if (themeMode === 'light') { + sunIcon.style.display = 'block'; + moonIcon.style.display = 'none'; + if (label) label.textContent = 'День'; + } else { + sunIcon.style.display = 'none'; + moonIcon.style.display = 'block'; + if (label) label.textContent = 'Ночь'; + } +} + +function switchMapStyle() { + const map = window._map; + if (!map) return; + const dark = isDarkTheme(); + const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; + const styleUrl = dark ? basePath + '/style.json' : basePath + '/style-light.json'; + + // Check if style-light.json exists — if not, keep current + fetch(styleUrl, { method: 'HEAD' }).then(r => { + if (r.ok) { + map.setStyle(styleUrl); + } else { + // No light style available, keep dark + if (!dark) { + // Try inline light style override or just skip + console.log('Light map style not available, keeping dark'); + } + } + }).catch(() => { + // Network error, don't switch + }); +} + +// Re-add layers after style change +function onMapStyleLoad() { + const map = window._map; + if (!map) return; + // Re-add any active route layers, markers, etc. + rebuildMapOverlays(); +} + +function rebuildMapOverlays() { + // Re-apply recon circle if active + if (reconMode && reconCenter) { + doRecon(reconCenter[0], reconCenter[1]); + } + // Re-draw route if active + if (routeMode && routeResults.length > 0) { + const savedResults = [...routeResults]; + const savedIdx = activeRouteIdx; + routeResults = []; + drawRouteResults(savedResults, savedIdx); + } + // Re-draw scenic routes + if (scenicMode && scenicRoutes.length > 0) { + const savedRoutes = [...scenicRoutes]; + scenicRoutes = []; + drawScenicRoutes(savedRoutes, activeScenicIdx); + } + // Re-draw link routes + if (linkMode && linkPoints.length >= 2) { + buildLinkRoute(); + } + // Re-draw ruler + if (rulerMode && rulerPoints.length > 0) { + const pts = [...rulerPoints]; + rulerPoints = []; + rulerTotal = 0; + rulerMarkers.forEach(m => m.remove()); + rulerMarkers = []; + const map = window._map; + if (map.getSource('ruler')) map.removeSource('ruler'); + if (map.getLayer('ruler-line')) map.removeLayer('ruler-line'); + pts.forEach(pt => addRulerPoint({ lng: pt[0], lat: pt[1] })); + } + // Re-render named markers + renderMarkers(); +} + +// ─── Utilities ────────────────────────────────────────────────────── function formatDuration(seconds) { const totalMin = Math.round(seconds / 60); @@ -33,7 +175,117 @@ function formatDist(m) { return Math.round(m) + ' м'; } -// ─── Компас ─────────────────────────────────────────────────────────────────── +// ─── Sheet Management ────────────────────────────────────────────── + +function openSheet(id) { + const sheet = document.getElementById(id); + if (!sheet) return; + // Close all other sheets first + document.querySelectorAll('.bottom-sheet.open').forEach(s => { + if (s.id !== id) closeSheet(s.id); + }); + sheet.classList.add('open'); + const backdrop = document.getElementById('sheet-backdrop'); + backdrop.classList.add('visible'); +} + +function closeSheet(id) { + const sheet = document.getElementById(id); + if (!sheet) return; + sheet.classList.remove('open'); + sheet.style.transform = ''; + // Check if any sheets still open + const anyOpen = document.querySelector('.bottom-sheet.open'); + if (!anyOpen) { + document.getElementById('sheet-backdrop').classList.remove('visible'); + } +} + +function closeAllSheets() { + document.querySelectorAll('.bottom-sheet.open').forEach(s => { + s.classList.remove('open'); + s.style.transform = ''; + }); + document.getElementById('sheet-backdrop').classList.remove('visible'); +} + +// ─── Swipe-down to close sheets ──────────────────────────────────── + +function initSheetSwipe() { + document.querySelectorAll('.bottom-sheet').forEach(sheet => { + let startY = 0; + let isDragging = false; + + sheet.addEventListener('touchstart', (e) => { + const rect = sheet.getBoundingClientRect(); + const touchY = e.touches[0].clientY; + // Only initiate swipe from the handle area (top 50px of sheet) + if (touchY < rect.top + 50 || e.target.closest('.sheet-handle')) { + isDragging = true; + startY = touchY; + sheet.classList.add('swiping'); + } + }, { passive: true }); + + sheet.addEventListener('touchmove', (e) => { + if (!isDragging) return; + const dy = e.touches[0].clientY - startY; + if (dy > 0) { + sheet.style.transform = `translateY(${dy}px)`; + } + }, { passive: true }); + + sheet.addEventListener('touchend', (e) => { + if (!isDragging) return; + isDragging = false; + sheet.classList.remove('swiping'); + const dy = e.changedTouches[0].clientY - startY; + if (dy > 80) { + // Close the sheet and deactivate mode + const sheetId = sheet.id; + closeSheet(sheetId); + // Deactivate corresponding mode + if (sheetId === 'sheet-route' && routeMode) toggleRouteMode(); + else if (sheetId === 'sheet-recon' && reconMode) toggleReconMode(); + else if (sheetId === 'sheet-scenic' && scenicMode) toggleScenicMode(); + else if (sheetId === 'sheet-link' && linkMode) toggleLinkMode(); + } else { + sheet.style.transform = ''; + } + }, { passive: true }); + }); +} + +// ─── Skeleton Loading ────────────────────────────────────────────── + +function showSkeleton(containerId, count) { + const container = document.getElementById(containerId); + if (!container) return; + count = count || 2; + let html = ''; + for (let i = 0; i < count; i++) { + html += `