Repository: dask/dask
Pull request: Visualize task graphs using ipycytoscape
This adds a new graph visualization engine based on `ipycytoscape`. Right now it's pretty much a re-implementation of the `graphviz` visualizer, but with zooming and panning. However, there are a few things that this could be more useful for in the near future.
1. The ability to run in pyodide without compiling `graphviz` to WASM (cf. #9053)
2. More interactivity, especially with different coloring schemes and layouts.
3. The possibility to add richer tooltips/popups (cf. #9069)

![cytoscape](https://user-images.githubusercontent.com/5728311/168489641-7039d5bd-8a08-4ae9-994d-bc04e9f294d5.gif)

- [x] Closes #7301
- [x] Tests added / passed
- [x] Passes `pre-commit run --all-files`

Problem statement:
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.

Hints from issue 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.

Commit message: Set up test infra

Patch to review:
diff --git a/continuous_integration/environment-3.10.yaml b/continuous_integration/environment-3.10.yaml
index 92ad6f2f01a..8fbc60237fe 100644
--- a/continuous_integration/environment-3.10.yaml
+++ b/continuous_integration/environment-3.10.yaml
@@ -49,6 +49,7 @@ dependencies:
   - cytoolz
   - distributed
   - ipython
+  - ipycytoscape
   - lz4
   - numba
   - partd
diff --git a/continuous_integration/environment-3.8.yaml b/continuous_integration/environment-3.8.yaml
index ea77f124e1e..d9a3f43710c 100644
--- a/continuous_integration/environment-3.8.yaml
+++ b/continuous_integration/environment-3.8.yaml
@@ -48,6 +48,7 @@ dependencies:
   - cytoolz
   - distributed
   - ipython
+  - ipycytoscape
   - lz4
   - numba
   - partd
diff --git a/continuous_integration/environment-3.9.yaml b/continuous_integration/environment-3.9.yaml
index 7dace6cabd3..e3906917a19 100644
--- a/continuous_integration/environment-3.9.yaml
+++ b/continuous_integration/environment-3.9.yaml
@@ -49,6 +49,7 @@ dependencies:
   - cytoolz
   - distributed
   - ipython
+  - ipycytoscape
   - lz4
   - numba
   - partd
diff --git a/dask/base.py b/dask/base.py
index 49ed4444f9b..72b93af5768 100644
--- 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,
+    visualizer: 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.
+    visualizer : {"graphviz", "ipycytoscape", "cytoscape"}, optional.
+        The visualization engine to use. If not provided, this checks the dask config
+        value "visualizer". 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 visualizer.
 
     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,34 @@ def label(x):
     elif color:
         raise NotImplementedError("Unknown value color=%s" % color)
 
-    return dot_graph(dsk, filename=filename, **kwargs)
+    # Determine which visualizer to dispatch to, first checking the kwarg, then config,
+    # then whichever of graphviz or ipycytoscape are installed, in that order.
+    visualizer = visualizer or config.get("visualizer", None)
+
+    if not visualizer:
+        try:
+            import graphviz  # noqa: F401
+
+            visualizer = "graphviz"
+        except ImportError:
+            pass
+        try:
+            import ipycytoscape  # noqa: F401
+
+            visualizer = "cytoscape"
+        except ImportError:
+            pass
+
+    if visualizer == "graphviz":
+        from dask.dot import dot_graph
+
+        return dot_graph(dsk, filename=filename, **kwargs)
+    elif visualizer in ("cytoscape", "ipycytoscape"):
+        from dask.dot import cytoscape_graph
+
+        return cytoscape_graph(dsk, filename=filename, **kwargs)
+    else:
+        raise ValueError(f"Visualizer {visualizer} not recognized")
 
 
 def persist(*args, traverse=True, optimize_graph=True, scheduler=None, **kwargs):
diff --git a/dask/dask-schema.yaml b/dask/dask-schema.yaml
index ab747f199a2..64362613762 100644
--- a/dask/dask-schema.yaml
+++ b/dask/dask-schema.yaml
@@ -10,6 +10,15 @@ properties:
       When the value is "null" (default), dask will create a directory from
       where dask was launched: `cwd/dask-worker-space`
 
+  visualizer:
+    type:
+      - string
+      - 'null'
+    description: |
+      Visualization engine to use when calling ``.visualize()`` on a Dask collection.
+      Currently supports ``'graphviz'``, ``'ipycytoscape'``, and ``'cytoscape'``
+      (alias for ``'ipycytoscape'``)
+
   tokenize:
     type: object
     properties:
diff --git a/dask/dask.yaml b/dask/dask.yaml
index 3f5b4f365c8..6a8a7a1e543 100644
--- a/dask/dask.yaml
+++ b/dask/dask.yaml
@@ -1,5 +1,7 @@
 temporary-directory: null  # Directory for local disk like /tmp, /scratch, or /local
 
+visualizer: graphviz  # Default visualizer to use when calling `.visualize()` on a collection
+
 tokenize:
   ensure-deterministic: false  # If true, tokenize will error instead of falling back to uuids
 
diff --git a/dask/dot.py b/dask/dot.py
index 160e5a7d63d..f5a84ed8051 100644
--- 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 visualizer 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 visualizer.
+    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 visualizer 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
index 1672e8eb1c1..755296eb605 100644
--- 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 visualizer 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 {}
diff --git a/dask/tests/test_base.py b/dask/tests/test_base.py
index a72aefcb373..df08d6cce5c 100644
--- 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,17 @@ 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"), visualizer="cytoscape")
+        assert os.path.exists(os.path.join(d, "cyt.html"))
+
+        visualize(x, filename=os.path.join(d, "cyt2.html"), visualizer="ipycytoscape")
+        assert os.path.exists(os.path.join(d, "cyt2.html"))
+
+        with dask.config.set(visualizer="cytoscape"):
+            visualize(x, filename=os.path.join(d, "cyt3.html"))
+            assert os.path.exists(os.path.join(d, "cyt3.html"))
+
         # 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
index 93d46aed092..cb3423aaa6b 100644
--- 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 cytoscape_graph, to_cytoscape_json
+
 if sys.flags.optimize != 2:
     pytest.importorskip("graphviz")
     from dask.dot import dot_graph, label, task_label, to_graphviz
@@ -108,6 +111,40 @@ 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():
+    from dask.delayed import Delayed
+
+    g = Delayed("f", dsk).visualize(visualizer="cytoscape")
+    init_color = g.graph.nodes[0].data["color"]
+    # Check that we changed the semantic color
+    g = Delayed("f", dsk).visualize(visualizer="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 +168,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 +186,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 +204,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 +316,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 +370,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 +381,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,


Code review comments:
diff_hunk: @@ -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,
+    visualizer: Literal["cytoscape", "ipycytoscape", "graphviz"] | None = None,
path: dask/base.py
text: @user1:
Thoughts on using `engine=` instead of `visualizer=`? `engine` would provide more consistency with other APIs (e.g. `read_parquet`)

@author:
Sure, I have no problem with that.

@author:
Thoughts on the config name in this case? `visualization-engine`? Or nested
```
visualize:
    engine: graphviz
```

@user1:
Nested seems nicer to me

diff_hunk: @@ -306,3 +308,237 @@ def graphviz_to_file(g, filename, format):
         f.write(data)
 
     return display_cls(filename=full_filename)
+
+
+def to_cytoscape_json(
path: dask/dot.py
text: @user1:
Given `dot` is the CLI for GraphViz, wonder if we should put this in a different module for organizational clarity

@author:
Yeah, I waffled on whether to create a new module for this, but decided to keep them in the same module since they share some of the naming utilities.

What about changing this to `dask/visualize.py` (with perhaps a `dot.py` backwards compatibility shim)?

diff_hunk: @@ -306,3 +308,237 @@ def graphviz_to_file(g, filename, format):
         f.write(data)
 
     return display_cls(filename=full_filename)
+
+
+def to_cytoscape_json(
path: dask/dot.py
text: @user1:
Is this intended to be a public method?

@author:
Not really, I can prefix it with a `_`