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

openwisp / netjsongraph.js / 17866282316

19 Sep 2025 06:10PM UTC coverage: 83.333% (-0.3%) from 83.597%
17866282316

push

github

web-flow
[chores] Refactor: enforce curly braces for all control statements

682 of 876 branches covered (77.85%)

Branch coverage included in aggregate %.

19 of 32 new or added lines in 4 files covered. (59.38%)

2 existing lines in 2 files now uncovered.

1068 of 1224 relevant lines covered (87.25%)

18.3 hits per line

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

68.27
/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
  GraphicComponent,
9
} from "echarts/components";
10
import {SVGRenderer} from "echarts/renderers";
11
import L from "leaflet/dist/leaflet";
12
import "echarts-gl";
13
import {addPolygonOverlays} from "./netjsongraph.geojson";
14

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

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

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

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

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

112
    return echartsLayer;
×
113
  }
114

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

135
      nodeResult.itemStyle = nodeStyleConfig;
4✔
136
      nodeResult.symbolSize = nodeSizeConfig;
4✔
137
      nodeResult.emphasis = {
4✔
138
        itemStyle: nodeEmphasisConfig.nodeStyle,
139
        symbolSize: nodeEmphasisConfig.nodeSize,
140
      };
141
      let resolvedName = "";
4✔
142
      if (typeof node.label === "string") {
4✔
143
        resolvedName = node.label;
1✔
144
      } else if (typeof node.name === "string") {
3✔
145
        resolvedName = node.name;
2✔
146
      } else if (node.id !== undefined && node.id !== null) {
1!
147
        resolvedName = String(node.id);
1✔
148
      }
149
      nodeResult.name = resolvedName;
4✔
150
      // Preserve original NetJSON node for sidebar use
151
      /* eslint-disable no-underscore-dangle */
152
      nodeResult._source = JSON.parse(JSON.stringify(node));
4✔
153
      return nodeResult;
4✔
154
    });
155
    const links = JSONData.links.map((link) => {
2✔
156
      const linkResult = JSON.parse(JSON.stringify(link));
×
157
      const {linkStyleConfig, linkEmphasisConfig} = self.utils.getLinkStyle(
×
158
        link,
159
        configs,
160
        "graph",
161
      );
162

163
      linkResult.lineStyle = linkStyleConfig;
×
164
      linkResult.emphasis = {lineStyle: linkEmphasisConfig.linkStyle};
×
165

166
      return linkResult;
×
167
    });
168

169
    // Clone label config to avoid mutating defaults
170
    const baseGraphSeries = {...configs.graphConfig.series};
2✔
171
    const baseGraphLabel = {...(baseGraphSeries.label || {})};
2!
172

173
    // Shared helper to get current graph zoom level
174
    const getGraphZoom = () => {
2✔
175
      try {
2✔
176
        const option = self.echarts.getOption();
2✔
177
        const series = Array.isArray(option.series) ? option.series : [];
2!
178
        const graphSeries = series.find((s) => s && s.id === "network-graph");
2✔
179
        return graphSeries && typeof graphSeries.zoom === "number"
2!
180
          ? graphSeries.zoom
181
          : 1;
182
      } catch (e) {
183
        return 1;
×
184
      }
185
    };
186

187
    // Set up dynamic label visibility based on zoom threshold
188
    if (
2✔
189
      typeof self.config.showGraphLabelsAtZoom === "number" &&
3✔
190
      self.config.showGraphLabelsAtZoom > 0
191
    ) {
192
      const threshold = self.config.showGraphLabelsAtZoom;
1✔
193
      baseGraphLabel.formatter = (params) =>
1✔
194
        getGraphZoom() >= threshold
2✔
195
          ? (params && params.data && params.data.name) || ""
3!
196
          : "";
197
    }
198
    baseGraphSeries.label = baseGraphLabel;
