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.
- {d3graph-2.9.3/d3graph.egg-info → d3graph-2.9.5}/PKG-INFO +9 -1
- {d3graph-2.9.3 → d3graph-2.9.5}/README.md +8 -0
- {d3graph-2.9.3 → d3graph-2.9.5}/d3graph/__init__.py +1 -1
- {d3graph-2.9.3 → d3graph-2.9.5}/d3graph/d3graph.py +82 -5
- {d3graph-2.9.3 → d3graph-2.9.5}/d3graph/d3js/d3graphscript.js +171 -44
- {d3graph-2.9.3 → d3graph-2.9.5}/d3graph/d3js/index.html.j2 +29 -101
- d3graph-2.9.5/d3graph/d3js/style.css +173 -0
- {d3graph-2.9.3 → d3graph-2.9.5}/d3graph/examples.py +9 -2
- d3graph-2.9.5/d3graph/examples_rankings.py +308 -0
- d3graph-2.9.5/d3graph/examples_rankings_Pvalue.py +378 -0
- {d3graph-2.9.3 → d3graph-2.9.5/d3graph.egg-info}/PKG-INFO +9 -1
- {d3graph-2.9.3 → d3graph-2.9.5}/d3graph.egg-info/SOURCES.txt +2 -0
- d3graph-2.9.3/d3graph/d3js/style.css +0 -45
- {d3graph-2.9.3 → d3graph-2.9.5}/LICENSE +0 -0
- {d3graph-2.9.3 → d3graph-2.9.5}/MANIFEST.in +0 -0
- {d3graph-2.9.3 → d3graph-2.9.5}/d3graph/d3js/d3.v3.js +0 -0
- {d3graph-2.9.3 → d3graph-2.9.5}/d3graph/examples_docs.py +0 -0
- {d3graph-2.9.3 → d3graph-2.9.5}/d3graph.egg-info/dependency_links.txt +0 -0
- {d3graph-2.9.3 → d3graph-2.9.5}/d3graph.egg-info/requires.txt +0 -0
- {d3graph-2.9.3 → d3graph-2.9.5}/d3graph.egg-info/top_level.txt +0 -0
- {d3graph-2.9.3 → d3graph-2.9.5}/pyproject.toml +0 -0
- {d3graph-2.9.3 → d3graph-2.9.5}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: d3graph
|
|
3
|
-
Version: 2.9.
|
|
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
|
|
@@ -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
|
-
|
|
332
|
-
|
|
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="
|
|
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
|
-
<!--
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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')
|
|
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>
|