Source code for netwulf.interactive

# -*- coding: utf-8 -*-
"""
This module provides the necessary functions to start up a local
HTTP server and open an interactive d3-visualization of a network.
"""
from __future__ import print_function

import os
import sys
import simplejson as json
from distutils.dir_util import copy_tree
import base64
import http.server
import webbrowser
import time
import threading
from copy import deepcopy
import shutil
from io import BytesIO
import pathlib

import numpy

import networkx as nx
import netwulf as wulf

netwulf_user_folder = pathlib.Path('~/.netwulf/').expanduser()
html_source_path = (pathlib.Path(wulf.__path__[0]) / 'js').expanduser()

def _json_default(o):
    if isinstance(o, numpy.int64): return int(o)
    elif isinstance(o, numpy.float64): return float(o)
    raise TypeError

[docs]def mkdirp_customdir(directory=None): """simulate `mkdir -p` functionality""" if directory is None: directory = netwulf_user_folder try: directory = pathlib.Path(directory).expanduser().resolve() except FileNotFoundError as e: directory = pathlib.Path(directory).expanduser() # Python 3.5 compliant directory.mkdir(parents=True, exist_ok=True)
[docs]def prepare_visualization_directory(): """Move all files from the netwulf/js directory to ~/.netwulf""" src = html_source_path dst = netwulf_user_folder # always copy source files to the subdirectory copy_tree(str(src), str(dst))
[docs]class NetwulfHTTPServer(http.server.HTTPServer): """Custom netwulf server class adapted from https://stackoverflow.com/questions/268629/how-to-stop-basehttpserver-serve-forever-in-a-basehttprequesthandler-subclass """ # The handler will write in this attribute posted_network_properties = None posted_config = None posted_image_base64 = None end_requested = False def __init__(self, server_address, handler, subjson, verbose=False): http.server.HTTPServer.__init__(self, server_address, handler) self.subjson = subjson self.verbose = verbose
[docs] def run(self): try: self.serve_forever() except OSError: pass
[docs] def serve_forever(self): """Handle one request at a time until doomsday.""" while not self.end_requested: self.handle_request() if self.verbose: print("serve_forever() terminated")
[docs] def stop_this(self): # Clean-up server (close socket, etc.) if self.verbose: print('was asked to stop the server') self.server_close() # try: for f in self.subjson: fPath = pathlib.Path(f) if fPath.exists(): fPath.unlink() if self.verbose: print('deleted all files')
[docs]class NetwulfHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): """A custom handler class adapted from https://stackoverflow.com/questions/6204029/extending-basehttprequesthandler-getting-the-posted-data and https://blog.anvileight.com/posts/simple-python-http-server/#do-post """
[docs] def do_POST(self): content_length = int(self.headers['Content-Length']) # an empty POST means the server should be stopped if content_length == 0: try: body = self.rfile.read(content_length) self.send_response(200) self.end_headers() response = BytesIO() response.write(b'Closing now.') self.wfile.write(response.getvalue()) except: #this should actually catch a ConnectionError for windows or firefox pass self.server.end_requested = True else: body = self.rfile.read(content_length) self.send_response(200) self.end_headers() response = BytesIO() response.write(b'Successful POST request.') self.wfile.write(response.getvalue()) # Save this posted data to the server object so it can be retrieved later on if self.server.verbose: print("Successfully posted network data to Python!") received_data = json.loads(body) self.server.posted_network_properties = received_data['network'] self.server.posted_config = received_data['config'] img = received_data['image'].split(',')[1] self.server.posted_image_base64 = base64.decodebytes(img.encode())
[docs] def log_message(self, format, *args): if self.server.verbose: print(self.address_string(), self.log_date_time_string(), *args)
default_config = { # Input/output 'zoom': 1, # Physics 'node_charge': -45, 'node_gravity': 0.1, 'link_distance': 15, 'link_distance_variation': 0, 'node_collision': True, 'wiggle_nodes': False, 'freeze_nodes': False, # Nodes 'node_fill_color': '#79aaa0', 'node_stroke_color': '#555555', 'node_label_color': '#000000', 'display_node_labels': False, 'scale_node_size_by_strength': False, 'node_size': 5, 'node_stroke_width': 1, 'node_size_variation': 0.5, # Links 'link_color': '#7c7c7c', 'link_width': 2, 'link_alpha': 0.5, 'link_width_variation': 0.5, # Thresholding 'display_singleton_nodes': True, 'min_link_weight_percentile': 0, 'max_link_weight_percentile': 1 }
[docs]def visualize(network, port=9853, verbose=False, config=None, plot_in_cell_below=True, is_test=False, ): """ Visualize a network interactively using Ulf Aslak's d3 web app. Saves the network as json, saves the passed config and runs a local HTTP server which then runs the web app. Parameters ---------- network : networkx.Graph or networkx.DiGraph or node-link dictionary The network to visualize port : int, default : 9853 The port at which to run the server locally. verbose : bool, default : False Be chatty. config : dict, default : None, In the default configuration, each key-value-pair will be overwritten with the key-value-pair provided in `config`. The default configuration is .. code:: python default_config = { # Input/output 'zoom': 1, # Physics 'node_charge': -45, 'node_gravity': 0.1, 'link_distance': 15, 'link_distance_variation': 0, 'node_collision': True, 'wiggle_nodes': False, 'freeze_nodes': False, # Nodes 'node_fill_color': '#79aaa0', 'node_stroke_color': '#555555', 'node_label_color': '#000000', 'display_node_labels': False, 'scale_node_size_by_strength': False, 'node_size': 5, 'node_stroke_width': 1, 'node_size_variation': 0.5, # Links 'link_color': '#7c7c7c', 'link_width': 2, 'link_alpha': 0.5, 'link_width_variation': 0.5, # Thresholding 'display_singleton_nodes': True, 'min_link_weight_percentile': 0, 'max_link_weight_percentile': 1 } When started from a Jupyter notebook, this will show a reproduced matplotlib figure of the stylized network in a cell below. Only works if ``verbose = False``. is_test : bool, default : False If ``True``, the interactive environment will post its visualization to Python automatically after 5 seconds. Returns ------- network_properties : dict contains all necessary information to redraw the figure which was created in the interactive visualization config : dict contains all configurational values of the interactive visualization """ this_config = deepcopy(default_config) if config is not None: this_config.update(config) path = netwulf_user_folder mkdirp_customdir() web_dir = pathlib.Path(path) # copy the html and js files for the visualizations prepare_visualization_directory() # create a json-file based on the current time file_id = "tmp_{:x}".format(int(time.time()*1000)) + ".json" filename = file_id configname = "config_" + filename filepath = str(web_dir / filename) configpath = str(web_dir / configname) with open(filepath,'w') as f: if type(network) in [nx.Graph, nx.DiGraph, nx.MultiDiGraph]: network = nx.node_link_data(network) if 'graph' in network: network.update(network['graph']) del network['graph'] json.dump(network, f, iterable_as_array=True, default=_json_default) elif type(network) == dict: json.dump(network, f, iterable_as_array=True, default=_json_default) else: raise TypeError("Netwulf only supports `nx.Graph`, `nx.DiGraph`, `nx.MultiDiGraph`, or `dict`.") with open(configpath,'w') as f: json.dump(this_config, f, default=_json_default) # change directory to this directory if verbose: print("changing directory to", str(web_dir)) print("starting server here ...", str(web_dir)) cwd = os.getcwd() os.chdir(str(web_dir)) server = NetwulfHTTPServer(("127.0.0.1", port), NetwulfHTTPRequestHandler, [filepath, configpath], verbose=verbose, ) # ========= start server ============ thread = threading.Thread(None, server.run) thread.start() url = "http://localhost:"+str(port)+"/?data=" + filename + "&config=" + configname if is_test: url += "&pytest" webbrowser.open(url) try: while not server.end_requested: time.sleep(0.1) is_keyboard_interrupted = False except KeyboardInterrupt: is_keyboard_interrupted = True server.end_requested = True if verbose: print('stopping server ...') server.stop_this() thread.join(0.2) posted_network_properties = server.posted_network_properties posted_config = server.posted_config if verbose: print('changing directory back to', cwd) os.chdir(cwd) # see whether or not the whole thing was started from a jupyter notebook and if yes, # actually re-draw the figure and display it env = os.environ try: is_jupyter = 'jupyter' in pathlib.PurePath(env['_']).name except: # this should actually be a key error # apparently this is how it has to be on Windows is_jupyter = 'JPY_PARENT_PID' in env if is_jupyter and plot_in_cell_below and not is_keyboard_interrupted: if verbose: print('recreating layout in matplotlib ...') if posted_network_properties is not None: fig, ax = wulf.draw_netwulf(posted_network_properties) return posted_network_properties, posted_config
if __name__ == "__main__": # download_d3() G = nx.fast_gnp_random_graph(100,2/100.) #G = nx.barabasi_albert_graph(100,1) posted_data = visualize(G,config={'collision':True},verbose=True) #if posted_data is not None: # print("received posted data:", posted_data)