d3graph 2.9.5__tar.gz → 2.9.6__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.5
3
+ Version: 2.9.6
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
@@ -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.5'
20
+ __version__ = '2.9.6'
21
21
 
22
22
  # Setup root logger
23
23
  _logger = logging.getLogger('d3graph')
@@ -65,9 +65,9 @@ class d3graph:
65
65
  def __init__(self,
66
66
  collision: float = 0.5,
67
67
  charge: int = 600,
68
+ link_tension: float = 1,
68
69
  slider=None,
69
70
  support: str = 'text',
70
- link_tension: float = 1,
71
71
  sticky: bool = True,
72
72
  verbose: int = 20) -> None:
73
73
  """Initialize d3graph.
@@ -106,7 +106,7 @@ class d3graph:
106
106
  if clean_config and hasattr(self, 'config'): del self.config
107
107
 
108
108
  def show(self,
109
- figsize=[1500, 800],
109
+ figsize=[None, None],
110
110
  title: str = 'd3graph',
111
111
  filepath: str = None,
112
112
  showfig: bool = True,
@@ -115,7 +115,7 @@ class d3graph:
115
115
  set_slider: bool = 0,
116
116
  click={'fill': None, 'stroke': 'black', 'size': 1.3, 'stroke-width': 3},
117
117
  background_color = '#FFFFFF',
118
- dark_mode = False,
118
+ dark_mode = True,
119
119
  notebook: bool = False,
120
120
  save_button: bool = True,
121
121
  link_tension: float = None,
@@ -126,15 +126,15 @@ class d3graph:
126
126
  canvas_edge_threshold: int = 2000,
127
127
  show_density: bool = False,
128
128
  density_grid_size: int = 60,
129
- density_blur: int = 10,
130
- density_opacity: float = 0.6,
129
+ density_blur: int = 15,
130
+ density_opacity: float = 0.8,
131
131
  show_controls: bool = True,
132
132
  ) -> None:
133
133
  """Build and show the graph.
134
134
 
135
135
  Parameters
136
136
  ----------
137
- figsize : tuple, (default: (1500, 800))
137
+ figsize : tuple, (default: (None, None))
138
138
  Size of the figure in the browser, (width, height).
139
139
  (None, None): Use the screen resolution.
140
140
  title : String, (default: None)
@@ -848,8 +848,8 @@ class d3graph:
848
848
  # Hide slider
849
849
  show_slider = ['', ''] if self.config['show_slider'] else ['<!--', '-->']
850
850
  # Set width and height to screen resolution if None.
851
- width = 'window.screen.width' if self.config['figsize'][0] is None else self.config['figsize'][0]
852
- height = 'window.screen.height' if self.config['figsize'][1] is None else self.config['figsize'][1]
851
+ width = 'window.innerWidth' if self.config['figsize'][0] is None else self.config['figsize'][0]
852
+ height = 'null' if self.config['figsize'][1] is None else self.config['figsize'][1]
853
853
  # Get support
854
854
  support = self.config.get('support')
855
855
  if support is None: support = get_support('text')
@@ -897,7 +897,6 @@ class d3graph:
897
897
  html = index_template.render(content)
898
898
 
899
899
  index_file = self.config['filepath']
900
- # index_file.write_text(index_template.render(content))
901
900
  if overwrite and index_file:
902
901
  logger.info(f'Write to path: [{index_file.absolute()}]')
903
902
  logger.info(f'File already exists and will be overwritten: [{index_file}]')
