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)
This commit is contained in:
you committed 2026-03-19 21:11:46 +00:00
1 parent ca2d5cedeb
commit 3bd2bf648f
3 files changed
+123 -36

No files matched your search

+62 -22
View File
@@ -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 = `
<div id="map-wrap" style="position:relative;width:100%;height:100%;">
<div id="leaflet-map" style="width:100%;height:100%;"></div>
<button class="map-controls-toggle" id="mapControlsToggle" aria-label="Toggle map controls" aria-expanded="true">⚙️</button>
<div class="map-controls" id="mapControls" role="region" aria-label="Map controls">
<h3>🗺️ Map Controls</h3>
<fieldset class="mc-section">
@@ -35,13 +41,13 @@
</fieldset>
<fieldset class="mc-section">
<legend class="mc-label">Display</legend>
<label><input type="checkbox" id="mcClusters"> Show clusters</label>
<label><input type="checkbox" id="mcHeatmap"> Heat map</label>
<label for="mcClusters"><input type="checkbox" id="mcClusters"> Show clusters</label>
<label for="mcHeatmap"><input type="checkbox" id="mcHeatmap"> Heat map</label>
</fieldset>
<fieldset class="mc-section">
<legend class="mc-label">Filters</legend>
<label><input type="checkbox" id="mcMqtt"> MQTT Connected Only</label>
<label><input type="checkbox" id="mcNeighbors"> Show direct neighbors</label>
<label for="mcMqtt"><input type="checkbox" id="mcMqtt"> MQTT Connected Only</label>
<label for="mcNeighbors"><input type="checkbox" id="mcNeighbors"> Show direct neighbors</label>
</fieldset>
<fieldset class="mc-section">
<legend class="mc-label">Last Heard</legend>
@@ -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 = `<div style="font-size:12px;min-width:160px">
<div style="font-weight:700;margin-bottom:4px">${label}: ${esc(p.name)}</div>
<div style="font-weight:700;margin-bottom:4px">${label}: ${safeEsc(p.name)}</div>
<div style="color:#9ca3af;font-size:11px;margin-bottom:4px">${p.role || 'unknown'}</div>
<div style="font-family:monospace;font-size:10px;color:#6b7280;margin-bottom:6px;word-break:break-all">${esc(p.pubkey || '')}</div>
<div style="font-family:monospace;font-size:10px;color:#6b7280;margin-bottom:6px;word-break:break-all">${safeEsc(p.pubkey || '')}</div>
<div style="font-size:11px;color:#9ca3af">${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}</div>
${p.pubkey ? `<div style="margin-top:6px"><a href="#/nodes/${p.pubkey}" style="color:var(--accent);font-size:11px">View Node →</a></div>` : ''}
</div>`;
@@ -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 = `<input type="checkbox" data-role="${role}" ${filters[role] ? 'checked' : ''}> <span style="color:${ROLE_COLORS[role]};font-weight:600;" aria-hidden="true">●</span> ${ROLE_LABELS[role]} <span style="color:var(--text-muted)">(${count})</span>`;
lbl.setAttribute('for', cbId);
lbl.innerHTML = `<input type="checkbox" id="${cbId}" data-role="${role}" ${filters[role] ? 'checked' : ''}> <span style="color:${ROLE_COLORS[role]};font-weight:600;" aria-hidden="true">●</span> ${ROLE_LABELS[role]} <span style="color:var(--text-muted)">(${count})</span>`;
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 = `<span style="display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600;background:${ROLE_COLORS[node.role] || '#4b5563'};color:#fff;">${(node.role || 'unknown').toUpperCase()}</span>`;
return `
<div style="font-family:var(--font);min-width:180px;">
<div style="font-weight:700;font-size:14px;margin-bottom:4px;">${node.name || 'Unknown'}</div>
<div class="map-popup" style="font-family:var(--font);min-width:180px;">
<h3 style="font-weight:700;font-size:14px;margin:0 0 4px;">${safeEsc(node.name || 'Unknown')}</h3>
${roleBadge}
<table style="margin-top:8px;font-size:12px;border-collapse:collapse;width:100%;">
<tr><td style="color:var(--text-muted);padding:2px 8px 2px 0;">Key</td><td style="font-family:var(--mono);font-size:11px;">${key}</td></tr>
<tr><td style="color:var(--text-muted);padding:2px 8px 2px 0;">Location</td><td>${loc}</td></tr>
<tr><td style="color:var(--text-muted);padding:2px 8px 2px 0;">Last Advert</td><td>${lastAdvert}</td></tr>
<tr><td style="color:var(--text-muted);padding:2px 8px 2px 0;">Adverts</td><td>${node.advert_count || 0}</td></tr>
</table>
<div style="margin-top:8px;"><a href="#/nodes/${node.public_key}" style="color:var(--accent);font-size:12px;">View Node →</a></div>
<dl style="margin-top:8px;font-size:12px;">
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Key</dt>
<dd style="font-family:var(--mono);font-size:11px;margin-left:88px;padding:2px 0;">${safeEsc(key)}</dd>
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Location</dt>
<dd style="margin-left:88px;padding:2px 0;">${loc}</dd>
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Last Advert</dt>
<dd style="margin-left:88px;padding:2px 0;">${lastAdvert}</dd>
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Adverts</dt>
<dd style="margin-left:88px;padding:2px 0;">${node.advert_count || 0}</dd>
</dl>
<div style="margin-top:8px;clear:both;"><a href="#/nodes/${node.public_key}" style="color:var(--accent);font-size:12px;">View Node →</a></div>
</div>`;
}
+19 -14
View File
@@ -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 = `<div class="node-fullscreen">
<div class="node-full-header">
<button class="ch-back-btn node-back-btn" onclick="location.hash='#/nodes'" aria-label="Back to nodes">←</button>
<button class="detail-back-btn node-back-btn" id="nodeBackBtn" aria-label="Back to nodes">←</button>
<span class="node-full-title">Loading…</span>
</div>
<div class="node-full-body" id="nodeFullBody">
<div class="text-center text-muted" style="padding:40px">Loading…</div>
</div>
</div>`;
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 ? `<div id="nodeFullMap" style="height:200px;border-radius:8px;overflow:hidden;margin-bottom:16px"></div>` : ''}
${hasLoc ? `<div id="nodeFullMap" class="node-detail-map" style="border-radius:8px;overflow:hidden;margin-bottom:16px"></div>` : ''}
<div class="node-full-card">
<div class="node-detail-name" style="font-size:20px">${escapeHtml(n.name || '(unnamed)')}</div>
<div style="margin:6px 0 12px"><span class="badge" style="background:${roleColor}20;color:${roleColor}">${n.role}</span> ${statusLabel}</div>
@@ -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 @@
</div>
<table class="data-table" id="nodesTable">
<thead><tr>
<th class="sortable" data-sort="name">Name</th>
<th class="sortable" data-sort="name" aria-sort="${sortBy === 'name' ? 'ascending' : 'none'}">Name</th>
<th>Public Key</th>
<th>Role</th>
<th>Regions</th>
<th class="sortable" data-sort="lastSeen">Last Seen</th>
<th class="sortable" data-sort="packetCount">Adverts</th>
<th class="sortable" data-sort="lastSeen" aria-sort="${sortBy === 'lastSeen' ? 'descending' : 'none'}">Last Seen</th>
<th class="sortable" data-sort="packetCount" aria-sort="${sortBy === 'packetCount' ? 'descending' : 'none'}">Adverts</th>
</tr></thead>
<tbody id="nodesBody"></tbody>
</table>`;
@@ -353,7 +357,7 @@
panel.innerHTML = `
<div class="node-detail">
${hasLoc ? `<div class="node-map-container" id="nodeMap" style="height:180px;border-radius:8px;overflow:hidden;"></div>` : ''}
${hasLoc ? `<div class="node-map-container node-detail-map" id="nodeMap" style="border-radius:8px;overflow:hidden;"></div>` : ''}
<div class="node-detail-name">${n.name || '(unnamed)'}</div>
<div class="node-detail-role"><span class="badge" style="background:${roleColor}20;color:${roleColor}">${n.role}</span></div>
@@ -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 {}
}
+42
View File
@@ -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;
}