thearn commited on
Commit
121ffc2
·
1 Parent(s): 5044d19
Files changed (5) hide show
  1. app.py +7 -276
  2. pyproject.toml +5 -2
  3. src/graph.py +111 -0
  4. src/network.py +57 -0
  5. src/viz.py +62 -0
app.py CHANGED
@@ -1,29 +1,11 @@
1
  import streamlit as st
2
  import asyncio
3
- import websockets
4
- import json
5
- import tempfile
6
- import os
7
- from typing import Dict, Any, Optional, List, Literal, TypedDict, cast, Set, Tuple
8
- from streamlit_agraph import agraph, Node, Edge, Config
9
- import pyvis.network as net
10
 
11
- GGRAPHER_URI = "wss://ggrphr.davidalber.net"
12
-
13
- class StartNodeRequest(TypedDict):
14
- recordId: int
15
- getAdvisors: bool
16
- getDescendants: bool
17
-
18
- class RequestPayload(TypedDict):
19
- kind: Literal["build-graph"]
20
- options: Dict[Literal["reportingCallback"], bool]
21
- startNodes: List[StartNodeRequest]
22
-
23
- class ProgressCallback(TypedDict):
24
- queued: int
25
- fetching: int
26
- done: int
27
 
28
  def get_id_from_input(val: str) -> Optional[int]:
29
  try:
@@ -31,265 +13,16 @@ def get_id_from_input(val: str) -> Optional[int]:
31
  except Exception:
32
  return None
33
 
34
- def make_payload(record_id: int) -> RequestPayload:
35
- return {
36
- "kind": "build-graph",
37
- "options": {"reportingCallback": True},
38
- "startNodes": [{
39
- "recordId": record_id,
40
- "getAdvisors": True,
41
- "getDescendants": False,
42
- }],
43
- }
44
-
45
- async def get_graph(payload: RequestPayload, progress_cb=None) -> Dict[str, Any]:
46
- def intify_record_keys(d: Dict[Any, Any]) -> Dict[Any, Any]:
47
- if "nodes" in d:
48
- ret = {k: v for k, v in d.items() if k != "nodes"}
49
- ret["nodes"] = {int(k): v for k, v in d["nodes"].items()}
50
- return ret
51
- return d
52
-
53
- async with websockets.connect( # type: ignore[attr-defined]
54
- GGRAPHER_URI,
55
- ) as ws:
56
- await ws.send(json.dumps(payload))
57
- while True:
58
- response_json = await ws.recv()
59
- response = json.loads(response_json, object_hook=intify_record_keys)
60
- response_payload = response.get("payload")
61
- if response["kind"] == "graph":
62
- return cast(Dict[str, Any], response_payload)
63
- elif response["kind"] == "progress" and progress_cb:
64
- progress = cast(ProgressCallback, response_payload)
65
- progress_cb(progress)
66
- else:
67
- continue
68
-
69
- def build_tree_structure(graph: Dict[str, Any], root_id: int) -> Dict[int, Dict[str, Any]]:
70
- """Build a hierarchical tree structure from the graph data."""
71
- nodes = graph.get("nodes", {})
72
- tree = {}
73
-
74
- # calculate depth/generation for each node
75
- def calculate_depths(node_id: int, visited: Set[int], depth: int = 0) -> Dict[int, int]:
76
- if node_id in visited:
77
- return {}
78
-
79
- visited.add(node_id)
80
- depths = {node_id: depth}
81
-
82
- node = nodes.get(node_id, {})
83
- for advisor_id in node.get("advisors", []):
84
- if advisor_id in nodes:
85
- advisor_depths = calculate_depths(advisor_id, visited, depth + 1)
86
- depths.update(advisor_depths)
87
-
88
- return depths
89
-
90
- depths = calculate_depths(root_id, set())
91
-
92
- # build tree structure with depth info
93
- for node_id, node in nodes.items():
94
- if node_id in depths:
95
- tree[node_id] = {
96
- **node,
97
- "depth": depths[node_id],
98
- "children": [],
99
- "advisors": node.get("advisors", [])
100
- }
101
-
102
- # establish parent-child relationships
103
- for node_id, node in tree.items():
104
- for advisor_id in node["advisors"]:
105
- if advisor_id in tree:
106
- tree[advisor_id]["children"].append(node_id)
107
-
108
- return tree
109
-
110
- def create_hierarchical_view(graph: Dict[str, Any], root_id: int, max_depth: int = None) -> Tuple[List[Node], List[Edge]]:
111
- """Create nodes and edges for hierarchical tree view."""
112
  tree = build_tree_structure(graph, root_id)
