From 3bd2bf648f0ae74a954748c64175d4636cf72afe Mon Sep 17 00:00:00 2001 From: you Date: Thu, 19 Mar 2026 21:11:46 +0000 Subject: [PATCH] Fix 10 UI bugs in map and nodes pages - Map controls panel collapsible with toggle button, default collapsed on mobile (closes #16) - jumpToRegion filters nodes by region observers instead of fitting all (closes #33) - Detail panel Leaflet maps tracked and removed on re-selection (closes #34) - Popup HTML uses semantic dl/dt/dd instead of table (closes #46) - fitBounds now tracks user interaction instead of savedView const (closes #47) - esc() replaced with safeEsc fallback for implicit global safety (closes #48) - Checkboxes in map controls have explicit for/id association (closes #49) - Sort columns have aria-sort attributes (closes #50) - Filter selects have aria-label attributes (closes #51) - Back button uses addEventListener instead of inline onclick (closes #52) - Detail panel map height increased to 280px on desktop (closes #53) --- public/map.js | 84 +++++++++++++++++++++++++++++++++++------------- public/nodes.js | 33 +++++++++++-------- public/style.css | 42 ++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 36 deletions(-) diff --git a/public/map.js b/public/map.js index 2182c2f7..6ff541f3 100644 --- a/public/map.js +++ b/public/map.js @@ -11,6 +11,11 @@ let filters = { repeater: true, companion: true, room: true, sensor: true, lastHeard: '30d', mqttOnly: false, neighbors: false, clusters: false }; let wsHandler = null; let heatLayer = null; + let userHasMoved = false; + let controlsCollapsed = false; + + // Safe escape — falls back to identity if app.js hasn't loaded yet + const safeEsc = (typeof esc === 'function') ? esc : function (s) { return s; }; // Role → marker style (WCAG AA compliant: all ≥4.5:1 on both light/dark backgrounds) const ROLE_STYLE = { @@ -27,6 +32,7 @@ container.innerHTML = `
+

🗺️ Map Controls

@@ -35,13 +41,13 @@
Display - - + +
Filters - - + +
Last Heard @@ -80,6 +86,7 @@ map.on('moveend', () => { const c = map.getCenter(); localStorage.setItem('map-view', JSON.stringify({ lat: c.lat, lng: c.lng, zoom: map.getZoom() })); + userHasMoved = true; }); markerLayer = L.layerGroup().addTo(map); @@ -88,6 +95,21 @@ // Fix map size on SPA load setTimeout(() => map.invalidateSize(), 100); + // Controls toggle + const toggleBtn = document.getElementById('mapControlsToggle'); + const controlsPanel = document.getElementById('mapControls'); + // Default collapsed on mobile + if (window.innerWidth <= 640) { + controlsCollapsed = true; + controlsPanel.classList.add('collapsed'); + toggleBtn.setAttribute('aria-expanded', 'false'); + } + toggleBtn.addEventListener('click', () => { + controlsCollapsed = !controlsCollapsed; + controlsPanel.classList.toggle('collapsed', controlsCollapsed); + toggleBtn.setAttribute('aria-expanded', String(!controlsCollapsed)); + }); + // Bind controls document.getElementById('mcClusters').addEventListener('change', e => { filters.clusters = e.target.checked; renderMarkers(); }); document.getElementById('mcHeatmap').addEventListener('change', e => { toggleHeatmap(e.target.checked); }); @@ -158,9 +180,9 @@ marker.bindTooltip(`${i + 1}. ${p.name}`, { permanent: true, direction: 'top', className: 'route-tooltip' }); const popupHtml = `
-
${label}: ${esc(p.name)}
+
${label}: ${safeEsc(p.name)}
${p.role || 'unknown'}
-
${esc(p.pubkey || '')}
+
${safeEsc(p.pubkey || '')}
${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}
${p.pubkey ? `` : ''}
`; @@ -187,7 +209,7 @@ buildJumpButtons(); renderMarkers(); - if (!savedView) fitBounds(); + if (!userHasMoved) fitBounds(); } catch (e) { console.error('Map load error:', e); } @@ -199,8 +221,10 @@ el.innerHTML = ''; for (const role of ['repeater', 'companion', 'room', 'sensor']) { const count = counts[role + 's'] || 0; + const cbId = 'mcRole_' + role; const lbl = document.createElement('label'); - lbl.innerHTML = ` ${ROLE_LABELS[role]} (${count})`; + lbl.setAttribute('for', cbId); + lbl.innerHTML = ` ${ROLE_LABELS[role]} (${count})`; lbl.querySelector('input').addEventListener('change', e => { filters[e.target.dataset.role] = e.target.checked; renderMarkers(); @@ -235,11 +259,23 @@ } function jumpToRegion(iata) { - // Find nodes observed in this region — use all nodes with location and fit bounds - // For now, just find the centroid of nodes that have location - const nodesWithLoc = nodes.filter(n => n.lat && n.lon); - if (nodesWithLoc.length === 0) return; - const bounds = L.latLngBounds(nodesWithLoc.map(n => [n.lat, n.lon])); + // Find observers in this region, then find nodes seen by those observers + const regionObserverIds = new Set(observers.filter(o => o.iata === iata).map(o => o.id || o.observer_id)); + // Filter nodes that have location; prefer nodes associated with region observers + let regionNodes = nodes.filter(n => n.lat && n.lon && n.observer_id && regionObserverIds.has(n.observer_id)); + // Fallback: if observers don't link to nodes, use observers' own locations + if (regionNodes.length === 0) { + const obsWithLoc = observers.filter(o => o.iata === iata && o.lat && o.lon); + if (obsWithLoc.length > 0) { + const bounds = L.latLngBounds(obsWithLoc.map(o => [o.lat, o.lon])); + map.fitBounds(bounds.pad(0.5), { padding: [40, 40], maxZoom: 12 }); + return; + } + // Final fallback: fit all nodes + regionNodes = nodes.filter(n => n.lat && n.lon); + } + if (regionNodes.length === 0) return; + const bounds = L.latLngBounds(regionNodes.map(n => [n.lat, n.lon])); map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 }); } @@ -275,16 +311,20 @@ const roleBadge = `${(node.role || 'unknown').toUpperCase()}`; return ` -
-
${node.name || 'Unknown'}
+
+

${safeEsc(node.name || 'Unknown')}

${roleBadge} - - - - - -
Key${key}
Location${loc}
Last Advert${lastAdvert}
Adverts${node.advert_count || 0}
- +
+
Key
+
${safeEsc(key)}
+
Location
+
${loc}
+
Last Advert
+
${lastAdvert}
+
Adverts
+
${node.advert_count || 0}
+
+
`; } diff --git a/public/nodes.js b/public/nodes.js index ed190798..b7ee02ba 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -16,6 +16,7 @@ let sortBy = 'lastSeen'; let lastHeard = ''; let wsHandler = null; + let detailMap = null; const ROLE_COLORS = { repeater: '#3b82f6', room: '#6b7280', companion: '#22c55e', sensor: '#f59e0b' }; const TABS = [ @@ -35,13 +36,14 @@ // Full-screen single node view app.innerHTML = `
- + Loading…
Loading…
`; + document.getElementById('nodeBackBtn').addEventListener('click', () => { location.hash = '#/nodes'; }); loadFullNode(directNode); // Escape to go back to nodes list document.addEventListener('keydown', function nodesEsc(e) { @@ -98,7 +100,7 @@ const statusLabel = statusAge < 3600000 ? '🟢 Active' : statusAge < 86400000 ? '🟡 Degraded' : '🔴 Silent'; body.innerHTML = ` - ${hasLoc ? `
` : ''} + ${hasLoc ? `
` : ''}
${escapeHtml(n.name || '(unnamed)')}
${n.role} ${statusLabel}
@@ -156,10 +158,11 @@ // Map if (hasLoc) { try { - const map = L.map('nodeFullMap', { zoomControl: true, attributionControl: false }).setView([n.lat, n.lon], 13); - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 }).addTo(map); - L.marker([n.lat, n.lon]).addTo(map).bindPopup(n.name || n.public_key.slice(0, 12)); - setTimeout(() => map.invalidateSize(), 100); + if (detailMap) { detailMap.remove(); detailMap = null; } + detailMap = L.map('nodeFullMap', { zoomControl: true, attributionControl: false }).setView([n.lat, n.lon], 13); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 }).addTo(detailMap); + L.marker([n.lat, n.lon]).addTo(detailMap).bindPopup(n.name || n.public_key.slice(0, 12)); + setTimeout(() => detailMap.invalidateSize(), 100); } catch {} } @@ -181,6 +184,7 @@ function destroy() { if (wsHandler) offWS(wsHandler); wsHandler = null; + if (detailMap) { detailMap.remove(); detailMap = null; } nodes = []; selectedKey = null; } @@ -239,12 +243,12 @@
- + - - + +
NameName Public Key Role RegionsLast SeenAdvertsLast SeenAdverts
`; @@ -353,7 +357,7 @@ panel.innerHTML = `
- ${hasLoc ? `
` : ''} + ${hasLoc ? `
` : ''}
${n.name || '(unnamed)'}
${n.role}
@@ -402,10 +406,11 @@ // Init map if (hasLoc) { try { - const map = L.map('nodeMap', { zoomControl: false, attributionControl: false }).setView([n.lat, n.lon], 13); - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 }).addTo(map); - L.marker([n.lat, n.lon]).addTo(map).bindPopup(n.name || n.public_key.slice(0, 12)); - setTimeout(() => map.invalidateSize(), 100); + if (detailMap) { detailMap.remove(); detailMap = null; } + detailMap = L.map('nodeMap', { zoomControl: false, attributionControl: false }).setView([n.lat, n.lon], 13); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 }).addTo(detailMap); + L.marker([n.lat, n.lon]).addTo(detailMap).bindPopup(n.name || n.public_key.slice(0, 12)); + setTimeout(() => detailMap.invalidateSize(), 100); } catch {} } diff --git a/public/style.css b/public/style.css index 504b56af..1efdacd7 100644 --- a/public/style.css +++ b/public/style.css @@ -1274,3 +1274,45 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); } .panel-resize-handle { width: 12px !important; } } /* #21 — max-width applied via .ch-msg-bubble rule above */ + +/* === Bug fixes: #16 collapsible controls, #53 detail map height === */ +.map-controls-toggle { + position: absolute; + top: 10px; + right: 10px; + z-index: 1001; + width: 36px; + height: 36px; + border-radius: 6px; + border: 1px solid var(--border, #333); + background: var(--bg-card, #1e1e1e); + color: var(--text, #fff); + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 6px rgba(0,0,0,0.3); +} +.map-controls.collapsed { + display: none; +} +.node-detail-map { + height: 280px; + min-height: 200px; +} +@media (max-width: 640px) { + .node-detail-map { + height: 200px; + min-height: 160px; + } +} +.detail-back-btn { + background: none; + border: 1px solid var(--border, #333); + color: var(--text, #fff); + padding: 4px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 16px; +}