Repository: dask/dask

Issue:
Interactive alternatives to graphviz for .visualize()
Thoughts on improving the Jupyter display of dask objects with some of the JS libraries like cytoscape? Graphviz works, but there are some things that JS libraries do really well - like tooltips for additional data, zooming in on a section of the graph, etc.


Discussion:
Thanks for raising an issue @Madhu94! I'm not familiar with cytoscape, so I can't speak to that project specifically. Though providing a more rich graph visualization experience is certainly in scope. FWIW you might find the discussion over in https://github.com/dask/dask/issues/7141 interesting. 

cc @ian-r-rose @GenevieveBuckley @jsignell who may be interested in thinking about interactively visualizing graphs 
Thanks, I'm definitely interested in this discussion (although I can't say I know anything about cytoscape)
Cytoscape was just an example:) I just wanted to start some discussion on JS libraries for .visualize(). IMO, Jupyter's full potential isn't used with graphviz. We might still want to add a fallback to graphviz
Yeah I can imagine having two versions of the visualize output kind of like we have for the dask array `repr`. I think that would be a really nice improvement.
Agreed, thanks for bringing thus up @Madhu94.

I haven't played with cytoscape myself, but it looks cool. Another option that might be a useful small step could be [d3-graphviz](https://observablehq.com/@magjac/introduction-to-d3-graphviz). Since it understands graphviz graphs already, it probably would be pretty easy to adapt the current visualization to use (I also like that it is pretty lightweight, producing just SVG)
 > I haven't played with cytoscape myself, but it looks cool. Another option that might be a useful small step could be [d3-graphviz](https://observablehq.com/@magjac/introduction-to-d3-graphviz). Since it understands graphviz graphs already, it probably would be pretty easy to adapt the current visualization to use (I also like that it is pretty lightweight, producing just SVG)

