Add clickable role filters to the map legend (#140)

* Make map legend role entries filter nodes

* Adjust map legend spacing and toggle text
This commit is contained in:
l5y
2025-09-21 09:33:48 +02:00
committed by GitHub
parent 6e709b0b67
commit 0a70ae4b3e
+129 -33
View File
@@ -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 `<span class="short-name" style="background:#ccc"${titleAttr}${infoAttr}>?&nbsp;&nbsp;&nbsp;</span>`;
}
const padded = escapeHtml(String(short).padStart(4, ' ')).replace(/ /g, '&nbsp;');
const color = roleColors[resolvedRole] || roleColors.CLIENT;
const color = getRoleColor(roleValue);
return `<span class="short-name" style="background:${color}"${titleAttr}${infoAttr}>${padded}</span>`;
}
@@ -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);