d3graph 3.0.1__tar.gz → 3.1.0__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: 3.0.1
3
+ Version: 3.1.0
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
@@ -28,6 +28,7 @@ Requires-Dist: markupsafe
28
28
  Requires-Dist: python-louvain
29
29
  Requires-Dist: datazets
30
30
  Requires-Dist: distfit
31
+ Requires-Dist: tqdm
31
32
  Dynamic: license-file
32
33
 
33
34
  # D3graph: Interactive force-directed networks
@@ -17,7 +17,7 @@ from d3graph.d3graph import (
17
17
 
18
18
  __author__ = 'Erdogan Tasksen'
19
19
  __email__ = 'erdogant@gmail.com'
20
- __version__ = '3.0.1'
20
+ __version__ = '3.1.0'
21
21
 
22
22
  # Setup root logger
23
23
  _logger = logging.getLogger('d3graph')
@@ -58,11 +58,26 @@ function d3graphscript(config = {
58
58
  var useCanvasEdges = false;
59
59
  var canvasEl, ctx;
60
60
  var currentTransform = { scale: 1, translate: [0, 0] };
61
+ var zoomBehavior = null; // set when SVG is created; used by _jumpToNode to sync state
61
62
  // Master on/off switch for edges (independent of the weight/component
62
63
  // sliders) — lets the user clear visual clutter on large graphs to see
63
64
  // node structure/clustering without redrawing or refiltering anything.
64
65
  var edgesVisible = true;
65
66
 
67
+ // ---- VISUAL SCALE MULTIPLIERS (physics panel sliders) ----
68
+ // Independent of the per-node data values; multiplied in at render time so
69
+ // changing them never mutates the underlying data and is instantly reversible.
70
+ var edgeWidthMult = 1.0; // 0.1 – 5.0, driven by the "Edge Width" slider
71
+ var nodeSizeMult = 1.0; // 0.1 – 5.0, driven by the "Node Size" slider
72
+
73
+ // ---- PERFORMANCE: cached quadtree for collision detection ----
74
+ // Rebuilding a quadtree from scratch every single tick is O(N log N) and
75
+ // dominates frame time on graphs with thousands of nodes. We reuse one
76
+ // quadtree across the whole tick instead — the quadtree is still built once
77
+ // per tick (nodes moved since last tick), but NOT once per node per tick.
78
+ var _collideQuadtree = null;
79
+ var _collideTickId = -1; // matches tickCount so we rebuild once per tick
80
+
66
81
  // ---- STATS PANEL (recolor nodes by a network statistic) ----
67
82
  // node_pagerank / node_hits_hub / node_hits_authority are precomputed
68
83
  // server-side (networkx) and normalized to [0, 1]; the panel just picks
@@ -126,6 +141,9 @@ function d3graphscript(config = {
126
141
  // structure pops. highlightedNodeIndex is null when nothing is
127
142
  // highlighted, or the .index of the currently-highlighted node.
128
143
  var highlightedNodeIndex = null;
144
+
145
+ // ---- SEARCH ----
146
+ var searchHighlightedIndex = null; // node .index of the current search hit
129
147
  // True (default): clicking a node highlights every node/edge in its whole
130
148
  // connected component, however many hops away. False: only its directly-
131
149
  // connected (one-hop) neighbors/edges light up, the old behavior.
@@ -400,17 +418,15 @@ function d3graphscript(config = {
400
418
  .style("top", 0)
401
419
  .style("left", 0)
402
420
  .style("background-color", "transparent")
403
- .call(d3.behavior.zoom().on("zoom", function () {
421
+ .call((zoomBehavior = d3.behavior.zoom().on("zoom", function () {
404
422
  panOrZoomOccurred = true;
405
423
  svg.attr("transform", "translate(" + d3.event.translate + ")" + " scale(" + d3.event.scale + ")");
406
- // Single class toggle (cheap) rather than iterating every label on
407
- // every zoom/pan event — CSS handles hiding all descendant <text>.
408
424
  svg.classed("labels-hidden", d3.event.scale < labelZoomThreshold);
409
425
  currentTransform.scale = d3.event.scale;
410
426
  currentTransform.translate = d3.event.translate;
411
427
  drawCanvasEdges();
412
428
  drawDensityLayer();
413
- }))
429
+ })))
414
430
  .on("dblclick.zoom", null)
415
431
  .append("g")
416
432
 
@@ -418,48 +434,104 @@ function d3graphscript(config = {
418
434
  // pan/zoom transform. No-ops when the graph is small enough to stay on SVG.
419
435
  function drawCanvasEdges() {
420
436
  if (!useCanvasEdges || !ctx || !edgesVisible) return;
437
+
438
+ var sc = currentTransform.scale;
439
+ var tx = currentTransform.translate[0];
440
+ var ty = currentTransform.translate[1];
441
+
442
+ // Compute viewport bounds in graph-space so we can skip edges whose
443
+ // both endpoints are well outside the visible area (cheap early-out).
444
+ var vpMargin = 20 / sc; // a small buffer in graph units
445
+ var vpMinX = (-tx / sc) - vpMargin;
446
+ var vpMaxX = ((canvasEl.width - tx) / sc) + vpMargin;
447
+ var vpMinY = (-ty / sc) - vpMargin;
448
+ var vpMaxY = ((canvasEl.height - ty) / sc) + vpMargin;
449
+
421
450
  ctx.save();
422
451
  ctx.setTransform(1, 0, 0, 1, 0, 0);
423
452
  ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
424
- ctx.setTransform(currentTransform.scale, 0, 0, currentTransform.scale, currentTransform.translate[0], currentTransform.translate[1]);
425
- for (var i = 0; i < graph.links.length; i++) {
426
- var d = graph.links[i];
453
+ ctx.setTransform(sc, 0, 0, sc, tx, ty);
454
+
455
+ // Batch edges by (strokeStyle + lineWidth + globalAlpha + shadowBlur +
456
+ // dash pattern) to minimise expensive canvas state changes. Instead of
457
+ // setting style then stroking once per edge, we bucket all edges that
458
+ // share the same style, then set the style once and stroke the whole
459
+ // bucket in a single path. On a 20 000-edge graph this typically
460
+ // reduces unique state-change sequences from 20 000 to a handful.
461
+
462
+ // Bucket map: key → { path points, ctx state }
463
+ // We collect lines per bucket, then replay with a single ctx state set.
464
+ var buckets = {}; // key → { a, lw, sw, sb, sc_str, dash, lines: [[x1,y1,x2,y2],...] }
465
+
466
+ var links = graph.links;
467
+ var nLinks = links.length;
468
+
469
+ for (var i = 0; i < nLinks; i++) {
470
+ var d = links[i];
427
471
  if (!d.source || !d.target || typeof d.source.x !== 'number') continue;
428
- ctx.beginPath();
429
- ctx.moveTo(d.source.x, d.source.y);
430
- ctx.lineTo(d.target.x, d.target.y);
431
- ctx.strokeStyle = d.edge_color || '#999';
472
+
473
+ var sx = d.source.x, sy = d.source.y;
474
+ var ex = d.target.x, ey = d.target.y;
475
+
476
+ // Viewport cull: skip if both endpoints are outside on the same side.
477
+ if ((sx < vpMinX && ex < vpMinX) || (sx > vpMaxX && ex > vpMaxX) ||
478
+ (sy < vpMinY && ey < vpMinY) || (sy > vpMaxY && ey > vpMaxY)) continue;
479
+
432
480
  var baseOpacity = (d.edge_opacity !== undefined && d.edge_opacity !== null) ? d.edge_opacity : 0.6;
433
- var baseWidth = d.edge_width || 1;
434
- ctx.setLineDash(d.edge_style === 'dashed' ? [6, 3] : d.edge_style === 'dotted' ? [1.5, 3] : []);
481
+ var baseWidth = (d.edge_width || 1) * edgeWidthMult;
482
+ var col = d.edge_color || '#999';
483
+ var dashStyle = d.edge_style;
484
+
485
+ var alpha, lw, shadowBlur, shadowColor;
435
486
 
436
487
  if (highlightedNodeIndex !== null) {
437
- // Click-highlight takes priority over the stat-driven boost below —
438
- // same precedence SVG-mode edges get in applyNeighborHighlightStyling().
439
- // When highlightedComponentSet is set (highlightFullNetwork), an edge
440
- // counts as connected if BOTH its ends are anywhere in the clicked
441
- // node's connected component, not just directly touching it.
442
488
  var isConnected = highlightedComponentSet
443
489
  ? (highlightedComponentSet[d.source.index] && highlightedComponentSet[d.target.index])
444
490
  : (d.source.index === highlightedNodeIndex || d.target.index === highlightedNodeIndex);
445
491
  if (isConnected) {
446
- ctx.globalAlpha = 1;
447
- ctx.lineWidth = baseWidth * EDGE_GLOW_WIDTH_MULT;
448
- ctx.shadowColor = EDGE_GLOW_COLOR;
449
- ctx.shadowBlur = EDGE_GLOW_CANVAS_BLUR;
492
+ alpha = 1; lw = baseWidth * EDGE_GLOW_WIDTH_MULT;
493
+ shadowBlur = EDGE_GLOW_CANVAS_BLUR; shadowColor = EDGE_GLOW_COLOR;
450
494
  } else {
451
- ctx.globalAlpha = NEIGHBOR_DIM_OPACITY;
452
- ctx.lineWidth = baseWidth;
453
- ctx.shadowBlur = 0;
495
+ alpha = NEIGHBOR_DIM_OPACITY; lw = baseWidth;
496
+ shadowBlur = 0; shadowColor = '';
454
497
  }
455
498
  } else {
456
- ctx.globalAlpha = statHighlightActive ? Math.min(1, baseOpacity * EDGE_HIGHLIGHT_OPACITY_MULT) : baseOpacity;
457
- ctx.lineWidth = statHighlightActive ? baseWidth * EDGE_HIGHLIGHT_WIDTH_MULT : baseWidth;
458
- ctx.shadowBlur = 0;
499
+ alpha = statHighlightActive ? Math.min(1, baseOpacity * EDGE_HIGHLIGHT_OPACITY_MULT) : baseOpacity;
500
+ lw = statHighlightActive ? baseWidth * EDGE_HIGHLIGHT_WIDTH_MULT : baseWidth;
501
+ shadowBlur = 0; shadowColor = '';
459
502
  }
460
503
 
504
+ // Round to 2 decimal places for bucketing to avoid float key explosion.
505
+ var key = col + '|' + (Math.round(alpha * 100)) + '|' + (Math.round(lw * 100)) +
506
+ '|' + shadowBlur + '|' + (shadowColor || '') + '|' + (dashStyle || '');
507
+ if (!buckets[key]) {
508
+ buckets[key] = { col: col, alpha: alpha, lw: lw,
509
+ shadowBlur: shadowBlur, shadowColor: shadowColor,
510
+ dash: dashStyle === 'dashed' ? [6, 3] : dashStyle === 'dotted' ? [1.5, 3] : [],
511
+ lines: [] };
512
+ }
513
+ buckets[key].lines.push(sx, sy, ex, ey);
514
+ }
515
+
516
+ // Replay each bucket as a single compound path — one state setup, one stroke.
517
+ for (var key in buckets) {
518
+ var b = buckets[key];
519
+ ctx.strokeStyle = b.col;
520
+ ctx.globalAlpha = b.alpha;
521
+ ctx.lineWidth = b.lw;
522
+ ctx.shadowBlur = b.shadowBlur;
523
+ ctx.shadowColor = b.shadowColor || '';
524
+ ctx.setLineDash(b.dash);
525
+
526
+ ctx.beginPath();
527
+ var pts = b.lines;
528
+ for (var j = 0, jn = pts.length; j < jn; j += 4) {
529
+ ctx.moveTo(pts[j], pts[j + 1]);
530
+ ctx.lineTo(pts[j + 2], pts[j + 3]);
531
+ }
461
532
  ctx.stroke();
462
533
  }
534
+
463
535
  ctx.restore();
464
536
  }
465
537
 
@@ -601,7 +673,7 @@ function d3graphscript(config = {
601
673
  linkEnter.append("title").text(function(d) { return d.tooltip; });
602
674
  link.attr("marker-end", function(d) {
603
675
  if (config.directed) { return 'url(#marker_' + d.marker_end + ')' } });
604
- link.style("stroke-width", function(d) { return d.edge_width; });
676
+ link.style("stroke-width", function(d) { return (parseFloat(d.edge_width) || 1) * edgeWidthMult; });
605
677
  link.style("stroke", function(d) { return d.edge_color; });
606
678
  link.style("stroke-dasharray", function(d) { return d.edge_style; });
607
679
  link.style("opacity", function(d) { return d.edge_opacity; });
@@ -694,6 +766,17 @@ function d3graphscript(config = {
694
766
  // Append the correct shape per node (circle, ellipse, rect, triangle, etc.)
695
767
  appendShape(node);
696
768
 
769
+ // Search highlight rings — two concentric circles behind each node,
770
+ // made visible via the .search-hit CSS class on the parent <g>.
771
+ // They live in graph-space (same SVG coordinate system as nodes) and
772
+ // are repositioned every tick alongside node shapes in updatePositions().
773
+ node.each(function() {
774
+ var g = d3.select(this);
775
+ var rings = g.insert('g', ':first-child').attr('class', 'search-rings');
776
+ rings.append('circle').attr('class', 'search-ring search-ring-border');
777
+ rings.append('circle').attr('class', 'search-ring search-ring-yellow');
778
+ });
779
+
697
780
  // Text in nodes
698
781
  node.append("text")
699
782
  .attr("dx", function(d) { return config.node_text_inside ? 0 : 10; })
@@ -744,6 +827,8 @@ function d3graphscript(config = {
744
827
  // path → transform translate(x, y)
745
828
  // Scoped to the bound `node` selection instead of re-querying the
746
829
  // entire DOM every tick (was: d3.selectAll(".node-shape")).
830
+ // nodeSizeMult is applied here for position-independent shapes (path),
831
+ // and through renderStatStyling / applyNodeSizeScale for radius attrs.
747
832
  node.select(".node-shape").each(function(d) {
748
833
  var el = d3.select(this);
749
834
  var tag = this.tagName.toLowerCase();
@@ -754,6 +839,12 @@ function d3graphscript(config = {
754
839
  }
755
840
  });
756
841
 
842
+ // Search ring positions — cheap: two attr sets per node per tick.
843
+ node.select('.search-rings').each(function(d) {
844
+ var r = (parseFloat(d.node_size) || 8) * nodeSizeMult + 5;
845
+ d3.select(this).selectAll('.search-ring').attr('cx', d.x).attr('cy', d.y).attr('r', r);
846
+ });
847
+
757
848
  // Scoped to node labels only (was: d3.selectAll("text"), which also
758
849
  // re-matched every link-text element on every tick).
759
850
  node.select("text").attr("x", function(d) { return d.x; })
@@ -765,6 +856,54 @@ function d3graphscript(config = {
765
856
  if (densityVisible) drawDensityLayer();
766
857
  }
767
858
 
859
+ // One-shot pass to apply the visual scale multipliers to all SVG nodes and
860
+ // edges without touching positions. Called when a slider changes, not
861
+ // every tick — so the cost is negligible compared to the tick loop.
862
+ function applyVisualScaleMultipliers() {
863
+ // Node sizes
864
+ node.select(".node-shape").each(function(d) {
865
+ var el = d3.select(this);
866
+ var tag = this.tagName.toLowerCase();
867
+ var baseR = parseFloat(d.node_size) || 8;
868
+ // Also factor in any stat-driven scaling that renderStatStyling applied.
869
+ var statScale = 1;
870
+ if (currentStatKey && currentStatKey !== 'network_clustering') {
871
+ var v = d[currentStatKey + '_colorNorm'];
872
+ if (typeof v === 'number' && !isNaN(v)) {
873
+ var isSignificance = (currentStatKey === 'node_proba');
874
+ var sizeVal = isSignificance ? (1 - (d[currentStatKey] || 0)) : v;
875
+ statScale = 1.2 + sizeVal * 1.3;
876
+ }
877
+ }
878
+ var r = baseR * nodeSizeMult * statScale;
879
+ if (tag === 'circle') {
880
+ el.attr('r', r);
881
+ } else if (tag === 'ellipse') {
882
+ el.attr('rx', r * 1.6).attr('ry', r);
883
+ } else if (tag === 'path') {
884
+ el.attr('d', shapePathD(d.node_marker, r));
885
+ }
886
+ });
887
+
888
+ // Search ring radii
889
+ node.select('.search-rings').each(function(d) {
890
+ var r = (parseFloat(d.node_size) || 8) * nodeSizeMult + 5;
891
+ d3.select(this).selectAll('.search-ring').attr('r', r);
892
+ });
893
+
894
+ // SVG edge widths
895
+ if (!useCanvasEdges) {
896
+ link.style("stroke-width", function(d) {
897
+ var w = parseFloat(d.edge_width) || 1;
898
+ w *= edgeWidthMult;
899
+ if (statHighlightActive) w *= EDGE_HIGHLIGHT_WIDTH_MULT;
900
+ return w;
901
+ });
902
+ } else {
903
+ drawCanvasEdges();
904
+ }
905
+ }
906
+
768
907
  force.on("tick", function() {
769
908
  // Auto-stop after maxTicks so a large graph doesn't keep re-running
770
909
  // collision detection / layout math for thousands of frames while
@@ -811,23 +950,31 @@ function d3graphscript(config = {
811
950
 
812
951
 
813
952
  // collision detection
814
-
815
- var padding = 1, // separation between circles
816
- radius = 8;
953
+ // Optimisation: build the quadtree ONCE per tick (stored in _collideQuadtree)
954
+ // rather than once per visited node. On a 5 000-node graph this alone cuts
955
+ // the per-tick cost of collision from O(N² log N) rebuilds to one O(N log N)
956
+ // build + O(N log N) queries.
957
+ var padding = 1; // separation between circles
817
958
 
818
959
  function collide(alpha) {
819
- var quadtree = d3.geom.quadtree(graph.nodes);
960
+ // Rebuild the shared quadtree only when we have advanced to a new tick.
961
+ if (_collideTickId !== tickCount) {
962
+ _collideQuadtree = d3.geom.quadtree(graph.nodes);
963
+ _collideTickId = tickCount;
964
+ }
965
+ var quadtree = _collideQuadtree;
820
966
  return function(d) {
821
- var rb = 2 * radius + padding,
822
- nx1 = d.x - rb,
823
- nx2 = d.x + rb,
824
- ny1 = d.y - rb,
825
- ny2 = d.y + rb;
967
+ // Use the node's actual rendered radius (scaled by nodeSizeMult) so
968
+ // collision boundaries match what the user sees on screen.
969
+ var r = (parseFloat(d.node_size) || 8) * nodeSizeMult;
970
+ var rb = r * 2 + padding;
971
+ var nx1 = d.x - rb, nx2 = d.x + rb,
972
+ ny1 = d.y - rb, ny2 = d.y + rb;
826
973
  quadtree.visit(function(quad, x1, y1, x2, y2) {
827
974
  if (quad.point && (quad.point !== d)) {
828
975
  var x = d.x - quad.point.x,
829
- y = d.y - quad.point.y,
830
- l = Math.sqrt(x * x + y * y);
976
+ y = d.y - quad.point.y,
977
+ l = Math.sqrt(x * x + y * y);
831
978
  if (l < rb) {
832
979
  l = (l - rb) / l * alpha;
833
980
  d.x -= x *= l;
@@ -1007,7 +1154,7 @@ function d3graphscript(config = {
1007
1154
  var statValue = currentStatKey ? d[currentStatKey] : null;
1008
1155
  var hasStatValue = currentStatKey && typeof statValue === "number" && !isNaN(statValue);
1009
1156
  var statScale = hasStatValue ? 1.2 + statValue * 1.3 : 1;
1010
- var clickScale = statScale * {{ CLICK_SIZE }};
1157
+ var clickScale = statScale * {{ CLICK_SIZE }} * nodeSizeMult;
1011
1158
 
1012
1159
  if (tag === "circle") {
1013
1160
  el.attr("r", baseR * clickScale);
@@ -1656,12 +1803,13 @@ function d3graphscript(config = {
1656
1803
  ? SIGNIFICANCE_BORDER_COLOR
1657
1804
  : d.node_color_edge;
1658
1805
 
1806
+ var scaledR = baseR * scale * nodeSizeMult;
1659
1807
  if (tag === 'circle') {
1660
- el.attr('r', baseR * scale);
1808
+ el.attr('r', scaledR);
1661
1809
  } else if (tag === 'ellipse') {
1662
- el.attr('rx', baseR * 1.6 * scale).attr('ry', baseR * scale);
1810
+ el.attr('rx', scaledR * 1.6).attr('ry', scaledR);
1663
1811
  } else if (tag === 'path') {
1664
- el.attr('d', shapePathD(d.node_marker, baseR * scale));
1812
+ el.attr('d', shapePathD(d.node_marker, scaledR));
1665
1813
  }
1666
1814
 
1667
1815
  el.style('fill', fillColor)
@@ -1673,7 +1821,7 @@ function d3graphscript(config = {
1673
1821
  // SVG-mode edges: bump width/opacity directly on the current selection.
1674
1822
  // Canvas-mode edges read statHighlightActive inside drawCanvasEdges().
1675
1823
  link.style("stroke-width", function(d) {
1676
- var w = parseFloat(d.edge_width) || 1;
1824
+ var w = (parseFloat(d.edge_width) || 1) * edgeWidthMult;
1677
1825
  return statHighlightActive ? w * EDGE_HIGHLIGHT_WIDTH_MULT : w;
1678
1826
  });
1679
1827
  link.style("opacity", function(d) {
@@ -1863,26 +2011,53 @@ function d3graphscript(config = {
1863
2011
  wirePhysicsSlider('collisionSlider', 'collisionValue', setCollision);
1864
2012
  wirePhysicsSlider('linkTensionSlider', 'linkTensionValue', setLinkTension);
1865
2013
 
2014
+ // ---- Edge Width multiplier slider ----
2015
+ function setEdgeWidthMult(value) {
2016
+ edgeWidthMult = value;
2017
+ // Cheap visual-only update; no need to reheat the simulation.
2018
+ applyVisualScaleMultipliers();
2019
+ }
2020
+ wirePhysicsSlider('edgeWidthMultSlider', 'edgeWidthMultValue', setEdgeWidthMult);
2021
+
2022
+ // ---- Node Size multiplier slider ----
2023
+ function setNodeSizeMult(value) {
2024
+ nodeSizeMult = value;
2025
+ // Invalidate cached quadtree so collision detection uses updated radii.
2026
+ _collideTickId = -1;
2027
+ applyVisualScaleMultipliers();
2028
+ }
2029
+ wirePhysicsSlider('nodeSizeMultSlider', 'nodeSizeMultValue', setNodeSizeMult);
2030
+
1866
2031
  var physicsResetBtn = document.getElementById('physicsResetButton');
1867
2032
  if (physicsResetBtn) {
1868
2033
  physicsResetBtn.addEventListener('click', function() {
1869
2034
  setCharge(DEFAULT_CHARGE);
1870
2035
  setCollision(DEFAULT_COLLISION);
1871
2036
  setLinkTension(DEFAULT_LINK_TENSION);
2037
+ setEdgeWidthMult(1.0);
2038
+ setNodeSizeMult(1.0);
1872
2039
 
1873
- var chargeSlider = document.getElementById('chargeSlider');
2040
+ var chargeSlider = document.getElementById('chargeSlider');
1874
2041
  var collisionSlider = document.getElementById('collisionSlider');
1875
- var tensionSlider = document.getElementById('linkTensionSlider');
1876
- if (chargeSlider) chargeSlider.value = DEFAULT_CHARGE;
2042
+ var tensionSlider = document.getElementById('linkTensionSlider');
2043
+ var ewSlider = document.getElementById('edgeWidthMultSlider');
2044
+ var nsSlider = document.getElementById('nodeSizeMultSlider');
2045
+ if (chargeSlider) chargeSlider.value = DEFAULT_CHARGE;
1877
2046
  if (collisionSlider) collisionSlider.value = DEFAULT_COLLISION;
1878
- if (tensionSlider) tensionSlider.value = DEFAULT_LINK_TENSION;
2047
+ if (tensionSlider) tensionSlider.value = DEFAULT_LINK_TENSION;
2048
+ if (ewSlider) ewSlider.value = 1.0;
2049
+ if (nsSlider) nsSlider.value = 1.0;
1879
2050
 
1880
- var chargeVal = document.getElementById('chargeValue');
2051
+ var chargeVal = document.getElementById('chargeValue');
1881
2052
  var collisionVal = document.getElementById('collisionValue');
1882
- var tensionVal = document.getElementById('linkTensionValue');
1883
- if (chargeVal) chargeVal.textContent = DEFAULT_CHARGE;
2053
+ var tensionVal = document.getElementById('linkTensionValue');
2054
+ var ewVal = document.getElementById('edgeWidthMultValue');
2055
+ var nsVal = document.getElementById('nodeSizeMultValue');
2056
+ if (chargeVal) chargeVal.textContent = DEFAULT_CHARGE;
1884
2057
  if (collisionVal) collisionVal.textContent = DEFAULT_COLLISION;
1885
- if (tensionVal) tensionVal.textContent = DEFAULT_LINK_TENSION;
2058
+ if (tensionVal) tensionVal.textContent = DEFAULT_LINK_TENSION;
2059
+ if (ewVal) ewVal.textContent = '1';
2060
+ if (nsVal) nsVal.textContent = '1';
1886
2061
  });
1887
2062
  }
1888
2063
 
@@ -2036,4 +2211,177 @@ function d3graphscript(config = {
2036
2211
 
2037
2212
  window.addEventListener("resize", resizeGraph);
2038
2213
 
2214
+ // =========================================================================
2215
+ // SEARCH
2216
+ // Matches node_name (label) and name (id) fields. Results are ranked:
2217
+ // prefix matches first, then substring; ties broken by shorter name.
2218
+ // Selecting a result highlights the node with a yellow ring AND selects it
2219
+ // into the Node Info panel (same as clicking it directly).
2220
+ // =========================================================================
2221
+ var searchInputEl = document.getElementById('searchInput');
2222
+ var searchResultsEl = document.getElementById('searchResults');
2223
+ if (searchInputEl && searchResultsEl) {
2224
+ var _searchActiveIdx = -1;
2225
+ var _searchMatches = []; // array of node datum objects
2226
+
2227
+ function _clearSearchHighlight() {
2228
+ searchHighlightedIndex = null;
2229
+ node.classed('search-hit', false);
2230
+ }
2231
+
2232
+ function _highlightSearchNode(datum) {
2233
+ searchHighlightedIndex = datum.index;
2234
+ node.classed('search-hit', function(d) { return d.index === datum.index; });
2235
+ }
2236
+
2237
+ function _jumpToNode(datum) {
2238
+ // 1. Highlight ring
2239
+ _highlightSearchNode(datum);
2240
+
2241
+ // 2. Pan the SVG so the node is centred in the viewport at the current scale.
2242
+ var s = currentTransform.scale || 1;
2243
+ var cx = (width || window.innerWidth) / 2;
2244
+ var cy = (height || window.innerHeight) / 2;
2245
+ var tx = cx - datum.x * s;
2246
+ var ty = cy - datum.y * s;
2247
+
2248
+ // KEY FIX: tell the zoom behaviour about the new translate/scale BEFORE
2249
+ // the transition fires. Without this, the next user scroll/drag fires
2250
+ // the zoom handler with the behaviour's stale internal state, snapping
2251
+ // the view back to wherever it was before the jump.
2252
+ if (zoomBehavior) {
2253
+ zoomBehavior.translate([tx, ty]).scale(s);
2254
+ }
2255
+ currentTransform.scale = s;
2256
+ currentTransform.translate = [tx, ty];
2257
+
2258
+ // Animate the SVG <g> to the new position.
2259
+ var svgG = d3.select(svg.node().parentNode).select('svg g');
2260
+ svg.transition().duration(500)
2261
+ .attr('transform', 'translate(' + tx + ',' + ty + ') scale(' + s + ')')
2262
+ .each('end', function() {
2263
+ // Redraw canvas overlays at the final position once animation settles.
2264
+ drawCanvasEdges();
2265
+ drawDensityLayer();
2266
+ });
2267
+ svg.classed('labels-hidden', s < labelZoomThreshold);
2268
+ // Immediate redraw for canvas edges (they stay correct during animation
2269
+ // because currentTransform is already updated above).
2270
+ drawCanvasEdges();
2271
+
2272
+ // 3. Select the node into the click-highlight / Node Info panel —
2273
+ // identical to the user clicking it directly.
2274
+ if (highlightedNodeIndex !== datum.index) {
2275
+ navigateNodeInfo(datum, null, false);
2276
+ }
2277
+
2278
+ // 4. Close dropdown
2279
+ searchResultsEl.classList.remove('open');
2280
+ searchInputEl.value = datum.node_name || datum.name || '';
2281
+ searchInputEl.blur();
2282
+ }
2283
+
2284
+ function _renderSearchResults(query) {
2285
+ var q = query.trim().toLowerCase();
2286
+ _searchActiveIdx = -1;
2287
+ if (!q) {
2288
+ _searchMatches = [];
2289
+ searchResultsEl.innerHTML = '';
2290
+ searchResultsEl.classList.remove('open');
2291
+ return;
2292
+ }
2293
+
2294
+ // Build candidate list from live graph.nodes (respects current slider state)
2295
+ var all = graph.nodes;
2296
+ _searchMatches = all.filter(function(d) {
2297
+ var label = (d.node_name || d.name || '').toLowerCase();
2298
+ return label.indexOf(q) !== -1;
2299
+ }).sort(function(a, b) {
2300
+ var al = (a.node_name || a.name || '').toLowerCase();
2301
+ var bl = (b.node_name || b.name || '').toLowerCase();
2302
+ var ap = al.indexOf(q) === 0 ? 0 : 1;
2303
+ var bp = bl.indexOf(q) === 0 ? 0 : 1;
2304
+ if (ap !== bp) return ap - bp;
2305
+ return al.length - bl.length || al.localeCompare(bl);
2306
+ }).slice(0, 12);
2307
+
2308
+ if (!_searchMatches.length) {
2309
+ searchResultsEl.innerHTML = '<div class="search-item" style="opacity:0.5;cursor:default">No matches</div>';
2310
+ searchResultsEl.classList.add('open');
2311
+ return;
2312
+ }
2313
+ searchResultsEl.innerHTML = _searchMatches.map(function(d, i) {
2314
+ var label = d.node_name || d.name || ('Node ' + d.index);
2315
+ var meta = 'idx ' + d.index;
2316
+ if (d.group !== undefined && d.group !== null && String(d.group) !== '-1' && String(d.group) !== '') {
2317
+ meta += ' · g' + d.group;
2318
+ }
2319
+ return '<div class="search-item" data-idx="' + i + '">' +
2320
+ escapeHtml(label) +
2321
+ '<div class="meta">' + escapeHtml(meta) + '</div></div>';
2322
+ }).join('');
2323
+ searchResultsEl.classList.add('open');
2324
+ }
2325
+
2326
+ searchInputEl.addEventListener('input', function() {
2327
+ _renderSearchResults(searchInputEl.value);
2328
+ });
2329
+ searchInputEl.addEventListener('focus', function() {
2330
+ if (searchInputEl.value.trim()) _renderSearchResults(searchInputEl.value);
2331
+ });
2332
+ searchInputEl.addEventListener('keydown', function(e) {
2333
+ var open = searchResultsEl.classList.contains('open') && _searchMatches.length;
2334
+ if (!open) {
2335
+ if (e.key === 'Enter' && searchInputEl.value.trim()) {
2336
+ if (_searchMatches.length) _jumpToNode(_searchMatches[0]);
2337
+ }
2338
+ return;
2339
+ }
2340
+ if (e.key === 'ArrowDown') {
2341
+ e.preventDefault();
2342
+ _searchActiveIdx = Math.min(_searchMatches.length - 1, _searchActiveIdx + 1);
2343
+ } else if (e.key === 'ArrowUp') {
2344
+ e.preventDefault();
2345
+ _searchActiveIdx = Math.max(0, _searchActiveIdx - 1);
2346
+ } else if (e.key === 'Enter') {
2347
+ e.preventDefault();
2348
+ var d = _searchMatches[_searchActiveIdx >= 0 ? _searchActiveIdx : 0];
2349
+ if (d) _jumpToNode(d);
2350
+ return;
2351
+ } else if (e.key === 'Escape') {
2352
+ searchResultsEl.classList.remove('open');
2353
+ searchInputEl.blur();
2354
+ return;
2355
+ } else { return; }
2356
+
2357
+ // Highlight active item
2358
+ searchResultsEl.querySelectorAll('.search-item').forEach(function(el, i) {
2359
+ el.classList.toggle('active', i === _searchActiveIdx);
2360
+ if (i === _searchActiveIdx) el.scrollIntoView({ block: 'nearest' });
2361
+ });
2362
+ });
2363
+
2364
+ searchResultsEl.addEventListener('mousedown', function(e) {
2365
+ // mousedown fires before blur so the list stays open long enough to register
2366
+ var item = e.target.closest('.search-item');
2367
+ if (!item) return;
2368
+ e.preventDefault();
2369
+ var idx = parseInt(item.getAttribute('data-idx'), 10);
2370
+ if (!isNaN(idx) && _searchMatches[idx]) _jumpToNode(_searchMatches[idx]);
2371
+ });
2372
+
2373
+ // Close dropdown when clicking outside
2374
+ document.addEventListener('click', function(e) {
2375
+ if (!e.target.closest('.search-wrap')) searchResultsEl.classList.remove('open');
2376
+ });
2377
+
2378
+ // Clear search highlight when the user clicks empty background
2379
+ var _origBgClick = container.on('click');
2380
+ container.on('click', function() {
2381
+ if (panOrZoomOccurred) { panOrZoomOccurred = false; return; }
2382
+ _clearSearchHighlight();
2383
+ clearNeighborHighlight();
2384
+ });
2385
+ } // end search block
2386
+
2039
2387
  }
@@ -19,7 +19,11 @@
19
19
  HTML into a page that supplies its own button chrome but still wants
20
20
  the slider). -->
21
21
  {% if show_controls or max_slider > min_slider %}
22
- <div class="header-row">
22
+ <div class="header-row">
23
+ <div class="search-wrap">
24
+ <input class="search-input" id="searchInput" type="search" placeholder="Find a node…" autocomplete="off" spellcheck="false" />
25
+ <div class="search-results" id="searchResults"></div>
26
+ </div>
23
27
  {% if max_slider > min_slider %}
24
28
  {{ slider_comment_start }}
25
29
  <div class="slider-container">
@@ -92,6 +96,14 @@
92
96
  <label for="linkTensionSlider">Edge Tension <span id="linkTensionValue">{{ link_tension }}</span></label>
93
97
  <input type="range" id="linkTensionSlider" min="0" max="5" step="0.1" value="{{ link_tension }}">
94
98
  </div>
99
+ <div class="physics-control">
100
+ <label for="edgeWidthMultSlider">Edge Width <span id="edgeWidthMultValue">1</span>×</label>
101
+ <input type="range" id="edgeWidthMultSlider" min="0.1" max="5" step="0.1" value="1">
102
+ </div>
103
+ <div class="physics-control">
104
+ <label for="nodeSizeMultSlider">Node Size <span id="nodeSizeMultValue">1</span>×</label>
105
+ <input type="range" id="nodeSizeMultSlider" min="0.1" max="5" step="0.1" value="1">
106
+ </div>
95
107
  <button id="physicsResetButton" class="button-switch">Reset to Default</button>
96
108
  </div>
97
109
  </div>
@@ -434,4 +434,98 @@ h3 {
434
434
  .dark-mode .styled-slider::-moz-range-thumb {
435
435
  background: #222;
436
436
  border: 2px solid #eee;
437
- }
437
+ }
438
+ /* ---- Search bar ---- */
439
+ .search-wrap {
440
+ position: relative;
441
+ display: inline-flex;
442
+ align-items: center;
443
+ grid-column: 1; /* left slot of .header-row grid */
444
+ justify-self: start;
445
+ margin: 0;
446
+ }
447
+ .search-input {
448
+ width: 200px;
449
+ padding: 5px 10px;
450
+ border-radius: 6px;
451
+ border: 1px solid #ccc;
452
+ background: #fff;
453
+ color: #222;
454
+ font-size: 13px;
455
+ font-family: "Helvetica", Arial, sans-serif;
456
+ outline: none;
457
+ transition: border-color 0.15s, width 0.2s;
458
+ }
459
+ .search-input:focus {
460
+ border-color: #1ABC9C;
461
+ width: 260px;
462
+ }
463
+ .dark-mode .search-input {
464
+ background: #333;
465
+ color: #eee;
466
+ border-color: #555;
467
+ }
468
+ .dark-mode .search-input:focus {
469
+ border-color: #1ABC9C;
470
+ }
471
+ .search-results {
472
+ display: none;
473
+ position: absolute;
474
+ top: calc(100% + 4px);
475
+ left: 0;
476
+ min-width: 200px;
477
+ max-height: 260px;
478
+ overflow-y: auto;
479
+ background: #fff;
480
+ border: 1px solid #ccc;
481
+ border-radius: 6px;
482
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
483
+ z-index: 2000;
484
+ font-family: "Helvetica", Arial, sans-serif;
485
+ font-size: 13px;
486
+ }
487
+ .search-results.open { display: block; }
488
+ .dark-mode .search-results {
489
+ background: #2a2a2a;
490
+ border-color: #555;
491
+ box-shadow: 0 4px 12px rgba(0,0,0,0.5);
492
+ }
493
+ .search-item {
494
+ padding: 7px 12px;
495
+ cursor: pointer;
496
+ color: #222;
497
+ border-bottom: 1px solid rgba(0,0,0,0.05);
498
+ line-height: 1.3;
499
+ }
500
+ .search-item:last-child { border-bottom: none; }
501
+ .search-item:hover,
502
+ .search-item.active { background: #e8f8f5; color: #111; }
503
+ .dark-mode .search-item { color: #eee; border-bottom-color: rgba(255,255,255,0.07); }
504
+ .dark-mode .search-item:hover,
505
+ .dark-mode .search-item.active { background: #1e4d42; }
506
+ .search-item .meta {
507
+ font-size: 11px;
508
+ color: #888;
509
+ margin-top: 1px;
510
+ }
511
+ .dark-mode .search-item .meta { color: #aaa; }
512
+
513
+ /* Search-hit highlight ring on the found node (SVG overlay circle) */
514
+ .search-ring-yellow {
515
+ fill: none;
516
+ stroke: #ffe600;
517
+ stroke-width: 3px;
518
+ pointer-events: none;
519
+ opacity: 0;
520
+ transition: opacity 0.2s;
521
+ }
522
+ .search-ring-border {
523
+ fill: none;
524
+ stroke: rgba(0,0,0,0.25);
525
+ stroke-width: 5px;
526
+ pointer-events: none;
527
+ opacity: 0;
528
+ transition: opacity 0.2s;
529
+ }
530
+ .search-hit .search-ring-yellow,
531
+ .search-hit .search-ring-border { opacity: 1; }
@@ -6,7 +6,7 @@ d3 = d3graph()
6
6
  # Load example data
7
7
  df = d3.import_example('socialmedia')
8
8
  # Slice first 10000 rows
9
- df = df[0:1000]
9
+ df = df[0:5000]
10
10
  # Create adjmat
11
11
  adjmat = vec2adjmat(source=df['source'], target=df['target'], weight=df['weight'])
12
12
  # Update matrix with random weights
@@ -17,7 +17,7 @@ adjmat = vec2adjmat(source=df['source'], target=df['target'], weight=df['weight'
17
17
  d3.graph(adjmat)
18
18
 
19
19
  # Compute node significance for specified network statistic
20
- d3.network_significance(adjmat, 'pagerank', n_top=100, n_random=100)
20
+ # d3.network_significance(adjmat, 'pagerank', n_top=100, n_random=100)
21
21
 
22
22
  # Show graph with custom specific settings
23
23
  d3.show(density_grid_size=60,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: d3graph
3
- Version: 3.0.1
3
+ Version: 3.1.0
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
@@ -28,6 +28,7 @@ Requires-Dist: markupsafe
28
28
  Requires-Dist: python-louvain
29
29
  Requires-Dist: datazets
30
30
  Requires-Dist: distfit
31
+ Requires-Dist: tqdm
31
32
  Dynamic: license-file
32
33
 
33
34
  # D3graph: Interactive force-directed networks
@@ -9,3 +9,4 @@ markupsafe
9
9
  python-louvain
10
10
  datazets
11
11
  distfit
12
+ tqdm
@@ -46,6 +46,7 @@ dependencies = [
46
46
  'python-louvain',
47
47
  'datazets',
48
48
  'distfit',
49
+ 'tqdm',
49
50
  ]
50
51
 
51
52
  [project.urls]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes