d3graph 2.9.3__tar.gz → 2.9.5__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.3
3
+ Version: 2.9.5
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.3'
20
+ __version__ = '2.9.5'
21
21
 
22
22
  # Setup root logger
23
23
  _logger = logging.getLogger('d3graph')
@@ -128,6 +128,7 @@ class d3graph:
128
128
  density_grid_size: int = 60,
129
129
  density_blur: int = 10,
130
130
  density_opacity: float = 0.6,
131
+ show_controls: bool = True,
131
132
  ) -> None:
132
133
  """Build and show the graph.
133
134
 
@@ -207,6 +208,14 @@ class d3graph:
207
208
  blocky grid.
208
209
  density_opacity : float, (default: 0.6)
209
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.
210
219
 
211
220
  Returns
212
221
  -------
@@ -236,6 +245,7 @@ class d3graph:
236
245
  self.config['density_grid_size'] = density_grid_size
237
246
  self.config['density_blur'] = density_blur
238
247
  self.config['density_opacity'] = density_opacity
248
+ self.config['show_controls'] = show_controls
239
249
 
240
250
  # Allow show() to override the link_tension set at __init__ time
241
251
  if link_tension is not None:
@@ -252,7 +262,7 @@ class d3graph:
252
262
  # Make slider
253
263
  self.setup_slider()
254
264
  # Create json
255
- json_data = json_create(self.G)
265
+ json_data = json_create(self.G, compute_stats=self.config.get('show_controls', True))
256
266
  # Create html with json file embedded
257
267
  html = self.write_html(json_data, overwrite=overwrite)
258
268
  # Display the chart
@@ -837,7 +847,6 @@ class d3graph:
837
847
 
838
848
  # Hide slider
839
849
  show_slider = ['', ''] if self.config['show_slider'] else ['<!--', '-->']
840
- show_save_button = ['', ''] if self.config['save_button'] else ['<!--', '-->']
841
850
  # Set width and height to screen resolution if None.
842
851
  width = 'window.screen.width' if self.config['figsize'][0] is None else self.config['figsize'][0]
843
852
  height = 'window.screen.height' if self.config['figsize'][1] is None else self.config['figsize'][1]
@@ -864,6 +873,8 @@ class d3graph:
864
873
  'density_grid_size': self.config.get('density_grid_size', 40),
865
874
  'density_blur': self.config.get('density_blur', 8),
866
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'],
867
878
  'node_text_inside': self.config.get('node_text_inside', False),
868
879
  'CLICK_COMMENT': CLICK_COMMENT,
869
880
  'CLICK_FILL': click_properties['fill'],
@@ -872,8 +883,6 @@ class d3graph:
872
883
  'CLICK_STROKEW': click_properties['stroke-width'],
873
884
  'slider_comment_start': show_slider[0],
874
885
  'slider_comment_stop': show_slider[1],
875
- 'save_button_comment_start': show_save_button[0],
876
- 'save_button_comment_stop': show_save_button[1],
877
886
  'SET_SLIDER': self.config['set_slider'],
878
887
  'SUPPORT': support,
879
888
  'background_color': self.config['background_color'],
@@ -1040,13 +1049,20 @@ def check_logger(verbose: [str, int] = 'info'):
1040
1049
 
1041
1050
 
1042
1051
  # %% Write network in json file
1043
- def json_create(G: nx.Graph) -> str:
1052
+ def json_create(G: nx.Graph, compute_stats: bool = True) -> str:
1044
1053
  """Create json from Graph.
1045
1054
 
1046
1055
  Parameters
1047
1056
  ----------
1048
1057
  G : Networkx object
1049
1058
  Graph G
1059
+ compute_stats : bool, (default: True)
1060
+ Compute PageRank, HITS (hub/authority), degree/closeness/betweenness centrality
1061
+ per node and embed them as node_pagerank / node_hits_hub / node_hits_authority /
1062
+ node_degree_centrality / node_closeness_centrality / node_betweenness_centrality,
1063
+ normalized to [0, 1], for the interactive stats side panel. Skipped when the
1064
+ panel won't be shown (show_controls=False) to avoid the wasted computation.
1065
+ Betweenness is approximated (sampled) above 500 nodes to bound generation time.
1050
1066
 
