Repository: jupyrdf/ipyforcegraph (language: python)

Issue:
When NodeSelection.multiple is False, Deselecting a node is not possible
## Description

The default library behaviors includes `NodeSelection` with the optional kwarg `multiple: bool` that changes the ability to select multiple nodes. The default ForceGraph behavior is that _shift+click_ allows user to select multiple nodes, and if a node is already selected this _deselects_ the node. 

When `multiple=False` the deselect behavior no longer works. 

## Reproduce

1. Go to Binder
2. Launch `Behaviors.ipynb`
3. Scroll down to `NodeSelection` behavior
4. Change kwargs to class (add `multiple=False`)
5. Scroll back up to the widget
6. Note that you can no longer select multiple nodes, but deselect is also broken.

## Expected behavior

I would expect multiselect to be disabled, but deselect to still work. 

## Context

- Operating System and version: Windows 10
- Browser and version: Chrome 109.0.5414.120 (Official Build) (64-bit)
- JupyterLab version: `3.6.1`
- IPyForceGraph version(s): `0.1.0` (also on binder)


Discussion / hints:

Fix patch:
diff --git a/.binder/environment.yml b/.binder/environment.yml
index 4f5f43a3..4313380a 100644
--- a/.binder/environment.yml
+++ b/.binder/environment.yml
@@ -12,8 +12,9 @@ dependencies:
   - jupyterlab-link-share
   - keyring
   - mypy >=1.0.0
+  - nbstripout
   - pyflakes
-  - pyproject-fmt
+  - pyproject-fmt !=0.9.1
   - pytest-asyncio
   - pytest-check-links
   - pytest-cov
@@ -29,6 +30,7 @@ dependencies:
   - stringcase
   ### docs/environment.yml ###
   - ansi2html
+  - autodoc-traits
   - bqplot >=0.12.20
   - docutils >=0.19
   - doit
diff --git a/.github/locks/linux-64_py3.11_lab3.5_environment.conda.lock b/.github/locks/linux-64_py3.11_lab3.5_environment.conda.lock
index 36eb2084..145fa3e3 100644
--- a/.github/locks/linux-64_py3.11_lab3.5_environment.conda.lock
+++ b/.github/locks/linux-64_py3.11_lab3.5_environment.conda.lock
@@ -12,8 +12,9 @@
 #   - jupyterlab-link-share
 #   - keyring
 #   - mypy >=1.0.0
+#   - nbstripout
 #   - pyflakes
-#   - pyproject-fmt
+#   - pyproject-fmt !=0.9.1
 #   - pytest-asyncio
 #   - pytest-check-links
 #   - pytest-cov
@@ -29,6 +30,7 @@
 #   - stringcase
 #   ### docs/environment.yml ###
 #   - ansi2html
+#   - autodoc-traits
 #   - bqplot >=0.12.20
 #   - docutils >=0.19
 #   - doit
@@ -202,7 +204,7 @@ https://conda.anaconda.org/conda-forge/noarch/rfc3986-2.0.0-pyhd8ed1ab_0.tar.bz2
 https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2#912a71cc01012ee38e6b90ddd561e36f
 https://conda.anaconda.org/conda-forge/noarch/robotframework-6.0.2-pyhd8ed1ab_1.conda#b4dcead8c5a99a14733d9b1ba5b7db09
 https://conda.anaconda.org/conda-forge/noarch/send2trash-1.8.0-pyhd8ed1ab_0.tar.bz2#edab14119efe85c3bf131ad747e9005c
-https://conda.anaconda.org/conda-forge/noarch/setuptools-67.1.0-pyhd8ed1ab_0.conda#845dae446df791b5e1a3eaa6454640c1
+https://conda.anaconda.org/conda-forge/noarch/setuptools-67.3.1-pyhd8ed1ab_0.conda#97e1034f136713e29e0ef02d68cb5f64
 https://conda.anaconda.org/conda-forge/noarch/six-1.16.0-pyh6c4a22f_0.tar.bz2#e5f25f8dbc060e9a8d912e432202afc2
 https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.0-pyhd8ed1ab_0.tar.bz2#dd6cbc539e74cb1f430efbd4575b9303
 https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-2.2.0-pyhd8ed1ab_0.tar.bz2#4d22a9315e78c6827f806065957d566e
@@ -244,7 +246,7 @@ https://conda.anaconda.org/conda-forge/linux-64/coverage-7.1.0-py311h2582759_0.c
 https://conda.anaconda.org/conda-forge/linux-64/curl-7.87.0-hdc1c0ab_0.conda#b14123ca479b9473d7f7395b0fd25c97
 https://conda.anaconda.org/conda-forge/noarch/h11-0.14.0-pyhd8ed1ab_0.tar.bz2#b21ed0883505ba1910994f1df031a428
 https://conda.anaconda.org/conda-forge/noarch/html5lib-1.1-pyh9f0ad1d_0.tar.bz2#b2355343d6315c892543200231d7154a
-https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.0-pyha770c72_0.conda#84601b6f71f06e161c68e459d4708534
+https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.1-pyha770c72_0.conda#3c044b3b920eb287f8c095c7f086ba64
 https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-6.0.0-pyha770c72_0.conda#691644becbcdca9f73243450b1c63e62
 https://conda.anaconda.org/conda-forge/noarch/importlib_resources-5.10.2-pyhd8ed1ab_0.conda#de76905f801c22fc43e624058574eab3
 https://conda.anaconda.org/conda-forge/noarch/isort-5.12.0-pyhd8ed1ab_1.conda#07ed3421bad60867234c7a9282ea39d4
@@ -318,8 +320,9 @@ https://conda.anaconda.org/conda-forge/noarch/nbformat-5.7.3-pyhd8ed1ab_0.conda#
 https://conda.anaconda.org/conda-forge/noarch/robotframework-tidy-3.3.3-pyhd8ed1ab_0.conda#bc4e21b26f0e5bb7517c5d6200912d35
 https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.14-pyhd8ed1ab_0.conda#01f33ad2e0aaf6b5ba4add50dad5ad29
 https://conda.anaconda.org/conda-forge/noarch/black-jupyter-23.1.0-hd8ed1ab_0.conda#5703ad719698bf0d936764e60c7e790e
-https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.1-pyh210e3f2_0.conda#225e7869419b8bcae5d3165eba50204d
+https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.2-pyh210e3f2_0.conda#f4c469f01c4aa05f77ab2f70c2fbbef2
 https://conda.anaconda.org/conda-forge/noarch/nbclient-0.5.13-pyhd8ed1ab_0.tar.bz2#3edde88a191701cf052216c4ba353a83
+https://conda.anaconda.org/conda-forge/noarch/nbstripout-0.6.1-pyhd8ed1ab_0.tar.bz2#53913d98739527409e0f3227ed7eef7d
 https://conda.anaconda.org/conda-forge/noarch/requests-2.28.2-pyhd8ed1ab_0.conda#11d178fc55199482ee48d6812ea83983
 https://conda.anaconda.org/conda-forge/noarch/selenium-4.7.2-pyhd8ed1ab_0.conda#4f6238a5e80996ee0bbee28b487aada5
 https://conda.anaconda.org/conda-forge/noarch/flit-3.8.0-pyhd8ed1ab_0.tar.bz2#d37c34176396c5402cf80c32e67731b7
@@ -330,6 +333,7 @@ https://conda.anaconda.org/conda-forge/noarch/requests-cache-0.9.8-pyhd8ed1ab_0.
 https://conda.anaconda.org/conda-forge/noarch/requests-toolbelt-0.10.1-pyhd8ed1ab_0.tar.bz2#a4cd20af9711434f89d1ec0d2b3ae6ba
 https://conda.anaconda.org/conda-forge/noarch/robotframework-seleniumlibrary-6.0.0-pyhd8ed1ab_0.tar.bz2#2acaef9c04081dc3644501ac575953bf
 https://conda.anaconda.org/conda-forge/noarch/sphinx-5.3.0-pyhd8ed1ab_0.tar.bz2#f9e1fcfe235d655900bfeb6aee426472
+https://conda.anaconda.org/conda-forge/noarch/autodoc-traits-1.1.0-pyhd8ed1ab_0.conda#66907045fcff46748e485201304cea80
 https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.36-pyhd8ed1ab_0.tar.bz2#bff49231f6b67c01a7a89d56ca0693ab
 https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.2.1-pyhd8ed1ab_0.conda#a753c19a0db139920092aaaed7b05a54
 https://conda.anaconda.org/conda-forge/noarch/myst-parser-0.18.1-pyhd8ed1ab_0.tar.bz2#bcfdf5c7d8bf5c6f6be7b4c66fff2eca
diff --git a/.github/locks/linux-64_py3.11_lab3.6_environment.conda.lock b/.github/locks/linux-64_py3.11_lab3.6_environment.conda.lock
index 09d2d464..39a6813b 100644
--- a/.github/locks/linux-64_py3.11_lab3.6_environment.conda.lock
+++ b/.github/locks/linux-64_py3.11_lab3.6_environment.conda.lock
@@ -12,8 +12,9 @@
 #   - jupyterlab-link-share
 #   - keyring
 #   - mypy >=1.0.0
+#   - nbstripout
 #   - pyflakes
-#   - pyproject-fmt
+#   - pyproject-fmt !=0.9.1
 #   - pytest-asyncio
 #   - pytest-check-links
 #   - pytest-cov
@@ -29,6 +30,7 @@
 #   - stringcase
 #   ### docs/environment.yml ###
 #   - ansi2html
+#   - autodoc-traits
 #   - bqplot >=0.12.20
 #   - docutils >=0.19
 #   - doit
@@ -203,7 +205,7 @@ https://conda.anaconda.org/conda-forge/linux-64/pyzmq-25.0.0-py311hd6ccaeb_0.con
 https://conda.anaconda.org/conda-forge/noarch/rfc3986-2.0.0-pyhd8ed1ab_0.tar.bz2#d337886e38f965bf97aaec382ff6db00
 https://conda.anaconda.org/conda-forge/noarch/robotframework-6.0.2-pyhd8ed1ab_1.conda#b4dcead8c5a99a14733d9b1ba5b7db09
 https://conda.anaconda.org/conda-forge/noarch/send2trash-1.8.0-pyhd8ed1ab_0.tar.bz2#edab14119efe85c3bf131ad747e9005c
-https://conda.anaconda.org/conda-forge/noarch/setuptools-67.1.0-pyhd8ed1ab_0.conda#845dae446df791b5e1a3eaa6454640c1
+https://conda.anaconda.org/conda-forge/noarch/setuptools-67.3.1-pyhd8ed1ab_0.conda#97e1034f136713e29e0ef02d68cb5f64
 https://conda.anaconda.org/conda-forge/noarch/six-1.16.0-pyh6c4a22f_0.tar.bz2#e5f25f8dbc060e9a8d912e432202afc2
 https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.0-pyhd8ed1ab_0.tar.bz2#dd6cbc539e74cb1f430efbd4575b9303
 https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-2.2.0-pyhd8ed1ab_0.tar.bz2#4d22a9315e78c6827f806065957d566e
@@ -246,7 +248,7 @@ https://conda.anaconda.org/conda-forge/linux-64/coverage-7.1.0-py311h2582759_0.c
 https://conda.anaconda.org/conda-forge/linux-64/curl-7.87.0-hdc1c0ab_0.conda#b14123ca479b9473d7f7395b0fd25c97
 https://conda.anaconda.org/conda-forge/noarch/h11-0.14.0-pyhd8ed1ab_0.tar.bz2#b21ed0883505ba1910994f1df031a428
 https://conda.anaconda.org/conda-forge/noarch/html5lib-1.1-pyh9f0ad1d_0.tar.bz2#b2355343d6315c892543200231d7154a
-https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.0-pyha770c72_0.conda#84601b6f71f06e161c68e459d4708534
+https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.1-pyha770c72_0.conda#3c044b3b920eb287f8c095c7f086ba64
 https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-6.0.0-pyha770c72_0.conda#691644becbcdca9f73243450b1c63e62
 https://conda.anaconda.org/conda-forge/noarch/importlib_resources-5.10.2-pyhd8ed1ab_0.conda#de76905f801c22fc43e624058574eab3
 https://conda.anaconda.org/conda-forge/noarch/isort-5.12.0-pyhd8ed1ab_1.conda#07ed3421bad60867234c7a9282ea39d4
@@ -322,8 +324,9 @@ https://conda.anaconda.org/conda-forge/noarch/nbformat-5.7.3-pyhd8ed1ab_0.conda#
 https://conda.anaconda.org/conda-forge/noarch/robotframework-tidy-3.3.3-pyhd8ed1ab_0.conda#bc4e21b26f0e5bb7517c5d6200912d35
 https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.14-pyhd8ed1ab_0.conda#01f33ad2e0aaf6b5ba4add50dad5ad29
 https://conda.anaconda.org/conda-forge/noarch/black-jupyter-23.1.0-hd8ed1ab_0.conda#5703ad719698bf0d936764e60c7e790e
-https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.1-pyh210e3f2_0.conda#225e7869419b8bcae5d3165eba50204d
+https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.2-pyh210e3f2_0.conda#f4c469f01c4aa05f77ab2f70c2fbbef2
 https://conda.anaconda.org/conda-forge/noarch/nbclient-0.5.13-pyhd8ed1ab_0.tar.bz2#3edde88a191701cf052216c4ba353a83
+https://conda.anaconda.org/conda-forge/noarch/nbstripout-0.6.1-pyhd8ed1ab_0.tar.bz2#53913d98739527409e0f3227ed7eef7d
 https://conda.anaconda.org/conda-forge/noarch/requests-2.28.2-pyhd8ed1ab_0.conda#11d178fc55199482ee48d6812ea83983
 https://conda.anaconda.org/conda-forge/noarch/selenium-4.7.2-pyhd8ed1ab_0.conda#4f6238a5e80996ee0bbee28b487aada5
 https://conda.anaconda.org/conda-forge/noarch/flit-3.8.0-pyhd8ed1ab_0.tar.bz2#d37c34176396c5402cf80c32e67731b7
@@ -334,6 +337,7 @@ https://conda.anaconda.org/conda-forge/noarch/requests-cache-0.9.8-pyhd8ed1ab_0.
 https://conda.anaconda.org/conda-forge/noarch/requests-toolbelt-0.10.1-pyhd8ed1ab_0.tar.bz2#a4cd20af9711434f89d1ec0d2b3ae6ba
 https://conda.anaconda.org/conda-forge/noarch/robotframework-seleniumlibrary-6.0.0-pyhd8ed1ab_0.tar.bz2#2acaef9c04081dc3644501ac575953bf
 https://conda.anaconda.org/conda-forge/noarch/sphinx-5.3.0-pyhd8ed1ab_0.tar.bz2#f9e1fcfe235d655900bfeb6aee426472
+https://conda.anaconda.org/conda-forge/noarch/autodoc-traits-1.1.0-pyhd8ed1ab_0.conda#66907045fcff46748e485201304cea80
 https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.36-pyhd8ed1ab_0.tar.bz2#bff49231f6b67c01a7a89d56ca0693ab
 https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.2.1-pyhd8ed1ab_0.conda#a753c19a0db139920092aaaed7b05a54
 https://conda.anaconda.org/conda-forge/noarch/myst-parser-0.18.1-pyhd8ed1ab_0.tar.bz2#bcfdf5c7d8bf5c6f6be7b4c66fff2eca
diff --git a/.github/locks/linux-64_py3.8_lab3.5_environment.conda.lock b/.github/locks/linux-64_py3.8_lab3.5_environment.conda.lock
index 028c5407..d35f4380 100644
--- a/.github/locks/linux-64_py3.8_lab3.5_environment.conda.lock
+++ b/.github/locks/linux-64_py3.8_lab3.5_environment.conda.lock
@@ -12,8 +12,9 @@
 #   - jupyterlab-link-share
 #   - keyring
 #   - mypy >=1.0.0
+#   - nbstripout
 #   - pyflakes
-#   - pyproject-fmt
+#   - pyproject-fmt !=0.9.1
 #   - pytest-asyncio
 #   - pytest-check-links
 #   - pytest-cov
@@ -29,6 +30,7 @@
 #   - stringcase
 #   ### docs/environment.yml ###
 #   - ansi2html
+#   - autodoc-traits
 #   - bqplot >=0.12.20
 #   - docutils >=0.19
 #   - doit
@@ -201,7 +203,7 @@ https://conda.anaconda.org/conda-forge/noarch/rfc3986-2.0.0-pyhd8ed1ab_0.tar.bz2
 https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2#912a71cc01012ee38e6b90ddd561e36f
 https://conda.anaconda.org/conda-forge/noarch/robotframework-6.0.2-pyhd8ed1ab_1.conda#b4dcead8c5a99a14733d9b1ba5b7db09
 https://conda.anaconda.org/conda-forge/noarch/send2trash-1.8.0-pyhd8ed1ab_0.tar.bz2#edab14119efe85c3bf131ad747e9005c
-https://conda.anaconda.org/conda-forge/noarch/setuptools-67.1.0-pyhd8ed1ab_0.conda#845dae446df791b5e1a3eaa6454640c1
+https://conda.anaconda.org/conda-forge/noarch/setuptools-67.3.1-pyhd8ed1ab_0.conda#97e1034f136713e29e0ef02d68cb5f64
 https://conda.anaconda.org/conda-forge/noarch/six-1.16.0-pyh6c4a22f_0.tar.bz2#e5f25f8dbc060e9a8d912e432202afc2
 https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.0-pyhd8ed1ab_0.tar.bz2#dd6cbc539e74cb1f430efbd4575b9303
 https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-2.2.0-pyhd8ed1ab_0.tar.bz2#4d22a9315e78c6827f806065957d566e
@@ -279,7 +281,7 @@ https://conda.anaconda.org/conda-forge/linux-64/cmarkgfm-0.8.0-py38h0a891b7_2.ta
 https://conda.anaconda.org/conda-forge/linux-64/cryptography-39.0.1-py38h3d167d9_0.conda#375c00c98c36b0e79aaaf2149e51f27d
 https://conda.anaconda.org/conda-forge/noarch/doit-0.36.0-pyhd8ed1ab_0.tar.bz2#fc5e53d070f1ee7bb38c2ece282dcb82
 https://conda.anaconda.org/conda-forge/linux-64/git-2.39.1-pl5321h693f4a3_0.conda#12f9ef434e479d7ec9dcd3ab9799a49d
-https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.0-pyha770c72_0.conda#84601b6f71f06e161c68e459d4708534
+https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.1-pyha770c72_0.conda#3c044b3b920eb287f8c095c7f086ba64
 https://conda.anaconda.org/conda-forge/noarch/importlib_metadata-6.0.0-hd8ed1ab_0.conda#a67d43e1527a37199dd8db913366f68e
 https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.17.3-pyhd8ed1ab_0.conda#723268a468177cd44568eb8f794e0d80
 https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.4.4-pyhd8ed1ab_1.conda#7c0965e1d4a0ee1529e8eaa03a78a5b3
@@ -318,8 +320,9 @@ https://conda.anaconda.org/conda-forge/noarch/nbformat-5.7.3-pyhd8ed1ab_0.conda#
 https://conda.anaconda.org/conda-forge/noarch/robotframework-tidy-3.3.3-pyhd8ed1ab_0.conda#bc4e21b26f0e5bb7517c5d6200912d35
 https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.14-pyhd8ed1ab_0.conda#01f33ad2e0aaf6b5ba4add50dad5ad29
 https://conda.anaconda.org/conda-forge/noarch/black-jupyter-23.1.0-hd8ed1ab_0.conda#5703ad719698bf0d936764e60c7e790e
-https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.1-pyh210e3f2_0.conda#225e7869419b8bcae5d3165eba50204d
+https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.2-pyh210e3f2_0.conda#f4c469f01c4aa05f77ab2f70c2fbbef2
 https://conda.anaconda.org/conda-forge/noarch/nbclient-0.5.13-pyhd8ed1ab_0.tar.bz2#3edde88a191701cf052216c4ba353a83
+https://conda.anaconda.org/conda-forge/noarch/nbstripout-0.6.1-pyhd8ed1ab_0.tar.bz2#53913d98739527409e0f3227ed7eef7d
 https://conda.anaconda.org/conda-forge/noarch/requests-2.28.2-pyhd8ed1ab_0.conda#11d178fc55199482ee48d6812ea83983
 https://conda.anaconda.org/conda-forge/noarch/selenium-4.7.2-pyhd8ed1ab_0.conda#4f6238a5e80996ee0bbee28b487aada5
 https://conda.anaconda.org/conda-forge/noarch/flit-3.8.0-pyhd8ed1ab_0.tar.bz2#d37c34176396c5402cf80c32e67731b7
@@ -330,6 +333,7 @@ https://conda.anaconda.org/conda-forge/noarch/requests-cache-0.9.8-pyhd8ed1ab_0.
 https://conda.anaconda.org/conda-forge/noarch/requests-toolbelt-0.10.1-pyhd8ed1ab_0.tar.bz2#a4cd20af9711434f89d1ec0d2b3ae6ba
 https://conda.anaconda.org/conda-forge/noarch/robotframework-seleniumlibrary-6.0.0-pyhd8ed1ab_0.tar.bz2#2acaef9c04081dc3644501ac575953bf
 https://conda.anaconda.org/conda-forge/noarch/sphinx-5.3.0-pyhd8ed1ab_0.tar.bz2#f9e1fcfe235d655900bfeb6aee426472
+https://conda.anaconda.org/conda-forge/noarch/autodoc-traits-1.1.0-pyhd8ed1ab_0.conda#66907045fcff46748e485201304cea80
 https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.36-pyhd8ed1ab_0.tar.bz2#bff49231f6b67c01a7a89d56ca0693ab
 https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.2.1-pyhd8ed1ab_0.conda#a753c19a0db139920092aaaed7b05a54
 https://conda.anaconda.org/conda-forge/noarch/myst-parser-0.18.1-pyhd8ed1ab_0.tar.bz2#bcfdf5c7d8bf5c6f6be7b4c66fff2eca
diff --git a/.github/locks/linux-64_py3.8_lab3.6_environment.conda.lock b/.github/locks/linux-64_py3.8_lab3.6_environment.conda.lock
index 439ed562..d0a877d9 100644
--- a/.github/locks/linux-64_py3.8_lab3.6_environment.conda.lock
+++ b/.github/locks/linux-64_py3.8_lab3.6_environment.conda.lock
@@ -12,8 +12,9 @@
 #   - jupyterlab-link-share
 #   - keyring
 #   - mypy >=1.0.0
+#   - nbstripout
 #   - pyflakes
-#   - pyproject-fmt
+#   - pyproject-fmt !=0.9.1
 #   - pytest-asyncio
 #   - pytest-check-links
 #   - pytest-cov
@@ -29,6 +30,7 @@
 #   - stringcase
 #   ### docs/environment.yml ###
 #   - ansi2html
+#   - autodoc-traits
 #   - bqplot >=0.12.20
 #   - docutils >=0.19
 #   - doit
@@ -202,7 +204,7 @@ https://conda.anaconda.org/conda-forge/linux-64/pyzmq-25.0.0-py38he24dcef_0.cond
 https://conda.anaconda.org/conda-forge/noarch/rfc3986-2.0.0-pyhd8ed1ab_0.tar.bz2#d337886e38f965bf97aaec382ff6db00
 https://conda.anaconda.org/conda-forge/noarch/robotframework-6.0.2-pyhd8ed1ab_1.conda#b4dcead8c5a99a14733d9b1ba5b7db09
 https://conda.anaconda.org/conda-forge/noarch/send2trash-1.8.0-pyhd8ed1ab_0.tar.bz2#edab14119efe85c3bf131ad747e9005c
-https://conda.anaconda.org/conda-forge/noarch/setuptools-67.1.0-pyhd8ed1ab_0.conda#845dae446df791b5e1a3eaa6454640c1
+https://conda.anaconda.org/conda-forge/noarch/setuptools-67.3.1-pyhd8ed1ab_0.conda#97e1034f136713e29e0ef02d68cb5f64
 https://conda.anaconda.org/conda-forge/noarch/six-1.16.0-pyh6c4a22f_0.tar.bz2#e5f25f8dbc060e9a8d912e432202afc2
 https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.0-pyhd8ed1ab_0.tar.bz2#dd6cbc539e74cb1f430efbd4575b9303
 https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-2.2.0-pyhd8ed1ab_0.tar.bz2#4d22a9315e78c6827f806065957d566e
@@ -281,7 +283,7 @@ https://conda.anaconda.org/conda-forge/linux-64/cmarkgfm-0.8.0-py38h0a891b7_2.ta
 https://conda.anaconda.org/conda-forge/linux-64/cryptography-39.0.1-py38h3d167d9_0.conda#375c00c98c36b0e79aaaf2149e51f27d
 https://conda.anaconda.org/conda-forge/noarch/doit-0.36.0-pyhd8ed1ab_0.tar.bz2#fc5e53d070f1ee7bb38c2ece282dcb82
 https://conda.anaconda.org/conda-forge/linux-64/git-2.39.1-pl5321h693f4a3_0.conda#12f9ef434e479d7ec9dcd3ab9799a49d
-https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.0-pyha770c72_0.conda#84601b6f71f06e161c68e459d4708534
+https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.1-pyha770c72_0.conda#3c044b3b920eb287f8c095c7f086ba64
 https://conda.anaconda.org/conda-forge/noarch/importlib_metadata-6.0.0-hd8ed1ab_0.conda#a67d43e1527a37199dd8db913366f68e
 https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.17.3-pyhd8ed1ab_0.conda#723268a468177cd44568eb8f794e0d80
 https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.4.4-pyhd8ed1ab_1.conda#7c0965e1d4a0ee1529e8eaa03a78a5b3
@@ -322,8 +324,9 @@ https://conda.anaconda.org/conda-forge/noarch/nbformat-5.7.3-pyhd8ed1ab_0.conda#
 https://conda.anaconda.org/conda-forge/noarch/robotframework-tidy-3.3.3-pyhd8ed1ab_0.conda#bc4e21b26f0e5bb7517c5d6200912d35
 https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.14-pyhd8ed1ab_0.conda#01f33ad2e0aaf6b5ba4add50dad5ad29
 https://conda.anaconda.org/conda-forge/noarch/black-jupyter-23.1.0-hd8ed1ab_0.conda#5703ad719698bf0d936764e60c7e790e
-https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.1-pyh210e3f2_0.conda#225e7869419b8bcae5d3165eba50204d
+https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.2-pyh210e3f2_0.conda#f4c469f01c4aa05f77ab2f70c2fbbef2
 https://conda.anaconda.org/conda-forge/noarch/nbclient-0.5.13-pyhd8ed1ab_0.tar.bz2#3edde88a191701cf052216c4ba353a83
+https://conda.anaconda.org/conda-forge/noarch/nbstripout-0.6.1-pyhd8ed1ab_0.tar.bz2#53913d98739527409e0f3227ed7eef7d
 https://conda.anaconda.org/conda-forge/noarch/requests-2.28.2-pyhd8ed1ab_0.conda#11d178fc55199482ee48d6812ea83983
 https://conda.anaconda.org/conda-forge/noarch/selenium-4.7.2-pyhd8ed1ab_0.conda#4f6238a5e80996ee0bbee28b487aada5
 https://conda.anaconda.org/conda-forge/noarch/flit-3.8.0-pyhd8ed1ab_0.tar.bz2#d37c34176396c5402cf80c32e67731b7
@@ -334,6 +337,7 @@ https://conda.anaconda.org/conda-forge/noarch/requests-cache-0.9.8-pyhd8ed1ab_0.
 https://conda.anaconda.org/conda-forge/noarch/requests-toolbelt-0.10.1-pyhd8ed1ab_0.tar.bz2#a4cd20af9711434f89d1ec0d2b3ae6ba
 https://conda.anaconda.org/conda-forge/noarch/robotframework-seleniumlibrary-6.0.0-pyhd8ed1ab_0.tar.bz2#2acaef9c04081dc3644501ac575953bf
 https://conda.anaconda.org/conda-forge/noarch/sphinx-5.3.0-pyhd8ed1ab_0.tar.bz2#f9e1fcfe235d655900bfeb6aee426472
+https://conda.anaconda.org/conda-forge/noarch/autodoc-traits-1.1.0-pyhd8ed1ab_0.conda#66907045fcff46748e485201304cea80
 https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.36-pyhd8ed1ab_0.tar.bz2#bff49231f6b67c01a7a89d56ca0693ab
 https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.2.1-pyhd8ed1ab_0.conda#a753c19a0db139920092aaaed7b05a54
 https://conda.anaconda.org/conda-forge/noarch/myst-parser-0.18.1-pyhd8ed1ab_0.tar.bz2#bcfdf5c7d8bf5c6f6be7b4c66fff2eca
diff --git a/.github/locks/osx-64_py3.11_lab3.5_environment.conda.lock b/.github/locks/osx-64_py3.11_lab3.5_environment.conda.lock
index 5f24ae70..3257dd37 100644
--- a/.github/locks/osx-64_py3.11_lab3.5_environment.conda.lock
+++ b/.github/locks/osx-64_py3.11_lab3.5_environment.conda.lock
@@ -12,8 +12,9 @@
 #   - jupyterlab-link-share
 #   - keyring
 #   - mypy >=1.0.0
+#   - nbstripout
 #   - pyflakes
-#   - pyproject-fmt
+#   - pyproject-fmt !=0.9.1
 #   - pytest-asyncio
 #   - pytest-check-links
 #   - pytest-cov
@@ -29,6 +30,7 @@
 #   - stringcase
 #   ### docs/environment.yml ###
 #   - ansi2html
+#   - autodoc-traits
 #   - bqplot >=0.12.20
 #   - docutils >=0.19
 #   - doit
@@ -179,7 +181,7 @@ https://conda.anaconda.org/conda-forge/noarch/rfc3986-2.0.0-pyhd8ed1ab_0.tar.bz2
 https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2#912a71cc01012ee38e6b90ddd561e36f
 https://conda.anaconda.org/conda-forge/noarch/robotframework-6.0.2-pyhd8ed1ab_1.conda#b4dcead8c5a99a14733d9b1ba5b7db09
 https://conda.anaconda.org/conda-forge/noarch/send2trash-1.8.0-pyhd8ed1ab_0.tar.bz2#edab14119efe85c3bf131ad747e9005c
-https://conda.anaconda.org/conda-forge/noarch/setuptools-67.1.0-pyhd8ed1ab_0.conda#845dae446df791b5e1a3eaa6454640c1
+https://conda.anaconda.org/conda-forge/noarch/setuptools-67.3.1-pyhd8ed1ab_0.conda#97e1034f136713e29e0ef02d68cb5f64
 https://conda.anaconda.org/conda-forge/noarch/six-1.16.0-pyh6c4a22f_0.tar.bz2#e5f25f8dbc060e9a8d912e432202afc2
 https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.0-pyhd8ed1ab_0.tar.bz2#dd6cbc539e74cb1f430efbd4575b9303
 https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-2.2.0-pyhd8ed1ab_0.tar.bz2#4d22a9315e78c6827f806065957d566e
@@ -221,7 +223,7 @@ https://conda.anaconda.org/conda-forge/osx-64/coverage-7.1.0-py311h5547dcb_0.con
 https://conda.anaconda.org/conda-forge/osx-64/curl-7.87.0-h6df9250_0.conda#af1db2937ff4a1af6440630102dd18bc
 https://conda.anaconda.org/conda-forge/noarch/h11-0.14.0-pyhd8ed1ab_0.tar.bz2#b21ed0883505ba1910994f1df031a428
 https://conda.anaconda.org/conda-forge/noarch/html5lib-1.1-pyh9f0ad1d_0.tar.bz2#b2355343d6315c892543200231d7154a
-https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.0-pyha770c72_0.conda#84601b6f71f06e161c68e459d4708534
+https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.1-pyha770c72_0.conda#3c044b3b920eb287f8c095c7f086ba64
 https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-6.0.0-pyha770c72_0.conda#691644becbcdca9f73243450b1c63e62
 https://conda.anaconda.org/conda-forge/noarch/importlib_resources-5.10.2-pyhd8ed1ab_0.conda#de76905f801c22fc43e624058574eab3
 https://conda.anaconda.org/conda-forge/noarch/isort-5.12.0-pyhd8ed1ab_1.conda#07ed3421bad60867234c7a9282ea39d4
@@ -299,8 +301,9 @@ https://conda.anaconda.org/conda-forge/osx-64/pandas-1.5.3-py311hd84f3f5_0.conda
 https://conda.anaconda.org/conda-forge/noarch/robotframework-tidy-3.3.3-pyhd8ed1ab_0.conda#bc4e21b26f0e5bb7517c5d6200912d35
 https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.14-pyhd8ed1ab_0.conda#01f33ad2e0aaf6b5ba4add50dad5ad29
 https://conda.anaconda.org/conda-forge/noarch/black-jupyter-23.1.0-hd8ed1ab_0.conda#5703ad719698bf0d936764e60c7e790e
-https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.1-pyh736e0ef_0.conda#feda661e258c358a604927b13aa2858b
+https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.2-pyh736e0ef_0.conda#aa43ffb10a39d9eb2538761a3f838a87
 https://conda.anaconda.org/conda-forge/noarch/nbclient-0.5.13-pyhd8ed1ab_0.tar.bz2#3edde88a191701cf052216c4ba353a83
+https://conda.anaconda.org/conda-forge/noarch/nbstripout-0.6.1-pyhd8ed1ab_0.tar.bz2#53913d98739527409e0f3227ed7eef7d
 https://conda.anaconda.org/conda-forge/noarch/requests-2.28.2-pyhd8ed1ab_0.conda#11d178fc55199482ee48d6812ea83983
 https://conda.anaconda.org/conda-forge/noarch/selenium-4.7.2-pyhd8ed1ab_0.conda#4f6238a5e80996ee0bbee28b487aada5
 https://conda.anaconda.org/conda-forge/noarch/flit-3.8.0-pyhd8ed1ab_0.tar.bz2#d37c34176396c5402cf80c32e67731b7
@@ -311,6 +314,7 @@ https://conda.anaconda.org/conda-forge/noarch/requests-cache-0.9.8-pyhd8ed1ab_0.
 https://conda.anaconda.org/conda-forge/noarch/requests-toolbelt-0.10.1-pyhd8ed1ab_0.tar.bz2#a4cd20af9711434f89d1ec0d2b3ae6ba
 https://conda.anaconda.org/conda-forge/noarch/robotframework-seleniumlibrary-6.0.0-pyhd8ed1ab_0.tar.bz2#2acaef9c04081dc3644501ac575953bf
 https://conda.anaconda.org/conda-forge/noarch/sphinx-5.3.0-pyhd8ed1ab_0.tar.bz2#f9e1fcfe235d655900bfeb6aee426472
+https://conda.anaconda.org/conda-forge/noarch/autodoc-traits-1.1.0-pyhd8ed1ab_0.conda#66907045fcff46748e485201304cea80
 https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.36-pyhd8ed1ab_0.tar.bz2#bff49231f6b67c01a7a89d56ca0693ab
 https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.2.1-pyhd8ed1ab_0.conda#a753c19a0db139920092aaaed7b05a54
 https://conda.anaconda.org/conda-forge/noarch/myst-parser-0.18.1-pyhd8ed1ab_0.tar.bz2#bcfdf5c7d8bf5c6f6be7b4c66fff2eca
diff --git a/.github/locks/osx-64_py3.11_lab3.6_environment.conda.lock b/.github/locks/osx-64_py3.11_lab3.6_environment.conda.lock
index 61009cfa..b96f4d9c 100644
--- a/.github/locks/osx-64_py3.11_lab3.6_environment.conda.lock
+++ b/.github/locks/osx-64_py3.11_lab3.6_environment.conda.lock
@@ -12,8 +12,9 @@
 #   - jupyterlab-link-share
 #   - keyring
 #   - mypy >=1.0.0
+#   - nbstripout
 #   - pyflakes
-#   - pyproject-fmt
+#   - pyproject-fmt !=0.9.1
 #   - pytest-asyncio
 #   - pytest-check-links
 #   - pytest-cov
@@ -29,6 +30,7 @@
 #   - stringcase
 #   ### docs/environment.yml ###
 #   - ansi2html
+#   - autodoc-traits
 #   - bqplot >=0.12.20
 #   - docutils >=0.19
 #   - doit
@@ -180,7 +182,7 @@ https://conda.anaconda.org/conda-forge/osx-64/pyzmq-25.0.0-py311habfacb3_0.conda
 https://conda.anaconda.org/conda-forge/noarch/rfc3986-2.0.0-pyhd8ed1ab_0.tar.bz2#d337886e38f965bf97aaec382ff6db00
 https://conda.anaconda.org/conda-forge/noarch/robotframework-6.0.2-pyhd8ed1ab_1.conda#b4dcead8c5a99a14733d9b1ba5b7db09
 https://conda.anaconda.org/conda-forge/noarch/send2trash-1.8.0-pyhd8ed1ab_0.tar.bz2#edab14119efe85c3bf131ad747e9005c
-https://conda.anaconda.org/conda-forge/noarch/setuptools-67.1.0-pyhd8ed1ab_0.conda#845dae446df791b5e1a3eaa6454640c1
+https://conda.anaconda.org/conda-forge/noarch/setuptools-67.3.1-pyhd8ed1ab_0.conda#97e1034f136713e29e0ef02d68cb5f64
 https://conda.anaconda.org/conda-forge/noarch/six-1.16.0-pyh6c4a22f_0.tar.bz2#e5f25f8dbc060e9a8d912e432202afc2
 https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.0-pyhd8ed1ab_0.tar.bz2#dd6cbc539e74cb1f430efbd4575b9303
 https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-2.2.0-pyhd8ed1ab_0.tar.bz2#4d22a9315e78c6827f806065957d566e
