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);