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

openwisp / netjsongraph.js / 21760645092

06 Feb 2026 06:00PM UTC coverage: 81.1% (+0.4%) from 80.722%
21760645092

push

github

web-flow
[change] Reduced size, provided 2 builds: full/echarts-only #392

**Two Build Variants Available:**
- **Full Bundle** (`yarn build:full`): Complete library with ECharts and Leaflet bundled
- **ECharts-Only Bundle** (`yarn build:echarts-only`): Library without Leaflet (~144 KiB smaller)

**For Projects with Existing Leaflet:**
Projects already using Leaflet (e.g., via django-leaflet in OpenWISP)
can now use the ECharts-only variant. Leaflet must be available as
a global `L` object before loading NetJSONGraph.

**Other changes:**
- Removed large GraphGL example data files (~40MB)
- Added compression plugin for `.gz` and `.br` file generation
- `package.json` `main` field now points to
  `dist/netjsongraph.min.js` instead of `src/js/netjsongraph.js`
- Webpack Bundle Analyzer Support:
  new stats commands to analyze bundle composition
- Dynamic Leaflet Loading: The echarts-only bundle can dynamically load Leaflet from CDN for development/demo purposes

**Breaking changes**
- Leaflet is now a **peer dependency** (optional),
  projects using the ECharts-only bundle must provide Leaflet themselves

Closes #392

Signed-off-by: Sankalp <sankalp.nex@gmail.com>
Co-authored-by: Federico Capoano <f.capoano@openwisp.io>

755 of 994 branches covered (75.96%)

Branch coverage included in aggregate %.

20 of 28 new or added lines in 5 files covered. (71.43%)

1176 of 1387 relevant lines covered (84.79%)

16.68 hits per line

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