Understanding graphviz graphs already sounds like a point in favour of d3-graphviz. But I did see that 
[there's a problem with tooltips](https://github.com/mstefaniuk/graph-viz-d3-js/issues/53) right now, which may or may not be a problem for us, depending on what we want to do.



On the cytoscape side there's [`ipycytoscape`](https://github.com/QuantStack/ipycytoscape) which might make it easier to integrate?

I've also come across [`ipyelk`](https://github.com/jupyrdf/ipyelk) which looks interesting, especially for me, the potential to collapse/drill down into different "jobs" running on the cluster at the same time.
That toggle collapsed feature of ipyelk looks very interesting

Thanks @dhirschfeld, those look great! I think plotly's Dash also has a cytoscape integration. What do you all think of summarizing the features that would be important for us and then we can do a proper evaluation around the open source ecosystem ?  

Summary of what we discussed -
* Tooltips 
* Zoom and drilldown (maybe even having the ability to lazily add nodes helps)

While features are good, but we might also look at how well funded / maintained the project is - something from the pydata ecosystem (where Quansight, Anaconda, Coiled, etc are already there) may be better.  This is just my opinion - but I'd be a bit concerned if Dask had to depend on a library with just 1-2 maintainers.
Just to drop another library name, which might be worthy of consideration: https://c3js.org/ (there's a demo on the home page).
I haven't had the chance to used it yet, but gave heard positive opinions about it.
Previous discussion on visualizing large graphs: https://github.com/dask/dask/issues/1991
[x-posting from the slack channel]
 
I am currently working on a C project on a social media which recommends friends for a User.

I decided to use Graphviz for the visualization of the friendship network and the results came out to be pretty decent.

### **I also color-coded sub-graphs and also introduced blocks for interdependent nodes.**

![image](https://user-images.githubusercontent.com/62539811/117108930-f14c3300-ad94-11eb-9498-4f7a31a32f83.png)

![image](https://user-images.githubusercontent.com/62539811/117109276-75061f80-ad95-11eb-8be9-587e8e0faffe.png)

It's still just a static image, but it's an overall improvement over the current `Dask.visualize` as it makes more intuitive sense to color code different User groups (here, Dask Task Groups). We can add interactivity by using [`dash-interactive-graphviz`](https://pypi.org/project/dash-interactive-graphviz/) which supports
- Zooming and Panning around the graph (With a reset)
- Selecting nodes
- Animation between different graphs

Even `d3-graphviz` understands `DOT` source. So, that can be used as well.

P.S. I am currently working on bringing interactivity to my Friendship Graph. If it works, I will comment on this thread.
This is a great discussion.  It seems like there are maybe a couple of conversations here:

1.  Use a different library for graph visualization
2.  Add more information to high level graphs so that users can start to use them more, rather than low level graphs, which can help when people get to larger scales.

On point one (graph library) I would like to voice a few constraints, just so that we're aware of them.  I suspect that everyone already has these thoughts in mind.  

Whatever solution we provide needs to ...

1. produce static images, like png, svg, and pdf files
2. work in a jupyter notebook
3. be easy to install for Python users (graphviz had a lot of pain around it originally because it was hard to install on some platforms)

On point two (information to high level graphs) I think that this is somewhat orthogonal to the choice of graph visualization library.  It should be easy to draw the kinds of graphs that we're drawing no matter which library we choose.

I encourage us to make one of these changes at a time, rather than both at once.  
This seems as good a place as any to put my thoughts for @freyam , following our conversation last week. His blog post about meeting the community can be found [here](https://freyam.medium.com/gsoc-journal-01-community-bonding-period-with-dask-bd2838555ac9), and the document listing some pros/cons for some graph display libraries from above is [here](https://docs.google.com/document/d/1HgmxxFyym9TCBfmJI5bB-caOKfq5J5TQbgWTgso5GIs/edit?usp=sharing). 

Immediate work areas for this week:
- to investigate the contents of a dask graph, both low-level and high-level variants, and how these get assigned properties for display with the current code in the `dot` module. What further information is available, based on the task naming conventions and e.g., for high-level graphs the number of sub-tasks and for arrays, the size of the operands. Such information might be added into the nodes as text, colour or edge-style (all probably optional).
- to have some familiarity with the distributed dashboard realtime view of the task graph: what information does the scheduler have about a task while its running, how much of this is derived from the graph itself? Please add Bokeh as one of the possible graph display libraries. Why is Bokeh problematic in the static case? What nice features does it give, which we would love for graphviz-style graphs to also have?
- read up on `client.get_task_stream` as a possible means to record the computation of a graph for post-hoc analysis. Compare with [performance reports](https://distributed.dask.org/en/latest/diagnosing-performance.html#performance-reports). Could we aim to have performance information, if available, embedded in a static graph view?

As far as the libraries investigation so far, I would like to see a reformatting in terms of pros/cons, only in terns of features that are useful to us. Some examples:
- animations look nice, but what can we use them for? I can think of: comparing two graphs pre/post optimisation; comparing two graphs with/without a given step
- interactive editing of node placement is probably not useful, except on the rare occasion we want to make publication-quality output
- being able to pack/unpack areas of the graph was specifically mentioned as a nice-to-have to be able to make sense of large graphs. Can this be done with static output? Can the packed view give summary information about its contents?

One potential concrete piece of work we discussed: modifying the task graph view in the distributed dashboard to remove the useless axes labels. It is unlikely that the bokeh codebase will feature in the rest of the project, so this would be a perhaps interesting diversion.

Sorry that this is a lot of text! I hope it is not overwhelming.

PS: am I the only one that keeps writing "daskboard"?
You're not the only one @martindurant - I write "daskboard" almost every time as well!

FYI there are ongoing efforts to update visualizations of the performance information coming out of the scheduler.  See for example  https://github.com/dask/distributed/pull/4886   .  

However, what I think would be *really valuable* is not visualizing information coming out of the scheduler, like how we do with Bokeh, but instead visualizing the graph information on the client side.  No one is working on this today and it would be really helpful.

If I were managing this work I would stop investigation into visualization tools, and I would start pumping data about high level graphs into a graphviz visualization.  I think that this can have high impact *today*, and doesn't require much investigation to make serious progress.  

Resolving patch:
diff --git a/dask/base.py b/dask/base.py
--- a/dask/base.py
+++ b/dask/base.py
@@ -16,6 +16,7 @@
 from functools import partial
 from numbers import Integral, Number
 from operator import getitem
+from typing import Literal
 
 from packaging.version import parse as parse_version
 from tlz import curry, groupby, identity, merge
@@ -601,7 +602,13 @@ def compute(
 
 
 def visualize(
-    *args, filename="mydask", traverse=True, optimize_graph=False, maxval=None, **kwargs
+    *args,
+    filename="mydask",
+    traverse=True,
+    optimize_graph=False,
+    maxval=None,
+    engine: Literal["cytoscape", "ipycytoscape", "graphviz"] | None = None,
+    **kwargs,
 ):
     """
     Visualize several dask graphs simultaneously.
@@ -655,8 +662,12 @@ def visualize(
     verbose : bool, optional
         Whether to label output and input boxes even if the data aren't chunked.
         Beware: these labels can get very long. Default is False.
+    engine : {"graphviz", "ipycytoscape", "cytoscape"}, optional.
+        The visualization engine to use. If not provided, this checks the dask config
+        value "visualization.engine". If that is not set, it tries to import ``graphviz``
+        and ``ipycytoscape``, using the first one to succeed.
     **kwargs
-       Additional keyword arguments to forward to ``to_graphviz``.
+       Additional keyword arguments to forward to the visualization engine.
 
     Examples
     --------
@@ -678,8 +689,6 @@ def visualize(
 
     https://docs.dask.org/en/latest/optimize.html
     """
-    from dask.dot import dot_graph
-
     args, _ = unpack_collections(*args, traverse=traverse)
 
     dsk = dict(collections_to_dsk(args, optimize_graph=optimize_graph))
@@ -707,7 +716,7 @@ def visualize(
         try:
             cmap = kwargs.pop("cmap")
         except KeyError:
-            cmap = plt.cm.RdBu
+            cmap = plt.cm.plasma
         if isinstance(cmap, str):
             import matplotlib.pyplot as plt
 
@@ -765,7 +774,37 @@ def label(x):
     elif color:
         raise NotImplementedError("Unknown value color=%s" % color)
 
-    return dot_graph(dsk, filename=filename, **kwargs)
+    # Determine which engine to dispatch to, first checking the kwarg, then config,
+    # then whichever of graphviz or ipycytoscape are installed, in that order.
+    engine = engine or config.get("visualization.engine", None)
+
+    if not engine:
+        try:
+            import graphviz  # noqa: F401
+
+            engine = "graphviz"
+        except ImportError:
+            try:
+                import ipycytoscape  # noqa: F401
+
+                engine = "cytoscape"
+            except ImportError:
+                pass
+
+    if engine == "graphviz":
+        from dask.dot import dot_graph
+
+        return dot_graph(dsk, filename=filename, **kwargs)
+    elif engine in ("cytoscape", "ipycytoscape"):
+        from dask.dot import cytoscape_graph
+
+        return cytoscape_graph(dsk, filename=filename, **kwargs)
+    elif engine is None:
+        raise RuntimeError(
+            "No visualization engine detected, please install graphviz or ipycytoscape"
+        )
+    else:
+        raise ValueError(f"Visualization engine {engine} not recognized")
 
 
 def persist(*args, traverse=True, optimize_graph=True, scheduler=None, **kwargs):
diff --git a/dask/dot.py b/dask/dot.py
--- a/dask/dot.py
+++ b/dask/dot.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import os
 import re
 from functools import partial
@@ -5,15 +7,6 @@
 from dask.core import get_dependencies, ishashable, istask
 from dask.utils import apply, funcname, import_required, key_split
 
-graphviz = import_required(
-    "graphviz",
-    "Drawing dask graphs requires the `graphviz` python library and the "
-    "`graphviz` system library.\n\n"
-    "Please either conda or pip install as follows:\n\n"
-    "  conda install python-graphviz     # either conda install\n"
-    "  python -m pip install graphviz    # or pip install and follow installation instructions",
-)
-
 
 def task_label(task):
     """Label for a task on a dot graph.
@@ -137,6 +130,15 @@ def to_graphviz(
     verbose=False,
     **kwargs,
 ):
+    graphviz = import_required(
+        "graphviz",
+        "Drawing dask graphs with the graphviz engine requires the `graphviz` "
+        "python library and the `graphviz` system library.\n\n"
+        "Please either conda or pip install as follows:\n\n"
+        "  conda install python-graphviz     # either conda install\n"
+        "  python -m pip install graphviz    # or pip install and follow installation instructions",
+    )
+
     data_attributes = data_attributes or {}
     function_attributes = function_attributes or {}
     graph_attr = graph_attr or {}
@@ -306,3 +308,237 @@ def graphviz_to_file(g, filename, format):
         f.write(data)
 
     return display_cls(filename=full_filename)
+
+
+def _to_cytoscape_json(
+    dsk,
+    data_attributes=None,
+    function_attributes=None,
+    collapse_outputs=False,
+    verbose=False,
+    **kwargs,
+):
+    """
+    Convert a dask graph to Cytoscape JSON:
+    https://js.cytoscape.org/#notation/elements-json
+    """
+    nodes = []
+    edges = []
+    data = {"nodes": nodes, "edges": edges}
+
+    data_attributes = data_attributes or {}
+    function_attributes = function_attributes or {}
+
+    seen = set()
+    connected = set()
+
+    for k, v in dsk.items():
+        k_name = name(k)
+        if istask(v):
+            func_name = name((k, "function")) if not collapse_outputs else k_name
+            if collapse_outputs or func_name not in seen:
+                seen.add(func_name)
+                attrs = function_attributes.get(k, {}).copy()
+                nodes.append(
+                    {
+                        "data": {
+                            "id": func_name,
+                            "label": key_split(k),
+                            "shape": "ellipse",
+                            "color": "gray",
+                            **attrs,
+                        }
+                    }
+                )
+            if not collapse_outputs:
+                edges.append({"data": {"source": func_name, "target": k_name}})
+
+                connected.add(func_name)
+                connected.add(k_name)
+
+            for dep in get_dependencies(dsk, k):
+                dep_name = name(dep)
+                if dep_name not in seen:
+                    seen.add(dep_name)
+                    attrs = data_attributes.get(dep, {}).copy()
+                    nodes.append(
+                        {
+                            "data": {
+                                "id": dep_name,
+                                "label": box_label(dep, verbose),
+                                "shape": "rectangle",
+                                "color": "gray",
+                                **attrs,
+                            }
+                        }
+                    )
+                edges.append(
+                    {
+                        "data": {
+                            "source": dep_name,
+                            "target": func_name,
+                        }
+                    }
+                )
+                connected.add(dep_name)
+                connected.add(func_name)
+
+        elif ishashable(v) and v in dsk:
+            v_name = name(v)
+            edges.append(
+                {
+                    "data": {
+                        "source": v_name,
+                        "target": k_name,
+                    }
+                }
+            )
+            connected.add(v_name)
+            connected.add(k_name)
+
+        if (not collapse_outputs or k_name in connected) and k_name not in seen:
+            seen.add(k_name)
+            attrs = data_attributes.get(k, {}).copy()
+            nodes.append(
+                {
+                    "data": {
+                        "id": k_name,
+                        "label": box_label(k, verbose),
+                        "shape": "rectangle",
+                        "color": "gray",
+                        **attrs,
+                    }
+                }
+            )
+    return data
+
+
+def cytoscape_graph(
+    dsk,
+    filename: str | None = "mydask",
+    format: str | None = None,
+    *,
+    rankdir: str = "BT",
+    node_sep: float = 10,
+    edge_sep: float = 10,
+    spacing_factor: float = 1,
+    node_style: dict[str, str] | None = None,
+    edge_style: dict[str, str] | None = None,
+    **kwargs,
+):
+    """
+    Create an ipycytoscape widget for a dask graph.
+
+    If `filename` is not None, write an HTML file to disk with the specified name.
+
+    This uses the Cytoscape dagre layout algorithm. Options for that are documented here:
+    https://github.com/cytoscape/cytoscape.js-dagre#api
+
+    Parameters
+    ----------
+    dsk : dict
+        The graph to display.
+    filename : str or None, optional
+        The name of the HTML file to write to disk.
+    format : str, optional
+        Not used in this engine.
+    rankdir: str
+        The direction in which to orient the visualization.
+    node_sep: float
+        The separation (in px) between nodes in the DAG layout.
+    edge_sep: float
+        The separation (in px) between edges in the DAG layout.
+    spacing_factor: float
+        An overall scaling factor to increase (>1) or decrease (<1) the spacing
+        of the layout.
+    node_style: dict[str, str], optional
+        A dictionary of style attributes for nodes (refer to Cytoscape JSON docs
+        for available options: https://js.cytoscape.org/#notation/elements-json)
+    edge_style: dict[str, str], optional
+        A dictionary of style attributes for edges (refer to Cytoscape JSON docs
+        for available options: https://js.cytoscape.org/#notation/elements-json)
+    **kwargs
+        Additional keyword arguments to forward to `_to_cytoscape_json`.
+
+    Returns
+    -------
+    result : ipycytoscape.CytoscapeWidget
+    """
+    ipycytoscape = import_required(
+        "ipycytoscape",
+        "Drawing dask graphs with the cytoscape engine requires the `ipycytoscape` "
+        "python library.\n\n"
+        "Please either conda or pip install as follows:\n\n"
+        "  conda install ipycytoscape            # either conda install\n"
+        "  python -m pip install ipycytoscape    # or pip install",
+    )
+
+    node_style = node_style or {}
+    edge_style = edge_style or {}
+
+    data = _to_cytoscape_json(dsk, **kwargs)
+    # TODO: it's not easy to programmatically increase the height of the widget.
+    # Ideally we would make it a bit bigger, but that will probably require upstreaming
+    # some fixes.
+    g = ipycytoscape.CytoscapeWidget(
+        layout={"height": "400px"},
+    )
+    g.set_layout(
+        name="dagre",
+        rankDir=rankdir,
+        nodeSep=node_sep,
+        edgeSep=edge_sep,
+        spacingFactor=spacing_factor,
+        nodeDimensionsIncludeLabels=True,
+    )
+    g.graph.add_graph_from_json(
+        data,
+        directed=True,
+    )
+    g.set_style(
+        [
+            {
+                "selector": "node",
+                "style": {
+                    "font-family": "helvetica",
+                    "font-size": "24px",
+                    "font-weight": "bold",
+                    "color": "black",
+                    "background-color": "#eee",
+                    "border-color": "data(color)",
+                    "border-width": 4,
+                    "opacity": "1.0",
+                    "text-valign": "center",
+                    "text-halign": "center",
+                    "label": "data(label)",
+                    "shape": "data(shape)",
+                    "width": 64,
+                    "height": 64,
+                    **node_style,
+                },
+            },
+            {
+                "selector": "edge",
+                "style": {
+                    "width": 8,
+                    "line-color": "gray",
+                    "target-arrow-shape": "triangle",
+                    "target-arrow-color": "gray",
+                    "curve-style": "bezier",
+                    **edge_style,
+                },
+            },
+        ],
+    )
+    # Tweak the zoom sensitivity
+    z = g.zoom
+    g.max_zoom = z * 2.0
+    g.min_zoom = z / 10.0
+    g.wheel_sensitivity = 0.1
+
+    if filename is not None:
+        from ipywidgets.embed import embed_minimal_html
+
+        filename = filename if filename.endswith(".html") else filename + ".html"
+        embed_minimal_html(filename, views=[g], title="Dask task graph")
+    return g
diff --git a/dask/highlevelgraph.py b/dask/highlevelgraph.py
--- a/dask/highlevelgraph.py
+++ b/dask/highlevelgraph.py
@@ -11,7 +11,7 @@
 from dask import config
 from dask.base import clone_key, flatten, is_dask_collection
 from dask.core import keys_in_tasks, reverse_dict
-from dask.utils import ensure_dict, ensure_set, key_split, stringify
+from dask.utils import ensure_dict, ensure_set, import_required, key_split, stringify
 from dask.widgets import get_template
 
 
@@ -1149,7 +1149,16 @@ def to_graphviz(
     edge_attr=None,
     **kwargs,
 ):
-    from dask.dot import graphviz, label, name
+    from dask.dot import label, name
+
+    graphviz = import_required(
+        "graphviz",
+        "Drawing dask graphs with the graphviz visualization engine requires the `graphviz` "
+        "python library and the `graphviz` system library.\n\n"
+        "Please either conda or pip install as follows:\n\n"
+        "  conda install python-graphviz     # either conda install\n"
+        "  python -m pip install graphviz    # or pip install and follow installation instructions",
+    )
 
     data_attributes = data_attributes or {}
     function_attributes = function_attributes or {}


Test patch:
diff --git a/dask/tests/test_base.py b/dask/tests/test_base.py
--- a/dask/tests/test_base.py
+++ b/dask/tests/test_base.py
@@ -1009,6 +1009,7 @@ def test_compute_nested():
 )
 def test_visualize():
     pytest.importorskip("graphviz")
+    pytest.importorskip("ipycytoscape")
     with tmpdir() as d:
         x = da.arange(5, chunks=2)
         x.visualize(filename=os.path.join(d, "mydask"))
@@ -1028,6 +1029,20 @@ def test_visualize():
         visualize(x, filename=os.path.join(d, "mydask.png"))
         assert os.path.exists(os.path.join(d, "mydask.png"))
 
+        x = Tuple(dsk, ["a", "b", "c"])
+        visualize(x, filename=os.path.join(d, "cyt"), engine="cytoscape")
+        assert os.path.exists(os.path.join(d, "cyt.html"))
+
+        visualize(x, filename=os.path.join(d, "cyt2.html"), engine="ipycytoscape")
+        assert os.path.exists(os.path.join(d, "cyt2.html"))
+
+        with dask.config.set(visualization__engine="cytoscape"):
+            visualize(x, filename=os.path.join(d, "cyt3.html"))
+            assert os.path.exists(os.path.join(d, "cyt3.html"))
+
+        with pytest.raises(ValueError, match="not-real"):
+            visualize(x, engine="not-real")
+
         # To see if visualize() works when the filename parameter is set to None
         # If the function raises an error, the test will fail
         x.visualize(filename=None)
diff --git a/dask/tests/test_dot.py b/dask/tests/test_dot.py
--- a/dask/tests/test_dot.py
+++ b/dask/tests/test_dot.py
@@ -7,6 +7,9 @@
 
 import pytest
 
+ipycytoscape = pytest.importorskip("ipycytoscape")
+from dask.dot import _to_cytoscape_json, cytoscape_graph
+
 if sys.flags.optimize != 2:
     pytest.importorskip("graphviz")
     from dask.dot import dot_graph, label, task_label, to_graphviz
@@ -108,6 +111,41 @@ def test_to_graphviz_custom():
     assert set(shapes) == {"box", "circle", "square", "ellipse"}
 
 
+def test_cytoscape_graph_custom():
+    # Check that custom styling and layout propagates through
+    g = cytoscape_graph(
+        dsk,
+        rankdir="LR",
+        node_sep=20,
+        edge_sep=30,
+        spacing_factor=2,
+        edge_style={"line-color": "red"},
+        node_style={"background-color": "green"},
+    )
+    sty = g.cytoscape_style
+    layout = g.cytoscape_layout
+    node_sty = next(s for s in sty if s["selector"] == "node")["style"]
+    edge_sty = next(s for s in sty if s["selector"] == "edge")["style"]
+
+    assert edge_sty["line-color"] == "red"
+    assert node_sty["background-color"] == "green"
+    assert layout["rankDir"] == "LR"
+    assert layout["nodeSep"] == 20
+    assert layout["edgeSep"] == 30
+    assert layout["spacingFactor"] == 2
+
+
+def test_cytoscape_graph_color():
+    pytest.importorskip("matplotlib.pyplot")
+    from dask.delayed import Delayed
+
+    g = Delayed("f", dsk).visualize(engine="cytoscape")
+    init_color = g.graph.nodes[0].data["color"]
+    # Check that we changed the semantic color
+    g = Delayed("f", dsk).visualize(engine="cytoscape", color="order")
+    assert any(n.data["color"] != init_color for n in g.graph.nodes)
+
+
 def test_to_graphviz_attributes():
     assert to_graphviz(dsk).graph_attr["rankdir"] == "BT"
     assert to_graphviz(dsk, rankdir="LR").graph_attr["rankdir"] == "LR"
@@ -131,6 +169,15 @@ def test_to_graphviz_verbose():
     assert set(shapes) == {"box", "circle"}
 
 
+def test__to_cytoscape_json_verbose():
+    data = _to_cytoscape_json(dsk, verbose=True)
+    labels = list(map(lambda x: x["data"]["label"], data["nodes"]))
+    assert len(labels) == 10
+    assert set(labels) == {"a", "b", "c", "d", "e", "f"}
+    shapes = list(map(lambda x: x["data"]["shape"], data["nodes"]))
+    assert set(shapes) == {"ellipse", "rectangle"}
+
+
 def test_to_graphviz_collapse_outputs():
     g = to_graphviz(dsk, collapse_outputs=True)
     labels = list(filter(None, map(get_label, g.body)))
@@ -140,6 +187,15 @@ def test_to_graphviz_collapse_outputs():
     assert set(shapes) == {"box", "circle"}
 
 
+def test__to_cytoscape_json_collapse_outputs():
+    data = _to_cytoscape_json(dsk, collapse_outputs=True)
+    labels = list(map(lambda x: x["data"]["label"], data["nodes"]))
+    assert len(labels) == 6  # 6 nodes total
+    assert set(labels) == {"c", "d", "e", "f", ""}
+    shapes = list(map(lambda x: x["data"]["shape"], data["nodes"]))
+    assert set(shapes) == {"ellipse", "rectangle"}
+
+
 def test_to_graphviz_collapse_outputs_and_verbose():
     g = to_graphviz(dsk, collapse_outputs=True, verbose=True)
     labels = list(filter(None, map(get_label, g.body)))
@@ -149,9 +205,19 @@ def test_to_graphviz_collapse_outputs_and_verbose():
     assert set(shapes) == {"box", "circle"}
 
 
+def test__to_cytoscape_json_collapse_outputs_and_verbose():
+    data = _to_cytoscape_json(dsk, collapse_outputs=True, verbose=True)
+    labels = list(map(lambda x: x["data"]["label"], data["nodes"]))
+    assert len(labels) == 6  # 6 nodes total
+    assert set(labels) == {"a", "b", "c", "d", "e", "f"}
+    shapes = list(map(lambda x: x["data"]["shape"], data["nodes"]))
+    assert set(shapes) == {"ellipse", "rectangle"}
+
+
 def test_to_graphviz_with_unconnected_node():
-    dsk["g"] = 3
-    g = to_graphviz(dsk, verbose=True)
+    dsk2 = dsk.copy()
+    dsk2["g"] = 3
+    g = to_graphviz(dsk2, verbose=True)
     labels = list(filter(None, map(get_label, g.body)))
     assert len(labels) == 11  # 11 nodes total
     assert set(labels) == {"a", "b", "c", "d", "e", "f", "g"}
@@ -251,6 +317,17 @@ def test_dot_graph_defaults():
         ensure_not_exists(target)
 
 
+def test_cytoscape_graph(tmpdir):
+    target = str(tmpdir.join("mydask.html"))
+    ensure_not_exists(target)
+    try:
+        result = cytoscape_graph(dsk, target)
+        assert os.path.isfile(target)
+        assert isinstance(result, ipycytoscape.CytoscapeWidget)
+    finally:
+        ensure_not_exists(target)
+
+
 @pytest.mark.parametrize(
     "filename,format,target,expected_result_type",
     [
@@ -294,7 +371,8 @@ def f(x, y=True):
     assert "apply" not in label
 
 
-def test_immutable_attributes():
+@pytest.mark.parametrize("viz_func", [to_graphviz, _to_cytoscape_json])
+def test_immutable_attributes(viz_func):
     def inc(x):
         return x + 1
 
@@ -304,7 +382,7 @@ def inc(x):
     attrs_func_test = copy.deepcopy(attrs_func)
     attrs_data_test = copy.deepcopy(attrs_data)
 
-    to_graphviz(
+    viz_func(
         dsk,
         function_attributes=attrs_func,
         data_attributes=attrs_data,