diff --git a/.eslintrc.js b/.eslintrc.js index 489a715b..b0c80676 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,9 @@ module.exports = { extends: ["airbnb", "prettier"], + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + }, rules: { "no-param-reassign": "off", "class-methods-use-this": "off", diff --git a/public/example_templates/netjsonmap-indoormap-overlay.html b/public/example_templates/netjsonmap-indoormap-overlay.html index d65ffe8c..3961d47e 100644 --- a/public/example_templates/netjsonmap-indoormap-overlay.html +++ b/public/example_templates/netjsonmap-indoormap-overlay.html @@ -73,6 +73,31 @@ #indoormap-container .njg-container .hidden .sideBarHandle { left: 35px; } + .njg-container .njg-sideBar { + display: none; + } + .njg-container .leaflet-popup-tip-container { + bottom: -5%; + } + .njg-container .default-popup .njg-popup-button-container { + display: flex; + justify-content: 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); + } + .njg-container .leaflet-popup-tip { + box-shadow: none; + } @@ -95,6 +120,15 @@ }, }, baseOptions: {media: [{option: {tooltip: {show: true}}}]}, + nodePopup: { + show: true, + content: createCustomPopup, + config: { + autoPan: true, + autoPanPadding: [25, 25], + offset: [-8, -8], + }, + }, }, bookmarkableActions: { enabled: true, @@ -113,11 +147,7 @@ }); return data; }, - onClickElement(type, data) { - if (type === "node") { - openIndoorMap(); - } - }, + onClickElement(type, data) {}, }); netjsonmap.setUtils({ // Added to open popup for a specific location Id in selenium tests @@ -154,6 +184,67 @@ // needed for selenium tests window._geoMap = netjsonmap; + function getIndoorMapFragment() { + let decodedHash = ""; + try { + decodedHash = decodeURIComponent( + window.location.hash.replace(/^#/, ""), + ); + } catch (err) { + return null; + } + const fragments = decodedHash.split(";").filter(Boolean); + + return fragments.find((fragment) => { + const params = new URLSearchParams(fragment); + return params.get("id") === "indoorMap"; + }); + } + if (getIndoorMapFragment()) { + openIndoorMap(); + } + + function createCustomPopup(node) { + const popupContent = document.createElement("div"); + popupContent.classList.add("default-popup"); + const location = node?.location || node?.properties?.location; + const fields = { + name: node?.name, + id: node?.id, + label: node?.label, + location: location + ? `${location.lat.toFixed(8)}, ${location.lng.toFixed(8)}` + : null, + }; + Object.keys(fields).forEach((key) => { + const value = fields[key]; + if (!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); + }); + const buttonContainer = document.createElement("div"); + buttonContainer.classList.add("njg-popup-button-container"); + + const button = document.createElement("button"); + button.classList.add("njg-popup-button"); + button.innerHTML = "Open Floorplan"; + buttonContainer.appendChild(button); + popupContent.appendChild(buttonContainer); + button.addEventListener("click", () => openIndoorMap()); + return popupContent; + } + function createIndoorMapContainer() { const container = document.createElement("div"); container.id = "indoormap-container"; @@ -180,10 +271,13 @@ onClose(); } container.remove(); + window._indoorMap = null; }); } - function openIndoorMap() { + if (window._indoorMap) { + return window._indoorMap; + } let indoorMapContainer = document.getElementById("indoormap-container"); if (!indoorMapContainer) { indoorMapContainer = createIndoorMapContainer(); @@ -211,6 +305,15 @@ animation: false, }, baseOptions: {media: [{option: {tooltip: {show: true}}}]}, + nodePopup: { + show: true, + content: null, + config: { + autoPan: true, + autoPanPadding: [25, 25], + offset: [-8, -8], + }, + }, }, bookmarkableActions: { enabled: true, @@ -254,12 +357,14 @@ const mapOptions = this.echarts.getOption(); // Refer netjsonmap-indoormap.html for full explanation of this workaround - mapOptions.series[0].data.forEach((data) => { + mapOptions.series[0].data.forEach((data, index) => { const node = data.node; const px = Number(node.location.lng); const py = -Number(node.location.lat); const nodeProjected = L.point(topLeft.x + px, topLeft.y + py); const nodeLatLng = map.unproject(nodeProjected, zoom); + this.data.nodes[index].location = nodeLatLng; + this.data.nodes[index].properties.location = nodeLatLng; node.location = nodeLatLng; node.properties.location = nodeLatLng; data.value = [nodeLatLng.lng, nodeLatLng.lat]; @@ -302,6 +407,7 @@ map.setMaxBounds(bnds); map.invalidateSize(); }, + onClickElement: function () {}, }, ); indoor.setUtils({ @@ -337,9 +443,16 @@ const popstateHandler = () => { const fragments = indoor.utils.parseUrlFragments(); const id = indoor.config.bookmarkableActions.id; - if (!fragments[id]) { - indoorMapContainer.remove(); - window.removeEventListener("popstate", popstateHandler); + if (fragments[id]) { + if (!document.getElementById("indoormap-container")) { + openIndoorMap(); + } + } else { + const container = document.getElementById("indoormap-container"); + if (container) { + container.remove(); + window._indoorMap = null; + } } }; window.addEventListener("popstate", popstateHandler); diff --git a/src/css/netjsongraph.css b/src/css/netjsongraph.css index 951d9ec3..289cd199 100755 --- a/src/css/netjsongraph.css +++ b/src/css/netjsongraph.css @@ -362,6 +362,46 @@ font-size: 18px; } +.njg-container .default-popup { + padding: 18px; +} + +.njg-container .default-popup a.leaflet-popup-close-button { + position: absolute; + top: 4px; + right: 4px; + font-size: 18px !important; +} + +.njg-container .default-popup a.leaflet-popup-close-button:hover { + color: black !important; +} + +.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..52732c11 100644 --- a/src/js/netjsongraph.config.js +++ b/src/js/netjsongraph.config.js @@ -245,6 +245,15 @@ const NetJSONGraphDefaultConfig = { }, ], }, + nodePopup: { + show: false, + content: null, + config: { + autoPan: true, + autoPanPadding: [25, 25], + offset: null, + }, + }, }, mapTileConfig: [ { diff --git a/src/js/netjsongraph.gui.js b/src/js/netjsongraph.gui.js index cec8ff2e..440b4ff7 100644 --- a/src/js/netjsongraph.gui.js +++ b/src/js/netjsongraph.gui.js @@ -278,6 +278,116 @@ class NetJSONGraphGUI { }; } + /** + * Load and display a popup for a node on the map using leaflet popup + * @param {Object} node - The node data containing location and properties + * @returns {void} + */ + async loadNodePopup(node) { + if (!this.self.leaflet) { + console.error("Leaflet map not available. Cannot load popup."); + return; + } + if (this.self.echarts) { + this.self.echarts.dispatchAction({type: "hideTip"}); + } + const nodeLocation = node?.properties?.location || node?.location; + if (!nodeLocation) { + console.error("Node location not available. Cannot load popup."); + return; + } + let popupContent = this.self.config.mapOptions.nodePopup.content; + if (popupContent == null) { + popupContent = this.createDefaultPopupContent(node); + } else if (popupContent && typeof popupContent === "function") { + const popupRequest = popupContent.call(this, node, this.self); + this.self.leaflet.currentPopupRequest = popupRequest; + try { + popupContent = await popupRequest; + } catch (error) { + if (this.self.leaflet.currentPopupRequest !== popupRequest) { + return; + } + console.error("Failed to build node popup content:", error); + return; + } + if (this.self.leaflet.currentPopupRequest !== popupRequest) { + return; + } + } + const popupConfigInput = this.self.config.mapOptions.nodePopup.config || {}; + const popupConfig = Object.fromEntries( + Object.entries(popupConfigInput).filter(([, value]) => value != null), + ); + + const popup = window.L.popup({ + closeOnClick: false, + ...popupConfig, + }) + .setLatLng(nodeLocation) + .setContent(popupContent) + .openOn(this.self.leaflet); + + this.self.leaflet.currentPopup = popup; + const {onOpen} = this.self.config.mapOptions.nodePopup; + if (onOpen && typeof onOpen === "function") { + try { + onOpen.call(this, this.self); + } catch (error) { + console.error("Failed to run popup onOpen callback:", error); + } + } + const popupElement = popup + .getElement() + .querySelector(".leaflet-popup-close-button"); + popupElement.addEventListener("click", () => { + if (!this.self.config.bookmarkableActions?.enabled) { + return; + } + const fragments = this.self.utils.parseUrlFragments(); + const {id} = this.self.config.bookmarkableActions; + const currentNodeId = fragments[id]?.get("nodeId"); + const popupNodeId = node?.id || node?.properties?.id; + if (currentNodeId === popupNodeId) { + fragments[id].delete("nodeId"); + this.self.utils.updateUrlFragments(fragments); + } + }); + } + + createDefaultPopupContent(node) { + const popupContent = document.createElement("div"); + popupContent.classList.add("default-popup"); + const location = node?.location || node?.properties?.location; + 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]; + if (!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..c9f6cefe 100644 --- a/src/js/netjsongraph.render.js +++ b/src/js/netjsongraph.render.js @@ -108,9 +108,16 @@ class NetJSONGraphRender { params.data, ); } - return params.componentSubType === "lines" - ? clickElement("link", params.data.link) - : !params.data.cluster && clickElement("node", params.data.node); + if (params.componentSubType === "lines") { + return clickElement("link", params.data.link); + } + if (!params.data.cluster) { + if (configs.mapOptions.nodePopup.show) { + self.gui.loadNodePopup(params.data.node); + } + return clickElement("node", params.data.node); + } + return null; }, {passive: true}, ); diff --git a/src/js/netjsongraph.util.js b/src/js/netjsongraph.util.js index af585f39..153bcbb8 100644 --- a/src/js/netjsongraph.util.js +++ b/src/js/netjsongraph.util.js @@ -1381,6 +1381,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); } diff --git a/test/netjsongraph.browser.test.js b/test/netjsongraph.browser.test.js index ac226382..e1d01c7d 100644 --- a/test/netjsongraph.browser.test.js +++ b/test/netjsongraph.browser.test.js @@ -169,8 +169,7 @@ describe("Chart Rendering Test", () => { "//span[@class='njg-valueLabel' and text()='10.149.3.3']", 2000, ); - const nodeId = await node.getText(); - + const nodeId = await node.getAttribute("textContent"); printConsoleErrors(consoleErrors); expect(consoleErrors.length).toBe(0); expect(canvas).not.toBeNull(); @@ -195,8 +194,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 +215,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 +241,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 +268,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 +302,19 @@ 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("bookmarkableActions: test forward/backward actions", async () => { @@ -314,12 +324,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 +350,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 +364,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,9 +378,13 @@ 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); @@ -424,7 +462,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..d54e51c3 100644 --- a/test/netjsongraph.dom.test.js +++ b/test/netjsongraph.dom.test.js @@ -442,3 +442,144 @@ 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); + }); +}); diff --git a/test/netjsongraph.render.test.js b/test/netjsongraph.render.test.js index 1bbda4c4..73ff570f 100644 --- a/test/netjsongraph.render.test.js +++ b/test/netjsongraph.render.test.js @@ -1819,3 +1819,136 @@ 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 data = { + nodes: [ + { + id: "node-1", + location: {lat: 10, lng: 20}, + }, + ], + links: [], + }; + const container = document.createElement("div"); + container.setAttribute("id", "map"); + document.body.appendChild(container); + const graph = new NetJSONGraphCore(data); + graph.event = graph.utils.createEvent(); + graph.setConfig({ + el: container, + mapOptions: { + nodePopup: { + show: true, + content: null, + config: { + autoPan: true, + }, + }, + }, + onClickElement: jest.fn(), + }); + graph.setUtils(); + expect(graph.config.mapOptions.nodePopup).toBeDefined(); + expect(graph.config.mapOptions.nodePopup.show).toBe(true); + expect(graph.config.mapOptions.nodePopup.config.autoPan).toBe(true); + if (graph.gui) { + expect(graph.gui.loadNodePopup).toBeInstanceOf(Function); + } + document.body.removeChild(container); + }); + + test("nodePopup disabled in config", () => { + const data = { + 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 graph = new NetJSONGraphCore(data); + graph.event = graph.utils.createEvent(); + graph.setConfig({ + el: container, + mapOptions: { + nodePopup: { + show: false, + }, + }, + }); + graph.setUtils(); + expect(graph.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 graph = new NetJSONGraphCore({ + type: "NetworkGraph", + nodes: [], + links: [], + }); + graph.event = graph.utils.createEvent(); + graph.gui = new (require("../src/js/netjsongraph.gui").default)(graph); + const node = { + id: "test-node", + name: "Test Node", + label: "Node Label", + location: { + lat: 45.123456, + lng: -87.654321, + }, + }; + const popupContent = graph.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 data = { + 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 graph = new NetJSONGraphCore(data); + graph.event = graph.utils.createEvent(); + graph.setConfig({ + el: container, + mapOptions: { + nodePopup: { + show: true, + content: customContentHandler, + config: { + autoPan: false, + offset: [10, 20], + }, + onOpen: onOpenHandler, + }, + }, + }); + graph.setUtils(); + expect(graph.config.mapOptions.nodePopup.content).toBe(customContentHandler); + expect(graph.config.mapOptions.nodePopup.onOpen).toBe(onOpenHandler); + expect(graph.config.mapOptions.nodePopup.config.autoPan).toBe(false); + expect(graph.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..2e738819 100644 --- a/test/netjsongraph.spec.js +++ b/test/netjsongraph.spec.js @@ -200,6 +200,15 @@ describe("NetJSONGraphCore Specification", () => { }, ], }, + nodePopup: { + show: false, + content: null, + config: { + autoPan: true, + autoPanPadding: [25, 25], + offset: null, + }, + }, }; test("APIs exist", () => { diff --git a/test/netjsongraph.util.test.js b/test/netjsongraph.util.test.js index 4f6eca96..7c89e93d 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,213 @@ 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("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); + }); +});