Skip to content

Commit 13aa4f0

Browse files
Copilothyanwong
andcommitted
Add d3js render-time override with inline and URL support
Agent-Logs-Url: https://github.com/hyanwong/tskit_arg_visualizer/sessions/8e87ee51-107a-41cd-a17c-213483a067b9 Co-authored-by: hyanwong <4699014+hyanwong@users.noreply.github.com>
1 parent 04633a8 commit 13aa4f0

4 files changed

Lines changed: 189 additions & 17 deletions

File tree

tests/test_draw_parameters.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import io
2+
import inspect
3+
4+
import pandas as pd
5+
6+
import tskit_arg_visualizer
7+
from tskit_arg_visualizer import D3ARG, draw_D3
8+
9+
10+
def _minimal_arg_json():
11+
return {
12+
"data": {"nodes": [], "edges": [], "mutations": [], "breakpoints": []},
13+
"width": 100,
14+
"height": 100,
15+
"y_axis": {"include_labels": True},
16+
"edges": {"type": "line"},
17+
"condense_mutations": False,
18+
"label_mutations": False,
19+
"tree_highlighting": True,
20+
"title": "None",
21+
"rotate_tip_labels": False,
22+
"plot_type": "full",
23+
}
24+
25+
26+
def _minimal_d3arg():
27+
return D3ARG(
28+
nodes=pd.DataFrame(columns=["id", "time"]),
29+
edges=pd.DataFrame(columns=["source", "target", "bounds"]),
30+
mutations=pd.DataFrame(columns=["position_01"]),
31+
breakpoints=pd.DataFrame(
32+
[
33+
{"start": 0.0, "stop": 1.0, "x_pos_01": 0.0, "width_01": 1.0, "fill": "#053e4e"}
34+
]
35+
),
36+
num_samples=0,
37+
sample_order=[],
38+
default_node_style={},
39+
time_units="generations",
40+
)
41+
42+
43+
class TestDrawParametersD3js:
44+
def test_draw_D3_default_uses_default_url(self, monkeypatch):
45+
call_args = None
46+
47+
def mock_display(*args, **kwargs):
48+
nonlocal call_args
49+
call_args = (args, kwargs)
50+
51+
monkeypatch.setattr(tskit_arg_visualizer, "display", mock_display)
52+
draw_D3(_minimal_arg_json(), is_notebook=True)
53+
html = call_args[0][0].data
54+
assert "https://d3js.org/d3.v7.min" in html
55+
56+
def test_public_draw_apis_accept_d3js_parameter(self):
57+
assert "d3js" in inspect.signature(D3ARG.draw).parameters
58+
assert "d3js" in inspect.signature(D3ARG.draw_node).parameters
59+
assert "d3js" in inspect.signature(D3ARG.draw_nodes).parameters
60+
assert "d3js" in inspect.signature(D3ARG.draw_genome_bar).parameters
61+
62+
def test_draw_D3_uses_url_override(self, monkeypatch):
63+
custom_url = "https://example.org/custom/d3.min"
64+
call_args = None
65+
66+
def mock_display(*args, **kwargs):
67+
nonlocal call_args
68+
call_args = (args, kwargs)
69+
70+
monkeypatch.setattr(tskit_arg_visualizer, "display", mock_display)
71+
draw_D3(_minimal_arg_json(), is_notebook=True, d3js=custom_url)
72+
html = call_args[0][0].data
73+
assert custom_url in html
74+
assert "https://d3js.org/d3.v7.min" not in html
75+
76+
def test_draw_D3_inlines_file_like_content(self, monkeypatch):
77+
inline_js = b"window.d3 = {version: 'inline'};"
78+
call_args = None
79+
80+
def mock_display(*args, **kwargs):
81+
nonlocal call_args
82+
call_args = (args, kwargs)
83+
84+
monkeypatch.setattr(tskit_arg_visualizer, "display", mock_display)
85+
draw_D3(_minimal_arg_json(), is_notebook=True, d3js=io.BytesIO(inline_js))
86+
html = call_args[0][0].data
87+
assert "<script>window.d3 = {version: 'inline'};</script>" in html
88+
89+
def test_draw_genome_bar_uses_d3js_override(self, monkeypatch):
90+
d3arg = _minimal_d3arg()
91+
custom_url = "https://example.org/alt/d3.min"
92+
call_args = None
93+
94+
def mock_display(*args, **kwargs):
95+
nonlocal call_args
96+
call_args = (args, kwargs)
97+
98+
monkeypatch.setattr(tskit_arg_visualizer, "display", mock_display)
99+
d3arg.draw_genome_bar(is_notebook=True, d3js=custom_url)
100+
html = call_args[0][0].data
101+
assert custom_url in html
102+
103+
def test_draw_genome_bar_inlines_file_like_content(self, monkeypatch):
104+
d3arg = _minimal_d3arg()
105+
call_args = None
106+
107+
def mock_display(*args, **kwargs):
108+
nonlocal call_args
109+
call_args = (args, kwargs)
110+
111+
monkeypatch.setattr(tskit_arg_visualizer, "display", mock_display)
112+
d3arg.draw_genome_bar(is_notebook=True, d3js=io.StringIO("window.d3 = {};"))
113+
html = call_args[0][0].data
114+
assert "<script>window.d3 = {};</script>" in html
115+
116+
def test_draw_genome_bar_default_uses_default_url(self, monkeypatch):
117+
d3arg = _minimal_d3arg()
118+
call_args = None
119+
120+
def mock_display(*args, **kwargs):
121+
nonlocal call_args
122+
call_args = (args, kwargs)
123+
124+
monkeypatch.setattr(tskit_arg_visualizer, "display", mock_display)
125+
d3arg.draw_genome_bar(is_notebook=True)
126+
html = call_args[0][0].data
127+
assert "https://d3js.org/d3.v7.min" in html

