├── .gitattributes ├── .gitignore ├── .gitmodules ├── .readthedocs.yml ├── .travis.yml ├── CHANGELOG.md ├── CITATION.cff ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── OUTLOOK.md ├── README.md ├── README.rst ├── docs ├── Makefile ├── about.rst ├── conf.py ├── cookbook │ ├── cookbook.rst │ └── img │ │ ├── labeled_graph.png │ │ └── random_partition_graph.png ├── dev_notes │ ├── general_remarks.rst │ ├── img │ │ ├── test_config.png │ │ ├── test_filtering_01.png │ │ ├── test_filtering_02.png │ │ ├── test_matplotlib.png │ │ └── test_posting.png │ └── testing.rst ├── img │ ├── BA.png │ ├── favicon.ico │ ├── favicon.xcf │ ├── logo.png │ ├── logo_small_title.xcf │ ├── new_logo_small.idraw │ ├── new_logo_very_small_2width.png │ └── simple_example.gif ├── index.rst ├── make.bat ├── python_api │ ├── data_io.rst │ ├── drawing.rst │ ├── img │ │ ├── figure_in_jupyter.png │ │ ├── post_to_python.png │ │ └── reproduced_figure.png │ ├── network_manipulation.rst │ ├── post_back.rst │ └── start.rst ├── reference │ ├── interactive.rst │ ├── io.rst │ └── tools.rst ├── requirements.txt └── visualization │ ├── img │ ├── control_panel.png │ ├── io.png │ ├── links.png │ ├── nodes.png │ ├── physics.png │ └── thresholding.png │ └── init.rst ├── img ├── BA_1.png ├── BA_2.png ├── attributes_1.png ├── logo.png ├── logo.xcf ├── logo_small.png ├── logo_smaller.xcf ├── netwulf.mp4 └── simple_example.gif ├── netwulf ├── __init__.py ├── interactive.py ├── io.py ├── metadata.py ├── tests │ ├── __init__.py │ └── test_all.py └── tools.py ├── paper ├── paper.bib ├── paper.md └── random_partition_graph.png ├── pytest.ini ├── requirements.txt ├── sandbox ├── BA.ipynb ├── BA.py ├── BA_network_properties.json ├── redraw_BA.py ├── save_BA.py ├── test_filtering.py ├── test_posting.py └── test_testing.py ├── setup.cfg └── setup.py /.gitattributes: -------------------------------------------------------------------------------- 1 | *.ipynb linguist-vendored 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.so* 2 | *.o* 3 | .DS_Store 4 | 5 | *_data 6 | *_data_* 7 | a.out 8 | *.npy 9 | *.mexmaci64 10 | *.m~ 11 | *.pickle 12 | *.pyc 13 | *.swp 14 | *.pdf 15 | 16 | /*.egg-info/ 17 | /build/ 18 | /tmp/ 19 | /dist/ 20 | 21 | /jupyter_notebooks/.ipynb_checkpoints/ 22 | 23 | /py_only_docs/ 24 | /docs/_build/ 25 | 26 | /.vscode/ 27 | /_vscode/ 28 | /sandbox/.ipynb_checkpoints/ 29 | 30 | __pycache__ 31 | /.eggs/ 32 | 33 | .coverage 34 | .pytest_cache 35 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "netwulf/js"] 2 | path = netwulf/js 3 | url = git@github.com:benmaier/network_styling_with_d3.git 4 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | # Build documentation with MkDocs 13 | #mkdocs: 14 | # configuration: mkdocs.yml 15 | 16 | # Optionally set the version of Python and requirements required to build your docs 17 | python: 18 | version: 3.7 19 | install: 20 | - requirements: docs/requirements.txt 21 | 22 | submodules: 23 | exclude: all 24 | recursive: true 25 | 26 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | script: 4 | - python setup.py test 5 | 6 | notifications: 7 | email: 8 | on_success: change 9 | on_failure: change 10 | 11 | after_success: 12 | - coveralls 13 | 14 | branches: 15 | except: 16 | - gh-pages 17 | 18 | dist: xenial 19 | 20 | env: 21 | - MOZ_HEADLESS=1 22 | addons: 23 | firefox: latest 24 | 25 | services: 26 | - xvfb 27 | 28 | matrix: 29 | include: 30 | - python: 3.5 31 | sudo: false 32 | - python: 3.6 33 | sudo: false 34 | - python: 3.7 35 | sudo: true 36 | 37 | # Handle git submodules yourself 38 | git: 39 | submodules: false 40 | 41 | # Use sed to replace the SSH URL with the public URL, then initialize submodules 42 | before_install: 43 | - sed -i 's/git@github.com:/https:\/\/github.com\//' .gitmodules 44 | - git submodule update --init --recursive 45 | - google-chrome-stable --headless --disable-gpu --remote-debugging-port=9222 http://localhost & 46 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [v0.1.5] - 2019-09-09 11 | ### Added 12 | - test for calling with dictionary 13 | 14 | ### Changed 15 | - each link can now be colored individually in the python draw backend 16 | 17 | ### Fixed 18 | - providing a link-node dictionary to the visualization function does not raise an error anymore 19 | - `G.node` gives an error since networkx 2.4 and we catch it now 20 | 21 | 22 | ## [v0.1.4] - 2019-09-09 23 | ### Fixed 24 | - having graph properties with numpy types raised an error when dumping to JSON, 25 | so these types are now converted to Python types prior to dumping 26 | 27 | ### Changed 28 | - nodes with strength 0 are now rescaled to strength 1 if node size is scaled by strength 29 | - when changing node and link properties in a frozen simulation, the simulation is not restarted anymore 30 | - The server is stopped as soon as the Browser window is closed 31 | 32 | ## [v0.1.3] - 2019-06-18 33 | ### Added 34 | - module `netwulf.io`, which contains functions to save and load stylized networks 35 | - appropriate tests for this module's functionality 36 | - sections in the docs 37 | - a label and link drawing cookbook example 38 | 39 | ### Changed 40 | - `zorder`-behavior in matplotlib drawing (`netwulf.draw_netwulf`) 41 | 42 | ## [v0.1.2] - 2019-06-17 43 | ### Added 44 | - function `netwulf.tools.node_pos` to get a node's position on the matplotlib axis 45 | - function `netwulf.tools.add_node_label` to add a node label to the matplotlib axis 46 | - function `netwulf.tools.add_edge_label` to add an edge label to the matplotlib axis 47 | 48 | ### Changed 49 | - The corresponding docs for node labels was changed to use the new functions. 50 | - The matplotlib test now contains additional tests for the edge label and node label positioning 51 | 52 | ## [v0.1.1] - 2019-05-24 53 | ### Changed 54 | - some default settings 55 | - set constant `dpi = 72` for unit conversions in matplotlib redrawing because apparently matplotlib only uses this value for conversions, see https://stackoverflow.com/a/35501485/4177832. 56 | 57 | ## [v0.1.0] - 2019-05-23 58 | ### Changed 59 | - `netwulf.tools.bind_positions_to_network` is now called `netwulf.bind_properties_to_network` and it now does exactly that -- write properties instead of just positions 60 | - several visualization config options were renamed 61 | - `netwulf.tools.draw_netwulf` now draws the canvas positions instead of the actual node positions (differences arise by zooming). 62 | 63 | ## [v0.0.18] - 2019-05-15 64 | ### Added 65 | - added automated test functionality in ``/tests/`` and ``Makefile`` 66 | 67 | ### Changed 68 | - catch Python 3.5 error for emulated ``mkdir -p`` functionality 69 | 70 | ## [v0.0.17] - 2019-05-15 71 | ### Changed 72 | - switched from usage of `os.path` to `pathlib` at the appropriate places 73 | 74 | [Unreleased]: https://github.com/benmaier/netwulf/compare/v0.1.5...HEAD 75 | [v0.1.5]: https://github.com/benmaier/netwulf/compare/v0.1.4...v0.1.5 76 | [v0.1.4]: https://github.com/benmaier/netwulf/compare/v0.1.3...v0.1.4 77 | [v0.1.3]: https://github.com/benmaier/netwulf/compare/v0.1.2...v0.1.3 78 | [v0.1.2]: https://github.com/benmaier/netwulf/compare/v0.1.1...v0.1.2 79 | [v0.1.1]: https://github.com/benmaier/netwulf/compare/v0.1.0...v0.1.1 80 | [v0.1.0]: https://github.com/benmaier/netwulf/compare/v0.0.18...v0.1.0 81 | [v0.0.18]: https://github.com/benmaier/netwulf/compare/v0.0.17...v0.0.18 82 | [v0.0.17]: https://github.com/benmaier/netwulf/releases/tag/v0.0.17 83 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | title: Netwulf 3 | message: >- 4 | If you use this software, please cite it using the 5 | metadata from this file. 6 | type: software 7 | authors: 8 | - given-names: Ulf 9 | family-names: Aslak 10 | email: ulfaslak@gmail.com 11 | - given-names: Benjamin Frank 12 | family-names: Maier 13 | email: benjaminfrankmaier@gmail.com 14 | identifiers: 15 | - type: doi 16 | value: 17 | repository-code: 'https://github.com/benmaier/netwulf' 18 | abstract: >- 19 | Netwulf is a light-weight Python library that provides a simple API for interactively visualizing 20 | a network and returning the computed layout and style. It is build around the philosophy 21 | that network manipulation and preprocessing should be done programmatically, but that the 22 | efficient generation of a visually appealing network is best done interactively, without code. 23 | Therefore, it offers no analysis functionality and only few exploration features, but instead 24 | focuses almost entirely on fast and intuitive layout manipulation and node/link styling. 25 | preferred-citation: 26 | type: article 27 | authors: 28 | - family-names: "Aslak" 29 | given-names: "Ulf" 30 | orcid: "https://orcid.org/0000-0003-4704-3609" 31 | - family-names: "Benjamin Frank" 32 | given-names: "Maier" 33 | orcid: "https://orcid.org/0000-0001-7414-8823" 34 | journal: "Journal of Open Source Software" 35 | title: "Netwulf: Interactive visualization of networks in Python" 36 | volume: 4 37 | issue: 42 38 | year: 2019 39 | start: 1425 40 | doi: "10.21105/joss.01425" 41 | url: "https://doi.org/10.21105/joss.01425" 42 | publisher: "The Open Journal" 43 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at 59 | `bfmaier (at) physik (dot) hu (minus) berlin (dot) de`. 60 | All 61 | complaints will be reviewed and investigated and will result in a response that 62 | is deemed necessary and appropriate to the circumstances. The project team is 63 | obligated to maintain confidentiality with regard to the reporter of an incident. 64 | Further details of specific enforcement policies may be posted separately. 65 | 66 | Project maintainers who do not follow or enforce the Code of Conduct in good 67 | faith may face temporary or permanent repercussions as determined by other 68 | members of the project's leadership. 69 | 70 | ## Attribution 71 | 72 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 73 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 74 | 75 | [homepage]: https://www.contributor-covenant.org 76 | 77 | For answers to common questions about this code of conduct, see 78 | https://www.contributor-covenant.org/faq 79 | 80 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via an [issue](https://github.com/benmaier/netwulf/issues/new). 4 | 5 | Please note we have a [code of conduct](https://github.com/benmaier/netwulf/blob/master/CODE_OF_CONDUCT.md), please follow it in all your interactions with the project. 6 | 7 | ## Pull Request Process 8 | 9 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 10 | build. 11 | 2. Update README.md and CHANGELOG.md with details of changes to the interface, this includes new environment 12 | variables, exposed ports, useful file locations and container parameters. 13 | 3. Increase the version number in [netwulf/metadata.py](https://github.com/benmaier/netwulf/blob/master/netwulf/metadata.py) and the README.md to the new version that this 14 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 15 | 4. Please read the documentation part on [testing](https://netwulf.readthedocs.io/en/latest/dev_notes/testing.html). Make sure [pytest](https://docs.pytest.org/en/latest/) is installed. Run ``make test`` to run all automated tests. Post the output as a comment in your Pull Request. 16 | 5. You may merge the Pull Request in once you have the sign-off of one other developer, or if you 17 | do not have permission to do that, you may request the reviewer to merge it for you. 18 | 19 | ## Attribution 20 | 21 | This Contributing Statement is adapted from [this template by @PurpleBooth](https://gist.github.com/PurpleBooth/b24679402957c63ec426). 22 | 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018-2019 Benjamin F. Maier and Ulf Aslak 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include setup.py 3 | include README.rst 4 | include LICENSE 5 | include netwulf/js/* 6 | include setup.cfg 7 | include netwulf/js/libs/* 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: 2 | make python 3 | 4 | clean: 5 | -rm -f *.o 6 | make pyclean 7 | 8 | clean_all: 9 | make clean 10 | make pyclean 11 | 12 | pyclean: 13 | -rm -f *.so 14 | -rm -rf *.egg-info* 15 | -rm -rf ./tmp/ 16 | -rm -rf ./build/ 17 | 18 | python: 19 | pip install -e ../netwulf --no-binary :all: 20 | 21 | checkdocs: 22 | python setup.py checkdocs 23 | 24 | pypi: 25 | rm dist/* 26 | python setup.py sdist 27 | twine check dist/* 28 | 29 | upload: 30 | twine upload dist/* 31 | 32 | readme: 33 | pandoc --from markdown_github --to rst README.md > _README.rst 34 | sed -e "s/^\:\:/\.\. code\:\: bash/g" _README.rst > README.rst 35 | rm _README.rst 36 | rstcheck README.rst 37 | 38 | test: 39 | pytest --cov=netwulf netwulf/tests/test_all.py 40 | -------------------------------------------------------------------------------- /OUTLOOK.md: -------------------------------------------------------------------------------- 1 | # Outlook 2 | 3 | This is a place to collect ideas on how to further improve the network visualization process. 4 | 5 | ## Directed and curved edges 6 | 7 | This is a crucial functionality for a lot of network scientists. We plan to [incorporate this functionality soon](ulfaslak/network_styling_with_d3#3), in fact we already [started working on it](benmaier/curved-edges). 8 | 9 | ## Tree styling 10 | 11 | d3 provides a great [API for tree styling](https://github.com/d3/d3-hierarchy/blob/master/README.md). We should build an addition tree styling function. 12 | 13 | ## Hierarchically clustered networks 14 | 15 | Networks are often inferred to be hierarchically clustered. Different visualizations could be used to visually cluster these networks in a way that does not rely on a force-layout, e.g. with something like a [Pack](https://github.com/d3/d3-hierarchy/blob/master/README.md#pack), see [also here](https://observablehq.com/@d3/zoomable-circle-packing) or with [hierarchical edge bundling](https://observablehq.com/@d3/hierarchical-edge-bundling). 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![logo](https://github.com/benmaier/netwulf/raw/master/img/logo_small.png) 3 | 4 | ## About 5 | 6 | **Simple and interactive network visualization in Python.** Network visualization is an indispensable tool for exploring and communicating patterns in complex systems. Netwulf offers an ultra-simple API for **reproducible interactive visualization** of networks directly from a Python prompt or Jupyter notebook. As a research tool, its purpose is to allow hassle-free quick interactive layouting/styling for communication purposes. 7 | 8 | The package is build around the philosophy that network manipulation and preprocessing should be done programmatically, but that the efficient generation of a visually appealing network is best done interactively, without code. 9 | 10 | ![example](https://github.com/benmaier/netwulf/raw/master/img/simple_example.gif) 11 | 12 | ## Paper 13 | 14 | [![DOI](https://joss.theoj.org/papers/10.21105/joss.01425/status.svg)](https://doi.org/10.21105/joss.01425) 15 | 16 | If you use `netwulf` for your scientific work, consider citing us! We're [published in JOSS](https://doi.org/10.21105/joss.01425). 17 | 18 | ## Install 19 | 20 | pip install netwulf 21 | 22 | `netwulf` was developed and tested for 23 | 24 | * Python 3.5 25 | * Python 3.6 26 | * Python 3.7 27 | 28 | So far, the package's functionality was tested on Mac OS X, several Linux distributions and Windows NT. Windows support cannot be guaranteed as we do not have constant access to machines with this OS. 29 | 30 | ## Dependencies 31 | 32 | `netwulf` directly depends on the following packages which will be installed by `pip` during the installation process 33 | 34 | * `networkx>=2.0` 35 | * `numpy>=0.14` 36 | * `matplotlib>=3.0` 37 | * `simplejson>=3.0` 38 | 39 | ## Documentation 40 | 41 | [![Documentation Status](https://readthedocs.org/projects/netwulf/badge/?version=latest)](https://netwulf.readthedocs.io/en/latest/?badge=latest) 42 | 43 | The full documentation is available at https://netwulf.rtfd.io. 44 | 45 | ## Example 46 | 47 | Create a network and look at it 48 | 49 | ```python 50 | import networkx as nx 51 | from netwulf import visualize 52 | 53 | G = nx.barabasi_albert_graph(100,m=1) 54 | visualize(G) 55 | ``` 56 | 57 | ![visualization example0](https://github.com/benmaier/netwulf/raw/master/img/BA_1.png) 58 | 59 | ## Changelog 60 | 61 | Changes are logged in a [separate file](https://github.com/benmaier/netwulf/blob/master/CHANGELOG.md). 62 | 63 | ## License 64 | 65 | This project is licensed under the [MIT License](https://github.com/benmaier/netwulf/blob/master/LICENSE). 66 | 67 | ## Contributing 68 | 69 | If you want to contribute to this project, please make sure to read the [code of conduct](https://github.com/benmaier/netwulf/blob/master/CODE_OF_CONDUCT.md) and the [contributing guidelines](https://github.com/benmaier/netwulf/blob/master/CONTRIBUTING.md). In case you're wondering about what to contribute, we're always collecting ideas of what we want to implement next in the [outlook notes](https://github.com/benmaier/netwulf/blob/master/OUTLOOK.md). 70 | 71 | [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v1.4%20adopted-ff69b4.svg)](code-of-conduct.md) 72 | 73 | ## Dev notes 74 | 75 | Clone and install this repository as 76 | 77 | ```bash 78 | git clone --recurse-submodules -j8 git@github.com:benmaier/netwulf.git 79 | make 80 | ``` 81 | 82 | Note that `make` per default lets `pip` install a development version of the repository. 83 | 84 | The JS base code in `/netwulf/js/` is a fork of [Ulf Aslak's interactive web app](https://github.com/ulfaslak/network_styling_with_d3). If this repository is updated, change to `/netwulf/js/`, then do 85 | 86 | ```bash 87 | git fetch upstream 88 | git merge upstream/master 89 | git commit -m "merged" 90 | git push 91 | ``` 92 | 93 | If you want to upload to PyPI, first convert the new `README.md` to `README.rst` 94 | 95 | ```bash 96 | make readme 97 | ``` 98 | 99 | It will give you warnings about bad `.rst`-syntax. Fix those errors in `README.rst`. Then wrap the whole thing 100 | 101 | ```bash 102 | make pypi 103 | ``` 104 | 105 | It will probably give you more warnings about `.rst`-syntax. Fix those until the warnings disappear. Then do 106 | 107 | ```bash 108 | make upload 109 | ``` 110 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |logo| 2 | 3 | About 4 | ----- 5 | 6 | **Simple and interactive network visualization in Python.** Network 7 | visualization is an indispensable tool for exploring and communicating 8 | patterns in complex systems. Netwulf offers an ultra-simple API for 9 | **reproducible interactive visualization** of networks directly from a 10 | Python prompt or Jupyter notebook. As a research tool, its purpose is to 11 | allow hassle-free quick interactive layouting/styling for communication 12 | purposes. 13 | 14 | The package is build around the philosophy that network manipulation and 15 | preprocessing should be done programmatically, but that the efficient 16 | generation of a visually appealing network is best done interactively, 17 | without code. 18 | 19 | |example| 20 | 21 | Paper 22 | ----- 23 | 24 | |DOI| 25 | 26 | If you use ``netwulf`` for your scientific work, consider citing us! 27 | We're `published in JOSS `__. 28 | 29 | Install 30 | ------- 31 | 32 | .. code:: bash 33 | 34 | pip install netwulf 35 | 36 | ``netwulf`` was developed and tested for 37 | 38 | - Python 3.5 39 | - Python 3.6 40 | - Python 3.7 41 | 42 | So far, the package's functionality was tested on Mac OS X, several 43 | Linux distributions and Windows NT. Windows support cannot be guaranteed 44 | as we do not have constant access to machines with this OS. 45 | 46 | Dependencies 47 | ------------ 48 | 49 | ``netwulf`` directly depends on the following packages which will be 50 | installed by ``pip`` during the installation process 51 | 52 | - ``networkx>=2.0`` 53 | - ``numpy>=0.14`` 54 | - ``matplotlib>=3.0`` 55 | - ``simplejson>=3.0`` 56 | 57 | Documentation 58 | ------------- 59 | 60 | |Documentation Status| 61 | 62 | The full documentation is available at https://netwulf.rtfd.io. 63 | 64 | Example 65 | ------- 66 | 67 | Create a network and look at it 68 | 69 | .. code:: python 70 | 71 | import networkx as nx 72 | from netwulf import visualize 73 | 74 | G = nx.barabasi_albert_graph(100,m=1) 75 | visualize(G) 76 | 77 | |visualization example0| 78 | 79 | Changelog 80 | --------- 81 | 82 | Changes are logged in a `separate 83 | file `__. 84 | 85 | License 86 | ------- 87 | 88 | This project is licensed under the `MIT 89 | License `__. 90 | 91 | Contributing 92 | ------------ 93 | 94 | If you want to contribute to this project, please make sure to read the 95 | `code of 96 | conduct `__ 97 | and the `contributing 98 | guidelines `__. 99 | In case you're wondering about what to contribute, we're always 100 | collecting ideas of what we want to implement next in the `outlook 101 | notes `__. 102 | 103 | |Contributor Covenant| 104 | 105 | Dev notes 106 | --------- 107 | 108 | Clone and install this repository as 109 | 110 | .. code:: bash 111 | 112 | git clone --recurse-submodules -j8 git@github.com:benmaier/netwulf.git 113 | make 114 | 115 | Note that ``make`` per default lets ``pip`` install a development 116 | version of the repository. 117 | 118 | The JS base code in ``/netwulf/js/`` is a fork of `Ulf Aslak's 119 | interactive web 120 | app `__. If this 121 | repository is updated, change to ``/netwulf/js/``, then do 122 | 123 | .. code:: bash 124 | 125 | git fetch upstream 126 | git merge upstream/master 127 | git commit -m "merged" 128 | git push 129 | 130 | If you want to upload to PyPI, first convert the new ``README.md`` to 131 | ``README.rst`` 132 | 133 | .. code:: bash 134 | 135 | make readme 136 | 137 | It will give you warnings about bad ``.rst``-syntax. Fix those errors in 138 | ``README.rst``. Then wrap the whole thing 139 | 140 | .. code:: bash 141 | 142 | make pypi 143 | 144 | It will probably give you more warnings about ``.rst``-syntax. Fix those 145 | until the warnings disappear. Then do 146 | 147 | .. code:: bash 148 | 149 | make upload 150 | 151 | .. |logo| image:: https://github.com/benmaier/netwulf/raw/master/img/logo_small.png 152 | .. |example| image:: https://github.com/benmaier/netwulf/raw/master/img/simple_example.gif 153 | .. |DOI| image:: https://joss.theoj.org/papers/10.21105/joss.01425/status.svg 154 | :target: https://doi.org/10.21105/joss.01425 155 | .. |Documentation Status| image:: https://readthedocs.org/projects/netwulf/badge/?version=latest 156 | :target: https://netwulf.readthedocs.io/en/latest/?badge=latest 157 | .. |visualization example0| image:: https://github.com/benmaier/netwulf/raw/master/img/BA_1.png 158 | .. |Contributor Covenant| image:: https://img.shields.io/badge/Contributor%20Covenant-v1.4%20adopted-ff69b4.svg 159 | :target: code-of-conduct.md 160 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help 23 | help: 24 | @echo "Please use \`make ' where is one of" 25 | @echo " html to make standalone HTML files" 26 | @echo " dirhtml to make HTML files named index.html in directories" 27 | @echo " singlehtml to make a single large HTML file" 28 | @echo " pickle to make pickle files" 29 | @echo " json to make JSON files" 30 | @echo " htmlhelp to make HTML files and a HTML help project" 31 | @echo " qthelp to make HTML files and a qthelp project" 32 | @echo " applehelp to make an Apple Help Book" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | @echo " coverage to run coverage check of the documentation (if enabled)" 49 | 50 | .PHONY: clean 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | rm -rf _generate/* 54 | rm -rf reference/_generate/* 55 | 56 | .PHONY: html 57 | html: 58 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 61 | 62 | .PHONY: dirhtml 63 | dirhtml: 64 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 65 | @echo 66 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 67 | 68 | .PHONY: singlehtml 69 | singlehtml: 70 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 71 | @echo 72 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 73 | 74 | .PHONY: pickle 75 | pickle: 76 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 77 | @echo 78 | @echo "Build finished; now you can process the pickle files." 79 | 80 | .PHONY: json 81 | json: 82 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 83 | @echo 84 | @echo "Build finished; now you can process the JSON files." 85 | 86 | .PHONY: htmlhelp 87 | htmlhelp: 88 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 89 | @echo 90 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 91 | ".hhp project file in $(BUILDDIR)/htmlhelp." 92 | 93 | .PHONY: qthelp 94 | qthelp: 95 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 96 | @echo 97 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 98 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 99 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/_netwulf.qhcp" 100 | @echo "To view the help file:" 101 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/_netwulf.qhc" 102 | 103 | .PHONY: applehelp 104 | applehelp: 105 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 106 | @echo 107 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 108 | @echo "N.B. You won't be able to view it unless you put it in" \ 109 | "~/Library/Documentation/Help or install it in your application" \ 110 | "bundle." 111 | 112 | .PHONY: devhelp 113 | devhelp: 114 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 115 | @echo 116 | @echo "Build finished." 117 | @echo "To view the help file:" 118 | @echo "# mkdir -p $$HOME/.local/share/devhelp/_netwulf" 119 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/_netwulf" 120 | @echo "# devhelp" 121 | 122 | .PHONY: epub 123 | epub: 124 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 125 | @echo 126 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 127 | 128 | .PHONY: latex 129 | latex: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo 132 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 133 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 134 | "(use \`make latexpdf' here to do that automatically)." 135 | 136 | .PHONY: latexpdf 137 | latexpdf: 138 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 139 | @echo "Running LaTeX files through pdflatex..." 140 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 141 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 142 | 143 | .PHONY: latexpdfja 144 | latexpdfja: 145 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 146 | @echo "Running LaTeX files through platex and dvipdfmx..." 147 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 148 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 149 | 150 | .PHONY: text 151 | text: 152 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 153 | @echo 154 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 155 | 156 | .PHONY: man 157 | man: 158 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 159 | @echo 160 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 161 | 162 | .PHONY: texinfo 163 | texinfo: 164 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 165 | @echo 166 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 167 | @echo "Run \`make' in that directory to run these through makeinfo" \ 168 | "(use \`make info' here to do that automatically)." 169 | 170 | .PHONY: info 171 | info: 172 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 173 | @echo "Running Texinfo files through makeinfo..." 174 | make -C $(BUILDDIR)/texinfo info 175 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 176 | 177 | .PHONY: gettext 178 | gettext: 179 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 180 | @echo 181 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 182 | 183 | .PHONY: changes 184 | changes: 185 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 186 | @echo 187 | @echo "The overview file is in $(BUILDDIR)/changes." 188 | 189 | .PHONY: linkcheck 190 | linkcheck: 191 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 192 | @echo 193 | @echo "Link check complete; look for any errors in the above output " \ 194 | "or in $(BUILDDIR)/linkcheck/output.txt." 195 | 196 | .PHONY: doctest 197 | doctest: 198 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 199 | @echo "Testing of doctests in the sources finished, look at the " \ 200 | "results in $(BUILDDIR)/doctest/output.txt." 201 | 202 | .PHONY: coverage 203 | coverage: 204 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 205 | @echo "Testing of coverage in the sources finished, look at the " \ 206 | "results in $(BUILDDIR)/coverage/python.txt." 207 | 208 | .PHONY: xml 209 | xml: 210 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 211 | @echo 212 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 213 | 214 | .PHONY: pseudoxml 215 | pseudoxml: 216 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 217 | @echo 218 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 219 | 220 | rst: 221 | sphinx-apidoc -f -o . .. 222 | 223 | show: 224 | open -n -a "Google Chrome" "http://localhost:1313" 225 | cd _build/html && python -m "http.server" 1313 & 226 | 227 | adev: 228 | make clean 229 | make html 230 | python -m "http.server" 1313 231 | 232 | dev: 233 | make html 234 | python -m "http.server" 1313 235 | -------------------------------------------------------------------------------- /docs/about.rst: -------------------------------------------------------------------------------- 1 | About this project 2 | ================== 3 | 4 | Netwulf is an interactive visualization tool for networkx_ Graph-objects, 5 | that allows you to produce beautifully looking network visualizations. Simply 6 | input a `networkx.Graph` object, style the network in the interactive console 7 | and either download the result as a PNG or pipe the layout back to Python for 8 | further processing. Netwulf is fast and relies on no crude dependencies. 9 | It is build around the philosophy that network manipulation and preprocessing 10 | should be done programmatically, but that the efficient generation of a visually 11 | appealing network is best done interactively, without code. 12 | 13 | Quick example 14 | ------------- 15 | 16 | .. code:: python 17 | 18 | import networkx as nx 19 | from netwulf import visualize 20 | 21 | G = nx.barabasi_albert_graph(100,2) 22 | 23 | visualize(G) 24 | 25 | .. figure:: img/simple_example.gif 26 | 27 | started visualization 28 | 29 | Why should I use netwulf 30 | ------------------------ 31 | 32 | Pros 33 | ~~~~ 34 | 35 | - Interactive styling of network visualizations in the browser, started from Python 36 | - No compiling needed 37 | - No external program needed 38 | - Cross-platform 39 | - Seamlessly use the inferred style back in Python 40 | - Redraw the visualization in Python using matplotlib 41 | 42 | Cons 43 | ~~~~ 44 | 45 | - No multiedges yet 46 | - No rendering of directed edges 47 | 48 | 49 | Install 50 | ------- 51 | 52 | :: 53 | 54 | pip install netwulf 55 | 56 | Make sure to read the ``README.md`` in the `public repository`_ for notes on dependencies and installation. 57 | 58 | ``netwulf`` directly depends on the following packages which will be 59 | installed by ``pip`` during the installation process 60 | 61 | - ``networkx>=2.0`` 62 | - ``numpy>=0.14`` 63 | - ``matplotlib>=3.0`` 64 | - ``simplejson>=3.0`` 65 | 66 | 67 | Bug reports & contributing 68 | -------------------------- 69 | 70 | You can contribute to the `public repository`_ and `raise issues`_ there. Please also make sure to follow the `code of conduct`_ and to read the `contributing notes`_. 71 | 72 | 73 | .. _`public repository`: https://github.com/benmaier/netwulf 74 | .. _networkx: https://networkx.github.io/ 75 | .. _`raise issues`: https://github.com/benmaier/netwulf/issues/new 76 | .. _`code of conduct`: https://github.com/benmaier/netwulf/blob/master/CODE_OF_CONDUCT.md 77 | .. _`contributing notes`: https://github.com/benmaier/netwulf/blob/master/CONTRIBUTING.md 78 | 79 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # _netwulf documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Feb 26 00:29:33 2016. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | from mock import Mock as MagicMock 19 | 20 | USE_KATEX = False 21 | 22 | if USE_KATEX: 23 | import sphinxcontrib.katex as katex 24 | 25 | class Mock(MagicMock): 26 | @classmethod 27 | def __getattr__(cls, name): 28 | return Mock() 29 | 30 | MOCK_MODULES = ['numpy', 'scipy', 'matplotlib', 'matplotlib.pyplot', 'matplotlib.collections', 31 | 'lmfit', 'networkx', 'community', 32 | 'scipy.optimize', 'scipy.stats', 'scipy.special', 'scipy.integrate', 33 | 'scipy.sparse', 'scipy.sparse.linalg', 'scipy.linalg', 34 | 'numpy.polynomial', 'numpy.polynomial.polynomial', 'wget', 35 | 'lmfit','simplejson', 36 | ] 37 | 38 | sys.modules.update((mod_name, Mock()) for mod_name in MOCK_MODULES) 39 | 40 | # If extensions (or modules to document with autodoc) are in another directory, 41 | # add these directories to sys.path here. If the directory is relative to the 42 | # documentation root, use os.path.abspath to make it absolute, like shown here. 43 | sys.path.insert(0, os.path.abspath('.')) 44 | sys.path.insert(0, os.path.abspath('../')) 45 | 46 | # -- General configuration ------------------------------------------------ 47 | 48 | # If your documentation needs a minimal Sphinx version, state it here. 49 | #needs_sphinx = '1.0' 50 | 51 | # Add any Sphinx extension module names here, as strings. They can be 52 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 53 | # ones. 54 | extensions = [ 55 | 'sphinx.ext.autodoc', 56 | 'sphinx.ext.viewcode', 57 | 'sphinx.ext.intersphinx', 58 | 'sphinx.ext.autosummary', 59 | 'sphinx.ext.napoleon', 60 | ] 61 | 62 | if USE_KATEX: 63 | extensions.append('sphinxcontrib.katex') 64 | else: 65 | extensions.append('sphinx.ext.mathjax') 66 | autosummary_generate = True 67 | 68 | # Add any paths that contain templates here, relative to this directory. 69 | templates_path = ['_templates'] 70 | 71 | # The suffix(es) of source filenames. 72 | # You can specify multiple suffix as a list of string: 73 | # source_suffix = ['.rst', '.md'] 74 | source_suffix = '.rst' 75 | 76 | # The encoding of source files. 77 | #source_encoding = 'utf-8-sig' 78 | 79 | # The master toctree document. 80 | master_doc = 'index' 81 | 82 | # General information about the project. 83 | project = u'netwulf' 84 | copyright = u'2019, Benjamin F. Maier' 85 | author = u'Benjamin F. Maier, Ulf Aslak' 86 | 87 | # The version info for the project you're documenting, acts as replacement for 88 | # |version| and |release|, also used in various other places throughout the 89 | # built documents. 90 | # 91 | # The short X.Y version. 92 | exec(open("../netwulf/metadata.py").read()) 93 | 94 | version = __version__ 95 | # The full version, including alpha/beta/rc tags. 96 | release = version 97 | 98 | # The language for content autogenerated by Sphinx. Refer to documentation 99 | # for a list of supported languages. 100 | # 101 | # This is also used if you do content translation via gettext catalogs. 102 | # Usually you set "language" from the command line for these cases. 103 | language = None 104 | 105 | # There are two options for replacing |today|: either, you set today to some 106 | # non-false value, then it is used: 107 | #today = '' 108 | # Else, today_fmt is used as the format for a strftime call. 109 | #today_fmt = '%B %d, %Y' 110 | 111 | # List of patterns, relative to source directory, that match files and 112 | # directories to ignore when looking for source files. 113 | exclude_patterns = ['_build'] 114 | 115 | # The reST default role (used for this markup: `text`) to use for all 116 | # documents. 117 | #default_role = None 118 | 119 | # If true, '()' will be appended to :func: etc. cross-reference text. 120 | #add_function_parentheses = True 121 | 122 | # If true, the current module name will be prepended to all description 123 | # unit titles (such as .. function::). 124 | #add_module_names = True 125 | 126 | # If true, sectionauthor and moduleauthor directives will be shown in the 127 | # output. They are ignored by default. 128 | #show_authors = False 129 | 130 | # The name of the Pygments (syntax highlighting) style to use. 131 | pygments_style = 'sphinx' 132 | 133 | # A list of ignored prefixes for module index sorting. 134 | #modindex_common_prefix = [] 135 | 136 | # If true, keep warnings as "system message" paragraphs in the built documents. 137 | #keep_warnings = False 138 | 139 | # If true, `todo` and `todoList` produce output, else they produce nothing. 140 | todo_include_todos = False 141 | 142 | # -------------- KaTeX options -------------- 143 | 144 | if USE_KATEX: 145 | 146 | katex_options = r"""{ 147 | displayMode: true, 148 | macros: { 149 | "\\expv": "\\left\\langle #1\\right\\rangle", 150 | "\\inn": "\\mathrm{in}", 151 | "\\out": "\\mathrm{out}", 152 | "\\max": "\\mathrm{max}" 153 | } 154 | }""" 155 | 156 | else: 157 | mathjax_config = { 158 | 'TeX' : { 159 | 'Macros' : { 160 | 'expv' : ( r'{\\left\\langle #1\\right\\rangle}', 1 ), 161 | 'inn' : r'{\\mathrm{in}}', 162 | 'out' : r'{\\mathrm{out}}', 163 | 'max' : r'{\\mathrm{max}}' 164 | } 165 | } 166 | } 167 | 168 | 169 | # -- Options for HTML output ---------------------------------------------- 170 | 171 | # The theme to use for HTML and HTML Help pages. See the documentation for 172 | # a list of builtin themes. 173 | html_theme = 'sphinx_rtd_theme' 174 | 175 | # Theme options are theme-specific and customize the look and feel of a theme 176 | # further. For a list of options available for each theme, see the 177 | # documentation. 178 | #html_theme_options = {} 179 | 180 | # Add any paths that contain custom themes here, relative to this directory. 181 | #html_theme_path = [] 182 | 183 | # The name for this set of Sphinx documents. If None, it defaults to 184 | # " v documentation". 185 | #html_title = None 186 | 187 | # A shorter title for the navigation bar. Default is the same as html_title. 188 | #html_short_title = None 189 | 190 | # The name of an image file (relative to this directory) to place at the top 191 | # of the sidebar. 192 | html_logo = 'img/new_logo_very_small_2width.png' 193 | 194 | # The name of an image file (within the static path) to use as favicon of the 195 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 196 | # pixels large. 197 | html_favicon = 'img/favicon.ico' 198 | 199 | # Add any paths that contain custom static files (such as style sheets) here, 200 | # relative to this directory. They are copied after the builtin static files, 201 | # so a file named "default.css" will overwrite the builtin "default.css". 202 | html_static_path = ['_static'] 203 | 204 | # Add any extra paths that contain custom files (such as robots.txt or 205 | # .htaccess) here, relative to this directory. These files are copied 206 | # directly to the root of the documentation. 207 | #html_extra_path = [] 208 | 209 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 210 | # using the given strftime format. 211 | #html_last_updated_fmt = '%b %d, %Y' 212 | 213 | # If true, SmartyPants will be used to convert quotes and dashes to 214 | # typographically correct entities. 215 | #html_use_smartypants = True 216 | 217 | # Custom sidebar templates, maps document names to template names. 218 | #html_sidebars = {} 219 | 220 | # Additional templates that should be rendered to pages, maps page names to 221 | # template names. 222 | #html_additional_pages = {} 223 | 224 | # If false, no module index is generated. 225 | #html_domain_indices = True 226 | 227 | # If false, no index is generated. 228 | #html_use_index = True 229 | 230 | # If true, the index is split into individual pages for each letter. 231 | #html_split_index = False 232 | 233 | # If true, links to the reST sources are added to the pages. 234 | html_show_sourcelink = True 235 | 236 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 237 | #html_show_sphinx = True 238 | 239 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 240 | #html_show_copyright = True 241 | 242 | # If true, an OpenSearch description file will be output, and all pages will 243 | # contain a tag referring to it. The value of this option must be the 244 | # base URL from which the finished HTML is served. 245 | #html_use_opensearch = '' 246 | 247 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 248 | #html_file_suffix = None 249 | 250 | # Language to be used for generating the HTML full-text search index. 251 | # Sphinx supports the following languages: 252 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 253 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 254 | #html_search_language = 'en' 255 | 256 | # A dictionary with options for the search language support, empty by default. 257 | # Now only 'ja' uses this config value 258 | #html_search_options = {'type': 'default'} 259 | 260 | # The name of a javascript file (relative to the configuration directory) that 261 | # implements a search results scorer. If empty, the default will be used. 262 | #html_search_scorer = 'scorer.js' 263 | 264 | # Output file base name for HTML help builder. 265 | htmlhelp_basename = 'netwulfdoc' 266 | 267 | # -- Options for LaTeX output --------------------------------------------- 268 | 269 | latex_elements = { 270 | # The paper size ('letterpaper' or 'a4paper'). 271 | #'papersize': 'letterpaper', 272 | 273 | # The font size ('10pt', '11pt' or '12pt'). 274 | #'pointsize': '10pt', 275 | 276 | # Additional stuff for the LaTeX preamble. 277 | #'preamble': '', 278 | 279 | # Latex figure (float) alignment 280 | #'figure_align': 'htbp', 281 | } 282 | 283 | # Grouping the document tree into LaTeX files. List of tuples 284 | # (source start file, target name, title, 285 | # author, documentclass [howto, manual, or own class]). 286 | latex_documents = [ 287 | (master_doc, 'netwulf.tex', u'netwulf Documentation', 288 | u'Benjamin F. Maier', 'manual'), 289 | ] 290 | 291 | # The name of an image file (relative to this directory) to place at the top of 292 | # the title page. 293 | #latex_logo = None 294 | 295 | # For "manual" documents, if this is true, then toplevel headings are parts, 296 | # not chapters. 297 | #latex_use_parts = False 298 | 299 | # If true, show page references after internal links. 300 | #latex_show_pagerefs = False 301 | 302 | # If true, show URL addresses after external links. 303 | #latex_show_urls = False 304 | 305 | # Documents to append as an appendix to all manuals. 306 | #latex_appendices = [] 307 | 308 | # If false, no module index is generated. 309 | #latex_domain_indices = True 310 | 311 | 312 | # -- Options for manual page output --------------------------------------- 313 | 314 | # One entry per manual page. List of tuples 315 | # (source start file, name, description, authors, manual section). 316 | man_pages = [ 317 | (master_doc, 'netwulf', u'netwulf Documentation', 318 | [author], 1) 319 | ] 320 | 321 | # If true, show URL addresses after external links. 322 | #man_show_urls = False 323 | 324 | 325 | # -- Options for Texinfo output ------------------------------------------- 326 | 327 | # Grouping the document tree into Texinfo files. List of tuples 328 | # (source start file, target name, title, author, 329 | # dir menu entry, description, category) 330 | texinfo_documents = [ 331 | (master_doc, 'netwulf', u'netwulf Documentation', 332 | author, __author__, 'An interactive network visualization tool for Python.', 333 | 'Miscellaneous'), 334 | ] 335 | 336 | # Documents to append as an appendix to all manuals. 337 | #texinfo_appendices = [] 338 | 339 | # If false, no module index is generated. 340 | #texinfo_domain_indices = True 341 | 342 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 343 | #texinfo_show_urls = 'footnote' 344 | 345 | # If true, do not generate a @detailmenu in the "Top" node's menu. 346 | #texinfo_no_detailmenu = False 347 | 348 | 349 | # Example configuration for intersphinx: refer to the Python standard library. 350 | intersphinx_mapping = {'https://docs.python.org/': None} 351 | -------------------------------------------------------------------------------- /docs/cookbook/cookbook.rst: -------------------------------------------------------------------------------- 1 | Simplest use case 2 | ----------------- 3 | 4 | Given a *networkx.Graph* object, you can launch netwulf like so: 5 | 6 | .. code:: python 7 | 8 | import networkx as nx 9 | import netwulf as nw 10 | 11 | G = nx.barabasi_albert_graph(100, 2) 12 | 13 | wulf.visualize(G) # <-- THIS IS IT 14 | 15 | Alternatively, *netwulf.visualize* can accept a node-link dictionary object `formatted like this `_. 16 | 17 | 18 | Node and link attributes 19 | ------------------------ 20 | 21 | Netwulf recognizes node attributes 'group' and 'size', and link attribute 'weight'. 22 | Users can create a *networkx.Graph* object with node and link data: 23 | 24 | .. code:: python 25 | 26 | list(G.nodes(data=True))[:3] 27 | # [(0, {'group': 0, 'size': 0.20982489558943607}), 28 | # (1, {'group': 0, 'size': 0.7118952904573288}), 29 | # (2, {'group': 0, 'size': 0.8785902846905586})] 30 | 31 | list(G.edges(data=True))[:3] 32 | # [(0, 5, {'weight': 0.8917083938103719}), 33 | # (0, 9, {'weight': 0.29583879684946757}), 34 | # (0, 12, {'weight': 0.36847140599448236})] 35 | 36 | Example: 37 | 38 | .. code:: python 39 | 40 | import numpy as np 41 | import networkx as nx 42 | import netwulf as nw 43 | 44 | # Create a network 45 | G = nx.random_partition_graph([10, 10, 10], .25, .01) 46 | 47 | # Change 'block' node attribute to 'group' 48 | for k, v in G.nodes(data=True): 49 | v['group'] = v['block']; del v['block'] 50 | 51 | # Or detect communities and encode them in 'group' attribute 52 | # import community 53 | # bb = community.best_partition(G) 54 | # nx.set_node_attributes(G, bb, 'group') 55 | 56 | # Set node 'size' attributes 57 | for n, data in G.nodes(data=True): 58 | data['size'] = np.random.random() 59 | 60 | # Set link 'weight' attributes 61 | for n1, n2, data in G.edges(data=True): 62 | data['weight'] = np.random.random() 63 | 64 | nw.visualize(G) 65 | 66 | 67 | .. figure:: img/random_partition_graph.png 68 | 69 | Note: If 'group' is not a color (like "red" or "#4fba21") the group colors are assigned randomly. 70 | 71 | 72 | Initial node positions 73 | ---------------------- 74 | 75 | A network can be launched with initial node positions. 76 | If netwulf sees node-attributes 'x' and 'y' like: 77 | 78 | .. code:: python 79 | 80 | list(G.nodes(data=True))[:3] 81 | # [(0, {'x': 600, 'y': 400}), 82 | # (1, {'x': 550, 'y': 450}), 83 | # (2, {'x': 500, 'y': 500})] 84 | 85 | it freezes the nodes in these positions at launch. 86 | Nodes can be moved around in their frozen states. 87 | Positions are relaxed upon untoggling "Freeze", toggling "Wiggle" or changing any of the physics parameters. 88 | 89 | 90 | 91 | Save as PDF 92 | ----------- 93 | 94 | .. code:: python 95 | 96 | import networkx as nx 97 | import netwulf as nw 98 | import matplotlib.pyplot as plt 99 | 100 | G = nx.barabasi_albert_graph(100, 2) 101 | 102 | network, config = nw.visualize(G, plot_in_cell_below=False) 103 | 104 | fig, ax = nw.draw_netwulf(network, figsize=(10,10)) 105 | plt.savefig("myfigure.pdf") 106 | 107 | 108 | Labels and node positions 109 | ------------------------- 110 | 111 | .. code:: python 112 | 113 | import networkx as nx 114 | import netwulf as nw 115 | import matplotlib.pyplot as plt 116 | 117 | G = nx.Graph() 118 | G.add_nodes_from([0,1,2,'a','b','c']) 119 | G.add_edges_from([(0,1),('a','b')]) 120 | 121 | network, config = nw.visualize(G,config={'zoom':3}) 122 | 123 | # draw links only at first 124 | fig, ax = nw.draw_netwulf(network,draw_nodes=False) 125 | 126 | # get positions of two unconnected nodes to draw a link anyway 127 | v0 = nw.node_pos(network, 'c') 128 | v1 = nw.node_pos(network, 2) 129 | ax.plot([v0[0],v1[0]],[v0[1],v1[1]],c='#d95f02') 130 | 131 | # draw nodes now 132 | nw.draw_netwulf(network,fig,ax,draw_links=False) 133 | 134 | # add labels to a node and an edge 135 | nw.add_node_label(ax,network,'c') 136 | nw.add_edge_label(ax,network,('a','b')) 137 | 138 | .. figure:: img/labeled_graph.png 139 | -------------------------------------------------------------------------------- /docs/cookbook/img/labeled_graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/cookbook/img/labeled_graph.png -------------------------------------------------------------------------------- /docs/cookbook/img/random_partition_graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/cookbook/img/random_partition_graph.png -------------------------------------------------------------------------------- /docs/dev_notes/general_remarks.rst: -------------------------------------------------------------------------------- 1 | General remarks 2 | =============== 3 | 4 | Contributing 5 | ------------ 6 | 7 | You can contribute to the `public repository`_ and `raise issues`_ there. Please also make sure to follow the `code of conduct`_ and to read the `contributing notes`_. Make sure to also read about :ref:`testing` before you open a pull request. 8 | 9 | Install dev version 10 | ------------------- 11 | 12 | Clone and install this repository as 13 | 14 | .. code:: bash 15 | 16 | git clone --recurse-submodules -j8 git@github.com:benmaier/netwulf.git 17 | make 18 | 19 | Note that ``make`` per default lets ``pip`` install a development 20 | version of the repository. 21 | 22 | Update JS base 23 | -------------- 24 | 25 | The JS base code in ``/netwulf/js/`` is a fork of `Ulf Aslak's 26 | interactive web 27 | app `__. If this 28 | repository is updated, change to ``/netwulf/js/``, then do 29 | 30 | .. code:: bash 31 | 32 | git fetch upstream 33 | git merge upstream/master 34 | git commit -m "merged" 35 | git push 36 | 37 | Upload to PyPI 38 | -------------- 39 | 40 | If you want to upload to PyPI, first convert the new ``README.md`` to 41 | ``README.rst`` 42 | 43 | .. code:: bash 44 | 45 | make readme 46 | 47 | It will give you warnings about bad ``.rst``-syntax. Fix those errors in 48 | ``README.rst``. Then wrap the whole thing 49 | 50 | .. code:: bash 51 | 52 | make pypi 53 | 54 | It will probably give you more warnings about ``.rst``-syntax. Fix those 55 | until the warnings disappear. Then do 56 | 57 | .. code:: bash 58 | 59 | make upload 60 | 61 | .. _`public repository`: https://github.com/benmaier/netwulf 62 | .. _`raise issues`: https://github.com/benmaier/netwulf/issues/new 63 | .. _`code of conduct`: https://github.com/benmaier/netwulf/blob/master/CODE_OF_CONDUCT.md 64 | .. _`contributing notes`: https://github.com/benmaier/netwulf/blob/master/CONTRIBUTING.md 65 | 66 | -------------------------------------------------------------------------------- /docs/dev_notes/img/test_config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/dev_notes/img/test_config.png -------------------------------------------------------------------------------- /docs/dev_notes/img/test_filtering_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/dev_notes/img/test_filtering_01.png -------------------------------------------------------------------------------- /docs/dev_notes/img/test_filtering_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/dev_notes/img/test_filtering_02.png -------------------------------------------------------------------------------- /docs/dev_notes/img/test_matplotlib.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/dev_notes/img/test_matplotlib.png -------------------------------------------------------------------------------- /docs/dev_notes/img/test_posting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/dev_notes/img/test_posting.png -------------------------------------------------------------------------------- /docs/dev_notes/testing.rst: -------------------------------------------------------------------------------- 1 | .. _testing: 2 | 3 | Testing 4 | ======= 5 | 6 | Usage 7 | ----- 8 | 9 | We're using ``pytest`` and ``pytest-cov`` to test and produce test reports. 10 | In order to obtain those, change to the root directory of the repository and run 11 | 12 | .. code:: bash 13 | 14 | make test 15 | 16 | You will be confronted with an automated test (opened browser windows will close themselves) and 17 | with an output looking like this 18 | 19 | .. code:: bash 20 | 21 | ---------- coverage: platform darwin, python 3.7.3-final-0 ----------- 22 | Name Stmts Miss Cover 23 | ----------------------------------------------- 24 | netwulf/__init__.py 4 0 100% 25 | netwulf/interactive.py 154 24 84% 26 | netwulf/metadata.py 9 0 100% 27 | netwulf/tests/__init__.py 1 0 100% 28 | netwulf/tests/test_all.py 70 2 97% 29 | netwulf/tools.py 92 1 99% 30 | ----------------------------------------------- 31 | TOTAL 330 27 92% 32 | 33 | ================ 5 passed, 4 warnings in 58.81 seconds ================ 34 | 35 | If you open a pull request, make sure you ran the tests and copy the test report 36 | as a comment with your pull request like so 37 | 38 | .. code:: 39 | 40 | ``` 41 | COPY-PASTED REPORT OUTPUT 42 | ``` 43 | 44 | Tests 45 | ----- 46 | 47 | You can run the tests manually, as described in the following. 48 | Each of the functions explained below will produce output that you have 49 | to expect to see in the automated testing above, as well. 50 | 51 | First, the test class has to be initiated like this 52 | 53 | .. code:: python 54 | 55 | from netwulf.tests import Test 56 | 57 | T = Test() 58 | 59 | Posting 60 | ~~~~~~~ 61 | 62 | Test the posting functionality first. 63 | 64 | .. code:: python 65 | 66 | T.test_posting() 67 | 68 | A browser window will be opened with a visualization looking like this. 69 | 70 | .. figure:: img/test_posting.png 71 | 72 | Posting test 73 | 74 | It will close automatically. 75 | 76 | Config 77 | ~~~~~~~ 78 | 79 | The config test starts a visualization with a configuration where each entry differs from its default value. 80 | 81 | .. code:: python 82 | 83 | T.test_config_adaption() 84 | 85 | A browser window will be opened with a visualization looking like this. 86 | 87 | .. figure:: img/test_config.png 88 | 89 | Posting test 90 | 91 | It will close automatically. The test checks wether the returned configuration is equal to the posted configuration. 92 | 93 | Reproducibility 94 | ~~~~~~~~~~~~~~~ 95 | 96 | The reproducibility feature should work like this 97 | 98 | 1. Open a network visualization 99 | 2. stylize network 100 | 3. retrieve stylized network and configs 101 | 4. bind retrieved positions to network 102 | 5. start new visualization with bound positions and config 103 | 6. the new visualization should be exactly the same as the old visualization 104 | 105 | Run a test like this 106 | 107 | .. code:: python 108 | 109 | T.test_reproducibility() 110 | 111 | A browser window will be opened with a visualization looking like this. 112 | 113 | .. figure:: img/test_posting.png 114 | 115 | Reproducibility test 1 116 | 117 | It will close automatically. Afterwards, the positions will be bound to the graph object and the visualization will be restarted which should look exactly like the first visualization: 118 | 119 | .. figure:: img/test_posting.png 120 | 121 | Reproducibility test 2 122 | 123 | Then the second visualization will close automatically, too. 124 | 125 | Filtering 126 | ~~~~~~~~~ 127 | 128 | The filtering test constructs a network with two edges of two different edge weight attributes. In the first test, the network is filtered for the first edge weight attribute. In the second test, it is filtered for the second attribute and also assigns a group attribute to the nodes. Start the test like this: 129 | 130 | .. code:: python 131 | 132 | T.test_filtering() 133 | 134 | A browser window will be opened with a visualization looking similar to this: 135 | 136 | .. figure:: img/test_filtering_01.png 137 | 138 | Filtering test 1 139 | 140 | It will close automatically. Afterwards, groups and edge weight attributes will be changed and a second visualization will be started, looking similar to this: 141 | 142 | .. figure:: img/test_filtering_02.png 143 | 144 | Filtering test 2 145 | 146 | Note that the second color will probably not be pink, as it is randomly assigned each time. The second visualization window will close automatically, too. 147 | 148 | Matlotlib redrawing 149 | ~~~~~~~~~~~~~~~~~~~ 150 | 151 | A stylized network is supposed to be reproduced by the function :mod:`netwulf.tools.draw_netwulf`. Start the test like this 152 | 153 | .. code:: python 154 | 155 | T.test_matplotlib() 156 | 157 | The resulting figure should look like this: 158 | 159 | 160 | .. figure:: img/test_matplotlib.png 161 | 162 | matplotlib test 1 163 | 164 | Data I/O 165 | ~~~~~~~~ 166 | 167 | A stylized network is supposed to be saved and loaded by the functions in the module :mod:`netwulf.io`. Start the test like this 168 | 169 | .. code:: python 170 | 171 | T.test_io() 172 | 173 | The test network will be styled in netwulf, saved, loaded and then redrawn in matplotlib. Hence the figure should look like the netwulf visualization. 174 | -------------------------------------------------------------------------------- /docs/img/BA.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/img/BA.png -------------------------------------------------------------------------------- /docs/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/img/favicon.ico -------------------------------------------------------------------------------- /docs/img/favicon.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/img/favicon.xcf -------------------------------------------------------------------------------- /docs/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/img/logo.png -------------------------------------------------------------------------------- /docs/img/logo_small_title.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/img/logo_small_title.xcf -------------------------------------------------------------------------------- /docs/img/new_logo_small.idraw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/img/new_logo_small.idraw -------------------------------------------------------------------------------- /docs/img/new_logo_very_small_2width.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/img/new_logo_very_small_2width.png -------------------------------------------------------------------------------- /docs/img/simple_example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/img/simple_example.gif -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. image:: img/logo.png 2 | :scale: 50 % 3 | 4 | 5 | 6 | Netwulf - Interactive Network Visualization 7 | =========================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 1 11 | 12 | about 13 | 14 | .. toctree:: 15 | :maxdepth: 2 16 | :caption: Python API 17 | 18 | python_api/start 19 | python_api/post_back 20 | python_api/drawing 21 | python_api/network_manipulation 22 | python_api/data_io 23 | 24 | .. toctree:: 25 | :maxdepth: 2 26 | :caption: Visualization 27 | 28 | visualization/init 29 | 30 | .. toctree:: 31 | :maxdepth: 2 32 | :caption: Cookbook 33 | 34 | cookbook/cookbook 35 | 36 | .. toctree:: 37 | :maxdepth: 2 38 | :caption: Dev notes 39 | 40 | dev_notes/general_remarks 41 | dev_notes/testing 42 | 43 | .. toctree:: 44 | :maxdepth: 2 45 | :caption: Reference 46 | 47 | reference/interactive 48 | reference/tools 49 | reference/io 50 | 51 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | echo. coverage to run coverage check of the documentation if enabled 41 | goto end 42 | ) 43 | 44 | if "%1" == "clean" ( 45 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 46 | del /q /s %BUILDDIR%\* 47 | goto end 48 | ) 49 | 50 | 51 | REM Check if sphinx-build is available and fallback to Python version if any 52 | %SPHINXBUILD% 1>NUL 2>NUL 53 | if errorlevel 9009 goto sphinx_python 54 | goto sphinx_ok 55 | 56 | :sphinx_python 57 | 58 | set SPHINXBUILD=python -m sphinx.__init__ 59 | %SPHINXBUILD% 2> nul 60 | if errorlevel 9009 ( 61 | echo. 62 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 63 | echo.installed, then set the SPHINXBUILD environment variable to point 64 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 65 | echo.may add the Sphinx directory to PATH. 66 | echo. 67 | echo.If you don't have Sphinx installed, grab it from 68 | echo.http://sphinx-doc.org/ 69 | exit /b 1 70 | ) 71 | 72 | :sphinx_ok 73 | 74 | 75 | if "%1" == "html" ( 76 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 80 | goto end 81 | ) 82 | 83 | if "%1" == "dirhtml" ( 84 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 88 | goto end 89 | ) 90 | 91 | if "%1" == "singlehtml" ( 92 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 93 | if errorlevel 1 exit /b 1 94 | echo. 95 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 96 | goto end 97 | ) 98 | 99 | if "%1" == "pickle" ( 100 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 101 | if errorlevel 1 exit /b 1 102 | echo. 103 | echo.Build finished; now you can process the pickle files. 104 | goto end 105 | ) 106 | 107 | if "%1" == "json" ( 108 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 109 | if errorlevel 1 exit /b 1 110 | echo. 111 | echo.Build finished; now you can process the JSON files. 112 | goto end 113 | ) 114 | 115 | if "%1" == "htmlhelp" ( 116 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 117 | if errorlevel 1 exit /b 1 118 | echo. 119 | echo.Build finished; now you can run HTML Help Workshop with the ^ 120 | .hhp project file in %BUILDDIR%/htmlhelp. 121 | goto end 122 | ) 123 | 124 | if "%1" == "qthelp" ( 125 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 129 | .qhcp project file in %BUILDDIR%/qthelp, like this: 130 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\python_example.qhcp 131 | echo.To view the help file: 132 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\python_example.ghc 133 | goto end 134 | ) 135 | 136 | if "%1" == "devhelp" ( 137 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. 141 | goto end 142 | ) 143 | 144 | if "%1" == "epub" ( 145 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 149 | goto end 150 | ) 151 | 152 | if "%1" == "latex" ( 153 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 157 | goto end 158 | ) 159 | 160 | if "%1" == "latexpdf" ( 161 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 162 | cd %BUILDDIR%/latex 163 | make all-pdf 164 | cd %~dp0 165 | echo. 166 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdfja" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf-ja 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "text" ( 181 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 182 | if errorlevel 1 exit /b 1 183 | echo. 184 | echo.Build finished. The text files are in %BUILDDIR%/text. 185 | goto end 186 | ) 187 | 188 | if "%1" == "man" ( 189 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 190 | if errorlevel 1 exit /b 1 191 | echo. 192 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 193 | goto end 194 | ) 195 | 196 | if "%1" == "texinfo" ( 197 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 198 | if errorlevel 1 exit /b 1 199 | echo. 200 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 201 | goto end 202 | ) 203 | 204 | if "%1" == "gettext" ( 205 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 206 | if errorlevel 1 exit /b 1 207 | echo. 208 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 209 | goto end 210 | ) 211 | 212 | if "%1" == "changes" ( 213 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 214 | if errorlevel 1 exit /b 1 215 | echo. 216 | echo.The overview file is in %BUILDDIR%/changes. 217 | goto end 218 | ) 219 | 220 | if "%1" == "linkcheck" ( 221 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 222 | if errorlevel 1 exit /b 1 223 | echo. 224 | echo.Link check complete; look for any errors in the above output ^ 225 | or in %BUILDDIR%/linkcheck/output.txt. 226 | goto end 227 | ) 228 | 229 | if "%1" == "doctest" ( 230 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 231 | if errorlevel 1 exit /b 1 232 | echo. 233 | echo.Testing of doctests in the sources finished, look at the ^ 234 | results in %BUILDDIR%/doctest/output.txt. 235 | goto end 236 | ) 237 | 238 | if "%1" == "coverage" ( 239 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 240 | if errorlevel 1 exit /b 1 241 | echo. 242 | echo.Testing of coverage in the sources finished, look at the ^ 243 | results in %BUILDDIR%/coverage/python.txt. 244 | goto end 245 | ) 246 | 247 | if "%1" == "xml" ( 248 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 249 | if errorlevel 1 exit /b 1 250 | echo. 251 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 252 | goto end 253 | ) 254 | 255 | if "%1" == "pseudoxml" ( 256 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 257 | if errorlevel 1 exit /b 1 258 | echo. 259 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 260 | goto end 261 | ) 262 | 263 | :end 264 | -------------------------------------------------------------------------------- /docs/python_api/data_io.rst: -------------------------------------------------------------------------------- 1 | Data I/O 2 | -------- 3 | 4 | It's not too hard to dump the returned visualization session to a json-file 5 | to restore it easily, but we wrote a wrapper for that nevertheless. 6 | 7 | Start a visualization like this 8 | 9 | .. code:: python 10 | 11 | import networkx as nx 12 | import netwulf as nw 13 | 14 | G = nx.barabasi_albert_graph(100,2) 15 | 16 | stylized_network, config = nw.visualize(G) 17 | 18 | You can either save/load the stylized network only 19 | 20 | .. code:: python 21 | 22 | nw.save("BA.json", stylized_network, config) 23 | stylized_network, config, _ = nw.load("BA.json") 24 | nw.draw_netwulf(stylized_network, config) 25 | 26 | 27 | Or you can save/load with the respective ``networkx.Graph``-object 28 | in order to replicate some other features. 29 | 30 | .. code:: python 31 | 32 | nw.save("BA.json", stylized_network, config, G) 33 | stylized_network, config, G = nw.load("BA.json") 34 | 35 | -------------------------------------------------------------------------------- /docs/python_api/drawing.rst: -------------------------------------------------------------------------------- 1 | Reproducing the figure in Python 2 | -------------------------------- 3 | 4 | Once retrieved, the stylized network data can be used 5 | to reproduce the figure in Python. To this end you can use 6 | the function :mod:`netwulf.tools.draw_netwulf`. 7 | 8 | .. code:: python 9 | 10 | import networkx as nx 11 | import netwulf as nw 12 | 13 | G = nx.barabasi_albert_graph(100,1) 14 | 15 | stylized_network, config = nw.visualize(G) 16 | 17 | import matplotlib.pyplot as plt 18 | fig, ax = nw.draw_netwulf(stylized_network) 19 | plt.show() 20 | 21 | 22 | A visualization window is opened and the network can be stylized. 23 | Once you're done, press the button `Post to Python`. Afterwards, 24 | the figure will be redrawn in matplotlib and opened. 25 | 26 | .. figure:: img/reproduced_figure.png 27 | 28 | Reproduced figure 29 | 30 | In order to add labels, use netwulf's functions 31 | :mod:`netwulf.tools.add_edge_label` 32 | or 33 | :mod:`netwulf.tools.add_node_label`. 34 | 35 | .. code:: python 36 | 37 | add_edge_label(ax, stylized_network, (0,1)) 38 | add_node_label(ax, stylized_network, 9) 39 | 40 | This will add the node id and edge tuple to the figure. You can add an optional label string as 41 | 42 | .. code:: python 43 | 44 | add_edge_label(ax, stylized_network, (0,1), label='this edge') 45 | add_node_label(ax, stylized_network, 9, label='this node') 46 | 47 | For additional styling options check out the respective functions docstrings at 48 | :mod:`netwulf.tools.add_edge_label` 49 | or 50 | :mod:`netwulf.tools.add_node_label`. 51 | -------------------------------------------------------------------------------- /docs/python_api/img/figure_in_jupyter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/python_api/img/figure_in_jupyter.png -------------------------------------------------------------------------------- /docs/python_api/img/post_to_python.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/python_api/img/post_to_python.png -------------------------------------------------------------------------------- /docs/python_api/img/reproduced_figure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/python_api/img/reproduced_figure.png -------------------------------------------------------------------------------- /docs/python_api/network_manipulation.rst: -------------------------------------------------------------------------------- 1 | Network manipulation 2 | -------------------- 3 | 4 | Filtering 5 | ~~~~~~~~~ 6 | 7 | Sometimes you have multiple edge or node attributes which can be 8 | used to style your network. Using :mod:`netwulf.tools.get_filtered_network` 9 | you can get a filtered network, where ``edge_weight_key`` is the name 10 | of the edge attribute which will be used as the weight in the visualization 11 | and ``node_group_key`` is the node attributed following which the nodes 12 | will be grouped and colored in the visualization. Here's an example 13 | 14 | .. code:: python 15 | 16 | import networkx as nx 17 | import netwulf as nw 18 | 19 | import numpy as np 20 | 21 | G = nx.barabasi_albert_graph(100,1) 22 | 23 | for u, v in G.edges(): 24 | # assign two random edge values to the network 25 | G[u][v]['foo'] = np.random.rand() 26 | G[u][v]['bar'] = np.random.rand() 27 | 28 | # filter the Graph to visualize one where the weight is determined by 'foo' 29 | new_G = nw.get_filtered_network(G,edge_weight_key='foo') 30 | nw.visualize(new_G) 31 | 32 | # assign node attributes according to some generic grouping 33 | grp = {u: 'ABCDE'[u%5] for u in G.nodes() } 34 | nx.set_node_attributes(G, grp, 'wum') 35 | 36 | # filter the Graph to visualize one where the weight is determined by 'bar' 37 | # and the node group (coloring) is determined by the node attribute 'wum' 38 | new_G = nw.get_filtered_network(G,edge_weight_key='bar',node_group_key='wum') 39 | nw.visualize(new_G) 40 | 41 | Binding positions 42 | ~~~~~~~~~~~~~~~~~ 43 | 44 | If the network has node attributes ``'x'`` and ``'y'``, those will be used as default 45 | values in the visualization. In order to reproduce a visualization or continue 46 | where you left off last time, you can bind the positions to the network 47 | 48 | .. code:: python 49 | 50 | nw.bind_positions_to_network(G, stylized_network) 51 | 52 | There's no return value and the positions are directly written to the Graph-object ``G``. 53 | 54 | 55 | -------------------------------------------------------------------------------- /docs/python_api/post_back.rst: -------------------------------------------------------------------------------- 1 | Use network style in Python 2 | --------------------------- 3 | 4 | The network data tuned by the visualization can be posted back 5 | to Python. The visualization function can actually return 6 | two dictionaries, the first containing information about the stylized 7 | network, the second containing information about the 8 | visualization control configuration. 9 | 10 | Start a visualization like this 11 | 12 | .. code:: python 13 | 14 | import networkx as nx 15 | import netwulf as nw 16 | 17 | G = nx.barabasi_albert_graph(100,2) 18 | 19 | stylized_network, config = nw.visualize(G) 20 | 21 | 22 | A visualization window is opened and the network can be stylized. 23 | Once you're done, press the button `Post to Python`. 24 | 25 | .. figure:: img/post_to_python.png 26 | :width: 500 27 | 28 | Pressing this button will pipe the data back to Python and close the browser 29 | The Python kernel is busy until either `Post to Python` is clicked or a 30 | ``KeyboardInterrupt`` signal is send (manually or using the `Stop`-Button 31 | in a Jupyter notebook). 32 | 33 | The returned stylized network dictionary will contain all the necessary information 34 | to reproduce the figure. It will look something like this. 35 | 36 | .. code:: python 37 | 38 | stylized_network = { 39 | 'xlim': [0, 833], 40 | 'ylim': [0, 833], 41 | 'linkColor': '#7c7c7c', 42 | 'linkAlpha': 0.5, 43 | 'nodeStrokeColor': '#000000', 44 | 'nodeStrokeWidth': 0.75, 45 | 'links': [ 46 | {'source': 0, 'target': 2, 'width': 3}, 47 | {'source': 0, 'target': 3, 'width': 3}, 48 | {'source': 0, 'target': 4, 'width': 3}, 49 | {'source': 1, 'target': 2, 'width': 3}, 50 | {'source': 1, 'target': 3, 'width': 3}, 51 | {'source': 1, 'target': 4, 'width': 3} 52 | ], 53 | 'nodes': [ 54 | {'id': 0, 55 | 'x': 436.0933431058901, 56 | 'y': 431.72418500564186, 57 | 'radius': 20, 58 | 'color': '#16a085'}, 59 | {'id': 1, 60 | 'x': 404.62184898400426, 61 | 'y': 394.8158724310507, 62 | 'radius': 20, 63 | 'color': '#16a085'}, 64 | {'id': 2, 65 | 'x': 409.15148692745356, 66 | 'y': 438.08415417584683, 67 | 'radius': 20, 68 | 'color': '#16a085'}, 69 | {'id': 3, 70 | 'x': 439.27989436871223, 71 | 'y': 397.14932001193233, 72 | 'radius': 20, 73 | 'color': '#16a085'}, 74 | {'id': 4, 75 | 'x': 393.4680683212157, 76 | 'y': 420.63184247673917, 77 | 'radius': 20, 78 | 'color': '#16a085'} 79 | ] 80 | } 81 | 82 | 83 | Furthermore, the configuration dictionary 84 | which was used to generate this figure will resemble 85 | 86 | .. code:: python 87 | 88 | default_config = { 89 | # Input/output 90 | 'zoom': 1, 91 | # Physics 92 | 'node_charge': -45, 93 | 'node_gravity': 0.1, 94 | 'link_distance': 15, 95 | 'link_distance_variation': 0, 96 | 'node_collision': True, 97 | 'wiggle_nodes': False, 98 | 'freeze_nodes': False, 99 | # Nodes 100 | 'node_fill_color': '#79aaa0', 101 | 'node_stroke_color': '#555555', 102 | 'node_label_color': '#000000', 103 | 'display_node_labels': False, 104 | 'scale_node_size_by_strength': False, 105 | 'node_size': 5, 106 | 'node_stroke_width': 1, 107 | 'node_size_variation': 0.5, 108 | # Links 109 | 'link_color': '#7c7c7c', 110 | 'link_width': 2, 111 | 'link_alpha': 0.5, 112 | 'link_width_variation': 0.5, 113 | # Thresholding 114 | 'display_singleton_nodes': True, 115 | 'min_link_weight_percentile': 0, 116 | 'max_link_weight_percentile': 1 117 | } 118 | 119 | If the visualization was started from a Jupyter notebook, a picture of the stylized 120 | network will appear in the cell below. 121 | 122 | .. figure:: img/figure_in_jupyter.png 123 | 124 | Stylized network in a Jupyter notebook. 125 | 126 | In order to reproduce this visualization, you may want to call the visualization function 127 | again with, passing the produced configuration. 128 | 129 | .. code:: python 130 | 131 | nw.visualize(G, config=config) 132 | -------------------------------------------------------------------------------- /docs/python_api/start.rst: -------------------------------------------------------------------------------- 1 | Starting a visualization 2 | ------------------------ 3 | 4 | The interactive visualization is started from Python. 5 | In this module, we introduce the main functionalities 6 | supplied by the Python package. 7 | 8 | A visualization is started as follows. 9 | 10 | .. code:: python 11 | 12 | import networkx as nx 13 | from netwulf import visualize 14 | 15 | G = nx.barabasi_albert_graph(100,2) 16 | 17 | visualize(G) 18 | 19 | 20 | A visualization window is opened and the network can be stylized. 21 | -------------------------------------------------------------------------------- /docs/reference/interactive.rst: -------------------------------------------------------------------------------- 1 | Interactive module 2 | ------------------ 3 | 4 | .. automodule:: netwulf.interactive 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | -------------------------------------------------------------------------------- /docs/reference/io.rst: -------------------------------------------------------------------------------- 1 | Data I/O module 2 | --------------- 3 | 4 | .. automodule:: netwulf.io 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | -------------------------------------------------------------------------------- /docs/reference/tools.rst: -------------------------------------------------------------------------------- 1 | Tools module 2 | ------------ 3 | 4 | .. automodule:: netwulf.tools 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | pip>=18.0 2 | 3 | -------------------------------------------------------------------------------- /docs/visualization/img/control_panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/visualization/img/control_panel.png -------------------------------------------------------------------------------- /docs/visualization/img/io.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/visualization/img/io.png -------------------------------------------------------------------------------- /docs/visualization/img/links.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/visualization/img/links.png -------------------------------------------------------------------------------- /docs/visualization/img/nodes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/visualization/img/nodes.png -------------------------------------------------------------------------------- /docs/visualization/img/physics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/visualization/img/physics.png -------------------------------------------------------------------------------- /docs/visualization/img/thresholding.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/docs/visualization/img/thresholding.png -------------------------------------------------------------------------------- /docs/visualization/init.rst: -------------------------------------------------------------------------------- 1 | Interactive visualization 2 | ------------------------- 3 | 4 | The interactive visualization gives the user an intuitive interface through which to manipulate and style their networks. Most of the parameters are intuitive, and each one has a short tooltip explanation that 5 | is revealed upon hovering. The control panel is scrollable and sections can be collapsed. 6 | 7 | Physics 8 | ~~~~~~~ 9 | 10 | .. figure:: img/physics.png 11 | :width: 500 12 | 13 | The network layout is computed using the `d3-force `_ module. In the interactive visualization users can control its three main parameters: 14 | 15 | * *Charge*: Implemented with the `d3.forceManyBody `_. The lower (more negative) the charge the more nodes, like electrons, repel each other. 16 | * *Gravity*: Implemented with the `d3.forceManyBody `_. Gravity pulls nodes towards the canvas center. Zero gravity lets connected components and single nodes float away entirely. 17 | * *Link distance*. Implemented with `d3.forceLink `_. Sets the optimal distance of links. 18 | 19 | The user is *not* given control over the `link.strength `_ because changing it from default rarely benefits the layout (though, in `an earlier version `_ the 'Link strength exponent' was a control parameter that users could tweak to change variation in link strengths). 20 | 21 | Other physics parameters are: 22 | 23 | * *Link distance variation*. Shortens the optimal distance of strong links. This is mostly useful in sparse networks as the link strengths in dense networks are small. Works well when link distance is large. 24 | * *Collision*: Implemented with `d3.forceCollide `_ with default strength (0.7) and makes overlapping nodes more unlikely. 25 | * *Wiggle*: Sets the simulation *alphaTarget* to 1, causing nodes to move more freely. Useful for "wiggling" nodes out of place if the layout seems stuck in a local minumum. 26 | * *Freeze*: Stops the force simulation. The simulation is restarted when this is untoggled. When the network nodes have initial positions (given as node-attributes "x" and "y"), "Freeze" is enabled at launch, so that the initial positions are respected. 27 | 28 | 29 | Nodes 30 | ~~~~~ 31 | 32 | .. figure:: img/nodes.png 33 | :width: 500 34 | 35 | * *Fill*: The color of nodes. If nodes have a "group" attribute, each group had its own random color, and changing *Fill* changes the colors of all groups continuously. Users, therefore, *cannot* control the exact colors that groups have inside of the interaction visualization. If this is desired, the "group" attribute should be an accepted color suck as "red" or "#4b23df". 36 | * *Stroke*: The color of the ring around each node. 37 | * *Label color*: Specified the color of labels. Labels are only visible if they are toggled or if nodes are clicked. 38 | * *Size*: The size of the largest node. 39 | * *Stroke width*: Width of the ring around each node. 40 | * *Size variation*: The variation of node sizes. Makes larger nodes larger and smaller nodes smaller. Only effective when nodes have a "size" attribute, or *Size by strength* is toggled. 41 | * *Display labels*: Display all node labels. Then untoggled, *all* node labels are cleared, also on nodes that were clicked. 42 | * *Size by strength*: Compute the strength of each node (i.e. weighted degree), and size each node by this value. If untoggled, nodes are either uniformly sized or sized after the "size" attribute optionally specified on each node. 43 | 44 | 45 | Links 46 | ~~~~~ 47 | 48 | .. figure:: img/links.png 49 | :width: 500 50 | 51 | * *Color*: The color of links. 52 | * *Width*: The width of the widest link. Widths are scaled by the "weight" attribute, optionally specified on each link. 53 | * *Alpha*: The transparency level of links. Useful in large dense networks. 54 | * *Width variation*: The variation of link widths. Only effective when "weight" attributed are specified on each link. 55 | 56 | 57 | Thresholding 58 | ~~~~~~~~~~~~ 59 | 60 | .. figure:: img/thresholding.png 61 | :width: 500 62 | 63 | * *Singleton nodes*: Display nodes that are not connected to any other nodes. Per default this is untoggled. 64 | * *Min. link percentile*: The lower threshold on link weights. Thresholds on percentiles and not actual weights (since link weight distributions are often heavy-tailed). For example, if *Min. link percentile* is 0.25, the 25% weakest links are removed. 65 | * *Max. link percentile*: The upper threshold on link weights. 66 | 67 | 68 | Online version 69 | ~~~~~~~~~~~~~~ 70 | 71 | An online version of the interactive visualization exists here_. 72 | It allows users to upload or specify a URL to a network in either JSON or CSV format. 73 | JSON formatted networks have at minimum two keys: "nodes" and "links". 74 | Each contains a list with nodes and links, respectively. 75 | See our example JSON_ and CSV_ files. An alternative CSV format is 76 | 77 | .. code:: 78 | 79 | source,target 80 | node0,node1 81 | node2,node2 82 | 83 | This data represents a network of three nodes with an edge between ``node0`` and ``node1`` 84 | and an isolated node ``node2``. 85 | 86 | In the online version, users can save, export and reset parameter presets using the top bar control panel: 87 | 88 | .. figure:: img/control_panel.png 89 | :width: 500 90 | 91 | It's useful if you have found a style you like and want save it for later. 92 | What you can do then, is click 'New' and give that parameter preset a name. 93 | When you load a new network (or close the browser and come back) your browser will remember those values. 94 | If you want to export your preset as JSON, you can click the "gears" icon. 95 | 96 | .. _here: https://ulfaslak.com/works/network_styling_with_d3/index.html 97 | .. _JSON: https://gist.githubusercontent.com/ulfaslak/6be66de1ac3288d5c1d9452570cbba5a/raw/0b9595c09b9f70a77ee05ca16d5a8b42a9130c9e/miserables.json 98 | .. _CSV: https://gist.githubusercontent.com/ulfaslak/66a0baa60b6fe1a5e4cc0891b2b1017d/raw/1cba9e4fbf3d0cec7c6c4f0ff6ab3fb54609f2d3/miserables.csv 99 | -------------------------------------------------------------------------------- /img/BA_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/img/BA_1.png -------------------------------------------------------------------------------- /img/BA_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/img/BA_2.png -------------------------------------------------------------------------------- /img/attributes_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/img/attributes_1.png -------------------------------------------------------------------------------- /img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/img/logo.png -------------------------------------------------------------------------------- /img/logo.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/img/logo.xcf -------------------------------------------------------------------------------- /img/logo_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/img/logo_small.png -------------------------------------------------------------------------------- /img/logo_smaller.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/img/logo_smaller.xcf -------------------------------------------------------------------------------- /img/netwulf.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/img/netwulf.mp4 -------------------------------------------------------------------------------- /img/simple_example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/img/simple_example.gif -------------------------------------------------------------------------------- /netwulf/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | netwulf offers an easy interface between networkx-network data 4 | and Ulf Aslak's d3-web app for network visualization. 5 | """ 6 | 7 | from .metadata import __version__ 8 | from .interactive import * 9 | from .tools import * 10 | from .io import * 11 | -------------------------------------------------------------------------------- /netwulf/interactive.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | This module provides the necessary functions to start up a local 4 | HTTP server and open an interactive d3-visualization of a network. 5 | """ 6 | from __future__ import print_function 7 | 8 | import os 9 | import sys 10 | import simplejson as json 11 | from distutils.dir_util import copy_tree 12 | import base64 13 | import http.server 14 | import webbrowser 15 | import time 16 | import threading 17 | from copy import deepcopy 18 | import shutil 19 | from io import BytesIO 20 | import pathlib 21 | 22 | import numpy 23 | 24 | import networkx as nx 25 | import netwulf as wulf 26 | 27 | netwulf_user_folder = pathlib.Path('~/.netwulf/').expanduser() 28 | html_source_path = (pathlib.Path(wulf.__path__[0]) / 'js').expanduser() 29 | 30 | def _json_default(o): 31 | if isinstance(o, numpy.int64): return int(o) 32 | elif isinstance(o, numpy.float64): return float(o) 33 | raise TypeError 34 | 35 | def mkdirp_customdir(directory=None): 36 | """simulate `mkdir -p` functionality""" 37 | if directory is None: 38 | directory = netwulf_user_folder 39 | 40 | try: 41 | directory = pathlib.Path(directory).expanduser().resolve() 42 | except FileNotFoundError as e: 43 | directory = pathlib.Path(directory).expanduser() # Python 3.5 compliant 44 | 45 | directory.mkdir(parents=True, exist_ok=True) 46 | 47 | def prepare_visualization_directory(): 48 | """Move all files from the netwulf/js directory to ~/.netwulf""" 49 | src = html_source_path 50 | dst = netwulf_user_folder 51 | 52 | # always copy source files to the subdirectory 53 | copy_tree(str(src), str(dst)) 54 | 55 | class NetwulfHTTPServer(http.server.HTTPServer): 56 | """Custom netwulf server class adapted from 57 | https://stackoverflow.com/questions/268629/how-to-stop-basehttpserver-serve-forever-in-a-basehttprequesthandler-subclass """ 58 | 59 | # The handler will write in this attribute 60 | posted_network_properties = None 61 | posted_config = None 62 | posted_image_base64 = None 63 | 64 | end_requested = False 65 | 66 | def __init__(self, server_address, handler, subjson, verbose=False): 67 | http.server.HTTPServer.__init__(self, server_address, handler) 68 | self.subjson = subjson 69 | self.verbose = verbose 70 | 71 | def run(self): 72 | try: 73 | self.serve_forever() 74 | except OSError: 75 | pass 76 | 77 | def serve_forever(self): 78 | """Handle one request at a time until doomsday.""" 79 | while not self.end_requested: 80 | self.handle_request() 81 | if self.verbose: 82 | print("serve_forever() terminated") 83 | 84 | def stop_this(self): 85 | # Clean-up server (close socket, etc.) 86 | if self.verbose: 87 | print('was asked to stop the server') 88 | self.server_close() 89 | 90 | # try: 91 | for f in self.subjson: 92 | fPath = pathlib.Path(f) 93 | if fPath.exists(): 94 | fPath.unlink() 95 | 96 | if self.verbose: 97 | print('deleted all files') 98 | 99 | 100 | class NetwulfHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): 101 | """A custom handler class adapted from 102 | https://stackoverflow.com/questions/6204029/extending-basehttprequesthandler-getting-the-posted-data 103 | and 104 | https://blog.anvileight.com/posts/simple-python-http-server/#do-post 105 | """ 106 | 107 | def do_POST(self): 108 | content_length = int(self.headers['Content-Length']) 109 | 110 | # an empty POST means the server should be stopped 111 | if content_length == 0: 112 | try: 113 | body = self.rfile.read(content_length) 114 | self.send_response(200) 115 | self.end_headers() 116 | response = BytesIO() 117 | response.write(b'Closing now.') 118 | self.wfile.write(response.getvalue()) 119 | except: #this should actually catch a ConnectionError for windows or firefox 120 | pass 121 | self.server.end_requested = True 122 | else: 123 | body = self.rfile.read(content_length) 124 | self.send_response(200) 125 | self.end_headers() 126 | response = BytesIO() 127 | response.write(b'Successful POST request.') 128 | self.wfile.write(response.getvalue()) 129 | 130 | # Save this posted data to the server object so it can be retrieved later on 131 | if self.server.verbose: 132 | print("Successfully posted network data to Python!") 133 | received_data = json.loads(body) 134 | self.server.posted_network_properties = received_data['network'] 135 | self.server.posted_config = received_data['config'] 136 | img = received_data['image'].split(',')[1] 137 | self.server.posted_image_base64 = base64.decodebytes(img.encode()) 138 | 139 | 140 | def log_message(self, format, *args): 141 | if self.server.verbose: 142 | print(self.address_string(), self.log_date_time_string(), *args) 143 | 144 | 145 | 146 | default_config = { 147 | # Input/output 148 | 'zoom': 1, 149 | # Physics 150 | 'node_charge': -45, 151 | 'node_gravity': 0.1, 152 | 'link_distance': 15, 153 | 'link_distance_variation': 0, 154 | 'node_collision': True, 155 | 'wiggle_nodes': False, 156 | 'freeze_nodes': False, 157 | # Nodes 158 | 'node_fill_color': '#79aaa0', 159 | 'node_stroke_color': '#555555', 160 | 'node_label_color': '#000000', 161 | 'display_node_labels': False, 162 | 'scale_node_size_by_strength': False, 163 | 'node_size': 5, 164 | 'node_stroke_width': 1, 165 | 'node_size_variation': 0.5, 166 | # Links 167 | 'link_color': '#7c7c7c', 168 | 'link_width': 2, 169 | 'link_alpha': 0.5, 170 | 'link_width_variation': 0.5, 171 | # Thresholding 172 | 'display_singleton_nodes': True, 173 | 'min_link_weight_percentile': 0, 174 | 'max_link_weight_percentile': 1 175 | } 176 | 177 | 178 | def visualize(network, 179 | port=9853, 180 | verbose=False, 181 | config=None, 182 | plot_in_cell_below=True, 183 | is_test=False, 184 | ): 185 | """ 186 | Visualize a network interactively using Ulf Aslak's d3 web app. 187 | Saves the network as json, saves the passed config and runs 188 | a local HTTP server which then runs the web app. 189 | 190 | Parameters 191 | ---------- 192 | network : networkx.Graph or networkx.DiGraph or node-link dictionary 193 | The network to visualize 194 | port : int, default : 9853 195 | The port at which to run the server locally. 196 | verbose : bool, default : False 197 | Be chatty. 198 | config : dict, default : None, 199 | In the default configuration, each key-value-pair will 200 | be overwritten with the key-value-pair provided in `config`. 201 | The default configuration is 202 | 203 | .. code:: python 204 | 205 | default_config = { 206 | # Input/output 207 | 'zoom': 1, 208 | # Physics 209 | 'node_charge': -45, 210 | 'node_gravity': 0.1, 211 | 'link_distance': 15, 212 | 'link_distance_variation': 0, 213 | 'node_collision': True, 214 | 'wiggle_nodes': False, 215 | 'freeze_nodes': False, 216 | # Nodes 217 | 'node_fill_color': '#79aaa0', 218 | 'node_stroke_color': '#555555', 219 | 'node_label_color': '#000000', 220 | 'display_node_labels': False, 221 | 'scale_node_size_by_strength': False, 222 | 'node_size': 5, 223 | 'node_stroke_width': 1, 224 | 'node_size_variation': 0.5, 225 | # Links 226 | 'link_color': '#7c7c7c', 227 | 'link_width': 2, 228 | 'link_alpha': 0.5, 229 | 'link_width_variation': 0.5, 230 | # Thresholding 231 | 'display_singleton_nodes': True, 232 | 'min_link_weight_percentile': 0, 233 | 'max_link_weight_percentile': 1 234 | } 235 | 236 | When started from a Jupyter notebook, this will show a 237 | reproduced matplotlib figure of the stylized network 238 | in a cell below. Only works if ``verbose = False``. 239 | is_test : bool, default : False 240 | If ``True``, the interactive environment will post 241 | its visualization to Python automatically after 5 seconds. 242 | 243 | Returns 244 | ------- 245 | network_properties : dict 246 | contains all necessary information to redraw the figure which 247 | was created in the interactive visualization 248 | config : dict 249 | contains all configurational values of the interactive 250 | visualization 251 | """ 252 | 253 | this_config = deepcopy(default_config) 254 | if config is not None: 255 | this_config.update(config) 256 | 257 | path = netwulf_user_folder 258 | mkdirp_customdir() 259 | web_dir = pathlib.Path(path) 260 | 261 | # copy the html and js files for the visualizations 262 | prepare_visualization_directory() 263 | 264 | # create a json-file based on the current time 265 | file_id = "tmp_{:x}".format(int(time.time()*1000)) + ".json" 266 | filename = file_id 267 | configname = "config_" + filename 268 | 269 | filepath = str(web_dir / filename) 270 | configpath = str(web_dir / configname) 271 | 272 | with open(filepath,'w') as f: 273 | if type(network) in [nx.Graph, nx.DiGraph, nx.MultiDiGraph]: 274 | network = nx.node_link_data(network) 275 | if 'graph' in network: 276 | network.update(network['graph']) 277 | del network['graph'] 278 | json.dump(network, f, iterable_as_array=True, default=_json_default) 279 | elif type(network) == dict: 280 | json.dump(network, f, iterable_as_array=True, default=_json_default) 281 | else: 282 | raise TypeError("Netwulf only supports `nx.Graph`, `nx.DiGraph`, `nx.MultiDiGraph`, or `dict`.") 283 | 284 | with open(configpath,'w') as f: 285 | json.dump(this_config, f, default=_json_default) 286 | 287 | # change directory to this directory 288 | if verbose: 289 | print("changing directory to", str(web_dir)) 290 | print("starting server here ...", str(web_dir)) 291 | cwd = os.getcwd() 292 | os.chdir(str(web_dir)) 293 | 294 | server = NetwulfHTTPServer(("127.0.0.1", port), 295 | NetwulfHTTPRequestHandler, 296 | [filepath, configpath], 297 | verbose=verbose, 298 | ) 299 | 300 | # ========= start server ============ 301 | thread = threading.Thread(None, server.run) 302 | thread.start() 303 | 304 | url = "http://localhost:"+str(port)+"/?data=" + filename + "&config=" + configname 305 | if is_test: 306 | url += "&pytest" 307 | webbrowser.open(url) 308 | 309 | try: 310 | while not server.end_requested: 311 | time.sleep(0.1) 312 | is_keyboard_interrupted = False 313 | except KeyboardInterrupt: 314 | is_keyboard_interrupted = True 315 | 316 | server.end_requested = True 317 | 318 | if verbose: 319 | print('stopping server ...') 320 | server.stop_this() 321 | thread.join(0.2) 322 | 323 | posted_network_properties = server.posted_network_properties 324 | posted_config = server.posted_config 325 | 326 | if verbose: 327 | print('changing directory back to', cwd) 328 | 329 | os.chdir(cwd) 330 | 331 | # see whether or not the whole thing was started from a jupyter notebook and if yes, 332 | # actually re-draw the figure and display it 333 | env = os.environ 334 | try: 335 | is_jupyter = 'jupyter' in pathlib.PurePath(env['_']).name 336 | except: # this should actually be a key error 337 | # apparently this is how it has to be on Windows 338 | is_jupyter = 'JPY_PARENT_PID' in env 339 | 340 | if is_jupyter and plot_in_cell_below and not is_keyboard_interrupted: 341 | if verbose: 342 | print('recreating layout in matplotlib ...') 343 | if posted_network_properties is not None: 344 | fig, ax = wulf.draw_netwulf(posted_network_properties) 345 | 346 | return posted_network_properties, posted_config 347 | 348 | 349 | if __name__ == "__main__": 350 | # download_d3() 351 | G = nx.fast_gnp_random_graph(100,2/100.) 352 | #G = nx.barabasi_albert_graph(100,1) 353 | posted_data = visualize(G,config={'collision':True},verbose=True) 354 | #if posted_data is not None: 355 | # print("received posted data:", posted_data) 356 | -------------------------------------------------------------------------------- /netwulf/io.py: -------------------------------------------------------------------------------- 1 | """ 2 | A data input/output module for netwulf. 3 | """ 4 | 5 | import simplejson as json 6 | import networkx as nx 7 | 8 | def _write(f,stylized_network,config,G): 9 | """Internal function to write the everything to a json-file.""" 10 | 11 | if G is not None: 12 | js_G = nx.node_link_data(G) 13 | else: 14 | js_G = None 15 | 16 | json.dump({ 17 | 'stylized_network' : stylized_network, 18 | 'config' : config, 19 | 'Graph' : js_G 20 | }, 21 | f, 22 | separators=(',', ':'), 23 | ) 24 | 25 | def _read(f): 26 | """Internal function to read everything from a json-file.""" 27 | 28 | data = json.load(f) 29 | 30 | stylized_network = data['stylized_network'] 31 | config = data['config'] 32 | G = data['Graph'] 33 | if G is not None: 34 | G = nx.node_link_graph(data['Graph']) 35 | 36 | return stylized_network, config, G 37 | 38 | def save(f,stylized_network,config,G=None): 39 | """ 40 | Parameters 41 | ---------- 42 | f : file-like object or str 43 | The file to which to write. 44 | stylized_network : dict 45 | dictionary returned by :mod:`netwulf.interactive.visualize` 46 | config : dict 47 | dictionary returned by :mod:`netwulf.interactive.visualize` 48 | G : networkx.Graph or similar, default : None 49 | Graph object from which the whole thing was generated. 50 | 51 | Example 52 | ------- 53 | >>> G = networkx.fast_gnp_random_graph(10,0.3) 54 | >>> style_nw, cf = netwulf.visualize(G) 55 | >>> netwulf.save("ER.json",style_nw,cf,G) 56 | """ 57 | 58 | if hasattr(f, 'write'): 59 | _write(f,stylized_network,config,G) 60 | else: 61 | with open(f,'w') as _f: 62 | _write(_f,stylized_network,config,G) 63 | 64 | def load(f): 65 | """ 66 | Parameters 67 | ---------- 68 | f : file-like object or str 69 | The file to which to write. 70 | 71 | Returns 72 | ------- 73 | stylized_network : dict 74 | dictionary returned by :mod:`netwulf.interactive.visualize` 75 | config : dict 76 | dictionary returned by :mod:`netwulf.interactive.visualize` 77 | G : networkx.Graph or similar, default : None 78 | Graph object from which the whole thing was generated. 79 | 80 | Example 81 | ------- 82 | >>> G = networkx.fast_gnp_random_graph(10,0.3) 83 | >>> style_nw, cf = netwulf.visualize(G) 84 | >>> netwulf.save("ER.json",style_nw,cf,G) 85 | >>> style_nw,cf,G = netwulf.load("ER.json") 86 | """ 87 | 88 | if hasattr(f, 'read'): 89 | return _read(f) 90 | else: 91 | with open(f,'r') as _f: 92 | return _read(_f) 93 | 94 | -------------------------------------------------------------------------------- /netwulf/metadata.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Contains a bunch of information about this package. 4 | """ 5 | 6 | __version__ = "0.1.5" 7 | 8 | __author__ = "Ulf Aslak, Benjamin F. Maier" 9 | __copyright__ = "Copyright 2018-2019, Ulf Aslak, Benjamin F. Maier" 10 | __credits__ = ["Benjamin F. Maier"] 11 | __license__ = "MIT" 12 | __maintainer__ = "Benjamin F. Maier" 13 | __email__ = "bfmaier@physik.hu-berlin.de" 14 | __status__ = "Development" 15 | -------------------------------------------------------------------------------- /netwulf/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .test_all import Test 2 | -------------------------------------------------------------------------------- /netwulf/tests/test_all.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | import matplotlib.pyplot as pl 5 | import networkx as nx 6 | 7 | from netwulf.tools import bind_properties_to_network, get_filtered_network, draw_netwulf, node_pos, add_node_label, add_edge_label 8 | from netwulf import visualize 9 | from netwulf.io import save, load 10 | 11 | import pathlib 12 | import tempfile 13 | 14 | def _get_test_network(): 15 | G = nx.Graph() 16 | G.add_nodes_from(range(2)) 17 | G.add_nodes_from("ab") 18 | G.add_edges_from([("a","b")]) 19 | G.add_edges_from([("a",0)]) 20 | 21 | return G 22 | 23 | def _get_test_config(): 24 | return {'node_size':2,'link_width':2,'zoom':3} 25 | 26 | def _drastically_round_positions(props,nprec=-1): 27 | 28 | for i, n in enumerate(props['nodes']): 29 | props['nodes'][i]['x'] = np.around(props['nodes'][i]['x'],nprec) 30 | props['nodes'][i]['y'] = np.around(props['nodes'][i]['y'],nprec) 31 | props['nodes'][i]['x_canvas'] = np.around(props['nodes'][i]['x_canvas'],nprec) 32 | props['nodes'][i]['y_canvas'] = np.around(props['nodes'][i]['y_canvas'],nprec) 33 | 34 | def _assert_positions_within_one_percent(props1,props2): 35 | 36 | for i, n in enumerate(props1['nodes']): 37 | assert(np.isclose(props1['nodes'][i]['x'], props2['nodes'][i]['x'],rtol=1e-2)) 38 | assert(np.isclose(props1['nodes'][i]['y'], props2['nodes'][i]['y'],rtol=1e-2)) 39 | assert(np.isclose(props1['nodes'][i]['x_canvas'], props2['nodes'][i]['x_canvas'],rtol=1e-2)) 40 | assert(np.isclose(props1['nodes'][i]['y_canvas'], props2['nodes'][i]['y_canvas'],rtol=1e-2)) 41 | 42 | 43 | class Test(unittest.TestCase): 44 | 45 | maxDiff = None 46 | 47 | def test_posting(self): 48 | """Test whether results are sucessfully posted to Python.""" 49 | G = _get_test_network() 50 | visualize(G,is_test=True,config=_get_test_config()) 51 | 52 | def test_reproducibility(self): 53 | """Test whether a restarted network visualization with binded positions results in the same visualization.""" 54 | G = _get_test_network() 55 | props, config = visualize(G,config=_get_test_config(),is_test=True) 56 | 57 | bind_properties_to_network(G, props) 58 | newprops, newconfig = visualize(G, config=config,is_test=True) 59 | 60 | # test if node positions are close within 1% and subsequently 61 | # round all positional values to the 2nd positon (e.g. 451 => 450) 62 | _assert_positions_within_one_percent(props,newprops) 63 | _drastically_round_positions(props) 64 | _drastically_round_positions(newprops) 65 | 66 | # the second visualization should have frozen the nodes 67 | assert(newconfig['freeze_nodes']) 68 | 69 | # change it so the dictionary-equal-assertion doesnt fail 70 | newconfig['freeze_nodes'] = False 71 | 72 | self.assertDictEqual(props, newprops) 73 | self.assertDictEqual(config, newconfig) 74 | 75 | def test_filtering(self): 76 | """Test whether filtering works the way it should.""" 77 | 78 | G = _get_test_network() 79 | weights = [10,100] 80 | for e, (u, v) in enumerate(G.edges()): 81 | G[u][v]['foo'] = weights[e] 82 | G[u][v]['bar'] = weights[(e+1)%2] 83 | 84 | grp = {u: 'AB'[i%2] for i, u in enumerate(G.nodes()) } 85 | 86 | new_G = get_filtered_network(G,edge_weight_key='foo') 87 | visualize(new_G,is_test=True,config=_get_test_config()) 88 | 89 | nx.set_node_attributes(G, grp, 'wum') 90 | 91 | new_G = get_filtered_network(G,edge_weight_key='bar',node_group_key='wum') 92 | visualize(new_G,is_test=True,config=_get_test_config()) 93 | 94 | def test_matplotlib(self): 95 | """Test how the produced figure looks""" 96 | stylized_network = { 'linkAlpha': 0.2, 97 | 'linkColor': '#758000', 98 | 'links': [ {'source': 0, 'target': 1, 'weight': 1, 'width': 40}, 99 | {'source': 0, 'target': 3, 'weight': 1, 'width': 40}, 100 | {'source': 1, 'target': 2, 'weight': 1, 'width': 40}, 101 | {'source': 1, 'target': 4, 'weight': 1, 'width': 40}], 102 | 'nodeStrokeColor': '#ffffff', 103 | 'nodeStrokeWidth': 4, 104 | 'nodes': [ { 'color': '#79aa00', 105 | 'id': 0, 106 | 'radius': 28.577380332470412, 107 | 'x': 420.4774227636891, 108 | 'x_canvas': 444.34195934582385, 109 | 'y': 402.92277557839725, 110 | 'y_canvas': 321.45942904878075}, 111 | { 'color': '#79aa00', 112 | 'id': 1, 113 | 'radius': 35, 114 | 'x': 419.94297017162165, 115 | 'x_canvas': 440.6007912013515, 116 | 'y': 426.56813829452074, 117 | 'y_canvas': 486.9769680616455}, 118 | { 'color': '#79aa00', 119 | 'id': 2, 120 | 'radius': 20.207259421636905, 121 | 'x': 405.5333864027982, 122 | 'x_canvas': 339.73370481958773, 123 | 'y': 411.01250794520007, 124 | 'y_canvas': 378.08755561640055}, 125 | { 'color': '#79aa00', 126 | 'id': 3, 127 | 'radius': 20.207259421636905, 128 | 'x': 434.1529311105535, 129 | 'x_canvas': 540.0705177738741, 130 | 'y': 413.3758620827409, 131 | 'y_canvas': 394.6310345791867}, 132 | { 'color': '#79aa00', 133 | 'id': 4, 134 | 'radius': 20.207259421636905, 135 | 'x': 403.23992572436083, 136 | 'x_canvas': 323.67948007052564, 137 | 'y': 429.81017078651104, 138 | 'y_canvas': 509.67119550557754}], 139 | 'xlim': [0, 833], 140 | 'ylim': [0, 833] 141 | } 142 | 143 | 144 | fig, ax = draw_netwulf(stylized_network) 145 | 146 | add_node_label(ax, stylized_network, 0) 147 | add_edge_label(ax, stylized_network, (0,1)) 148 | pl.show(block=False) 149 | pl.pause(5) 150 | pl.close() 151 | 152 | 153 | def test_config_adaption(self): 154 | """Test whether config values are properly adapted.""" 155 | config = { 156 | # Input/output 157 | 'zoom':4, 158 | # Physics 159 | 'node_charge': -70, 160 | 'node_gravity': 0.5, 161 | 'link_distance': 15, 162 | 'link_distance_variation': 2, 163 | 'node_collision': True, 164 | 'wiggle_nodes': True, 165 | 'freeze_nodes': False, 166 | # Nodes 167 | 'node_fill_color': '#79aa00', 168 | 'node_stroke_color': '#ffffff', 169 | 'node_label_color': '#888888', 170 | 'display_node_labels': True, 171 | 'scale_node_size_by_strength': True, 172 | 'node_size': 5, 173 | 'node_stroke_width': 1, 174 | 'node_size_variation': 0.7, 175 | # Links 176 | 'link_color': '#758000', 177 | 'link_width': 10, 178 | 'link_alpha': 0.2, 179 | 'link_width_variation': 0.7, 180 | # Thresholding 181 | 'display_singleton_nodes': False, 182 | 'min_link_weight_percentile': 0.1, 183 | 'max_link_weight_percentile': 0.9, 184 | } 185 | 186 | G = _get_test_network() 187 | _, newconfig = visualize(G,config=config,is_test=True) 188 | 189 | self.assertDictEqual(config, newconfig) 190 | 191 | def test_io(self): 192 | G = _get_test_network() 193 | props, config = visualize(G,config=_get_test_config(),is_test=True) 194 | 195 | fn = ".test.json" 196 | 197 | save(fn,props,config,G=None) 198 | props, config, G = load(fn) 199 | assert(G is None) 200 | 201 | save(fn,props,config,G) 202 | props, config, G = load(fn) 203 | 204 | fig, ax = draw_netwulf(props) 205 | pl.show(block=False) 206 | pl.pause(2) 207 | pl.close() 208 | 209 | # remove test file 210 | pathlib.Path(fn).unlink() 211 | 212 | def test_dict(self): 213 | 214 | props, config = visualize({'nodes':[{'id':0},{'id':1},{'id':2},{'id':3}],'links':[{'source':0, 'target':1}]},is_test=True) 215 | assert(props is not None) 216 | 217 | 218 | 219 | if __name__ == "__main__": 220 | 221 | 222 | T = Test() 223 | 224 | #T.test_matplotlib() 225 | #T.test_io() 226 | T.test_dict() 227 | #T.test_config_adaption() 228 | -------------------------------------------------------------------------------- /netwulf/tools.py: -------------------------------------------------------------------------------- 1 | """ 2 | Some useful things to tweak and reproduce the visualizations. 3 | """ 4 | 5 | import numpy as np 6 | import networkx as nx 7 | 8 | import matplotlib as mpl 9 | import matplotlib.pyplot as pl 10 | from matplotlib.collections import LineCollection, EllipseCollection 11 | 12 | def _get_node_index(network_properties,node_id): 13 | """ 14 | Get the node's index position in the node list of the 15 | stylized network. 16 | 17 | Parameters 18 | ---------- 19 | network_properties : dict 20 | The network properties which are returned from the 21 | interactive visualization. 22 | node_id : str or int 23 | The node of which to get the position 24 | 25 | Returns 26 | ------- 27 | i : int 28 | Index position of the node ``network_properties['nodes']`` 29 | Returns `None` if node is not found 30 | 31 | Example 32 | ------- 33 | >>> props, _ = visualize(G) 34 | >>> i = _get_node_index(props, 0) 35 | """ 36 | 37 | N = len(network_properties['nodes']) 38 | for index, node in enumerate(network_properties['nodes']): 39 | if node_id == node['id']: 40 | break 41 | elif index == N-1: 42 | index = None 43 | 44 | return index 45 | 46 | 47 | def node_pos(network_properties,node_id): 48 | """ 49 | Get the node's position in matplotlib data coordinates. 50 | 51 | Parameters 52 | ---------- 53 | network_properties : dict 54 | The network properties which are returned from the 55 | interactive visualization. 56 | node_id : str or int 57 | The node of which to get the position 58 | 59 | Returns 60 | ------- 61 | x : float 62 | The x-position in matplotlib data coordinates 63 | y : float 64 | The y-position in matplotlib data coordinates 65 | 66 | Example 67 | ------- 68 | >>> props, _ = visualize(G) 69 | >>> node_pos(props, 0) 70 | """ 71 | 72 | index = _get_node_index(network_properties,node_id) 73 | 74 | height = network_properties['ylim'][1] - network_properties['ylim'][0] 75 | node = network_properties['nodes'][index] 76 | 77 | return node['x_canvas'], height - node['y_canvas'] 78 | 79 | def add_node_label(ax, 80 | network_properties, 81 | node_id, 82 | label=None, 83 | dx=0, 84 | dy=0, 85 | ha='center', 86 | va='center', 87 | **kwargs): 88 | """ 89 | Add a label to a node in the drawn matplotlib axis 90 | 91 | Parameters 92 | ---------- 93 | ax : matplotlib.Axis 94 | The Axis object which has been used to draw the network 95 | network_properties : dict 96 | The network properties which are returned from the 97 | interactive visualization. 98 | node_id : str or int 99 | The focal node's id in the `network_properties` dict 100 | label : str, default : None 101 | The text to write at the node's position 102 | If `None`, the value of `node_id` will be put there. 103 | dx : float, default : 0.0 104 | Label offset in x-direction 105 | dy : float, default : 0.0 106 | Label offset in y-direction 107 | ha : str, default : 'center' 108 | Horizontal anchor orientation of the text 109 | va : str, default : 'center' 110 | Vertical anchor orientation of the text 111 | **kwargs : dict 112 | Additional styling arguments forwarded to Axis.text 113 | 114 | 115 | Example 116 | ------- 117 | >>> netw, _ = netwulf.visualize(G) 118 | >>> fig, ax = netwulf.draw_netwulf(netw) 119 | >>> netwulf.add_node_label(ax,netw,0) 120 | """ 121 | 122 | pos = node_pos(network_properties, node_id) 123 | 124 | if label is None: 125 | label = str(node_id) 126 | 127 | zorder = max( _c.get_zorder() for _c in ax.get_children()) + 1 128 | ax.text(pos[0]+dx,pos[1]+dy,label,ha=ha,va=va,zorder=zorder,**kwargs) 129 | 130 | def add_edge_label(ax, 131 | network_properties, 132 | edge, 133 | label=None, 134 | dscale=0.5, 135 | dx=0, 136 | dy=0, 137 | ha='center', 138 | va='center', 139 | **kwargs): 140 | """ 141 | Add a label to an edge in the drawn matplotlib axis 142 | 143 | Parameters 144 | ---------- 145 | ax : matplotlib.Axis 146 | The Axis object which has been used to draw the network 147 | edge : 2-tuple of str or int 148 | The edge's node ids 149 | network_properties : dict 150 | The network properties which are returned from the 151 | interactive visualization. 152 | label : str, default : None 153 | The text to write at the node's position 154 | If `None`, the tuple of node ids in `edge` will be put there. 155 | dscale : float, default : 0.5 156 | At which position between the two nodes to put the label 157 | (``dscale = 0.0`` refers to the position of node ``edge[0]`` 158 | and ``dscale = 1.0`` refers to the position of node ``edge[1]``, 159 | so use any number between 0.0 and 1.0). 160 | dx : float, default : 0.0 161 | Additional label offset in x-direction 162 | dy : float, default : 0.0 163 | Additional label offset in y-direction 164 | ha : str, default : 'center' 165 | Horizontal anchor orientation of the text 166 | va : str, default : 'center' 167 | Vertical anchor orientation of the text 168 | **kwargs : dict 169 | Additional styling arguments forwarded to Axis.text 170 | 171 | 172 | Example 173 | ------- 174 | >>> netw, _ = netwulf.visualize(G) 175 | >>> fig, ax = netwulf.draw_netwulf(netw) 176 | >>> netwulf.add_node_label(ax,netw,0) 177 | """ 178 | 179 | v0 = np.array(node_pos(network_properties, edge[0])) 180 | v1 = np.array(node_pos(network_properties, edge[1])) 181 | e = (v1-v0) 182 | 183 | if label is None: 184 | label = str("("+str(edge[0])+", "+str(edge[1])+")") 185 | 186 | pos = v0 + dscale * e 187 | ax.text(pos[0]+dx,pos[1]+dy,label,ha=ha,va=va,**kwargs) 188 | 189 | def bind_properties_to_network(network, 190 | network_properties, 191 | bind_node_positions=True, 192 | bind_node_color=True, 193 | bind_node_radius=True, 194 | bind_node_stroke_color=True, 195 | bind_node_stroke_width=True, 196 | bind_link_width=True, 197 | bind_link_color=True, 198 | bind_link_alpha=True): 199 | """ 200 | Binds calculated positional values to the network as node attributes `x` and `y`. 201 | 202 | Parameters 203 | ---------- 204 | network : networkx.Graph or something alike 205 | The network object to which the position should be bound 206 | network_properties : dict 207 | The network properties which are returned from the 208 | interactive visualization. 209 | bind_node_positions : bool (default: True) 210 | bind_node_color : bool (default: True) 211 | bind_node_radius : bool (default: True) 212 | bind_node_stroke_color : bool (default: True) 213 | bind_node_stroke_width : bool (default: True) 214 | bind_link_width : bool (default: True) 215 | bind_link_color : bool (default: True) 216 | bind_link_alpha : bool (default: True) 217 | 218 | Example 219 | ------- 220 | >>> props, _ = netwulf.visualize(G) 221 | >>> netwulf.bind_properties_to_network(G, props) 222 | """ 223 | # Add individial node attributes 224 | if bind_node_positions: 225 | x = { node['id']: node['x'] for node in network_properties['nodes'] } 226 | y = { node['id']: node['y'] for node in network_properties['nodes'] } 227 | nx.set_node_attributes(network, x, 'x') 228 | nx.set_node_attributes(network, y, 'y') 229 | network.graph['rescale'] = False 230 | if bind_node_color: 231 | color = { node['id']: node['color'] for node in network_properties['nodes'] } 232 | nx.set_node_attributes(network, color, 'color') 233 | if bind_node_radius: 234 | radius = { node['id']: node['radius'] for node in network_properties['nodes'] } 235 | nx.set_node_attributes(network, radius, 'radius') 236 | 237 | # Add individual link attributes 238 | if bind_link_width: 239 | width = { (link['source'], link['target']): link['width'] for link in network_properties['links'] } 240 | nx.set_edge_attributes(network, width, 'width') 241 | 242 | # Add global style properties 243 | if bind_node_stroke_color: 244 | network.graph['nodeStrokeColor'] = network_properties['nodeStrokeColor'] 245 | if bind_node_stroke_width: 246 | network.graph['nodeStrokeWidth'] = network_properties['nodeStrokeWidth'] 247 | if bind_link_color: 248 | network.graph['linkColor'] = network_properties['linkColor'] 249 | if bind_link_alpha: 250 | network.graph['linkAlpha'] = network_properties['linkAlpha'] 251 | 252 | def get_filtered_network(network,edge_weight_key=None,node_group_key=None): 253 | """ 254 | Get a copy of a network where the edge attribute ``'weight'`` is 255 | set to the attribute given by the keyword ``edge_weight_key`` and the 256 | nodes are regrouped according to their node attribute provided by 257 | ``node_group_key``. 258 | 259 | Parameters 260 | ---------- 261 | network : networkx.Graph or alike 262 | The network object which is about to be filtered 263 | edge_weight_key : str, default : None 264 | If provided, set the edge weight to the edge attribute 265 | given by ``edge_weight_key`` and delete all other edge attributes 266 | node_group_key : str, default : None 267 | If provided, set the node ``'group'`` attribute according to a 268 | new grouping provided by the node attribute ``node_group_key``. 269 | 270 | Returns 271 | ------- 272 | G : networkx.Graph or alike 273 | A filtered copy of the original network. 274 | """ 275 | 276 | G = network.copy() 277 | 278 | if edge_weight_key is not None: 279 | for u, v, d in G.edges(data=True): 280 | keep_value = d[edge_weight_key] 281 | d.clear() 282 | G[u][v]['weight'] = keep_value 283 | 284 | if node_group_key is not None: 285 | groups = { node[1][node_group_key] for node in network.nodes(data=True) } 286 | groups_enum = {v: k for k,v in enumerate(groups)} 287 | for u in network.nodes(): 288 | try: 289 | # networkx v < 2.4 290 | grp = G.node[u].pop(node_group_key) 291 | keep_value = groups_enum[grp] 292 | G.node[u]['group'] = keep_value 293 | except AttributeError as e: 294 | # networkx v >= 2.4 295 | grp = G.nodes[u].pop(node_group_key) 296 | keep_value = groups_enum[grp] 297 | G.nodes[u]['group'] = keep_value 298 | 299 | 300 | return G 301 | 302 | def draw_netwulf(network_properties, fig=None, ax=None, figsize=None, draw_links=True,draw_nodes=True,link_zorder=-1,node_zorder=1000): 303 | """ 304 | Redraw the visualization using matplotlib. Creates 305 | figure and axes if None provided. 306 | In order to add labels, check out 307 | :mod:`netwulf.tools.add_node_label` 308 | and 309 | :mod:`netwulf.tools.add_edge_label` 310 | 311 | Parameters 312 | ---------- 313 | network_properties : dict 314 | The network properties which are returned from the 315 | interactive visualization. 316 | fig : matplotlib.Figure, default : None 317 | The figure in which to draw 318 | ax : matplotlib.Axes, default : None 319 | The Axes in which to draw 320 | figsize : float, default : None 321 | the size of the figure in inches (sidelength of a square) 322 | if None, will be taken as the minimum of the values in 323 | ``matplotlib.rcParams['figure.figsize']``. 324 | draw_links : bool, default : True 325 | Whether the links should be drawn 326 | draw_nodes : bool, default : True 327 | Whether the nodes should be drawn 328 | 329 | Returns 330 | ------- 331 | fig : matplotlib.Figure, default : None 332 | Resulting figure 333 | ax : matplotlib.Axes, default : None 334 | Resulting axes 335 | """ 336 | 337 | # if no figure given, create a square one 338 | if ax is None or fig is None: 339 | if figsize is None: 340 | size = min(mpl.rcParams['figure.figsize']) 341 | else: 342 | size = figsize 343 | 344 | fig = pl.figure(figsize=(size,size)) 345 | ax = fig.add_axes([0, 0, 1, 1]) 346 | # Customize the axis 347 | # remove top and right spines 348 | ax.spines['right'].set_color('none') 349 | ax.spines['left'].set_color('none') 350 | ax.spines['top'].set_color('none') 351 | ax.spines['bottom'].set_color('none') 352 | # turn off ticks 353 | ax.xaxis.set_ticks_position('none') 354 | ax.yaxis.set_ticks_position('none') 355 | ax.xaxis.set_ticklabels([]) 356 | ax.yaxis.set_ticklabels([]) 357 | 358 | 359 | # for conversion of inches to points 360 | # (important for markersize and linewidths). 361 | # Apparently matplotlib uses 72 dpi internally for conversions in all figures even for those 362 | # which do not follow dpi = 72 which is freaking weird but hey why not. 363 | dpi = 72 364 | 365 | # set everything square and get the axis size in points 366 | ax.axis('square') 367 | ax.axis('off') 368 | ax.margins(0) 369 | ax.set_xlim(network_properties['xlim']) 370 | ax.set_ylim(network_properties['ylim']) 371 | bbox = ax.get_window_extent().transformed(fig.dpi_scale_trans.inverted()) 372 | axwidth, axheight = bbox.width*dpi, bbox.height*dpi 373 | 374 | 375 | 376 | # filter out node positions for links 377 | width = network_properties['xlim'][1] - network_properties['xlim'][0] 378 | height = network_properties['ylim'][1] - network_properties['ylim'][0] 379 | pos = { node['id']: np.array([node['x_canvas'], height - node['y_canvas']]) for node in network_properties['nodes'] } 380 | 381 | if draw_links: 382 | #zorder = max( _c.get_zorder() for _c in ax.get_children()) + 1 383 | zorder = -1 # make sure that links are very much in the background 384 | 385 | lines = [] 386 | linewidths = [] 387 | linecolors = [] 388 | for link in network_properties['links']: 389 | u, v = link['source'], link['target'] 390 | lines.append([ 391 | [pos[u][0], pos[v][0]], 392 | [pos[u][1], pos[v][1]] 393 | ]) 394 | linewidths.append(link['width']/width*axwidth) 395 | if 'color' in link.keys(): 396 | linecolors.append(link['color']) 397 | else: 398 | linecolors.append(network_properties['linkColor']) 399 | 400 | # collapse to line segments 401 | lines = [list(zip(x, y)) for x, y in lines] 402 | 403 | # plot Lines 404 | alpha = network_properties['linkAlpha'] 405 | 406 | ax.add_collection(LineCollection(lines, 407 | colors=linecolors, 408 | alpha=alpha, 409 | linewidths=linewidths, 410 | zorder=zorder 411 | )) 412 | 413 | if draw_nodes: 414 | zorder = max( _c.get_zorder() for _c in ax.get_children()) + 1 415 | 416 | # compute node positions and properties 417 | XY = [] 418 | size = [] 419 | node_colors = [] 420 | 421 | for node in network_properties['nodes']: 422 | XY.append([node['x_canvas'], height - node['y_canvas']]) 423 | # size has to be given in points*2 424 | size.append( 2*node['radius'] ) 425 | node_colors.append(node['color']) 426 | 427 | XY = np.array(XY) 428 | size = np.array(size) 429 | circles = EllipseCollection(size,size,np.zeros_like(size), 430 | offsets=XY, 431 | units='x', 432 | transOffset=ax.transData, 433 | facecolors=node_colors, 434 | linewidths=network_properties['nodeStrokeWidth']/width*axwidth, 435 | edgecolors=network_properties['nodeStrokeColor'], 436 | zorder=zorder 437 | ) 438 | ax.add_collection(circles) 439 | 440 | return fig, ax 441 | 442 | 443 | -------------------------------------------------------------------------------- /paper/paper.bib: -------------------------------------------------------------------------------- 1 | @inproceedings{gephi, 2 | title = {Gephi: { An } { Open } { Source } { Software } for { Exploring } and { Manipulating } { Networks }}, 3 | url = {http://www.aaai.org/ocs/index.php/ICWSM/09/paper/view/154}, 4 | abstract = {Jacomy}, 5 | author = {Bastian, Mathieu and Heymann, Sebastien and Jacomy, Mathieu}, 6 | year = {2009}, 7 | doi = {10.13140/2.1.1341.1520} 8 | } 9 | 10 | @article{cytoscape, 11 | title = {Cytoscape: a software environment for integrated models of biomolecular interaction networks}, 12 | author = {Shannon, Paul and Markiel, Andrew and Ozier, Owen and Baliga, Nitin S and Wang, Jonathan T and Ramage, Daniel and Amin, Nada and Schwikowski, Benno and Ideker, Trey}, 13 | journal = {Genome research}, 14 | volume = {13}, 15 | number = {11}, 16 | pages = {2498--2504}, 17 | year = {2003}, 18 | publisher = {Cold Spring Harbor Lab}, 19 | doi = {10.1101/gr.1239303} 20 | } 21 | 22 | @article{webweb, 23 | title = {webweb: a tool for creating, displaying, and sharing interactive network visualizations on the web}, 24 | shorttitle = {webweb}, 25 | journal = {Journal of Open Source Software}, 26 | author = {Wapman, Hunter K and Larremore, Daniel B}, 27 | doi = {10.21105/joss.01458}, 28 | year = {2019} 29 | } 30 | 31 | @article{matplotlib, 32 | title = {Matplotlib: { A } 2D graphics environment}, 33 | volume = {9}, 34 | doi = {10.1109/MCSE.2007.55}, 35 | abstract = {Matplotlib is a 2D graphics package used for Python for application development, interactive scripting, and publication-quality image generation across user interfaces and operating systems.}, 36 | number = {3}, 37 | journal = {Computing In Science \& Engineering}, 38 | author = {Hunter, J. D.}, 39 | year = {2007}, 40 | pages = {90--95} 41 | } 42 | 43 | @article{d3, 44 | title = {D3: { Data } - { Driven } { Documents }}, 45 | volume = {17}, 46 | number = {12}, 47 | journal = {IEEE Transactions on Visualization and Computer Graphics}, 48 | author = {Bostock, Michael and Ogievetsky, Vadim and Heer, Jeffrey}, 49 | year = {2011}, 50 | doi = {10.1109/TVCG.2011.185} 51 | } 52 | 53 | @InProceedings{networkx, 54 | address = {Pasadena, CA USA}, 55 | title = {Exploring network structure, dynamics, and function using {NetworkX}}, 56 | volume = {7}, 57 | booktitle = {{Proceedings of the 7th Python in Science Conference (SciPy 2008)}}, 58 | author = {Aric A. Hagberg and Daniel A. Schult and Pieter J. Swart}, 59 | editor = {G{\"a}el Varoquaux, Travis Vaught, and Millman, Jarrod}, 60 | year = {2008}, 61 | pages = {11--15} 62 | } 63 | 64 | @InProceedings{graphviz, 65 | author="Ellson, John 66 | and Gansner, Emden 67 | and Koutsofios, Lefteris 68 | and North, Stephen C. 69 | and Woodhull, Gordon", 70 | editor="Mutzel, Petra 71 | and J{\"u}nger, Michael 72 | and Leipert, Sebastian", 73 | title="Graphviz--- Open Source Graph Drawing Tools", 74 | booktitle="Graph Drawing", 75 | year="2002", 76 | publisher="Springer Berlin Heidelberg", 77 | address="Berlin, Heidelberg", 78 | pages="483--484", 79 | abstract="Graphviz is a heterogeneous collection of graph drawing tools containing batch layout programs (dot, neato, fdp, twopi); a platform for incremental layout (Dynagraph); customizable graph editors (dotty, Grappa); a server for including graphs in Web pages (WebDot); support for graphs as COM objects (Montage); utility programs useful in graph visualization; and libraries for attributed graphs. The software is available under an Open Source license. The article[1] provides a detailed description of the package.", 80 | isbn="978-3-540-45848-7", 81 | doi={10.1007/3-540-45848-4_57} 82 | } 83 | 84 | 85 | 86 | @manual{developersurvey, 87 | url = {https://insights.stackoverflow.com/survey/2019}, 88 | title = {Stack Overflow Developer Survey}, 89 | year = {2019}, 90 | urldate = {2019-04-23} 91 | } 92 | 93 | @article{graphtool, 94 | title = {The graph-tool python library}, 95 | url = {http://figshare.com/articles/graph_tool/1164194}, 96 | doi = {10.6084/m9.figshare.1164194}, 97 | urldate = {2014-09-10}, 98 | journal = {figshare}, 99 | author = {Peixoto, Tiago P.}, 100 | year = {2014}, 101 | keywords = {all, complex networks, graph, network, other} 102 | } 103 | -------------------------------------------------------------------------------- /paper/paper.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Netwulf: Interactive visualization of networks in Python' 3 | tags: 4 | - Python 5 | - JavaScript 6 | - networks 7 | - visualization 8 | - interactive 9 | authors: 10 | - name: Ulf Aslak 11 | orcid: 0000-0003-4704-3609 12 | affiliation: "1, 2" 13 | - name: Benjamin F. Maier 14 | orcid: 0000-0001-7414-8823 15 | affiliation: "3, 4" 16 | affiliations: 17 | - name : Center for Social Data Science, University of Copenhagen, DK-1353 København K 18 | index: 1 19 | - name : DTU Compute, Technical University of Denmark, DK-2800 Kgs. Lyngby 20 | index: 2 21 | - name: Robert Koch Institute, Nordufer 20, D-13353 Berlin 22 | index: 3 23 | - name: Department of Physics, Humboldt-University of Berlin, Newtonstr. 15, D-12489 Berlin 24 | index: 4 25 | date: 26 April 2019 26 | bibliography: paper.bib 27 | --- 28 | 29 | # Summary 30 | 31 | Network visualization is an effective way to illustrate properties of a complex system. It is an important tool for exploring and finding patterns, and is used by researchers and practitioners across many fields and industries. 32 | Currently, there exist a number of tools for visualizing networks. *Networkx* [@networkx] is a popular Python package for network analysis which provides limited functionality for computing layouts and plotting networks statically. Layout computations are done in Python or using the php-based software *Graphviz* [@graphviz], which is slow. Another Python package for network analysis and visualization is *graph-tool* [@graphtool], which relies on a high number of external C++-libraries for installation/compilation which can be overwhelming for beginners. Furthermore, its visualization functions have limited interactive features. *Gephi* [@gephi] and *Cytoscape* [@cytoscape] are dedicated interactive visualization and analysis software programs. They are both Java-based and run desktop clients with a GUI where users save and load networks as separate files. *Webweb* [@webweb] enables interactive visualization for Python and Matlab networks using the d3.js [@d3] force layout. Its main purpose, however, is exploration of network features and exporting one-time visualizations as SVG or HTML. 33 | 34 | For many users, these tools offer the necessary functionality to visualize networks in most desired ways. However, since a growing population of network researchers and practitioners are relying on Python for doing network science [@developersurvey], it is increasingly pressing that a fast and intuitive Python tool for reproducible network visualization exists. 35 | 36 | *Netwulf* is a light-weight Python library that provides a simple API for interactively visualizing a network and returning the computed layout and style. It is build around the philosophy that network manipulation and preprocessing should be done programmatically, but that the efficient generation of a visually appealing network is best done interactively, without code. Therefore, it offers no analysis functionality and only few exploration features, but instead focuses almost entirely on fast and intuitive layout manipulation and node/link styling. Interaction with Netwulf typically works as follows: 37 | 38 | 1. Users have a network object, `G`, in either dictionary or *networkx.Graph* format. They then launch a Netwulf visualization by calling `netwulf.visualize(G)`. 39 | 2. The command opens a new browser window containing `G` as an interactive, manipulable, stylable network. Here, the user can, for instance, explore how different configurations of physics parameters like *node charge* and *gravity* influence the layout, they can change properties like node color and link opacity, and even threshold the network data for weak or strong links. When the user has finalized the layouting process, they may either: 40 | 1. Save the image directly from the interactive visualization as a PNG file. 41 | 2. Post the style and computed node positions back to Python in a dictionary format, which allows for further manipulation in the Python backend. Moreover, using the function `netwulf.draw_netwulf`, the network can be redrawn using the common Python drawing library *matplotlib* [@matplotlib], which further enables saving the visualization in any format. 42 | 43 | The interactive visualization is implemented in JavaScript, relies on d3.js [@d3] for computing layouts, and uses the HTML5-object `canvas` for rendering. This makes it, to our knowledge, the most performant tool for interactive network visualization in Python to date. 44 | 45 | ![Interactive visualization of a modular network in Netwulf.](random_partition_graph.png) 46 | 47 | # Acknowledgements 48 | 49 | Both authors contributed equally to the software, documentation, and manuscript. B. F. M. is financially supported as an *Add-On Fellow for Interdisciplinary Life Science* by the Joachim Herz Stiftung. 50 | 51 | # References 52 | -------------------------------------------------------------------------------- /paper/random_partition_graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmaier/netwulf/6d6477321b8bd89bd39a8164582bc0f9f38b6bed/paper/random_partition_graph.png -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = netwulf/tests/ 3 | python_files = netwulf/tests/*.py 4 | python_class = Test 5 | pep8ignore = E221,E226,E241,E242 6 | addopts= --cov netwulf/ 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | networkx>=2.0 2 | numpy>=0.14 3 | matplotlib>=3.0 4 | simplejson>=3.0 5 | -------------------------------------------------------------------------------- /sandbox/BA.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": {}, 7 | "outputs": [ 8 | { 9 | "data": { 10 | "image/png": "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\n", 11 | "text/plain": [ 12 | "
" 13 | ] 14 | }, 15 | "metadata": { 16 | "needs_background": "light" 17 | }, 18 | "output_type": "display_data" 19 | } 20 | ], 21 | "source": [ 22 | "%matplotlib inline\n", 23 | "import networkx as nx\n", 24 | "from netwulf import visualize\n", 25 | "\n", 26 | "G = nx.barabasi_albert_graph(100,2)\n", 27 | "\n", 28 | "stylized_network, config = visualize(G,verbose=False)" 29 | ] 30 | }, 31 | { 32 | "cell_type": "code", 33 | "execution_count": null, 34 | "metadata": {}, 35 | "outputs": [], 36 | "source": [] 37 | } 38 | ], 39 | "metadata": { 40 | "kernelspec": { 41 | "display_name": "Python 3", 42 | "language": "python", 43 | "name": "python3" 44 | }, 45 | "language_info": { 46 | "codemirror_mode": { 47 | "name": "ipython", 48 | "version": 3 49 | }, 50 | "file_extension": ".py", 51 | "mimetype": "text/x-python", 52 | "name": "python", 53 | "nbconvert_exporter": "python", 54 | "pygments_lexer": "ipython3", 55 | "version": "3.7.2" 56 | } 57 | }, 58 | "nbformat": 4, 59 | "nbformat_minor": 2 60 | } 61 | -------------------------------------------------------------------------------- /sandbox/BA.py: -------------------------------------------------------------------------------- 1 | import networkx as nx 2 | from netwulf import visualize 3 | 4 | G = nx.barabasi_albert_graph(100,2) 5 | 6 | visualize(G) 7 | -------------------------------------------------------------------------------- /sandbox/redraw_BA.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as pl 2 | import json 3 | from netwulf import draw_netwulf 4 | 5 | with open('BA_network_properties.json', 'r') as f: 6 | props = json.load(f) 7 | 8 | fig, ax = draw_netwulf(props) 9 | pl.show() 10 | -------------------------------------------------------------------------------- /sandbox/save_BA.py: -------------------------------------------------------------------------------- 1 | import networkx as nx 2 | from netwulf import visualize 3 | import json 4 | 5 | G = nx.barabasi_albert_graph(1000,2) 6 | 7 | props, config = visualize(G,config={'Node size by strength':True,'Collision':True,'Node size': 25}) 8 | with open('BA_network_properties.json', 'w') as outfile: 9 | json.dump(props, outfile) 10 | 11 | -------------------------------------------------------------------------------- /sandbox/test_filtering.py: -------------------------------------------------------------------------------- 1 | import networkx as nx 2 | from netwulf import visualize 3 | from netwulf import get_filtered_network 4 | 5 | import numpy as np 6 | 7 | G = nx.barabasi_albert_graph(100,2) 8 | 9 | for u, v in G.edges(): 10 | G[u][v]['foo'] = np.random.rand() 11 | G[u][v]['bar'] = np.random.rand() 12 | 13 | grp = {u: 'ABCDE'[u%5] for u in G.nodes() } 14 | 15 | 16 | new_G = get_filtered_network(G,edge_weight_key='foo') 17 | visualize(new_G) 18 | 19 | nx.set_node_attributes(G, grp, 'wum') 20 | 21 | new_G = get_filtered_network(G,edge_weight_key='bar',node_group_key='wum') 22 | visualize(new_G) 23 | 24 | -------------------------------------------------------------------------------- /sandbox/test_posting.py: -------------------------------------------------------------------------------- 1 | from netwulf.tools import bind_positions_to_network 2 | import networkx as nx 3 | 4 | if __name__ == "__main__": 5 | import pprint 6 | pp = pprint.PrettyPrinter(indent=4) 7 | 8 | G = nx.Graph() 9 | G.add_nodes_from(range(10)) 10 | G.add_nodes_from("abcde") 11 | G.add_edges_from([("a","b")]) 12 | 13 | from netwulf import visualize 14 | props, config = visualize(G) 15 | config['Zoom'] = 0.666 16 | 17 | pp.pprint(props) 18 | pp.pprint(config) 19 | bind_positions_to_network(G, props) 20 | visualize(G, config=config) 21 | -------------------------------------------------------------------------------- /sandbox/test_testing.py: -------------------------------------------------------------------------------- 1 | from netwulf.tests import Test 2 | 3 | T = Test() 4 | 5 | T.test_posting() 6 | 7 | T.test_filtering() 8 | 9 | T.test_matplotlib() 10 | 11 | T.test_reproducibility() 12 | 13 | 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | # required for python setup.py test 3 | test = pytest 4 | 5 | [metadata] 6 | long-description = file: README.rst 7 | license = MIT 8 | classifiers = 9 | Programming Language :: Python :: 3.5 10 | Programming Language :: Python :: 3.6 11 | Programming Language :: Python :: 3.7 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, Extension 2 | import setuptools 3 | import os, sys 4 | 5 | # get __version__, __author__, and __email__ 6 | exec(open("./netwulf/metadata.py").read()) 7 | 8 | setup( 9 | name = 'netwulf', 10 | version = __version__, 11 | author = __author__, 12 | author_email = __email__, 13 | url = 'https://github.com/benmaier/netwulf', 14 | license = __license__, 15 | description = "Interactively visualize networks with Ulf Aslak's d3-tool from Python.", 16 | long_description = '', 17 | packages = setuptools.find_packages(), 18 | python_requires='~=3.5', 19 | install_requires = [ 20 | 'networkx>=2.0', 21 | 'numpy>=0.14', 22 | 'matplotlib>=3.0', 23 | 'simplejson>=3.0', 24 | ], 25 | tests_require=['pytest', 'pytest-cov'], 26 | setup_requires=['pytest-runner'], 27 | classifiers=['License :: OSI Approved :: MIT License', 28 | 'Programming Language :: Python :: 3.5', 29 | 'Programming Language :: Python :: 3.6', 30 | 'Programming Language :: Python :: 3.7' 31 | ], 32 | project_urls={ 33 | 'Documentation': 'https://netwulf.rtfd.io', 34 | 'Contributing Statement': 'https://github.com/benmaier/netwulf/blob/master/CONTRIBUTING.md', 35 | 'Bug Reports': 'https://github.com/benmaier/netwulf/issues', 36 | 'Source': 'https://github.com/benmaier/netwulf/', 37 | 'PyPI': 'https://pypi.org/project/netwulf/', 38 | }, 39 | include_package_data = True, 40 | zip_safe = False, 41 | ) 42 | --------------------------------------------------------------------------------