@@ -223,7 +225,7 @@ https://conda.anaconda.org/conda-forge/osx-64/coverage-7.1.0-py311h5547dcb_0.con
 https://conda.anaconda.org/conda-forge/osx-64/curl-7.87.0-h6df9250_0.conda#af1db2937ff4a1af6440630102dd18bc
 https://conda.anaconda.org/conda-forge/noarch/h11-0.14.0-pyhd8ed1ab_0.tar.bz2#b21ed0883505ba1910994f1df031a428
 https://conda.anaconda.org/conda-forge/noarch/html5lib-1.1-pyh9f0ad1d_0.tar.bz2#b2355343d6315c892543200231d7154a
-https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.0-pyha770c72_0.conda#84601b6f71f06e161c68e459d4708534
+https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.1-pyha770c72_0.conda#3c044b3b920eb287f8c095c7f086ba64
 https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-6.0.0-pyha770c72_0.conda#691644becbcdca9f73243450b1c63e62
 https://conda.anaconda.org/conda-forge/noarch/importlib_resources-5.10.2-pyhd8ed1ab_0.conda#de76905f801c22fc43e624058574eab3
 https://conda.anaconda.org/conda-forge/noarch/isort-5.12.0-pyhd8ed1ab_1.conda#07ed3421bad60867234c7a9282ea39d4
@@ -303,8 +305,9 @@ https://conda.anaconda.org/conda-forge/osx-64/pandas-1.5.3-py311hd84f3f5_0.conda
 https://conda.anaconda.org/conda-forge/noarch/robotframework-tidy-3.3.3-pyhd8ed1ab_0.conda#bc4e21b26f0e5bb7517c5d6200912d35
 https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.14-pyhd8ed1ab_0.conda#01f33ad2e0aaf6b5ba4add50dad5ad29
 https://conda.anaconda.org/conda-forge/noarch/black-jupyter-23.1.0-hd8ed1ab_0.conda#5703ad719698bf0d936764e60c7e790e
-https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.1-pyh736e0ef_0.conda#feda661e258c358a604927b13aa2858b
+https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.2-pyh736e0ef_0.conda#aa43ffb10a39d9eb2538761a3f838a87
 https://conda.anaconda.org/conda-forge/noarch/nbclient-0.5.13-pyhd8ed1ab_0.tar.bz2#3edde88a191701cf052216c4ba353a83
+https://conda.anaconda.org/conda-forge/noarch/nbstripout-0.6.1-pyhd8ed1ab_0.tar.bz2#53913d98739527409e0f3227ed7eef7d
 https://conda.anaconda.org/conda-forge/noarch/requests-2.28.2-pyhd8ed1ab_0.conda#11d178fc55199482ee48d6812ea83983
 https://conda.anaconda.org/conda-forge/noarch/selenium-4.7.2-pyhd8ed1ab_0.conda#4f6238a5e80996ee0bbee28b487aada5
 https://conda.anaconda.org/conda-forge/noarch/flit-3.8.0-pyhd8ed1ab_0.tar.bz2#d37c34176396c5402cf80c32e67731b7
@@ -315,6 +318,7 @@ https://conda.anaconda.org/conda-forge/noarch/requests-cache-0.9.8-pyhd8ed1ab_0.
 https://conda.anaconda.org/conda-forge/noarch/requests-toolbelt-0.10.1-pyhd8ed1ab_0.tar.bz2#a4cd20af9711434f89d1ec0d2b3ae6ba
 https://conda.anaconda.org/conda-forge/noarch/robotframework-seleniumlibrary-6.0.0-pyhd8ed1ab_0.tar.bz2#2acaef9c04081dc3644501ac575953bf
 https://conda.anaconda.org/conda-forge/noarch/sphinx-5.3.0-pyhd8ed1ab_0.tar.bz2#f9e1fcfe235d655900bfeb6aee426472
+https://conda.anaconda.org/conda-forge/noarch/autodoc-traits-1.1.0-pyhd8ed1ab_0.conda#66907045fcff46748e485201304cea80
 https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.36-pyhd8ed1ab_0.tar.bz2#bff49231f6b67c01a7a89d56ca0693ab
 https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.2.1-pyhd8ed1ab_0.conda#a753c19a0db139920092aaaed7b05a54
 https://conda.anaconda.org/conda-forge/noarch/myst-parser-0.18.1-pyhd8ed1ab_0.tar.bz2#bcfdf5c7d8bf5c6f6be7b4c66fff2eca
diff --git a/.github/locks/osx-64_py3.8_lab3.5_environment.conda.lock b/.github/locks/osx-64_py3.8_lab3.5_environment.conda.lock
index f7e86bdb..6e646d68 100644
--- a/.github/locks/osx-64_py3.8_lab3.5_environment.conda.lock
+++ b/.github/locks/osx-64_py3.8_lab3.5_environment.conda.lock
@@ -12,8 +12,9 @@
 #   - jupyterlab-link-share
 #   - keyring
 #   - mypy >=1.0.0
+#   - nbstripout
 #   - pyflakes
-#   - pyproject-fmt
+#   - pyproject-fmt !=0.9.1
 #   - pytest-asyncio
 #   - pytest-check-links
 #   - pytest-cov
@@ -29,6 +30,7 @@
 #   - stringcase
 #   ### docs/environment.yml ###
 #   - ansi2html
+#   - autodoc-traits
 #   - bqplot >=0.12.20
 #   - docutils >=0.19
 #   - doit
@@ -178,7 +180,7 @@ https://conda.anaconda.org/conda-forge/noarch/rfc3986-2.0.0-pyhd8ed1ab_0.tar.bz2
 https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2#912a71cc01012ee38e6b90ddd561e36f
 https://conda.anaconda.org/conda-forge/noarch/robotframework-6.0.2-pyhd8ed1ab_1.conda#b4dcead8c5a99a14733d9b1ba5b7db09
 https://conda.anaconda.org/conda-forge/noarch/send2trash-1.8.0-pyhd8ed1ab_0.tar.bz2#edab14119efe85c3bf131ad747e9005c
-https://conda.anaconda.org/conda-forge/noarch/setuptools-67.1.0-pyhd8ed1ab_0.conda#845dae446df791b5e1a3eaa6454640c1
+https://conda.anaconda.org/conda-forge/noarch/setuptools-67.3.1-pyhd8ed1ab_0.conda#97e1034f136713e29e0ef02d68cb5f64
 https://conda.anaconda.org/conda-forge/noarch/six-1.16.0-pyh6c4a22f_0.tar.bz2#e5f25f8dbc060e9a8d912e432202afc2
 https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.0-pyhd8ed1ab_0.tar.bz2#dd6cbc539e74cb1f430efbd4575b9303
 https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-2.2.0-pyhd8ed1ab_0.tar.bz2#4d22a9315e78c6827f806065957d566e
@@ -257,7 +259,7 @@ https://conda.anaconda.org/conda-forge/osx-64/cmarkgfm-0.8.0-py38hef030d1_2.tar.
 https://conda.anaconda.org/conda-forge/osx-64/cryptography-39.0.1-py38h4257468_0.conda#8690c39b6d44c9dcc298da68a284e7a9
 https://conda.anaconda.org/conda-forge/noarch/doit-0.36.0-pyhd8ed1ab_0.tar.bz2#fc5e53d070f1ee7bb38c2ece282dcb82
 https://conda.anaconda.org/conda-forge/osx-64/git-2.39.1-pl5321h0195497_0.conda#a52d6e4a846f6381b815ad3442101559
-https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.0-pyha770c72_0.conda#84601b6f71f06e161c68e459d4708534
+https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.1-pyha770c72_0.conda#3c044b3b920eb287f8c095c7f086ba64
 https://conda.anaconda.org/conda-forge/noarch/importlib_metadata-6.0.0-hd8ed1ab_0.conda#a67d43e1527a37199dd8db913366f68e
 https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.17.3-pyhd8ed1ab_0.conda#723268a468177cd44568eb8f794e0d80
 https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.4.4-pyhd8ed1ab_1.conda#7c0965e1d4a0ee1529e8eaa03a78a5b3
@@ -299,8 +301,9 @@ https://conda.anaconda.org/conda-forge/osx-64/pandas-1.5.3-py38hec72209_0.conda#
 https://conda.anaconda.org/conda-forge/noarch/robotframework-tidy-3.3.3-pyhd8ed1ab_0.conda#bc4e21b26f0e5bb7517c5d6200912d35
 https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.14-pyhd8ed1ab_0.conda#01f33ad2e0aaf6b5ba4add50dad5ad29
 https://conda.anaconda.org/conda-forge/noarch/black-jupyter-23.1.0-hd8ed1ab_0.conda#5703ad719698bf0d936764e60c7e790e
-https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.1-pyh736e0ef_0.conda#feda661e258c358a604927b13aa2858b
+https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.2-pyh736e0ef_0.conda#aa43ffb10a39d9eb2538761a3f838a87
 https://conda.anaconda.org/conda-forge/noarch/nbclient-0.5.13-pyhd8ed1ab_0.tar.bz2#3edde88a191701cf052216c4ba353a83
+https://conda.anaconda.org/conda-forge/noarch/nbstripout-0.6.1-pyhd8ed1ab_0.tar.bz2#53913d98739527409e0f3227ed7eef7d
 https://conda.anaconda.org/conda-forge/noarch/requests-2.28.2-pyhd8ed1ab_0.conda#11d178fc55199482ee48d6812ea83983
 https://conda.anaconda.org/conda-forge/noarch/selenium-4.7.2-pyhd8ed1ab_0.conda#4f6238a5e80996ee0bbee28b487aada5
 https://conda.anaconda.org/conda-forge/noarch/flit-3.8.0-pyhd8ed1ab_0.tar.bz2#d37c34176396c5402cf80c32e67731b7
@@ -311,6 +314,7 @@ https://conda.anaconda.org/conda-forge/noarch/requests-cache-0.9.8-pyhd8ed1ab_0.
 https://conda.anaconda.org/conda-forge/noarch/requests-toolbelt-0.10.1-pyhd8ed1ab_0.tar.bz2#a4cd20af9711434f89d1ec0d2b3ae6ba
 https://conda.anaconda.org/conda-forge/noarch/robotframework-seleniumlibrary-6.0.0-pyhd8ed1ab_0.tar.bz2#2acaef9c04081dc3644501ac575953bf
 https://conda.anaconda.org/conda-forge/noarch/sphinx-5.3.0-pyhd8ed1ab_0.tar.bz2#f9e1fcfe235d655900bfeb6aee426472
+https://conda.anaconda.org/conda-forge/noarch/autodoc-traits-1.1.0-pyhd8ed1ab_0.conda#66907045fcff46748e485201304cea80
 https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.36-pyhd8ed1ab_0.tar.bz2#bff49231f6b67c01a7a89d56ca0693ab
 https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.2.1-pyhd8ed1ab_0.conda#a753c19a0db139920092aaaed7b05a54
 https://conda.anaconda.org/conda-forge/noarch/myst-parser-0.18.1-pyhd8ed1ab_0.tar.bz2#bcfdf5c7d8bf5c6f6be7b4c66fff2eca
diff --git a/.github/locks/osx-64_py3.8_lab3.6_environment.conda.lock b/.github/locks/osx-64_py3.8_lab3.6_environment.conda.lock
index 2f9d86af..a3b7d825 100644
--- a/.github/locks/osx-64_py3.8_lab3.6_environment.conda.lock
+++ b/.github/locks/osx-64_py3.8_lab3.6_environment.conda.lock
@@ -12,8 +12,9 @@
 #   - jupyterlab-link-share
 #   - keyring
 #   - mypy >=1.0.0
+#   - nbstripout
 #   - pyflakes
-#   - pyproject-fmt
+#   - pyproject-fmt !=0.9.1
 #   - pytest-asyncio
 #   - pytest-check-links
 #   - pytest-cov
@@ -29,6 +30,7 @@
 #   - stringcase
 #   ### docs/environment.yml ###
 #   - ansi2html
+#   - autodoc-traits
 #   - bqplot >=0.12.20
 #   - docutils >=0.19
 #   - doit
@@ -179,7 +181,7 @@ https://conda.anaconda.org/conda-forge/osx-64/pyzmq-25.0.0-py38h0b711fd_0.conda#
 https://conda.anaconda.org/conda-forge/noarch/rfc3986-2.0.0-pyhd8ed1ab_0.tar.bz2#d337886e38f965bf97aaec382ff6db00
 https://conda.anaconda.org/conda-forge/noarch/robotframework-6.0.2-pyhd8ed1ab_1.conda#b4dcead8c5a99a14733d9b1ba5b7db09
 https://conda.anaconda.org/conda-forge/noarch/send2trash-1.8.0-pyhd8ed1ab_0.tar.bz2#edab14119efe85c3bf131ad747e9005c
-https://conda.anaconda.org/conda-forge/noarch/setuptools-67.1.0-pyhd8ed1ab_0.conda#845dae446df791b5e1a3eaa6454640c1
+https://conda.anaconda.org/conda-forge/noarch/setuptools-67.3.1-pyhd8ed1ab_0.conda#97e1034f136713e29e0ef02d68cb5f64
 https://conda.anaconda.org/conda-forge/noarch/six-1.16.0-pyh6c4a22f_0.tar.bz2#e5f25f8dbc060e9a8d912e432202afc2
 https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.0-pyhd8ed1ab_0.tar.bz2#dd6cbc539e74cb1f430efbd4575b9303
 https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-2.2.0-pyhd8ed1ab_0.tar.bz2#4d22a9315e78c6827f806065957d566e
@@ -259,7 +261,7 @@ https://conda.anaconda.org/conda-forge/osx-64/cmarkgfm-0.8.0-py38hef030d1_2.tar.
 https://conda.anaconda.org/conda-forge/osx-64/cryptography-39.0.1-py38h4257468_0.conda#8690c39b6d44c9dcc298da68a284e7a9
 https://conda.anaconda.org/conda-forge/noarch/doit-0.36.0-pyhd8ed1ab_0.tar.bz2#fc5e53d070f1ee7bb38c2ece282dcb82
 https://conda.anaconda.org/conda-forge/osx-64/git-2.39.1-pl5321h0195497_0.conda#a52d6e4a846f6381b815ad3442101559
-https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.0-pyha770c72_0.conda#84601b6f71f06e161c68e459d4708534
+https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.1-pyha770c72_0.conda#3c044b3b920eb287f8c095c7f086ba64
 https://conda.anaconda.org/conda-forge/noarch/importlib_metadata-6.0.0-hd8ed1ab_0.conda#a67d43e1527a37199dd8db913366f68e
 https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.17.3-pyhd8ed1ab_0.conda#723268a468177cd44568eb8f794e0d80
 https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.4.4-pyhd8ed1ab_1.conda#7c0965e1d4a0ee1529e8eaa03a78a5b3
@@ -303,8 +305,9 @@ https://conda.anaconda.org/conda-forge/osx-64/pandas-1.5.3-py38hec72209_0.conda#
 https://conda.anaconda.org/conda-forge/noarch/robotframework-tidy-3.3.3-pyhd8ed1ab_0.conda#bc4e21b26f0e5bb7517c5d6200912d35
 https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.14-pyhd8ed1ab_0.conda#01f33ad2e0aaf6b5ba4add50dad5ad29
 https://conda.anaconda.org/conda-forge/noarch/black-jupyter-23.1.0-hd8ed1ab_0.conda#5703ad719698bf0d936764e60c7e790e
-https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.1-pyh736e0ef_0.conda#feda661e258c358a604927b13aa2858b
+https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.2-pyh736e0ef_0.conda#aa43ffb10a39d9eb2538761a3f838a87
 https://conda.anaconda.org/conda-forge/noarch/nbclient-0.5.13-pyhd8ed1ab_0.tar.bz2#3edde88a191701cf052216c4ba353a83
+https://conda.anaconda.org/conda-forge/noarch/nbstripout-0.6.1-pyhd8ed1ab_0.tar.bz2#53913d98739527409e0f3227ed7eef7d
 https://conda.anaconda.org/conda-forge/noarch/requests-2.28.2-pyhd8ed1ab_0.conda#11d178fc55199482ee48d6812ea83983
 https://conda.anaconda.org/conda-forge/noarch/selenium-4.7.2-pyhd8ed1ab_0.conda#4f6238a5e80996ee0bbee28b487aada5
 https://conda.anaconda.org/conda-forge/noarch/flit-3.8.0-pyhd8ed1ab_0.tar.bz2#d37c34176396c5402cf80c32e67731b7
@@ -315,6 +318,7 @@ https://conda.anaconda.org/conda-forge/noarch/requests-cache-0.9.8-pyhd8ed1ab_0.
 https://conda.anaconda.org/conda-forge/noarch/requests-toolbelt-0.10.1-pyhd8ed1ab_0.tar.bz2#a4cd20af9711434f89d1ec0d2b3ae6ba
 https://conda.anaconda.org/conda-forge/noarch/robotframework-seleniumlibrary-6.0.0-pyhd8ed1ab_0.tar.bz2#2acaef9c04081dc3644501ac575953bf
 https://conda.anaconda.org/conda-forge/noarch/sphinx-5.3.0-pyhd8ed1ab_0.tar.bz2#f9e1fcfe235d655900bfeb6aee426472
+https://conda.anaconda.org/conda-forge/noarch/autodoc-traits-1.1.0-pyhd8ed1ab_0.conda#66907045fcff46748e485201304cea80
 https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.36-pyhd8ed1ab_0.tar.bz2#bff49231f6b67c01a7a89d56ca0693ab
 https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.2.1-pyhd8ed1ab_0.conda#a753c19a0db139920092aaaed7b05a54
 https://conda.anaconda.org/conda-forge/noarch/myst-parser-0.18.1-pyhd8ed1ab_0.tar.bz2#bcfdf5c7d8bf5c6f6be7b4c66fff2eca
diff --git a/.github/locks/win-64_py3.11_lab3.5_environment.conda.lock b/.github/locks/win-64_py3.11_lab3.5_environment.conda.lock
index ef5eff53..9f274488 100644
--- a/.github/locks/win-64_py3.11_lab3.5_environment.conda.lock
+++ b/.github/locks/win-64_py3.11_lab3.5_environment.conda.lock
@@ -12,8 +12,9 @@
 #   - jupyterlab-link-share
 #   - keyring
 #   - mypy >=1.0.0
+#   - nbstripout
 #   - pyflakes
-#   - pyproject-fmt
+#   - pyproject-fmt !=0.9.1
 #   - pytest-asyncio
 #   - pytest-check-links
 #   - pytest-cov
@@ -29,6 +30,7 @@
 #   - stringcase
 #   ### docs/environment.yml ###
 #   - ansi2html
+#   - autodoc-traits
 #   - bqplot >=0.12.20
 #   - docutils >=0.19
 #   - doit
@@ -166,7 +168,7 @@ https://conda.anaconda.org/conda-forge/noarch/rfc3986-2.0.0-pyhd8ed1ab_0.tar.bz2
 https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2#912a71cc01012ee38e6b90ddd561e36f
 https://conda.anaconda.org/conda-forge/noarch/robotframework-6.0.2-pyhd8ed1ab_1.conda#b4dcead8c5a99a14733d9b1ba5b7db09
 https://conda.anaconda.org/conda-forge/noarch/send2trash-1.8.0-pyhd8ed1ab_0.tar.bz2#edab14119efe85c3bf131ad747e9005c
-https://conda.anaconda.org/conda-forge/noarch/setuptools-67.1.0-pyhd8ed1ab_0.conda#845dae446df791b5e1a3eaa6454640c1
+https://conda.anaconda.org/conda-forge/noarch/setuptools-67.3.1-pyhd8ed1ab_0.conda#97e1034f136713e29e0ef02d68cb5f64
 https://conda.anaconda.org/conda-forge/noarch/six-1.16.0-pyh6c4a22f_0.tar.bz2#e5f25f8dbc060e9a8d912e432202afc2
 https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.0-pyhd8ed1ab_0.tar.bz2#dd6cbc539e74cb1f430efbd4575b9303
 https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-2.2.0-pyhd8ed1ab_0.tar.bz2#4d22a9315e78c6827f806065957d566e
@@ -244,7 +246,7 @@ https://conda.anaconda.org/conda-forge/win-64/brotlipy-0.7.0-py311ha68e1ae_1005.
 https://conda.anaconda.org/conda-forge/win-64/cmarkgfm-0.8.0-py311ha68e1ae_2.tar.bz2#206cb594ef199270b7e0d3d17aa87cbe
 https://conda.anaconda.org/conda-forge/win-64/cryptography-39.0.1-py311h28e9c30_0.conda#617ff3f61e12922a0ba8c112ace9ba30
 https://conda.anaconda.org/conda-forge/noarch/doit-0.36.0-pyhd8ed1ab_0.tar.bz2#fc5e53d070f1ee7bb38c2ece282dcb82
-https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.0-pyha770c72_0.conda#84601b6f71f06e161c68e459d4708534
+https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.1-pyha770c72_0.conda#3c044b3b920eb287f8c095c7f086ba64
 https://conda.anaconda.org/conda-forge/noarch/importlib_metadata-6.0.0-hd8ed1ab_0.conda#a67d43e1527a37199dd8db913366f68e
 https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.17.3-pyhd8ed1ab_0.conda#723268a468177cd44568eb8f794e0d80
 https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.4.4-pyhd8ed1ab_1.conda#7c0965e1d4a0ee1529e8eaa03a78a5b3
@@ -286,8 +288,9 @@ https://conda.anaconda.org/conda-forge/win-64/pandas-1.5.3-py311hf63dbb6_0.conda
 https://conda.anaconda.org/conda-forge/noarch/robotframework-tidy-3.3.3-pyhd8ed1ab_0.conda#bc4e21b26f0e5bb7517c5d6200912d35
 https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.14-pyhd8ed1ab_0.conda#01f33ad2e0aaf6b5ba4add50dad5ad29
 https://conda.anaconda.org/conda-forge/noarch/black-jupyter-23.1.0-hd8ed1ab_0.conda#5703ad719698bf0d936764e60c7e790e
-https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.1-pyh025b116_0.conda#ec06073a146686e6075ef66924eef65e
+https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.2-pyh025b116_0.conda#2f0ef6a37b24f898508d7a5026e32c02
 https://conda.anaconda.org/conda-forge/noarch/nbclient-0.5.13-pyhd8ed1ab_0.tar.bz2#3edde88a191701cf052216c4ba353a83
+https://conda.anaconda.org/conda-forge/noarch/nbstripout-0.6.1-pyhd8ed1ab_0.tar.bz2#53913d98739527409e0f3227ed7eef7d
 https://conda.anaconda.org/conda-forge/noarch/requests-2.28.2-pyhd8ed1ab_0.conda#11d178fc55199482ee48d6812ea83983
 https://conda.anaconda.org/conda-forge/noarch/selenium-4.7.2-pyhd8ed1ab_0.conda#4f6238a5e80996ee0bbee28b487aada5
 https://conda.anaconda.org/conda-forge/noarch/flit-3.8.0-pyhd8ed1ab_0.tar.bz2#d37c34176396c5402cf80c32e67731b7
@@ -298,6 +301,7 @@ https://conda.anaconda.org/conda-forge/noarch/requests-cache-0.9.8-pyhd8ed1ab_0.
 https://conda.anaconda.org/conda-forge/noarch/requests-toolbelt-0.10.1-pyhd8ed1ab_0.tar.bz2#a4cd20af9711434f89d1ec0d2b3ae6ba
 https://conda.anaconda.org/conda-forge/noarch/robotframework-seleniumlibrary-6.0.0-pyhd8ed1ab_0.tar.bz2#2acaef9c04081dc3644501ac575953bf
 https://conda.anaconda.org/conda-forge/noarch/sphinx-5.3.0-pyhd8ed1ab_0.tar.bz2#f9e1fcfe235d655900bfeb6aee426472
+https://conda.anaconda.org/conda-forge/noarch/autodoc-traits-1.1.0-pyhd8ed1ab_0.conda#66907045fcff46748e485201304cea80
 https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.36-pyhd8ed1ab_0.tar.bz2#bff49231f6b67c01a7a89d56ca0693ab
 https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.2.1-pyhd8ed1ab_0.conda#a753c19a0db139920092aaaed7b05a54
 https://conda.anaconda.org/conda-forge/noarch/myst-parser-0.18.1-pyhd8ed1ab_0.tar.bz2#bcfdf5c7d8bf5c6f6be7b4c66fff2eca
diff --git a/.github/locks/win-64_py3.11_lab3.6_environment.conda.lock b/.github/locks/win-64_py3.11_lab3.6_environment.conda.lock
index 3e4a7ab2..dbadc79b 100644
--- a/.github/locks/win-64_py3.11_lab3.6_environment.conda.lock
+++ b/.github/locks/win-64_py3.11_lab3.6_environment.conda.lock
@@ -12,8 +12,9 @@
 #   - jupyterlab-link-share
 #   - keyring
 #   - mypy >=1.0.0
+#   - nbstripout
 #   - pyflakes
-#   - pyproject-fmt
+#   - pyproject-fmt !=0.9.1
 #   - pytest-asyncio
 #   - pytest-check-links
 #   - pytest-cov
@@ -29,6 +30,7 @@
 #   - stringcase
 #   ### docs/environment.yml ###
 #   - ansi2html
+#   - autodoc-traits
 #   - bqplot >=0.12.20
 #   - docutils >=0.19
 #   - doit
@@ -167,7 +169,7 @@ https://conda.anaconda.org/conda-forge/win-64/pyzmq-25.0.0-py311h7b3f143_0.conda
 https://conda.anaconda.org/conda-forge/noarch/rfc3986-2.0.0-pyhd8ed1ab_0.tar.bz2#d337886e38f965bf97aaec382ff6db00
 https://conda.anaconda.org/conda-forge/noarch/robotframework-6.0.2-pyhd8ed1ab_1.conda#b4dcead8c5a99a14733d9b1ba5b7db09
 https://conda.anaconda.org/conda-forge/noarch/send2trash-1.8.0-pyhd8ed1ab_0.tar.bz2#edab14119efe85c3bf131ad747e9005c
-https://conda.anaconda.org/conda-forge/noarch/setuptools-67.1.0-pyhd8ed1ab_0.conda#845dae446df791b5e1a3eaa6454640c1
+https://conda.anaconda.org/conda-forge/noarch/setuptools-67.3.1-pyhd8ed1ab_0.conda#97e1034f136713e29e0ef02d68cb5f64
 https://conda.anaconda.org/conda-forge/noarch/six-1.16.0-pyh6c4a22f_0.tar.bz2#e5f25f8dbc060e9a8d912e432202afc2
 https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.0-pyhd8ed1ab_0.tar.bz2#dd6cbc539e74cb1f430efbd4575b9303
 https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-2.2.0-pyhd8ed1ab_0.tar.bz2#4d22a9315e78c6827f806065957d566e
@@ -246,7 +248,7 @@ https://conda.anaconda.org/conda-forge/win-64/brotlipy-0.7.0-py311ha68e1ae_1005.
 https://conda.anaconda.org/conda-forge/win-64/cmarkgfm-0.8.0-py311ha68e1ae_2.tar.bz2#206cb594ef199270b7e0d3d17aa87cbe
 https://conda.anaconda.org/conda-forge/win-64/cryptography-39.0.1-py311h28e9c30_0.conda#617ff3f61e12922a0ba8c112ace9ba30
 https://conda.anaconda.org/conda-forge/noarch/doit-0.36.0-pyhd8ed1ab_0.tar.bz2#fc5e53d070f1ee7bb38c2ece282dcb82
-https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.0-pyha770c72_0.conda#84601b6f71f06e161c68e459d4708534
+https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.1-pyha770c72_0.conda#3c044b3b920eb287f8c095c7f086ba64
 https://conda.anaconda.org/conda-forge/noarch/importlib_metadata-6.0.0-hd8ed1ab_0.conda#a67d43e1527a37199dd8db913366f68e
 https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.17.3-pyhd8ed1ab_0.conda#723268a468177cd44568eb8f794e0d80
 https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.4.4-pyhd8ed1ab_1.conda#7c0965e1d4a0ee1529e8eaa03a78a5b3
@@ -290,8 +292,9 @@ https://conda.anaconda.org/conda-forge/win-64/pandas-1.5.3-py311hf63dbb6_0.conda
 https://conda.anaconda.org/conda-forge/noarch/robotframework-tidy-3.3.3-pyhd8ed1ab_0.conda#bc4e21b26f0e5bb7517c5d6200912d35
 https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.14-pyhd8ed1ab_0.conda#01f33ad2e0aaf6b5ba4add50dad5ad29
 https://conda.anaconda.org/conda-forge/noarch/black-jupyter-23.1.0-hd8ed1ab_0.conda#5703ad719698bf0d936764e60c7e790e
-https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.1-pyh025b116_0.conda#ec06073a146686e6075ef66924eef65e
+https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.2-pyh025b116_0.conda#2f0ef6a37b24f898508d7a5026e32c02
 https://conda.anaconda.org/conda-forge/noarch/nbclient-0.5.13-pyhd8ed1ab_0.tar.bz2#3edde88a191701cf052216c4ba353a83
+https://conda.anaconda.org/conda-forge/noarch/nbstripout-0.6.1-pyhd8ed1ab_0.tar.bz2#53913d98739527409e0f3227ed7eef7d
 https://conda.anaconda.org/conda-forge/noarch/requests-2.28.2-pyhd8ed1ab_0.conda#11d178fc55199482ee48d6812ea83983
 https://conda.anaconda.org/conda-forge/noarch/selenium-4.7.2-pyhd8ed1ab_0.conda#4f6238a5e80996ee0bbee28b487aada5
 https://conda.anaconda.org/conda-forge/noarch/flit-3.8.0-pyhd8ed1ab_0.tar.bz2#d37c34176396c5402cf80c32e67731b7
@@ -302,6 +305,7 @@ https://conda.anaconda.org/conda-forge/noarch/requests-cache-0.9.8-pyhd8ed1ab_0.
 https://conda.anaconda.org/conda-forge/noarch/requests-toolbelt-0.10.1-pyhd8ed1ab_0.tar.bz2#a4cd20af9711434f89d1ec0d2b3ae6ba
 https://conda.anaconda.org/conda-forge/noarch/robotframework-seleniumlibrary-6.0.0-pyhd8ed1ab_0.tar.bz2#2acaef9c04081dc3644501ac575953bf
 https://conda.anaconda.org/conda-forge/noarch/sphinx-5.3.0-pyhd8ed1ab_0.tar.bz2#f9e1fcfe235d655900bfeb6aee426472
+https://conda.anaconda.org/conda-forge/noarch/autodoc-traits-1.1.0-pyhd8ed1ab_0.conda#66907045fcff46748e485201304cea80
 https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.36-pyhd8ed1ab_0.tar.bz2#bff49231f6b67c01a7a89d56ca0693ab
 https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.2.1-pyhd8ed1ab_0.conda#a753c19a0db139920092aaaed7b05a54
 https://conda.anaconda.org/conda-forge/noarch/myst-parser-0.18.1-pyhd8ed1ab_0.tar.bz2#bcfdf5c7d8bf5c6f6be7b4c66fff2eca
diff --git a/.github/locks/win-64_py3.8_lab3.5_environment.conda.lock b/.github/locks/win-64_py3.8_lab3.5_environment.conda.lock
index d14ae5ef..ccd2cd45 100644
--- a/.github/locks/win-64_py3.8_lab3.5_environment.conda.lock
+++ b/.github/locks/win-64_py3.8_lab3.5_environment.conda.lock
@@ -12,8 +12,9 @@
 #   - jupyterlab-link-share
 #   - keyring
 #   - mypy >=1.0.0
+#   - nbstripout
 #   - pyflakes
-#   - pyproject-fmt
+#   - pyproject-fmt !=0.9.1
 #   - pytest-asyncio
 #   - pytest-check-links
 #   - pytest-cov
@@ -29,6 +30,7 @@
 #   - stringcase
 #   ### docs/environment.yml ###
 #   - ansi2html
+#   - autodoc-traits
 #   - bqplot >=0.12.20
 #   - docutils >=0.19
 #   - doit
@@ -165,7 +167,7 @@ https://conda.anaconda.org/conda-forge/noarch/rfc3986-2.0.0-pyhd8ed1ab_0.tar.bz2
 https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2#912a71cc01012ee38e6b90ddd561e36f
 https://conda.anaconda.org/conda-forge/noarch/robotframework-6.0.2-pyhd8ed1ab_1.conda#b4dcead8c5a99a14733d9b1ba5b7db09
 https://conda.anaconda.org/conda-forge/noarch/send2trash-1.8.0-pyhd8ed1ab_0.tar.bz2#edab14119efe85c3bf131ad747e9005c
-https://conda.anaconda.org/conda-forge/noarch/setuptools-67.1.0-pyhd8ed1ab_0.conda#845dae446df791b5e1a3eaa6454640c1
+https://conda.anaconda.org/conda-forge/noarch/setuptools-67.3.1-pyhd8ed1ab_0.conda#97e1034f136713e29e0ef02d68cb5f64
 https://conda.anaconda.org/conda-forge/noarch/six-1.16.0-pyh6c4a22f_0.tar.bz2#e5f25f8dbc060e9a8d912e432202afc2
 https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.0-pyhd8ed1ab_0.tar.bz2#dd6cbc539e74cb1f430efbd4575b9303
 https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-2.2.0-pyhd8ed1ab_0.tar.bz2#4d22a9315e78c6827f806065957d566e
@@ -244,7 +246,7 @@ https://conda.anaconda.org/conda-forge/win-64/brotlipy-0.7.0-py38h91455d4_1005.t
 https://conda.anaconda.org/conda-forge/win-64/cmarkgfm-0.8.0-py38h91455d4_2.tar.bz2#53a0c89393e15de6d6684886e441fa51
 https://conda.anaconda.org/conda-forge/win-64/cryptography-39.0.1-py38h95f5157_0.conda#d108e9db131ecd9f37bdf2872188d345
 https://conda.anaconda.org/conda-forge/noarch/doit-0.36.0-pyhd8ed1ab_0.tar.bz2#fc5e53d070f1ee7bb38c2ece282dcb82
-https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.0-pyha770c72_0.conda#84601b6f71f06e161c68e459d4708534
+https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.1-pyha770c72_0.conda#3c044b3b920eb287f8c095c7f086ba64
 https://conda.anaconda.org/conda-forge/noarch/importlib_metadata-6.0.0-hd8ed1ab_0.conda#a67d43e1527a37199dd8db913366f68e
 https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.17.3-pyhd8ed1ab_0.conda#723268a468177cd44568eb8f794e0d80
 https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.4.4-pyhd8ed1ab_1.conda#7c0965e1d4a0ee1529e8eaa03a78a5b3
@@ -286,8 +288,9 @@ https://conda.anaconda.org/conda-forge/win-64/pandas-1.5.3-py38h5846ac1_0.conda#
 https://conda.anaconda.org/conda-forge/noarch/robotframework-tidy-3.3.3-pyhd8ed1ab_0.conda#bc4e21b26f0e5bb7517c5d6200912d35
 https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.14-pyhd8ed1ab_0.conda#01f33ad2e0aaf6b5ba4add50dad5ad29
 https://conda.anaconda.org/conda-forge/noarch/black-jupyter-23.1.0-hd8ed1ab_0.conda#5703ad719698bf0d936764e60c7e790e
-https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.1-pyh025b116_0.conda#ec06073a146686e6075ef66924eef65e
+https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.2-pyh025b116_0.conda#2f0ef6a37b24f898508d7a5026e32c02
 https://conda.anaconda.org/conda-forge/noarch/nbclient-0.5.13-pyhd8ed1ab_0.tar.bz2#3edde88a191701cf052216c4ba353a83
+https://conda.anaconda.org/conda-forge/noarch/nbstripout-0.6.1-pyhd8ed1ab_0.tar.bz2#53913d98739527409e0f3227ed7eef7d
 https://conda.anaconda.org/conda-forge/noarch/requests-2.28.2-pyhd8ed1ab_0.conda#11d178fc55199482ee48d6812ea83983
 https://conda.anaconda.org/conda-forge/noarch/selenium-4.7.2-pyhd8ed1ab_0.conda#4f6238a5e80996ee0bbee28b487aada5
 https://conda.anaconda.org/conda-forge/noarch/flit-3.8.0-pyhd8ed1ab_0.tar.bz2#d37c34176396c5402cf80c32e67731b7
@@ -298,6 +301,7 @@ https://conda.anaconda.org/conda-forge/noarch/requests-cache-0.9.8-pyhd8ed1ab_0.
 https://conda.anaconda.org/conda-forge/noarch/requests-toolbelt-0.10.1-pyhd8ed1ab_0.tar.bz2#a4cd20af9711434f89d1ec0d2b3ae6ba
 https://conda.anaconda.org/conda-forge/noarch/robotframework-seleniumlibrary-6.0.0-pyhd8ed1ab_0.tar.bz2#2acaef9c04081dc3644501ac575953bf
 https://conda.anaconda.org/conda-forge/noarch/sphinx-5.3.0-pyhd8ed1ab_0.tar.bz2#f9e1fcfe235d655900bfeb6aee426472
+https://conda.anaconda.org/conda-forge/noarch/autodoc-traits-1.1.0-pyhd8ed1ab_0.conda#66907045fcff46748e485201304cea80
 https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.36-pyhd8ed1ab_0.tar.bz2#bff49231f6b67c01a7a89d56ca0693ab
 https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.2.1-pyhd8ed1ab_0.conda#a753c19a0db139920092aaaed7b05a54
 https://conda.anaconda.org/conda-forge/noarch/myst-parser-0.18.1-pyhd8ed1ab_0.tar.bz2#bcfdf5c7d8bf5c6f6be7b4c66fff2eca
diff --git a/.github/locks/win-64_py3.8_lab3.6_environment.conda.lock b/.github/locks/win-64_py3.8_lab3.6_environment.conda.lock
index 7e3bdb6b..698e7dcf 100644
--- a/.github/locks/win-64_py3.8_lab3.6_environment.conda.lock
+++ b/.github/locks/win-64_py3.8_lab3.6_environment.conda.lock
@@ -12,8 +12,9 @@
 #   - jupyterlab-link-share
 #   - keyring
 #   - mypy >=1.0.0