tskit_arg_visualizer/__init__.py

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,30 @@ def running_in_notebook():
9797
return False # Other type (?)
9898
except NameError:
9999
return False # Probably standard Python interpreter
100-
100+
101+
102+
DEFAULT_D3JS_URL = "https://d3js.org/d3.v7.min"
103+
104+
D3jsSource = collections.namedtuple("D3jsSource", ["url", "inline_script"])
105+
"""
106+
Result of resolve_d3js_source. Exactly one field is meaningful:
107+
- url is a string URL (and inline_script is ""), or
108+
- url is None and inline_script is a "<script>…</script>" HTML fragment.
109+
"""
110+
111+
112+
def resolve_d3js_source(d3js):
113+
if d3js is None:
114+
return D3jsSource(url=DEFAULT_D3JS_URL, inline_script="")
115+
try:
116+
d3_content = d3js.read()
117+
except AttributeError:
118+
return D3jsSource(url=str(d3js), inline_script="")
119+
if isinstance(d3_content, bytes):
120+
d3_content = d3_content.decode("utf-8")
121+
return D3jsSource(url=None, inline_script=f"<script>{d3_content}</script>")
122+
123+
101124
def calculate_evenly_distributed_positions(num_elements, start=0, end=1, round_to=0):
102125
"""Returns a list of `num_elements` evenly distributed positions on a given `length`
103126
@@ -179,21 +202,23 @@ def convert_time_to_position(t, min_time, max_time, scale, unique_times, h_spaci
179202
return (1-(t-min_time)/time_range) * (height-100) + y_shift
180203

181204

182-
def draw_D3(arg_json, styles=None, is_notebook=None):
205+
def draw_D3(arg_json, styles=None, is_notebook=None, d3js=None):
206+
d3js_source = resolve_d3js_source(d3js)
183207
if is_notebook is None:
184208
is_notebook = running_in_notebook()
185209
arg_json["source"] = json.dumps(arg_json.copy()) # first escape the plain json data
210+
arg_json["d3_url"] = d3js_source.url # Could be None if an inline script is used
186211
arg_json = {k: json.dumps(v) for k, v in arg_json.items()} # now escape all
187212
arg_json["divnum"] = str(random.randint(0,9999999999))
188213
arg_id = "arg_" + arg_json['divnum']
189214
JS_text = Template((
190-
'<div id="{}" class="d3arg" style="min-width:{}px; min-height:{}px;"></div>'
215+
'<div id="{}" class="d3arg" style="min-width:{}px; min-height:{}px;"></div>$d3_inline_script'
191216
'<script>$main_text</script>'
192217
).format(arg_id, float(arg_json["width"]) + 40, float(arg_json["height"]) + 80))
193218
with open(os.path.dirname(__file__) + "/visualizer.js", "r") as visualizerjs:
194219
main_text_template = Template(visualizerjs.read())
195220
main_text = main_text_template.safe_substitute(arg_json)
196-
html = JS_text.safe_substitute({'main_text': main_text})
221+
html = JS_text.safe_substitute({'main_text': main_text, 'd3_inline_script': d3js_source.inline_script})
197222
with open(os.path.dirname(__file__) + "/visualizer.css", "r") as css:
198223
general_styles = css.read()
199224
specific_styles = ""
@@ -1459,6 +1484,7 @@ def draw(
14591484
styles=None,
14601485
preamble=None,
14611486
save_filename=None,
1487+
d3js=None,
14621488
):
14631489
"""Draws the D3ARG using D3.js by sending a custom JSON object to visualizer.js
14641490
@@ -1533,6 +1559,12 @@ def draw(
15331559
save_filename : str
15341560
Filename to use when selecting "Download as" in the visualization
15351561
(default=None, treated as "tskit_arg_visualizer")
1562+
d3js : optional
1563+
Source for loading D3.js. If None, uses the default URL
1564+
https://d3js.org/d3.v7.min. Otherwise, this is first treated as a file-like
1565+
object by trying `.read()`: if successful, the returned JavaScript source is
1566+
embedded directly in the output HTML (bytes are decoded as UTF-8). If `.read()`
1567+
is unavailable, the value is converted to `str(...)` and used as a URL.
15361568
15371569
Returns
15381570
-------
@@ -1574,7 +1606,7 @@ def draw(
15741606
preamble=preamble,
15751607
save_filename=save_filename,
15761608
)
1577-
info = draw_D3(arg_json=arg, styles=styles, is_notebook=is_notebook)
1609+
info = draw_D3(arg_json=arg, styles=styles, is_notebook=is_notebook, d3js=d3js)
15781610
info.included.nodes = included_nodes["id"].tolist()
15791611
return info
15801612

@@ -1723,6 +1755,7 @@ def draw_node(
17231755
styles=None,
17241756
preamble=None,
17251757
save_filename=None,
1758+
d3js=None,
17261759
):
17271760
"""Draws a subgraph of the D3ARG using D3.js by sending a custom JSON object to visualizer.js.
17281761
@@ -1789,6 +1822,12 @@ def draw_node(
17891822
save_filename : str
17901823
Filename to use when selecting "Download as" in the visualization
17911824
(default=None, treated as "tskit_arg_visualizer")
1825+
d3js : optional
1826+
Source for loading D3.js. If None, uses the default URL
1827+
https://d3js.org/d3.v7.min. Otherwise, this is first treated as a file-like
1828+
object by trying `.read()`: if successful, the returned JavaScript source is
1829+
embedded directly in the output HTML (bytes are decoded as UTF-8). If `.read()`
1830+
is unavailable, the value is converted to `str(...)` and used as a URL.
17921831
17931832
Returns
17941833
-------
@@ -1825,7 +1864,7 @@ def draw_node(
18251864
preamble=preamble,
18261865
save_filename=save_filename,
18271866
)
1828-
info = draw_D3(arg_json=arg, styles=styles, is_notebook=is_notebook)
1867+
info = draw_D3(arg_json=arg, styles=styles, is_notebook=is_notebook, d3js=d3js)
18291868
info.included.nodes = included.nodes["id"].tolist()
18301869
return info
18311870

@@ -1839,6 +1878,7 @@ def draw_genome_bar(
18391878
windows=None,
18401879
show_mutations=False,
18411880
is_notebook=None,
1881+
d3js=None,
18421882
):
18431883
"""Draws a genome bar for the D3ARG using D3.js
18441884
@@ -1857,10 +1897,16 @@ def draw_genome_bar(
18571897
whether it is being called in a notebook environment. This may not work in
18581898
some untested environments, in which case you may wish to set this explicitly
18591899
to True (to force a notebook display) or False (to force a standalone HTML page).
1900+
d3js : optional
1901+
Source for loading D3.js. If None, uses the default URL
1902+
https://d3js.org/d3.v7.min. Otherwise, this is first treated as a file-like
1903+
object by trying `.read()`: if successful, the returned JavaScript source is
1904+
embedded directly in the output HTML (bytes are decoded as UTF-8). If `.read()`
1905+
is unavailable, the value is converted to `str(...)` and used as a URL.
18601906
"""
18611907
if is_notebook is None:
18621908
is_notebook = running_in_notebook()
1863-
1909+
d3js_source = resolve_d3js_source(d3js)
18641910
transformed_bps = self.breakpoints.loc[:,:]
18651911
transformed_bps["x_pos"] = transformed_bps["x_pos_01"] * width
18661912
transformed_bps["width"] = transformed_bps["width_01"] * width
@@ -1897,13 +1943,15 @@ def draw_genome_bar(
18971943
}
18981944

18991945
genome_bar_json["source"] = genome_bar_json.copy()
1946+
genome_bar_json["d3_url"] = d3js_source.url # Could be None if an inline script is used. Will be escaped below
1947+
genome_bar_json = {k: json.dumps(v) for k, v in genome_bar_json.items()}
19001948
genome_bar_json["divnum"] = str(random.randint(0,9999999999))
1901-
JS_text = Template("<div id='genome_bar_" + genome_bar_json['divnum'] + "'class='d3arg' style='min-width:" + str(genome_bar_json["width"]+40) + "px; min-height:180px;'></div><script>$main_text</script>")
1949+
JS_text = Template("<div id='genome_bar_" + genome_bar_json['divnum'] + "'class='d3arg' style='min-width:" + str(json.loads(genome_bar_json["width"])+40) + "px; min-height:180px;'></div>$d3_inline_script<script>$main_text</script>")
19021950
breakpointsjs = open(os.path.dirname(__file__) + "/alternative_plots/genome_bar.js", "r")
19031951
main_text_template = Template(breakpointsjs.read())
19041952
breakpointsjs.close()
19051953
main_text = main_text_template.safe_substitute(genome_bar_json)
1906-
html = JS_text.safe_substitute({'main_text': main_text})
1954+
html = JS_text.safe_substitute({'main_text': main_text, 'd3_inline_script': d3js_source.inline_script})
19071955
css = open(os.path.dirname(__file__) + "/visualizer.css", "r")
19081956
styles = css.read()
19091957
css.close()
@@ -1912,7 +1960,7 @@ def draw_genome_bar(
19121960
else:
19131961
with tempfile.NamedTemporaryFile("w", delete=False, suffix=".html") as f:
19141962
url = "file://" + f.name
1915-
f.write("<!DOCTYPE html><html><head><style>"+styles+"</style><script src='https://cdn.rawgit.com/eligrey/canvas-toBlob.js/f1a01896135ab378aa5c0118eadd81da55e698d8/canvas-toBlob.js'></script><script src='https://cdn.rawgit.com/eligrey/FileSaver.js/e9d941381475b5df8b7d7691013401e171014e89/FileSaver.min.js'></script><script src='https://d3js.org/d3.v7.min.js'></script></head><body>" + html + "</body></html>")
1963+
f.write("<!DOCTYPE html><html><head><style>"+styles+"</style><script src='https://cdn.rawgit.com/eligrey/canvas-toBlob.js/f1a01896135ab378aa5c0118eadd81da55e698d8/canvas-toBlob.js'></script><script src='https://cdn.rawgit.com/eligrey/FileSaver.js/e9d941381475b5df8b7d7691013401e171014e89/FileSaver.min.js'></script></head><body>" + html + "</body></html>")
19161964
webbrowser.open(url, new=2)
19171965

19181966

tskit_arg_visualizer/alternative_plots/genome_bar.js

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ function loadScript(src) {
99
});
1010
}
1111

12-
if (typeof d3 !== 'undefined') {
12+
if ($d3_url === null) {
1313
draw_genome_bar(d3);
1414
} else {
15-
loadScript('https://d3js.org/d3.v7.min.js')
15+
loadScript($d3_url)
1616
.then(() => {
1717
if (typeof d3 === 'undefined') {
1818
throw new Error('D3 loaded but global "d3" is unavailable.');
@@ -136,5 +136,3 @@ function draw_genome_bar(d3) {
136136
})
137137
.text(function(d) { return d.site_id; });
138138
}
139-
140-
draw_genome_bar()

tskit_arg_visualizer/visualizer.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1366,10 +1366,10 @@ function main_visualizer(
13661366
can be used to pass in the appropriate data
13671367
*/
13681368

1369-
if (typeof d3 !== 'undefined') {
1369+
if ($d3_url === null) {
13701370
main_visualizer(d3, $divnum, $data, $width, $height, $y_axis, $edges, $condense_mutations, $label_mutations, $tree_highlighting, $title, $rotate_tip_labels, $plot_type, $preamble, $source, $save_filename)
13711371
} else {
1372-
loadScript('https://d3js.org/d3.v7.min.js')
1372+
loadScript($d3_url)
13731373
.then(() => {
13741374
if (typeof d3 === 'undefined') {
13751375
throw new Error('D3 loaded but global "d3" is unavailable.');
@@ -1378,4 +1378,3 @@ if (typeof d3 !== 'undefined') {
13781378
})
13791379
.catch(err => console.error('Failed to load d3 script:', err));
13801380
}
1381-

0 commit comments

Comments
 (0)