2✔
199
    const series = [
2✔
200
      {
201
        ...baseGraphSeries,
202
        id: "network-graph",
203
        type: configs.graphConfig.series.type === "graphGL" ? "graphGL" : "graph",
2!
204
        layout:
205
          configs.graphConfig.series.type === "graphGL"
2!
206
            ? "forceAtlas2"
207
            : configs.graphConfig.series.layout,
208
        nodes,
209
        links,
210
      },
211
    ];
212
    const legend = categories.length
2!
213
      ? {
214
          data: categories,
215
        }
216
      : undefined;
217

218
    return {
2✔
219
      legend,
220
      series,
221
      ...configs.graphConfig.baseOptions,
222
    };
223
  }
224

225
  /**
226
   * @function
227
   * @name generateMapOption
228
   *
229
   * generate map option in echarts by JSONData.
230
   *
231
   * @param  {object}  JSONData        Render data
232
   * @param  {object}  self          NetJSONGraph object
233
   *
234
   * @return {object}  map option
235
   *
236
   */
237
  generateMapOption(JSONData, self, clusters = []) {
18✔
238
    const configs = self.config;
18✔
239
    const {nodes, links} = JSONData;
18✔
240
    const flatNodes = JSONData.flatNodes || {};
18✔
241
    const linesData = [];
18✔
242
    let nodesData = [];
18✔
243

244
    nodes.forEach((node) => {
18✔
245
      if (node.properties) {
19!
246
        // Maintain flatNodes lookup regardless of whether the node is rendered as a marker
247
        if (!JSONData.flatNodes) {
19✔
248
          flatNodes[node.id] = JSON.parse(JSON.stringify(node));
16✔
249
        }
250
      }
251

252
      // Non-Point geometries should not become scatter markers, but we still need them for lines
253
      if (
19!
254
        node.properties &&
54✔
255
        // eslint-disable-next-line no-underscore-dangle
256
        node.properties._featureType &&
257
        // eslint-disable-next-line no-underscore-dangle
258
        node.properties._featureType !== "Point"
259
      ) {
260
        return; // skip marker push only
×
261
      }
262
      if (!node.properties) {
19!
263
        console.error(`Node ${node.id} position is undefined!`);
×
264
      } else {
265
        const {location} = node.properties;
19✔
266

267
        if (!location || !location.lng || !location.lat) {
19✔
268
          console.error(`Node ${node.id} position is undefined!`);
16✔
269
        } else {
270
          const {nodeEmphasisConfig} = self.utils.getNodeStyle(node, configs, "map");
3✔
271

272
          let nodeName = "";
3✔
273
          if (typeof node.label === "string") {
3✔
274
            nodeName = node.label;
1✔
275
          } else if (typeof node.name === "string") {
2✔
276
            nodeName = node.name;
1✔
277
          } else if (node.id !== undefined && node.id !== null) {
1!
278
            nodeName = String(node.id);
1✔
279
          }
280
          nodesData.push({
3✔
281
            name: nodeName,
282
            value: [location.lng, location.lat],
283
            emphasis: {
284
              itemStyle: nodeEmphasisConfig.nodeStyle,
285
              symbolSize: nodeEmphasisConfig.nodeSize,
286
            },
287
            node,
288
            _source: JSON.parse(JSON.stringify(node)),
289
          });
290
        }
291
      }
292
    });
293
    links.forEach((link) => {
18✔
294
      if (!flatNodes[link.source]) {
2!
295
        console.warn(`Node ${link.source} does not exist!`);
×
296
      } else if (!flatNodes[link.target]) {
2!
297
        console.warn(`Node ${link.target} does not exist!`);
×
298
      } else {
299
        const {linkStyleConfig, linkEmphasisConfig} = self.utils.getLinkStyle(
2✔
300
          link,
301
          configs,
302
          "map",
303
        );
304
        linesData.push({
2✔
305
          coords: [
306
            [
307
              flatNodes[link.source].properties.location.lng,
308
              flatNodes[link.source].properties.location.lat,
309
            ],
310
            [
311
              flatNodes[link.target].properties.location.lng,
312
              flatNodes[link.target].properties.location.lat,
313
            ],
314
          ],
315
          lineStyle: linkStyleConfig,
316
          emphasis: {lineStyle: linkEmphasisConfig.linkStyle},
317
          link,
318
        });
319
      }
320
    });
321

322
    nodesData = nodesData.concat(clusters);
17✔
323

324
    const series = [
17✔
325
      {
326
        id: "geo-map",
327
        type:
328
          configs.mapOptions.nodeConfig.type === "effectScatter"
17!
329
            ? "effectScatter"
330
            : "scatter",
331
        name: "nodes",
332
        coordinateSystem: "leaflet",
333
        data: nodesData,
334
        animationDuration: 1000,
335
        label: configs.mapOptions.nodeConfig.label,
336
        itemStyle: {
337
          color: (params) => {
338
            if (
5✔
339
              params.data &&
12✔
340
              params.data.cluster &&
341
              params.data.itemStyle &&
342
              params.data.itemStyle.color
343
            ) {
344
              return params.data.itemStyle.color;
1✔
345
            }
346
            if (params.data && params.data.node && params.data.node.category) {
4✔
347
              const category = configs.nodeCategories.find(
2✔
348
                (cat) => cat.name === params.data.node.category,
1✔
349
              );
350
              const nodeColor =
351
                (category && category.nodeStyle && category.nodeStyle.color) ||
2!
352
                (configs.mapOptions.nodeConfig &&
353
                  configs.mapOptions.nodeConfig.nodeStyle &&
354
                  configs.mapOptions.nodeConfig.nodeStyle.color) ||
355
                "#6c757d";
356
              return nodeColor;
2✔
357
            }
358
            const defaultColor =
359
              (configs.mapOptions.nodeConfig &&
2✔
360
                configs.mapOptions.nodeConfig.nodeStyle &&
361
                configs.mapOptions.nodeConfig.nodeStyle.color) ||
362
              "#6c757d";
363
            return defaultColor;
2✔
364
          },
365
        },
366
        symbolSize: (value, params) => {
367
          if (params.data && params.data.cluster) {
7✔
368
            return (
2✔
369
              (configs.mapOptions.clusterConfig &&
5✔
370
                configs.mapOptions.clusterConfig.symbolSize) ||
371
              30
372
            );
373
          }
374
          if (params.data && params.data.node) {
5✔
375
            const {nodeSizeConfig} = self.utils.getNodeStyle(
3✔
376
              params.data.node,
377
              configs,
378
              "map",
379
            );
380
            return typeof nodeSizeConfig === "object"
3✔
381
              ? (configs.mapOptions.nodeConfig &&
5✔
382
                  configs.mapOptions.nodeConfig.nodeSize) ||
383
                  17
384
              : nodeSizeConfig;
385
          }
386
          return (
2✔
387
            (configs.mapOptions.nodeConfig && configs.mapOptions.nodeConfig.nodeSize) ||
5✔
388
            17
389
          );
390
        },
391
        emphasis: configs.mapOptions.nodeConfig.emphasis,
392
      },
393
      Object.assign(configs.mapOptions.linkConfig, {
394
        id: "map-links",
395
        type: "lines",
396
        coordinateSystem: "leaflet",
397
        data: linesData,
398
      }),
399
    ];
400

401
    return {
17✔
402
      leaflet: {
403
        tiles: configs.mapTileConfig,
404
        mapOptions: configs.mapOptions,
405
      },
406
      series,
407
      ...configs.mapOptions.baseOptions,
408
    };
409
  }
410

411
  /**
412
   * @function
413
   * @name graphRender
414
   *
415
   * Render the final graph result based on JSONData.
416
   * @param  {object}  JSONData        Render data
417
   * @param  {object}  self          NetJSONGraph object
418
   *
419
   */
420
  graphRender(JSONData, self) {
421
    self.utils.echartsSetOption(self.utils.generateGraphOption(JSONData, self), self);
1✔
422

423
    window.onresize = () => {
1✔
424
      self.echarts.resize();
×
425
    };
426

427
    // Toggle labels on zoom threshold crossing
428
    if (self.config.showGraphLabelsAtZoom > 0) {
1!
429
      self.echarts.on("graphRoam", (params) => {
1✔
430
        if (!params || !params.zoom) {
1!
NEW
431
          return;
×
432
        }
433
        const option = self.echarts.getOption();
1✔
434
        const labelsVisible =
435
          option &&
1✔
436
          option.series &&
437
          option.series[0] &&
438
          option.series[0].zoom >= self.config.showGraphLabelsAtZoom;
439
        if (labelsVisible !== self._labelsVisible) {
1!
440
          self.echarts.resize({animation: false, silent: true});
1✔
441
          self._labelsVisible = labelsVisible;
1✔
442
        }
443
      });
444
    }
445

446
    self.event.emit("onLoad");
1✔
447
    self.event.emit("onReady");
1✔
448
    self.event.emit("renderArray");
1✔
449
  }
