From de602e468d04a03f1af77ef2debd560fb4c51bbc Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Fri, 6 Nov 2026 22:23:03 -0800 Subject: [PATCH] more language changes --- meshview/lang/en.json | 9 +++- meshview/lang/es.json | 9 +++- meshview/lang/ru.json | 9 +++- meshview/templates/map-view.html | 29 +++++++++++-- tests/i18n.test.cjs | 71 ++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 7 deletions(-) diff --git a/meshview/lang/en.json b/meshview/lang/en.json index 390374d..ad309f1 100644 --- a/meshview/lang/en.json +++ b/meshview/lang/en.json @@ -111,7 +111,11 @@ "no": "No", "link_copied": "Link Copied!", "legend_traceroute": "Traceroute (with arrows)", - "legend_neighbor": "Neighbor" + "legend_neighbor": "Neighbor", + "layer_nodes": "Nodes", + "layer_packets_reach": "Packets Reach", + "layer_traffic_heatmap": "Traffic HeatMap", + "map_layers": "Map layers" }, "stats": { "text_senders_title": "Top text senders · main channel", @@ -585,6 +589,7 @@ "Main channel: {channel}": "Main channel: {channel}", "Main navigation": "Main navigation", "Map Report": "Map Report", + "Map layers": "Map layers", "Map of highest routers and their altitudes": "Map of highest routers and their altitudes", "Map top 50": "Map top 50", "Mesh Nodes Population Heatmap": "Mesh Nodes Population Heatmap", @@ -677,6 +682,7 @@ "Packet totals unavailable": "Packet totals unavailable", "Packet type": "Packet type", "Packet type channel": "Packet type channel", + "Packets Reach": "Packets Reach", "Packets sent": "Packets sent", "Packets to review": "Packets to review", "Page {page} / {total}": "Page {page} / {total}", @@ -816,6 +822,7 @@ "Traceroute": "Traceroute", "Traceroute edge legend": "Traceroute edge legend", "Traceroute not found.": "Traceroute not found.", + "Traffic HeatMap": "Traffic HeatMap", "Traffic unavailable": "Traffic unavailable", "Type": "Type", "UNKNOWN APP": "UNKNOWN APP", diff --git a/meshview/lang/es.json b/meshview/lang/es.json index 420a754..c0e3f29 100644 --- a/meshview/lang/es.json +++ b/meshview/lang/es.json @@ -114,7 +114,11 @@ "no": "No", "link_copied": "¡Enlace copiado!", "legend_traceroute": "Ruta de traceroute (flechas de dirección)", - "legend_neighbor": "Vínculo de vecinos" + "legend_neighbor": "Vínculo de vecinos", + "layer_nodes": "Nodos", + "layer_packets_reach": "Alcance de paquetes", + "layer_traffic_heatmap": "Mapa de calor del tráfico", + "map_layers": "Capas del mapa" }, "stats": { "node_id": "ID de Nodo", @@ -593,6 +597,7 @@ "Main channel: {channel}": "Canal principal: {channel}", "Main navigation": "Navegación principal", "Map Report": "Informe de mapa", + "Map layers": "Capas del mapa", "Map of highest routers and their altitudes": "Mapa de los routers más altos y sus altitudes", "Map top 50": "Ver los 50 primeros en el mapa", "Mesh Nodes Population Heatmap": "Mapa de calor de densidad de nodos mesh", @@ -685,6 +690,7 @@ "Packet totals unavailable": "Totales de paquetes no disponibles", "Packet type": "Tipo de paquete", "Packet type channel": "Canal para tipos de paquetes", + "Packets Reach": "Alcance de paquetes", "Packets sent": "Paquetes enviados", "Packets to review": "Paquetes que revisar", "Page {page} / {total}": "Página {page} / {total}", @@ -824,6 +830,7 @@ "Traceroute": "Trazado de ruta", "Traceroute edge legend": "Leyenda de enlaces de trazado de ruta", "Traceroute not found.": "No se encontró el trazado de ruta.", + "Traffic HeatMap": "Mapa de calor del tráfico", "Traffic unavailable": "Tráfico no disponible", "Type": "Tipo", "UNKNOWN APP": "APLICACIÓN DESCONOCIDA", diff --git a/meshview/lang/ru.json b/meshview/lang/ru.json index 1ff7069..4006e1d 100644 --- a/meshview/lang/ru.json +++ b/meshview/lang/ru.json @@ -111,7 +111,11 @@ "no": "Нет", "link_copied": "Ссылка скопирована!", "legend_traceroute": "Трассировка (со стрелками)", - "legend_neighbor": "Сосед" + "legend_neighbor": "Сосед", + "layer_nodes": "Ноды", + "layer_packets_reach": "Достижимость пакетов", + "layer_traffic_heatmap": "Тепловая карта трафика", + "map_layers": "Слои карты" }, "stats": { "text_senders_title": "Топ отправителей текста · основной канал", @@ -585,6 +589,7 @@ "Main channel: {channel}": "Основной канал: {channel}", "Main navigation": "Основная навигация", "Map Report": "Отчёт карты", + "Map layers": "Слои карты", "Map of highest routers and their altitudes": "Карта самых высоких роутеров и их высот", "Map top 50": "Топ-50 на карте", "Mesh Nodes Population Heatmap": "Тепловая карта плотности mesh-узлов", @@ -677,6 +682,7 @@ "Packet totals unavailable": "Итоги пакетов недоступны", "Packet type": "Тип пакета", "Packet type channel": "Канал типов пакетов", + "Packets Reach": "Достижимость пакетов", "Packets sent": "Отправлено пакетов", "Packets to review": "Пакетов для проверки", "Page {page} / {total}": "Страница {page} / {total}", @@ -816,6 +822,7 @@ "Traceroute": "Трассировка", "Traceroute edge legend": "Легенда связей трассировки", "Traceroute not found.": "Трассировка не найдена.", + "Traffic HeatMap": "Тепловая карта трафика", "Traffic unavailable": "Трафик недоступен", "Type": "Тип", "UNKNOWN APP": "НЕИЗВ-НОЕ ПРИЛОЖ", diff --git a/meshview/templates/map-view.html b/meshview/templates/map-view.html index 698382b..567d6b2 100644 --- a/meshview/templates/map-view.html +++ b/meshview/templates/map-view.html @@ -356,6 +356,7 @@ async function loadTranslationsMap() { const res = await fetch(`/api/lang?lang=${lang}§ion=map`); mapTranslations = await res.json(); applyTranslationsMap(); + updateMapLayerTranslations(); } catch (err) { console.error("Map translation load failed:", err); } @@ -412,13 +413,33 @@ var trafficHeatLayer = L.heatLayer([], { } }); var overlayLayers = { - "Nodes": nodeLayer, - "Packets Reach": packetSeenLayer, - "Traffic HeatMap": trafficHeatLayer + [MeshI18n.h("Nodes")]: nodeLayer, + [MeshI18n.h("Packets Reach")]: packetSeenLayer, + [MeshI18n.h("Traffic HeatMap")]: trafficHeatLayer }; -L.control.layers(baseLayers, overlayLayers, { +var mapLayerControl = L.control.layers(baseLayers, overlayLayers, { collapsed: window.matchMedia("(max-width: 700px)").matches }).addTo(map); +const layerToggle = mapLayerControl.getContainer().querySelector('.leaflet-control-layers-toggle'); +layerToggle.title = MeshI18n.t("Map layers"); +layerToggle.setAttribute('aria-label', MeshI18n.t("Map layers")); + +function updateMapLayerTranslations() { + // The page catalog refreshes independently of the server's startup UI catalog. + const layers = [ + [nodeLayer, mapTranslations.layer_nodes || MeshI18n.t("Nodes")], + [packetSeenLayer, mapTranslations.layer_packets_reach || MeshI18n.t("Packets Reach")], + [trafficHeatLayer, mapTranslations.layer_traffic_heatmap || MeshI18n.t("Traffic HeatMap")] + ]; + layers.forEach(([layer, label]) => { + // Removing a layer from the control leaves its visibility on the map intact. + mapLayerControl.removeLayer(layer); + mapLayerControl.addOverlay(layer, escapeHtml(label)); + }); + const title = mapTranslations.map_layers || MeshI18n.t("Map layers"); + layerToggle.title = title; + layerToggle.setAttribute('aria-label', title); +} // Data structures var nodes = [], markers = {}, markerById = {}, nodeMap = new Map(); diff --git a/tests/i18n.test.cjs b/tests/i18n.test.cjs index 88967c0..64d1110 100644 --- a/tests/i18n.test.cjs +++ b/tests/i18n.test.cjs @@ -79,3 +79,74 @@ test('failed static catalog request leaves English copy available', async () => await api.ready; assert.equal(api.t('Search'),'Search'); }); + +test('map layer registration translates labels while preserving providers and overlay objects', () => { + const path = require('node:path'); + const template = fs.readFileSync(path.join(__dirname, '../meshview/templates/map-view.html'), 'utf8'); + const registration = template.slice(template.indexOf('var overlayLayers ='), template.indexOf('// Data structures')); + for (const language of ['en', 'es', 'ru']) { + const catalog = JSON.parse(fs.readFileSync(path.join(__dirname, `../meshview/lang/${language}.json`))).ui; + const {api} = boot({catalog, language}); + const nodeLayer = {}, packetSeenLayer = {}, trafficHeatLayer = {}; + const baseLayers = {OpenStreetMap: {}, OpenTopoMap: {}}; + const toggle = {setAttribute(key, value) {this[key] = value;}}; + let registered; + vm.runInNewContext(registration, { + MeshI18n: api, nodeLayer, packetSeenLayer, trafficHeatLayer, baseLayers, map: {}, + window: {matchMedia: () => ({matches: false})}, + L: {control: {layers(bases, overlays, options) { + registered = {bases, overlays, options}; + return {addTo: () => ({getContainer: () => ({querySelector: () => toggle})})}; + }}} + }); + assert.equal(registered.bases, baseLayers); + assert.deepEqual(Object.keys(registered.overlays), [catalog.Nodes, catalog['Packets Reach'], catalog['Traffic HeatMap']]); + assert.equal(registered.overlays[catalog.Nodes], nodeLayer); + assert.equal(registered.overlays[catalog['Packets Reach']], packetSeenLayer); + assert.equal(registered.overlays[catalog['Traffic HeatMap']], trafficHeatLayer); + assert.equal(toggle.title, catalog['Map layers']); + assert.equal(toggle['aria-label'], catalog['Map layers']); + } +}); + +test('fresh map catalog replaces layer labels missing from the startup catalog', async () => { + const path = require('node:path'); + const template = fs.readFileSync(path.join(__dirname, '../meshview/templates/map-view.html'), 'utf8'); + const loader = template.slice(template.indexOf('let mapTranslations = {};'), template.indexOf('var map = L.map')); + const registration = template.slice(template.indexOf('var overlayLayers ='), template.indexOf('// Data structures')); + for (const language of ['en', 'es', 'ru']) { + const catalog = JSON.parse(fs.readFileSync(path.join(__dirname, `../meshview/lang/${language}.json`))); + const {api} = boot({catalog: {Nodes: catalog.ui.Nodes}, language}); + const nodeLayer = {}, packetSeenLayer = {}, trafficHeatLayer = {}; + const overlays = new Map(); + const toggle = {setAttribute(key, value) {this[key] = value;}}; + const control = { + addTo() {return this;}, + getContainer: () => ({querySelector: () => toggle}), + removeLayer(layer) {overlays.delete(layer);}, + addOverlay(layer, label) {overlays.set(layer, label);} + }; + const window = {matchMedia: () => ({matches: false}), _siteConfigPromise: Promise.resolve({site:{language}})}; + const context = vm.createContext({ + MeshI18n: api, nodeLayer, packetSeenLayer, trafficHeatLayer, baseLayers: {}, map: {}, window, + document: {querySelectorAll: () => []}, + fetch: async () => ({json: async () => catalog.map}), + console, + L: {control: {layers(bases, labels) { + for (const [label, layer] of Object.entries(labels)) overlays.set(layer, label); + return control; + }}} + }); + vm.runInContext(fs.readFileSync(path.join(__dirname, '../meshview/static/html-escape.js'), 'utf8'), context); + context.escapeHtml = window.escapeHtml; + vm.runInContext(loader + registration, context); + assert.equal(overlays.get(packetSeenLayer), 'Packets Reach'); + assert.equal(overlays.get(trafficHeatLayer), 'Traffic HeatMap'); + await vm.runInContext('loadTranslationsMap()', context); + assert.equal(overlays.size, 3); + assert.equal(overlays.get(nodeLayer), catalog.ui.Nodes); + assert.equal(overlays.get(packetSeenLayer), catalog.ui['Packets Reach']); + assert.equal(overlays.get(trafficHeatLayer), catalog.ui['Traffic HeatMap']); + assert.equal(toggle.title, catalog.ui['Map layers']); + } +});