1051
1067
  Returns
1052
1068
  -------
@@ -1063,6 +1079,59 @@ def json_create(G: nx.Graph) -> str:
1063
1079
  source.append(node_id[edge[0] == node_ui][0])
1064
1080
  target.append(node_id[edge[1] == node_ui][0])
1065
1081
 
1082
+ # Common network statistics for the optional interactive stats panel
1083
+ # (recolors nodes by the selected metric). Computed once here, server-side,
1084
+ # rather than in the browser, since networkx already has these and it keeps
1085
+ # the JS side simple (just reads a precomputed per-node number).
1086
+ if compute_stats and len(node_ui) > 0:
1087
+ def _normalize_dict(d):
1088
+ vals = np.array(list(d.values()), dtype=float)
1089
+ vmin, vmax = vals.min(), vals.max()
1090
+ if (vmax - vmin) < 1e-12:
1091
+ return {k: 0.0 for k in d}
1092
+ return {k: float((v - vmin) / (vmax - vmin)) for k, v in d.items()}
1093
+
1094
+ try:
1095
+ pagerank_norm = _normalize_dict(nx.pagerank(G, weight='weight'))
1096
+ except Exception:
1097
+ pagerank_norm = {n: 0.0 for n in G.nodes()}
1098
+
1099
+ try:
1100
+ hubs, authorities = nx.hits(G, max_iter=1000)
1101
+ hubs_norm = _normalize_dict(hubs)
1102
+ authorities_norm = _normalize_dict(authorities)
1103
+ except Exception:
1104
+ hubs_norm = {n: 0.0 for n in G.nodes()}
1105
+ authorities_norm = {n: 0.0 for n in G.nodes()}
1106
+
1107
+ try:
1108
+ degree_norm = _normalize_dict(nx.degree_centrality(G))
1109
+ except Exception:
1110
+ degree_norm = {n: 0.0 for n in G.nodes()}
1111
+
1112
+ try:
1113
+ closeness_norm = _normalize_dict(nx.closeness_centrality(G))
1114
+ except Exception:
1115
+ closeness_norm = {n: 0.0 for n in G.nodes()}
1116
+
1117
+ try:
1118
+ # Betweenness is O(V*E) (Brandes) - exact for smaller graphs, but that
1119
+ # gets slow fast on larger ones. Above 500 nodes, approximate via a
1120
+ # fixed-seed sample of source nodes so generation time stays bounded.
1121
+ n_nodes = G.number_of_nodes()
1122
+ k = min(100, n_nodes) if n_nodes > 500 else None
1123
+ betweenness_norm = _normalize_dict(
1124
+ nx.betweenness_centrality(G, k=k, weight='weight', seed=42 if k else None))
1125
+ except Exception:
1126
+ betweenness_norm = {n: 0.0 for n in G.nodes()}
1127
+ else:
1128
+ pagerank_norm = {n: 0.0 for n in G.nodes()}
1129
+ hubs_norm = {n: 0.0 for n in G.nodes()}
1130
+ authorities_norm = {n: 0.0 for n in G.nodes()}
1131
+ degree_norm = {n: 0.0 for n in G.nodes()}
1132
+ closeness_norm = {n: 0.0 for n in G.nodes()}
1133
+ betweenness_norm = {n: 0.0 for n in G.nodes()}
1134
+
1066
1135
  # Set edge properties
1067
1136
  links = pd.DataFrame([*G.edges.values()]).T.to_dict()
1068
1137
  links_new = []
@@ -1099,6 +1168,14 @@ def json_create(G: nx.Graph) -> str:
1099
1168
  nodes[i]['node_color_edge'] = nodes[i].pop('edge_color')
1100
1169
  nodes[i]['node_fontcolor'] = nodes[i].pop('fontcolor')
1101
1170
  nodes[i]['node_fontsize'] = nodes[i].pop('fontsize')
