mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-08 18:02:46 +02:00
web: collapse federation map ledgend (#604)
* web: collapse federation map ledgend * web: cover missing unit test vectors
This commit is contained in:
@@ -27,6 +27,9 @@ test('federation map centers on configured coordinates and follows theme filters
|
||||
|
||||
const mapEl = createElement('div', 'map');
|
||||
registerElement('map', mapEl);
|
||||
const mapPanel = createElement('div', 'mapPanel');
|
||||
mapPanel.dataset.legendCollapsed = 'true';
|
||||
registerElement('mapPanel', mapPanel);
|
||||
const statusEl = createElement('div', 'status');
|
||||
registerElement('status', statusEl);
|
||||
const tableEl = createElement('table', 'instances');
|
||||
@@ -408,15 +411,192 @@ test('federation table sorting, contact rendering, and legend creation', async (
|
||||
assert.deepEqual(mapSetViewCalls[0], [[0, 0], 3]);
|
||||
assert.equal(mapFitBoundsCalls[0][0].length, 3);
|
||||
|
||||
assert.equal(legendContainers.length, 1);
|
||||
const legend = legendContainers[0];
|
||||
assert.ok(legend.className.includes('legend'));
|
||||
assert.equal(legendContainers.length, 2);
|
||||
const legend = legendContainers.find(container => container.className.includes('legend--instances'));
|
||||
assert.ok(legend);
|
||||
assert.ok(legend.className.includes('legend-hidden'));
|
||||
const legendHeader = legend.children.find(child => child.className === 'legend-header');
|
||||
const legendTitle = legendHeader && Array.isArray(legendHeader.children)
|
||||
? legendHeader.children.find(child => child.className === 'legend-title')
|
||||
: null;
|
||||
assert.ok(legendTitle);
|
||||
assert.equal(legendTitle.textContent, 'Active nodes');
|
||||
const legendToggle = legendContainers.find(container => container.className.includes('legend-toggle'));
|
||||
assert.ok(legendToggle);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('federation legend toggle respects media query changes', async () => {
|
||||
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: false });
|
||||
const { document, createElement, registerElement, cleanup } = env;
|
||||
|
||||
const mapEl = createElement('div', 'map');
|
||||
registerElement('map', mapEl);
|
||||
const mapPanel = createElement('div', 'mapPanel');
|
||||
mapPanel.setAttribute('data-legend-collapsed', 'false');
|
||||
registerElement('mapPanel', mapPanel);
|
||||
const statusEl = createElement('div', 'status');
|
||||
registerElement('status', statusEl);
|
||||
|
||||
const tableEl = createElement('table', 'instances');
|
||||
const tbodyEl = createElement('tbody');
|
||||
registerElement('instances', tableEl);
|
||||
tableEl.appendChild(tbodyEl);
|
||||
|
||||
const configPayload = {
|
||||
mapCenter: { lat: 0, lon: 0 },
|
||||
mapZoom: 3,
|
||||
tileFilters: { light: 'none', dark: 'invert(1)' }
|
||||
};
|
||||
const configEl = createElement('div');
|
||||
configEl.setAttribute('data-app-config', JSON.stringify(configPayload));
|
||||
|
||||
document.querySelector = selector => {
|
||||
if (selector === '[data-app-config]') return configEl;
|
||||
if (selector === '#instances tbody') return tbodyEl;
|
||||
return null;
|
||||
};
|
||||
|
||||
let mediaQueryHandler = null;
|
||||
window.matchMedia = () => ({
|
||||
matches: false,
|
||||
addListener(handler) {
|
||||
mediaQueryHandler = handler;
|
||||
}
|
||||
});
|
||||
|
||||
const legendContainers = [];
|
||||
const legendButtons = [];
|
||||
|
||||
const DomUtil = {
|
||||
create(tag, className, parent) {
|
||||
const classSet = new Set(className ? className.split(/\s+/).filter(Boolean) : []);
|
||||
const el = {
|
||||
tagName: tag,
|
||||
className,
|
||||
classList: {
|
||||
toggle(name, force) {
|
||||
const shouldAdd = typeof force === 'boolean' ? force : !classSet.has(name);
|
||||
if (shouldAdd) {
|
||||
classSet.add(name);
|
||||
} else {
|
||||
classSet.delete(name);
|
||||
}
|
||||
el.className = Array.from(classSet).join(' ');
|
||||
}
|
||||
},
|
||||
children: [],
|
||||
style: {},
|
||||
textContent: '',
|
||||
attributes: new Map(),
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
},
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
},
|
||||
addEventListener(event, handler) {
|
||||
if (event === 'click') {
|
||||
this._clickHandler = handler;
|
||||
}
|
||||
},
|
||||
querySelector() {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
if (parent && parent.appendChild) parent.appendChild(el);
|
||||
if (className && className.includes('legend-toggle-button')) {
|
||||
legendButtons.push(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
};
|
||||
|
||||
const controlStub = () => {
|
||||
const ctrl = {
|
||||
onAdd: null,
|
||||
container: null,
|
||||
addTo(map) {
|
||||
this.container = this.onAdd ? this.onAdd(map) : null;
|
||||
legendContainers.push(this.container);
|
||||
return this;
|
||||
},
|
||||
getContainer() {
|
||||
return this.container;
|
||||
}
|
||||
};
|
||||
return ctrl;
|
||||
};
|
||||
|
||||
const markersLayer = {
|
||||
addLayer() {
|
||||
return null;
|
||||
},
|
||||
addTo() {
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
const leafletStub = {
|
||||
map() {
|
||||
return {
|
||||
setView() {},
|
||||
on() {},
|
||||
fitBounds() {}
|
||||
};
|
||||
},
|
||||
tileLayer() {
|
||||
return {
|
||||
addTo() {
|
||||
return this;
|
||||
},
|
||||
getContainer() {
|
||||
return null;
|
||||
},
|
||||
on() {}
|
||||
};
|
||||
},
|
||||
layerGroup() {
|
||||
return markersLayer;
|
||||
},
|
||||
circleMarker() {
|
||||
return {
|
||||
bindPopup() {
|
||||
return this;
|
||||
}
|
||||
};
|
||||
},
|
||||
control: controlStub,
|
||||
DomUtil,
|
||||
DomEvent: {
|
||||
disableClickPropagation() {},
|
||||
disableScrollPropagation() {}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchImpl = async () => ({
|
||||
ok: true,
|
||||
json: async () => []
|
||||
});
|
||||
|
||||
try {
|
||||
await initializeFederationPage({ config: configPayload, fetchImpl, leaflet: leafletStub });
|
||||
|
||||
const legend = legendContainers.find(container => container.className.includes('legend--instances'));
|
||||
assert.ok(legend);
|
||||
assert.ok(!legend.className.includes('legend-hidden'));
|
||||
|
||||
assert.equal(legendButtons.length, 1);
|
||||
legendButtons[0]._clickHandler?.({ preventDefault() {}, stopPropagation() {} });
|
||||
assert.ok(legend.className.includes('legend-hidden'));
|
||||
|
||||
if (mediaQueryHandler) {
|
||||
mediaQueryHandler({ matches: false });
|
||||
assert.ok(!legend.className.includes('legend-hidden'));
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { readAppConfig } from './config.js';
|
||||
import { resolveLegendVisibility } from './map-legend-visibility.js';
|
||||
import { mergeConfig } from './settings.js';
|
||||
import { roleColors } from './role-helpers.js';
|
||||
|
||||
@@ -204,6 +205,31 @@ function hasNumberValue(value) {
|
||||
return toFiniteNumber(value) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the legend hidden class on a container element.
|
||||
*
|
||||
* @param {HTMLElement|{ classList?: { toggle?: Function }, className?: string }} container Legend container.
|
||||
* @param {boolean} hidden Whether the legend should be hidden.
|
||||
* @returns {void}
|
||||
*/
|
||||
function toggleLegendHiddenClass(container, hidden) {
|
||||
if (!container) return;
|
||||
if (container.classList && typeof container.classList.toggle === 'function') {
|
||||
container.classList.toggle('legend-hidden', hidden);
|
||||
return;
|
||||
}
|
||||
if (typeof container.className === 'string') {
|
||||
const classes = container.className.split(/\s+/).filter(Boolean);
|
||||
const hasHidden = classes.includes('legend-hidden');
|
||||
if (hidden && !hasHidden) {
|
||||
classes.push('legend-hidden');
|
||||
} else if (!hidden && hasHidden) {
|
||||
classes.splice(classes.indexOf('legend-hidden'), 1);
|
||||
}
|
||||
container.className = classes.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
const TILE_LAYER_URL = 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png';
|
||||
|
||||
/**
|
||||
@@ -223,6 +249,7 @@ export async function initializeFederationPage(options = {}) {
|
||||
const fetchImpl = options.fetchImpl || fetch;
|
||||
const leaflet = options.leaflet || (typeof window !== 'undefined' ? window.L : null);
|
||||
const mapContainer = document.getElementById('map');
|
||||
const mapPanel = document.getElementById('mapPanel');
|
||||
const tableEl = document.getElementById('instances');
|
||||
const tableBody = document.querySelector('#instances tbody');
|
||||
const statusEl = document.getElementById('status');
|
||||
@@ -239,6 +266,13 @@ export async function initializeFederationPage(options = {}) {
|
||||
let map = null;
|
||||
let markersLayer = null;
|
||||
let tileLayer = null;
|
||||
let legendContainer = null;
|
||||
let legendToggleButton = null;
|
||||
let legendVisible = true;
|
||||
const legendCollapsedValue = mapPanel ? mapPanel.getAttribute('data-legend-collapsed') : null;
|
||||
const legendDefaultCollapsed = legendCollapsedValue == null
|
||||
? true
|
||||
: legendCollapsedValue.trim() !== 'false';
|
||||
const tableSorters = {
|
||||
name: { getValue: inst => inst.name ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
||||
domain: { getValue: inst => inst.domain ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
||||
@@ -357,6 +391,37 @@ export async function initializeFederationPage(options = {}) {
|
||||
syncSortIndicators();
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the pressed state of the legend visibility toggle button.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateLegendToggleState = () => {
|
||||
if (!legendToggleButton) return;
|
||||
const baseLabel = legendVisible ? 'Hide map legend' : 'Show map legend';
|
||||
const baseText = legendVisible ? 'Hide legend' : 'Show legend';
|
||||
legendToggleButton.setAttribute('aria-pressed', legendVisible ? 'true' : 'false');
|
||||
legendToggleButton.setAttribute('aria-label', baseLabel);
|
||||
legendToggleButton.textContent = baseText;
|
||||
};
|
||||
|
||||
/**
|
||||
* Show or hide the map legend component.
|
||||
*
|
||||
* @param {boolean} visible Whether the legend should be displayed.
|
||||
* @returns {void}
|
||||
*/
|
||||
const setLegendVisibility = visible => {
|
||||
legendVisible = Boolean(visible);
|
||||
if (legendContainer) {
|
||||
toggleLegendHiddenClass(legendContainer, !legendVisible);
|
||||
if (typeof legendContainer.setAttribute === 'function') {
|
||||
legendContainer.setAttribute('aria-hidden', legendVisible ? 'false' : 'true');
|
||||
}
|
||||
}
|
||||
updateLegendToggleState();
|
||||
};
|
||||
|
||||
/**
|
||||
* Wire up click and keyboard handlers for sortable headers.
|
||||
*
|
||||
@@ -483,6 +548,15 @@ export async function initializeFederationPage(options = {}) {
|
||||
const canRenderLegend =
|
||||
typeof leaflet.control === 'function' && leaflet.DomUtil && typeof leaflet.DomUtil.create === 'function';
|
||||
if (canRenderLegend) {
|
||||
const legendMediaQuery = typeof window !== 'undefined' && window.matchMedia
|
||||
? window.matchMedia('(max-width: 1024px)')
|
||||
: null;
|
||||
const initialLegendVisible = resolveLegendVisibility({
|
||||
defaultCollapsed: legendDefaultCollapsed,
|
||||
mediaQueryMatches: legendMediaQuery ? legendMediaQuery.matches : false
|
||||
});
|
||||
legendVisible = initialLegendVisible;
|
||||
|
||||
const legendStops = NODE_COUNT_COLOR_STOPS.map((stop, index) => {
|
||||
const lower = index === 0 ? 0 : NODE_COUNT_COLOR_STOPS[index - 1].limit;
|
||||
const upper = stop.limit - 1;
|
||||
@@ -495,7 +569,11 @@ export async function initializeFederationPage(options = {}) {
|
||||
const legend = leaflet.control({ position: 'bottomright' });
|
||||
legend.onAdd = function onAdd() {
|
||||
const container = leaflet.DomUtil.create('div', 'legend legend--instances');
|
||||
container.id = 'federationLegend';
|
||||
container.setAttribute('aria-label', 'Active nodes legend');
|
||||
container.setAttribute('role', 'region');
|
||||
container.setAttribute('aria-hidden', initialLegendVisible ? 'false' : 'true');
|
||||
toggleLegendHiddenClass(container, !initialLegendVisible);
|
||||
const header = leaflet.DomUtil.create('div', 'legend-header', container);
|
||||
const title = leaflet.DomUtil.create('span', 'legend-title', header);
|
||||
title.textContent = 'Active nodes';
|
||||
@@ -508,9 +586,46 @@ export async function initializeFederationPage(options = {}) {
|
||||
const label = leaflet.DomUtil.create('span', 'legend-label', item);
|
||||
label.textContent = stop.label;
|
||||
});
|
||||
legendContainer = container;
|
||||
return container;
|
||||
};
|
||||
legend.addTo(map);
|
||||
|
||||
const legendToggleControl = leaflet.control({ position: 'bottomright' });
|
||||
legendToggleControl.onAdd = function onAdd() {
|
||||
const container = leaflet.DomUtil.create('div', 'leaflet-control legend-toggle');
|
||||
const button = leaflet.DomUtil.create('button', 'legend-toggle-button', container);
|
||||
button.type = 'button';
|
||||
button.setAttribute('aria-controls', 'federationLegend');
|
||||
button.addEventListener?.('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setLegendVisibility(!legendVisible);
|
||||
});
|
||||
legendToggleButton = button;
|
||||
updateLegendToggleState();
|
||||
if (leaflet.DomEvent && typeof leaflet.DomEvent.disableClickPropagation === 'function') {
|
||||
leaflet.DomEvent.disableClickPropagation(container);
|
||||
}
|
||||
if (leaflet.DomEvent && typeof leaflet.DomEvent.disableScrollPropagation === 'function') {
|
||||
leaflet.DomEvent.disableScrollPropagation(container);
|
||||
}
|
||||
return container;
|
||||
};
|
||||
legendToggleControl.addTo(map);
|
||||
|
||||
setLegendVisibility(initialLegendVisible);
|
||||
if (legendMediaQuery) {
|
||||
const changeHandler = event => {
|
||||
if (legendDefaultCollapsed) return;
|
||||
setLegendVisibility(!event.matches);
|
||||
};
|
||||
if (typeof legendMediaQuery.addEventListener === 'function') {
|
||||
legendMediaQuery.addEventListener('change', changeHandler);
|
||||
} else if (typeof legendMediaQuery.addListener === 'function') {
|
||||
legendMediaQuery.addListener(changeHandler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const instance of instances) {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<section class="federation-page federation-page--full-width">
|
||||
<div class="federation-page__content">
|
||||
<div class="federation-page__map-row">
|
||||
<%= erb :"shared/_map_panel", locals: { full_screen: true } %>
|
||||
<%= erb :"shared/_map_panel", locals: { full_screen: true, legend_collapsed: true } %>
|
||||
</div>
|
||||
<%= erb :"shared/_instances_table" %>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user