Skip to content

Commit 04ddbb1

Browse files
committed
ENH(PLOT,jupyter): Zoomable SVGs with _repr_html_() ...
bc could not simply implement _repr_svg_() due to jupyterlab/jupyterlab#7497 + feat(utils): +ifnone() + doc of new svg config-args.
1 parent c98e80f commit 04ddbb1

File tree

4 files changed

+144
-19
lines changed

4 files changed

+144
-19
lines changed

docs/source/plotting.rst

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,14 @@ If you want the bare-bone diagram, plot network::
5252
pip install graphtik[plot]
5353

5454
.. Tip::
55-
The `pydot.Dot <https://pypi.org/project/pydot/>`_ instances returned by ``plot()``
56-
are rendered directly in *Jupyter/IPython* notebooks as SVG images.
55+
The `pydot.Dot <https://pypi.org/project/pydot/>`_ instances returned by
56+
:meth:`.Plotter.plot()` are rendered directly in *Jupyter/IPython* notebooks
57+
as SVG images.
58+
59+
You may increase the height of the SVG cell output with something like this::
60+
61+
graphop.plot(svg_element_styles="height: 600px; width: 100%")
62+
5763

5864

5965
.. _debugging:

graphtik/base.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
log = logging.getLogger(__name__)
1010

1111

12+
def ifnone(item, default):
13+
return default if item is None else item
14+
15+
1216
def aslist(i, argname, allowed_types=list):
1317
"""Utility to accept singular strings as lists, and None --> []."""
1418
if not i:
@@ -138,7 +142,15 @@ class Plotter(abc.ABC):
138142
The purpose is to avoid copying this function & documentation here around.
139143
"""
140144

141-
def plot(self, filename=None, show=False, **kws):
145+
def plot(
146+
self,
147+
filename=None,
148+
show=False,
149+
svg_pan_zoom_json=None,
150+
svg_element_styles=None,
151+
svg_container_styles=None,
152+
**kws,
153+
):
142154
"""
143155
Entry-point for plotting ready made operation graphs.
144156
@@ -168,12 +180,27 @@ def plot(self, filename=None, show=False, **kws):
168180
an optional nested dict of Grapvhiz attributes for certain edges
169181
:param clusters:
170182
an optional mapping of nodes --> cluster-names, to group them
171-
183+
:param svg_pan_zoom_json:
184+
arguments controlling the rendering of a zoomable SVG in
185+
Jupyter notebooks, as defined in https://github.com/ariutta/svg-pan-zoom#how-to-use
186+
Defaults apply if `None`; read about the defaults in :func:`.render_pydot()`.
187+
:param svg_element_styles:
188+
mostly for sizing the zoomable SVG in Jupyter notebooks.
189+
Defaults apply if `None`; read about the defaults in :func:`.render_pydot()`, and
190+
inspect & experiment on the html page of the notebook with browser tools.
191+
:param svg_container_styles:
192+
like `svg_element_styles`
172193
:return:
173-
A ``pydot.Dot`` instance.
174-
NOTE that the returned instance is monkeypatched to support
175-
direct rendering in *jupyter cells* as SVG.
194+
a `pydot.Dot <https://pypi.org/project/pydot/>`_ instance
195+
196+
.. Tip::
197+
The :class:`pydot.Dot` instance returned is rendered directly
198+
in *Jupyter/IPython* notebooks as SVG images.
199+
200+
You may increase the height of the SVG cell output with
201+
something like this::
176202
203+
graphop.plot(svg_element_styles="height: 600px; width: 100%")
177204
178205
Note that the `graph` argument is absent - Each Plotter provides
179206
its own graph internally; use directly :func:`.render_pydot()` to provide
@@ -251,7 +278,14 @@ def plot(self, filename=None, show=False, **kws):
251278
from .plot import render_pydot
252279

253280
dot = self._build_pydot(**kws)
254-
return render_pydot(dot, filename=filename, show=show)
281+
return render_pydot(
282+
dot,
283+
filename=filename,
284+
show=show,
285+
svg_pan_zoom_json=svg_pan_zoom_json,
286+
svg_element_styles=svg_element_styles,
287+
svg_container_styles=svg_container_styles,
288+
)
255289

256290
@abc.abstractmethod
257291
def _build_pydot(self, **kws):

graphtik/plot.py

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

99
import pydot
1010

11+
from .base import ifnone
12+
1113
log = logging.getLogger(__name__)
1214

1315

@@ -34,15 +36,55 @@ def _report_unmatched_user_props(user_props, kind):
3436

3537

3638
def _monkey_patch_for_jupyter(pydot):
37-
# Ensure Dot nstance render in Jupyter
38-
# (see pydot/pydot#220)
39-
if not hasattr(pydot.Dot, "_repr_svg_"):
39+
"""
40+
Make Dot instance render in Jupyter notebooks.
41+
42+
.. Note::
43+
Had to use ``_repr_html_()`` and not simply ``_repr_svg_()`` because
44+
(due to https://github.com/jupyterlab/jupyterlab/issues/7497)
45+
46+
Old ``_repr_svg_()`` trick was suggested in https://github.com/pydot/pydot/issues/220.
47+
48+
.. TODO:
49+
Render fully client-side with:
50+
https://visjs.github.io/vis-network/docs/network/#importDot
51+
"""
52+
import re
53+
import textwrap
54+
55+
if not hasattr(pydot.Dot, "_repr_html_"):
4056