113
- nodes_list = []
114
- edges_list = []
115
-
116
- # color scheme by generation
117
- colors = ["#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4", "#feca57", "#ff9ff3", "#54a0ff"]
118
-
119
- # group nodes by depth for positioning
120
- nodes_by_depth = {}
121
- for node_id, node in tree.items():
122
- if max_depth is None or node["depth"] <= max_depth:
123
- depth = node["depth"]
124
- if depth not in nodes_by_depth:
125
- nodes_by_depth[depth] = []
126
- nodes_by_depth[depth].append((node_id, node))
127
-
128
- # sort nodes within each depth by year (oldest first, recent last for lower position)
129
- for depth in nodes_by_depth:
130
- nodes_by_depth[depth].sort(key=lambda x: x[1].get('year') or 1400)
131
-
132
- # create nodes with positioning hints
133
- for depth in sorted(nodes_by_depth.keys()):
134
- depth_nodes = nodes_by_depth[depth]
135
-
136
- for i, (node_id, node) in enumerate(depth_nodes):
137
- color = colors[depth % len(colors)]
138
-
139
- name = node.get("name", str(node_id))
140
- year_str = f" ({node.get('year')})" if node.get('year') is not None else ""
141
- label = f"{name}{year_str}"
142
-
143
- # calculate positioning based on year within generation
144
- year = node.get('year') or 1500 # default to old year if missing
145
- base_y = depth * 300 # spacing between generations
146
-
147
- # position nodes with year-based offset within generation
148
- # more recent years get higher y values (appear lower on screen)
149
- year_offset = (year - 1400) * 0.2 # scale factor for year spacing
150
- x_pos = i * 180 + (depth * 20) # slight x offset per depth to avoid overlap
151
- y_pos = base_y + year_offset
152
-
153
- # create streamlit-agraph node with positioning
154
- ag_node = Node(
155
- id=str(node_id),
156
- label=label,
157
- size=25 if node_id == root_id else 20,
158
- color=color,
159
- title=f"Name: {name}\nYear: {node.get('year', 'N/A')}\nInstitution: {node.get('institution', 'N/A')}",
160
- x=x_pos,
161
- y=y_pos,
162
- font={"color": "white", "size": 12}
163
- )
164
- nodes_list.append(ag_node)
165
-
166
- # create edges to advisors
167
- for advisor_id in node["advisors"]:
168
- if advisor_id in tree and (max_depth is None or tree[advisor_id]["depth"] <= max_depth):
169
- edge = Edge(
170
- source=str(advisor_id),
171
- target=str(node_id),
172
- color="#666666"
173
- )
174
- edges_list.append(edge)
175
-
176
- return nodes_list, edges_list
177
-
178
- def create_pyvis_network(graph: Dict[str, Any], root_id: int) -> str:
179
- """Create an interactive network using pyvis."""
180
- nodes = graph.get("nodes", {})
181
-
182
- # create network
183
- nt = net.Network(
184
- height="600px",
185
- width="100%",
186
- bgcolor="#ffffff",
187
- font_color="black",
188
- directed=True
189
- )
190
-
191
- # configure physics
192
- nt.set_options("""
193
- var options = {
194
- "physics": {
195
- "enabled": true,
196
- "hierarchicalRepulsion": {
197
- "centralGravity": 0.3,
198
- "springLength": 120,
199
- "springConstant": 0.01,
200
- "nodeDistance": 200,
201
- "damping": 0.09
202
- },
203
- "solver": "hierarchicalRepulsion"
204
- },
205
- "layout": {
206
- "hierarchical": {
207
- "enabled": true,
208
- "direction": "UD",
209
- "sortMethod": "directed"
210
- }
211
- }
212
- }
213
- """)
214
-
215
- # calculate depths for coloring
216
- tree = build_tree_structure(graph, root_id)
217
- colors = ["#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4", "#feca57", "#ff9ff3", "#54a0ff"]
218
-
219
- # add nodes
220
- for node_id, node in nodes.items():
221
- name = node.get("name", str(node_id))
222
- year_str = f" ({node.get('year')})" if node.get('year') is not None else ""
223
- label = f"{name}{year_str}"
224
-
225
- depth = tree.get(node_id, {}).get("depth", 0)
226
- color = colors[depth % len(colors)]
227
-
228
- nt.add_node(
229
- node_id,
230
- label=label,
231
- title=f"Name: {name}\nYear: {node.get('year', 'N/A')}\nInstitution: {node.get('institution', 'N/A')}",
232
- color=color,
233
- size=30 if node_id == root_id else 20
234
- )
235
-
236
- # add edges
237
- for node_id, node in nodes.items():
238
- for advisor_id in node.get("advisors", []):
239
- if advisor_id in nodes:
240
- nt.add_edge(advisor_id, node_id)
241
-
242
- # save to temp file
243
- temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".html")
244
- nt.save_graph(temp_file.name)
245
-
246
- with open(temp_file.name, 'r') as f:
247
- html_content = f.read()
248
-
249
- os.unlink(temp_file.name)
250
- return html_content
251
-
252
- def tree_to_dot(graph: Dict[str, Any]) -> str:
253
- """Original graphviz DOT format (kept as fallback)."""
254
- nodes = graph.get("nodes", {})
255
- lines = [
256
- "digraph G {",
257
- " rankdir=TB;",
258
- ' node [shape=box, style="rounded,filled", fillcolor=lightyellow];',
259
- ' edge [arrowhead=vee];'
260
- ]
261
-
262
- for node_id, node in nodes.items():
263
- name = node.get("name", str(node_id))
264
- year_str = f" ({node.get('year')})" if node.get('year') is not None else " (Year Unknown)"
265
- label = f"{name}{year_str}"
266
- tooltip = f"ID: {node_id}\\nName: {name}\\nYear: {node.get('year', 'N/A')}\\nInstitution: {node.get('institution', 'N/A')}"
267
- lines.append(f' "{node_id}" [label="{label}", tooltip="{tooltip}"];')
268
-
269
- for node_id, node in nodes.items():
270
- for adv_id in node.get("advisors", []):
271
- if adv_id in nodes:
272
- lines.append(f' "{adv_id}" -> "{node_id}";')
273
-
274
- lines.append("}")
275
- return "\n".join(lines)
276
-
277
- def display_tree_summary(graph: Dict[str, Any], root_id: int):
278
- """Display summary statistics about the tree."""
279
- tree = build_tree_structure(graph, root_id)
280
-
281
  if not tree:
