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

openwisp / netjsongraph.js / 28999780420

09 Jul 2026 06:50AM UTC coverage: 78.147% (-4.9%) from 83.04%
28999780420

Pull #577

github

web-flow
Merge e1a49135f into cedd37460
Pull Request #577: [feature] Node/link visual highlighting

964 of 1355 branches covered (71.14%)

Branch coverage included in aggregate %.

90 of 183 new or added lines in 3 files covered. (49.18%)

1414 of 1688 relevant lines covered (83.77%)

27.39 hits per line

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

70.27
/src/js/netjsongraph.render.js
1
import {use} from "echarts/core";
2
import {install as LinesChart} from "echarts/lib/chart/lines/install";
3
import {install as GraphChart} from "echarts/lib/chart/graph/install";
4
import {install as ScatterChart} from "echarts/lib/chart/scatter/install";
5
import {install as TooltipComponent} from "echarts/lib/component/tooltip/install";
6
import {install as TitleComponent} from "echarts/lib/component/title/install";
7
import {install as ToolboxComponent} from "echarts/lib/component/toolbox/install";
8
import {install as LegendComponent} from "echarts/lib/component/legend/install";
9
import {install as GraphicComponent} from "echarts/lib/component/graphic/install";
10
import {install as SVGRenderer} from "echarts/lib/renderer/installSVGRenderer";
11
import {install as CanvasRenderer} from "echarts/lib/renderer/installCanvasRenderer";
12
import getLeaflet from "./leaflet-loader";
13
import {addPolygonOverlays} from "./netjsongraph.geojson";
14

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

28
/**
29
 * @class
30
 * NetJSONGraphRender - Rendering utilities and visualization engine integration.
31
 *
32
 * Main Responsibilities:
33
 * - Provides mapRender() and graphRender() methods for different view modes
34
 * - Integrates ECharts and Leaflet for network topology visualization
35
 * - Handles interactive features like tooltips and legends
36
 * - Manages node clustering and styling
37
 */