+#   - nbstripout
 #   - pyflakes
-#   - pyproject-fmt
+#   - pyproject-fmt !=0.9.1
 #   - pytest-asyncio
 #   - pytest-check-links
 #   - pytest-cov
@@ -29,6 +30,7 @@
 #   - stringcase
 #   ### docs/environment.yml ###
 #   - ansi2html
+#   - autodoc-traits
 #   - bqplot >=0.12.20
 #   - docutils >=0.19
 #   - doit
@@ -166,7 +168,7 @@ https://conda.anaconda.org/conda-forge/win-64/pyzmq-25.0.0-py38ha85f68a_0.conda#
 https://conda.anaconda.org/conda-forge/noarch/rfc3986-2.0.0-pyhd8ed1ab_0.tar.bz2#d337886e38f965bf97aaec382ff6db00
 https://conda.anaconda.org/conda-forge/noarch/robotframework-6.0.2-pyhd8ed1ab_1.conda#b4dcead8c5a99a14733d9b1ba5b7db09
 https://conda.anaconda.org/conda-forge/noarch/send2trash-1.8.0-pyhd8ed1ab_0.tar.bz2#edab14119efe85c3bf131ad747e9005c
-https://conda.anaconda.org/conda-forge/noarch/setuptools-67.1.0-pyhd8ed1ab_0.conda#845dae446df791b5e1a3eaa6454640c1
+https://conda.anaconda.org/conda-forge/noarch/setuptools-67.3.1-pyhd8ed1ab_0.conda#97e1034f136713e29e0ef02d68cb5f64
 https://conda.anaconda.org/conda-forge/noarch/six-1.16.0-pyh6c4a22f_0.tar.bz2#e5f25f8dbc060e9a8d912e432202afc2
 https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.0-pyhd8ed1ab_0.tar.bz2#dd6cbc539e74cb1f430efbd4575b9303
 https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-2.2.0-pyhd8ed1ab_0.tar.bz2#4d22a9315e78c6827f806065957d566e
@@ -246,7 +248,7 @@ https://conda.anaconda.org/conda-forge/win-64/brotlipy-0.7.0-py38h91455d4_1005.t
 https://conda.anaconda.org/conda-forge/win-64/cmarkgfm-0.8.0-py38h91455d4_2.tar.bz2#53a0c89393e15de6d6684886e441fa51
 https://conda.anaconda.org/conda-forge/win-64/cryptography-39.0.1-py38h95f5157_0.conda#d108e9db131ecd9f37bdf2872188d345
 https://conda.anaconda.org/conda-forge/noarch/doit-0.36.0-pyhd8ed1ab_0.tar.bz2#fc5e53d070f1ee7bb38c2ece282dcb82
-https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.0-pyha770c72_0.conda#84601b6f71f06e161c68e459d4708534
+https://conda.anaconda.org/conda-forge/noarch/hypothesis-6.68.1-pyha770c72_0.conda#3c044b3b920eb287f8c095c7f086ba64
 https://conda.anaconda.org/conda-forge/noarch/importlib_metadata-6.0.0-hd8ed1ab_0.conda#a67d43e1527a37199dd8db913366f68e
 https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.17.3-pyhd8ed1ab_0.conda#723268a468177cd44568eb8f794e0d80
 https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.4.4-pyhd8ed1ab_1.conda#7c0965e1d4a0ee1529e8eaa03a78a5b3
@@ -290,8 +292,9 @@ https://conda.anaconda.org/conda-forge/win-64/pandas-1.5.3-py38h5846ac1_0.conda#
 https://conda.anaconda.org/conda-forge/noarch/robotframework-tidy-3.3.3-pyhd8ed1ab_0.conda#bc4e21b26f0e5bb7517c5d6200912d35
 https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.14-pyhd8ed1ab_0.conda#01f33ad2e0aaf6b5ba4add50dad5ad29
 https://conda.anaconda.org/conda-forge/noarch/black-jupyter-23.1.0-hd8ed1ab_0.conda#5703ad719698bf0d936764e60c7e790e
-https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.1-pyh025b116_0.conda#ec06073a146686e6075ef66924eef65e
+https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.21.2-pyh025b116_0.conda#2f0ef6a37b24f898508d7a5026e32c02
 https://conda.anaconda.org/conda-forge/noarch/nbclient-0.5.13-pyhd8ed1ab_0.tar.bz2#3edde88a191701cf052216c4ba353a83
+https://conda.anaconda.org/conda-forge/noarch/nbstripout-0.6.1-pyhd8ed1ab_0.tar.bz2#53913d98739527409e0f3227ed7eef7d
 https://conda.anaconda.org/conda-forge/noarch/requests-2.28.2-pyhd8ed1ab_0.conda#11d178fc55199482ee48d6812ea83983
 https://conda.anaconda.org/conda-forge/noarch/selenium-4.7.2-pyhd8ed1ab_0.conda#4f6238a5e80996ee0bbee28b487aada5
 https://conda.anaconda.org/conda-forge/noarch/flit-3.8.0-pyhd8ed1ab_0.tar.bz2#d37c34176396c5402cf80c32e67731b7
@@ -302,6 +305,7 @@ https://conda.anaconda.org/conda-forge/noarch/requests-cache-0.9.8-pyhd8ed1ab_0.
 https://conda.anaconda.org/conda-forge/noarch/requests-toolbelt-0.10.1-pyhd8ed1ab_0.tar.bz2#a4cd20af9711434f89d1ec0d2b3ae6ba
 https://conda.anaconda.org/conda-forge/noarch/robotframework-seleniumlibrary-6.0.0-pyhd8ed1ab_0.tar.bz2#2acaef9c04081dc3644501ac575953bf
 https://conda.anaconda.org/conda-forge/noarch/sphinx-5.3.0-pyhd8ed1ab_0.tar.bz2#f9e1fcfe235d655900bfeb6aee426472
+https://conda.anaconda.org/conda-forge/noarch/autodoc-traits-1.1.0-pyhd8ed1ab_0.conda#66907045fcff46748e485201304cea80
 https://conda.anaconda.org/conda-forge/noarch/bqplot-0.12.36-pyhd8ed1ab_0.tar.bz2#bff49231f6b67c01a7a89d56ca0693ab
 https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.2.1-pyhd8ed1ab_0.conda#a753c19a0db139920092aaaed7b05a54
 https://conda.anaconda.org/conda-forge/noarch/myst-parser-0.18.1-pyhd8ed1ab_0.tar.bz2#bcfdf5c7d8bf5c6f6be7b4c66fff2eca
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f6ae0fe3..fc18c7cf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,7 +4,12 @@ on:
   push:
     branches: [main]
   pull_request:
-    branches: [main]
+    branches: ['*']
+  workflow_dispatch:
+
+concurrency:
+  group: ${{ github.ref }}
+  cancel-in-progress: true
 
 env:
   PYTHONIOENCODING: utf-8
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 679a3f4f..6c7c1a35 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,12 +11,20 @@
 - adds `LinkSelection` which mirrors `NodeSelection`, but returns link indices in
   `.source.links`, as they are not guaranteed to have a an `id` column
 - adds `LinkWidths`
+- adds `NodeSizes`
+- adds most of the browser's `Math` functions and constants to the environment made
+  available to `.template` values
+- adds `GraphForces`, which exposes a large number of the forces from [`d3-force-3d`]
+- renames the top-level graph imports `ipyforcegraph.graphs` (was
+  `ipyforcegraph.forcegraph`)
+
+[d3-force-3d]: https://github.com/vasturiano/d3-force-3d
 
 ### `@jupyrdf/jupyter-forcegraph 0.2.0`
 
 - implements bidirectional serialization of `zstd`-compressed dataframes
 - updates TypeScript `target` to `es2018`
-- update to `3d-force-graph 1.70.20`
+- update to `3d-force-graph 1.71.1`
 - update to `force-graph 1.43.0`
 
 ## `0.1.0`
diff --git a/docs/conf.py b/docs/conf.py
index 6e5e5bbb..0f6d3bd3 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,9 +1,8 @@
-""" documentation for ipyforcegraph
-"""
+"""documentation for ``ipyforcegraph``"""
+
 # Copyright (c) 2023 ipyforcegraph contributors.
 # Distributed under the terms of the Modified BSD License.
 
-
 from datetime import datetime
 from pathlib import Path
 
@@ -26,6 +25,7 @@
     "sphinx.ext.autosectionlabel",
     "sphinx.ext.extlinks",
     "sphinx.ext.autodoc",
+    "autodoc_traits",
     "sphinx_autodoc_typehints",
     "sphinx.ext.viewcode",
     "sphinx.ext.intersphinx",
@@ -48,6 +48,7 @@
 autodoc_default_options = {
     "members": True,
     "show-inheritance": True,
+    "undoc-members": True,
 }
 inheritance_alias = {}
 
diff --git a/docs/dictionary.txt b/docs/dictionary.txt
index 5a533771..3a4f43ee 100644
--- a/docs/dictionary.txt
+++ b/docs/dictionary.txt
@@ -1,12 +1,19 @@
 0a1
 2FA
 3d
+BaseD3Force
 bool
+CenterForce
 ci
+ClusterForce
+CollisionForce
 composable
 conda
+cooldown
+d3
 dataframe
 DataFrame
+dataframes
 DataFrameSource
 doit
 env
@@ -15,8 +22,12 @@ ForceBase
 forcegraph
 ForceGraph
 ForceGraph3D
+GraphData
+GraphForces
 GraphImage
 ipyforcegraph
+IPyForceGraph
+IPython
 ipywidgets
 js
 jupyrdf
@@ -35,26 +46,44 @@ LinkDirectionalParticleColor
 LinkDirectionalParticles
 LinkDirectionalParticleSpeed
 LinkDirectionalParticleWidth
+LinkForce
 LinkLabels
+LinkSelection
+LinkWidths
 lockfiles
 maintenance
 Mambaforge
+ManyBodyForce
 networkx
 NetworkX
 NodeColors
 NodeLabels
 NodeSelection
+NodeSizes
 npm
 numpy
 nunjucks
+PandasType
 PRs
 PyData
 pypi
 PyPI
+quadtrees
+RadialForce
 ReadTheDocs
 repr
+rgba
 str
 TBD
 TODO
+traitleted
 traitlets
+traittypes
+TypedTuple
 TypeScript
+vasturiano
+warmup
+WidgetSource
+XForce
+YForce
+ZForce
diff --git a/docs/environment.yml b/docs/environment.yml
index be2a773b..bf76c921 100644
--- a/docs/environment.yml
+++ b/docs/environment.yml
@@ -4,6 +4,7 @@ channels:
 dependencies:
   ### docs/environment.yml ###
   - ansi2html
+  - autodoc-traits
   - bqplot >=0.12.20
   - docutils >=0.19
   - doit
diff --git a/docs/reference/base.md b/docs/reference/base.md
index fd936853..b624b3e0 100644
--- a/docs/reference/base.md
+++ b/docs/reference/base.md
@@ -1,10 +1,13 @@
-# Base Classes
+# Bases
 
 These classes are not generally needed, but are included for completeness.
 
 ```{eval-rst}
 .. currentmodule:: ipyforcegraph
 .. automodule:: ipyforcegraph._base
-    :members:
-    :special-members:
+```
+
+```{eval-rst}
+.. currentmodule:: ipyforcegraph
+.. automodule:: ipyforcegraph.behaviors._base
 ```
diff --git a/docs/reference/behaviors.md b/docs/reference/behaviors.md
index 18470330..6d0ecda3 100644
--- a/docs/reference/behaviors.md
+++ b/docs/reference/behaviors.md
@@ -1,8 +1,22 @@
 # Behaviors
 
+## Graph Behaviors
+
+```{eval-rst}
+.. currentmodule:: ipyforcegraph
+.. automodule:: ipyforcegraph.behaviors.graph
+```
+
+## Link Behaviors
+
+```{eval-rst}
+.. currentmodule:: ipyforcegraph
+.. automodule:: ipyforcegraph.behaviors.link
+```
+
+## Node Behaviors
+
 ```{eval-rst}
 .. currentmodule:: ipyforcegraph
-.. automodule:: ipyforcegraph.behaviors
-    :members:
-    :special-members:
+.. automodule:: ipyforcegraph.behaviors.node
 ```
diff --git a/docs/reference/forces.md b/docs/reference/forces.md
new file mode 100644
index 00000000..45853cd3
--- /dev/null
+++ b/docs/reference/forces.md
@@ -0,0 +1,6 @@
+# Forces
+
+```{eval-rst}
+.. currentmodule:: ipyforcegraph
+.. automodule:: ipyforcegraph.behaviors.forces
+```
diff --git a/docs/reference/index.md b/docs/reference/index.md
index e4df1081..27dd8f1b 100644
--- a/docs/reference/index.md
+++ b/docs/reference/index.md
@@ -9,6 +9,7 @@ This page describe the overall API for `ipyforcegraph`.
 widgets
 sources
 behaviors
+forces
 ```
 
 ## Bases
diff --git a/docs/reference/sources.md b/docs/reference/sources.md
index 57b3f4aa..a691ebfd 100644
--- a/docs/reference/sources.md
+++ b/docs/reference/sources.md
@@ -1,10 +1,11 @@
 # Sources
 
-## DataFrame
-
 ```{eval-rst}
 .. currentmodule:: ipyforcegraph
 .. automodule:: ipyforcegraph.sources.dataframe
-    :members:
-    :special-members:
+```
+
+```{eval-rst}
+.. currentmodule:: ipyforcegraph
+.. automodule:: ipyforcegraph.sources.widget
 ```
diff --git a/docs/reference/widgets.md b/docs/reference/widgets.md
index 5a1a3a1c..732bf36f 100644
--- a/docs/reference/widgets.md
+++ b/docs/reference/widgets.md
@@ -1,8 +1,6 @@
-# Widgets
+# Graphs
 
 ```{eval-rst}
 .. currentmodule:: ipyforcegraph
-.. automodule:: ipyforcegraph.forcegraph
-    :members:
-    :special-members:
+.. automodule:: ipyforcegraph.graphs
 ```
diff --git a/dodo.py b/dodo.py
index 18a47e7b..4d3ee7ea 100644
--- a/dodo.py
+++ b/dodo.py
@@ -1,4 +1,4 @@
-""" doit tasks for ipyforcegraph
+"""doit tasks for ``ipyforcegraph``
 
     Generally, you'll just want to `doit`.
 
@@ -530,7 +530,7 @@ def _test():
         robot_out = robot_template.parent / robot_template.name.replace(".j2", "")
         yield dict(
             name=f"atest:template:{robot_template.relative_to(P.ATEST)}",
-            actions=[(P.template_one, [robot_template, robot_out])],
+            actions=[(U.template_one, [robot_template, robot_out])],
             file_dep=[robot_template],
             targets=[robot_out],
         )
@@ -633,8 +633,18 @@ def task_lint():
                 name=f"nblint:{nb.name}".replace(" ", "_").replace(".ipynb", ""),
                 file_dep=[P.YARN_INTEGRITY, nb, P.HISTORY, P.OK_BLACK],
                 actions=[
-                    [*P.IN_ENV, *P.PYM, "scripts.nblint", nb],
-                    [*P.IN_ENV, "black", "--quiet", nb],
+                    [*P.IN_ENV, "nbstripout", nb],
+                    (U.notebook_lint, [nb]),
+                    [
+                        *P.IN_ENV,
+                        "jupyter-nbconvert",
+                        "--log-level=WARN",
+                        "--to=notebook",
+                        "--inplace",
+                        "--output",
+                        nb,
+                        nb,
+                    ],
                 ],
             ),
             P.OK_NBLINT[nb.name],
@@ -816,33 +826,72 @@ def task_watch_docs():
 
 def _make_spellcheck(dep, html):
     ok = P.BUILD / "spell" / f"{dep.relative_to(html)}.ok"
+    fail_path = P.BUILD / "spell" / f"{dep.relative_to(html)}.fail"
+
+    def spell():
+        if fail_path.exists():
+            fail_path.unlink()
+        fails = sorted(
+            set(
+                subprocess.check_output(
+                    [
+                        *P.IN_ENV,
+                        "hunspell",
+                        "-l",
+                        "-d",
+                        "en_US,en-GB",
+                        "-p",
+                        P.DICTIONARY,
+                        "-H",
+                        dep,
+                    ]
+                )
+                .decode("utf-8")
+                .strip()
+                .splitlines()
+            )
+        )
+
+        if fails:
+            fail_str = "\n".join(fails)
+            print("Unrecognized words in", dep)
+            print(fail_str)
+            fail_path.parent.mkdir(exist_ok=True, parents=True)
+            fail_path.write_text(fail_str, encoding="utf-8")
+
     return _ok(
         dict(
             name=f"""spell:{dep.relative_to(html)}""".replace("/", "_"),
             doc=f"check spelling in {dep.relative_to(html)}",
             file_dep=[dep, P.DICTIONARY],
-            actions=[
-                [
-                    *P.IN_ENV,
-                    "hunspell",
-                    "-l",
-                    "-d",
-                    "en_US,en-GB",
-                    "-p",
-                    P.DICTIONARY,
-                    "-H",
-                    dep,
-                ]
-            ],
+            actions=[spell],
         ),
         ok,
     )
 
 
+def _all_spell():
+    all_fail = []
+    for path in sorted(P.ALL_SPELL.parent.rglob("*.fail")):
+        if path == P.ALL_SPELL:
+            continue
+        all_fail += path.read_text(encoding="utf-8").strip().splitlines()
+
+    all_fail_str = "\n".join(sorted(set(all_fail)))
+
+    if all_fail_str:
+        print("ALL Unrecognized words")
+        print(all_fail_str)
+
+    P.ALL_SPELL.write_text(all_fail_str, encoding="utf-8")
+
+    return len(all_fail) == 0
+
+
 @create_after(executed="docs", target_regex=r"build/docs/html/.*\.html")
 def task_checkdocs():
     """check spelling and links of build docs HTML."""
-    no_check = ["htmlcov", "pytest", "_static"]
+    no_check = ["htmlcov", "pytest", "_static", "genindex"]
     html = P.DOCS_BUILD / "html"
     file_dep = sorted(
         {
@@ -880,5 +929,16 @@ def task_checkdocs():
         P.OK_LINKS,
     )
 
+    spell_tasks = []
+
     for dep in file_dep:
-        yield _make_spellcheck(dep, html)
+        task = _make_spellcheck(dep, html)
+        spell_tasks += [f"""checkdocs:{task["name"]}"""]
+        yield task
+
+    yield dict(
+        name="spell:ALL",
+        task_dep=spell_tasks,
+        actions=[_all_spell],
+        targets=[P.ALL_SPELL],
+    )
diff --git a/examples/3D.ipynb b/examples/3D.ipynb
index b90f2c13..3e446376 100644
--- a/examples/3D.ipynb
+++ b/examples/3D.ipynb
@@ -14,7 +14,9 @@
    "cell_type": "code",
    "execution_count": null,
    "id": "58ac5386-3099-4e55-9b76-ad6d1821a89b",
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
     "if __name__ == \"__main__\" and \"pyodide\" in __import__(\"sys\").modules:\n",
@@ -25,11 +27,14 @@
    "cell_type": "code",
    "execution_count": null,
    "id": "e6accf09-c655-4868-b7d9-dde0eb24b0a2",
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
     "with __import__(\"importnb\").Notebook():\n",
     "    import Behaviors as B\n",
+    "    import Forces as F\n",
     "    import Utils as U"
    ]
   },
@@ -37,22 +42,26 @@
    "cell_type": "code",
    "execution_count": null,
    "id": "eab2ddf5-9eb6-4b27-9fad-3ba26834be3b",
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
-    "from ipyforcegraph.forcegraph import ForceGraph3D"
+    "from ipyforcegraph.graphs import ForceGraph3D"
    ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
    "id": "b4940d34-d11b-4b73-a333-62960c4c1304",
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
     "def make_a_3d_demo():\n",
     "    fg, box = U.make_a_demo(GraphClass=ForceGraph3D)\n",
-    "    for add_behavior in B.all_behaviors:\n",
+    "    for add_behavior in [*B.all_behaviors, *F.all_forces]:\n",
     "        add_behavior(fg, box)\n",
     "    return fg, box"
    ]
@@ -71,6 +80,7 @@
    "source": [
     "if __name__ == \"__main__\":\n",
     "    fg, box = make_a_3d_demo()\n",
+    "    box.description = \"3D Graph Demo\"\n",
     "    display(box)"
    ]
   },
diff --git a/examples/Behaviors.ipynb b/examples/Behaviors.ipynb
index 5b8d3604..b0d25a7a 100644
--- a/examples/Behaviors.ipynb
+++ b/examples/Behaviors.ipynb
@@ -16,7 +16,9 @@
    "cell_type": "code",
    "execution_count": null,
    "id": "a6283c28-99f4-48d0-aa66-6b0e6366f881",
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
     "if __name__ == \"__main__\" and \"pyodide\" in __import__(\"sys\").modules:\n",
@@ -27,10 +29,14 @@
    "cell_type": "code",
    "execution_count": null,
    "id": "9f3f85fe-a0a9-4086-992e-2ca1552f07b8",
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
     "import random\n",
+    "import json\n",
+    "from pathlib import Path\n",
     "\n",
     "import ipywidgets as W\n",
     "import traitlets as T\n",
@@ -42,7 +48,9 @@
    "cell_type": "code",
    "execution_count": null,
    "id": "38280a14-ef4b-451b-be64-cf541cb254e1",
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
     "with __import__(\"importnb\").Notebook():\n",
@@ -71,6 +79,7 @@
    "source": [
     "if __name__ == \"__main__\":\n",
     "    fg, box = U.make_a_demo()\n",
+    "    box.description = \"Behavior Demo\"\n",
     "    display(box)"
    ]
   },
@@ -83,6 +92,60 @@
     "> context menu to watch."
    ]
   },
+  {
+   "cell_type": "markdown",
+   "id": "f6b7f35f-7c46-4966-a41c-635d393490a7",
+   "metadata": {},
+   "source": [
+    "## Data\n",
+    "\n",
+    "A number of datasets are provided."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b097f1f7-66b4-4cb0-ac84-9553cd6119e0",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "def add_dataset(fg, box):\n",
+    "    from ipyforcegraph.sources.dataframe import DataFrameSource\n",
+    "\n",
+    "    datasets = sorted(Path(\"datasets\").glob(\"*.json\"), key=lambda d: d.stat().st_size)\n",
+    "    picker = W.Select(\n",
+    "        options={d.name: d for d in datasets},\n",
+    "        multiple=False,\n",
+    "        rows=1,\n",
+    "        description=\"dataset\",\n",
+    "    )\n",
+    "\n",
+    "    def on_dataset_changed(change):\n",
+    "        old_source = fg.source\n",
+    "        dataset = json.loads(picker.value.read_text(encoding=\"utf-8\"))\n",
+    "        fg.source = DataFrameSource(**dataset)\n",
+    "        old_source.close()\n",
+    "\n",
+    "    picker.observe(on_dataset_changed)\n",
+    "    ui = U.make_a_collapsible_picker(f\"graph: dataset\", children={\"\": [picker]})\n",
+    "    box.graph_ui = {**box.graph_ui, \"dataset\": ui}"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "8fe9fb84-a9d3-4ebe-8a4b-97f54f184105",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "if __name__ == \"__main__\":\n",
+    "    add_dataset(fg, box)"
+   ]
+  },
   {
    "cell_type": "markdown",
    "id": "03480b70-9d5a-44cd-8520-6eae409902d4",
@@ -274,6 +337,12 @@
     "        placeholder=\"select some nodes\",\n",
     "        allowed_tags=sorted(fg.source.nodes.id),\n",
     "    )\n",
+    "\n",
+    "    def on_source_change(change):\n",
+    "        ui_selection.value = []\n",
+    "        ui_selection.allowed_tags = sorted(fg.source.nodes.id)\n",
+    "\n",
+    "    fg.observe(on_source_change, [\"source\"])\n",
     "    box.behaviors = {**box.behaviors, \"node_selection\": selection}\n",
     "    box.node_ui = {**box.node_ui, \"node_selection\": ui_selection}\n",
     "    T.link((selection, \"selected\"), (ui_selection, \"value\"))\n",
@@ -337,9 +406,13 @@
     "\n",
     "    if column_name not in fg.source.nodes:\n",
     "        U.make_random_color_series(fg, \"nodes\", column_name)\n",
+    "\n",
     "    ui_color_column = W.Dropdown(\n",
     "        options=list(fg.source.nodes.columns), value=column_name\n",
     "    )\n",
+    "\n",
+    "    U.subscribe_to_columns(fg, \"nodes\", ui_color_column, \"options\")\n",
+    "\n",
     "    ui_color_template = W.Textarea(layout=dict(max_width=\"100%\"))\n",
     "    ui_color_template_enabled = W.Checkbox(description=\"enabled?\")\n",
     "    T.link((ui_color_column, \"value\"), (colors, \"column_name\"))\n",
@@ -389,7 +462,8 @@
     "calculating dynamic values on the client.\n",
     "\n",
     "The syntax is intentionally very similar to\n",
-    "[jinja2](https://jinja.palletsprojects.com/en/3.1.x/templates).\n",
+    "[jinja2](https://jinja.palletsprojects.com/en/3.1.x/templates), and a number of\n",
+    "[extra template functions](#Templates) are provided\n",
     "\n",
     "Inside of a template, one can use:\n",
     "\n",
@@ -418,6 +492,63 @@
     "  > ```"
    ]
   },
+  {
+   "cell_type": "markdown",
+   "id": "70256045-b566-4161-b71e-d275357e426a",
+   "metadata": {},
+   "source": [
+    "### `NodeSizes`\n",
+    "\n",
+    "Node size can be changed by column or template."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "dca0c06a-7df5-4863-84a4-60851d4cc275",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from ipyforcegraph.behaviors import NodeSizes\n",
+    "\n",
+    "\n",
+    "def add_node_sizes(fg, box, column_name=\"group\"):\n",
+    "    behavior = NodeSizes()\n",
+    "    ui_column = W.Dropdown(options=list(fg.source.nodes.columns), value=column_name)\n",
+    "    U.subscribe_to_columns(fg, \"nodes\", ui_column, \"options\")\n",
+    "    ui_template = W.Textarea()\n",
+    "    ui_template_enabled = W.Checkbox(description=\"enabled?\")\n",
+    "    T.link((ui_column, \"value\"), (behavior, \"column_name\"))\n",
+    "    T.dlink(\n",
+    "        (ui_template, \"value\"),\n",
+    "        (behavior, \"template\"),\n",
+    "        lambda x: x if ui_template_enabled.value else \"\",\n",
+    "    )\n",
+    "    ui = U.make_a_collapsible_picker(\n",
+    "        \"node: size\",\n",
+    "        {\n",
+    "            \"off\": [],\n",
+    "            \"column\": [ui_column],\n",
+    "            \"template\": [W.VBox([ui_template_enabled, ui_template])],\n",
+    "        },\n",
+    "    )\n",
+    "    label = \"node: size\"\n",
+    "    box.node_ui = {**box.node_ui, label: ui}\n",
+    "    U.make_link_dropdown_responsive(behavior, label, ui, box)\n",
+    "    return fg, box"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "1c0627fa-78ac-496b-b23a-e55718c1d3ea",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "if __name__ == \"__main__\":\n",
+    "    add_node_sizes(fg, box)"
+   ]
+  },
   {
    "cell_type": "markdown",
    "id": "96990557-2450-4600-889e-d6e19fa72909",
@@ -444,6 +575,7 @@
     "    ui_label_column = W.Dropdown(\n",
     "        options=list(fg.source.nodes.columns), value=column_name\n",
     "    )\n",
+    "    U.subscribe_to_columns(fg, \"nodes\", ui_label_column, \"options\")\n",
     "    ui_label_template = W.Textarea()\n",
     "    ui_label_template_enabled = W.Checkbox(description=\"enabled?\")\n",
     "    T.link((ui_label_column, \"value\"), (behavior, \"column_name\"))\n",
@@ -538,6 +670,12 @@
     "        placeholder=\"select some link indices\",\n",
     "        allowed_tags=[*range(len(fg.source.links))],\n",
     "    )\n",
+    "\n",
+    "    def on_source_change(change):\n",
+    "        ui_selection.value = []\n",
+    "        ui_selection.allowed_tags = [*range(len(fg.source.links))]\n",
+    "\n",
+    "    fg.observe(on_source_change, [\"source\"])\n",
     "    box.behaviors = {**box.behaviors, \"link_selection\": selection}\n",
     "    box.link_ui = {**box.link_ui, \"link_selection\": ui_selection}\n",
     "    T.link((selection, \"selected\"), (ui_selection, \"value\"))\n",
@@ -825,6 +963,145 @@
     "    add_link_directional_particles(fg, box)"
    ]
   },
