Skip to content

Commit 65ff005

Browse files
authored
Issue #308: d3js visualization fails to render in jupyter notebook (#309)
1 parent 445c3aa commit 65ff005

3 files changed

Lines changed: 53 additions & 61 deletions

File tree

src/pathpyG/visualisations/_d3js/backend.py

Lines changed: 50 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
import uuid
2222
import webbrowser
2323
from copy import deepcopy
24-
from string import Template
2524

2625
from pathpyG.utils.config import config
2726
from pathpyG.visualisations.network_plot import NetworkPlot
@@ -39,7 +38,7 @@
3938
TemporalNetworkPlot: "temporal",
4039
TimeUnfoldedNetworkPlot: "unfolded",
4140
}
42-
_CDN_URL = "https://d3js.org/d3.v7.min.js"
41+
_CDN_URL = "https://cdn.jsdelivr.net/npm/d3@7/+esm"
4342

4443

4544
class D3jsBackend(PlotBackend):
@@ -116,8 +115,8 @@ def save(self, filename: str) -> None:
116115
- Embedded in websites or documentation
117116
- Shared without additional dependencies
118117
"""
119-
# Default to the CDN version of d3js since browsers may block local scripts
120-
self.config["d3js_local"] = self.config.get("d3js_local", False)
118+
# Default to embedded local version to obtain a self-contained file
119+
self.config["d3js_local"] = config.get("d3js_local", True)
121120
with open(filename, "w+") as new:
122121
new.write(self.to_html())
123122

@@ -133,13 +132,13 @@ def show(self) -> None:
133132
and choose appropriate display method automatically.
134133
"""
135134
# Default to CDN version if reachable
136-
# Check if CDN is reachable
137135
try:
138-
urllib.request.urlopen(_CDN_URL, timeout=2)
139-
self.config["d3js_local"] = self.config.get("d3js_local", False)
136+
# Attempt to access the CDN URL to check if it's reachable
137+
urllib.request.urlopen(urllib.request.Request(_CDN_URL, headers={"User-Agent": "Mozilla/5.0"}), timeout=2)
138+
self.config["d3js_local"] = config.get("d3js_local", False)
140139
except (urllib.error.URLError, urllib.error.HTTPError):
141-
self.config["d3js_local"] = self.config.get("d3js_local", True)
142-
140+
self.config["d3js_local"] = config.get("d3js_local", True)
141+
143142
if config["environment"]["interactive"]:
144143
from IPython.display import display_html, HTML # noqa I001
145144