4157
def make_svg(self):
42-
return self.create_svg().decode()
58+
## Discard everything above and including `<svg>` element.
59+
svg_txt = self.create_svg().decode()
60+
m = re.search("<svg[^>]+>", svg_txt)
61+
svg_txt = svg_txt[m.end() :]
62+
63+
html = f"""
64+
<div class="svg_container">
65+
<style>
66+
.svg_container {{
67+
{self._svg_container_styles}
68+
}}
69+
.svg_container SVG {{
70+
{self._svg_element_styles}
71+
}}
72+
</style>
73+
<script src="http://ariutta.github.io/svg-pan-zoom/dist/svg-pan-zoom.min.js"></script>
74+
<script type="text/javascript">
75+
var scriptTag = document.scripts[document.scripts.length - 1];
76+
var parentTag = scriptTag.parentNode;
77+
svg_el = parentTag.querySelector(".svg_container svg");
78+
svgPanZoom(svg_el, {self._svg_pan_zoom_json});
79+
</script>
80+
<svg>
81+
{svg_txt}
82+
</div>
83+
"""
84+
return html
4385

4486
# monkey patch class
45-
pydot.Dot._repr_svg_ = make_svg
87+
pydot.Dot._repr_html_ = make_svg
4688

4789

4890
def build_pydot(
@@ -142,11 +184,16 @@ def get_node_name(a):
142184
if executed and nx_node in executed:
143185
kw["style"] = "filled"
144186
kw["fillcolor"] = fill_color
187+
try:
188+
kw["URL"] = f"file://{inspect.getfile(nx_node.fn)}"
189+
except Exception as ex:
190+
log.debug(
191+
"Ignoring error while inspecting file of %s: %s", nx_node.fn, ex
192+
)
145193
node = pydot.Node(
146194
name=quote_dot_kws(nx_node.name),
147195
shape=shape,
148196
## NOTE: Jupyter lab is bocking local-urls (e.g. on SVGs).
149-
URL=f"file://{inspect.getfile(nx_node.fn)}",
150197
**kw,
151198
)
152199

@@ -211,7 +258,14 @@ def supported_plot_formats():
211258
return [".%s" % f for f in pydot.Dot().formats]
212259

213260

214-
def render_pydot(dot, filename=None, show=False):
261+
def render_pydot(
262+
dot,
263+
filename=None,
264+
show=False,
265+
svg_pan_zoom_json: str = None,
266+
svg_element_styles: str = None,
267+
svg_container_styles: str = None,
268+
):
215269
"""
216270
Plot a *Graphviz* dot in a matplotlib, in file or return it for Jupyter.
217271
@@ -224,6 +278,22 @@ def render_pydot(dot, filename=None, show=False):
224278
:param show:
225279
If it evaluates to true, opens the diagram in a matplotlib window.
226280
If it equals `-1`, it returns the image but does not open the Window.
281+
:param svg_pan_zoom_json:
282+
arguments controlling the rendering of a zoomable SVG in
283+
Jupyter notebooks, as defined in https://github.com/ariutta/svg-pan-zoom#how-to-use
284+
if `None`, defaults to string::
285+
286+
{controlIconsEnabled: true, zoomScaleSensitivity: 0.4, fit: true}",
287+
288+
:param svg_element_styles:
289+
mostly for sizing the zoomable SVG in Jupyter notebooks.
290+
Inspect & experiment on the html page of the notebook with browser tools.
291+
if `None`, defaults to string::
292+
293+
width: 100%; height: 300px;
294+
295+
:param svg_container_styles:
296+
like `svg_element_styles`, if `None`, defaults to empty string.
227297
228298
:return:
229299
the matplotlib image if ``show=-1``, or the `dot`.
@@ -259,11 +329,26 @@ def render_pydot(dot, filename=None, show=False):
259329

260330
return img
261331

332+
## Set properties for rendering in Jupyter as zoomable SVG
333+
#
334+
dot._svg_pan_zoom_json = ifnone(
335+
svg_pan_zoom_json,
336+
"{controlIconsEnabled: true, zoomScaleSensitivity: 0.4, fit: true}",
337+
)
338+
dot._svg_element_styles = ifnone(svg_element_styles, "width: 100%; height: 300px;")
339+
dot._svg_container_styles = ifnone(svg_container_styles, "")
340+
262341
return dot
263342

264343

265-
def legend(filename=None, show=None):
266-
"""Generate a legend for all plots (see Plotter.plot() for args)"""
344+
def legend(
345+
filename=None,
346+
show=None,
347+
svg_pan_zoom_json: str = None,
348+
svg_element_styles: str = None,
349+
svg_container_styles: str = None,
350+
):
351+
"""Generate a legend for all plots (see :meth:`.Plotter.plot()` for args)"""
267352
import pydot
268353

269354
_monkey_patch_for_jupyter(pydot)

test/test_plot.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,8 @@ def test_plot_jupyter(pipeline, tmp_path):
171171
## Try returned Jupyter SVG.
172172

173173
dot = pipeline.plot()
174-
s = dot._repr_svg_()
175-
assert "SVG" in s
174+
s = dot._repr_html_()
175+
assert "<svg" in s.lower()
176176

177177

178178
def test_plot_legend(pipeline, tmp_path):

0 commit comments

Comments
 (0)