mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-10 02:42:49 +02:00
Handle offline-ready map fallback (#202)
This commit is contained in:
+402
-128
@@ -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 = `<strong>Map unavailable</strong>${message ? `<br/><span>${message}</span>` : ''}`;
|
||||
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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user