diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f79a58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# binaries +*.pyc +.cache/ +.coverage + +# notebook checkpoints +.ipynb_checkpoints/ +notebooks/.ipynb_checkpoints + +# test related +.pytest_cache/ +pyvis/test.py + +# local files +pyvis/full_graph.txt +pyvis/source/stormofswords.csv + +# package details +pyvis.egg-info/ +build/ +dist/ +pyvis/make.bat + +# vscode specific +.vscode/ +venv + +# jetbrains specific files +.idea \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..10e4d7a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,18 @@ +# Pull Requests + +Here are some guidelines for pull requests: + +* All work is submitted via Pull Requests. +* Pull Requests can be submitted as soon as there is code worth discussing. The worst case is that the PR is closed. +* Pull Requests should be made against master +* Pull Requests should be tested, if feasible: + - bugfixes should include regression tests. + - new behavior should at least get minimal exercise. + - new features should include a screenshot +* Don't make 'cleanup' pull requests just to change code style. We don't follow any style guide strictly, and we consider formatting changes unnecessary noise. If you're making functional changes, you can clean up the specific pieces of code you're working on. + +Pyvis wraps vis.js so JavaScript functionality issues should be directed at the main `vis.js` project. + +# Other contributions + +Outside of Pull Requests (PRs), we welcome additions/corrections/clarification to the existing documentation as contributions at least as valuable as code submissions. diff --git a/LICENSE_BSD.txt b/LICENSE_BSD.txt index 6e3050f..024edb5 100644 --- a/LICENSE_BSD.txt +++ b/LICENSE_BSD.txt @@ -11,7 +11,7 @@ are permitted provided that the following conditions are met: this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - - Neither the name of Thomas J Bradley nor the names of its contributors may + - Neither the name of West Health Institute nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. diff --git a/README.md b/README.md index 19add10..7e79cb4 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ python setup.py install [ipython](https://ipython.org/ipython-doc/2/install/install.html) +[jsonpickle](https://jsonpickle.github.io/) + ## Quick Start The most basic use case of a pyvis instance is to create a Network object and invoke methods: @@ -37,4 +39,7 @@ g.add_node(0) g.add_node(1) g.add_edge(0, 1) g.show("basic.html") -``` \ No newline at end of file +``` + +## Interactive Notebook playground with examples +[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/WestHealth/pyvis/master?filepath=notebooks%2Fexample.ipynb) diff --git a/notebooks/dot.html b/notebooks/dot.html new file mode 100644 index 0000000..d0b07ad --- /dev/null +++ b/notebooks/dot.html @@ -0,0 +1,84 @@ + + + + +
+

+
+ + + + + + + + +
+ + + + + \ No newline at end of file diff --git a/notebooks/example.html b/notebooks/example.html new file mode 100644 index 0000000..689d070 --- /dev/null +++ b/notebooks/example.html @@ -0,0 +1,108 @@ + + + + +
+