@@ -168,15 +167,21 @@ def _prepare_data(self) -> dict:
168167
**Edges**: Include uid, source/target references, and styling
169168
"""
170169
node_data = self.data["nodes"].copy()
171-
node_data["uid"] = self.data["nodes"].index.map(lambda x: f"({x[0]},{x[1]})" if isinstance(x, tuple) else str(x))
170+
node_data["uid"] = self.data["nodes"].index.map(
171+
lambda x: f"({x[0]},{x[1]})" if isinstance(x, tuple) else str(x)
172+
)
172173
node_data = node_data.rename(columns={"x": "xpos", "y": "ypos"})
173174
if self._kind == "unfolded":
174175
node_data["ypos"] = 1 - node_data["ypos"] # Invert y-axis for unfolded layout
175176
edge_data = self.data["edges"].copy()
176177
edge_data["uid"] = self.data["edges"].index.map(lambda x: f"{x[0]}-{x[1]}")
177178
if len(edge_data) > 0:
178-
edge_data["source"] = edge_data.index.to_frame()["source"].map(lambda x: f"({x[0]},{x[1]})" if isinstance(x, tuple) else str(x))
179-
edge_data["target"] = edge_data.index.to_frame()["target"].map(lambda x: f"({x[0]},{x[1]})" if isinstance(x, tuple) else str(x))
179+
edge_data["source"] = edge_data.index.to_frame()["source"].map(
180+
lambda x: f"({x[0]},{x[1]})" if isinstance(x, tuple) else str(x)
181+
)
182+
edge_data["target"] = edge_data.index.to_frame()["target"].map(
183+
lambda x: f"({x[0]},{x[1]})" if isinstance(x, tuple) else str(x)
184+
)
180185
data_dict = {
181186
"nodes": node_data.to_dict(orient="records"),
182187
"edges": edge_data.to_dict(orient="records"),
@@ -253,17 +258,8 @@ def to_html(self) -> str:
253258
os.path.normpath("_d3js/templates"),
254259
)
255260

256-
# get d3js library path
257-
if self.config.get("d3js_local", False):
258-
d3js = os.path.join(template_dir, "d3.v7.min.js")
259-
else:
260-
d3js = _CDN_URL
261-
262261
js_template = self.get_template(template_dir)
263262

264-
with open(os.path.join(template_dir, "setup.js")) as template:
265-
setup_template = template.read()
266-
267263
with open(os.path.join(template_dir, "styles.css")) as template:
268264
css_template = template.read()
269265

@@ -277,17 +273,16 @@ def to_html(self) -> str:
277273
# div environment for the plot object
278274
html += f'\n<div id = "{dom_id[1:]}"> </div>\n'
279275

280-
# add d3js library
281-
html += f'<script charset="utf-8" src="{d3js}"></script>\n'
282-
283276
# start JavaScript
284277
html += '<script charset="utf-8">\n'
285278

286-
# add setup code to run d3js in multiple environments
287-
html += Template(setup_template).substitute(d3js=d3js)
279+
# add d3 render function with unique name to avoid conflicts
280+
callback_name = f"render_plot_{uuid.uuid4().hex}"
281+
html += f"function {callback_name}() {{\n"
288282

289-
# start d3 environment
290-
html += "require(['d3'], function(d3){ //START\n"
283+
# define 'd3' inside the scope (standardizing access)
284+
html += " const d3 = window.d3;\n"
285+
html += " if (!d3) { console.error('D3 not loaded'); return; }\n"
291286

292287
# add data and config
293288
html += f"const data = {data_json}\n"
@@ -299,12 +294,37 @@ def to_html(self) -> str:
299294
# add JavaScript
300295
html += js_template
301296

302-
# end d3 environment
303-
html += "\n}); //END\n"
297+
# Close the render function
298+
html += "\n}; //END of Render Function\n"
304299

305300
# end JavaScript
306301
html += "\n</script>"
307302

303+
# add d3js library - either from CDN or as embedded script (local)
304+
if self.config.get("d3js_local", False):
305+
d3js_path = os.path.join(template_dir, "d3.v7.min.js")
306+
with open(d3js_path, "r", encoding="utf-8") as f:
307+
raw_d3_js = f.read()
308+
309+
# We wrap the local D3 code in an IIFE (Immediately Invoked Function Expression).
310+
# Inside this function, we set 'define' and 'exports' to undefined.
311+
# This forces D3 to ignore VS Code's module system and attach to window.d3.
312+
html += "<script>\n"
313+
html += "(function() { var define = undefined; var exports = undefined; \n"
314+
html += raw_d3_js
315+
html += "\n})();\n"
316+
html += "</script>\n"
317+
html += f"<script>{callback_name}();</script>\n"
318+
else:
319+
d3_url = _CDN_URL
320+
html += f"""
321+
<script type="module">
322+
import * as d3 from "{d3_url}";
323+
window.d3 = d3;
324+
{callback_name}();
325+
</script>
326+
"""
327+
308328
return html
309329

310330
def get_template(self, template_dir: str) -> str:

src/pathpyG/visualisations/_d3js/templates/setup.js

Lines changed: 0 additions & 28 deletions
This file was deleted.

tests/visualisations/_d3js/test_backend.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import pytest
1010

11+
from pathpyG import config
1112
from pathpyG.core.graph import Graph
1213
from pathpyG.core.temporal_graph import TemporalGraph
1314
from pathpyG.visualisations._d3js.backend import D3jsBackend
@@ -359,11 +360,10 @@ def test_save_creates_html_file(self):
359360
assert len(content) > 0
360361
assert "<script" in content
361362

362-
@patch("pathpyG.visualisations._d3js.backend.config")
363363
@patch("pathpyG.visualisations._d3js.backend.webbrowser")
364-
def test_show_in_browser_opens_file(self, mock_browser, mock_config):
364+
def test_show_in_browser_opens_file(self, mock_browser):
365365
"""Test that show opens browser in non-interactive mode."""
366-
mock_config.__getitem__.return_value = {"interactive": False}
366+
config["environment"]["interactive"] = False
367367

368368
self.backend.show()
369369

0 commit comments

Comments
 (0)