450

451
  /**
452
   * @function
453
   * @name mapRender
454
   *
455
   * Render the final map result based on JSONData.
456
   * @param  {object}  JSONData       Render data
457
   * @param  {object}  self         NetJSONGraph object
458
   *
459
   */
460
  mapRender(JSONData, self) {
461
    if (!self.config.mapTileConfig[0]) {
13!
462
      throw new Error(`You must add the tiles via the "mapTileConfig" param!`);
×
463
    }
464

465
    // Accept both NetJSON and GeoJSON inputs. If GeoJSON is detected,
466
    // deep-copy it for polygon overlays and convert the working copy to
467
    // NetJSON so the rest of the pipeline can operate uniformly.
468
    if (self.utils.isGeoJSON(JSONData)) {
13✔
469
      self.originalGeoJSON = JSON.parse(JSON.stringify(JSONData));
9✔
470
      JSONData = self.utils.geojsonToNetjson(JSONData);
9✔
471
      // From this point forward we treat the data as NetJSON internally,
472
      // but keep the public-facing `type` value unchanged ("geojson").
473
    }
474

475
    const initialMapOptions = self.utils.generateMapOption(JSONData, self);
13✔
476
    self.utils.echartsSetOption(initialMapOptions, self);
12✔
477
    self.bboxData = {
9✔
478
      nodes: [],
479
      links: [],
480
    };
481

482
    // eslint-disable-next-line no-underscore-dangle
483
    self.leaflet = self.echarts._api.getCoordinateSystems()[0].getLeaflet();
9✔
484
    // eslint-disable-next-line no-underscore-dangle
485
    self.leaflet._zoomAnimated = false;
9✔
486

487
    self.config.geoOptions = self.utils.deepMergeObj(
9✔
488
      {
489
        pointToLayer: (feature, latlng) =>
490
          L.circleMarker(latlng, self.config.geoOptions.style),
×
491
        onEachFeature: (feature, layer) => {
492
          layer.on("click", () => {
×
493
            const properties = {
×
494
              ...feature.properties,
495
            };
496
            self.config.onClickElement.call(self, "Feature", properties);
×
497
          });
498
        },
499
      },
500
      self.config.geoOptions,
501
    );
502

503
    // Render Polygon and MultiPolygon features from the original GeoJSON data.
504
    // While nodes (Points) and links (LineStrings) are handled by ECharts,
505
    // polygon features are rendered directly onto the Leaflet map using
506
    // a separate L.geoJSON layer. This allows for displaying geographical
507
    // areas like parks or districts alongside the network topology.
508
    if (self.originalGeoJSON) {
9!
509
      addPolygonOverlays(self);
9✔
510
      // Auto-fit view to encompass ALL geometries (polygons + nodes)
511
      let bounds = null;
9✔
512

513
      // 1. Polygon overlays (if any)
514
      if (
9!
515
        self.leaflet.polygonGeoJSON &&
11✔
516
        typeof self.leaflet.polygonGeoJSON.getBounds === "function"
517
      ) {
518
        bounds = self.leaflet.polygonGeoJSON.getBounds();
×
519
      }
520

521
      // 2. Nodes (Points)
522
      if (JSONData.nodes && JSONData.nodes.length) {
9!
523
        const latlngs = JSONData.nodes
×
524
          .map((n) => n.properties.location)
×
525
          .map((loc) => [loc.lat, loc.lng]);
×
526
        if (bounds) {
×
527
          latlngs.forEach((ll) => bounds.extend(ll));
×
528
        } else {
529
          bounds = L.latLngBounds(latlngs);
×
530
        }
531
      }
532

533
      if (bounds && bounds.isValid()) {
9!
534
        self.leaflet.fitBounds(bounds, {padding: [20, 20]});
×
535
      }
536
    }
537

538
    if (self.leaflet.getZoom() < self.config.showLabelsAtZoomLevel) {
9✔
539
      self.echarts.setOption({
6✔
540
        series: [
541
          {
542
            id: "geo-map",
543
            label: {
544
              show: false,
545
            },
546
            emphasis: {
547
              label: {
548
                show: false,
549
              },
550
            },
551
          },
552
        ],
553
      });
554
    }
555

556
    self.leaflet.on("zoomend", () => {
9✔
557
      const currentZoom = self.leaflet.getZoom();
9✔
558
      const showLabel = currentZoom >= self.config.showLabelsAtZoomLevel;
9✔
559
      self.echarts.setOption({
9✔
560
        series: [
561
          {
562
            id: "geo-map",
563
            label: {
564
              show: showLabel,
565
            },
566
            emphasis: {
567
              label: {
568
                show: showLabel,
569
              },
570
            },
571
          },
572
        ],
573
      });
574

575
      // Zoom in/out buttons disabled only when it is equal to min/max zoomlevel
576
      // Manually handle zoom control state to ensure correct behavior with float zoom levels
577
      const minZoom = self.leaflet.getMinZoom();
9✔
578
      const maxZoom = self.leaflet.getMaxZoom();
9✔
579
      const zoomIn = document.querySelector(".leaflet-control-zoom-in");
9✔
580
      const zoomOut = document.querySelector(".leaflet-control-zoom-out");
9✔
581

582
      if (zoomIn && zoomOut) {
9!
583
        if (Math.round(currentZoom) >= maxZoom) {
9✔
584
          zoomIn.classList.add("leaflet-disabled");
4✔
585
        } else {
586
          zoomIn.classList.remove("leaflet-disabled");
5✔
587
        }
588

589
        if (Math.round(currentZoom) <= minZoom) {
9✔
590
          zoomOut.classList.add("leaflet-disabled");
2✔
591
        } else {
592
          zoomOut.classList.remove("leaflet-disabled");
7✔
593
        }
594
      }
595
    });
596

597
    self.leaflet.on("moveend", async () => {
9✔
598
      const bounds = self.leaflet.getBounds();
5✔
599
      const removeBBoxData = () => {
5✔
600
        const removeNodes = new Set(self.bboxData.nodes);
×
601
        const removeLinks = new Set(self.bboxData.links);
×
602

603
        JSONData = {
×
604
          ...JSONData,
605
          nodes: JSONData.nodes.filter((node) => !removeNodes.has(node)),
×
606
          links: JSONData.links.filter((link) => !removeLinks.has(link)),
×
607
        };
608

609
        self.data = JSONData;
×
610
        self.echarts.setOption(self.utils.generateMapOption(JSONData, self));
×
611
        self.bboxData.nodes = [];
×
612
        self.bboxData.links = [];
×
613
      };
614
      if (
5✔
615
        self.leaflet.getZoom() >= self.config.loadMoreAtZoomLevel &&
6✔
616
        self.hasMoreData
617
      ) {
618
        const data = await self.utils.getBBoxData.call(self, self.JSONParam, bounds);
1✔
619
        self.config.prepareData.call(self, data);
1✔
620
        const dataNodeSet = new Set(self.data.nodes.map((n) => n.id));
1✔
621
        const sourceLinkSet = new Set(self.data.links.map((l) => l.source));
1✔
622
        const targetLinkSet = new Set(self.data.links.map((l) => l.target));
1✔
623
        const nodes = data.nodes.filter((node) => !dataNodeSet.has(node.id));
1✔
624
        const links = data.links.filter(
1✔
625
          (link) => !sourceLinkSet.has(link.source) && !targetLinkSet.has(link.target),
×
626
        );
627
        const boundsDataSet = new Set(data.nodes.map((n) => n.id));
1✔
628
        const nonCommonNodes = self.bboxData.nodes.filter(
1✔
629
          (node) => !boundsDataSet.has(node.id),
×
630
        );
631
        const removableNodes = new Set(nonCommonNodes.map((n) => n.id));
1✔
632

633
        JSONData.nodes = JSONData.nodes.filter((node) => !removableNodes.has(node.id));
1✔
634
        self.bboxData.nodes = self.bboxData.nodes.concat(nodes);
1✔
635
        self.bboxData.links = self.bboxData.links.concat(links);
1✔
636
        JSONData = {
1✔
637
          ...JSONData,
638
          nodes: JSONData.nodes.concat(nodes),
639
          links: JSONData.links.concat(links),
640
        };
641
        self.echarts.setOption(self.utils.generateMapOption(JSONData, self));
1✔
642
        self.data = JSONData;
1✔
643
      } else if (self.hasMoreData && self.bboxData.nodes.length > 0) {
4!
644
        removeBBoxData();
×
645
      }
646
    });
647
    if (
9!
648
      self.config.clustering &&
11✔
649
      self.config.clusteringThreshold < JSONData.nodes.length
650
    ) {
651
      let {clusters, nonClusterNodes, nonClusterLinks} = self.utils.makeCluster(self);
×
652

653
      // Only show clusters if we're below the disableClusteringAtLevel
654
      if (self.leaflet.getZoom() > self.config.disableClusteringAtLevel) {
×
655
        clusters = [];
×
656
        nonClusterNodes = JSONData.nodes;
×
657
        nonClusterLinks = JSONData.links;
×
658
      }
659

660
      self.echarts.setOption(
×
661
        self.utils.generateMapOption(
662
          {
663
            ...JSONData,
664
            nodes: nonClusterNodes,
665
            links: nonClusterLinks,
666
          },
667
          self,
668
          clusters,
669
        ),
670
      );
671

672
      self.echarts.on("click", (params) => {
×
673
        if (
×
674
          (params.componentSubType === "scatter" ||
×
675
            params.componentSubType === "effectScatter") &&
676
          params.data.cluster
677
        ) {
678
          // Zoom into the clicked cluster instead of expanding it
679
          const currentZoom = self.leaflet.getZoom();
×
680
          const targetZoom = Math.min(currentZoom + 2, self.leaflet.getMaxZoom());
×
681
          self.leaflet.setView(
×
682
            [params.data.value[1], params.data.value[0]],
683
            targetZoom,
684
          );
685
        }
686
      });
687

688
      // Ensure zoom handler consistently applies the same clustering logic
689
      self.leaflet.on("zoomend", () => {
×
690
        if (self.leaflet.getZoom() < self.config.disableClusteringAtLevel) {
×
691
          const nodeData = self.utils.makeCluster(self);
×
692
          clusters = nodeData.clusters;
×
693
          nonClusterNodes = nodeData.nonClusterNodes;
×
694
          nonClusterLinks = nodeData.nonClusterLinks;
×
695
          self.echarts.setOption(
×
696
            self.utils.generateMapOption(
697
              {
698
                ...JSONData,
699
                nodes: nonClusterNodes,
700
                links: nonClusterLinks,
701
              },
702
              self,
703
              clusters,
704
            ),
705
          );
706
        } else {
707
          // When above the threshold, show all nodes without clustering
708
          self.echarts.setOption(self.utils.generateMapOption(JSONData, self));
×
709
        }
710
      });
711
    }
712

713
    self.event.emit("onLoad");
9✔
714
    self.event.emit("onReady");
9✔
715
    self.event.emit("renderArray");
9✔
716
  }
