diff --git a/README.md b/README.md index 38b44102..02a2515e 100644 --- a/README.md +++ b/README.md @@ -366,6 +366,70 @@ For map, you need to configure `mapOptions`. The [`mapOptions`](https://leafletj You can also customize some global properties with [`echartsOption`](https://echarts.apache.org/en/option.html) in echarts. +## Cluster Utilities + +The library includes utilities for handling cluster visualization and preventing overlap in map views. These utilities are available in the `lib/js/clusterUtils.js` module. + +### Functions + +#### `preventClusterOverlap()` + +Identifies clusters at the same location and arranges them in a circular pattern to prevent overlap. This is particularly useful when you have multiple clusters at the same geographic location, such as nodes with different statuses at the same coordinates. + +```javascript +import {preventClusterOverlap} from "../../lib/js/clusterUtils.js"; + +// Call the function to arrange overlapping clusters +preventClusterOverlap(); +``` + +#### `setupClusterOverlapPrevention(leafletMap)` + +Sets up event listeners for cluster overlap prevention. This function automatically applies the `preventClusterOverlap()` function when map events occur that might cause clusters to overlap. + +```javascript +import {setupClusterOverlapPrevention} from "../../lib/js/clusterUtils.js"; + +// Get the Leaflet map instance +const leafletMap = map.map; + +// Set up overlap prevention +setupClusterOverlapPrevention(leafletMap); +``` + +### Usage Example + +To use the cluster utilities in your project: + +```javascript +// Import the cluster utilities +import { + preventClusterOverlap, + setupClusterOverlapPrevention, +} from "../../lib/js/clusterUtils.js"; + +// Initialize your map +const map = new NetJSONGraph(data, { + render: "map", + clustering: true, + clusteringThreshold: 1, + clusterRadius: 40, + clusteringAttribute: "status", // Cluster by status +}); + +map.render(); + +// Get the Leaflet map instance +const leafletMap = map.map; + +// Set up cluster overlap prevention when the map is loaded +window.addEventListener("load", () => { + setupClusterOverlapPrevention(leafletMap); +}); +``` + +See the [Cluster Overlap Example](https://openwisp.github.io/netjsongraph.js/examples/netjson-cluster-overlap.html) for a complete demonstration. + ### API Introduction #### Core @@ -789,7 +853,7 @@ Using array files to append data step by step at start. Similiar to the first method, but easier. [ Append data using arrays demo](https://openwisp.github.io/netjsongraph.js/examples/netjsonmap-appendData2.html) -The demo shows the clustering of nodes. +The demo shows how to handle overlapping clusters with different statuses. [ Clustering demo](https://openwisp.github.io/netjsongraph.js/examples/netjson-clustering.html) ### Upgrading from 0.1.x versions to 0.2.x @@ -855,3 +919,39 @@ Refer to the [Arguments section](#arguments) section for more details. ### License [BSD 3-Clause License](https://github.com/interop-dev/netjsongraph.js/blob/master/LICENSE). + +#### Cluster Overlap Prevention + +To prevent visual clutter when multiple clusters occupy the same geographic coordinates, NetJSONGraph.js can automatically arrange them in a circular layout. This is particularly useful when you have nodes with different statuses in the same location. + +You can enable this feature in two ways: + +1. Using the `clusterOverlapPrevention` option: + +```javascript +const graph = new NetJSONGraph("./data/your_data.json", { + render: "map", + clustering: true, + clusterOverlapPrevention: true, + // ... other options +}); +``` + +2. Or manually using the utility functions: + +```javascript +import {setupClusterOverlapPrevention} from "../../lib/js/clusterUtils.js"; + +// Initialize your map +const map = new NetJSONGraph(data, { + render: "map", + clustering: true, + // ... other options +}); +map.render(); + +// Set up cluster overlap prevention +setupClusterOverlapPrevention(map.map); +``` + +See the [Cluster Overlap Example](./examples/netjson-clustering.html) for a complete demonstration. diff --git a/public/example_templates/netjson-clustering.html b/public/example_templates/netjson-clustering.html index bb86f258..dd985fb8 100644 --- a/public/example_templates/netjson-clustering.html +++ b/public/example_templates/netjson-clustering.html @@ -1,7 +1,7 @@ - + - netjsongraph.js: basic example + NetJSON Cluster Overlap Example - + diff --git a/src/js/cluster-utils.js b/src/js/cluster-utils.js new file mode 100644 index 00000000..63abb913 --- /dev/null +++ b/src/js/cluster-utils.js @@ -0,0 +1,69 @@ +/** + * Cluster utilities for NetJSONGraph + * Functions to handle cluster overlapping and arrangement + */ + +/** + * Function to prevent cluster overlap + * Identifies clusters at the same location and arranges them in a circular pattern + */ +export function preventClusterOverlap() { + const clusterMarkers = document.querySelectorAll(".marker-cluster"); + + if (clusterMarkers.length === 0) { + return; + } + + const positions = {}; + + clusterMarkers.forEach((marker) => { + const rect = marker.getBoundingClientRect(); + const key = `${Math.round(rect.left)}-${Math.round(rect.top)}`; + + if (!positions[key]) { + positions[key] = []; + } + positions[key].push(marker); + }); + + // Arrange overlapping markers in a circle + Object.values(positions).forEach((markers) => { + if (markers.length > 1) { + const radius = 30; // Distance from center + const angleStep = (2 * Math.PI) / markers.length; + + markers.forEach((marker, i) => { + if (i > 0) { + // Skip the first marker (keep it at center) + const angle = angleStep * i; + const offsetX = radius * Math.cos(angle); + const offsetY = radius * Math.sin(angle); + + marker.style.transform = `translate(${offsetX}px, ${offsetY}px)`; + marker.style.zIndex = 1000 + i; // Ensure visibility + } + }); + } + }); +} + +/** + * Sets up event listeners for cluster overlap prevention + * @param {Object} leafletMap - The Leaflet map instance + */ +export function setupClusterOverlapPrevention(leafletMap) { + // Apply immediately + preventClusterOverlap(); + + if (leafletMap) { + leafletMap.on("zoomend", preventClusterOverlap); + leafletMap.on("moveend", preventClusterOverlap); + leafletMap.on("layeradd", preventClusterOverlap); + + window.addEventListener("resize", preventClusterOverlap); + } else { + console.warn( + "[NetJSONGraph] setupClusterOverlapPrevention: Leaflet map instance is required for cluster overlap prevention.", + ); + } +} diff --git a/src/js/netjsongraph.config.js b/src/js/netjsongraph.config.js index dbde0ce6..0f99bdfb 100644 --- a/src/js/netjsongraph.config.js +++ b/src/js/netjsongraph.config.js @@ -252,7 +252,26 @@ const NetJSONGraphDefaultConfig = { radius: 8, }, }, - nodeCategories: [], + nodeCategories: [ + { + name: "ok", + nodeStyle: { + color: "#28a745", + }, + }, + { + name: "problem", + nodeStyle: { + color: "#ffc107", + }, + }, + { + name: "critical", + nodeStyle: { + color: "#dc3545", + }, + }, + ], linkCategories: [], /** @@ -265,8 +284,26 @@ const NetJSONGraphDefaultConfig = { * @this {object} The instantiated object of NetJSONGraph * */ - // eslint-disable-next-line no-unused-vars - prepareData(JSONData) {}, + prepareData(JSONData) { + if (JSONData && JSONData.nodes) { + JSONData.nodes.forEach((node) => { + if (node.properties && node.properties.status) { + const status = node.properties.status.toLowerCase(); + if ( + status === "ok" || + status === "problem" || + status === "critical" + ) { + node.category = status; + } else { + node.category = "unknown"; + } + } else { + node.category = "unknown"; + } + }); + } + }, /** * @function @@ -283,16 +320,16 @@ const NetJSONGraphDefaultConfig = { let nodeLinkData; if (this.type === "netjson") { if (type === "node") { - nodeLinkData = this.utils.nodeInfo(data); + ({nodeLinkData} = {nodeLinkData: this.utils.nodeInfo(data)}); } else { - nodeLinkData = this.utils.linkInfo(data); + ({nodeLinkData} = {nodeLinkData: this.utils.linkInfo(data)}); } if (this.config.showMetaOnNarrowScreens || this.el.clientWidth > 850) { this.gui.metaInfoContainer.style.display = "flex"; } } else { - nodeLinkData = data; + ({nodeLinkData} = {nodeLinkData: data}); } this.gui.getNodeLinkInfo(type, nodeLinkData); @@ -311,4 +348,5 @@ const NetJSONGraphDefaultConfig = { onReady() {}, }; +export const {prepareData} = NetJSONGraphDefaultConfig; export default {...NetJSONGraphDefaultConfig}; diff --git a/src/js/netjsongraph.render.js b/src/js/netjsongraph.render.js index 74bf8511..8efd9ba1 100644 --- a/src/js/netjsongraph.render.js +++ b/src/js/netjsongraph.render.js @@ -221,8 +221,8 @@ class NetJSONGraphRender { nodesData.push({ name: typeof node.label === "string" ? node.label : node.id, value: [location.lng, location.lat], - symbolSize: nodeSizeConfig, itemStyle: nodeStyleConfig, + symbolSize: nodeSizeConfig, emphasis: { itemStyle: nodeEmphasisConfig.nodeStyle, symbolSize: nodeEmphasisConfig.nodeSize, @@ -267,15 +267,74 @@ class NetJSONGraphRender { nodesData = nodesData.concat(clusters); const series = [ - Object.assign(configs.mapOptions.nodeConfig, { + { type: configs.mapOptions.nodeConfig.type === "effectScatter" ? "effectScatter" : "scatter", + name: "nodes", coordinateSystem: "leaflet", data: nodesData, animationDuration: 1000, - }), + label: configs.mapOptions.nodeConfig.label, + itemStyle: { + color: (params) => { + if ( + params.data && + params.data.cluster && + params.data.itemStyle && + params.data.itemStyle.color + ) { + return params.data.itemStyle.color; + } + if (params.data && params.data.node && params.data.node.category) { + const category = configs.nodeCategories.find( + (cat) => cat.name === params.data.node.category, + ); + const nodeColor = + (category && category.nodeStyle && category.nodeStyle.color) || + (configs.mapOptions.nodeConfig && + configs.mapOptions.nodeConfig.nodeStyle && + configs.mapOptions.nodeConfig.nodeStyle.color) || + "#6c757d"; + return nodeColor; + } + const defaultColor = + (configs.mapOptions.nodeConfig && + configs.mapOptions.nodeConfig.nodeStyle && + configs.mapOptions.nodeConfig.nodeStyle.color) || + "#6c757d"; + return defaultColor; + }, + }, + symbolSize: (value, params) => { + if (params.data && params.data.cluster) { + return ( + (configs.mapOptions.clusterConfig && + configs.mapOptions.clusterConfig.symbolSize) || + 30 + ); + } + if (params.data && params.data.node) { + const {nodeSizeConfig} = self.utils.getNodeStyle( + params.data.node, + configs, + "map", + ); + return typeof nodeSizeConfig === "object" + ? (configs.mapOptions.nodeConfig && + configs.mapOptions.nodeConfig.nodeSize) || + 17 + : nodeSizeConfig; + } + return ( + (configs.mapOptions.nodeConfig && + configs.mapOptions.nodeConfig.nodeSize) || + 17 + ); + }, + emphasis: configs.mapOptions.nodeConfig.emphasis, + }, Object.assign(configs.mapOptions.linkConfig, { type: "lines", coordinateSystem: "leaflet", @@ -332,10 +391,8 @@ class NetJSONGraphRender { } if (self.type === "netjson") { - self.utils.echartsSetOption( - self.utils.generateMapOption(JSONData, self), - self, - ); + const initialMapOptions = self.utils.generateMapOption(JSONData, self); + self.utils.echartsSetOption(initialMapOptions, self); self.bboxData = { nodes: [], links: [], diff --git a/src/js/netjsongraph.util.js b/src/js/netjsongraph.util.js index ecaa5128..9dfcb6d0 100644 --- a/src/js/netjsongraph.util.js +++ b/src/js/netjsongraph.util.js @@ -35,6 +35,7 @@ class NetJSONGraphUtil { try { let paginatedResponse = await this.utils.JSONParamParse(JSONParam); if (paginatedResponse.json) { + // eslint-disable-next-line no-await-in-loop res = await paginatedResponse.json(); data = res.results ? res.results : res; while (res.next && data.nodes.length <= this.config.maxPointsFetched) { @@ -68,6 +69,7 @@ class NetJSONGraphUtil { JSONParam = JSONParam[0].split("?")[0]; // eslint-disable-next-line no-underscore-dangle const url = `${JSONParam}bbox?swLat=${bounds._southWest.lat}&swLng=${bounds._southWest.lng}&neLat=${bounds._northEast.lat}&neLng=${bounds._northEast.lng}`; + // eslint-disable-next-line no-await-in-loop const res = await this.utils.JSONParamParse(url); data = await res.json(); } catch (e) { @@ -314,78 +316,87 @@ class NetJSONGraphUtil { }); const index = new KDBush(nodes.length); - /* eslint-disable no-restricted-syntax */ - for (const {x, y} of nodes) index.add(x, y); - /* eslint-enable no-restricted-syntax */ + nodes.forEach(({x, y}) => index.add(x, y)); index.finish(); + const locationGroups = new Map(); nodes.forEach((node) => { - let cluster; - let centroid = [0, 0]; - const addNode = (n) => { - n.visited = true; - n.cluster = clusterId; - nodeMap.set(n.id, n.cluster); - centroid[0] += n.location.lng; - centroid[1] += n.location.lat; - }; - if (!node.visited) { - const neighbors = index - .within(node.x, node.y, self.config.clusterRadius) - .map((id) => nodes[id]); - const results = neighbors.filter((n) => { - if (self.config.clusteringAttribute) { - if ( - n.properties[self.config.clusteringAttribute] === - node.properties[self.config.clusteringAttribute] && - n.cluster === null - ) { - addNode(n); - return true; - } - return false; - } + if (node.visited) return; + + const neighbors = index + .within(node.x, node.y, self.config.clusterRadius) + .map((id) => nodes[id]); - if (n.cluster === null) { - addNode(n); - return true; + if (neighbors.length > 1) { + const key = `${Math.round(node.x)},${Math.round(node.y)}`; + if (!locationGroups.has(key)) { + locationGroups.set(key, new Map()); + } + const groupByAttribute = locationGroups.get(key); + + neighbors.forEach((n) => { + if (n.visited) return; + const attr = self.config.clusteringAttribute + ? n.properties[self.config.clusteringAttribute] + : "default"; + if (!groupByAttribute.has(attr)) { + groupByAttribute.set(attr, []); } - return false; + groupByAttribute.get(attr).push(n); + n.visited = true; }); + } else { + node.visited = true; + nodeMap.set(node.id, null); + nonClusterNodes.push(node); + } + }); + + locationGroups.forEach((attributeGroups) => { + attributeGroups.forEach((groupNodes, attr) => { + if (groupNodes.length > 1) { + let centroid = [0, 0]; + groupNodes.forEach((n) => { + n.cluster = clusterId; + nodeMap.set(n.id, n.cluster); + centroid[0] += n.location.lng; + centroid[1] += n.location.lat; + }); - if (results.length > 1) { centroid = [ - centroid[0] / results.length, - centroid[1] / results.length, + centroid[0] / groupNodes.length, + centroid[1] / groupNodes.length, ]; - cluster = { + + const cluster = { id: clusterId, cluster: true, - name: results.length, + name: groupNodes.length, value: centroid, - childNodes: results, + childNodes: groupNodes, ...self.config.mapOptions.clusterConfig, }; if (self.config.clusteringAttribute) { - const {color} = self.config.nodeCategories.find( - (cat) => - cat.name === node.properties[self.config.clusteringAttribute], - ).nodeStyle; - - cluster.itemStyle = { - ...cluster.itemStyle, - color, - }; + const category = self.config.nodeCategories.find( + (cat) => cat.name === attr, + ); + if (category) { + cluster.itemStyle = { + ...cluster.itemStyle, + color: category.nodeStyle.color, + }; + } } clusters.push(cluster); - } else if (results.length === 1) { - nodeMap.set(results[0].id, null); - nonClusterNodes.push(results[0]); + clusterId += 1; + } else if (groupNodes.length === 1) { + const node = groupNodes[0]; + nodeMap.set(node.id, null); + nonClusterNodes.push(node); } - clusterId += 1; - } + }); }); links.forEach((link) => { @@ -650,47 +661,96 @@ class NetJSONGraphUtil { let nodeStyleConfig; let nodeSizeConfig = {}; let nodeEmphasisConfig = {}; - if (node.category && config.nodeCategories.length) { + let categoryFound = false; + + if ( + node.category && + config.nodeCategories && + config.nodeCategories.length + ) { const category = config.nodeCategories.find( (cat) => cat.name === node.category, ); - nodeStyleConfig = this.generateStyle(category.nodeStyle || {}, node); + if (category) { + categoryFound = true; + nodeStyleConfig = this.generateStyle(category.nodeStyle || {}, node); + nodeSizeConfig = this.generateStyle(category.nodeSize || {}, node); - nodeSizeConfig = this.generateStyle(category.nodeSize || {}, node); + let emphasisNodeStyle = {}; + let emphasisNodeSize = {}; - nodeEmphasisConfig = { - ...nodeEmphasisConfig, - nodeStyle: category.emphasis - ? this.generateStyle(category.emphasis.nodeStyle || {}, node) - : {}, - }; + if (category.emphasis) { + emphasisNodeStyle = this.generateStyle( + category.emphasis.nodeStyle || {}, + node, + ); + // Corrected typo: empahsis -> emphasis + emphasisNodeSize = this.generateStyle( + category.emphasis.nodeSize || {}, + node, + ); + nodeEmphasisConfig = { + nodeStyle: emphasisNodeStyle, + nodeSize: emphasisNodeSize, + }; + } + } + } - nodeEmphasisConfig = { - ...nodeEmphasisConfig, - nodeSize: category.empahsis - ? this.generateStyle(category.emphasis.nodeSize || {}, node) - : {}, - }; - } else if (type === "map") { - nodeStyleConfig = this.generateStyle( - config.mapOptions.nodeConfig.nodeStyle, - node, - ); - nodeSizeConfig = this.generateStyle( - config.mapOptions.nodeConfig.nodeSize, - node, - ); - } else { - nodeStyleConfig = this.generateStyle( - config.graphConfig.series.nodeStyle, - node, - ); - nodeSizeConfig = this.generateStyle( - config.graphConfig.series.nodeSize, - node, - ); + if (!categoryFound) { + if (type === "map") { + const nodeConf = config.mapOptions && config.mapOptions.nodeConfig; + nodeStyleConfig = this.generateStyle( + (nodeConf && nodeConf.nodeStyle) || {}, + node, + ); + nodeSizeConfig = this.generateStyle( + (nodeConf && nodeConf.nodeSize) || {}, + node, + ); + + const emphasisConf = nodeConf && nodeConf.emphasis; + if (emphasisConf) { + nodeEmphasisConfig = { + nodeStyle: this.generateStyle( + (emphasisConf && emphasisConf.nodeStyle) || {}, + node, + ), + nodeSize: this.generateStyle( + (emphasisConf && emphasisConf.nodeSize) || {}, + node, + ), + }; + } + } else { + const seriesConf = config.graphConfig && config.graphConfig.series; + nodeStyleConfig = this.generateStyle( + (seriesConf && seriesConf.nodeStyle) || {}, + node, + ); + nodeSizeConfig = this.generateStyle( + (seriesConf && seriesConf.nodeSize) || {}, + node, + ); + + const emphasisConf = seriesConf && seriesConf.emphasis; + if (emphasisConf) { + nodeEmphasisConfig = { + nodeStyle: this.generateStyle( + (emphasisConf && emphasisConf.itemStyle) || {}, + node, + ), + + nodeSize: this.generateStyle( + (emphasisConf && emphasisConf.symbolSize) || nodeSizeConfig || {}, + node, + ), + }; + } + } } + return {nodeStyleConfig, nodeSizeConfig, nodeEmphasisConfig}; } diff --git a/test/netjsongraph.render.test.js b/test/netjsongraph.render.test.js index 24f95174..336865cd 100644 --- a/test/netjsongraph.render.test.js +++ b/test/netjsongraph.render.test.js @@ -1,3 +1,7 @@ +import { + preventClusterOverlap, + setupClusterOverlapPrevention, +} from "../src/js/cluster-utils"; import NetJSONGraph from "../src/js/netjsongraph.core"; import {NetJSONGraphRender, L} from "../src/js/netjsongraph.render"; @@ -388,6 +392,75 @@ describe("Test netjsongraph properties", () => { }); }); +// --- Cluster Overlap Prevention and Category Assignment Tests --- + +describe("Cluster Overlap Prevention Utilities", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + it("should not throw if there are no cluster markers", () => { + expect(() => preventClusterOverlap()).not.toThrow(); + }); + + it("should not modify a single cluster marker at a position", () => { + document.body.innerHTML = ` +
+ `; + const rect = {left: 200, top: 200, width: 40, height: 40}; + document.querySelectorAll(".marker-cluster").forEach((el) => { + el.getBoundingClientRect = () => rect; + }); + preventClusterOverlap(); + const cluster = document.querySelector(".marker-cluster"); + expect(cluster.style.transform).toBe(""); + expect(cluster.style.zIndex).toBe(""); + }); + it("should arrange overlapping clusters in a circle", () => { + document.body.innerHTML = ` +
+
+
+ `; + const rect = {left: 100, top: 100, width: 40, height: 40}; + document.querySelectorAll(".marker-cluster").forEach((el) => { + el.getBoundingClientRect = () => rect; + }); + preventClusterOverlap(); + const clusters = document.querySelectorAll(".marker-cluster"); + expect(clusters[0].style.transform).toBe(""); + expect(clusters[1].style.transform).toMatch(/translate\(.+px, .+px\)/); + expect(clusters[2].style.transform).toMatch(/translate\(.+px, .+px\)/); + + expect(clusters[0].style.zIndex).toBe(""); + expect(clusters[1].style.zIndex).toBe("1001"); + expect(clusters[2].style.zIndex).toBe("1002"); + }); + it("should warn if leafletMap is not provided", () => { + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + setupClusterOverlapPrevention(null); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Leaflet map instance is required"), + ); + warnSpy.mockRestore(); + }); + it("should add event listeners if leafletMap is provided", () => { + const leafletMap = {on: jest.fn()}; + setupClusterOverlapPrevention(leafletMap); + expect(leafletMap.on).toHaveBeenCalledWith( + "zoomend", + preventClusterOverlap, + ); + expect(leafletMap.on).toHaveBeenCalledWith( + "moveend", + preventClusterOverlap, + ); + expect(leafletMap.on).toHaveBeenCalledWith( + "layeradd", + preventClusterOverlap, + ); + }); +}); + describe("Test netjsongraph GeoJSON properties", () => { const geoJSONData = { type: "FeatureCollection", @@ -492,6 +565,147 @@ describe("Test when invalid data is passed", () => { }); }); +describe("generateMapOption - node processing and dynamic styling", () => { + let self; + beforeEach(() => { + self = { + config: { + mapOptions: { + nodeConfig: { + type: "scatter", + nodeStyle: {}, + nodeSize: undefined, + label: {}, + emphasis: {}, + }, + linkConfig: {}, + baseOptions: {}, + clusterConfig: {}, + }, + mapTileConfig: [{}], + nodeCategories: [], + }, + utils: { + getNodeStyle: jest.fn(() => ({ + nodeEmphasisConfig: {nodeStyle: {}, nodeSize: 10}, + nodeSizeConfig: 10, + })), + getLinkStyle: jest.fn(() => ({ + linkStyleConfig: {}, + linkEmphasisConfig: {linkStyle: {}}, + })), + }, + }; + }); + describe("color function", () => { + test("cluster color", () => { + const render = new NetJSONGraphRender(); + const params = { + data: {cluster: true, itemStyle: {color: "specified_cluster_color"}}, + }; + const option = render.generateMapOption({nodes: [], links: []}, self); + const colorFn = option.series[0].itemStyle.color; + expect(colorFn(params)).toBe("specified_cluster_color"); + }); + test("node category color", () => { + self.config.nodeCategories = [ + {name: "myCategory", nodeStyle: {color: "category_color"}}, + ]; + const render = new NetJSONGraphRender(); + const params = {data: {node: {category: "myCategory"}}}; + const option = render.generateMapOption({nodes: [], links: []}, self); + const colorFn = option.series[0].itemStyle.color; + expect(colorFn(params)).toBe("category_color"); + }); + test("node category fallback", () => { + self.config.nodeCategories = []; + self.config.mapOptions.nodeConfig.nodeStyle.color = "default_node_color"; + const render = new NetJSONGraphRender(); + const params = {data: {node: {category: "someCategory"}}}; + const option = render.generateMapOption({nodes: [], links: []}, self); + const colorFn = option.series[0].itemStyle.color; + expect(colorFn(params)).toBe("default_node_color"); + }); + test("default node color", () => { + self.config.mapOptions.nodeConfig.nodeStyle.color = "default_node_color"; + const render = new NetJSONGraphRender(); + const params = {data: {node: {}}}; + const option = render.generateMapOption({nodes: [], links: []}, self); + const colorFn = option.series[0].itemStyle.color; + expect(colorFn(params)).toBe("default_node_color"); + }); + test("absolute default color", () => { + delete self.config.mapOptions.nodeConfig.nodeStyle.color; + const render = new NetJSONGraphRender(); + const params = {data: {node: {}}}; + const option = render.generateMapOption({nodes: [], links: []}, self); + const colorFn = option.series[0].itemStyle.color; + expect(colorFn(params)).toBe("#6c757d"); + }); + }); + + describe("symbolSize function", () => { + test("cluster size configured", () => { + self.config.mapOptions.clusterConfig.symbolSize = 40; + const render = new NetJSONGraphRender(); + const params = {data: {cluster: true}}; + const option = render.generateMapOption({nodes: [], links: []}, self); + const sizeFn = option.series[0].symbolSize; + expect(sizeFn(null, params)).toBe(40); + }); + test("cluster size default", () => { + delete self.config.mapOptions.clusterConfig.symbolSize; + const render = new NetJSONGraphRender(); + const params = {data: {cluster: true}}; + const option = render.generateMapOption({nodes: [], links: []}, self); + const sizeFn = option.series[0].symbolSize; + expect(sizeFn(null, params)).toBe(30); + }); + test("node size specific number", () => { + self.utils.getNodeStyle = jest.fn(() => ({nodeSizeConfig: 25})); + const render = new NetJSONGraphRender(); + const params = {data: {node: {foo: "bar"}}}; + const option = render.generateMapOption({nodes: [], links: []}, self); + const sizeFn = option.series[0].symbolSize; + expect(sizeFn(null, params)).toBe(25); + }); + test("node size default configured", () => { + self.utils.getNodeStyle = jest.fn(() => ({nodeSizeConfig: {}})); + self.config.mapOptions.nodeConfig.nodeSize = 22; + const render = new NetJSONGraphRender(); + const params = {data: {node: {foo: "bar"}}}; + const option = render.generateMapOption({nodes: [], links: []}, self); + const sizeFn = option.series[0].symbolSize; + expect(sizeFn(null, params)).toBe(22); + }); + test("node size default fallback", () => { + self.utils.getNodeStyle = jest.fn(() => ({nodeSizeConfig: {}})); + delete self.config.mapOptions.nodeConfig.nodeSize; + const render = new NetJSONGraphRender(); + const params = {data: {node: {foo: "bar"}}}; + const option = render.generateMapOption({nodes: [], links: []}, self); + const sizeFn = option.series[0].symbolSize; + expect(sizeFn(null, params)).toBe(17); + }); + test("overall default configured", () => { + self.config.mapOptions.nodeConfig.nodeSize = 15; + const render = new NetJSONGraphRender(); + const params = {data: {}}; + const option = render.generateMapOption({nodes: [], links: []}, self); + const sizeFn = option.series[0].symbolSize; + expect(sizeFn(null, params)).toBe(15); + }); + test("overall default fallback", () => { + delete self.config.mapOptions.nodeConfig.nodeSize; + const render = new NetJSONGraphRender(); + const params = {data: {}}; + const option = render.generateMapOption({nodes: [], links: []}, self); + const sizeFn = option.series[0].symbolSize; + expect(sizeFn(null, params)).toBe(17); + }); + }); +}); + describe("Test when more data is present than maxPointsFetched", () => { const data = { nodes: [ diff --git a/test/netjsongraph.spec.js b/test/netjsongraph.spec.js index 2b04a8b9..41712448 100644 --- a/test/netjsongraph.spec.js +++ b/test/netjsongraph.spec.js @@ -269,7 +269,26 @@ describe("NetJSONGraph Specification", () => { radius: 8, }, }); - expect(graph.config.nodeCategories).toEqual([]); + expect(graph.config.nodeCategories).toEqual([ + { + name: "ok", + nodeStyle: { + color: "#28a745", + }, + }, + { + name: "problem", + nodeStyle: { + color: "#ffc107", + }, + }, + { + name: "critical", + nodeStyle: { + color: "#dc3545", + }, + }, + ]); expect(graph.config.linkCategories).toEqual([]); expect(graph.config.onInit).toBeInstanceOf(Function); expect(graph.config.onInit.call(graph)).toBe(graph.config);