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.
- {d3graph-2.9.5/d3graph.egg-info → d3graph-2.9.6}/PKG-INFO +1 -1
- {d3graph-2.9.5 → d3graph-2.9.6}/d3graph/__init__.py +1 -1
- {d3graph-2.9.5 → d3graph-2.9.6}/d3graph/d3graph.py +8 -9
- {d3graph-2.9.5 → d3graph-2.9.6}/d3graph/d3js/d3graphscript.js +652 -16
- {d3graph-2.9.5 → d3graph-2.9.6}/d3graph/d3js/index.html.j2 +83 -19
- {d3graph-2.9.5 → d3graph-2.9.6}/d3graph/d3js/style.css +73 -10
- {d3graph-2.9.5 → d3graph-2.9.6}/d3graph/examples.py +11 -9
- {d3graph-2.9.5 → d3graph-2.9.6/d3graph.egg-info}/PKG-INFO +1 -1
- {d3graph-2.9.5 → d3graph-2.9.6}/LICENSE +0 -0
- {d3graph-2.9.5 → d3graph-2.9.6}/MANIFEST.in +0 -0
- {d3graph-2.9.5 → d3graph-2.9.6}/README.md +0 -0
- {d3graph-2.9.5 → d3graph-2.9.6}/d3graph/d3js/d3.v3.js +0 -0
- {d3graph-2.9.5 → d3graph-2.9.6}/d3graph/examples_docs.py +0 -0
- {d3graph-2.9.5 → d3graph-2.9.6}/d3graph/examples_rankings.py +0 -0
- {d3graph-2.9.5 → d3graph-2.9.6}/d3graph/examples_rankings_Pvalue.py +0 -0
- {d3graph-2.9.5 → d3graph-2.9.6}/d3graph.egg-info/SOURCES.txt +0 -0
- {d3graph-2.9.5 → d3graph-2.9.6}/d3graph.egg-info/dependency_links.txt +0 -0
- {d3graph-2.9.5 → d3graph-2.9.6}/d3graph.egg-info/requires.txt +0 -0
- {d3graph-2.9.5 → d3graph-2.9.6}/d3graph.egg-info/top_level.txt +0 -0
- {d3graph-2.9.5 → d3graph-2.9.6}/pyproject.toml +0 -0
- {d3graph-2.9.5 → d3graph-2.9.6}/setup.cfg +0 -0
|
@@ -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=[
|
|
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 =
|
|
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 =
|
|
130
|
-
density_opacity: float = 0.
|
|
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: (
|
|
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.
|
|
852
|
-
height = '
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
963
|
-
force
|
|
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
|
|
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
|
-
|
|
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="
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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:
|
|
55
|
-
top:
|
|
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
|
-
|
|
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:
|
|
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=
|
|
25
|
-
density_opacity=0.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|