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",
]
}
pFad - Phonifier reborn
Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies:
Alternative Proxy
pFad Proxy
pFad v3 Proxy
pFad v4 Proxy