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

openwisp / netjsongraph.js / 17011380063

16 Aug 2025 06:03PM UTC coverage: 80.95% (+0.03%) from 80.924%
17011380063

push

github

nemesifier
[qa] Reformat JS code

565 of 747 branches covered (75.64%)

Branch coverage included in aggregate %.

28 of 34 new or added lines in 7 files covered. (82.35%)

1 existing line in 1 file now uncovered.

901 of 1064 relevant lines covered (84.68%)

7.45 hits per line

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

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

14
echarts.use([
2✔
15
  GraphChart,
16
  EffectScatterChart,
17
  LinesChart,
18
  TooltipComponent,
19
  TitleComponent,
20
  ToolboxComponent,
21
  LegendComponent,
22
  SVGRenderer,
23
  ScatterChart,
24
]);
25

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

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

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

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

110
    return echartsLayer;
×
111
  }
112

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

133
      nodeResult.itemStyle = nodeStyleConfig;
×
134
      nodeResult.symbolSize = nodeSizeConfig;
×
135
      nodeResult.emphasis = {
×
136
        itemStyle: nodeEmphasisConfig.nodeStyle,
137
        symbolSize: nodeEmphasisConfig.nodeSize,
138
      };
139
      nodeResult.name = typeof node.label === "string" ? node.label : "";
×
140

141
      return nodeResult;
×
142
    });
143
    const links = JSONData.links.map((link) => {
×
144
      const linkResult = JSON.parse(JSON.stringify(link));
×
145
      const {linkStyleConfig, linkEmphasisConfig} = self.utils.getLinkStyle(
×
146
        link,
147
        configs,
148
        "graph",
149
      );
150

151
      linkResult.lineStyle = linkStyleConfig;
×
152
      linkResult.emphasis = {lineStyle: linkEmphasisConfig.linkStyle};
×
153

154
      return linkResult;
×
155
    });
156

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

174
    return {
×
175
      legend,
176
      series,
177
      ...configs.graphConfig.baseOptions,
178
    };
179
  }
180

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

