d3graph 2.9.2__tar.gz → 2.9.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: d3graph
3
- Version: 2.9.2
3
+ Version: 2.9.4
4
4
  Summary: Python package to create interactive network based on d3js.
5
5
  Author-email: Erdogan Taskesen <erdogant@gmail.com>
6
6
  License-Expression: BSD-3-Clause
@@ -103,6 +103,14 @@ from d3graph import d3graph
103
103
  </a>
104
104
  </p>
105
105
 
106
+
107
+ <p align="left">
108
+ <a href="https://erdogant.github.io/docs/d3graph/titanic_example/index.html">
109
+ <img src="https://github.com/d3blocks/d3blocks/blob/main/docs/figs/d3graph/socialmedia.gif" width="1000"/>
110
+ </a>
111
+ </p>
112
+
113
+
106
114
  <hr>
107
115
 
108
116
  ### Contributors
@@ -72,6 +72,14 @@ from d3graph import d3graph
72
72
  </a>
73
73
  </p>
74
74
 
75
+
76
+ <p align="left">
77
+ <a href="https://erdogant.github.io/docs/d3graph/titanic_example/index.html">
78
+ <img src="https://github.com/d3blocks/d3blocks/blob/main/docs/figs/d3graph/socialmedia.gif" width="1000"/>
79
+ </a>
80
+ </p>
81
+
82
+
75
83
  <hr>
76
84
 
77
85
  ### Contributors
@@ -17,7 +17,7 @@ from d3graph.d3graph import (
17
17
 
18
18
  __author__ = 'Erdogan Tasksen'
19
19
  __email__ = 'erdogant@gmail.com'
20
- __version__ = '2.9.2'
20
+ __version__ = '2.9.4'
21
21
 
22
22
  # Setup root logger
23
23
  _logger = logging.getLogger('d3graph')
@@ -123,6 +123,12 @@ class d3graph:
123
123
  node_text_inside: bool = False,
124
124
  max_ticks: int = 300,
125
125
  label_zoom_threshold: float = 0.4,
126
+ canvas_edge_threshold: int = 2000,
127
+ show_density: bool = False,
128
+ density_grid_size: int = 60,
129
+ density_blur: int = 10,
130
+ density_opacity: float = 0.6,
131
+ show_controls: bool = True,
126
132
  ) -> None:
127
133
  """Build and show the graph.
128
134
 
@@ -177,6 +183,39 @@ class d3graph:
177
183
  Node and edge labels are hidden below this zoom scale (unreadable anyway, and
178
184
  costly to keep rendering for large graphs) and reappear once zoomed back in
179
185
  past it. Uses a single CSS class toggle, not per-label work. 0: never hide.
186
+ canvas_edge_threshold : int, (default: 2000)
187
+ Above this many visible edges, edges are drawn on a <canvas> layer instead of
188
+ as individual SVG <line> elements. SVG's per-element DOM overhead is what makes
189
+ tens of thousands of edges freeze the page; canvas draw calls stay cheap
190
+ regardless of edge count. Nodes always stay SVG (drag/click/tooltips). Only
191
+ applies once edges exceed this count, so small/medium graphs are unaffected -
192
+ note that in canvas mode, the "Save as SVG" export won't include edges, since
193
+ they no longer live in the SVG DOM.
194
+ show_density : bool, (default: False)
195
+ Adds a node-clustering heatmap layer (grid-binned density of node positions),
196
+ drawn on its own canvas beneath the edges and nodes, with a toggle button in
197
+ the UI ("Show/Hide Density") to turn it on/off regardless of this default.
198
+ Recomputed from live node positions each frame it's visible, so it tracks the
199
+ force layout as nodes settle, and it responds to the weight/component sliders
200
+ since it's based on whichever nodes are currently on screen. Color scheme is
201
+ a yellow-to-red heat gradient in light mode, single-hue blue in dark mode
202
+ (updates live when dark mode is toggled).
203
+ density_grid_size : int, (default: 40)
204
+ Grid resolution for the density heatmap (cells along the longer axis of the
205
+ node bounding box). Higher = finer detail on tight clusters, more cells to draw.
206
+ density_blur : int, (default: 8)
207
+ Blur radius (px) applied to the heatmap for a smooth look instead of a
208
+ blocky grid.
209
+ density_opacity : float, (default: 0.6)
210
+ Maximum heatmap opacity, reached at the highest-density grid cell.
211
+ show_controls : bool, (default: True)
212
+ Whether to render the top-panel buttons (Dark Mode, Hide Edges, Show Density,
213
+ Save) at all. Set to False when embedding the generated HTML into an existing
214
+ page/app that provides its own UI chrome and doesn't need d3graph's built-in
215
+ controls - the buttons (and their JS wiring) are omitted entirely rather than
216
+ just hidden, so there's no extra DOM/clutter in the embed. The weight/component
217
+ sliders are controlled separately via show_slider; the Save button specifically
218
+ is also controlled via save_button, independent of this.
180
219
 
181
220
  Returns
182
221
  -------
@@ -201,6 +240,12 @@ class d3graph:
201
240
  self.config['node_text_inside'] = node_text_inside
202
241
  self.config['max_ticks'] = max_ticks
203
242
  self.config['label_zoom_threshold'] = label_zoom_threshold
243
+ self.config['canvas_edge_threshold'] = canvas_edge_threshold
244
+ self.config['show_density'] = show_density
245
+ self.config['density_grid_size'] = density_grid_size
246
+ self.config['density_blur'] = density_blur
247
+ self.config['density_opacity'] = density_opacity
248
+ self.config['show_controls'] = show_controls
204
249
 
205
250
  # Allow show() to override the link_tension set at __init__ time
206
251
  if link_tension is not None:
@@ -802,7 +847,6 @@ class d3graph:
802
847
 
803
848
  # Hide slider
804
849
  show_slider = ['', ''] if self.config['show_slider'] else ['<!--', '-->']
805
- show_save_button = ['', ''] if self.config['save_button'] else ['<!--', '-->']
806
850
  # Set width and height to screen resolution if None.
807
851
  width = 'window.screen.width' if self.config['figsize'][0] is None else self.config['figsize'][0]
808
852
  height = 'window.screen.height' if self.config['figsize'][1] is None else self.config['figsize'][1]
@@ -824,6 +868,13 @@ class d3graph:
824
868
  'sticky': self.config.get('sticky', False),
825
869
  'max_ticks': self.config.get('max_ticks', 300),
826
870
  'label_zoom_threshold': self.config.get('label_zoom_threshold', 0.6),
871
+ 'canvas_edge_threshold': self.config.get('canvas_edge_threshold', 2000),
872
+ 'show_density': self.config.get('show_density', False),
873
+ 'density_grid_size': self.config.get('density_grid_size', 40),
874
+ 'density_blur': self.config.get('density_blur', 8),
875
+ 'density_opacity': self.config.get('density_opacity', 0.6),
876
+ 'show_controls': self.config.get('show_controls', True),
877
+ 'save_button': self.config['save_button'],
827
878
  'node_text_inside': self.config.get('node_text_inside', False),
828
879
  'CLICK_COMMENT': CLICK_COMMENT,
829
880
  'CLICK_FILL': click_properties['fill'],
@@ -832,8 +883,6 @@ class d3graph:
832
883
  'CLICK_STROKEW': click_properties['stroke-width'],
833
884
  'slider_comment_start': show_slider[0],
834
885
  'slider_comment_stop': show_slider[1],
835
- 'save_button_comment_start': show_save_button[0],
836
- 'save_button_comment_stop': show_save_button[1],
837
886
  'SET_SLIDER': self.config['set_slider'],
838
887
  'SUPPORT': support,
839
888
  'background_color': self.config['background_color'],
@@ -12,6 +12,12 @@ function d3graphscript(config = {
12
12
  node_text_inside: false,
13
13
  max_ticks: 300,
14
14
  label_zoom_threshold: 0.6,
15
+ canvas_edge_threshold: 2000,
16
+ density_grid_size: 40,
17
+ density_blur: 8,
18
+ density_opacity: 0.6,
19
+ show_density: false,
20
+ dark_mode: false,
15
21
  }) {
16
22
 
17
23
  //Constants for the SVG
@@ -30,6 +36,34 @@ function d3graphscript(config = {
30
36
  // single CSS class, not per-element work) instead of rendering thousands
31
37
  // of illegible <text> nodes. They reappear once zoomed back in past it.
32
38
  var labelZoomThreshold = (config.label_zoom_threshold !== undefined && config.label_zoom_threshold !== null) ? config.label_zoom_threshold : 0.6;
39
+ // Above this many visible edges, draw links on a <canvas> instead of as
40
+ // SVG <line> elements. SVG per-element overhead (DOM node creation,
41
+ // layout, GC) is what makes tens of thousands of edges freeze the page;
42
+ // canvas just issues draw calls into a single bitmap each frame, so cost
43
+ // stays flat regardless of edge count. Nodes stay SVG either way (far
44
+ // fewer of them, and it keeps drag/click/tooltip interactivity simple).
45
+ var canvasEdgeThreshold = (config.canvas_edge_threshold !== undefined && config.canvas_edge_threshold !== null) ? config.canvas_edge_threshold : 2000;
46
+ var useCanvasEdges = false;
47
+ var canvasEl, ctx;
48
+ var currentTransform = { scale: 1, translate: [0, 0] };
49
+ // Master on/off switch for edges (independent of the weight/component
50
+ // sliders) — lets the user clear visual clutter on large graphs to see
51
+ // node structure/clustering without redrawing or refiltering anything.
52
+ var edgesVisible = true;
53
+
54
+ // ---- DENSITY (clustering heatmap) LAYER ----
55
+ // Grid-binned node density, drawn on its own canvas beneath the edges and
56
+ // nodes. Recomputed from live node positions every tick it's visible, so
57
+ // it tracks the force layout as nodes settle/move — cheap since it's a
58
+ // single O(nodes) pass, unlike the edge count this was never the
59
+ // bottleneck. Color scheme adapts to dark mode (updated live via
60
+ // window.d3graphSetDarkMode, called from the dark-mode toggle).
61
+ var densityGridSize = (config.density_grid_size !== undefined && config.density_grid_size !== null) ? config.density_grid_size : 40;
62
+ var densityBlur = (config.density_blur !== undefined && config.density_blur !== null) ? config.density_blur : 8;
63
+ var densityOpacity = (config.density_opacity !== undefined && config.density_opacity !== null) ? config.density_opacity : 0.6;
64
+ var densityVisible = config.show_density || false;
65
+ var darkMode = config.dark_mode || false;
66
+ var densityCanvasEl, densityCtx, densityOffscreen;
33
67
 
34
68
  // Set the body background color
35
69
  document.body.style.backgroundColor = background_color;
@@ -67,6 +101,7 @@ function d3graphscript(config = {
67
101
  } else {
68
102
  d3.select(this).attr("cx", d.x = d3.event.x).attr("cy", d.y = d3.event.y);
69
103
  }
104
+ if (densityVisible) drawDensityLayer();
70
105
  }
71
106
 
72
107
  function dragended(d) {
@@ -223,19 +258,239 @@ function d3graphscript(config = {
223
258
 
224
259
  // ---- END SHAPE RENDERING ----
225
260
 
226
- //Append a SVG to the body of the html page. Assign this SVG as an object to svg
227
- var svg = d3.select("body").append("svg")
261
+ // Layered container: canvas (background + edges, used only above
262
+ // canvasEdgeThreshold) sits under a transparent SVG (nodes always render
263
+ // here, for drag/click/tooltip interactivity). The background color lives
264
+ // on the container so it shows through the transparent SVG either way.
265
+ var container = d3.select("body").append("div")
266
+ .attr("id", "graphContainer")
267
+ .style("position", "relative")
268
+ .style("width", width + "px")
269
+ .style("height", height + "px")
270
+ .style("background-color", background_color);
271
+
272
+ // Density (clustering heatmap) layer — created first so it stacks
273
+ // beneath both the edge canvas and the SVG node layer.
274
+ densityCanvasEl = container.append("canvas")
228
275
  .attr("width", width)
229
276
  .attr("height", height)
230
- .style("background-color", background_color)
277
+ .style("position", "absolute")
278
+ .style("top", 0)
279
+ .style("left", 0)
280
+ .style("pointer-events", "none")
281
+ .style("display", densityVisible ? null : "none")
282
+ .node();
283
+ densityCtx = densityCanvasEl.getContext("2d");
284
+
285
+ canvasEl = container.append("canvas")
286
+ .attr("width", width)
287
+ .attr("height", height)
288
+ .style("position", "absolute")
289
+ .style("top", 0)
290
+ .style("left", 0)
291
+ .style("pointer-events", "none")
292
+ .node();
293
+ ctx = canvasEl.getContext("2d");
294
+
295
+ //Append a SVG to the container. Assign this SVG as an object to svg
296
+ var svg = container.append("svg")
297
+ .attr("width", width)
298
+ .attr("height", height)
299
+ .style("position", "absolute")
300
+ .style("top", 0)
301
+ .style("left", 0)
302
+ .style("background-color", "transparent")
231
303
  .call(d3.behavior.zoom().on("zoom", function () {
232
304
  svg.attr("transform", "translate(" + d3.event.translate + ")" + " scale(" + d3.event.scale + ")");
233
305
  // Single class toggle (cheap) rather than iterating every label on
234
306
  // every zoom/pan event — CSS handles hiding all descendant <text>.
235
307
  svg.classed("labels-hidden", d3.event.scale < labelZoomThreshold);
308
+ currentTransform.scale = d3.event.scale;
309
+ currentTransform.translate = d3.event.translate;
310
+ drawCanvasEdges();
311
+ drawDensityLayer();
236
312
  }))
237
313
  .on("dblclick.zoom", null)
238
314
  .append("g")
315
+
316
+ // Draws all links onto the canvas layer, matching the SVG group's current
317
+ // pan/zoom transform. No-ops when the graph is small enough to stay on SVG.
318
+ function drawCanvasEdges() {
319
+ if (!useCanvasEdges || !ctx || !edgesVisible) return;
320
+ ctx.save();
321
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
322
+ ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
323
+ ctx.setTransform(currentTransform.scale, 0, 0, currentTransform.scale, currentTransform.translate[0], currentTransform.translate[1]);
324
+ for (var i = 0; i < graph.links.length; i++) {
325
+ var d = graph.links[i];
326
+ if (!d.source || !d.target || typeof d.source.x !== 'number') continue;
327
+ ctx.beginPath();
328
+ ctx.moveTo(d.source.x, d.source.y);
329
+ ctx.lineTo(d.target.x, d.target.y);
330
+ ctx.strokeStyle = d.edge_color || '#999';
331
+ ctx.globalAlpha = (d.edge_opacity !== undefined && d.edge_opacity !== null) ? d.edge_opacity : 0.6;
332
+ ctx.lineWidth = d.edge_width || 1;
333
+ ctx.setLineDash(d.edge_style === 'dashed' ? [6, 3] : d.edge_style === 'dotted' ? [1.5, 3] : []);
334
+ ctx.stroke();
335
+ }
336
+ ctx.restore();
337
+ }
338
+
339
+ // Bins the currently visible nodes' live (x, y) positions into a coarse
340
+ // grid over their bounding box. O(nodes) — cheap enough to recompute
341
+ // every tick, unlike the edge count this was never the bottleneck.
342
+ function computeDensityGrid() {
343
+ var nodesData = node.data();
344
+ if (!nodesData.length) return null;
345
+
346
+ var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
347
+ for (var i = 0; i < nodesData.length; i++) {
348
+ var nx = nodesData[i].x, ny = nodesData[i].y;
349
+ if (typeof nx !== 'number' || typeof ny !== 'number') continue;
350
+ if (nx < minX) minX = nx;
351
+ if (nx > maxX) maxX = nx;
352
+ if (ny < minY) minY = ny;
353
+ if (ny > maxY) maxY = ny;
354
+ }
355
+ if (minX === Infinity) return null;
356
+
357
+ var padX = (maxX - minX) * 0.05 || 20;
358
+ var padY = (maxY - minY) * 0.05 || 20;
359
+ minX -= padX; maxX += padX; minY -= padY; maxY += padY;
360
+
361
+ var w = (maxX - minX) || 1;
362
+ var h = (maxY - minY) || 1;
363
+ var cellsX, cellsY;
364
+ if (w >= h) {
365
+ cellsX = densityGridSize;
366
+ cellsY = Math.max(1, Math.round(densityGridSize * h / w));
367
+ } else {
368
+ cellsY = densityGridSize;
369
+ cellsX = Math.max(1, Math.round(densityGridSize * w / h));
370
+ }
371
+ var cellW = w / cellsX;
372
+ var cellH = h / cellsY;
373
+
374
+ var grid = new Float32Array(cellsX * cellsY);
375
+ var maxCount = 0;
376
+ for (var j = 0; j < nodesData.length; j++) {
377
+ var d = nodesData[j];
378
+ if (typeof d.x !== 'number' || typeof d.y !== 'number') continue;
379
+ var cx = Math.min(cellsX - 1, Math.max(0, Math.floor((d.x - minX) / cellW)));
380
+ var cy = Math.min(cellsY - 1, Math.max(0, Math.floor((d.y - minY) / cellH)));
381
+ var idx = cy * cellsX + cx;
382
+ grid[idx] += 1;
383
+ if (grid[idx] > maxCount) maxCount = grid[idx];
384
+ }
385
+
386
+ return { grid: grid, cellsX: cellsX, cellsY: cellsY, cellW: cellW, cellH: cellH, minX: minX, minY: minY, maxCount: maxCount };
387
+ }
388
+
389
+ // t in [0, 1] (relative density) -> fill color. Light mode: yellow -> red
390
+ // heat gradient. Dark mode: single-hue blue, so it reads well against a
391
+ // dark background instead of clashing with it.
392
+ function densityColor(t) {
393
+ if (darkMode) {
394
+ var l = 25 + t * 45; // 25%..70% lightness
395
+ return 'hsl(210, 90%, ' + l + '%)';
396
+ }
397
+ var hue = 60 - t * 60; // 60=yellow -> 0=red
398
+ return 'hsl(' + hue + ', 100%, 50%)';
399
+ }
400
+
401
+ // Draws the heatmap: unblurred cells go to an offscreen buffer first,
402
+ // then get composited onto the visible canvas with a single blurred
403
+ // drawImage — much cheaper than blurring each cell individually.
404
+ function drawDensityLayer() {
405
+ if (!densityVisible || !densityCtx) return;
406
+ densityCtx.save();
407
+ densityCtx.setTransform(1, 0, 0, 1, 0, 0);
408
+ densityCtx.clearRect(0, 0, densityCanvasEl.width, densityCanvasEl.height);
409
+
410
+ var data = computeDensityGrid();
411
+ if (!data || data.maxCount <= 0) { densityCtx.restore(); return; }
412
+
413
+ if (!densityOffscreen) densityOffscreen = document.createElement('canvas');
414
+ densityOffscreen.width = densityCanvasEl.width;
415
+ densityOffscreen.height = densityCanvasEl.height;
416
+ var offCtx = densityOffscreen.getContext('2d');
417
+ offCtx.clearRect(0, 0, densityOffscreen.width, densityOffscreen.height);
418
+ offCtx.setTransform(currentTransform.scale, 0, 0, currentTransform.scale, currentTransform.translate[0], currentTransform.translate[1]);
419
+
420
+ for (var cy = 0; cy < data.cellsY; cy++) {
421
+ for (var cx = 0; cx < data.cellsX; cx++) {
422
+ var count = data.grid[cy * data.cellsX + cx];
423
+ if (count <= 0) continue;
424
+ var t = count / data.maxCount;
425
+ offCtx.fillStyle = densityColor(t);
426
+ offCtx.globalAlpha = densityOpacity * t;
427
+ offCtx.fillRect(data.minX + cx * data.cellW, data.minY + cy * data.cellH, data.cellW, data.cellH);
428
+ }
429
+ }
430
+
431
+ densityCtx.filter = 'blur(' + densityBlur + 'px)';
432
+ densityCtx.drawImage(densityOffscreen, 0, 0);
433
+ densityCtx.filter = 'none';
434
+ densityCtx.restore();
435
+ }
436
+
437
+
438
+ function applyEdgeVisibility() {
439
+ svg.classed("edges-hidden", !edgesVisible);
440
+ if (useCanvasEdges) {
441
+ if (edgesVisible) {
442
+ d3.select(canvasEl).style("display", null);
443
+ drawCanvasEdges();
444
+ } else {
445
+ if (ctx) ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
446
+ d3.select(canvasEl).style("display", "none");
447
+ }
448
+ } else {
449
+ d3.select(canvasEl).style("display", "none");
450
+ }
451
+ }
452
+
453
+ // Builds (or tears down) the SVG <line>/<text> elements for links, or
454
+ // switches to canvas rendering, based on the current edge count. Shared
455
+ // by the initial render and by restart() (slider-driven updates), so the
456
+ // threshold is re-evaluated every time the visible edge set changes.
457
+ function renderLinks() {
458
+ useCanvasEdges = graph.links.length > canvasEdgeThreshold;
459
+
460
+ if (useCanvasEdges) {
461
+ // Canvas takes over — drop any SVG link/link-text DOM entirely.
462
+ link = link.data([]);
463
+ link.exit().remove();
464
+ linkText = linkText.data([]);
465
+ linkText.exit().remove();
466
+ } else {
467
+ if (ctx) ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
468
+
469
+ link = link.data(graph.links);
470
+ link.exit().remove();
471
+ var linkEnter = link.enter().insert("line", ".node")
472
+ .attr("class", "link")
473
+ .attr('marker-start', function(d) { return 'url(#marker_' + d.marker_start + ')' });
474
+ linkEnter.append("title").text(function(d) { return d.tooltip; });
475
+ link.attr("marker-end", function(d) {
476
+ if (config.directed) { return 'url(#marker_' + d.marker_end + ')' } });
477
+ link.style("stroke-width", function(d) { return d.edge_width; });
478
+ link.style("stroke", function(d) { return d.edge_color; });
479
+ link.style("stroke-dasharray", function(d) { return d.edge_style; });
480
+ link.style("opacity", function(d) { return d.edge_opacity; });
481
+
482
+ linkText = linkText.data(graph.links);
483
+ linkText.exit().remove();
484
+ linkText.enter().append("text")
485
+ .attr("class", "link-text")
486
+ .attr("font-size", function(d) { return d.label_fontsize + "px"; })
487
+ .style("fill", function(d) { return d.label_color; })
488
+ .style("font-family", "Arial")
489
+ .text(function(d) { return d.label; });
490
+ }
491
+
492
+ applyEdgeVisibility();
493
+ }
239
494
 
240
495
  graphRec = JSON.parse(JSON.stringify(graph)); // Full, unfiltered copy — used by the slider to restore edges later
241
496
 
@@ -253,32 +508,13 @@ function d3graphscript(config = {
253
508
  .links(graph.links)
254
509
  .start();
255
510
 
256
- // Create all the line svgs but without locations yet
257
- var link = svg.selectAll(".link")
258
- .data(graph.links)
259
- .enter().append("line")
260
- .attr("class", "link")
261
- .attr('marker-start', function(d){ return 'url(#marker_' + d.marker_start + ')' })
262
- .attr("marker-end", function(d) {
263
- if (config.directed) {return 'url(#marker_' + d.marker_end + ')' }})
264
- .style("stroke-width", function(d) {return d.edge_width;}) // LINK-WIDTH
265
- .style("stroke", function(d) {return d.edge_color;}) // EDGE-COLORS
266
- .style("stroke-dasharray", function(d) {return d.edge_style;}) // EDGE-STYLE
267
- .style("opacity", function(d) {return d.edge_opacity;}) // EDGE-OPACITY
268
- ;
269
-
270
- link.append("title").text(function(d) { return d.tooltip; });
271
-
272
- // ADD TEXT ON THE EDGES (PART 1/2)
273
- var linkText = svg.selectAll(".link-text")
274
- .data(graph.links)
275
- .enter().append("text")
276
- .attr("class", "link-text")
277
- .attr("font-size", function(d) {return d.label_fontsize + "px";})
278
- .style("fill", function(d) {return d.label_color;})
279
- .style("font-family", "Arial")
280
- .text(function(d) { return d.label; });
281
-
511
+ // Create empty node/link/link-text selections. renderLinks(), called
512
+ // below, decides whether links go to SVG or canvas and populates
513
+ // link/linkText accordingly.
514
+ var link = svg.selectAll(".link").data([]);
515
+ var linkText = svg.selectAll(".link-text").data([]);
516
+ renderLinks();
517
+
282
518
  //Do the same with the circles for the nodes
283
519
  var node = svg.selectAll(".node")
284
520
  .data(graph.nodes)
@@ -346,10 +582,14 @@ function d3graphscript(config = {
346
582
  return;
347
583
  }
348
584
 
349
- link.attr("x1", function(d) { return d.source.x; })
350
- .attr("y1", function(d) { return d.source.y; })
351
- .attr("x2", function(d) { return d.target.x; })
352
- .attr("y2", function(d) { return d.target.y; });
585
+ if (useCanvasEdges) {
586
+ drawCanvasEdges();
587
+ } else {
588
+ link.attr("x1", function(d) { return d.source.x; })
589
+ .attr("y1", function(d) { return d.source.y; })
590
+ .attr("x2", function(d) { return d.target.x; })
591
+ .attr("y2", function(d) { return d.target.y; });
592
+ }
353
593
 
354
594
  // Position each shape according to its SVG element type:
355
595
  // circle / ellipse → cx / cy attributes
@@ -376,6 +616,8 @@ function d3graphscript(config = {
376
616
 
377
617
  node.each(collide(config.collision)); //COLLISION DETECTION. High means a big fight to get untouchable nodes (default=0.5)
378
618
 
619
+ if (densityVisible) drawDensityLayer();
620
+
379
621
  });
380
622
 
381
623
  // --------- MARKER FOR EDGE ENDINGS -----------
@@ -549,29 +791,45 @@ function d3graphscript(config = {
549
791
 
550
792
  d3.select("#thresholdSlider").on("change", threshold);
551
793
 
794
+ // Master edges on/off toggle — independent of the weight slider, purely
795
+ // visual, doesn't touch the underlying filtered data.
796
+ var edgeToggleBtn = document.getElementById('edgeToggleButton');
797
+ if (edgeToggleBtn) {
798
+ edgeToggleBtn.addEventListener('click', function() {
799
+ edgesVisible = !edgesVisible;
800
+ edgeToggleBtn.textContent = edgesVisible ? 'Hide Edges' : 'Show Edges';
801
+ applyEdgeVisibility();
802
+ });
803
+ }
804
+
805
+ // Density (clustering heatmap) layer toggle.
806
+ var densityToggleBtn = document.getElementById('densityToggleButton');
807
+ if (densityToggleBtn) {
808
+ densityToggleBtn.addEventListener('click', function() {
809
+ densityVisible = !densityVisible;
810
+ densityToggleBtn.textContent = densityVisible ? 'Hide Density' : 'Show Density';
811
+ d3.select(densityCanvasEl).style("display", densityVisible ? null : "none");
812
+ if (densityVisible) {
813
+ drawDensityLayer();
814
+ } else {
815
+ densityCtx.setTransform(1, 0, 0, 1, 0, 0);
816
+ densityCtx.clearRect(0, 0, densityCanvasEl.width, densityCanvasEl.height);
817
+ }
818
+ });
819
+ }
820
+
821
+ // Called from the dark-mode toggle (outside this function's scope, in the
822
+ // page's own script) so the density color scheme switches live instead of
823
+ // only reflecting whatever mode the page loaded in.
824
+ window.d3graphSetDarkMode = function(isDark) {
825
+ darkMode = !!isDark;
826
+ if (densityVisible) drawDensityLayer();
827
+ };
828
+
552
829
  //Restart the visualisation after any node and link changes
553
830
  function restart() {
554
831
 
555
- // Update EDGE-LINKS
556
- link = link.data(graph.links);
557
- link.exit().remove();
558
- link.enter().insert("line", ".node").attr("class", "link");
559
- link.style("stroke-width", function(d) {return d.edge_width;}); // LINK-WIDTH AFTER BREAKING WITH SLIDER
560
- link.style("marker-end", function(d) { // Include the markers.
561
- if (config.directed) {return 'url(#marker_' + d.marker_end + ')' }})
562
- link.style("stroke", function(d) {return d.edge_color;}); // EDGE-COLOR AFTER BREAKING WITH SLIDER
563
- link.style("stroke-dasharray", function(d) {return d.edge_style;}) // EDGE-STYLE
564
- link.style("opacity", function(d) {return d.edge_opacity;}); // EDGE-OPACITY AFTER BREAKING WITH SLIDER
565
-
566
- // Update EDGE-LABELS
567
- linkText = linkText.data(graph.links);
568
- linkText.exit().remove();
569
- linkText.enter().append("text")
570
- .attr("class", "link-text")
571
- .attr("font-size", function(d) {return d.label_fontsize + "px";})
572
- .style("fill", function(d) {return d.label_color;})
573
- .style("font-family", "Arial")
574
- .text(function(d) { return d.label; });
832
+ renderLinks();
575
833
 
576
834
  node = node.data(graph.nodes);
577
835
  node.enter().insert("circle", ".cursor").attr("class", "node").attr("r", 5).call(force.drag);