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);