200
    nodes.forEach((node) => {
17✔
201
      if (node.properties) {
16!
202
        // Maintain flatNodes lookup regardless of whether the node is rendered as a marker
203
        if (!JSONData.flatNodes) {
16!
204
          flatNodes[node.id] = JSON.parse(JSON.stringify(node));
16✔
205
        }
206
      }
207

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

223
        if (!location || !location.lng || !location.lat) {
16!
224
          console.error(`Node ${node.id} position is undefined!`);
16✔
225
        } else {
NEW
226
          const {nodeEmphasisConfig} = self.utils.getNodeStyle(node, configs, "map");
×
227

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

269
    nodesData = nodesData.concat(clusters);
16✔
270

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

346
    return {
16✔
347
      leaflet: {
348
        tiles: configs.mapTileConfig,
349
        mapOptions: configs.mapOptions,
350
      },
351
      series,
352
      ...configs.mapOptions.baseOptions,
353
    };
354
  }
355

356
  /**
357
   * @function
358
   * @name graphRender
359
   *
360
   * Render the final graph result based on JSONData.
361
   * @param  {object}  JSONData        Render data
362
   * @param  {object}  self          NetJSONGraph object
363
   *
364
   */
365
  graphRender(JSONData, self) {
NEW
366
    self.utils.echartsSetOption(self.utils.generateGraphOption(JSONData, self), self);
×
367

368
    window.onresize = () => {
×
369
      self.echarts.resize();
×
370
    };
371

372
    self.event.emit("onLoad");
×
373
    self.event.emit("onReady");
×
374
    self.event.emit("renderArray");
×
375
  }
376

377
  /**
378
   * @function
379
   * @name mapRender
380
   *
381
   * Render the final map result based on JSONData.
382
   * @param  {object}  JSONData       Render data
383
   * @param  {object}  self         NetJSONGraph object
384
   *
385
   */
386
  mapRender(JSONData, self) {
387
    if (!self.config.mapTileConfig[0]) {
12!
388
      throw new Error(`You must add the tiles via the "mapTileConfig" param!`);
×
389
    }
390

391
    // Accept both NetJSON and GeoJSON inputs. If GeoJSON is detected,
392
    // deep-copy it for polygon overlays and convert the working copy to
393
    // NetJSON so the rest of the pipeline can operate uniformly.
394
    if (self.utils.isGeoJSON(JSONData)) {
12✔
395
      self.originalGeoJSON = JSON.parse(JSON.stringify(JSONData));
8✔
396
      JSONData = self.utils.geojsonToNetjson(JSONData);
8✔
397
      // From this point forward we treat the data as NetJSON internally,
398
      // but keep the public-facing `type` value unchanged ("geojson").
399
    }
400

401
    const initialMapOptions = self.utils.generateMapOption(JSONData, self);
12✔
402
    self.utils.echartsSetOption(initialMapOptions, self);
11✔
403
    self.bboxData = {
8✔
404
      nodes: [],
405
      links: [],
406
    };
407

408
    // eslint-disable-next-line no-underscore-dangle
409
    self.leaflet = self.echarts._api.getCoordinateSystems()[0].getLeaflet();
8✔
410
    // eslint-disable-next-line no-underscore-dangle
411
    self.leaflet._zoomAnimated = false;
8✔
412

413
    self.config.geoOptions = self.utils.deepMergeObj(
8✔
414
      {
415
        pointToLayer: (feature, latlng) =>
416
          L.circleMarker(latlng, self.config.geoOptions.style),
×
417
        onEachFeature: (feature, layer) => {
418
          layer.on("click", () => {
×
419
            const properties = {
×
420
              ...feature.properties,
421
            };
422
            self.config.onClickElement.call(self, "Feature", properties);
×
423
          });
424
        },
425
      },
426
      self.config.geoOptions,
427
    );
428

429
    // Render Polygon and MultiPolygon features from the original GeoJSON data.
430
    // While nodes (Points) and links (LineStrings) are handled by ECharts,
431
    // polygon features are rendered directly onto the Leaflet map using
432
    // a separate L.geoJSON layer. This allows for displaying geographical
433
    // areas like parks or districts alongside the network topology.
434
    if (self.originalGeoJSON) {
8!
435
      addPolygonOverlays(self);
8✔
436
      // Auto-fit view to encompass ALL geometries (polygons + nodes)
437
      let bounds = null;
8✔
438

439
      // 1. Polygon overlays (if any)
440
      if (
8!
441
        self.leaflet.polygonGeoJSON &&
10✔
442
        typeof self.leaflet.polygonGeoJSON.getBounds === "function"
443
      ) {
444
        bounds = self.leaflet.polygonGeoJSON.getBounds();
×
445
      }
446

447
      // 2. Nodes (Points)
448
      if (JSONData.nodes && JSONData.nodes.length) {
8!
449
        const latlngs = JSONData.nodes
×
450
          .map((n) => n.properties.location)
×
451
          .map((loc) => [loc.lat, loc.lng]);
×
452
        if (bounds) {
×
453
          latlngs.forEach((ll) => bounds.extend(ll));
×
454
        } else {
455
          bounds = L.latLngBounds(latlngs);
×
456
        }
457
      }
458

459
      if (bounds && bounds.isValid()) {
8!
460
        self.leaflet.fitBounds(bounds, {padding: [20, 20]});
×
461
      }
462
    }
463

464
    if (self.leaflet.getZoom() < self.config.showLabelsAtZoomLevel) {
8✔
465
      self.echarts.setOption({
5✔
466
        series: [
467
          {
468
            label: {
469
              show: false,
470
            },
471
            emphasis: {
472
              label: {
473
                show: false,
474
              },
475
            },
476
          },
477
        ],
478
      });
479
    }
480

481
    self.leaflet.on("zoomend", () => {
8✔
482
      const currentZoom = self.leaflet.getZoom();
8✔
483
      const showLabel = currentZoom >= self.config.showLabelsAtZoomLevel;
8✔
484
      self.echarts.setOption({
8✔
485
        series: [
486
          {
487
            label: {
488
              show: showLabel,
489
            },
490
            emphasis: {
491
              label: {
492
                show: showLabel,
493
              },
494
            },
495
          },
496
        ],
497
      });
498

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

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

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

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

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

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

557
        JSONData.nodes = JSONData.nodes.filter((node) => !removableNodes.has(node.id));
1✔
558
        self.bboxData.nodes = self.bboxData.nodes.concat(nodes);
1✔
559
        self.bboxData.links = self.bboxData.links.concat(links);
1✔
560
        JSONData = {
1✔
561
          ...JSONData,
562
          nodes: JSONData.nodes.concat(nodes),
563
          links: JSONData.links.concat(links),
564
        };
565
        self.echarts.setOption(self.utils.generateMapOption(JSONData, self));
1✔
566
        self.data = JSONData;
1✔
567
      } else if (self.hasMoreData && self.bboxData.nodes.length > 0) {
4!
568
        removeBBoxData();
×
569
      }
570
    });
571
    if (
8!
572
      self.config.clustering &&
10✔
573
      self.config.clusteringThreshold < JSONData.nodes.length
574
    ) {
NEW
575
      let {clusters, nonClusterNodes, nonClusterLinks} = self.utils.makeCluster(self);
×
576

577
      // Only show clusters if we're below the disableClusteringAtLevel
578
      if (self.leaflet.getZoom() > self.config.disableClusteringAtLevel) {
×
579
        clusters = [];
×
580
        nonClusterNodes = JSONData.nodes;
×
581
        nonClusterLinks = JSONData.links;
×
582
      }
583

584
      self.echarts.setOption(
×
585
        self.utils.generateMapOption(
586
          {
587
            ...JSONData,
588
            nodes: nonClusterNodes,
589
            links: nonClusterLinks,
590
          },
591
          self,
592
          clusters,
593
        ),
594
      );
595

596
      self.echarts.on("click", (params) => {
×
597
        if (
×
598
          (params.componentSubType === "scatter" ||
×
599
            params.componentSubType === "effectScatter") &&
600
          params.data.cluster
601
        ) {
602
          // Zoom into the clicked cluster instead of expanding it
603
          const currentZoom = self.leaflet.getZoom();
×
NEW
604
          const targetZoom = Math.min(currentZoom + 2, self.leaflet.getMaxZoom());
×
UNCOV
605
          self.leaflet.setView(
×
606
            [params.data.value[1], params.data.value[0]],
607
            targetZoom,
608
          );
609
        }
610
      });
611

612
      // Ensure zoom handler consistently applies the same clustering logic
613
      self.leaflet.on("zoomend", () => {
×
614
        if (self.leaflet.getZoom() < self.config.disableClusteringAtLevel) {
×
615
          const nodeData = self.utils.makeCluster(self);
×
616
          clusters = nodeData.clusters;
×
617
          nonClusterNodes = nodeData.nonClusterNodes;
×
618
          nonClusterLinks = nodeData.nonClusterLinks;
×
619
          self.echarts.setOption(
×
620
            self.utils.generateMapOption(
621
              {
622
                ...JSONData,
623
                nodes: nonClusterNodes,
624
                links: nonClusterLinks,
625
              },
626
              self,
627
              clusters,
628
            ),
629
          );
630
        } else {
631
          // When above the threshold, show all nodes without clustering
632
          self.echarts.setOption(self.utils.generateMapOption(JSONData, self));
×
633
        }
634
      });
635
    }
636

637
    self.event.emit("onLoad");
8✔
638
    self.event.emit("onReady");
8✔
639
    self.event.emit("renderArray");
8✔
640
  }
641

642
  /**
643
   * @function
644
   * @name appendData
645
   * Append new data. Can only be used for `map` render!
646
   *
647
   * @param  {object}         JSONData   Data
648
   * @param  {object}         self     NetJSONGraph object
649
   *
650
   */
651
  appendData(JSONData, self) {
652
    if (self.config.render !== self.utils.mapRender) {
1!
653
      throw new Error("AppendData function can only be used for map render!");
×
654
    }
655

656
    if (self.config.render === self.utils.mapRender) {
1!
657
      const opts = self.utils.generateMapOption(JSONData, self);
1✔
658
      opts.series.forEach((obj, index) => {
1✔
659
        self.echarts.appendData({seriesIndex: index, data: obj.data});
2✔
660
      });
661
      // modify this.data
662
      self.utils.mergeData(JSONData, self);
1✔
663
    }
664

665
    self.config.afterUpdate.call(self);
1✔
666
  }
667

668
  /**
669
   * @function
670
   * @name addData
671
   * Add new data. Mainly used for `graph` render.
672
   *
673
   * @param  {object}         JSONData      Data
674
   * @param  {object}         self        NetJSONGraph object
675
   */
676
  addData(JSONData, self) {
677
    // modify this.data
678
    self.utils.mergeData(JSONData, self);
1✔
679

680
    // Ensure nodes are unique by ID using the utility function
681
    if (self.data.nodes && self.data.nodes.length > 0) {
1!
682
      self.data.nodes = self.utils.deduplicateNodesById(self.data.nodes);
1✔
683
    }
684

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

688
    self.config.afterUpdate.call(self);
1✔
689
  }
690

691
  /**
692
   * @function
693
   * @name mergeData
694
   * Merge new data. Modify this.data.
695
   *
696
   * @param  {object}         JSONData      Data
697
   * @param  {object}         self        NetJSONGraph object
698
   */
699
  mergeData(JSONData, self) {
700
    // Ensure incoming nodes array exists
701
    if (!JSONData.nodes) {
3!
702
      JSONData.nodes = [];
×
703
    }
704

705
    // Create a set of existing node IDs for efficient lookup
706
    const existingNodeIds = new Set();
3✔
707
    self.data.nodes.forEach((node) => {
3✔
708
      if (node.id) {
5!
709
        existingNodeIds.add(node.id);
5✔
710
      }
711
    });
712

713
    // Filter incoming nodes: keep nodes without IDs or with new IDs
714
    const newNodes = JSONData.nodes.filter((node) => {
3✔
715
      if (!node.id) {
3!
716
        return true;
×
717
      }
718
      if (existingNodeIds.has(node.id)) {
3✔
719
        console.warn(`Duplicate node ID ${node.id} detected during merge and skipped.`);
1✔
720
        return false;
1✔
721
      }
722
      return true;
2✔
723
    });
724

725
    const nodes = self.data.nodes.concat(newNodes);
3✔
726
    // Ensure incoming links array exists
727
    const incomingLinks = JSONData.links || [];
3!
728
    const links = self.data.links.concat(incomingLinks);
3✔
729

730
    Object.assign(self.data, JSONData, {
3✔
731
      nodes,
732
      links,
733
    });
734
  }
735
}
736

737
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