• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

openwisp / netjsongraph.js / 16840071563

08 Aug 2025 08:39PM UTC coverage: 80.913% (+0.08%) from 80.832%
16840071563

push

github

web-flow
[feature] Added Cluster Prevention Mechanism #171

Closes #171

---------

Co-authored-by: Federico Capoano <f.capoano@openwisp.io>

565 of 747 branches covered (75.64%)

Branch coverage included in aggregate %.

155 of 184 new or added lines in 4 files covered. (84.24%)

2 existing lines in 1 file now uncovered.

906 of 1071 relevant lines covered (84.59%)

7.4 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

56.17
/src/js/netjsongraph.render.js
1
import * as echarts from "echarts/core";
2
import {
3
  GraphChart,
4
  EffectScatterChart,
5
  LinesChart,
6
  ScatterChart,
7
} from "echarts/charts";
8
import {
9
  TooltipComponent,
10
  TitleComponent,
11
  ToolboxComponent,
12
  LegendComponent,
13
} from "echarts/components";
14
import {SVGRenderer} from "echarts/renderers";
15
import L from "leaflet/dist/leaflet";
16
import "echarts-gl";
17
import {addPolygonOverlays} from "./netjsongraph.geojson";
18

19
echarts.use([
2✔
20
  GraphChart,
21
  EffectScatterChart,
22
  LinesChart,
23
  TooltipComponent,
24
  TitleComponent,
25
  ToolboxComponent,
26
  LegendComponent,
27
  SVGRenderer,
28
  ScatterChart,
29
]);
30

