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

openwisp / netjsongraph.js / 14457510447

14 Apr 2025 11:06PM UTC coverage: 77.498% (-0.08%) from 77.573%
14457510447

push

github

nemesifier
[fix] Fixed disableClusteringAtLevel on initial render #353

Fixes #353

346 of 462 branches covered (74.89%)

Branch coverage included in aggregate %.

2 of 6 new or added lines in 1 file covered. (33.33%)

2 existing lines in 1 file now uncovered.

670 of 849 relevant lines covered (78.92%)

6.72 hits per line

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

30.83
/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 "leaflet.markercluster";
17
import "leaflet.markercluster/dist/MarkerCluster.css";
18
import "leaflet.markercluster/dist/MarkerCluster.Default.css";
19

20
import "echarts-gl";
21

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

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

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

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

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

118
    return echartsLayer;
×
119
  }
120

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

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

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

159
      linkResult.lineStyle = linkStyleConfig;
×
160
      linkResult.emphasis = {lineStyle: linkEmphasisConfig.linkStyle};
×
161

162
      return linkResult;
×
163
    });
164

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

183
    return {
×
184
      legend,
185
      series,
186
      ...configs.graphConfig.baseOptions,
187
    };
188
  }
189

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

209
    nodes.forEach((node) => {
3✔
210
      if (!node.properties) {
14✔
211
        console.error(`Node ${node.id} position is undefined!`);
4✔
212
      } else {
213
        const {location} = node.properties;
10✔
214

215
        if (!location || !location.lng || !location.lat) {
10!
216
          console.error(`Node ${node.id} position is undefined!`);
10✔
217
        } else {
218
          const {nodeStyleConfig, nodeSizeConfig, nodeEmphasisConfig} =
219
            self.utils.getNodeStyle(node, configs, "map");
×
220

221
          nodesData.push({
×
222
            name: typeof node.label === "string" ? node.label : node.id,
×
223
            value: [location.lng, location.lat],
224
            symbolSize: nodeSizeConfig,
225
            itemStyle: nodeStyleConfig,
226
            emphasis: {
227
              itemStyle: nodeEmphasisConfig.nodeStyle,
228
              symbolSize: nodeEmphasisConfig.nodeSize,
229
            },
230
            node,
231
          });
232
          if (!JSONData.flatNodes) {
×
233
            flatNodes[node.id] = JSON.parse(JSON.stringify(node));
×
234
          }
235
        }
236
      }
237
    });
238
    links.forEach((link) => {
3✔
239
      if (!flatNodes[link.source]) {
2!
240
        console.warn(`Node ${link.source} does not exist!`);
2✔
241
      } else if (!flatNodes[link.target]) {
×
242
        console.warn(`Node ${link.target} does not exist!`);
×
243
      } else {
244
        const {linkStyleConfig, linkEmphasisConfig} = self.utils.getLinkStyle(
×
245
          link,
246
          configs,
247
          "map",
248
        );
249
        linesData.push({
×
250
          coords: [
251
            [
252
              flatNodes[link.source].properties.location.lng,
253
              flatNodes[link.source].properties.location.lat,
254
            ],
255
            [
256
              flatNodes[link.target].properties.location.lng,
257
              flatNodes[link.target].properties.location.lat,
258
            ],
259
          ],
260
          lineStyle: linkStyleConfig,
261
          emphasis: {lineStyle: linkEmphasisConfig.linkStyle},
262
          link,
263
        });
264
      }
265
    });
266

267
    nodesData = nodesData.concat(clusters);
3✔
268

269
    const series = [
3✔
270
      Object.assign(configs.mapOptions.nodeConfig, {
271
        type:
272
          configs.mapOptions.nodeConfig.type === "effectScatter"
3!
273
            ? "effectScatter"
274
            : "scatter",
275
        coordinateSystem: "leaflet",
276
        data: nodesData,
277
        animationDuration: 1000,
278
      }),
279
      Object.assign(configs.mapOptions.linkConfig, {
280
        type: "lines",
281
        coordinateSystem: "leaflet",
282
        data: linesData,
283
      }),
284
    ];
285

286
    return {
3✔
287
      leaflet: {
288
        tiles: configs.mapTileConfig,
289
        mapOptions: configs.mapOptions,
290
      },
291
      series,
292
      ...configs.mapOptions.baseOptions,
293
    };
294
  }
295

296
  /**
297
   * @function
298
   * @name graphRender
299
   *
300
   * Render the final graph result based on JSONData.
301
   * @param  {object}  JSONData        Render data
302
   * @param  {object}  self          NetJSONGraph object
303
   *
304
   */