+
+ + + + + + + + +
+ + + + + \ No newline at end of file diff --git a/notebooks/example.ipynb b/notebooks/example.ipynb new file mode 100644 index 0000000..e93d81b --- /dev/null +++ b/notebooks/example.ipynb @@ -0,0 +1,483 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "sys.path.append('../')\n", + "from pyvis.network import Network" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Basic Example" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "g = Network(notebook=True)\n", + "g.add_nodes(range(5))\n", + "g.add_edges([\n", + " (0, 2),\n", + " (0, 3),\n", + " (0, 4),\n", + " (1, 1),\n", + " (1, 3),\n", + " (1, 2)\n", + "])\n", + "g.show(\"example.html\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Dot File example" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": { + "scrolled": true + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "h = Network(notebook=True)\n", + "h.from_DOT(\"test.dot\")\n", + "\n", + "# All properties have to be enclosed by double quotes and \n", + "# there and there must be no comma at the end of a list.\n", + "# See https://visjs.github.io/vis-network/docs/network/ for all options\n", + "h.set_options(\"\"\"\n", + "var options = {\n", + " \"physics\": {\n", + " \"enabled\": true,\n", + " \"barnesHut\": {\n", + " \"theta\": 0.5,\n", + " \"gravitationalConstant\": -2000,\n", + " \"centralGravity\": 0.3,\n", + " \"springLength\": 200,\n", + " \"springConstant\": 0.04,\n", + " \"damping\": 0.09,\n", + " \"avoidOverlap\": 0\n", + " },\n", + " \"maxVelocity\": 50,\n", + " \"minVelocity\": 0.1,\n", + " \"solver\": \"barnesHut\",\n", + " \"stabilization\": {\n", + " \"enabled\": true,\n", + " \"iterations\": 1000,\n", + " \"updateInterval\": 100,\n", + " \"onlyDynamicEdges\": false,\n", + " \"fit\": true\n", + " }\n", + " }\n", + "}\n", + "\"\"\")\n", + "h.show(\"dot.html\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Basic NetworkX example" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "import networkx as nx" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": { + "scrolled": false + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "nxg = nx.random_tree(20)\n", + "g.from_nx(nxg)\n", + "g.show(\"example.html\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Disabling Physics interaction" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "g.toggle_physics(False)\n", + "g.show(\"example.html\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Explicit coordinates to layout nodes" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "g = Network(notebook=True)\n", + "g.add_nodes([1,2,3],\n", + " value=[10, 100, 400],\n", + " title=[\"I am node 1\", \"node 2 here\", \"and im node 3\"],\n", + " x=[21.4, 21.4, 21.4], y=[100.2, 223.54, 32.1],\n", + " label=[\"NODE 1\", \"NODE 2\", \"NODE 3\"],\n", + " color=[\"#00ff1e\", \"#162347\", \"#dd4b39\"])\n", + "g.show(\"example.html\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Full Game of Thrones example" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pandas as pd\n", + "\n", + "got_net = Network(notebook=True, height=\"750px\", width=\"100%\", bgcolor=\"#222222\", font_color=\"white\")\n", + "\n", + "# set the physics layout of the network\n", + "got_net.barnes_hut()\n", + "got_data = pd.read_csv(\"https://www.macalester.edu/~abeverid/data/stormofswords.csv\")\n", + "\n", + "sources = got_data['Source']\n", + "targets = got_data['Target']\n", + "weights = got_data['Weight']\n", + "\n", + "edge_data = zip(sources, targets, weights)\n", + "\n", + "for e in edge_data:\n", + " src = e[0]\n", + " dst = e[1]\n", + " w = e[2]\n", + "\n", + " got_net.add_node(src, src, title=src)\n", + " got_net.add_node(dst, dst, title=dst)\n", + " got_net.add_edge(src, dst, value=w)\n", + "\n", + "neighbor_map = got_net.get_adj_list()\n", + "\n", + "# add neighbor data to node hover data\n", + "for node in got_net.nodes:\n", + " node[\"title\"] += \" Neighbors:
\" + \"
\".join(neighbor_map[node[\"id\"]])\n", + " node[\"value\"] = len(neighbor_map[node[\"id\"]]) # this value attrribute for the node affects node size\n", + "\n", + "got_net.show(\"example.html\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Experimenting with options UI\n", + "Scroll down underneath the graph to play around with the physics settings to acheive optimal layout and behavior. You can use the generate options button to display the JSON representation of the configuration." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "got_net.show_buttons(filter_=\"physics\")\n", + "got_net.show(\"example.html\")" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "got_net.set_options('''var options = {\n", + " \"physics\": {\n", + " \"barnesHut\": {\n", + " \"gravitationalConstant\": -80000,\n", + " \"springLength\": 250,\n", + " \"springConstant\": 0.001\n", + " },\n", + " \"maxVelocity\": 34,\n", + " \"minVelocity\": 0.75\n", + " }\n", + "}''')" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "import networkx as nx" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "nx_graph = nx.cycle_graph(10)\n", + "nx_graph.nodes[1]['title'] = 'Number 1'\n", + "nx_graph.nodes[1]['group'] = 1\n", + "nx_graph.nodes[3]['title'] = 'I belong to a different group!'\n", + "nx_graph.nodes[3]['group'] = 10\n", + "nx_graph.add_node(20, size=20, title='couple', group=2)\n", + "nx_graph.add_node(21, size=15, title='couple', group=2)\n", + "nx_graph.add_edge(20, 21, weight=5)\n", + "nx_graph.add_node(25, size=25, label='lonely', title='lonely node', group=3)\n", + "\n", + "nt = Network(notebook=True, height=\"750px\", width=\"100%\")\n", + "\n", + "nt.from_nx(nx_graph)\n", + "nt.show(\"nx.html\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.10" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/test.dot b/notebooks/test.dot new file mode 100644 index 0000000..c421f4f --- /dev/null +++ b/notebooks/test.dot @@ -0,0 +1,9 @@ +digraph { + a [label=12, entity_id=12, entity_class="truck"]; + b [label=7, entity_id=7,entity_class="bike"]; + c [label=3, entity_id=3, entity_class="car"]; + a -> b[label="solid edge"]; + a -> b [label="dashed edge", style=dashed]; + a -> c [label="dashed edge", style=dashed]; + a -> c [label="dotted edge", style=dotted]; +} \ No newline at end of file diff --git a/pyvis/__init__.py b/pyvis/__init__.py index 2f25379..011b8cd 100644 --- a/pyvis/__init__.py +++ b/pyvis/__init__.py @@ -1 +1,2 @@ -from . import network \ No newline at end of file +from . import network +from ._version import __version__ \ No newline at end of file diff --git a/pyvis/_version.py b/pyvis/_version.py new file mode 100644 index 0000000..b4f711f --- /dev/null +++ b/pyvis/_version.py @@ -0,0 +1 @@ +__version__ = '0.2.0' # bump version diff --git a/pyvis/edge.py b/pyvis/edge.py index 269e732..27d06fd 100644 --- a/pyvis/edge.py +++ b/pyvis/edge.py @@ -5,4 +5,5 @@ def __init__(self, source, dest, directed=False, **options): self.options['from'] = source self.options['to'] = dest if directed: - self.options["arrows"] = "from" + if 'arrows' not in self.options: + self.options["arrows"] = "to" diff --git a/pyvis/network.py b/pyvis/network.py index 29a35be..71d255c 100644 --- a/pyvis/network.py +++ b/pyvis/network.py @@ -1,13 +1,15 @@ from .node import Node from .edge import Edge from .options import Options, Configure -from .utils import check_html +from .utils import check_html, HREFParser from jinja2 import Template import webbrowser from IPython.display import IFrame +from IPython.core.display import HTML from collections import defaultdict import networkx as nx import json +import jsonpickle import os @@ -27,7 +29,9 @@ def __init__(self, directed=False, notebook=False, bgcolor="#ffffff", - font_color=False): + font_color=False, + layout=None, + heading=""): """ :param height: The height of the canvas :param width: The width of the canvas @@ -36,6 +40,7 @@ def __init__(self, :param notebook: True if using jupyter notebook. :param bgcolor: The background color of the canvas. :font_color: The color of the node labels text + :layout: Use hierarchical layout if this is set :type height: num or str :type width: num or str @@ -43,11 +48,13 @@ def __init__(self, :type notebook: bool :type bgcolor: str :type font_color: str + :type layout: bool """ self.nodes = [] self.edges = [] self.height = height self.width = width + self.heading = heading self.html = "" self.shape = "dot" self.font_color = font_color @@ -55,16 +62,17 @@ def __init__(self, self.bgcolor = bgcolor self.use_DOT = False self.dot_lang = "" - self.options = Options() + self.options = Options(layout) self.widget = False self.node_ids = [] + self.node_map = {} self.template = None self.conf = False self.path = os.path.dirname(__file__) + "/templates/template.html" - + if notebook: self.prep_notebook() - + def __str__(self): """ override print to show readable graph data @@ -75,7 +83,8 @@ def __str__(self): "Nodes": self.node_ids, "Edges": self.edges, "Height": self.height, - "Width": self.width + "Width": self.width, + "Heading": self.heading }, indent=4 ) @@ -200,9 +209,11 @@ def add_node(self, n_id, label=None, shape="dot", **options): else: node_label = n_id if n_id not in self.node_ids: - n = Node(n_id, shape, label=node_label, font_color=self.font_color, **options) + n = Node(n_id, shape, label=node_label, + font_color=self.font_color, **options) self.nodes.append(n.options) self.node_ids.append(n_id) + self.node_map[n_id] = n.options def add_nodes(self, nodes, **kwargs): """ @@ -228,7 +239,8 @@ def add_nodes(self, nodes, **kwargs): :type nodes: list """ - valid_args = ["size", "value", "title", "x", "y", "label", "color"] + valid_args = ["size", "value", "title", + "x", "y", "label", "color", "shape"] for k in kwargs: assert k in valid_args, "invalid arg '" + k + "'" @@ -245,8 +257,14 @@ def add_nodes(self, nodes, **kwargs): nd[nodes[i]].update({k: v[i]}) for node in nodes: - assert isinstance(node, int) or isinstance(node, str) - self.add_node(node, **nd[node]) + # check if node is `number-like` + try: + node = int(node) + self.add_node(node, **nd[node]) + except: + # or node could be string + assert isinstance(node, str) + self.add_node(node, **nd[node]) def num_nodes(self): """ @@ -322,15 +340,17 @@ def add_edge(self, source, to, **options): assert to in self.get_nodes(), \ "non existent node '" + str(to) + "'" - for e in self.edges: - frm = e['from'] - dest = e['to'] - if ( - (source == dest and to == frm) or - (source == frm and to == dest) - ): - # edge already exists - edge_exists = True + # we only check existing edge for undirected graphs + if not self.directed: + for e in self.edges: + frm = e['from'] + dest = e['to'] + if ( + (source == dest and to == frm) or + (source == frm and to == dest) + ): + # edge already exists + edge_exists = True if not edge_exists: e = Edge(source, to, self.directed, **options) @@ -366,9 +386,13 @@ def get_network_data(self): Usage: - >>> nodes, edges, height, width, options = net.get_network_data() + >>> nodes, edges, heading, height, width, options = net.get_network_data() """ - return (self.nodes, self.edges, self.height, + if isinstance(self.options, dict): + return (self.nodes, self.edges, self.heading, self.height, + self.width, json.dumps(self.options)) + else: + return (self.nodes, self.edges, self.heading, self.height, self.width, self.options.to_json()) def save_graph(self, name): @@ -381,26 +405,26 @@ def save_graph(self, name): check_html(name) self.write_html(name) - def write_html(self, name, notebook=False): + def generate_html(self, notebook=False): """ - This method gets the data structures supporting the nodes, edges, - and options and updates the template to write the HTML holding + This method generates HTML from the data structures supporting the nodes, edges, + and options and updates the template to generate the HTML holding the visualization. - :type name_html: str + :type notebook: bool + + Returns + :type out: str """ - check_html(name) - # here, check if an href is present in the hover data + + # here, check if a href is present in the hover data use_link_template = False for n in self.nodes: title = n.get("title", None) if title: - if "href" in title: - """ - this tells the template to override default hover - mechanic, as the tooltip would move with the mouse - cursor which made interacting with hover data useless. - """ + href_parser = HREFParser() + href_parser.feed(str(title)) + if href_parser.is_valid(): use_link_template = True break if not notebook: @@ -410,18 +434,43 @@ def write_html(self, name, notebook=False): else: template = self.template - nodes, edges, height, width, options = self.get_network_data() - self.html = template.render(height=height, - width=width, - nodes=nodes, - edges=edges, - options=options, - use_DOT=self.use_DOT, - dot_lang=self.dot_lang, - widget=self.widget, - bgcolor=self.bgcolor, - conf=self.conf, - tooltip_link=use_link_template) + nodes, edges, heading, height, width, options = self.get_network_data() + + # check if physics is enabled + if isinstance(self.options, dict): + if 'physics' in self.options and 'enabled' in self.options['physics']: + physics_enabled = self.options['physics']['enabled'] + else: + physics_enabled = True + else: + physics_enabled = self.options.physics.enabled + + out = template.render(height=height, + width=width, + nodes=nodes, + edges=edges, + heading=heading, + options=options, + physics_enabled=physics_enabled, + use_DOT=self.use_DOT, + dot_lang=self.dot_lang, + widget=self.widget, + bgcolor=self.bgcolor, + conf=self.conf, + tooltip_link=use_link_template) + + return out + + def write_html(self, name, notebook=False): + """ + This method gets the data structures supporting the nodes, edges, + and options and updates the template to write the HTML holding + the visualization. + + :type name_html: str + """ + check_html(name) + self.html = self.generate_html(notebook=notebook) with open(name, "w+") as out: out.write(self.html) @@ -533,7 +582,8 @@ def neighbors(self, node): assert(node in self.node_ids), "error: %s node not in network" % node return self.get_adj_list()[node] - def from_nx(self, nx_graph): + def from_nx(self, nx_graph, node_size_transf=(lambda x: x), edge_weight_transf=(lambda x: x), + default_node_size =10, default_edge_weight=1, show_edge_weights=True): """ This method takes an exisitng Networkx graph and translates it to a PyVis graph format that can be accepted by the VisJs @@ -541,22 +591,52 @@ def from_nx(self, nx_graph): :param nx_graph: The Networkx graph object that is to be translated. :type nx_graph: networkx.Graph instance - >>> nx_graph = Networkx.cycle_graph() + :param node_size_transf: function to transform the node size for plotting + :type node_size_transf: func + :param edge_weight_transf: function to transform the edge weight for plotting + :type edge_weight_transf: func + :param default_node_size: default node size if not specified + :param default_edge_weight: default edge weight if not specified + >>> nx_graph = nx.cycle_graph(10) + >>> nx_graph.nodes[1]['title'] = 'Number 1' + >>> nx_graph.nodes[1]['group'] = 1 + >>> nx_graph.nodes[3]['title'] = 'I belong to a different group!' + >>> nx_graph.nodes[3]['group'] = 10 + >>> nx_graph.add_node(20, size=20, title='couple', group=2) + >>> nx_graph.add_node(21, size=15, title='couple', group=2) + >>> nx_graph.add_edge(20, 21, weight=5) + >>> nx_graph.add_node(25, size=25, label='lonely', title='lonely node', group=3) >>> nt = Network("500px", "500px") # populates the nodes and edges data structures >>> nt.from_nx(nx_graph) >>> nt.show("nx.html") """ assert(isinstance(nx_graph, nx.Graph)) - edges = nx_graph.edges(data=True) - nodes = nx_graph.nodes() + edges=nx_graph.edges(data = True) + nodes=nx_graph.nodes(data = True) + if len(edges) > 0: for e in edges: - self.add_node(e[0], e[0], title=e[0]) - self.add_node(e[1], e[1], title=e[1]) - self.add_edge(e[0], e[1]) - else: - self.add_nodes(nodes) + if 'size' not in nodes[e[0]].keys(): + nodes[e[0]]['size']=default_node_size + nodes[e[0]]['size']=int(node_size_transf(nodes[e[0]]['size'])) + if 'size' not in nodes[e[1]].keys(): + nodes[e[1]]['size']=default_node_size + nodes[e[1]]['size']=int(node_size_transf(nodes[e[1]]['size'])) + self.add_node(e[0], **nodes[e[0]]) + self.add_node(e[1], **nodes[e[1]]) + + if 'weight' not in e[2].keys(): + e[2]['weight']=default_edge_weight + e[2]['weight']=edge_weight_transf(e[2]['weight']) + if show_edge_weights: + e[2]["label"] = e[2]["weight"] + self.add_edge(e[0], e[1], **e[2]) + + for node in nx.isolates(nx_graph): + if 'size' not in nodes[node].keys(): + nodes[node]['size']=default_node_size + self.add_node(node, **nodes[node]) def get_nodes(self): """ @@ -566,6 +646,16 @@ def get_nodes(self): """ return self.node_ids + def get_node(self, n_id): + """ + Lookup node by ID and return it. + + :param n_id: The ID given to the node. + + :returns: dict containing node properties + """ + return self.node_map[n_id] + def get_edges(self): """ This method returns an iterable list of edge objects @@ -585,7 +675,7 @@ def barnes_hut( ): """ BarnesHut is a quadtree based gravity model. It is the fastest. default - and recommended solver for non-heirarchical layouts. + and recommended solver for non-hierarchical layouts. :param gravity: The more negative the gravity value is, the stronger the repulsion is. @@ -708,9 +798,8 @@ def force_atlas_2based( """ self.options.physics.use_force_atlas_2based(locals()) - def to_json(self): - return json.dumps(self, default=lambda o: o.__dict__, - sort_keys=True, indent=4) + def to_json(self, max_depth=1, **args): + return jsonpickle.encode(self, max_depth=max_depth, **args) def set_edge_smooth(self, smooth_type): """ @@ -753,7 +842,7 @@ def toggle_hide_nodes_on_drag(self, status): """ self.options.interaction.hideNodesOnDrag = status - def inherit_edge_colors_from(self, status): + def inherit_edge_colors(self, status): """ Edges take on the color of the node they are coming from. @@ -768,7 +857,10 @@ def show_buttons(self, filter_=None): network. Usage: - >>> g.toggle_buttons(filter_=['nodes', 'edges', 'physics']) + >>> g.show_buttons(filter_=['nodes', 'edges', 'physics']) + + Or to show all options: + >>> g.show_buttons() :param status: When set to True, the widgets will be shown. Default is set to False. @@ -788,11 +880,12 @@ def show_buttons(self, filter_=None): def toggle_physics(self, status): """ - Displays or hides certain widgets to dynamically modify the - network. + Toggles physics simulation - :param status: When set to True, the widgets will be shown. - Default is set to False. + :param status: When False, nodes are not part of the physics + simulation. They will not move except for from + manual dragging. + Default is set to True. :type status: bool """ @@ -803,7 +896,7 @@ def toggle_drag_nodes(self, status): Toggles the dragging of the nodes in the network. :param status: When set to True, the nodes can be dragged around - in the network. Default is set to False. + in the network. Default is set to True. :type status: bool """ @@ -818,3 +911,15 @@ def toggle_stabilization(self, status): :type status: bool """ self.options.physics.toggle_stabilization(status) + + def set_options(self, options): + """ + Overrides the default options object passed to the VisJS framework. + Delegates to the :meth:`options.Options.set` routine. + + :param options: The string representation of the Javascript-like object + to be used to override default options. + + :type options: str + """ + self.options = self.options.set(options) diff --git a/pyvis/options.py b/pyvis/options.py index d58e218..0ba1526 100644 --- a/pyvis/options.py +++ b/pyvis/options.py @@ -1,15 +1,40 @@ from .physics import * class EdgeOptions(object): + """ + This is where the construction of the edges' options takes place. + So far, the edge smoothness can be switched through this object + as well as the edge color's inheritance. + """ def __init__(self): self.smooth = self.Smooth() self.color = self.Color() def inherit_colors(self, status): + """ + Whether or not to inherit colors from the source node. + If this is set to `from` then the edge will take the color + of the source node. If it is set to `to` then the color will + be that of the destination node. + + .. note:: If set to `True` then the `from` behavior is adopted + and vice versa. + """ self.color.inherit = status def toggle_smoothness(self, smooth_type): + """ + Change smooth option for edges. When using dynamic, the edges will + have an invisible support node guiding the shape. This node is part + of the physics simulation, + + :param smooth_type: Possible options are dynamic, continuous, discrete, + diagonalCross, straightCross, horizontal, vertical, + curvedCW, curvedCCW, cubicBezier + + :type smooth_type: str + """ self.smooth.type = smooth_type def __repr__(self): @@ -30,13 +55,13 @@ def __repr__(self): return str(self.__dict__) def __init__(self): - self.enabled = False - self.type = "continuous" + self.enabled = True + self.type = "dynamic" class Color(object): """ The color object contains the color information of the edge - in every situation. When the edge only needs a sngle color value + in every situation. When the edge only needs a single color value like 'rgb(120,32,14)', '#ffffff' or 'red' can be supplied instead of an object. """ @@ -48,7 +73,10 @@ def __init__(self): class Interaction(object): - + """ + Used for all user interaction with the network. Handles mouse + and touch events as well as the navigation buttons and the popups. + """ def __repr__(self): return str(self.__dict__) @@ -62,6 +90,10 @@ def __getitem__(self, item): class Configure(object): + """ + Handles the HTML part of the canvas and generates + an interactive option editor with filtering. + """ def __repr__(self): return str(self.__dict__) @@ -75,19 +107,124 @@ def __getitem__(self, item): return self.__dict__[item] +class Layout(object): + """ + Acts as the camera that looks on the canvas. + Does the animation, zooming and focusing. + """ + + def __repr__(self): + return str(self.__dict__) -class Options(object): + def __init__(self, randomSeed=None, improvedLayout=True): + if not randomSeed: + self.randomSeed = 0 + else: + self.randomSeed = randomSeed + self.improvedLayout = improvedLayout + self.hierarchical = self.Hierarchical(enabled=True) + + def set_separation(self, distance): + """ + The distance between the different levels. + """ + self.hierarchical.levelSeparation = distance + + def set_tree_spacing(self, distance): + """ + Distance between different trees (independent networks). This is + only for the initial layout. If you enable physics, the repulsion + model will denote the distance between the trees. + """ + self.hierarchical.treeSpacing = distance + + def set_edge_minimization(self, status): + """ + Method for reducing whitespace. Can be used alone or together with + block shifting. Enabling block shifting will usually speed up the + layout process. Each node will try to move along its free axis to + reduce the total length of it's edges. This is mainly for the + initial layout. If you enable physics, they layout will be determined + by the physics. This will greatly speed up the stabilization time + """ + self.hierarchical.edgeMinimization = status + + class Hierarchical(object): + + def __getitem__(self, item): + return self.__dict__[item] + + def __init__(self, + enabled=False, + levelSeparation=150, + treeSpacing=200, + blockShifting=True, + edgeMinimization=True, + parentCentralization=True, + sortMethod='hubsize'): + + self.enabled = enabled + self.levelSeparation = levelSeparation + self.treeSpacing = treeSpacing + self.blockShifting = blockShifting + self.edgeMinimization = edgeMinimization + self.parentCentralization = parentCentralization + self.sortMethod = sortMethod + + +class Options(object): + """ + Represents the global options of the network. + This object consists of indiviual sub-objects + that map to VisJS's modules of: + - configure + - layout + - interaction + - physics + - edges + + The JSON representation of this object is directly passed + in to the VisJS framework. + In the future this can be expanded to completely mimic + the structure VisJS can expect. + """ def __repr__(self): return str(self.__dict__) - def __init__(self): - # self.layout = Layout() + def __getitem__(self, item): + return self.__dict__[item] + + def __init__(self, layout=None): + if layout: + self.layout = Layout() self.interaction = Interaction() self.configure = Configure() self.physics = Physics() self.edges = EdgeOptions() + def set(self, new_options): + """ + This method should accept a JSON string and replace its internal + options structure with the given argument after parsing it. + In practice, this method should be called after using the browser + to experiment with different physics and layout options, using + the generated JSON options structure that is spit out from the + front end to serve as input to this method as a string. + + :param new_options: The JSON like string of the options that will + override. + + :type new_options: str + """ + + options = new_options.replace("\n", "").replace(" ", "") + first_bracket = options.find("{") + options = options[first_bracket:] + options = json.loads(options) + return options + + def to_json(self): return json.dumps( self, default=lambda o: o.__dict__, diff --git a/pyvis/physics.py b/pyvis/physics.py index 9bfaf11..a5b5ff4 100644 --- a/pyvis/physics.py +++ b/pyvis/physics.py @@ -1,116 +1,116 @@ -import json - - -class Physics(object): - - engine_chosen = False - - def __getitem__(self, item): - return self.__dict__[item] - - def __repr__(self): - return str(self.__dict__) - - class barnesHut(object): - """ - BarnesHut is a quadtree based gravity model. - This is the fastest, default and recommended. - """ - - def __init__(self, params): - self.gravitationalConstant = params["gravity"] - self.centralGravity = params["central_gravity"] - self.springLength = params["spring_length"] - self.springConstant = params["spring_strength"] - self.damping = params["damping"] - self.avoidOverlap = params["overlap"] - - - class forceAtlas2Based(object): - """ - Force Atlas 2 has been develoved by Jacomi et all (2014) - for use with Gephi. The force Atlas based solver makes use - of some of the equations provided by them and makes use of - some of the barnesHut implementation in vis. The Main differences - are the central gravity model, which is here distance independent, - and repulsion being linear instead of quadratic. Finally, all node - masses have a multiplier based on the amount of connected edges - plus one. - """ - def __init__(self, params): - self.gravitationalConstant = params["gravity"] - self.centralGravity = params["central_gravity"] - self.springLength = params["spring_length"] - self.springConstant = params["spring_strength"] - self.damping = params["damping"] - self.avoidOverlap = params["overlap"] - - class Repulsion(object): - """ - The repulsion model assumes nodes have a simplified field - around them. Its force lineraly decreases from 1 - (at 0.5*nodeDistace and smaller) to 0 (at 2*nodeDistance) - """ - def __init__(self, params): - self.nodeDistance = params['node_distance'] - self.centralGravity = params['central_gravity'] - self.springLength = params['spring_length'] - self.springConstant = params['spring_strength'] - self.damping = params['damping'] - - class hierarchicalRepulsion(object): - """ - This model is based on the repulsion solver but the levels - are taken into accound and the forces - are normalized. - """ - def __init__(self, params): - self.nodeDistance = params['node_distance'] - self.centralGravity = params['central_gravity'] - self.springLength = params['spring_length'] - self.springConstant = params['spring_strength'] - self.damping = params['damping'] - - class Stabilization(object): - """ - This makes the network stabilized on load using default settings. - """ - def __getitem__(self, item): - return self.__dict__[item] - - def __init__(self): - self.enabled = True - self.iterations = 1000 - self.updateInterval = 50 - self.onlyDynamicEdges = False - self.fit = True - - def toggle_stabilization(self, status): - self.enabled = status - - def __init__(self): - self.enabled = True - self.stabilization = self.Stabilization() - - def use_barnes_hut(self, params): - self.barnesHut = self.barnesHut(params) - - def use_force_atlas_2based(self, params): - self.forceAtlas2Based = self.forceAtlas2Based(params) - self.solver = 'forceAtlas2Based' - - def use_repulsion(self, params): - self.repulsion = self.Repulsion(params) - self.solver = 'repulsion' - - def use_hrepulsion(self, params): - self.hierarchicalRepulsion = self.hierarchicalRepulsion(params) - self.solver = 'hierarchicalRepulsion' - - def toggle_stabilization(self, status): - self.stabilization.toggle_stabilization(status) - - def to_json(self): - return json.dumps( - self, default=lambda o: o.__dict__, - sort_keys=True, indent=4) +import json + + +class Physics(object): + + engine_chosen = False + + def __getitem__(self, item): + return self.__dict__[item] + + def __repr__(self): + return str(self.__dict__) + + class barnesHut(object): + """ + BarnesHut is a quadtree based gravity model. + This is the fastest, default and recommended. + """ + + def __init__(self, params): + self.gravitationalConstant = params["gravity"] + self.centralGravity = params["central_gravity"] + self.springLength = params["spring_length"] + self.springConstant = params["spring_strength"] + self.damping = params["damping"] + self.avoidOverlap = params["overlap"] + + + class forceAtlas2Based(object): + """ + Force Atlas 2 has been develoved by Jacomi et all (2014) + for use with Gephi. The force Atlas based solver makes use + of some of the equations provided by them and makes use of + some of the barnesHut implementation in vis. The Main differences + are the central gravity model, which is here distance independent, + and repulsion being linear instead of quadratic. Finally, all node + masses have a multiplier based on the amount of connected edges + plus one. + """ + def __init__(self, params): + self.gravitationalConstant = params["gravity"] + self.centralGravity = params["central_gravity"] + self.springLength = params["spring_length"] + self.springConstant = params["spring_strength"] + self.damping = params["damping"] + self.avoidOverlap = params["overlap"] + + class Repulsion(object): + """ + The repulsion model assumes nodes have a simplified field + around them. Its force lineraly decreases from 1 + (at 0.5*nodeDistace and smaller) to 0 (at 2*nodeDistance) + """ + def __init__(self, params): + self.nodeDistance = params['node_distance'] + self.centralGravity = params['central_gravity'] + self.springLength = params['spring_length'] + self.springConstant = params['spring_strength'] + self.damping = params['damping'] + + class hierarchicalRepulsion(object): + """ + This model is based on the repulsion solver but the levels + are taken into accound and the forces + are normalized. + """ + def __init__(self, params): + self.nodeDistance = params['node_distance'] + self.centralGravity = params['central_gravity'] + self.springLength = params['spring_length'] + self.springConstant = params['spring_strength'] + self.damping = params['damping'] + + class Stabilization(object): + """ + This makes the network stabilized on load using default settings. + """ + def __getitem__(self, item): + return self.__dict__[item] + + def __init__(self): + self.enabled = True + self.iterations = 1000 + self.updateInterval = 50 + self.onlyDynamicEdges = False + self.fit = True + + def toggle_stabilization(self, status): + self.enabled = status + + def __init__(self): + self.enabled = True + self.stabilization = self.Stabilization() + + def use_barnes_hut(self, params): + self.barnesHut = self.barnesHut(params) + + def use_force_atlas_2based(self, params): + self.forceAtlas2Based = self.forceAtlas2Based(params) + self.solver = 'forceAtlas2Based' + + def use_repulsion(self, params): + self.repulsion = self.Repulsion(params) + self.solver = 'repulsion' + + def use_hrepulsion(self, params): + self.hierarchicalRepulsion = self.hierarchicalRepulsion(params) + self.solver = 'hierarchicalRepulsion' + + def toggle_stabilization(self, status): + self.stabilization.toggle_stabilization(status) + + def to_json(self): + return json.dumps( + self, default=lambda o: o.__dict__, + sort_keys=True, indent=4) diff --git a/pyvis/source/conf.py b/pyvis/source/conf.py index f00eaf8..6cede2c 100644 --- a/pyvis/source/conf.py +++ b/pyvis/source/conf.py @@ -16,6 +16,9 @@ import os import sphinx_rtd_theme +# dynamically read the version +exec(open('../_version.py').read()) + # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. @@ -63,7 +66,7 @@ # built documents. # # The short X.Y version. -version = u'0.1' +version = __version__ # The full version, including alpha/beta/rc tags. release = u'0.1.3.1' diff --git a/pyvis/source/documentation.rst b/pyvis/source/documentation.rst index 760ce22..5bb6174 100644 --- a/pyvis/source/documentation.rst +++ b/pyvis/source/documentation.rst @@ -3,4 +3,7 @@ Documentation ============= .. automodule:: pyvis.network + :members: + +.. automodule:: pyvis.options :members: \ No newline at end of file diff --git a/pyvis/source/gameofthrones.html b/pyvis/source/gameofthrones.html index 3a7399c..ae12fb2 100644 --- a/pyvis/source/gameofthrones.html +++ b/pyvis/source/gameofthrones.html @@ -8,7 +8,7 @@ + + + + +
+ + + + + \ No newline at end of file diff --git a/pyvis/source/set_options_ex.gif b/pyvis/source/set_options_ex.gif new file mode 100644 index 0000000..8fe8726 Binary files /dev/null and b/pyvis/source/set_options_ex.gif differ diff --git a/pyvis/source/tutorial.rst b/pyvis/source/tutorial.rst index ef54914..f5375ab 100644 --- a/pyvis/source/tutorial.rst +++ b/pyvis/source/tutorial.rst @@ -6,13 +6,12 @@ The pyvis library is meant for quick generation of visual network graphs with minimal python code. It is designed as a wrapper around the popular Javascript visJS library found at this link_. -.. _link: http://visjs.org/network_examples.html - +.. _link: https://visjs.github.io/vis-network/examples/ Getting started --------------- -All networks must be instantiated as a Network class instance +All networks must be instantiated as a ``Network`` class instance >>> from pyvis.network import Network >>> net = Network() @@ -22,13 +21,14 @@ Add nodes to the network >>> net.add_node(1, label="Node 1") # node id = 1 and label = Node 1 >>> net.add_node(2) # node id and label = 2 -Here, the first parameter to the add_node method is the desired ID to give the Node. This can be a string or a numeric. The label -argument is the string that will be visibly attached to the node in the final visualization. If no label argument is specified then -the node id will be used as a label. +Here, the first parameter to the add_node method is the desired ID to give the +Node. This can be a string or a numeric. The label argument is the string that +will be visibly attached to the node in the final visualization. If no label +argument is specified then the node id will be used as a label. .. note:: The ``ID`` parameter must be unique -Or add a list of nodes +You can also add a list of nodes >>> nodes = ["a", "b", "c", "d"] >>> net.add_nodes(nodes) # node ids and labels = ["a", "b", "c", "d"] @@ -38,11 +38,25 @@ Or add a list of nodes Node properties --------------- -A call to :meth:`add_node` supports various node properties that can be set individually. All of these properties can be found here_, courtesy of VisJS_. For the direct Python -translation of these attributes, reference the :meth:`network.Network.add_node` docs. +A call to :meth:`add_node` supports various node properties that can be set +individually. All of these properties can be found here_, courtesy of VisJS_. +For the direct Python translation of these attributes, reference the +:meth:`network.Network.add_node` docs. + +.. _here: https://visjs.github.io/vis-network/docs/network/nodes.html +.. _VisJS: https://visjs.github.io/vis-network/docs/network/ -.. _here: http://visjs.org/docs/network/nodes.html -.. _VisJS: http://visjs.org/docs/network/ +.. note:: Through no fault of pyvis, some of the attributes in the VisJS_ documentation do not + work as expected, or at all. Pyvis can translate into the JavaScript + elements for VisJS_ but after that it's up to VisJS_! + +Indexing a Node +--------------- +Use the :meth:`get_node` method to index a node by its ID: + +>>> net.add_nodes(["a", "b", "c"]) +>>> net.get_node("c") +>>> {'id': 'c', 'label': 'c', 'shape': 'dot'} Adding list of nodes with properties @@ -50,61 +64,90 @@ Adding list of nodes with properties When using the :meth:`network.Network.add_nodes` method optional keyword arguments can be passed in to add properties to these nodes as well. The valid properties in this case are ->>> ["size", "value", "title", "x", "y", "label", "color"] + >>> ['size', 'value', 'title', 'x', 'y', 'label', 'color'] Each of these keyword args must be the same length as the nodes parameter to the method. Example: >>> g = Network() - >>> g.add_nodes([1,2,3], value=[10, 100, 400], title=["I am node 1", "node 2 here", "and im node 3"], x=[21.4, 54.2, 11.2], y=[100.2, 23.54, 32.1], label=["NODE 1", "NODE 2", "NODE 3"], color=["#00ff1e", "#162347", "#dd4b39"]) + >>> g.add_nodes([1,2,3], value=[10, 100, 400], + title=['I am node 1', 'node 2 here', 'and im node 3'], + x=[21.4, 54.2, 11.2], + y=[100.2, 23.54, 32.1], + label=['NODE 1', 'NODE 2', 'NODE 3'], + color=['#00ff1e', '#162347', '#dd4b39']) .. raw:: html :file: mulnodes.html -.. note:: If you mouse over each node you will see that the "title" of a node attribute is responsible for rendering data on mouse hover. +.. note:: If you mouse over each node you will see that the ``title`` of a node + attribute is responsible for rendering data on mouse hover. You can add ``HTML`` + in your ``title`` string and it will be rendered as such. + +.. note:: The ``color`` attribute can also be a plain HTML ``color`` like red or blue. You can also + specify the full ``rgba`` specification if needed. The VisJS_ documentation has more + details. -Detailed optioal argument documentation for nodes are in the :meth:`network.Network.add_node` method documentation. +Detailed optional argument documentation for nodes are in the +:meth:`network.Network.add_node` method documentation. Edges ----- -Assuming the network's nodes are in place, the edges can then be added according to node id's +Assuming the network's nodes exist, the edges can then be added according to node id's ->>> net.add_node(0, label="a") ->>> net.add_node(1, label="b") ->>> net.add_edge(0, 1) + >>> net.add_node(0, label='a') + >>> net.add_node(1, label='b') + >>> net.add_edge(0, 1) -Edges can contain a weight property as well +Edges can contain a ``weight`` attribute as well ->>> net.add_edge(0, 1, weight=.87) + >>> net.add_edge(0, 1, weight=.87) -Edges can be customized and documentation on options can be found at :meth:`network.Network.add_edge` method documentation, or by referencing the original VisJS edge_ module docs. +Edges can be customized and documentation on options can be found at +:meth:`network.Network.add_edge` method documentation, or by referencing the +original VisJS edge_ module docs. -.. _edge: http://visjs.org/docs/network/edges.html +.. _edge: https://visjs.github.io/vis-network/docs/network/edges.html `Networkx `_ integration ------------------------------------------------------ -An easy way to visualize and construct pyvis networks is to use networkx and use pyvis's built-in networkx helper -method to translate the graph. +An easy way to visualize and construct pyvis networks is to use `Networkx `_ +and use pyvis's built-in networkx helper method to translate the graph. Note that the +Networkx node properties with the same names as those consumed by pyvis (e.g., ``title``) are +translated directly to the correspondingly-named pyvis node attributes. + + >>> from pyvis.network import Network + >>> import networkx as nx + >>> nx_graph = nx.cycle_graph(10) + >>> nx_graph.nodes[1]['title'] = 'Number 1' + >>> nx_graph.nodes[1]['group'] = 1 + >>> nx_graph.nodes[3]['title'] = 'I belong to a different group!' + >>> nx_graph.nodes[3]['group'] = 10 + >>> nx_graph.add_node(20, size=20, title='couple', group=2) + >>> nx_graph.add_node(21, size=15, title='couple', group=2) + >>> nx_graph.add_edge(20, 21, weight=5) + >>> nx_graph.add_node(25, size=25, label='lonely', title='lonely node', group=3) + >>> nt = Network('500px', '500px') + # populates the nodes and edges data structures + >>> nt.from_nx(nx_graph) + >>> nt.show('nx.html') ->>> from pyvis.network import Network ->>> import networkx as nx ->>> nxg = nx.complete_graph(10) ->>> G = Network() ->>> G.from_nx(nxg) +.. raw:: html + :file: nx.html -.. note:: This method does not respect any properties nodes and edges may have on the networkx instance. Properties would need to reassigned through the pyvis layer. Visualization ------------- -The displaying of a graph is achieved by a single method call on :meth:`network.Network.show()` after the underlying network is constructed. -The visual is presented as a static html file and is interactive. +The displaying of a graph is achieved by a single method call on +:meth:`network.Network.show()` after the underlying network is constructed. +The interactive visualization is presented as a static HTML file. ->>> net.enable_physics(True) ->>> net.show("mygraph.html") +>>> net.toggle_physics(True) +>>> net.show('mygraph.html') .. note:: Triggering the :meth:`toggle_physics` method allows for more fluid graph interactions @@ -118,11 +161,11 @@ The following code block is a minimal example of the capabilities of pyvis. from pyvis.network import Network import pandas as pd - got_net = Network(height="750px", width="100%". bgcolor="#222222", font_color="white") + got_net = Network(height='750px', width='100%', bgcolor='#222222', font_color='white') # set the physics layout of the network got_net.barnes_hut() - got_data = pd.read_csv("stormofswords.csv") + got_data = pd.read_csv('https://www.macalester.edu/~abeverid/data/stormofswords.csv') sources = got_data['Source'] targets = got_data['Target'] @@ -130,8 +173,6 @@ The following code block is a minimal example of the capabilities of pyvis. edge_data = zip(sources, targets, weights) - print got_net.get_adj_list() - for e in edge_data: src = e[0] dst = e[1] @@ -145,10 +186,10 @@ The following code block is a minimal example of the capabilities of pyvis. # add neighbor data to node hover data for node in got_net.nodes: - node["title"] += " Neighbors:
" + "
".join(neighbor_map[node["id"]]) - node["value"] = len(neighbor_map[node["id"]]) + node['title'] += ' Neighbors:
' + '
'.join(neighbor_map[node['id']]) + node['value'] = len(neighbor_map[node['id']]) - got_net.show("gameofthrones.html") + got_net.show('gameofthrones.html') If you want to try out the above code, the csv data source can be `downloaded `_ @@ -158,26 +199,29 @@ If you want to try out the above code, the csv data source can be `downloaded >> net.show_buttons(filter_=['physics']) .. image:: buttons.gif -Using pyviz within `Jupyter `_ notebook +.. note:: You can copy/paste the output from the `generate options` button in the above UI + into :meth:`network.Network.set_options` to finalize your results from experimentation + with the settings. + +.. image:: set_options_ex.gif + +Using pyvis within `Jupyter `_ notebook ------------------------------------------------------------ -Pyviz supports `Jupyter `_ notebook embedding through the -use of the -:meth:`network.Network` contructor. The network instance must be +Pyvis supports `Jupyter `_ notebook embedding through the +use of the :meth:`network.Network` constructor. The network instance must be "prepped" during instantiation by supplying the `notebook=True` kwarg. Example: .. image:: jup.png - - diff --git a/pyvis/templates/template.html b/pyvis/templates/template.html index 4a451b0..0e1bdb0 100644 --- a/pyvis/templates/template.html +++ b/pyvis/templates/template.html @@ -1,7 +1,10 @@ - - + + +
+

{{heading}}

+
@@ -17,7 +20,7 @@ float: left; } - {% if nodes|length > 100 %} + {% if nodes|length > 100 and physics_enabled %} #loadingBar { position:absolute; top:0px; @@ -122,7 +125,7 @@
-{% if nodes|length > 100 %} +{% if nodes|length > 100 and physics_enabled %}
0%
@@ -152,23 +155,23 @@ {% if use_DOT %} var DOTstring = "{{dot_lang|safe}}"; - var parsedData = vis.network.convertDot(DOTstring); - - data = { - nodes: parsedData.nodes, - edges: parsedData.edges - } - - var options = parsedData.options; - options.nodes = { - shape: "dot" - } + data = vis.network.dotparser.DOTToGraph(DOTstring); + + var options = data.options; + options = Object.assign(options, { + nodes: { + shape: "dot" + }, + }); + {% if options %} + options = Object.assign(options, {{options|safe}}) + {% endif %} {% else %} // parsing and collecting nodes and edges from the python - nodes = new vis.DataSet({{nodes|safe}}); - edges = new vis.DataSet({{edges|safe}}); + nodes = new vis.DataSet({{nodes|tojson}}); + edges = new vis.DataSet({{edges|tojson}}); // adding nodes and edges to the graph data = {nodes: nodes, edges: edges}; @@ -177,10 +180,6 @@ {% endif %} - // default to using dot shape for nodes - options.nodes = { - shape: "dot" - } {% if conf %} // if this network requires displaying the configure window, // put it in its div @@ -188,7 +187,7 @@ {% endif %} network = new vis.Network(container, data, options); - + {% if tooltip_link %} // make a custom popup var popup = document.createElement("div"); @@ -227,18 +226,29 @@ // showing the popup function showPopup(nodeId) { - // get the data from the vis.DataSet - var nodeData = nodes.get([nodeId]); - popup.innerHTML = nodeData[0].title; + // get the data from the vis.DataSet + var nodeData = nodes.get(nodeId); // get the position of the node var posCanvas = network.getPositions([nodeId])[nodeId]; - // get the bounding box of the node - var boundingBox = network.getBoundingBox(nodeId); - - //position tooltip: - posCanvas.x = posCanvas.x + 0.5 * (boundingBox.right - boundingBox.left); + if (!nodeData) { + var edgeData = edges.get(nodeId); + var poses = network.getPositions([edgeData.from, edgeData.to]); + var middle_x = (poses[edgeData.to].x - poses[edgeData.from].x) * 0.5; + var middle_y = (poses[edgeData.to].y - poses[edgeData.from].y) * 0.5; + posCanvas = poses[edgeData.from]; + posCanvas.x = posCanvas.x + middle_x; + posCanvas.y = posCanvas.y + middle_y; + + popup.innerHTML = edgeData.title; + } else { + popup.innerHTML = nodeData.title; + // get the bounding box of the node + var boundingBox = network.getBoundingBox(nodeId); + posCanvas.x = posCanvas.x + 0.5 * (boundingBox.right - boundingBox.left); + posCanvas.y = posCanvas.y + 0.5 * (boundingBox.top - boundingBox.bottom); + }; // convert coordinates to the DOM space var posDOM = network.canvasToDOM(posCanvas); @@ -255,7 +265,7 @@ {% endif %} - {% if nodes|length > 100 %} + {% if nodes|length > 100 and physics_enabled %} network.on("stabilizationProgress", function(params) { document.getElementById('loadingBar').removeAttribute("style"); var maxWidth = 496; @@ -283,4 +293,4 @@ - \ No newline at end of file + diff --git a/pyvis/tests/test_graph.py b/pyvis/tests/test_graph.py index 84e4527..c75bbd7 100644 --- a/pyvis/tests/test_graph.py +++ b/pyvis/tests/test_graph.py @@ -1,6 +1,7 @@ import unittest -from ..network import Network +from pyvis.network import Network import os +from pyvis.utils import HREFParser class NodeTestCase(unittest.TestCase): @@ -114,7 +115,7 @@ def test_length(self): self.assertEqual(g.num_nodes(), 1) def test_get_network_data(self): - self.assertEqual(len(self.g.get_network_data()), 5) + self.assertEqual(len(self.g.get_network_data()), 6) class EdgeTestCase(unittest.TestCase): @@ -200,7 +201,7 @@ def test_add_edge_directed(self): self.g.add_edge(0, 1) self.assertTrue(self.g.edges) for e in self.g.edges: - self.assertTrue(e["arrows"] == "from") + self.assertTrue(e["arrows"] == "to") class UtilsTestCase(unittest.TestCase): @@ -294,11 +295,56 @@ def setUp(self): self.g.add_nodes([0, 1, 2, 3]) def test_set_edge_smooth(self): - self.assertEqual(self.g.options.edges.smooth.type, 'continuous') - self.g.set_edge_smooth('dynamic') self.assertEqual(self.g.options.edges.smooth.type, 'dynamic') + self.g.set_edge_smooth('continuous') + self.assertEqual(self.g.options.edges.smooth.type, 'continuous') def test_inherit_colors(self): self.assertTrue(self.g.options.edges.color.inherit) - self.g.inherit_edge_colors_from(False) + self.g.inherit_edge_colors(False) self.assertFalse(self.g.options.edges.color.inherit) + + +class LayoutTestCase(unittest.TestCase): + + def setUp(self): + self.g = Network(layout=True) + + def test_can_enable_init(self): + self.assertTrue(self.g.options['layout']) + + def test_layout_disabled(self): + self.g = Network() + self.assertRaises(KeyError, lambda: self.g.options['layout']) + + def test_levelSeparation(self): + self.assertTrue(self.g.options.layout.hierarchical.levelSeparation) + + def test_treeSpacing(self): + self.assertTrue(self.g.options.layout.hierarchical.treeSpacing) + + def test_blockShifting(self): + self.assertTrue(self.g.options.layout.hierarchical.blockShifting) + + def test_edgeMinimization(self): + self.assertTrue(self.g.options.layout.hierarchical.edgeMinimization) + + def test_parentCentralization(self): + self.assertTrue(self.g.options.layout.hierarchical.parentCentralization) + + def test_sortMethod(self): + self.assertTrue(self.g.options.layout.hierarchical.sortMethod) + + def test_set_edge_minimization(self): + self.g.options.layout.set_separation(10) + self.assertTrue(self.g.options.layout.hierarchical.levelSeparation == 10) + + def test_set_tree_spacing(self): + self.g.options.layout.set_tree_spacing(10) + self.assertTrue(self.g.options.layout.hierarchical.treeSpacing == 10) + + def test_set_edge_minimization(self): + self.g.options.layout.set_edge_minimization(True) + self.assertTrue(self.g.options.layout.hierarchical.edgeMinimization == True) + self.g.options.layout.set_edge_minimization(False) + self.assertTrue(self.g.options.layout.hierarchical.edgeMinimization == False) diff --git a/pyvis/tests/test_href_parser.py b/pyvis/tests/test_href_parser.py new file mode 100644 index 0000000..c841baf --- /dev/null +++ b/pyvis/tests/test_href_parser.py @@ -0,0 +1,39 @@ +import unittest +from pyvis.utils import HREFParser + + +class HreParserTestCase(unittest.TestCase): + + def test_valid_href(self): + parser = HREFParser() + test_text = ' Google ' + parser.feed(test_text) + self.assertTrue(parser.is_valid()) + + def test_invalid_href(self): + parser = HREFParser() + test_text = ' Google Google ' + parser.feed(test_text) + self.assertFalse(parser.is_valid()) + + parser = HREFParser() + test_text = ' Google ' + parser.feed(test_text) + self.assertFalse(parser.is_valid()) + + def test_empty_string(self): + parser = HREFParser() + test_text = "" + parser.feed(test_text) + self.assertFalse(parser.is_valid()) + + def test_other_html_elements(self): + parser = HREFParser() + test_text = '
google
' + parser.feed(test_text) + self.assertFalse(parser.is_valid()) diff --git a/pyvis/tests/test_me.py b/pyvis/tests/test_me.py index 1325a1c..5bdf68f 100644 --- a/pyvis/tests/test_me.py +++ b/pyvis/tests/test_me.py @@ -1,4 +1,7 @@ -from ..network import Network + +import numpy as np + +from pyvis.network import Network def test_canvas_size(): @@ -68,3 +71,13 @@ def test_add_edge(): net.add_edge(0, 9) assert(net.get_adj_list()[0] == set([2, 1, 3, 4, 5, 6, 7, 8, 9])) + +def test_add_numpy_nodes(): + """ + Test adding numpy array nodes since these + nodes will have specific numpy types + """ + arrayNodes = np.array([1,2,3,4]) + g = Network() + g.add_nodes(np.array([1,2,3,4])) + assert g.get_nodes() == [1,2,3,4] diff --git a/pyvis/utils.py b/pyvis/utils.py index 727e52c..0dc1e47 100644 --- a/pyvis/utils.py +++ b/pyvis/utils.py @@ -1,4 +1,5 @@ # utility and helper functions for use in pyvis +from html.parser import HTMLParser def check_html(name): @@ -8,6 +9,33 @@ def check_html(name): :param: name: the name to check :type name: str """ - assert len(name.split(".")) == 2, "invalid file type for %s" % name + assert len(name.split(".")) >= 2, "invalid file type for %s" % name assert name.split( - ".")[1] == "html", "%s is not a valid html file" % name + ".")[-1] == "html", "%s is not a valid html file" % name + + +class HREFParser(HTMLParser): + + count = 0 + count_changed = False + + """ + Given a string, check if it contains a valid href + """ + def handle_starttag(self, tag, attributes): + """ + Checks if the tags and attributes contain a valid href, returning True if one is detected, False otherwise + """ + # Only parse tags where hrefs can appear + if tag == "a": + for name, value in attributes: + if name == "href": + self.count += 1 + self.count_changed = True + + def handle_endtag(self, tag: str): + if tag == "a": + self.count -= 1 + + def is_valid(self) -> bool: + return self.count == 0 and self.count_changed diff --git a/requirements.txt b/requirements.txt index b8c110f..de7a43e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,7 @@ -Jinja2==2.8 -networkx==1.11 -ipython==5.3.0 \ No newline at end of file +Jinja2>=2.10 +networkx>=1.11 +ipython>=5.3.0 +pandas>=0.23.4 +jsonpickle>=1.4.1 +numpy>=1.19.5 +HTMLParser>= diff --git a/setup.py b/setup.py index 9c87e41..085c9b5 100644 --- a/setup.py +++ b/setup.py @@ -1,18 +1,26 @@ -from setuptools import setup, find_packages +from setuptools import setup, find_packages, __version__ +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +exec(open('pyvis/_version.py').read()) setup( name="pyvis", - version="0.1.3.1", - description="A Python network visualization library", + version=__version__, + description="A Python network graph visualization library", + long_description=long_description, + long_description_content_type='text/markdown', url="https://github.com/WestHealth/pyvis", - author="Giancarlo Perrone", - author_email="gperrone@westhealth.org", + author="Jose Unpingco", + author_email="datascience@westhealth.org", license="BSD", packages=find_packages(), include_package_data=True, install_requires=[ "jinja2 >= 2.9.6", "networkx >= 1.11", - "ipython == 5.3.0" - ] + "ipython >= 5.3.0", + "jsonpickle >= 1.4.1" + ], + python_requires=">3.6", )