31
class NetJSONGraphRender {
32
  /**
33
   * @function
34
   * @name echartsSetOption
35
   *
36
   * set option in echarts and render.
37
   *
38
   * @param  {object}  customOption    custom option determined by different render.
39
   * @param  {object}  self          NetJSONGraph object
40
   *
41
   * @return {object}  graph object
42
   *
43
   */
44
  echartsSetOption(customOption, self) {
45
    const configs = self.config;
3✔
46
    const echartsLayer = self.echarts;
3✔
47
    const commonOption = self.utils.deepMergeObj(
3✔
48
      {
49
        // Show element's detail when hover
50

51
        tooltip: {
52
          confine: true,
53
          position: (pos, params, dom, rect, size) => {
54
            let position = "right";
×
55
            if (size.viewSize[0] - pos[0] < size.contentSize[0]) {
×
56
              position = "left";
×
57
            }
58
            if (params.componentSubType === "lines") {
×
59
              position = [
×
60
                pos[0] + size.contentSize[0] / 8,
61
                pos[1] - size.contentSize[1] / 2,
62
              ];
63

64
              if (size.viewSize[0] - position[0] < size.contentSize[0]) {
×
65
                position[0] -= 1.25 * size.contentSize[0];
×
66
              }
67
            }
68
            return position;
×
69
          },
70
          padding: [5, 12],
71
          textStyle: {
72
            lineHeight: 20,
73
          },
74
          renderMode: "html",
75
          className: "njg-tooltip",
76
          formatter: (params) => {
77
            if (params.componentSubType === "graph") {
×
78
              return params.dataType === "edge"
×
79
                ? self.utils.getLinkTooltipInfo(params.data)
80
                : self.utils.getNodeTooltipInfo(params.data);
81
            }
82
            if (params.componentSubType === "graphGL") {
×
83
              return self.utils.getNodeTooltipInfo(params.data);
×
84
            }
85
            return params.componentSubType === "lines"
×
86
              ? self.utils.getLinkTooltipInfo(params.data.link)
87
              : self.utils.getNodeTooltipInfo(params.data.node);
88
          },
89
        },
90
      },
91
      configs.echartsOption,
92
    );
93

94
    echartsLayer.setOption(self.utils.deepMergeObj(commonOption, customOption));
3✔
95
    echartsLayer.on(
3✔
96
      "click",
97
      (params) => {
98
        const clickElement = configs.onClickElement.bind(self);
×
99
        if (params.componentSubType === "graph") {
×
100
          return clickElement(
×
101
            params.dataType === "edge" ? "link" : "node",
×
102
            params.data,
103
          );
104
        }
105
        if (params.componentSubType === "graphGL") {
×
106
          return clickElement("node", params.data);
×
107
        }
108
        return params.componentSubType === "lines"
×
109
          ? clickElement("link", params.data.link)
110
          : !params.data.cluster && clickElement("node", params.data.node);
×
111
      },
112
      {passive: true},
113
    );
114

115
    return echartsLayer;
×
116
  }
117

118
  /**
119
   * @function
120
   * @name generateGraphOption
121
   *
122
   * generate graph option in echarts by JSONData.
123
   *
124
   * @param  {object}  JSONData        Render data
125
   * @param  {object}  self          NetJSONGraph object
126
   *
127
   * @return {object}  graph option
128
   *
129
   */
130
  generateGraphOption(JSONData, self) {
131
    const categories = [];
×
132
    const configs = self.config;
×
133
    const nodes = JSONData.nodes.map((node) => {
×
134
      const nodeResult = JSON.parse(JSON.stringify(node));
×
135
      const {nodeStyleConfig, nodeSizeConfig, nodeEmphasisConfig} =
136
        self.utils.getNodeStyle(node, configs, "graph");
×
137

138
      nodeResult.itemStyle = nodeStyleConfig;
×
139
      nodeResult.symbolSize = nodeSizeConfig;
×
140
      nodeResult.emphasis = {
×
141
        itemStyle: nodeEmphasisConfig.nodeStyle,
142
        symbolSize: nodeEmphasisConfig.nodeSize,
143
      };
144
      nodeResult.name = typeof node.label === "string" ? node.label : "";
×
145

146
      return nodeResult;
×
147
    });
148
    const links = JSONData.links.map((link) => {
×
149
      const linkResult = JSON.parse(JSON.stringify(link));
×
150
      const {linkStyleConfig, linkEmphasisConfig} = self.utils.getLinkStyle(
×
151
        link,
152
        configs,
153
        "graph",
154
      );
155

156
      linkResult.lineStyle = linkStyleConfig;
×
157
      linkResult.emphasis = {lineStyle: linkEmphasisConfig.linkStyle};
×
158

159
      return linkResult;
×
160
    });
161

162
    const series = [
×
163
      Object.assign(configs.graphConfig.series, {
164
        type:
165
          configs.graphConfig.series.type === "graphGL" ? "graphGL" : "graph",
×
166
        layout:
167
          configs.graphConfig.series.type === "graphGL"
×
168
            ? "forceAtlas2"
169
            : configs.graphConfig.series.layout,
170
        nodes,
171
        links,
172
      }),
173
    ];
174
    const legend = categories.length
×
175
      ? {
176
          data: categories,
177
        }
178
      : undefined;
179

180
    return {
×
181
      legend,
182
      series,
183
      ...configs.graphConfig.baseOptions,
184
    };
185
  }
186

187
  /**
188
   * @function
189
   * @name generateMapOption
190
   *
191
   * generate map option in echarts by JSONData.
192
   *
193
   * @param  {object}  JSONData        Render data
194
   * @param  {object}  self          NetJSONGraph object
195
   *
196
   * @return {object}  map option
197
   *
198
   */
199
  generateMapOption(JSONData, self, clusters = []) {
17✔
200
    const configs = self.config;
17✔
201
    const {nodes, links} = JSONData;
17✔
202
    const flatNodes = JSONData.flatNodes || {};
17✔
203
    const linesData = [];
17✔
204
    let nodesData = [];
17✔
205

206
    nodes.forEach((node) => {
17✔
207
      if (node.properties) {
16!
208
        // Maintain flatNodes lookup regardless of whether the node is rendered as a marker
209
        if (!JSONData.flatNodes) {
16!
210
          flatNodes[node.id] = JSON.parse(JSON.stringify(node));
16✔
211
        }
212
      }
213

214
      // Non-Point geometries should not become scatter markers, but we still need them for lines
215
      if (
16!
216
        node.properties &&
48✔
217
        // eslint-disable-next-line no-underscore-dangle
218
        node.properties._featureType &&
219
        // eslint-disable-next-line no-underscore-dangle
220
        node.properties._featureType !== "Point"
221
      ) {
222
        return; // skip marker push only
×
223
      }
224
      if (!node.properties) {
16!
UNCOV
225
        console.error(`Node ${node.id} position is undefined!`);
×
226
      } else {
227
        const {location} = node.properties;
16✔
228

229
        if (!location || !location.lng || !location.lat) {
16!
230
          console.error(`Node ${node.id} position is undefined!`);
16✔
231
        } else {
NEW
232
          const {nodeEmphasisConfig} = self.utils.getNodeStyle(
×
233
            node,
234
            configs,
235
            "map",
236
          );
237

238
          nodesData.push({
×
239
            name: typeof node.label === "string" ? node.label : "",
×
240
            value: [location.lng, location.lat],
241
            emphasis: {
242
              itemStyle: nodeEmphasisConfig.nodeStyle,
243
              symbolSize: nodeEmphasisConfig.nodeSize,
244
            },
245
            node,
246
          });
247
        }
248
      }
249
    });
250
    links.forEach((link) => {
17✔
251
      if (!flatNodes[link.source]) {
1!
UNCOV
252
        console.warn(`Node ${link.source} does not exist!`);
×
253
      } else if (!flatNodes[link.target]) {
1!
254
        console.warn(`Node ${link.target} does not exist!`);
×
255
      } else {
256
        const {linkStyleConfig, linkEmphasisConfig} = self.utils.getLinkStyle(
1✔
257
          link,
258
          configs,
259
          "map",
260
        );
261
        linesData.push({
1✔
262
          coords: [
263
            [
264
              flatNodes[link.source].properties.location.lng,
265
              flatNodes[link.source].properties.location.lat,
266
            ],
267
            [
268
              flatNodes[link.target].properties.location.lng,
269
              flatNodes[link.target].properties.location.lat,
270
            ],
271
          ],
272
          lineStyle: linkStyleConfig,
273
          emphasis: {lineStyle: linkEmphasisConfig.linkStyle},
274
          link,
275
        });
276
      }
277
    });
278

279
    nodesData = nodesData.concat(clusters);
16✔
280

281
    const series = [
16✔
282
      {
283
        type:
284
          configs.mapOptions.nodeConfig.type === "effectScatter"
16!
285
            ? "effectScatter"
286
            : "scatter",
287
        name: "nodes",
288
        coordinateSystem: "leaflet",
289
        data: nodesData,
290
        animationDuration: 1000,
291
        label: configs.mapOptions.nodeConfig.label,
292
        itemStyle: {
293
          color: (params) => {
294
            if (
5✔
295
              params.data &&
12✔
296
              params.data.cluster &&
297
              params.data.itemStyle &&
298
              params.data.itemStyle.color
299
            ) {
300
              return params.data.itemStyle.color;
1✔
301
            }
302
            if (params.data && params.data.node && params.data.node.category) {
4✔
303
              const category = configs.nodeCategories.find(
2✔
304
                (cat) => cat.name === params.data.node.category,
1✔
305
              );
306
              const nodeColor =
307
                (category && category.nodeStyle && category.nodeStyle.color) ||
2!
308
                (configs.mapOptions.nodeConfig &&
309
                  configs.mapOptions.nodeConfig.nodeStyle &&
310
                  configs.mapOptions.nodeConfig.nodeStyle.color) ||
311
                "#6c757d";
312
              return nodeColor;
2✔
313
            }
314
            const defaultColor =
315
              (configs.mapOptions.nodeConfig &&
2✔
316
                configs.mapOptions.nodeConfig.nodeStyle &&
317
                configs.mapOptions.nodeConfig.nodeStyle.color) ||
318
              "#6c757d";
319
            return defaultColor;
2✔
320
          },
321
        },
322
        symbolSize: (value, params) => {
323
          if (params.data && params.data.cluster) {
7✔
324
            return (
2✔
325
              (configs.mapOptions.clusterConfig &&
5✔
326
                configs.mapOptions.clusterConfig.symbolSize) ||
327
              30
328
            );
329
          }
330
          if (params.data && params.data.node) {
5✔
331
            const {nodeSizeConfig} = self.utils.getNodeStyle(
3✔
332
              params.data.node,
333
              configs,
334
              "map",
335
            );
336
            return typeof nodeSizeConfig === "object"
3✔
337
              ? (configs.mapOptions.nodeConfig &&
5✔
338
                  configs.mapOptions.nodeConfig.nodeSize) ||
339
                  17
340
              : nodeSizeConfig;
341
          }
342
          return (
2✔
343
            (configs.mapOptions.nodeConfig &&
5✔
344
              configs.mapOptions.nodeConfig.nodeSize) ||
345
            17
346
          );
347
        },
348
        emphasis: configs.mapOptions.nodeConfig.emphasis,
349
      },
350
      Object.assign(configs.mapOptions.linkConfig, {
351
        type: "lines",
352
        coordinateSystem: "leaflet",
353
        data: linesData,
354
      }),
355
    ];
356

357
    return {
16✔
358
      leaflet: {
359
        tiles: configs.mapTileConfig,
360
        mapOptions: configs.mapOptions,
361
      },
362
      series,
363
      ...configs.mapOptions.baseOptions,
364
    };
365
  }
366

367
  /**
368
   * @function
369
   * @name graphRender
370
   *
371
   * Render the final graph result based on JSONData.
372
   * @param  {object}  JSONData        Render data
373
   * @param  {object}  self          NetJSONGraph object
374
   *
375
   */
376
  graphRender(JSONData, self) {
377
    self.utils.echartsSetOption(
×
378
      self.utils.generateGraphOption(JSONData, self),
379
      self,
380
    );
381

382
    window.onresize = () => {
×
383
      self.echarts.resize();
×
384
    };
385

386
    self.event.emit("onLoad");
×
387
    self.event.emit("onReady");
×
388
    self.event.emit("renderArray");
×
389
  }
390

391
  /**
392
   * @function
393
   * @name mapRender
394
   *
395
   * Render the final map result based on JSONData.
396
   * @param  {object}  JSONData       Render data
397
   * @param  {object}  self         NetJSONGraph object
398
   *
399
   */
400
  mapRender(JSONData, self) {
401
    if (!self.config.mapTileConfig[0]) {
12!
402
      throw new Error(`You must add the tiles via the "mapTileConfig" param!`);
×
403
    }
404

405
    // Accept both NetJSON and GeoJSON inputs. If GeoJSON is detected,
406
    // deep-copy it for polygon overlays and convert the working copy to
407
    // NetJSON so the rest of the pipeline can operate uniformly.
408
    if (self.utils.isGeoJSON(JSONData)) {
12✔
409
      self.originalGeoJSON = JSON.parse(JSON.stringify(JSONData));
8✔
410
      JSONData = self.utils.geojsonToNetjson(JSONData);
8✔
411
      // From this point forward we treat the data as NetJSON internally,
412
      // but keep the public-facing `type` value unchanged ("geojson").
413
    }
414

415
    const initialMapOptions = self.utils.generateMapOption(JSONData, self);
12✔
416
    self.utils.echartsSetOption(initialMapOptions, self);
11✔
417
    self.bboxData = {
8✔
418
      nodes: [],
419
      links: [],
420
    };
421

422
    // eslint-disable-next-line no-underscore-dangle
423
    self.leaflet = self.echarts._api.getCoordinateSystems()[0].getLeaflet();
8✔
424
    // eslint-disable-next-line no-underscore-dangle
425
    self.leaflet._zoomAnimated = false;
8✔
426

427
    self.config.geoOptions = self.utils.deepMergeObj(
8✔
428
      {
429
        pointToLayer: (feature, latlng) =>
430
          L.circleMarker(latlng, self.config.geoOptions.style),
×
431
        onEachFeature: (feature, layer) => {
432
          layer.on("click", () => {
×
433
            const properties = {
×
434
              ...feature.properties,
435
            };
436
            self.config.onClickElement.call(self, "Feature", properties);
×
437
          });
438
        },
439
      },
440
      self.config.geoOptions,
441
    );
442

443
    // Render Polygon and MultiPolygon features from the original GeoJSON data.
444
    // While nodes (Points) and links (LineStrings) are handled by ECharts,
445
    // polygon features are rendered directly onto the Leaflet map using
446
    // a separate L.geoJSON layer. This allows for displaying geographical
447
    // areas like parks or districts alongside the network topology.
448
    if (self.originalGeoJSON) {
8!
449
      addPolygonOverlays(self);
8✔
450
      // Auto-fit view to encompass ALL geometries (polygons + nodes)
451
      let bounds = null;
8✔
452

453
      // 1. Polygon overlays (if any)
454
      if (
8!
455
        self.leaflet.polygonGeoJSON &&
10✔
456
        typeof self.leaflet.polygonGeoJSON.getBounds === "function"
457
      ) {
458
        bounds = self.leaflet.polygonGeoJSON.getBounds();
×
459
      }
460

461
      // 2. Nodes (Points)
462
      if (JSONData.nodes && JSONData.nodes.length) {
8!
463
        const latlngs = JSONData.nodes
×
464
          .map((n) => n.properties.location)
×
465
          .map((loc) => [loc.lat, loc.lng]);
×
466
        if (bounds) {
×
467
          latlngs.forEach((ll) => bounds.extend(ll));
×
468
        } else {
469
          bounds = L.latLngBounds(latlngs);
×
470
        }
471
      }
472

473
      if (bounds && bounds.isValid()) {
8!
474
        self.leaflet.fitBounds(bounds, {padding: [20, 20]});
×
475
      }
476
    }
477

478
    if (self.leaflet.getZoom() < self.config.showLabelsAtZoomLevel) {
8✔
479
      self.echarts.setOption({
5✔
480
        series: [
481
          {
482
            label: {
483
              show: false,
484
            },
485
          },
486
        ],
487
      });
488
    }
489

490
    self.leaflet.on("zoomend", () => {
8✔
491
      const currentZoom = self.leaflet.getZoom();
8✔
492
      self.echarts.setOption({
8✔
493
        series: [
494
          {
495
            label: {
496
              show: currentZoom >= self.config.showLabelsAtZoomLevel,
497
            },
498
          },
499
        ],
500
      });
501

502
      // Zoom in/out buttons disabled only when it is equal to min/max zoomlevel
503
      // Manually handle zoom control state to ensure correct behavior with float zoom levels
504
      const minZoom = self.leaflet.getMinZoom();
8✔
505
      const maxZoom = self.leaflet.getMaxZoom();
8✔
506
      const zoomIn = document.querySelector(".leaflet-control-zoom-in");
8✔
507
      const zoomOut = document.querySelector(".leaflet-control-zoom-out");
8✔
508

509
      if (zoomIn && zoomOut) {
8!
510
        if (Math.round(currentZoom) >= maxZoom) {
8✔
511
          zoomIn.classList.add("leaflet-disabled");
4✔
512
        } else {
513
          zoomIn.classList.remove("leaflet-disabled");
4✔
514
        }
515

516
        if (Math.round(currentZoom) <= minZoom) {
8✔
517
          zoomOut.classList.add("leaflet-disabled");
2✔
518
        } else {
519
          zoomOut.classList.remove("leaflet-disabled");
6✔
520
        }
521
      }
522
    });
523

524
    self.leaflet.on("moveend", async () => {
8✔
525
      const bounds = self.leaflet.getBounds();
5✔
526
      const removeBBoxData = () => {
5✔
527
        const removeNodes = new Set(self.bboxData.nodes);
×
528
        const removeLinks = new Set(self.bboxData.links);
×
529

530
        JSONData = {
×
531
          ...JSONData,
532
          nodes: JSONData.nodes.filter((node) => !removeNodes.has(node)),
×
533
          links: JSONData.links.filter((link) => !removeLinks.has(link)),
×
534
        };
535

536
        self.data = JSONData;
×
537
        self.echarts.setOption(self.utils.generateMapOption(JSONData, self));
×
538
        self.bboxData.nodes = [];
×
539
        self.bboxData.links = [];
×
540
      };
541
      if (
5✔
542
        self.leaflet.getZoom() >= self.config.loadMoreAtZoomLevel &&
6✔
543
        self.hasMoreData
544
      ) {
545
        const data = await self.utils.getBBoxData.call(
1✔
546
          self,
547
          self.JSONParam,
548
          bounds,
549
        );
550
        self.config.prepareData.call(self, data);
1✔
551
        const dataNodeSet = new Set(self.data.nodes.map((n) => n.id));
1✔
552
        const sourceLinkSet = new Set(self.data.links.map((l) => l.source));
1✔
553
        const targetLinkSet = new Set(self.data.links.map((l) => l.target));
1✔
554
        const nodes = data.nodes.filter((node) => !dataNodeSet.has(node.id));
1✔
555
        const links = data.links.filter(
1✔
556
          (link) =>
557
            !sourceLinkSet.has(link.source) && !targetLinkSet.has(link.target),
×
558
        );
559
        const boundsDataSet = new Set(data.nodes.map((n) => n.id));
1✔
560
        const nonCommonNodes = self.bboxData.nodes.filter(
1✔
561
          (node) => !boundsDataSet.has(node.id),
×
562
        );
563
        const removableNodes = new Set(nonCommonNodes.map((n) => n.id));
1✔
564

565
        JSONData.nodes = JSONData.nodes.filter(
1✔
566
          (node) => !removableNodes.has(node.id),
×
567
        );
568
        self.bboxData.nodes = self.bboxData.nodes.concat(nodes);
1✔
569
        self.bboxData.links = self.bboxData.links.concat(links);
1✔
570
        JSONData = {
1✔
571
          ...JSONData,
572
          nodes: JSONData.nodes.concat(nodes),
573
          links: JSONData.links.concat(links),
574
        };
575
        self.echarts.setOption(self.utils.generateMapOption(JSONData, self));
1✔
576
        self.data = JSONData;
1✔
577
      } else if (self.hasMoreData && self.bboxData.nodes.length > 0) {
4!
578
        removeBBoxData();
×
579
      }
580
    });
581
    if (
8!
582
      self.config.clustering &&
10✔
583
      self.config.clusteringThreshold < JSONData.nodes.length
584
    ) {
585
      let {clusters, nonClusterNodes, nonClusterLinks} =
586
        self.utils.makeCluster(self);
×
587

588
      // Only show clusters if we're below the disableClusteringAtLevel
589
      if (self.leaflet.getZoom() > self.config.disableClusteringAtLevel) {
×
590
        clusters = [];
×
591
        nonClusterNodes = JSONData.nodes;
×
592
        nonClusterLinks = JSONData.links;
×
593
      }
594

595
      self.echarts.setOption(
×
596
        self.utils.generateMapOption(
597
          {
598
            ...JSONData,
599
            nodes: nonClusterNodes,
600
            links: nonClusterLinks,
601
          },
602
          self,
603
          clusters,
604
        ),
605
      );
606

607
      self.echarts.on("click", (params) => {
×
608
        if (
×
609
          (params.componentSubType === "scatter" ||
×
610
            params.componentSubType === "effectScatter") &&
611
          params.data.cluster
612
        ) {
613
          // Zoom into the clicked cluster instead of expanding it
NEW
614
          const currentZoom = self.leaflet.getZoom();
×
NEW
615
          const targetZoom = Math.min(
×
616
            currentZoom + 2,
617
            self.leaflet.getMaxZoom(),
618
          );
NEW
619
          self.leaflet.setView(
×
620
            [params.data.value[1], params.data.value[0]],
621
            targetZoom,
622
          );
623
        }
624
      });
625

626
      // Ensure zoom handler consistently applies the same clustering logic
627
      self.leaflet.on("zoomend", () => {
×
628
        if (self.leaflet.getZoom() < self.config.disableClusteringAtLevel) {
×
629
          const nodeData = self.utils.makeCluster(self);
×
630
          clusters = nodeData.clusters;
×
631
          nonClusterNodes = nodeData.nonClusterNodes;
×
632
          nonClusterLinks = nodeData.nonClusterLinks;
×
633
          self.echarts.setOption(
×
634
            self.utils.generateMapOption(
635
              {
636
                ...JSONData,
637
                nodes: nonClusterNodes,
638
                links: nonClusterLinks,
639
              },
640
              self,
641
              clusters,
642
            ),
643
          );
644
        } else {
645
          // When above the threshold, show all nodes without clustering
646
          self.echarts.setOption(self.utils.generateMapOption(JSONData, self));
×
647
        }
648
      });
649
    }
650

651
    self.event.emit("onLoad");
8✔
652
    self.event.emit("onReady");
8✔
653
    self.event.emit("renderArray");
8✔
654
  }
655

656
  /**
657
   * @function
658
   * @name appendData
659
   * Append new data. Can only be used for `map` render!
660
   *
661
   * @param  {object}         JSONData   Data
662
   * @param  {object}         self     NetJSONGraph object
663
   *
664
   */
665
  appendData(JSONData, self) {
666
    if (self.config.render !== self.utils.mapRender) {
1!
667
      throw new Error("AppendData function can only be used for map render!");
×
668
    }
669

670
    if (self.config.render === self.utils.mapRender) {
1!
671
      const opts = self.utils.generateMapOption(JSONData, self);
1✔
672
      opts.series.forEach((obj, index) => {
1✔
673
        self.echarts.appendData({seriesIndex: index, data: obj.data});
2✔
674
      });
675
      // modify this.data
676
      self.utils.mergeData(JSONData, self);
1✔
677
    }
678

679
    self.config.afterUpdate.call(self);
1✔
680
  }
681

682
  /**
683
   * @function
684
   * @name addData
685
   * Add new data. Mainly used for `graph` render.
686
   *
687
   * @param  {object}         JSONData      Data
688
   * @param  {object}         self        NetJSONGraph object
689
   */
690
  addData(JSONData, self) {
691
    // modify this.data
692
    self.utils.mergeData(JSONData, self);
1✔
693

694
    // Ensure nodes are unique by ID using the utility function
695
    if (self.data.nodes && self.data.nodes.length > 0) {
1!
696
      self.data.nodes = self.utils.deduplicateNodesById(self.data.nodes);
1✔
697
    }
698

699
    // `graph` render can't append data. So we have to merge the data and re-render.
700
    self.utils.render();
1✔
701

702
    self.config.afterUpdate.call(self);
1✔
703
  }
704

705
  /**
706
   * @function
707
   * @name mergeData
708
   * Merge new data. Modify this.data.
709
   *
710
   * @param  {object}         JSONData      Data
711
   * @param  {object}         self        NetJSONGraph object
712
   */
713
  mergeData(JSONData, self) {
714
    // Ensure incoming nodes array exists
715
    if (!JSONData.nodes) {
3!
716
      JSONData.nodes = [];
×
717
    }
718

719
    // Create a set of existing node IDs for efficient lookup
720
    const existingNodeIds = new Set();
3✔
721
    self.data.nodes.forEach((node) => {
3✔
722
      if (node.id) {
5!
723
        existingNodeIds.add(node.id);
5✔
724
      }
725
    });
726

727
    // Filter incoming nodes: keep nodes without IDs or with new IDs
728
    const newNodes = JSONData.nodes.filter((node) => {
3✔
729
      if (!node.id) {
3!
730
        return true;
×
731
      }
732
      if (existingNodeIds.has(node.id)) {
3✔
733
        console.warn(
1✔
734
          `Duplicate node ID ${node.id} detected during merge and skipped.`,
735
        );
736
        return false;
1✔
737
      }
738
      return true;
2✔
739
    });
740

741
    const nodes = self.data.nodes.concat(newNodes);
3✔
742
    // Ensure incoming links array exists
743
    const incomingLinks = JSONData.links || [];
3!
744
    const links = self.data.links.concat(incomingLinks);
3✔
745

746
    Object.assign(self.data, JSONData, {
3✔
747
      nodes,
748
      links,
749
    });
750
  }
751
}
752

753
export {NetJSONGraphRender, echarts, L};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc