From 1f2328613ccf8d62cc91bdaadcf243bb6e9026e3 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Fri, 3 Oct 2025 11:24:18 +0200 Subject: [PATCH] Handle offline-ready map fallback (#202) --- web/views/index.erb | 530 +++++++++++++++++++++++++++++++++----------- 1 file changed, 402 insertions(+), 128 deletions(-) diff --git a/web/views/index.erb b/web/views/index.erb index cd1f187..6d4a5c4 100644 --- a/web/views/index.erb +++ b/web/views/index.erb @@ -185,7 +185,56 @@ var(--fg); } .site-title img { width: 52px; height: 52px; display: block; border-radius: 12px; } .meta { color:#555; margin-bottom:12px } .pill{ display:inline-block; padding:2px 8px; border-radius:999px; background:#eee; font-size:12px } - #map { flex: 1; height: 60vh; border: 1px solid #ddd; border-radius: 8px; } + #map { + position: relative; + flex: 1; + height: 60vh; + border: 1px solid #ddd; + border-radius: 8px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + } + #map[data-map-status="placeholder"] { + background: repeating-linear-gradient(135deg, rgba(0,0,0,0.02), rgba(0,0,0,0.02) 12px, rgba(0,0,0,0.04) 12px, rgba(0,0,0,0.04) 24px); + } + #map .map-placeholder-message { + text-align: center; + color: #555; + font-size: 14px; + line-height: 1.5; + padding: 0 16px; + } + #map .map-placeholder-message strong { display: block; margin-bottom: 6px; font-size: 16px; } + #map .map-placeholder-message span { display: block; margin-top: 4px; } + body.dark #map .map-placeholder-message { color: #ddd; } + #map .map-status-message { + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + padding: 6px 12px; + border-radius: 999px; + font-size: 12px; + line-height: 1.2; + backdrop-filter: blur(6px); + pointer-events: none; + border: 1px solid rgba(0,0,0,0.15); + background: rgba(255,255,255,0.9); + color: #333; + box-shadow: 0 4px 16px rgba(0,0,0,0.12); + z-index: 1200; + } + body.dark #map[data-map-status="placeholder"] { + background: repeating-linear-gradient(135deg, rgba(255,255,255,0.02), rgba(255,255,255,0.02) 12px, rgba(255,255,255,0.06) 12px, rgba(255,255,255,0.06) 24px); + } + body.dark #map .map-status-message { + border-color: rgba(255,255,255,0.16); + background: rgba(0,0,0,0.65); + color: #eee; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); + } table { border-collapse: collapse; width: 100%; margin: 0; } th, td { padding: 4px 6px; text-align: left; } th { position: sticky; top: 0; background: #fafafa; } @@ -728,8 +777,18 @@ var(--fg); } } } - const MAP_CENTER = L.latLng(<%= map_center_lat %>, <%= map_center_lon %>); + const MAP_CENTER_COORDS = Object.freeze({ lat: <%= map_center_lat %>, lon: <%= map_center_lon %> }); + const hasLeaflet = typeof window !== 'undefined' && typeof window.L === 'object' && window.L && typeof window.L.map === 'function'; + const mapContainer = document.getElementById('map'); + let mapStatusEl = null; + let map = null; + let mapCenterLatLng = null; + let tiles = null; + let offlineTiles = null; + let usingOfflineTiles = false; const MAX_NODE_DISTANCE_KM = <%= max_node_distance_km %>; + let markersLayer = null; + let tileDomObserver = null; // Firmware 2.7.10 / Android 2.7.0 roles and colors (see issue #177) const roleColors = Object.freeze({ @@ -761,6 +820,46 @@ var(--fg); } const activeRoleFilters = new Set(); const legendRoleButtons = new Map(); + function ensureMapStatusElement() { + if (!mapContainer) return null; + if (mapStatusEl && mapStatusEl.parentElement === mapContainer) { + return mapStatusEl; + } + mapStatusEl = document.createElement('div'); + mapStatusEl.className = 'map-status-message'; + mapStatusEl.hidden = true; + mapContainer.appendChild(mapStatusEl); + return mapStatusEl; + } + + function showMapStatus(message) { + if (!mapContainer) return; + const el = ensureMapStatusElement(); + if (!el) return; + if (message) { + el.textContent = message; + el.hidden = false; + } else { + el.hidden = true; + } + } + + function hideMapStatus() { + if (mapStatusEl) { + mapStatusEl.hidden = true; + } + } + + function setMapPlaceholder(message) { + if (!mapContainer) return; + mapContainer.dataset.mapStatus = 'placeholder'; + mapContainer.innerHTML = ''; + const placeholder = document.createElement('div'); + placeholder.className = 'map-placeholder-message'; + placeholder.innerHTML = `Map unavailable${message ? `
${message}` : ''}`; + mapContainer.appendChild(placeholder); + } + function normalizeRole(role) { if (role == null) return 'CLIENT'; const str = String(role).trim(); @@ -787,17 +886,20 @@ var(--fg); } } // --- Map setup --- - const map = L.map('map', { worldCopyJump: true, attributionControl: false }); const TILE_LAYER_URL = 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png'; const TILE_FILTER_LIGHT = '<%= tile_filter_light %>'; const TILE_FILTER_DARK = '<%= tile_filter_dark %>'; + if (hasLeaflet) { + mapCenterLatLng = L.latLng(MAP_CENTER_COORDS.lat, MAP_CENTER_COORDS.lon); + } + function resolveTileFilter() { return document.body.classList.contains('dark') ? TILE_FILTER_DARK : TILE_FILTER_LIGHT; } function applyFilterToTileElement(tile, filterValue) { - if (!tile) return; + if (!tile || usingOfflineTiles) return; if (tile.classList && !tile.classList.contains('map-tiles')) { tile.classList.add('map-tiles'); } @@ -808,14 +910,21 @@ var(--fg); } } } + function getActiveTileLayerContainer() { + if (!map) return null; + const layer = usingOfflineTiles ? offlineTiles : tiles; + return layer && typeof layer.getContainer === 'function' ? layer.getContainer() : null; + } + function applyFilterToTileContainers(filterValue) { + if (!map) return; const value = filterValue || resolveTileFilter(); - const tileContainer = tiles && typeof tiles.getContainer === 'function' ? tiles.getContainer() : null; - if (tileContainer && tileContainer.style) { - tileContainer.style.filter = value; - tileContainer.style.webkitFilter = value; + const container = getActiveTileLayerContainer(); + if (container && container.style) { + container.style.filter = value; + container.style.webkitFilter = value; } - const tilePane = map && typeof map.getPane === 'function' ? map.getPane('tilePane') : null; + const tilePane = typeof map.getPane === 'function' ? map.getPane('tilePane') : null; if (tilePane && tilePane.style) { tilePane.style.filter = value; tilePane.style.webkitFilter = value; @@ -823,85 +932,205 @@ var(--fg); } } function ensureTileHasCurrentFilter(tile) { - if (!tile) return; + if (!map || usingOfflineTiles) return; const filterValue = resolveTileFilter(); applyFilterToTileElement(tile, filterValue); } function applyFiltersToAllTiles() { + if (!map) return; const filterValue = resolveTileFilter(); document.body.style.setProperty('--map-tiles-filter', filterValue); - const tileEls = document.querySelectorAll('#map .leaflet-tile'); - tileEls.forEach(tile => applyFilterToTileElement(tile, filterValue)); + if (!usingOfflineTiles) { + const tileEls = mapContainer ? mapContainer.querySelectorAll('.leaflet-tile') : []; + tileEls.forEach(tile => applyFilterToTileElement(tile, filterValue)); + } applyFilterToTileContainers(filterValue); } - const tiles = L.tileLayer(TILE_LAYER_URL, { - maxZoom: 19, - className: 'map-tiles', - crossOrigin: 'anonymous' - }); + function tileToLon(x, z) { + return (x / Math.pow(2, z)) * 360 - 180; + } - let tileDomObserver = null; + function tileToLat(y, z) { + const n = Math.PI - (2 * Math.PI * y) / Math.pow(2, z); + return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))); + } - function observeTileContainer() { - if (typeof MutationObserver !== 'function') return; - const container = tiles && typeof tiles.getContainer === 'function' ? tiles.getContainer() : null; - const tilePane = map && typeof map.getPane === 'function' ? map.getPane('tilePane') : null; + function createOfflineTileLayer() { + if (!hasLeaflet) return null; + const offlineLayer = L.gridLayer({ className: 'map-tiles map-tiles-offline' }); + offlineLayer.createTile = coords => { + const size = 256; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + const gradient = ctx.createLinearGradient(0, 0, size, size); + gradient.addColorStop(0, 'rgba(33, 66, 110, 0.92)'); + gradient.addColorStop(1, 'rgba(64, 98, 144, 0.92)'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, size, size); + + ctx.strokeStyle = 'rgba(255,255,255,0.12)'; + ctx.lineWidth = 1; + const steps = 4; + for (let i = 1; i < steps; i++) { + const pos = (size / steps) * i; + ctx.beginPath(); + ctx.moveTo(pos, 0); + ctx.lineTo(pos, size); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, pos); + ctx.lineTo(size, pos); + ctx.stroke(); + } + + const west = tileToLon(coords.x, coords.z); + const east = tileToLon(coords.x + 1, coords.z); + const north = tileToLat(coords.y, coords.z); + const south = tileToLat(coords.y + 1, coords.z); + + ctx.fillStyle = 'rgba(255,255,255,0.7)'; + ctx.font = '12px system-ui, sans-serif'; + ctx.textBaseline = 'top'; + ctx.fillText(`${west.toFixed(1)}°`, 8, 8); + ctx.textBaseline = 'bottom'; + ctx.fillText(`${east.toFixed(1)}°`, 8, size - 8); + ctx.textAlign = 'right'; + ctx.textBaseline = 'top'; + ctx.fillText(`${north.toFixed(1)}°`, size - 8, 8); + ctx.textBaseline = 'bottom'; + ctx.fillText(`${south.toFixed(1)}°`, size - 8, size - 8); + + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.font = 'bold 22px system-ui, sans-serif'; + ctx.fillText('PotatoMesh offline basemap', size / 2, size / 2); + + return canvas; + }; + return offlineLayer; + } + + function disconnectTileObserver() { + if (tileDomObserver) { + tileDomObserver.disconnect(); + tileDomObserver = null; + } + } + + function observeTileContainer(layer) { + if (!map || typeof MutationObserver !== 'function') return; + const targetLayer = layer || (usingOfflineTiles ? offlineTiles : tiles); + const container = targetLayer && typeof targetLayer.getContainer === 'function' ? targetLayer.getContainer() : null; + const tilePane = typeof map.getPane === 'function' ? map.getPane('tilePane') : null; const targets = []; if (container) targets.push(container); if (tilePane && !targets.includes(tilePane)) targets.push(tilePane); if (!targets.length) return; - if (tileDomObserver) { - tileDomObserver.disconnect(); - } - const handleNode = (node, filterValue) => { - if (!node || node.nodeType !== 1) return; - if (node.classList && node.classList.contains('leaflet-tile')) { - applyFilterToTileElement(node, filterValue); - } - if (typeof node.querySelectorAll === 'function') { - const nestedTiles = node.querySelectorAll('.leaflet-tile'); - nestedTiles.forEach(tile => applyFilterToTileElement(tile, filterValue)); - } - }; + disconnectTileObserver(); tileDomObserver = new MutationObserver(mutations => { const filterValue = resolveTileFilter(); mutations.forEach(mutation => { - mutation.addedNodes.forEach(node => handleNode(node, filterValue)); + mutation.addedNodes.forEach(node => { + if (!node || node.nodeType !== 1) return; + if (!usingOfflineTiles && node.classList && node.classList.contains('leaflet-tile')) { + applyFilterToTileElement(node, filterValue); + } + if (typeof node.querySelectorAll === 'function') { + const nestedTiles = node.querySelectorAll('.leaflet-tile'); + nestedTiles.forEach(tile => applyFilterToTileElement(tile, filterValue)); + } + }); }); applyFilterToTileContainers(filterValue); }); targets.forEach(target => tileDomObserver.observe(target, { childList: true, subtree: true })); } - tiles.on('tileloadstart', event => { - if (!event || !event.tile) return; - ensureTileHasCurrentFilter(event.tile); - applyFilterToTileContainers(); - }); - - tiles.on('tileload', event => { - if (!event || !event.tile) return; - ensureTileHasCurrentFilter(event.tile); - applyFilterToTileContainers(); - }); - - tiles.on('load', () => { + function activateOfflineTiles(message) { + if (!hasLeaflet || !map) { + if (mapContainer) { + setMapPlaceholder(message); + } + return; + } + if (usingOfflineTiles) { + if (message) showMapStatus(message); + return; + } + usingOfflineTiles = true; + if (tiles && map.hasLayer(tiles)) { + map.removeLayer(tiles); + } + if (!offlineTiles) { + offlineTiles = createOfflineTileLayer(); + } + if (offlineTiles) { + offlineTiles.addTo(map); + observeTileContainer(offlineTiles); + } + if (message) { + showMapStatus(message); + } applyFiltersToAllTiles(); - observeTileContainer(); - }); + } - tiles.addTo(map); - observeTileContainer(); - // Default view until first data arrives - map.setView(MAP_CENTER, 10); - applyFiltersToAllTiles(); + if (hasLeaflet && mapContainer) { + map = L.map(mapContainer, { worldCopyJump: true, attributionControl: false }); + showMapStatus('Loading map tiles…'); + tiles = L.tileLayer(TILE_LAYER_URL, { + maxZoom: 19, + className: 'map-tiles', + crossOrigin: 'anonymous' + }); - map.on('moveend', applyFiltersToAllTiles); - map.on('zoomend', applyFiltersToAllTiles); + tiles.on('tileloadstart', event => { + if (!event || !event.tile) return; + ensureTileHasCurrentFilter(event.tile); + applyFilterToTileContainers(); + }); - const markersLayer = L.layerGroup().addTo(map); + tiles.on('tileload', event => { + if (!event || !event.tile) return; + ensureTileHasCurrentFilter(event.tile); + applyFilterToTileContainers(); + }); + + tiles.on('load', () => { + usingOfflineTiles = false; + hideMapStatus(); + applyFiltersToAllTiles(); + observeTileContainer(tiles); + }); + + tiles.on('tileerror', () => { + activateOfflineTiles('Map tiles unavailable. Showing offline placeholder basemap.'); + }); + + tiles.addTo(map); + observeTileContainer(tiles); + map.setView(mapCenterLatLng || [MAP_CENTER_COORDS.lat, MAP_CENTER_COORDS.lon], 10); + applyFiltersToAllTiles(); + + map.on('moveend', applyFiltersToAllTiles); + map.on('zoomend', applyFiltersToAllTiles); + + markersLayer = L.layerGroup().addTo(map); + + if (typeof navigator !== 'undefined' && navigator && navigator.onLine === false) { + activateOfflineTiles('Offline mode detected. Using placeholder basemap.'); + } + } else if (mapContainer) { + setMapPlaceholder('Leaflet assets are unavailable. Data will continue to refresh without a live map.'); + } + + if (typeof window !== 'undefined') { + window.applyFiltersToAllTiles = applyFiltersToAllTiles; + } let legendContainer = null; let legendToggleButton = null; @@ -961,82 +1190,98 @@ var(--fg); } applyFilter(); } - const legend = L.control({ position: 'bottomright' }); - legend.onAdd = function () { - const div = L.DomUtil.create('div', 'legend'); - div.id = 'mapLegend'; - div.setAttribute('role', 'region'); - div.setAttribute('aria-label', 'Map legend'); - legendContainer = div; + if (map && hasLeaflet) { + const legend = L.control({ position: 'bottomright' }); + legend.onAdd = function () { + const div = L.DomUtil.create('div', 'legend'); + div.id = 'mapLegend'; + div.setAttribute('role', 'region'); + div.setAttribute('aria-label', 'Map legend'); + legendContainer = div; - const header = L.DomUtil.create('div', 'legend-header', div); - const title = L.DomUtil.create('span', 'legend-title', header); - title.textContent = 'Legend'; + const header = L.DomUtil.create('div', 'legend-header', div); + const title = L.DomUtil.create('span', 'legend-title', header); + title.textContent = 'Legend'; - const itemsContainer = L.DomUtil.create('div', 'legend-items', div); - legendRoleButtons.clear(); - for (const [role, color] of Object.entries(roleColors)) { - const item = L.DomUtil.create('button', 'legend-item', itemsContainer); - item.type = 'button'; - item.setAttribute('aria-pressed', 'false'); - item.dataset.role = role; - const swatch = L.DomUtil.create('span', 'legend-swatch', item); - swatch.style.background = color; - swatch.setAttribute('aria-hidden', 'true'); - const label = L.DomUtil.create('span', 'legend-label', item); - label.textContent = role; - item.addEventListener('click', event => { + const itemsContainer = L.DomUtil.create('div', 'legend-items', div); + legendRoleButtons.clear(); + for (const [role, color] of Object.entries(roleColors)) { + const item = L.DomUtil.create('button', 'legend-item', itemsContainer); + item.type = 'button'; + item.setAttribute('aria-pressed', 'false'); + item.dataset.role = role; + const swatch = L.DomUtil.create('span', 'legend-swatch', item); + swatch.style.background = color; + swatch.setAttribute('aria-hidden', 'true'); + const label = L.DomUtil.create('span', 'legend-label', item); + label.textContent = role; + item.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + const exclusive = event.metaKey || event.ctrlKey; + if (exclusive) { + activeRoleFilters.clear(); + activeRoleFilters.add(role); + updateLegendRoleFiltersUI(); + applyFilter(); + } else { + toggleRoleFilter(role); + } + }); + legendRoleButtons.set(role, item); + } + updateLegendRoleFiltersUI(); + + const toggle = L.DomUtil.create('div', 'legend-toggle', div); + const resetButton = L.DomUtil.create('button', 'legend-item legend-reset', toggle); + resetButton.type = 'button'; + resetButton.textContent = 'Clear filters'; + resetButton.addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); - const exclusive = event.metaKey || event.ctrlKey; - if (exclusive) { - activeRoleFilters.clear(); - activeRoleFilters.add(role); - updateLegendRoleFiltersUI(); - applyFilter(); - } else { - toggleRoleFilter(role); - } + activeRoleFilters.clear(); + updateLegendRoleFiltersUI(); + applyFilter(); }); - legendRoleButtons.set(role, item); - } - updateLegendRoleFiltersUI(); - L.DomEvent.disableClickPropagation(div); - L.DomEvent.disableScrollPropagation(div); + L.DomEvent.disableClickPropagation(div); + L.DomEvent.disableScrollPropagation(div); - return div; - }; - legend.addTo(map); - legendContainer = legend.getContainer(); + return div; + }; + legend.addTo(map); + legendContainer = legend.getContainer(); - const legendToggleControl = L.control({ position: 'bottomright' }); - legendToggleControl.onAdd = function () { - const container = L.DomUtil.create('div', 'leaflet-control legend-toggle'); - const button = L.DomUtil.create('button', 'legend-toggle-button', container); - button.type = 'button'; - button.textContent = 'Hide legend (filters)'; - button.setAttribute('aria-pressed', 'true'); - button.setAttribute('aria-label', 'Hide map legend'); - button.setAttribute('aria-controls', 'mapLegend'); - button.addEventListener('click', event => { - event.preventDefault(); - event.stopPropagation(); - setLegendVisibility(!legendVisible); + legendToggleControl = L.control({ position: 'bottomright' }); + legendToggleControl.onAdd = function () { + const container = L.DomUtil.create('div', 'leaflet-control legend-toggle'); + const button = L.DomUtil.create('button', 'legend-toggle-button', container); + button.type = 'button'; + button.textContent = 'Hide legend (filters)'; + button.setAttribute('aria-pressed', 'true'); + button.setAttribute('aria-label', 'Hide map legend'); + button.setAttribute('aria-controls', 'mapLegend'); + button.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + setLegendVisibility(!legendVisible); + }); + legendToggleButton = button; + updateLegendToggleState(); + L.DomEvent.disableClickPropagation(container); + L.DomEvent.disableScrollPropagation(container); + return container; + }; + legendToggleControl.addTo(map); + + const legendMediaQuery = window.matchMedia('(max-width: 1024px)'); + setLegendVisibility(!legendMediaQuery.matches); + legendMediaQuery.addEventListener('change', event => { + setLegendVisibility(!event.matches); }); - legendToggleButton = button; - updateLegendToggleState(); - L.DomEvent.disableClickPropagation(container); - L.DomEvent.disableScrollPropagation(container); - return container; - }; - legendToggleControl.addTo(map); - - const legendMediaQuery = window.matchMedia('(max-width: 1024px)'); - setLegendVisibility(!legendMediaQuery.matches); - legendMediaQuery.addEventListener('change', event => { - setLegendVisibility(!event.matches); - }); + } else if (mapContainer && !hasLeaflet) { + setLegendVisibility(false); + } themeToggle.addEventListener('click', () => { const dark = document.body.classList.toggle('dark'); @@ -1409,6 +1654,32 @@ var(--fg); } return r.json(); } + function toRadians(deg) { + return (deg * Math.PI) / 180; + } + + function haversineDistanceKm(lat1, lon1, lat2, lon2) { + const R = 6371; + const dLat = toRadians(lat2 - lat1); + const dLon = toRadians(lon2 - lon1); + const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + } + + function distanceFromCenterKm(lat, lon) { + if (hasLeaflet && mapCenterLatLng) { + try { + return L.latLng(lat, lon).distanceTo(mapCenterLatLng) / 1000; + } catch (err) { + // fall through to haversine fallback + } + } + return haversineDistanceKm(lat, lon, MAP_CENTER_COORDS.lat, MAP_CENTER_COORDS.lon); + } + function computeDistances(nodes) { for (const n of nodes) { const latRaw = n.latitude; @@ -1423,7 +1694,7 @@ var(--fg); } n.distance_km = null; continue; } - n.distance_km = L.latLng(lat, lon).distanceTo(MAP_CENTER) / 1000; + n.distance_km = distanceFromCenterKm(lat, lon); } } @@ -1457,6 +1728,9 @@ var(--fg); } } function renderMap(nodes, nowSec) { + if (!map || !markersLayer || !hasLeaflet) { + return; + } markersLayer.clearLayers(); const pts = []; const nodesByRenderOrder = nodes @@ -1499,7 +1773,7 @@ var(--fg); } marker.addTo(markersLayer); pts.push([lat, lon]); } - if (pts.length && fitBoundsEl.checked) { + if (pts.length && fitBoundsEl && fitBoundsEl.checked) { const b = L.latLngBounds(pts); map.fitBounds(b.pad(0.2), { animate: false }); }