@@ -21,8 +21,19 @@ function d3graphscript(config = {
21
21
  }) {
22
22
 
23
23
  //Constants for the SVG
24
+ //var width = config.width;
25
+ //var height = config.height;
26
+
27
+ // Constants for the SVG
24
28
  var width = config.width;
25
29
  var height = config.height;
30
+ // When height is null, fill the remaining viewport below the header.
31
+ if (height === null) {
32
+ var header = document.querySelector(".header-row");
33
+ var headerHeight = header ? header.offsetHeight : 0;
34
+ height = window.innerHeight - headerHeight;
35
+ }
36
+
26
37
  var background_color = config.background_color || '#FFFFFF';
27
38
  var sticky = config.sticky || false;
28
39
  // Cap how many simulation ticks run before auto-stopping, instead of
@@ -55,12 +66,31 @@ function d3graphscript(config = {
55
66
  // node_pagerank / node_hits_hub / node_hits_authority are precomputed
56
67
  // server-side (networkx) and normalized to [0, 1]; the panel just picks
57
68
  // which one (if any) drives node fill color. null = original node colors.
69
+ // 'network_clustering' is the one exception: instead of a precomputed
70
+ // per-node number, it's derived client-side (connected components over
71
+ // whatever edges are currently visible past the weight-threshold slider)
72
+ // so it always reflects the network's live, filtered state rather than
73
+ // a static server-side clustering. networkClusters maps node index ->
74
+ // small integer cluster id, recomputed on every applyStatStyling() call
75
+ // while that mode is active.
58
76
  var currentStatKey = null;
77
+ var networkClusters = {};
78
+
79
+ // ---- LAYOUT MENU ----
80
+ // 'force' (default) is the existing physics simulation, unchanged.
81
+ // 'circular' / 'grid' are static, one-shot arrangements: force is
82
+ // stopped, positions are computed directly, and updatePositions() draws
83
+ // them once (no tick loop is running to do it automatically).
84
+ var currentLayout = 'force';
59
85
 
60
86
  // COLOR SCHEME
61
87
  var statColorScale = d3.scale.linear()
62
88
  .domain([0, 0.5, 1])
63
89
  .range(["#2166ac", "#f7f7f7", "#b2182b"]);
90
+
91
+ // Categorical palette for network-clustering mode — one color per
92
+ // connected-component id, cycling every 20 clusters.
93
+ var clusterColorScale = d3.scale.category20();
64
94
 
65
95
  // While a stat is active, nodes/edges get a visibility boost (bigger nodes,
66
96
  // thicker/more opaque edges) so the highlighted structure reads clearly.
@@ -104,7 +134,7 @@ function d3graphscript(config = {
104
134
  function dragstarted(d) {
105
135
  d3.event.sourceEvent.stopPropagation();
106
136
  d3.select(this).classed("dragging", true);
107
- if (sticky) {
137
+ if (sticky && currentLayout === 'force') {
108
138
  d.fixed = true;
109
139
  tickCount = 0;
110
140
  force.start();
@@ -118,6 +148,9 @@ function d3graphscript(config = {
118
148
  } else {
119
149
  d3.select(this).attr("cx", d.x = d3.event.x).attr("cy", d.y = d3.event.y);
120
150
  }
151
+ // With the force simulation stopped (static layouts), there's no tick
152
+ // to refresh connected edges/labels — update them directly here.
153
+ if (currentLayout !== 'force') updatePositions();
121
154
  if (densityVisible) drawDensityLayer();
122
155
  }
123
156
 
@@ -592,15 +625,11 @@ function d3graphscript(config = {
592
625
  )
593
626
 
594
627
  //Now we are giving the SVGs co-ordinates - the force layout is generating the co-ordinates which this code is using to update the attributes of the SVG elements
595
- force.on("tick", function() {
596
- // Auto-stop after maxTicks so a large graph doesn't keep re-running
597
- // collision detection / layout math for thousands of frames while
598
- // settling. maxTicks <= 0 disables the cap (run to natural cooldown).
599
- if (maxTicks > 0 && ++tickCount > maxTicks) {
600
- force.stop();
601
- return;
602
- }
603
-
628
+ // Draws nodes/links/labels at their current (d.x, d.y). Called every
629
+ // force tick, and also directly by static layouts (circular/grid) that
630
+ // set positions once instead of simulating — those need a render call
631
+ // since there's no tick to trigger it.
632
+ function updatePositions() {
604
633
  if (useCanvasEdges) {
605
634
  drawCanvasEdges();
606
635
  } else {
@@ -632,10 +661,21 @@ function d3graphscript(config = {
632
661
  linkText.attr("x", function(d) { return (d.source.x + d.target.x) / 2; }) // ADD TEXT ON THE EDGES (PART 2/2)
633
662
  .attr("y", function(d) { return (d.source.y + d.target.y) / 2; })
634
663
  .attr("text-anchor", "middle");
635
-
636
- node.each(collide(config.collision)); //COLLISION DETECTION. High means a big fight to get untouchable nodes (default=0.5)
637
664
 
638
665
  if (densityVisible) drawDensityLayer();
666
+ }
667
+
668
+ force.on("tick", function() {
669
+ // Auto-stop after maxTicks so a large graph doesn't keep re-running
670
+ // collision detection / layout math for thousands of frames while
671
+ // settling. maxTicks <= 0 disables the cap (run to natural cooldown).
672
+ if (maxTicks > 0 && ++tickCount > maxTicks) {
673
+ force.stop();
674
+ return;
675
+ }
676
+
677
+ updatePositions();
678
+ node.each(collide(config.collision)); //COLLISION DETECTION. High means a big fight to get untouchable nodes (default=0.5)
639
679
 
640
680
  });
641
681
 
@@ -806,6 +846,13 @@ function d3graphscript(config = {
806
846
  function threshold() {
807
847
  let thresh = this.value;
808
848
 
849
+ // Reflect the current slider value in the header label. Kept inside
850
+ // threshold() itself (not a separate handler) so it stays in sync with
851
+ // every path that sets the network state: the initial load call below,
852
+ // and the "change" listener — no risk of the two drifting apart.
853
+ var thresholdValueEl = document.getElementById('thresholdValue');
854
+ if (thresholdValueEl) thresholdValueEl.textContent = thresh;
855
+
809
856
  graph.links.splice(0, graph.links.length);
810
857
  linkText = linkText.data([]); // CLEAR EDGE-LABELS: Clear the linkText selection
811
858
  linkText.exit().remove(); // CLEAR EDGE-LABELS: Clear the linkText elements from the DOM
@@ -825,6 +872,18 @@ function d3graphscript(config = {
825
872
 
826
873
  d3.select("#thresholdSlider").on("change", threshold);
827
874
 
875
+ // Live-update the label while dragging, without paying for a full
876
+ // restart() (network refilter + recompute of whichever stat is active)
877
+ // on every intermediate value — that still only happens on "change"
878
+ // (drag release), same as before.
879
+ var thresholdValueLiveEl = document.getElementById('thresholdValue');
880
+ var thresholdSliderEl = document.getElementById('thresholdSlider');
881
+ if (thresholdValueLiveEl && thresholdSliderEl) {
882
+ thresholdSliderEl.addEventListener('input', function() {
883
+ thresholdValueLiveEl.textContent = this.value;
884
+ });
885
+ }
886
+
828
887
  // Master edges on/off toggle — independent of the weight slider, purely
829
888
  // visual, doesn't touch the underlying filtered data.
830
889
  var edgeToggleBtn = document.getElementById('edgeToggleButton');
@@ -838,6 +897,7 @@ function d3graphscript(config = {
838
897
 
839
898
  // Density (clustering heatmap) layer toggle.
840
899
  var densityToggleBtn = document.getElementById('densityToggleButton');
900
+ var densityPanelEl = document.getElementById('densityPanel');
841
901
  if (densityToggleBtn) {
842
902
  densityToggleBtn.addEventListener('click', function() {
843
903
  densityVisible = !densityVisible;
@@ -849,7 +909,14 @@ function d3graphscript(config = {
849
909
  densityCtx.setTransform(1, 0, 0, 1, 0, 0);
850
910
  densityCtx.clearRect(0, 0, densityCanvasEl.width, densityCanvasEl.height);
851
911
  }
912
+ // The density tuning panel (grid size / blur / opacity) is only
913
+ // useful while the layer is actually visible.
914
+ if (densityPanelEl) densityPanelEl.style.display = densityVisible ? '' : 'none';
915
+ // Re-stack the remaining side panels now that one changed size/visibility.
916
+ if (typeof positionSidePanels === 'function') positionSidePanels();
852
917
  });
918
+ // Reflect the initial show_density state on load, in case it started true.
919
+ if (densityPanelEl) densityPanelEl.style.display = densityVisible ? '' : 'none';
853
920
  }
854
921
 
855
922
  // Called from the dark-mode toggle (outside this function's scope, in the
@@ -860,14 +927,337 @@ function d3graphscript(config = {
860
927
  if (densityVisible) drawDensityLayer();
861
928
  };
862
929
 
930
+ // ---- DYNAMIC STAT RECOMPUTATION ----
931
+ // PageRank / HITS / degree / closeness / betweenness centrality arrive
932
+ // precomputed server-side (networkx), since that's the only graph that
933
+ // exists at render time. But the weight-threshold slider changes which
934
+ // edges are actually "in" the network, and a statistic computed on
935
+ // since-removed edges is stale. So these are recomputed client-side from
936
+ // graph.links (the currently filtered edge set) every time
937
+ // applyStatStyling() runs — i.e. on stat selection AND on every slider
938
+ // change (restart() -> applyStatStyling()) — overwriting d[<stat key>]
939
+ // on each node so the rest of the styling pipeline needs no changes.
940
+ var DYNAMIC_STAT_KEYS = ['node_pagerank', 'node_hits_hub', 'node_hits_authority',
941
+ 'node_degree_centrality', 'node_closeness_centrality', 'node_betweenness_centrality'];
942
+
943
+ // Adjacency built fresh from the CURRENT graph.nodes/graph.links.
944
+ //
945
+ // IMPORTANT: this always follows edges source -> target, never
946
+ // symmetrized — regardless of config.directed. config.directed only
947
+ // controls whether arrowheads are drawn (it's a display setting); the
948
+ // underlying graph object these stats need to match is the one built
949
+ // server-side by make_graph(), which is unconditionally an nx.DiGraph
950
+ // (see d3graph.py). Treating the network as undirected here (as an
951
+ // earlier version of this code did whenever config.directed was false,
952
+ // which is also the default) was the actual bug behind mismatched
953
+ // HITS hub/authority and skewed degree/closeness/betweenness scores —
954
+ // it silently added a reverse edge for every edge, which networkx's
955
+ // computation never does.
956
+ function buildAdjacency() {
957
+ var indices = graph.nodes.map(function(d) { return d.index; });
958
+ var out = {}, inn = {};
959
+ indices.forEach(function(i) { out[i] = []; inn[i] = []; });
960
+
961
+ graph.links.forEach(function(l) {
962
+ var s = (l.source && l.source.index !== undefined) ? l.source.index : l.source;
963
+ var t = (l.target && l.target.index !== undefined) ? l.target.index : l.target;
964
+ if (out[s] === undefined || out[t] === undefined) return; // safety
965
+ out[s].push(t);
966
+ inn[t].push(s);
967
+ });
968
+
969
+ return { indices: indices, out: out, inn: inn, n: indices.length };
970
+ }
971
+
972
+ function computeDegreeCentrality(adj) {
973
+ var result = {};
974
+ var n = adj.n;
975
+ adj.indices.forEach(function(i) {
976
+ // Matches networkx's degree_centrality on a DiGraph: total (in + out)
977
+ // degree / (n - 1).
978
+ var deg = adj.out[i].length + adj.inn[i].length;
979
+ result[i] = (n > 1) ? deg / (n - 1) : 0;
980
+ });
981
+ return result;
982
+ }
983
+
984
+ function bfsDistances(adj, sourceIdx, neighborsOf) {
985
+ var dist = {};
986
+ dist[sourceIdx] = 0;
987
+ var queue = [sourceIdx];
988
+ var head = 0;
989
+ while (head < queue.length) {
990
+ var u = queue[head++];
991
+ var neighbors = neighborsOf[u];
992
+ for (var k = 0; k < neighbors.length; k++) {
993
+ var v = neighbors[k];
994
+ if (dist[v] === undefined) {
995
+ dist[v] = dist[u] + 1;
996
+ queue.push(v);
997
+ }
998
+ }
999
+ }
1000
+ return dist;
1001
+ }
1002
+
1003
+ function computeClosenessCentrality(adj) {
1004
+ var result = {};
1005
+ var n = adj.n;
1006
+ adj.indices.forEach(function(i) {
1007
+ // networkx's closeness_centrality reverses directed graphs before
1008
+ // computing shortest paths, so a node's score reflects how reachable
1009
+ // it is FROM the rest of the network (incoming paths), not how far
1010
+ // it can reach outward. Traverse via `inn` (in-edges) to match —
1011
+ // using `out` here was inverting the metric for any graph with real
1012
+ // directionality.
1013
+ var dist = bfsDistances(adj, i, adj.inn);
1014
+ var reachable = Object.keys(dist).length; // includes i itself
1015
+ var totalDist = 0;
1016
+ for (var key in dist) totalDist += dist[key];
1017
+ if (reachable > 1 && totalDist > 0 && n > 1) {
1018
+ // Wasserman-Faust "improved" formula (networkx default): scales by
1019
+ // the fraction of the graph actually reached, so a node stuck in a
1020
+ // small component doesn't get an inflated score just for being
1021
+ // close to the few nodes it can see.
1022
+ result[i] = ((reachable - 1) / totalDist) * ((reachable - 1) / (n - 1));
1023
+ } else {
1024
+ result[i] = 0;
1025
+ }
1026
+ });
1027
+ return result;
1028
+ }
1029
+
1030
+ // Brandes' algorithm, unweighted — O(V*E), single pass computes
1031
+ // betweenness for every node at once.
1032
+ function computeBetweennessCentrality(adj) {
1033
+ var n = adj.n;
1034
+ var betweenness = {};
1035
+ adj.indices.forEach(function(i) { betweenness[i] = 0; });
1036
+
1037
+ adj.indices.forEach(function(s) {
1038
+ var S = [];
1039
+ var P = {}, sigma = {}, dist = {};
1040
+ adj.indices.forEach(function(i) { P[i] = []; sigma[i] = 0; dist[i] = -1; });
1041
+ sigma[s] = 1;
1042
+ dist[s] = 0;
1043
+ var queue = [s];
1044
+ var head = 0;
1045
+ while (head < queue.length) {
1046
+ var v = queue[head++];
1047
+ S.push(v);
1048
+ var neighbors = adj.out[v];
1049
+ for (var k = 0; k < neighbors.length; k++) {
1050
+ var w = neighbors[k];
1051
+ if (dist[w] < 0) {
1052
+ queue.push(w);
1053
+ dist[w] = dist[v] + 1;
1054
+ }
1055
+ if (dist[w] === dist[v] + 1) {
1056
+ sigma[w] += sigma[v];
1057
+ P[w].push(v);
1058
+ }
1059
+ }
1060
+ }
1061
+ var delta = {};
1062
+ adj.indices.forEach(function(i) { delta[i] = 0; });
1063
+ while (S.length) {
1064
+ var w = S.pop();
1065
+ for (var k = 0; k < P[w].length; k++) {
1066
+ var v = P[w][k];
1067
+ delta[v] += (sigma[v] / sigma[w]) * (1 + delta[w]);
1068
+ }
1069
+ if (w !== s) betweenness[w] += delta[w];
1070
+ }
1071
+ });
1072
+
1073
+ // Normalize (networkx defaults, normalized=True, directed graph): scale
1074
+ // by 1/((n-1)(n-2)) — no halving, since directed pairs (s,t) and (t,s)
1075
+ // are distinct and each is only ever visited once as a source here.
1076
+ var scale = (n > 2) ? 1 / ((n - 1) * (n - 2)) : 0;
1077
+ adj.indices.forEach(function(i) { betweenness[i] = betweenness[i] * scale; });
1078
+
1079
+ return betweenness;
1080
+ }
1081
+
1082
+ // Power-iteration PageRank (damping 0.85), with dangling nodes (no
1083
+ // out-edges) leaking their rank uniformly to every node, same as
1084
+ // networkx's default handling.
1085
+ function computePageRank(adj) {
1086
+ var n = adj.n;
1087
+ if (n === 0) return {};
1088
+ var alpha = 0.85, maxIter = 100, tol = 1e-8;
1089
+ var rank = {};
1090
+ adj.indices.forEach(function(i) { rank[i] = 1 / n; });
1091
+ var outDegree = {};
1092
+ adj.indices.forEach(function(i) { outDegree[i] = adj.out[i].length; });
1093
+
1094
+ for (var iter = 0; iter < maxIter; iter++) {
1095
+ var newRank = {};
1096
+ adj.indices.forEach(function(i) { newRank[i] = (1 - alpha) / n; });
1097
+
1098
+ var danglingSum = 0;
1099
+ adj.indices.forEach(function(i) { if (outDegree[i] === 0) danglingSum += rank[i]; });
1100
+ adj.indices.forEach(function(i) { newRank[i] += alpha * danglingSum / n; });
1101
+
1102
+ adj.indices.forEach(function(u) {
1103
+ if (outDegree[u] === 0) return;
1104
+ var share = alpha * rank[u] / outDegree[u];
1105
+ adj.out[u].forEach(function(v) { newRank[v] += share; });
1106
+ });
1107
+
1108
+ var diff = 0;
1109
+ adj.indices.forEach(function(i) { diff += Math.abs(newRank[i] - rank[i]); });
1110
+ rank = newRank;
1111
+ if (diff < tol) break;
1112
+ }
1113
+
1114
+ return rank;
1115
+ }
1116
+
1117
+ // Power-iteration HITS. Hub and authority are computed together since
1118
+ // they're mutually dependent either way.
1119
+ function computeHITS(adj) {
1120
+ var hub = {}, auth = {};
1121
+ adj.indices.forEach(function(i) { hub[i] = 1; auth[i] = 1; });
1122
+ var maxIter = 100, tol = 1e-8;
1123
+
1124
+ for (var iter = 0; iter < maxIter; iter++) {
1125
+ var newAuth = {};
1126
+ adj.indices.forEach(function(i) { newAuth[i] = 0; });
1127
+ adj.indices.forEach(function(u) {
1128
+ adj.out[u].forEach(function(v) { newAuth[v] += hub[u]; });
1129
+ });
1130
+ var authNorm = Math.sqrt(adj.indices.reduce(function(s, i) { return s + newAuth[i] * newAuth[i]; }, 0)) || 1;
1131
+ adj.indices.forEach(function(i) { newAuth[i] /= authNorm; });
1132
+
1133
+ var newHub = {};
1134
+ adj.indices.forEach(function(i) { newHub[i] = 0; });
1135
+ adj.indices.forEach(function(u) {
1136
+ adj.out[u].forEach(function(v) { newHub[u] += newAuth[v]; });
1137
+ });
1138
+ var hubNorm = Math.sqrt(adj.indices.reduce(function(s, i) { return s + newHub[i] * newHub[i]; }, 0)) || 1;
1139
+ adj.indices.forEach(function(i) { newHub[i] /= hubNorm; });
1140
+
1141
+ var diff = 0;
1142
+ adj.indices.forEach(function(i) { diff += Math.abs(newHub[i] - hub[i]) + Math.abs(newAuth[i] - auth[i]); });
1143
+ hub = newHub;
1144
+ auth = newAuth;
1145
+ if (diff < tol) break;
1146
+ }
1147
+
1148
+ return { hub: hub, authority: auth };
1149
+ }
1150
+
1151
+ // Min-max normalize a {index -> value} map to [0,1] — matches d3graph.py's
1152
+ // server-side _normalize_dict() exactly, which is applied uniformly to
1153
+ // ALL six stats there. Doing the same here (rather than the previous
1154
+ // divide-by-max, and rather than leaving degree/closeness/betweenness
1155
+ // unnormalized) keeps the color scale's [0, 0.5, 1] domain fully used
1156
+ // regardless of which stat is active or how the slider has filtered the
1157
+ // network — a stat whose raw scores are all clustered near 0 (e.g.
1158
+ // betweenness on a sparse graph) still stretches across the full range
1159
+ // instead of rendering as a single flat color.
1160
+ function normalizeMinMax(values, indices) {
1161
+ var vmin = Infinity, vmax = -Infinity;
1162
+ indices.forEach(function(i) {
1163
+ var v = values[i];
1164
+ if (v < vmin) vmin = v;
1165
+ if (v > vmax) vmax = v;
1166
+ });
1167
+ var range = vmax - vmin;
1168
+ var result = {};
1169
+ indices.forEach(function(i) {
1170
+ result[i] = (range < 1e-12) ? 0 : (values[i] - vmin) / range;
1171
+ });
1172
+ return result;
1173
+ }
1174
+
1175
+ function computeDynamicStatValues(key, adj) {
1176
+ var raw;
1177
+ switch (key) {
1178
+ case 'node_degree_centrality': raw = computeDegreeCentrality(adj); break;
1179
+ case 'node_closeness_centrality': raw = computeClosenessCentrality(adj); break;
1180
+ case 'node_betweenness_centrality':raw = computeBetweennessCentrality(adj); break;
1181
+ case 'node_pagerank': raw = computePageRank(adj); break;
1182
+ case 'node_hits_hub': raw = computeHITS(adj).hub; break;
1183
+ case 'node_hits_authority': raw = computeHITS(adj).authority; break;
1184
+ default: return null;
1185
+ }
1186
+ return normalizeMinMax(raw, adj.indices);
1187
+ }
1188
+
1189
+ // Groups nodes into connected components using ONLY the edges currently
1190
+ // in `graph.links` — i.e. whatever survives the weight-threshold slider
1191
+ // right now. Re-run every time applyStatStyling() is called while
1192
+ // 'network_clustering' is the active stat, so the coloring always
1193
+ // reflects the live, filtered network rather than a one-time snapshot.
1194
+ // Cheap union-find over nodes/links; fine at interactive graph sizes.
1195
+ function computeNetworkClusters() {
1196
+ var parent = {};
1197
+ function find(x) {
1198
+ while (parent[x] !== x) {
1199
+ parent[x] = parent[parent[x]]; // path halving
1200
+ x = parent[x];
1201
+ }
1202
+ return x;
1203
+ }
1204
+ function union(a, b) {
1205
+ var ra = find(a), rb = find(b);
1206
+ if (ra !== rb) parent[ra] = rb;
1207
+ }
1208
+
1209
+ graph.nodes.forEach(function(d) { parent[d.index] = d.index; });
1210
+ graph.links.forEach(function(l) {
1211
+ var s = (l.source && l.source.index !== undefined) ? l.source.index : l.source;
1212
+ var t = (l.target && l.target.index !== undefined) ? l.target.index : l.target;
1213
+ union(s, t);
1214
+ });
1215
+
1216
+ // Re-number component roots to small, dense integers (0, 1, 2, ...) so
1217
+ // they map cleanly onto the categorical color scale.
1218
+ networkClusters = {};
1219
+ var rootToClusterId = {};
1220
+ var nextId = 0;
1221
+ graph.nodes.forEach(function(d) {
1222
+ var root = find(d.index);
1223
+ if (!(root in rootToClusterId)) rootToClusterId[root] = nextId++;
1224
+ networkClusters[d.index] = rootToClusterId[root];
1225
+ });
1226
+ }
1227
+
863
1228
  // Recolors AND resizes node shapes by the currently selected statistic
864
1229
  // (reverting to each node's original color/size/opacity when none is
865
1230
  // selected), and boosts edge width/opacity for readability against the
866
1231
  // now-emphasized nodes. Handles circle/ellipse/path shapes generically,
867
1232
  // same approach as the click-to-highlight scaling. Safe to call even if a
868
1233
  // node is missing the stat (e.g. older data) — falls back to defaults.
1234
+ //
1235
+ // 'network_clustering' is handled as a variant of this same flow: it has
1236
+ // no single [0,1] value to size nodes by (cluster ids are categorical,
1237
+ // not a magnitude), so size stays at the default scale, but fill color
1238
+ // still comes from the stat — just from the categorical cluster palette
1239
+ // instead of statColorScale. Clusters are recomputed here every call, so
1240
+ // switching the weight-threshold slider (which rebuilds graph.links and
1241
+ // calls restart() -> applyStatStyling()) immediately updates node colors
1242
+ // to match the new connected-component structure.
1243
+ //
1244
+ // The other stats (PageRank, HITS, degree/closeness/betweenness
1245
+ // centrality) get the same live-recompute treatment: DYNAMIC_STAT_KEYS
1246
+ // are recalculated from the current graph.links before being read below,
1247
+ // so they also update immediately on every slider change.
869
1248
  function applyStatStyling() {
870
- statHighlightActive = !!currentStatKey;
1249
+ var isClustering = (currentStatKey === 'network_clustering');
1250
+ statHighlightActive = !!currentStatKey && !isClustering;
1251
+
1252
+ if (isClustering) computeNetworkClusters();
1253
+
1254
+ if (currentStatKey && DYNAMIC_STAT_KEYS.indexOf(currentStatKey) !== -1) {
1255
+ var adj = buildAdjacency();
1256
+ var values = computeDynamicStatValues(currentStatKey, adj);
1257
+ if (values) {
1258
+ graph.nodes.forEach(function(d) { d[currentStatKey] = values[d.index]; });
1259
+ }
1260
+ }
871
1261
 
872
1262
  node.select(".node-shape").each(function(d) {
873
1263
  var el = d3.select(this);
@@ -877,7 +1267,12 @@ function d3graphscript(config = {
877
1267
  var hasVal = (typeof v === 'number' && !isNaN(v));
878
1268
  // 1.2x–2.5x range: even low scorers get a visibility bump, high scorers stand out more.
879
1269
  var scale = (statHighlightActive && hasVal) ? (1.2 + v * 1.3) : 1;
880
- var fillColor = (statHighlightActive && hasVal) ? statColorScale(v) : d.node_color;
1270
+ // Clustering has no [0,1] magnitude to size by, but it does have a
1271
+ // categorical color per component — use that for fill instead of
1272
+ // falling back to the node's original color.
1273
+ var fillColor = isClustering
1274
+ ? clusterColorScale(networkClusters[d.index] % 20)
1275
+ : ((statHighlightActive && hasVal) ? statColorScale(v) : d.node_color);
881
1276
 
882
1277
  if (tag === 'circle') {
883
1278
  el.attr('r', baseR * scale);
@@ -902,6 +1297,17 @@ function d3graphscript(config = {
902
1297
  var o = (d.edge_opacity !== undefined && d.edge_opacity !== null) ? d.edge_opacity : 0.6;
903
1298
  return statHighlightActive ? Math.min(1, o * EDGE_HIGHLIGHT_OPACITY_MULT) : o;
904
1299
  });
1300
+
1301
+ // Node label color: cluster color while clustering mode is active (so
1302
+ // the label visibly matches its node's cluster), otherwise each node's
1303
+ // own configured font color. Always set explicitly (rather than
1304
+ // clearing the inline style) so switching away from clustering cleanly
1305
+ // restores the original per-node color instead of falling through to
1306
+ // the CSS default.
1307
+ node.select("text").style("fill", function(d) {
1308
+ return isClustering ? clusterColorScale(networkClusters[d.index] % 20) : d.node_fontcolor;
1309
+ });
1310
+
905
1311
  if (useCanvasEdges) drawCanvasEdges();
906
1312
  }
907
1313
 
@@ -915,6 +1321,193 @@ function d3graphscript(config = {
915
1321
  });
916
1322
  }
917
1323
 
1324
+ // Arranges nodes evenly around a circle. Order follows each node's stable
1325
+ // .index (assignment order), not the current force-settled position, so
1326
+ // the arrangement is deterministic and doesn't depend on prior layout.
1327
+ function layoutCircular() {
1328
+ var nodesData = graph.nodes;
1329
+ var n = nodesData.length;
1330
+ if (!n) return;
1331
+ var cx = width / 2, cy = height / 2;
1332
+ var r = Math.min(width, height) * 0.4;
1333
+ nodesData.forEach(function(d, i) {
1334
+ var angle = (i / n) * 2 * Math.PI;
1335
+ d.x = d.px = cx + r * Math.cos(angle);
1336
+ d.y = d.py = cy + r * Math.sin(angle);
1337
+ });
1338
+ }
1339
+
1340
+ // Arranges nodes in a roughly-square grid.
1341
+ function layoutGrid() {
1342
+ var nodesData = graph.nodes;
1343
+ var n = nodesData.length;
1344
+ if (!n) return;
1345
+ var cols = Math.max(1, Math.ceil(Math.sqrt(n)));
1346
+ var rows = Math.max(1, Math.ceil(n / cols));
1347
+ var spacingX = width / (cols + 1);
1348
+ var spacingY = height / (rows + 1);
1349
+ nodesData.forEach(function(d, i) {
1350
+ var col = i % cols;
1351
+ var row = Math.floor(i / cols);
1352
+ d.x = d.px = spacingX * (col + 1);
1353
+ d.y = d.py = spacingY * (row + 1);
1354
+ });
1355
+ }
1356
+
1357
+ // Switches between the physics-simulated force layout (default) and
1358
+ // static, one-shot arrangements. Static layouts stop the simulation so
1359
+ // they don't get pulled back out of shape by ongoing forces; switching
1360
+ // back to 'force' just resumes the simulation from wherever nodes
1361
+ // currently are.
1362
+ function setLayout(layoutName) {
1363
+ currentLayout = layoutName;
1364
+
1365
+ if (layoutName === 'force') {
1366
+ tickCount = 0;
1367
+ force.resume();
1368
+ return;
1369
+ }
1370
+
1371
+ force.stop();
1372
+ if (layoutName === 'circular') {
1373
+ layoutCircular();
1374
+ } else if (layoutName === 'grid') {
1375
+ layoutGrid();
1376
+ }
1377
+ updatePositions();
1378
+ }
1379
+
1380
+ var layoutRadios = document.querySelectorAll('input[name="layoutMode"]');
1381
+ if (layoutRadios.length) {
1382
+ layoutRadios.forEach(function(radio) {
1383
+ radio.addEventListener('change', function() {
1384
+ setLayout(this.value);
1385
+ });
1386
+ });
1387
+ }
1388
+
1389
+ // ---- PHYSICS PANEL (charge / collision / link tension sliders) ----
1390
+ // Snapshot the values the graph was generated with, so Reset can restore
1391
+ // them exactly — not some hardcoded JS default, but whatever was actually
1392
+ // passed into show().
1393
+ var DEFAULT_CHARGE = config.charge;
1394
+ var DEFAULT_COLLISION = config.collision;
1395
+ var DEFAULT_LINK_TENSION = (config.link_tension !== undefined && config.link_tension !== null) ? config.link_tension : 1;
1396
+
1397
+ // Only reheats the simulation if the force layout is actually the one
1398
+ // active — if a static layout (circular/grid) is selected, the new value
1399
+ // is stored and takes effect next time the user switches back to it,
1400
+ // rather than yanking them out of the layout they chose.
1401
+ function reheatIfForceActive() {
1402
+ if (currentLayout === 'force') {
1403
+ tickCount = 0;
1404
+ force.start();
1405
+ }
1406
+ }
1407
+
1408
+ function setCharge(value) {
1409
+ config.charge = value;
1410
+ force.charge(value);
1411
+ reheatIfForceActive();
1412
+ }
1413
+ function setCollision(value) {
1414
+ // Read live every tick via node.each(collide(config.collision)) — no
1415
+ // force.* setter call needed, just update the value it reads.
1416
+ config.collision = value;
1417
+ reheatIfForceActive();
1418
+ }
1419
+ function setLinkTension(value) {
1420
+ config.link_tension = value;
1421
+ force.linkStrength(value);
1422
+ reheatIfForceActive();
1423
+ }
1424
+
1425
+ function wirePhysicsSlider(sliderId, valueId, onChange) {
1426
+ var slider = document.getElementById(sliderId);
1427
+ var valueLabel = document.getElementById(valueId);
1428
+ if (!slider) return;
1429
+ slider.addEventListener('input', function() {
1430
+ var v = parseFloat(this.value);
1431
+ if (valueLabel) valueLabel.textContent = v;
1432
+ onChange(v);
1433
+ });
1434
+ }
1435
+ wirePhysicsSlider('chargeSlider', 'chargeValue', setCharge);
1436
+ wirePhysicsSlider('collisionSlider', 'collisionValue', setCollision);
1437
+ wirePhysicsSlider('linkTensionSlider', 'linkTensionValue', setLinkTension);
1438
+
1439
+ var physicsResetBtn = document.getElementById('physicsResetButton');
1440
+ if (physicsResetBtn) {
1441
+ physicsResetBtn.addEventListener('click', function() {
1442
+ setCharge(DEFAULT_CHARGE);
1443
+ setCollision(DEFAULT_COLLISION);
1444
+ setLinkTension(DEFAULT_LINK_TENSION);
1445
+
1446
+ var chargeSlider = document.getElementById('chargeSlider');
1447
+ var collisionSlider = document.getElementById('collisionSlider');
1448
+ var tensionSlider = document.getElementById('linkTensionSlider');
1449
+ if (chargeSlider) chargeSlider.value = DEFAULT_CHARGE;
1450
+ if (collisionSlider) collisionSlider.value = DEFAULT_COLLISION;
1451
+ if (tensionSlider) tensionSlider.value = DEFAULT_LINK_TENSION;
1452
+
1453
+ var chargeVal = document.getElementById('chargeValue');
1454
+ var collisionVal = document.getElementById('collisionValue');
1455
+ var tensionVal = document.getElementById('linkTensionValue');
1456
+ if (chargeVal) chargeVal.textContent = DEFAULT_CHARGE;
1457
+ if (collisionVal) collisionVal.textContent = DEFAULT_COLLISION;
1458
+ if (tensionVal) tensionVal.textContent = DEFAULT_LINK_TENSION;
1459
+ });
1460
+ }
1461
+
1462
+ // ---- DENSITY PANEL (grid size / blur / opacity sliders) ----
1463
+ // Snapshot the values the graph was generated with, so Reset restores
1464
+ // exactly those, not a hardcoded JS default.
1465
+ var DEFAULT_DENSITY_GRID_SIZE = densityGridSize;
1466
+ var DEFAULT_DENSITY_BLUR = densityBlur;
1467
+ var DEFAULT_DENSITY_OPACITY = densityOpacity;
1468
+
1469
+ // Only visually matters while the density layer is toggled on, but the
1470
+ // values are stored either way (so turning the layer on later reflects
1471
+ // whatever the sliders were last set to).
1472
+ function setDensityGridSize(value) {
1473
+ densityGridSize = value;
1474
+ if (densityVisible) drawDensityLayer();
1475
+ }
1476
+ function setDensityBlur(value) {
1477
+ densityBlur = value;
1478
+ if (densityVisible) drawDensityLayer();
1479
+ }
1480
+ function setDensityOpacity(value) {
1481
+ densityOpacity = value;
1482
+ if (densityVisible) drawDensityLayer();
1483
+ }
1484
+ wirePhysicsSlider('densityGridSizeSlider', 'densityGridSizeValue', setDensityGridSize);
1485
+ wirePhysicsSlider('densityBlurSlider', 'densityBlurValue', setDensityBlur);
1486
+ wirePhysicsSlider('densityOpacitySlider', 'densityOpacityValue', setDensityOpacity);
1487
+
1488
+ var densityResetBtn = document.getElementById('densityResetButton');
1489
+ if (densityResetBtn) {
1490
+ densityResetBtn.addEventListener('click', function() {
1491
+ setDensityGridSize(DEFAULT_DENSITY_GRID_SIZE);
1492
+ setDensityBlur(DEFAULT_DENSITY_BLUR);
1493
+ setDensityOpacity(DEFAULT_DENSITY_OPACITY);
1494
+
1495
+ var gridSlider = document.getElementById('densityGridSizeSlider');
1496
+ var blurSlider = document.getElementById('densityBlurSlider');
1497
+ var opacitySlider = document.getElementById('densityOpacitySlider');
1498
+ if (gridSlider) gridSlider.value = DEFAULT_DENSITY_GRID_SIZE;
1499
+ if (blurSlider) blurSlider.value = DEFAULT_DENSITY_BLUR;
1500
+ if (opacitySlider) opacitySlider.value = DEFAULT_DENSITY_OPACITY;
1501
+
1502
+ var gridVal = document.getElementById('densityGridSizeValue');
1503
+ var blurVal = document.getElementById('densityBlurValue');
1504
+ var opacityVal = document.getElementById('densityOpacityValue');
1505
+ if (gridVal) gridVal.textContent = DEFAULT_DENSITY_GRID_SIZE;
1506
+ if (blurVal) blurVal.textContent = DEFAULT_DENSITY_BLUR;
1507
+ if (opacityVal) opacityVal.textContent = DEFAULT_DENSITY_OPACITY;
1508
+ });
1509
+ }
1510
+
918
1511
  // Exports the currently filtered nodes/edges (respecting the weight and
919
1512
  // component sliders — whatever's "in scope" right now, not necessarily
920
1513
  // what's on screen if edges are toggled off) together with their
@@ -959,9 +1552,52 @@ function d3graphscript(config = {
959
1552
 
960
1553
  node = node.data(graph.nodes);
961
1554
  node.enter().insert("circle", ".cursor").attr("class", "node").attr("r", 5).call(force.drag);
962
- tickCount = 0;
963
- force.start();
1555
+
1556
+ if (currentLayout === 'force') {
1557
+ tickCount = 0;
1558
+ force.start();
1559
+ } else {
1560
+ // Static layout active: re-apply it (new/changed nodes need positions
1561
+ // too) instead of reheating the physics simulation.
1562
+ if (currentLayout === 'circular') layoutCircular();
1563
+ else if (currentLayout === 'grid') layoutGrid();
1564
+ updatePositions();
1565
+ }
964
1566
  applyStatStyling();
965
1567
  }
966
1568
 
1569
+ function resizeGraph() {
1570
+
1571
+ if (config.width === window.innerWidth) {
1572
+ width = window.innerWidth;
1573
+ }
1574
+
1575
+ if (config.height === null) {
1576
+ var header = document.querySelector(".header-row");
1577
+ var headerHeight = header ? header.offsetHeight : 0;
1578
+ height = window.innerHeight - headerHeight;
1579
+ }
1580
+
1581
+ container
1582
+ .style("width", width + "px")
1583
+ .style("height", height + "px");
1584
+
1585
+ container.select("svg")
1586
+ .attr("width", width)
1587
+ .attr("height", height);
1588
+
1589
+ canvasEl.width = width;
1590
+ canvasEl.height = height;
1591
+
1592
+ densityCanvasEl.width = width;
1593
+ densityCanvasEl.height = height;
1594
+
1595
+ force.size([width, height]).resume();
1596
+
1597
+ drawCanvasEdges();
1598
+ drawDensityLayer();
1599
+ }
1600
+
1601
+ window.addEventListener("resize", resizeGraph);
1602
+
967
1603
  }
@@ -13,8 +13,21 @@
13
13
  </head>
14
14
  <body style="background-color: {{ background_color }};{% if dark_mode %} color: #eee;{% endif %}"{% if dark_mode %} class="dark-mode"{% endif %}>
15
15
 
16
- <!-- Top right panel with dark mode and save buttons -->
16
+ <!-- Top row: edge-threshold slider (centered) + top-panel buttons (right) -->
17
17
  {% if show_controls %}
18
+ <div class="header-row">
19
+ {% if max_slider > min_slider %}
20
+ {{ slider_comment_start }}
21
+ <div class="slider-container">
22
+ <form style="margin:0;">
23
+ <span class="slider-label">Edge Threshold: <span id="thresholdValue">{{ SET_SLIDER }}</span></span>
24
+ <span class="slider-label">{{ min_slider }}</span>
25
+ <input type="range" autocomplete="off" id="thresholdSlider" value="{{ min_slider }}" min="{{ min_slider }}" max="{{ max_slider }}" class="styled-slider">
26
+ <span class="slider-label">{{ max_slider }}</span>
27
+ </form>
28
+ </div>
29
+ {{ slider_comment_stop }}
30
+ {% endif %}
18
31
  <div class="top-panel">
19
32
  <button id="darkModeSwitch" class="button-switch">{% if dark_mode %}☀ Light Mode{% else %}🌙 Dark Mode{% endif %}</button>
20
33
  <button id="edgeToggleButton" class="button-switch">Hide Edges</button>
@@ -22,20 +35,65 @@
22
35
  <button id="exportDataButton" class="button-switch">Export Data</button>
23
36
  {% if save_button %}<button id="saveButton" class="button-switch">Save</button>{% endif %}
24
37
  </div>
25
- 1
38
+ </div>
39
+
26
40
  <!-- Side panel: interactively recolor/resize nodes by common network statistics -->
27
41
  <div class="stats-panel" id="statsPanel">
28
42
  <div class="stats-panel-title">Network Statistic</div>
29
- <label><input type="radio" name="statMetric" value="none" checked> Default</label>
43
+ <label><input type="radio" name="statMetric" value="user_default" checked> Default</label>
30
44
  <label><input type="radio" name="statMetric" value="node_pagerank"> PageRank</label>
31
45
  <label><input type="radio" name="statMetric" value="node_hits_hub"> HITS (Hub)</label>
32
46
  <label><input type="radio" name="statMetric" value="node_hits_authority"> HITS (Authority)</label>
33
47
  <label><input type="radio" name="statMetric" value="node_degree_centrality"> Degree Centrality</label>
34
48
  <label><input type="radio" name="statMetric" value="node_closeness_centrality"> Closeness Centrality</label>
35
49
  <label><input type="radio" name="statMetric" value="node_betweenness_centrality"> Betweenness Centrality</label>
50
+ <label><input type="radio" name="statMetric" value="network_clustering"> Network Clustering</label>
51
+ </div>
52
+
53
+ <!-- Layout panel (Force-directed / Circular / Grid) removed from the UI -
54
+ circular/grid felt too static to be useful. The underlying setLayout()/
55
+ layoutCircular()/layoutGrid() functions are still in d3graphscript.js,
56
+ just unwired, in case this is worth revisiting later. -->
57
+
58
+ <!-- Side panel: interactively tune the force simulation -->
59
+ <div class="stats-panel" id="physicsPanel">
60
+ <div class="stats-panel-title">Physics</div>
61
+ <div class="physics-control">
62
+ <label for="chargeSlider">Network Tension <span id="chargeValue">{{ charge }}</span></label>
63
+ <input type="range" id="chargeSlider" min="-2000" max="200" step="10" value="{{ charge }}">
64
+ </div>
65
+ <div class="physics-control">
66
+ <label for="collisionSlider">Collision <span id="collisionValue">{{ collision }}</span></label>
67
+ <input type="range" id="collisionSlider" min="0" max="2" step="0.05" value="{{ collision }}">
68
+ </div>
69
+ <div class="physics-control">
70
+ <label for="linkTensionSlider">Edge Tension <span id="linkTensionValue">{{ link_tension }}</span></label>
71
+ <input type="range" id="linkTensionSlider" min="0" max="5" step="0.1" value="{{ link_tension }}">
72
+ </div>
73
+ <button id="physicsResetButton" class="button-switch">Reset to Default</button>
36
74
  </div>
75
+
76
+ <!-- Side panel: interactively tune the density heatmap layer -->
77
+ <div class="stats-panel" id="densityPanel">
78
+ <div class="stats-panel-title">Density Layer</div>
79
+ <div class="physics-control">
80
+ <label for="densityGridSizeSlider">Grid Size <span id="densityGridSizeValue">{{ density_grid_size }}</span></label>
81
+ <input type="range" id="densityGridSizeSlider" min="5" max="120" step="5" value="{{ density_grid_size }}">
82
+ </div>
83
+ <div class="physics-control">
84
+ <label for="densityBlurSlider">Blur <span id="densityBlurValue">{{ density_blur }}</span></label>
85
+ <input type="range" id="densityBlurSlider" min="0" max="30" step="1" value="{{ density_blur }}">
86
+ </div>
87
+ <div class="physics-control">
88
+ <label for="densityOpacitySlider">Opacity <span id="densityOpacityValue">{{ density_opacity }}</span></label>
89
+ <input type="range" id="densityOpacitySlider" min="0" max="1" step="0.05" value="{{ density_opacity }}">
90
+ </div>
91
+ <button id="densityResetButton" class="button-switch">Reset to Default</button>
92
+ </div>
93
+
37
94
  {% endif %}
38
95
 
96
+
39
97
  <script>
40
98
  {% include "d3.v3.js" %}
41
99
  {% include "d3graphscript.js" %}
@@ -63,8 +121,30 @@
63
121
  show_density: {{ show_density | lower }},
64
122
  dark_mode: {{ 'true' if dark_mode else 'false' }},
65
123
  })
124
+ positionSidePanels();
66
125
  });
67
126
 
127
+ // Stacks every .stats-panel element (Network Statistic, Layout, ...)
128
+ // below the header row (edge-threshold slider + top-panel buttons),
129
+ // using its actual measured height rather than a hardcoded pixel
130
+ // guess guessed offset breaks the moment the row wraps onto two
131
+ // lines on a narrower screen, which is exactly what caused this
132
+ // panel's earlier positioning bugs. Scales to any number of panels
133
+ // automatically. Falls back to .top-panel alone if .header-row isn't
134
+ // present (e.g. no edge-threshold slider to show).
135
+ function positionSidePanels() {
136
+ var headerRow = document.querySelector('.header-row') || document.querySelector('.top-panel');
137
+ var panels = document.querySelectorAll('.stats-panel');
138
+ if (!headerRow || !panels.length) return;
139
+ var y = headerRow.getBoundingClientRect().bottom + 8;
140
+ panels.forEach(function (panel) {
141
+ panel.style.top = y + 'px';
142
+ panel.style.left = '10px';
143
+ y += panel.getBoundingClientRect().height + 8;
144
+ });
145
+ }
146
+ window.addEventListener('resize', positionSidePanels);
147
+
68
148
  // Dark mode toggle logic
69
149
  const darkSwitch = document.getElementById('darkModeSwitch');
70
150
  let darkMode = {{ 'true' if dark_mode else 'false' }};
@@ -118,22 +198,6 @@
118
198
 
119
199
 
120
200
 
121
- <!-- SLIDER -->
122
- {{ slider_comment_start }}
123
-
124
- {% if max_slider > min_slider %}
125
- <div style="max-width:{{ width }}px;" class="slider-container">
126
- <form style="margin:0;">
127
- <span class="slider-label">Edge Threshold</span>
128
- <span class="slider-label">{{ min_slider }}</span>
129
- <input type="range" autocomplete="off" id="thresholdSlider" value="{{ min_slider }}" min="{{ min_slider }}" max="{{ max_slider }}" class="styled-slider">
130
- <span class="slider-label">{{ max_slider }}</span>
131
- </form>
132
- </div>
133
- {% endif %}
134
-
135
- {{ slider_comment_stop }}
136
-
137
201
  </body>
138
202
  </html>
139
203
 
@@ -1,3 +1,19 @@
1
+ html,
2
+ body {
3
+ margin: 0;
4
+ padding: 0;
5
+ width: 100%;
6
+ height: 100%;
7
+ overflow: hidden;
8
+ box-sizing: border-box;
9
+ }
10
+
11
+ *,
12
+ *::before,
13
+ *::after {
14
+ box-sizing: inherit;
15
+ }
16
+
1
17
  .node text {
2
18
  font-size: 10px;
3
19
  font-family: "Helvetica";
@@ -44,15 +60,17 @@ h3 {
44
60
  display: none;
45
61
  }
46
62
 
47
- /* Stats side panel (PageRank / HITS / centrality toggles). Deliberately NOT
48
- position:fixed/absolute — this codebase has twice hit bugs from
49
- floating-overlay panels landing behind or on top of other content in ways
50
- that depend on the exact height of the (wrapping) top-panel row. Normal
51
- document flow sidesteps that whole category of bug: it always renders
52
- directly below the top-panel, full stop. */
63
+ /* Stats/layout side panels. Positioned via JS (positionSidePanels(), in
64
+ index_html.j2) rather than a hardcoded pixel offset — this codebase has
65
+ twice hit bugs from guessed offsets that assumed a fixed top-panel height,
66
+ which breaks the moment the top-panel wraps onto two lines on a narrower
67
+ screen. JS measures the top-panel's actual rendered height at runtime and
68
+ stacks every .stats-panel element below it in DOM order, so this scales
69
+ to any number of panels on any screen size. top/left below are just the
70
+ pre-JS fallback (first paint, before the script runs). */
53
71
  .stats-panel {
54
- position: absolute;
55
- top: 70px; /* Adjust to place it below the top controls */
72
+ position: fixed;
73
+ top: 10px;
56
74
  left: 10px;
57
75
  z-index: 1000;
58
76
 
@@ -83,6 +101,31 @@ h3 {
83
101
  cursor: pointer;
84
102
  }
85
103
 
104
+ .stats-panel .physics-control {
105
+ margin: 10px 0;
106
+ }
107
+
108
+ .stats-panel .physics-control label {
109
+ display: flex;
110
+ justify-content: space-between;
111
+ margin-bottom: 3px;
112
+ cursor: default;
113
+ font-weight: normal;
114
+ }
115
+
116
+ .stats-panel .physics-control input[type="range"] {
117
+ width: 100%;
118
+ box-sizing: border-box;
119
+ }
120
+
121
+ .stats-panel .button-switch {
122
+ display: block;
123
+ width: 100%;
124
+ text-align: center;
125
+ margin: 10px 0 0 0;
126
+ box-sizing: border-box;
127
+ }
128
+
86
129
 
87
130
  .dark-mode {
88
131
  background-color: #222 !important;
@@ -109,12 +152,32 @@ h3 {
109
152
  font-family: inherit, Arial, sans-serif;
110
153
  transition: background 0.2s, color 0.2s;
111
154
  }
155
+ /* Header row: top-panel buttons (right) + edge-threshold slider (centered)
156
+ share one line. Grid with equal 1fr flanks keeps the slider centered on
157
+ the row regardless of how wide the button group is; align-items:start
158
+ keeps both children measured from the same top edge so
159
+ positionSidePanels() (which measures .header-row, not .top-panel alone)
160
+ gets an accurate height even if the slider and buttons differ in height. */
161
+ .header-row {
162
+ display: grid;
163
+ grid-template-columns: 1fr auto 1fr;
164
+ align-items: start;
165
+ gap: 8px;
166
+ padding: 10px;
167
+ width: 100%;
168
+ box-sizing: border-box;
169
+ }
170
+ .header-row .slider-container {
171
+ grid-column: 2;
172
+ margin: 0;
173
+ }
112
174
  .top-panel {
113
175
  display: flex;
114
176
  flex-wrap: wrap;
115
177
  justify-content: flex-end;
178
+ align-content: start;
116
179
  gap: 8px;
117
- padding: 10px;
180
+ grid-column: 3;
118
181
  }
119
182
  .slider-container {
120
183
  display: flex;
@@ -170,4 +233,4 @@ h3 {
170
233
  .dark-mode .styled-slider::-moz-range-thumb {
171
234
  background: #222;
172
235
  border: 2px solid #eee;
173
- }
236
+ }
@@ -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:10000]
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
@@ -14,15 +14,12 @@ tmpadjmat = np.random.randint(1, 10, size=adjmat.shape)
14
14
  adjmat = adjmat*tmpadjmat
15
15
 
16
16
  # Create graph
17
- d3.graph(adjmat)
18
-
19
- # Show graph with default settings
20
- # d3.show()
17
+ d3.graph(adjmat, opacity=None)
21
18
 
22
19
  # Show graph with custom specific settings
23
20
  d3.show(density_grid_size=60,
24
- density_blur=100,
25
- density_opacity=0.6,
21
+ density_blur=15, # Smaller is more dense heatmap
22
+ density_opacity=0.8,
26
23
  dark_mode=True,
27
24
  show_density=True,
28
25
  show_slider=True,
@@ -85,7 +82,10 @@ d3.graph(adjmat)
85
82
  # Mix shapes per node
86
83
  markers = ['circle', 'star', 'diamond', 'square', 'pentagon', 'hexagon', 'rectangle', 'triangle-down', 'triangle'] * 8
87
84
  d3.set_node_properties(marker=markers[:len(adjmat.columns)], color=df['label'].values, cmap='Set1')
88
- d3.show(filepath="d:/repos/erdogant.github.io/docs/d3graph/d3graph/node_shapes.html")
85
+ # d3.show(filepath="d:/repos/erdogant.github.io/docs/d3graph/d3graph/node_shapes.html")
86
+
87
+ d3.node_properties
88
+ d3.edge_properties
89
89
 
90
90
  # %%
91
91
  from d3graph import d3graph, import_example
@@ -422,7 +422,7 @@ d3.set_node_properties(marker=['circle', 'circle', 'circle', 'rect', 'rect', 're
422
422
 
423
423
  d3.show(filepath=r'c:\temp\\d3graph\circle.html', set_slider=5, save_button=False)
424
424
 
425
-
425
+ d3.node_properties
426
426
 
427
427
  # %% Edge distance
428
428
 
@@ -566,6 +566,8 @@ html = d3.show(click={'fill': 'green', 'stroke': 'black', 'size': 2, 'stroke-wid
566
566
  html = d3.show(click=None, filepath=r'c:\temp\d3graph\d3graph.html')
567
567
  html = d3.show(filepath=r'c:\temp\d3graph\d3graph.html')
568
568
 
569
+ d3.edge_properties
570
+ d3.node_properties
569
571
 
570
572
  # %% notebook examples
571
573
  from d3graph import d3graph
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: d3graph
3
- Version: 2.9.5
3
+ Version: 2.9.6
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes