This commit is contained in:
rchac committed 2026-06-28 09:52:29 -06:00
1 parent 2a273a5a0b
commit 0d73ab04c1
13 files changed
+768 -264

No files matched your search

@@ -160,7 +160,7 @@ export class ThroughputRingDash extends DashletBaseInsight{
updateTooltip() {
let container = document.getElementById(this.id);
if (!container) return;
let tooltipAnchor = container.querySelector(".dashbox-title a[data-bs-toggle='tooltip']");
let tooltipAnchor = container.querySelector(".dashbox-title [data-bs-toggle='tooltip']");
if (!tooltipAnchor) return;
let html = this.tooltip();
tooltipAnchor.setAttribute("title", html);
@@ -0,0 +1,50 @@
export const MAPLIBRE_SCRIPT_SRC = "vendor/maplibre-gl.js";
export const MAPLIBRE_STYLESHEET_HREF = "vendor/maplibre-gl.css";
let mapLibrePromise = null;
export function ensureMapLibreAssets() {
if (typeof window !== "undefined" && window.maplibregl?.Map) {
ensureMapLibreStylesheet();
return Promise.resolve();
}
if (mapLibrePromise) {
return mapLibrePromise;
}
ensureMapLibreStylesheet();
mapLibrePromise = new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${MAPLIBRE_SCRIPT_SRC}"]`);
if (existing) {
if (window.maplibregl?.Map) {
resolve();
return;
}
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error("Unable to load MapLibre")), { once: true });
return;
}
const script = document.createElement("script");
script.src = MAPLIBRE_SCRIPT_SRC;
script.onload = () => resolve();
script.onerror = () => reject(new Error("Unable to load MapLibre"));
document.head.appendChild(script);
}).catch((err) => {
mapLibrePromise = null;
throw err;
});
return mapLibrePromise;
}
export function ensureMapLibreStylesheet() {
if (document.querySelector(`link[href="${MAPLIBRE_STYLESHEET_HREF}"]`)) {
return;
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = MAPLIBRE_STYLESHEET_HREF;
document.head.appendChild(link);
}
export function resetMapLibreAssetLoaderForTest() {
mapLibrePromise = null;
}
@@ -0,0 +1,150 @@
import assert from "node:assert/strict";
import {afterEach, test} from "node:test";
import {
ensureMapLibreAssets,
MAPLIBRE_SCRIPT_SRC,
MAPLIBRE_STYLESHEET_HREF,
resetMapLibreAssetLoaderForTest,
} from "./world_map_assets.mjs";
class FakeElement {
constructor(tagName) {
this.tagName = tagName.toUpperCase();
this.listeners = new Map();
this.attributes = new Map();
}
set src(value) {
this.attributes.set("src", value);
}
get src() {
return this.attributes.get("src");
}
set href(value) {
this.attributes.set("href", value);
}
get href() {
return this.attributes.get("href");
}
set rel(value) {
this.attributes.set("rel", value);
}
get rel() {
return this.attributes.get("rel");
}
addEventListener(eventName, handler) {
this.listeners.set(eventName, handler);
}
dispatch(eventName) {
const handler = this.listeners.get(eventName);
if (handler) handler();
}
}
function setupDocument() {
const elements = [];
globalThis.window = {};
globalThis.document = {
head: {
appendChild: (element) => {
elements.push(element);
return element;
},
},
createElement: (tagName) => new FakeElement(tagName),
querySelector: (selector) => {
const scriptMatch = selector.match(/^script\[src="(.+)"\]$/);
if (scriptMatch) {
return elements.find((element) => element.tagName === "SCRIPT" && element.src === scriptMatch[1]) || null;
}
const linkMatch = selector.match(/^link\[href="(.+)"\]$/);
if (linkMatch) {
return elements.find((element) => element.tagName === "LINK" && element.href === linkMatch[1]) || null;
}
return null;
},
};
return elements;
}
afterEach(() => {
resetMapLibreAssetLoaderForTest();
delete globalThis.document;
delete globalThis.window;
});
test("ensureMapLibreAssets injects local stylesheet and script once", async () => {
const elements = setupDocument();
const promise = ensureMapLibreAssets();
const links = elements.filter((element) => element.tagName === "LINK");
const scripts = elements.filter((element) => element.tagName === "SCRIPT");
assert.equal(links.length, 1);
assert.equal(links[0].href, MAPLIBRE_STYLESHEET_HREF);
assert.equal(links[0].rel, "stylesheet");
assert.equal(scripts.length, 1);
assert.equal(scripts[0].src, MAPLIBRE_SCRIPT_SRC);
scripts[0].onload();
await promise;
await ensureMapLibreAssets();
assert.equal(elements.filter((element) => element.tagName === "LINK").length, 1);
assert.equal(elements.filter((element) => element.tagName === "SCRIPT").length, 1);
});
test("ensureMapLibreAssets waits on an existing local script tag", async () => {
const elements = setupDocument();
const existing = document.createElement("script");
existing.src = MAPLIBRE_SCRIPT_SRC;
document.head.appendChild(existing);
const promise = ensureMapLibreAssets();
assert.equal(elements.filter((element) => element.tagName === "SCRIPT").length, 1);
existing.dispatch("load");
await promise;
});
test("ensureMapLibreAssets resolves when an existing local script is already loaded", async () => {
const elements = setupDocument();
const existing = document.createElement("script");
existing.src = MAPLIBRE_SCRIPT_SRC;
document.head.appendChild(existing);
window.maplibregl = { Map: class {} };
await ensureMapLibreAssets();
assert.equal(elements.filter((element) => element.tagName === "SCRIPT").length, 1);
});
test("ensureMapLibreAssets rejects and allows retry after script load failure", async () => {
let elements = setupDocument();
const first = ensureMapLibreAssets();
const firstScript = elements.find((element) => element.tagName === "SCRIPT");
firstScript.onerror();
await assert.rejects(first, /Unable to load MapLibre/);
elements = setupDocument();
const second = ensureMapLibreAssets();
const secondScript = elements.find((element) => element.tagName === "SCRIPT");
secondScript.onload();
await second;
});
test("ensureMapLibreAssets only adds the stylesheet when MapLibre is already present", async () => {
const elements = setupDocument();
window.maplibregl = { Map: class {} };
await ensureMapLibreAssets();
assert.equal(elements.filter((element) => element.tagName === "LINK").length, 1);
assert.equal(elements.filter((element) => element.tagName === "SCRIPT").length, 0);
});
@@ -0,0 +1,25 @@
export const WORLD_MAP_ENCODING_DESCRIPTION = "Larger points indicate more traffic, and point colors indicate RTT.";
export function worldMapStatusText(pointCount) {
const noun = pointCount === 1 ? "location" : "locations";
return `Showing ${pointCount} recent endpoint ${noun}. ${WORLD_MAP_ENCODING_DESCRIPTION}`;
}
export function applyWorldMapFeatureUpdate(dashlet, features, eventName) {
const hasData = features.length > 0;
dashlet._showEmpty(!hasData);
if (!hasData) {
dashlet.graph?.updateFeatures(features);
return;
}
dashlet._setStatus(worldMapStatusText(features.length));
dashlet.traceRender("onMessage", { eventName, pointCount: features.length });
try {
dashlet.graph.updateFeatures(features);
dashlet.traceRender("update-ok", { eventName, pointCount: features.length });
} catch (err) {
dashlet.traceRender("update-error", { eventName, error: err && err.message ? err.message : String(err) });
throw err;
}
}
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import {test} from "node:test";
import {
applyWorldMapFeatureUpdate,
WORLD_MAP_ENCODING_DESCRIPTION,
worldMapStatusText,
} from "./world_map_dashlet_state.mjs";
function makeDashlet() {
const graphUpdates = [];
const traceEvents = [];
return {
emptyStates: [],
statusMessages: [],
graph: {
updateFeatures: (features) => graphUpdates.push(features),
},
graphUpdates,
traceEvents,
_showEmpty(show) {
this.emptyStates.push(show);
},
_setStatus(message) {
this.statusMessages.push(message);
},
traceRender(stage, details) {
traceEvents.push({ stage, details });
},
};
}
test("worldMapStatusText reports feature count and encodings", () => {
assert.equal(
worldMapStatusText(3),
`Showing 3 recent endpoint locations. ${WORLD_MAP_ENCODING_DESCRIPTION}`,
);
assert.equal(
worldMapStatusText(1),
`Showing 1 recent endpoint location. ${WORLD_MAP_ENCODING_DESCRIPTION}`,
);
});
test("applyWorldMapFeatureUpdate renders features and announces the latest count", () => {
const dashlet = makeDashlet();
const features = [
{ type: "Feature", properties: { id: 1 } },
{ type: "Feature", properties: { id: 2 } },
];
applyWorldMapFeatureUpdate(dashlet, features, "EndpointLatLon");
assert.deepEqual(dashlet.emptyStates, [false]);
assert.deepEqual(dashlet.graphUpdates, [features]);
assert.deepEqual(dashlet.statusMessages, [worldMapStatusText(2)]);
assert.deepEqual(dashlet.traceEvents.map((event) => event.stage), ["onMessage", "update-ok"]);
});
test("applyWorldMapFeatureUpdate clears rendered features on no data", () => {
const dashlet = makeDashlet();
applyWorldMapFeatureUpdate(dashlet, [], "EndpointLatLon");
assert.deepEqual(dashlet.emptyStates, [true]);
assert.deepEqual(dashlet.graphUpdates, [[]]);
assert.deepEqual(dashlet.statusMessages, []);
assert.deepEqual(dashlet.traceEvents, []);
});
test("applyWorldMapFeatureUpdate traces and rethrows graph update errors", () => {
const dashlet = makeDashlet();
dashlet.graph.updateFeatures = () => {
throw new Error("map update failed");
};
assert.throws(
() => applyWorldMapFeatureUpdate(dashlet, [{ type: "Feature" }], "EndpointLatLon"),
/map update failed/,
);
assert.deepEqual(dashlet.traceEvents.map((event) => event.stage), ["onMessage", "update-error"]);
});
@@ -1,120 +1,287 @@
import {BaseDashlet} from "../lq_js_common/dashboard/base_dashlet";
import {DashboardGraph} from "../graphs/dashboard_graph";
import {colorByRttMs} from "../helpers/color_scales";
import {isDarkMode} from "../helpers/dark_mode";
import {ensureMapLibreAssets} from "./world_map_assets.mjs";
import {applyWorldMapFeatureUpdate, WORLD_MAP_ENCODING_DESCRIPTION} from "./world_map_dashlet_state.mjs";
import {emptyFeatureCollection, rowsToEndpointFeatures} from "./world_map_model.mjs";
function ensureWorldMap() {
try {
if (typeof echarts !== 'undefined' && echarts.getMap && echarts.getMap('world')) {
return Promise.resolve();
}
} catch (_) {}
if (window._worldMapPromise) return window._worldMapPromise;
window._worldMapPromise = new Promise((resolve, reject) => {
// Try local vendor first (static2/vendor/world.js)
const load = (src, onfail) => {
const s = document.createElement('script');
s.src = src;
s.onload = () => resolve();
s.onerror = () => onfail ? onfail() : reject();
document.head.appendChild(s);
const WORLD_CENTER = [0, 18];
const WORLD_ZOOM = 0.85;
const ENDPOINT_SOURCE_ID = "dashboard-world-endpoints";
const ENDPOINT_LAYER_ID = "dashboard-world-endpoints-circle";
const ENDPOINT_HEAT_LAYER_ID = "dashboard-world-endpoints-heat";
const COASTLINE_SOURCE_ID = "dashboard-world-coastlines";
const COASTLINE_LAYER_ID = "dashboard-world-coastlines-line";
const BACKGROUND_LAYER_ID = "dashboard-world-background";
function mapThemePalette() {
if (isDarkMode()) {
return {
background: "#101820",
coastline: "#94a3b8",
endpointStroke: "#111827",
};
load('vendor/world.js', () => {
// Fallback to CDN if local not present
load('https://fastly.jsdelivr.net/npm/echarts@4.9.0/map/js/world.js');
});
});
return window._worldMapPromise;
}
return {
background: "#edf2f7",
coastline: "#64748b",
endpointStroke: "#ffffff",
};
}
export class WorldMap3DGraph extends DashboardGraph {
constructor(id) {
super(id);
// Do not dim the world map; remove the global 'muted' filter.
if (this.dom && this.dom.classList) {
this.dom.classList.remove('muted');
}
const envColor = isDarkMode() ? '#000000' : '#e6eaef';
const landColor = isDarkMode() ? '#6a6a6a' : '#bcbcbc';
this.option = {
backgroundColor: 'transparent',
geo3D: {
map: 'world',
shading: 'realistic',
silent: true,
// Match page theme: black in dark mode, light grey in light mode
environment: envColor,
realisticMaterial: { roughness: 0.8, metalness: 0 },
// Disable post-processing to avoid shimmer/pulsing
postEffect: { enable: false },
groundPlane: { show: false },
light: {
main: { intensity: 1.0, alpha: 30 },
ambient: { intensity: 0.0 }
},
viewControl: { distance: 70, alpha: 89, panMouseButton: 'left', rotateMouseButton: 'right' },
itemStyle: {
color: landColor,
// Hide coastlines/outlines that appear as stray dots at this zoom
borderColor: landColor,
borderWidth: 0.3
},
regionHeight: 0.5
function worldMapStyle() {
const palette = mapThemePalette();
return {
version: 8,
sources: {
[COASTLINE_SOURCE_ID]: {
type: "geojson",
data: "vendor/site_map_coastlines.geojson",
},
series: [
{
type: 'scatter3D',
coordinateSystem: 'geo3D',
// Remove additive blending that causes glow in dark mode
// blendMode: 'lighter',
symbolSize: 2,
itemStyle: { opacity: 0.6 },
lineStyle: { width: 0.2, opacity: 0.05 },
data: []
}
]
};
this.chart.setOption(this.option);
this.baseApplied = false;
}
update(data){
this.chart.hideLoading();
// Ensure the base option (with geo3D) is applied once after the world map is registered.
if (!this.baseApplied && typeof echarts !== 'undefined' && echarts.getMap && echarts.getMap('world')) {
this.chart.setOption(this.option, true);
this.baseApplied = true;
[ENDPOINT_SOURCE_ID]: {
type: "geojson",
data: emptyFeatureCollection(),
},
},
layers: [
{
id: BACKGROUND_LAYER_ID,
type: "background",
paint: {
"background-color": palette.background,
},
},
{
id: COASTLINE_LAYER_ID,
type: "line",
source: COASTLINE_SOURCE_ID,
paint: {
"line-color": palette.coastline,
"line-opacity": 0.74,
"line-width": [
"interpolate", ["linear"], ["zoom"],
0, 0.55,
3, 1.2,
],
},
},
{
id: ENDPOINT_HEAT_LAYER_ID,
type: "heatmap",
source: ENDPOINT_SOURCE_ID,
maxzoom: 5,
paint: {
"heatmap-weight": ["interpolate", ["linear"], ["get", "weight"], 0, 0, 1, 1],
"heatmap-intensity": ["interpolate", ["linear"], ["zoom"], 0, 0.5, 4, 1.8],
"heatmap-radius": ["interpolate", ["linear"], ["zoom"], 0, 7, 4, 20],
"heatmap-opacity": ["interpolate", ["linear"], ["zoom"], 0, 0.68, 5, 0],
"heatmap-color": [
"interpolate", ["linear"], ["heatmap-density"],
0, "rgba(0,0,0,0)",
0.25, "rgba(79, 172, 254, 0.45)",
0.55, "rgba(124, 255, 178, 0.55)",
0.8, "rgba(253, 221, 96, 0.7)",
1, "rgba(255, 110, 118, 0.8)",
],
},
},
{
id: ENDPOINT_LAYER_ID,
type: "circle",
source: ENDPOINT_SOURCE_ID,
paint: {
"circle-color": ["get", "color"],
"circle-radius": ["get", "radius"],
"circle-opacity": 0.76,
"circle-stroke-color": palette.endpointStroke,
"circle-stroke-width": 0.75,
},
},
],
};
}
export class WorldMapGraph {
constructor(id) {
this.dom = document.getElementById(id);
this.map = null;
this.themeObserver = null;
this.resizeHandler = () => this.resize();
this.pendingFeatures = emptyFeatureCollection();
this.ready = false;
this.disposed = false;
if (!this.dom) {
throw new Error(`WorldMapGraph: missing DOM element '${id}'`);
}
// Then only update series data to avoid re-rendering geo3D and post effects
this.chart.setOption({ series: [{ data }] }, false, false);
this.dom.classList.add("lqos-dashboard-map");
this.dom.textContent = "";
ensureMapLibreAssets()
.then(() => this.initMap())
.catch((err) => {
if (this.disposed) return;
this.dom.textContent = err?.message || "Unable to load map";
});
}
onThemeChange(){
// Only adjust the environment to maintain appropriate background
const envColor = isDarkMode() ? '#000000' : '#e6eaef';
const landColor = isDarkMode() ? '#6a6a6a' : '#bcbcbc';
if (!this.option.geo3D) this.option.geo3D = {};
this.option.geo3D.environment = envColor;
if (!this.option.geo3D.itemStyle) this.option.geo3D.itemStyle = {};
this.option.geo3D.itemStyle.color = landColor;
this.option.geo3D.itemStyle.borderColor = landColor;
this.chart.setOption(this.option, true);
this.baseApplied = true;
initMap() {
if (this.disposed || this.map || !window.maplibregl?.Map) {
return;
}
this.map = new window.maplibregl.Map({
container: this.dom,
style: worldMapStyle(),
center: WORLD_CENTER,
zoom: WORLD_ZOOM,
minZoom: 0,
maxZoom: 5,
attributionControl: false,
interactive: true,
renderWorldCopies: false,
});
this.map.scrollZoom.disable();
this.map.dragRotate.disable();
this.map.touchZoomRotate.disableRotation();
this.map.addControl(new window.maplibregl.NavigationControl({ showCompass: false }), "bottom-right");
const renderWhenReady = () => {
if (this.disposed || !this.map || !this.map.getSource(ENDPOINT_SOURCE_ID)) {
return;
}
this.ready = true;
this.applyCanvasAccessibility();
this.applyTheme();
this.renderFeatures();
this.map.resize();
};
this.map.on("load", renderWhenReady);
window.addEventListener("resize", this.resizeHandler);
this.observeThemeChanges();
renderWhenReady();
}
applyCanvasAccessibility() {
const canvas = this.dom.querySelector(".maplibregl-canvas");
if (!canvas) {
return;
}
const label = this.dom.getAttribute("aria-label");
const describedBy = this.dom.getAttribute("aria-describedby");
if (label) {
canvas.setAttribute("aria-label", label);
}
if (describedBy) {
canvas.setAttribute("aria-describedby", describedBy);
}
}
observeThemeChanges() {
if (this.themeObserver) {
return;
}
this.themeObserver = new MutationObserver(() => this.applyTheme());
this.themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-bs-theme"],
});
}
applyTheme() {
if (!this.map) {
return;
}
const palette = mapThemePalette();
if (this.map.getLayer(BACKGROUND_LAYER_ID)) {
this.map.setPaintProperty(BACKGROUND_LAYER_ID, "background-color", palette.background);
}
if (this.map.getLayer(COASTLINE_LAYER_ID)) {
this.map.setPaintProperty(COASTLINE_LAYER_ID, "line-color", palette.coastline);
}
if (this.map.getLayer(ENDPOINT_LAYER_ID)) {
this.map.setPaintProperty(ENDPOINT_LAYER_ID, "circle-stroke-color", palette.endpointStroke);
}
}
updateFeatures(features) {
this.pendingFeatures = {
type: "FeatureCollection",
features,
};
this.renderFeatures();
}
renderFeatures() {
if (!this.ready || !this.map) {
return;
}
const source = this.map.getSource(ENDPOINT_SOURCE_ID);
if (source?.setData) {
source.setData(this.pendingFeatures);
}
}
resize() {
if (this.map) {
this.map.resize();
}
}
dispose() {
this.disposed = true;
if (this.themeObserver) {
this.themeObserver.disconnect();
this.themeObserver = null;
}
window.removeEventListener("resize", this.resizeHandler);
if (this.map) {
this.map.remove();
this.map = null;
}
}
}
export class ShaperWorldMapDown extends BaseDashlet {
constructor(slot){ super(slot); this.last = null; this._emptyId = this.id + "_empty"; }
export class ShaperWorldMapDashlet extends BaseDashlet {
constructor(slot, title){
super(slot);
this._emptyId = this.id + "_empty";
this._descriptionId = this.id + "_description";
this._statusId = this.id + "_status";
this._title = title;
}
canBeSlowedDown(){ return true; }
title(){ return "Shaper World Map (Download)"; }
tooltip(){ return "<h5>World Map</h5><p>Endpoint locations sized by traffic and colored by RTT."; }
title(){ return this._title; }
tooltip(){ return `<h5>${this.title()}</h5><p>${WORLD_MAP_ENCODING_DESCRIPTION}</p>`; }
subscribeTo(){ return ["EndpointLatLon"]; }
buildContainer(){ let b=super.buildContainer(); b.appendChild(this.graphDiv()); return b; }
buildContainer(){
let b = super.buildContainer();
const graph = this.graphDiv();
graph.setAttribute("aria-label", this.title());
graph.setAttribute("aria-describedby", `${this._descriptionId} ${this._statusId}`);
const description = document.createElement("div");
description.id = this._descriptionId;
description.classList.add("visually-hidden");
description.textContent = `World map showing recent endpoint locations. ${WORLD_MAP_ENCODING_DESCRIPTION}`;
const status = document.createElement("div");
status.id = this._statusId;
status.classList.add("visually-hidden");
status.setAttribute("role", "status");
status.setAttribute("aria-live", "polite");
b.appendChild(graph);
b.appendChild(description);
b.appendChild(status);
return b;
}
setup(){
this.traceRender("setup-start");
this.graph = new WorldMap3DGraph(this.graphDivId());
this.graph = new WorldMapGraph(this.graphDivId());
this.traceRender("setup-complete", { graphId: this.graphDivId() });
if (this.last) this.graph.update(this.last);
}
_showEmpty(show, msg = "No recent data"){
onTabActivated(){
requestAnimationFrame(() => this.graph?.resize());
}
_setStatus(msg){
const status = document.getElementById(this._statusId);
if (status) status.textContent = msg;
}
_showEmpty(show, msg = "No recent endpoint location data"){
const card = document.getElementById(this.id);
if (!card) return;
let empty = document.getElementById(this._emptyId);
@@ -130,58 +297,21 @@ export class ShaperWorldMapDown extends BaseDashlet {
if (show) {
empty.style.display = '';
if (graph) graph.style.display = 'none';
this._setStatus("No recent endpoint location data.");
} else {
empty.style.display = 'none';
if (graph) graph.style.display = '';
this.graph?.resize();
}
}
onMessage(msg){
if (msg.event !== "EndpointLatLon") return;
const rows = msg.data || [];
let maxBytes = 0;
for (let i=0;i<rows.length;i++) {
const b = Number(rows[i][3] || 0);
if (b > maxBytes) maxBytes = b;
}
const minSize = 1, maxSize = 8;
const out = [];
for (let i=0;i<rows.length;i++) {
const lat = rows[i][0], lon = rows[i][1];
const bytes = Number(rows[i][3] || 0);
const rtt = Number(rows[i][4]||0);
const color = colorByRttMs(rtt);
let norm = 0;
if (maxBytes > 0) {
norm = Math.sqrt(bytes / maxBytes);
if (!isFinite(norm) || norm < 0) norm = 0;
if (norm > 1) norm = 1;
}
const size = Math.round(minSize + (maxSize - minSize) * norm);
out.push({ value: [lon, lat], symbolSize: size, itemStyle: { color, opacity: 0.6 } });
}
const hasData = out.length > 0;
this._showEmpty(!hasData);
if (hasData) {
this.last = out;
this.traceRender("onMessage", { eventName: msg.event, pointCount: out.length });
ensureWorldMap().then(() => {
try {
this.graph.update(out);
this.traceRender("update-ok", { eventName: msg.event, pointCount: out.length });
} catch (err) {
this.traceRender("update-error", { eventName: msg.event, error: err && err.message ? err.message : String(err) });
throw err;
}
}).catch(() => {
// If the world map fails to load, still attempt to render points
try {
this.graph.update(out);
this.traceRender("update-ok", { eventName: msg.event, pointCount: out.length, fallback: true });
} catch (err) {
this.traceRender("update-error", { eventName: msg.event, error: err && err.message ? err.message : String(err), fallback: true });
throw err;
}
});
}
const features = rowsToEndpointFeatures(rows, colorByRttMs);
applyWorldMapFeatureUpdate(this, features, msg.event);
}
}
export class ShaperWorldMapDown extends ShaperWorldMapDashlet {
constructor(slot){ super(slot, "Shaper World Map (Download)"); }
}
@@ -0,0 +1,52 @@
const MAX_SYMBOL_SIZE = 8;
const MIN_SYMBOL_SIZE = 1;
export function emptyFeatureCollection() {
return { type: "FeatureCollection", features: [] };
}
export function rowsToEndpointFeatures(rows, colorForRtt) {
if (!Array.isArray(rows)) {
return [];
}
const validRows = [];
let maxBytes = 0;
for (let i = 0; i < rows.length; i++) {
const lat = Number(rows[i]?.[0]);
const lon = Number(rows[i]?.[1]);
if (!Number.isFinite(lat) || !Number.isFinite(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) {
continue;
}
const rawBytes = Number(rows[i]?.[3] || 0);
const bytes = Number.isFinite(rawBytes) ? rawBytes : 0;
if (bytes > maxBytes) {
maxBytes = bytes;
}
validRows.push({ row: rows[i], lat, lon, bytes });
}
const features = [];
for (let i = 0; i < validRows.length; i++) {
const { row, lat, lon, bytes } = validRows[i];
const rawRtt = Number(row?.[4] || 0);
const rtt = Number.isFinite(rawRtt) ? rawRtt : 0;
let weight = 0;
if (maxBytes > 0) {
weight = Math.sqrt(bytes / maxBytes);
if (!Number.isFinite(weight) || weight < 0) weight = 0;
if (weight > 1) weight = 1;
}
features.push({
type: "Feature",
geometry: {
type: "Point",
coordinates: [lon, lat],
},
properties: {
color: colorForRtt(rtt),
radius: Math.round(MIN_SYMBOL_SIZE + (MAX_SYMBOL_SIZE - MIN_SYMBOL_SIZE) * weight),
weight,
},
});
}
return features;
}
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import {test} from "node:test";
import {emptyFeatureCollection, rowsToEndpointFeatures} from "./world_map_model.mjs";
test("emptyFeatureCollection returns an empty GeoJSON collection", () => {
assert.deepEqual(emptyFeatureCollection(), {
type: "FeatureCollection",
features: [],
});
});
test("rowsToEndpointFeatures converts endpoint rows to weighted point features", () => {
const colors = [];
const features = rowsToEndpointFeatures([
[10, 20, "a", 100, 25],
[15, 30, "b", 400, 75],
], (rtt) => {
colors.push(rtt);
return `rtt-${rtt}`;
});
assert.deepEqual(colors, [25, 75]);
assert.equal(features.length, 2);
assert.deepEqual(features[0].geometry.coordinates, [20, 10]);
assert.deepEqual(features[1].geometry.coordinates, [30, 15]);
assert.equal(features[0].properties.color, "rtt-25");
assert.equal(features[1].properties.color, "rtt-75");
assert.equal(features[0].properties.weight, 0.5);
assert.equal(features[1].properties.weight, 1);
assert.equal(features[0].properties.radius, 5);
assert.equal(features[1].properties.radius, 8);
});
test("rowsToEndpointFeatures filters invalid coordinates", () => {
const features = rowsToEndpointFeatures([
["not-lat", 20, "a", 999999, 1],
[91, 20, "b", 999999, 1],
[10, -181, "c", 999999, 1],
[-90, 180, "d", 100, 1],
], () => "#fff");
assert.equal(features.length, 1);
assert.deepEqual(features[0].geometry.coordinates, [180, -90]);
assert.equal(features[0].properties.weight, 1);
assert.equal(features[0].properties.radius, 8);
});
test("rowsToEndpointFeatures handles empty or malformed payloads", () => {
assert.deepEqual(rowsToEndpointFeatures(null, () => "#fff"), []);
assert.deepEqual(rowsToEndpointFeatures({}, () => "#fff"), []);
const rtts = [];
const features = rowsToEndpointFeatures([
[1, 2, "zero", 0, 15],
[3, 4, "missing"],
[5, 6, "nan", "not-bytes", "not-rtt"],
], (rtt) => {
rtts.push(rtt);
return "#fff";
});
assert.deepEqual(rtts, [15, 0, 0]);
assert.equal(features.length, 3);
assert.equal(features[0].properties.weight, 0);
assert.equal(features[0].properties.radius, 1);
assert.equal(features[1].properties.weight, 0);
assert.equal(features[1].properties.radius, 1);
assert.equal(features[2].properties.weight, 0);
assert.equal(features[2].properties.radius, 1);
});
@@ -1,113 +1,5 @@
import {BaseDashlet} from "../lq_js_common/dashboard/base_dashlet";
import {WorldMap3DGraph as _WorldMap3DGraph} from "./world_map_down";
import {colorByRttMs} from "../helpers/color_scales";
import {ShaperWorldMapDashlet} from "./world_map_down";
function ensureWorldMap() {
try {
if (typeof echarts !== 'undefined' && echarts.getMap && echarts.getMap('world')) {
return Promise.resolve();
}
} catch (_) {}
if (window._worldMapPromise) return window._worldMapPromise;
window._worldMapPromise = new Promise((resolve, reject) => {
const load = (src, onfail) => {
const s = document.createElement('script');
s.src = src;
s.onload = () => resolve();
s.onerror = () => onfail ? onfail() : reject();
document.head.appendChild(s);
};
// Prefer local vendor file if shipped (static2/vendor/world.js)
load('vendor/world.js', () => {
load('https://fastly.jsdelivr.net/npm/echarts@4.9.0/map/js/world.js');
});
});
return window._worldMapPromise;
}
// Reuse the same graph implementation; differ in title/tooltip only
class WorldMap3DGraph extends _WorldMap3DGraph {}
export class ShaperWorldMapUp extends BaseDashlet {
constructor(slot){ super(slot); this.last = null; this._emptyId = this.id + "_empty"; }
canBeSlowedDown(){ return true; }
title(){ return "Shaper World Map (Upload)"; }
tooltip(){ return "<h5>World Map</h5><p>Endpoint locations sized by traffic and colored by RTT."; }
subscribeTo(){ return ["EndpointLatLon"]; }
buildContainer(){ let b=super.buildContainer(); b.appendChild(this.graphDiv()); return b; }
setup(){
this.traceRender("setup-start");
this.graph = new WorldMap3DGraph(this.graphDivId());
this.traceRender("setup-complete", { graphId: this.graphDivId() });
if (this.last) this.graph.update(this.last);
}
_showEmpty(show, msg = "No recent data"){
const card = document.getElementById(this.id);
if (!card) return;
let empty = document.getElementById(this._emptyId);
if (!empty) {
empty = document.createElement('div');
empty.id = this._emptyId;
empty.classList.add('text-center','text-muted','small');
empty.style.padding = '12px';
card.appendChild(empty);
}
empty.textContent = msg;
const graph = document.getElementById(this.graphDivId());
if (show) {
empty.style.display = '';
if (graph) graph.style.display = 'none';
} else {
empty.style.display = 'none';
if (graph) graph.style.display = '';
}
}
onMessage(msg){
if (msg.event !== "EndpointLatLon") return;
const rows = msg.data || [];
let maxBytes = 0;
for (let i=0;i<rows.length;i++) {
const b = Number(rows[i][3] || 0);
if (b > maxBytes) maxBytes = b;
}
const minSize = 1, maxSize = 8;
const out = [];
for (let i=0;i<rows.length;i++) {
const lat = rows[i][0], lon = rows[i][1];
const bytes = Number(rows[i][3] || 0);
const rtt = Number(rows[i][4]||0);
let norm = 0;
if (maxBytes > 0) {
norm = Math.sqrt(bytes / maxBytes);
if (!isFinite(norm) || norm < 0) norm = 0;
if (norm > 1) norm = 1;
}
const size = Math.round(minSize + (maxSize - minSize) * norm);
const color = colorByRttMs(rtt);
out.push({ value: [lon, lat], symbolSize: size, itemStyle: { color, opacity: 0.6 } });
}
const hasData = out.length > 0;
this._showEmpty(!hasData);
if (hasData) {
this.last = out;
this.traceRender("onMessage", { eventName: msg.event, pointCount: out.length });
ensureWorldMap().then(() => {
try {
this.graph.update(out);
this.traceRender("update-ok", { eventName: msg.event, pointCount: out.length });
} catch (err) {
this.traceRender("update-error", { eventName: msg.event, error: err && err.message ? err.message : String(err) });
throw err;
}
}).catch(() => {
try {
this.graph.update(out);
this.traceRender("update-ok", { eventName: msg.event, pointCount: out.length, fallback: true });
} catch (err) {
this.traceRender("update-error", { eventName: msg.event, error: err && err.message ? err.message : String(err), fallback: true });
throw err;
}
});
}
}
export class ShaperWorldMapUp extends ShaperWorldMapDashlet {
constructor(slot){ super(slot, "Shaper World Map (Upload)"); }
}
@@ -11,6 +11,7 @@ export class BaseDashlet {
this.graphs = [];
this.graphDivs = [];
this.zoomed = false;
this.zoomReturnFocus = null;
this.disposed = false;
}
@@ -166,13 +167,14 @@ export class BaseDashlet {
if (tt !== null) {
let tooltip = document.createElement("span");
tooltip.style.marginLeft = "5px";
let button = document.createElement("a");
//button.type = "button";
//button.classList.add("btn", "btn-sm", "btn-info");
let button = document.createElement("button");
button.type = "button";
button.title = tt;
button.setAttribute("aria-label", `${this.title()} information`);
button.setAttribute("data-bs-toggle", "tooltip");
button.setAttribute("data-bs-placement", "top");
button.setAttribute("data-bs-html", "true");
button.setAttribute("data-bs-trigger", "hover focus");
button.innerHTML = "<i class='fas fa-info-circle'></i>";
tooltip.appendChild(button);
title.appendChild(tooltip);
@@ -181,8 +183,10 @@ export class BaseDashlet {
if (this.supportsZoom()) {
let zoom = document.createElement("span");
zoom.style.marginLeft = "5px";
let button = document.createElement("a");
let button = document.createElement("button");
button.type = "button";
button.title = "Zoom";
button.setAttribute("aria-label", `Zoom ${this.title()}`);
button.innerHTML = "<i class='fas fa-search-plus'></i>";
button.onclick = () => {
this.openZoom();
@@ -210,18 +214,24 @@ export class BaseDashlet {
}
this.zoomed = true;
this.zoomReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
let zoomDiv = document.createElement("div");
zoomDiv.classList.add("zoomed");
zoomDiv.id = this.zoomContainerId();
zoomDiv.classList.add("dashbox");
zoomDiv.setAttribute("role", "dialog");
zoomDiv.setAttribute("aria-modal", "false");
zoomDiv.setAttribute("aria-label", `Zoomed ${this.title()}`);
let title = document.createElement("h5");
title.classList.add("dashbox-title");
title.innerText = this.title();
let closeBtn = document.createElement("a");
closeBtn.title = "Zoom";
let closeBtn = document.createElement("button");
closeBtn.type = "button";
closeBtn.title = "Close zoom";
closeBtn.setAttribute("aria-label", `Close zoomed ${this.title()}`);
closeBtn.innerHTML = "<i class='fas fa-search-minus'></i>";
closeBtn.style.marginLeft = "5px";
closeBtn.onclick = () => {
@@ -245,6 +255,7 @@ export class BaseDashlet {
console.error("Failed to set up zoomed dashlet", e);
}
zoomDiv.scrollIntoView({behavior: "smooth"});
closeBtn.focus();
});
}
@@ -262,6 +273,10 @@ export class BaseDashlet {
zoomDiv.remove();
}
this.zoomed = false;
if (this.zoomReturnFocus?.isConnected) {
this.zoomReturnFocus.focus();
}
this.zoomReturnFocus = null;
}
dispose() {
@@ -285,7 +300,9 @@ export class BaseDashlet {
return;
}
try {
if (graph.chart && graph.chart.dispose) {
if (typeof graph.dispose === "function") {
graph.dispose();
} else if (graph.chart && graph.chart.dispose) {
graph.chart.dispose();
}
} catch (_) {}
@@ -83,6 +83,22 @@ for asset in "${required_template_assets[@]}"; do
[[ -f "${STATIC_DIR}/${asset}" ]] || fail "Missing required node_manager asset referenced by template.html: ${asset}"
done
required_dashboard_map_assets=(
"vendor/maplibre-gl.css"
"vendor/maplibre-gl.js"
"vendor/site_map_coastlines.geojson"
)
for asset in "${required_dashboard_map_assets[@]}"; do
[[ -f "${STATIC_DIR}/${asset}" ]] || fail "Missing required dashboard world map asset: ${asset}"
done
for asset in "${required_dashboard_map_assets[@]}"; do
if ! grep -R -F -q "${asset}" "${SRC_DIR}/dashlets/world_map_assets.mjs" "${SRC_DIR}/dashlets/world_map_down.js"; then
fail "Dashboard world map asset exists but is not referenced by the source bundle: ${asset}"
fi
done
check_cachebusted_bundle_refs() {
local file_path="$1"
local label="$2"
@@ -16,6 +16,9 @@ node --test \
"${SCRIPT_DIR}/src/config/config_interface_mtu.test.mjs" \
"${SCRIPT_DIR}/src/config/network_mode_storage.test.mjs" \
"${SCRIPT_DIR}/src/config/ssl_redirect.test.mjs" \
"${SCRIPT_DIR}/src/dashlets/world_map_assets.test.mjs" \
"${SCRIPT_DIR}/src/dashlets/world_map_dashlet_state.test.mjs" \
"${SCRIPT_DIR}/src/dashlets/world_map_model.test.mjs" \
"${SCRIPT_DIR}/src/pubsub/ws.test.mjs"
echo "[test-node-manager] Building bundles..."
@@ -521,8 +521,14 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon {
.dashbox .dashbox-title > span:first-of-type {
margin-left: auto;
}
.dashbox .dashbox-title a {
.dashbox .dashbox-title a,
.dashbox .dashbox-title button {
appearance: none;
border: 0;
background: transparent;
padding: 0;
color: var(--bs-secondary-color);
font: inherit;
text-decoration: none;
}
@@ -548,7 +554,9 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon {
max-height: 18rem;
}
}
.dashbox .dashbox-title a:hover {
.dashbox .dashbox-title a:hover,
.dashbox .dashbox-title button:hover,
.dashbox .dashbox-title button:focus {
color: var(--bs-body-color);
}
.dashbox > :not(.dashbox-title) {
@@ -614,6 +622,17 @@ html[data-bs-theme="light"] .sidebar .navbar-toggler-icon {
width: 100%;
height: 250px;
}
.lqos-dashboard-map {
position: relative;
overflow: hidden;
border-radius: var(--lqos-radius-md);
border: 1px solid var(--lqos-border);
background: var(--lqos-surface-2);
}
.lqos-dashboard-map .maplibregl-canvas:focus-visible {
outline: 2px solid var(--bs-primary);
outline-offset: -2px;
}
.lqos-loading-block {
min-height: 220px;
display: flex;