305
  graphRender(JSONData, self) {
306
    self.utils.echartsSetOption(
×
307
      self.utils.generateGraphOption(JSONData, self),
308
      self,
309
    );
310

311
    window.onresize = () => {
×
312
      self.echarts.resize();
×
313
    };
314

315
    self.event.emit("onLoad");
×
316
    self.event.emit("onReady");
×
317
    self.event.emit("renderArray");
×
318
  }
319

320
  /**
321
   * @function
322
   * @name mapRender
323
   *
324
   * Render the final map result based on JSONData.
325
   * @param  {object}  JSONData       Render data
326
   * @param  {object}  self         NetJSONGraph object
327
   *
328
   */
329
  mapRender(JSONData, self) {
330
    if (!self.config.mapTileConfig[0]) {
6!
331
      throw new Error(`You must add the tiles via the "mapTileConfig" param!`);
×
332
    }
333

334
    if (self.type === "netjson") {
6✔
335
      self.utils.echartsSetOption(
3✔
336
        self.utils.generateMapOption(JSONData, self),
337
        self,
338
      );
339
      self.bboxData = {
×
340
        nodes: [],
341
        links: [],
342
      };
343
    } else if (self.type === "geojson") {
3!
344
      const {nodeConfig, linkConfig, baseOptions, ...options} =
345
        self.config.mapOptions;
3✔
346

347
      self.echarts.setOption({
3✔
348
        leaflet: {
349
          tiles: self.config.mapTileConfig,
350
          mapOptions: options,
351
        },
352
      });
353

354
      self.bboxData = {
3✔
355
        features: [],
356
      };
357
    }
358

359
    // eslint-disable-next-line no-underscore-dangle
360
    self.leaflet = self.echarts._api.getCoordinateSystems()[0].getLeaflet();
3✔
361
    // eslint-disable-next-line no-underscore-dangle
362
    self.leaflet._zoomAnimated = false;
3✔
363

364
    self.config.geoOptions = self.utils.deepMergeObj(
3✔
365
      {
366
        pointToLayer: (feature, latlng) =>
367
          L.circleMarker(latlng, self.config.geoOptions.style),
1✔
368
        onEachFeature: (feature, layer) => {
369
          layer.on("click", () => {
1✔
370
            const properties = {
×
371
              ...feature.properties,
372
            };
373
            self.config.onClickElement.call(self, "Feature", properties);
×
374
          });
375
        },
376
      },
377
      self.config.geoOptions,
378
    );
379

380
    if (self.type === "geojson") {
3!
381
      self.leaflet.geoJSON = L.geoJSON(self.data, self.config.geoOptions);
3✔
382

383
      // Check if clustering should be applied based on current zoom level and configuration
384
      const needsClustering =
385
        self.config.clustering &&
3✔
386
        self.leaflet.getZoom() < self.config.disableClusteringAtLevel;
387

388
      if (needsClustering) {
3✔
389
        const clusterOptions = {
1✔
390
          showCoverageOnHover: false,
391
          spiderfyOnMaxZoom: false,
392
          maxClusterRadius: self.config.clusterRadius,
393
          disableClusteringAtZoom: self.config.disableClusteringAtLevel,
394
        };
395

396
        if (self.config.clusteringAttribute) {
1!
397
          const clusterTypeSet = new Set();
×
398
          self.data.features.forEach((feature) => {
×
399
            clusterTypeSet.add(
×
400
              feature.properties[self.config.clusteringAttribute] || "default",
×
401
            );
402
            if (!feature.properties[self.config.clusteringAttribute]) {
×
403
              feature.properties[self.config.clusteringAttribute] = "default";
×
404
            }
405
          });
406
          const clusterTypes = Array.from(clusterTypeSet);
×
407
          const clusterGroup = [];
×
408
          clusterTypes.forEach((type) => {
×
409
            const features = self.data.features.filter(
×
410
              (feature) =>
411
                feature.properties[self.config.clusteringAttribute] === type,
×
412
            );
413
            const layer = L.geoJSON(
×
414
              {
415
                ...self.data,
416
                features,
417
              },
418
              self.config.geoOptions,
419
            );
420
            const cluster = L.markerClusterGroup({
×
421
              ...clusterOptions,
422
              iconCreateFunction: (c) => {
423
                const childCount = c.getChildCount();
×
424
                return L.divIcon({
×
425
                  html: `<div><span>${childCount}</span></div>`,
426
                  className: `marker-cluster ${type}`,
427
                  iconSize: L.point(40, 40),
428
                });
429
              },
430
            }).addTo(self.leaflet);
431
            clusterGroup.push(cluster);
×
432
            cluster.addLayer(layer);
×
433
          });
434
          self.leaflet.clusterGroup = clusterGroup;
×
435
        } else {
436
          self.leaflet.markerClusterGroup = L.markerClusterGroup(
1✔
437
            clusterOptions,
438
          ).addTo(self.leaflet);
439
          self.leaflet.markerClusterGroup.addLayer(self.leaflet.geoJSON);
1✔
440
        }
441
      } else {
442
        self.leaflet.geoJSON.addTo(self.leaflet);
2✔
443
      }
444
    }
445

446
    if (self.leaflet.getZoom() < self.config.showLabelsAtZoomLevel) {
3✔
447
      self.echarts.setOption({
1✔
448
        series: [
449
          {
450
            label: {
451
              show: false,
452
            },
453
          },
454
        ],
455
      });
456
    }
457

458
    self.leaflet.on("zoomend", () => {
3✔
459
      if (self.leaflet.getZoom() >= self.config.showLabelsAtZoomLevel) {
×
460
        self.echarts.setOption({
×
461
          series: [
462
            {
463
              label: {
464
                show: true,
465
              },
466
            },
467
          ],
468
        });
469
      } else {
470
        self.echarts.setOption({
×
471
          series: [
472
            {
473
              label: {
474
                show: false,
475
              },
476
            },
477
          ],
478
        });
479
      }
480
    });
481

482
    self.leaflet.on("moveend", async () => {
3✔
483
      const bounds = self.leaflet.getBounds();
×
484
      const removeBBoxData = () => {
×
485
        if (self.type === "netjson") {
×
486
          const removeNodes = new Set(self.bboxData.nodes);
×
487
          const removeLinks = new Set(self.bboxData.links);
×
488
          const updatedNodes = JSONData.nodes.filter(
×
489
            (node) => !removeNodes.has(node),
×
490
          );
491
          const updatedLinks = JSONData.links.filter(
×
492
            (link) => !removeLinks.has(link),
×
493
          );
494

495
          JSONData = {
×
496
            ...JSONData,
497
            nodes: updatedNodes,
498
            links: updatedLinks,
499
          };
500

501
          self.data = JSONData;
×
502
          self.echarts.setOption(self.utils.generateMapOption(JSONData, self));
×
503
          self.bboxData.nodes = [];
×
504
          self.bboxData.links = [];
×
505
        } else {
506
          const removeFeatures = new Set(self.bboxData.features);
×
507
          const updatedFeatures = JSONData.features.filter(
×
508
            (feature) => !removeFeatures.has(feature),
×
509
          );
510
          JSONData = {
×
511
            ...JSONData,
512
            features: updatedFeatures,
513
          };
514
          self.utils.overrideData(JSONData, self);
×
515
          self.bboxData.features = [];
×
516
        }
517
      };
518
      if (
×
519
        self.leaflet.getZoom() >= self.config.loadMoreAtZoomLevel &&
×
520
        self.hasMoreData
521
      ) {
522
        const data = await self.utils.getBBoxData.call(
×
523
          self,
524
          self.JSONParam,
525
          bounds,
526
        );
527
        if (self.type === "netjson") {
×
528
          self.config.prepareData.call(this, data);
×
529
          const dataNodeSet = new Set(self.data.nodes.map((n) => n.id));
×
530
          const sourceLinkSet = new Set(self.data.links.map((l) => l.source));
×
531
          const targetLinkSet = new Set(self.data.links.map((l) => l.target));
×
532
          const nodes = data.nodes.filter((node) => !dataNodeSet.has(node.id));
×
533
          const links = data.links.filter(
×
534
            (link) =>
535
              !sourceLinkSet.has(link.source) &&
×
536
              !targetLinkSet.has(link.target),
537
          );
538
          const boundsDataSet = new Set(data.nodes.map((n) => n.id));
×
539
          const nonCommonNodes = self.bboxData.nodes.filter(
×
540
            (node) => !boundsDataSet.has(node.id),
×
541
          );
542
          const removableNodes = new Set(nonCommonNodes.map((n) => n.id));
×
543

544
          JSONData.nodes = JSONData.nodes.filter(
×
545
            (node) => !removableNodes.has(node.id),
×
546
          );
547
          self.bboxData.nodes = self.bboxData.nodes.concat(nodes);
×
548
          self.bboxData.links = self.bboxData.links.concat(links);
×
549
          JSONData = {
×
550
            ...JSONData,
551
            nodes: JSONData.nodes.concat(nodes),
552
            links: JSONData.links.concat(links),
553
          };
554
          self.echarts.setOption(self.utils.generateMapOption(JSONData, self));
×
555
          self.data = JSONData;
×
556
        } else {
557
          const dataSet = new Set(self.data.features);
×
558
          const features = data.features.filter(
×
559
            (feature) => !dataSet.has(feature),
×
560
          );
561
          const boundsDataSet = new Set(data.features);
×
562
          const nonCommonFeatures = self.bboxData.features.filter(
×
563
            (feature) => !boundsDataSet.has(feature),
×
564
          );
565
          const removableFeatures = new Set(nonCommonFeatures);
×
566

567
          JSONData.features = JSONData.features.filter(
×
568
            (feature) => !removableFeatures.has(feature),
×
569
          );
570
          self.bboxData.features = self.bboxData.features.concat(features);
×
571
          self.utils.appendData(features, self);
×
572
        }
573
      } else if (self.hasMoreData && self.bboxData.nodes.length > 0) {
×
574
        removeBBoxData();
×
575
      }
576
    });
577
    if (
3!
578
      self.type === "netjson" &&
3!
579
      self.config.clustering &&
580
      self.config.clusteringThreshold < JSONData.nodes.length
581
    ) {
582
      let {clusters, nonClusterNodes, nonClusterLinks} =
583
        self.utils.makeCluster(self);
×
584

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

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

604
      self.echarts.on("click", (params) => {
×
605
        if (
×
606
          (params.componentSubType === "scatter" ||
×
607
            params.componentSubType === "effectScatter") &&
608
          params.data.cluster
609
        ) {
610
          nonClusterNodes = nonClusterNodes.concat(params.data.childNodes);
×
611
          clusters = clusters.filter(
×
612
            (cluster) => cluster.id !== params.data.id,
×
613
          );
614
          self.echarts.setOption(
×
615
            self.utils.generateMapOption(
616
              {
617
                ...JSONData,
618
                nodes: nonClusterNodes,
619
              },
620
              self,
621
              clusters,
622
            ),
623
          );
624
          self.leaflet.setView([params.data.value[1], params.data.value[0]]);
×
625
        }
626
      });
627

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

653
    self.event.emit("onLoad");
3✔
654
    self.event.emit("onReady");
3✔
655
    self.event.emit("renderArray");
3✔
656
  }
657

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

672
    if (self.type === "netjson") {
1!
673
      const opts = self.utils.generateMapOption(JSONData, self);
×
674
      opts.series.forEach((obj, index) => {
×
675
        self.echarts.appendData({seriesIndex: index, data: obj.data});
×
676
      });
677
      // modify this.data
678
      self.utils.mergeData(JSONData, self);
×
679
    }
680

681
    if (self.type === "geojson") {
1!
682
      self.data = {
×
683
        ...self.data,
684
        features: self.data.features.concat(JSONData.features),
685
      };
686

687
      // Remove the existing points from the map. Otherwise,
688
      // the original points are duplicated on the map.
689
      self.leaflet.geoJSON.removeFrom(self.leaflet);
×
690
      self.utils.render();
×
691
    }
692

693
    self.config.afterUpdate.call(self);
1✔
694
  }