+  {
+   "cell_type": "markdown",
+   "id": "97cddfbf-7dc0-44ee-8554-2704e632a802",
+   "metadata": {},
+   "source": [
+    "## Templates\n",
+    "\n",
+    "In addition to the\n",
+    "[built-in `nunjucks` globals, templates, and tags](https://mozilla.github.io/nunjucks/templating.html),\n",
+    "the following\n",
+    "[JS `Math`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math)\n",
+    "constants and functions are also exposed."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "cdb5dca7-c869-4bba-a8e2-f6e364463f72",
+   "metadata": {},
+   "source": [
+    "### Collections"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "c98d240c-6ff7-4e83-bb30-0138f74fa9ea",
+   "metadata": {},
+   "source": [
+    "#### `where`\n",
+    "\n",
+    "Return all items that match a given attribute, e.g. for `NodeSizes.template` to scale a\n",
+    "node by out-degree.\n",
+    "\n",
+    "```\n",
+    "{{ graphData.links | where('source', node) | count }}\n",
+    "```"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "ca7a560b-e7e5-4952-947f-e9c369f3bd81",
+   "metadata": {},
+   "source": [
+    "### Time\n",
+    "\n",
+    "#### `now`\n",
+    "\n",
+    "Returns the current high-precision time from\n",
+    "[`performance.now`](https://developer.mozilla.org/en-US/docs/Web/API/Performance/now)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "ea088d7d-3f0b-4b19-b16a-8d65da4296b2",
+   "metadata": {
+    "tags": []
+   },
+   "source": [
+    "### Constants\n",
+    "\n",
+    "> All are referenced directly:\n",
+    ">\n",
+    "> - `{{ E }}`\n",
+    "\n",
+    "- `E`\n",
+    "- `LN10`\n",
+    "- `LN2`\n",
+    "- `LOG10E`\n",
+    "- `LOG2E`\n",
+    "- `PI`\n",
+    "- `SQRT1_2`\n",
+    "- `SQRT2`"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "4f5a1176-c6f0-4e50-a4db-76a1fa376a5c",
+   "metadata": {},
+   "source": [
+    "### Unary Math Functions\n",
+    "\n",
+    "> All accept a single numeric argument:\n",
+    ">\n",
+    "> - `{{ acos(node.value) }}`.\n",
+    "\n",
+    "- `acos`\n",
+    "- `acosh`\n",
+    "- `asin`\n",
+    "- `asinh`\n",
+    "- `atan`\n",
+    "- `atanh`\n",
+    "- `cbrt`\n",
+    "- `ceil`\n",
+    "- `cos`\n",
+    "- `exp`\n",
+    "- `expm1`\n",
+    "- `floor`\n",
+    "- `fround`\n",
+    "- `log`\n",
+    "- `log10`\n",
+    "- `log1p`\n",
+    "- `log2`\n",
+    "- `sign`\n",
+    "- `sin`\n",
+    "- `sqrt`\n",
+    "- `tan`\n",
+    "- `trunc`"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "d90628fc-5c85-403c-8acd-bb660728934f",
+   "metadata": {},
+   "source": [
+    "### Binary Math Functions\n",
+    "\n",
+    "> All accept exactly two numerica arguments\n",
+    ">\n",
+    "> - `{{ imul(node.value, 1) }}`\n",
+    "\n",
+    "- `imul`\n",
+    "- `atan2`"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "ea0e5496-db57-4923-8a33-9cf3bba53ee2",
+   "metadata": {},
+   "source": [
+    "### N-ary Math Functions\n",
+    "\n",
+    "> All accept _either_:\n",
+    ">\n",
+    "> - `{{ min(3,2,1) }}`\n",
+    "> - `{{ min([4,5,6]) }}`\n",
+    "\n",
+    "- `min`\n",
+    "- `max`"
+   ]
+  },
   {
    "cell_type": "code",
    "execution_count": null,
@@ -835,11 +1112,13 @@
    "outputs": [],
    "source": [
     "all_behaviors = [\n",
+    "    add_dataset,\n",
     "    add_graph_colors,\n",
     "    add_graph_image,\n",
     "    add_graph_data,\n",
     "    add_node_selection,\n",
     "    add_node_colors,\n",
+    "    add_node_sizes,\n",
     "    add_node_labels,\n",
     "    add_link_selection,\n",
     "    add_link_colors,\n",
diff --git a/examples/Forces.ipynb b/examples/Forces.ipynb
new file mode 100644
index 00000000..be623f57
--- /dev/null
+++ b/examples/Forces.ipynb
@@ -0,0 +1,840 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Forces 🏋️\n",
+    "\n",
+    "- [ForceGraph Top level API](https://github.com/vasturiano/force-graph#force-engine-d3-force-configuration)\n",
+    "  - Forcing Functions:\n",
+    "    - `d3Force(str, [fn])`\n",
+    "    - Default forces: {\"link\", \"charge\", \"center\"}\n",
+    "    - Example of\n",
+    "      [\"collide\" and \"box\"](https://vasturiano.github.io/force-graph/example/collision-detection/)([source](https://github.com/vasturiano/force-graph/blob/master/example/collision-detection/index.html))\n",
+    "    - https://www.d3indepth.com/force-layout/\n",
+    "      - forceCenter\n",
+    "      - forceCollide\n",
+    "        - [using mouse](https://observablehq.com/@d3/collision-detection/2?collection=@d3/d3-force)\n",
+    "      - forceLink\n",
+    "      - forceManyBody\n",
+    "      - forceRadial\n",
+    "      - forceX\n",
+    "      - forceY\n",
+    "    - [full on custom](https://technology.amis.nl/frontend/introduction-to-d3-force-for-simulation-and-animation/)\n",
+    "      - use handlebar templates?\n",
+    "  - [DAG](https://github.com/vasturiano/force-graph/blob/master/example/tree/index.html)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "if __name__ == \"__main__\" and \"pyodide\" in __import__(\"sys\").modules:\n",
+    "    %pip install -q -r requirements.txt"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "import asyncio\n",
+    "import random\n",
+    "from typing import Dict\n",
+    "\n",
+    "import ipywidgets as W\n",
+    "import traitlets as T\n",
+    "\n",
+    "import ipyforcegraph.behaviors.forces as F"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "with __import__(\"importnb\").Notebook():\n",
+    "    import Behaviors as B\n",
+    "    import Utils as U\n",
+    "\n",
+    "\n",
+    "node_template_help = {\"context\": \"node\", \"context_all\": \"nodes\"}\n",
+    "link_template_help = {\"context\": \"link\", \"context_all\": \"links\"}\n",
+    "\n",
+    "\n",
+    "def make_slider_template(\n",
+    "    force: F.BaseD3Force,\n",
+    "    attr: str,\n",
+    "    template_help: Dict[str, str],\n",
+    "    slider_kwargs: dict = None,\n",
+    "):\n",
+    "    \"\"\"Make a slider and template combo and link to given `force`'s `attr`.\"\"\"\n",
+    "    slider_kwargs = slider_kwargs if slider_kwargs is not None else {}\n",
+    "    if \"description\" not in slider_kwargs:\n",
+    "        slider_kwargs[\"description\"] = attr.title()\n",
+    "    if \"layout\" not in slider_kwargs:\n",
+    "        slider_kwargs[\"layout\"] = {\"flex\": \"1\"}\n",
+    "    placeholder = (\n",
+    "        \"{{{{{context}}}}}, {{{{i}}}}, {{{{{context_all}}}}} are defined\".format(\n",
+    "            **template_help\n",
+    "        )\n",
+    "    )\n",
+    "\n",
+    "    ui_slider = W.FloatSlider(**slider_kwargs)\n",
+    "    ui_template = W.Textarea(layout=dict(max_width=\"100%\"), placeholder=placeholder)\n",
+    "    T.dlink(\n",
+    "        (ui_template, \"value\"),\n",
+    "        (ui_slider, \"disabled\"),\n",
+    "        lambda x: True if x else False,\n",
+    "    )\n",
+    "    T.dlink((ui_template, \"value\"), (force, attr))\n",
+    "    T.dlink((ui_slider, \"value\"), (force, attr), str)\n",
+    "    return [ui_slider, ui_template]"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "if __name__ == \"__main__\":\n",
+    "    fg, box = U.make_a_demo()\n",
+    "    box.description = \"Force Demo\"\n",
+    "    display(box)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## `GraphForces`\n",
+    "\n",
+    "If the built-in forces do not meet the needs of a particular application, these can be\n",
+    "customized in many ways. These are collected under the `GraphForces` behavior, which has\n",
+    "both top-level parameters, common to the overall simulation engine, as well is\n",
+    "individual named forces inside of it, which are themselves highly configurable."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Warmup and Cooldown\n",
+    "\n",
+    "These `warmup_ticks` and `cooldown_ticks` parameters control how the simulation starts\n",
+    "up and how long it is allowed to run.\n",
+    "\n",
+    "By default, `cooldown_ticks` is set to `-1`, meaning the simulation will be allowed to\n",
+    "run as long as it needs before reaching a steady state.\n",
+    "\n",
+    "When `cooldown_ticks` is set to `0`, the simulation won't run at all, useful for\n",
+    "pre-calculated positions.\n",
+    "\n",
+    "`warmup_ticks` controls how much is simulated off-screen, before any force-driven\n",
+    "animation begins."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "def add_force_ticks(fg, box):\n",
+    "    gf = U.ensure_graph_forces(fg, box)\n",
+    "\n",
+    "    warmup = W.IntSlider(description=\"warmup\", min=0, max=100)\n",
+    "    cooldown = W.IntSlider(description=\"cooldown\", min=-1, max=100)\n",
+    "\n",
+    "    T.link((gf, \"warmup_ticks\"), (warmup, \"value\"))\n",
+    "    T.link((gf, \"cooldown_ticks\"), (cooldown, \"value\"))\n",
+    "\n",
+    "    ui_ticks = U.make_a_collapsible_picker(\n",
+    "        \"graph: warmup & cooldown\",\n",
+    "        {\n",
+    "            \"spoon\": [W.VBox([warmup, cooldown], layout=dict(width=\"100%\"))],\n",
+    "        },\n",
+    "    )\n",
+    "    box.node_ui = {**box.node_ui, \"graph_ticks\": ui_ticks}\n",
+    "    return fg, box"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "if __name__ == \"__main__\":\n",
+    "    add_force_ticks(fg, box)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Alpha and Velocity\n",
+    "\n",
+    "The parameters `alpha_min`, `alpha_decay` and `velocity_decay` fine-tune the natural\n",
+    "stopping state of the simulation."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "def add_alpha_velocity(fg, box):\n",
+    "    gf = U.ensure_graph_forces(fg, box)\n",
+    "\n",
+    "    alpha_min = W.FloatSlider(\n",
+    "        description=\"alpha min\", min=0, max=1, step=0.0001, readout_format=\".4f\"\n",
+    "    )\n",
+    "    alpha_decay = W.FloatSlider(\n",
+    "        description=\"alpha decay\", min=0, max=1, step=0.0001, readout_format=\".4f\"\n",
+    "    )\n",
+    "    velocity_decay = W.FloatSlider(\n",
+    "        description=\"velocity decay\", min=0, max=1, step=0.0001, readout_format=\".4f\"\n",
+    "    )\n",
+    "\n",
+    "    T.link((gf, \"alpha_min\"), (alpha_min, \"value\"))\n",
+    "    T.link((gf, \"alpha_decay\"), (alpha_decay, \"value\"))\n",
+    "    T.link((gf, \"velocity_decay\"), (velocity_decay, \"value\"))\n",
+    "\n",
+    "    ui_ticks = U.make_a_collapsible_picker(\n",
+    "        \"graph: alpha & velocity\",\n",
+    "        {\n",
+    "            \"av\": [\n",
+    "                W.VBox(\n",
+    "                    [alpha_min, alpha_decay, velocity_decay], layout=dict(width=\"100%\")\n",
+    "                )\n",
+    "            ],\n",
+    "        },\n",
+    "    )\n",
+    "    box.node_ui = {**box.node_ui, \"graph_alpha_velocity\": ui_ticks}\n",
+    "    return fg, box"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "if __name__ == \"__main__\":\n",
+    "    add_alpha_velocity(fg, box)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Base Forces"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "By default the forcegraph uses the following forces:\n",
+    "\n",
+    "- `link` - LinkForce\n",
+    "- `charge` - ManyBodyForce\n",
+    "- `force_center` - CenterForce"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "def force_toggle_ui(gf, force):\n",
+    "    force_enabled = W.Checkbox(value=True, description=\"enabled?\")\n",
+    "    T.link((force, \"active\"), (force_enabled, \"value\"))\n",
+    "    return force_enabled\n",
+    "\n",
+    "\n",
+    "def add_charge_force(fg, box):\n",
+    "    gf = U.ensure_graph_forces(fg, box)\n",
+    "    force = F.ManyBodyForce()\n",
+    "    gf.forces = {**gf.forces, \"charge\": force}\n",
+    "\n",
+    "    sliders = dict(\n",
+    "        distance_min=W.FloatSlider(\n",
+    "            description=\"Min Distance\", min=0, value=0, max=200, layout={\"flex\": \"1\"}\n",
+    "        ),\n",
+    "        distance_max=W.FloatSlider(\n",
+    "            description=\"Max Distance\",\n",
+    "            min=0,\n",
+    "            value=20000,\n",
+    "            max=20000,\n",
+    "            layout={\"flex\": \"1\"},\n",
+    "        ),\n",
+    "    )\n",
+    "\n",
+    "    for trait, slider in sliders.items():\n",
+    "        T.link((slider, \"value\"), (force, trait))\n",
+    "\n",
+    "    controls = [\n",
+    "        force_toggle_ui(gf, force),\n",
+    "        *sliders.values(),\n",
+    "        *make_slider_template(\n",
+    "            force,\n",
+    "            attr=\"strength\",\n",
+    "            template_help=node_template_help,\n",
+    "            slider_kwargs={\"min\": -60, \"value\": -30, \"max\": 2},\n",
+    "        ),\n",
+    "    ]\n",
+    "\n",
+    "    force_ui, children = U.ensure_graph_force_ui(\n",
+    "        fg, box, {\"charge\": [W.VBox(controls, layout={\"flex\": \"1\"})]}\n",
+    "    )\n",
+    "    return fg, box\n",
+    "\n",
+    "\n",
+    "def add_link_force(fg, box):\n",
+    "    gf = U.ensure_graph_forces(fg, box)\n",
+    "    force = F.LinkForce()\n",
+    "    gf.forces = {**gf.forces, \"link\": force}\n",
+    "\n",
+    "    controls = [\n",
+    "        force_toggle_ui(gf, force),\n",
+    "        *make_slider_template(\n",
+    "            force,\n",
+    "            attr=\"distance\",\n",
+    "            template_help=link_template_help,\n",
+    "            slider_kwargs={\"min\": 0, \"value\": 30, \"max\": 100},\n",
+    "        ),\n",
+    "        *make_slider_template(\n",
+    "            force,\n",
+    "            attr=\"strength\",\n",
+    "            template_help=link_template_help,\n",
+    "            slider_kwargs={\"min\": 0, \"value\": 0.1, \"max\": 1},\n",
+    "        ),\n",
+    "    ]\n",
+    "\n",
+    "    force_ui, children = U.ensure_graph_force_ui(\n",
+    "        fg, box, {\"link\": [W.VBox(controls, layout={\"flex\": \"1\"})]}\n",
+    "    )\n",
+    "    return fg, box\n",
+    "\n",
+    "\n",
+    "def add_center_force(fg, box):\n",
+    "    gf = U.ensure_graph_forces(fg, box)\n",
+    "    force = F.CenterForce()\n",
+    "    gf.forces = {**gf.forces, \"center\": force}\n",
+    "\n",
+    "    # Center UI\n",
+    "    center_sliders = dict(\n",
+    "        x=W.FloatSlider(description=\"X\", min=-200, max=200, layout={\"flex\": \"1\"}),\n",
+    "        y=W.FloatSlider(description=\"Y\", min=-200, max=200, layout={\"flex\": \"1\"}),\n",
+    "        z=W.FloatSlider(description=\"Z\", min=-200, max=200, layout={\"flex\": \"1\"}),\n",
+    "    )\n",
+    "\n",
+    "    for key in [\"x\", \"y\", \"z\"]:\n",
+    "        T.link((center_sliders[key], \"value\"), (force, key))\n",
+    "    controls = [force_toggle_ui(gf, force), *center_sliders.values()]\n",
+    "\n",
+    "    force_ui, children = U.ensure_graph_force_ui(\n",
+    "        fg, box, {\"center\": [W.VBox(controls, layout={\"flex\": \"1\"})]}\n",
+    "    )\n",
+    "    return fg, box\n",
+    "\n",
+    "\n",
+    "if __name__ == \"__main__\":\n",
+    "    fg, box = add_charge_force(fg, box)\n",
+    "    fg, box = add_link_force(fg, box)\n",
+    "    fg, box = add_center_force(fg, box)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## `CollisionForce`\n",
+    "\n",
+    "The collision force treats nodes as circles with a given `radius`, rather than points\n",
+    "and prevents nodes from overlapping."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "def add_collide_force(fg, box):\n",
+    "    gf = U.ensure_graph_forces(fg, box)\n",
+    "    force = F.CollisionForce(radius=\"4\", active=False)\n",
+    "    gf.forces = {**gf.forces, \"collide\": force}\n",
+    "    strength_slider = W.FloatSlider(\n",
+    "        description=\"Strength\", min=0, value=1, max=1, layout={\"flex\": \"1\"}\n",
+    "    )\n",
+    "    controls = [\n",
+    "        force_toggle_ui(gf, force),\n",
+    "        strength_slider,\n",
+    "        *make_slider_template(\n",
+    "            force,\n",
+    "            attr=\"radius\",\n",
+    "            template_help=node_template_help,\n",
+    "            slider_kwargs={\"min\": 0, \"value\": int(force.radius), \"max\": 100},\n",
+    "        ),\n",
+    "    ]\n",
+    "\n",
+    "    T.link((strength_slider, \"value\"), (force, \"strength\"))\n",
+    "    children = {\n",
+    "        \"collide\": [W.VBox(controls, layout={\"flex\": \"1\"})],\n",
+    "    }\n",
+    "    force_ui, children = U.ensure_graph_force_ui(fg, box, children)\n",
+    "\n",
+    "    return fg, box"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "if __name__ == \"__main__\":\n",
+    "    add_collide_force(fg, box)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## `RadialForce`\n",
+    "\n",
+    "The radial positioning force create a force towards a circle of the specified radius\n",
+    "centered at (x, y)."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "def add_radial_force(fg, box):\n",
+    "    gf = U.ensure_graph_forces(fg, box)\n",
+    "    force = F.RadialForce(radius=\"100\", strength=\"0\", active=False)\n",
+    "    gf.forces = {**gf.forces, \"radial\": force}\n",
+    "\n",
+    "    center_sliders = dict(\n",
+    "        x=W.FloatSlider(description=\"X\", min=-200, max=200, layout={\"flex\": \"1\"}),\n",
+    "        y=W.FloatSlider(description=\"Y\", min=-200, max=200, layout={\"flex\": \"1\"}),\n",
+    "        z=W.FloatSlider(description=\"Z\", min=-200, max=200, layout={\"flex\": \"1\"}),\n",
+    "    )\n",
+    "\n",
+    "    for key, slider in center_sliders.items():\n",
+    "        T.link((slider, \"value\"), (force, key))\n",
+    "\n",
+    "    controls = [\n",
+    "        force_toggle_ui(gf, force),\n",
+    "        *center_sliders.values(),\n",
+    "        *make_slider_template(\n",
+    "            force,\n",
+    "            attr=\"strength\",\n",
+    "            template_help=node_template_help,\n",
+    "            slider_kwargs={\"min\": -10, \"value\": 0, \"max\": 10},\n",
+    "        ),\n",
+    "        *make_slider_template(\n",
+    "            force,\n",
+    "            attr=\"radius\",\n",
+    "            template_help=node_template_help,\n",
+    "            slider_kwargs={\"min\": 0, \"value\": 10, \"max\": 200},\n",
+    "        ),\n",
+    "    ]\n",
+    "\n",
+    "    children = {\n",
+    "        \"radial\": [\n",
+    "            W.VBox(\n",
+    "                controls,\n",
+    "                layout={\"flex\": \"1\"},\n",
+    "            )\n",
+    "        ],\n",
+    "    }\n",
+    "    force_ui, children = U.ensure_graph_force_ui(fg, box, children)\n",
+    "\n",
+    "    return fg, box\n",
+    "\n",
+    "\n",
+    "if __name__ == \"__main__\":\n",
+    "    fg, box = add_radial_force(fg, box)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Cluster Force\n",
+    "\n",
+    "A force type that attracts nodes toward a set of cluster centers.\n",
+    "\n",
+    "`centers` - Defines each node's cluster center. All cluster centers should be defined as\n",
+    "a radius and set of coordinates { radius, x, y, z }, according to the number of spatial\n",
+    "dimensions in the simulation."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "def add_cluster_force(fg, box):\n",
+    "    gf = U.ensure_graph_forces(fg, box)\n",
+    "    force = F.ClusterForce(active=False)\n",
+    "    gf.forces = {**gf.forces, \"cluster\": force}\n",
+    "\n",
+    "    center_inertia = W.FloatSlider(\n",
+    "        description=\"Inertia\", min=0, value=0, max=1, layout={\"flex\": \"1\"}\n",
+    "    )\n",
+    "    strength = W.FloatSlider(\n",
+    "        description=\"Strength\", min=0, value=0, max=1, layout={\"flex\": \"1\"}\n",
+    "    )\n",
+    "    centers_template = W.Textarea(\n",
+    "        layout=dict(flex=\"1\", max_width=\"100%\"), placeholder=\"cluster center template\"\n",
+    "    )\n",
+    "    T.link((center_inertia, \"value\"), (force, \"center_inertia\"))\n",
+    "    T.link((strength, \"value\"), (force, \"strength\"))\n",
+    "    T.link((centers_template, \"value\"), (force, \"centers\"))\n",
+    "\n",
+    "    controls = [\n",
+    "        force_toggle_ui(gf, force),\n",
+    "        center_inertia,\n",
+    "        strength,\n",
+    "        centers_template,\n",
+    "    ]\n",
+    "\n",
+    "    children = {\n",
+    "        \"cluster\": [\n",
+    "            W.VBox(\n",
+    "                controls,\n",
+    "                layout={\"flex\": \"1\"},\n",
+    "            )\n",
+    "        ],\n",
+    "    }\n",
+    "    force_ui, children = U.ensure_graph_force_ui(fg, box, children)\n",
+    "\n",
+    "    return fg, box\n",
+    "\n",
+    "\n",
+    "if __name__ == \"__main__\":\n",
+    "    fg, box = add_cluster_force(fg, box)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Positioning Forces X, Y, and Z will create a force pushing/pulling nodes along given\n",
+    "axis position."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def config_pos_force(axis: str):\n",
+    "    force_map = {\n",
+    "        \"x\": F.XForce,\n",
+    "        \"y\": F.YForce,\n",
+    "        \"z\": F.ZForce,\n",
+    "    }\n",
+    "\n",
+    "    force = force_map[axis](active=False)\n",
+    "\n",
+    "    def add_pos_force(fg, box):\n",
+    "        gf = U.ensure_graph_forces(fg, box)\n",
+    "\n",
+    "        gf.forces = {**gf.forces, axis: force}\n",
+    "        controls = [\n",
+    "            force_toggle_ui(gf, force),\n",
+    "            *make_slider_template(\n",
+    "                force,\n",
+    "                attr=axis,\n",
+    "                template_help=node_template_help,\n",
+    "                slider_kwargs=dict(max=10),\n",
+    "            ),\n",
+    "            *make_slider_template(\n",
+    "                force, attr=\"strength\", template_help=node_template_help\n",
+    "            ),\n",
+    "        ]\n",
+    "\n",
+    "        children = {\n",
+    "            axis: [\n",
+    "                W.VBox(\n",
+    "                    controls,\n",
+    "                    layout={\"flex\": \"1\"},\n",
+    "                )\n",
+    "            ],\n",
+    "        }\n",
+    "        force_ui, children = U.ensure_graph_force_ui(fg, box, children)\n",
+    "        return fg, box\n",
+    "\n",
+    "    return add_pos_force"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "add_x_force = config_pos_force(\"x\")\n",
+    "add_y_force = config_pos_force(\"y\")\n",
+    "add_z_force = config_pos_force(\"z\")\n",
+    "if __name__ == \"__main__\":\n",
+    "    fg, box = add_x_force(fg, box)\n",
+    "    fg, box = add_y_force(fg, box)\n",
+    "    fg, box = add_z_force(fg, box)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "add_base_forces = [\n",
+    "    add_link_force,\n",
+    "    add_charge_force,\n",
+    "    add_center_force,\n",
+    "]\n",
+    "all_forces = [\n",
+    "    add_force_ticks,\n",
+    "    add_alpha_velocity,\n",
+    "    *add_base_forces,\n",
+    "    add_collide_force,\n",
+    "    add_radial_force,\n",
+    "    add_cluster_force,\n",
+    "    add_x_force,\n",
+    "    add_y_force,\n",
+    "    add_z_force,\n",
+    "]"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Tests\n",
+    "\n",
+    "These tests are executed in the [Force Tests Demo](./Test_Forces.ipynb) notebook."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import pandas as pd\n",
+    "\n",
+    "\n",
+    "async def get_positions(fg, box) -> pd.DataFrame:\n",
+    "    graph_data = box.behaviors[\"graph_data\"]\n",
+    "    graph_data.capturing = True\n",
+    "    data = await U.wait_for_change(graph_data.sources[0], \"nodes\")\n",
+    "    return data\n",
+    "\n",
+    "\n",
+    "def check_center(pos: pd.DataFrame, x=0, y=0, tolerance=0.1):\n",
+    "    x0, y0 = map(lambda s: s.mean(), [pos.x, pos.y])\n",
+    "    delta_x = tolerance * (pos.x.max() - pos.x.min())\n",
+    "    delta_y = tolerance * (pos.y.max() - pos.y.min())\n",
+    "\n",
+    "    errors = []\n",
+    "\n",
+    "    if abs(x0 - x) > delta_x:\n",
+    "        errors.append(\n",
+    "            f\"Expecting x position to be near {x}. \\n\\tNot x={x0} \\n\\tdelta_x={delta_x}\"\n",
+    "        )\n",
+    "    if abs(y0 - y) > delta_y:\n",
+    "        errors.append(\n",
+    "            f\"Expecting y position to be near {y}. \\n\\tNot y={y0} \\n\\tdelta_y={delta_y}\"\n",
+    "        )\n",
+    "    assert len(errors) == 0, \"\\n\".join(errors)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Center Force Test"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "async def test_center_force(fg, box):\n",
+    "    \"\"\"Varies the x and y coordinate for the center force\"\"\"\n",
+    "    gf = U.ensure_graph_forces(fg, box)\n",
+    "    force = gf.forces[\"center\"]\n",
+    "    force.active = True  # ensure the force is on\n",
+    "\n",
+    "    force.x = 100\n",
+    "    force.y = 100\n",
+    "    box.description = f\"Moving center to ({force.x}, {force.y})\"\n",
+    "    await asyncio.sleep(4)\n",
+    "    pos = await get_positions(fg, box)\n",
+    "\n",
+    "    check_center(\n",
+    "        pos,\n",
+    "        force.x,\n",
+    "        force.y,\n",
+    "    )\n",
+    "\n",
+    "    force.x = 0\n",
+    "    force.y = 0\n",
+    "    box.description = f\"Moving center to ({force.x}, {force.y})\"\n",
+    "    await asyncio.sleep(4)\n",
+    "    pos = await get_positions(fg, box)\n",
+    "    check_center(\n",
+    "        pos,\n",
+    "        force.x,\n",
+    "        force.y,\n",
+    "    )"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Positioning Force Tests"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def test_pos_force(axis: str):\n",
+    "    assert axis in [\"x\", \"y\"], f\"Invalid pos axis `{axis}`\"\n",
+    "    off_axis = \"y\" if axis == \"x\" else \"x\"\n",
+    "\n",
+    "    async def test_force(fg, box):\n",
+    "        gf = U.ensure_graph_forces(fg, box)\n",
+    "        force = gf.forces[axis]\n",
+    "        force.active = True  # ensure the force is on\n",
+    "        force.strength = \"1\"\n",
+    "        axis_pos = 10\n",
+    "        setattr(force, axis, str(axis_pos))\n",
+    "        box.description = f\"Moving axis to {axis}={axis_pos}\"\n",
+    "        await asyncio.sleep(4)\n",
+    "        pos = await get_positions(fg, box)\n",
+    "        center = pos[axis].mean()\n",
+    "        assert (center - axis_pos) / (\n",
+    "            pos[axis].max() - pos[axis].min()\n",
+    "        ) < 0.01, f\"Expecting center near {axis_pos} not {center}\"\n",
+    "\n",
+    "        axis_pos = 200\n",
+    "        setattr(force, axis, str(axis_pos))\n",
+    "        box.description = f\"Moving axis to {axis}={axis_pos}\"\n",
+    "        await asyncio.sleep(4)\n",
+    "        pos = await get_positions(fg, box)\n",
+    "        center = pos[axis].mean()\n",
+    "        assert (center - axis_pos) / (\n",
+    "            pos[axis].max() - pos[axis].min()\n",
+    "        ) < 0.01, f\"Expecting center near {axis_pos} not {center}\"\n",
+    "\n",
+    "        # test the spread of y goes down after reseting the force strength\n",
+    "        std_off_axis = pos[off_axis].std()\n",
+    "        force.strength = None\n",
+    "        box.description = f\"Testing spread on {off_axis}.\"\n",
+    "        await asyncio.sleep(4)\n",
+    "        pos = await get_positions(fg, box)\n",
+    "\n",
+    "        assert (\n",
+    "            std_off_axis > pos[off_axis].std()\n",
+    "        ), f\"Expecting `{off_axis}` spread to be smaller after resetting the force\"\n",
+    "\n",
+    "    test_force.__doc__ = f\"Changing the position of the Force on the {axis} axis.\"\n",
+    "    return test_force"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "all_force_tests = [test_center_force, test_pos_force(\"x\"), test_pos_force(\"y\")]"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.11.0"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/examples/Test_Forces.ipynb b/examples/Test_Forces.ipynb
new file mode 100644
index 00000000..09bd2b83
--- /dev/null
+++ b/examples/Test_Forces.ipynb
@@ -0,0 +1,104 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "873e787a-c90a-4fb4-b095-f0d48b45bc5e",
+   "metadata": {},
+   "source": [
+    "# Forces Test Demo 🎥"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b9d276ac-b3ed-4410-9056-578eea1458b5",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "if __name__ == \"__main__\" and \"pyodide\" in __import__(\"sys\").modules:\n",
+    "    %pip install -q -r requirements.txt"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "7d6746c8-9c4f-40ab-b326-8b7eba2a86ce",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "import asyncio\n",
+    "\n",
+    "import ipyforcegraph.behaviors.forces as F\n",
+    "\n",
+    "with __import__(\"importnb\").Notebook():\n",
+    "    import Behaviors as B\n",
+    "    import Forces as F\n",
+    "    import Utils as U"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "c13e2350-546c-45c9-9544-15343e1051da",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "fg, box = U.make_a_demo()\n",
+    "display(box)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "1dc808d0-1c8e-42e1-9b2b-88ea2aef0eb9",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "B.add_graph_data(fg, box)\n",
+    "for add_behavior in F.all_forces:\n",
+    "    add_behavior(fg, box)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "18ab7af5-b6ef-4eb5-81ea-0d528f673cc3",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "asyncio.create_task(U.run_tests(fg, box, F.all_force_tests))"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.11.0"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/Utils.ipynb b/examples/Utils.ipynb
index 9cd1bee7..9ee125cb 100644
--- a/examples/Utils.ipynb
+++ b/examples/Utils.ipynb
@@ -25,17 +25,24 @@
    "cell_type": "code",
    "execution_count": null,
    "id": "9f3f85fe-a0a9-4086-992e-2ca1552f07b8",
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
+    "import asyncio\n",
     "import json\n",
     "import random\n",
+    "import traceback\n",
     "from pathlib import Path\n",
+    "from typing import Any\n",
     "\n",
     "import ipywidgets as W\n",
     "import traitlets as T\n",
     "\n",
-    "from ipyforcegraph.forcegraph import DataFrameSource, ForceGraph"
+    "import ipyforcegraph.behaviors as B\n",
+    "import ipyforcegraph.graphs as G\n",
+    "import ipyforcegraph.sources as S"
    ]
   },
   {
@@ -45,11 +52,12 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "def make_a_demo(source=None, dataset=\"datasets/miserables.json\", GraphClass=ForceGraph):\n",
+    "def make_a_demo(source=None, dataset=\"datasets/miserables.json\", GraphClass=None):\n",
+    "    GraphClass = GraphClass or G.ForceGraph\n",
     "    if source is None:\n",
     "        data = json.loads(Path(dataset).read_text())\n",
-    "        source = DataFrameSource(**data)\n",
-    "    fg = GraphClass(source=source, layout=dict(min_height=\"500px\"))\n",
+    "        source = S.DataFrameSource(**data)\n",
+    "    fg = GraphClass(source=source, layout=dict(min_height=\"500px\", flex=\"1\"))\n",
     "    style = W.HTML(\n",
     "        \"\"\"<style>\n",
     "        .jp-fg-demo{\n",
@@ -59,19 +67,23 @@
     "        .widget-box:empty{display:none;}\n",
     "    </style>\"\"\"\n",
     "    )\n",
+    "    description_ui = W.HTML(layout=dict(flex=\"0\"))\n",
     "    graph_ui = W.VBox()\n",
     "    node_ui = W.VBox()\n",
     "    link_ui = W.VBox()\n",
     "    ui = W.VBox(\n",
     "        [graph_ui, node_ui, link_ui], layout=dict(width=\"500px\", overflow_y=\"scroll\")\n",
     "    )\n",
-    "    box = W.HBox([style, fg, ui], layout=dict(height=\"100%\", max_height=\"100vh\"))\n",
+    "    layout = dict(height=\"100%\", max_height=\"100vh\")\n",
+    "    fg_wrap = W.VBox([description_ui, fg], layout=dict(flex=\"1\", **layout))\n",
+    "    box = W.HBox([style, fg_wrap, ui], layout=layout)\n",
     "    box.add_class(\"jp-fg-demo\")\n",
     "    box.add_traits(\n",
     "        behaviors=T.Dict(),\n",
     "        node_ui=T.Dict(),\n",
     "        link_ui=T.Dict(),\n",
     "        graph_ui=T.Dict(),\n",
+    "        description=T.Unicode(),\n",
     "    )\n",
     "\n",
     "    def on_box_behaviors(change=None):\n",
@@ -83,6 +95,7 @@
     "    T.dlink((box, \"graph_ui\"), (graph_ui, \"children\"), lambda d: tuple(d.values()))\n",
     "    T.dlink((box, \"node_ui\"), (node_ui, \"children\"), lambda d: tuple(d.values()))\n",
     "    T.dlink((box, \"link_ui\"), (link_ui, \"children\"), lambda d: tuple(d.values()))\n",
+    "    T.dlink((box, \"description\"), (description_ui, \"value\"), lambda d: d or \"\")\n",
     "    return fg, box"
    ]
   },
@@ -190,6 +203,7 @@
     "        ui_column_name = W.Dropdown(\n",
     "            options=list(fg.source.links.columns), value=column_name\n",
     "        )\n",
+    "        subscribe_to_columns(fg, \"links\", ui_column_name, \"options\")\n",
     "        ui_template = W.Textarea()\n",
     "        ui_template_enabled = W.Checkbox(description=\"enabled?\")\n",
     "        T.link((ui_column_name, \"value\"), (behavior, \"column_name\"))\n",
@@ -216,6 +230,164 @@
     "\n",
     "    return add_behavior"
    ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "c8448de6-ffaa-4e8d-b9cb-ffb6ec0c00eb",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def subscribe_to_columns(fg, elements, ui, trait, tx=None):\n",
+    "    tx = tx or list\n",
+    "\n",
+    "    def on_source_change(change):\n",
+    "        setattr(ui, trait, tx(getattr(fg.source, elements).columns))\n",
+    "\n",
+    "    fg.observe(on_source_change, [\"source\"])"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "94ed0ccf-3d9e-4b9b-b365-613e6ca266da",
+   "metadata": {},
+   "source": [
+    "## Forces"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "379d8c85-a6c6-4d74-8291-13d96022f8d1",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def ensure_graph_forces(fg, box):\n",
+    "    if \"graph_forces\" not in box.behaviors:\n",
+    "        box.behaviors = {**box.behaviors, \"graph_forces\": B.GraphForces()}\n",
+    "\n",
+    "    return box.behaviors[\"graph_forces\"]"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "98fd780d-fc4c-4021-87d0-8ea9f5a7025b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def make_a_force_picker(title, children):\n",
+    "    ui_children = []\n",
+    "    ui = W.HBox(layout=dict(flex_wrap=\"wrap\"))\n",
+    "    box = W.Accordion([ui], titles=[title])\n",
+    "    select = None\n",
+    "    if len(children) > 1:\n",
+    "        options = [(key, value) for key, value in children.items()]\n",
+    "        select = W.Dropdown(options=options, layout=dict(flex=\"0\"))\n",
+    "        ui.children = [select]\n",
+    "        T.dlink((select, \"value\"), (ui, \"children\"), lambda x: [select, *select.value])\n",
+    "        T.dlink(\n",
+    "            (select, \"value\"),\n",
+    "            (box, \"titles\"),\n",
+    "            lambda x: (f\"{title} ({[n for n, v in select.options if v is x][0]})\",),\n",
+    "        )\n",
+    "        box.titles = (f\"{title} ({select.value})\",)\n",
+    "    elif len(children) == 1:\n",
+    "        ui.children = tuple(list(children.values())[0])\n",
+    "    else:\n",
+    "        raise ValueError(f\"unexpected number of children {children}\")\n",
+    "    box._select = select\n",
+    "    return box"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "999cce0f-c262-43df-a4fc-4ec318f92c6b",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "def ensure_graph_force_ui(fg, box, children: dict):\n",
+    "    if \"forces\" not in box.graph_ui:\n",
+    "        ui = make_a_force_picker(\"Forces\", {\"link\": [], \"charge\": [], \"center\": []})\n",
+    "        box.graph_ui = {**box.graph_ui, \"forces\": ui}\n",
+    "\n",
+    "    ui = box.graph_ui[\"forces\"]\n",
+    "    existing = {key: child for key, child in ui._select.options}\n",
+    "    for key, child in children.items():\n",
+    "        existing[key] = child\n",
+    "\n",
+    "    ui._select.options = [(key, value) for key, value in existing.items()]\n",
+    "    return ui, existing"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "684e9c17-6a15-4549-aef0-15c8762879ca",
+   "metadata": {},
+   "source": [
+    "## Tests"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "210181a9-d7d5-444e-a987-f177fc37bf3d",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def wait_for_change(widget: W.Widget, value: Any, timeout=10) -> asyncio.Future:\n",
+    "    \"\"\"Initial pattern from\n",
+    "    https://ipywidgets.readthedocs.io/en/stable/examples/Widget%20Asynchronous.html\n",
+    "    \"\"\"\n",
+    "\n",
+    "    future: asyncio.Future = asyncio.Future()\n",
+    "\n",
+    "    def getvalue(change: T.Bunch) -> None:\n",
+    "        \"\"\"make the new value available\"\"\"\n",
+    "        future.set_result(change.new)\n",
+    "\n",
+    "    def unobserve(f: Any) -> None:\n",
+    "        \"\"\"unobserves the `getvalue` callback\"\"\"\n",
+    "        widget.unobserve(getvalue, value)\n",
+    "\n",
+    "    future.add_done_callback(unobserve)\n",
+    "\n",
+    "    widget.observe(getvalue, value)\n",
+    "\n",
+    "    return asyncio.wait_for(future, timeout)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "329e714e-a404-445e-be91-0cb997e52ac5",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "async def run_tests(fg, box, tests: list, timeout=30):\n",
+    "    result = \"\"\n",
+    "    errors = []\n",
+    "    for test in tests:\n",
+    "        box.description = test.__doc__\n",
+    "\n",
+    "        try:\n",
+    "            await asyncio.wait_for(test(fg, box), timeout)\n",
+    "            box.description = f\"✅ {test.__doc__}\"\n",
+    "            result += \"✅\"\n",
+    "        except Exception as err:\n",
+    "            box.description = f\"💥 {test.__doc__}\"\n",
+    "            errors += [str(err), traceback.format_exc()]\n",
+    "            result += \"💥\"\n",
+    "    box.description = f\"{result} Tests Completed\"\n",
+    "    if errors:\n",
+    "        nl = \"\\n\"\n",
+    "        box.description += f\"\"\"<pre>{nl.join(errors)}</pre>\"\"\""
+   ]
   }
  ],
  "metadata": {
diff --git a/examples/WidgetSource.ipynb b/examples/WidgetSource.ipynb
new file mode 100644
index 00000000..a23f0f9f
--- /dev/null
+++ b/examples/WidgetSource.ipynb
@@ -0,0 +1,253 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "23c055f5-b003-4560-8e62-4f788a07671b",
+   "metadata": {},
+   "source": [
+    "# 🥄 Widget Source\n",
+    "\n",
+    "Instantiating a rich Jupyter Widgets application can create a graph with thousands of\n",
+    "dynamic nodes and links. The `WidgetSource` allows for viewing a snapshot of this."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "82522d4b-d143-499a-b9e0-47b69e15ecb7",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "if __name__ == \"__main__\" and \"pyodide\" in __import__(\"sys\").modules:\n",
+    "    %pip install -q -r requirements.txt"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "54882fbb-8178-4a1d-9125-bb92e59db30c",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "import ipywidgets as W\n",
+    "import json\n",
+    "import IPython\n",
+    "import traitlets as T\n",
+    "from ipyforcegraph.sources.widget import WidgetSource\n",
+    "from ipyforcegraph.graphs import ForceGraph\n",
+    "import ipyforcegraph.behaviors as B\n",
+    "import ipyforcegraph.behaviors.forces as F"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "8d39598d-2044-4c0e-90f2-6643e84d1ca8",
+   "metadata": {},
+   "source": [
+    "## A Simple Widget App"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "ce87aa13-1a27-47c2-ae6e-a45a8594391a",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "x = W.FloatSlider()\n",
+    "y = W.IntSlider()\n",
+    "box = W.HBox([x, y])\n",
+    "T.link((x, \"value\"), (y, \"value\"))\n",
+    "box"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "ab0cc4f8-607a-4306-ad39-acffc06170d5",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "fg = ForceGraph(\n",
+    "    source=WidgetSource([box]),\n",
+    "    behaviors=[\n",
+    "        B.NodeLabels(\n",
+    "            template=(\n",
+    "                \"<code>{{ node.name }}</code> \"\n",
+    "                \"{{ node.type }} \"\n",
+    "                \"(<code>{{ node.py_module}}.<b>{{ node.py_class }}</b></code>)\"\n",
+    "            ),\n",
+    "        ),\n",
+    "        B.NodeSizes(template=\"\"\"{{ {\"widget\": 10, \"trait\": 2}[node.type]  }}\"\"\"),\n",
+    "        B.LinkDirectionalArrowLength(template=\"4\"),\n",
+    "        B.LinkDirectionalArrowColor(template=\"black\"),\n",
+    "        B.LinkDirectionalArrowRelPos(template=\"0.5\"),\n",
+    "    ],\n",
+    "    layout=dict(height=\"100%\"),\n",
+    ")\n",
+    "fg"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "390a4b99-185e-436d-90be-40f629b3ad9a",
+   "metadata": {},
+   "source": [
+    "## Viewing a `ForceGraph` itself\n",
+    "\n",
+    "As a `ForceGraph`'s source can be changed dynamically, its underlying data can be shown."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "17676c79-b08c-4bdf-ada1-f6f527c60f07",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "fg.source = WidgetSource([fg])"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "dd684dc4-b7b8-44a2-ac44-f7755fed347c",
+   "metadata": {
+    "tags": []
+   },
+   "source": [
+    "> Re-running the above will cause the originally observed app to \"disappear\"."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "552df077-5dd3-4117-b621-2d14cf36c75b",
+   "metadata": {},
+   "source": [
+    "## A More Complex Example\n",
+    "\n",
+    "Without even viewing the widget, it is possible to inspect its graph, such as the\n",
+    "[3D](./3D.ipynb) example."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "13051399-4d67-4f49-91b0-87226afa2b0c",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "with __import__(\"importnb\").Notebook():\n",
+    "    import __3D\n",
+    "fg3, box3 = __3D.make_a_3d_demo()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "2d3e8253-9176-4959-82c5-6be28bbb4c15",
+   "metadata": {},
+   "source": [
+    "This can include just the widget itself:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "4bd53c88-6085-4467-aa6f-1cab618943b7",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "fg.source = WidgetSource([fg3])"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "86dc4b97-addb-432e-889e-de2ab1ed446f",
+   "metadata": {},
+   "source": [
+    "Or, in this case, the whole application."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "5be634e1-9278-4db9-8549-132e15a86658",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "fg.source = WidgetSource([box3])"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "56793749-a1fa-45a7-9029-366ba0eaeb7c",
+   "metadata": {},
+   "source": [
+    "## Beyond Widgets\n",
+    "\n",
+    "Under the hood, widgets are just a special case of\n",
+    "[traitlets](https://traitlets.readthedocs.io). As such, it is also possible to view the\n",
+    "relationships between the underlying application components."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "15582069-a6a9-49d3-8bcf-fd6ed90e617a",
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "shell = IPython.get_ipython()\n",
+    "fg.source = WidgetSource(\n",
+    "    [fg3, shell, shell.kernel], ignore_traits=[], ignore_modules=[], ignore_classes=[]\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "43d67c06-3f4b-46bb-b72a-9cc4ec1e0077",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.11.0"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/_index.ipynb b/examples/_index.ipynb
index d2846747..9e260fd9 100644
--- a/examples/_index.ipynb
+++ b/examples/_index.ipynb
@@ -16,7 +16,9 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
     "if __name__ == \"__main__\" and \"pyodide\" in __import__(\"sys\").modules:\n",
@@ -40,7 +42,9 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
     "import json\n",
@@ -59,12 +63,15 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
-    "from ipyforcegraph.forcegraph import DataFrameSource, ForceGraph\n",
+    "from ipyforcegraph.graphs import ForceGraph\n",
     "\n",
-    "fg2 = ForceGraph(source=DataFrameSource(**data))\n",
+    "fg2 = ForceGraph()\n",
+    "fg2.source.nodes, fg2.source.links = data[\"nodes\"], data[\"links\"]\n",
     "fg2"
    ]
   },
@@ -78,10 +85,12 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
-    "from ipyforcegraph.forcegraph import ForceGraph3D\n",
+    "from ipyforcegraph.graphs import ForceGraph3D\n",
     "\n",
     "fg3 = ForceGraph3D(source=fg2.source)\n",
     "fg3"
@@ -101,7 +110,8 @@
     "### Graph Data Source\n",
     "\n",
     "Each `ForceGraph` has a `DataFrameSource`. Some sample data in various formats are\n",
-    "included.\n",
+    "included, while a graph of [widget data](./WidgetSource.ipynb) can also be used as a\n",
+    "source.\n",
     "\n",
     "> 💡 As shown above, multiple graphs can directly share the same `source`."
    ]
@@ -109,7 +119,9 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
     "from IPython.display import JSON"
@@ -118,7 +130,9 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
     "JSON(data)"
@@ -134,7 +148,9 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
     "from pandas import DataFrame"
@@ -157,7 +173,9 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
     "DataFrame(data[\"nodes\"]).T"
@@ -181,7 +199,9 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {},
+   "metadata": {
+    "tags": []
+   },
    "outputs": [],
    "source": [
     "DataFrame(data[\"links\"]).T"
@@ -209,7 +229,9 @@
     "## Examples\n",
     "\n",
     "- [👟 Behaviors](./Behaviors.ipynb)\n",
-    "- [🔮 3D](./3D.ipynb)"
+    "- [🏋️ Forces](./Forces.ipynb)\n",
+    "- [🔮 3D](./3D.ipynb)\n",
+    "- [🥄 Widget Source](./WidgetSource.ipynb)"
    ]
   },
   {
@@ -218,7 +240,8 @@
    "source": [
     "## Miscellaneous\n",
     "\n",
-    "- [🔧 Demo Utilities](./Utils.ipynb)"
+    "- [🔧 Demo Utilities](./Utils.ipynb)\n",
+    "- [🎥 Test Forces](./Test_Forces.ipynb)"
    ]
   }
  ],
diff --git a/examples/future/FORCEGRAPH_DEBUG.ipynb b/examples/future/FORCEGRAPH_DEBUG.ipynb
index c9b48618..a7c39ea7 100644
--- a/examples/future/FORCEGRAPH_DEBUG.ipynb
+++ b/examples/future/FORCEGRAPH_DEBUG.ipynb
@@ -37,7 +37,7 @@
     "import pandas as pd\n",
     "\n",
     "from ipyforcegraph.behaviors import NodeSelection\n",
-    "from ipyforcegraph.forcegraph import DataFrameSource, ForceGraph"
+    "from ipyforcegraph.graphs import DataFrameSource, ForceGraph"
    ]
   },
   {
diff --git a/js/index.ts b/js/index.ts
index e2bc5f2a..32fc779e 100644
--- a/js/index.ts
+++ b/js/index.ts
@@ -6,3 +6,4 @@
 export * from './tokens';
 export * from './widgets';
 export * from './widgets/serializers';
+export * from './template-utils';
diff --git a/js/template-utils.ts b/js/template-utils.ts
new file mode 100644
index 00000000..bd9c4c73
--- /dev/null
+++ b/js/template-utils.ts
@@ -0,0 +1,184 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+import type { NodeObject } from 'force-graph';
+import { LinkObject } from 'force-graph/dist/force-graph';
+import { Environment, Template } from 'nunjucks';
+
+import { PromiseDelegate } from '@lumino/coreutils';
+
+import { DEBUG, EMOJI } from './tokens';
+
+export const MATH_CONST = {
+  E: Math.E,
+  LN10: Math.LN10,
+  LN2: Math.LN2,
+  LOG10E: Math.LOG10E,
+  LOG2E: Math.LOG2E,
+  PI: Math.PI,
+  SQRT1_2: Math.SQRT1_2,
+  SQRT2: Math.SQRT2,
+};
+
+export const MATH_UNARY = [
+  Math.acos,
+  Math.acosh,
+  Math.asin,
+  Math.asinh,
+  Math.atan,
+  Math.atanh,
+  Math.cbrt,
+  Math.ceil,
+  Math.cos,
+  Math.exp,
+  Math.expm1,
+  Math.floor,
+  Math.fround,
+  Math.log,
+  Math.log10,
+  Math.log1p,
+  Math.log2,
+  Math.sign,
+  Math.sin,
+  Math.sqrt,
+  Math.tan,
+  Math.trunc,
+];
+
+export const MATH_BINARY = [Math.imul, Math.atan2];
+
+export const MATH_N_ARY = [Math.min, Math.max];
+
+export function isNumeric(val: string): boolean {
+  return !isNaN(Number(val));
+}
+
+function noop() {
+  return null;
+}
+
+namespace Private {
+  export let env: Environment | null = null;
+  export let loading: PromiseDelegate<Environment> | null = null;
+  export let TemplateClass: typeof Template | null = null;
+}
+
+export async function newTemplate(src: string): Promise<Template> {
+  const env = await nunjucksEnv();
+  return new Private.TemplateClass(src, env, null, true);
+}
+
+export async function nunjucksEnv(): Promise<Environment> {
+  if (Private.env) {
+    return Private.env;
+  }
+  if (Private.loading) {
+    await Private.loading.promise;
+    return Private.env;
+  }
+  Private.loading = new PromiseDelegate();
+  const nunjucks = await import('nunjucks');
+  const env = new nunjucks.Environment();
+  // install custom tags
+  addCustomGlobals(env);
+
+  // save in namespace
+  Private.TemplateClass = nunjucks.Template;
+  Private.env = env;
+  Private.loading.resolve(env);
+  return env;
+}
+
+export interface INAryJs {
+  (...values: number[]): number;
+}
+
+export interface INAryPy {
+  (values: number[] | number, ...moreValues: number[]): number;
+}
+
+/**
+ * Register globals in the environment that can be called by any template.
+ */
+function addCustomGlobals(env: Environment) {
+  for (const [constName, constValue] of Object.entries(MATH_CONST)) {
+    env.addGlobal(constName, constValue);
+  }
+  for (const fn of MATH_UNARY) {
+    env.addGlobal(fn.name, fn);
+  }
+  for (const fn of MATH_BINARY) {
+    env.addGlobal(fn.name, fn);
+  }
+  for (const fn of MATH_N_ARY) {
+    env.addGlobal(fn.name, wrapNAry(fn));
+  }
+  env.addGlobal('now', () => performance.now());
+  env.addFilter('where', (iterable: any[], attr: string, value: any) => {
+    const results: any[] = [];
+    for (const item of iterable) {
+      if (item[attr] === value) {
+        results.push(item);
+      }
+    }
+    return results;
+  });
+}
+
+/**
+ * Make a `Math` method more like python.
+ */
+function wrapNAry(jsFn: INAryJs): INAryPy {
+  function pyFn(values: number[] | number, ...rest: number[]) {
+    return Array.isArray(values) ? jsFn(...values) : jsFn(values, ...rest);
+  }
+  return pyFn;
+}
+
+async function makeForceTemplate<T = any>(
+  src: string,
+  contextName: string,
+  contextAllName: string
+): Promise<CallableFunction> {
+  let template: Template;
+  try {
+    template = await newTemplate(src);
+  } catch (err) {
+    DEBUG && console.warn(EMOJI, err);
+    return noop;
+  }
+
+  const renderTemplate = (context: T, i: number, contextAll: T[]) => {
+    let value: number | null;
+    try {
+      let rendered = template.render({
+        [contextName]: context,
+        i,
+        [contextAllName]: contextAll,
+      });
+      value = rendered == null ? null : Number(rendered);
+
+      if (value == null || isNaN(value)) {
+        value = null;
+      }
+    } catch (err) {
+      DEBUG && console.warn(EMOJI, err);
+      value = null;
+    }
+    return value;
+  };
+  return renderTemplate;
+}
+
+export async function makeForceNodeTemplate(
+  template: string
+): Promise<CallableFunction> {
+  return await makeForceTemplate<NodeObject>(template, 'node', 'nodes');
+}
+
+export async function makeForceLinkTemplate(
+  template: string
+): Promise<CallableFunction> {
+  return await makeForceTemplate<LinkObject>(template, 'link', 'links');
+}
diff --git a/js/tokens.ts b/js/tokens.ts
index abb56510..a0d947ed 100644
--- a/js/tokens.ts
+++ b/js/tokens.ts
@@ -2,6 +2,7 @@
  * Copyright (c) 2023 ipyforcegraph contributors.
  * Distributed under the terms of the Modified BSD License.
  */
+import d3Force3d from 'd3-force-3d';
 import type { GraphData, LinkObject, NodeObject } from 'force-graph';
 import type { WebGLRenderer } from 'three';
 
@@ -41,6 +42,7 @@ export const DEFAULT_COLORS = {
 export const DEFAULT_WIDTHS = {
   link: 1,
   selected: 2,
+  node: 1,
 };
 
 export const WIDGET_DEFAULTS = {
@@ -50,8 +52,17 @@ export const WIDGET_DEFAULTS = {
   _view_module_version: VERSION,
 };
 
+//  Using bit flags for the TUpdateKind number
+export enum EUpdate {
+  Unknown = 0,
+  Reheat = 1 << 0,
+  Cosmetic = 1 << 1,
+  Render = 1 << 2,
+}
+export type TUpdateKind = void | number;
+
 export interface IBehave {
-  updateRequested: ISignal<IBehave, void>;
+  updateRequested: ISignal<IBehave, TUpdateKind>;
   // link
   getLinkColor?(options: ILinkBehaveOptions): string | null;
   getLinkWidth?(options: ILinkBehaveOptions): string | null;
@@ -66,6 +77,7 @@ export interface IBehave {
   // node
   getNodeColor?(options: INodeBehaveOptions): string | null;
   getNodeLabel?(options: INodeBehaveOptions): string | null;
+  getNodeSize?(options: INodeBehaveOptions): string | null;
   // evented
   onNodeClick?(options: INodeEventBehaveOptions): boolean;
   onLinkClick?(options: ILinkEventBehaveOptions): boolean;
@@ -83,14 +95,24 @@ export const ALL_LINK_METHODS = [
   'getLinkDirectionalParticleSpeed',
   'getLinkDirectionalParticleWidth',
   'getLinkDirectionalParticles',
+  'onLinkClick',
 ];
 export type TLinkBehaveMethod = (typeof ALL_LINK_METHODS)[number];
 
-export const ALL_NODE_METHODS = ['getNodeLabel', 'getNodeColor'];
+export const ALL_NODE_METHODS = [
+  'getNodeLabel',
+  'getNodeColor',
+  'getNodeSize',
+  'onNodeClick',
+];
 export type TNodeBehaveMethod = (typeof ALL_NODE_METHODS)[number];
 
+export const ALL_GRAPH_METHODS = ['onRender'];
+export type TGraphBehaveMethod = (typeof ALL_GRAPH_METHODS)[number];
+
 export type TNodeMethodMap = Map<TNodeBehaveMethod, IBehave[]>;
 export type TLinkMethodMap = Map<TLinkBehaveMethod, IBehave[]>;
+export type TGraphMethodMap = Map<TGraphBehaveMethod, IBehave[]>;
 
 export interface IBehaveOptions {
   view: IHasGraph;
@@ -133,6 +155,21 @@ export interface ISource {
   dataUpdated: ISignal<ISource, void>;
 }
 
+export type TAnyForce =
+  | d3Force3d.forceCenter
+  | d3Force3d.forceCollide
+  | d3Force3d.forceLink
+  | d3Force3d.forceManyBody
+  | d3Force3d.forceRadial
+  | d3Force3d.forceSimulation
+  | d3Force3d.forceX
+  | d3Force3d.forceY
+  | d3Force3d.forceZ;
+
+export interface IForce {
+  forceFactory(): TAnyForce;
+}
+
 export type TSelectedSet = Set<string | number>;
 
 export const emptyArray = Object.freeze([]);
diff --git a/js/widgets/behaviors/base.ts b/js/widgets/behaviors/base.ts
index 7694972a..774c3b73 100644
--- a/js/widgets/behaviors/base.ts
+++ b/js/widgets/behaviors/base.ts
@@ -8,24 +8,26 @@ import { ISignal, Signal } from '@lumino/signaling';
 
 import { IBackboneModelOptions, WidgetModel } from '@jupyter-widgets/base';
 
+import { newTemplate } from '../../template-utils';
 import {
   EMOJI,
   IBehave,
   IHasGraph,
   ILinkBehaveOptions,
   INodeBehaveOptions,
+  TUpdateKind,
   WIDGET_DEFAULTS,
 } from '../../tokens';
 
 export class BehaviorModel extends WidgetModel implements IBehave {
-  protected _updateRequested: Signal<IBehave, void>;
+  protected _updateRequested: Signal<IBehave, TUpdateKind>;
 
   initialize(attributes: Backbone.ObjectHash, options: IBackboneModelOptions) {
     super.initialize(attributes, options);
     this._updateRequested = new Signal(this);
   }
 
-  get updateRequested(): ISignal<IBehave, void> {
+  get updateRequested(): ISignal<IBehave, TUpdateKind> {
     return this._updateRequested;
   }
 }
@@ -54,6 +56,8 @@ export class ColumnOrTemplateModel extends BehaviorModel implements IBehave {
     super.initialize(attributes, options);
     this.on('change:column_name', this.onColumnNameChange, this);
     this.on('change:template', this.onTemplateChange, this);
+    void this.onColumnNameChange();
+    void this.onTemplateChange();
   }
 
   protected async onTemplateChange(): Promise<void> {
@@ -61,11 +65,10 @@ export class ColumnOrTemplateModel extends BehaviorModel implements IBehave {
     if (template == null) {
       this._nunjucksTemplate = null;
     } else {
-      const nunjucks = await import('nunjucks');
       try {
-        this._nunjucksTemplate = new nunjucks.Template(template);
+        this._nunjucksTemplate = await newTemplate(template);
       } catch (err) {
-        console.warn(EMOJI, err, err['message']);
+        console.warn(EMOJI, err['message']);
         this._nunjucksTemplate = null;
       }
     }
diff --git a/js/widgets/behaviors/forces/force-center.ts b/js/widgets/behaviors/forces/force-center.ts
new file mode 100644
index 00000000..350dba79
--- /dev/null
+++ b/js/widgets/behaviors/forces/force-center.ts
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+import { forceCenter as d3ForceCenter } from 'd3-force-3d';
+
+import { IBehave, IForce, TAnyForce } from '../../../tokens';
+
+import { ForceBehaviorModel } from './force';
+
+export class CenterForceModel extends ForceBehaviorModel implements IBehave, IForce {
+  static model_name = 'CenterForceModel';
+  _force: d3ForceCenter;
+
+  defaults() {
+    return {
+      ...super.defaults(),
+      _model_name: CenterForceModel.model_name,
+      x: null,
+      y: null,
+      z: null,
+    };
+  }
+
+  forceFactory(): d3ForceCenter {
+    return d3ForceCenter();
+  }
+
+  get triggerChanges(): string {
+    return 'change:x change:y change:z change:active';
+  }
+
+  get force(): TAnyForce {
+    const { x, y, z } = this;
+
+    let force = this._force;
+    force = x == null ? force : force.x(x);
+    force = y == null ? force : force.y(y);
+    force = z == null ? force : force.z(z);
+    return force;
+  }
+
+  get x() {
+    return this.get('x');
+  }
+
+  get y() {
+    return this.get('y');
+  }
+
+  get z() {
+    return this.get('z');
+  }
+}
diff --git a/js/widgets/behaviors/forces/force-cluster.ts b/js/widgets/behaviors/forces/force-cluster.ts
new file mode 100644
index 00000000..ae6f9a44
--- /dev/null
+++ b/js/widgets/behaviors/forces/force-cluster.ts
@@ -0,0 +1,111 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+import { ObjectHash } from 'backbone';
+import { default as d3ClusterForce } from 'd3-force-cluster-3d';
+import { NodeObject } from 'force-graph/dist/force-graph';
+
+import { IBackboneModelOptions } from '@jupyter-widgets/base';
+
+import { isNumeric, makeForceNodeTemplate } from '../../../template-utils';
+import { IBehave, IForce, TAnyForce } from '../../../tokens';
+
+import { ForceBehaviorModel } from './force';
+
+// interface Cluster{
+//   x: number,
+//   y: number,
+//   z: number,
+// }
+
+export class ClusterForceModel extends ForceBehaviorModel implements IBehave, IForce {
+  static model_name = 'ClusterForceModel';
+  _force: d3ClusterForce;
+  strength: CallableFunction | Number | null;
+  clusters: object;
+  get_cluster: CallableFunction; // render the cluster template
+
+  defaults() {
+    return {
+      ...super.defaults(),
+      _model_name: ClusterForceModel.model_name,
+      x: null,
+      strength: null,
+    };
+  }
+
+  initialize(attributes: ObjectHash, options: IBackboneModelOptions): void {
+    super.initialize(attributes, options);
+    this.clusters = {};
+    this.get_cluster = () => 0;
+  }
+
+  forceFactory(): d3ClusterForce {
+    return d3ClusterForce();
+  }
+
+  get triggerChanges(): string {
+    return 'change:centers change:strength change:center_inertia change:active';
+  }
+
+  get force(): TAnyForce {
+    const { centers, strength, centerInertia } = this;
+
+    let force = this._force;
+    force = centerInertia == null ? force : force.centerInertia(centerInertia);
+    force = centers == null ? force : force.centers(centers);
+    force = strength == null ? force : force.strength(strength);
+    return force;
+  }
+
+  async update() {
+    await this.update_centers();
+    await this.update_strength();
+  }
+
+  async update_centers() {
+    let value = this.get('centers');
+    this.get_cluster = await makeForceNodeTemplate(value);
+  }
+
+  centers = (node: NodeObject, i: number, nodes: NodeObject[]) => {
+    if (!(node as any)?.radius) {
+      (node as any).radius = 1;
+    }
+    let key = this.get_cluster(node, i, nodes);
+
+    // get the cluster center and reset if the position has gone NaN
+    if (!(key in this.clusters)) {
+      this.clusters[key] = { x: 0, y: 0, z: 0, radius: 0 };
+    }
+    let center = this.clusters[key];
+    if (isNaN(center.x)) {
+      center.x = 0;
+    }
+    if (isNaN(center.y)) {
+      center.y = 0;
+    }
+    if (isNaN(center.z)) {
+      center.z = 0;
+    }
+    if (isNaN(center.radius) || center.radius == undefined) {
+      center.radius = 0;
+    }
+
+    return center;
+  };
+
+  async update_strength() {
+    let value = this.get('strength');
+    if (isNumeric(value)) {
+      this.strength = Number(value);
+    } else {
+      this.strength = await makeForceNodeTemplate(value);
+    }
+  }
+
+  get centerInertia() {
+    return this.get('center_inertia');
+  }
+}
diff --git a/js/widgets/behaviors/forces/force-collision.ts b/js/widgets/behaviors/forces/force-collision.ts
new file mode 100644
index 00000000..bfe8ebd6
--- /dev/null
+++ b/js/widgets/behaviors/forces/force-collision.ts
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+import { forceCollide as d3ForceCollision } from 'd3-force-3d';
+
+import { isNumeric, makeForceNodeTemplate } from '../../../template-utils';
+import { IBehave, IForce } from '../../../tokens';
+
+import { ForceBehaviorModel } from './force';
+
+export class CollisionForceModel extends ForceBehaviorModel implements IBehave, IForce {
+  static model_name = 'CollisionForceModel';
+  _force: d3ForceCollision;
+  radius: CallableFunction | Number | null;
+  strength: CallableFunction | Number | null;
+
+  defaults() {
+    return {
+      ...super.defaults(),
+      _model_name: CollisionForceModel.model_name,
+      strength: null,
+      radius: null,
+    };
+  }
+
+  forceFactory(): d3ForceCollision {
+    return d3ForceCollision();
+  }
+
+  get force(): d3ForceCollision {
+    const { radius, strength } = this;
+
+    let force = this._force;
+    force = radius == null ? force : force.radius(radius);
+    force = strength == null ? force : force.strength(strength);
+    return force;
+  }
+
+  get triggerChanges(): string {
+    return 'change:radius change:strength change:active';
+  }
+
+  async update() {
+    await this.update_radius();
+    await this.update_strength();
+  }
+
+  async update_strength() {
+    let value = this.get('strength');
+    if (isNumeric(value)) {
+      this.strength = Number(value);
+    } else {
+      this.strength = await makeForceNodeTemplate(value);
+    }
+  }
+
+  async update_radius() {
+    let value = this.get('radius');
+    if (isNumeric(value)) {
+      this.radius = Number(value);
+    } else {
+      this.radius = await makeForceNodeTemplate(value);
+    }
+  }
+}
diff --git a/js/widgets/behaviors/forces/force-link.ts b/js/widgets/behaviors/forces/force-link.ts
new file mode 100644
index 00000000..6730b122
--- /dev/null
+++ b/js/widgets/behaviors/forces/force-link.ts
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+// import type d3Force from 'd3-force';
+import { forceLink as d3ForceLink } from 'd3-force-3d';
+
+import { isNumeric, makeForceLinkTemplate } from '../../../template-utils';
+import { IBehave, IForce } from '../../../tokens';
+
+import { ForceBehaviorModel } from './force';
+
+export class LinkForceModel extends ForceBehaviorModel implements IBehave, IForce {
+  static model_name = 'LinkForceModel';
+  _force: d3ForceLink;
+
+  protected strength: CallableFunction | Number | null;
+  protected distance: CallableFunction | Number | null;
+
+  defaults() {
+    return {
+      ...super.defaults(),
+      _model_name: LinkForceModel.model_name,
+      strength: null,
+      distance: null,
+    };
+  }
+
+  forceFactory(): d3ForceLink {
+    return d3ForceLink();
+  }
+
+  get force(): d3ForceLink {
+    const { strength, distance } = this;
+
+    let force = this._force;
+    force = strength == null ? force : force.strength(strength);
+    force = distance == null ? force : force.distance(distance);
+    return force;
+  }
+
+  get triggerChanges(): string {
+    return 'change:strength change:distance change:active';
+  }
+
+  async update() {
+    await this.update_distance();
+    await this.update_strength();
+  }
+
+  async update_strength() {
+    let value = this.get('strength');
+    if (isNumeric(value)) {
+      this.strength = Number(value);
+    } else {
+      this.strength = await makeForceLinkTemplate(value);
+    }
+  }
+
+  async update_distance() {
+    let value = this.get('distance');
+    if (isNumeric(value)) {
+      this.distance = Number(value);
+    } else {
+      this.distance = await makeForceLinkTemplate(value);
+    }
+  }
+}
diff --git a/js/widgets/behaviors/forces/force-manybody.ts b/js/widgets/behaviors/forces/force-manybody.ts
new file mode 100644
index 00000000..b057586e
--- /dev/null
+++ b/js/widgets/behaviors/forces/force-manybody.ts
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+import { forceManyBody as d3ForceManyBody } from 'd3-force-3d';
+
+import { isNumeric, makeForceNodeTemplate } from '../../../template-utils';
+import { IBehave, IForce } from '../../../tokens';
+
+import { ForceBehaviorModel } from './force';
+
+export class ManyBodyForceModel extends ForceBehaviorModel implements IBehave, IForce {
+  static model_name = 'ManyBodyForceModel';
+  _force: d3ForceManyBody;
+  strength: CallableFunction | Number | null;
+
+  defaults() {
+    return {
+      ...super.defaults(),
+      _model_name: ManyBodyForceModel.model_name,
+    };
+  }
+
+  forceFactory(): d3ForceManyBody {
+    return d3ForceManyBody();
+  }
+
+  get force(): d3ForceManyBody {
+    const { strength, theta, distanceMax, distanceMin } = this;
+
+    let force = this._force;
+    force = strength == null ? force : force.strength(strength);
+    force = theta == null ? force : force.theta(theta);
+    force = distanceMax == null ? force : force.distanceMax(distanceMax);
+    force = distanceMin == null ? force : force.distanceMin(distanceMin);
+    return force;
+  }
+
+  get triggerChanges(): string {
+    return 'change:strength change:theta change:distance_min change:distance_max change:active';
+  }
+
+  async update() {
+    await this.update_strength();
+  }
+
+  async update_strength() {
+    let value = this.get('strength');
+    if (isNumeric(value)) {
+      this.strength = Number(value);
+    } else {
+      this.strength = await makeForceNodeTemplate(value);
+    }
+  }
+
+  get theta() {
+    return this.get('theta');
+  }
+
+  get distanceMin() {
+    return this.get('distance_min');
+  }
+
+  get distanceMax() {
+    return this.get('distance_max');
+  }
+}
diff --git a/js/widgets/behaviors/forces/force-radial.ts b/js/widgets/behaviors/forces/force-radial.ts
new file mode 100644
index 00000000..4d308ff2
--- /dev/null
+++ b/js/widgets/behaviors/forces/force-radial.ts
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+import { forceRadial as d3ForceRadial } from 'd3-force-3d';
+
+import { isNumeric, makeForceNodeTemplate } from '../../../template-utils';
+import { IBehave, IForce } from '../../../tokens';
+
+import { ForceBehaviorModel } from './force';
+
+export class RadialForceModel extends ForceBehaviorModel implements IBehave, IForce {
+  static model_name = 'RadialForceModel';
+  _force: d3ForceRadial;
+  radius: CallableFunction | Number | null;
+
+  defaults() {
+    return {
+      ...super.defaults(),
+      _model_name: RadialForceModel.model_name,
+    };
+  }
+
+  forceFactory(): d3ForceRadial {
+    return d3ForceRadial();
+  }
+
+  get force(): d3ForceRadial {
+    const { strength, radius, x, y, z } = this;
+
+    let force = this._force;
+    force = strength == null ? force : force.strength(strength);
+    force = radius == null ? force : force.radius(this.radius);
+    force = x == null ? force : force.x(x);
+    force = y == null ? force : force.y(y);
+    force = z == null ? force : force.z(z);
+    return force;
+  }
+
+  get triggerChanges(): string {
+    return 'change:strength change:radius change:x change:y change:z change:active';
+  }
+
+  async update() {
+    await this.update_radius();
+  }
+
+  get strength() {
+    return this.get('strength');
+  }
+
+  async update_radius() {
+    let value = this.get('radius');
+    if (isNumeric(value)) {
+      this.radius = Number(value);
+    } else {
+      this.radius = await makeForceNodeTemplate(value);
+    }
+  }
+
+  get x() {
+    return this.get('x');
+  }
+
+  get y() {
+    return this.get('y');
+  }
+
+  get z() {
+    return this.get('z');
+  }
+}
diff --git a/js/widgets/behaviors/forces/force-x.ts b/js/widgets/behaviors/forces/force-x.ts
new file mode 100644
index 00000000..ab71607f
--- /dev/null
+++ b/js/widgets/behaviors/forces/force-x.ts
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+import { forceX as d3XForce } from 'd3-force-3d';
+
+import { isNumeric, makeForceNodeTemplate } from '../../../template-utils';
+import { IBehave, IForce, TAnyForce } from '../../../tokens';
+
+import { ForceBehaviorModel } from './force';
+
+export class XForceModel extends ForceBehaviorModel implements IBehave, IForce {
+  static model_name = 'XForceModel';
+  _force: d3XForce;
+  x: CallableFunction | Number | null;
+  strength: CallableFunction | Number | null;
+
+  defaults() {
+    return {
+      ...super.defaults(),
+      _model_name: XForceModel.model_name,
+      x: null,
+      strength: null,
+    };
+  }
+
+  forceFactory(): d3XForce {
+    return d3XForce();
+  }
+
+  get triggerChanges(): string {
+    return 'change:x change:strength change:active';
+  }
+
+  get force(): TAnyForce {
+    const { x, strength } = this;
+
+    let force = this._force;
+    force = x == null ? force : force.x(x);
+    force = strength == null ? force : force.strength(strength);
+    return force;
+  }
+
+  async update() {
+    await this.update_x();
+    await this.update_strength();
+  }
+
+  async update_x() {
+    let value = this.get('x');
+    if (isNumeric(value)) {
+      this.x = Number(value);
+    } else {
+      this.x = await makeForceNodeTemplate(value);
+    }
+  }
+
+  async update_strength() {
+    let value = this.get('strength');
+    if (isNumeric(value)) {
+      this.strength = Number(value);
+    } else {
+      this.strength = await makeForceNodeTemplate(value);
+    }
+  }
+}
diff --git a/js/widgets/behaviors/forces/force-y.ts b/js/widgets/behaviors/forces/force-y.ts
new file mode 100644
index 00000000..6a52cece
--- /dev/null
+++ b/js/widgets/behaviors/forces/force-y.ts
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+import { forceY as d3YForce } from 'd3-force-3d';
+
+import { isNumeric, makeForceNodeTemplate } from '../../../template-utils';
+import { IBehave, IForce, TAnyForce } from '../../../tokens';
+
+import { ForceBehaviorModel } from './force';
+
+export class YForceModel extends ForceBehaviorModel implements IBehave, IForce {
+  static model_name = 'YForceModel';
+  _force: d3YForce;
+  y: CallableFunction | Number | null;
+  strength: CallableFunction | Number | null;
+
+  defaults() {
+    return {
+      ...super.defaults(),
+      _model_name: d3YForce.model_name,
+      y: null,
+    };
+  }
+
+  forceFactory(): d3YForce {
+    return d3YForce();
+  }
+
+  get triggerChanges(): string {
+    return 'change:y change:strength change:active';
+  }
+
+  get force(): TAnyForce {
+    const { y, strength } = this;
+
+    let force = this._force;
+    force = y == null ? force : force.y(y);
+    force = strength == null ? force : force.strength(strength);
+    return force;
+  }
+
+  async update() {
+    await this.update_y();
+    await this.update_strength();
+  }
+
+  async update_y() {
+    let value = this.get('y');
+    if (isNumeric(value)) {
+      this.y = Number(value);
+    } else {
+      this.y = await makeForceNodeTemplate(value);
+    }
+  }
+
+  async update_strength() {
+    let value = this.get('strength');
+    if (isNumeric(value)) {
+      this.strength = Number(value);
+    } else {
+      this.strength = await makeForceNodeTemplate(value);
+    }
+  }
+}
diff --git a/js/widgets/behaviors/forces/force-z.ts b/js/widgets/behaviors/forces/force-z.ts
new file mode 100644
index 00000000..f356afce
--- /dev/null
+++ b/js/widgets/behaviors/forces/force-z.ts
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+import { forceZ as d3ZForce } from 'd3-force-3d';
+
+import { isNumeric, makeForceNodeTemplate } from '../../../template-utils';
+import { IBehave, IForce, TAnyForce } from '../../../tokens';
+
+import { ForceBehaviorModel } from './force';
+
+export class ZForceModel extends ForceBehaviorModel implements IBehave, IForce {
+  static model_name = 'ZForceModel';
+  _force: d3ZForce;
+  z: CallableFunction | Number | null;
+  strength: CallableFunction | Number | null;
+
+  defaults() {
+    return {
+      ...super.defaults(),
+      _model_name: ZForceModel.model_name,
+      z: null,
+    };
+  }
+
+  forceFactory(): d3ZForce {
+    return d3ZForce();
+  }
+
+  get triggerChanges(): string {
+    return 'change:z change:strength change:active';
+  }
+
+  get force(): TAnyForce {
+    const { z, strength } = this;
+
+    let force = this._force;
+    force = z == null ? force : force.z(z);
+    force = strength == null ? force : force.strength(strength);
+    return force;
+  }
+
+  async update() {
+    await this.update_z();
+    await this.update_strength();
+  }
+
+  async update_z() {
+    let value = this.get('z');
+    if (isNumeric(value)) {
+      this.z = Number(value);
+    } else {
+      this.z = await makeForceNodeTemplate(value);
+    }
+  }
+
+  async update_strength() {
+    let value = this.get('strength');
+    if (isNumeric(value)) {
+      this.strength = Number(value);
+    } else {
+      this.strength = await makeForceNodeTemplate(value);
+    }
+  }
+}
diff --git a/js/widgets/behaviors/forces/force.ts b/js/widgets/behaviors/forces/force.ts
new file mode 100644
index 00000000..41773f84
--- /dev/null
+++ b/js/widgets/behaviors/forces/force.ts
@@ -0,0 +1,225 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+import { ObjectHash } from 'backbone';
+import type { ForceGraphInstance, NodeObject } from 'force-graph/dist/force-graph';
+
+import {
+  IBackboneModelOptions,
+  WidgetModel,
+  unpack_models as deserialize,
+} from '@jupyter-widgets/base';
+
+import { EUpdate, IForce, TAnyForce } from '../../../tokens';
+import { LinkColumnOrTemplateModel } from '../base';
+
+export type TForceRecord = Record<string, ForceBehaviorModel | null>;
+
+export class ForceBehaviorModel extends LinkColumnOrTemplateModel implements IForce {
+  static model_name = 'ForceBehaviorModel';
+  _force: TAnyForce;
+
+  defaults() {
+    return {
+      ...super.defaults(),
+      _model_name: ForceBehaviorModel.model_name,
+      active: true,
+    };
+  }
+
+  initialize(attributes: ObjectHash, options: IBackboneModelOptions): void {
+    super.initialize(attributes, options);
+
+    this._force = this.forceFactory();
+    this.on(this.triggerChanges, this.onChanged, this);
+  }
+
+  async onChanged(model) {
+    if (this.active) {
+      await this.update();
+      this._updateRequested.emit(void 0);
+    } else if ('active' in model.changed) {
+      this._updateRequested.emit(void 0);
+    }
+  }
+
+  async update() {
+    // method for subclasses to implement addition state updates
+  }
+
+  forceFactory(): TAnyForce {
+    throw new Error('Not implemented');
+  }
+
+  get triggerChanges(): string {
+    throw new Error('Not implemented');
+  }
+
+  get force(): TAnyForce {
+    return this._force;
+  }
+
+  get active(): boolean {
+    return this.get('active');
+  }
+}
+
+export class GraphForcesModel extends LinkColumnOrTemplateModel {
+  static model_name = 'GraphForcesModel';
+  static serializers = {
+    ...WidgetModel.serializers,
+    forces: { deserialize },
+  };
+
+  defaults() {
+    return {
+      ...super.defaults(),
+      _model_name: GraphForcesModel.model_name,
+      forces: {},
+      warmup_ticks: 0,
+      cooldown_ticks: -1,
+      alpha_min: 0.0,
+      alpha_decay: 0.228,
+      velocity_decay: 0.4,
+    };
+  }
+
+  initialize(attributes: ObjectHash, options: IBackboneModelOptions): void {
+    super.initialize(attributes, options);
+    this.on(
+      'change:forces change:cooldown_ticks change:warmup_ticks change:alpha_min change:alpha_decay change:velocity_decay',
+      this.onForcesChange,
+      this
+    );
+    this.onForcesChange();
+  }
+
+  get forces(): TForceRecord {
+    return this.get('forces') || {};
+  }
+
+  async onForcesChange(): Promise<void> {
+    const { forces, previousForces } = this;
+
+    for (const force of Object.values(previousForces)) {
+      if (force) {
+        force.updateRequested.disconnect(this.onForceUpdated, this);
+      }
+    }
+
+    for (const force of Object.values(forces)) {
+      if (force) {
+        force.updateRequested.connect(this.onForceUpdated, this);
+      }
+    }
+    this.onForceUpdated();
+  }
+
+  get previousForces(): TForceRecord {
+    return (this.previous && this.previous('forces')) || {};
+  }
+
+  protected onForceUpdated(change?: any) {
+    this._updateRequested.emit(EUpdate.Reheat);
+  }
+
+  get warmupTicks(): number {
+    return this.get('warmup_ticks');
+  }
+
+  get cooldownTicks(): number {
+    const ticks = this.get('cooldown_ticks');
+    return ticks < 0 ? Infinity : ticks;
+  }
+
+  get alphaMin(): number {
+    return this.get('alpha_min');
+  }
+
+  get alphaDecay(): number {
+    return this.get('alpha_decay');
+  }
+
+  get velocityDecay(): number {
+    return this.get('velocity_decay');
+  }
+
+  checkPositions(graph: ForceGraphInstance) {
+    let { nodes, links } = graph.graphData();
+
+    let anyNaN = false;
+    for (let n of nodes) {
+      if (isNaN(n.x) || isNaN(n.y)) {
+        anyNaN = true;
+        break;
+      }
+    }
+    if (anyNaN) {
+      for (let n of nodes) {
+        n.x = NaN;
+        n.y = NaN;
+        // n.z = NaN,
+        n.vx = NaN;
+        n.vy = NaN;
+        // n.vz = NaN;
+      }
+      initializeNodes(nodes);
+      // parse links
+      links.forEach((link) => {
+        let source = link.source as any;
+        if (source !== nodes[source.index]) {
+          console.log('mismatch', source, nodes[source.index]);
+        }
+      });
+    }
+  }
+}
+
+// initializeNodes function from `d3-force-3d/simulation.js`
+function initializeNodes(nodes: NodeObject[], numDimensions = 2) {
+  let MAX_DIMENSIONS = 3;
+  let nDim = Math.min(MAX_DIMENSIONS, Math.max(1, Math.round(numDimensions)));
+  var initialRadius = 10,
+    initialAngleRoll = Math.PI * (3 - Math.sqrt(5)), // Golden ratio angle
+    initialAngleYaw = (Math.PI * 20) / (9 + Math.sqrt(221)); // Markov irrational number
+
+  for (var i = 0, n = nodes.length, node; i < n; ++i) {
+    (node = nodes[i]), (node.index = i);
+    if (node.fx != null) node.x = node.fx;
+    if (node.fy != null) node.y = node.fy;
+    if (node.fz != null) node.z = node.fz;
+    if (isNaN(node.x) || (nDim > 1 && isNaN(node.y)) || (nDim > 2 && isNaN(node.z))) {
+      var radius =
+          initialRadius *
+          (nDim > 2 ? Math.cbrt(0.5 + i) : nDim > 1 ? Math.sqrt(0.5 + i) : i),
+        rollAngle = i * initialAngleRoll,
+        yawAngle = i * initialAngleYaw;
+
+      if (nDim === 1) {
+        node.x = radius;
+      } else if (nDim === 2) {
+        node.x = radius * Math.cos(rollAngle);
+        node.y = radius * Math.sin(rollAngle);
+      } else {
+        // 3 dimensions: use spherical distribution along 2 irrational number angles
+        node.x = radius * Math.sin(rollAngle) * Math.cos(yawAngle);
+        node.y = radius * Math.cos(rollAngle);
+        node.z = radius * Math.sin(rollAngle) * Math.sin(yawAngle);
+      }
+    }
+    if (
+      isNaN(node.vx) ||
+      (nDim > 1 && isNaN(node.vy)) ||
+      (nDim > 2 && isNaN(node.vz))
+    ) {
+      node.vx = 0;
+      if (nDim > 1) {
+        node.vy = 0;
+      }
+      if (nDim > 2) {
+        node.vz = 0;
+      }
+    }
+  }
+}
diff --git a/js/widgets/behaviors/forces/index.ts b/js/widgets/behaviors/forces/index.ts
new file mode 100644
index 00000000..e5bc11cb
--- /dev/null
+++ b/js/widgets/behaviors/forces/index.ts
@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+export * from './force-center';
+export * from './force-cluster';
+export * from './force-collision';
+export * from './force-link';
+export * from './force-manybody';
+export * from './force-radial';
+export * from './force-x';
+export * from './force-y';
+export * from './force-z';
+export * from './force';
diff --git a/js/widgets/behaviors/graph-data.ts b/js/widgets/behaviors/graph-data.ts
index 9ed1287c..3d048be7 100644
--- a/js/widgets/behaviors/graph-data.ts
+++ b/js/widgets/behaviors/graph-data.ts
@@ -8,7 +8,7 @@ import {
   unpack_models as deserialize,
 } from '@jupyter-widgets/base';
 
-import { IBehave, IRenderOptions, WIDGET_DEFAULTS } from '../../tokens';
+import { EUpdate, IBehave, IRenderOptions, WIDGET_DEFAULTS } from '../../tokens';
 import { DataFrameSourceModel } from '../sources';
 
 import { BehaviorModel } from './base';
@@ -53,6 +53,9 @@ export class GraphDataModel extends BehaviorModel implements IBehave {
 
   protected onCapturingChanged(e?: any): void {
     this._sourcesToCapture = this.capturing ? this.sourceCount : 0;
+    if (this._sourcesToCapture) {
+      this._updateRequested.emit(EUpdate.Render);
+    }
   }
 
   onRender(options: IRenderOptions): void {
diff --git a/js/widgets/behaviors/graph-image.ts b/js/widgets/behaviors/graph-image.ts
index 1cb0e25b..a74dcdf2 100644
--- a/js/widgets/behaviors/graph-image.ts
+++ b/js/widgets/behaviors/graph-image.ts
@@ -9,7 +9,7 @@ import {
 } from '@jupyter-widgets/base';
 import { ImageModel } from '@jupyter-widgets/controls';
 
-import { EMOJI, IBehave, IRenderOptions, WIDGET_DEFAULTS } from '../../tokens';
+import { EMOJI, EUpdate, IBehave, IRenderOptions, WIDGET_DEFAULTS } from '../../tokens';
 
 import { BehaviorModel } from './base';
 
@@ -53,6 +53,9 @@ export class GraphImageModel extends BehaviorModel implements IBehave {
 
   protected onCapturingChanged(e?: any): void {
     this._framesToCapture = this.capturing ? this.frameCount : 0;
+    if (this._framesToCapture) {
+      this._updateRequested.emit(EUpdate.Render);
+    }
   }
 
   onRender(options: IRenderOptions): void {
diff --git a/js/widgets/behaviors/index.ts b/js/widgets/behaviors/index.ts
index a95be1fa..2dd62306 100644
--- a/js/widgets/behaviors/index.ts
+++ b/js/widgets/behaviors/index.ts
@@ -19,4 +19,6 @@ export * from './link-selection';
 export * from './link-width';
 export * from './node-color';
 export * from './node-label';
+export * from './node-size';
 export * from './node-selection';
+export * from './forces';
diff --git a/js/widgets/behaviors/link-selection.ts b/js/widgets/behaviors/link-selection.ts
index 7726ca3d..27b0083c 100644
--- a/js/widgets/behaviors/link-selection.ts
+++ b/js/widgets/behaviors/link-selection.ts
@@ -58,6 +58,10 @@ export class LinkSelectionModel extends BehaviorModel implements IBehave {
     return this.get('selected_width') || DEFAULT_WIDTHS.selected;
   }
 
+  get multiple(): boolean {
+    return this.get('multiple');
+  }
+
   getLinkWidth({ index }: ILinkBehaveOptions): string | null {
     const width = this.selected.has(index) ? this.selectedWidth : null;
     return width;
@@ -69,12 +73,13 @@ export class LinkSelectionModel extends BehaviorModel implements IBehave {
   }
 
   onLinkClick = ({ index, event }: ILinkEventBehaveOptions): boolean => {
-    let { selected } = this;
-    if (this.get('multiple') && (event.ctrlKey || event.shiftKey || event.altKey)) {
-      selected.has(index) ? selected.delete(index) : selected.add(index);
+    let { selected, multiple } = this;
+    const indexSelected = selected.has(index);
+    if (multiple && (event.ctrlKey || event.shiftKey || event.altKey)) {
+      indexSelected ? selected.delete(index) : selected.add(index);
     } else {
       selected.clear();
-      selected.add(index);
+      !indexSelected && selected.add(index);
     }
 
     this.selected = selected;
diff --git a/js/widgets/behaviors/node-selection.ts b/js/widgets/behaviors/node-selection.ts
index e2ccd8c5..70d14bb3 100644
--- a/js/widgets/behaviors/node-selection.ts
+++ b/js/widgets/behaviors/node-selection.ts
@@ -52,19 +52,25 @@ export class NodeSelectionModel extends BehaviorModel implements IBehave {
     return this.get('selected_color') || DEFAULT_COLORS.selected;
   }
 
+  get multiple(): boolean {
+    return this.get('multiple');
+  }
+
   getNodeColor({ node }: INodeBehaveOptions): string | null {
     const color = this.selected.has(node.id) ? this.selectedColor : null;
     return color;
   }
 
   onNodeClick = ({ node, event }: INodeEventBehaveOptions): boolean => {
-    let { selected } = this;
+    let { selected, multiple } = this;
     const id = node.id;
-    if (this.get('multiple') && (event.ctrlKey || event.shiftKey || event.altKey)) {
-      selected.has(id) ? selected.delete(id) : selected.add(id);
+    const idSelected = selected.has(id);
+
+    if (multiple && (event.ctrlKey || event.shiftKey || event.altKey)) {
+      idSelected ? selected.delete(id) : selected.add(id);
     } else {
       selected.clear();
-      selected.add(id);
+      !idSelected && selected.add(id);
     }
 
     this.selected = selected;
diff --git a/js/widgets/behaviors/node-size.ts b/js/widgets/behaviors/node-size.ts
new file mode 100644
index 00000000..a2fa2479
--- /dev/null
+++ b/js/widgets/behaviors/node-size.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+import { IBehave, IHasGraph, INodeBehaveOptions } from '../../tokens';
+
+import { NodeColumnOrTemplateModel } from './base';
+
+export class NodeSizeModel extends NodeColumnOrTemplateModel implements IBehave {
+  static model_name = 'NodeSizeModel';
+
+  defaults() {
+    return { ...super.defaults(), _model_name: NodeSizeModel.model_name };
+  }
+
+  columnName(hasGraph: IHasGraph): string | null {
+    let columnName = super.getColumnName(hasGraph);
+    return columnName || null;
+  }
+
+  getNodeSize(options: INodeBehaveOptions): string | null {
+    return super.getNodeAttr(options);
+  }
+}
diff --git a/js/widgets/display/2d.ts b/js/widgets/display/2d.ts
index 5d4361d4..7fdf72f8 100644
--- a/js/widgets/display/2d.ts
+++ b/js/widgets/display/2d.ts
@@ -23,6 +23,7 @@ import {
 } from '@jupyter-widgets/base';
 
 import {
+  ALL_GRAPH_METHODS,
   ALL_LINK_METHODS,
   ALL_NODE_METHODS,
   CSS,
@@ -31,6 +32,7 @@ import {
   DEFAULT_WIDTHS,
   EMOJI,
   EMPTY_GRAPH_DATA,
+  EUpdate,
   IBehave,
   IHasGraph,
   ILinkBehaveOptions,
@@ -39,13 +41,18 @@ import {
   INodeEventBehaveOptions,
   IRenderOptions,
   ISource,
+  TAnyForce,
+  TGraphBehaveMethod,
+  TGraphMethodMap,
   TLinkBehaveMethod,
   TLinkMethodMap,
   TNodeBehaveMethod,
   TNodeMethodMap,
+  TUpdateKind,
   WIDGET_DEFAULTS,
   emptyArray,
 } from '../../tokens';
+import { ForceBehaviorModel, GraphForcesModel } from '../behaviors';
 
 export class ForceGraphModel extends DOMWidgetModel {
   static model_name = 'ForceGraphModel';
@@ -57,6 +64,8 @@ export class ForceGraphModel extends DOMWidgetModel {
 
   protected _nodeBehaviorsByMethod: TNodeMethodMap;
   protected _linkBehaviorsByMethod: TLinkMethodMap;
+  protected _graphBehaviorsByMethod: TGraphMethodMap;
+  protected _forceBehaviors: GraphForcesModel[];
   protected _behaviorsChanged: Signal<ForceGraphModel, void>;
 
   defaults() {
@@ -67,10 +76,11 @@ export class ForceGraphModel extends DOMWidgetModel {
       _view_name: ForceGraphView.view_name,
       source: null,
       behaviors: [],
-      default_node_color: DEFAULT_COLORS.node,
+      background_color: DEFAULT_COLORS.background,
       default_link_color: DEFAULT_COLORS.link,
       default_link_width: DEFAULT_WIDTHS.link,
-      background_color: DEFAULT_COLORS.background,
+      default_node_color: DEFAULT_COLORS.node,
+      default_node_size: DEFAULT_WIDTHS.node,
     };
   }
 
@@ -85,6 +95,7 @@ export class ForceGraphModel extends DOMWidgetModel {
       this._behaviorsChanged = new Signal(this);
       this._linkBehaviorsByMethod = new Map();
       this._nodeBehaviorsByMethod = new Map();
+      this._graphBehaviorsByMethod = new Map();
     }
     const { behaviors } = this;
 
@@ -107,6 +118,23 @@ export class ForceGraphModel extends DOMWidgetModel {
       }
       this._nodeBehaviorsByMethod.set(nodeMethod, methodBehaviors);
     }
+
+    for (let graphMethod of ALL_GRAPH_METHODS) {
+      let graphBehaviors: IBehave[] = [];
+      for (const behavior of behaviors) {
+        if (behavior[graphMethod]) {
+          graphBehaviors.push(behavior);
+        }
+      }
+      this._graphBehaviorsByMethod.set(graphMethod, graphBehaviors);
+    }
+
+    this._forceBehaviors = [];
+    for (const behavior of behaviors) {
+      if (behavior instanceof GraphForcesModel) {
+        this._forceBehaviors.push(behavior);
+      }
+    }
     this._behaviorsChanged.emit(void 0);
   }
 
@@ -118,6 +146,14 @@ export class ForceGraphModel extends DOMWidgetModel {
     return this._nodeBehaviorsByMethod.get(method) || emptyArray;
   }
 
+  graphBehaviorsForMethod(method: TGraphBehaveMethod): readonly IBehave[] {
+    return this._graphBehaviorsByMethod.get(method) || emptyArray;
+  }
+
+  get forceBehaviors(): readonly GraphForcesModel[] {
+    return this._forceBehaviors;
+  }
+
   get graphData(): GraphData {
     const source = this.get('source');
     return source ? source.graphData : EMPTY_GRAPH_DATA;
@@ -139,6 +175,10 @@ export class ForceGraphModel extends DOMWidgetModel {
     return this.get('default_node_color') || DEFAULT_COLORS.node;
   }
 
+  get defaultNodeSize(): string {
+    return this.get('default_node_size') || DEFAULT_WIDTHS.node;
+  }
+
   get defaultLinkColor(): string {
     return this.get('default_link_color') || DEFAULT_COLORS.link;
   }
@@ -181,7 +221,11 @@ export class ForceGraphView<T = ForceGraphGenericInstance<ForceGraphInstance>>
     this.luminoWidget.addClass(`${CSS.widget}-${this.graphJsClass}`);
     this._rendered = new PromiseDelegate();
     this.model.on('change:source', this.onSourceChange, this);
-    this.model.on('change:background_color', this.onBackgroundColorChange, this);
+    this.model.on(
+      'change:default_node_color change:default_link_color change:background_color change:default_node_size change:default_link_width',
+      this.postUpdate,
+      this
+    );
 
     this.model.behaviorsChanged.connect(this.onBehaviorsChange, this);
     this.luminoWidget.disposed.connect(this.onDisposed, this);
@@ -225,8 +269,7 @@ export class ForceGraphView<T = ForceGraphGenericInstance<ForceGraphInstance>>
     this.graph = graph as any;
     contentWindow.addEventListener('resize', this.onWindowResize);
     this._rendered.resolve(void 0);
-    await this.onBackgroundColorChange();
-    await this.update();
+    await this.redraw();
   };
 
   protected onWindowResize = () => {
@@ -288,104 +331,213 @@ export class ForceGraphView<T = ForceGraphGenericInstance<ForceGraphInstance>>
     return src;
   }
 
-  async update(): Promise<void> {
+  async redraw(): Promise<void> {
     await this._rendered.promise;
     let { graphData } = this.model;
     DEBUG && console.warn(`${EMOJI} updating...`, graphData);
-    (this.graph as any).graphData(graphData);
     await this.postUpdate();
+    (this.graph as any).graphData(graphData);
   }
 
   wrapFunction(fn: Function) {
     return (this._iframe.contentWindow as any).wrapFunction(fn);
   }
 
-  async onBackgroundColorChange() {
-    await this.rendered;
-    (this.graph as ForceGraphInstance).backgroundColor(this.model.backgroundColor);
-  }
-
   onSourceChange(change?: any) {
     const { source, previousSource } = this;
 
     if (previousSource) {
-      previousSource.dataUpdated.disconnect(this.update, this);
+      previousSource.dataUpdated.disconnect(this.redraw, this);
     }
 
     if (source) {
-      source.dataUpdated.connect(this.update, this);
-      this.update();
+      source.dataUpdated.connect(this.redraw, this);
+      this.redraw();
     }
   }
 
-  protected async postUpdate(): Promise<void> {
+  protected async postUpdate(caller?: any, kind?: TUpdateKind): Promise<void> {
     await this.rendered;
     const graph = this.graph as ForceGraphInstance;
-    if (graph) {
-      // link
-      graph.linkColor(this.wrapFunction(this.getLinkColor));
-      graph.linkWidth(this.wrapFunction(this.getLinkWidth));
-      graph.linkLabel(this.wrapFunction(this.getLinkLabel));
-
-      graph.linkDirectionalArrowColor(
-        this.wrapFunction(this.getLinkDirectionalArrowColor)
-      );
-      graph.linkDirectionalArrowLength(
-        this.wrapFunction(this.getLinkDirectionalArrowLength)
-      );
-      graph.linkDirectionalArrowRelPos(
-        this.wrapFunction(this.getLinkDirectionalArrowRelPos)
-      );
-      graph.linkDirectionalParticleColor(
-        this.wrapFunction(this.getLinkDirectionalParticleColor)
-      );
-      graph.linkDirectionalParticleSpeed(
-        this.wrapFunction(this.getLinkDirectionalParticleSpeed)
-      );
-      graph.linkDirectionalParticleWidth(
-        this.wrapFunction(this.getLinkDirectionalParticleWidth)
-      );
-      graph.linkDirectionalParticles(
-        this.wrapFunction(this.getLinkDirectionalParticles)
-      );
-
-      // node
-      graph.nodeColor(this.wrapFunction(this.getNodeColor));
-      graph.nodeLabel(this.wrapFunction(this.getNodeLabel));
-
-      // evented
-      graph.onNodeClick(this.wrapFunction(this.onNodeClick));
-      graph.onLinkClick(this.wrapFunction(this.onLinkClick));
-
-      // (3d-)force-graph-specific
-      this.getOnRenderPostUpdate();
-    } else {
+    if (!graph) {
       console.warn(`${EMOJI} no graph to postUpdate`);
+      return;
+    }
+
+    const {
+      backgroundColor,
+      defaultLinkColor,
+      defaultNodeColor,
+      defaultLinkWidth,
+      defaultNodeSize,
+    } = this.model;
+
+    // graph
+    graph.backgroundColor(backgroundColor);
+
+    // link
+    graph.linkColor(
+      this.model.linkBehaviorsForMethod('getLinkColor').length
+        ? this.wrapFunction(this.getLinkColor)
+        : this.wrapFunction(() => defaultLinkColor)
+    );
+    graph.linkWidth(
+      this.model.linkBehaviorsForMethod('getLinkWidth').length
+        ? this.wrapFunction(this.getLinkWidth)
+        : this.wrapFunction(() => defaultLinkWidth)
+    );
+    graph.linkLabel(
+      this.model.linkBehaviorsForMethod('getLinkLabel').length
+        ? this.wrapFunction(this.getLinkLabel)
+        : null
+    );
+
+    graph.linkDirectionalArrowColor(
+      this.model.linkBehaviorsForMethod('getLinkDirectionalArrowColor').length
+        ? this.wrapFunction(this.getLinkDirectionalArrowColor)
+        : null
+    );
+    graph.linkDirectionalArrowLength(
+      this.model.linkBehaviorsForMethod('getLinkDirectionalArrowLength').length
+        ? this.wrapFunction(this.getLinkDirectionalArrowLength)
+        : null
+    );
+    graph.linkDirectionalArrowRelPos(
+      this.model.linkBehaviorsForMethod('getLinkDirectionalArrowRelPos').length
+        ? this.wrapFunction(this.getLinkDirectionalArrowRelPos)
+        : null
+    );
+    graph.linkDirectionalParticleColor(
+      this.model.linkBehaviorsForMethod('getLinkDirectionalParticleColor').length
+        ? this.wrapFunction(this.getLinkDirectionalParticleColor)
+        : null
+    );
+    graph.linkDirectionalParticleSpeed(
+      this.model.linkBehaviorsForMethod('getLinkDirectionalParticleSpeed').length
+        ? this.wrapFunction(this.getLinkDirectionalParticleSpeed)
+        : null
+    );
+    graph.linkDirectionalParticleWidth(
+      this.model.linkBehaviorsForMethod('getLinkDirectionalParticleWidth').length
+        ? this.wrapFunction(this.getLinkDirectionalParticleWidth)
+        : null
+    );
+    graph.linkDirectionalParticles(
+      this.model.linkBehaviorsForMethod('getLinkDirectionalParticles').length
+        ? this.wrapFunction(this.getLinkDirectionalParticles)
+        : null
+    );
+
+    // node
+    graph.nodeColor(
+      this.model.nodeBehaviorsForMethod('getNodeColor').length
+        ? this.wrapFunction(this.getNodeColor)
+        : this.wrapFunction(() => defaultNodeColor)
+    );
+    graph.nodeVal(
+      this.model.nodeBehaviorsForMethod('getNodeSize').length
+        ? this.wrapFunction(this.getNodeSize)
+        : this.wrapFunction(() => defaultNodeSize)
+    );
+    graph.nodeLabel(
+      this.model.nodeBehaviorsForMethod('getNodeLabel').length
+        ? this.wrapFunction(this.getNodeLabel)
+        : null
+    );
+
+    // evented
+    graph.onNodeClick(
+      this.model.nodeBehaviorsForMethod('onNodeClick').length
+        ? this.wrapFunction(this.onNodeClick)
+        : null
+    );
+    graph.onLinkClick(
+      this.model.linkBehaviorsForMethod('onLinkClick').length
+        ? this.wrapFunction(this.onLinkClick)
+        : null
+    );
+
+    // forces
+    this.getForceUpdate();
+
+    // finally, (3d-)force-graph-specific after all other behaviors
+    this.getOnRenderPostUpdate();
+
+    if (kind && EUpdate.Reheat === (kind & EUpdate.Reheat)) {
+      graph.d3ReheatSimulation();
+    }
+  }
+
+  protected getForceUpdate() {
+    const graph = this.graph as ForceGraphInstance;
+    let needsPost: TAnyForce[] = [];
+    for (let simBehavior of this.model.forceBehaviors) {
+      const { warmupTicks, cooldownTicks, alphaDecay, alphaMin, velocityDecay } =
+        simBehavior;
+      simBehavior.checkPositions(graph);
+      graph.cooldownTicks(cooldownTicks);
+      graph.warmupTicks(warmupTicks);
+      graph.d3AlphaDecay(alphaDecay);
+      graph.d3AlphaMin(alphaMin);
+      graph.d3VelocityDecay(velocityDecay);
+
+      for (let key in simBehavior.forces) {
+        let behavior: ForceBehaviorModel | null = simBehavior.forces[key];
+        let force = behavior?.force || null;
+        if (force && !behavior?.active) {
+          force = null;
+        }
+        graph.d3Force(key, force);
+        if (force?.links) {
+          needsPost.push(force);
+        }
+      }
+    }
+    if (needsPost.length) {
+      setTimeout(this.postForceUpdate, 250, needsPost);
     }
   }
 
+  protected postForceUpdate = (forces: TAnyForce[]) => {
+    const { links } = (this.graph as ForceGraphInstance).graphData();
+    if (!links.length) {
+      return;
+    }
+    const firstSource = typeof links[0].source;
+    if (firstSource === 'string' || firstSource === 'number') {
+      setTimeout(this.postForceUpdate, 250, forces);
+      DEBUG && console.log('Polling postForceUpdate...');
+      return;
+    }
+    for (let force of forces) {
+      if (force.links) {
+        force.links(links);
+      }
+    }
+  };
+
   protected getOnRenderPostUpdate() {
     const graph = this.graph as ForceGraphInstance;
-    graph.onRenderFramePost(this.wrapFunction(this.onRender));
+    graph.onRenderFramePost(
+      this.model.graphBehaviorsForMethod('onRender').length
+        ? this.wrapFunction(this.onRender)
+        : null
+    );
   }
 
   // composable behaviors
   async onBehaviorsChange(): Promise<void> {
     const { behaviors, previousBehaviors } = this.model;
 
-    for (const previous of previousBehaviors) {
-      if (!behaviors.includes(previous)) {
-        previous.updateRequested.disconnect(this.postUpdate, this);
-      }
+    for (const behavior of previousBehaviors) {
+      behavior.updateRequested.disconnect(this.postUpdate, this);
     }
 
     for (const behavior of behaviors) {
-      if (!previousBehaviors.includes(behavior)) {
-        behavior.updateRequested.connect(this.postUpdate, this);
-      }
+      behavior.updateRequested.connect(this.postUpdate, this);
     }
 
-    await this.update();
+    await this.postUpdate();
   }
 
   // link behaviors
@@ -481,6 +633,10 @@ export class ForceGraphView<T = ForceGraphGenericInstance<ForceGraphInstance>>
     return this.getComposedNodeAttr(node, 'getNodeLabel', '');
   };
 
+  protected getNodeSize = (node: NodeObject): string => {
+    return this.getComposedNodeAttr(node, 'getNodeSize', this.model.defaultNodeSize);
+  };
+
   getComposedNodeAttr(
     node: NodeObject,
     methodName: TNodeBehaveMethod,
@@ -506,7 +662,6 @@ export class ForceGraphView<T = ForceGraphGenericInstance<ForceGraphInstance>>
   }
 
   protected onNodeClick = (node: NodeObject, event: MouseEvent) => {
-    const { behaviors } = this.model;
     const graphData = (this.graph as ForceGraphInstance).graphData();
     let shouldContinue = true;
     const options: INodeEventBehaveOptions = {
@@ -515,10 +670,7 @@ export class ForceGraphView<T = ForceGraphGenericInstance<ForceGraphInstance>>
       event,
       node,
     };
-    for (const behavior of behaviors) {
-      if (!behavior.onNodeClick) {
-        continue;
-      }
+    for (const behavior of this.model.nodeBehaviorsForMethod('onNodeClick')) {
       shouldContinue = behavior.onNodeClick(options);
       if (!shouldContinue) {
         return;
@@ -527,7 +679,6 @@ export class ForceGraphView<T = ForceGraphGenericInstance<ForceGraphInstance>>
   };
 
   protected onLinkClick = (link: LinkObject, event: MouseEvent) => {
-    const { behaviors } = this.model;
     const graphData = (this.graph as ForceGraphInstance).graphData();
     let shouldContinue = true;
     const options: ILinkEventBehaveOptions = {
@@ -537,10 +688,7 @@ export class ForceGraphView<T = ForceGraphGenericInstance<ForceGraphInstance>>
       link,
       index: graphData.links.indexOf(link),
     };
-    for (const behavior of behaviors) {
-      if (!behavior.onLinkClick) {
-        continue;
-      }
+    for (const behavior of this.model.linkBehaviorsForMethod('onLinkClick')) {
       shouldContinue = behavior.onLinkClick(options);
       if (!shouldContinue) {
         return;
@@ -553,7 +701,6 @@ export class ForceGraphView<T = ForceGraphGenericInstance<ForceGraphInstance>>
   }
 
   protected onRender = (ctx: CanvasRenderingContext2D, globalScale: any) => {
-    const { behaviors } = this.model;
     const graphData = (this.graph as ForceGraphInstance).graphData();
     let options: IRenderOptions = {
       view: this,
@@ -562,10 +709,7 @@ export class ForceGraphView<T = ForceGraphGenericInstance<ForceGraphInstance>>
       globalScale,
     };
     options = this.updateRenderOptions(options);
-    for (const behavior of behaviors) {
-      if (!behavior.onRender) {
-        continue;
-      }
+    for (const behavior of this.model.graphBehaviorsForMethod('onRender')) {
       behavior.onRender(options);
     }
   };
diff --git a/js/widgets/display/3d.ts b/js/widgets/display/3d.ts
index 584785a7..e21d8252 100644
--- a/js/widgets/display/3d.ts
+++ b/js/widgets/display/3d.ts
@@ -49,7 +49,11 @@ export class ForceGraph3DView extends ForceGraphView<
   }
 
   protected getOnRenderPostUpdate() {
-    this.threeRenderer.setAnimationLoop(this.wrapFunction(this.onRender));
+    this.threeRenderer.setAnimationLoop(
+      this.model.graphBehaviorsForMethod('onRender').length
+        ? this.wrapFunction(this.onRender)
+        : null
+    );
   }
 
   protected updateRenderOptions(options: IRenderOptions): IRenderOptions {
diff --git a/js/widgets/sources/dataframe.ts b/js/widgets/sources/dataframe.ts
index 59ecfd06..9564decb 100644
--- a/js/widgets/sources/dataframe.ts
+++ b/js/widgets/sources/dataframe.ts
@@ -43,9 +43,7 @@ export class DataFrameSourceModel extends WidgetModel {
 
   initialize(attributes: Backbone.ObjectHash, options: IBackboneModelOptions) {
     super.initialize(attributes, options);
-
-    this.on('change:nodes', this.graphUpdate, this);
-    this.on('change:links', this.graphUpdate, this);
+    this.on('change:nodes change:links', this.graphUpdate, this);
   }
 
   get dataUpdated(): ISignal<DataFrameSourceModel, void> {
diff --git a/package.json b/package.json
index cd52f176..461697f0 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,8 @@
     "@jupyter-widgets/jupyterlab-manager": "^5.0.5",
     "3d-force-graph": "~1.71.1",
     "buffer": "^6.0.3",
+    "d3-force-3d": "^3.0.5",
+    "d3-force-cluster-3d": "~1.1.1",
     "force-graph": "~1.43.0",
     "nunjucks": "^3.2.3"
   },
@@ -56,6 +58,7 @@
     "@jupyterlab/theme-dark-extension": "3",
     "@jupyterlab/theme-light-extension": "3",
     "@trivago/prettier-plugin-sort-imports": "^4.0.0",
+    "@types/d3-force": "^3.0.4",
     "@types/nunjucks": "^3.2.1",
     "@types/three": "^0.148.0",
     "file-loader": "^6.2.0",
@@ -92,6 +95,15 @@
       },
       "buffer": {
         "bundled": true
+      },
+      "nunjucks": {
+        "bundled": true
+      },
+      "d3-force-3d": {
+        "bundled": true
+      },
+      "d3-force-cluster-3d": {
+        "bundled": true
       }
     }
   },
diff --git a/scripts/lab.py b/scripts/lab.py
deleted file mode 100644
index 8efe4019..00000000
--- a/scripts/lab.py
+++ /dev/null
@@ -1,59 +0,0 @@
-""" handle lingering issues with jupyterlab 1.x build
-"""
-
-# Copyright (c) 2023 ipyforcegraph contributors.
-# Distributed under the terms of the Modified BSD License.
-
-import shutil
-import subprocess
-import sys
-from pathlib import Path
-
-HERE = Path(__file__).parent
-ROOT = HERE.parent
-
-JLPM = shutil.which("jlpm")
-
-
-def watch():
-    """after preparing, start watchers"""
-    print("watching src...", flush=True)
-    ts = subprocess.Popen([JLPM, "watch"], cwd=str(ROOT))
-    ext = subprocess.Popen(["jupyter", "labextension", "watch", "."])
-
-    def stop():
-        print("stopping watchers...", flush=True)
-        ext.terminate()
-        ts.terminate()
-        ts.wait()
-        ext.wait()
-        print("...watchers stopped!", flush=True)
-
-    print("Starting lab...", flush=True)
-
-    lab = subprocess.Popen(
-        ["jupyter", "lab", "--no-browser", "--debug"],
-        cwd=str(ROOT),
-        stdin=subprocess.PIPE,
-    )
-
-    try:
-        lab.wait()
-    except KeyboardInterrupt:
-        print(
-            "attempting to stop lab, you may want to check your process monitor",
-            flush=True,
-        )
-    finally:
-        stop()
-        lab.terminate()
-        lab.communicate(b"y\n")
-
-    stop()
-    lab.wait()
-    return 0
-
-
-if __name__ == "__main__":
-    if "watch" in sys.argv:
-        sys.exit(watch())
diff --git a/scripts/nblint.py b/scripts/nblint.py
deleted file mode 100644
index eebfb17e..00000000
--- a/scripts/nblint.py
+++ /dev/null
@@ -1,104 +0,0 @@
-""" linter and formatter of notebooks
-"""
-
-# Copyright (c) 2023 ipyforcegraph contributors.
-# Distributed under the terms of the Modified BSD License.
-
-import shutil
-import subprocess
-import sys
-from hashlib import sha256
-from pathlib import Path
-
-import isort
-import nbformat
-from isort.api import sort_code_string
-
-from . import project as P
-
-NODE = [shutil.which("node") or shutil.which("node.exe") or shutil.which("node.cmd")]
-
-NB_METADATA_KEYS = ["kernelspec", "language_info"]
-
-ISORT_CONFIG = isort.settings.Config(settings_path=P.PY_PROJ)
-
-
-def nblint_one(nb_node):
-    """format/lint one notebook"""
-    changes = 0
-    has_empty = 0
-    nb_metadata_keys = list(nb_node.metadata.keys())
-    for key in nb_metadata_keys:
-        if key not in NB_METADATA_KEYS:
-            nb_node.metadata.pop(key)
-    for cell in nb_node.cells:
-        cell_type = cell["cell_type"]
-        source = "".join(cell["source"])
-        if not source.strip():
-            has_empty += 1
-        if cell_type == "markdown":
-            args = [*P.PRETTIER, "--stdin-filepath", "foo.md"]
-            prettier = subprocess.Popen(
-                list(map(str, args)),
-                stdin=subprocess.PIPE,
-                stdout=subprocess.PIPE,
-            )
-            out, _err = prettier.communicate(source.encode("utf-8"))
-            new = out.decode("utf-8").rstrip()
-            if new != source:
-                cell["source"] = new.splitlines(True)
-                changes += 1
-        elif cell_type == "code":
-            if cell["outputs"] or cell["execution_count"]:
-                cell["outputs"] = []
-                cell["execution_count"] = None
-                changes += 1
-            if [line for line in source.splitlines() if line.strip().startswith("!")]:
-                continue
-            if source.startswith("%"):
-                continue
-            new = sort_code_string(source, config=ISORT_CONFIG)
-            if new != source:
-                cell["source"] = new.splitlines(True)
-                changes += 1
-
-    if has_empty:
-        changes += 1
-        nb_node.cells = [
-            cell for cell in nb_node.cells if "".join(cell["source"]).strip()
-        ]
-
-    return nb_node
-
-
-def nb_hash(nb_text):
-    """hash one notebook"""
-    return sha256(nb_text.encode("utf-8")).hexdigest()
-
-
-def nblint(nb_paths):
-    """lint a number of notebook paths"""
-    len_paths = len(nb_paths)
-
-    for i, nb_path in enumerate(nb_paths):
-        nb_text = nb_path.read_text(encoding="utf-8")
-        pre_hash = nb_hash(nb_text)
-
-        if len_paths > 1:
-            print(f"[{i + 1} of {len_paths}] {nb_path}", flush=True)
-
-        nb_node = nblint_one(nbformat.reads(nb_text, 4))
-
-        with nb_path.open("w", encoding="utf-8") as fpt:
-            nbformat.write(nb_node, fpt)
-
-        post_hash = nb_hash(nb_path.read_text(encoding="utf-8"))
-
-        if post_hash != pre_hash:
-            print(f"\t{nb_path.name} formatted")
-
-    return 0
-
-
-if __name__ == "__main__":
-    sys.exit(nblint([Path(p) for p in sys.argv[1:]] or P.EXAMPLE_IPYNB))
diff --git a/scripts/preflight.py b/scripts/preflight.py
index 7d3e6498..0dfc18ca 100644
--- a/scripts/preflight.py
+++ b/scripts/preflight.py
@@ -1,7 +1,4 @@
-""" ensure the development environment is sane
-
-    be careful about imports here:
-"""
+"""ensure the development environment is sane"""
 
 # Copyright (c) 2023 ipyforcegraph contributors.
 # Distributed under the terms of the Modified BSD License.
diff --git a/scripts/project.py b/scripts/project.py
index 0ff7af3e..45db0bfe 100644
--- a/scripts/project.py
+++ b/scripts/project.py
@@ -1,6 +1,6 @@
-""" important project paths
+"""important project paths
 
-    this should not import anything not in py36+ stdlib, or any local paths
+this should not import anything not in py36+ stdlib, or any local paths
 """
 
 # Copyright (c) 2023 ipyforcegraph contributors.
@@ -167,6 +167,7 @@ def _get_boolish(name, default="false"):
 DOCS_BUILD = BUILD / "docs"
 DOCS_CONF = DOCS / "conf.py"
 DICTIONARY = DOCS / "dictionary.txt"
+ALL_SPELL = BUILD / "spell/ALL.fail"
 LITE_SPEC = ["--pre", "jupyterlite==0.1.0b18"]
 LITE_BUILD = BUILD / "lite"
 LITE_SHA256SUMS = LITE_BUILD / "SHA256SUMS"
@@ -268,16 +269,6 @@ def _get_boolish(name, default="false"):
 DOCS_LINKS = BUILD / "links"
 
 
-def template_one(src: Path, dest: Path, context=None):
-    """Update a file from a template"""
-    try:
-        import jinja2
-    except ImportError:
-        print(f"Can't update {src} without jinja2")
-        return
-
-    context = context or {}
-
-    tmpl = jinja2.Template(src.read_text(encoding="utf-8"))
-    text = tmpl.render(**context)
-    dest.write_text(text, encoding="utf-8")
+# nblint
+NB_METADATA_KEYS = ["kernelspec", "language_info"]
+CLOBBER_CELL_METADATA_KEYS = ["jupyter", "collapsed"]
diff --git a/scripts/utils.py b/scripts/utils.py
index 582049bc..0629b3cc 100644
--- a/scripts/utils.py
+++ b/scripts/utils.py
@@ -1,13 +1,16 @@
-# from https://github.com/jupyrdf/ipyradiant/blob/master/_scripts/utils.py
+"""automation utilities for ``ipyforcegraph``"""
 
-# Copyright (c) 2020 ipyradiant contributors.
-# Distributed under the terms of the Modified BSD License.
 # Copyright (c) 2023 ipyforcegraph contributors.
 # Distributed under the terms of the Modified BSD License.
 
+import json
 import re
+import subprocess
+import tempfile
 from pathlib import Path
 
+from . import project as P
+
 RE_TIMESTAMP = r"\d{4}-\d{2}-\d{2} \d{2}:\d{2} -\d*"
 RE_PYTEST_TIMESTAMP = r"on \d{2}-[^\-]+-\d{4} at \d{2}:\d{2}:\d{2}"
 
@@ -40,3 +43,80 @@ def replace_between_patterns(src: Path, dest: Path, pattern: str):
         "".join([dest_chunks[0], pattern, src_chunks[1], pattern, dest_chunks[2]]),
         encoding="utf-8",
     )
+
+
+def template_one(src: Path, dest: Path, context=None):
+    """Update a file from a template"""
+    try:
+        import jinja2
+    except ImportError:
+        print(f"Can't update {src} without jinja2")
+        return
+
+    context = context or {}
+
+    tmpl = jinja2.Template(src.read_text(encoding="utf-8"))
+    text = tmpl.render(**context)
+    dest.write_text(text, encoding="utf-8")
+
+
+def clean_notebook_metadata(nb_json):
+    nb_metadata_keys = list(nb_json["metadata"].keys())
+    for key in nb_metadata_keys:
+        if key not in P.NB_METADATA_KEYS:
+            nb_json["metadata"].pop(key)
+    for cell in nb_json["cells"]:
+        for clobber in P.CLOBBER_CELL_METADATA_KEYS:
+            if clobber in cell["metadata"]:
+                cell["metadata"].pop(clobber)
+
+
+def pretty_markdown_cells(ipynb, nb_json):
+    cells = [c for c in nb_json["cells"] if c["cell_type"] == "markdown"]
+
+    if not cells:
+        return
+
+    print(f"... prettying {len(cells)} markdown cells of {ipynb.stem}")
+    with tempfile.TemporaryDirectory() as td:
+        tdp = Path(td)
+
+        files = {}
+
+        for i, cell in enumerate(cells):
+            files[i] = tdp / f"{ipynb.stem}-{i:03d}.md"
+            files[i].write_text("".join([*cell["source"], "\n"]), encoding="utf-8")
+
+        args = [
+            *P.IN_ENV,
+            "jlpm",
+            "--silent",
+            "prettier",
+            "--config",
+            P.PACKAGE_JSON,
+            "--write",
+            "--list-different",
+        ]
+
+        subprocess.call([*args, tdp])
+
+        for i, cell in enumerate(cells):
+            cells[i]["source"] = (
+                files[i].read_text(encoding="utf-8").rstrip().splitlines(True)
+            )
+
+
+def notebook_lint(ipynb: Path):
+    nb_text = ipynb.read_text(encoding="utf-8")
+    nb_json = json.loads(nb_text)
+
+    pretty_markdown_cells(ipynb, nb_json)
+    clean_notebook_metadata(nb_json)
+
+    ipynb.write_text(json.dumps(nb_json), encoding="utf-8")
+
+    print(f"... blackening {ipynb.stem}")
+    black_args = []
+    black_args += ["--quiet"]
+    if subprocess.call([*P.IN_ENV, "black", *black_args, ipynb]) != 0:
+        return False
diff --git a/src/ipyforcegraph/__init__.py b/src/ipyforcegraph/__init__.py
index 5724929e..c06768ef 100644
--- a/src/ipyforcegraph/__init__.py
+++ b/src/ipyforcegraph/__init__.py
@@ -1,4 +1,5 @@
-"""Main entrypoint for ipyforcegraph."""
+"""Main entrypoint for ``ipyforcegraph``."""
+
 # Copyright (c) 2023 ipyforcegraph contributors.
 # Distributed under the terms of the Modified BSD License.
 
diff --git a/src/ipyforcegraph/_base.py b/src/ipyforcegraph/_base.py
index 23a84fa2..cba19242 100644
--- a/src/ipyforcegraph/_base.py
+++ b/src/ipyforcegraph/_base.py
@@ -1,4 +1,5 @@
-"""Base widget identification for ipyforcegraph."""
+"""Base widget identification for ``ipyforcegraph``."""
+
 # Copyright (c) 2023 ipyforcegraph contributors.
 # Distributed under the terms of the Modified BSD License.
 
@@ -16,9 +17,3 @@ class ForceBase(W.Widget):
     _model_module_version: str = T.Unicode(EXTENSION_SPEC_VERSION).tag(sync=True)
     _view_module: str = T.Unicode(EXTENSION_NAME).tag(sync=True)
     _view_module_version: str = T.Unicode(EXTENSION_SPEC_VERSION).tag(sync=True)
-
-
-class Behavior(ForceBase):
-    """The base class for all IPyForceGraph graph behaviors."""
-
-    _model_name: str = T.Unicode("BehaviorModel").tag(sync=True)
diff --git a/src/ipyforcegraph/behaviors/__init__.py b/src/ipyforcegraph/behaviors/__init__.py
new file mode 100644
index 00000000..e29513bc
--- /dev/null
+++ b/src/ipyforcegraph/behaviors/__init__.py
@@ -0,0 +1,43 @@
+"""Behaviors for ``ipyforcegraph`` graphs, nodes, and links."""
+
+# Copyright (c) 2023 ipyforcegraph contributors.
+# Distributed under the terms of the Modified BSD License.
+
+from ._base import Behavior
+from .graph import GraphData, GraphForces, GraphImage
+from .link import (
+    LinkColors,
+    LinkDirectionalArrowColor,
+    LinkDirectionalArrowLength,
+    LinkDirectionalArrowRelPos,
+    LinkDirectionalParticleColor,
+    LinkDirectionalParticles,
+    LinkDirectionalParticleSpeed,
+    LinkDirectionalParticleWidth,
+    LinkLabels,
+    LinkSelection,
+    LinkWidths,
+)
+from .node import NodeColors, NodeLabels, NodeSelection, NodeSizes
+
+__all__ = [
+    "Behavior",
+    "GraphData",
+    "GraphForces",
+    "GraphImage",
+    "LinkColors",
+    "LinkDirectionalArrowColor",
+    "LinkDirectionalArrowLength",
+    "LinkDirectionalArrowRelPos",
+    "LinkDirectionalParticleColor",
+    "LinkDirectionalParticles",
+    "LinkDirectionalParticleSpeed",
+    "LinkDirectionalParticleWidth",
+    "LinkLabels",
+    "LinkSelection",
+    "LinkWidths",
+    "NodeColors",
+    "NodeLabels",
+    "NodeSelection",
+    "NodeSizes",
+]
diff --git a/src/ipyforcegraph/behaviors/_base.py b/src/ipyforcegraph/behaviors/_base.py
new file mode 100644
index 00000000..408642ec
--- /dev/null
+++ b/src/ipyforcegraph/behaviors/_base.py
@@ -0,0 +1,31 @@
+"""Base classes for all behaviors and forces."""
+
+# Copyright (c) 2023 ipyforcegraph contributors.
+# Distributed under the terms of the Modified BSD License.
+
+import traitlets as T
+
+from .._base import ForceBase
+
+
+class Behavior(ForceBase):
+    """The base class for all IPyForceGraph graph behaviors."""
+
+    _model_name: str = T.Unicode("BehaviorModel").tag(sync=True)
+
+
+class BaseD3Force(Behavior):
+    """A base for all ``d3-force-3d`` force wrappers."""
+
+    _model_name: str = T.Unicode("BaseD3ForceModel").tag(sync=True)
+    key: str = T.Unicode(
+        "unknown",
+        help=(
+            "force simulation identifier for the force. Must be unique, with "
+            "special care taken around the default forces of ``link`` "
+            "``charge``, ``center``, and ``dagRadial``."
+        ),
+    ).tag(sync=True)
+    active: bool = T.Bool(True, help="whether the force is currently active").tag(
+        sync=True
+    )
diff --git a/src/ipyforcegraph/behaviors/forces.py b/src/ipyforcegraph/behaviors/forces.py
new file mode 100644
index 00000000..b1eb6d88
--- /dev/null
+++ b/src/ipyforcegraph/behaviors/forces.py
@@ -0,0 +1,283 @@
+"""Forces for ``ipyforcegraph``.
+
+Using documentation from:
+
+- `d3-forces <https://github.com/d3/d3-force#links>`_
+- `d3-forces-3d <https://github.com/vasturiano/d3-force-3d#api-reference>`_
+"""
+
+# Copyright (c) 2023 ipyforcegraph contributors.
+# Distributed under the terms of the Modified BSD License.
+
+from typing import Optional
+
+import ipywidgets as W
+import traitlets as T
+
+from ._base import BaseD3Force
+
+
+@W.register
+class LinkForce(BaseD3Force):
+    """This Link Force is between two nodes that share an edge.
+
+    https://github.com/d3/d3-force#links
+    """
+
+    _model_name: str = T.Unicode("LinkForceModel").tag(sync=True)
+    # id_accessor
+    distance: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="a nunjucks template to use to calculate link distance. Context takes `link`",
+    ).tag(sync=True)
+
+    strength: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="a nunjucks template to use to calculate link strength. Context takes `link`",
+    ).tag(sync=True)
+
+    iterations: Optional[int] = T.Unicode(
+        None,
+        allow_none=True,
+        help="the number of iterations per application to the specified number and returns this force.",
+    ).tag(sync=True)
+
+
+@W.register
+class CenterForce(BaseD3Force):
+    """The centering force translates nodes uniformly so that the mean position
+    of all nodes (center of mass if all nodes have equal weight) is at the given
+    position (x, y, z).
+
+    https://github.com/d3/d3-force#centering
+    """
+
+    _model_name: str = T.Unicode("CenterForceModel").tag(sync=True)
+    key: str = T.Unicode("center").tag(sync=True)
+    x: Optional[float] = T.Float(
+        None,
+        allow_none=True,
+        help="sets the x-coordinate of the centering position to the specified number and returns this force.",
+    ).tag(sync=True)
+
+    y: Optional[float] = T.Float(
+        None,
+        allow_none=True,
+        help="sets the y-coordinate of the centering position to the specified number and returns this force.",
+    ).tag(sync=True)
+
+    z: Optional[float] = T.Float(
+        None,
+        allow_none=True,
+        help="sets the z-coordinate of the centering position to the specified number and returns this force.",
+    ).tag(sync=True)
+
+
+@W.register
+class XForce(BaseD3Force):
+    """The X position force push nodes towards a desired position along the
+    given dimension with a configurable strength.
+
+    https://github.com/d3/d3-force#positioning
+    """
+
+    _model_name: str = T.Unicode("XForceModel").tag(sync=True)
+    x: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="nunjucks template sets the x-coordinate of the centering position to the specified number and returns this force. Context takes `node`.",
+    ).tag(sync=True)
+
+    strength: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="a nunjucks template to use to calculate strength. Context takes `node`",
+    ).tag(sync=True)
+
+
+@W.register
+class YForce(BaseD3Force):
+    """The Y position force push nodes towards a desired position along the
+    given dimension with a configurable strength.
+
+    https://github.com/d3/d3-force#positioning
+    """
+
+    _model_name: str = T.Unicode("YForceModel").tag(sync=True)
+
+    y: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="nunjucks template sets the y-coordinate of the centering position to the specified number and returns this force. Context takes `node`.",
+    ).tag(sync=True)
+
+    strength: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="a nunjucks template to use to calculate strength. Context takes `node`",
+    ).tag(sync=True)
+
+
+@W.register
+class ZForce(BaseD3Force):
+    """The Z position force push nodes towards a desired position along the
+    given dimension with a configurable strength.
+
+    https://github.com/d3/d3-force#positioning
+    """
+
+    _model_name: str = T.Unicode("ZForceModel").tag(sync=True)
+
+    z: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="nunjucks template sets the z-coordinate of the centering position to the specified number and returns this force. Context takes `node`.",
+    ).tag(sync=True)
+
+    strength: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="a nunjucks template to use to calculate strength. Context takes `node`",
+    ).tag(sync=True)
+
+
+@W.register
+class ManyBodyForce(BaseD3Force):
+    """The many-body (or n-body) force applies mutually amongst all nodes. It
+    can be used to simulate gravity (attraction) if the strength is positive, or
+    electrostatic charge (repulsion) if the strength is negative. This
+    implementation uses quadtrees and the Barnes–Hut approximation to greatly
+    improve performance; the accuracy can be customized using the theta
+    parameter.
+
+    https://github.com/d3/d3-force#many-body
+    """
+
+    _model_name: str = T.Unicode("ManyBodyForceModel").tag(sync=True)
+    strength: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="a nunjucks template to use to calculate strength. Context takes `node`",
+    ).tag(sync=True)
+
+    theta: Optional[float] = T.Float(
+        None,
+        allow_none=True,
+        help="sets the Barnes–Hut approximation criterion to the specified number and returns this force.",
+    ).tag(sync=True)
+
+    distance_min: Optional[float] = T.Float(
+        None,
+        allow_none=True,
+        help="sets the minimum distance between nodes over which this force is considered.",
+    ).tag(sync=True)
+
+    distance_max: Optional[float] = T.Float(
+        None,
+        allow_none=True,
+        help="sets the maximum distance between nodes over which this force is considered.",
+    ).tag(sync=True)
+
+
+@W.register
+class RadialForce(BaseD3Force):
+    """The radial positioning force create a force towards a circle of the
+    specified radius centered at (x, y).
+
+    https://github.com/d3/d3-force#forceRadial
+    """
+
+    _model_name: str = T.Unicode("RadialForceModel").tag(sync=True)
+    radius: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="a nunjucks template to use to calculate radius. Context takes `node`",
+    ).tag(sync=True)
+
+    strength: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="a nunjucks template to use to calculate strength. Context takes `node`",
+    ).tag(sync=True)
+
+    x: Optional[float] = T.Float(
+        None,
+        allow_none=True,
+        help="sets the x-coordinate of the centering position to the specified number and returns this force.",
+    ).tag(sync=True)
+
+    y: Optional[float] = T.Float(
+        None,
+        allow_none=True,
+        help="sets the y-coordinate of the centering position to the specified number and returns this force.",
+    ).tag(sync=True)
+
+    z: Optional[float] = T.Float(
+        None,
+        allow_none=True,
+        help="sets the z-coordinate of the centering position to the specified number and returns this force.",
+    ).tag(sync=True)
+
+
+@W.register
+class CollisionForce(BaseD3Force):
+    """The collision force treats nodes as circles with a given `radius`, rather
+    than points and prevents nodes from overlapping.
+
+    https://github.com/d3/d3-force#collision
+    """
+
+    _model_name: str = T.Unicode("CollisionForceModel").tag(sync=True)
+    radius: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="a nunjucks template to use to calculate node radius",
+    ).tag(sync=True)
+
+    strength: Optional[float] = T.Float(
+        None,
+        allow_none=True,
+        min=0,
+        max=1,
+        default=1,
+        help="sets the strength of the force.",
+    ).tag(sync=True)
+
+    iterations: Optional[int] = T.Unicode(
+        None,
+        allow_none=True,
+        help="the number of iterations per application to the specified number and returns this force.",
+    ).tag(sync=True)
+
+
+@W.register
+class ClusterForce(BaseD3Force):
+    """A force type that attracts nodes toward a set of cluster centers.
+
+    https://github.com/vasturiano/d3-force-cluster-3d
+    """
+
+    _model_name: str = T.Unicode("ClusterForceModel").tag(sync=True)
+    centers: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="Defines each node's cluster center. All cluster centers should be defined as a radius and set of coordinates { radius, x, y, z }, according to the number of spatial dimensions in the simulation.",
+    ).tag(sync=True)
+    strength: Optional[float] = T.Float(
+        None,
+        allow_none=True,
+        min=0,
+        max=1,
+        default=0.1,
+        help="sets the strength of the force.",
+    ).tag(sync=True)
+    center_inertia: Optional[float] = T.Float(
+        None,
+        allow_none=True,
+        min=0,
+        max=1,
+        default=0,
+        help="Lower values (close to 0) result in cluster center nodes with lower inertia: they are easily pulled around by other nodes in the cluster.",
+    ).tag(sync=True)
diff --git a/src/ipyforcegraph/behaviors/graph.py b/src/ipyforcegraph/behaviors/graph.py
new file mode 100644
index 00000000..4aad57be
--- /dev/null
+++ b/src/ipyforcegraph/behaviors/graph.py
@@ -0,0 +1,132 @@
+"""Graph behaviors for ``ipyforcegraph``."""
+
+# Copyright (c) 2023 ipyforcegraph contributors.
+# Distributed under the terms of the Modified BSD License.
+
+from typing import Dict, Optional, Tuple
+
+import ipywidgets as W
+import traitlets as T
+
+from ..sources.dataframe import DataFrameSource
+from ._base import BaseD3Force, Behavior
+
+
+@W.register
+class GraphImage(Behavior):
+    """Captures multiple subsequent frames of a canvas, each as an :class:`~ipywidgets.widgets.widget_media.Image`."""
+
+    _model_name: str = T.Unicode("GraphImageModel").tag(sync=True)
+
+    capturing = T.Bool(False, help="Whether the frame capture is currently active").tag(
+        sync=True
+    )
+
+    frame_count = T.Int(1, help="The number of frames to capture").tag(sync=True)
+
+    frames: Tuple[W.Image, ...] = W.TypedTuple(
+        T.Instance(W.Image),
+        help="A tuple of :class:`~ipywidgets.widgets.widget_media.Image` to populate with frames of the graph.",
+    ).tag(sync=True, **W.widget_serialization)
+
+    def _get_frames(self) -> Tuple[W.Image, ...]:
+        return tuple(
+            [W.Image(description=f"frame {i}") for i in range(self.frame_count)]
+        )
+
+    @T.default("frames")
+    def _default_frames(self) -> Tuple[W.Image, ...]:
+        return self._get_frames()
+
+    @T.observe("frame_count")
+    def _on_frame_count(self, change: T.Bunch) -> None:
+        frames = self.frames
+
+        self.frames = tuple()
+
+        for frame in frames:
+            frame.close()
+
+        self.frames = self._get_frames()
+
+
+@W.register
+class GraphForces(Behavior):
+    """Customize :class:`~ipyforcegraph.graphs.ForceGraph` force simulation.
+
+    These also apply to :class:`~ipyforcegraph.graphs.ForceGraph3D`
+
+    For more, see the frontend documentation on https://github.com/vasturiano/force-graph#force-engine-d3-force-configuration
+    """
+
+    _model_name: str = T.Unicode("GraphForcesModel").tag(sync=True)
+
+    forces: Dict[str, BaseD3Force] = T.Dict(
+        value_trait=T.Instance(BaseD3Force, allow_none=True),
+        help="named forces. Set a name `None` to remove a force: By default, ForceGraph has `link`, `charge`, and `center`.",
+    ).tag(sync=True, **W.widget_serialization)
+
+    warmup_ticks: Optional[int] = T.Int(
+        0,
+        min=0,
+        help="layout engine cycles to dry-run at ignition before starting to render.",
+    ).tag(sync=True)
+
+    cooldown_ticks: Optional[int] = T.Int(
+        -1,
+        help="frames to render before stopping and freezing the layout engine. Values less than zero will be translated to `Infinity`.",
+    ).tag(sync=True)
+
+    alpha_min: Optional[float] = T.Float(
+        0.0, min=0.0, max=1.0, help="simulation alpha min parameter"
+    ).tag(sync=True)
+
+    alpha_decay: Optional[float] = T.Float(
+        0.0228,
+        min=0.0,
+        max=1.0,
+        help="simulation intensity decay parameter",
+    ).tag(sync=True)
+
+    velocity_decay: Optional[float] = T.Float(
+        0.4,
+        min=0.0,
+        max=1.0,
+        help="nodes' velocity decay that simulates the medium resistance",
+    ).tag(sync=True)
+
+
+@W.register
+class GraphData(Behavior):
+    """Captures multiple subsequent ticks of a graph simulation, each as a :class:`~pandas.DataFrame`."""
+
+    _model_name: str = T.Unicode("GraphDataModel").tag(sync=True)
+
+    capturing: bool = T.Bool(
+        False, help="Whether the dataframe capture is currently active"
+    ).tag(sync=True)
+
+    source_count = T.Int(1, help="The number of sources to capture").tag(sync=True)
+
+    sources: Tuple[DataFrameSource, ...] = W.TypedTuple(
+        T.Instance(DataFrameSource),
+        help="A tuple of :class:`~ipyforcegraph.sources.dataframe.DataFrameSource` to be populated with data of the graph.",
+    ).tag(sync=True, **W.widget_serialization)
+
+    def _get_sources(self) -> Tuple[DataFrameSource, ...]:
+        return tuple([DataFrameSource() for i in range(self.source_count)])
+
+    @T.default("sources")
+    def _default_sources(self) -> Tuple[DataFrameSource, ...]:
+        return self._get_sources()
+
+    @T.observe("source_count")
+    def _on_source_count(self, change: T.Bunch) -> None:
+        sources = self.sources
+
+        self.sources = tuple()
+
+        for source in sources:
+            source.close()
+
+        self.sources = self._get_sources()
diff --git a/src/ipyforcegraph/behaviors.py b/src/ipyforcegraph/behaviors/link.py
similarity index 59%
rename from src/ipyforcegraph/behaviors.py
rename to src/ipyforcegraph/behaviors/link.py
index 10447ada..0f1f3742 100644
--- a/src/ipyforcegraph/behaviors.py
+++ b/src/ipyforcegraph/behaviors/link.py
@@ -1,4 +1,4 @@
-"""Behaviors for ``ipyforcegraph``."""
+"""Link behaviors for ``ipyforcegraph``."""
 
 # Copyright (c) 2023 ipyforcegraph contributors.
 # Distributed under the terms of the Modified BSD License.
@@ -9,139 +9,6 @@
 import traitlets as T
 
 from ._base import Behavior
-from .sources.dataframe import DataFrameSource
-
-
-@W.register
-class GraphImage(Behavior):
-    """Captures multiple subsequent frames of a canvas, each as an :class:`~ipywidgets.widgets.widget_media.Image`."""
-
-    _model_name: str = T.Unicode("GraphImageModel").tag(sync=True)
-
-    capturing = T.Bool(False, help="Whether the frame capture is currently active").tag(
-        sync=True
-    )
-
-    frame_count = T.Int(1, help="The number of frames to capture").tag(sync=True)
-
-    frames: Tuple[W.Image, ...] = W.TypedTuple(
-        T.Instance(W.Image),
-        help="A tuple of :class:`~ipywidgets.widgets.widget_media.Image` to populate with frames of the graph.",
-    ).tag(sync=True, **W.widget_serialization)
-
-    def _get_frames(self) -> Tuple[W.Image, ...]:
-        return tuple(
-            [W.Image(description=f"frame {i}") for i in range(self.frame_count)]
-        )
-
-    @T.default("frames")
-    def _default_frames(self) -> Tuple[W.Image, ...]:
-        return self._get_frames()
-
-    @T.observe("frame_count")
-    def _on_frame_count(self, change: T.Bunch) -> None:
-        frames = self.frames
-
-        self.frames = tuple()
-
-        for frame in frames:
-            frame.close()
-
-        self.frames = self._get_frames()
-
-
-@W.register
-class GraphData(Behavior):
-    """Captures multiple subsequent ticks of a graph simulation, each as a :class:`~pandas.DataFrame`."""
-
-    _model_name: str = T.Unicode("GraphDataModel").tag(sync=True)
-
-    capturing: bool = T.Bool(
-        False, help="Whether the dataframe capture is currently active"
-    ).tag(sync=True)
-
-    source_count = T.Int(1, help="The number of sources to capture").tag(sync=True)
-
-    sources: Tuple[DataFrameSource, ...] = W.TypedTuple(
-        T.Instance(DataFrameSource),
-        help="A tuple of :class:`~ipyforcegraph.sources.dataframe.DataFrameSource` to be populated with data of the graph.",
-    ).tag(sync=True, **W.widget_serialization)
-
-    def _get_sources(self) -> Tuple[DataFrameSource, ...]:
-        return tuple([DataFrameSource() for i in range(self.source_count)])
-
-    @T.default("sources")
-    def _default_sources(self) -> Tuple[DataFrameSource, ...]:
-        return self._get_sources()
-
-    @T.observe("source_count")
-    def _on_source_count(self, change: T.Bunch) -> None:
-        sources = self.sources
-
-        self.sources = tuple()
-
-        for source in sources:
-            source.close()
-
-        self.sources = self._get_sources()
-
-
-@W.register
-class NodeSelection(Behavior):
-    """Enable node selection with synced ids of selected nodes."""
-
-    _model_name: str = T.Unicode("NodeSelectionModel").tag(sync=True)
-
-    selected: Tuple[Union[int, str], ...] = W.TypedTuple(
-        T.Union((T.Int(), T.Unicode())),
-        allow_none=True,
-        help="the ids of any selected nodes",
-    ).tag(sync=True)
-
-    multiple: bool = T.Bool(True).tag(sync=True)
-
-    selected_color: str = T.Unicode("#B3A369", help="the color of selected nodes").tag(
-        sync=True
-    )
-
-
-@W.register
-class NodeLabels(Behavior):
-    """Display node labels."""
-
-    _model_name: str = T.Unicode("NodeLabelModel").tag(sync=True)
-
-    column_name: str = T.Unicode(
-        None,
-        allow_none=True,
-        help=(
-            "name of the source column to use for node labels. If `None`, use "
-            "the source's `node_id_column`."
-        ),
-    ).tag(sync=True)
-
-    template: Optional[str] = T.Unicode(
-        None,
-        allow_none=True,
-        help="a nunjucks template to use to calculate node labels",
-    ).tag(sync=True)
-
-
-@W.register
-class NodeColors(Behavior):
-    _model_name: str = T.Unicode("NodeColorModel").tag(sync=True)
-
-    column_name: str = T.Unicode(
-        None,
-        allow_none=True,
-        help="name of the source column to use for node colors.",
-    ).tag(sync=True)
-
-    template: Optional[str] = T.Unicode(
-        None,
-        allow_none=True,
-        help="a nunjucks template to use to calculate colors",
-    ).tag(sync=True)
 
 
 @W.register
@@ -169,6 +36,8 @@ class LinkSelection(Behavior):
 
 @W.register
 class LinkColors(Behavior):
+    """Customize link colors based on a column or template."""
+
     _model_name: str = T.Unicode("LinkColorModel").tag(sync=True)
 
     column_name: str = T.Unicode(
@@ -186,6 +55,8 @@ class LinkColors(Behavior):
 
 @W.register
 class LinkWidths(Behavior):
+    """Customize link widths based on a column or template."""
+
     _model_name: str = T.Unicode("LinkWidthModel").tag(sync=True)
 
     column_name: str = T.Unicode(
@@ -203,6 +74,8 @@ class LinkWidths(Behavior):
 
 @W.register
 class LinkLabels(Behavior):
+    """Customize link labels, displayed on hover, based on a column or template."""
+
     _model_name: str = T.Unicode("LinkLabelModel").tag(sync=True)
 
     column_name: str = T.Unicode(
@@ -220,6 +93,8 @@ class LinkLabels(Behavior):
 
 @W.register
 class LinkDirectionalArrowColor(Behavior):
+    """Customize arrow color on links, based on a column or template."""
+
     _model_name: str = T.Unicode("LinkDirectionalArrowColorModel").tag(sync=True)
 
     column_name: str = T.Unicode(
@@ -237,6 +112,8 @@ class LinkDirectionalArrowColor(Behavior):
 
 @W.register
 class LinkDirectionalArrowLength(Behavior):
+    """Customize arrow direction on links, based on a column or template."""
+
     _model_name: str = T.Unicode("LinkDirectionalArrowLengthModel").tag(sync=True)
 
     column_name: str = T.Unicode(
@@ -254,6 +131,11 @@ class LinkDirectionalArrowLength(Behavior):
 
 @W.register
 class LinkDirectionalArrowRelPos(Behavior):
+    """Customize arrow positioning on links, based on a column or template.
+
+    This generated value must be between ``0.0``, at the source, or ``1.0``, at the target
+    """
+
     _model_name: str = T.Unicode("LinkDirectionalArrowRelPosModel").tag(sync=True)
 
     column_name: str = T.Unicode(
@@ -271,6 +153,8 @@ class LinkDirectionalArrowRelPos(Behavior):
 
 @W.register
 class LinkDirectionalParticleColor(Behavior):
+    """Customize the color of animated particles on links, based on a column or template."""
+
     _model_name: str = T.Unicode("LinkDirectionalParticleColorModel").tag(sync=True)
 
     column_name: str = T.Unicode(
@@ -288,6 +172,12 @@ class LinkDirectionalParticleColor(Behavior):
 
 @W.register
 class LinkDirectionalParticleSpeed(Behavior):
+    """Customize the speed of animated particles on links, based on a column or template.
+
+    The value should be between ``0.0``, stationary, and ``~0.1``, or they will
+    exceed the frame rate of the animation.
+    """
+
     _model_name: str = T.Unicode("LinkDirectionalParticleSpeedModel").tag(sync=True)
 
     column_name: str = T.Unicode(
@@ -305,6 +195,12 @@ class LinkDirectionalParticleSpeed(Behavior):
 
 @W.register
 class LinkDirectionalParticleWidth(Behavior):
+    """Customize the size of animated particles on links, based on a column or template.
+
+    The value should be between ``0.0``, invisible (not recommended) and ``~5.0``,
+    which is rather large, depending on the size of nodes.
+    """
+
     _model_name: str = T.Unicode("LinkDirectionalParticleWidthModel").tag(sync=True)
 
     column_name: str = T.Unicode(
@@ -322,6 +218,12 @@ class LinkDirectionalParticleWidth(Behavior):
 
 @W.register
 class LinkDirectionalParticles(Behavior):
+    """Customize the density of animated particles on links, based on a column or template.
+
+    The value should be between ``0.0``, invisible (not recommended) and an upper
+    bound, dependant on the visual length of links.
+    """
+
     _model_name: str = T.Unicode("LinkDirectionalParticlesModel").tag(sync=True)
 
     column_name: str = T.Unicode(
diff --git a/src/ipyforcegraph/behaviors/node.py b/src/ipyforcegraph/behaviors/node.py
new file mode 100644
index 00000000..34c183b9
--- /dev/null
+++ b/src/ipyforcegraph/behaviors/node.py
@@ -0,0 +1,94 @@
+"""Node behaviors for ``ipyforcegraph``."""
+
+# Copyright (c) 2023 ipyforcegraph contributors.
+# Distributed under the terms of the Modified BSD License.
+
+from typing import Optional, Tuple, Union
+
+import ipywidgets as W
+import traitlets as T
+
+from ._base import Behavior
+
+
+@W.register
+class NodeSelection(Behavior):
+    """Enable node selection with synced ids of selected nodes."""
+
+    _model_name: str = T.Unicode("NodeSelectionModel").tag(sync=True)
+
+    selected: Tuple[Union[int, str], ...] = W.TypedTuple(
+        T.Union((T.Int(), T.Unicode())),
+        allow_none=True,
+        help="the ids of any selected nodes",
+    ).tag(sync=True)
+
+    multiple: bool = T.Bool(True).tag(sync=True)
+
+    selected_color: str = T.Unicode(
+        "rgba(179, 163, 105, 1.0)",
+        help="the color of selected nodes",
+    ).tag(sync=True)
+
+
+@W.register
+class NodeLabels(Behavior):
+    """Display node labels on hover.
+
+    These may be strings or full HTML.
+    """
+
+    _model_name: str = T.Unicode("NodeLabelModel").tag(sync=True)
+
+    column_name: str = T.Unicode(
+        None,
+        allow_none=True,
+        help=(
+            "name of the source column to use for node labels. If `None`, use "
+            "the source's `node_id_column`."
+        ),
+    ).tag(sync=True)
+
+    template: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="a nunjucks template to use to calculate node labels",
+    ).tag(sync=True)
+
+
+@W.register
+class NodeSizes(Behavior):
+    """Change the size of nodes by template or column."""
+
+    _model_name: str = T.Unicode("NodeSizeModel").tag(sync=True)
+
+    column_name: str = T.Unicode(
+        None,
+        allow_none=True,
+        help="name of the source column to use for node sizes.",
+    ).tag(sync=True)
+
+    template: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="a nunjucks template to use to calculate node sizes",
+    ).tag(sync=True)
+
+
+@W.register
+class NodeColors(Behavior):
+    """Change the colors of nodes by template or column."""
+
+    _model_name: str = T.Unicode("NodeColorModel").tag(sync=True)
+
+    column_name: str = T.Unicode(
+        None,
+        allow_none=True,
+        help="name of the source column to use for node colors.",
+    ).tag(sync=True)
+
+    template: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="a nunjucks template to use to calculate colors",
+    ).tag(sync=True)
diff --git a/src/ipyforcegraph/constants.py b/src/ipyforcegraph/constants.py
index 82574347..644d5061 100644
--- a/src/ipyforcegraph/constants.py
+++ b/src/ipyforcegraph/constants.py
@@ -1,4 +1,4 @@
-"""Constants for ipyforcegraph."""
+"""Constants for ``ipyforcegraph``."""
 
 # Copyright (c) 2023 ipyforcegraph contributors.
 # Distributed under the terms of the Modified BSD License.
diff --git a/src/ipyforcegraph/forcegraph.py b/src/ipyforcegraph/graphs.py
similarity index 71%
rename from src/ipyforcegraph/forcegraph.py
rename to src/ipyforcegraph/graphs.py
index a87095ab..ea7cf8b0 100644
--- a/src/ipyforcegraph/forcegraph.py
+++ b/src/ipyforcegraph/graphs.py
@@ -8,7 +8,8 @@
 import ipywidgets as W
 import traitlets as T
 
-from ._base import Behavior, ForceBase
+from ._base import ForceBase
+from .behaviors import Behavior
 from .sources.dataframe import DataFrameSource
 
 
@@ -26,22 +27,31 @@ class ForceGraph(W.DOMWidget, ForceBase):
     behaviors: Tuple[Behavior, ...] = W.TypedTuple(
         T.Instance(Behavior),
         kw={},
-        help="the behaviors that modify the appearance of nodes, links and the graph itself",
+        help=(
+            "the behaviors that modify the appearance of :mod:`~ipyforcegraph.behaviors.node`, "
+            ":mod:`~ipyforcegraph.behaviors.link`, and the "
+            ":mod:`~ipyforcegraph.behaviors.graph` itself"
+        ),
     ).tag(sync=True, **W.widget_serialization)
 
     default_node_color: str = T.Unicode(
         "rgba(31, 120, 179, 1.0)",
-        help="a default node color, which can be overridden by :class:`~ipyforcegraph.behaviors.NodeColors`.",
+        help="a default node color, which can be overridden by :class:`~ipyforcegraph.behaviors.node.NodeColors`.",
+    ).tag(sync=True)
+
+    default_node_size: float = T.Float(
+        1,
+        help="a default node size, which can be overridden by :class:`~ipyforcegraph.behaviors.node.NodeSizes`.",
     ).tag(sync=True)
 
     default_link_color: str = T.Unicode(
         "rgba(66, 66, 66, 0.5)",
-        help="a default link color, which can be overridden by :class:`~ipyforcegraph.behaviors.LinkColors`",
+        help="a default link color, which can be overridden by :class:`~ipyforcegraph.behaviors.link.LinkColors`",
     ).tag(sync=True)
 
     default_link_width: str = T.Float(
         1.0,
-        help="a default link width, which can be overridden by :class:`~ipyforcegraph.behaviors.LinkWidths`",
+        help="a default link width, which can be overridden by :class:`~ipyforcegraph.behaviors.link.LinkWidths`",
     ).tag(sync=True)
 
     background_color: str = T.Unicode(
diff --git a/src/ipyforcegraph/js.py b/src/ipyforcegraph/js.py
index f7fe9989..02e48776 100644
--- a/src/ipyforcegraph/js.py
+++ b/src/ipyforcegraph/js.py
@@ -1,4 +1,4 @@
-"""Client resource discovery and metadata for ipyforcegraph."""
+"""Client resource discovery and metadata for ``ipyforcegraph``."""
 
 # Copyright (c) 2023 ipyforcegraph contributors.
 # Distributed under the terms of the Modified BSD License.
diff --git a/src/ipyforcegraph/serializers.py b/src/ipyforcegraph/serializers.py
index 444af81a..8eef3634 100644
--- a/src/ipyforcegraph/serializers.py
+++ b/src/ipyforcegraph/serializers.py
@@ -6,6 +6,7 @@
 
 import ipywidgets as W
 import numcodecs as N
+import numpy as np
 import pandas as P
 import traitlets as T
 
@@ -27,7 +28,7 @@ def dataframe_to_json(
     if value is T.Undefined:
         raise T.TraitError("Cannot serialize undefined dataframe!")
 
-    df_data = value.to_dict(orient="list")
+    df_data = value.replace({np.nan: None}).to_dict(orient="list")
 
     if HAS_ORJSON:
         df_json = orjson.dumps(df_data)
diff --git a/src/ipyforcegraph/sources/__init__.py b/src/ipyforcegraph/sources/__init__.py
index 1be58b25..ddc28380 100644
--- a/src/ipyforcegraph/sources/__init__.py
+++ b/src/ipyforcegraph/sources/__init__.py
@@ -1,2 +1,11 @@
+"""Graph data sources for ``ipyforcegraph``."""
 # Copyright (c) 2023 ipyforcegraph contributors.
 # Distributed under the terms of the Modified BSD License.
+
+from .dataframe import DataFrameSource
+from .widget import WidgetSource
+
+__all__ = [
+    "DataFrameSource",
+    "WidgetSource",
+]
diff --git a/src/ipyforcegraph/sources/widget.py b/src/ipyforcegraph/sources/widget.py
new file mode 100644
index 00000000..fc31e69a
--- /dev/null
+++ b/src/ipyforcegraph/sources/widget.py
@@ -0,0 +1,239 @@
+# Copyright (c) 2023 ipyforcegraph contributors.
+# Distributed under the terms of the Modified BSD License.
+
+from typing import Any, Dict, Optional, Tuple, Type
+
+import IPython
+import ipywidgets as W
+import pandas as P
+import traitlets as T
+
+from .dataframe import DataFrameSource
+
+TAnyDict = Dict[str, Any]
+
+
+class WidgetSource(DataFrameSource):
+    """A source that displays the widgets, traits, links, and other features of one or
+    more :class:`~ipywidgets.widgets.widget.Widget` (or, technically,
+    :class:`~traitlets.HasTraits`) instances.
+    """
+
+    _shell: Optional[IPython.InteractiveShell]
+
+    observed_has_traits: Tuple[T.HasTraits, ...] = W.TypedTuple(
+        T.Instance(T.HasTraits), help="the traitleted from which to discover data"
+    )
+
+    graph_data: TAnyDict = T.Dict(help="an internal collection of observed Data").tag(
+        sync=False
+    )
+
+    ignore_classes: Tuple[Type, ...] = W.TypedTuple(
+        T.Instance(type), help="widget classes that should never be collected"
+    ).tag(sync=False)
+
+    ignore_modules: Tuple[str, ...] = W.TypedTuple(
+        T.Instance(str), help="modules from which instances should never be collected"
+    ).tag(sync=False)
+
+    ignore_traits: Tuple[str, ...] = W.TypedTuple(
+        T.Instance(str), help="names of traits that should never be collected"
+    ).tag(sync=False)
+
+    def __init__(self, widgets: Tuple[T.HasTraits, ...], **kwargs: Any):
+        """Detect IPython, then continue with normal widget initialization."""
+        self._shell = IPython.get_ipython()
+        kwargs["observed_has_traits"] = widgets
+        super().__init__(**kwargs)
+
+    @T.default("nodes")
+    def _default_nodes(self) -> P.DataFrame:
+        return P.DataFrame(self.graph_data["nodes"].values())
+
+    @T.default("links")
+    def _default_links(self) -> P.DataFrame:
+        return P.DataFrame(self.graph_data["links"].values())
+
+    @T.default("ignore_classes")
+    def _default_ignore_classes(self) -> Tuple[Type, ...]:
+        """Some core widgets that are generally not interesting to observe."""
+        return (
+            W.Layout,
+            W.ButtonStyle,
+            W.SliderStyle,
+            W.ToggleButtonsStyle,
+        )
+
+    @T.default("ignore_modules")
+    def _default_ignore_modules(self) -> Tuple[str, ...]:
+        return ("IPython", "ipykernel", "comm")
+
+    @T.default("graph_data")
+    def _default_graph_data(self) -> TAnyDict:
+        return self.find_graph_data()
+
+    @T.default("ignore_traits")
+    def _default_ignore_trats(self) -> Tuple[str, ...]:
+        return tuple(W.Widget._traits.keys())
+
+    def find_graph_data(self) -> TAnyDict:
+        graph_data: TAnyDict = {"nodes": {}, "links": {}, "widgets": set()}
+        for widget in self.observed_has_traits:
+            self.find_widget_graph_data(widget, graph_data)
+        for widget in graph_data["widgets"]:
+            self.find_trait_notifier_graph_data(widget, graph_data)
+        return graph_data
+
+    def should_discover(self, candidate: Any, graph_data: TAnyDict) -> bool:
+        """Whether the candidate is a :class:`~traitlets.HasTraits`, and has not been otherwise ignored."""
+        if not isinstance(candidate, T.HasTraits):
+            return False
+        if candidate in graph_data["widgets"]:
+            return False
+        klass = candidate.__class__
+        if any(klass.__module__.startswith(mod) for mod in self.ignore_modules):
+            return False
+        if klass in self.ignore_classes:
+            return False
+        return True
+
+    def find_widget_graph_data(self, widget: T.HasTraits, graph_data: TAnyDict) -> None:
+        """Discover a widget and its traits."""
+        node = self.add_widget_node(widget, graph_data)
+
+        for trait_name in node["traits"]:
+            self.find_trait_graph_data(trait_name, widget, graph_data)
+
+    def add_widget_node(self, widget: T.HasTraits, graph_data: TAnyDict) -> TAnyDict:
+        """Add a single widget node."""
+        trait_names = sorted(set(widget._traits.keys()) - set(self.ignore_traits))
+        name = None
+        widget_id = id(widget)
+        if self._shell is not None:
+            for var_name, value in self._shell.user_ns.items():
+                if var_name.startswith("_"):
+                    continue
+                if id(value) == widget_id:
+                    name = var_name
+                    break
+        node = {
+            "id": f"{widget_id}",
+            "name": name,
+            "type": "has_traits",
+            "py_class": widget.__class__.__name__,
+            "py_module": widget.__class__.__module__,
+            "traits": trait_names,
+        }
+
+        if isinstance(widget, W.Widget):
+            node.update(
+                {
+                    "type": "widget",
+                    "model_name": widget._model_name,
+                    "model_module": widget._model_module,
+                    "view_name": widget._view_name,
+                    "view_module": widget._view_module,
+                }
+            )
+
+        graph_data["widgets"] |= {widget}
+        graph_data["nodes"][widget_id] = node
+        return node
+
+    def find_trait_graph_data(
+        self, trait_name: str, widget: T.HasTraits, graph_data: TAnyDict
+    ) -> None:
+        widget_id = f"{id(widget)}"
+        trait_value = getattr(widget, trait_name)
+        trait_id = f"{widget_id}-{trait_name}"
+        klass = widget._traits[trait_name].__class__
+        graph_data["nodes"][trait_id] = {
+            "id": trait_id,
+            "type": "trait",
+            "name": trait_name,
+            "py_module": klass.__module__,
+            "py_class": klass.__name__,
+        }
+        graph_data["links"][trait_id] = {
+            "source": widget_id,
+            "target": trait_id,
+            "type": "has_trait",
+        }
+        if (
+            isinstance(
+                trait_value,
+                (
+                    str,
+                    int,
+                    float,
+                    P.DataFrame,
+                ),
+            )
+            or trait_value == None
+        ):
+            return
+        elif isinstance(trait_value, T.HasTraits):
+            if not self.should_discover(trait_value, graph_data):
+                return
+            graph_data["links"][f"{trait_id}-value"] = {
+                "source": trait_id,
+                "target": f"{id(trait_value)}",
+                "type": "has_trait_value",
+            }
+            self.find_widget_graph_data(trait_value, graph_data)
+        elif isinstance(trait_value, dict):
+            for key, trait_child in trait_value.items():
+                if not self.should_discover(trait_value, graph_data):
+                    continue
+                graph_data["links"][f"{trait_id}-value-{key}"] = {
+                    "source": trait_id,
+                    "target": f"{id(trait_child)}",
+                    "type": "has_trait_value",
+                    "key": f"{key}",
+                }
+                self.find_widget_graph_data(trait_child, graph_data)
+        elif isinstance(trait_value, (list, tuple, set)):
+            for i, trait_child in enumerate(trait_value):
+                if not self.should_discover(trait_child, graph_data):
+                    continue
+                graph_data["links"][f"{trait_id}-value-{i}"] = {
+                    "source": trait_id,
+                    "target": f"{id(trait_child)}",
+                    "type": "has_trait_value",
+                    "index": i,
+                }
+                self.find_widget_graph_data(trait_child, graph_data)
+
+    def find_trait_notifier_graph_data(
+        self, widget: T.HasTraits, graph_data: TAnyDict
+    ) -> None:
+        """Discover trait notifiers from the observed has_traits."""
+        for trait_name, event_notifiers in widget._trait_notifiers.items():
+            if trait_name == "comm":
+                continue
+            for event, notifiers in event_notifiers.items():
+                for i, notifier in enumerate(notifiers):
+                    try:
+                        notifier_self = notifier.__self__
+                        source_widget, source_trait = notifier_self.source
+                        target_widget, target_trait = notifier_self.target
+                    except:
+                        # these are un-characterized ObserveHandler or functions
+                        continue
+                    if (
+                        source_trait in self.ignore_traits
+                        or target_trait in self.ignore_traits
+                    ):
+                        continue
+                    source_id = f"{id(source_widget)}-{source_trait}"
+                    target_id = f"{id(target_widget)}-{target_trait}"
+                    link_id = f"{event}-{source_id}-{target_id}-{i}"
+                    if (
+                        source_id in graph_data["nodes"]
+                        and target_id in graph_data["nodes"]
+                    ):
+                        graph_data["links"][link_id] = {
+                            "source": source_id,
+                            "target": target_id,
+                        }
diff --git a/style/index.css b/style/index.css
index 9725840b..35247947 100644
--- a/style/index.css
+++ b/style/index.css
@@ -19,3 +19,23 @@
   border-color: var(--jp-border-color2);
   transition: border-color 0.5s;
 }
+
+/*
+When drag events occur, `lm-mod-override-cursor` is added to the body.
+Because iframes steal all cursor events, the following two rules are necessary
+to suppress pointer events while resize drags are occurring. There may be a
+better solution to this problem.
+*/
+body.lm-mod-override-cursor .jp-ForceGraph {
+  position: relative;
+}
+
+body.lm-mod-override-cursor .jp-ForceGraph::before {
+  content: '';
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: transparent;
+}
diff --git a/yarn.lock b/yarn.lock
index 0f5ca61c..46181d55 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1150,6 +1150,11 @@
     "@types/jquery" "*"
     "@types/underscore" "*"
 
+"@types/d3-force@^3.0.4":
+  version "3.0.4"
+  resolved "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.4.tgz#2d50bd2b695f709797e1745644f6bc123e6e5f5a"
+  integrity sha512-q7xbVLrWcXvSBBEoadowIUJ7sRpS1yvgMWnzHJggFy5cUZBq2HZL5k/pBSm0GdYWS1vs5/EDwMjSKF55PDY4Aw==
+
 "@types/dom4@^2.0.1":
   version "2.0.2"
   resolved "https://registry.npmjs.org/@types/dom4/-/dom4-2.0.2.tgz#6495303f049689ce936ed328a3e5ede9c51408ee"
@@ -1825,10 +1830,10 @@ csstype@3.0.10, csstype@^3.0.2, csstype@~3.0.3:
   dependencies:
     internmap "1 - 2"
 
-d3-binarytree@^0.2.0:
-  version "0.2.2"
-  resolved "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-0.2.2.tgz#2b3421a4ac41ddaf3ebd2584f96f8e68ecb7c444"
-  integrity sha512-TmgSEKWO4lSjX26Rk77hbTdiF3TQ1v5LqL+cmSz6/5RiSxmq6+e6qHE6X/KwrsIESekhEReH63X5yM8dvXaT7A==
+d3-binarytree@1:
+  version "1.0.2"
+  resolved "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz#ed43ebc13c70fbabfdd62df17480bc5a425753cc"
+  integrity sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==
 
 "d3-color@1 - 3", d3-color@^3.0.1:
   version "3.1.0"
@@ -1853,17 +1858,22 @@ d3-binarytree@^0.2.0:
   resolved "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4"
   integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
 
-"d3-force-3d@2 - 3":
-  version "3.0.3"
-  resolved "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.3.tgz#ddd0b9120837ef3cd6ffdad668d99d1992483af2"
-  integrity sha512-8HGTbw6y35UYManGCPU4+b9/PGgnyjzRq80DRsp7zFsRl0leVz2pzwx18dwkPe4rDxTOEpM4BuWQ2krbVaGQQA==
+"d3-force-3d@2 - 3", d3-force-3d@^3.0.5:
+  version "3.0.5"
+  resolved "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.5.tgz#9c8931b49acc3554f9110e128bc580cd3ab830f2"
+  integrity sha512-tdwhAhoTYZY/a6eo9nR7HP3xSW/C6XvJTbeRpR92nlPzH6OiE+4MliN9feuSFd0tPtEUo+191qOhCTWx3NYifg==
   dependencies:
-    d3-binarytree "^0.2.0"
+    d3-binarytree "1"
     d3-dispatch "1 - 3"
-    d3-octree "^0.2.0"
+    d3-octree "1"
     d3-quadtree "1 - 3"
     d3-timer "1 - 3"
 
+d3-force-cluster-3d@~1.1.1:
+  version "1.1.1"
+  resolved "https://registry.npmjs.org/d3-force-cluster-3d/-/d3-force-cluster-3d-1.1.1.tgz#dbe80381ac4844ee3350fcd29085c4f8850a8971"
+  integrity sha512-VlPGCf/K/wkJPGX6Pd/vBVyUe55zwciCMb46HPD7/NLIuJFLhagYE9ah8iE7MBeZQ/WVU5BX30buw0WMbGYLWw==
+
 "d3-format@1 - 3", d3-format@^3.0.1:
   version "3.1.0"
   resolved "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641"
@@ -1876,10 +1886,10 @@ d3-binarytree@^0.2.0:
   dependencies:
     d3-color "1 - 3"
 
-d3-octree@^0.2.0:
-  version "0.2.2"
-  resolved "https://registry.npmjs.org/d3-octree/-/d3-octree-0.2.2.tgz#24c513d809252d14fd3b0bf7eb5af321c30bd69b"
-  integrity sha512-ysk9uSPAhZVb0Gq4GXzghl/Yqxu80dHrq55I53qaIMdGB65+0UfO84sr4Fci2JHumcgh6H4WE0r8LwxPagkE+g==
+d3-octree@1:
+  version "1.0.2"
+  resolved "https://registry.npmjs.org/d3-octree/-/d3-octree-1.0.2.tgz#b39026b82701e45c7163e34ee056dc492035a017"
+  integrity sha512-Qxg4oirJrNXauiuC94uKMbgxwnhdda9xRLl9ihq45srlJ4Ga3CSgqGcAL8iW7N5CIv4Oz8x3E734ulxyvHPvwA==
 
 "d3-quadtree@1 - 3":
   version "3.0.1"


Test patch:
diff --git a/atest/API/01_Behaviors.robot b/atest/API/01_Behaviors.robot
index e5b4fa61..3c63c660 100644
--- a/atest/API/01_Behaviors.robot
+++ b/atest/API/01_Behaviors.robot
@@ -18,47 +18,47 @@ ${SCREENS}      ${SCREENS ROOT}${/}api
 
 *** Test Cases ***
 2D Node Select
-    [Setup]    Set Up Behavior Example    NodeSelection    ${IPYFORCGRAPH CLASS}
+    [Setup]    Set Up Behavior Example    NodeSelection    ${IPYFORCEGRAPH CLASS}
     Click IPyForceGraph Canvas
     Wait Until Tag Widget Exists    hello world
     Remove Widget Tag
     Wait Until No Tag Widgets Exist
 
 2D Link Select
-    [Setup]    Set Up Behavior Example    LinkSelection    ${IPYFORCGRAPH CLASS}
+    [Setup]    Set Up Behavior Example    LinkSelection    ${IPYFORCEGRAPH CLASS}
     Click IPyForceGraph Canvas
     Wait Until Tag Widget Exists    0
     Remove Widget Tag
     Wait Until No Tag Widgets Exist
 
 2D Node Labels
-    [Setup]    Set Up Behavior Example    NodeLabels    ${IPYFORCGRAPH CLASS}
+    [Setup]    Set Up Behavior Example    NodeLabels    ${IPYFORCEGRAPH CLASS}
     Click IPyForceGraph Canvas    text=hello world
 
 3D Node Select
-    [Setup]    Set Up Behavior Example    NodeSelection    ${IPYFORCGRAPH CLASS 3D}
+    [Setup]    Set Up Behavior Example    NodeSelection    ${IPYFORCEGRAPH CLASS 3D}
     Click IPyForceGraph Canvas
     Wait Until Tag Widget Exists    hello world
     Remove Widget Tag
     Wait Until No Tag Widgets Exist
 
 3D Link Select
-    [Setup]    Set Up Behavior Example    LinkSelection    ${IPYFORCGRAPH CLASS 3D}
+    [Setup]    Set Up Behavior Example    LinkSelection    ${IPYFORCEGRAPH CLASS 3D}
     Click IPyForceGraph Canvas
     Wait Until Tag Widget Exists    0
     Remove Widget Tag
     Wait Until No Tag Widgets Exist
 
 3D Node Labels
-    [Setup]    Set Up Behavior Example    NodeLabels    ${IPYFORCGRAPH CLASS 3D}
+    [Setup]    Set Up Behavior Example    NodeLabels    ${IPYFORCEGRAPH CLASS 3D}
     Click IPyForceGraph Canvas    text=hello world
 
 2D Graph Data
-    [Setup]    Set Up Behavior Example    GraphData    ${IPYFORCGRAPH CLASS}
+    [Setup]    Set Up Behavior Example    GraphData    ${IPYFORCEGRAPH CLASS}
     Page Should Not Contain Standard Errors    01-no-errors.png
 
 3D Graph Data
-    [Setup]    Set Up Behavior Example    GraphData    ${IPYFORCGRAPH CLASS 3D}
+    [Setup]    Set Up Behavior Example    GraphData    ${IPYFORCEGRAPH CLASS 3D}
     Page Should Not Contain Standard Errors    01-no-errors.png
 
 
diff --git a/atest/API/02_Colors.robot b/atest/API/02_Colors.robot
index 7a543e40..caeb4ae5 100644
--- a/atest/API/02_Colors.robot
+++ b/atest/API/02_Colors.robot
@@ -49,7 +49,7 @@ Color Feature Works As Expected
     Wait For All Cells To Run
     Sleep    1s
     ${color} =    Get Element Screenshot Size    ${frame}    ${screens}    02-color.png
-    Should Be True    ${color} > ${transparent}
+    Should Be True Or Screenshot    ${color} > ${transparent}    03-color-bigger-than-transparent.png
     [Teardown]    Clean Up Color Example
 
 Set Up Color Example
diff --git a/atest/API/02_Colors.robot.j2 b/atest/API/02_Colors.robot.j2
index 2b3df91f..72eecfb7 100644
--- a/atest/API/02_Colors.robot.j2
+++ b/atest/API/02_Colors.robot.j2
@@ -37,7 +37,7 @@ Color Feature Works As Expected
     Wait For All Cells To Run
     Sleep    1s
     ${color} =   Get Element Screenshot Size    ${frame}    ${screens}    02-color.png
-    Should Be True    ${color} > ${transparent}
+    Should Be True Or Screenshot    ${color} > ${transparent}  03-color-bigger-than-transparent.png
     [Teardown]    Clean Up Color Example
 
 Set Up Color Example
diff --git a/atest/Notebooks/04_Force.robot b/atest/Notebooks/04_Force.robot
new file mode 100644
index 00000000..84ab347d
--- /dev/null
+++ b/atest/Notebooks/04_Force.robot
@@ -0,0 +1,24 @@
+*** Settings ***
+Resource            ../_resources/keywords/Browser.robot
+Resource            ../_resources/keywords/Lab.robot
+Resource            ../_resources/keywords/IPyForceGraph.robot
+Library             Collections
+
+Test Teardown       Clean up after IPyForceGraph Example
+
+Test Tags           data:miserables    widget:forcegraph    behavior:forces
+
+
+*** Variables ***
+${SCREENS}      ${SCREENS ROOT}${/}forces
+
+
+*** Test Cases ***
+Forces
+    Example Should Restart-and-Run-All    ${FORCES}
+
+Forces Test
+    Maybe Skip A Test    example=${FORCES_TEST}
+    Example Should Restart-and-Run-All    ${FORCES_TEST}
+    Wait Until Page Contains    Tests Completed    timeout=120s
+    Page Should Not Contain    💥
diff --git a/atest/Notebooks/05_WidgetSource.robot b/atest/Notebooks/05_WidgetSource.robot
new file mode 100644
index 00000000..11629701
--- /dev/null
+++ b/atest/Notebooks/05_WidgetSource.robot
@@ -0,0 +1,18 @@
+*** Settings ***
+Resource            ../_resources/keywords/Browser.robot
+Resource            ../_resources/keywords/Lab.robot
+Resource            ../_resources/keywords/IPyForceGraph.robot
+Library             Collections
+
+Test Teardown       Clean up after IPyForceGraph Example
+
+Test Tags           widget:forcegraph    source:widget
+
+
+*** Variables ***
+${SCREENS}      ${SCREENS ROOT}${/}widget-source
+
+
+*** Test Cases ***
+Forces
+    Example Should Restart-and-Run-All    ${WIDGET_SOURCE}
diff --git a/atest/_libraries/Ports.py b/atest/_libraries/Ports.py
index 439227b6..c08cf490 100644
--- a/atest/_libraries/Ports.py
+++ b/atest/_libraries/Ports.py
@@ -1,5 +1,4 @@
-""" get a random port
-"""
+"""get a random port"""
 import socket
 
 
diff --git a/atest/_resources/fixtures/api/Colors.py b/atest/_resources/fixtures/api/Colors.py
index 2095789c..74b37f4e 100644
--- a/atest/_resources/fixtures/api/Colors.py
+++ b/atest/_resources/fixtures/api/Colors.py
@@ -1,8 +1,8 @@
-from ipyforcegraph.forcegraph import WIDGET_CLASS
-import pandas as pd
+from ipyforcegraph.graphs import WIDGET_CLASS
 fg = WIDGET_CLASS()
-fg.source.nodes = pd.DataFrame([{"id": "hello"}, {"id": "world"}])
-fg.source.links = pd.DataFrame([{"source": "hello", "target": "world"}])
+display(fg)
+fg.source.nodes = [{"id": "hello"}, {"id": "world"}]
+fg.source.links = [{"source": "hello", "target": "world"}]
 transparent = "rgba(0,0,0,0.0)"
+fg.default_link_width = fg.default_node_size = 10
 fg.default_node_color = fg.default_link_color = fg.background_color = transparent
-fg
diff --git a/atest/_resources/fixtures/api/GraphData.py b/atest/_resources/fixtures/api/GraphData.py
index 706b139a..3cff7901 100644
--- a/atest/_resources/fixtures/api/GraphData.py
+++ b/atest/_resources/fixtures/api/GraphData.py
@@ -1,14 +1,13 @@
-from ipyforcegraph.forcegraph import WIDGET_CLASS
+from ipyforcegraph.graphs import WIDGET_CLASS
 from ipyforcegraph import behaviors as B
-import pandas as pd
 import asyncio
 g = B.GraphData(column_name="id")
 fg = WIDGET_CLASS(behaviors=[g])
 display(fg)
 g.capturing = True
 await asyncio.sleep(1)
-fg.source.nodes = pd.DataFrame([{"id": "hello"}, {"id": "world"}])
-fg.source.links = pd.DataFrame([{"source": "hello", "target": "world"}])
+fg.source.nodes = [{"id": "hello"}, {"id": "world"}]
+fg.source.links = [{"source": "hello", "target": "world"}]
 await asyncio.sleep(1)
 display(g.sources[0].links, g.sources[0].nodes)
 assert 0 not in g.sources[0].links.shape
diff --git a/atest/_resources/fixtures/api/LinkSelection.py b/atest/_resources/fixtures/api/LinkSelection.py
index 16c58ba9..d83524d2 100644
--- a/atest/_resources/fixtures/api/LinkSelection.py
+++ b/atest/_resources/fixtures/api/LinkSelection.py
@@ -1,11 +1,11 @@
-import traitlets as T, ipywidgets as W, pandas as pd
-from ipyforcegraph.forcegraph import WIDGET_CLASS
+import traitlets as T, ipywidgets as W
+from ipyforcegraph.graphs import WIDGET_CLASS
 from ipyforcegraph import behaviors as B
 
 b = B.LinkSelection()
 fg = WIDGET_CLASS(behaviors=[b], default_link_width=10)
-fg.source.nodes = pd.DataFrame([{"id": "hello"}, {"id": "world"}])
-fg.source.links = pd.DataFrame([{"source": "hello", "target": "world"}])
+fg.source.nodes = [{"id": "hello"}, {"id": "world"}]
+fg.source.links = [{"source": "hello", "target": "world"}]
 t = W.IntsInput(allowed_tags=[0])
 T.link((b, "selected"), (t, "value"))
 W.VBox([t, fg])
diff --git a/atest/_resources/fixtures/api/NodeLabels.py b/atest/_resources/fixtures/api/NodeLabels.py
index d3f1d5e7..65c0193d 100644
--- a/atest/_resources/fixtures/api/NodeLabels.py
+++ b/atest/_resources/fixtures/api/NodeLabels.py
@@ -1,6 +1,5 @@
-from ipyforcegraph.forcegraph import WIDGET_CLASS
+from ipyforcegraph.graphs import WIDGET_CLASS
 from ipyforcegraph import behaviors as B
-import pandas as pd
 fg = WIDGET_CLASS(behaviors=[B.NodeLabels(column_name="id")])
-fg.source.nodes = pd.DataFrame([{"id": "hello world"}])
+fg.source.nodes = [{"id": "hello world"}]
 fg
diff --git a/atest/_resources/fixtures/api/NodeSelection.py b/atest/_resources/fixtures/api/NodeSelection.py
index 78db045c..27549c83 100644
--- a/atest/_resources/fixtures/api/NodeSelection.py
+++ b/atest/_resources/fixtures/api/NodeSelection.py
@@ -1,10 +1,10 @@
-import traitlets as T, ipywidgets as W, pandas as pd
-from ipyforcegraph.forcegraph import WIDGET_CLASS
+import traitlets as T, ipywidgets as W
+from ipyforcegraph.graphs import WIDGET_CLASS
 from ipyforcegraph import behaviors as B
 
 b = B.NodeSelection()
 fg = WIDGET_CLASS(behaviors=[b])
-fg.source.nodes = pd.DataFrame([{"id": "hello world"}])
+fg.source.nodes = [{"id": "hello world"}]
 t = W.TagsInput(allowed_tags=sorted(fg.source.nodes.id))
 T.link((b, "selected"), (t, "value"))
 W.VBox([t, fg])
diff --git a/atest/_resources/keywords/IPyForceGraph.robot b/atest/_resources/keywords/IPyForceGraph.robot
index 5e2072b7..814edecb 100644
--- a/atest/_resources/keywords/IPyForceGraph.robot
+++ b/atest/_resources/keywords/IPyForceGraph.robot
@@ -77,6 +77,7 @@ Clean up after IPyForceGraph Example
     ${data} =    Get All IPyForceGraph Example Data Names
     Clean up after Working With Files    @{files}
     Clean up after Working With Files    @{data}
+    Gently Reset Workspace
 
 Click IPyForceGraph Canvas
     [Arguments]    ${x}=0    ${y}=0    ${text}=${EMPTY}
@@ -107,10 +108,15 @@ Wait Until No Tag Widgets Exist
 
 Maybe Skip A Test
     [Documentation]    Capture common reasons for skipping tests
-    [Arguments]    ${widget_class}=${EMPTY}
+    [Arguments]    ${widget_class}=${EMPTY}    ${example}=${EMPTY}
     IF    "${OS}" == "Darwin"
-        IF    "${widget_class}" == "${IPYFORCGRAPH CLASS 3D}"
+        IF    "${widget_class}" == "${IPYFORCEGRAPH CLASS 3D}"
             Pass Execution    Can't test 3d canvas on MacOS
             ...    skip:darwin:3d
         END
+    ELSE IF    "${OS}" == "Windows"
+        IF    "${example}" == "${FORCES_TEST}"
+            Pass Execution    Windows asyncio issues
+            ...    skip:windows:asyncio
+        END
     END
diff --git a/atest/_resources/keywords/Lab.robot b/atest/_resources/keywords/Lab.robot
index da863f7a..fbe77239 100644
--- a/atest/_resources/keywords/Lab.robot
+++ b/atest/_resources/keywords/Lab.robot
@@ -98,7 +98,6 @@ Clean Up After Working With Files
         ${src}    ${name} =    Split Path    ${file}
         Remove File    ${nb_dir}${/}${name}
     END
-    Maybe Reset Application State
 
 Wait For Dialog
     Wait Until Page Contains Element    ${DIALOG WINDOW}    timeout=180s
diff --git a/atest/_resources/keywords/Screenshots.robot b/atest/_resources/keywords/Screenshots.robot
index 8a4490d4..0b5420ef 100644
--- a/atest/_resources/keywords/Screenshots.robot
+++ b/atest/_resources/keywords/Screenshots.robot
@@ -5,3 +5,10 @@ Get Element Screenshot Size
     ...    Capture Element Screenshot    ${selector}    ${screen}
     ${size} =    Get File Size    ${screens}${/}${screen}
     RETURN    ${size}
+
+Should Be True Or Screenshot
+    [Arguments]    ${expr}    ${screenshot}
+    IF    not ${expr}
+        Capture Page Screenshot    ${screenshot}
+        Should Be True    ${expr}
+    END
diff --git a/atest/_resources/keywords/Server.robot b/atest/_resources/keywords/Server.robot
index dc55e22a..93f12f9c 100644
--- a/atest/_resources/keywords/Server.robot
+++ b/atest/_resources/keywords/Server.robot
@@ -13,7 +13,7 @@ Setup Server and Browser
     ${nb_dir} =    Initialize Jupyter Directory
     Wait For New Jupyter Server To Be Ready    jupyter-lab    notebook_dir=${nb_dir}
     ...    cwd=${nb_dir}
-    ...    stdout=${nb_dir}${/}jupyter-lab.log
+    ...    stdout=${OUTPUT_DIR}${/}jupyter-lab.log
     ...    stderr=STDOUT
     Open JupyterLab
     Set Window Size    1920    1080
@@ -39,4 +39,4 @@ Initialize Jupyter Directory
 
 Tear Down Everything
     Close All Browsers
-    Terminate All Jupyter Servers
+    Run Keyword and Ignore Error    Terminate All Jupyter Servers
diff --git a/atest/_resources/variables/IPyForceGraph.robot b/atest/_resources/variables/IPyForceGraph.robot
index 643a2655..24ddc1b6 100644
--- a/atest/_resources/variables/IPyForceGraph.robot
+++ b/atest/_resources/variables/IPyForceGraph.robot
@@ -1,11 +1,14 @@
 *** Variables ***
-${INDEX}                    _index
-${BEHAVIORS}                Behaviors
-${THREEDEE}                 3D
+${INDEX}                        _index
+${BEHAVIORS}                    Behaviors
+${THREEDEE}                     3D
+${FORCES}                       Forces
+${FORCES_TEST}                  Test_Forces
+${WIDGET_SOURCE}                WidgetSource
 
-${IPYFORCEGRAPH GRAPH}      .jp-ForceGraph
-${IPYFORCEGRAPH FRAME}      ${IPYFORCEGRAPH GRAPH} iframe
-${IPYFORCEGRAPH TOOLTIP}    .graph-tooltip,.scene-tooltip
+${IPYFORCEGRAPH GRAPH}          .jp-ForceGraph
+${IPYFORCEGRAPH FRAME}          ${IPYFORCEGRAPH GRAPH} iframe
+${IPYFORCEGRAPH TOOLTIP}        .graph-tooltip,.scene-tooltip
 
-${IPYFORCGRAPH CLASS}       ForceGraph
-${IPYFORCGRAPH CLASS 3D}    ForceGraph3D
+${IPYFORCEGRAPH CLASS}          ForceGraph
+${IPYFORCEGRAPH CLASS 3D}       ForceGraph3D
diff --git a/examples/future/_testing.ipynb b/examples/future/_testing.ipynb
new file mode 100644
index 00000000..ddc00c40
--- /dev/null
+++ b/examples/future/_testing.ipynb
@@ -0,0 +1,261 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "fbee713c-58e8-4383-bc28-219bfe1cd0d9",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "70bfecf6-248f-4b18-abce-1be0da67a828",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "application/vnd.jupyter.widget-view+json": {
+       "model_id": "dcdda4730eb74e269434317f3ebe6ce8",
+       "version_major": 2,
+       "version_minor": 0
+      },
+      "text/plain": [
+       "HBox(children=(ForceGraph(behaviors=(NodeSelection(),), layout=Layout(height='500px'), source=DataFrameSource(…"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "import json\n",
+    "from pathlib import Path\n",
+    "\n",
+    "import ipywidgets as W\n",
+    "\n",
+    "from ipyforcegraph.graphs import DataFrameSource, ForceGraph\n",
+    "import ipyforcegraph.behaviors as B\n",
+    "\n",
+    "def make_a_simple_example(\n",
+    "    source=None, dataset=\"datasets/miserables.json\", GraphClass=ForceGraph, node_color=None,\n",
+    "):\n",
+    "    if source is None:\n",
+    "        data = json.loads(Path(dataset).read_text())\n",
+    "        source = DataFrameSource(**data)\n",
+    "        if node_color:\n",
+    "            source.nodes[\"color\"] = node_color\n",
+    "    \n",
+    "    selection = B.NodeSelection()\n",
+    "    fg = GraphClass(\n",
+    "        source=source,\n",
+    "        layout=dict(height=\"500px\"),\n",
+    "        behaviors=(\n",
+    "            selection,\n",
+    "        )\n",
+    "    )\n",
+    "    box = W.HBox([fg, W.VBox([])])\n",
+    "    return fg, box\n",
+    "\n",
+    "if __name__ == \"__main__\":\n",
+    "    fg, box = make_a_simple_example(node_color=\"red\")\n",
+    "    display(box)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "id": "f08a2ba3-de0a-43a4-bd4e-959c1b98e665",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "NodeSelection()"
+      ]
+     },
+     "execution_count": 6,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "B.NodeSelection()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "4eb99d3a-e507-40fe-8f66-e81a77789c06",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "id": "7e4ea28e-3ddc-45f9-ad7c-835604ff31e1",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "behaviors = [\n",
+    "    B.add_node_selection,\n",
+    "]\n",
+    "for add_behavior in behaviors:\n",
+    "    add_behavior(fg, box)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "id": "dbe05dd4-31ae-4b2c-a77f-bb129c9db5a8",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<div>\n",
+       "<style scoped>\n",
+       "    .dataframe tbody tr th:only-of-type {\n",
+       "        vertical-align: middle;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe tbody tr th {\n",
+       "        vertical-align: top;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe thead th {\n",
+       "        text-align: right;\n",
+       "    }\n",
+       "</style>\n",
+       "<table border=\"1\" class=\"dataframe\">\n",
+       "  <thead>\n",
+       "    <tr style=\"text-align: right;\">\n",
+       "      <th></th>\n",
+       "      <th>id</th>\n",
+       "      <th>group</th>\n",
+       "      <th>color</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th>0</th>\n",
+       "      <td>Myriel</td>\n",
+       "      <td>1</td>\n",
+       "      <td>red</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>1</th>\n",
+       "      <td>Napoleon</td>\n",
+       "      <td>1</td>\n",
+       "      <td>red</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>2</th>\n",
+       "      <td>Mlle.Baptistine</td>\n",
+       "      <td>1</td>\n",
+       "      <td>red</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>3</th>\n",
+       "      <td>Mme.Magloire</td>\n",
+       "      <td>1</td>\n",
+       "      <td>red</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>4</th>\n",
+       "      <td>CountessdeLo</td>\n",
+       "      <td>1</td>\n",
+       "      <td>red</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>...</th>\n",
+       "      <td>...</td>\n",
+       "      <td>...</td>\n",
+       "      <td>...</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>72</th>\n",
+       "      <td>Toussaint</td>\n",
+       "      <td>5</td>\n",
+       "      <td>red</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>73</th>\n",
+       "      <td>Child1</td>\n",
+       "      <td>10</td>\n",
+       "      <td>red</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>74</th>\n",
+       "      <td>Child2</td>\n",
+       "      <td>10</td>\n",
+       "      <td>red</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>75</th>\n",
+       "      <td>Brujon</td>\n",
+       "      <td>4</td>\n",
+       "      <td>red</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>76</th>\n",
+       "      <td>Mme.Hucheloup</td>\n",
+       "      <td>8</td>\n",
+       "      <td>red</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n",
+       "<p>77 rows × 3 columns</p>\n",
+       "</div>"
+      ],
+      "text/plain": [
+       "                 id  group color\n",
+       "0            Myriel      1   red\n",
+       "1          Napoleon      1   red\n",
+       "2   Mlle.Baptistine      1   red\n",
+       "3      Mme.Magloire      1   red\n",
+       "4      CountessdeLo      1   red\n",
+       "..              ...    ...   ...\n",
+       "72        Toussaint      5   red\n",
+       "73           Child1     10   red\n",
+       "74           Child2     10   red\n",
+       "75           Brujon      4   red\n",
+       "76    Mme.Hucheloup      8   red\n",
+       "\n",
+       "[77 rows x 3 columns]"
+      ]
+     },
+     "execution_count": 6,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "source.nodes"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.11.0"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/scripts/atest.py b/scripts/atest.py
index 15234323..f2bcb45d 100644
--- a/scripts/atest.py
+++ b/scripts/atest.py
@@ -1,5 +1,4 @@
-""" Run acceptance tests with robot framework
-"""
+"""Run acceptance tests with robot framework"""
 
 # Copyright (c) 2023 ipyforcegraph contributors.
 # Distributed under the terms of the Modified BSD License.
diff --git a/src/ipyforcegraph/tests/test_widget_model_meta.py b/src/ipyforcegraph/tests/test_widget_model_meta.py
index 6412bd34..5237e5fe 100644
--- a/src/ipyforcegraph/tests/test_widget_model_meta.py
+++ b/src/ipyforcegraph/tests/test_widget_model_meta.py
@@ -7,19 +7,28 @@
 import ipywidgets as W
 import pytest
 
-from ipyforcegraph import behaviors, forcegraph
+from ipyforcegraph import behaviors, graphs
 from ipyforcegraph._base import ForceBase
+from ipyforcegraph.behaviors import forces
+from ipyforcegraph.sources.widget import WidgetSource
 
 TSubclassSet = Set[Type[W.Widget]]
 TSubclassData = Dict[Type[W.Widget], str]
 
+#: the bases we'll walk to find subclasses
+SUBCLASS_BASES = {ForceBase}
+
+#: these reuse the upstream data model
+PURE_PY_SUBCLASSES = {WidgetSource}
+
 
 @pytest.fixture
 def widget_subclasses() -> TSubclassSet:
     assert behaviors
-    assert forcegraph
+    assert forces
+    assert graphs
 
-    subclasses = set([ForceBase])
+    subclasses = SUBCLASS_BASES
     subclass_count = -1
 
     while len(subclasses) != subclass_count:
@@ -29,7 +38,7 @@ def widget_subclasses() -> TSubclassSet:
         )
 
     assert subclasses
-    return subclasses
+    return subclasses - PURE_PY_SUBCLASSES
 
 
 @pytest.fixture
diff --git a/src/ipyforcegraph/tests/test_widget_source.py b/src/ipyforcegraph/tests/test_widget_source.py
new file mode 100644
index 00000000..c38e6c18
--- /dev/null
+++ b/src/ipyforcegraph/tests/test_widget_source.py
@@ -0,0 +1,13 @@
+# Copyright (c) 2023 ipyforcegraph contributors.
+# Distributed under the terms of the Modified BSD License.
+
+import ipywidgets as W
+
+from ..sources.widget import WidgetSource
+
+
+def test_widget_source() -> None:
+    slider = W.FloatSlider()
+    src = WidgetSource((slider,))
+    assert src.nodes.shape == (18, 10)
+    assert src.links.shape == (17, 3)