282
  return
283
-
284
  max_depth = max(node["depth"] for node in tree.values()) if tree else 0
285
  total_nodes = len(tree)
286
-
287
- # count nodes by generation
288
- depth_counts = {}
289
  for node in tree.values():
290
  depth = node["depth"]
291
  depth_counts[depth] = depth_counts.get(depth, 0) + 1
292
-
293
  col1, col2, col3 = st.columns(3)
294
  with col1:
295
  st.metric("Total Mathematicians", total_nodes)
@@ -298,8 +31,6 @@ def display_tree_summary(graph: Dict[str, Any], root_id: int):
298
  with col3:
299
  root_name = tree.get(root_id, {}).get("name", "Unknown")
300
  st.metric("Root", root_name)
301
-
302
-
303
  def main():
304
  st.title("Math Genealogy Ancestor Tree")
305
  st.write("Interactive visualization of academic advisor relationships from the Mathematics Genealogy Project")
 
1
  import streamlit as st
2
  import asyncio
3
+ from typing import Dict, Any, Optional
4
+ from streamlit_agraph import agraph, Config
 
 
 
 
 
5
 
6
+ from src.network import make_payload, get_graph
7
+ from src.graph import build_tree_structure, create_hierarchical_view, tree_to_dot
8
+ from src.viz import create_pyvis_network
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  def get_id_from_input(val: str) -> Optional[int]:
11
  try:
 
