From 0a70ae4b3e86c00f1998f10692ebe00514ca55fa Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sun, 21 Sep 2025 09:33:48 +0200 Subject: [PATCH] Add clickable role filters to the map legend (#140) * Make map legend role entries filter nodes * Adjust map legend spacing and toggle text --- web/views/index.erb | 162 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 129 insertions(+), 33 deletions(-) diff --git a/web/views/index.erb b/web/views/index.erb index 9a444a4..f558c87 100644 --- a/web/views/index.erb +++ b/web/views/index.erb @@ -91,12 +91,27 @@ label { font-size: 14px; color: #333; } input[type="text"] { padding: 6px 10px; border: 1px solid #ccc; border-radius: 6px; } .legend { position: relative; background: #fff; padding: 8px 10px 10px; border: 1px solid #ccc; border-radius: 8px; font-size: 12px; line-height: 18px; min-width: 160px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); } - .legend-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; font-weight: 600; } + .legend-header { display: flex; align-items: center; justify-content: flex-start; gap: 4px; margin-bottom: 6px; font-weight: 600; } .legend-title { font-size: 13px; } - .legend-close { border: none; background: transparent; cursor: pointer; padding: 2px; line-height: 1; font-size: 16px; border-radius: 4px; color: inherit; } - .legend-close:hover { background: rgba(0, 0, 0, 0.08); } - .legend-items { display: flex; flex-direction: column; gap: 4px; } - .legend-item { display: flex; align-items: center; gap: 6px; } + .legend-items { display: flex; flex-direction: column; gap: 2px; } + .legend-item { + display: flex; + align-items: center; + gap: 6px; + font: inherit; + color: inherit; + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + padding: 3px 6px; + cursor: pointer; + width: 100%; + justify-content: flex-start; + text-align: left; + } + .legend-item:hover { background: rgba(0, 0, 0, 0.05); } + .legend-item:focus-visible { outline: 2px solid #4a90e2; outline-offset: 2px; } + .legend-item[aria-pressed="true"] { border-color: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.08); } .legend-swatch { display: inline-block; width: 12px; height: 12px; border-radius: 2px; } .legend-hidden { display: none !important; } .legend-toggle { margin-top: 8px; } @@ -193,9 +208,10 @@ body.dark label { color: #ddd; } body.dark input[type="text"] { background: #222; color: #eee; border-color: #444; } body.dark .legend { background: #333; border-color: #444; color: #eee; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45); } - body.dark .legend-close:hover { background: rgba(255, 255, 255, 0.1); } body.dark .legend-toggle-button { background: #333; border-color: #444; color: #eee; } body.dark .legend-toggle-button:hover { background: #444; } + body.dark .legend-item:hover { background: rgba(255, 255, 255, 0.1); } + body.dark .legend-item[aria-pressed="true"] { border-color: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.16); } body.dark .leaflet-popup-content-wrapper, body.dark .leaflet-popup-tip { background: #333; @@ -515,6 +531,28 @@ ROUTER: '#E88B94' }); + const activeRoleFilters = new Set(); + const legendRoleButtons = new Map(); + + function normalizeRole(role) { + if (role == null) return 'CLIENT'; + const str = String(role).trim(); + return str.length ? str : 'CLIENT'; + } + + function getRoleKey(role) { + const normalized = normalizeRole(role); + if (roleColors[normalized]) return normalized; + const upper = normalized.toUpperCase(); + if (roleColors[upper]) return upper; + return normalized; + } + + function getRoleColor(role) { + const key = getRoleKey(role); + return roleColors[key] || roleColors.CLIENT || '#3388ff'; + } + // --- Map setup --- const map = L.map('map', { worldCopyJump: true }); const TILE_LAYER_URL = 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png'; @@ -639,17 +677,58 @@ let legendToggleButton = null; let legendVisible = true; + function updateLegendToggleState() { + if (!legendToggleButton) return; + const hasFilters = activeRoleFilters.size > 0; + legendToggleButton.setAttribute('aria-pressed', legendVisible ? 'true' : 'false'); + const baseLabel = legendVisible ? 'Hide map legend' : 'Show map legend'; + const baseText = legendVisible ? 'Hide legend' : 'Show legend'; + const labelSuffix = hasFilters ? ' (role filters active)' : ''; + const textSuffix = ' (filters)'; + legendToggleButton.setAttribute('aria-label', baseLabel + labelSuffix); + legendToggleButton.textContent = baseText + textSuffix; + if (hasFilters) { + legendToggleButton.setAttribute('data-has-active-filters', 'true'); + } else { + legendToggleButton.removeAttribute('data-has-active-filters'); + } + } + function setLegendVisibility(visible) { legendVisible = visible; if (legendContainer) { legendContainer.classList.toggle('legend-hidden', !visible); legendContainer.setAttribute('aria-hidden', visible ? 'false' : 'true'); } - if (legendToggleButton) { - legendToggleButton.setAttribute('aria-pressed', visible ? 'true' : 'false'); - legendToggleButton.setAttribute('aria-label', visible ? 'Hide map legend' : 'Show map legend'); - legendToggleButton.textContent = visible ? 'Hide legend' : 'Show legend'; + updateLegendToggleState(); + } + + function updateLegendRoleFiltersUI() { + const hasFilters = activeRoleFilters.size > 0; + legendRoleButtons.forEach((button, role) => { + if (!button) return; + const isActive = activeRoleFilters.has(role); + button.setAttribute('aria-pressed', isActive ? 'true' : 'false'); + }); + if (legendContainer) { + if (hasFilters) { + legendContainer.setAttribute('data-has-active-filters', 'true'); + } else { + legendContainer.removeAttribute('data-has-active-filters'); + } } + updateLegendToggleState(); + } + + function toggleRoleFilter(role) { + if (!role) return; + if (activeRoleFilters.has(role)) { + activeRoleFilters.delete(role); + } else { + activeRoleFilters.add(role); + } + updateLegendRoleFiltersUI(); + applyFilter(); } const legend = L.control({ position: 'bottomright' }); @@ -663,28 +742,35 @@ const header = L.DomUtil.create('div', 'legend-header', div); const title = L.DomUtil.create('span', 'legend-title', header); title.textContent = 'Legend'; - const closeButton = L.DomUtil.create('button', 'legend-close', header); - closeButton.type = 'button'; - closeButton.setAttribute('aria-label', 'Hide legend'); - closeButton.textContent = '×'; - closeButton.addEventListener('click', event => { - event.preventDefault(); - event.stopPropagation(); - setLegendVisibility(false); - if (legendToggleButton) { - legendToggleButton.focus(); - } - }); const itemsContainer = L.DomUtil.create('div', 'legend-items', div); + legendRoleButtons.clear(); for (const [role, color] of Object.entries(roleColors)) { - const item = L.DomUtil.create('div', 'legend-item', itemsContainer); + 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(); L.DomEvent.disableClickPropagation(div); L.DomEvent.disableScrollPropagation(div); @@ -699,7 +785,7 @@ 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'; + button.textContent = 'Hide legend (filters)'; button.setAttribute('aria-pressed', 'true'); button.setAttribute('aria-label', 'Hide map legend'); button.setAttribute('aria-controls', 'mapLegend'); @@ -709,6 +795,7 @@ setLegendVisibility(!legendVisible); }); legendToggleButton = button; + updateLegendToggleState(); L.DomEvent.disableClickPropagation(container); L.DomEvent.disableScrollPropagation(container); return container; @@ -826,14 +913,14 @@ function renderShortHtml(short, role, longName, nodeData = null){ const safeTitle = longName ? escapeHtml(String(longName)) : ''; const titleAttr = safeTitle ? ` title="${safeTitle}"` : ''; - const resolvedRole = role || (nodeData && nodeData.role) || 'CLIENT'; + const roleValue = normalizeRole(role != null && role !== '' ? role : (nodeData && nodeData.role)); let infoAttr = ''; if (nodeData && typeof nodeData === 'object') { const info = { nodeId: nodeData.node_id ?? nodeData.nodeId ?? '', shortName: short != null ? String(short) : (nodeData.short_name ?? ''), longName: nodeData.long_name ?? longName ?? '', - role: resolvedRole, + role: roleValue, hwModel: nodeData.hw_model ?? nodeData.hwModel ?? '', battery: nodeData.battery_level ?? nodeData.battery ?? null, voltage: nodeData.voltage ?? null, @@ -847,7 +934,7 @@ return `?   `; } const padded = escapeHtml(String(short).padStart(4, ' ')).replace(/ /g, ' '); - const color = roleColors[resolvedRole] || roleColors.CLIENT; + const color = getRoleColor(roleValue); return `${padded}`; } @@ -1107,7 +1194,7 @@ if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; if (n.distance_km != null && n.distance_km > MAX_NODE_DISTANCE_KM) continue; - const color = roleColors[n.role] || '#3388ff'; + const color = getRoleColor(n.role); const marker = L.circleMarker([lat, lon], { radius: 9, color: '#000', @@ -1136,14 +1223,23 @@ } } + function matchesTextFilter(node, query) { + if (!query) return true; + return [node?.node_id, node?.short_name, node?.long_name] + .filter(value => value != null && value !== '') + .some(value => String(value).toLowerCase().includes(query)); + } + + function matchesRoleFilter(node) { + if (!activeRoleFilters.size) return true; + const roleKey = getRoleKey(node && node.role); + return activeRoleFilters.has(roleKey); + } + function applyFilter() { const rawQuery = filterInput ? filterInput.value : ''; const q = rawQuery.trim().toLowerCase(); - const filteredNodes = !q ? allNodes.slice() : allNodes.filter(n => { - return [n.node_id, n.short_name, n.long_name] - .filter(value => value != null && value !== '') - .some(value => String(value).toLowerCase().includes(q)); - }); + const filteredNodes = allNodes.filter(n => matchesTextFilter(n, q) && matchesRoleFilter(n)); const sortedNodes = sortNodes(filteredNodes); const nowSec = Date.now()/1000; renderTable(sortedNodes, nowSec);