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

openwisp / netjsongraph.js / 16582217787

28 Jul 2025 10:59PM UTC coverage: 80.832% (+2.0%) from 78.786%
16582217787

push

github

web-flow
[change] Uniform geojson map rendering #395

Closes #395

449 of 599 branches covered (74.96%)

Branch coverage included in aggregate %.

140 of 187 new or added lines in 7 files covered. (74.87%)

4 existing lines in 3 files now uncovered.

795 of 940 relevant lines covered (84.57%)

7.27 hits per line

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

48.69
/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;
4✔
46
    const echartsLayer = self.echarts;
4✔
47
    const commonOption = self.utils.deepMergeObj(
4✔
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));
4✔
95
    echartsLayer.on(
4✔
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
      };
NEW
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 = []) {
5✔
200
    const configs = self.config;
5✔
201
    const {nodes, links} = JSONData;
5✔
202
    const flatNodes = JSONData.flatNodes || {};
5✔
203
    const linesData = [];
5✔
204
    let nodesData = [];
5✔
205

206
    nodes.forEach((node) => {
5✔
207
      if (node.properties) {
16✔
208
        // Maintain flatNodes lookup regardless of whether the node is rendered as a marker
209
        if (!JSONData.flatNodes) {
10!
210
          flatNodes[node.id] = JSON.parse(JSON.stringify(node));
10✔
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 &&
26!
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
      ) {
NEW
222
        return; // skip marker push only
×
223
      }
224
      if (!node.properties) {
16✔
225
        console.error(`Node ${node.id} position is undefined!`);
6✔
226
      } else {
227
        const {location} = node.properties;
10✔
228

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

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

278
    nodesData = nodesData.concat(clusters);
5✔
279

280
    const series = [
5✔
281
      Object.assign(configs.mapOptions.nodeConfig, {
282
        type:
283
          configs.mapOptions.nodeConfig.type === "effectScatter"
5!
284
            ? "effectScatter"
285
            : "scatter",
286
        coordinateSystem: "leaflet",
287
        data: nodesData,
288
        animationDuration: 1000,
289
      }),
290
      Object.assign(configs.mapOptions.linkConfig, {
291
        type: "lines",
292
        coordinateSystem: "leaflet",
293
        data: linesData,
294
      }),
295
    ];
296

297
    return {
5✔
298
      leaflet: {
299
        tiles: configs.mapTileConfig,
300
        mapOptions: configs.mapOptions,
301
      },
302
      series,
303
      ...configs.mapOptions.baseOptions,
304
    };
305
  }
306

307
  /**
308
   * @function
309
   * @name graphRender
310
   *
311
   * Render the final graph result based on JSONData.
312
   * @param  {object}  JSONData        Render data
313
   * @param  {object}  self          NetJSONGraph object
314
   *
315
   */
316
  graphRender(JSONData, self) {
317
    self.utils.echartsSetOption(
×
318
      self.utils.generateGraphOption(JSONData, self),
319
      self,
320
    );
321

322
    window.onresize = () => {
×
323
      self.echarts.resize();
×
324
    };
325

326
    self.event.emit("onLoad");
×
327
    self.event.emit("onReady");
×
328
    self.event.emit("renderArray");
×
329
  }
330

331
  /**
332
   * @function
333
   * @name mapRender
334
   *
335
   * Render the final map result based on JSONData.
336
   * @param  {object}  JSONData       Render data
337
   * @param  {object}  self         NetJSONGraph object
338
   *
339
   */
340
  mapRender(JSONData, self) {
341
    if (!self.config.mapTileConfig[0]) {
12!
342
      throw new Error(`You must add the tiles via the "mapTileConfig" param!`);
×
343
    }
344

345
    // Accept both NetJSON and GeoJSON inputs. If GeoJSON is detected,
346
    // deep-copy it for polygon overlays and convert the working copy to
347
    // NetJSON so the rest of the pipeline can operate uniformly.
348
    if (self.utils.isGeoJSON(JSONData)) {
12✔
349
      self.originalGeoJSON = JSON.parse(JSON.stringify(JSONData));
8✔
350
      JSONData = self.utils.geojsonToNetjson(JSONData);
8✔
351
      // From this point forward we treat the data as NetJSON internally,
352
      // but keep the public-facing `type` value unchanged ("geojson").
353
    }
354

355
    const initialMapOptions = self.utils.generateMapOption(JSONData, self);
12✔
356
    self.utils.echartsSetOption(initialMapOptions, self);
12✔
357
    self.bboxData = {
8✔
358
      nodes: [],
359
      links: [],
360
    };
361

362
    // eslint-disable-next-line no-underscore-dangle
363
    self.leaflet = self.echarts._api.getCoordinateSystems()[0].getLeaflet();
8✔
364
    // eslint-disable-next-line no-underscore-dangle
365
    self.leaflet._zoomAnimated = false;
8✔
366

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

383
    // Render Polygon and MultiPolygon features from the original GeoJSON data.
384
    // While nodes (Points) and links (LineStrings) are handled by ECharts,
385
    // polygon features are rendered directly onto the Leaflet map using
386
    // a separate L.geoJSON layer. This allows for displaying geographical
387
    // areas like parks or districts alongside the network topology.
388
    if (self.originalGeoJSON) {
8!
389
      addPolygonOverlays(self);
8✔
390
      // Auto-fit view to encompass ALL geometries (polygons + nodes)
391
      let bounds = null;
8✔
392

393
      // 1. Polygon overlays (if any)
394
      if (
8!
395
        self.leaflet.polygonGeoJSON &&
10✔
396
        typeof self.leaflet.polygonGeoJSON.getBounds === "function"
397
      ) {
NEW
398
        bounds = self.leaflet.polygonGeoJSON.getBounds();
×
399
      }
400

401
      // 2. Nodes (Points)
402
      if (JSONData.nodes && JSONData.nodes.length) {
8!
NEW
403
        const latlngs = JSONData.nodes
×
NEW
404
          .map((n) => n.properties.location)
×
NEW
405
          .map((loc) => [loc.lat, loc.lng]);
×
NEW
406
        if (bounds) {
×
NEW
407
          latlngs.forEach((ll) => bounds.extend(ll));
×
408
        } else {
NEW
409
          bounds = L.latLngBounds(latlngs);
×
410
        }
411
      }
412

413
      if (bounds && bounds.isValid()) {
8!
NEW
414
        self.leaflet.fitBounds(bounds, {padding: [20, 20]});
×
415
      }
416
    }
417

418
    if (self.leaflet.getZoom() < self.config.showLabelsAtZoomLevel) {
8✔
419
      self.echarts.setOption({
5✔
420
        series: [
421
          {
422
            label: {
423
              show: false,
424
            },
425
          },
426
        ],
427
      });
428
    }
429

430
    self.leaflet.on("zoomend", () => {
8✔
431
      const currentZoom = self.leaflet.getZoom();
8✔
432
      self.echarts.setOption({
8✔
433
        series: [
434
          {
435
            label: {
436
              show: currentZoom >= self.config.showLabelsAtZoomLevel,
437
            },
438
          },
439
        ],
440
      });
441

442
      // Zoom in/out buttons disabled only when it is equal to min/max zoomlevel
443
      // Manually handle zoom control state to ensure correct behavior with float zoom levels
444
      const minZoom = self.leaflet.getMinZoom();
8✔
445
      const maxZoom = self.leaflet.getMaxZoom();
8✔
446
      const zoomIn = document.querySelector(".leaflet-control-zoom-in");
8✔
447
      const zoomOut = document.querySelector(".leaflet-control-zoom-out");
8✔
448

449
      if (zoomIn && zoomOut) {
8!
450
        if (Math.round(currentZoom) >= maxZoom) {
8✔
451
          zoomIn.classList.add("leaflet-disabled");
4✔
452
        } else {
453
          zoomIn.classList.remove("leaflet-disabled");
4✔
454
        }
455

456
        if (Math.round(currentZoom) <= minZoom) {
8✔
457
          zoomOut.classList.add("leaflet-disabled");
2✔
458
        } else {
459
          zoomOut.classList.remove("leaflet-disabled");
6✔
460
        }
461
      }
462
    });
463

464
    self.leaflet.on("moveend", async () => {
8✔
465
      const bounds = self.leaflet.getBounds();
5✔
466
      const removeBBoxData = () => {
5✔
NEW
467
        const removeNodes = new Set(self.bboxData.nodes);
×
NEW
468
        const removeLinks = new Set(self.bboxData.links);
×
469

NEW
470
        JSONData = {
×
471
          ...JSONData,
NEW
472
          nodes: JSONData.nodes.filter((node) => !removeNodes.has(node)),
×
NEW
473
          links: JSONData.links.filter((link) => !removeLinks.has(link)),
×
474
        };
475

NEW
476
        self.data = JSONData;
×
NEW
477
        self.echarts.setOption(self.utils.generateMapOption(JSONData, self));
×
NEW
478
        self.bboxData.nodes = [];
×
NEW
479
        self.bboxData.links = [];
×
480
      };
481
      if (
5✔
482
        self.leaflet.getZoom() >= self.config.loadMoreAtZoomLevel &&
6✔
483
        self.hasMoreData
484
      ) {
485
        const data = await self.utils.getBBoxData.call(
1✔
486
          self,
487
          self.JSONParam,
488
          bounds,
489
        );
490
        self.config.prepareData.call(self, data);
1✔
491
        const dataNodeSet = new Set(self.data.nodes.map((n) => n.id));
1✔
492
        const sourceLinkSet = new Set(self.data.links.map((l) => l.source));
1✔
493
        const targetLinkSet = new Set(self.data.links.map((l) => l.target));
1✔
494
        const nodes = data.nodes.filter((node) => !dataNodeSet.has(node.id));
1✔
495
        const links = data.links.filter(
1✔
496
          (link) =>
NEW
497
            !sourceLinkSet.has(link.source) && !targetLinkSet.has(link.target),
×
498
        );
499
        const boundsDataSet = new Set(data.nodes.map((n) => n.id));
1✔
500
        const nonCommonNodes = self.bboxData.nodes.filter(
1✔
NEW
501
          (node) => !boundsDataSet.has(node.id),
×
502
        );
503
        const removableNodes = new Set(nonCommonNodes.map((n) => n.id));
1✔
504

505
        JSONData.nodes = JSONData.nodes.filter(
1✔
NEW
506
          (node) => !removableNodes.has(node.id),
×
507
        );
508
        self.bboxData.nodes = self.bboxData.nodes.concat(nodes);
1✔
509
        self.bboxData.links = self.bboxData.links.concat(links);
1✔
510
        JSONData = {
1✔
511
          ...JSONData,
512
          nodes: JSONData.nodes.concat(nodes),
513
          links: JSONData.links.concat(links),
514
        };
515
        self.echarts.setOption(self.utils.generateMapOption(JSONData, self));
1✔
516
        self.data = JSONData;
1✔
517
      } else if (self.hasMoreData && self.bboxData.nodes.length > 0) {
4!
518
        removeBBoxData();
×
519
      }
520
    });
521
    if (
8!
522
      self.config.clustering &&
10✔
523
      self.config.clusteringThreshold < JSONData.nodes.length
524
    ) {
525
      let {clusters, nonClusterNodes, nonClusterLinks} =
526
        self.utils.makeCluster(self);
×
527

528
      // Only show clusters if we're below the disableClusteringAtLevel
529
      if (self.leaflet.getZoom() > self.config.disableClusteringAtLevel) {
×
530
        clusters = [];
×
531
        nonClusterNodes = JSONData.nodes;
×
532
        nonClusterLinks = JSONData.links;
×
533
      }
534

535
      self.echarts.setOption(
×
536
        self.utils.generateMapOption(
537
          {
538
            ...JSONData,
539
            nodes: nonClusterNodes,
540
            links: nonClusterLinks,
541
          },
542
          self,
543
          clusters,
544
        ),
545
      );
546

547
      self.echarts.on("click", (params) => {
×
548
        if (
×
549
          (params.componentSubType === "scatter" ||
×
550
            params.componentSubType === "effectScatter") &&
551
          params.data.cluster
552
        ) {
553
          nonClusterNodes = nonClusterNodes.concat(params.data.childNodes);
×
554
          clusters = clusters.filter(
×
555
            (cluster) => cluster.id !== params.data.id,
×
556
          );
557
          self.echarts.setOption(
×
558
            self.utils.generateMapOption(
559
              {
560
                ...JSONData,
561
                nodes: nonClusterNodes,
562
              },
563
              self,
564
              clusters,
565
            ),
566
          );
567
          self.leaflet.setView([params.data.value[1], params.data.value[0]]);
×
568
        }
569
      });
570

571
      // Ensure zoom handler consistently applies the same clustering logic
572
      self.leaflet.on("zoomend", () => {
×
573
        if (self.leaflet.getZoom() < self.config.disableClusteringAtLevel) {
×
574
          const nodeData = self.utils.makeCluster(self);
×
575
          clusters = nodeData.clusters;
×
576
          nonClusterNodes = nodeData.nonClusterNodes;
×
577
          nonClusterLinks = nodeData.nonClusterLinks;
×
578
          self.echarts.setOption(
×
579
            self.utils.generateMapOption(
580
              {
581
                ...JSONData,
582
                nodes: nonClusterNodes,
583
                links: nonClusterLinks,
584
              },
585
              self,
586
              clusters,
587
            ),
588
          );
589
        } else {
590
          // When above the threshold, show all nodes without clustering
591
          self.echarts.setOption(self.utils.generateMapOption(JSONData, self));
×
592
        }
593
      });
594
    }
595

596
    self.event.emit("onLoad");
8✔
597
    self.event.emit("onReady");
8✔
598
    self.event.emit("renderArray");
8✔
599
  }
600

601
  /**
602
   * @function
603
   * @name appendData
604
   * Append new data. Can only be used for `map` render!
605
   *
606
   * @param  {object}         JSONData   Data
607
   * @param  {object}         self     NetJSONGraph object
608
   *
609
   */
610
  appendData(JSONData, self) {
611
    if (self.config.render !== self.utils.mapRender) {
1!
612
      throw new Error("AppendData function can only be used for map render!");
×
613
    }
614

615
    if (self.config.render === self.utils.mapRender) {
1!
616
      const opts = self.utils.generateMapOption(JSONData, self);
1✔
617
      opts.series.forEach((obj, index) => {
1✔
618
        self.echarts.appendData({seriesIndex: index, data: obj.data});
2✔
619
      });
620
      // modify this.data
621
      self.utils.mergeData(JSONData, self);
1✔
622
    }
623

624
    self.config.afterUpdate.call(self);
1✔
625
  }
626

627
  /**
628
   * @function
629
   * @name addData
630
   * Add new data. Mainly used for `graph` render.
631
   *
632
   * @param  {object}         JSONData      Data
633
   * @param  {object}         self        NetJSONGraph object
634
   */
635
  addData(JSONData, self) {
636
    // modify this.data
637
    self.utils.mergeData(JSONData, self);
1✔
638

639
    // Ensure nodes are unique by ID using the utility function
640
    if (self.data.nodes && self.data.nodes.length > 0) {
1!
641
      self.data.nodes = self.utils.deduplicateNodesById(self.data.nodes);
1✔
642
    }
643

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

647
    self.config.afterUpdate.call(self);
1✔
648
  }
649

650
  /**
651
   * @function
652
   * @name mergeData
653
   * Merge new data. Modify this.data.
654
   *
655
   * @param  {object}         JSONData      Data
656
   * @param  {object}         self        NetJSONGraph object
657
   */
658
  mergeData(JSONData, self) {
659
    // Ensure incoming nodes array exists
660
    if (!JSONData.nodes) {
3!
661
      JSONData.nodes = [];
×
662
    }
663

664
    // Create a set of existing node IDs for efficient lookup
665
    const existingNodeIds = new Set();
3✔
666
    self.data.nodes.forEach((node) => {
3✔
667
      if (node.id) {
5!
668
        existingNodeIds.add(node.id);
5✔
669
      }
670
    });
671

672
    // Filter incoming nodes: keep nodes without IDs or with new IDs
673
    const newNodes = JSONData.nodes.filter((node) => {
3✔
674
      if (!node.id) {
3!
675
        return true;
×
676
      }
677
      if (existingNodeIds.has(node.id)) {
3✔
678
        console.warn(
1✔
679
          `Duplicate node ID ${node.id} detected during merge and skipped.`,
680
        );
681
        return false;
1✔
682
      }
683
      return true;
2✔
684
    });
685

686
    const nodes = self.data.nodes.concat(newNodes);
3✔
687
    // Ensure incoming links array exists
688
    const incomingLinks = JSONData.links || [];
3!
689
    const links = self.data.links.concat(incomingLinks);
3✔
690

691
    Object.assign(self.data, JSONData, {
3✔
692
      nodes,
693
      links,
694
    });
695
  }
696
}
697

698
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