66.4
/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
          position: (pos, params, dom, rect, size) => {
61
            let position = "right";
×
62
            if (size.viewSize[0] - pos[0] < size.contentSize[0]) {
×
63
              position = "left";
×
64
            }
65
            if (params.componentSubType === "lines") {
×
66
              position = [
×
67
                pos[0] + size.contentSize[0] / 8,
68
                pos[1] - size.contentSize[1] / 2,
69
              ];
70

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

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

117
    return echartsLayer;
×
118
  }
119

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

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

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

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

190
    // Set up dynamic label visibility based on zoom threshold
191
    if (
2✔
192
      typeof self.config.showGraphLabelsAtZoom === "number" &&
3✔
193
      self.config.showGraphLabelsAtZoom > 0
194
    ) {
195
      const threshold = self.config.showGraphLabelsAtZoom;
1✔
196
      baseGraphLabel.formatter = (params) =>
1✔
197
        getGraphZoom() >= threshold
2✔
198
          ? (params && params.data && params.data.name) || ""
3!
199
          : "";
200
    }
201
    baseGraphSeries.label = baseGraphLabel;
2✔
202
    const series = [
2✔
203
      {
204
        ...baseGraphSeries,
205
        id: "network-graph",
206
        type: "graph",
207
        layout: configs.graphConfig.series.layout || "force",
2!
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] = self.utils.fastDeepCopy(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: self.utils.fastDeepCopy(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: "scatter",
328
        name: "nodes",
329
        coordinateSystem: "leaflet",
330
        data: nodesData,
331
        label: configs.mapOptions.nodeConfig.label,
332
        itemStyle: {
333
          color: (params) => {
334
            if (
5✔
335
              params.data &&
12✔
336
              params.data.cluster &&
337
              params.data.itemStyle &&
338
              params.data.itemStyle.color
339
            ) {
340
              return params.data.itemStyle.color;
1✔
341
            }
342
            if (params.data && params.data.node && params.data.node.category) {
4✔
343
              const category = configs.nodeCategories.find(
2✔
344
                (cat) => cat.name === params.data.node.category,
1✔
345
              );
346
              const nodeColor =
347
                (category && category.nodeStyle && category.nodeStyle.color) ||
2!
348
                (configs.mapOptions.nodeConfig &&
349
                  configs.mapOptions.nodeConfig.nodeStyle &&
350
                  configs.mapOptions.nodeConfig.nodeStyle.color) ||
351
                "#6c757d";
352
              return nodeColor;
2✔
353
            }
354
            const defaultColor =
355
              (configs.mapOptions.nodeConfig &&
2✔
356
                configs.mapOptions.nodeConfig.nodeStyle &&
357
                configs.mapOptions.nodeConfig.nodeStyle.color) ||
358
              "#6c757d";
359
            return defaultColor;
2✔
360
          },
361
        },
362
        symbolSize: (value, params) => {
363
          if (params.data && params.data.cluster) {
7✔
364
            return (
2✔
365
              (configs.mapOptions.clusterConfig &&
5✔
366
                configs.mapOptions.clusterConfig.symbolSize) ||
367
              30
368
            );
369
          }
370
          if (params.data && params.data.node) {
5✔
371
            const {nodeSizeConfig} = self.utils.getNodeStyle(
3✔
372
              params.data.node,
373
              configs,
374
              "map",
375
            );
376
            return typeof nodeSizeConfig === "object"
3✔
377
              ? (configs.mapOptions.nodeConfig &&
5✔
378
                  configs.mapOptions.nodeConfig.nodeSize) ||
379
                  17
380
              : nodeSizeConfig;
381
          }
382
          return (
2✔
383
            (configs.mapOptions.nodeConfig && configs.mapOptions.nodeConfig.nodeSize) ||
5✔
384
            17
385
          );
386
        },
387
        emphasis: configs.mapOptions.nodeConfig.emphasis,
388
      },
389
      Object.assign(configs.mapOptions.linkConfig, {
390
        id: "map-links",
391
        type: "lines",
392
        coordinateSystem: "leaflet",
393
        data: linesData,
394
      }),
395
    ];
396

397
    return {
17✔
398
      leaflet: {
399
        tiles: configs.mapTileConfig,
400
        mapOptions: configs.mapOptions,
401
      },
402
      series,
403
      ...configs.mapOptions.baseOptions,
404
    };
405
  }
406

407
  /**
408
   * @function
409
   * @name _propagateGraphZoom
410
   *
411
   * Enables wheel zoom outside graph canvas area.
412
   * @param  {object}  self          NetJSONGraph object
413
   *
414
   */
415
  _propagateGraphZoom(self) {
416
    const dom = self.echarts.getDom && self.echarts.getDom();
×
417
    if (!dom) {
×
418
      return;
×
419
    }
420
    const canvas = dom.querySelector("canvas");
×
421
    dom.addEventListener(
×
422
      "wheel",
423
      (e) => {
424
        if (!canvas) {
×
425
          return;
×
426
        }
427
        const rect = dom.getBoundingClientRect();
×
428
        // if coordinates of mouse are inside the canvas,
429
        // zoom will be handled normally
430
        if (
×
431
          e.clientX < rect.left ||
×
432
          e.clientX > rect.right ||
433
          e.clientY < rect.top ||
434
          e.clientY > rect.bottom
435
        ) {
436
          // during manual testing I couldn't trigger this
437
          // but let's leave it just in case the internals
438
          // of the library some day may change
439
          return;
×
440
        }
441
        // if we get here it's because the mouse is outside the canvas
442
        e.preventDefault();
×
443
        const r = canvas.getBoundingClientRect();
×
444
        // trigger wheel event in the canvas to propagate zoom
445
        canvas.dispatchEvent(
×
446
          new WheelEvent("wheel", {
447
            bubbles: true,
448
            cancelable: true,
449
            view: window,
450
            clientX: r.left + r.width / 2,
451
            clientY: r.top + r.height / 2,
452
            // invert deltaY to match expected zoom direction
453
            deltaY: -e.deltaY,
454
            deltaMode: e.deltaMode,
455
          }),
456
        );
457
      },
458
      {passive: false},
459
    );
460
  }
461

462
  /**
463
   * @function
464
   * @name graphRender
465
   *
466
   * Render the final graph result based on JSONData.
467
   * @param  {object}  JSONData        Render data
468
   * @param  {object}  self          NetJSONGraph object
469
   *
470
   */
471
  graphRender(JSONData, self) {
472
    self.utils.echartsSetOption(self.utils.generateGraphOption(JSONData, self), self);
1✔
473
    window.onresize = () => {
1✔
474
      self.echarts.resize();
×
475
    };
476
    // Handles zoom outside graph area
477
    self.utils._propagateGraphZoom(self);
1✔
478
    // Toggle labels on zoom threshold crossing
479
    if (self.config.showGraphLabelsAtZoom > 0) {
1!
480
      self.echarts.on("graphRoam", (params) => {
1✔
481
        if (!params || !params.zoom) {
1!
482
          return;
×
483
        }
484
        const option = self.echarts.getOption();
1✔
485
        const labelsVisible =
486
          option &&
1✔
487
          option.series &&
488
          option.series[0] &&
489
          option.series[0].zoom >= self.config.showGraphLabelsAtZoom;
490
        if (labelsVisible !== self._labelsVisible) {
1!
491
          self.echarts.resize({animation: false, silent: true});
1✔
492
          self._labelsVisible = labelsVisible;
1✔
493
        }
494
      });
495
    }
496

497
    self.utils.setupHashChangeHandler(self);
1✔
498

499
    self.event.emit("onLoad");
1✔
500
    self.event.emit("onReady");
1✔
501
    self.event.emit("renderArray");
1✔
502
    self.event.emit("applyUrlFragmentState");
1✔
503
  }
504

505
  /**
506
   * @function
507
   * @name mapRender
508
   *
509
   * Render the final map result based on JSONData.
510
   * @param  {object}  JSONData       Render data
511
   * @param  {object}  self         NetJSONGraph object
512
   *
513
   */
514
  mapRender(JSONData, self) {
515
    const L = getLeaflet();
13✔
516
    if (!L) {
13!
NEW
517
      throw new Error("Leaflet api is not loaded");
×
518
    }
519
    const {circleMarker, latLngBounds} = L;
13✔
520

521
    if (!self.config.mapTileConfig[0]) {
13!
522
      throw new Error(`You must add the tiles via the "mapTileConfig" param!`);
×
523
    }
524

525
    // Accept both NetJSON and GeoJSON inputs. If GeoJSON is detected,
526
    // deep-copy it for polygon overlays and convert the working copy to
527
    // NetJSON so the rest of the pipeline can operate uniformly.
528
    if (self.utils.isGeoJSON(JSONData)) {
13✔
529
      self.originalGeoJSON = self.utils.fastDeepCopy(JSONData);
9✔
530
      JSONData = self.utils.geojsonToNetjson(JSONData);
9✔
531
      // From this point forward we treat the data as NetJSON internally,
532
      // but keep the public-facing `type` value unchanged ("geojson").
533
    }
534

535
    const initialMapOptions = self.utils.generateMapOption(JSONData, self);
13✔
536
    self.utils.echartsSetOption(initialMapOptions, self);
12✔
537
    self.bboxData = {
9✔
538
      nodes: [],
539
      links: [],
540
    };
541

542
    // eslint-disable-next-line no-underscore-dangle
543
    self.leaflet = self.echarts._api.getCoordinateSystems()[0].getLeaflet();
9✔
544
    // eslint-disable-next-line no-underscore-dangle
545
    self.leaflet._zoomAnimated = false;
9✔
546

547
    self.config.geoOptions = self.utils.deepMergeObj(
9✔
548
      {
549
        pointToLayer: (feature, latlng) =>
NEW
550
          circleMarker(latlng, self.config.geoOptions.style),
×
551
        onEachFeature: (feature, layer) => {
552
          layer.on("click", () => {
×
553
            const properties = {
×
554
              ...feature.properties,
555
            };
556
            self.config.onClickElement.call(self, "Feature", properties);
×
557
          });
558
        },
559
      },
560
      self.config.geoOptions,
561
    );
562

563
    // Render Polygon and MultiPolygon features from the original GeoJSON data.
564
    // While nodes (Points) and links (LineStrings) are handled by ECharts,
565
    // polygon features are rendered directly onto the Leaflet map using
566
    // a separate L.geoJSON layer. This allows for displaying geographical
567
    // areas like parks or districts alongside the network topology.
568
    if (self.originalGeoJSON) {
9!
569
      addPolygonOverlays(self);
9✔
570
      // Auto-fit view to encompass ALL geometries (polygons + nodes)
571
      let bounds = null;
9✔
572

573
      // 1. Polygon overlays (if any)
574
      if (
9!
575
        self.leaflet.polygonGeoJSON &&
11✔
576
        typeof self.leaflet.polygonGeoJSON.getBounds === "function"
577
      ) {
578
        bounds = self.leaflet.polygonGeoJSON.getBounds();
×
579
      }
580

581
      // 2. Nodes (Points)
582
      if (JSONData.nodes && JSONData.nodes.length) {
9!
583
        const latlngs = JSONData.nodes
×
584
          .map((n) => n.properties.location)
×
585
          .map((loc) => [loc.lat, loc.lng]);
×
586
        if (bounds) {
×
587
          latlngs.forEach((ll) => bounds.extend(ll));
×
588
        } else {
NEW
589
          bounds = latLngBounds(latlngs);
×
590
        }
591
      }
592

593
      if (bounds && bounds.isValid()) {
9!
594
        self.leaflet.fitBounds(bounds, {padding: [20, 20]});
×
595
      }
596
    }
597

598
    if (self.leaflet.getZoom() < self.config.showLabelsAtZoomLevel) {
9✔
599
      self.echarts.setOption({
6✔
600
        series: [
601
          {
602
            id: "geo-map",
603
            label: {
604
              show: false,
605
            },
606
            emphasis: {
607
              label: {
608
                show: false,
609
              },
610
            },
611
          },
612
        ],
613
      });
614
    }
615

616
    self.leaflet.on("zoomend", () => {
9✔
617
      const currentZoom = self.leaflet.getZoom();
9✔
618
      const showLabel = currentZoom >= self.config.showLabelsAtZoomLevel;
9✔
619
      self.echarts.setOption({
9✔
620
        series: [
621
          {
622
            id: "geo-map",
623
            label: {
624
              show: showLabel,
625
            },
626
            emphasis: {
627
              label: {
628
                show: showLabel,
629
              },
630
            },
631
          },
632
        ],
633
      });
634

635
      // Zoom in/out buttons disabled only when it is equal to min/max zoomlevel
636
      // Manually handle zoom control state to ensure correct behavior with float zoom levels
637
      const minZoom = self.leaflet.getMinZoom();
9✔
638
      const maxZoom = self.leaflet.getMaxZoom();
9✔
639
      const zoomIn = document.querySelector(".leaflet-control-zoom-in");
9✔
640
      const zoomOut = document.querySelector(".leaflet-control-zoom-out");
9✔
641

642
      if (zoomIn && zoomOut) {
9!
643
        if (Math.round(currentZoom) >= maxZoom) {
9✔
644
          zoomIn.classList.add("leaflet-disabled");
4✔
645
        } else {
646
          zoomIn.classList.remove("leaflet-disabled");
5✔
647
        }
648

649
        if (Math.round(currentZoom) <= minZoom) {
9✔
650
          zoomOut.classList.add("leaflet-disabled");
2✔
651
        } else {
652
          zoomOut.classList.remove("leaflet-disabled");
7✔
653
        }
654
      }
655
    });
656

657
    self.leaflet.on("moveend", async () => {
9✔
658
      const bounds = self.leaflet.getBounds();
5✔
659
      const removeBBoxData = () => {
5✔
660
        const removeNodes = new Set(self.bboxData.nodes);
×
661
        const removeLinks = new Set(self.bboxData.links);
×
662

663
        JSONData = {
×
664
          ...JSONData,
665
          nodes: JSONData.nodes.filter((node) => !removeNodes.has(node)),
×
666
          links: JSONData.links.filter((link) => !removeLinks.has(link)),
×
667
        };
668

669
        self.data = JSONData;
×
670
        self.echarts.setOption(self.utils.generateMapOption(JSONData, self));
×
671
        self.bboxData.nodes = [];
×
672
        self.bboxData.links = [];
×
673
      };
674
      if (
5✔
675
        self.leaflet.getZoom() >= self.config.loadMoreAtZoomLevel &&
6✔
676
        self.hasMoreData
677
      ) {
678
        const data = await self.utils.getBBoxData.call(self, self.JSONParam, bounds);
1✔
679
        self.config.prepareData.call(self, data);
1✔
680
        const dataNodeSet = new Set(self.data.nodes.map((n) => n.id));
1✔
681
        const sourceLinkSet = new Set(self.data.links.map((l) => l.source));
1✔
682
        const targetLinkSet = new Set(self.data.links.map((l) => l.target));
1✔
683
        const nodes = data.nodes.filter((node) => !dataNodeSet.has(node.id));
1✔
684
        const links = data.links.filter(
1✔
685
          (link) => !sourceLinkSet.has(link.source) && !targetLinkSet.has(link.target),
×
686
        );
687
        const boundsDataSet = new Set(data.nodes.map((n) => n.id));
1✔
688
        const nonCommonNodes = self.bboxData.nodes.filter(
1✔
689
          (node) => !boundsDataSet.has(node.id),
×
690
        );
691
        const removableNodes = new Set(nonCommonNodes.map((n) => n.id));
1✔
692

693
        JSONData.nodes = JSONData.nodes.filter((node) => !removableNodes.has(node.id));
1✔
694
        self.bboxData.nodes = self.bboxData.nodes.concat(nodes);
1✔
695
        self.bboxData.links = self.bboxData.links.concat(links);
1✔
696
        JSONData = {
1✔
697
          ...JSONData,
698
          nodes: JSONData.nodes.concat(nodes),
699
          links: JSONData.links.concat(links),
700
        };
701
        self.echarts.setOption(self.utils.generateMapOption(JSONData, self));
1✔
702
        self.data = JSONData;
1✔
703
      } else if (self.hasMoreData && self.bboxData.nodes.length > 0) {
4!
704
        removeBBoxData();
×
705
      }
706
    });
707
    if (
9!
708
      self.config.clustering &&
11✔
709
      self.config.clusteringThreshold < JSONData.nodes.length
710
    ) {
711
      let {clusters, nonClusterNodes, nonClusterLinks} = self.utils.makeCluster(self);
×
712

713
      // Only show clusters if we're below the disableClusteringAtLevel
714
      if (self.leaflet.getZoom() > self.config.disableClusteringAtLevel) {
×
715
        clusters = [];
×
716
        nonClusterNodes = JSONData.nodes;
×
717
        nonClusterLinks = JSONData.links;
×
718
      }
719

720
      self.echarts.setOption(
×
721
        self.utils.generateMapOption(
722
          {
723
            ...JSONData,
724
            nodes: nonClusterNodes,
725
            links: nonClusterLinks,
726
          },
727
          self,
728
          clusters,
729
        ),
730
      );
731

732
      self.echarts.on("click", (params) => {
×
NEW
733
        if (params.componentSubType === "scatter" && params.data.cluster) {
×
734
          // Zoom into the clicked cluster instead of expanding it
735
          const currentZoom = self.leaflet.getZoom();
×
736
          const targetZoom = Math.min(currentZoom + 2, self.leaflet.getMaxZoom());
×
737
          self.leaflet.setView(
×
738
            [params.data.value[1], params.data.value[0]],
739
            targetZoom,
740
          );
741
        }
742
      });
743

744
      // Ensure zoom handler consistently applies the same clustering logic
745
      self.leaflet.on("zoomend", () => {
×
746
        if (self.leaflet.getZoom() < self.config.disableClusteringAtLevel) {
×
747
          const nodeData = self.utils.makeCluster(self);
×
748
          clusters = nodeData.clusters;
×
749
          nonClusterNodes = nodeData.nonClusterNodes;
×
750
          nonClusterLinks = nodeData.nonClusterLinks;
×
751
          self.echarts.setOption(
×
752
            self.utils.generateMapOption(
753
              {
754
                ...JSONData,
755
                nodes: nonClusterNodes,
756
                links: nonClusterLinks,
757
              },
758
              self,
759
              clusters,
760
            ),
761
          );
762
        } else {
763
          // When above the threshold, show all nodes without clustering
764
          self.echarts.setOption(self.utils.generateMapOption(JSONData, self));
×
765
        }
766
      });
767
    }
768

769
    self.utils.setupHashChangeHandler(self);
9✔
770
    self.event.emit("onLoad");
9✔
771
    self.event.emit("onReady");
9✔
772
    self.event.emit("renderArray");
9✔
773
    self.event.emit("applyUrlFragmentState");
9✔
774
  }
775

776
  /**
777
   * @function
778
   * @name appendData
779
   * Append new data. Can only be used for `map` render!
780
   *
781
   * @param  {object}         JSONData   Data
782
   * @param  {object}         self     NetJSONGraph object
783
   *
784
   */
785
  appendData(JSONData, self) {
786
    if (self.config.render !== self.utils.mapRender) {
1!
787
      throw new Error("AppendData function can only be used for map render!");
×
788
    }
789

790
    if (self.config.render === self.utils.mapRender) {
1!
791
      const opts = self.utils.generateMapOption(JSONData, self);
1✔
792
      opts.series.forEach((obj, index) => {
1✔
793
        self.echarts.appendData({seriesIndex: index, data: obj.data});
2✔
794
      });
795
      // modify this.data
796
      self.utils.mergeData(JSONData, self);
1✔
797
    }
798

799
    self.config.afterUpdate.call(self);
1✔
800
  }
801

802
  /**
803
   * @function
804
   * @name addData
805
   * Add new data. Mainly used for `graph` render.
806
   *
807
   * @param  {object}         JSONData      Data
808
   * @param  {object}         self        NetJSONGraph object
809
   */
810
  addData(JSONData, self) {
811
    // modify this.data
812
    self.utils.mergeData(JSONData, self);
1✔
813

814
    // Ensure nodes are unique by ID using the utility function
815
    if (self.data.nodes && self.data.nodes.length > 0) {
1!
816
      self.data.nodes = self.utils.deduplicateNodesById(self.data.nodes);
1✔
817
    }
818

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

822
    self.config.afterUpdate.call(self);
1✔
823
  }
824

825
  /**
826
   * @function
827
   * @name mergeData
828
   * Merge new data. Modify this.data.
829
   *
830
   * @param  {object}         JSONData      Data
831
   * @param  {object}         self        NetJSONGraph object
832
   */
833
  mergeData(JSONData, self) {
834
    // Ensure incoming nodes array exists
835
    if (!JSONData.nodes) {
3!
836
      JSONData.nodes = [];
×
837
    }
838

839
    // Create a set of existing node IDs for efficient lookup
840
    const existingNodeIds = new Set();
3✔
841
    self.data.nodes.forEach((node) => {
3✔
842
      if (node.id) {
5!
843
        existingNodeIds.add(node.id);
5✔
844
      }
845
    });
846

847
    // Filter incoming nodes: keep nodes without IDs or with new IDs
848
    const newNodes = JSONData.nodes.filter((node) => {
3✔
849
      if (!node.id) {
3!
850
        return true;
×
851
      }
852
      if (existingNodeIds.has(node.id)) {
3✔
853
        console.warn(`Duplicate node ID ${node.id} detected during merge and skipped.`);
1✔
854
        return false;
1✔
855
      }
856
      return true;
2✔
857
    });
858

859
    const nodes = self.data.nodes.concat(newNodes);
3✔
860
    // Ensure incoming links array exists
861
    const incomingLinks = JSONData.links || [];
3!
862
    const links = self.data.links.concat(incomingLinks);
3✔
863

864
    Object.assign(self.data, JSONData, {
3✔
865
      nodes,
866
      links,
867
    });
868
  }
869
}
870

871
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