717

718
  /**
719
   * @function
720
   * @name appendData
721
   * Append new data. Can only be used for `map` render!
722
   *
723
   * @param  {object}         JSONData   Data
724
   * @param  {object}         self     NetJSONGraph object
725
   *
726
   */
727
  appendData(JSONData, self) {
728
    if (self.config.render !== self.utils.mapRender) {
1!
729
      throw new Error("AppendData function can only be used for map render!");
×
730
    }
731

732
    if (self.config.render === self.utils.mapRender) {
1!
733
      const opts = self.utils.generateMapOption(JSONData, self);
1✔
734
      opts.series.forEach((obj, index) => {
1✔
735
        self.echarts.appendData({seriesIndex: index, data: obj.data});
2✔
736
      });
737
      // modify this.data
738
      self.utils.mergeData(JSONData, self);
1✔
739
    }
740

741
    self.config.afterUpdate.call(self);
1✔
742
  }
743

744
  /**
745
   * @function
746
   * @name addData
747
   * Add new data. Mainly used for `graph` render.
748
   *
749
   * @param  {object}         JSONData      Data
750
   * @param  {object}         self        NetJSONGraph object
751
   */
752
  addData(JSONData, self) {
753
    // modify this.data
754
    self.utils.mergeData(JSONData, self);
1✔
755

756
    // Ensure nodes are unique by ID using the utility function
757
    if (self.data.nodes && self.data.nodes.length > 0) {
1!
758
      self.data.nodes = self.utils.deduplicateNodesById(self.data.nodes);
1✔
759
    }
760

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

764
    self.config.afterUpdate.call(self);
1✔
765
  }
