mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-25 22:30:05 -05:00
changes
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
+80
@@ -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)"); }
|
||||
}
|
||||
+24
-7
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user