diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3abcfaaf0..5fe2f65fe 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,10 +22,10 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v3
- - name: Set up Python 3.9
+ - name: Set up Python 3.11
uses: actions/setup-python@v4
with:
- python-version: 3.9
+ python-version: 3.11
- name: Install llvmpipe and lavapipe for offscreen canvas, and git lfs
run: |
sudo apt-get update -y -qq
@@ -36,18 +36,18 @@ jobs:
sudo apt-get install ./pandoc-3.1.4-1-amd64.deb
- name: Install dev dependencies
run: |
- python -m pip install --upgrade pip
- # remove pygfx from requirements, we install a specific commit of pygfx since both fpl and pygfx are fast evolving
+ python -m pip install --upgrade pip setuptools
+ # remove pygfx from install_requires, we install using pygfx@main
sed -i "/pygfx/d" ./setup.py
- pip install git+https://github.com/pygfx/pygfx.git@cf1da6c32223ba3cf7256982ce9b89c81b593076
+ pip install git+https://github.com/pygfx/pygfx.git@main
pip install -e ".[notebook,docs,tests]"
- name: Build docs
run: |
cd docs
make html SPHINXOPTS="-W --keep-going"
- test-build:
- name: Test examples
+ test-build-full:
+ name: Test examples, env with notebook and glfw
runs-on: ubuntu-latest
if: ${{ !github.event.pull_request.draft }}
strategy:
@@ -60,6 +60,8 @@ jobs:
pyversion: '3.10'
- name: Test py311
pyversion: '3.11'
+ - name: Test py312
+ pyversion: '3.12'
steps:
- name: Install git-lfs
run: |
@@ -75,10 +77,10 @@ jobs:
sudo apt-get install --no-install-recommends -y libegl1-mesa libgl1-mesa-dri libxcb-xfixes0-dev mesa-vulkan-drivers git-lfs
- name: Install dev dependencies
run: |
- python -m pip install --upgrade pip
- # remove pygfx from requirements, we install a specific commit of pygfx since both fpl and pygfx are fast evolving
+ python -m pip install --upgrade pip setuptools
+ # remove pygfx from install_requires, we install using pygfx@main
sed -i "/pygfx/d" ./setup.py
- pip install git+https://github.com/pygfx/pygfx.git@cf1da6c32223ba3cf7256982ce9b89c81b593076
+ pip install git+https://github.com/pygfx/pygfx.git@main
pip install -e ".["tests"]"
- name: Show wgpu backend
run:
@@ -100,3 +102,58 @@ jobs:
path: |
examples/desktop/diffs
examples/notebooks/diffs
+
+ test-build-desktop:
+ name: Test examples, env with only glfw
+ runs-on: ubuntu-latest
+ if: ${{ !github.event.pull_request.draft }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - name: Test py39
+ pyversion: '3.9'
+ - name: Test py310
+ pyversion: '3.10'
+ - name: Test py311
+ pyversion: '3.11'
+ - name: Test py312
+ pyversion: '3.12'
+ steps:
+ - name: Install git-lfs
+ run: |
+ sudo apt install --no-install-recommends -y git-lfs
+ - uses: actions/checkout@v3
+ - name: Set up Python
+ uses: actions/setup-python@v3
+ with:
+ python-version: ${{ matrix.pyversion }}
+ - name: Install llvmpipe and lavapipe for offscreen canvas
+ run: |
+ sudo apt-get update -y -qq
+ sudo apt-get install --no-install-recommends -y libegl1-mesa libgl1-mesa-dri libxcb-xfixes0-dev mesa-vulkan-drivers git-lfs
+ - name: Install dev dependencies
+ run: |
+ python -m pip install --upgrade pip setuptools
+ # remove pygfx from install_requires, we install using pygfx@main
+ sed -i "/pygfx/d" ./setup.py
+ pip install git+https://github.com/pygfx/pygfx.git@main
+ pip install -e ".["tests-desktop"]"
+ - name: Show wgpu backend
+ run:
+ python -c "from examples.tests.testutils import wgpu_backend; print(wgpu_backend)"
+ - name: fetch git lfs files
+ run: |
+ git lfs fetch --all
+ git lfs pull
+ - name: Test examples
+ env:
+ PYGFX_EXPECT_LAVAPIPE: true
+ run: |
+ pytest -v examples
+ - uses: actions/upload-artifact@v3
+ if: ${{ failure() }}
+ with:
+ name: screenshot-diffs
+ path: |
+ examples/desktop/diffs
diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml
index ec703542b..207d92351 100644
--- a/.github/workflows/pypi-publish.yml
+++ b/.github/workflows/pypi-publish.yml
@@ -25,7 +25,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v3
with:
- python-version: '3.x'
+ python-version: '3.10'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml
index 488ad108f..d4cfb94d3 100644
--- a/.github/workflows/screenshots.yml
+++ b/.github/workflows/screenshots.yml
@@ -20,20 +20,20 @@ jobs:
run: |
sudo apt install --no-install-recommends -y git-lfs
- uses: actions/checkout@v3
- - name: Set up Python 3.10
+ - name: Set up Python 3.11
uses: actions/setup-python@v4
with:
- python-version: '3.10'
+ python-version: '3.11'
- name: Install llvmpipe and lavapipe for offscreen canvas
run: |
sudo apt-get update -y -qq
sudo apt-get install --no-install-recommends -y libegl1-mesa libgl1-mesa-dri libxcb-xfixes0-dev mesa-vulkan-drivers
- name: Install dev dependencies
run: |
- python -m pip install --upgrade pip
- # remove pygfx from requirements, we install a specific commit of pygfx since both fpl and pygfx are fast evolving
+ python -m pip install --upgrade pip setuptools
+ # remove pygfx from install_requires, we install using pygfx@main
sed -i "/pygfx/d" ./setup.py
- pip install git+https://github.com/pygfx/pygfx.git@b63f22a1aa61993c32cd96895316cb8248a81e4d
+ pip install git+https://github.com/pygfx/pygfx.git@main
pip install -e ".["tests"]"
- name: Show wgpu backend
run:
diff --git a/README.md b/README.md
index dccd8196b..ae03ea13b 100644
--- a/README.md
+++ b/README.md
@@ -4,13 +4,19 @@
[](https://fastplotlib.readthedocs.io/en/latest/?badge=latest)
[](https://gitter.im/fastplotlib/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
-[**Installation**](https://github.com/kushalkolar/fastplotlib#installation) | [**GPU Drivers**](https://github.com/kushalkolar/fastplotlib#graphics-drivers) | [**Examples**](https://github.com/kushalkolar/fastplotlib#examples) | [**Contributing**](https://github.com/kushalkolar/fastplotlib#heart-contributing)
+[**Installation**](https://github.com/kushalkolar/fastplotlib#installation) |
+[**GPU Drivers**](https://github.com/kushalkolar/fastplotlib#graphics-drivers) |
+[**Examples**](https://github.com/kushalkolar/fastplotlib#examples) |
+[**Contributing**](https://github.com/kushalkolar/fastplotlib#heart-contributing)
A fast plotting library built using the [`pygfx`](https://github.com/pygfx/pygfx) render engine utilizing [Vulkan](https://en.wikipedia.org/wiki/Vulkan), [DX12](https://en.wikipedia.org/wiki/DirectX#DirectX_12), or [Metal](https://developer.apple.com/metal/) via WGPU, so it is very fast! We also aim to be an expressive plotting library that enables rapid prototyping for large scale explorative scientific visualization.
-
+
+
+### SciPy Talk
+
+[](https://www.youtube.com/watch?v=Q-UJpAqljsU)
-Higher resolution demo: [https://github.com/kushalkolar/fastplotlib/assets/9403332/1df06d4d-9a7e-4f0d-aad8-8d2e9b387647](https://github.com/kushalkolar/fastplotlib/assets/9403332/1df06d4d-9a7e-4f0d-aad8-8d2e9b387647)
# Supported frameworks
@@ -139,12 +145,6 @@ plot.show()

-### Image widget
-
-Interactive visualization of large imaging datasets in the notebook.
-
-
-
## Graphics drivers
You will need a relatively modern GPU (newer integrated GPUs in CPUs are usually fine). Generally if your GPU is from 2017 or later it should be fine.
diff --git a/docs/source/quickstart.ipynb b/docs/source/quickstart.ipynb
index aebe04b25..6a3afec33 100644
--- a/docs/source/quickstart.ipynb
+++ b/docs/source/quickstart.ipynb
@@ -599,7 +599,7 @@
"plot_v.add_image(data=data, name=\"random-image\")\n",
"\n",
"# a function to update the image_graphic\n",
- "# a plot will pass its plot instance to the animation function as an arugment\n",
+ "# a plot will pass its plot instance to the animation function as an argument\n",
"def update_data(plot_instance):\n",
" new_data = np.random.rand(512, 512)\n",
" plot_instance[\"random-image\"].data = new_data\n",
@@ -1073,7 +1073,7 @@
"\n",
"plot_l.add_image(img, name=\"image\", cmap=\"gray\")\n",
"\n",
- "# z axix position -1 so it is below all the lines\n",
+ "# z axis position -1 so it is below all the lines\n",
"plot_l[\"image\"].position_z = -1\n",
"plot_l[\"image\"].position_x = -50"
]
diff --git a/examples/desktop/line/line_colorslice.py b/examples/desktop/line/line_colorslice.py
index f757a7efe..f2aca8125 100644
--- a/examples/desktop/line/line_colorslice.py
+++ b/examples/desktop/line/line_colorslice.py
@@ -62,7 +62,6 @@
plot.auto_scale()
-img = np.asarray(plot.renderer.target.draw())
if __name__ == "__main__":
print(__doc__)
diff --git a/examples/desktop/line/line_dataslice.py b/examples/desktop/line/line_dataslice.py
index ef3cccfe8..ea87ba552 100644
--- a/examples/desktop/line/line_dataslice.py
+++ b/examples/desktop/line/line_dataslice.py
@@ -51,7 +51,6 @@
plot.auto_scale()
-img = np.asarray(plot.renderer.target.draw())
if __name__ == "__main__":
print(__doc__)
diff --git a/examples/desktop/line/line_present_scaling.py b/examples/desktop/line/line_present_scaling.py
index b8e9be63c..327186c16 100644
--- a/examples/desktop/line/line_present_scaling.py
+++ b/examples/desktop/line/line_present_scaling.py
@@ -45,7 +45,6 @@
plot.auto_scale()
-img = np.asarray(plot.renderer.target.draw())
if __name__ == "__main__":
print(__doc__)
diff --git a/examples/desktop/scatter/scatter.py b/examples/desktop/scatter/scatter.py
index 243924035..778f37deb 100644
--- a/examples/desktop/scatter/scatter.py
+++ b/examples/desktop/scatter/scatter.py
@@ -28,7 +28,6 @@
plot.auto_scale()
-img = np.asarray(plot.renderer.target.draw())
if __name__ == "__main__":
print(__doc__)
diff --git a/examples/desktop/scatter/scatter_cmap.py b/examples/desktop/scatter/scatter_cmap.py
index ae113537a..edc55a4b1 100644
--- a/examples/desktop/scatter/scatter_cmap.py
+++ b/examples/desktop/scatter/scatter_cmap.py
@@ -41,7 +41,6 @@
scatter_graphic.cmap = "tab10"
-# img = np.asarray(plot.renderer.target.draw())
if __name__ == "__main__":
print(__doc__)
diff --git a/examples/desktop/scatter/scatter_colorslice.py b/examples/desktop/scatter/scatter_colorslice.py
index f5f32f5be..d752cacbd 100644
--- a/examples/desktop/scatter/scatter_colorslice.py
+++ b/examples/desktop/scatter/scatter_colorslice.py
@@ -33,7 +33,6 @@
scatter_graphic.colors[75:150] = "white"
scatter_graphic.colors[::2] = "blue"
-img = np.asarray(plot.renderer.target.draw())
if __name__ == "__main__":
print(__doc__)
diff --git a/examples/desktop/scatter/scatter_dataslice.py b/examples/desktop/scatter/scatter_dataslice.py
index 7b80d6c9e..22c495bff 100644
--- a/examples/desktop/scatter/scatter_dataslice.py
+++ b/examples/desktop/scatter/scatter_dataslice.py
@@ -36,7 +36,6 @@
scatter_graphic.data[10:15] = scatter_graphic.data[0:5] + np.array([1, 1, 1])
scatter_graphic.data[50:100:2] = scatter_graphic.data[100:150:2] + np.array([1,1,0])
-img = np.asarray(plot.renderer.target.draw())
if __name__ == "__main__":
print(__doc__)
diff --git a/examples/desktop/scatter/scatter_present.py b/examples/desktop/scatter/scatter_present.py
index fe0a3bf4f..ad4be837f 100644
--- a/examples/desktop/scatter/scatter_present.py
+++ b/examples/desktop/scatter/scatter_present.py
@@ -32,7 +32,6 @@
scatter_graphic.present = False
-img = np.asarray(plot.renderer.target.draw())
if __name__ == "__main__":
print(__doc__)
diff --git a/examples/desktop/scatter/scatter_size.py b/examples/desktop/scatter/scatter_size.py
new file mode 100644
index 000000000..5b6987b7c
--- /dev/null
+++ b/examples/desktop/scatter/scatter_size.py
@@ -0,0 +1,56 @@
+"""
+Scatter Plot
+============
+Example showing point size change for scatter plot.
+"""
+
+# test_example = true
+import numpy as np
+import fastplotlib as fpl
+
+# grid with 2 rows and 3 columns
+grid_shape = (2,1)
+
+# pan-zoom controllers for each view
+# views are synced if they have the
+# same controller ID
+controllers = [
+ [0],
+ [0]
+]
+
+
+# you can give string names for each subplot within the gridplot
+names = [
+ ["scalar_size"],
+ ["array_size"]
+]
+
+# Create the grid plot
+plot = fpl.GridPlot(
+ shape=grid_shape,
+ controllers=controllers,
+ names=names,
+ size=(1000, 1000)
+)
+
+# get y_values using sin function
+angles = np.arange(0, 20*np.pi+0.001, np.pi / 20)
+y_values = 30*np.sin(angles) # 1 thousand points
+x_values = np.array([x for x in range(len(y_values))], dtype=np.float32)
+
+data = np.column_stack([x_values, y_values])
+
+plot["scalar_size"].add_scatter(data=data, sizes=5, colors="blue") # add a set of scalar sizes
+
+non_scalar_sizes = np.abs((y_values / np.pi)) # ensure minimum size of 5
+plot["array_size"].add_scatter(data=data, sizes=non_scalar_sizes, colors="red")
+
+for graph in plot:
+ graph.auto_scale(maintain_aspect=True)
+
+plot.show()
+
+if __name__ == "__main__":
+ print(__doc__)
+ fpl.run()
\ No newline at end of file
diff --git a/examples/desktop/screenshots/scatter_size.png b/examples/desktop/screenshots/scatter_size.png
new file mode 100644
index 000000000..db637d270
--- /dev/null
+++ b/examples/desktop/screenshots/scatter_size.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a4cefd4cf57e54e1ef7883edea54806dfde57939d0a395c5a7758124e41b8beb
+size 63485
diff --git a/examples/notebooks/gridplot_simple.ipynb b/examples/notebooks/gridplot_simple.ipynb
index f90c0b157..8b50b2701 100644
--- a/examples/notebooks/gridplot_simple.ipynb
+++ b/examples/notebooks/gridplot_simple.ipynb
@@ -12,7 +12,9 @@
"cell_type": "code",
"execution_count": 1,
"id": "5171a06e-1bdc-4908-9726-3c1fd45dbb9d",
- "metadata": {},
+ "metadata": {
+ "tags": []
+ },
"outputs": [],
"source": [
"import numpy as np\n",
@@ -23,12 +25,14 @@
"cell_type": "code",
"execution_count": 2,
"id": "86a2488f-ae1c-4b98-a7c0-18eae8013af1",
- "metadata": {},
+ "metadata": {
+ "tags": []
+ },
"outputs": [
{
"data": {
"application/vnd.jupyter.widget-view+json": {
- "model_id": "5e4e0c5ca610425b8216db8e30cae997",
+ "model_id": "f9067cd724094b8c8dfecf60208acbfa",
"version_major": 2,
"version_minor": 0
},
@@ -40,31 +44,12 @@
"output_type": "display_data"
},
{
- "data": {
- "text/html": [
- "

initial snapshot
"
- ],
- "text/plain": [
- ""
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "1eeb8c42e1b24c4fb40e3b5daa63909a",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "JupyterWgpuCanvas()"
- ]
- },
- "execution_count": 2,
- "metadata": {},
- "output_type": "execute_result"
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "/home/clewis7/repos/fastplotlib/fastplotlib/graphics/_features/_base.py:34: UserWarning: converting float64 array to float32\n",
+ " warn(f\"converting {array.dtype} array to float32\")\n"
+ ]
}
],
"source": [
@@ -105,15 +90,18 @@
"cell_type": "code",
"execution_count": 3,
"id": "17c6bc4a-5340-49f1-8597-f54528cfe915",
- "metadata": {},
+ "metadata": {
+ "tags": []
+ },
"outputs": [
{
"data": {
"text/plain": [
- "unnamed: Subplot @ 0x7fd4cc9bf820\n",
- " parent: None\n",
+ "unnamed: Subplot @ 0x7f15df4f5c50\n",
+ " parent: fastplotlib.GridPlot @ 0x7f15d3f27890\n",
+ "\n",
" Graphics:\n",
- "\t'rand-img': ImageGraphic @ 0x7fd4f675a350"
+ "\t'rand-img': ImageGraphic @ 0x7f15d3fb5390"
]
},
"execution_count": 3,
@@ -139,12 +127,14 @@
"cell_type": "code",
"execution_count": 4,
"id": "34130f12-9ef6-43b0-b929-931de8b7da25",
- "metadata": {},
+ "metadata": {
+ "tags": []
+ },
"outputs": [
{
"data": {
"text/plain": [
- "('rand-img': ImageGraphic @ 0x7fd4a03295a0,)"
+ "(,)"
]
},
"execution_count": 4,
@@ -166,12 +156,14 @@
},
{
"cell_type": "code",
- "execution_count": 5,
+ "execution_count": 11,
"id": "ef8a29a6-b19c-4e6b-a2ba-fb4823c01451",
- "metadata": {},
+ "metadata": {
+ "tags": []
+ },
"outputs": [],
"source": [
- "grid_plot[0, 1].graphics[0].vmax = 0.5"
+ "grid_plot[0, 1].graphics[0].cmap.vmax = 0.5"
]
},
{
@@ -186,7 +178,9 @@
"cell_type": "code",
"execution_count": 6,
"id": "d6c2fa4b-c634-4dcf-8b61-f1986f7c4918",
- "metadata": {},
+ "metadata": {
+ "tags": []
+ },
"outputs": [],
"source": [
"# you can give subplots human-readable string names\n",
@@ -197,15 +191,18 @@
"cell_type": "code",
"execution_count": 7,
"id": "2f6b549c-3165-496d-98aa-45b96c3de674",
- "metadata": {},
+ "metadata": {
+ "tags": []
+ },
"outputs": [
{
"data": {
"text/plain": [
- "top-right-plot: Subplot @ 0x7fd4cca0ffd0\n",
- " parent: None\n",
+ "top-right-plot: Subplot @ 0x7f15d3f769d0\n",
+ " parent: fastplotlib.GridPlot @ 0x7f15d3f27890\n",
+ "\n",
" Graphics:\n",
- "\t'rand-img': ImageGraphic @ 0x7fd4a03716c0"
+ "\t'rand-img': ImageGraphic @ 0x7f15b83f7250"
]
},
"execution_count": 7,
@@ -219,9 +216,11 @@
},
{
"cell_type": "code",
- "execution_count": 8,
+ "execution_count": 12,
"id": "be436e04-33a6-4597-8e6a-17e1e5225419",
- "metadata": {},
+ "metadata": {
+ "tags": []
+ },
"outputs": [
{
"data": {
@@ -229,7 +228,7 @@
"(0, 2)"
]
},
- "execution_count": 8,
+ "execution_count": 12,
"metadata": {},
"output_type": "execute_result"
}
@@ -241,9 +240,11 @@
},
{
"cell_type": "code",
- "execution_count": 9,
+ "execution_count": 13,
"id": "6699cda6-af86-4258-87f5-1832f989a564",
- "metadata": {},
+ "metadata": {
+ "tags": []
+ },
"outputs": [
{
"data": {
@@ -251,7 +252,7 @@
"True"
]
},
- "execution_count": 9,
+ "execution_count": 13,
"metadata": {},
"output_type": "execute_result"
}
@@ -271,9 +272,11 @@
},
{
"cell_type": "code",
- "execution_count": 10,
+ "execution_count": 14,
"id": "545b627b-d794-459a-a75a-3fde44f0ea95",
- "metadata": {},
+ "metadata": {
+ "tags": []
+ },
"outputs": [],
"source": [
"grid_plot[\"top-right-plot\"][\"rand-img\"].vmin = 0.5"
@@ -281,8 +284,20 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 15,
"id": "36432d5b-b76c-4a2a-a32c-097faf5ab269",
+ "metadata": {
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "grid_plot.close()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "b507b723-1371-44e7-aa6d-6aeb3196b27d",
"metadata": {},
"outputs": [],
"source": []
@@ -304,7 +319,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.10.5"
+ "version": "3.11.3"
}
},
"nbformat": 4,
diff --git a/examples/notebooks/linear_region_selector.ipynb b/examples/notebooks/linear_region_selector.ipynb
index 11cd3a490..43cea4f81 100644
--- a/examples/notebooks/linear_region_selector.ipynb
+++ b/examples/notebooks/linear_region_selector.ipynb
@@ -2,7 +2,7 @@
"cells": [
{
"cell_type": "markdown",
- "id": "40bf515f-7ca3-4f16-8ec9-31076e8d4bde",
+ "id": "1db50ec4-8754-4421-9f5e-6ba8ca6b81e3",
"metadata": {},
"source": [
"# `LinearRegionSelector` with single lines"
@@ -11,15 +11,13 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "41f4e1d0-9ae9-4e59-9883-d9339d985afe",
- "metadata": {
- "tags": []
- },
+ "id": "b7bbfeb4-1ad0-47db-9a82-3d3f642a1f63",
+ "metadata": {},
"outputs": [],
"source": [
"import fastplotlib as fpl\n",
"import numpy as np\n",
- "\n",
+ "from ipywidgets import IntRangeSlider, FloatRangeSlider, VBox\n",
"\n",
"gp = fpl.GridPlot((2, 2))\n",
"\n",
@@ -88,7 +86,7 @@
},
{
"cell_type": "markdown",
- "id": "66b1c599-42c0-4223-b33e-37c1ef077204",
+ "id": "0bad4a35-f860-4f85-9061-920154ab682b",
"metadata": {},
"source": [
"### On the x-axis we have a 1-1 mapping from the data that we have passed and the line geometry positions. So the `bounds` min max corresponds directly to the data indices."
@@ -97,10 +95,8 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "8b26a37d-aa1d-478e-ad77-99f68a2b7d0c",
- "metadata": {
- "tags": []
- },
+ "id": "2c96a3ff-c2e7-4683-8097-8491e97dd6d3",
+ "metadata": {},
"outputs": [],
"source": [
"ls_x.selection()"
@@ -109,10 +105,8 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "c2be060c-8f87-4b5c-8262-619768f6e6af",
- "metadata": {
- "tags": []
- },
+ "id": "3ec71e3f-291c-43c6-a954-0a082ba5981c",
+ "metadata": {},
"outputs": [],
"source": [
"ls_x.get_selected_indices()"
@@ -120,7 +114,7 @@
},
{
"cell_type": "markdown",
- "id": "d1bef432-d764-4841-bd6d-9b9e4c86ff62",
+ "id": "1588a89e-1da4-4ada-92e2-7437ba942065",
"metadata": {},
"source": [
"### However, for the y-axis line we have passed a 2D array where we've used a linspace, so there is not a 1-1 mapping from the data to the line geometry positions. Use `get_selected_indices()` to get the indices of the data bounded by the current selection. In addition the position of the Graphic is not `(0, 0)`. You must use `get_selected_indices()` whenever you want the indices of the selected data."
@@ -129,10 +123,8 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "c370d6d7-d92a-4680-8bf0-2f9d541028be",
- "metadata": {
- "tags": []
- },
+ "id": "18e10277-6d5d-42fe-8715-1733efabefa0",
+ "metadata": {},
"outputs": [],
"source": [
"ls_y.selection()"
@@ -141,10 +133,8 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "cdf351e1-63a2-4f5a-8199-8ac3f70909c1",
- "metadata": {
- "tags": []
- },
+ "id": "8e9c42b9-60d2-4544-96c5-c8c6832b79e3",
+ "metadata": {},
"outputs": [],
"source": [
"ls_y.get_selected_indices()"
@@ -153,10 +143,8 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "6fd608ad-9732-4f50-9d43-8630603c86d0",
- "metadata": {
- "tags": []
- },
+ "id": "a9583d2e-ec52-405c-a875-f3fec5e3aa16",
+ "metadata": {},
"outputs": [],
"source": [
"import fastplotlib as fpl\n",
@@ -204,7 +192,7 @@
},
{
"cell_type": "markdown",
- "id": "63acd2b6-958e-458d-bf01-903037644cfe",
+ "id": "0fa051b5-d6bc-4e4e-8f12-44f638a00c88",
"metadata": {},
"source": [
"# Large line stack with selector"
@@ -213,10 +201,8 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "20e53223-6ccd-4145-bf67-32eb409d3b0a",
- "metadata": {
- "tags": []
- },
+ "id": "d5ffb678-c989-49ee-85a9-4fd7822f033c",
+ "metadata": {},
"outputs": [],
"source": [
"import fastplotlib as fpl\n",
@@ -259,7 +245,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "80e276ba-23b3-43d0-9e0c-86acab79ac67",
+ "id": "cbcd6309-fb47-4941-9fd1-2b091feb3ae7",
"metadata": {},
"outputs": [],
"source": []
diff --git a/examples/notebooks/linear_selector.ipynb b/examples/notebooks/linear_selector.ipynb
index a4d6b97ea..9382ffa63 100644
--- a/examples/notebooks/linear_selector.ipynb
+++ b/examples/notebooks/linear_selector.ipynb
@@ -2,7 +2,7 @@
"cells": [
{
"cell_type": "markdown",
- "id": "e0354810-f942-4e4a-b4b9-bb8c083a314e",
+ "id": "a06e1fd9-47df-42a3-a76c-19e23d7b89fd",
"metadata": {},
"source": [
"## `LinearSelector`, draggable selector that can optionally associated with an ipywidget."
@@ -11,17 +11,15 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "d79bb7e0-90af-4459-8dcb-a7a21a89ef64",
- "metadata": {
- "tags": []
- },
+ "id": "eb95ba19-14b5-4bf4-93d9-05182fa500cb",
+ "metadata": {},
"outputs": [],
"source": [
"import fastplotlib as fpl\n",
"from fastplotlib.graphics.selectors import Synchronizer\n",
"\n",
"import numpy as np\n",
- "from ipywidgets import VBox\n",
+ "from ipywidgets import VBox, IntSlider, FloatSlider\n",
"\n",
"plot = fpl.Plot()\n",
"\n",
@@ -49,19 +47,24 @@
"\n",
"# fastplotlib LineSelector can make an ipywidget slider and return it :D \n",
"ipywidget_slider = selector.make_ipywidget_slider()\n",
+ "ipywidget_slider.description = \"slider1\"\n",
+ "\n",
+ "# or you can make your own ipywidget sliders and connect them to the linear selector\n",
+ "ipywidget_slider2 = IntSlider(min=0, max=100, description=\"slider2\")\n",
+ "ipywidget_slider3 = FloatSlider(min=0, max=100, description=\"slider3\")\n",
+ "\n",
+ "selector2.add_ipywidget_handler(ipywidget_slider2, step=5)\n",
+ "selector3.add_ipywidget_handler(ipywidget_slider3, step=0.1)\n",
"\n",
"plot.auto_scale()\n",
- "plot.show()\n",
- "VBox([plot.show(), ipywidget_slider])"
+ "plot.show(vbox=[ipywidget_slider])"
]
},
{
"cell_type": "code",
"execution_count": null,
- "id": "a632c8ee-2d4c-44fc-9391-7b2880223fdb",
- "metadata": {
- "tags": []
- },
+ "id": "7ab9f141-f92f-4c4c-808b-97dafd64ca25",
+ "metadata": {},
"outputs": [],
"source": [
"selector.step = 0.1"
@@ -69,7 +72,7 @@
},
{
"cell_type": "markdown",
- "id": "2c49cdc2-0555-410c-ae2e-da36c3bf3bf0",
+ "id": "3b0f448f-bbe4-4b87-98e3-093f561c216c",
"metadata": {},
"source": [
"### Drag linear selectors with the mouse, hold \"Shift\" to synchronize movement of all the selectors"
@@ -77,7 +80,7 @@
},
{
"cell_type": "markdown",
- "id": "69057edd-7e23-41e7-a284-ac55df1df5d9",
+ "id": "c6f041b7-8779-46f1-8454-13cec66f53fd",
"metadata": {},
"source": [
"## Also works for line collections"
@@ -86,10 +89,8 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "1a3b98bd-7139-48d9-bd70-66c500cd260d",
- "metadata": {
- "tags": []
- },
+ "id": "e36da217-f82a-4dfa-9556-1f4a2c7c4f1c",
+ "metadata": {},
"outputs": [],
"source": [
"sines = [sine] * 10\n",
@@ -113,7 +114,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "b6c2d9d6-ffe0-484c-a550-cafb44fa8465",
+ "id": "71ae4fca-f644-4d4f-8f32-f9d069bbc2f1",
"metadata": {},
"outputs": [],
"source": []
diff --git a/examples/notebooks/scatter_sizes_animation.ipynb b/examples/notebooks/scatter_sizes_animation.ipynb
new file mode 100644
index 000000000..061f444d6
--- /dev/null
+++ b/examples/notebooks/scatter_sizes_animation.ipynb
@@ -0,0 +1,71 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from time import time\n",
+ "\n",
+ "import numpy as np\n",
+ "import fastplotlib as fpl\n",
+ "\n",
+ "plot = fpl.Plot()\n",
+ "\n",
+ "points = np.array([[-1,0,1],[-1,0,1]], dtype=np.float32).swapaxes(0,1)\n",
+ "size_delta_scales = np.array([10, 40, 100], dtype=np.float32)\n",
+ "min_sizes = 6\n",
+ "\n",
+ "def update_positions():\n",
+ " current_time = time()\n",
+ " newPositions = points + np.sin(((current_time / 4) % 1)*np.pi)\n",
+ " plot.graphics[0].data = newPositions\n",
+ " plot.camera.width = 4*np.max(newPositions[0,:])\n",
+ " plot.camera.height = 4*np.max(newPositions[1,:])\n",
+ "\n",
+ "def update_sizes():\n",
+ " current_time = time()\n",
+ " sin_sample = np.sin(((current_time / 4) % 1)*np.pi)\n",
+ " size_delta = sin_sample*size_delta_scales\n",
+ " plot.graphics[0].sizes = min_sizes + size_delta\n",
+ "\n",
+ "points = np.array([[0,0], \n",
+ " [1,1], \n",
+ " [2,2]])\n",
+ "scatter = plot.add_scatter(points, colors=[\"red\", \"green\", \"blue\"], sizes=12)\n",
+ "plot.add_animations(update_positions, update_sizes)\n",
+ "plot.show(autoscale=True)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "fastplotlib-dev",
+ "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.4"
+ },
+ "orig_nbformat": 4
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/examples/notebooks/scatter_sizes_grid.ipynb b/examples/notebooks/scatter_sizes_grid.ipynb
new file mode 100644
index 000000000..ff64184f7
--- /dev/null
+++ b/examples/notebooks/scatter_sizes_grid.ipynb
@@ -0,0 +1,86 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "\"\"\"\n",
+ "Scatter Plot\n",
+ "============\n",
+ "Example showing point size change for scatter plot.\n",
+ "\"\"\"\n",
+ "\n",
+ "# test_example = true\n",
+ "import numpy as np\n",
+ "import fastplotlib as fpl\n",
+ "\n",
+ "# grid with 2 rows and 3 columns\n",
+ "grid_shape = (2,1)\n",
+ "\n",
+ "# pan-zoom controllers for each view\n",
+ "# views are synced if they have the \n",
+ "# same controller ID\n",
+ "controllers = [\n",
+ " [0],\n",
+ " [0]\n",
+ "]\n",
+ "\n",
+ "\n",
+ "# you can give string names for each subplot within the gridplot\n",
+ "names = [\n",
+ " [\"scalar_size\"],\n",
+ " [\"array_size\"]\n",
+ "]\n",
+ "\n",
+ "# Create the grid plot\n",
+ "plot = fpl.GridPlot(\n",
+ " shape=grid_shape,\n",
+ " controllers=controllers,\n",
+ " names=names,\n",
+ " size=(1000, 1000)\n",
+ ")\n",
+ "\n",
+ "# get y_values using sin function\n",
+ "angles = np.arange(0, 20*np.pi+0.001, np.pi / 20)\n",
+ "y_values = 30*np.sin(angles) # 1 thousand points\n",
+ "x_values = np.array([x for x in range(len(y_values))], dtype=np.float32)\n",
+ "\n",
+ "data = np.column_stack([x_values, y_values])\n",
+ "\n",
+ "plot[\"scalar_size\"].add_scatter(data=data, sizes=5, colors=\"blue\") # add a set of scalar sizes\n",
+ "\n",
+ "non_scalar_sizes = np.abs((y_values / np.pi)) # ensure minimum size of 5\n",
+ "plot[\"array_size\"].add_scatter(data=data, sizes=non_scalar_sizes, colors=\"red\")\n",
+ "\n",
+ "for graph in plot:\n",
+ " graph.auto_scale(maintain_aspect=True)\n",
+ "\n",
+ "plot.show()"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "fastplotlib-dev",
+ "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.4"
+ },
+ "orig_nbformat": 4
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/examples/notebooks/simple.ipynb b/examples/notebooks/simple.ipynb
index e994bfba8..753de5a98 100644
--- a/examples/notebooks/simple.ipynb
+++ b/examples/notebooks/simple.ipynb
@@ -76,7 +76,9 @@
"id": "a9b386ac-9218-4f8f-97b3-f29b4201ef55",
"metadata": {},
"source": [
- "## Simple image"
+ "## Simple image\n",
+ "\n",
+ "We are going to be using `jupyterlab-sidecar` to render some of the plots on the side. This makes it very easy to interact with your plots without having to constantly scroll up and down :D"
]
},
{
@@ -108,7 +110,7 @@
"source": [
"**Use the handle on the bottom right corner of the _canvas_ to resize it. You can also pan and zoom using your mouse!**\n",
"\n",
- "By default the origin is on the bottom left, you can click the flip button to flip the y-axis, or use `plot.camera.world.scale_y *= -1`"
+ "By default the origin is on the bottom left, you can click the flip button to flip the y-axis, or use `plot.camera.local.scale_y *= -1`"
]
},
{
@@ -120,7 +122,7 @@
},
"outputs": [],
"source": [
- "plot.camera.world.scale_y *= -1"
+ "plot.camera.local.scale_y *= -1"
]
},
{
@@ -325,6 +327,18 @@
"plot_test(\"astronaut\", plot)"
]
},
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "0bb1cfc7-1a06-4abb-a10a-a877a0d51c6b",
+ "metadata": {
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "plot.canvas.get_logical_size()"
+ ]
+ },
{
"cell_type": "markdown",
"id": "b53bc11a-ddf1-4786-8dca-8f3d2eaf993d",
@@ -429,6 +443,17 @@
"image_graphic == plot[\"sample-image\"]"
]
},
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "058d9785-a692-46f6-a062-cdec9c040afe",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# close the sidecar\n",
+ "plot.sidecar.close()"
+ ]
+ },
{
"cell_type": "markdown",
"id": "5694dca1-1041-4e09-a1da-85b293c5af47",
@@ -452,6 +477,7 @@
"\n",
"plot_rgb.add_image(new_data, name=\"rgb-image\")\n",
"\n",
+ "# show the plot\n",
"plot_rgb.show()"
]
},
@@ -464,7 +490,7 @@
},
"outputs": [],
"source": [
- "plot_rgb.camera.world.scale_y *= -1"
+ "plot_rgb.camera.local.scale_y *= -1"
]
},
{
@@ -500,6 +526,17 @@
"plot_test(\"astronaut_RGB\", plot_rgb)"
]
},
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "8316b4f2-3d6e-46b5-8776-c7c963a7aa99",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# close sidecar\n",
+ "plot_rgb.sidecar.close()"
+ ]
+ },
{
"cell_type": "markdown",
"id": "1cb03f42-1029-4b16-a16b-35447d9e2955",
@@ -533,7 +570,7 @@
"plot_v.add_image(data=data, name=\"random-image\")\n",
"\n",
"# a function to update the image_graphic\n",
- "# a plot will pass its plot instance to the animation function as an arugment\n",
+ "# a plot will pass its plot instance to the animation function as an argument\n",
"def update_data(plot_instance):\n",
" new_data = np.random.rand(512, 512)\n",
" plot_instance[\"random-image\"].data = new_data\n",
@@ -576,7 +613,7 @@
"\n",
"plot_sync.add_animations(update_data_2)\n",
"\n",
- "plot_sync.show()"
+ "plot_sync.show(sidecar=False)"
]
},
{
@@ -602,7 +639,7 @@
"metadata": {},
"outputs": [],
"source": [
- "VBox([plot_v.show(), plot_sync.show()])"
+ "VBox([plot_v.show(sidecar=False), plot_sync.show(sidecar=False)])"
]
},
{
@@ -612,7 +649,18 @@
"metadata": {},
"outputs": [],
"source": [
- "HBox([plot_v.show(), plot_sync.show()])"
+ "HBox([plot_v.show(sidecar=False), plot_sync.show(sidecar=False)])"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "f33f4cd9-02fc-41b7-961b-9dfeb455b63a",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# close sidecar\n",
+ "plot_v.sidecar.close()"
]
},
{
@@ -688,7 +736,8 @@
"colors = [\"r\"] * 25 + [\"purple\"] * 25 + [\"y\"] * 25 + [\"b\"] * 25\n",
"sinc_graphic = plot_l.add_line(data=sinc, thickness=5, colors = colors)\n",
"\n",
- "plot_l.show()"
+ "# show the plot\n",
+ "plot_l.show(sidecar_kwargs={\"title\": \"lines\", \"layout\": {'width': '800px'}})"
]
},
{
@@ -952,7 +1001,7 @@
"\n",
"plot_l.add_image(img[::20, ::20], name=\"image\", cmap=\"gray\")\n",
"\n",
- "# z axix position -1 so it is below all the lines\n",
+ "# z axis position -1 so it is below all the lines\n",
"plot_l[\"image\"].position_z = -1\n",
"plot_l[\"image\"].position_x = -8\n",
"plot_l[\"image\"].position_y = -8"
@@ -971,6 +1020,17 @@
"plot_test(\"lines-underlay\", plot_l)"
]
},
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "bef729ea-f524-4efd-a189-bfca23b39af5",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# close sidecar\n",
+ "plot_l.sidecar.close()"
+ ]
+ },
{
"cell_type": "markdown",
"id": "2c90862e-2f2a-451f-a468-0cf6b857e87a",
@@ -1030,6 +1090,19 @@
"plot_test(\"lines-3d\", plot_l3d)"
]
},
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "c2c70541-98fe-4e02-a718-ac2857cc25be",
+ "metadata": {
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "# close sidecar\n",
+ "plot_l3d.sidecar.close()"
+ ]
+ },
{
"cell_type": "markdown",
"id": "a202b3d0-2a0b-450a-93d4-76d0a1129d1d",
@@ -1159,6 +1232,17 @@
"scatter_graphic.data[n_points:n_points * 2, 0] = np.linspace(-40, 0, n_points)"
]
},
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "a9ffdde4-4b8e-4ff7-98b3-464cf5462d20",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# close sidecar\n",
+ "plot_s.sidecar.close()"
+ ]
+ },
{
"cell_type": "markdown",
"id": "d9e554de-c436-4684-a46a-ce8a33d409ac",
@@ -1176,8 +1260,8 @@
"metadata": {},
"outputs": [],
"source": [
- "row1 = HBox([plot.show(), plot_v.show(), plot_sync.show()])\n",
- "row2 = HBox([plot_l.show(), plot_l3d.show(), plot_s.show()])\n",
+ "row1 = HBox([plot.show(sidecar=False), plot_v.show(sidecar=False), plot_sync.show(sidecar=False)])\n",
+ "row2 = HBox([plot_l.show(sidecar=False), plot_l3d.show(sidecar=False), plot_s.show(sidecar=False)])\n",
"\n",
"VBox([row1, row2])"
]
diff --git a/fastplotlib/VERSION b/fastplotlib/VERSION
index 99bed0205..9a1d5d93c 100644
--- a/fastplotlib/VERSION
+++ b/fastplotlib/VERSION
@@ -1 +1 @@
-0.1.0.a12
+0.1.0.a13
diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py
index d30f7175f..d145821e4 100644
--- a/fastplotlib/graphics/_base.py
+++ b/fastplotlib/graphics/_base.py
@@ -166,11 +166,11 @@ class Interaction(ABC):
"""Mixin class that makes graphics interactive"""
@abstractmethod
- def _set_feature(self, feature: str, new_data: Any, indices: Any):
+ def set_feature(self, feature: str, new_data: Any, indices: Any):
pass
@abstractmethod
- def _reset_feature(self, feature: str):
+ def reset_feature(self, feature: str):
pass
def link(
@@ -312,14 +312,14 @@ def _event_handler(self, event):
# the real world object in the pick_info and not the proxy
if wo is event.pick_info["world_object"]:
indices = i
- target_info.target._set_feature(
+ target_info.target.set_feature(
feature=target_info.feature,
new_data=target_info.new_data,
indices=indices,
)
else:
# if target is a single graphic, then indices do not matter
- target_info.target._set_feature(
+ target_info.target.set_feature(
feature=target_info.feature,
new_data=target_info.new_data,
indices=None,
diff --git a/fastplotlib/graphics/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py
index 8e78a6260..a6ce9c3a3 100644
--- a/fastplotlib/graphics/_features/__init__.py
+++ b/fastplotlib/graphics/_features/__init__.py
@@ -1,5 +1,6 @@
from ._colors import ColorFeature, CmapFeature, ImageCmapFeature, HeatmapCmapFeature
from ._data import PointsDataFeature, ImageDataFeature, HeatmapDataFeature
+from ._sizes import PointsSizesFeature
from ._present import PresentFeature
from ._thickness import ThicknessFeature
from ._base import GraphicFeature, GraphicFeatureIndexable, FeatureEvent, to_gpu_supported_dtype
@@ -11,6 +12,7 @@
"ImageCmapFeature",
"HeatmapCmapFeature",
"PointsDataFeature",
+ "PointsSizesFeature",
"ImageDataFeature",
"HeatmapDataFeature",
"PresentFeature",
diff --git a/fastplotlib/graphics/_features/_present.py b/fastplotlib/graphics/_features/_present.py
index ba257e60b..b0bb627c5 100644
--- a/fastplotlib/graphics/_features/_present.py
+++ b/fastplotlib/graphics/_features/_present.py
@@ -38,7 +38,7 @@ def _set(self, present: bool):
if i > 100:
raise RecursionError(
- "Exceded scene graph depth threshold, cannot find Scene associated with"
+ "Exceeded scene graph depth threshold, cannot find Scene associated with"
"this graphic."
)
diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py
index ae486026e..5f161562f 100644
--- a/fastplotlib/graphics/_features/_selection_features.py
+++ b/fastplotlib/graphics/_features/_selection_features.py
@@ -150,14 +150,14 @@ class LinearSelectionFeature(GraphicFeature):
def __init__(self, parent, axis: str, value: float, limits: Tuple[int, int]):
super(LinearSelectionFeature, self).__init__(parent, data=value)
- self.axis = axis
- self.limits = limits
+ self._axis = axis
+ self._limits = limits
def _set(self, value: float):
- if not (self.limits[0] <= value <= self.limits[1]):
+ if not (self._limits[0] <= value <= self._limits[1]):
return
- if self.axis == "x":
+ if self._axis == "x":
self._parent.position_x = value
else:
self._parent.position_y = value
@@ -219,7 +219,7 @@ def __init__(
super(LinearRegionSelectionFeature, self).__init__(parent, data=selection)
self._axis = axis
- self.limits = limits
+ self._limits = limits
self._set(selection)
@@ -238,7 +238,7 @@ def _set(self, value: Tuple[float, float]):
# make sure bounds not exceeded
for v in value:
- if not (self.limits[0] <= v <= self.limits[1]):
+ if not (self._limits[0] <= v <= self._limits[1]):
return
# make sure `selector width >= 2`, left edge must not move past right edge!
diff --git a/fastplotlib/graphics/_features/_sizes.py b/fastplotlib/graphics/_features/_sizes.py
index e69de29bb..377052918 100644
--- a/fastplotlib/graphics/_features/_sizes.py
+++ b/fastplotlib/graphics/_features/_sizes.py
@@ -0,0 +1,108 @@
+from typing import Any
+
+import numpy as np
+
+import pygfx
+
+from ._base import (
+ GraphicFeatureIndexable,
+ cleanup_slice,
+ FeatureEvent,
+ to_gpu_supported_dtype,
+ cleanup_array_slice,
+)
+
+
+class PointsSizesFeature(GraphicFeatureIndexable):
+ """
+ Access to the vertex buffer data shown in the graphic.
+ Supports fancy indexing if the data array also supports it.
+ """
+
+ def __init__(self, parent, sizes: Any, collection_index: int = None):
+ sizes = self._fix_sizes(sizes, parent)
+ super(PointsSizesFeature, self).__init__(
+ parent, sizes, collection_index=collection_index
+ )
+
+ @property
+ def buffer(self) -> pygfx.Buffer:
+ return self._parent.world_object.geometry.sizes
+
+ def __getitem__(self, item):
+ return self.buffer.data[item]
+
+ def _fix_sizes(self, sizes, parent):
+ graphic_type = parent.__class__.__name__
+
+ n_datapoints = parent.data().shape[0]
+ if not isinstance(sizes, (list, tuple, np.ndarray)):
+ sizes = np.full(n_datapoints, sizes, dtype=np.float32) # force it into a float to avoid weird gpu errors
+ elif not isinstance(sizes, np.ndarray): # if it's not a ndarray already, make it one
+ sizes = np.array(sizes, dtype=np.float32) # read it in as a numpy.float32
+ if (sizes.ndim != 1) or (sizes.size != parent.data().shape[0]):
+ raise ValueError(
+ f"sequence of `sizes` must be 1 dimensional with "
+ f"the same length as the number of datapoints"
+ )
+
+ sizes = to_gpu_supported_dtype(sizes)
+
+ if any(s < 0 for s in sizes):
+ raise ValueError("All sizes must be positive numbers greater than or equal to 0.0.")
+
+ if sizes.ndim == 1:
+ if graphic_type == "ScatterGraphic":
+ sizes = np.array(sizes)
+ else:
+ raise ValueError(f"Sizes must be an array of shape (n,) where n == the number of data points provided.\
+ Received shape={sizes.shape}.")
+
+ return np.array(sizes)
+
+ def __setitem__(self, key, value):
+ if isinstance(key, np.ndarray):
+ # make sure 1D array of int or boolean
+ key = cleanup_array_slice(key, self._upper_bound)
+
+ # put sizes into right shape if they're only indexing datapoints
+ if isinstance(key, (slice, int, np.ndarray, np.integer)):
+ value = self._fix_sizes(value, self._parent)
+ # otherwise assume that they have the right shape
+ # numpy will throw errors if it can't broadcast
+
+ if value.size != self.buffer.data[key].size:
+ raise ValueError(f"{value.size} is not equal to buffer size {self.buffer.data[key].size}.\
+ If you want to set size to a non-scalar value, make sure it's the right length!")
+
+ self.buffer.data[key] = value
+ self._update_range(key)
+ # avoid creating dicts constantly if there are no events to handle
+ if len(self._event_handlers) > 0:
+ self._feature_changed(key, value)
+
+ def _update_range(self, key):
+ self._update_range_indices(key)
+
+ def _feature_changed(self, key, new_data):
+ if key is not None:
+ key = cleanup_slice(key, self._upper_bound)
+ if isinstance(key, (int, np.integer)):
+ indices = [key]
+ elif isinstance(key, slice):
+ indices = range(key.start, key.stop, key.step)
+ elif isinstance(key, np.ndarray):
+ indices = key
+ elif key is None:
+ indices = None
+
+ pick_info = {
+ "index": indices,
+ "collection-index": self._collection_index,
+ "world_object": self._parent.world_object,
+ "new_data": new_data,
+ }
+
+ event_data = FeatureEvent(type="sizes", pick_info=pick_info)
+
+ self._call_event_handlers(event_data)
\ No newline at end of file
diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py
index d60fa36b2..121134de5 100644
--- a/fastplotlib/graphics/image.py
+++ b/fastplotlib/graphics/image.py
@@ -304,10 +304,10 @@ def __init__(
# set it with the actual data
self.data = data
- def _set_feature(self, feature: str, new_data: Any, indices: Any):
+ def set_feature(self, feature: str, new_data: Any, indices: Any):
pass
- def _reset_feature(self, feature: str):
+ def reset_feature(self, feature: str):
pass
@@ -500,8 +500,8 @@ def vmax(self, value: float):
"""Maximum contrast limit."""
self._material.clim = (self._material.clim[0], value)
- def _set_feature(self, feature: str, new_data: Any, indices: Any):
+ def set_feature(self, feature: str, new_data: Any, indices: Any):
pass
- def _reset_feature(self, feature: str):
+ def reset_feature(self, feature: str):
pass
diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py
index aeeeea3b0..d6f061ab0 100644
--- a/fastplotlib/graphics/line.py
+++ b/fastplotlib/graphics/line.py
@@ -114,7 +114,7 @@ def __init__(
world_object: pygfx.Line = pygfx.Line(
# self.data.feature_data because data is a Buffer
geometry=pygfx.Geometry(positions=self.data(), colors=self.colors()),
- material=material(thickness=self.thickness(), vertex_colors=True),
+ material=material(thickness=self.thickness(), color_mode="vertex"),
)
self._set_world_object(world_object)
@@ -281,11 +281,11 @@ def _get_linear_selector_init_args(self, padding: float, **kwargs):
def _add_plot_area_hook(self, plot_area):
self._plot_area = plot_area
- def _set_feature(self, feature: str, new_data: Any, indices: Any = None):
+ def set_feature(self, feature: str, new_data: Any, indices: Any = None):
if not hasattr(self, "_previous_data"):
self._previous_data = dict()
elif hasattr(self, "_previous_data"):
- self._reset_feature(feature)
+ self.reset_feature(feature)
feature_instance = getattr(self, feature)
if indices is not None:
@@ -302,7 +302,7 @@ def _set_feature(self, feature: str, new_data: Any, indices: Any = None):
data=previous, indices=indices
)
- def _reset_feature(self, feature: str):
+ def reset_feature(self, feature: str):
if feature not in self._previous_data.keys():
return
diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py
index 06f260ee7..062c5ba91 100644
--- a/fastplotlib/graphics/line_collection.py
+++ b/fastplotlib/graphics/line_collection.py
@@ -415,7 +415,7 @@ def _get_linear_selector_init_args(self, padding, **kwargs):
def _add_plot_area_hook(self, plot_area):
self._plot_area = plot_area
- def _set_feature(self, feature: str, new_data: Any, indices: Any):
+ def set_feature(self, feature: str, new_data: Any, indices: Any):
# if single value force to be an array of size 1
if isinstance(indices, (np.integer, int)):
indices = np.array([indices])
@@ -429,7 +429,7 @@ def _set_feature(self, feature: str, new_data: Any, indices: Any):
if self._previous_data[feature].indices == indices:
return # nothing to change, and this allows bidirectional linking without infinite recursion
- self._reset_feature(feature)
+ self.reset_feature(feature)
# coll_feature = getattr(self[indices], feature)
@@ -455,7 +455,7 @@ def _set_feature(self, feature: str, new_data: Any, indices: Any):
# since calling `feature._set()` triggers all the feature callbacks
feature_instance._set(new_data)
- def _reset_feature(self, feature: str):
+ def reset_feature(self, feature: str):
if feature not in self._previous_data.keys():
return
diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py
index 9e162c57a..961324c23 100644
--- a/fastplotlib/graphics/scatter.py
+++ b/fastplotlib/graphics/scatter.py
@@ -5,16 +5,16 @@
from ..utils import parse_cmap_values
from ._base import Graphic
-from ._features import PointsDataFeature, ColorFeature, CmapFeature
+from ._features import PointsDataFeature, ColorFeature, CmapFeature, PointsSizesFeature
class ScatterGraphic(Graphic):
- feature_events = ("data", "colors", "cmap", "present")
+ feature_events = ("data", "sizes", "colors", "cmap", "present")
def __init__(
self,
data: np.ndarray,
- sizes: Union[int, np.ndarray, list] = 1,
+ sizes: Union[int, float, np.ndarray, list] = 1,
colors: np.ndarray = "w",
alpha: float = 1.0,
cmap: str = None,
@@ -86,25 +86,12 @@ def __init__(
self, self.colors(), cmap_name=cmap, cmap_values=cmap_values
)
- if isinstance(sizes, int):
- sizes = np.full(self.data().shape[0], sizes, dtype=np.float32)
- elif isinstance(sizes, np.ndarray):
- if (sizes.ndim != 1) or (sizes.size != self.data().shape[0]):
- raise ValueError(
- f"numpy array of `sizes` must be 1 dimensional with "
- f"the same length as the number of datapoints"
- )
- elif isinstance(sizes, list):
- if len(sizes) != self.data().shape[0]:
- raise ValueError(
- "list of `sizes` must have the same length as the number of datapoints"
- )
-
+ self.sizes = PointsSizesFeature(self, sizes)
super(ScatterGraphic, self).__init__(*args, **kwargs)
world_object = pygfx.Points(
- pygfx.Geometry(positions=self.data(), sizes=sizes, colors=self.colors()),
- material=pygfx.PointsMaterial(vertex_colors=True, vertex_sizes=True),
+ pygfx.Geometry(positions=self.data(), sizes=self.sizes(), colors=self.colors()),
+ material=pygfx.PointsMaterial(color_mode="vertex", vertex_sizes=True),
)
self._set_world_object(world_object)
diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py
index a4159c194..2b1a2aa0d 100644
--- a/fastplotlib/graphics/selectors/_base_selector.py
+++ b/fastplotlib/graphics/selectors/_base_selector.py
@@ -80,10 +80,15 @@ def __init__(
self._move_info: MoveInfo = None
+ # sets to `True` on "pointer_down", sets to `False` on "pointer_up"
+ self._moving = False #: indicates if the selector is currently being moved
+
# used to disable fill area events if the edge is being actively hovered
# otherwise annoying and requires too much accuracy to move just an edge
self._edge_hovered: bool = False
+ self._pygfx_event = None
+
def get_selected_index(self):
"""Not implemented for this selector"""
raise NotImplementedError
@@ -189,6 +194,7 @@ def _move_start(self, event_source: WorldObject, ev):
last_position = self._plot_area.map_screen_to_world(ev)
self._move_info = MoveInfo(last_position=last_position, source=event_source)
+ self._moving = True
def _move(self, ev):
"""
@@ -231,6 +237,7 @@ def _move_graphic(self, delta: np.ndarray):
def _move_end(self, ev):
self._move_info = None
+ self._moving = False
self._plot_area.controller.enabled = True
def _move_to_pointer(self, ev):
diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py
index 39710305d..c00bebcc7 100644
--- a/fastplotlib/graphics/selectors/_linear.py
+++ b/fastplotlib/graphics/selectors/_linear.py
@@ -1,5 +1,6 @@
from typing import *
import math
+from numbers import Real
import numpy as np
@@ -18,6 +19,21 @@
class LinearSelector(Graphic, BaseSelector):
+ @property
+ def limits(self) -> Tuple[float, float]:
+ return self._limits
+
+ @limits.setter
+ def limits(self, values: Tuple[float, float]):
+ # check that `values` is an iterable of two real numbers
+ # using `Real` here allows it to work with builtin `int` and `float` types, and numpy scaler types
+ if len(values) != 2 or not all(map(lambda v: isinstance(v, Real), values)):
+ raise TypeError(
+ "limits must be an iterable of two numeric values"
+ )
+ self._limits = tuple(map(round, values)) # if values are close to zero things get weird so round them
+ self.selection._limits = self._limits
+
# TODO: make `selection` arg in graphics data space not world space
def __init__(
self,
@@ -27,7 +43,6 @@ def __init__(
parent: Graphic = None,
end_points: Tuple[int, int] = None,
arrow_keys_modifier: str = "Shift",
- ipywidget_slider=None,
thickness: float = 2.5,
color: Any = "w",
name: str = None,
@@ -57,9 +72,6 @@ def __init__(
"Control", "Shift", "Alt" or ``None``. Double click on the selector first to enable the
arrow key movements, or set the attribute ``arrow_key_events_enabled = True``
- ipywidget_slider: IntSlider, optional
- ipywidget slider to associate with this graphic
-
thickness: float, default 2.5
thickness of the slider
@@ -84,7 +96,8 @@ def __init__(
if len(limits) != 2:
raise ValueError("limits must be a tuple of 2 integers, i.e. (int, int)")
- limits = tuple(map(round, limits))
+ self._limits = tuple(map(round, limits))
+
selection = round(selection)
if axis == "x":
@@ -94,8 +107,8 @@ def __init__(
line_data = np.column_stack([xs, ys, zs])
elif axis == "y":
- xs = np.zeros(end_points)
- ys = np.array(2)
+ xs = np.array(end_points)
+ ys = np.zeros(2)
zs = np.zeros(2)
line_data = np.column_stack([xs, ys, zs])
@@ -141,21 +154,17 @@ def __init__(
self.position_y = selection
self.selection = LinearSelectionFeature(
- self, axis=axis, value=selection, limits=limits
+ self, axis=axis, value=selection, limits=self._limits
)
- self.ipywidget_slider = ipywidget_slider
-
- if self.ipywidget_slider is not None:
- self._setup_ipywidget_slider(ipywidget_slider)
-
self._move_info: dict = None
- self._pygfx_event = None
self.parent = parent
self._block_ipywidget_call = False
+ self._handled_widgets = list()
+
# init base selector
BaseSelector.__init__(
self,
@@ -166,21 +175,41 @@ def __init__(
)
def _setup_ipywidget_slider(self, widget):
- # setup ipywidget slider with callbacks to this LinearSelector
- widget.value = int(self.selection())
+ # setup an ipywidget slider with bidirectional callbacks to this LinearSelector
+ value = self.selection()
+
+ if isinstance(widget, ipywidgets.IntSlider):
+ value = int(value)
+
+ widget.value = value
+
+ # user changes widget -> linear selection changes
widget.observe(self._ipywidget_callback, "value")
- self.selection.add_event_handler(self._update_ipywidget)
+
+ # user changes linear selection -> widget changes
+ self.selection.add_event_handler(self._update_ipywidgets)
+
self._plot_area.renderer.add_event_handler(self._set_slider_layout, "resize")
- def _update_ipywidget(self, ev):
- # update the ipywidget slider value when LinearSelector value changes
- self._block_ipywidget_call = True
- self.ipywidget_slider.value = int(ev.pick_info["new_data"])
+ self._handled_widgets.append(widget)
+
+ def _update_ipywidgets(self, ev):
+ # update the ipywidget sliders when LinearSelector value changes
+ self._block_ipywidget_call = True # prevent infinite recursion
+
+ value = ev.pick_info["new_data"]
+ # update all the handled slider widgets
+ for widget in self._handled_widgets:
+ if isinstance(widget, ipywidgets.IntSlider):
+ widget.value = int(value)
+ else:
+ widget.value = value
+
self._block_ipywidget_call = False
def _ipywidget_callback(self, change):
# update the LinearSelector if the ipywidget value changes
- if self._block_ipywidget_call:
+ if self._block_ipywidget_call or self._moving:
return
self.selection = change["new"]
@@ -188,7 +217,8 @@ def _ipywidget_callback(self, change):
def _set_slider_layout(self, *args):
w, h = self._plot_area.renderer.logical_size
- self.ipywidget_slider.layout = ipywidgets.Layout(width=f"{w}px")
+ for widget in self._handled_widgets:
+ widget.layout = ipywidgets.Layout(width=f"{w}px")
def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs):
"""
@@ -197,7 +227,7 @@ def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs):
Parameters
----------
kind: str
- "IntSlider" or "FloatSlider"
+ "IntSlider", "FloatSlider" or "FloatLogSlider"
kwargs
passed to the ipywidget slider constructor
@@ -207,28 +237,68 @@ def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs):
ipywidgets.Intslider or ipywidgets.FloatSlider
"""
- if self.ipywidget_slider is not None:
- raise AttributeError("Already has ipywidget slider")
if not HAS_IPYWIDGETS:
raise ImportError(
"Must installed `ipywidgets` to use `make_ipywidget_slider()`"
)
+ if kind not in ["IntSlider", "FloatSlider", "FloatLogSlider"]:
+ raise TypeError(
+ f"`kind` must be one of: 'IntSlider', 'FloatSlider' or 'FloatLogSlider'\n"
+ f"You have passed: '{kind}'"
+ )
+
cls = getattr(ipywidgets, kind)
+ value = self.selection()
+ if "Int" in kind:
+ value = int(self.selection())
+
slider = cls(
- min=self.selection.limits[0],
- max=self.selection.limits[1],
- value=int(self.selection()),
- step=1,
+ min=self.limits[0],
+ max=self.limits[1],
+ value=value,
**kwargs,
)
- self.ipywidget_slider = slider
- self._setup_ipywidget_slider(slider)
+ self.add_ipywidget_handler(slider)
return slider
+ def add_ipywidget_handler(
+ self,
+ widget,
+ step: Union[int, float] = None
+ ):
+ """
+ Bidirectionally connect events with a ipywidget slider
+
+ Parameters
+ ----------
+ widget: ipywidgets.IntSlider, ipywidgets.FloatSlider, or ipywidgets.FloatLogSlider
+ ipywidget slider to connect to
+
+ step: int or float, default ``None``
+ step size, if ``None`` 100 steps are created
+
+ """
+
+ if not isinstance(widget, (ipywidgets.IntSlider, ipywidgets.FloatSlider, ipywidgets.FloatLogSlider)):
+ raise TypeError(
+ f"`widget` must be one of: ipywidgets.IntSlider, ipywidgets.FloatSlider, or ipywidgets.FloatLogSlider\n"
+ f"You have passed a: <{type(widget)}"
+ )
+
+ if step is None:
+ step = (self.limits[1] - self.limits[0]) / 100
+
+ if isinstance(widget, ipywidgets.IntSlider):
+ step = int(step)
+
+ widget.step = step
+
+ self._setup_ipywidget_slider(widget)
+
def get_selected_index(self, graphic: Graphic = None) -> Union[int, List[int]]:
"""
Data index the slider is currently at w.r.t. the Graphic data. With LineGraphic data, the geometry x or y
@@ -278,9 +348,9 @@ def _get_selected_index(self, graphic):
or math.fabs(find_value - geo_positions[idx - 1])
< math.fabs(find_value - geo_positions[idx])
):
- return int(idx - 1)
+ return round(idx - 1)
else:
- return int(idx)
+ return round(idx)
if (
"Heatmap" in graphic.__class__.__name__
@@ -288,7 +358,7 @@ def _get_selected_index(self, graphic):
):
# indices map directly to grid geometry for image data buffer
index = self.selection() - offset
- return int(index)
+ return round(index)
def _move_graphic(self, delta: np.ndarray):
"""
diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py
index 0759cd4fc..8579ad6d0 100644
--- a/fastplotlib/graphics/selectors/_linear_region.py
+++ b/fastplotlib/graphics/selectors/_linear_region.py
@@ -1,4 +1,13 @@
from typing import *
+from numbers import Real
+
+try:
+ import ipywidgets
+
+ HAS_IPYWIDGETS = True
+except (ImportError, ModuleNotFoundError):
+ HAS_IPYWIDGETS = False
+
import numpy as np
import pygfx
@@ -9,6 +18,21 @@
class LinearRegionSelector(Graphic, BaseSelector):
+ @property
+ def limits(self) -> Tuple[float, float]:
+ return self._limits
+
+ @limits.setter
+ def limits(self, values: Tuple[float, float]):
+ # check that `values` is an iterable of two real numbers
+ # using `Real` here allows it to work with builtin `int` and `float` types, and numpy scaler types
+ if len(values) != 2 or not all(map(lambda v: isinstance(v, Real), values)):
+ raise TypeError(
+ "limits must be an iterable of two numeric values"
+ )
+ self._limits = tuple(map(round, values)) # if values are close to zero things get weird so round them
+ self.selection._limits = self._limits
+
def __init__(
self,
bounds: Tuple[int, int],
@@ -81,9 +105,9 @@ def __init__(
"""
- # lots of very close to zero values etc. so round them
+ # lots of very close to zero values etc. so round them, otherwise things get weird
bounds = tuple(map(round, bounds))
- limits = tuple(map(round, limits))
+ self._limits = tuple(map(round, limits))
origin = tuple(map(round, origin))
# TODO: sanity checks, we recommend users to add LinearSelection using the add_linear_selector() methods
@@ -203,9 +227,13 @@ def __init__(
# set the initial bounds of the selector
self.selection = LinearRegionSelectionFeature(
- self, bounds, axis=axis, limits=limits
+ self, bounds, axis=axis, limits=self._limits
)
+ self._handled_widgets = list()
+ self._block_ipywidget_call = False
+ self._pygfx_event = None
+
BaseSelector.__init__(
self,
edges=self.edges,
@@ -341,6 +369,130 @@ def get_selected_indices(
ixs = np.arange(*self.selection(), dtype=int)
return ixs
+ def make_ipywidget_slider(self, kind: str = "IntRangeSlider", **kwargs):
+ """
+ Makes and returns an ipywidget slider that is associated to this LinearSelector
+
+ Parameters
+ ----------
+ kind: str
+ "IntRangeSlider" or "FloatRangeSlider"
+
+ kwargs
+ passed to the ipywidget slider constructor
+
+ Returns
+ -------
+ ipywidgets.Intslider or ipywidgets.FloatSlider
+
+ """
+
+ if not HAS_IPYWIDGETS:
+ raise ImportError(
+ "Must installed `ipywidgets` to use `make_ipywidget_slider()`"
+ )
+
+ if kind not in ["IntRangeSlider", "FloatRangeSlider"]:
+ raise TypeError(
+ f"`kind` must be one of: 'IntRangeSlider', or 'FloatRangeSlider'\n"
+ f"You have passed: '{kind}'"
+ )
+
+ cls = getattr(ipywidgets, kind)
+
+ value = self.selection()
+ if "Int" in kind:
+ value = tuple(map(int, self.selection()))
+
+ slider = cls(
+ min=self.limits[0],
+ max=self.limits[1],
+ value=value,
+ **kwargs,
+ )
+ self.add_ipywidget_handler(slider)
+
+ return slider
+
+ def add_ipywidget_handler(
+ self,
+ widget,
+ step: Union[int, float] = None
+ ):
+ """
+ Bidirectionally connect events with a ipywidget slider
+
+ Parameters
+ ----------
+ widget: ipywidgets.IntRangeSlider or ipywidgets.FloatRangeSlider
+ ipywidget slider to connect to
+
+ step: int or float, default ``None``
+ step size, if ``None`` 100 steps are created
+
+ """
+ if not isinstance(widget, (ipywidgets.IntRangeSlider, ipywidgets.FloatRangeSlider)):
+ raise TypeError(
+ f"`widget` must be one of: ipywidgets.IntRangeSlider or ipywidgets.FloatRangeSlider\n"
+ f"You have passed a: <{type(widget)}"
+ )
+
+ if step is None:
+ step = (self.limits[1] - self.limits[0]) / 100
+
+ if isinstance(widget, ipywidgets.IntSlider):
+ step = int(step)
+
+ widget.step = step
+
+ self._setup_ipywidget_slider(widget)
+
+ def _setup_ipywidget_slider(self, widget):
+ # setup an ipywidget slider with bidirectional callbacks to this LinearSelector
+ value = self.selection()
+
+ if isinstance(widget, ipywidgets.IntSlider):
+ value = tuple(map(int, value))
+
+ widget.value = value
+
+ # user changes widget -> linear selection changes
+ widget.observe(self._ipywidget_callback, "value")
+
+ # user changes linear selection -> widget changes
+ self.selection.add_event_handler(self._update_ipywidgets)
+
+ self._plot_area.renderer.add_event_handler(self._set_slider_layout, "resize")
+
+ self._handled_widgets.append(widget)
+
+ def _update_ipywidgets(self, ev):
+ # update the ipywidget sliders when LinearSelector value changes
+ self._block_ipywidget_call = True # prevent infinite recursion
+
+ value = ev.pick_info["new_data"]
+ # update all the handled slider widgets
+ for widget in self._handled_widgets:
+ if isinstance(widget, ipywidgets.IntSlider):
+ widget.value = tuple(map(int, value))
+ else:
+ widget.value = value
+
+ self._block_ipywidget_call = False
+
+ def _ipywidget_callback(self, change):
+ # update the LinearSelector if the ipywidget value changes
+ if self._block_ipywidget_call or self._moving:
+ return
+
+ self.selection = change["new"]
+
+ def _set_slider_layout(self, *args):
+ w, h = self._plot_area.renderer.logical_size
+
+ for widget in self._handled_widgets:
+ widget.layout = ipywidgets.Layout(width=f"{w}px")
+
def _move_graphic(self, delta: np.ndarray):
# add delta to current bounds to get new positions
if self.selection.axis == "x":
diff --git a/fastplotlib/graphics/selectors/_sync.py b/fastplotlib/graphics/selectors/_sync.py
index b01823394..8ba7dfd97 100644
--- a/fastplotlib/graphics/selectors/_sync.py
+++ b/fastplotlib/graphics/selectors/_sync.py
@@ -1,8 +1,9 @@
from . import LinearSelector
+from typing import *
class Synchronizer:
- def __init__(self, *selectors: LinearSelector, key_bind: str = "Shift"):
+ def __init__(self, *selectors: LinearSelector, key_bind: Union[str, None] = "Shift"):
"""
Synchronize the movement of `Selectors`. Selectors will move in sync only when the selected `"key_bind"` is
used during the mouse movement event. Valid key binds are: ``"Control"``, ``"Shift"`` and ``"Alt"``.
@@ -74,7 +75,7 @@ def _move_selectors(self, source, delta):
for s in self.selectors:
# must use == and not is to compare Graphics because they are weakref proxies!
if s == source:
- # if it's the source, since it has already movied
+ # if it's the source, since it has already moved
continue
s._move_graphic(delta)
diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_base.py
index 69f50800e..c5dcb0581 100644
--- a/fastplotlib/layouts/_base.py
+++ b/fastplotlib/layouts/_base.py
@@ -40,7 +40,7 @@ def __init__(
):
"""
Base class for plot creation and management. ``PlotArea`` is not intended to be instantiated by users
- but rather to provide functionallity for ``subplot`` in ``gridplot`` and single ``plot``.
+ but rather to provide functionality for ``subplot`` in ``gridplot`` and single ``plot``.
Parameters
----------
diff --git a/fastplotlib/layouts/_gridplot.py b/fastplotlib/layouts/_gridplot.py
index b339e8659..be268fa9a 100644
--- a/fastplotlib/layouts/_gridplot.py
+++ b/fastplotlib/layouts/_gridplot.py
@@ -12,7 +12,9 @@
from wgpu.gui.auto import WgpuCanvas, is_jupyter
if is_jupyter():
- from ipywidgets import HBox, Layout, Button, ToggleButton, VBox, Dropdown
+ from ipywidgets import HBox, Layout, Button, ToggleButton, VBox, Dropdown, Widget
+ from sidecar import Sidecar
+ from IPython.display import display
from ._utils import make_canvas_and_renderer
from ._defaults import create_controller
@@ -81,6 +83,9 @@ def __init__(
self.shape = shape
self.toolbar = None
+ self.sidecar = None
+ self.vbox = None
+ self.plot_open = False
canvas, renderer = make_canvas_and_renderer(canvas, renderer)
@@ -294,7 +299,13 @@ def remove_animation(self, func):
self._animate_funcs_post.remove(func)
def show(
- self, autoscale: bool = True, maintain_aspect: bool = None, toolbar: bool = True
+ self,
+ autoscale: bool = True,
+ maintain_aspect: bool = None,
+ toolbar: bool = True,
+ sidecar: bool = True,
+ sidecar_kwargs: dict = None,
+ vbox: list = None
):
"""
Begins the rendering event loop and returns the canvas
@@ -307,15 +318,26 @@ def show(
maintain_aspect: bool, default ``True``
maintain aspect ratio
- toolbar: bool, default True
+ toolbar: bool, default ``True``
show toolbar
+ sidecar: bool, default ``True``
+ display plot in a ``jupyterlab-sidecar``
+
+ sidecar_kwargs: dict, default ``None``
+ kwargs for sidecar instance to display plot
+ i.e. title, layout
+
+ vbox: list, default ``None``
+ list of ipywidgets to be displayed with plot
+
Returns
-------
WgpuCanvas
the canvas
"""
+
self.canvas.request_draw(self.render)
self.canvas.set_logical_size(*self._starting_size)
@@ -343,7 +365,38 @@ def show(
0, 0
].camera.maintain_aspect
- return VBox([self.canvas, self.toolbar.widget])
+ # validate vbox if not None
+ if vbox is not None:
+ for widget in vbox:
+ if not isinstance(widget, Widget):
+ raise ValueError(f"Items in vbox must be ipywidgets. Item: {widget} is of type: {type(widget)}")
+ self.vbox = VBox(vbox)
+
+ if not sidecar:
+ if self.vbox is not None:
+ return VBox([self.canvas, self.toolbar.widget, self.vbox])
+ else:
+ return VBox([self.canvas, self.toolbar.widget])
+
+ # used when plot.show() is being called again but sidecar has been closed via "x" button
+ # need to force new sidecar instance
+ # couldn't figure out how to get access to "close" button in order to add observe method on click
+ if self.plot_open:
+ self.sidecar = None
+
+ if self.sidecar is None:
+ if sidecar_kwargs is not None:
+ self.sidecar = Sidecar(**sidecar_kwargs)
+ self.plot_open = True
+ else:
+ self.sidecar = Sidecar()
+ self.plot_open = True
+
+ with self.sidecar:
+ if self.vbox is not None:
+ return display(VBox([self.canvas, self.toolbar.widget, self.vbox]))
+ else:
+ return display(VBox([self.canvas, self.toolbar.widget]))
def close(self):
"""Close the GridPlot"""
@@ -352,6 +405,14 @@ def close(self):
if self.toolbar is not None:
self.toolbar.widget.close()
+ if self.sidecar is not None:
+ self.sidecar.close()
+
+ if self.vbox is not None:
+ self.vbox.close()
+
+ self.plot_open = False
+
def clear(self):
"""Clear all Subplots"""
for subplot in self:
@@ -415,9 +476,9 @@ def __init__(self, plot: GridPlot):
self.flip_camera_button = Button(
value=False,
disabled=False,
- icon="arrows-v",
+ icon="arrow-up",
layout=Layout(width="auto"),
- tooltip="flip",
+ tooltip="y-axis direction",
)
self.record_button = ToggleButton(
@@ -490,7 +551,11 @@ def maintain_aspect(self, obj):
def flip_camera(self, obj):
current = self.current_subplot
- current.camera.world.scale_y *= -1
+ current.camera.local.scale_y *= -1
+ if current.camera.local.scale_y == -1:
+ self.flip_camera_button.icon = "arrow-down"
+ else:
+ self.flip_camera_button.icon = "arrow-up"
def update_current_subplot(self, ev):
for subplot in self.plot:
diff --git a/fastplotlib/layouts/_plot.py b/fastplotlib/layouts/_plot.py
index 1f91bb303..253b6296b 100644
--- a/fastplotlib/layouts/_plot.py
+++ b/fastplotlib/layouts/_plot.py
@@ -7,7 +7,9 @@
from wgpu.gui.auto import WgpuCanvas, is_jupyter
if is_jupyter():
- from ipywidgets import HBox, Layout, Button, ToggleButton, VBox
+ from ipywidgets import HBox, Layout, Button, ToggleButton, VBox, Widget
+ from sidecar import Sidecar
+ from IPython.display import display
from ._subplot import Subplot
from ._record_mixin import RecordMixin
@@ -64,6 +66,9 @@ def __init__(
self._starting_size = size
self.toolbar = None
+ self.sidecar = None
+ self.vbox = None
+ self.plot_open = False
def render(self):
super(Plot, self).render()
@@ -72,7 +77,13 @@ def render(self):
self.canvas.request_draw()
def show(
- self, autoscale: bool = True, maintain_aspect: bool = None, toolbar: bool = True
+ self,
+ autoscale: bool = True,
+ maintain_aspect: bool = None,
+ toolbar: bool = True,
+ sidecar: bool = True,
+ sidecar_kwargs: dict = None,
+ vbox: list = None
):
"""
Begins the rendering event loop and returns the canvas
@@ -85,15 +96,26 @@ def show(
maintain_aspect: bool, default ``None``
maintain aspect ratio, uses ``camera.maintain_aspect`` if ``None``
- toolbar: bool, default True
+ toolbar: bool, default ``True``
show toolbar
+ sidecar: bool, default ``True``
+ display the plot in a ``jupyterlab-sidecar``
+
+ sidecar_kwargs: dict, default ``None``
+ kwargs for sidecar instance to display plot
+ i.e. title, layout
+
+ vbox: list, default ``None``
+ list of ipywidgets to be displayed with plot
+
Returns
-------
WgpuCanvas
the canvas
"""
+
self.canvas.request_draw(self.render)
self.canvas.set_logical_size(*self._starting_size)
@@ -117,7 +139,38 @@ def show(
self.toolbar = ToolBar(self)
self.toolbar.maintain_aspect_button.value = maintain_aspect
- return VBox([self.canvas, self.toolbar.widget])
+ # validate vbox if not None
+ if vbox is not None:
+ for widget in vbox:
+ if not isinstance(widget, Widget):
+ raise ValueError(f"Items in vbox must be ipywidgets. Item: {widget} is of type: {type(widget)}")
+ self.vbox = VBox(vbox)
+
+ if not sidecar:
+ if self.vbox is not None:
+ return VBox([self.canvas, self.toolbar.widget, self.vbox])
+ else:
+ return VBox([self.canvas, self.toolbar.widget])
+
+ # used when plot.show() is being called again but sidecar has been closed via "x" button
+ # need to force new sidecar instance
+ # couldn't figure out how to get access to "close" button in order to add observe method on click
+ if self.plot_open:
+ self.sidecar = None
+
+ if self.sidecar is None:
+ if sidecar_kwargs is not None:
+ self.sidecar = Sidecar(**sidecar_kwargs)
+ self.plot_open = True
+ else:
+ self.sidecar = Sidecar()
+ self.plot_open = True
+
+ with self.sidecar:
+ if self.vbox is not None:
+ return display(VBox([self.canvas, self.toolbar.widget, self.vbox]))
+ else:
+ return display(VBox([self.canvas, self.toolbar.widget]))
def close(self):
"""Close Plot"""
@@ -126,6 +179,14 @@ def close(self):
if self.toolbar is not None:
self.toolbar.widget.close()
+ if self.sidecar is not None:
+ self.sidecar.close()
+
+ if self.vbox is not None:
+ self.vbox.close()
+
+ self.plot_open = False
+
class ToolBar:
def __init__(self, plot: Plot):
@@ -170,7 +231,7 @@ def __init__(self, plot: Plot):
self.flip_camera_button = Button(
value=False,
disabled=False,
- icon="arrows-v",
+ icon="arrow-up",
layout=Layout(width="auto"),
tooltip="flip",
)
@@ -224,7 +285,11 @@ def maintain_aspect(self, obj):
self.plot.camera.maintain_aspect = self.maintain_aspect_button.value
def flip_camera(self, obj):
- self.plot.camera.world.scale_y *= -1
+ self.plot.camera.local.scale_y *= -1
+ if self.plot.camera.local.scale_y == -1:
+ self.flip_camera_button.icon = "arrow-down"
+ else:
+ self.flip_camera_button.icon = "arrow-up"
def add_polygon(self, obj):
ps = PolygonSelector(edge_width=3, edge_color="magenta")
diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py
index 962a94151..9dbad277e 100644
--- a/fastplotlib/widgets/image.py
+++ b/fastplotlib/widgets/image.py
@@ -17,6 +17,8 @@
Play,
jslink,
)
+from sidecar import Sidecar
+from IPython.display import display
from ..layouts import GridPlot
from ..graphics import ImageGraphic
@@ -271,6 +273,8 @@ def __init__(
self._names = None
self.toolbar = None
+ self.sidecar = None
+ self.plot_open = False
if isinstance(data, list):
# verify that it's a list of np.ndarray
@@ -300,7 +304,7 @@ def __init__(
if names is not None:
if not all([isinstance(n, str) for n in names]):
raise TypeError(
- "optinal argument `names` must be a list of str"
+ "optional argument `names` must be a list of str"
)
if len(names) != len(self.data):
@@ -350,7 +354,7 @@ def __init__(
# dict of {array_ix: dims_order_str}
for data_ix in list(dims_order.keys()):
if not isinstance(data_ix, int):
- raise TypeError("`dims_oder` dict keys must be ")
+ raise TypeError("`dims_order` dict keys must be ")
if len(dims_order[data_ix]) != self.ndim:
raise ValueError(
f"number of dims '{len(dims_order)} passed to `dims_order` "
@@ -913,7 +917,7 @@ def set_data(
if reset_vmin_vmax:
self.reset_vmin_vmax()
- def show(self, toolbar: bool = True):
+ def show(self, toolbar: bool = True, sidecar: bool = True, sidecar_kwargs: dict = None):
"""
Show the widget
@@ -930,13 +934,50 @@ def show(self, toolbar: bool = True):
if self.toolbar is None:
self.toolbar = ImageWidgetToolbar(self)
- return VBox(
- [
- self.gridplot.show(toolbar=True),
- self.toolbar.widget,
- self._vbox_sliders,
- ]
- )
+ if not sidecar:
+ return VBox(
+ [
+ self.gridplot.show(toolbar=True, sidecar=False, sidecar_kwargs=None),
+ self.toolbar.widget,
+ self._vbox_sliders,
+ ]
+ )
+
+ if self.plot_open:
+ self.sidecar = None
+
+ if self.sidecar is None:
+ if sidecar_kwargs is not None:
+ self.sidecar = Sidecar(**sidecar_kwargs)
+ self.plot_open = True
+ else:
+ self.sidecar = Sidecar()
+ self.plot_open = True
+
+ with self.sidecar:
+ return display(VBox(
+ [
+ self.gridplot.show(toolbar=True, sidecar=False, sidecar_kwargs=None),
+ self.toolbar.widget,
+ self._vbox_sliders
+ ]
+ )
+ )
+
+ def close(self):
+ """Close Widget"""
+ self.gridplot.canvas.close()
+
+ self._vbox_sliders.close()
+
+ if self.toolbar is not None:
+ self.toolbar.widget.close()
+ self.gridplot.toolbar.widget.close()
+
+ if self.sidecar is not None:
+ self.sidecar.close()
+
+ self.plot_open = False
class ImageWidgetToolbar:
diff --git a/setup.py b/setup.py
index 2616093fc..6557994ef 100644
--- a/setup.py
+++ b/setup.py
@@ -4,7 +4,7 @@
install_requires = [
"numpy>=1.23.0",
- "pygfx>=0.1.13",
+ "pygfx>=0.1.14",
]
@@ -19,14 +19,16 @@
"sphinx-design",
"nbsphinx",
"pandoc",
- "jupyterlab"
+ "jupyterlab",
+ "sidecar"
],
"notebook":
[
"jupyterlab",
"jupyter-rfb>=0.4.1",
- "ipywidgets>=8.0.0,<9"
+ "ipywidgets>=8.0.0,<9",
+ "sidecar"
],
"tests":
@@ -39,7 +41,17 @@
"jupyter-rfb>=0.4.1",
"ipywidgets>=8.0.0,<9",
"scikit-learn",
- "tqdm"
+ "tqdm",
+ "sidecar"
+ ],
+
+ "tests-desktop":
+ [
+ "pytest",
+ "scipy",
+ "imageio",
+ "scikit-learn",
+ "tqdm",
]
}