1171
+ # Stats for the interactive side panel (see compute_stats above)
1172
+ nid = node_ui[i]
1173
+ nodes[i]['node_pagerank'] = pagerank_norm.get(nid, 0.0)
1174
+ nodes[i]['node_hits_hub'] = hubs_norm.get(nid, 0.0)
1175
+ nodes[i]['node_hits_authority'] = authorities_norm.get(nid, 0.0)
1176
+ nodes[i]['node_degree_centrality'] = degree_norm.get(nid, 0.0)
1177
+ nodes[i]['node_closeness_centrality'] = closeness_norm.get(nid, 0.0)
1178
+ nodes[i]['node_betweenness_centrality'] = betweenness_norm.get(nid, 0.0)
1102
1179
  # Combine all information into new list
1103
1180
  nodes_new[i] = nodes[i]
1104
1181
  data = {'links': links_new, 'nodes': nodes_new}
@@ -51,6 +51,23 @@ function d3graphscript(config = {
51
51
  // node structure/clustering without redrawing or refiltering anything.
52
52
  var edgesVisible = true;
53
53
 
54
+ // ---- STATS PANEL (recolor nodes by a network statistic) ----
55
+ // node_pagerank / node_hits_hub / node_hits_authority are precomputed
56
+ // server-side (networkx) and normalized to [0, 1]; the panel just picks
57
+ // which one (if any) drives node fill color. null = original node colors.
58
+ var currentStatKey = null;
59
+
60
+ // COLOR SCHEME
61
+ var statColorScale = d3.scale.linear()
62
+ .domain([0, 0.5, 1])
63
+ .range(["#2166ac", "#f7f7f7", "#b2182b"]);
64
+
65
+ // While a stat is active, nodes/edges get a visibility boost (bigger nodes,
66
+ // thicker/more opaque edges) so the highlighted structure reads clearly.
67
+ var statHighlightActive = false;
68
+ var EDGE_HIGHLIGHT_WIDTH_MULT = 1.8;
69
+ var EDGE_HIGHLIGHT_OPACITY_MULT = 1.6;
70
+
54
71
  // ---- DENSITY (clustering heatmap) LAYER ----
55
72
  // Grid-binned node density, drawn on its own canvas beneath the edges and
56
73
  // nodes. Recomputed from live node positions every tick it's visible, so
@@ -328,8 +345,10 @@ function d3graphscript(config = {
328
345
  ctx.moveTo(d.source.x, d.source.y);
329
346
  ctx.lineTo(d.target.x, d.target.y);
330
347
  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;
348
+ var baseOpacity = (d.edge_opacity !== undefined && d.edge_opacity !== null) ? d.edge_opacity : 0.6;
349
+ var baseWidth = d.edge_width || 1;
350
+ ctx.globalAlpha = statHighlightActive ? Math.min(1, baseOpacity * EDGE_HIGHLIGHT_OPACITY_MULT) : baseOpacity;
351
+ ctx.lineWidth = statHighlightActive ? baseWidth * EDGE_HIGHLIGHT_WIDTH_MULT : baseWidth;
333
352
  ctx.setLineDash(d.edge_style === 'dashed' ? [6, 3] : d.edge_style === 'dotted' ? [1.5, 3] : []);
334
353
  ctx.stroke();
335
354
  }
@@ -702,49 +721,64 @@ function d3graphscript(config = {
702
721
 
703
722
  // COLOR ON CLICK
704
723
  function color_on_click() {
705
- // Reset all nodes to their original styles
706
- d3.selectAll(".node")
707
- .select(".node-shape")
708
- .style("fill", function(d) {return d.node_color;})
709
- .style("opacity", function(d) {return d.node_opacity;})
710
- .style("stroke", function(d) {return d.node_color_edge;})
711
- .style("stroke-width", function(d) {return d.node_size_edge;})
712
- // Restore pinned cue on still-fixed nodes
713
- .style("stroke-dasharray", function(d) { return (sticky && d.fixed) ? "4,2" : null; });
714
-
715
- // Reset circle radii
716
- d3.selectAll(".node").select("circle.node-shape")
717
- .attr("r", function(d) { return d.node_size; });
718
-
719
- // Apply click styling to the selected node's shape
720
- var clickedShape = d3.select(this).select(".node-shape");
721
- clickedShape
722
- .style("fill", {{ CLICK_FILL }})
723
- .style("stroke", "{{ CLICK_STROKE }}")
724
- .style("stroke-width", {{ CLICK_STROKEW }});
725
-
726
- // Scale up the shape — strategy depends on element type
727
- var shapeNode = clickedShape.node();
728
- if (!shapeNode) return;
729
- var tag = shapeNode.tagName.toLowerCase();
730
- if (tag === 'circle') {
731
- clickedShape.attr("r", function(d) { return d.node_size * {{ CLICK_SIZE }}; });
732
- } else if (tag === 'ellipse') {
733
- clickedShape.each(function(d) {
734
- var r = parseFloat(d.node_size) || 8;
735
- d3.select(this)
736
- .attr("rx", r * 1.6 * {{ CLICK_SIZE }})
737
- .attr("ry", r * {{ CLICK_SIZE }});
738
- });
739
- } else {
740
- // <path>: re-compute path with a scaled radius
741
- clickedShape.each(function(d) {
742
- var r = (parseFloat(d.node_size) || 8) * {{ CLICK_SIZE }};
743
- d3.select(this).attr("d", shapePathD(d.node_marker, r));
744
- });
745
- }
746
- }
747
724
 
725
+ // First restore the currently active base styling:
726
+ // - statistic colors/sizes when a radio metric is active
727
+ // - original node styling when no statistic is active
728
+ applyStatStyling();
729
+
730
+ // Restore pinned cue after applyStatStyling()
731
+ node.select(".node-shape")
732
+ .style("stroke-dasharray", function(d) {
733
+ return (sticky && d.fixed) ? "4,2" : null;
734
+ });
735
+
736
+ // Apply click styling only to the selected node
737
+ var clickedShape = d3.select(this).select(".node-shape");
738
+
739
+ clickedShape
740
+ .style("fill", {{ CLICK_FILL }})
741
+ .style("stroke", "{{ CLICK_STROKE }}")
742
+ .style("stroke-width", {{ CLICK_STROKEW }});
743
+
744
+ var shapeNode = clickedShape.node();
745
+ if (!shapeNode) return;
746
+
747
+ var tag = shapeNode.tagName.toLowerCase();
748
+
749
+ clickedShape.each(function(d) {
750
+ var el = d3.select(this);
751
+ var baseR = parseFloat(d.node_size) || 8;
752
+
753
+ // Preserve the statistic-driven size before applying click enlargement
754
+ var statValue = currentStatKey ? d[currentStatKey] : null;
755
+ var hasStatValue =
756
+ currentStatKey &&
757
+ typeof statValue === "number" &&
758
+ !isNaN(statValue);
759
+
760
+ var statScale = hasStatValue
761
+ ? 1.2 + statValue * 1.3
762
+ : 1;
763
+
764
+ var clickScale = statScale * {{ CLICK_SIZE }};
765
+
766
+ if (tag === "circle") {
767
+ el.attr("r", baseR * clickScale);
768
+
769
+ } else if (tag === "ellipse") {
770
+ el
771
+ .attr("rx", baseR * 1.6 * clickScale)
772
+ .attr("ry", baseR * clickScale);
773
+
774
+ } else if (tag === "path") {
775
+ el.attr(
776
+ "d",
777
+ shapePathD(d.node_marker, baseR * clickScale)
778
+ );
779
+ }
780
+ });
781
+ }
748
782
 
749
783
 
750
784
  function connectedNodes() {
@@ -826,6 +860,98 @@ function d3graphscript(config = {
826
860
  if (densityVisible) drawDensityLayer();
827
861
  };
828
862
 
863
+ // Recolors AND resizes node shapes by the currently selected statistic
864
+ // (reverting to each node's original color/size/opacity when none is
865
+ // selected), and boosts edge width/opacity for readability against the
866
+ // now-emphasized nodes. Handles circle/ellipse/path shapes generically,
867
+ // same approach as the click-to-highlight scaling. Safe to call even if a
868
+ // node is missing the stat (e.g. older data) — falls back to defaults.
869
+ function applyStatStyling() {
870
+ statHighlightActive = !!currentStatKey;
871
+
872
+ node.select(".node-shape").each(function(d) {
873
+ var el = d3.select(this);
874
+ var tag = this.tagName.toLowerCase();
875
+ var baseR = parseFloat(d.node_size) || 8;
876
+ var v = statHighlightActive ? d[currentStatKey] : null;
877
+ var hasVal = (typeof v === 'number' && !isNaN(v));
878
+ // 1.2x–2.5x range: even low scorers get a visibility bump, high scorers stand out more.
879
+ var scale = (statHighlightActive && hasVal) ? (1.2 + v * 1.3) : 1;
880
+ var fillColor = (statHighlightActive && hasVal) ? statColorScale(v) : d.node_color;
881
+
882
+ if (tag === 'circle') {
883
+ el.attr('r', baseR * scale);
884
+ } else if (tag === 'ellipse') {
885
+ el.attr('rx', baseR * 1.6 * scale).attr('ry', baseR * scale);
886
+ } else if (tag === 'path') {
887
+ el.attr('d', shapePathD(d.node_marker, baseR * scale));
888
+ }
889
+
890
+ el.style('fill', fillColor)
891
+ .style('opacity', statHighlightActive ? 1 : d.node_opacity)
892
+ .style('stroke-width', statHighlightActive ? (parseFloat(d.node_size_edge) || 1) * 1.5 : d.node_size_edge);
893
+ });
894
+
895
+ // SVG-mode edges: bump width/opacity directly on the current selection.
896
+ // Canvas-mode edges read statHighlightActive inside drawCanvasEdges().
897
+ link.style("stroke-width", function(d) {
898
+ var w = parseFloat(d.edge_width) || 1;
899
+ return statHighlightActive ? w * EDGE_HIGHLIGHT_WIDTH_MULT : w;
900
+ });
901
+ link.style("opacity", function(d) {
902
+ var o = (d.edge_opacity !== undefined && d.edge_opacity !== null) ? d.edge_opacity : 0.6;
903
+ return statHighlightActive ? Math.min(1, o * EDGE_HIGHLIGHT_OPACITY_MULT) : o;
904
+ });
905
+ if (useCanvasEdges) drawCanvasEdges();
906
+ }
907
+
908
+ var statRadios = document.querySelectorAll('input[name="statMetric"]');
909
+ if (statRadios.length) {
910
+ statRadios.forEach(function(radio) {
911
+ radio.addEventListener('change', function() {
912
+ currentStatKey = (this.value === 'none') ? null : this.value;
913
+ applyStatStyling();
914
+ });
915
+ });
916
+ }
917
+
918
+ // Exports the currently filtered nodes/edges (respecting the weight and
919
+ // component sliders — whatever's "in scope" right now, not necessarily
920
+ // what's on screen if edges are toggled off) together with their
921
+ // precomputed stats, as a single JSON file.
922
+ var exportDataBtn = document.getElementById('exportDataButton');
923
+ if (exportDataBtn) {
924
+ exportDataBtn.addEventListener('click', function() {
925
+ var nodesOut = graph.nodes.map(function(d) {
926
+ return {
927
+ id: d.index,
928
+ name: d.node_name,
929
+ pagerank: d.node_pagerank,
930
+ hits_hub: d.node_hits_hub,
931
+ hits_authority: d.node_hits_authority,
932
+ degree_centrality: d.node_degree_centrality,
933
+ closeness_centrality: d.node_closeness_centrality,
934
+ betweenness_centrality: d.node_betweenness_centrality
935
+ };
936
+ });
937
+ var edgesOut = graph.links.map(function(d) {
938
+ return {
939
+ source: (d.source && d.source.node_name !== undefined) ? d.source.node_name : d.source,
940
+ target: (d.target && d.target.node_name !== undefined) ? d.target.node_name : d.target,
941
+ weight: d.edge_weight
942
+ };
943
+ });
944
+ var payload = { nodes: nodesOut, edges: edgesOut };
945
+ var blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8" });
946
+ var url = URL.createObjectURL(blob);
947
+ var a = document.createElement('a');
948
+ a.href = url;
949
+ a.download = '{{ title }}_export.json';
950
+ a.click();
951
+ URL.revokeObjectURL(url);
952
+ });
953
+ }
954
+
829
955
  //Restart the visualisation after any node and link changes
830
956
  function restart() {
831
957
 
@@ -835,6 +961,7 @@ function d3graphscript(config = {
835
961
  node.enter().insert("circle", ".cursor").attr("class", "node").attr("r", 5).call(force.drag);
836
962
  tickCount = 0;
837
963
  force.start();
964
+ applyStatStyling();
838
965
  }
839
966
 
840
967
  }
@@ -9,109 +9,32 @@
9
9
 
10
10
  <style type="text/css">
11
11
  {% include "style.css" %}
12
- .dark-mode {
13
- background-color: #222 !important;
14
- color: #eee !important;
15
- }
16
- .dark-mode #graphContainer {
17
- background-color: #222 !important;
18
- }
19
- .dark-mode .button-switch {
20
- background: #333;
21
- color: #eee;
22
- border: 1px solid #555;
23
- }
24
- .button-switch {
25
- display: inline-block;
26
- margin: 8px 4px;
27
- padding: 6px 16px;
28
- border-radius: 6px;
29
- border: 1px solid #ccc;
30
- background: #fff;
31
- color: #222;
32
- cursor: pointer;
33
- font-size: 1em;
34
- font-family: inherit, Arial, sans-serif;
35
- transition: background 0.2s, color 0.2s;
36
- }
37
- .top-panel {
38
- display: flex;
39
- flex-wrap: wrap;
40
- justify-content: flex-end;
41
- gap: 8px;
42
- padding: 10px;
43
- }
44
- .slider-container {
45
- display: flex;
46
- align-items: center;
47
- justify-content: center;
48
- margin: 12px 0;
49
- }
50
- .slider-label {
51
- margin: 0 8px;
52
- font-size: 1em;
53
- font-family: inherit, Arial, sans-serif;
54
- }
55
- .styled-slider {
56
- -webkit-appearance: none;
57
- width: 180px;
58
- height: 8px;
59
- background: #eee;
60
- border-radius: 5px;
61
- outline: none;
62
- transition: background 0.2s;
63
- margin: 0 8px;
64
- }
65
- .styled-slider::-webkit-slider-thumb {
66
- -webkit-appearance: none;
67
- appearance: none;
68
- width: 22px;
69
- height: 22px;
70
- border-radius: 50%;
71
- background: #fff;
72
- border: 2px solid #888;
73
- cursor: pointer;
74
- box-shadow: 0 0 2px #888;
75
- }
76
- .styled-slider::-moz-range-thumb {
77
- width: 22px;
78
- height: 22px;
79
- border-radius: 50%;
80
- background: #fff;
81
- border: 2px solid #888;
82
- cursor: pointer;
83
- box-shadow: 0 0 2px #888;
84
- }
85
- .styled-slider:focus {
86
- background: #ddd;
87
- }
88
- .dark-mode .styled-slider {
89
- background: #444;
90
- }
91
- .dark-mode .styled-slider::-webkit-slider-thumb {
92
- background: #222;
93
- border: 2px solid #eee;
94
- }
95
- .dark-mode .styled-slider::-moz-range-thumb {
96
- background: #222;
97
- border: 2px solid #eee;
98
- }
99
12
  </style>
100
13
  </head>
101
14
  <body style="background-color: {{ background_color }};{% if dark_mode %} color: #eee;{% endif %}"{% if dark_mode %} class="dark-mode"{% endif %}>
102
15
 
103
16
  <!-- Top right panel with dark mode and save buttons -->
17
+ {% if show_controls %}
104
18
  <div class="top-panel">
105
19
  <button id="darkModeSwitch" class="button-switch">{% if dark_mode %}☀ Light Mode{% else %}🌙 Dark Mode{% endif %}</button>
106
20
  <button id="edgeToggleButton" class="button-switch">Hide Edges</button>
107
21
  <button id="densityToggleButton" class="button-switch">{% if show_density %}Hide Density{% else %}Show Density{% endif %}</button>
108
- <button id="saveButton" class="button-switch">Save</button>
22
+ <button id="exportDataButton" class="button-switch">Export Data</button>
23
+ {% if save_button %}<button id="saveButton" class="button-switch">Save</button>{% endif %}
109
24
  </div>
110
-
111
- <!-- Create save button (removed old location) -->
112
- {{ save_button_comment_start }}
113
- <!-- Save button now in top-panel -->
114
- {{ save_button_comment_stop }}
25
+ 1
26
+ <!-- Side panel: interactively recolor/resize nodes by common network statistics -->
27
+ <div class="stats-panel" id="statsPanel">
28
+ <div class="stats-panel-title">Network Statistic</div>
29
+ <label><input type="radio" name="statMetric" value="none" checked> Default</label>
30
+ <label><input type="radio" name="statMetric" value="node_pagerank"> PageRank</label>
31
+ <label><input type="radio" name="statMetric" value="node_hits_hub"> HITS (Hub)</label>
32
+ <label><input type="radio" name="statMetric" value="node_hits_authority"> HITS (Authority)</label>
33
+ <label><input type="radio" name="statMetric" value="node_degree_centrality"> Degree Centrality</label>
34
+ <label><input type="radio" name="statMetric" value="node_closeness_centrality"> Closeness Centrality</label>
35
+ <label><input type="radio" name="statMetric" value="node_betweenness_centrality"> Betweenness Centrality</label>
36
+ </div>
37
+ {% endif %}
115
38
 
116
39
  <script>
117
40
  {% include "d3.v3.js" %}
@@ -153,29 +76,33 @@
153
76
  document.body.style.color = '#eee';
154
77
  let graphBg = document.getElementById('graphContainer');
155
78
  if (graphBg) graphBg.style.backgroundColor = '#222';
156
- darkSwitch.textContent = '☀ Light Mode';
79
+ if (darkSwitch) darkSwitch.textContent = '☀ Light Mode';
157
80
  } else {
158
81
  document.body.classList.remove('dark-mode');
159
82
  document.body.style.backgroundColor = originalBg;
160
83
  document.body.style.color = '';
161
84
  let graphBg = document.getElementById('graphContainer');
162
85
  if (graphBg) graphBg.style.backgroundColor = originalBg;
163
- darkSwitch.textContent = '🌙 Dark Mode';
86
+ if (darkSwitch) darkSwitch.textContent = '🌙 Dark Mode';
164
87
  }
165
88
  if (window.d3graphSetDarkMode) window.d3graphSetDarkMode(on);
166
89
  }
167
90
  // Set initial mode
168
91
  setDarkMode(darkMode);
169
- darkSwitch.addEventListener('click', function () {
170
- darkMode = !darkMode;
171
- setDarkMode(darkMode);
172
- });
92
+ if (darkSwitch) {
93
+ darkSwitch.addEventListener('click', function () {
94
+ darkMode = !darkMode;
95
+ setDarkMode(darkMode);
96
+ });
97
+ }
173
98
 
174
99
  // Save image to svg
175
100
  // Note: when the graph is large enough to switch to canvas-rendered edges
176
101
  // (see canvas_edge_threshold), this export will include nodes but not edges,
177
102
  // since edges live on a separate <canvas> element, not in the SVG DOM.
178
- document.getElementById('saveButton').addEventListener('click', function () {
103
+ var saveBtn = document.getElementById('saveButton');
104
+ if (saveBtn) {
105
+ saveBtn.addEventListener('click', function () {
179
106
  var svgData = document.querySelector('svg').outerHTML;
180
107
  var blob = new Blob([svgData], {type: "image/svg+xml;charset=utf-8"});
181
108
  var url = URL.createObjectURL(blob);
@@ -183,7 +110,8 @@
183
110
  link.href = url;
184
111
  link.download = '{{ title }}.svg';
185
112
  link.click();
186
- });
113
+ });
114
+ }
187
115
 
188
116
 
189
117
  </script>