38
class NetJSONGraphRender {
39
  /**
40
   * @function
41
   * @name echartsSetOption
42
   *
43
   * set option in echarts and render.
44
   *
45
   * @param  {object}  customOption    custom option determined by different render.
46
   * @param  {object}  self          NetJSONGraph object
47
   *
48
   * @return {object}  graph object
49
   *
50
   */
51
  echartsSetOption(customOption, self) {
52
    const configs = self.config;
3✔
53
    const echartsLayer = self.echarts;
3✔
54
    const commonOption = self.utils.deepMergeObj(
3✔
55
      {
56
        // Show element's detail when hover
57

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

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

99
    echartsLayer.setOption(self.utils.deepMergeObj(commonOption, customOption));
3✔
100
    if (self.echartsClickHandler && typeof echartsLayer.off === "function") {
3!
NEW
101
      echartsLayer.off("click", self.echartsClickHandler);
×
102
    }
103
    self.echartsClickHandler = (params) => {
3✔
NEW
104
      self.utils.addActionToUrl(self, params);
×
NEW
105
      if (params.componentSubType === "graph") {
×
NEW
106
        if (params.dataType === "edge") {
×
NEW
107
          self.utils.highlightLink(params.data, {
×
108
            openTooltip: true,
109
            showInfo: true,
110
            event: params.event,
111
            seriesIndex: params.seriesIndex,
112
            dataIndex: params.dataIndex,
113
            dataType: "edge",
114
          });
115
        } else {
NEW
116
          self.utils.highlightNode(params.data, {
×
117
            openTooltip: true,
118
            showInfo: true,
119
            event: params.event,
120
            seriesIndex: params.seriesIndex,
121
            dataIndex: params.dataIndex,
122
            dataType: "node",
123
          });
124
        }
NEW
125
        return;
×
126
      }
NEW
127
      if (params.componentSubType === "lines") {
×
NEW
128
        self.utils.highlightLink(params.data.link, {
×
129
          openTooltip: false,
130
          showInfo: true,
131
          event: params.event,
132
          seriesIndex: params.seriesIndex,
133
          dataIndex: params.dataIndex,
134
        });
NEW
135
        return;
×
136
      }
NEW
137
      if (params.data && !params.data.cluster) {
×
NEW
138
        if (configs.mapOptions?.nodePopup?.show) {
×
NEW
139
          self.gui.loadNodePopup(params.data.node);
×
140
        }
NEW
141
        self.utils.highlightNode(params.data.node, {
×
142
          openTooltip: true,
143
          showInfo: true,
144
          event: params.event,
145
          seriesIndex: params.seriesIndex,
146
          dataIndex: params.dataIndex,
147
        });
148
      }
149
    };
150
    echartsLayer.on("click", self.echartsClickHandler, {passive: true});
3✔
NEW
151
    self.utils.setupHighlightEventHandlers(self);
×
152

153
    return echartsLayer;
×
154
  }
155

156
  /**
157
   * @function
158
   * @name generateGraphOption
159
   *
160
   * generate graph option in echarts by JSONData.
161
   *
162
   * @param  {object}  JSONData        Render data
163
   * @param  {object}  self          NetJSONGraph object
164
   *
165
   * @return {object}  graph option
166
   *
167
   */
168
  generateGraphOption(JSONData, self) {
169
    const categories = [];
3✔
170
    const configs = self.config;
3✔
171
    const nodes = JSONData.nodes.map((node) => {
3✔
172
      const nodeResult = self.utils.fastDeepCopy(node);
6✔
173
      const {nodeStyleConfig, nodeSizeConfig, nodeEmphasisConfig} =
174
        self.utils.getNodeStyle(node, configs, "graph");
6✔
175
      const seriesEmphasis = (configs.graphConfig.series || {}).emphasis || {};
6!
176

177
      nodeResult.itemStyle = nodeStyleConfig;
6✔
178
      nodeResult.symbolSize = nodeSizeConfig;
6✔
179
      nodeResult.emphasis = {
6✔
180
        ...seriesEmphasis,
181
        focus: "none",
182
        itemStyle: {
183
          ...(seriesEmphasis.itemStyle || {}),
10✔
184
          ...(nodeEmphasisConfig.nodeStyle || {}),
6!
185
        },
186
        symbolSize: nodeEmphasisConfig.nodeSize,
187
      };
188
      let resolvedName = "";
6✔
189
      if (typeof node.label === "string") {
6✔
190
        resolvedName = node.label;
1✔
191
      } else if (typeof node.name === "string") {
5✔
192
        resolvedName = node.name;
2✔
193
      } else if (node.id !== undefined && node.id !== null) {
3!
194
        resolvedName = String(node.id);
3✔
195
      }
196
      nodeResult.name = resolvedName;
6✔
197
      // Preserve original NetJSON node for sidebar use
198
      /* eslint-disable no-underscore-dangle */
199
      nodeResult._source = self.utils.fastDeepCopy(node);
6✔
200
      return nodeResult;
6✔
201
    });
202
    const links = JSONData.links.map((link) => {
3✔
203
      const linkResult = self.utils.fastDeepCopy(link);
1✔
204
      const {linkStyleConfig, linkEmphasisConfig} = self.utils.getLinkStyle(
1✔
205
        link,
206
        configs,
207
        "graph",
208
      );
209
      const seriesEmphasis = (configs.graphConfig.series || {}).emphasis || {};
1!
210
      linkResult.lineStyle = linkStyleConfig;
1✔
211
      linkResult.emphasis = {
1✔
212
        ...seriesEmphasis,
213
        focus: "none",
214
        lineStyle: {
215
          ...(seriesEmphasis.lineStyle || {}),
1!
216
          ...(linkEmphasisConfig.linkStyle || {}),
1!
217
        },
218
      };
219
      return linkResult;
1✔
220
    });
221

222
    // Clone label config to avoid mutating defaults
223
    const baseGraphSeries = {...configs.graphConfig.series};
3✔
224
    const baseGraphLabel = {...(baseGraphSeries.label || {})};
3!
225

226
    // Shared helper to get current graph zoom level
227
    const getGraphZoom = () => {
3✔
228
      try {
2✔
229
        const option = self.echarts.getOption();
2✔
230
        const series = Array.isArray(option.series) ? option.series : [];
2!
231
        const graphSeries = series.find((s) => s && s.id === "network-graph");
2✔
232
        return graphSeries && typeof graphSeries.zoom === "number"
2!
233
          ? graphSeries.zoom
234
          : 1;
235
      } catch (e) {
236
        return 1;
×
237
      }
238
    };
239

240
    // Set up dynamic label visibility based on zoom threshold
241
    if (
3✔
242
      typeof self.config.showGraphLabelsAtZoom === "number" &&
4✔
243
      self.config.showGraphLabelsAtZoom > 0
244
    ) {
245
      const threshold = self.config.showGraphLabelsAtZoom;
1✔
246
      baseGraphLabel.formatter = (params) =>
1✔
247
        getGraphZoom() >= threshold
2✔
248
          ? (params && params.data && params.data.name) || ""
3!
249
          : "";
250
    }
251
    baseGraphSeries.label = baseGraphLabel;
3✔
252
    baseGraphSeries.emphasis = {
3✔
253
      ...(baseGraphSeries.emphasis || {}),
5✔
254
      focus: "none",
255
    };
256
    const series = [
3✔
257
      {
258
        ...baseGraphSeries,
259
        id: "network-graph",
260
        type: "graph",
261
        layout: configs.graphConfig.series.layout || "force",
3!
262
        nodes,
263
        links,
264
      },
265
    ];
266
    const legend = categories.length
3!
267
      ? {
268
          data: categories,
269
        }
270
      : undefined;
271

272
    return {
3✔
273
      legend,
274
      series,
275
      ...configs.graphConfig.baseOptions,
276
    };
277
  }
278

279
  /**
280
   * @function
281
   * @name generateMapOption
282
   *
283
   * generate map option in echarts by JSONData.
284
   *
285
   * @param  {object}  JSONData        Render data
286
   * @param  {object}  self          NetJSONGraph object
287
   *
288
   * @return {object}  map option
289
   *
290
   */
291
  generateMapOption(JSONData, self, clusters = []) {
21✔
292
    const configs = self.config;
21✔
293
    const {nodes, links} = JSONData;
21✔
294
    const flatNodes = JSONData.flatNodes || {};
21✔
295
    const linesData = [];
21✔
296
    let nodesData = [];
21✔
297

298
    nodes.forEach((node) => {
21✔
299
      if (node.properties) {
21!
300
        // Maintain flatNodes lookup regardless of whether the node is rendered as a marker
301
        if (!JSONData.flatNodes) {
21✔
302
          flatNodes[node.id] = self.utils.fastDeepCopy(node);
18✔
303
        }
304
      }
305

306
      // Non-Point geometries should not become scatter markers, but we still need them for lines
307
      if (
21!
308
        node.properties &&
58✔
309
        // eslint-disable-next-line no-underscore-dangle
310
        node.properties._featureType &&
311
        // eslint-disable-next-line no-underscore-dangle
312
        node.properties._featureType !== "Point"
313
      ) {
314
        return; // skip marker push only
×
315
      }
316
      if (!node.properties) {
21!
317
        console.error(`Node ${node.id} position is undefined!`);
×
318
      } else {
319
        const {location} = node.properties;
21✔
320

321
        if (!location || !location.lng || !location.lat) {
21✔
322
          console.error(`Node ${node.id} position is undefined!`);
16✔
323
        } else {
324
          const {nodeEmphasisConfig} = self.utils.getNodeStyle(node, configs, "map");
5✔
325

326
          let nodeName = "";
5✔
327
          if (typeof node.label === "string") {
5✔
328
            nodeName = node.label;
1✔
329
          } else if (typeof node.name === "string") {
4✔
330
            nodeName = node.name;
1✔
331
          } else if (node.id !== undefined && node.id !== null) {
3!
332
            nodeName = String(node.id);
3✔
333
          }
334
          nodesData.push({
5✔
335
            name: nodeName,
336
            value: [location.lng, location.lat],
337
            emphasis: {
338
              ...(configs.mapOptions.nodeConfig.emphasis || {}),
5!
339
              focus: "none",
340
              itemStyle: {
341
                ...((configs.mapOptions.nodeConfig.emphasis || {}).itemStyle || {}),
13!
342
                ...(nodeEmphasisConfig.nodeStyle || {}),
5!
343
              },
344
              symbolSize: nodeEmphasisConfig.nodeSize,
345
            },
346
            node,
347
            _source: self.utils.fastDeepCopy(node),
348
          });
349
        }
350
      }
351
    });
352
    links.forEach((link) => {
21✔
353
      if (!flatNodes[link.source]) {
3!
354
        console.warn(`Node ${link.source} does not exist!`);
×
355
      } else if (!flatNodes[link.target]) {
3!
356
        console.warn(`Node ${link.target} does not exist!`);
×
357
      } else {
358
        const {linkStyleConfig, linkEmphasisConfig} = self.utils.getLinkStyle(
3✔
359
          link,
360
          configs,
361
          "map",
362
        );
363
        linesData.push({
3✔
364
          coords: [
365
            [
366
              flatNodes[link.source].properties.location.lng,
367
              flatNodes[link.source].properties.location.lat,
368
            ],
369
            [
370
              flatNodes[link.target].properties.location.lng,
371
              flatNodes[link.target].properties.location.lat,
372
            ],
373
          ],
374
          lineStyle: linkStyleConfig,
375
          emphasis: {
376
            ...(configs.mapOptions.linkConfig.emphasis || {}),
3✔
377
            focus: "none",
378
            lineStyle: {
379
              ...((configs.mapOptions.linkConfig.emphasis || {}).lineStyle || {}),
6✔
380
              ...(linkEmphasisConfig.linkStyle || {}),
2!
381
            },
382
          },
383
          link,
384
        });
385
      }
386
    });
387

388
    nodesData = nodesData.concat(clusters);
20✔
389
    const series = [
20✔
390
      {
391
        id: "geo-map",
392
        type: "scatter",
393
        name: "nodes",
394
        coordinateSystem: "leaflet",
395
        data: nodesData,
396
        label: {
397
          ...(configs.mapOptions.nodeConfig.label || {}),
20!
398
          ...(configs.showMapLabelsAtZoom === false ? {show: false} : {}),
20!
399
          silent: true,
400
        },
401
        itemStyle: {
402
          color: (params) => {
403
            if (
5✔
404
              params.data &&
12✔
405
              params.data.cluster &&
406
              params.data.itemStyle &&
407
              params.data.itemStyle.color
408
            ) {
409
              return params.data.itemStyle.color;
1✔
410
            }
411
            if (params.data && params.data.node && params.data.node.category) {
4✔
412
              const category = configs.nodeCategories.find(
2✔
413
                (cat) => cat.name === params.data.node.category,
1✔
414
              );
415
              const nodeColor =
416
                (category && category.nodeStyle && category.nodeStyle.color) ||
2!
417
                (configs.mapOptions.nodeConfig &&
418
                  configs.mapOptions.nodeConfig.nodeStyle &&
419
                  configs.mapOptions.nodeConfig.nodeStyle.color) ||
420
                "#6c757d";
421
              return nodeColor;
2✔
422
            }
423
            const defaultColor =
424
              (configs.mapOptions.nodeConfig &&
2✔
425
                configs.mapOptions.nodeConfig.nodeStyle &&
426
                configs.mapOptions.nodeConfig.nodeStyle.color) ||
427
              "#6c757d";
428
            return defaultColor;
2✔
429
          },
430
        },
431
        symbolSize: (value, params) => {
432
          if (params.data && params.data.cluster) {
7✔
433
            return (
2✔
434
              (configs.mapOptions.clusterConfig &&
5✔
435
                configs.mapOptions.clusterConfig.symbolSize) ||
436
              30
437
            );
438
          }
439
          if (params.data && params.data.node) {
5✔
440
            const {nodeSizeConfig} = self.utils.getNodeStyle(
3✔
441
              params.data.node,
442
              configs,
443
              "map",
444
            );
445
            return typeof nodeSizeConfig === "object"
3✔
446
              ? (configs.mapOptions.nodeConfig &&
5✔
447
                  configs.mapOptions.nodeConfig.nodeSize) ||
448
                  17
449
              : nodeSizeConfig;
450
          }
451
          return (
2✔
452
            (configs.mapOptions.nodeConfig && configs.mapOptions.nodeConfig.nodeSize) ||
5✔
453
            17
454
          );
455
        },
456
        emphasis: {
457
          ...(configs.mapOptions.nodeConfig.emphasis || {}),
22✔
458
          focus: "none",
459
        },
460
      },
461
      Object.assign(configs.mapOptions.linkConfig, {
462
        id: "map-links",
463
        type: "lines",
464
        coordinateSystem: "leaflet",
465
        data: linesData,
466
      }),
467
    ];
468

469
    return {
20✔
470
      leaflet: {
471
        tiles: configs.mapTileConfig,
472
        mapOptions: configs.mapOptions,
473
      },
474
      series,
475
      ...configs.mapOptions.baseOptions,
476
    };
477
  }
478

479
  /**
480
   * @function
481
   * @name _propagateGraphZoom
482
   *
483
   * Enables wheel zoom outside graph canvas area.
484
   * @param  {object}  self          NetJSONGraph object
485
   *
486
   */
487
  _propagateGraphZoom(self) {
488
    const dom = self.echarts.getDom && self.echarts.getDom();
×
489
    if (!dom) {
×
490
      return;
×
491
    }
492
    const canvas = dom.querySelector("canvas");
×
493
    dom.addEventListener(
×
494
      "wheel",
495
      (e) => {
496
        if (!canvas) {
×
497
          return;
×
498
        }
499
        const rect = dom.getBoundingClientRect();
×
500
        // if coordinates of mouse are inside the canvas,
501
        // zoom will be handled normally
502
        if (
×
503
          e.clientX < rect.left ||
×
504
          e.clientX > rect.right ||
505
          e.clientY < rect.top ||
506
          e.clientY > rect.bottom
507
        ) {
508
          // during manual testing I couldn't trigger this
509
          // but let's leave it just in case the internals
510
          // of the library some day may change
511
          return;
×
512
        }
513
        // if we get here it's because the mouse is outside the canvas
514
        e.preventDefault();
×
515
        const r = canvas.getBoundingClientRect();
×
516
        // trigger wheel event in the canvas to propagate zoom
517
        canvas.dispatchEvent(
×
518
          new WheelEvent("wheel", {
519
            bubbles: true,
520
            cancelable: true,
521
            view: window,
522
            clientX: r.left + r.width / 2,
523
            clientY: r.top + r.height / 2,
524
            // invert deltaY to match expected zoom direction
525
            deltaY: -e.deltaY,
526
            deltaMode: e.deltaMode,
527
          }),
528
        );
529
      },
530
      {passive: false},
531
    );
532
  }
533

534
  /**
535
   * @function
536
   * @name graphRender
537
   *
538
   * Render the final graph result based on JSONData.
539
   * @param  {object}  JSONData        Render data
540
   * @param  {object}  self          NetJSONGraph object
541
   *
542
   */
543
  graphRender(JSONData, self) {
544
    self.utils.echartsSetOption(self.utils.generateGraphOption(JSONData, self), self);
1✔
545
    window.onresize = () => {
1✔
546
      self.echarts.resize();
×
547
    };
548
    // Handles zoom outside graph area
549
    self.utils._propagateGraphZoom(self);
1✔
550
    // Toggle labels on zoom threshold crossing
551
    if (self.config.showGraphLabelsAtZoom > 0) {
1!
552
      self.echarts.on("graphRoam", (params) => {
1✔
553
        if (!params || !params.zoom) {
1!
554
          return;
×
555
        }
556
        const option = self.echarts.getOption();
1✔
557
        const labelsVisible =
558
          option &&
1✔
559
          option.series &&
560
          option.series[0] &&
561
          option.series[0].zoom >= self.config.showGraphLabelsAtZoom;
562
        if (labelsVisible !== self._labelsVisible) {
1!
563
          self.echarts.resize({animation: false, silent: true});
1✔
564
          self._labelsVisible = labelsVisible;
1✔
565
        }
566
      });
567
    }
568

569
    self.utils.setupHashChangeHandler(self);
1✔
570

571
    self.event.emit("onLoad");
1✔
572
    self.event.emit("onReady");
1✔
573
    self.event.emit("renderArray");
1✔
574
    self.event.emit("applyUrlFragmentState");
1✔
575
  }
576

577
  /**
578
   * @function
579
   * @name mapRender
580
   *
581
   * Render the final map result based on JSONData.
582
   * @param  {object}  JSONData       Render data
583
   * @param  {object}  self         NetJSONGraph object
584
   *
585
   */
586
  mapRender(JSONData, self) {
587
    const L = getLeaflet();
19✔
588
    if (!L) {
19!
589
      throw new Error("Leaflet api is not loaded");
×
590
    }
591
    const {circleMarker, latLngBounds} = L;
19✔
592

593
    if (!self.config.mapTileConfig[0]) {
19!
594
      throw new Error(`You must add the tiles via the "mapTileConfig" param!`);
×
595
    }
596

597
    // Accept both NetJSON and GeoJSON inputs. If GeoJSON is detected,
598
    // deep-copy it for polygon overlays and convert the working copy to
599
    // NetJSON so the rest of the pipeline can operate uniformly.
600
    if (self.utils.isGeoJSON(JSONData)) {
19✔
601
      self.originalGeoJSON = self.utils.fastDeepCopy(JSONData);
15✔
602
      JSONData = self.utils.geojsonToNetjson(JSONData);
15✔
603
      // From this point forward we treat the data as NetJSON internally,
604
      // but keep the public-facing `type` value unchanged ("geojson").
605
    }
606

607
    const initialMapOptions = self.utils.generateMapOption(JSONData, self);
19✔
608
    self.utils.echartsSetOption(initialMapOptions, self);
18✔
609
    self.bboxData = {
15✔
610
      nodes: [],
611
      links: [],
612
    };
613

614
    // eslint-disable-next-line no-underscore-dangle
615
    self.leaflet = self.echarts._api.getCoordinateSystems()[0].getLeaflet();
15✔
616
    // eslint-disable-next-line no-underscore-dangle
617
    self.leaflet._zoomAnimated = false;
15✔
618

619
    self.config.geoOptions = self.utils.deepMergeObj(
15✔
620
      {
621
        pointToLayer: (feature, latlng) =>
622
          circleMarker(latlng, self.config.geoOptions.style),
×
623
        onEachFeature: (feature, layer) => {
624
          layer.on("click", () => {
×
625
            const properties = {
×
626
              ...feature.properties,
627
            };
628
            self.config.onClickElement.call(self, "Feature", properties);
×
629
          });
630
        },
631
      },
632
      self.config.geoOptions,
633
    );
634

635
    // Render Polygon and MultiPolygon features from the original GeoJSON data.
636
    // While nodes (Points) and links (LineStrings) are handled by ECharts,
637
    // polygon features are rendered directly onto the Leaflet map using
638
    // a separate L.geoJSON layer. This allows for displaying geographical
639
    // areas like parks or districts alongside the network topology.
640
    if (self.originalGeoJSON) {
15!
641
      addPolygonOverlays(self);
15✔
642
      // Auto-fit view to encompass ALL geometries (polygons + nodes)
643
      let bounds = null;
15✔
644

645
      // 1. Polygon overlays (if any)
646
      if (
15!
647
        self.leaflet.polygonGeoJSON &&
17✔
648
        typeof self.leaflet.polygonGeoJSON.getBounds === "function"
649
      ) {
650
        bounds = self.leaflet.polygonGeoJSON.getBounds();
×
651
      }
652

653
      // 2. Nodes (Points)
654
      if (JSONData.nodes && JSONData.nodes.length) {
15✔
655
        const latlngs = JSONData.nodes
2✔
656
          .map((n) => n.properties.location)
2✔
657
          .map((loc) => [loc.lat, loc.lng]);
2✔
658
        if (bounds) {
2!
659
          latlngs.forEach((ll) => bounds.extend(ll));
×
660
        } else {
661
          bounds = latLngBounds(latlngs);
2✔
662
        }
663
      }
664

665
      if (bounds && bounds.isValid()) {
15✔
666
        self.leaflet.fitBounds(bounds, {padding: [20, 20]});
2✔
667
      }
668
    }
669
    self.utils.updateLabelVisibility(self, true);
15✔
670

671
    self.leaflet.on("zoomend", () => {
15✔
672
      self.utils.updateLabelVisibility(self, true);
16✔
673
      // Zoom in/out buttons disabled only when it is equal to min/max zoomlevel
674
      // Manually handle zoom control state to ensure correct behavior with float zoom levels
675
      const currentZoom = self.leaflet.getZoom();
16✔
676
      const minZoom = self.leaflet.getMinZoom();
16✔
677
      const maxZoom = self.leaflet.getMaxZoom();
16✔
678
      const zoomIn = document.querySelector(".leaflet-control-zoom-in");
16✔
679
      const zoomOut = document.querySelector(".leaflet-control-zoom-out");
16✔
680

681
      if (zoomIn && zoomOut) {
16!
682
        if (Math.round(currentZoom) >= maxZoom) {
16✔
683
          zoomIn.classList.add("leaflet-disabled");
6✔
684
        } else {
685
          zoomIn.classList.remove("leaflet-disabled");
10✔
686
        }
687

688
        if (Math.round(currentZoom) <= minZoom) {
16✔
689
          zoomOut.classList.add("leaflet-disabled");
3✔
690
        } else {
691
          zoomOut.classList.remove("leaflet-disabled");
13✔
692
        }
693
      }
694
    });
695

696
    self.leaflet.on("moveend", async () => {
15✔
697
      const bounds = self.leaflet.getBounds();
5✔
698
      const removeBBoxData = () => {
5✔
699
        const removeNodes = new Set(self.bboxData.nodes);
×
700
        const removeLinks = new Set(self.bboxData.links);
×
701

702
        JSONData = {
×
703
          ...JSONData,
704
          nodes: JSONData.nodes.filter((node) => !removeNodes.has(node)),
×
705
          links: JSONData.links.filter((link) => !removeLinks.has(link)),
×
706
        };
707

708
        self.data = JSONData;
×
709
        self.echarts.setOption(self.utils.generateMapOption(JSONData, self));
×
710
        self.bboxData.nodes = [];
×
711
        self.bboxData.links = [];
×
712
      };
713
      if (
5✔
714
        self.leaflet.getZoom() >= self.config.loadMoreAtZoomLevel &&
6✔
715
        self.hasMoreData
716
      ) {
717
        const data = await self.utils.getBBoxData.call(self, self.JSONParam, bounds);
1✔
718
        self.config.prepareData.call(self, data);
1✔
719
        const dataNodeSet = new Set(self.data.nodes.map((n) => n.id));
1✔
720
        const sourceLinkSet = new Set(self.data.links.map((l) => l.source));
1✔
721
        const targetLinkSet = new Set(self.data.links.map((l) => l.target));
1✔
722
        const nodes = data.nodes.filter((node) => !dataNodeSet.has(node.id));
1✔
723
        const links = data.links.filter(
1✔
724
          (link) => !sourceLinkSet.has(link.source) && !targetLinkSet.has(link.target),
×
725
        );
726
        const boundsDataSet = new Set(data.nodes.map((n) => n.id));
1✔
727
        const nonCommonNodes = self.bboxData.nodes.filter(
1✔
728
          (node) => !boundsDataSet.has(node.id),
×
729
        );
730
        const removableNodes = new Set(nonCommonNodes.map((n) => n.id));
1✔
731

732
        JSONData.nodes = JSONData.nodes.filter((node) => !removableNodes.has(node.id));
1✔
733
        self.bboxData.nodes = self.bboxData.nodes.concat(nodes);
1✔
734
        self.bboxData.links = self.bboxData.links.concat(links);
1✔
735
        JSONData = {
1✔
736
          ...JSONData,
737
          nodes: JSONData.nodes.concat(nodes),
738
          links: JSONData.links.concat(links),
739
        };
740
        self.echarts.setOption(self.utils.generateMapOption(JSONData, self));
1✔
741
        self.data = JSONData;
1✔
742
      } else if (self.hasMoreData && self.bboxData.nodes.length > 0) {
4!
743
        removeBBoxData();
×
744
      }
745
    });
746
    if (
15✔
747
      self.config.clustering &&
19✔
748
      self.config.clusteringThreshold < JSONData.nodes.length
749
    ) {
750
      let {clusters, nonClusterNodes, nonClusterLinks} = self.utils.makeCluster(self);
2✔
751

752
      // Only show clusters if we're below the disableClusteringAtLevel
753
      if (self.leaflet.getZoom() > self.config.disableClusteringAtLevel) {
2!
754
        clusters = [];
×
755
        nonClusterNodes = JSONData.nodes;
×
756
        nonClusterLinks = JSONData.links;
×
757
      }
758

759
      self.echarts.setOption(
2✔
760
        self.utils.generateMapOption(
761
          {
762
            ...JSONData,
763
            nodes: nonClusterNodes,
764
            links: nonClusterLinks,
765
          },
766
          self,
767
          clusters,
768
        ),
769
      );
770
      self.utils.updateLabelVisibility(self, true);
2✔
771

772
      self.echarts.on("click", (params) => {
2✔
773
        if (
×
774
          (params.componentSubType === "scatter" ||
×
775
            params.componentSubType === "effectScatter") &&
776
          params.data.cluster
777
        ) {
778
          const currentZoom = self.leaflet.getZoom();
×
779
          const targetZoom = Math.min(currentZoom + 2, self.leaflet.getMaxZoom());
×
780

781
          self.leaflet.setView(
×
782
            [params.data.value[1], params.data.value[0]],
783
            targetZoom,
784
          );
785
        }
786
      });
787

788
      // Ensure zoom handler consistently applies the same clustering logic
789
      self.leaflet.on("zoomend", () => {
2✔
790
        if (self.leaflet.getZoom() < self.config.disableClusteringAtLevel) {
1!
791
          const nodeData = self.utils.makeCluster(self);
1✔
792
          clusters = nodeData.clusters;
1✔
793
          nonClusterNodes = nodeData.nonClusterNodes;
1✔
794
          nonClusterLinks = nodeData.nonClusterLinks;
1✔
795
          self.echarts.setOption(
1✔
796
            self.utils.generateMapOption(
797
              {
798
                ...JSONData,
799
                nodes: nonClusterNodes,
800
                links: nonClusterLinks,
801
              },
802
              self,
803
              clusters,
804
            ),
805
          );
806
        } else {
807
          // When above the threshold, show all nodes without clustering
808
          self.echarts.setOption(self.utils.generateMapOption(JSONData, self));
×
809
        }
810
        self.utils.updateLabelVisibility(self, true);
1✔
811
      });
812
    }
813

814
    self.utils.setupHashChangeHandler(self);
15✔
815
    self.event.emit("onLoad");
15✔
816
    self.event.emit("onReady");
15✔
817
    self.event.emit("renderArray");
15✔
818
    self.event.emit("applyUrlFragmentState");
15✔
819
  }
820

821
  /**
822
   * @function
823
   * @name appendData
824
   * Append new data. Can only be used for `map` render!
825
   *
826
   * @param  {object}         JSONData   Data
827
   * @param  {object}         self     NetJSONGraph object
828
   *
829
   */
830
  appendData(JSONData, self) {
831
    if (self.config.render !== self.utils.mapRender) {
1!
832
      throw new Error("AppendData function can only be used for map render!");
×
833
    }
834

835
    if (self.config.render === self.utils.mapRender) {
1!
836
      const opts = self.utils.generateMapOption(JSONData, self);
1✔
837
      opts.series.forEach((obj, index) => {
1✔
838
        self.echarts.appendData({seriesIndex: index, data: obj.data});
2✔
839
      });
840
      // modify this.data
841
      self.utils.mergeData(JSONData, self);
1✔
842
    }
843

844
    self.config.afterUpdate.call(self);
1✔
845
  }
846

847
  /**
848
   * @function
849
   * @name addData
850
   * Add new data. Mainly used for `graph` render.
851
   *
852
   * @param  {object}         JSONData      Data
853
   * @param  {object}         self        NetJSONGraph object
854
   */
855
  addData(JSONData, self) {
856
    // modify this.data
857
    self.utils.mergeData(JSONData, self);
1✔
858

859
    // Ensure nodes are unique by ID using the utility function
860
    if (self.data.nodes && self.data.nodes.length > 0) {
1!
861
      self.data.nodes = self.utils.deduplicateNodesById(self.data.nodes);
1✔
862
    }
863

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

867
    self.config.afterUpdate.call(self);
1✔
868
  }
869

870
  /**
871
   * @function
872
   * @name mergeData
873
   * Merge new data. Modify this.data.
874
   *
875
   * @param  {object}         JSONData      Data
876
   * @param  {object}         self        NetJSONGraph object
877
   */
878
  mergeData(JSONData, self) {
879
    // Ensure incoming nodes array exists
880
    if (!JSONData.nodes) {
3!
881
      JSONData.nodes = [];
×
882
    }
883

884
    // Create a set of existing node IDs for efficient lookup
885
    const existingNodeIds = new Set();
3✔
886
    self.data.nodes.forEach((node) => {
3✔
887
      if (node.id) {
5!
888
        existingNodeIds.add(node.id);
5✔
889
      }
890
    });
891

892
    // Filter incoming nodes: keep nodes without IDs or with new IDs
893
    const newNodes = JSONData.nodes.filter((node) => {
3✔
894
      if (!node.id) {
3!
895
        return true;
×
896
      }
897
      if (existingNodeIds.has(node.id)) {
3✔
898
        console.warn(`Duplicate node ID ${node.id} detected during merge and skipped.`);
1✔
899
        return false;
1✔
900
      }
901
      return true;
2✔
902
    });
903

904
    const nodes = self.data.nodes.concat(newNodes);
3✔
905
    // Ensure incoming links array exists
906
    const incomingLinks = JSONData.links || [];
3!
907
    const links = self.data.links.concat(incomingLinks);
3✔
908

909
    Object.assign(self.data, JSONData, {
3✔
910
      nodes,
911
      links,
912
    });
913
  }
914
}
915

916
export default NetJSONGraphRender;
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