13
  except Exception:
14
  return None
15
 
16
+ def display_tree_summary(graph: Dict[str, Any], root_id: int) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  tree = build_tree_structure(graph, root_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  if not tree:
19
  return
 
20
  max_depth = max(node["depth"] for node in tree.values()) if tree else 0
21
  total_nodes = len(tree)
22
+ depth_counts: dict[int, int] = {}
 
 
23
  for node in tree.values():
24
  depth = node["depth"]
25
  depth_counts[depth] = depth_counts.get(depth, 0) + 1
 
26
  col1, col2, col3 = st.columns(3)
27
  with col1:
28
  st.metric("Total Mathematicians", total_nodes)
 
31
  with col3:
32
  root_name = tree.get(root_id, {}).get("name", "Unknown")
33
  st.metric("Root", root_name)
 
 
34
  def main():
35
  st.title("Math Genealogy Ancestor Tree")
36
  st.write("Interactive visualization of academic advisor relationships from the Mathematics Genealogy Project")
pyproject.toml CHANGED
@@ -9,13 +9,16 @@ packages = [{ include = "src" }]
9
  [tool.poetry.dependencies]
10
  python = ">=3.9,<3.9.7 || >3.9.7,<4.0"
11
  streamlit = "^1.35.0"
12
- aiohttp = "^3.12.0"
13
- beautifulsoup4 = "^4.13.4"
14
  streamlit-agraph = "^0.0.45"
15
  pyvis = "^0.3.2"
 
16
 
17
  [tool.poetry.group.dev.dependencies]
18
  pytest = "^8.2.0"
 
 
 
 
19
 
20
  [build-system]
21
  requires = ["poetry-core"]
 
9
  [tool.poetry.dependencies]
10
  python = ">=3.9,<3.9.7 || >3.9.7,<4.0"
11
  streamlit = "^1.35.0"
 
 
12
  streamlit-agraph = "^0.0.45"
13
  pyvis = "^0.3.2"
14
+ websockets = "^12.0"
15
 
16
  [tool.poetry.group.dev.dependencies]
17
  pytest = "^8.2.0"
18
+ black = "^24.4.2"
19
+ isort = "^5.13.2"
20
+ flake8 = "^7.0.0"
21
+ mypy = "^1.10.0"
22
 
23
  [build-system]
24
  requires = ["poetry-core"]
src/graph.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Any, Set, Tuple, List
2
+ from streamlit_agraph import Node, Edge
3
+
4
+ def build_tree_structure(graph: Dict[str, Any], root_id: int) -> Dict[int, Dict[str, Any]]:
5
+ nodes = graph.get("nodes", {})
6
+ tree = {}
7
+
8
+ def calculate_depths(node_id: int, visited: Set[int], depth: int = 0) -> Dict[int, int]:
9
+ if node_id in visited:
10
+ return {}
11
+ visited.add(node_id)
12
+ depths = {node_id: depth}
13
+ node = nodes.get(node_id, {})
14
+ for advisor_id in node.get("advisors", []):
15
+ if advisor_id in nodes:
16
+ advisor_depths = calculate_depths(advisor_id, visited, depth + 1)
17
+ depths.update(advisor_depths)
18
+ return depths
19
+
20
+ depths = calculate_depths(root_id, set())
21
+
22
+ for node_id, node in nodes.items():
23
+ if node_id in depths:
24
+ tree[node_id] = {
25
+ **node,
26
+ "depth": depths[node_id],
27
+ "children": [],
28
+ "advisors": node.get("advisors", [])
29
+ }
30
+
31
+ for node_id, node in tree.items():
32
+ for advisor_id in node["advisors"]:
33
+ if advisor_id in tree:
34
+ tree[advisor_id]["children"].append(node_id)
35
+
36
+ return tree
37
+
38
+ def create_hierarchical_view(
39
+ graph: Dict[str, Any],
40
+ root_id: int,
41
+ max_depth: int = None
42
+ ) -> Tuple[List[Node], List[Edge]]:
43
+ tree = build_tree_structure(graph, root_id)
44
+ nodes_list = []
45
+ edges_list = []
46
+
47
+ colors = ["#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4", "#feca57", "#ff9ff3", "#54a0ff"]
48
+ nodes_by_depth = {}
49
+ for node_id, node in tree.items():
50
+ if max_depth is None or node["depth"] <= max_depth:
51
+ depth = node["depth"]
52
+ if depth not in nodes_by_depth:
53
+ nodes_by_depth[depth] = []
54
+ nodes_by_depth[depth].append((node_id, node))
55
+
56
+ for depth in nodes_by_depth:
57
+ nodes_by_depth[depth].sort(key=lambda x: x[1].get('year') or 1400)
58
+
59
+ for depth in sorted(nodes_by_depth.keys()):
60
+ depth_nodes = nodes_by_depth[depth]
61
+ for i, (node_id, node) in enumerate(depth_nodes):
62
+ color = colors[depth % len(colors)]
63
+ name = node.get("name", str(node_id))
64
+ year_str = f" ({node.get('year')})" if node.get('year') is not None else ""
65
+ label = f"{name}{year_str}"
66
+ year = node.get('year') or 1500
67
+ base_y = depth * 300
68
+ year_offset = (year - 1400) * 0.2
69
+ x_pos = i * 180 + (depth * 20)
70
+ y_pos = base_y + year_offset
71
+ ag_node = Node(
72
+ id=str(node_id),
73
+ label=label,
74
+ size=25 if node_id == root_id else 20,
75
+ color=color,
76
+ title=f"Name: {name}\nYear: {node.get('year', 'N/A')}\nInstitution: {node.get('institution', 'N/A')}",
77
+ x=x_pos,
78
+ y=y_pos,
79
+ font={"color": "white", "size": 12}
80
+ )
81
+ nodes_list.append(ag_node)
82
+ for advisor_id in node["advisors"]:
83
+ if advisor_id in tree and (max_depth is None or tree[advisor_id]["depth"] <= max_depth):
84
+ edge = Edge(
85
+ source=str(advisor_id),
86
+ target=str(node_id),
87
+ color="#666666"
88
+ )
89
+ edges_list.append(edge)
90
+ return nodes_list, edges_list
91
+
92
+ def tree_to_dot(graph: Dict[str, Any]) -> str:
93
+ nodes = graph.get("nodes", {})
94
+ lines = [
95
+ "digraph G {",
96
+ " rankdir=TB;",
97
+ ' node [shape=box, style="rounded,filled", fillcolor=lightyellow];',
98
+ ' edge [arrowhead=vee];'
99
+ ]
100
+ for node_id, node in nodes.items():
101
+ name = node.get("name", str(node_id))
102
+ year_str = f" ({node.get('year')})" if node.get('year') is not None else " (Year Unknown)"
103
+ label = f"{name}{year_str}"
104
+ tooltip = f"ID: {node_id}\\nName: {name}\\nYear: {node.get('year', 'N/A')}\\nInstitution: {node.get('institution', 'N/A')}"
105
+ lines.append(f' "{node_id}" [label="{label}", tooltip="{tooltip}"];')
106
+ for node_id, node in nodes.items():
107
+ for adv_id in node.get("advisors", []):
108
+ if adv_id in nodes:
109
+ lines.append(f' "{adv_id}" -> "{node_id}";')
110
+ lines.append("}")
111
+ return "\n".join(lines)
src/network.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import websockets
3
+ import json
4
+ from typing import Dict, Any, Callable, Optional, TypedDict, Literal, List, cast
5
+
6
+ GGRAPHER_URI = "wss://ggrphr.davidalber.net"
7
+
8
+ class StartNodeRequest(TypedDict):
9
+ recordId: int
10
+ getAdvisors: bool
11
+ getDescendants: bool
12
+
13
+ class RequestPayload(TypedDict):
14
+ kind: Literal["build-graph"]
15
+ options: Dict[Literal["reportingCallback"], bool]
16
+ startNodes: List[StartNodeRequest]
17
+
18
+ class ProgressCallback(TypedDict):
19
+ queued: int
20
+ fetching: int
21
+ done: int
22
+
23
+ def make_payload(record_id: int) -> RequestPayload:
24
+ return {
25
+ "kind": "build-graph",
26
+ "options": {"reportingCallback": True},
27
+ "startNodes": [{
28
+ "recordId": record_id,
29
+ "getAdvisors": True,
30
+ "getDescendants": False,
31
+ }],
32
+ }
33
+
34
+ async def get_graph(
35
+ payload: RequestPayload,
36
+ progress_cb: Optional[Callable[[ProgressCallback], None]] = None
37
+ ) -> Dict[str, Any]:
38
+ def intify_record_keys(d: Dict[Any, Any]) -> Dict[Any, Any]:
39
+ if "nodes" in d:
40
+ ret = {k: v for k, v in d.items() if k != "nodes"}
41
+ ret["nodes"] = {int(k): v for k, v in d["nodes"].items()}
42
+ return ret
43
+ return d
44
+
45
+ async with websockets.connect(GGRAPHER_URI) as ws:
46
+ await ws.send(json.dumps(payload))
47
+ while True:
48
+ response_json = await ws.recv()
49
+ response = json.loads(response_json, object_hook=intify_record_keys)
50
+ response_payload = response.get("payload")
51
+ if response["kind"] == "graph":
52
+ return cast(Dict[str, Any], response_payload)
53
+ elif response["kind"] == "progress" and progress_cb:
54
+ progress = cast(ProgressCallback, response_payload)
55
+ progress_cb(progress)
56
+ else:
57
+ continue
src/viz.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import tempfile
2
+ import os
3
+ from typing import Dict, Any
4
+ import pyvis.network as net
5
+ from .graph import build_tree_structure
6
+
7
+ def create_pyvis_network(graph: Dict[str, Any], root_id: int) -> str:
8
+ nodes = graph.get("nodes", {})
9
+ nt = net.Network(
10
+ height="600px",
11
+ width="100%",
12
+ bgcolor="#ffffff",
13
+ font_color="black",
14
+ directed=True
15
+ )
16
+ nt.set_options("""
17
+ var options = {
18
+ "physics": {
19
+ "enabled": true,
20
+ "hierarchicalRepulsion": {
21
+ "centralGravity": 0.3,
22
+ "springLength": 120,
23
+ "springConstant": 0.01,
24
+ "nodeDistance": 200,
25
+ "damping": 0.09
26
+ },
27
+ "solver": "hierarchicalRepulsion"
28
+ },
29
+ "layout": {
30
+ "hierarchical": {
31
+ "enabled": true,
32
+ "direction": "UD",
33
+ "sortMethod": "directed"
34
+ }
35
+ }
36
+ }
37
+ """)
38
+ tree = build_tree_structure(graph, root_id)
39
+ colors = ["#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4", "#feca57", "#ff9ff3", "#54a0ff"]
40
+ for node_id, node in nodes.items():
41
+ name = node.get("name", str(node_id))
42
+ year_str = f" ({node.get('year')})" if node.get('year') is not None else ""
43
+ label = f"{name}{year_str}"
44
+ depth = tree.get(node_id, {}).get("depth", 0)
45
+ color = colors[depth % len(colors)]
46
+ nt.add_node(
47
+ node_id,
48
+ label=label,
49
+ title=f"Name: {name}\nYear: {node.get('year', 'N/A')}\nInstitution: {node.get('institution', 'N/A')}",
50
+ color=color,
51
+ size=30 if node_id == root_id else 20
52
+ )
53
+ for node_id, node in nodes.items():
54
+ for advisor_id in node.get("advisors", []):
55
+ if advisor_id in nodes:
56
+ nt.add_edge(advisor_id, node_id)
57
+ temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".html")
58
+ nt.save_graph(temp_file.name)
59
+ with open(temp_file.name, 'r') as f:
60
+ html_content = f.read()
61
+ os.unlink(temp_file.name)
62
+ return html_content