Repository: pybel/pybel
Pull request #397 (2019-11-15 13:16:14)

Add pybel-jupyter to main repo

codecov[bot]: # [Codecov](https://codecov.io/gh/pybel/pybel/pull/397?src=pr&el=h1) Report
> :exclamation: No coverage uploaded for pull request base (`master@b377e3d`). [Click here to learn what that means](https://docs.codecov.io/docs/error-reference#section-missing-base-commit).
> The diff coverage is `93.75%`.

[![Impacted file tree graph](https://codecov.io/gh/pybel/pybel/pull/397/graphs/tree.svg?width=650&token=J7joRTRygG&height=150&src=pr)](https://codecov.io/gh/pybel/pybel/pull/397?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##             master     #397   +/-   ##
=========================================
  Coverage          ?   87.32%           
=========================================
  Files             ?      144           
  Lines             ?     6983           
  Branches          ?      938           
=========================================
  Hits              ?     6098           
  Misses            ?      673           
  Partials          ?      212
```


| [Impacted Files](https://codecov.io/gh/pybel/pybel/pull/397?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/pybel/io/jupyter/constants.py](https://codecov.io/gh/pybel/pybel/pull/397/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL2lvL2p1cHl0ZXIvY29uc3RhbnRzLnB5) | `100% <100%> (ø)` | |
| [src/pybel/io/jinja\_utils.py](https://codecov.io/gh/pybel/pybel/pull/397/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL2lvL2ppbmphX3V0aWxzLnB5) | `100% <100%> (ø)` | |
| [src/pybel/io/jupyter/\_\_init\_\_.py](https://codecov.io/gh/pybel/pybel/pull/397/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL2lvL2p1cHl0ZXIvX19pbml0X18ucHk=) | `100% <100%> (ø)` | |
| [src/pybel/io/jupyter/visualization.py](https://codecov.io/gh/pybel/pybel/pull/397/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL2lvL2p1cHl0ZXIvdmlzdWFsaXphdGlvbi5weQ==) | `85.71% <85.71%> (ø)` | |
| [src/pybel/io/jupyter/inline.py](https://codecov.io/gh/pybel/pybel/pull/397/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL2lvL2p1cHl0ZXIvaW5saW5lLnB5) | `95% <95%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/pybel/pybel/pull/397?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/pybel/pybel/pull/397?src=pr&el=footer). Last update [b377e3d...6588057](https://codecov.io/gh/pybel/pybel/pull/397?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/README.rst b/README.rst
index 862da96e..8567cdb3 100644
--- a/README.rst
+++ b/README.rst
@@ -22,7 +22,7 @@ Citation
 If you find PyBEL useful for your work, please consider citing:
 
 .. [1] Hoyt, C. T., *et al.* (2017). `PyBEL: a Computational Framework for Biological Expression Language
-       <https://doi.org/10.1093/bioinformatics/btx660>`_. Bioinformatics, 34(December), 1–2.
+       <https://doi.org/10.1093/bioinformatics/btx660>`_. *Bioinformatics*, 34(December), 1–2.
 
 Getting Started
 ---------------
@@ -65,13 +65,13 @@ or from the latest code on `GitHub <https://github.com/pybel/pybel>`_ with:
     $ pip install git+https://github.com/pybel/pybel.git
 
 See the `installation documentation <http://pybel.readthedocs.io/en/latest/installation.html>`_ for more advanced
-instructions. Also, check the change log at `CHANGELOG.rst <https://github.com/pybel/pybel/blob/develop/CHANGELOG.rst>`_.
+instructions. Also, check the change log at `CHANGELOG.rst <https://github.com/pybel/pybel/blob/master/CHANGELOG.rst>`_.
 
 Contributing
 ------------
 Contributions, whether filing an issue, making a pull request, or forking, are appreciated. See
-`CONTRIBUTING.rst <https://github.com/pybel/pybel/blob/develop/CONTRIBUTING.rst>`_ for more information on getting
-involved. Please add your name to `AUTHORS.rst <https://github.com/pybel/pybel/blob/develop/AUTHORS.rst>`_!
+`CONTRIBUTING.rst <https://github.com/pybel/pybel/blob/master/CONTRIBUTING.rst>`_ for more information on getting
+involved. Please add your name to `AUTHORS.rst <https://github.com/pybel/pybel/blob/master/AUTHORS.rst>`_!
 
 Acknowledgements
 ----------------
@@ -121,8 +121,8 @@ Links
     :alt: Current version on PyPI
 
 .. |pypi_license| image:: https://img.shields.io/pypi/l/PyBEL.svg
-    :target: https://github.com/pybel/pybel/blob/develop/LICENSE
-    :alt: Apache 2.0 License
+    :target: https://github.com/pybel/pybel/blob/master/LICENSE
+    :alt: MIT License
 
 .. |zenodo| image:: https://zenodo.org/badge/68376693.svg
     :target: https://zenodo.org/badge/latestdoi/68376693
diff --git a/setup.cfg b/setup.cfg
index 36d579d2..06c22c8c 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -76,6 +76,9 @@ where = src
 [options.extras_require]
 indra =
     indra
+jupyter =
+    jinja2
+    ipython
 neo4j =
     py2neo
 docs =
diff --git a/src/pybel/io/jinja_utils.py b/src/pybel/io/jinja_utils.py
new file mode 100644
index 00000000..6a18de9a
--- /dev/null
+++ b/src/pybel/io/jinja_utils.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+
+"""Utilities for Jinja2 templating."""
+
+import os
+
+from jinja2 import Environment, FileSystemLoader
+
+__all__ = [
+    'build_template_environment',
+    'build_template_renderer',
+]
+
+
+def build_template_environment(here: str) -> Environment:
+    """Build a custom templating environment so Flask apps can get data from lots of different places.
+
+    :param here: Give this the result of :code:`os.path.dirname(os.path.abspath(__file__))`
+    """
+    loader = FileSystemLoader(os.path.join(here, 'templates'))
+    environment = Environment(
+        autoescape=True,
+        loader=loader,
+        trim_blocks=False,
+    )
+    environment.globals['STATIC_PREFIX'] = here + '/static/'
+    return environment
+
+
+def build_template_renderer(path: str):
+    """Build a render template function.
+
+    :param path: The location of the current file. Pass it :code:`__file__` like in the example below.
+
+    >>> render_template = build_template_renderer(__file__)
+    """
+    here = os.path.dirname(os.path.abspath(path))
+    template_environment = build_template_environment(here)
+
+    def render_template_enclosure(template_filename: str, **context) -> str:
+        """Render a template as a unicode string.
+
+        :param template_filename: The name of the file to render in the template directory
+        :param dict context: The variables to template
+        """
+        return template_environment.get_template(template_filename).render(context)
+
+    render_template_enclosure.environment = template_environment
+    return render_template_enclosure
diff --git a/src/pybel/io/jupyter/__init__.py b/src/pybel/io/jupyter/__init__.py
new file mode 100644
index 00000000..e5d96334
--- /dev/null
+++ b/src/pybel/io/jupyter/__init__.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+
+"""Support for displaying BEL graphs in Jupyter notebooks."""
+
+from .inline import to_jupyter, to_jupyter_str
+from .visualization import to_html, to_html_file, to_html_path
+
+__all__ = [
+    'to_html',
+    'to_html_file',
+    'to_html_path',
+    'to_jupyter',
+    'to_jupyter_str',
+]
diff --git a/src/pybel/io/jupyter/constants.py b/src/pybel/io/jupyter/constants.py
new file mode 100644
index 00000000..ebd2c64d
--- /dev/null
+++ b/src/pybel/io/jupyter/constants.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+
+"""Constants for PyBEL-Jupyter."""
+
+from ...constants import ABUNDANCE, BIOPROCESS, COMPLEX, COMPOSITE, GENE, MIRNA, PATHOLOGY, PROTEIN, REACTION, RNA
+
+#: The color map defining the node colors in visualization
+DEFAULT_COLOR_MAP = {
+    PROTEIN: "#1F77B4",
+    PATHOLOGY: "#FF7F0E",
+    BIOPROCESS: "#2CA02C",
+    MIRNA: "#D62728",
+    COMPLEX: "#98DF8A",
+    COMPOSITE: "#9467BD",
+    REACTION: "#000000",
+    GENE: "#FFBB78",
+    ABUNDANCE: "#AEC7E8",
+    RNA: "#FF9896",
+}
diff --git a/src/pybel/io/jupyter/inline.py b/src/pybel/io/jupyter/inline.py
new file mode 100644
index 00000000..201e11bf
--- /dev/null
+++ b/src/pybel/io/jupyter/inline.py
@@ -0,0 +1,83 @@
+# -*- coding: utf-8 -*-
+
+"""Utilities for displaying graphs with inline HTML in Jupyter Notebooks."""
+
+from random import sample
+from typing import Mapping, Optional
+
+from IPython.display import Javascript
+
+from .constants import DEFAULT_COLOR_MAP
+from ..jinja_utils import build_template_renderer
+from ..nodelink import to_nodelink_jsons
+from ...struct import BELGraph
+
+__all__ = [
+    'to_jupyter',
+    'to_jupyter_str'
+]
+
+DEFAULT_WIDTH = 1000
+DEFAULT_HEIGHT = 650
+
+#: Renders templates from pybel.io.jupyter.templates folder
+render_template = build_template_renderer(__file__)
+
+
+def _generate_id() -> str:
+    """Generate a random string of letters."""
+    return "".join(sample('abcdefghjkmopqrstuvqxyz', 16))
+
+
+def to_jupyter(
+    graph: BELGraph,
+    width: int = DEFAULT_WIDTH,
+    height: int = DEFAULT_HEIGHT,
+    color_map: Optional[Mapping[str, str]] = None,
+) -> Javascript:
+    """Display a BEL graph inline in a Jupyter notebook.
+
+    To use successfully, make run as the last statement in a cell inside a Jupyter notebook.
+
+    :param graph: A BEL graph
+    :param width: The width of the visualization window to render
+    :param height: The height of the visualization window to render
+    :param color_map: A dictionary from PyBEL internal node functions to CSS color strings like #FFEE00. Defaults
+                    to :data:`default_color_map`
+    :return: An IPython notebook Javascript object
+    :rtype: :class:`IPython.display.Javascript`
+    """
+    return Javascript(to_jupyter_str(
+        graph,
+        width=width,
+        height=height,
+        color_map=color_map,
+    ))
+
+
+def to_jupyter_str(
+    graph: BELGraph,
+    width: int = DEFAULT_WIDTH,
+    height: int = DEFAULT_HEIGHT,
+    color_map: Optional[Mapping[str, str]] = None,
+) -> str:
+    """Return the string to be javascript-ified by the Jupyter notebook function :class:`IPython.display.Javascript`.
+
+    :param graph: A BEL graph
+    :param width: The width of the visualization window to render
+    :param height: The height of the visualization window to render
+    :param color_map: A dictionary from PyBEL internal node functions to CSS color strings like #FFEE00. Defaults
+                    to :data:`default_color_map`
+    :return: The javascript string to turn into magic
+    """
+    gjson = to_nodelink_jsons(graph)
+    chart_id = _generate_id()
+
+    return render_template(
+        'pybel_jupyter.js',
+        graph=gjson,
+        chart=chart_id,
+        width=width,
+        height=height,
+        color_map=(color_map or DEFAULT_COLOR_MAP),
+    )
diff --git a/src/pybel/io/jupyter/templates/graph_template.html b/src/pybel/io/jupyter/templates/graph_template.html
new file mode 100644
index 00000000..da69812c
--- /dev/null
+++ b/src/pybel/io/jupyter/templates/graph_template.html
@@ -0,0 +1,25 @@
+<!DOCTYPE html >
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
+    <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.5.0/d3.min.js"></script>
+    <script type="text/javascript">
+        {% include "pybel_vis.js" %}
+    </script>
+    <script type="text/javascript">
+        graph_data = {{ json }};
+
+        $(document).ready(function () {
+            w = $("#graph-chart").width();
+            g = document.getElementsByTagName('body')[0];
+            h = w.innerHeight || document.documentElement.clientHeight || g.clientHeight;
+            color_map = {{ cmap }};
+            init_d3_force(d3, graph_data, "#graph-chart", w, h, color_map);
+        });
+    </script>
+</head>
+<body>
+<div id="graph-chart"></div>
+</body>
+</html>
diff --git a/src/pybel/io/jupyter/templates/pybel_jupyter.js b/src/pybel/io/jupyter/templates/pybel_jupyter.js
new file mode 100644
index 00000000..09a5028d
--- /dev/null
+++ b/src/pybel/io/jupyter/templates/pybel_jupyter.js
@@ -0,0 +1,15 @@
+{% include "pybel_vis.js" %}
+
+require.config({
+    paths: {
+        d3: '//cdnjs.cloudflare.com/ajax/libs/d3/4.5.0/d3.min'
+    }
+});
+
+var elementInnerHTML = "<div id='{{ chart }}'></div>";
+
+element.append(elementInnerHTML);
+
+require(['d3'], function (d3) {
+    return init_d3_force(d3, {{ graph}}, "#{{ chart }}", {{ width }}, {{ height }}, {{ color_map  }});
+});
diff --git a/src/pybel/io/jupyter/templates/pybel_vis.js b/src/pybel/io/jupyter/templates/pybel_vis.js
new file mode 100644
index 00000000..55e81426
--- /dev/null
+++ b/src/pybel/io/jupyter/templates/pybel_vis.js
@@ -0,0 +1,307 @@
+function init_d3_force(d3, graph, chart, width, height, function_colors) {
+    var focus_node = null;
+    var highlight_node = null;
+
+    // Highlight color variables
+
+    // Highlight color of the node boundering
+    const highlight_node_boundering = "#4EB2D4";
+
+    // Highlight color of the edge
+    const highlighted_link_color = "#4EB2D4";
+
+    // Text highlight color
+    const highlight_text = "#4EB2D4";
+
+    // Size when zooming scale
+    var size = d3.scalePow().exponent(1)
+        .domain([1, 100])
+        .range([8, 24]);
+
+    // Simulation parameters
+    const linkDistance = 100;
+    const fCharge = -1000;
+    const linkStrength = 0.7;
+    const collideStrength = 1;
+
+    // Simulation defined with variables
+    var simulation = d3.forceSimulation()
+        .force("link", d3.forceLink()
+            .distance(linkDistance)
+            .strength(linkStrength)
+        )
+        .force("collide", d3.forceCollide()
+            .radius(function (d) {
+                return d.r + 10
+            })
+            .strength(collideStrength)
+        )
+        .force("charge", d3.forceManyBody()
+            .strength(fCharge)
+        )
+        .force("center", d3.forceCenter(width / 2, height / 2))
+        .force("y", d3.forceY(0))
+        .force("x", d3.forceX(0));
+
+    // Pin down functionality
+    var node_drag = d3.drag()
+        .on("start", dragstarted)
+        .on("drag", dragged)
+        .on("end", dragended);
+
+    function dragstarted(d) {
+        if (!d3.event.active) simulation.alphaTarget(0.3).restart();
+        d.fx = d.x;
+        d.fy = d.y;
+    }
+
+    function dragged(d) {
+        d.fx = d3.event.x;
+        d.fy = d3.event.y;
+    }
+
+    function dragended(d) {
+        if (!d3.event.active) simulation.alphaTarget(0);
+    }
+
+    function releasenode(d) {
+        d.fx = null;
+        d.fy = null;
+    }
+
+    //END Pin down functionality
+
+
+    /**
+     * Gets the best name for a node object
+     * @param {object} d object
+     * @returns {str} canonical name of the node
+     */
+    function getCanonicalName(d) {
+        if (d.name && !(d.variants || d.reactants || d.products || d.members)) {
+            return d.name
+        } else if (d.bel) {
+            return d.bel
+        } else {
+            console.log('Undefined node: ' + d);
+            return 'UNDEFINED'
+        }
+    }
+
+    const color_circunferencia = "black";
+    const default_link_color = "#AAAAAA";
+    const nominal_base_node_size = 8;
+
+    // Normal and highlighted stroke of the links (double the width of the link when highlighted)
+    const nominal_stroke = 1.5;
+
+    // Zoom variables
+    const min_zoom = 0.1;
+    const max_zoom = 10;
+
+    var svg = d3.select(chart).append("svg")
+        .attr("width", width)
+        .attr("height", height);
+
+    // // Create definition for arrowhead.
+    svg.append("defs").append("marker")
+        .attr("id", "arrowhead")
+        .attr("viewBox", "0 -5 10 10")
+        .attr("refX", 20)
+        .attr("refY", 0)
+        .attr("markerUnits", "strokeWidth")
+        .attr("markerWidth", 6)
+        .attr("markerHeight", 6)
+        .attr("orient", "auto")
+        .append("path")
+        .attr("d", "M0,-5L10,0L0,5");
+
+    // // Create definition for stub.
+    svg.append("defs").append("marker")
+        .attr("id", "stub")
+        .attr("viewBox", "-1 -5 2 10")
+        .attr("refX", 15)
+        .attr("refY", 0)
+        .attr("markerUnits", "strokeWidth")
+        .attr("markerWidth", 6)
+        .attr("markerHeight", 6)
+        .attr("orient", "auto")
+        .append("path")
+        .attr("d", "M 0,0 m -1,-5 L 1,-5 L 1,5 L -1,5 Z");
+
+    // Background
+    svg.append("rect")
+        .attr("width", "100%")
+        .attr("height", "100%")
+        .attr("fill", "#ffffff")
+        .style("pointer-events", "all")
+        // Zoom + panning functionality
+        .call(d3.zoom()
+            .scaleExtent([min_zoom, max_zoom])
+            .on("zoom", zoomed))
+        .on("dblclick.zoom", null);
+
+
+    function zoomed() {
+        g.attr("transform", d3.event.transform);
+    }
+
+    // g = svg object where the graph will be appended
+    var g = svg.append("g");
+
+    var linkedByIndex = {};
+    graph.links.forEach(function (d) {
+        linkedByIndex[d.source + "," + d.target] = true;
+    });
+
+    function isConnected(a, b) {
+        return linkedByIndex[a.index + "," + b.index] || linkedByIndex[b.index + "," + a.index] || a.index == b.index;
+    }
+
+    function ticked() {
+        link.attr("x1", function (d) {
+            return d.source.x;
+        })
+            .attr("y1", function (d) {
+                return d.source.y;
+            })
+            .attr("x2", function (d) {
+                return d.target.x;
+            })
+            .attr("y2", function (d) {
+                return d.target.y;
+            });
+
+        node
+            .attr("transform", function (d) {
+                return "translate(" + d.x + ", " + d.y + ")";
+            });
+    }
+
+
+    simulation
+        .nodes(graph.nodes)
+        .on("tick", ticked);
+
+    simulation.force("link")
+        .links(graph.links);
+
+    // Definition of links nodes text...
+
+    var link = g.selectAll(".link")
+        .data(graph.links)
+        .enter().append("line")
+        .style("stroke-width", nominal_stroke)
+        .style("stroke", default_link_color)
+        .style("stroke-dasharray", function (d) {
+            if (['decreases', 'directlyDecreases', 'increases', 'directlyIncreases', 'negativeCorrelation',
+                'positiveCorrelation'].indexOf(d.relation) >= 0) {
+                return "none"
+            } else {
+                return "4, 4"
+            }
+        })
+        .attr("marker-start", function (d) {
+            if ('positiveCorrelation' == d.relation) {
+                return "url(#arrowhead)"
+            }
+            else if ('negativeCorrelation' == d.relation) {
+                return "url(#stub)"
+            }
+            else {
+                return ""
+            }
+        })
+        .attr("marker-end", function (d) {
+            if (['increases', 'directlyIncreases', 'positiveCorrelation', 'isA', 'partOf'].indexOf(d.relation) >= 0) {
+                return "url(#arrowhead)"
+            } else if (['decreases', 'directlyDecreases', 'negativeCorrelation'].indexOf(d.relation) >= 0) {
+                return "url(#stub)"
+            } else {
+                return ""
+            }
+        });
+
+    var node = g.selectAll(".nodes")
+        .data(graph.nodes)
+        .enter().append("g")
+        .attr("class", "node")
+        // Next two lines -> Pin down functionality
+        .on('dblclick', releasenode)
+        .call(node_drag);
+
+    var circle = node.append("path")
+        .attr("d", d3.symbol()
+            .size(function (d) {
+                return Math.PI * Math.pow(size(d.size) || nominal_base_node_size, 2);
+            })
+        )
+        .attr("class", function (d) {
+            return d.function
+        })
+        .style('fill', function (d) {
+            return function_colors[d.function]
+        })
+        .style("stroke-width", nominal_stroke)
+        .style("stroke", color_circunferencia);
+
+    var text = node.append("text")
+        .attr("class", "node-name")
+        // .attr("id", nodehashes[d])
+        .attr("fill", "black")
+        .attr("dx", 12)
+        .attr("dy", ".35em")
+        .text(function (d) {
+            return getCanonicalName(d)
+        });
+
+    // Highlight on mouseenter and back to normal on mouseout
+    node.on("mouseenter", function (d) {
+        set_highlight(d);
+    })
+        .on("mousedown", function () {
+            d3.event.stopPropagation();
+        }).on("mouseout", function () {
+        exit_highlight();
+    });
+
+    function exit_highlight() {
+        highlight_node = null;
+        if (focus_node === null) {
+            if (highlight_node_boundering != color_circunferencia) {
+                circle.style("stroke", color_circunferencia);
+                text.style("fill", "black");
+                link.style("stroke", default_link_color);
+            }
+        }
+    }
+
+    function set_highlight(d) {
+        if (focus_node !== null) d = focus_node;
+        highlight_node = d;
+
+        if (highlight_node_boundering != color_circunferencia) {
+            circle.style("stroke", function (o) {
+                return isConnected(d, o) ? highlight_node_boundering : color_circunferencia;
+            });
+            text.style("fill", function (o) {
+                return isConnected(d, o) ? highlight_text : "black";
+            });
+            link.style("stroke", function (o) {
+                return o.source.index == d.index || o.target.index == d.index ? highlighted_link_color : default_link_color;
+            });
+        }
+    }
+
+
+    // Freeze the graph when space is pressed
+    function freezeGraph() {
+        // Space button Triggers STOP
+        if (d3.event.keyCode == 32) {
+            simulation.stop();
+        }
+    }
+
+    // Call freezeGraph when a key is pressed, freezeGraph checks whether this key is "Space" that triggers the freeze
+    d3.select(window).on("keydown", freezeGraph);
+}
diff --git a/src/pybel/io/jupyter/visualization.py b/src/pybel/io/jupyter/visualization.py
new file mode 100644
index 00000000..e013b879
--- /dev/null
+++ b/src/pybel/io/jupyter/visualization.py
@@ -0,0 +1,79 @@
+# -*- coding: utf-8 -*-
+
+"""Convert BEL graphs to HTML.
+
+This module provides functions for making HTML visualizations of BEL Graphs. Because the :class:`pybel.BELGraph`
+inherits from :class:`networkx.MultiDiGraph`, it can also be visualized using :mod:`networkx`
+`library <https://networkx.github.io/documentation/latest/reference/drawing.html>`_.
+"""
+
+import json
+import os
+from typing import Mapping, Optional
+
+from .constants import DEFAULT_COLOR_MAP
+from ..jinja_utils import build_template_renderer
+from ..nodelink import to_nodelink_jsons
+from ...struct import BELGraph
+
+__all__ = [
+    'to_html',
+    'to_html_file',
+    'to_html_path',
+]
+
+#: Renders templates from pybel.io.jupyter.templates folder
+render_template = build_template_renderer(__file__)
+
+
+def build_graph_context(graph: BELGraph, color_map: Optional[Mapping[str, str]] = None) -> Mapping:
+    """Build the data dictionary to be used by the Jinja templating engine in :py:func:`to_html`.
+
+    :param graph: A BEL graph
+    :param color_map: A dictionary from PyBEL internal node functions to CSS color strings like #FFEE00.
+                    Defaults to :data:`default_color_map`
+    :return: JSON context for rendering
+    """
+    color_map = DEFAULT_COLOR_MAP if color_map is None else color_map
+
+    return {
+        'json': to_nodelink_jsons(graph),
+        'cmap': json.dumps(color_map),
+        'number_nodes': graph.number_of_nodes(),
+        'number_edges': graph.number_of_edges(),
+    }
+
+
+def to_html(graph: BELGraph, color_map: Optional[Mapping[str, str]] = None) -> str:
+    """Create an HTML visualization for the given JSON representation of a BEL graph.
+
+    :param graph: A BEL graph
+    :param color_map: A dictionary from PyBEL internal node functions to CSS color strings like #FFEE00.
+                    Defaults to :data:`default_color_map`
+    :return: HTML string representing the graph
+    """
+    context = build_graph_context(graph, color_map=color_map)
+    return render_template('graph_template.html', context=context)
+
+
+def to_html_file(graph: BELGraph, file, color_map: Optional[Mapping[str, str]] = None) -> None:
+    """Write the HTML visualization to a file or file-like.
+
+    :param graph: A BEL graph
+    :param color_map: A dictionary from PyBEL internal node functions to CSS color strings like #FFEE00.
+                    Defaults to :data:`default_color_map`
+    :param file file: A writable file or file-like
+    """
+    print(to_html(graph, color_map=color_map), file=file)
+
+
+def to_html_path(graph: BELGraph, path: str, color_map: Optional[Mapping[str, str]] = None) -> None:
+    """Write the HTML visualization to a file specified by the file path.
+
+    :param graph: A BEL graph
+    :param color_map: A dictionary from PyBEL internal node functions to CSS color strings like #FFEE00.
+                    Defaults to :data:`default_color_map`
+    :param path: The file path
+    """
+    with open(os.path.expanduser(path), 'w') as file:
+        to_html_file(graph, file, color_map=color_map)
diff --git a/tox.ini b/tox.ini
index 89bd5298..f6fec900 100644
--- a/tox.ini
+++ b/tox.ini
@@ -28,6 +28,8 @@ deps =
     coverage
     pytest
     {env:PYBEL_TEST_CONNECTOR:}
+extras =
+    jupyter
 whitelist_externals =
     /bin/cat
     /bin/cp


Test patch:
diff --git a/tests/test_io/test_jupyter.py b/tests/test_io/test_jupyter.py
new file mode 100644
index 00000000..bfdb99d8
--- /dev/null
+++ b/tests/test_io/test_jupyter.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+
+"""Test for export functions in PyBEL-Jupyter."""
+
+import unittest
+
+from pybel.examples import sialic_acid_graph
+from pybel.io.jupyter import to_html, to_jupyter_str
+
+
+class TestHTML(unittest.TestCase):
+    """Text HTML functions."""
+
+    def test_to_html(self):
+        """Test export to HTML."""
+        html = to_html(sialic_acid_graph)
+        self.assertIsNotNone(html)
+
+    def test_to_jupyter(self):
+        """Test export to JavaScript for Jupyter."""
+        javascript = to_jupyter_str(sialic_acid_graph)
+        self.assertIsNotNone(javascript)