diff --git a/.eslintrc.js b/.eslintrc.js
index 489a715b..71cc6577 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -1,5 +1,10 @@
module.exports = {
extends: ["airbnb", "prettier"],
+ parserOptions: {
+ // Override airbnb-config's ecmaVersion: 2018 so optional chaining
+ // (used by gui.js / util.js) parses without errors.
+ ecmaVersion: 2020,
+ },
rules: {
"no-param-reassign": "off",
"class-methods-use-this": "off",
diff --git a/README.md b/README.md
index a81765dd..8eb7dc50 100644
--- a/README.md
+++ b/README.md
@@ -433,6 +433,14 @@ NetJSON format used internally is based on [networkgraph](http://netjson.org/rfc
clusterConfig:{
// The configuration for the clusters
},
+ nodePopup:{
+ show: boolean,
+ content: function|HTMLElement|string|null,
+ config:{
+ // Leaflet popup options
+ },
+ onOpen: function,
+ },
baseOptions:{
// The global configuration for Echarts specifically for the map.
}
@@ -455,6 +463,20 @@ NetJSON format used internally is based on [networkgraph](http://netjson.org/rfc
The `linkStyle` property is used to customize the style of the links. The list of all available style properties can be found in the [Echarts documentation](https://echarts.apache.org/en/option.html#series-lines.lineStyle).
+ `nodePopup` displays a Leaflet popup when a map node is clicked.
+ Set `show` to `true` to enable it. If `content` is `null`, the popup shows the default node details.
+ If you need custom content, set `content` to a function that receives the clicked node and returns a DOM element or a string.
+ Use `config` to pass Leaflet popup options. Use `onOpen` if you need to run code after the popup opens.
+
+ **Note:** `content` can also return a promise.
+ If the user clicks multiple nodes quickly, only the latest popup result is shown.
+ Older requests may still finish in the background, but their result is ignored.
+ Avoid putting important side effects in `content`, because they may still run after a newer click.
+
+ **Security:** when `content` returns a string, Leaflet treats it as HTML.
+ If the string includes node data from a remote API or user input, escape it first.
+ The safer option is to return a DOM element and set text with `textContent`, like the built-in popup does.
+
- `mapTileConfig`
The configuration for the map tiles. You can use multiple tiles by passing an array of tile configurations.
diff --git a/public/example_templates/netjsonmap-indoormap-overlay.html b/public/example_templates/netjsonmap-indoormap-overlay.html
index d65ffe8c..6911652e 100644
--- a/public/example_templates/netjsonmap-indoormap-overlay.html
+++ b/public/example_templates/netjsonmap-indoormap-overlay.html
@@ -73,6 +73,30 @@
#indoormap-container .njg-container .hidden .sideBarHandle {
left: 35px;
}
+ .njg-container .njg-sideBar {
+ display: none;
+ }
+ .njg-container .leaflet-popup-tip-container {
+ bottom: -3.5%;
+ }
+ .njg-container .leaflet-popup-tip {
+ box-shadow: none;
+ }
+ .njg-container .default-popup .njg-popup-button-container {
+ text-align: center;
+ }
+ .njg-container .default-popup .njg-popup-button {
+ padding: 6px 12px;
+ border: none;
+ border-radius: 5px;
+ background-color: black;
+ color: white;
+ cursor: pointer;
+ margin-top: 5px;
+ }
+ .njg-container .default-popup .njg-popup-button:hover {
+ background-color: rgb(85, 85, 85);
+ }
@@ -81,6 +105,17 @@
diff --git a/src/css/netjsongraph.css b/src/css/netjsongraph.css
index 951d9ec3..4bd4af13 100755
--- a/src/css/netjsongraph.css
+++ b/src/css/netjsongraph.css
@@ -319,6 +319,10 @@
user-select: text !important;
}
+.njg-container.njg-hide-tooltip .njg-tooltip {
+ display: none !important;
+}
+
.njg-container .njg-tooltip .njg-closeButton {
display: none;
}
@@ -362,6 +366,34 @@
font-size: 18px;
}
+.njg-container .default-popup {
+ padding: 18px;
+}
+.njg-container .default-popup .njg-tooltip-item {
+ display: flex;
+ align-items: center;
+ margin-bottom: 8px;
+ font-size: 14px;
+}
+
+.njg-container .default-popup .njg-tooltip-item:last-child {
+ margin-bottom: 0;
+}
+
+.njg-container .default-popup .njg-tooltip-key {
+ flex-basis: 45%;
+ font-weight: 600;
+ text-transform: capitalize;
+ color: black;
+}
+
+.njg-container .default-popup .njg-tooltip-value {
+ flex: 1;
+ overflow-wrap: anywhere;
+ word-break: normal;
+ color: black;
+}
+
@media only screen and (max-width: 850px) {
.njg-container .njg-sideBar {
top: 0;
diff --git a/src/js/netjsongraph.config.js b/src/js/netjsongraph.config.js
index b4795793..e56d44c9 100644
--- a/src/js/netjsongraph.config.js
+++ b/src/js/netjsongraph.config.js
@@ -245,6 +245,16 @@ const NetJSONGraphDefaultConfig = {
},
],
},
+ nodePopup: {
+ show: false,
+ content: null,
+ // `offset` is intentionally not set — Leaflet picks an offset that
+ // accounts for its own anchor; we only override autoPan defaults here.
+ config: {
+ autoPan: true,
+ autoPanPadding: [25, 25],
+ },
+ },
},
mapTileConfig: [
{
diff --git a/src/js/netjsongraph.gui.js b/src/js/netjsongraph.gui.js
index cec8ff2e..be8cfc20 100644
--- a/src/js/netjsongraph.gui.js
+++ b/src/js/netjsongraph.gui.js
@@ -278,6 +278,204 @@ class NetJSONGraphGUI {
};
}
+ /**
+ * Canonical lookup order for a node's location. `prepareData` normalizes a
+ * node by setting `properties.location = node.location`, so the
+ * post-pipeline value lives on `properties.location`; we read it first
+ * and fall back to the top-level NetJSON field for nodes that bypass
+ * `prepareData`.
+ *
+ * @param {Object} node
+ * @returns {{lat: number, lng: number}|undefined}
+ */
+ getNodeLocation(node) {
+ return node?.properties?.location || node?.location;
+ }
+
+ /**
+ * Load and display a Leaflet popup for a node on the map.
+ *
+ * Resolves `mapOptions.nodePopup.content`:
+ * - if `null` → uses the built-in `createDefaultPopupContent`
+ * - if a function → calls it with `this` = the netjsongraph instance and
+ * awaits the result (the function may be async)
+ *
+ * Concurrency: only the latest invocation's result is rendered. Earlier
+ * pending content promises are not cancelled; their resolved/rejected
+ * values are discarded. Callers should not assume their content function's
+ * return value reaches the screen.
+ *
+ * Failure handling:
+ * - if content building throws (sync or async), the URL fragment for the
+ * current bookmarkable action is cleared and no popup is opened
+ * - if `onOpen` throws after the popup is shown, the popup is removed
+ * and the URL fragment is cleared (via the popup's own remove handler)
+ *
+ * @param {Object} node - The node data containing location and properties.
+ * @returns {Promise}
+ */
+ async loadNodePopup(node) {
+ const {self} = this;
+ if (!self.leaflet) {
+ console.error("Leaflet map not available. Cannot load popup.");
+ return;
+ }
+ const nodeLocation = this.getNodeLocation(node);
+ if (!nodeLocation) {
+ console.error("Node location not available. Cannot load popup.");
+ return;
+ }
+ const bookmarkableActionId =
+ self.config.bookmarkableActions && self.config.bookmarkableActions.id;
+ const popupRequest = {};
+ self.leaflet.currentPopupRequest = popupRequest;
+ // Track whether tooltip/labels were already hidden by an open popup, so
+ // we can restore them if replacement content generation fails.
+ const hadOpenPopup = Boolean(self.leaflet.currentPopup);
+ if (self.leaflet.currentPopup) {
+ // Null out before remove() so the old popup's "remove" handler bails on
+ // the currentPopup !== popup check. Otherwise it runs the user-close
+ // cleanup path (tooltip/label restore + URL fragment removal), which on
+ // popstate restoration of the same node ends up wiping the URL fragment
+ // we just restored.
+ const previousPopup = self.leaflet.currentPopup;
+ self.leaflet.currentPopup = null;
+ previousPopup.remove();
+ }
+ let popupContent = self.config.mapOptions.nodePopup.content;
+ if (popupContent == null) {
+ popupContent = this.createDefaultPopupContent(node);
+ } else if (typeof popupContent === "function") {
+ try {
+ // Matches the project-wide callback convention: callbacks receive the
+ // netjsongraph instance as `this` and any context as positional args.
+ popupContent = await popupContent.call(self, node);
+ } catch (error) {
+ if (self.leaflet.currentPopupRequest !== popupRequest) {
+ return;
+ }
+ self.utils.removeUrlFragment(bookmarkableActionId, "nodeId");
+ self.leaflet.currentPopupRequest = null;
+ // If we tore down a previous popup before content generation failed,
+ // its remove handler was bypassed — restore tooltip/labels manually so
+ // the map doesn't stay in popup-open visual state with no popup.
+ if (hadOpenPopup) {
+ self.utils.setTooltipVisibility(self, true);
+ self.utils.updateLabelVisibility(self, true);
+ }
+ console.error("Failed to build node popup content:", error);
+ return;
+ }
+ }
+ if (self.leaflet.currentPopupRequest !== popupRequest) {
+ return;
+ }
+ const popupConfigInput = self.config.mapOptions.nodePopup.config || {};
+ const popupConfig = Object.fromEntries(
+ Object.entries(popupConfigInput).filter(([, value]) => value != null),
+ );
+
+ const popup = window.L.popup({
+ ...popupConfig,
+ })
+ .setLatLng(nodeLocation)
+ .setContent(popupContent);
+ const popupNodeId = node && node.id != null ? String(node.id) : null;
+
+ popup.on("remove", () => {
+ if (self.leaflet.currentPopup !== popup) {
+ return;
+ }
+ // Restore the chart's own tooltip (we hid it while the popup was open).
+ self.utils.setTooltipVisibility(self, true);
+ self.utils.updateLabelVisibility(self, true);
+ if (
+ self.config.bookmarkableActions &&
+ self.config.bookmarkableActions.enabled &&
+ popupNodeId
+ ) {
+ const fragments = self.utils.parseUrlFragments();
+ const currentFragment = fragments[bookmarkableActionId];
+ if (currentFragment && currentFragment.get("nodeId") === popupNodeId) {
+ self.utils.removeUrlFragment(bookmarkableActionId, "nodeId");
+ }
+ }
+ self.leaflet.currentPopup = null;
+ if (self.leaflet.currentPopupRequest === popupRequest) {
+ self.leaflet.currentPopupRequest = null;
+ }
+ });
+
+ self.leaflet.currentPopup = popup;
+ popup.openOn(self.leaflet);
+ // Hide tooltip and labels while the popup is open
+ self.utils.setTooltipVisibility(self, false);
+ self.utils.updateLabelVisibility(self, false);
+
+ const {onOpen} = self.config.mapOptions.nodePopup;
+ if (typeof onOpen === "function") {
+ try {
+ onOpen.call(self);
+ } catch (error) {
+ // onOpen runs after the popup is already visible. If it throws we
+ // roll back to a consistent state by removing the popup; the popup's
+ // own remove handler clears the URL fragment when it still points
+ // at the now-rolled-back node.
+ if (self.leaflet.currentPopup) {
+ self.leaflet.currentPopup.remove();
+ }
+ console.error("Failed to run popup onOpen callback:", error);
+ }
+ }
+ }
+
+ /**
+ * Build the default popup body for a node: a `.default-popup` div with
+ * `name`, `id`, `label`, and `location` rows (rendered only when present).
+ * All values are written with `textContent`, so they are XSS-safe even if
+ * the node fields contain user-controlled strings.
+ *
+ * Consumers may call this from a custom `mapOptions.nodePopup.content`
+ * function and extend the returned element with additional UI.
+ *
+ * @param {Object} node
+ * @returns {HTMLElement}
+ */
+ createDefaultPopupContent(node) {
+ const popupContent = document.createElement("div");
+ popupContent.classList.add("default-popup");
+ const location = this.getNodeLocation(node);
+ const lat = Number(location?.lat);
+ const lng = Number(location?.lng);
+ const hasCoords = Number.isFinite(lat) && Number.isFinite(lng);
+ const fields = {
+ name: node?.name,
+ id: node?.id,
+ label: node?.label,
+ location: hasCoords ? `${lat.toFixed(8)}, ${lng.toFixed(8)}` : null,
+ };
+ Object.keys(fields).forEach((key) => {
+ const value = fields[key];
+ // Skip only null/undefined/empty-string; preserve falsy-but-valid
+ // values like id: 0.
+ if (value == null || value === "") {
+ return;
+ }
+ const item = document.createElement("div");
+ item.classList.add("njg-tooltip-item");
+ const keyLabel = document.createElement("span");
+ keyLabel.classList.add("njg-tooltip-key");
+ keyLabel.textContent = key;
+ const valueLabel = document.createElement("span");
+ valueLabel.classList.add("njg-tooltip-value");
+ valueLabel.textContent = String(value);
+ item.appendChild(keyLabel);
+ item.appendChild(valueLabel);
+ popupContent.appendChild(item);
+ });
+ return popupContent;
+ }
+
init() {
this.sideBar = this.createSideBar();
if (this.self.config.switchMode) {
diff --git a/src/js/netjsongraph.render.js b/src/js/netjsongraph.render.js
index 1a4635bc..2eaadc54 100644
--- a/src/js/netjsongraph.render.js
+++ b/src/js/netjsongraph.render.js
@@ -103,14 +103,19 @@ class NetJSONGraphRender {
const clickElement = configs.onClickElement.bind(self);
self.utils.addActionToUrl(self, params);
if (params.componentSubType === "graph") {
- return clickElement(
- params.dataType === "edge" ? "link" : "node",
- params.data,
- );
+ clickElement(params.dataType === "edge" ? "link" : "node", params.data);
+ return;
+ }
+ if (params.componentSubType === "lines") {
+ clickElement("link", params.data.link);
+ return;
+ }
+ if (params.data && !params.data.cluster) {
+ if (configs.mapOptions?.nodePopup?.show) {
+ self.gui.loadNodePopup(params.data.node);
+ }
+ clickElement("node", params.data.node);
}
- return params.componentSubType === "lines"
- ? clickElement("link", params.data.link)
- : !params.data.cluster && clickElement("node", params.data.node);
},
{passive: true},
);
@@ -598,33 +603,10 @@ class NetJSONGraphRender {
self.leaflet.fitBounds(bounds, {padding: [20, 20]});
}
}
-
- const updateLabelVisibility = () => {
- const showLabel =
- self.config.showMapLabelsAtZoom !== false &&
- self.leaflet.getZoom() >= self.config.showMapLabelsAtZoom;
- self.echarts.setOption({
- series: [
- {
- id: "geo-map",
- label: {
- show: showLabel,
- silent: true,
- },
- emphasis: {
- label: {
- show: false,
- },
- },
- },
- ],
- });
- };
-
- updateLabelVisibility();
+ self.utils.updateLabelVisibility(self, true);
self.leaflet.on("zoomend", () => {
- updateLabelVisibility();
+ self.utils.updateLabelVisibility(self, true);
// Zoom in/out buttons disabled only when it is equal to min/max zoomlevel
// Manually handle zoom control state to ensure correct behavior with float zoom levels
const currentZoom = self.leaflet.getZoom();
@@ -722,7 +704,7 @@ class NetJSONGraphRender {
clusters,
),
);
- updateLabelVisibility();
+ self.utils.updateLabelVisibility(self, true);
self.echarts.on("click", (params) => {
if (
@@ -762,7 +744,7 @@ class NetJSONGraphRender {
// When above the threshold, show all nodes without clustering
self.echarts.setOption(self.utils.generateMapOption(JSONData, self));
}
- updateLabelVisibility();
+ self.utils.updateLabelVisibility(self, true);
});
}
diff --git a/src/js/netjsongraph.util.js b/src/js/netjsongraph.util.js
index af585f39..8518cb3d 100644
--- a/src/js/netjsongraph.util.js
+++ b/src/js/netjsongraph.util.js
@@ -1328,9 +1328,29 @@ class NetJSONGraphUtil {
this.updateUrlFragments(fragments, nodeData);
}
- removeUrlFragment(id) {
+ /**
+ * Remove the URL fragment for the given action id, or just one URLSearchParams
+ * key inside that fragment.
+ *
+ * @param {string} id The bookmarkable action id (e.g. "geoMap").
+ * @param {string} [paramName] If provided, only this query-param is removed
+ * from the fragment. If omitted, the whole fragment for the id is dropped.
+ */
+ removeUrlFragment(id, paramName = null) {
const fragments = this.parseUrlFragments();
- if (fragments[id]) {
+ if (!fragments[id]) {
+ return;
+ }
+ if (paramName) {
+ fragments[id].delete(paramName);
+ // Drop the whole entry if only the bare action id is left — a fragment
+ // like "#id=geoMap" with no other params is a useless stub that
+ // parseUrlFragments would still pick up on subsequent visits.
+ const remainingKeys = Array.from(fragments[id].keys()).filter((k) => k !== "id");
+ if (remainingKeys.length === 0) {
+ delete fragments[id];
+ }
+ } else {
delete fragments[id];
}
const state = {id};
@@ -1347,6 +1367,11 @@ class NetJSONGraphUtil {
const nodeId =
fragmentParams && fragmentParams.get ? fragmentParams.get("nodeId") : undefined;
if (!nodeId || !self.nodeLinkIndex || self.nodeLinkIndex[nodeId] == null) {
+ // Popstate to a state without a selected node: close any open popup so
+ // the visible map state matches the URL.
+ if (self.leaflet && self.leaflet.currentPopup) {
+ self.leaflet.currentPopup.remove();
+ }
return;
}
const [source, target] = nodeId.split("~");
@@ -1381,6 +1406,9 @@ class NetJSONGraphUtil {
self.leaflet.setView(center, zoom);
}
}
+ if (target == null && self.config.mapOptions?.nodePopup?.show) {
+ self.gui.loadNodePopup(node);
+ }
if (typeof self.config.onClickElement === "function") {
self.config.onClickElement.call(self, source && target ? "link" : "node", node);
}
@@ -1441,6 +1469,43 @@ class NetJSONGraphUtil {
series: options.series,
});
}
+
+ updateLabelVisibility(self, show) {
+ if (!self.echarts || typeof self.echarts.setOption !== "function") {
+ console.warn("updateLabelVisibility: ECharts instance not ready");
+ return;
+ }
+ const showLabel =
+ show &&
+ self.config.showMapLabelsAtZoom !== false &&
+ self.leaflet.getZoom() >= self.config.showMapLabelsAtZoom;
+ self.echarts.setOption({
+ series: [
+ {
+ id: "geo-map",
+ label: {
+ show: showLabel,
+ silent: true,
+ },
+ emphasis: {
+ label: {
+ show: false,
+ },
+ },
+ },
+ ],
+ });
+ }
+
+ /**
+ * Hide/show the rendered ECharts tooltip without changing user tooltip config.
+ */
+ setTooltipVisibility(self, visible) {
+ if (!self.el) {
+ return;
+ }
+ self.el.classList.toggle("njg-hide-tooltip", !visible);
+ }
}
export default NetJSONGraphUtil;
diff --git a/test/browser.test.utils.js b/test/browser.test.utils.js
index 14cbb504..f7786ccc 100644
--- a/test/browser.test.utils.js
+++ b/test/browser.test.utils.js
@@ -132,8 +132,31 @@ export const getPresentNodesAndLinksCount = async (example) => {
export const captureConsoleErrors = async (driver) => {
const logs = await driver.manage().logs().get("browser");
+ // OSM tile 503s are upstream rate-limiting flakes — filter them so CI
+ // does not red on unrelated infrastructure issues.
+ const isIgnoredNoise = (msg) => {
+ if (msg.includes("favicon.ico")) {
+ return true;
+ }
+ if (!msg.includes("503")) {
+ return false;
+ }
+ const urlMatch = msg.match(/https?:\/\/[^\s"')]+/);
+ if (!urlMatch) {
+ return false;
+ }
+ try {
+ const {hostname} = new URL(urlMatch[0]);
+ return (
+ hostname === "tile.openstreetmap.org" ||
+ hostname.endsWith(".tile.openstreetmap.org")
+ );
+ } catch (error) {
+ return false;
+ }
+ };
return logs.filter(
- (log) => log.level.name === "SEVERE" && !log.message.includes("favicon.ico"),
+ (log) => log.level.name === "SEVERE" && !isIgnoredNoise(log.message),
);
};
diff --git a/test/netjsongraph.browser.test.js b/test/netjsongraph.browser.test.js
index ac226382..c51a1b0c 100644
--- a/test/netjsongraph.browser.test.js
+++ b/test/netjsongraph.browser.test.js
@@ -169,8 +169,12 @@ describe("Chart Rendering Test", () => {
"//span[@class='njg-valueLabel' and text()='10.149.3.3']",
2000,
);
- const nodeId = await node.getText();
-
+ // Use textContent rather than getText(): in the popup-based examples the
+ // sidebar is hidden via CSS, and Selenium's getText() returns "" for
+ // elements that aren't visible. textContent gives us the raw text
+ // regardless of layout state, so all the bookmarkable tests stay
+ // consistent across visible-sidebar and hidden-sidebar examples.
+ const nodeId = await node.getAttribute("textContent");
printConsoleErrors(consoleErrors);
expect(consoleErrors.length).toBe(0);
expect(canvas).not.toBeNull();
@@ -195,8 +199,8 @@ describe("Chart Rendering Test", () => {
"//span[@class='njg-valueLabel' and text()='172.16.155.4']",
2000,
);
- const sourceId = await source.getText();
- const targetId = await target.getText();
+ const sourceId = await source.getAttribute("textContent");
+ const targetId = await target.getAttribute("textContent");
printConsoleErrors(consoleErrors);
expect(consoleErrors.length).toBe(0);
@@ -216,7 +220,7 @@ describe("Chart Rendering Test", () => {
"//span[@class='njg-valueLabel' and text()='172.16.169.1']",
2000,
);
- const nodeId = await node.getText();
+ const nodeId = await node.getAttribute("textContent");
printConsoleErrors(consoleErrors);
expect(consoleErrors.length).toBe(0);
@@ -242,8 +246,8 @@ describe("Chart Rendering Test", () => {
"//span[@class='njg-valueLabel' and text()='172.16.185.13']",
2000,
);
- const sourceId = await source.getText();
- const targetId = await target.getText();
+ const sourceId = await source.getAttribute("textContent");
+ const targetId = await target.getAttribute("textContent");
printConsoleErrors(consoleErrors);
expect(consoleErrors.length).toBe(0);
@@ -269,6 +273,12 @@ describe("Chart Rendering Test", () => {
await driver.executeScript('window._geoMap.utils.triggerOnClick("172.16.171.15");');
let currentUrl = await driver.getCurrentUrl();
expect(currentUrl).toContain("172.16.171.15");
+ const floorplanBtn = await getElementByCss(driver, ".njg-popup-button", 2000);
+ expect(floorplanBtn).not.toBeNull();
+ // Use JS click to avoid Leaflet popup click interception in Chrome
+ await driver.executeScript("arguments[0].click();", floorplanBtn);
+ // wait for overlay to open and render indoor map
+ await driver.sleep(500);
let indoorContainer = await getElementByCss(driver, "#indoormap-container", 2000);
const indoorCanvas = await getElementByCss(driver, "canvas", 2000);
const floorplanImage = await getElementByCss(driver, ".leaflet-image-layer", 2000);
@@ -297,14 +307,41 @@ describe("Chart Rendering Test", () => {
test("bookmarkableActions: test url fragments for nodes", async () => {
await driver.get(`${urls.indoorMapOverlay}#id=geoMap&nodeId=172.16.177.33`);
const canvas = await getElementByCss(driver, "canvas", 2000);
- const indoorContainer = await getElementByCss(driver, "#indoormap-container", 2000);
- const floorplanImage = await getElementByCss(driver, ".leaflet-image-layer", 2000);
+ const indoorContainer = await getElementByCss(driver, ".njg-popup-button", 2000);
+ const node = await getElementByXpath(
+ driver,
+ "//span[@class='njg-tooltip-value' and text()='172.16.177.33']",
+ 2000,
+ );
+ const nodeId = await node.getAttribute("textContent");
const consoleErrors = await captureConsoleErrors(driver);
printConsoleErrors(consoleErrors);
expect(consoleErrors.length).toBe(0);
expect(canvas).not.toBeNull();
expect(indoorContainer).not.toBeNull();
- expect(floorplanImage).not.toBeNull();
+ expect(nodeId).toBe("172.16.177.33");
+ });
+
+ test("nodePopup toggles tooltip suppression while popup is open", async () => {
+ await driver.get(urls.indoorMapOverlay);
+ const canvas = await getElementByCss(driver, "canvas", 2000);
+ expect(canvas).not.toBeNull();
+ await driver.executeScript('window._geoMap.utils.triggerOnClick("172.16.171.15");');
+ const popupCloseBtn = await getElementByCss(
+ driver,
+ ".leaflet-popup-close-button",
+ 2000,
+ );
+ expect(popupCloseBtn).not.toBeNull();
+ let tooltipSuppressed = await driver.executeScript(
+ 'return window._geoMap.el.classList.contains("njg-hide-tooltip");',
+ );
+ expect(tooltipSuppressed).toBe(true);
+ await driver.executeScript("arguments[0].click();", popupCloseBtn);
+ tooltipSuppressed = await driver.executeScript(
+ 'return window._geoMap.el.classList.contains("njg-hide-tooltip");',
+ );
+ expect(tooltipSuppressed).toBe(false);
});
test("bookmarkableActions: test forward/backward actions", async () => {
@@ -314,12 +351,25 @@ describe("Chart Rendering Test", () => {
await driver.executeScript('window._geoMap.utils.triggerOnClick("172.16.171.15");');
let currentUrl = await driver.getCurrentUrl();
expect(currentUrl).toContain("172.16.171.15");
- let indoorContainer = await getElementByCss(driver, "#indoormap-container");
+ let node = await getElementByXpath(
+ driver,
+ "//span[@class='njg-tooltip-value' and text()='172.16.171.15']",
+ 2000,
+ );
+ let nodeId = await node.getAttribute("textContent");
+ expect(nodeId).toBe("172.16.171.15");
+ const floorplanBtn = await getElementByCss(driver, ".njg-popup-button", 2000);
+ expect(floorplanBtn).not.toBeNull();
+ await driver.executeScript("arguments[0].click();", floorplanBtn);
+ // wait for overlay to open and render indoor map
+ await driver.sleep(500);
+ let indoorContainer = await getElementByCss(driver, "#indoormap-container", 2000);
expect(indoorContainer).not.toBeNull();
await driver.executeScript('window._indoorMap.utils.triggerOnClick("node_2");');
currentUrl = await driver.getCurrentUrl();
expect(currentUrl).toContain("node_2");
await driver.get("http://0.0.0.0:8080");
+ await driver.sleep(500);
await driver.navigate().back();
await driver.sleep(500);
currentUrl = await driver.getCurrentUrl();
@@ -327,9 +377,13 @@ describe("Chart Rendering Test", () => {
expect(currentUrl).toContain("node_2");
indoorContainer = await getElementByCss(driver, "#indoormap-container");
expect(indoorContainer).not.toBeNull();
- let node = await getElementByCss(driver, "#indoormap-container .njg-valueLabel");
- let nodeId = await node.getText();
- expect(nodeId).toBe("Node_2");
+ node = await getElementByXpath(
+ driver,
+ "//span[@class='njg-tooltip-value' and text()='Node 2']",
+ 2000,
+ );
+ nodeId = await node.getAttribute("textContent");
+ expect(nodeId).toBe("Node 2");
await driver.navigate().back();
await driver.sleep(500);
currentUrl = await driver.getCurrentUrl();
@@ -337,6 +391,13 @@ describe("Chart Rendering Test", () => {
expect(currentUrl).not.toContain("node_2");
indoorContainer = await getElementByCss(driver, "#indoormap-container");
expect(indoorContainer).toBeNull();
+ node = await getElementByXpath(
+ driver,
+ "//span[@class='njg-tooltip-value' and text()='172.16.171.15']",
+ 2000,
+ );
+ nodeId = await node.getAttribute("textContent");
+ expect(nodeId).toBe("172.16.171.15");
await driver.navigate().forward();
await driver.sleep(500);
currentUrl = await driver.getCurrentUrl();
@@ -344,13 +405,17 @@ describe("Chart Rendering Test", () => {
expect(currentUrl).toContain("node_2");
indoorContainer = await getElementByCss(driver, "#indoormap-container");
expect(indoorContainer).not.toBeNull();
- node = await getElementByCss(driver, "#indoormap-container .njg-valueLabel");
- nodeId = await node.getText();
- expect(nodeId).toBe("Node_2");
+ node = await getElementByXpath(
+ driver,
+ "//span[@class='njg-tooltip-value' and text()='Node 2']",
+ 2000,
+ );
+ nodeId = await node.getAttribute("textContent");
+ expect(nodeId).toBe("Node 2");
const consoleErrors = await captureConsoleErrors(driver);
printConsoleErrors(consoleErrors);
expect(consoleErrors.length).toBe(0);
- });
+ }, 10000); // This test needs more time
test("bookmarkableActions: check if parseUrlFragments handles invalid UTF-8", async () => {
// Invalid UTF-8 sequence in hash
@@ -424,7 +489,7 @@ describe("Chart Rendering Test", () => {
expect(canvas).not.toBeNull();
// Wait for map to initialize and get initial position
- await driver.sleep(2000);
+ await driver.sleep(500);
const initialPosition = await driver.executeScript(() => {
const options = window.map.echarts.getOption();
const series = options.series.find((s) => s.type === "scatter");
diff --git a/test/netjsongraph.dom.test.js b/test/netjsongraph.dom.test.js
index 964982eb..5deb6e71 100644
--- a/test/netjsongraph.dom.test.js
+++ b/test/netjsongraph.dom.test.js
@@ -442,3 +442,655 @@ describe("Test GUI on narrow screens", () => {
expect(graph.gui.nodeLinkInfoContainer.innerHTML).toContain("region");
});
});
+
+describe("Test GUI createDefaultPopupContent", () => {
+ beforeEach(() => {
+ graph.gui = new NetJSONGraphGUI(graph);
+ });
+ afterEach(() => {
+ graph.gui = null;
+ });
+ test("Create default popup content with valid location coordinates", () => {
+ const node = {
+ id: "node-1",
+ name: "Test Node",
+ label: "Node Label",
+ location: {
+ lat: 12.3456789,
+ lng: 98.7654321,
+ },
+ };
+ const content = graph.gui.createDefaultPopupContent(node);
+ expect(content).toBeInstanceOf(HTMLElement);
+ expect(content.classList.contains("default-popup")).toBe(true);
+ expect(content.innerHTML).toContain("node-1");
+ expect(content.innerHTML).toContain("Test Node");
+ expect(content.innerHTML).toContain("Node Label");
+ expect(content.innerHTML).toContain("12.34567890");
+ expect(content.innerHTML).toContain("98.76543210");
+ });
+
+ test("Create default popup content with missing location should not display coordinates", () => {
+ const node = {
+ id: "node-2",
+ name: "Test Node No Location",
+ label: "No Location Node",
+ };
+ const content = graph.gui.createDefaultPopupContent(node);
+ expect(content).toBeInstanceOf(HTMLElement);
+ expect(content.innerHTML).toContain("node-2");
+ expect(content.innerHTML).not.toContain("location");
+ });
+
+ test("Create default popup content with null location should not display coordinates", () => {
+ const node = {
+ id: "node-3",
+ name: "Test Node",
+ label: "Node Label",
+ location: null,
+ };
+ const content = graph.gui.createDefaultPopupContent(node);
+ expect(content).toBeInstanceOf(HTMLElement);
+ expect(content.innerHTML).toContain("node-3");
+ expect(content.innerHTML).not.toContain("location");
+ });
+
+ test("Create default popup content with NaN coordinates should not display coordinates", () => {
+ const node = {
+ id: "node-4",
+ name: "Test Node",
+ label: "Node Label",
+ location: {
+ lat: NaN,
+ lng: 98.7654321,
+ },
+ };
+ const content = graph.gui.createDefaultPopupContent(node);
+ expect(content).toBeInstanceOf(HTMLElement);
+ expect(content.innerHTML).toContain("node-4");
+ expect(content.innerHTML).not.toContain("location");
+ });
+
+ test("Create default popup content with Infinity coordinates should not display coordinates", () => {
+ const node = {
+ id: "node-5",
+ name: "Test Node",
+ label: "Node Label",
+ location: {
+ lat: Infinity,
+ lng: 98.7654321,
+ },
+ };
+ const content = graph.gui.createDefaultPopupContent(node);
+ expect(content).toBeInstanceOf(HTMLElement);
+ expect(content.innerHTML).toContain("node-5");
+ expect(content.innerHTML).not.toContain("location");
+ });
+
+ test("Create default popup content with string coordinates should convert and validate", () => {
+ const node = {
+ id: "node-6",
+ name: "Test Node",
+ label: "Node Label",
+ location: {
+ lat: "45.123456",
+ lng: "-87.654321",
+ },
+ };
+ const content = graph.gui.createDefaultPopupContent(node);
+ expect(content).toBeInstanceOf(HTMLElement);
+ expect(content.innerHTML).toContain("45.12345600");
+ expect(content.innerHTML).toContain("-87.65432100");
+ });
+
+ test("Create default popup content with properties.location fallback", () => {
+ const node = {
+ id: "node-7",
+ name: "Test Node",
+ label: "Node Label",
+ properties: {
+ location: {
+ lat: 10.5,
+ lng: 20.5,
+ },
+ },
+ };
+ const content = graph.gui.createDefaultPopupContent(node);
+ expect(content).toBeInstanceOf(HTMLElement);
+ expect(content.innerHTML).toContain("10.50000000");
+ expect(content.innerHTML).toContain("20.50000000");
+ });
+
+ test("Create default popup content with only finite lat should not display coordinates", () => {
+ const node = {
+ id: "node-8",
+ name: "Test Node",
+ label: "Node Label",
+ location: {
+ lat: 45.123,
+ lng: NaN,
+ },
+ };
+ const content = graph.gui.createDefaultPopupContent(node);
+ expect(content).toBeInstanceOf(HTMLElement);
+ expect(content.innerHTML).not.toContain("location");
+ });
+
+ test("Create default popup content with empty node", () => {
+ const node = {};
+ const content = graph.gui.createDefaultPopupContent(node);
+ expect(content).toBeInstanceOf(HTMLElement);
+ expect(content.classList.contains("default-popup")).toBe(true);
+ });
+
+ test("Create default popup content prefers properties.location over top-level location", () => {
+ // loadNodePopup positions the popup using node.properties.location ||
+ // node.location. createDefaultPopupContent must read coordinates in the
+ // same order, otherwise the rendered text would disagree with the popup
+ // position when both fields exist with different values.
+ const node = {
+ id: "node-mismatch",
+ location: {lat: 1, lng: 2},
+ properties: {location: {lat: 3, lng: 4}},
+ };
+ const content = graph.gui.createDefaultPopupContent(node);
+ expect(content.innerHTML).toContain("3.00000000");
+ expect(content.innerHTML).toContain("4.00000000");
+ expect(content.innerHTML).not.toContain("1.00000000");
+ expect(content.innerHTML).not.toContain("2.00000000");
+ });
+
+ test("Create default popup content keeps falsy-but-valid id (id:0)", () => {
+ // id:0 is a legal integer NetJSON node id and must not be filtered out.
+ const node = {
+ id: 0,
+ name: "Origin node",
+ label: "Origin",
+ };
+ const content = graph.gui.createDefaultPopupContent(node);
+ expect(content).toBeInstanceOf(HTMLElement);
+ const items = content.querySelectorAll(".njg-tooltip-item");
+ const idItem = Array.from(items).find(
+ (el) => el.querySelector(".njg-tooltip-key")?.textContent === "id",
+ );
+ expect(idItem).toBeDefined();
+ expect(idItem.querySelector(".njg-tooltip-value").textContent).toBe("0");
+ });
+});
+
+describe("Test GUI loadNodePopup with async and tooltip handling", () => {
+ let testGraph;
+ let container;
+ let originalLeaflet;
+ let mockPopup;
+
+ const mockLeafletPopup = (popupElement) => {
+ mockPopup = {
+ getElement: jest.fn(() => popupElement),
+ setLatLng: jest.fn(() => mockPopup),
+ setContent: jest.fn(() => mockPopup),
+ openOn: jest.fn(() => mockPopup),
+ handlers: {},
+ on: jest.fn((event, handler) => {
+ mockPopup.handlers[event] = handler;
+ return mockPopup;
+ }),
+ remove: jest.fn(),
+ };
+ window.L = {
+ CRS: {
+ EPSG3857: {},
+ },
+ popup: jest.fn(() => mockPopup),
+ };
+ global.L = window.L;
+ };
+
+ beforeEach(() => {
+ originalLeaflet = window.L;
+ mockLeafletPopup({
+ querySelector: jest.fn(),
+ });
+ container = document.createElement("div");
+ container.setAttribute("id", "test-popup-map");
+ document.body.appendChild(container);
+ testGraph = new NetJSONGraphCore({
+ nodes: [{id: "node-1", location: {lat: 10, lng: 20}}],
+ links: [],
+ });
+ testGraph.event = testGraph.utils.createEvent();
+ testGraph.gui = new NetJSONGraphGUI(testGraph);
+ testGraph.setConfig({
+ el: container,
+ mapOptions: {
+ nodePopup: {
+ show: true,
+ content: null,
+ config: {autoPan: true},
+ },
+ },
+ bookmarkableActions: {
+ enabled: true,
+ id: "id",
+ },
+ });
+ testGraph.setUtils();
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ if (container && document.body.contains(container)) {
+ document.body.removeChild(container);
+ }
+ window.L = originalLeaflet;
+ global.L = originalLeaflet;
+ testGraph = null;
+ });
+
+ test("loadNodePopup hides tooltip on popup open", async () => {
+ testGraph.echarts = {
+ setOption: jest.fn(),
+ };
+ testGraph.leaflet = {
+ currentPopup: null,
+ once: jest.fn(),
+ off: jest.fn(),
+ };
+ testGraph.utils.updateLabelVisibility = jest.fn();
+ testGraph.utils.setTooltipVisibility = jest.fn();
+ const node = {id: "node-1", location: {lat: 10, lng: 20}};
+ await testGraph.gui.loadNodePopup(node);
+ expect(testGraph.utils.setTooltipVisibility).toHaveBeenCalledWith(testGraph, false);
+ expect(testGraph.utils.updateLabelVisibility).toHaveBeenCalledWith(
+ testGraph,
+ false,
+ );
+ });
+
+ test("loadNodePopup handles async content error and cleans up URL fragment", async () => {
+ const asyncContentHandler = jest.fn(() =>
+ Promise.reject(new Error("Content load failed")),
+ );
+ testGraph.setConfig({
+ mapOptions: {
+ nodePopup: {
+ show: true,
+ content: asyncContentHandler,
+ config: {autoPan: true},
+ },
+ },
+ bookmarkableActions: {
+ enabled: true,
+ id: "id",
+ },
+ });
+ testGraph.echarts = {
+ setOption: jest.fn(),
+ };
+ testGraph.leaflet = {
+ currentPopup: null,
+ currentPopupRequest: null,
+ once: jest.fn(),
+ off: jest.fn(),
+ };
+ testGraph.utils.updateLabelVisibility = jest.fn();
+ testGraph.utils.removeUrlFragment = jest.fn();
+ const node = {id: "node-1", location: {lat: 10, lng: 20}};
+ await testGraph.gui.loadNodePopup(node);
+ expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith("id", "nodeId");
+ expect(testGraph.echarts.setOption).not.toHaveBeenCalled();
+ expect(testGraph.utils.updateLabelVisibility).not.toHaveBeenCalled();
+ });
+
+ test("loadNodePopup restores tooltip and labels when replacement content fails", async () => {
+ // Replacement path: a previous popup was open (tooltip already hidden),
+ // we null currentPopup and call previousPopup.remove() so its handler
+ // bails. If new content generation then fails the catch must restore
+ // the tooltip/labels — otherwise the map is stuck in popup-open visual
+ // state with no popup actually visible.
+ const asyncContentHandler = jest.fn(() =>
+ Promise.reject(new Error("Replacement content failed")),
+ );
+ testGraph.setConfig({
+ mapOptions: {
+ nodePopup: {
+ show: true,
+ content: asyncContentHandler,
+ config: {autoPan: true},
+ },
+ },
+ bookmarkableActions: {
+ enabled: true,
+ id: "id",
+ },
+ });
+ testGraph.echarts = {setOption: jest.fn()};
+ testGraph.leaflet = {
+ currentPopup: {remove: jest.fn()},
+ currentPopupRequest: null,
+ once: jest.fn(),
+ off: jest.fn(),
+ };
+ testGraph.utils.updateLabelVisibility = jest.fn();
+ testGraph.utils.setTooltipVisibility = jest.fn();
+ testGraph.utils.removeUrlFragment = jest.fn();
+ const node = {id: "node-1", location: {lat: 10, lng: 20}};
+ await testGraph.gui.loadNodePopup(node);
+ expect(testGraph.utils.setTooltipVisibility).toHaveBeenCalledWith(testGraph, true);
+ expect(testGraph.utils.updateLabelVisibility).toHaveBeenCalledWith(testGraph, true);
+ expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith("id", "nodeId");
+ });
+
+ test("loadNodePopup catches synchronous custom content errors", async () => {
+ const contentHandler = jest.fn(() => {
+ throw new Error("Content build failed");
+ });
+ testGraph.setConfig({
+ mapOptions: {
+ nodePopup: {
+ show: true,
+ content: contentHandler,
+ config: {autoPan: true},
+ },
+ },
+ });
+ testGraph.echarts = {
+ setOption: jest.fn(),
+ };
+ testGraph.leaflet = {
+ currentPopup: null,
+ currentPopupRequest: null,
+ once: jest.fn(),
+ off: jest.fn(),
+ };
+ testGraph.utils.updateLabelVisibility = jest.fn();
+ testGraph.utils.removeUrlFragment = jest.fn();
+ const node = {id: "node-1", location: {lat: 10, lng: 20}};
+ await testGraph.gui.loadNodePopup(node);
+ expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith("id", "nodeId");
+ expect(testGraph.echarts.setOption).not.toHaveBeenCalled();
+ });
+
+ test("loadNodePopup restores tooltip and removes matching fragment on popup remove", async () => {
+ testGraph.echarts = {
+ setOption: jest.fn(),
+ };
+ testGraph.leaflet = {
+ currentPopup: null,
+ once: jest.fn(),
+ off: jest.fn(),
+ };
+ testGraph.utils.updateLabelVisibility = jest.fn();
+ testGraph.utils.setTooltipVisibility = jest.fn();
+ testGraph.utils.parseUrlFragments = jest.fn(() => ({
+ id: new URLSearchParams("id=id&nodeId=node-1"),
+ }));
+ testGraph.utils.removeUrlFragment = jest.fn();
+ const node = {id: "node-1", location: {lat: 10, lng: 20}};
+ await testGraph.gui.loadNodePopup(node);
+ expect(mockPopup.on).toHaveBeenCalledWith("remove", expect.any(Function));
+ mockPopup.handlers.remove();
+ expect(testGraph.utils.setTooltipVisibility).toHaveBeenCalledWith(testGraph, true);
+ expect(testGraph.utils.updateLabelVisibility).toHaveBeenCalledWith(testGraph, true);
+ expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith("id", "nodeId");
+ });
+
+ test("loadNodePopup ignores stale async content without clearing newer URL fragment", async () => {
+ let resolveFirst;
+ const asyncContentHandler = jest
+ .fn()
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveFirst = resolve;
+ }),
+ )
+ .mockResolvedValueOnce("Second Popup
");
+ testGraph.setConfig({
+ mapOptions: {
+ nodePopup: {
+ show: true,
+ content: asyncContentHandler,
+ config: {autoPan: true},
+ },
+ },
+ });
+ testGraph.echarts = {
+ setOption: jest.fn(),
+ };
+ testGraph.leaflet = {
+ currentPopup: null,
+ currentPopupRequest: null,
+ once: jest.fn(),
+ off: jest.fn(),
+ };
+ testGraph.utils.updateLabelVisibility = jest.fn();
+ testGraph.utils.removeUrlFragment = jest.fn();
+ const node = {id: "node-1", location: {lat: 10, lng: 20}};
+ const firstRequest = testGraph.gui.loadNodePopup(node);
+ await testGraph.gui.loadNodePopup(node);
+ resolveFirst("First Popup
");
+ await firstRequest;
+ expect(testGraph.utils.removeUrlFragment).not.toHaveBeenCalled();
+ expect(mockPopup.setContent).toHaveBeenCalledWith("Second Popup
");
+ });
+
+ test("loadNodePopup closes the current popup before waiting for async content", async () => {
+ let resolveContent;
+ const asyncContentHandler = jest.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveContent = resolve;
+ }),
+ );
+ testGraph.setConfig({
+ mapOptions: {
+ nodePopup: {
+ show: true,
+ content: asyncContentHandler,
+ config: {autoPan: true},
+ },
+ },
+ });
+ const currentPopup = {remove: jest.fn()};
+ testGraph.echarts = {
+ setOption: jest.fn(),
+ };
+ testGraph.leaflet = {
+ currentPopup,
+ currentPopupRequest: null,
+ once: jest.fn(),
+ off: jest.fn(),
+ };
+ testGraph.utils.updateLabelVisibility = jest.fn();
+ const node = {id: "node-1", location: {lat: 10, lng: 20}};
+ const popupRequest = testGraph.gui.loadNodePopup(node);
+ expect(currentPopup.remove).toHaveBeenCalled();
+ resolveContent("New Popup
");
+ await popupRequest;
+ });
+
+ test("loadNodePopup handles null popup element gracefully", async () => {
+ testGraph.echarts = {
+ setOption: jest.fn(),
+ };
+ testGraph.leaflet = {
+ currentPopup: null,
+ once: jest.fn(),
+ off: jest.fn(),
+ };
+ testGraph.utils.updateLabelVisibility = jest.fn();
+ mockLeafletPopup(null);
+ const node = {id: "node-1", location: {lat: 10, lng: 20}};
+ await expect(testGraph.gui.loadNodePopup(node)).resolves.toBeUndefined();
+ });
+
+ test("loadNodePopup with async custom content handler that succeeds", async () => {
+ const customContent = "Custom Popup
";
+ const asyncContentHandler = jest.fn(() => Promise.resolve(customContent));
+ testGraph.setConfig({
+ mapOptions: {
+ nodePopup: {
+ show: true,
+ content: asyncContentHandler,
+ config: {autoPan: true},
+ },
+ },
+ });
+ testGraph.echarts = {
+ setOption: jest.fn(),
+ };
+ testGraph.leaflet = {
+ currentPopup: null,
+ currentPopupRequest: null,
+ once: jest.fn(),
+ off: jest.fn(),
+ };
+ testGraph.utils.updateLabelVisibility = jest.fn();
+ const node = {id: "node-1", location: {lat: 10, lng: 20}};
+ await testGraph.gui.loadNodePopup(node);
+ // Verify async content was resolved and popup was created with it.
+ // Callback receives the netjsongraph instance as `this` (project-wide
+ // callback convention) and the node as the positional argument.
+ expect(asyncContentHandler).toHaveBeenCalledWith(node);
+ expect(asyncContentHandler.mock.instances[0]).toBe(testGraph);
+ expect(mockPopup.setContent).toHaveBeenCalledWith(customContent);
+ });
+
+ test("loadNodePopup calls onOpen callback if provided", async () => {
+ const onOpenCallback = jest.fn();
+ testGraph.setConfig({
+ mapOptions: {
+ nodePopup: {
+ show: true,
+ content: null,
+ config: {autoPan: true},
+ onOpen: onOpenCallback,
+ },
+ },
+ });
+ testGraph.echarts = {
+ setOption: jest.fn(),
+ };
+ testGraph.leaflet = {
+ currentPopup: null,
+ once: jest.fn(),
+ off: jest.fn(),
+ };
+ testGraph.utils.updateLabelVisibility = jest.fn();
+ testGraph.utils.removeUrlFragment = jest.fn();
+ const node = {id: "node-1", location: {lat: 10, lng: 20}};
+ await testGraph.gui.loadNodePopup(node);
+ // Verify onOpen callback was called with the netjsongraph instance as
+ // `this` (project-wide callback convention) and no positional args.
+ expect(onOpenCallback).toHaveBeenCalledWith();
+ expect(onOpenCallback.mock.instances[0]).toBe(testGraph);
+ });
+
+ test("loadNodePopup closes the popup and clears URL when onOpen throws", async () => {
+ // The popup is already visible by the time onOpen runs (openOn happens
+ // before onOpen is invoked). If onOpen throws, both the URL fragment and
+ // the popup must be rolled back so the visible map state matches the URL.
+ const onOpenError = new Error("onOpen failed");
+ const onOpenCallback = jest.fn(() => {
+ throw onOpenError;
+ });
+ testGraph.setConfig({
+ mapOptions: {
+ nodePopup: {
+ show: true,
+ content: null,
+ config: {autoPan: true},
+ onOpen: onOpenCallback,
+ },
+ },
+ bookmarkableActions: {
+ enabled: true,
+ id: "id",
+ },
+ });
+ testGraph.echarts = {setOption: jest.fn()};
+ testGraph.leaflet = {
+ currentPopup: null,
+ once: jest.fn(),
+ off: jest.fn(),
+ };
+ testGraph.utils.updateLabelVisibility = jest.fn();
+ testGraph.utils.parseUrlFragments = jest.fn(() => ({
+ id: new URLSearchParams("id=id&nodeId=node-1"),
+ }));
+ testGraph.utils.removeUrlFragment = jest.fn();
+ // Match real Leaflet: .remove() fires the registered "remove" handler
+ // synchronously. This lets the assertions confirm both that the popup
+ // got removed AND that the handler then cleared the URL fragment.
+ mockPopup.remove = jest.fn(() => {
+ if (typeof mockPopup.handlers.remove === "function") {
+ mockPopup.handlers.remove();
+ }
+ });
+ const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
+ const node = {id: "node-1", location: {lat: 10, lng: 20}};
+ await testGraph.gui.loadNodePopup(node);
+ expect(mockPopup.remove).toHaveBeenCalled();
+ expect(testGraph.utils.removeUrlFragment).toHaveBeenCalledWith("id", "nodeId");
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ "Failed to run popup onOpen callback:",
+ onOpenError,
+ );
+ });
+
+ test("loadNodePopup preserves URL fragment when replacing popup for the still-current node", async () => {
+ // Reproduces the popstate-restoration regression: when the URL still
+ // points to the currently-open node and applyUrlFragmentState re-invokes
+ // loadNodePopup for that same node, removing the previous popup must
+ // not trigger the user-close cleanup that strips the URL fragment.
+ const popups = [];
+ const makePopup = () => {
+ const popup = {
+ setLatLng: jest.fn(() => popup),
+ setContent: jest.fn(() => popup),
+ openOn: jest.fn(() => popup),
+ handlers: {},
+ on: jest.fn((event, handler) => {
+ popup.handlers[event] = handler;
+ return popup;
+ }),
+ remove: jest.fn(() => {
+ // Match real Leaflet: firing remove() invokes the "remove" event
+ // listener synchronously.
+ if (typeof popup.handlers.remove === "function") {
+ popup.handlers.remove();
+ }
+ }),
+ };
+ popups.push(popup);
+ return popup;
+ };
+ window.L = {CRS: {EPSG3857: {}}, popup: jest.fn(makePopup)};
+ global.L = window.L;
+
+ testGraph.echarts = {setOption: jest.fn()};
+ testGraph.leaflet = {
+ currentPopup: null,
+ currentPopupRequest: null,
+ once: jest.fn(),
+ off: jest.fn(),
+ };
+ testGraph.utils.updateLabelVisibility = jest.fn();
+ testGraph.utils.parseUrlFragments = jest.fn(() => ({
+ id: new URLSearchParams("id=id&nodeId=node-1"),
+ }));
+ testGraph.utils.removeUrlFragment = jest.fn();
+
+ const node = {id: "node-1", location: {lat: 10, lng: 20}};
+ await testGraph.gui.loadNodePopup(node);
+ expect(popups).toHaveLength(1);
+
+ // Second invocation for the same node (popstate restoration path).
+ await testGraph.gui.loadNodePopup(node);
+ expect(popups).toHaveLength(2);
+ expect(popups[0].remove).toHaveBeenCalled();
+
+ expect(testGraph.utils.removeUrlFragment).not.toHaveBeenCalled();
+ });
+});
diff --git a/test/netjsongraph.render.test.js b/test/netjsongraph.render.test.js
index 1bbda4c4..d8088b90 100644
--- a/test/netjsongraph.render.test.js
+++ b/test/netjsongraph.render.test.js
@@ -1,6 +1,11 @@
import L from "leaflet";
import NetJSONGraphCore from "../src/js/netjsongraph.core";
import NetJSONGraphRender from "../src/js/netjsongraph.render";
+import NetJSONGraphGUI from "../src/js/netjsongraph.gui";
+import NetJSONGraphUtil from "../src/js/netjsongraph.util";
+
+const createUpdateLabelVisibilityMock = () =>
+ jest.fn((self, show) => new NetJSONGraphUtil().updateLabelVisibility(self, show));
const JSONFILE = "test";
const JSONData = {
@@ -512,6 +517,7 @@ describe("generateMapOption - node processing and dynamic styling", () => {
})),
parseUrlFragments: jest.fn(),
setupHashChangeHandler: jest.fn(),
+ updateLabelVisibility: createUpdateLabelVisibilityMock(),
},
};
});
@@ -997,6 +1003,7 @@ describe("Test disableClusteringAtLevel: 0", () => {
fastDeepCopy: jest.fn((obj) => JSON.parse(JSON.stringify(obj))),
setupHashChangeHandler: jest.fn(),
parseUrlFragments: jest.fn(),
+ updateLabelVisibility: createUpdateLabelVisibilityMock(),
},
event: {
emit: jest.fn(),
@@ -1097,6 +1104,7 @@ describe("Test leaflet zoomend handler and zoom control state", () => {
fastDeepCopy: jest.fn((obj) => JSON.parse(JSON.stringify(obj))),
parseUrlFragments: jest.fn(),
setupHashChangeHandler: jest.fn(),
+ updateLabelVisibility: createUpdateLabelVisibilityMock(),
},
event: {
emit: jest.fn(),
@@ -1242,6 +1250,7 @@ describe("mapRender – polygon overlay & moveend bbox logic", () => {
fastDeepCopy: jest.fn((obj) => JSON.parse(JSON.stringify(obj))),
parseUrlFragments: jest.fn(),
setupHashChangeHandler: jest.fn(),
+ updateLabelVisibility: createUpdateLabelVisibilityMock(),
},
event: {emit: jest.fn()},
};
@@ -1307,6 +1316,7 @@ describe("graph label visibility and fallbacks", () => {
fastDeepCopy: jest.fn((obj) => JSON.parse(JSON.stringify(obj))),
parseUrlFragments: jest.fn(),
setupHashChangeHandler: jest.fn(),
+ updateLabelVisibility: createUpdateLabelVisibilityMock(),
},
echarts: {
getOption: jest.fn(() => ({series: [{id: "network-graph", zoom: 1}]})),
@@ -1345,6 +1355,7 @@ describe("graph label visibility and fallbacks", () => {
fastDeepCopy: jest.fn((obj) => JSON.parse(JSON.stringify(obj))),
parseUrlFragments: jest.fn(),
setupHashChangeHandler: jest.fn(),
+ updateLabelVisibility: createUpdateLabelVisibilityMock(),
},
echarts: {
getOption: jest
@@ -1377,6 +1388,7 @@ describe("graph label visibility and fallbacks", () => {
parseUrlFragments: jest.fn(),
setupHashChangeHandler: jest.fn(),
_propagateGraphZoom: jest.fn(),
+ updateLabelVisibility: createUpdateLabelVisibilityMock(),
},
echarts: {
on: jest.fn((evt, cb) => {
@@ -1423,6 +1435,7 @@ describe("map series ids and name fallbacks", () => {
fastDeepCopy: jest.fn((obj) => JSON.parse(JSON.stringify(obj))),
parseUrlFragments: jest.fn(),
setupHashChangeHandler: jest.fn(),
+ updateLabelVisibility: createUpdateLabelVisibilityMock(),
},
};
@@ -1489,6 +1502,7 @@ describe("map series ids and name fallbacks", () => {
fastDeepCopy: jest.fn((obj) => JSON.parse(JSON.stringify(obj))),
parseUrlFragments: jest.fn(),
setupHashChangeHandler: jest.fn(),
+ updateLabelVisibility: createUpdateLabelVisibilityMock(),
},
event: {emit: jest.fn()},
};
@@ -1555,6 +1569,7 @@ describe("mapRender label and tooltip interaction (emphasis behavior)", () => {
})),
echartsSetOption: jest.fn((opt) => mockSelf.echarts.setOption(opt)), // Link to spy
setupHashChangeHandler: jest.fn(),
+ updateLabelVisibility: createUpdateLabelVisibilityMock(),
},
event: {emit: jest.fn()},
};
@@ -1763,6 +1778,7 @@ describe("mapRender clustering label visibility", () => {
nonClusterLinks: [],
})),
setupHashChangeHandler: jest.fn(),
+ updateLabelVisibility: createUpdateLabelVisibilityMock(),
},
event: {emit: jest.fn()},
};
@@ -1819,3 +1835,139 @@ describe("mapRender clustering label visibility", () => {
expect(series.emphasis.label.show).toBe(false);
});
});
+
+describe("Test nodePopup on node and link click", () => {
+ test("nodePopup config with loadNodePopup method exists", () => {
+ const nodePopupTestData = {
+ nodes: [
+ {
+ id: "node-1",
+ location: {lat: 10, lng: 20},
+ },
+ ],
+ links: [],
+ };
+ const container = document.createElement("div");
+ container.setAttribute("id", "map");
+ document.body.appendChild(container);
+ const popupGraph = new NetJSONGraphCore(nodePopupTestData);
+ popupGraph.event = popupGraph.utils.createEvent();
+ popupGraph.setConfig({
+ el: container,
+ mapOptions: {
+ nodePopup: {
+ show: true,
+ content: null,
+ config: {
+ autoPan: true,
+ },
+ },
+ },
+ onClickElement: jest.fn(),
+ });
+ popupGraph.setUtils();
+ popupGraph.gui = new NetJSONGraphGUI(popupGraph);
+ expect(popupGraph.config.mapOptions.nodePopup).toBeDefined();
+ expect(popupGraph.config.mapOptions.nodePopup.show).toBe(true);
+ expect(popupGraph.config.mapOptions.nodePopup.config.autoPan).toBe(true);
+ expect(popupGraph.gui).toBeDefined();
+ expect(popupGraph.gui.loadNodePopup).toBeInstanceOf(Function);
+ document.body.removeChild(container);
+ });
+
+ test("nodePopup disabled in config", () => {
+ const nodePopupTestData2 = {
+ nodes: [
+ {
+ id: "node-1",
+ location: {lat: 10, lng: 20},
+ },
+ ],
+ links: [],
+ };
+ const container = document.createElement("div");
+ container.setAttribute("id", "map-2");
+ document.body.appendChild(container);
+ const disabledPopupGraph = new NetJSONGraphCore(nodePopupTestData2);
+ disabledPopupGraph.event = disabledPopupGraph.utils.createEvent();
+ disabledPopupGraph.setConfig({
+ el: container,
+ mapOptions: {
+ nodePopup: {
+ show: false,
+ },
+ },
+ });
+ disabledPopupGraph.setUtils();
+ expect(disabledPopupGraph.config.mapOptions.nodePopup.show).toBe(false);
+ document.body.removeChild(container);
+ });
+
+ test("createDefaultPopupContent creates valid HTML", () => {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const defaultPopupGraph = new NetJSONGraphCore({
+ type: "NetworkGraph",
+ nodes: [],
+ links: [],
+ });
+ defaultPopupGraph.event = defaultPopupGraph.utils.createEvent();
+ defaultPopupGraph.gui = new NetJSONGraphGUI(defaultPopupGraph);
+ const node = {
+ id: "test-node",
+ name: "Test Node",
+ label: "Node Label",
+ location: {
+ lat: 45.123456,
+ lng: -87.654321,
+ },
+ };
+ const popupContent = defaultPopupGraph.gui.createDefaultPopupContent(node);
+ expect(popupContent).toBeInstanceOf(HTMLElement);
+ expect(popupContent.classList.contains("default-popup")).toBe(true);
+ expect(popupContent.innerHTML).toContain("test-node");
+ expect(popupContent.innerHTML).toContain("Test Node");
+ expect(popupContent.innerHTML).toContain("Node Label");
+ expect(popupContent.innerHTML).toContain("45.12345600");
+ expect(popupContent.innerHTML).toContain("-87.65432100");
+ document.body.removeChild(container);
+ });
+
+ test("nodePopup configuration includes custom popup content handler", () => {
+ const nodePopupTestData3 = {
+ nodes: [{id: "node-1", location: {lat: 10, lng: 20}}],
+ links: [],
+ };
+ const customContentHandler = jest.fn();
+ const onOpenHandler = jest.fn();
+ const container = document.createElement("div");
+ container.setAttribute("id", "map-3");
+ document.body.appendChild(container);
+ const customPopupGraph = new NetJSONGraphCore(nodePopupTestData3);
+ customPopupGraph.event = customPopupGraph.utils.createEvent();
+ customPopupGraph.setConfig({
+ el: container,
+ mapOptions: {
+ nodePopup: {
+ show: true,
+ content: customContentHandler,
+ config: {
+ autoPan: false,
+ offset: [10, 20],
+ },
+ onOpen: onOpenHandler,
+ },
+ },
+ });
+ customPopupGraph.setUtils();
+ expect(customPopupGraph.config.mapOptions.nodePopup.content).toBe(
+ customContentHandler,
+ );
+ expect(customPopupGraph.config.mapOptions.nodePopup.onOpen).toBe(onOpenHandler);
+ expect(customPopupGraph.config.mapOptions.nodePopup.config.autoPan).toBe(false);
+ expect(customPopupGraph.config.mapOptions.nodePopup.config.offset).toEqual([
+ 10, 20,
+ ]);
+ document.body.removeChild(container);
+ });
+});
diff --git a/test/netjsongraph.spec.js b/test/netjsongraph.spec.js
index 85a3e324..3bfe9c31 100644
--- a/test/netjsongraph.spec.js
+++ b/test/netjsongraph.spec.js
@@ -200,6 +200,14 @@ describe("NetJSONGraphCore Specification", () => {
},
],
},
+ nodePopup: {
+ show: false,
+ content: null,
+ config: {
+ autoPan: true,
+ autoPanPadding: [25, 25],
+ },
+ },
};
test("APIs exist", () => {
diff --git a/test/netjsongraph.util.test.js b/test/netjsongraph.util.test.js
index 4f6eca96..0b60c522 100644
--- a/test/netjsongraph.util.test.js
+++ b/test/netjsongraph.util.test.js
@@ -314,7 +314,10 @@ describe("Test URL fragment utilities", () => {
zoomOnRestore: true,
},
graphConfig: {series: {type: null}},
- mapOptions: {nodeConfig: {type: "scatter"}, center: [0, 0]},
+ mapOptions: {
+ nodeConfig: {type: "scatter"},
+ center: [0, 0],
+ },
onClickElement: mockOnClick,
},
nodeLinkIndex: {n1: node},
@@ -429,3 +432,456 @@ describe("Test move Node in Real Time", () => {
expect(updated.value).toEqual([newLocation.lng, newLocation.lat]);
});
});
+
+describe("Test applyUrlFragmentState with nodePopup", () => {
+ test("calls loadNodePopup when target is null and nodePopup.show is true", () => {
+ const util = new NetJSONGraphUtil();
+ const node = {
+ id: "node-1",
+ location: {lat: 10, lng: 20},
+ };
+ const params = new URLSearchParams();
+ params.set("id", "id");
+ params.set("nodeId", "node-1");
+ const fragments = {
+ id: params,
+ };
+ const mockSelf = {
+ config: {
+ bookmarkableActions: {
+ enabled: true,
+ id: "id",
+ zoomOnRestore: false,
+ },
+ mapOptions: {
+ nodePopup: {
+ show: true,
+ },
+ },
+ onClickElement: jest.fn(),
+ },
+ gui: {
+ loadNodePopup: jest.fn(),
+ },
+ utils: {
+ parseUrlFragments: jest.fn(() => fragments),
+ },
+ nodeLinkIndex: {
+ "node-1": node,
+ },
+ leaflet: {
+ setView: jest.fn(),
+ },
+ };
+ util.applyUrlFragmentState.call(util, mockSelf);
+ expect(mockSelf.gui.loadNodePopup).toHaveBeenCalledWith(node);
+ });
+
+ test("does not call loadNodePopup when target is not null (link case)", () => {
+ const util = new NetJSONGraphUtil();
+ const params = new URLSearchParams();
+ params.set("id", "id");
+ params.set("nodeId", "node-1~node-2");
+ const fragments = {
+ id: params,
+ };
+ const mockSelf = {
+ config: {
+ bookmarkableActions: {
+ enabled: true,
+ id: "id",
+ zoomOnRestore: false,
+ },
+ mapOptions: {
+ nodePopup: {
+ show: true,
+ },
+ },
+ onClickElement: jest.fn(),
+ },
+ gui: {
+ loadNodePopup: jest.fn(),
+ },
+ utils: {
+ parseUrlFragments: jest.fn(() => fragments),
+ },
+ nodeLinkIndex: {
+ "node-1~node-2": {id: "node-1~node-2", location: {lat: 12, lng: 22}},
+ },
+ leaflet: {
+ setView: jest.fn(),
+ },
+ };
+ util.applyUrlFragmentState.call(util, mockSelf);
+ expect(mockSelf.gui.loadNodePopup).not.toHaveBeenCalled();
+ });
+
+ test("does not call loadNodePopup when nodePopup.show is false", () => {
+ const util = new NetJSONGraphUtil();
+ const node = {
+ id: "node-3",
+ location: {lat: 20, lng: 30},
+ };
+ const params = new URLSearchParams();
+ params.set("id", "id");
+ params.set("nodeId", "node-3");
+
+ const fragments = {
+ id: params,
+ };
+ const mockSelf = {
+ config: {
+ bookmarkableActions: {
+ enabled: true,
+ id: "id",
+ zoomOnRestore: false,
+ },
+ mapOptions: {
+ nodePopup: {
+ show: false,
+ },
+ },
+ onClickElement: jest.fn(),
+ },
+ gui: {
+ loadNodePopup: jest.fn(),
+ },
+ utils: {
+ parseUrlFragments: jest.fn(() => fragments),
+ },
+ nodeLinkIndex: {
+ "node-3": node,
+ },
+ leaflet: {
+ setView: jest.fn(),
+ },
+ };
+ util.applyUrlFragmentState.call(util, mockSelf);
+ expect(mockSelf.gui.loadNodePopup).not.toHaveBeenCalled();
+ });
+
+ test("does not call loadNodePopup when mapOptions.nodePopup is not configured", () => {
+ const util = new NetJSONGraphUtil();
+ const node = {
+ id: "node-4",
+ location: {lat: 25, lng: 35},
+ };
+ const params = new URLSearchParams();
+ params.set("id", "id");
+ params.set("nodeId", "node-4");
+ const fragments = {
+ id: params,
+ };
+ const mockSelf = {
+ config: {
+ bookmarkableActions: {
+ enabled: true,
+ id: "id",
+ zoomOnRestore: false,
+ },
+ mapOptions: {},
+ onClickElement: jest.fn(),
+ },
+ gui: {
+ loadNodePopup: jest.fn(),
+ },
+ utils: {
+ parseUrlFragments: jest.fn(() => fragments),
+ },
+ nodeLinkIndex: {
+ "node-4": node,
+ },
+ leaflet: {
+ setView: jest.fn(),
+ },
+ };
+ util.applyUrlFragmentState.call(util, mockSelf);
+ expect(mockSelf.gui.loadNodePopup).not.toHaveBeenCalled();
+ });
+
+ test("closes the open popup when popstate navigates to a state without nodeId", () => {
+ // Regression for popstate-back to a no-nodeId state: the URL no longer
+ // references a selected node, so any popup that was opened by an earlier
+ // applyUrlFragmentState must be closed to keep the visible state in sync
+ // with the URL.
+ const util = new NetJSONGraphUtil();
+ const fragments = {};
+ const popupRemove = jest.fn();
+ const mockSelf = {
+ config: {
+ bookmarkableActions: {
+ enabled: true,
+ id: "id",
+ zoomOnRestore: false,
+ },
+ mapOptions: {
+ nodePopup: {
+ show: true,
+ },
+ },
+ onClickElement: jest.fn(),
+ },
+ gui: {
+ loadNodePopup: jest.fn(),
+ },
+ utils: {
+ parseUrlFragments: jest.fn(() => fragments),
+ },
+ nodeLinkIndex: {},
+ leaflet: {
+ setView: jest.fn(),
+ currentPopup: {remove: popupRemove},
+ },
+ };
+ util.applyUrlFragmentState.call(util, mockSelf);
+ expect(popupRemove).toHaveBeenCalled();
+ expect(mockSelf.gui.loadNodePopup).not.toHaveBeenCalled();
+ });
+
+ test("calls onClickElement for node clicks regardless of nodePopup setting", () => {
+ const util = new NetJSONGraphUtil();
+ const node = {
+ id: "node-5",
+ location: {lat: 30, lng: 40},
+ };
+ const params = new URLSearchParams();
+ params.set("id", "id");
+ params.set("nodeId", "node-5");
+ const fragments = {
+ id: params,
+ };
+ const mockSelf = {
+ config: {
+ bookmarkableActions: {
+ enabled: true,
+ id: "id",
+ zoomOnRestore: false,
+ },
+ mapOptions: {
+ nodePopup: {
+ show: true,
+ },
+ },
+ onClickElement: jest.fn(),
+ },
+ gui: {
+ loadNodePopup: jest.fn(),
+ },
+ utils: {
+ parseUrlFragments: jest.fn(() => fragments),
+ },
+ nodeLinkIndex: {
+ "node-5": node,
+ },
+ leaflet: {
+ setView: jest.fn(),
+ },
+ };
+ util.applyUrlFragmentState.call(util, mockSelf);
+ expect(mockSelf.config.onClickElement).toHaveBeenCalledWith("node", node);
+ });
+});
+
+describe("Test removeUrlFragment with paramName argument", () => {
+ test("removeUrlFragment deletes only the named param when paramName is provided", () => {
+ const util = new NetJSONGraphUtil();
+ const params = new URLSearchParams();
+ params.set("id", "id");
+ params.set("nodeId", "node-1");
+ params.set("other", "value");
+ util.parseUrlFragments = jest.fn(() => ({
+ id: params,
+ }));
+ util.updateUrlFragments = jest.fn();
+ util.removeUrlFragment.call(util, "id", "nodeId");
+ expect(util.updateUrlFragments).toHaveBeenCalledWith({id: params}, {id: "id"});
+ expect(params.has("nodeId")).toBe(false);
+ expect(params.get("other")).toBe("value");
+ });
+
+ test("removeUrlFragment deletes entire fragment when paramName is not provided", () => {
+ const util = new NetJSONGraphUtil();
+ util.parseUrlFragments = jest.fn(() => ({
+ id: new URLSearchParams("nodeId=node-1"),
+ }));
+ util.updateUrlFragments = jest.fn();
+ util.removeUrlFragment.call(util, "id");
+ expect(util.updateUrlFragments).toHaveBeenCalledWith({}, {id: "id"});
+ });
+
+ test("removeUrlFragment returns early when fragment does not exist", () => {
+ const util = new NetJSONGraphUtil();
+ util.parseUrlFragments = jest.fn(() => ({}));
+ util.updateUrlFragments = jest.fn();
+ util.removeUrlFragment.call(util, "nonexistent");
+ expect(util.updateUrlFragments).not.toHaveBeenCalled();
+ });
+
+ test("removeUrlFragment drops the whole fragment entry when only the action id remains", () => {
+ // If removing the named param leaves nothing but the bare `id` key, the
+ // fragment becomes a useless stub like "#id=geoMap" — drop it entirely.
+ const util = new NetJSONGraphUtil();
+ const params = new URLSearchParams();
+ params.set("id", "geoMap");
+ params.set("nodeId", "node-1");
+ util.parseUrlFragments = jest.fn(() => ({
+ geoMap: params,
+ }));
+ util.updateUrlFragments = jest.fn();
+ util.removeUrlFragment.call(util, "geoMap", "nodeId");
+ // After deletion, only `id` would remain → entire entry should be gone.
+ expect(util.updateUrlFragments).toHaveBeenCalledWith({}, {id: "geoMap"});
+ });
+});
+
+describe("Test updateLabelVisibility utility method", () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ test("updateLabelVisibility hides labels when show is false", () => {
+ const util = new NetJSONGraphUtil();
+ const mockSelf = {
+ echarts: {
+ setOption: jest.fn(),
+ },
+ config: {
+ showMapLabelsAtZoom: 3,
+ },
+ leaflet: {
+ getZoom: jest.fn(() => 5),
+ },
+ };
+ util.updateLabelVisibility.call(util, mockSelf, false);
+ expect(mockSelf.echarts.setOption).toHaveBeenCalledWith({
+ series: [
+ {
+ id: "geo-map",
+ label: {
+ show: false,
+ silent: true,
+ },
+ emphasis: {
+ label: {
+ show: false,
+ },
+ },
+ },
+ ],
+ });
+ });
+
+ test("updateLabelVisibility shows labels when show is true and zoom >= threshold", () => {
+ const util = new NetJSONGraphUtil();
+ const mockSelf = {
+ echarts: {
+ setOption: jest.fn(),
+ },
+ config: {
+ showMapLabelsAtZoom: 3,
+ },
+ leaflet: {
+ getZoom: jest.fn(() => 5),
+ },
+ };
+ util.updateLabelVisibility.call(util, mockSelf, true);
+ expect(mockSelf.echarts.setOption).toHaveBeenCalledWith({
+ series: [
+ {
+ id: "geo-map",
+ label: {
+ show: true,
+ silent: true,
+ },
+ emphasis: {
+ label: {
+ show: false,
+ },
+ },
+ },
+ ],
+ });
+ });
+
+ test("updateLabelVisibility hides labels when zoom < threshold even if show is true", () => {
+ const util = new NetJSONGraphUtil();
+ const mockSelf = {
+ echarts: {
+ setOption: jest.fn(),
+ },
+ config: {
+ showMapLabelsAtZoom: 10,
+ },
+ leaflet: {
+ getZoom: jest.fn(() => 5),
+ },
+ };
+ util.updateLabelVisibility.call(util, mockSelf, true);
+ expect(mockSelf.echarts.setOption).toHaveBeenCalledWith({
+ series: [
+ {
+ id: "geo-map",
+ label: {
+ show: false,
+ silent: true,
+ },
+ emphasis: {
+ label: {
+ show: false,
+ },
+ },
+ },
+ ],
+ });
+ });
+
+ test("updateLabelVisibility always hides labels when showMapLabelsAtZoom is false", () => {
+ const util = new NetJSONGraphUtil();
+ const mockSelf = {
+ echarts: {
+ setOption: jest.fn(),
+ },
+ config: {
+ showMapLabelsAtZoom: false,
+ },
+ leaflet: {
+ getZoom: jest.fn(() => 10),
+ },
+ };
+ util.updateLabelVisibility.call(util, mockSelf, true);
+ expect(mockSelf.echarts.setOption).toHaveBeenCalledWith({
+ series: [
+ {
+ id: "geo-map",
+ label: {
+ show: false,
+ silent: true,
+ },
+ emphasis: {
+ label: {
+ show: false,
+ },
+ },
+ },
+ ],
+ });
+ });
+
+ test("updateLabelVisibility returns early when echarts is not ready", () => {
+ const util = new NetJSONGraphUtil();
+ const mockSelf = {
+ echarts: null,
+ config: {
+ showMapLabelsAtZoom: 3,
+ },
+ leaflet: {
+ getZoom: jest.fn(() => 5),
+ },
+ };
+ const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
+ util.updateLabelVisibility.call(util, mockSelf, true);
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ "updateLabelVisibility: ECharts instance not ready",
+ );
+ });
+});