695

696
  /**
697
   * @function
698
   * @name addData
699
   * Add new data. Mainly used for `graph` render.
700
   *
701
   * @param  {object}         JSONData      Data
702
   * @param  {object}         self        NetJSONGraph object
703
   */
704
  addData(JSONData, self) {
705
    // modify this.data
706
    self.utils.mergeData(JSONData, self);
1✔
707

708
    // Ensure nodes are unique by ID using the utility function
709
    if (self.data.nodes && self.data.nodes.length > 0) {
1!
710
      self.data.nodes = self.utils.deduplicateNodesById(self.data.nodes);
1✔
711
    }
712

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

716
    self.config.afterUpdate.call(self);
1✔
717
  }
718

719
  /**
720
   * @function
721
   * @name mergeData
722
   * Merge new data. Modify this.data.
723
   *
724
   * @param  {object}         JSONData      Data
725
   * @param  {object}         self        NetJSONGraph object
726
   */
727
  mergeData(JSONData, self) {
728
    // Ensure incoming nodes array exists
729
    if (!JSONData.nodes) {
2!
730
      JSONData.nodes = [];
×
731
    }
732

733
    // Create a set of existing node IDs for efficient lookup
734
    const existingNodeIds = new Set();
2✔
735
    self.data.nodes.forEach((node) => {
2✔
736
      if (node.id) {
5!
737
        existingNodeIds.add(node.id);
5✔
738
      }
739
    });
740

741
    // Filter incoming nodes: keep nodes without IDs or with new IDs
742
    const newNodes = JSONData.nodes.filter((node) => {
2✔
743
      if (!node.id) {
2!
744
        return true;
×
745
      }
746
      if (existingNodeIds.has(node.id)) {
2✔
747
        console.warn(
1✔
748
          `Duplicate node ID ${node.id} detected during merge and skipped.`,
749
        );
750
        return false;
1✔
751
      }
752
      return true;
1✔
753
    });
754

755
    const nodes = self.data.nodes.concat(newNodes);
2✔
756
    // Ensure incoming links array exists
757
    const incomingLinks = JSONData.links || [];
2!
758
    const links = self.data.links.concat(incomingLinks);
2✔
759

760
    Object.assign(self.data, JSONData, {
2✔
761
      nodes,
762
      links,
763
    });
764
  }
765
}
766

767
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