766

767
  /**
768
   * @function
769
   * @name mergeData
770
   * Merge new data. Modify this.data.
771
   *
772
   * @param  {object}         JSONData      Data
773
   * @param  {object}         self        NetJSONGraph object
774
   */
775
  mergeData(JSONData, self) {
776
    // Ensure incoming nodes array exists
777
    if (!JSONData.nodes) {
3!
778
      JSONData.nodes = [];
×
779
    }
780

781
    // Create a set of existing node IDs for efficient lookup
782
    const existingNodeIds = new Set();
3✔
783
    self.data.nodes.forEach((node) => {
3✔
784
      if (node.id) {
5!
785
        existingNodeIds.add(node.id);
5✔
786
      }
787
    });
788

789
    // Filter incoming nodes: keep nodes without IDs or with new IDs
790
    const newNodes = JSONData.nodes.filter((node) => {
3✔
791
      if (!node.id) {
3!
792
        return true;
×
793
      }
794
      if (existingNodeIds.has(node.id)) {
3✔
795
        console.warn(`Duplicate node ID ${node.id} detected during merge and skipped.`);
1✔
796
        return false;
1✔
797
      }
798
      return true;
2✔
799
    });
800

801
    const nodes = self.data.nodes.concat(newNodes);
3✔
802
    // Ensure incoming links array exists
803
    const incomingLinks = JSONData.links || [];
3!
804
    const links = self.data.links.concat(incomingLinks);
3✔
805

806
    Object.assign(self.data, JSONData, {
3✔
807
      nodes,
808
      links,
809
    });
810
  }
811
}
812

813
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