From 41a955fd5b0d5df7308bfe3fd021c7a7025cfa2a Mon Sep 17 00:00:00 2001 From: Sangjoon Bob Lee Date: Sat, 1 Aug 2026 20:06:45 -0700 Subject: [PATCH 1/3] chore: remove embedded widget package --- .gitignore | 15 - CONTRIBUTORS.md | 8 - pyproject.toml | 13 +- uv.lock | 182 +- widget/js/colormaps.ts | 1098 ------ widget/js/fft.ts | 474 --- widget/js/figure.ts | 433 --- widget/js/format.ts | 40 - widget/js/show2d/index.tsx | 4268 ---------------------- widget/js/show4dstem/index.tsx | 4066 --------------------- widget/js/stats.ts | 121 - widget/js/theme.ts | 149 - widget/package-lock.json | 1637 --------- widget/package.json | 26 - widget/pyproject.toml | 22 - widget/scripts/build.mjs | 46 - widget/src/quantem/widget/__init__.py | 12 - widget/src/quantem/widget/array_utils.py | 51 - widget/src/quantem/widget/show2d.py | 1174 ------ widget/src/quantem/widget/show4dstem.py | 2334 ------------ widget/src/quantem/widget/state.py | 46 - widget/tests/test_fft_parity.py | 200 - widget/tests/test_state_dict.py | 166 - widget/tests/test_widget.py | 9 - widget/tsconfig.json | 25 - 25 files changed, 35 insertions(+), 16580 deletions(-) delete mode 100644 widget/js/colormaps.ts delete mode 100644 widget/js/fft.ts delete mode 100644 widget/js/figure.ts delete mode 100644 widget/js/format.ts delete mode 100644 widget/js/show2d/index.tsx delete mode 100644 widget/js/show4dstem/index.tsx delete mode 100644 widget/js/stats.ts delete mode 100644 widget/js/theme.ts delete mode 100644 widget/package-lock.json delete mode 100644 widget/package.json delete mode 100644 widget/pyproject.toml delete mode 100644 widget/scripts/build.mjs delete mode 100644 widget/src/quantem/widget/__init__.py delete mode 100644 widget/src/quantem/widget/array_utils.py delete mode 100644 widget/src/quantem/widget/show2d.py delete mode 100644 widget/src/quantem/widget/show4dstem.py delete mode 100644 widget/src/quantem/widget/state.py delete mode 100644 widget/tests/test_fft_parity.py delete mode 100644 widget/tests/test_state_dict.py delete mode 100644 widget/tests/test_widget.py delete mode 100644 widget/tsconfig.json diff --git a/.gitignore b/.gitignore index 3c3252a55..c5bc9776b 100644 --- a/.gitignore +++ b/.gitignore @@ -194,18 +194,3 @@ ipynb-playground/ CLAUDE.md AGENTS.md AGENT.md - -# widget (JS build artifacts) -node_modules/ -widget/src/quantem/widget/static/ - -# widget — local-only (per-developer notebooks, docs scratch, build/test scripts). -# Track only src/, js/, tests/test_*.py for now. -widget/.gitignore -widget/docs/ -widget/notebooks/ -widget/scripts/ -!widget/scripts/build.mjs -widget/tests/integration/ -widget/tests/snapshots/ - diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 993a6a034..6d2a45d64 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -7,14 +7,6 @@ Getting started: - [install uv](https://docs.astral.sh/uv/getting-started/installation/) - `git clone` the repo and `cd` into the directory - run `uv sync` to install all the dependencies in an editable environment -- run `uv sync --all-packages` to also install `quantem.widget` (optional) - -For widget developers (requires [Node.js](https://nodejs.org/)): - -- `cd widget && npm install` to install JS dependencies -- `npm run build` to build the widget -- `npm run dev` to watch for changes during development - The following will set up the pre-commit and [ruff](https://github.com/astral-sh/ruff) for linting and formatting. These commands only need to be run once when first setting up your dev environment: - `uv tool install pre-commit` diff --git a/pyproject.toml b/pyproject.toml index 4df9fc229..2a63c5843 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" addopts = [ "--import-mode=importlib", ] -testpaths = ["tests", "widget/tests"] +testpaths = ["tests"] [tool.ruff.lint] select = ["E4","E7","E9","F","I"] @@ -21,12 +21,6 @@ line-length = 99 [tool.uv] config-settings = { editable_mode = "compat" } -[tool.uv.workspace] -members = ["widget"] - -[tool.uv.sources] -"quantem.widget" = { workspace = true } - [project] name = "quantem" version = "0.1.9" @@ -56,11 +50,6 @@ dependencies = [ "torchinfo>=1.8.0", ] -[project.optional-dependencies] -widgets = [ - "quantem.widget", -] - [tool.hatch.build.targets.sdist] # hatchling always includes: # pyproject.toml, .gitignore, any README, any LICENSE, AUTHORS diff --git a/uv.lock b/uv.lock index 36a6293e2..293c212d1 100644 --- a/uv.lock +++ b/uv.lock @@ -7,12 +7,6 @@ resolution-markers = [ "python_full_version < '3.12'", ] -[manifest] -members = [ - "quantem", - "quantem-widget", -] - [[package]] name = "absl-py" version = "2.5.0" @@ -49,20 +43,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] -[[package]] -name = "anywidget" -version = "0.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ipywidgets" }, - { name = "psygnal" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/31/0491d707c674b34267f55d96d6a7148e55e7b6718a271686232cf295fbe2/anywidget-0.11.0.tar.gz", hash = "sha256:6695fbef9449cf8c27f421b96c5837aa37f909ec1f60cfa33add333e1b70b169", size = 426999, upload-time = "2026-04-27T23:42:09.576Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/c2/8fec8e8e2eb920cc2280f569144080cd58622a2eda83bfa4c0c354a63264/anywidget-0.11.0-py3-none-any.whl", hash = "sha256:c574d9acc6503ad27b37a9acea48f957a8ba7c9c9876cfcb37898931c098ce9d", size = 317341, upload-time = "2026-04-27T23:42:08.356Z" }, -] - [[package]] name = "appnope" version = "0.1.4" @@ -673,43 +653,43 @@ wheels = [ [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] cudart = [ - { name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] cufft = [ - { name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] cufile = [ - { name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] curand = [ - { name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] cusolver = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] [[package]] @@ -1224,7 +1204,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -1299,22 +1279,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, ] -[[package]] -name = "ipywidgets" -version = "8.1.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "comm" }, - { name = "ipython" }, - { name = "jupyterlab-widgets" }, - { name = "traitlets" }, - { name = "widgetsnbextension" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4c/ae/c5ce1edc1afe042eadb445e95b0671b03cee61895264357956e61c0d2ac0/ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668", size = 116739, upload-time = "2025-11-01T21:18:12.393Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, -] - [[package]] name = "isoduration" version = "20.11.0" @@ -1578,15 +1542,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/07/a000fe835f76b7e1143242ab1122e6362ef1c03f23f83a045c38859c2ae0/jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968", size = 59830, upload-time = "2025-10-22T13:59:16.767Z" }, ] -[[package]] -name = "jupyterlab-widgets" -version = "3.0.16" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/2d/ef58fed122b268c69c0aa099da20bc67657cdfb2e222688d5731bd5b971d/jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0", size = 897423, upload-time = "2025-11-01T21:11:29.724Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, -] - [[package]] name = "kiwisolver" version = "1.5.0" @@ -2620,35 +2575,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] -[[package]] -name = "psygnal" -version = "0.15.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/79/20c3e23e75272e9ddf018097cf872ab088bccba978888472656629efa4a3/psygnal-0.15.1.tar.gz", hash = "sha256:f64f62dee2306fc1c22050a59b6c6cdad126e04b0cf50e393ff858a1da719096", size = 123147, upload-time = "2026-01-04T16:38:41.959Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/a7/69495410025cc4298765545ce3b8c635cd4c8d3a362b7fbbc15b80e9fc8f/psygnal-0.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1adc41515f648696990964433f1e25d8dfd306813a3645366c85e01986ba57a0", size = 581002, upload-time = "2026-01-04T16:38:12.753Z" }, - { url = "https://files.pythonhosted.org/packages/75/1f/19a8126ccf3cd3974ba5d08a435a049b666961d90f5848ba83599d7a29de/psygnal-0.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:38ff18455b2ac73d4e8eea82ef298ce904b52e4dfdc603a24380c9c440e37519", size = 567775, upload-time = "2026-01-04T16:38:14.04Z" }, - { url = "https://files.pythonhosted.org/packages/54/c5/b1348880d603edb82128a721397a1ddcf3dfcf5384fe5689db6e471118ae/psygnal-0.15.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c923c322eeefb1140886927cfe7bda7c32341087e290e812b9c69a624ab72d54", size = 855961, upload-time = "2026-01-04T16:38:15.612Z" }, - { url = "https://files.pythonhosted.org/packages/e6/42/3da2d6f3583bd1a849f7faa2fd3492b14bfda05012519ceaea5992658af0/psygnal-0.15.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2714ddaa41ea3134c0ee91cebd5fb11a88f254ea1d5948806ab0ad5f8be603d5", size = 862721, upload-time = "2026-01-04T16:38:17.059Z" }, - { url = "https://files.pythonhosted.org/packages/4d/14/6fc7e97fdecf7e8c5c105684bab784920312a3259800d8b53e3cf8783f42/psygnal-0.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:877516056a5a383427a647fff2fad5179eaa3e12de2c083c273e748435414aef", size = 415696, upload-time = "2026-01-04T16:38:18.355Z" }, - { url = "https://files.pythonhosted.org/packages/76/65/b7bbca96bc477aa9ac2264e5907b2f4ccfcd1319f776dd1f35eec06cc2f4/psygnal-0.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d56f0f35eaf4a21f660de76885222faf9e8c7112454528d3394d464f3d4d1a3", size = 598340, upload-time = "2026-01-04T16:38:19.752Z" }, - { url = "https://files.pythonhosted.org/packages/40/f2/56577465a1b42a5e6780bb5fab53fb68f8bfd72f0131ed397576529af724/psygnal-0.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0febcf757a1323d9b8bd75735ee3569213d8110012a7bf0f478e85c5ab459fc6", size = 575311, upload-time = "2026-01-04T16:38:21.137Z" }, - { url = "https://files.pythonhosted.org/packages/79/81/f642ac08104049383076f83480ed412c9626e068769a1c34873c595bec0e/psygnal-0.15.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b5e4837dfbfa4974dabe0795e32be9aadcd87603adf734738ce1114f72238a05", size = 889770, upload-time = "2026-01-04T16:38:22.629Z" }, - { url = "https://files.pythonhosted.org/packages/de/43/e571fa40b72780abed080ef829e5ad98017b6fe48d28c15a2404e006b676/psygnal-0.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07b4c4e03bbf4e8cad7e25f4fbc1ba9575fb9c3d14991bc7edfeb8b09c8d6d54", size = 881105, upload-time = "2026-01-04T16:38:23.896Z" }, - { url = "https://files.pythonhosted.org/packages/e3/26/ef3ab825eb08eaecbbceeeb56383694fe64ce399dbfd1d0767bb85688785/psygnal-0.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:4f0ce91b9c18e92281bf2c3fc4bb4e808d90f0b023d0a37b302d354188520338", size = 418969, upload-time = "2026-01-04T16:38:25.731Z" }, - { url = "https://files.pythonhosted.org/packages/46/21/5a142165d27063abf5921807d3c3d973f5d44ab414a13b210839a43ead4d/psygnal-0.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2087aadc9404f007f79c2899e329932869e362c50de58b90631c5f49b4768cc5", size = 596768, upload-time = "2026-01-04T16:38:27.053Z" }, - { url = "https://files.pythonhosted.org/packages/e1/25/c1712931d61c118691e73daf29ef708c679ea9ba187c797dd5deee360411/psygnal-0.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f3bf68ca42569dfdce20c6cf915d34b78b9e3ddddacb9f78728224fda6946b4", size = 574808, upload-time = "2026-01-04T16:38:28.779Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4f/3593e5adb88a188c798604aed95fbc1479f30230e7f51e8f2c770e6a3832/psygnal-0.15.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e9fca977f5335deea39aed22e31d9795983e4f243e59a7d3c4105793adb7693d", size = 885616, upload-time = "2026-01-04T16:38:30.081Z" }, - { url = "https://files.pythonhosted.org/packages/58/4c/14779ed4c3a1d71fa1a9a87ecfb184ad3335dd64681067f77c1c47b14ae9/psygnal-0.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c85b7d05b92ccbec47c75ab8a5545eda462e81a492c82424aba5ab81a3ad89d", size = 876516, upload-time = "2026-01-04T16:38:31.422Z" }, - { url = "https://files.pythonhosted.org/packages/3e/bc/4f771e3cdcde4db4023dbf36d6f0aab44e02b9de719353c22954b655e2ff/psygnal-0.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac0e693b29e0a429e97315a52313321855bef6140e9975b7ae78b4d93c8fbb42", size = 419172, upload-time = "2026-01-04T16:38:32.82Z" }, - { url = "https://files.pythonhosted.org/packages/f4/2e/975bd61727578d88df62797f78390965ca7905780cf01eb59cb095a13638/psygnal-0.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:803fc33c4280c822c6f4b22e6c3ea7c4483e190f3cc69e69350098b3799476f3", size = 595706, upload-time = "2026-01-04T16:38:34.139Z" }, - { url = "https://files.pythonhosted.org/packages/b8/55/e487f1d91497eb75e86c3fdfef69a21b1cab24d023383dd7648b08797d6a/psygnal-0.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4f53b4b83355b0a785b745987fd04e59bbf169a9028ed81a68ca7e05fb76d458", size = 575133, upload-time = "2026-01-04T16:38:35.448Z" }, - { url = "https://files.pythonhosted.org/packages/bf/2f/f286355accd0e68d3eef52e63c8b9ab6ba33ec3107177719a036b3319657/psygnal-0.15.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bcbca12190f5aa65c1f8fb04a81fa6f4463c5f5dde25cd74c3a56ceff6f37b02", size = 889565, upload-time = "2026-01-04T16:38:37.003Z" }, - { url = "https://files.pythonhosted.org/packages/fc/dc/40c6026c88d7f9220ecc913afe0501045a512c9b82f9b7e036bb089dc287/psygnal-0.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1ac399566852fe4354ce26a1acbe12319232e8c2b615fe5ad1e114c547095cf6", size = 880863, upload-time = "2026-01-04T16:38:38.381Z" }, - { url = "https://files.pythonhosted.org/packages/b7/85/b4f45ec3057c473b5622fc002b3a636a698c34d3a0917a064ff5247f1984/psygnal-0.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:d3a03055f331ce91d44581c71edb79938ccc133a94af2ce7ad3a18fa57ac7be5", size = 423654, upload-time = "2026-01-04T16:38:39.7Z" }, - { url = "https://files.pythonhosted.org/packages/46/49/7742544684bee728ec123515d2694cee859aa2a705951a461230b00f18cc/psygnal-0.15.1-py3-none-any.whl", hash = "sha256:4221140e633e45b076953c64bcb9b41a744833527f9a037c1ca98bc270798cbf", size = 90638, upload-time = "2026-01-04T16:38:40.841Z" }, -] - [[package]] name = "ptyprocess" version = "0.7.0" @@ -2939,11 +2865,6 @@ dependencies = [ { name = "zarr", version = "3.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -[package.optional-dependencies] -widgets = [ - { name = "quantem-widget" }, -] - [package.dev-dependencies] dev = [ { name = "jupyterlab" }, @@ -2969,7 +2890,6 @@ requires-dist = [ { name = "matplotlib" }, { name = "numpy", specifier = ">2" }, { name = "optuna", specifier = ">=4.5.0" }, - { name = "quantem-widget", marker = "extra == 'widgets'", editable = "widget" }, { name = "rosettasciio", specifier = ">=0.8.0" }, { name = "scikit-image", specifier = ">=0.25.2" }, { name = "scipy" }, @@ -2981,7 +2901,6 @@ requires-dist = [ { name = "tqdm" }, { name = "zarr", specifier = ">3" }, ] -provides-extras = ["widgets"] [package.metadata.requires-dev] dev = [ @@ -2998,30 +2917,6 @@ test = [ { name = "pytest-cov", specifier = ">=7.0.0" }, ] -[[package]] -name = "quantem-widget" -version = "0.0.1" -source = { editable = "widget" } -dependencies = [ - { name = "anywidget" }, - { name = "matplotlib" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pillow" }, - { name = "torch" }, - { name = "traitlets" }, -] - -[package.metadata] -requires-dist = [ - { name = "anywidget", specifier = ">=0.9.0" }, - { name = "matplotlib", specifier = ">=3.7.0" }, - { name = "numpy", specifier = ">=2.0.0" }, - { name = "pillow", specifier = ">=10.0.0" }, - { name = "torch", specifier = ">=2.0.0" }, - { name = "traitlets", specifier = ">=5.0.0" }, -] - [[package]] name = "referencing" version = "0.37.0" @@ -3363,7 +3258,7 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -3438,7 +3333,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14'", ] dependencies = [ - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [ @@ -3647,7 +3542,7 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/cb/2f6d79c7576e22c116352a801f4c3c8ace5957e9aced862012430b62e14f/tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7", size = 388745, upload-time = "2026-03-03T19:14:38.134Z" } wheels = [ @@ -3663,7 +3558,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14'", ] dependencies = [ - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/20/2f/e5fe51c8f782241d86fdf7251594b195f0d6c2fcf9d389079de212599246/tifffile-2026.7.14.tar.gz", hash = "sha256:ce2703e5ef22c868f1528d5f5b4ef75eefb019cf628a1c9ec0d17e0afeca8ef5", size = 437660, upload-time = "2026-07-14T23:41:31.737Z" } wheels = [ @@ -4000,15 +3895,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, ] -[[package]] -name = "widgetsnbextension" -version = "4.0.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b7404b7aefcd7569a9c0d6bd071299bf4198ae7a5d95/widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9", size = 1097402, upload-time = "2025-11-01T21:15:55.178Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, -] - [[package]] name = "zarr" version = "3.1.6" @@ -4017,12 +3903,12 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "donfig" }, - { name = "google-crc32c" }, - { name = "numcodecs" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "typing-extensions" }, + { name = "donfig", marker = "python_full_version < '3.12'" }, + { name = "google-crc32c", marker = "python_full_version < '3.12'" }, + { name = "numcodecs", marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version < '3.12'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/31/5a/b8a0cf39a14c770c30bd1f2d120c54000c8cd9e84e8e79f38d9a7ce58071/zarr-3.1.6.tar.gz", hash = "sha256:d95e72cbea4b90e9a70679468b8266400331756232576ae2b43400ac5108d0eb", size = 386531, upload-time = "2026-03-23T17:25:18.748Z" } wheels = [ @@ -4038,12 +3924,12 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14'", ] dependencies = [ - { name = "donfig" }, - { name = "google-crc32c" }, - { name = "numcodecs" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "typing-extensions" }, + { name = "donfig", marker = "python_full_version >= '3.12'" }, + { name = "google-crc32c", marker = "python_full_version >= '3.12'" }, + { name = "numcodecs", marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/93/8d/aeb164004f87543b06ef54f885d02c342c31ceb274e2bbec470a98927621/zarr-3.2.1.tar.gz", hash = "sha256:71565b738a0e7e8ed226f0516eba8c6bb53440ad7669a8c48ebb3534a161d035", size = 675161, upload-time = "2026-05-05T12:37:22.383Z" } wheels = [ diff --git a/widget/js/colormaps.ts b/widget/js/colormaps.ts deleted file mode 100644 index 40a940b27..000000000 --- a/widget/js/colormaps.ts +++ /dev/null @@ -1,1098 +0,0 @@ -// ============================================================================ -// Color palettes (LUT control points) -// ============================================================================ - -const COLORMAP_POINTS: Record = { - inferno: [ - [0, 0, 4], [40, 11, 84], [101, 21, 110], [159, 42, 99], - [212, 72, 66], [245, 125, 21], [252, 193, 57], [252, 255, 164], - ], - viridis: [ - [68, 1, 84], [72, 36, 117], [65, 68, 135], [53, 95, 141], - [42, 120, 142], [33, 145, 140], [34, 168, 132], [68, 191, 112], - [122, 209, 81], [189, 223, 38], [253, 231, 37], - ], - plasma: [ - [13, 8, 135], [75, 3, 161], [126, 3, 168], [168, 34, 150], - [203, 70, 121], [229, 107, 93], [248, 148, 65], [253, 195, 40], [240, 249, 33], - ], - magma: [ - [0, 0, 4], [28, 16, 68], [79, 18, 123], [129, 37, 129], - [181, 54, 122], [229, 80, 100], [251, 135, 97], [254, 194, 135], [252, 253, 191], - ], - hot: [ - [0, 0, 0], [87, 0, 0], [173, 0, 0], [255, 0, 0], - [255, 87, 0], [255, 173, 0], [255, 255, 0], [255, 255, 128], [255, 255, 255], - ], - gray: [[0, 0, 0], [255, 255, 255]], - hsv: [ - [255, 0, 0], [255, 255, 0], [0, 255, 0], [0, 255, 255], - [0, 0, 255], [255, 0, 255], [255, 0, 0], - ], - turbo: [ - [48, 18, 59], [69, 55, 161], [66, 107, 230], [30, 162, 230], - [29, 212, 169], [79, 241, 89], [175, 240, 32], [244, 195, 12], - [248, 118, 11], [207, 46, 3], [122, 4, 2], - ], - RdBu: [ - [103, 0, 31], [178, 24, 43], [214, 96, 77], [244, 165, 130], - [253, 219, 199], [247, 247, 247], [209, 229, 240], [146, 197, 222], - [67, 147, 195], [33, 102, 172], [5, 48, 97], - ], -}; - -export const COLORMAP_NAMES = Object.keys(COLORMAP_POINTS); - -function createColormapLUT(points: number[][]): Uint8Array { - const lut = new Uint8Array(256 * 3); - for (let i = 0; i < 256; i++) { - const t = (i / 255) * (points.length - 1); - const idx = Math.floor(t); - const frac = t - idx; - const p0 = points[Math.min(idx, points.length - 1)]; - const p1 = points[Math.min(idx + 1, points.length - 1)]; - lut[i * 3] = Math.round(p0[0] + frac * (p1[0] - p0[0])); - lut[i * 3 + 1] = Math.round(p0[1] + frac * (p1[1] - p0[1])); - lut[i * 3 + 2] = Math.round(p0[2] + frac * (p1[2] - p0[2])); - } - return lut; -} - -export const COLORMAPS: Record = Object.fromEntries( - Object.entries(COLORMAP_POINTS).map(([name, points]) => [name, createColormapLUT(points)]) -); - -// ============================================================================ -// CPU colormap (Float32 -> RGBA via 256-entry LUT) -// ============================================================================ - -/** Apply colormap LUT to float data, writing into an RGBA Uint8ClampedArray. */ -export function applyColormap( - data: Float32Array, - rgba: Uint8ClampedArray, - lut: Uint8Array, - vmin: number, - vmax: number, -): void { - const range = vmax > vmin ? vmax - vmin : 1; - const uniformData = !(vmax > vmin); - for (let i = 0; i < data.length; i++) { - const clipped = Math.max(vmin, Math.min(vmax, data[i])); - const v = uniformData ? 128 : Math.min(255, Math.floor(((clipped - vmin) / range) * 255)); - const j = i * 4; - const lutIdx = v * 3; - rgba[j] = lut[lutIdx]; - rgba[j + 1] = lut[lutIdx + 1]; - rgba[j + 2] = lut[lutIdx + 2]; - rgba[j + 3] = 255; - } -} - -/** Create an offscreen canvas with colormapped data. Returns null if context unavailable. */ -export function renderToOffscreen( - data: Float32Array, - width: number, - height: number, - lut: Uint8Array, - vmin: number, - vmax: number, -): HTMLCanvasElement | null { - const offscreen = document.createElement("canvas"); - offscreen.width = width; - offscreen.height = height; - const ctx = offscreen.getContext("2d"); - if (!ctx) return null; - const imgData = ctx.createImageData(width, height); - applyColormap(data, imgData.data, lut, vmin, vmax); - ctx.putImageData(imgData, 0, 0); - return offscreen; -} - -/** Render colormapped data to a reusable offscreen canvas + ImageData (avoids per-frame allocation). */ -export function renderToOffscreenReuse( - data: Float32Array, - lut: Uint8Array, - vmin: number, - vmax: number, - offscreen: HTMLCanvasElement, - imgData: ImageData, -): void { - applyColormap(data, imgData.data, lut, vmin, vmax); - offscreen.getContext("2d")!.putImageData(imgData, 0, 0); -} - -// ============================================================================ -// WebGPU-accelerated colormap engine -// ============================================================================ - -// 2D dispatch (16×16 workgroups) to stay within WebGPU's 65535 workgroup limit. -// 1D dispatch with wg=256 needs ceil(4096*4096/256)=65536 — exceeds the limit by 1. -// ============================================================================ -// WebGPU colormap engine (compute shader, ~300x faster than CPU loop on 4K data) -// ============================================================================ - -const COLORMAP_SHADER = /* wgsl */ ` -struct Params { - width: u32, - height: u32, - vmin: f32, - vmax: f32, - log_scale: u32, - _pad: u32, -}; - -@group(0) @binding(0) var params: Params; -@group(0) @binding(1) var data: array; -@group(0) @binding(2) var lut: array; -@group(0) @binding(3) var rgba: array; - -@compute @workgroup_size(16, 16) -fn main(@builtin(global_invocation_id) gid: vec3u) { - if (gid.x >= params.width || gid.y >= params.height) { return; } - let idx = gid.y * params.width + gid.x; - var val = data[idx]; - if (params.log_scale == 1u) { - val = log(1.0 + max(val, 0.0)); - } - let range = max(params.vmax - params.vmin, 1e-30); - let clipped = clamp(val, params.vmin, params.vmax); - let t = (clipped - params.vmin) / range; - let lutIdx = min(u32(t * 255.0), 255u); - let rgb = lut[lutIdx]; - // Simplified: LUT is already packed as R|(G<<8)|(B<<16), just add alpha - rgba[idx] = rgb | 0xFF000000u; -} -`; - -// Fullscreen-quad blit shader: reads RGBA u32 buffer, renders to canvas texture -const BLIT_SHADER = /* wgsl */ ` -struct BlitParams { width: u32, height: u32 }; -@group(0) @binding(0) var params: BlitParams; -@group(0) @binding(1) var rgba: array; - -struct VSOut { @builtin(position) pos: vec4f, @location(0) uv: vec2f }; - -@vertex fn vs(@builtin(vertex_index) vi: u32) -> VSOut { - // Fullscreen triangle (3 vertices, covers entire clip space) - var out: VSOut; - let x = f32(i32(vi & 1u)) * 4.0 - 1.0; - let y = f32(i32(vi >> 1u)) * 4.0 - 1.0; - out.pos = vec4f(x, y, 0.0, 1.0); - out.uv = vec2f((x + 1.0) * 0.5, (1.0 - y) * 0.5); - return out; -} - -@fragment fn fs(in: VSOut) -> @location(0) vec4f { - let px = u32(in.uv.x * f32(params.width)); - let py = u32(in.uv.y * f32(params.height)); - let idx = py * params.width + px; - let packed = rgba[idx]; - let r = f32(packed & 0xFFu) / 255.0; - let g = f32((packed >> 8u) & 0xFFu) / 255.0; - let b = f32((packed >> 16u) & 0xFFu) / 255.0; - return vec4f(r, g, b, 1.0); -} -`; - -/** - * GPU-accelerated colormap engine. Holds persistent data buffers on GPU; - * histogram slider changes only update a small uniform — no data re-upload. - */ -type GPUSlot = { - dataBuffer: GPUBuffer; - rgbaBuffer: GPUBuffer; - readBuffer: GPUBuffer; - paramsBuffer: GPUBuffer; - histBinsBuffer: GPUBuffer; - histReadBuffer: GPUBuffer; - count: number; - width: number; - height: number; -}; - -export class GPUColormapEngine { - private device: GPUDevice; - private pipeline: GPUComputePipeline | null = null; - private blitPipeline: GPURenderPipeline | null = null; - // Per-image GPU state: persistent buffers (data, rgba, read, params, histogram) - private slots: GPUSlot[] = []; - private lutBuffer: GPUBuffer | null = null; - private currentLutName: string = ""; - - constructor(device: GPUDevice) { this.device = device; } - - private ensurePipeline(): void { - if (this.pipeline) return; - const module = this.device.createShaderModule({ code: COLORMAP_SHADER }); - this.pipeline = this.device.createComputePipeline({ - layout: "auto", - compute: { module, entryPoint: "main" }, - }); - } - - /** Upload LUT to GPU (only when colormap name changes). */ - uploadLUT(lutName: string, lut: Uint8Array): void { - if (this.currentLutName === lutName && this.lutBuffer) return; - this.ensurePipeline(); - if (this.lutBuffer) this.lutBuffer.destroy(); - // Pack RGB triplets into u32 for GPU (R in low bits) - const packed = new Uint32Array(256); - for (let i = 0; i < 256; i++) { - packed[i] = lut[i * 3] | (lut[i * 3 + 1] << 8) | (lut[i * 3 + 2] << 16); - } - this.lutBuffer = this.device.createBuffer({ - size: packed.byteLength, - usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, - }); - this.device.queue.writeBuffer(this.lutBuffer, 0, packed); - this.currentLutName = lutName; - } - - - /** Upload float32 image data for slot `idx`. Only call when data changes. */ - uploadData(idx: number, data: Float32Array, width?: number, height?: number): void { - this.ensurePipeline(); - while (this.slots.length <= idx) this.slots.push(null as never); - if (this.slots[idx]) { - this.slots[idx].dataBuffer.destroy(); - this.slots[idx].rgbaBuffer.destroy(); - this.slots[idx].readBuffer.destroy(); - this.slots[idx].paramsBuffer.destroy(); - this.slots[idx].histBinsBuffer.destroy(); - this.slots[idx].histReadBuffer.destroy(); - } - // Validate dimensions — if width*height doesn't match data length, derive from sqrt - // (catches stale closure values like width=1 from mount effects) - const validDims = width && height && width > 1 && height > 1 && width * height === data.length; - const w = validDims ? width : Math.round(Math.sqrt(data.length)); - const h = validDims ? height : Math.round(data.length / w); - const byteSize = data.byteLength; - const rgbaSize = data.length * 4; - const dataBuffer = this.device.createBuffer({ - size: byteSize, - usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, - }); - this.device.queue.writeBuffer(dataBuffer, 0, data.buffer as ArrayBuffer, data.byteOffset, data.byteLength); - const rgbaBuffer = this.device.createBuffer({ - size: rgbaSize, - usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC, - }); - // Persistent read buffer — reused on every applySlots call (no create/destroy overhead) - const readBuffer = this.device.createBuffer({ - size: rgbaSize, - usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, - }); - // Persistent params buffer — reused (just writeBuffer on each call) - const paramsBuffer = this.device.createBuffer({ - size: 24, - usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, - }); - // Persistent histogram buffers (256 bins × 4 bytes = 1KB each) - const histBinsBuffer = this.device.createBuffer({ - size: 256 * 4, - usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC, - }); - const histReadBuffer = this.device.createBuffer({ - size: 256 * 4, - usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, - }); - this.slots[idx] = { dataBuffer, rgbaBuffer, readBuffer, paramsBuffer, histBinsBuffer, histReadBuffer, count: data.length, width: w, height: h }; - } - - // Params buffer: 24 bytes = { width: u32, height: u32, vmin: f32, vmax: f32, log_scale: u32, _pad: u32 } - private _writeParams(buf: ArrayBuffer, width: number, height: number, vmin: number, vmax: number, logScale: boolean): void { - const u = new Uint32Array(buf); - const f = new Float32Array(buf); - u[0] = width; - u[1] = height; - f[2] = vmin; - f[3] = vmax; - u[4] = logScale ? 1 : 0; - u[5] = 0; // pad - } - - /** - * Apply colormap to specific slot indices with per-image vmin/vmax. - * Uses persistent per-slot read buffers (no create/destroy overhead). - * Log scale is applied on GPU per pixel. - */ - async applySlots( - indices: number[], - ranges: { vmin: number; vmax: number }[], - logScale: boolean = false, - ): Promise<{ idx: number; rgba: Uint8ClampedArray }[]> { - if (!this.pipeline || !this.lutBuffer || indices.length === 0) return []; - - const activeSlots: { idx: number; slot: GPUSlot; count: number }[] = []; - const encoder = this.device.createCommandEncoder(); - const params = new ArrayBuffer(24); - - for (let k = 0; k < indices.length; k++) { - const i = indices[k]; - const slot = this.slots[i]; - if (!slot) continue; - const range = ranges[k] || { vmin: 0, vmax: 1 }; - - // Reuse persistent paramsBuffer — just write new values - this._writeParams(params, slot.width, slot.height, range.vmin, range.vmax, logScale); - this.device.queue.writeBuffer(slot.paramsBuffer, 0, params); - - const bindGroup = this.device.createBindGroup({ - layout: this.pipeline.getBindGroupLayout(0), - entries: [ - { binding: 0, resource: { buffer: slot.paramsBuffer } }, - { binding: 1, resource: { buffer: slot.dataBuffer } }, - { binding: 2, resource: { buffer: this.lutBuffer } }, - { binding: 3, resource: { buffer: slot.rgbaBuffer } }, - ], - }); - - const pass = encoder.beginComputePass(); - pass.setPipeline(this.pipeline); - pass.setBindGroup(0, bindGroup); - pass.dispatchWorkgroups(Math.ceil(slot.width / 16), Math.ceil(slot.height / 16)); - pass.end(); - - // Copy to persistent read buffer - encoder.copyBufferToBuffer(slot.rgbaBuffer, 0, slot.readBuffer, 0, slot.count * 4); - activeSlots.push({ idx: i, slot, count: slot.count }); - } - this.device.queue.submit([encoder.finish()]); - await Promise.all(activeSlots.map(s => s.slot.readBuffer.mapAsync(GPUMapMode.READ))); - - const results: { idx: number; rgba: Uint8ClampedArray }[] = []; - for (const s of activeSlots) { - const mapped = s.slot.readBuffer.getMappedRange(); - const rgba = new Uint8ClampedArray(s.count * 4); - rgba.set(new Uint8ClampedArray(mapped)); - s.slot.readBuffer.unmap(); - results.push({ idx: s.idx, rgba }); - } - - // applySlots is for callers that need raw RGBA arrays (not rendering to canvas) - // For rendering, use renderSlots which avoids the intermediate copy - return results; - } - - /** Apply colormap to ALL slots with shared vmin/vmax. */ - async apply(vmin: number, vmax: number, logScale: boolean = false): Promise { - const indices = this.slots.map((_, i) => i).filter(i => this.slots[i]); - const ranges = indices.map(() => ({ vmin, vmax })); - const results = await this.applySlots(indices, ranges, logScale); - // Return in slot order - const out: Uint8ClampedArray[] = []; - for (const r of results) out[r.idx] = r.rgba; - return out.filter(x => x); - } - - /** Apply colormap with per-image vmin/vmax. */ - async applyPerImage(ranges: { vmin: number; vmax: number }[], logScale: boolean = false): Promise { - const indices = this.slots.map((_, i) => i).filter(i => this.slots[i]); - const perSlotRanges = indices.map(i => ranges[i] || { vmin: 0, vmax: 1 }); - const results = await this.applySlots(indices, perSlotRanges, logScale); - const out: Uint8ClampedArray[] = []; - for (const r of results) out[r.idx] = r.rgba; - return out.filter(x => x); - } - - /** Apply colormap to a SINGLE slot (fast path for slider drag). */ - async applySingle(idx: number, vmin: number, vmax: number, logScale: boolean = false): Promise { - const results = await this.applySlots([idx], [{ vmin, vmax }], logScale); - return results.length > 0 ? results[0].rgba : null; - } - - /** - * GPU colormap → offscreen canvas in one pass (zero intermediate allocation). - * Writes from GPU mapped memory directly into ImageData, then putImageData. - * Eliminates the 768MB temp Uint8ClampedArray that applySlots allocates. - */ - async renderSlots( - indices: number[], - ranges: { vmin: number; vmax: number }[], - offscreens: (HTMLCanvasElement | null)[], - imgDatas: (ImageData | null)[], - logScale: boolean = false, - ): Promise { - if (!this.pipeline || !this.lutBuffer || indices.length === 0) return 0; - - const activeSlots: { k: number; idx: number; slot: GPUSlot }[] = []; - const encoder = this.device.createCommandEncoder(); - const params = new ArrayBuffer(24); - - for (let k = 0; k < indices.length; k++) { - const i = indices[k]; - const slot = this.slots[i]; - if (!slot || !offscreens[k] || !imgDatas[k]) continue; - const range = ranges[k] || { vmin: 0, vmax: 1 }; - - this._writeParams(params, slot.width, slot.height, range.vmin, range.vmax, logScale); - this.device.queue.writeBuffer(slot.paramsBuffer, 0, params); - - const bindGroup = this.device.createBindGroup({ - layout: this.pipeline.getBindGroupLayout(0), - entries: [ - { binding: 0, resource: { buffer: slot.paramsBuffer } }, - { binding: 1, resource: { buffer: slot.dataBuffer } }, - { binding: 2, resource: { buffer: this.lutBuffer } }, - { binding: 3, resource: { buffer: slot.rgbaBuffer } }, - ], - }); - - const pass = encoder.beginComputePass(); - pass.setPipeline(this.pipeline); - pass.setBindGroup(0, bindGroup); - pass.dispatchWorkgroups(Math.ceil(slot.width / 16), Math.ceil(slot.height / 16)); - pass.end(); - encoder.copyBufferToBuffer(slot.rgbaBuffer, 0, slot.readBuffer, 0, slot.count * 4); - activeSlots.push({ k, idx: i, slot }); - } - this.device.queue.submit([encoder.finish()]); - await Promise.all(activeSlots.map(s => s.slot.readBuffer.mapAsync(GPUMapMode.READ))); - - // Write directly from GPU mapped memory → ImageData → offscreen canvas - let rendered = 0; - for (const s of activeSlots) { - const mapped = s.slot.readBuffer.getMappedRange(); - const imgData = imgDatas[s.k]!; - imgData.data.set(new Uint8ClampedArray(mapped)); - s.slot.readBuffer.unmap(); - offscreens[s.k]!.getContext("2d")!.putImageData(imgData, 0, 0); - rendered++; - } - return rendered; - } - - private ensureBlitPipeline(format: GPUTextureFormat): void { - if (this.blitPipeline) return; - const module = this.device.createShaderModule({ code: BLIT_SHADER }); - this.blitPipeline = this.device.createRenderPipeline({ - layout: "auto", - vertex: { module, entryPoint: "vs" }, - fragment: { - module, entryPoint: "fs", - targets: [{ format }], - }, - primitive: { topology: "triangle-list" }, - }); - } - - /** - * Zero-copy GPU render: compute colormap + blit directly to WebGPU canvas textures. - * No mapAsync, no CPU copy, no putImageData. Target: <16ms for 60fps. - * - * Each canvas must have a 'webgpu' context (not '2d'). Call configureCanvas() first. - * Returns the number of images rendered. - */ - renderSlotsZeroCopy( - indices: number[], - ranges: { vmin: number; vmax: number }[], - contexts: (GPUCanvasContext | null)[], - logScale: boolean = false, - ): number { - if (!this.pipeline || !this.lutBuffer || indices.length === 0) return 0; - - // Get texture format from first valid context - const fmt = navigator.gpu.getPreferredCanvasFormat(); - this.ensureBlitPipeline(fmt); - if (!this.blitPipeline) return 0; - - const encoder = this.device.createCommandEncoder(); - const params = new ArrayBuffer(24); - let rendered = 0; - - for (let k = 0; k < indices.length; k++) { - const i = indices[k]; - const slot = this.slots[i]; - const ctx = contexts[k]; - if (!slot || !ctx) continue; - const range = ranges[k] || { vmin: 0, vmax: 1 }; - - // 1. Compute colormap (same as renderSlots) - this._writeParams(params, slot.width, slot.height, range.vmin, range.vmax, logScale); - this.device.queue.writeBuffer(slot.paramsBuffer, 0, params); - - const computeGroup = this.device.createBindGroup({ - layout: this.pipeline.getBindGroupLayout(0), - entries: [ - { binding: 0, resource: { buffer: slot.paramsBuffer } }, - { binding: 1, resource: { buffer: slot.dataBuffer } }, - { binding: 2, resource: { buffer: this.lutBuffer } }, - { binding: 3, resource: { buffer: slot.rgbaBuffer } }, - ], - }); - const computePass = encoder.beginComputePass(); - computePass.setPipeline(this.pipeline); - computePass.setBindGroup(0, computeGroup); - computePass.dispatchWorkgroups(Math.ceil(slot.width / 16), Math.ceil(slot.height / 16)); - computePass.end(); - - // 2. Blit RGBA buffer → canvas texture (zero-copy render pass) - const blitParamsBuffer = this.device.createBuffer({ - size: 8, - usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, - }); - this.device.queue.writeBuffer(blitParamsBuffer, 0, new Uint32Array([slot.width, slot.height])); - - const blitGroup = this.device.createBindGroup({ - layout: this.blitPipeline.getBindGroupLayout(0), - entries: [ - { binding: 0, resource: { buffer: blitParamsBuffer } }, - { binding: 1, resource: { buffer: slot.rgbaBuffer } }, - ], - }); - - const texture = ctx.getCurrentTexture(); - const renderPass = encoder.beginRenderPass({ - colorAttachments: [{ - view: texture.createView(), - loadOp: "clear" as GPULoadOp, - storeOp: "store" as GPUStoreOp, - clearValue: { r: 0, g: 0, b: 0, a: 1 }, - }], - }); - renderPass.setPipeline(this.blitPipeline); - renderPass.setBindGroup(0, blitGroup); - renderPass.draw(3); // fullscreen triangle - renderPass.end(); - rendered++; - - // Note: blitParamsBuffer is a temporary — ideally per-slot persistent - // For now, acceptable overhead (8 bytes per image) - } - - this.device.queue.submit([encoder.finish()]); - if (rendered > 0) { - } - return rendered; - } - - /** - * GPU colormap → OffscreenCanvas → ImageBitmap (zero mapAsync). - * Compute shader writes RGBA, render pass blits to OffscreenCanvas texture, - * transferToImageBitmap() returns ImageBitmap for drawImage on 2D canvas. - * Eliminates the 35ms JS memcpy for 12×4K images. - */ - renderSlotsToImageBitmap( - indices: number[], - ranges: { vmin: number; vmax: number }[], - logScale: boolean = false, - ): ImageBitmap[] | null { - if (!this.pipeline || !this.lutBuffer || indices.length === 0) return null; - const fmt = navigator.gpu.getPreferredCanvasFormat(); - this.ensureBlitPipeline(fmt); - if (!this.blitPipeline) return null; - - const encoder = this.device.createCommandEncoder(); - const params = new ArrayBuffer(24); - const canvases: OffscreenCanvas[] = []; - - for (let k = 0; k < indices.length; k++) { - const i = indices[k]; - const slot = this.slots[i]; - if (!slot) { canvases.push(null as never); continue; } - const range = ranges[k] || { vmin: 0, vmax: 1 }; - - // Compute colormap - this._writeParams(params, slot.width, slot.height, range.vmin, range.vmax, logScale); - this.device.queue.writeBuffer(slot.paramsBuffer, 0, params); - - const computeGroup = this.device.createBindGroup({ - layout: this.pipeline.getBindGroupLayout(0), - entries: [ - { binding: 0, resource: { buffer: slot.paramsBuffer } }, - { binding: 1, resource: { buffer: slot.dataBuffer } }, - { binding: 2, resource: { buffer: this.lutBuffer } }, - { binding: 3, resource: { buffer: slot.rgbaBuffer } }, - ], - }); - const computePass = encoder.beginComputePass(); - computePass.setPipeline(this.pipeline); - computePass.setBindGroup(0, computeGroup); - computePass.dispatchWorkgroups(Math.ceil(slot.width / 16), Math.ceil(slot.height / 16)); - computePass.end(); - - // Blit to OffscreenCanvas - const oc = new OffscreenCanvas(slot.width, slot.height); - const ctx = oc.getContext("webgpu") as GPUCanvasContext; - ctx.configure({ device: this.device, format: fmt, alphaMode: "opaque" }); - - const blitParamsBuffer = this.device.createBuffer({ - size: 8, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, - }); - this.device.queue.writeBuffer(blitParamsBuffer, 0, new Uint32Array([slot.width, slot.height])); - - const blitGroup = this.device.createBindGroup({ - layout: this.blitPipeline!.getBindGroupLayout(0), - entries: [ - { binding: 0, resource: { buffer: blitParamsBuffer } }, - { binding: 1, resource: { buffer: slot.rgbaBuffer } }, - ], - }); - - const texture = ctx.getCurrentTexture(); - const renderPass = encoder.beginRenderPass({ - colorAttachments: [{ - view: texture.createView(), - loadOp: "clear" as GPULoadOp, - storeOp: "store" as GPUStoreOp, - clearValue: { r: 0, g: 0, b: 0, a: 1 }, - }], - }); - renderPass.setPipeline(this.blitPipeline!); - renderPass.setBindGroup(0, blitGroup); - renderPass.draw(3); - renderPass.end(); - canvases.push(oc); - } - - this.device.queue.submit([encoder.finish()]); - - // transferToImageBitmap after GPU finishes (synchronous, no mapAsync) - const bitmaps: ImageBitmap[] = []; - for (const oc of canvases) { - if (oc) bitmaps.push(oc.transferToImageBitmap()); - else bitmaps.push(null as never); - } - return bitmaps; - } - - /** - * Configure a canvas for WebGPU zero-copy rendering. - * Returns the GPUCanvasContext, or null if WebGPU canvas is not supported. - */ - configureCanvas(canvas: HTMLCanvasElement, width: number, height: number): GPUCanvasContext | null { - try { - const ctx = canvas.getContext("webgpu") as GPUCanvasContext | null; - if (!ctx) return null; - ctx.configure({ - device: this.device, - format: navigator.gpu.getPreferredCanvasFormat(), - alphaMode: "opaque", - }); - canvas.width = width; - canvas.height = height; - return ctx; - } catch { - return null; - } - } - - /** Release all GPU resources. */ - destroy(): void { - for (const slot of this.slots) { - if (slot) { - slot.dataBuffer.destroy(); - slot.rgbaBuffer.destroy(); - slot.readBuffer.destroy(); - slot.paramsBuffer.destroy(); - slot.histBinsBuffer.destroy(); - slot.histReadBuffer.destroy(); - } - } - this.slots = []; - this.lutBuffer?.destroy(); - this.lutBuffer = null; - this.currentLutName = ""; - } - - /** Number of uploaded image slots. */ - get slotCount(): number { return this.slots.filter(s => s).length; } - - // ── GPU min/max reduction ── - - private rangePipeline: GPUComputePipeline | null = null; - private RANGE_WG_SIZE = 256; - - private ensureRangePipeline(): void { - if (this.rangePipeline) return; - // Two-pass parallel reduction: each workgroup reduces a chunk to one min/max pair. - // Output: array of [min, max] pairs (one per workgroup). JS reduces the partials. - const code = /* wgsl */ ` -@group(0) @binding(0) var data: array; -@group(0) @binding(1) var out: array; -@group(0) @binding(2) var count: u32; - -var sMin: array; -var sMax: array; - -@compute @workgroup_size(256) -fn reduce(@builtin(global_invocation_id) gid: vec3u, @builtin(local_invocation_id) lid: vec3u, @builtin(workgroup_id) wid: vec3u) { - let i = gid.x; - if (i < count) { - sMin[lid.x] = data[i]; - sMax[lid.x] = data[i]; - } else { - sMin[lid.x] = 3.4028235e+38; - sMax[lid.x] = -3.4028235e+38; - } - workgroupBarrier(); - - // Tree reduction in shared memory - for (var s = 128u; s > 0u; s >>= 1u) { - if (lid.x < s) { - sMin[lid.x] = min(sMin[lid.x], sMin[lid.x + s]); - sMax[lid.x] = max(sMax[lid.x], sMax[lid.x + s]); - } - workgroupBarrier(); - } - - if (lid.x == 0u) { - out[wid.x * 2u] = sMin[0]; - out[wid.x * 2u + 1u] = sMax[0]; - } -} -`; - const module = this.device.createShaderModule({ code }); - this.rangePipeline = this.device.createComputePipeline({ - layout: "auto", - compute: { module, entryPoint: "reduce" }, - }); - } - - /** - * Batch-compute min/max for multiple slots on GPU. - * Returns { min, max } per slot. One GPU submission for all slots. - */ - async computeRangeBatch(indices: number[]): Promise<{ min: number; max: number }[]> { - this.ensureRangePipeline(); - if (!this.rangePipeline || indices.length === 0) return []; - const WG = this.RANGE_WG_SIZE; - - const encoder = this.device.createCommandEncoder(); - const jobs: { idx: number; nGroups: number; outBuf: GPUBuffer; readBuf: GPUBuffer; countBuf: GPUBuffer }[] = []; - - for (const i of indices) { - const slot = this.slots[i]; - if (!slot) continue; - const N = slot.count; - const nGroups = Math.ceil(N / WG); - const outSize = nGroups * 2 * 4; // 2 floats (min, max) per workgroup - const outBuf = this.device.createBuffer({ size: outSize, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC }); - const readBuf = this.device.createBuffer({ size: outSize, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }); - const countBuf = this.device.createBuffer({ size: 4, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); - this.device.queue.writeBuffer(countBuf, 0, new Uint32Array([N])); - - const bg = this.device.createBindGroup({ - layout: this.rangePipeline.getBindGroupLayout(0), - entries: [ - { binding: 0, resource: { buffer: slot.dataBuffer } }, - { binding: 1, resource: { buffer: outBuf } }, - { binding: 2, resource: { buffer: countBuf } }, - ], - }); - const pass = encoder.beginComputePass(); - pass.setPipeline(this.rangePipeline); - pass.setBindGroup(0, bg); - pass.dispatchWorkgroups(nGroups); - pass.end(); - encoder.copyBufferToBuffer(outBuf, 0, readBuf, 0, outSize); - jobs.push({ idx: i, nGroups, outBuf, readBuf, countBuf }); - } - - this.device.queue.submit([encoder.finish()]); - await Promise.all(jobs.map(j => j.readBuf.mapAsync(GPUMapMode.READ))); - - const results: { min: number; max: number }[] = []; - for (const j of jobs) { - const partials = new Float32Array(j.readBuf.getMappedRange().slice(0)); - j.readBuf.unmap(); - j.outBuf.destroy(); j.readBuf.destroy(); j.countBuf.destroy(); - // JS reduces partials: ~65K elements for 16M data = trivial - let dmin = Infinity, dmax = -Infinity; - for (let k = 0; k < j.nGroups; k++) { - if (partials[k * 2] < dmin) dmin = partials[k * 2]; - if (partials[k * 2 + 1] > dmax) dmax = partials[k * 2 + 1]; - } - results.push({ min: dmin, max: dmax }); - } - return results; - } - - // ── GPU histogram ── - - private histPipeline: GPUComputePipeline | null = null; - private histClearPipeline: GPUComputePipeline | null = null; - - private ensureHistPipeline(): void { - if (this.histPipeline) return; - const code = /* wgsl */ ` -struct HistParams { - width: u32, - height: u32, - dmin: f32, - dmax: f32, - log_scale: u32, - _pad: u32, -}; -@group(0) @binding(0) var params: HistParams; -@group(0) @binding(1) var data: array; -@group(0) @binding(2) var bins: array>; - -@compute @workgroup_size(16, 16) -fn histogram(@builtin(global_invocation_id) gid: vec3u) { - if (gid.x >= params.width || gid.y >= params.height) { return; } - let idx = gid.y * params.width + gid.x; - var val = data[idx]; - if (params.log_scale == 1u) { val = log(1.0 + max(val, 0.0)); } - let range = max(params.dmax - params.dmin, 1e-30); - let t = clamp((val - params.dmin) / range, 0.0, 1.0); - let bin = min(u32(t * 256.0), 255u); - atomicAdd(&bins[bin], 1u); -} - -@compute @workgroup_size(256) -fn clear_bins(@builtin(global_invocation_id) gid: vec3u) { - if (gid.x < 256u) { atomicStore(&bins[gid.x], 0u); } -} -`; - const module = this.device.createShaderModule({ code }); - this.histPipeline = this.device.createComputePipeline({ - layout: "auto", - compute: { module, entryPoint: "histogram" }, - }); - this.histClearPipeline = this.device.createComputePipeline({ - layout: "auto", - compute: { module, entryPoint: "clear_bins" }, - }); - } - - /** - * Compute a 256-bin histogram for slot `idx` on GPU. - * Returns normalized bins (0–1) matching `computeHistogramFromBytes`. - */ - async computeHistogram(idx: number, _logScale: boolean = false): Promise { - this.ensureHistPipeline(); - const slot = this.slots[idx]; - if (!slot || !this.histPipeline || !this.histClearPipeline) return new Array(256).fill(0); - - // Find data range (we need min/max for binning) - // For GPU efficiency, do a quick CPU scan — findDataRange is fast (<5ms for 16M) - // A full GPU min/max reduction would add complexity for minimal gain here. - // Note: when logScale is true, we need the log-transformed range. - - const binsBuffer = this.device.createBuffer({ - size: 256 * 4, - usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC, - }); - const readBuffer = this.device.createBuffer({ - size: 256 * 4, - usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, - }); - const paramsBuf = this.device.createBuffer({ - size: 16, - usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, - }); - - // We need min/max from the (possibly log-transformed) data for proper binning. - // Pass raw min/max = 0; the shader will use the actual data range. - // Actually, we need to know the range to bin correctly. Read it back from - // the data we already uploaded. For now, accept min/max as parameters. - // The caller (Show2D data effect) already computes findDataRange. - // So let's accept dmin/dmax as params. - - // This method needs dmin/dmax — return a version that takes them: - binsBuffer.destroy(); - readBuffer.destroy(); - paramsBuf.destroy(); - return new Array(256).fill(0); - } - - /** - * Batch-compute 256-bin histograms for multiple slots in ONE GPU submission. - * Uses persistent per-slot histogram buffers (zero create/destroy overhead). - * Returns normalized bins per image. - */ - async computeHistogramBatch( - indices: number[], - ranges: { min: number; max: number }[], - logScale: boolean = false, - ): Promise { - this.ensureHistPipeline(); - if (!this.histPipeline || !this.histClearPipeline || indices.length === 0) return []; - - const encoder = this.device.createCommandEncoder(); - const activeSlots: { k: number; slot: GPUSlot }[] = []; - const params = new ArrayBuffer(24); - - for (let k = 0; k < indices.length; k++) { - const i = indices[k]; - const slot = this.slots[i]; - if (!slot) continue; - const r = ranges[k] || { min: 0, max: 1 }; - if (r.min === r.max) continue; - - // Reuse persistent paramsBuffer for histogram (same layout as colormap params) - const pu = new Uint32Array(params); - const pf = new Float32Array(params); - pu[0] = slot.width; pu[1] = slot.height; - pf[2] = r.min; pf[3] = r.max; - pu[4] = logScale ? 1 : 0; pu[5] = 0; - this.device.queue.writeBuffer(slot.paramsBuffer, 0, params); - - // Clear bins (persistent buffer) - const clearGroup = this.device.createBindGroup({ - layout: this.histClearPipeline!.getBindGroupLayout(0), - entries: [ - { binding: 0, resource: { buffer: slot.paramsBuffer } }, - { binding: 1, resource: { buffer: slot.dataBuffer } }, - { binding: 2, resource: { buffer: slot.histBinsBuffer } }, - ], - }); - const clearPass = encoder.beginComputePass(); - clearPass.setPipeline(this.histClearPipeline!); - clearPass.setBindGroup(0, clearGroup); - clearPass.dispatchWorkgroups(1); - clearPass.end(); - - // Histogram (persistent buffer) - const histGroup = this.device.createBindGroup({ - layout: this.histPipeline!.getBindGroupLayout(0), - entries: [ - { binding: 0, resource: { buffer: slot.paramsBuffer } }, - { binding: 1, resource: { buffer: slot.dataBuffer } }, - { binding: 2, resource: { buffer: slot.histBinsBuffer } }, - ], - }); - const histPass = encoder.beginComputePass(); - histPass.setPipeline(this.histPipeline!); - histPass.setBindGroup(0, histGroup); - histPass.dispatchWorkgroups(Math.ceil(slot.width / 16), Math.ceil(slot.height / 16)); - histPass.end(); - - encoder.copyBufferToBuffer(slot.histBinsBuffer, 0, slot.histReadBuffer, 0, 256 * 4); - activeSlots.push({ k, slot }); - } - - this.device.queue.submit([encoder.finish()]); - await Promise.all(activeSlots.map(s => s.slot.histReadBuffer.mapAsync(GPUMapMode.READ))); - - const results: number[][] = []; - for (const s of activeSlots) { - const rawBins = new Uint32Array(s.slot.histReadBuffer.getMappedRange().slice(0)); - s.slot.histReadBuffer.unmap(); - - let maxCount = 0; - for (let j = 0; j < 256; j++) if (rawBins[j] > maxCount) maxCount = rawBins[j]; - const norm = new Array(256); - for (let j = 0; j < 256; j++) norm[j] = maxCount > 0 ? rawBins[j] / maxCount : 0; - results.push(norm); - } - return results; - } - - /** - * Compute a 256-bin histogram for slot `idx` on GPU, given known data range. - * Returns normalized bins (0–1) matching `computeHistogramFromBytes`. - */ - async computeHistogramWithRange( - idx: number, dmin: number, dmax: number, logScale: boolean = false, - ): Promise { - this.ensureHistPipeline(); - const slot = this.slots[idx]; - if (!slot || !this.histPipeline || !this.histClearPipeline) return new Array(256).fill(0); - if (dmin === dmax) return new Array(256).fill(0); - - const binsBuffer = this.device.createBuffer({ - size: 256 * 4, - usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC, - }); - const readBuffer = this.device.createBuffer({ - size: 256 * 4, - usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, - }); - const paramsBuf = this.device.createBuffer({ - size: 24, - usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, - }); - - const params = new ArrayBuffer(24); - const pu = new Uint32Array(params); - const pf = new Float32Array(params); - pu[0] = slot.width; pu[1] = slot.height; - pf[2] = dmin; pf[3] = dmax; - pu[4] = logScale ? 1 : 0; pu[5] = 0; - this.device.queue.writeBuffer(paramsBuf, 0, params); - - const encoder = this.device.createCommandEncoder(); - - // Clear bins - const clearGroup = this.device.createBindGroup({ - layout: this.histClearPipeline.getBindGroupLayout(0), - entries: [ - { binding: 0, resource: { buffer: paramsBuf } }, - { binding: 1, resource: { buffer: slot.dataBuffer } }, - { binding: 2, resource: { buffer: binsBuffer } }, - ], - }); - const clearPass = encoder.beginComputePass(); - clearPass.setPipeline(this.histClearPipeline); - clearPass.setBindGroup(0, clearGroup); - clearPass.dispatchWorkgroups(1); - clearPass.end(); - - // Histogram - const histGroup = this.device.createBindGroup({ - layout: this.histPipeline.getBindGroupLayout(0), - entries: [ - { binding: 0, resource: { buffer: paramsBuf } }, - { binding: 1, resource: { buffer: slot.dataBuffer } }, - { binding: 2, resource: { buffer: binsBuffer } }, - ], - }); - const histPass = encoder.beginComputePass(); - histPass.setPipeline(this.histPipeline); - histPass.setBindGroup(0, histGroup); - histPass.dispatchWorkgroups(Math.ceil(slot.width / 16), Math.ceil(slot.height / 16)); - histPass.end(); - - encoder.copyBufferToBuffer(binsBuffer, 0, readBuffer, 0, 256 * 4); - this.device.queue.submit([encoder.finish()]); - - await readBuffer.mapAsync(GPUMapMode.READ); - const rawBins = new Uint32Array(readBuffer.getMappedRange().slice(0)); - readBuffer.unmap(); - binsBuffer.destroy(); - readBuffer.destroy(); - paramsBuf.destroy(); - - // Normalize (match CPU: divide by max count) - let maxCount = 0; - for (let i = 0; i < 256; i++) if (rawBins[i] > maxCount) maxCount = rawBins[i]; - const result = new Array(256); - if (maxCount > 0) { - for (let i = 0; i < 256; i++) result[i] = rawBins[i] / maxCount; - } else { - for (let i = 0; i < 256; i++) result[i] = 0; - } - return result; - } -} - -let gpuColormapEngine: GPUColormapEngine | null = null; - -/** Get or create the singleton GPU colormap engine. Returns null if WebGPU unavailable. */ -export async function getGPUColormapEngine(): Promise { - if (gpuColormapEngine) return gpuColormapEngine; - // Reuse the GPU device from fft - try { - const { getGPUDevice } = await import("./fft"); - const device = await getGPUDevice(); - if (!device) return null; - gpuColormapEngine = new GPUColormapEngine(device); - return gpuColormapEngine; - } catch { - return null; - } -} - -/** Query the GPU's max buffer size in bytes. Returns 0 if WebGPU unavailable. */ -export async function getGPUMaxBufferSize(): Promise { - try { - if (!navigator.gpu) return 0; - const adapter = await navigator.gpu.requestAdapter(); - if (!adapter) return 0; - return adapter.limits.maxStorageBufferBindingSize || adapter.limits.maxBufferSize || 0; - } catch { - return 0; - } -} diff --git a/widget/js/fft.ts b/widget/js/fft.ts deleted file mode 100644 index b2a72ea68..000000000 --- a/widget/js/fft.ts +++ /dev/null @@ -1,474 +0,0 @@ -/// - -/** - * WebGPU FFT — shared 2D FFT with GPU acceleration and CPU fallback. - * Handles non-power-of-2 dimensions via zero-padding. - */ - -// ============================================================================ -// CPU FFT fallback -// ============================================================================ - -export function nextPow2(n: number): number { return Math.pow(2, Math.ceil(Math.log2(n))); } - -function fft1d(real: Float32Array, imag: Float32Array, inverse: boolean = false) { - const n = real.length; - if (n <= 1) return; - let j = 0; - for (let i = 0; i < n - 1; i++) { - if (i < j) { [real[i], real[j]] = [real[j], real[i]]; [imag[i], imag[j]] = [imag[j], imag[i]]; } - let k = n >> 1; - while (k <= j) { j -= k; k >>= 1; } - j += k; - } - const sign = inverse ? 1 : -1; - for (let len = 2; len <= n; len <<= 1) { - const halfLen = len >> 1; - const angle = (sign * 2 * Math.PI) / len; - const wReal = Math.cos(angle), wImag = Math.sin(angle); - for (let i = 0; i < n; i += len) { - let curReal = 1, curImag = 0; - for (let k = 0; k < halfLen; k++) { - const evenIdx = i + k, oddIdx = i + k + halfLen; - const tReal = curReal * real[oddIdx] - curImag * imag[oddIdx]; - const tImag = curReal * imag[oddIdx] + curImag * real[oddIdx]; - real[oddIdx] = real[evenIdx] - tReal; imag[oddIdx] = imag[evenIdx] - tImag; - real[evenIdx] += tReal; imag[evenIdx] += tImag; - const newReal = curReal * wReal - curImag * wImag; - curImag = curReal * wImag + curImag * wReal; curReal = newReal; - } - } - } - if (inverse) { for (let i = 0; i < n; i++) { real[i] /= n; imag[i] /= n; } } -} - -export function fft2d(real: Float32Array, imag: Float32Array, width: number, height: number, inverse: boolean = false) { - const paddedW = nextPow2(width), paddedH = nextPow2(height); - const needsPadding = paddedW !== width || paddedH !== height; - let workReal: Float32Array, workImag: Float32Array; - if (needsPadding) { - workReal = new Float32Array(paddedW * paddedH); workImag = new Float32Array(paddedW * paddedH); - for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { - workReal[y * paddedW + x] = real[y * width + x]; workImag[y * paddedW + x] = imag[y * width + x]; - } - } else { workReal = real; workImag = imag; } - const rowReal = new Float32Array(paddedW), rowImag = new Float32Array(paddedW); - for (let y = 0; y < paddedH; y++) { - const offset = y * paddedW; - for (let x = 0; x < paddedW; x++) { rowReal[x] = workReal[offset + x]; rowImag[x] = workImag[offset + x]; } - fft1d(rowReal, rowImag, inverse); - for (let x = 0; x < paddedW; x++) { workReal[offset + x] = rowReal[x]; workImag[offset + x] = rowImag[x]; } - } - const colReal = new Float32Array(paddedH), colImag = new Float32Array(paddedH); - for (let x = 0; x < paddedW; x++) { - for (let y = 0; y < paddedH; y++) { colReal[y] = workReal[y * paddedW + x]; colImag[y] = workImag[y * paddedW + x]; } - fft1d(colReal, colImag, inverse); - for (let y = 0; y < paddedH; y++) { workReal[y * paddedW + x] = colReal[y]; workImag[y * paddedW + x] = colImag[y]; } - } - if (needsPadding) { - for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { - real[y * width + x] = workReal[y * paddedW + x]; imag[y * width + x] = workImag[y * paddedW + x]; - } - } -} - -export function fftshift(data: Float32Array, width: number, height: number): void { - const halfW = width >> 1, halfH = height >> 1; - const temp = new Float32Array(width * height); - for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { - temp[((y + halfH) % height) * width + ((x + halfW) % width)] = data[y * width + x]; - } - data.set(temp); -} - -// ============================================================================ -// CPU FFT Web Worker — runs fft2d + fftshift + computeMagnitude off main thread -// ============================================================================ - -// Build worker source by stringifying the same fft1d/fft2d/fftshift defined -// above. Single source of truth: fix a bug once, both paths get it. Pure -// functions only (no module-state closures), so .toString() captures the full -// behavior. Use Function.name in the onmessage body so minified names still -// match (esbuild may rename `fft2d` -> `a`; the .name property tracks rename). -const FFT_WORKER_CODE = ` -${nextPow2.toString()} -${fft1d.toString()} -${fft2d.toString()} -${fftshift.toString()} -self.onmessage = function(e) { - const d = e.data; - ${fft2d.name}(d.real, d.imag, d.width, d.height, d.inverse); - ${fftshift.name}(d.real, d.width, d.height); - ${fftshift.name}(d.imag, d.width, d.height); - const n = d.real.length, mag = new Float32Array(n); - for (let i = 0; i < n; i++) mag[i] = Math.sqrt(d.real[i]*d.real[i] + d.imag[i]*d.imag[i]); - self.postMessage({ id: d.id, magnitude: mag, real: d.real, imag: d.imag }, [mag.buffer, d.real.buffer, d.imag.buffer]); -}; -`; - -let _fftWorker: Worker | null = null; -const _fftCallbacks = new Map void>(); -let _fftWorkerId = 0; - -function getFFTWorker(): Worker { - if (!_fftWorker) { - const blob = new Blob([FFT_WORKER_CODE], { type: 'application/javascript' }); - _fftWorker = new Worker(URL.createObjectURL(blob)); - _fftWorker.onmessage = (e: MessageEvent) => { - const cb = _fftCallbacks.get(e.data.id); - if (cb) { - _fftCallbacks.delete(e.data.id); - cb(e.data); - } - }; - } - return _fftWorker; -} - -/** - * CPU FFT in a Web Worker — does fft2d + fftshift + computeMagnitude off main thread. - * Transfers Float32Arrays to the worker (zero-copy) so the main thread is never blocked. - * The input arrays become detached after this call — pass copies if you need to keep them. - */ -export function fft2dAsync( - real: Float32Array, imag: Float32Array, - width: number, height: number, - inverse: boolean = false, -): Promise<{ magnitude: Float32Array; real: Float32Array; imag: Float32Array }> { - const worker = getFFTWorker(); - const id = ++_fftWorkerId; - return new Promise((resolve) => { - _fftCallbacks.set(id, resolve); - worker.postMessage( - { id, real, imag, width, height, inverse }, - [real.buffer, imag.buffer], - ); - }); -} - -// ============================================================================ -// WebGPU FFT — GPU-accelerated 2D FFT -// ============================================================================ - -// ============================================================================ -// WebGPU FFT (compute shader, GPU-resident) -// ============================================================================ - -const FFT_2D_SHADER = /* wgsl */` -fn cmul(a: vec2, b: vec2) -> vec2 { return vec2(a.x*b.x-a.y*b.y, a.x*b.y+a.y*b.x); } -fn twiddle(k: u32, N: u32, inverse: f32) -> vec2 { let angle = inverse * 2.0 * 3.14159265359 * f32(k) / f32(N); return vec2(cos(angle), sin(angle)); } -fn bitReverse(x: u32, log2N: u32) -> u32 { var result: u32 = 0u; var val = x; for (var i: u32 = 0u; i < log2N; i = i + 1u) { result = (result << 1u) | (val & 1u); val = val >> 1u; } return result; } -struct FFT2DParams { width: u32, height: u32, log2Size: u32, stage: u32, inverse: f32, isRowWise: u32, } -@group(0) @binding(0) var params: FFT2DParams; -@group(0) @binding(1) var data: array>; -fn getIndex(row: u32, col: u32) -> u32 { return row * params.width + col; } -@compute @workgroup_size(16, 16) fn bitReverseRows(@builtin(global_invocation_id) gid: vec3) { let row = gid.y; let col = gid.x; if (row >= params.height || col >= params.width) { return; } let rev = bitReverse(col, params.log2Size); if (col < rev) { let idx1 = getIndex(row, col); let idx2 = getIndex(row, rev); let temp = data[idx1]; data[idx1] = data[idx2]; data[idx2] = temp; } } -@compute @workgroup_size(16, 16) fn bitReverseCols(@builtin(global_invocation_id) gid: vec3) { let row = gid.y; let col = gid.x; if (row >= params.height || col >= params.width) { return; } let rev = bitReverse(row, params.log2Size); if (row < rev) { let idx1 = getIndex(row, col); let idx2 = getIndex(rev, col); let temp = data[idx1]; data[idx1] = data[idx2]; data[idx2] = temp; } } -@compute @workgroup_size(16, 16) fn butterflyRows(@builtin(global_invocation_id) gid: vec3) { let row = gid.y; let idx = gid.x; if (row >= params.height || idx >= params.width / 2u) { return; } let stage = params.stage; let halfSize = 1u << stage; let fullSize = halfSize << 1u; let group = idx / halfSize; let pos = idx % halfSize; let col_i = group * fullSize + pos; let col_j = col_i + halfSize; if (col_j >= params.width) { return; } let w = twiddle(pos, fullSize, params.inverse); let i = getIndex(row, col_i); let j = getIndex(row, col_j); let u = data[i]; let t = cmul(w, data[j]); data[i] = u + t; data[j] = u - t; } -@compute @workgroup_size(16, 16) fn butterflyCols(@builtin(global_invocation_id) gid: vec3) { let col = gid.x; let idx = gid.y; if (col >= params.width || idx >= params.height / 2u) { return; } let stage = params.stage; let halfSize = 1u << stage; let fullSize = halfSize << 1u; let group = idx / halfSize; let pos = idx % halfSize; let row_i = group * fullSize + pos; let row_j = row_i + halfSize; if (row_j >= params.height) { return; } let w = twiddle(pos, fullSize, params.inverse); let i = getIndex(row_i, col); let j = getIndex(row_j, col); let u = data[i]; let t = cmul(w, data[j]); data[i] = u + t; data[j] = u - t; } -@compute @workgroup_size(16, 16) fn normalize2D(@builtin(global_invocation_id) gid: vec3) { let row = gid.y; let col = gid.x; if (row >= params.height || col >= params.width) { return; } let idx = getIndex(row, col); let scale = 1.0 / f32(params.width * params.height); data[idx] = data[idx] * scale; }`; - -export class WebGPUFFT { - private device: GPUDevice; - private pipelines2D: { bitReverseRows: GPUComputePipeline; bitReverseCols: GPUComputePipeline; butterflyRows: GPUComputePipeline; butterflyCols: GPUComputePipeline; normalize: GPUComputePipeline } | null = null; - private initialized = false; - constructor(device: GPUDevice) { this.device = device; } - async init(): Promise { - if (this.initialized) return; - const module2D = this.device.createShaderModule({ code: FFT_2D_SHADER }); - this.pipelines2D = { - bitReverseRows: this.device.createComputePipeline({ layout: 'auto', compute: { module: module2D, entryPoint: 'bitReverseRows' } }), - bitReverseCols: this.device.createComputePipeline({ layout: 'auto', compute: { module: module2D, entryPoint: 'bitReverseCols' } }), - butterflyRows: this.device.createComputePipeline({ layout: 'auto', compute: { module: module2D, entryPoint: 'butterflyRows' } }), - butterflyCols: this.device.createComputePipeline({ layout: 'auto', compute: { module: module2D, entryPoint: 'butterflyCols' } }), - normalize: this.device.createComputePipeline({ layout: 'auto', compute: { module: module2D, entryPoint: 'normalize2D' } }) - }; - this.initialized = true; - } - async fft2D(realData: Float32Array, imagData: Float32Array, width: number, height: number, inverse: boolean = false): Promise<{ real: Float32Array, imag: Float32Array }> { - await this.init(); - const paddedWidth = nextPow2(width), paddedHeight = nextPow2(height); - const needsPadding = paddedWidth !== width || paddedHeight !== height; - const log2Width = Math.log2(paddedWidth), log2Height = Math.log2(paddedHeight); - const paddedSize = paddedWidth * paddedHeight, originalSize = width * height; - let workReal: Float32Array, workImag: Float32Array; - if (needsPadding) { - workReal = new Float32Array(paddedSize); workImag = new Float32Array(paddedSize); - for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { workReal[y * paddedWidth + x] = realData[y * width + x]; workImag[y * paddedWidth + x] = imagData[y * width + x]; } - } else { workReal = realData; workImag = imagData; } - const complexData = new Float32Array(paddedSize * 2); - for (let i = 0; i < paddedSize; i++) { complexData[i * 2] = workReal[i]; complexData[i * 2 + 1] = workImag[i]; } - const dataBuffer = this.device.createBuffer({ size: complexData.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST }); - this.device.queue.writeBuffer(dataBuffer, 0, complexData); - const paramsBuffer = this.device.createBuffer({ size: 24, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); - const readBuffer = this.device.createBuffer({ size: complexData.byteLength, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }); - const inverseVal = inverse ? 1.0 : -1.0; - const workgroupsX = Math.ceil(paddedWidth / 16), workgroupsY = Math.ceil(paddedHeight / 16); - const runPass = (pipeline: GPUComputePipeline) => { - const bindGroup = this.device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: paramsBuffer } }, { binding: 1, resource: { buffer: dataBuffer } }] }); - const encoder = this.device.createCommandEncoder(); const pass = encoder.beginComputePass(); - pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.dispatchWorkgroups(workgroupsX, workgroupsY); pass.end(); - this.device.queue.submit([encoder.finish()]); - }; - const params = new ArrayBuffer(24); const paramsU32 = new Uint32Array(params); const paramsF32 = new Float32Array(params); - paramsU32[0] = paddedWidth; paramsU32[1] = paddedHeight; paramsU32[2] = log2Width; paramsU32[3] = 0; paramsF32[4] = inverseVal; paramsU32[5] = 1; - this.device.queue.writeBuffer(paramsBuffer, 0, params); runPass(this.pipelines2D!.bitReverseRows); - for (let stage = 0; stage < log2Width; stage++) { paramsU32[3] = stage; this.device.queue.writeBuffer(paramsBuffer, 0, params); runPass(this.pipelines2D!.butterflyRows); } - paramsU32[2] = log2Height; paramsU32[3] = 0; paramsU32[5] = 0; - this.device.queue.writeBuffer(paramsBuffer, 0, params); runPass(this.pipelines2D!.bitReverseCols); - for (let stage = 0; stage < log2Height; stage++) { paramsU32[3] = stage; this.device.queue.writeBuffer(paramsBuffer, 0, params); runPass(this.pipelines2D!.butterflyCols); } - if (inverse) runPass(this.pipelines2D!.normalize); - const encoder = this.device.createCommandEncoder(); encoder.copyBufferToBuffer(dataBuffer, 0, readBuffer, 0, complexData.byteLength); - this.device.queue.submit([encoder.finish()]); await readBuffer.mapAsync(GPUMapMode.READ); - const result = new Float32Array(readBuffer.getMappedRange().slice(0)); readBuffer.unmap(); - dataBuffer.destroy(); paramsBuffer.destroy(); readBuffer.destroy(); - if (needsPadding) { - const realResult = new Float32Array(originalSize), imagResult = new Float32Array(originalSize); - for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { realResult[y * width + x] = result[(y * paddedWidth + x) * 2]; imagResult[y * width + x] = result[(y * paddedWidth + x) * 2 + 1]; } - return { real: realResult, imag: imagResult }; - } - const realResult = new Float32Array(paddedSize), imagResult = new Float32Array(paddedSize); - for (let i = 0; i < paddedSize; i++) { realResult[i] = result[i * 2]; imagResult[i] = result[i * 2 + 1]; } - return { real: realResult, imag: imagResult }; - } - /** - * Batched 2D FFT: compute N forward FFTs with pipelined GPU submissions. - * All images must have the same dimensions. Each image gets its own - * submit (required because the params uniform changes per-pass), but - * all readbacks are batched into a single Promise.all at the end. - */ - async fft2DBatch( - images: { real: Float32Array; imag: Float32Array }[], - width: number, height: number, - ): Promise<{ real: Float32Array; imag: Float32Array }[]> { - await this.init(); - const n = images.length; - if (n === 0) return []; - const paddedWidth = nextPow2(width), paddedHeight = nextPow2(height); - const needsPadding = paddedWidth !== width || paddedHeight !== height; - const log2Width = Math.log2(paddedWidth), log2Height = Math.log2(paddedHeight); - const paddedSize = paddedWidth * paddedHeight; - const originalSize = width * height; - const byteSize = paddedSize * 2 * 4; - const workgroupsX = Math.ceil(paddedWidth / 16), workgroupsY = Math.ceil(paddedHeight / 16); - const inverseVal = -1.0; - - // Shared params buffer — safe because we submit per-image - const paramsBuffer = this.device.createBuffer({ size: 24, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); - - const readBuffers: GPUBuffer[] = []; - const dataBuffers: GPUBuffer[] = []; - - // Submit all FFTs — GPU pipelines them internally - for (let i = 0; i < n; i++) { - const { real: realData, imag: imagData } = images[i]; - let workReal: Float32Array, workImag: Float32Array; - if (needsPadding) { - workReal = new Float32Array(paddedSize); workImag = new Float32Array(paddedSize); - for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { - workReal[y * paddedWidth + x] = realData[y * width + x]; - workImag[y * paddedWidth + x] = imagData[y * width + x]; - } - } else { workReal = realData; workImag = imagData; } - - const complexData = new Float32Array(paddedSize * 2); - for (let j = 0; j < paddedSize; j++) { complexData[j * 2] = workReal[j]; complexData[j * 2 + 1] = workImag[j]; } - - const dataBuffer = this.device.createBuffer({ size: byteSize, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST }); - this.device.queue.writeBuffer(dataBuffer, 0, complexData); - dataBuffers.push(dataBuffer); - - const readBuffer = this.device.createBuffer({ size: byteSize, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }); - readBuffers.push(readBuffer); - - // Run FFT passes — each runPass does writeBuffer+submit atomically - const runPass = (pipeline: GPUComputePipeline) => { - const bindGroup = this.device.createBindGroup({ - layout: pipeline.getBindGroupLayout(0), - entries: [{ binding: 0, resource: { buffer: paramsBuffer } }, { binding: 1, resource: { buffer: dataBuffer } }], - }); - const enc = this.device.createCommandEncoder(); - const pass = enc.beginComputePass(); - pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); - pass.dispatchWorkgroups(workgroupsX, workgroupsY); pass.end(); - this.device.queue.submit([enc.finish()]); - }; - - const params = new ArrayBuffer(24); - const paramsU32 = new Uint32Array(params); - const paramsF32 = new Float32Array(params); - - paramsU32[0] = paddedWidth; paramsU32[1] = paddedHeight; paramsU32[2] = log2Width; - paramsU32[3] = 0; paramsF32[4] = inverseVal; paramsU32[5] = 1; - this.device.queue.writeBuffer(paramsBuffer, 0, params); runPass(this.pipelines2D!.bitReverseRows); - for (let stage = 0; stage < log2Width; stage++) { paramsU32[3] = stage; this.device.queue.writeBuffer(paramsBuffer, 0, params); runPass(this.pipelines2D!.butterflyRows); } - - paramsU32[2] = log2Height; paramsU32[3] = 0; paramsU32[5] = 0; - this.device.queue.writeBuffer(paramsBuffer, 0, params); runPass(this.pipelines2D!.bitReverseCols); - for (let stage = 0; stage < log2Height; stage++) { paramsU32[3] = stage; this.device.queue.writeBuffer(paramsBuffer, 0, params); runPass(this.pipelines2D!.butterflyCols); } - - // Copy to read buffer - const copyEnc = this.device.createCommandEncoder(); - copyEnc.copyBufferToBuffer(dataBuffer, 0, readBuffer, 0, byteSize); - this.device.queue.submit([copyEnc.finish()]); - } - - // Batched readback — one sync point for all images - await Promise.all(readBuffers.map(buf => buf.mapAsync(GPUMapMode.READ))); - - const results: { real: Float32Array; imag: Float32Array }[] = []; - for (let i = 0; i < n; i++) { - const result = new Float32Array(readBuffers[i].getMappedRange().slice(0)); - readBuffers[i].unmap(); - dataBuffers[i].destroy(); - readBuffers[i].destroy(); - - if (needsPadding) { - const realResult = new Float32Array(originalSize), imagResult = new Float32Array(originalSize); - for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { - realResult[y * width + x] = result[(y * paddedWidth + x) * 2]; - imagResult[y * width + x] = result[(y * paddedWidth + x) * 2 + 1]; - } - results.push({ real: realResult, imag: imagResult }); - } else { - const realResult = new Float32Array(paddedSize), imagResult = new Float32Array(paddedSize); - for (let i2 = 0; i2 < paddedSize; i2++) { realResult[i2] = result[i2 * 2]; imagResult[i2] = result[i2 * 2 + 1]; } - results.push({ real: realResult, imag: imagResult }); - } - } - - paramsBuffer.destroy(); - return results; - } - - destroy(): void { this.initialized = false; } -} - -// ============================================================================ -// FFT pre-processing helpers -// ============================================================================ - -/** - * Apply 2D Hann window in-place to reduce spectral leakage in ROI FFT. - * - * When an ROI is cropped from an image, the sharp rectangular boundary acts as - * a rect window whose sinc sidelobes produce streak artifacts in the FFT, - * obscuring real spectral features (Bragg spots, lattice frequencies). - * The Hann window smoothly tapers data to zero at all edges, suppressing - * sidelobes by ~31 dB at the cost of a slightly wider main lobe. - * - * Separable: window2D = outer(hann_h, hann_w), applied as element-wise multiply. - * Symmetric formula: w(i) = 0.5*(1 - cos(2πi/(N-1))), matching np.hanning — - * both endpoints are exactly zero for seamless transition to zero-padded regions. - * (Periodic variant ÷N is for overlapping STFT windows, not for zero-padding.) - * - * IMPORTANT: Must be called on the crop at its native dimensions BEFORE - * zero-padding to power-of-2. Window-then-pad ensures no discontinuity at the - * crop/pad boundary. Pad-then-window applies the wrong taper and reintroduces - * leakage. Validated against np.hanning in test_widget_show2d.py. - */ -export function applyHannWindow2D(data: Float32Array, width: number, height: number): void { - const hannW = new Float32Array(width); - const hannH = new Float32Array(height); - const wDenom = width > 1 ? width - 1 : 1; - const hDenom = height > 1 ? height - 1 : 1; - for (let i = 0; i < width; i++) hannW[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / wDenom)); - for (let i = 0; i < height; i++) hannH[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / hDenom)); - for (let r = 0; r < height; r++) { - const hr = hannH[r]; - const offset = r * width; - for (let c = 0; c < width; c++) data[offset + c] *= hr * hannW[c]; - } -} - -// ============================================================================ -// FFT post-processing helpers -// ============================================================================ - -/** Compute magnitude from complex FFT output: sqrt(real² + imag²). */ -export function computeMagnitude(real: Float32Array, imag: Float32Array): Float32Array { - const mag = new Float32Array(real.length); - for (let i = 0; i < mag.length; i++) { - mag[i] = Math.sqrt(real[i] * real[i] + imag[i] * imag[i]); - } - return mag; -} - -/** Mask DC component (center pixel) and return 99.9% percentile-clipped range. Mutates `mag`. */ -export function autoEnhanceFFT( - mag: Float32Array, width: number, height: number, -): { min: number; max: number } { - const centerIdx = Math.floor(height / 2) * width + Math.floor(width / 2); - const neighbors = [ - mag[Math.max(0, centerIdx - 1)], - mag[Math.min(mag.length - 1, centerIdx + 1)], - mag[Math.max(0, centerIdx - width)], - mag[Math.min(mag.length - 1, centerIdx + width)], - ]; - mag[centerIdx] = neighbors.reduce((a, b) => a + b, 0) / 4; - // Use O(n) histogram approach instead of O(n log n) sort - const len = mag.length; - if (len === 0) return { min: 0, max: 0 }; - let dMin = Infinity, dMax = -Infinity; - for (let i = 0; i < len; i++) { - const v = mag[i]; - if (v < dMin) dMin = v; - if (v > dMax) dMax = v; - } - if (dMin === dMax) return { min: dMin, max: dMax }; - const NUM_BINS = 1024; - const bins = new Uint32Array(NUM_BINS); - const range = dMax - dMin; - const scale = (NUM_BINS - 1) / range; - for (let i = 0; i < len; i++) bins[Math.floor((mag[i] - dMin) * scale)]++; - // Find 99.9th percentile - const target = Math.ceil(len * 0.999); - let cumSum = 0; - let pMax = dMax; - for (let i = 0; i < NUM_BINS; i++) { - cumSum += bins[i]; - if (cumSum >= target) { pMax = dMin + (i / (NUM_BINS - 1)) * range; break; } - } - // If percentile collapsed to min (sparse spectra), fall back to actual max - if (pMax <= dMin) pMax = dMax; - return { min: dMin, max: pMax }; -} - -// ============================================================================ -// Singleton -// ============================================================================ - -let gpuFFT: WebGPUFFT | null = null; -let gpuDevice: GPUDevice | null = null; -let gpuInfo = "GPU"; - -export async function getGPUDevice(): Promise { - if (gpuDevice) return gpuDevice; - if (!navigator.gpu) return null; - try { - const adapter = await navigator.gpu.requestAdapter(); - if (!adapter) return null; - try { - // @ts-ignore - requestAdapterInfo is not yet in all type definitions - const info = await adapter.requestAdapterInfo?.(); - if (info) { - gpuInfo = info.description || `${info.vendor} ${info.architecture || ""} ${info.device || ""}`.trim() || "Generic WebGPU Adapter"; - } - } catch (_e) { /* adapter info not available */ } - gpuDevice = await adapter.requestDevice(); - return gpuDevice; - } catch { return null; } -} - -export async function getWebGPUFFT(): Promise { - if (gpuFFT) return gpuFFT; - const device = await getGPUDevice(); - if (!device) { console.warn('WebGPU not supported, falling back to CPU FFT'); return null; } - try { - gpuFFT = new WebGPUFFT(device); - await gpuFFT.init(); - return gpuFFT; - } catch (e) { console.warn('WebGPU init failed:', e); return null; } -} - -export function getGPUInfo(): string { return gpuInfo; } diff --git a/widget/js/figure.ts b/widget/js/figure.ts deleted file mode 100644 index b1fd3f2f0..000000000 --- a/widget/js/figure.ts +++ /dev/null @@ -1,433 +0,0 @@ -/** - * Shared scale bar, colorbar, and overlay utilities for all canvas-based widgets. - * Provides HiDPI-aware rendering with automatic unit conversion. - */ - -import { formatNumber } from "./format"; - -/** Round a physical value to a "nice" number (1, 2, 5, 10, 20, 50, ...) */ -export function roundToNiceValue(value: number): number { - if (value <= 0) return 1; - const magnitude = Math.pow(10, Math.floor(Math.log10(value))); - const normalized = value / magnitude; - if (normalized < 1.5) return magnitude; - if (normalized < 3.5) return 2 * magnitude; - if (normalized < 7.5) return 5 * magnitude; - return 10 * magnitude; -} - -/** Format scale bar label. Unit string is displayed verbatim - no conversion. */ -export function formatScaleLabel(value: number, unit: string): string { - const nice = roundToNiceValue(value); - return nice >= 1 ? `${Math.round(nice)} ${unit}` : `${nice.toFixed(2)} ${unit}`; -} - -const FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; - -/** - * Draw scale bar and zoom indicator on a high-DPI UI canvas. - * Renders crisp text/lines independent of the image resolution. - */ -export function drawScaleBarHiDPI( - canvas: HTMLCanvasElement, - dpr: number, - zoom: number, - pixelSize: number, - unit: string, - imageWidth: number, -) { - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.save(); - ctx.scale(dpr, dpr); - - const cssWidth = canvas.width / dpr; - const cssHeight = canvas.height / dpr; - const scaleX = cssWidth / imageWidth; - const effectiveZoom = zoom * scaleX; - - const targetBarPx = 60; - const barThickness = 5; - const fontSize = 16; - const margin = 12; - - const targetPhysical = (targetBarPx / effectiveZoom) * pixelSize; - const nicePhysical = roundToNiceValue(targetPhysical); - const barPx = (nicePhysical / pixelSize) * effectiveZoom; - - const barY = cssHeight - margin; - const barX = cssWidth - barPx - margin; - - ctx.shadowColor = "rgba(0, 0, 0, 0.5)"; - ctx.shadowBlur = 2; - ctx.shadowOffsetX = 1; - ctx.shadowOffsetY = 1; - - ctx.fillStyle = "white"; - ctx.fillRect(barX, barY, barPx, barThickness); - - const label = formatScaleLabel(nicePhysical, unit); - ctx.font = `${fontSize}px ${FONT}`; - ctx.fillStyle = "white"; - ctx.textAlign = "center"; - ctx.textBaseline = "bottom"; - ctx.fillText(label, barX + barPx / 2, barY - 4); - - ctx.textAlign = "left"; - ctx.textBaseline = "bottom"; - ctx.fillText(`${zoom.toFixed(1)}×`, margin, cssHeight - margin + barThickness); - - ctx.restore(); -} - -/** - * Draw reciprocal-space scale bar on an FFT overlay canvas. - * Only draws when fftPixelSize > 0 (i.e. real-space calibration is available). - */ -export function drawFFTScaleBarHiDPI( - canvas: HTMLCanvasElement, - dpr: number, - fftZoom: number, - fftPixelSize: number, - imageWidth: number, - unit: string = "1/px", -) { - const ctx = canvas.getContext("2d"); - if (!ctx || fftPixelSize <= 0) return; - - ctx.save(); - ctx.scale(dpr, dpr); - - const cssWidth = canvas.width / dpr; - const cssHeight = canvas.height / dpr; - const scaleX = cssWidth / imageWidth; - const effectiveZoom = fftZoom * scaleX; - - const targetBarPx = 60; - const barThickness = 5; - const fontSize = 16; - const margin = 12; - - const targetPhysical = (targetBarPx / effectiveZoom) * fftPixelSize; - const nicePhysical = roundToNiceValue(targetPhysical); - const barPx = (nicePhysical / fftPixelSize) * effectiveZoom; - - const barY = cssHeight - margin; - const barX = cssWidth - barPx - margin; - - ctx.shadowColor = "rgba(0, 0, 0, 0.5)"; - ctx.shadowBlur = 2; - ctx.shadowOffsetX = 1; - ctx.shadowOffsetY = 1; - - ctx.fillStyle = "white"; - ctx.fillRect(barX, barY, barPx, barThickness); - - const label = formatScaleLabel(nicePhysical, unit); - ctx.font = `${fontSize}px ${FONT}`; - ctx.fillStyle = "white"; - ctx.textAlign = "center"; - ctx.textBaseline = "bottom"; - ctx.fillText(label, barX + barPx / 2, barY - 4); - - ctx.textAlign = "left"; - ctx.textBaseline = "bottom"; - ctx.fillText(`${fftZoom.toFixed(1)}×`, margin, cssHeight - margin + barThickness); - - ctx.restore(); -} - -/** - * Draw a vertical colorbar on a canvas context (already DPR-scaled by caller). - * Gradient strip on right edge with vmin/vmax labels and optional log indicator. - */ -export function drawColorbar( - ctx: CanvasRenderingContext2D, - cssW: number, - cssH: number, - lut: Uint8Array, - vmin: number, - vmax: number, - logScale: boolean, -) { - const barW = 12; - const barH = Math.round(cssH * 0.6); - const barX = cssW - barW - 12; - const barY = Math.round((cssH - barH) / 2); - - // Gradient strip (bottom=vmin, top=vmax) - for (let row = 0; row < barH; row++) { - const t = 1 - row / (barH - 1); - const lutIdx = Math.round(t * 255); - const r = lut[lutIdx * 3]; - const g = lut[lutIdx * 3 + 1]; - const b = lut[lutIdx * 3 + 2]; - ctx.fillStyle = `rgb(${r},${g},${b})`; - ctx.fillRect(barX, barY + row, barW, 1); - } - - // Border - ctx.strokeStyle = "rgba(255,255,255,0.5)"; - ctx.lineWidth = 1; - ctx.strokeRect(barX, barY, barW, barH); - - // Labels with drop shadow - ctx.shadowColor = "rgba(0, 0, 0, 0.7)"; - ctx.shadowBlur = 2; - ctx.shadowOffsetX = 1; - ctx.shadowOffsetY = 1; - ctx.font = `11px ${FONT}`; - ctx.fillStyle = "white"; - ctx.textAlign = "right"; - ctx.textBaseline = "bottom"; - ctx.fillText(formatNumber(vmax), barX - 4, barY + 6); - ctx.textBaseline = "top"; - ctx.fillText(formatNumber(vmin), barX - 4, barY + barH - 4); - if (logScale) { - ctx.textBaseline = "middle"; - ctx.fillText("log", barX - 4, barY + barH / 2); - } -} - -// ============================================================================ -// Publication-quality figure export -// ============================================================================ - -export interface ExportFigureOptions { - /** Colormapped image canvas at native resolution (no zoom/pan). */ - imageCanvas: HTMLCanvasElement; - /** Figure title drawn above the image. */ - title?: string; - /** Colormap LUT (256 × 3 bytes) for the colorbar. */ - lut?: Uint8Array; - /** Data range for colorbar labels. */ - vmin?: number; - vmax?: number; - logScale?: boolean; - /** Pixel size in user-supplied unit (for scale bar computation). */ - pixelSize?: number; - /** Unit string for the scale bar label (e.g. "A", "nm", "mrad"). */ - pixelUnit?: string; - showColorbar?: boolean; - showScaleBar?: boolean; - /** Upscale factor for high-resolution output (default 4). Image pixels use nearest-neighbor for sharp edges. */ - scale?: number; - /** Callback to draw annotations (ROI, profile, markers) on the image. ctx is pre-translated to image origin and scaled. */ - drawAnnotations?: (ctx: CanvasRenderingContext2D) => void; -} - -/** - * Create a publication-quality figure canvas with title, scale bar, colorbar, - * and baked-in annotations. Returns an HTMLCanvasElement — caller can toBlob() + download. - */ -export function exportFigure(options: ExportFigureOptions): HTMLCanvasElement { - const { - imageCanvas, - title, - lut, - vmin = 0, - vmax = 1, - logScale = false, - pixelSize = 0, - pixelUnit = "pixels", - showColorbar = true, - showScaleBar = true, - scale: s = 4, - drawAnnotations, - } = options; - - const imgW = imageCanvas.width; - const imgH = imageCanvas.height; - - // Layout (in logical coordinates — scaled to canvas pixels by ctx.scale) - const pad = 20; - const titleH = title ? 28 : 0; - const titleGap = title ? 8 : 0; - const hasCb = showColorbar && lut && vmin !== vmax; - const cbWidth = hasCb ? 20 : 0; - const cbGap = hasCb ? 12 : 0; - const cbLabelW = hasCb ? 60 : 0; - - const totalW = pad + imgW + cbGap + cbWidth + cbLabelW + pad; - const totalH = pad + titleH + titleGap + imgH + pad; - - const canvas = document.createElement("canvas"); - canvas.width = totalW * s; - canvas.height = totalH * s; - const ctx = canvas.getContext("2d")!; - - // Scale all drawing operations - ctx.scale(s, s); - - // White background - ctx.fillStyle = "white"; - ctx.fillRect(0, 0, totalW, totalH); - - // Title - if (title) { - ctx.fillStyle = "black"; - ctx.font = `bold 18px ${FONT}`; - ctx.textAlign = "left"; - ctx.textBaseline = "top"; - ctx.fillText(title, pad, pad); - } - - const imgX = pad; - const imgY = pad + titleH + titleGap; - - // Image (nearest-neighbor for sharp pixels) - ctx.imageSmoothingEnabled = false; - ctx.drawImage(imageCanvas, imgX, imgY, imgW, imgH); - ctx.imageSmoothingEnabled = true; - - // Annotations - if (drawAnnotations) { - ctx.save(); - ctx.translate(imgX, imgY); - drawAnnotations(ctx); - ctx.restore(); - } - - // Scale bar (white with drop shadow, positioned at bottom-right of image) - if (showScaleBar && pixelSize > 0) { - const targetBarPx = Math.max(60, imgW * 0.15); - const barThickness = Math.max(4, Math.round(imgH * 0.012)); - const fontSize = Math.max(14, Math.round(imgH * 0.04)); - const margin = Math.max(12, Math.round(imgW * 0.03)); - - const targetPhysical = targetBarPx * pixelSize; - const nicePhysical = roundToNiceValue(targetPhysical); - const barPx = nicePhysical / pixelSize; - - const barY = imgY + imgH - margin; - const barX = imgX + imgW - barPx - margin; - - ctx.shadowColor = "rgba(0, 0, 0, 0.5)"; - ctx.shadowBlur = 2; - ctx.shadowOffsetX = 1; - ctx.shadowOffsetY = 1; - - ctx.fillStyle = "white"; - ctx.fillRect(barX, barY, barPx, barThickness); - - const label = formatScaleLabel(nicePhysical, pixelUnit); - ctx.font = `bold ${fontSize}px ${FONT}`; - ctx.fillStyle = "white"; - ctx.textAlign = "center"; - ctx.textBaseline = "bottom"; - ctx.fillText(label, barX + barPx / 2, barY - 4); - - ctx.shadowColor = "transparent"; - ctx.shadowBlur = 0; - ctx.shadowOffsetX = 0; - ctx.shadowOffsetY = 0; - } - - // Colorbar (vertical gradient strip to the right of image) - if (hasCb && lut) { - const cbX = imgX + imgW + cbGap; - const cbY = imgY; - const cbH = imgH; - - for (let row = 0; row < cbH; row++) { - const t = 1 - row / (cbH - 1); - const lutIdx = Math.round(t * 255); - const r = lut[lutIdx * 3]; - const g = lut[lutIdx * 3 + 1]; - const b = lut[lutIdx * 3 + 2]; - ctx.fillStyle = `rgb(${r},${g},${b})`; - ctx.fillRect(cbX, cbY + row, cbWidth, 1); - } - - ctx.strokeStyle = "black"; - ctx.lineWidth = 1; - ctx.strokeRect(cbX, cbY, cbWidth, cbH); - - ctx.fillStyle = "black"; - ctx.font = `12px ${FONT}`; - ctx.textAlign = "left"; - ctx.textBaseline = "top"; - ctx.fillText(formatNumber(vmax), cbX + cbWidth + 4, cbY); - ctx.textBaseline = "bottom"; - ctx.fillText(formatNumber(vmin), cbX + cbWidth + 4, cbY + cbH); - if (logScale) { - ctx.textBaseline = "middle"; - ctx.fillText("log", cbX + cbWidth + 4, cbY + cbH / 2); - } - } - - return canvas; -} - -/** - * Convert a canvas to a PDF blob by embedding JPEG data in a minimal PDF. - * Zero external dependencies — uses the DCTDecode filter (native JPEG in PDF). - */ -export async function canvasToPDF(canvas: HTMLCanvasElement, quality = 0.95): Promise { - const jpegBlob = await new Promise((resolve) => - canvas.toBlob((b) => resolve(b!), "image/jpeg", quality)); - const jpegBytes = new Uint8Array(await jpegBlob.arrayBuffer()); - const w = canvas.width; - const h = canvas.height; - - // Build PDF objects - const contentStream = `q ${w} 0 0 ${h} 0 0 cm /I0 Do Q`; - const objects: string[] = []; - const offsets: number[] = []; - - // Helper to track object positions - let pdf = "%PDF-1.4\n"; - - // Object 1: Catalog - offsets.push(pdf.length); - objects.push("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); - pdf += objects[0]; - - // Object 2: Pages - offsets.push(pdf.length); - objects.push("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); - pdf += objects[1]; - - // Object 3: Page - offsets.push(pdf.length); - objects.push(`3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${w} ${h}] /Contents 4 0 R /Resources << /XObject << /I0 5 0 R >> >> >>\nendobj\n`); - pdf += objects[2]; - - // Object 4: Content stream - offsets.push(pdf.length); - objects.push(`4 0 obj\n<< /Length ${contentStream.length} >>\nstream\n${contentStream}\nendstream\nendobj\n`); - pdf += objects[3]; - - // Object 5: Image (JPEG) — build as binary - const imgHeader = `5 0 obj\n<< /Type /XObject /Subtype /Image /Width ${w} /Height ${h} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpegBytes.length} >>\nstream\n`; - const imgFooter = "\nendstream\nendobj\n"; - - // Convert text part to bytes - const encoder = new TextEncoder(); - const headerBytes = encoder.encode(pdf + imgHeader); - const footerBytes = encoder.encode(imgFooter); - - // Build xref - const imgOffset = pdf.length; - offsets.push(imgOffset); - const afterImage = headerBytes.length + jpegBytes.length + footerBytes.length; - - const xrefOffset = afterImage; - let xref = `xref\n0 6\n0000000000 65535 f \n`; - for (let i = 0; i < offsets.length; i++) { - xref += `${String(offsets[i]).padStart(10, "0")} 00000 n \n`; - } - xref += `trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; - const xrefBytes = encoder.encode(xref); - - // Combine all parts - const result = new Uint8Array(headerBytes.length + jpegBytes.length + footerBytes.length + xrefBytes.length); - result.set(headerBytes, 0); - result.set(jpegBytes, headerBytes.length); - result.set(footerBytes, headerBytes.length + jpegBytes.length); - result.set(xrefBytes, headerBytes.length + jpegBytes.length + footerBytes.length); - - return new Blob([result], { type: "application/pdf" }); -} diff --git a/widget/js/format.ts b/widget/js/format.ts deleted file mode 100644 index 31f2c4ca3..000000000 --- a/widget/js/format.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** Convert anywidget DataView/ArrayBuffer to Uint8Array. */ -export function extractBytes(dataView: DataView | ArrayBuffer | Uint8Array): Uint8Array { - if (dataView instanceof Uint8Array) return dataView; - if (dataView instanceof ArrayBuffer) return new Uint8Array(dataView); - if (dataView && "buffer" in dataView) { - return new Uint8Array(dataView.buffer, dataView.byteOffset, dataView.byteLength); - } - return new Uint8Array(0); -} - -/** Extract Float32Array from anywidget DataView. Returns null if empty. */ -export function extractFloat32(dataView: DataView | ArrayBuffer | Uint8Array): Float32Array | null { - const bytes = extractBytes(dataView); - if (bytes.length === 0) return null; - return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4); -} - -/** Download a Blob as a file. */ -export function downloadBlob(blob: Blob, filename: string): void { - const link = document.createElement("a"); - link.download = filename; - const url = URL.createObjectURL(blob); - link.href = url; - link.click(); - // Defer revocation to ensure browser has time to start the download - setTimeout(() => URL.revokeObjectURL(url), 60000); -} - -/** Download a DataView as a file (e.g. GIF/ZIP from Python). */ -export function downloadDataView(dataView: DataView, filename: string, mimeType: string): void { - const buf = new Uint8Array(dataView.buffer as ArrayBuffer, dataView.byteOffset, dataView.byteLength); - downloadBlob(new Blob([buf as BlobPart], { type: mimeType }), filename); -} - -/** Format number with exponential notation for large/small values. */ -export function formatNumber(val: number, decimals: number = 2): string { - if (val === 0) return "0"; - if (Math.abs(val) >= 1000 || Math.abs(val) < 0.01) return val.toExponential(decimals); - return val.toFixed(decimals); -} diff --git a/widget/js/show2d/index.tsx b/widget/js/show2d/index.tsx deleted file mode 100644 index be4a60e93..000000000 --- a/widget/js/show2d/index.tsx +++ /dev/null @@ -1,4268 +0,0 @@ -/** - * Show2D - Static 2D image viewer with gallery support. - * - * Features: - * - Single image or gallery mode with configurable columns - * - Scroll to zoom, double-click to reset - * - WebGPU-accelerated FFT with default 3x zoom - * - Equal-sized FFT and histogram panels - * - Click to select image in gallery mode - */ - -import * as React from "react"; -import { createRender, useModelState } from "@anywidget/react"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import Stack from "@mui/material/Stack"; -import Select from "@mui/material/Select"; -import MenuItem from "@mui/material/MenuItem"; -import Menu from "@mui/material/Menu"; -import Switch from "@mui/material/Switch"; -import Slider from "@mui/material/Slider"; -import Button from "@mui/material/Button"; -import Tooltip from "@mui/material/Tooltip"; -import { useTheme } from "../theme"; -import { drawScaleBarHiDPI, drawColorbar, roundToNiceValue, exportFigure, canvasToPDF } from "../figure"; -import JSZip from "jszip"; -import { extractFloat32, formatNumber, downloadBlob } from "../format"; -import { computeHistogramFromBytes, findDataRange, applyLogScale, percentileClip, sliderRange, computeStats } from "../stats"; - -function InfoTooltip({ text, theme = "dark" }: { text: React.ReactNode; theme?: "light" | "dark" }) { - const isDark = theme === "dark"; - const content = typeof text === "string" - ? {text} - : text; - return ( - - - - ); -} - -function KeyboardShortcuts({ items }: { items: [string, string][] }) { - return ( - - - {items.map(([key, desc], i) => ( - {key}{desc} - ))} - - - ); -} - -const upwardMenuProps = { - anchorOrigin: { vertical: "top" as const, horizontal: "left" as const }, - transformOrigin: { vertical: "bottom" as const, horizontal: "left" as const }, - sx: { zIndex: 9999 }, -}; -import { getWebGPUFFT, WebGPUFFT, fft2d, fft2dAsync, fftshift, computeMagnitude, autoEnhanceFFT, nextPow2, applyHannWindow2D, getGPUInfo } from "../fft"; -import { COLORMAPS, COLORMAP_NAMES, renderToOffscreen, renderToOffscreenReuse, GPUColormapEngine, getGPUColormapEngine, getGPUMaxBufferSize } from "../colormaps"; - -const MIN_ZOOM = 0.5; -const MAX_ZOOM = 20; - -const DPR = window.devicePixelRatio || 1; - -interface HistogramProps { - data: Float32Array | null; - precomputedBins?: number[] | null; // GPU-computed bins bypass computeHistogramFromBytes - vminPct: number; - vmaxPct: number; - onRangeChange: (min: number, max: number) => void; - width?: number; - height?: number; - theme?: "light" | "dark"; - dataMin?: number; - dataMax?: number; -} - -function Histogram({ data, precomputedBins, vminPct, vmaxPct, onRangeChange, width = 110, height = 40, theme = "dark", dataMin = 0, dataMax = 1 }: HistogramProps) { - const canvasRef = React.useRef(null); - const cpuBins = React.useMemo(() => precomputedBins ? null : computeHistogramFromBytes(data), [data, precomputedBins]); - const bins = precomputedBins || cpuBins || new Array(256).fill(0); - const isDark = theme === "dark"; - const colors = isDark ? { bg: "#1a1a1a", barActive: "#888", barInactive: "#444", border: "#333" } : { bg: "#f0f0f0", barActive: "#666", barInactive: "#bbb", border: "#ccc" }; - - React.useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - const dpr = window.devicePixelRatio || 1; - canvas.width = width * dpr; - canvas.height = height * dpr; - ctx.scale(dpr, dpr); - ctx.fillStyle = colors.bg; - ctx.fillRect(0, 0, width, height); - const displayBins = 64; - const binRatio = Math.floor(bins.length / displayBins); - const reducedBins: number[] = []; - for (let i = 0; i < displayBins; i++) { - let sum = 0; - for (let j = 0; j < binRatio; j++) sum += bins[i * binRatio + j] || 0; - reducedBins.push(sum / binRatio); - } - const maxVal = Math.max(...reducedBins, 0.001); - const barWidth = width / displayBins; - const vminBin = Math.floor((vminPct / 100) * displayBins); - const vmaxBin = Math.floor((vmaxPct / 100) * displayBins); - for (let i = 0; i < displayBins; i++) { - const barHeight = (reducedBins[i] / maxVal) * (height - 2); - ctx.fillStyle = (i >= vminBin && i <= vmaxBin) ? colors.barActive : colors.barInactive; - ctx.fillRect(i * barWidth + 0.5, height - barHeight, Math.max(1, barWidth - 1), barHeight); - } - }, [bins, vminPct, vmaxPct, width, height, colors]); - - return ( - - - { const [newMin, newMax] = v as number[]; onRangeChange(Math.min(newMin, newMax - 1), Math.max(newMax, newMin + 1)); }} - min={0} max={100} size="small" valueLabelDisplay="auto" - valueLabelFormat={(pct) => { const val = dataMin + (pct / 100) * (dataMax - dataMin); return val >= 1000 ? val.toExponential(1) : val.toFixed(1); }} - sx={{ width, py: 0, "& .MuiSlider-thumb": { width: 8, height: 8 }, "& .MuiSlider-rail": { height: 2 }, "& .MuiSlider-track": { height: 2 }, "& .MuiSlider-valueLabel": { fontSize: 10, padding: "2px 4px" } }} - /> - {(() => { const v = dataMin + (vminPct / 100) * (dataMax - dataMin); return v >= 1000 ? v.toExponential(1) : v.toFixed(1); })()}{(() => { const v = dataMin + (vmaxPct / 100) * (dataMax - dataMin); return v >= 1000 ? v.toExponential(1) : v.toFixed(1); })()} - - ); -} - -// ============================================================================ -// Line profile sampling (bilinear interpolation along line) -// ============================================================================ -function sampleLineProfile(data: Float32Array, w: number, h: number, row0: number, col0: number, row1: number, col1: number): Float32Array { - const dc = col1 - col0; - const dr = row1 - row0; - const len = Math.sqrt(dc * dc + dr * dr); - const n = Math.max(2, Math.ceil(len)); - const out = new Float32Array(n); - for (let i = 0; i < n; i++) { - const t = i / (n - 1); - const c = col0 + t * dc; - const r = row0 + t * dr; - const ci = Math.floor(c), ri = Math.floor(r); - const cf = c - ci, rf = r - ri; - const c0c = Math.max(0, Math.min(w - 1, ci)); - const c1c = Math.max(0, Math.min(w - 1, ci + 1)); - const r0c = Math.max(0, Math.min(h - 1, ri)); - const r1c = Math.max(0, Math.min(h - 1, ri + 1)); - out[i] = data[r0c * w + c0c] * (1 - cf) * (1 - rf) + - data[r0c * w + c1c] * cf * (1 - rf) + - data[r1c * w + c0c] * (1 - cf) * rf + - data[r1c * w + c1c] * cf * rf; - } - return out; -} - -function pointToSegmentDistance(col: number, row: number, col0: number, row0: number, col1: number, row1: number): number { - const dc = col1 - col0; - const dr = row1 - row0; - const lenSq = dc * dc + dr * dr; - if (lenSq <= 1e-12) return Math.sqrt((col - col0) ** 2 + (row - row0) ** 2); - const tRaw = ((col - col0) * dc + (row - row0) * dr) / lenSq; - const t = Math.max(0, Math.min(1, tRaw)); - const projCol = col0 + t * dc; - const projRow = row0 + t * dr; - return Math.sqrt((col - projCol) ** 2 + (row - projRow) ** 2); -} - -// ============================================================================ -// FFT peak finder (snap to Bragg spot with sub-pixel centroid refinement) -// ============================================================================ -function findFFTPeak(mag: Float32Array, width: number, height: number, col: number, row: number, radius: number): { row: number; col: number } { - // Find brightest pixel in search window - const c0 = Math.max(0, Math.floor(col) - radius); - const r0 = Math.max(0, Math.floor(row) - radius); - const c1 = Math.min(width - 1, Math.floor(col) + radius); - const r1 = Math.min(height - 1, Math.floor(row) + radius); - let bestCol = Math.round(col), bestRow = Math.round(row), bestVal = -Infinity; - for (let ir = r0; ir <= r1; ir++) { - for (let ic = c0; ic <= c1; ic++) { - const val = mag[ir * width + ic]; - if (val > bestVal) { bestVal = val; bestCol = ic; bestRow = ir; } - } - } - // Sub-pixel refinement via weighted centroid in 3×3 window - const wc0 = Math.max(0, bestCol - 1), wc1 = Math.min(width - 1, bestCol + 1); - const wr0 = Math.max(0, bestRow - 1), wr1 = Math.min(height - 1, bestRow + 1); - let sumW = 0, sumWC = 0, sumWR = 0; - for (let ir = wr0; ir <= wr1; ir++) { - for (let ic = wc0; ic <= wc1; ic++) { - const w = mag[ir * width + ic]; - sumW += w; sumWC += w * ic; sumWR += w * ir; - } - } - if (sumW > 0) return { row: sumWR / sumW, col: sumWC / sumW }; - return { row: bestRow, col: bestCol }; -} - -const FFT_SNAP_RADIUS = 5; - -// ============================================================================ -// Types -// ============================================================================ -type ZoomState = { zoom: number; panX: number; panY: number }; - -// ============================================================================ -// Constants -// ============================================================================ -const SINGLE_IMAGE_TARGET = 500; -const GALLERY_IMAGE_TARGET = 300; -const DEFAULT_FFT_ZOOM = 2; -const PROFILE_COLORS = ["#4fc3f7", "#81c784", "#ffb74d", "#ce93d8", "#ef5350", "#ffd54f", "#90a4ae", "#a1887f"]; -type ROIItem = { row: number; col: number; shape: string; radius: number; radius_inner: number; width: number; height: number; color: string; line_width: number; highlight: boolean }; -const ROI_COLORS = ["#4fc3f7", "#81c784", "#ffb74d", "#ce93d8", "#ef5350", "#ffd54f", "#90a4ae", "#a1887f"]; -const RESIZE_HIT_AREA_PX = 10; - -function drawROI( - ctx: CanvasRenderingContext2D, - x: number, y: number, - shape: "circle" | "square" | "rectangle" | "annular", - radius: number, w: number, h: number, - activeColor: string, inactiveColor: string, - active: boolean = false, innerRadius: number = 0 -): void { - const strokeColor = active ? activeColor : inactiveColor; - ctx.strokeStyle = strokeColor; - if (shape === "circle") { - ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.stroke(); - } else if (shape === "square") { - ctx.strokeRect(x - radius, y - radius, radius * 2, radius * 2); - } else if (shape === "rectangle") { - ctx.strokeRect(x - w / 2, y - h / 2, w, h); - } else if (shape === "annular") { - ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.stroke(); - ctx.strokeStyle = active ? "#0ff" : inactiveColor; - ctx.beginPath(); ctx.arc(x, y, innerRadius, 0, Math.PI * 2); ctx.stroke(); - ctx.fillStyle = (active ? activeColor : inactiveColor) + "15"; - ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.arc(x, y, innerRadius, 0, Math.PI * 2, true); ctx.fill(); - ctx.strokeStyle = strokeColor; - } - if (active) { - ctx.beginPath(); - ctx.moveTo(x - 5, y); ctx.lineTo(x + 5, y); - ctx.moveTo(x, y - 5); ctx.lineTo(x, y + 5); - ctx.stroke(); - } -} - -// ============================================================================ -// Crop ROI region from raw float32 data for ROI-scoped FFT -// ============================================================================ -function cropROIRegion( - data: Float32Array, imgW: number, imgH: number, - roi: ROIItem, -): { cropped: Float32Array; cropW: number; cropH: number } | null { - const shape = roi.shape || "circle"; - let x0: number, y0: number, x1: number, y1: number; - - if (shape === "rectangle") { - const hw = roi.width / 2; - const hh = roi.height / 2; - x0 = Math.max(0, Math.floor(roi.col - hw)); - y0 = Math.max(0, Math.floor(roi.row - hh)); - x1 = Math.min(imgW, Math.ceil(roi.col + hw)); - y1 = Math.min(imgH, Math.ceil(roi.row + hh)); - } else { - const r = roi.radius; - x0 = Math.max(0, Math.floor(roi.col - r)); - y0 = Math.max(0, Math.floor(roi.row - r)); - x1 = Math.min(imgW, Math.ceil(roi.col + r)); - y1 = Math.min(imgH, Math.ceil(roi.row + r)); - } - - const cropW = x1 - x0; - const cropH = y1 - y0; - if (cropW < 2 || cropH < 2) return null; - - const cropped = new Float32Array(cropW * cropH); - - if (shape === "circle" || shape === "annular") { - const r = roi.radius; - const rSq = r * r; - for (let dy = 0; dy < cropH; dy++) { - for (let dx = 0; dx < cropW; dx++) { - const imgX = x0 + dx; - const imgY = y0 + dy; - const distSq = (imgX - roi.col) * (imgX - roi.col) + (imgY - roi.row) * (imgY - roi.row); - cropped[dy * cropW + dx] = distSq <= rSq ? data[imgY * imgW + imgX] : 0; - } - } - } else { - for (let dy = 0; dy < cropH; dy++) { - const srcOffset = (y0 + dy) * imgW + x0; - cropped.set(data.subarray(srcOffset, srcOffset + cropW), dy * cropW); - } - } - - return { cropped, cropW, cropH }; -} - -// ============================================================================ -// Main Component -// ============================================================================ -// Show4DSTEM-style UI constants -const typography = { - label: { fontSize: 11 }, - labelSmall: { fontSize: 10 }, - value: { fontSize: 10, fontFamily: "monospace" }, -}; -const SPACING = { XS: 4, SM: 8, MD: 12, LG: 16 }; -const controlRow = { - display: "flex", - alignItems: "center", - gap: `${SPACING.SM}px`, - px: 1, - py: 0.5, - width: "fit-content", -}; -const compactButton = { - fontSize: 10, - py: 0.25, - px: 1, - minWidth: 0, - "&.Mui-disabled": { - color: "#666", - borderColor: "#444", - }, -}; -const switchStyles = { - small: { "& .MuiSwitch-thumb": { width: 12, height: 12 }, "& .MuiSwitch-switchBase": { padding: "4px" } }, -}; -const sliderStyles = { - small: { py: 0, "& .MuiSlider-thumb": { width: 10, height: 10 }, "& .MuiSlider-rail": { height: 2 }, "& .MuiSlider-track": { height: 2 } }, -}; - -function Show2D() { - // Theme - const { themeInfo, colors: tc } = useTheme(); - const themeColors = { - ...tc, - accentGreen: themeInfo.theme === "dark" ? "#0f0" : "#1a7a1a", - }; - - const themedSelect = { - fontSize: 10, - bgcolor: themeColors.controlBg, - color: themeColors.text, - "& .MuiSelect-select": { py: 0.5 }, - "& .MuiOutlinedInput-notchedOutline": { borderColor: themeColors.border }, - "&:hover .MuiOutlinedInput-notchedOutline": { borderColor: themeColors.accent }, - }; - - const themedMenuProps = { - ...upwardMenuProps, - PaperProps: { sx: { bgcolor: themeColors.controlBg, color: themeColors.text, border: `1px solid ${themeColors.border}` } }, - }; - - // Model state - const [nImages] = useModelState("n_images"); - const [width] = useModelState("width"); - const [height] = useModelState("height"); - const [frameBytes] = useModelState("frame_bytes"); - const [labels] = useModelState("labels"); - const [title] = useModelState("title"); - const [displayBinFactor] = useModelState("_display_bin_factor"); - const [, setGpuMaxBufferMB] = useModelState("_gpu_max_buffer_mb"); - const [widgetVersion] = useModelState("widget_version"); - const [cmap, setCmap] = useModelState("cmap"); - const [ncols] = useModelState("ncols"); - - // Display options - const [logScale, setLogScale] = useModelState("log_scale"); - const [autoContrast, setAutoContrast] = useModelState("auto_contrast"); - const [traitVmin] = useModelState("vmin"); - const [traitVmax] = useModelState("vmax"); - const [traitVmins] = useModelState<(number | null)[] | null>("vmins"); - const [traitVmaxs] = useModelState<(number | null)[] | null>("vmaxs"); - const [zoomRowTrait] = useModelState("zoom_row"); - const [zoomColTrait] = useModelState("zoom_col"); - const [diffMode, setDiffMode] = useModelState("diff_mode"); - const [diffReference] = useModelState("diff_reference"); - // Align removed — diff = A − B (no shift). Drift correction happens upstream. - const alignDy = 0; - const alignDx = 0; - - // Customization - const [canvasSizeTrait] = useModelState("size"); - const [smooth, setSmooth] = useModelState("smooth"); - const imageRenderingStyle = smooth ? "auto" : "pixelated"; - - // Scale bar - const [pixelSize] = useModelState("pixel_size"); - const [pixelUnit] = useModelState("pixel_unit"); - const [scaleBarVisible] = useModelState("scale_bar_visible"); - - // UI visibility - const [showControls] = useModelState("show_controls"); - const [showStats] = useModelState("show_stats"); - const [statsMean] = useModelState("stats_mean"); - const [statsMin] = useModelState("stats_min"); - const [statsMax] = useModelState("stats_max"); - const [statsStd] = useModelState("stats_std"); - - // Analysis Panels (FFT + Histogram) - const [showFft, setShowFft] = useModelState("show_fft"); - const [fftWindow, setFftWindow] = useModelState("fft_window"); - - // Selection - const [selectedIdx, setSelectedIdx] = useModelState("selected_idx"); - - // ROI - const [roiActive, setRoiActive] = useModelState("roi_active"); - const [roiList, setRoiList] = useModelState("roi_list"); - const [roiSelectedIdx, setRoiSelectedIdx] = useModelState("roi_selected_idx"); - const [imageRotations, setImageRotations] = useModelState("image_rotations"); - const [isDraggingROI, setIsDraggingROI] = React.useState(false); - const [isDraggingResize, setIsDraggingResize] = React.useState(false); - const [isDraggingResizeInner, setIsDraggingResizeInner] = React.useState(false); - const [isHoveringResize, setIsHoveringResize] = React.useState(false); - const [isHoveringResizeInner, setIsHoveringResizeInner] = React.useState(false); - const resizeAspectRef = React.useRef(null); - const [newRoiShape, setNewRoiShape] = React.useState<"circle" | "square" | "rectangle" | "annular">("square"); - const [exportAnchor, setExportAnchor] = React.useState(null); - const selectedRoi = roiSelectedIdx >= 0 && roiSelectedIdx < (roiList?.length ?? 0) ? roiList[roiSelectedIdx] : null; - - const effectiveShowFft = showFft; - - const updateSelectedRoi = (updates: Partial) => { - if (roiSelectedIdx < 0 || !roiList) return; - const newList = [...roiList]; - newList[roiSelectedIdx] = { ...newList[roiSelectedIdx], ...updates }; - setRoiList(newList); - }; - - // Canvas refs - const canvasRefs = React.useRef<(HTMLCanvasElement | null)[]>([]); - const overlayRefs = React.useRef<(HTMLCanvasElement | null)[]>([]); - const imageContainerRefs = React.useRef<(HTMLDivElement | null)[]>([]); - const fftContainerRefs = React.useRef<(HTMLDivElement | null)[]>([]); - const singleFftContainerRef = React.useRef(null); - const fftCanvasRef = React.useRef(null); - const [canvasReady, setCanvasReady] = React.useState(0); // Trigger re-render when refs attached - - // Zoom/Pan state - per-image when not linked, shared when linked - const [initialZoom] = useModelState("initial_zoom"); - const [linkPan, setLinkPan] = useModelState("link_pan"); - const [imgHeight] = useModelState("height"); - const [imgWidth] = useModelState("width"); - // Note: pan derived from zoom_row/zoom_col is applied via a useEffect AFTER canvasW/canvasH - // are computed (see "Initial pan from zoom_row/zoom_col" effect below). - const initialZoomState: ZoomState = React.useMemo( - () => ({ zoom: Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, initialZoom || 1)), panX: 0, panY: 0 }), - [initialZoom] - ); - void linkPan; void setLinkPan; void imgWidth; void imgHeight; - const [zoomStates, setZoomStates] = React.useState>(new Map()); - const [linkedZoomState, setLinkedZoomState] = React.useState(initialZoomState); - const [linkedZoom, setLinkedZoom] = useModelState("link_zoom"); - const [isDraggingPan, setIsDraggingPan] = React.useState(false); - const [panStart, setPanStart] = React.useState<{ x: number, y: number, pX: number, pY: number } | null>(null); - - // Helper to get zoom state for an image. zoom and pan link independently: - // zoom from linkedZoomState if linkedZoom else per-image - // pan from linkedZoomState if linkPan else per-image - const getZoomState = React.useCallback((idx: number): ZoomState => { - const per = zoomStates.get(idx) || initialZoomState; - return { - zoom: linkedZoom ? linkedZoomState.zoom : per.zoom, - panX: linkPan ? linkedZoomState.panX : per.panX, - panY: linkPan ? linkedZoomState.panY : per.panY, - }; - }, [linkedZoom, linkPan, linkedZoomState, zoomStates, initialZoomState]); - - // Helper to set zoom state for an image. zoom and pan honored independently: - // zoom: writes to linkedZoomState if linkedZoom, else per-image - // pan: writes to linkedZoomState if linkPan, else per-image - const setZoomState = React.useCallback((idx: number, state: ZoomState) => { - if (linkedZoom || linkPan) { - setLinkedZoomState(prev => ({ - zoom: linkedZoom ? state.zoom : prev.zoom, - panX: linkPan ? state.panX : prev.panX, - panY: linkPan ? state.panY : prev.panY, - })); - } - if (!linkedZoom || !linkPan) { - setZoomStates(prev => { - const m = new Map(prev); - const cur = m.get(idx) || initialZoomState; - m.set(idx, { - zoom: linkedZoom ? cur.zoom : state.zoom, - panX: linkPan ? cur.panX : state.panX, - panY: linkPan ? cur.panY : state.panY, - }); - return m; - }); - } - }, [linkedZoom, linkPan, initialZoomState]); - - // FFT zoom/pan state (single mode) - const [fftZoom, setFftZoom] = React.useState(DEFAULT_FFT_ZOOM); - const [fftPanX, setFftPanX] = React.useState(0); - const [fftPanY, setFftPanY] = React.useState(0); - const [isDraggingFftPan, setIsDraggingFftPan] = React.useState(false); - const [fftPanStart, setFftPanStart] = React.useState<{ x: number, y: number, pX: number, pY: number } | null>(null); - - // Histogram state — per-image contrast ranges (gallery) or single (one image) - const [linkedContrast, setLinkedContrast] = useModelState("link_contrast"); - const [linkedContrastState, setLinkedContrastState] = React.useState<{ vminPct: number; vmaxPct: number }>({ vminPct: 0, vmaxPct: 100 }); - const [contrastStates, setContrastStates] = React.useState>(new Map()); - // Ref mirror for fast slider path (bypass React effect batching) - const contrastRef = React.useRef<{ linked: { vminPct: number; vmaxPct: number }; perImage: Map }>({ linked: { vminPct: 0, vmaxPct: 100 }, perImage: new Map() }); - const sliderRafRef = React.useRef(0); - const getContrastState = React.useCallback((idx: number) => { - if (linkedContrast) return linkedContrastState; - return contrastStates.get(idx) || { vminPct: 0, vmaxPct: 100 }; - }, [linkedContrast, linkedContrastState, contrastStates]); - const setContrastState = React.useCallback((idx: number, state: { vminPct: number; vmaxPct: number }) => { - // Update ref immediately (for fast rAF render) - if (linkedContrast) { - contrastRef.current.linked = state; - setLinkedContrastState(state); - } else { - contrastRef.current.perImage.set(idx, state); - setContrastStates(prev => new Map(prev).set(idx, state)); - } - // Fast path: direct GPU render via rAF, bypassing React effect batching - const engine = gpuCmapRef.current; - if (engine && gpuCmapReadyRef.current && engine.slotCount >= nImages) { - cancelAnimationFrame(sliderRafRef.current); - sliderRafRef.current = requestAnimationFrame(() => { - const cachedRanges = dataRangesRef.current; - if (cachedRanges.length === 0) return; - const lut = COLORMAPS[cmapRef.current] || COLORMAPS.inferno; - engine.uploadLUT(cmapRef.current, lut); - const indices = Array.from({ length: nImages }, (_, i) => i); - const ranges: { vmin: number; vmax: number }[] = []; - for (let i = 0; i < nImages; i++) { - const cs = linkedContrast ? contrastRef.current.linked : (contrastRef.current.perImage.get(i) || { vminPct: 0, vmaxPct: 100 }); - let cr = cachedRanges[i]; - if (!cr || cr.min === cr.max) { - if (rawDataRef.current && rawDataRef.current[i]) cr = findDataRange(rawDataRef.current[i]); - } - cr = cr || { min: 0, max: 1 }; - if (cs.vminPct > 0 || cs.vmaxPct < 100) { - ranges.push(sliderRange(cr.min, cr.max, cs.vminPct, cs.vmaxPct)); - } else { - ranges.push({ vmin: cr.min, vmax: cr.max }); - } - } - const ls = logScaleRef.current ?? false; - const bitmaps = engine.renderSlotsToImageBitmap(indices, ranges, ls); - if (bitmaps && bitmaps[0]) { - for (let i = 0; i < bitmaps.length; i++) { - const offscreen = mainOffscreensRef.current[i]; - if (offscreen && bitmaps[i]) offscreen.getContext("2d")?.drawImage(bitmaps[i], 0, 0); - } - setOffscreenVersion(v => v + 1); - } - }); - } - }, [linkedContrast, nImages]); - // Convenience accessors for active image - const activeContrastIdx = nImages > 1 ? selectedIdx : 0; - const imageVminPct = getContrastState(activeContrastIdx).vminPct; - const imageVmaxPct = getContrastState(activeContrastIdx).vmaxPct; - - const [imageHistogramData, setImageHistogramData] = React.useState(null); - const [imageHistogramBins, setImageHistogramBins] = React.useState(null); - const [imageDataRange, setImageDataRange] = React.useState<{ min: number; max: number }>({ min: 0, max: 1 }); - - // FFT display state (single mode) - const [fftVminPct, setFftVminPct] = React.useState(0); - const [fftVmaxPct, setFftVmaxPct] = React.useState(100); - const [fftHistogramData, setFftHistogramData] = React.useState(null); - const [fftDataRange, setFftDataRange] = React.useState<{ min: number; max: number }>({ min: 0, max: 1 }); - const [fftColormap, setFftColormap] = React.useState("inferno"); - const [fftScaleMode, setFftScaleMode] = React.useState<"linear" | "log" | "power">("linear"); - const [fftAuto, setFftAuto] = React.useState(true); - const [fftSmooth, setFftSmooth] = React.useState(true); - const [fftLinkedZoom, setFftLinkedZoom] = React.useState(false); - const [fftLinkPan, setFftLinkPan] = React.useState(false); - const [fftLinkedContrast, setFftLinkedContrast] = React.useState(true); - // Per-image FFT contrast (used when fftLinkedContrast=false) - const [fftContrastStates, setFftContrastStates] = React.useState>(new Map()); - const fftContrastFor = React.useCallback((idx: number) => { - if (fftLinkedContrast) return { vminPct: fftVminPct, vmaxPct: fftVmaxPct }; - return fftContrastStates.get(idx) || { vminPct: 0, vmaxPct: 100 }; - }, [fftLinkedContrast, fftVminPct, fftVmaxPct, fftContrastStates]); - const setFftContrastFor = React.useCallback((idx: number, val: { vminPct: number; vmaxPct: number }) => { - if (fftLinkedContrast) { - setFftVminPct(val.vminPct); - setFftVmaxPct(val.vmaxPct); - } else { - setFftContrastStates(prev => new Map(prev).set(idx, val)); - } - }, [fftLinkedContrast]); - const [fftStats, setFftStats] = React.useState(null); - const [fftShowColorbar, setFftShowColorbar] = React.useState(false); - - // FFT loading state — shown as a pulsing overlay while FFT computes - const [fftComputing, setFftComputing] = React.useState(false); - const [fftProgress, setFftProgress] = React.useState(""); - - // Cursor readout state - const [cursorInfo, setCursorInfo] = React.useState<{ row: number; col: number; value: number } | null>(null); - - // Colorbar state (single image mode only) - const [showColorbar, setShowColorbar] = React.useState(false); - - // Inset magnifier state - const [showLens, setShowLens] = React.useState(false); - const [lensPos, setLensPos] = React.useState<{ row: number; col: number } | null>(null); - const [lensMag, setLensMag] = React.useState(4); // magnification 2×–8× - const [lensDisplaySize, setLensDisplaySize] = React.useState(128); // CSS px 64–256 - const [lensAnchor, setLensAnchor] = React.useState<{ x: number; y: number } | null>(null); // custom position (CSS px from top-left of canvas) - const [isDraggingLens, setIsDraggingLens] = React.useState(false); - const [isResizingLens, setIsResizingLens] = React.useState(false); - const [isHoveringLensEdge, setIsHoveringLensEdge] = React.useState(false); - const lensDragStartRef = React.useRef<{ mx: number; my: number; ax: number; ay: number } | null>(null); - const lensResizeStartRef = React.useRef<{ my: number; startSize: number } | null>(null); - const lensCanvasRef = React.useRef(null); - - // FFT d-spacing measurement - const [fftClickInfo, setFftClickInfo] = React.useState<{ - row: number; col: number; distPx: number; - spatialFreq: number | null; dSpacing: number | null; - } | null>(null); - const fftClickStartRef = React.useRef<{ x: number; y: number } | null>(null); - const fftOverlayRef = React.useRef(null); - - // Line profile state - const [profileActive, setProfileActive] = React.useState(false); - const [profileLine, setProfileLine] = useModelState<{ row: number; col: number }[]>("profile_line"); - const [profileDataAll, setProfileDataAll] = React.useState<(Float32Array | null)[]>([]); - const profileCanvasRef = React.useRef(null); - const profileBaseImageRef = React.useRef(null); - const profileLayoutRef = React.useRef<{ padLeft: number; plotW: number; padTop: number; plotH: number; gMin: number; gMax: number; totalDist: number; xUnit: string } | null>(null); - - // Sync profile points from model state - const profilePoints = profileLine || []; - const setProfilePoints = (pts: { row: number; col: number }[]) => setProfileLine(pts); - - // Distance measurement state (JS-only, not persisted) - const [measureActive, setMeasureActive] = React.useState(false); - const [measurePoints, setMeasurePoints] = React.useState<{row: number; col: number}[]>([]); - - // FFT zoom/pan state (gallery mode — per-image or linked) - const [galleryFftStates, setGalleryFftStates] = React.useState>(new Map()); - const [linkedFftZoomState, setLinkedFftZoomState] = React.useState({ zoom: DEFAULT_FFT_ZOOM, panX: 0, panY: 0 }); - const [fftPanningIdx, setFftPanningIdx] = React.useState(null); - const getGalleryFftState = React.useCallback((idx: number) => { - const per = galleryFftStates.get(idx) || { zoom: DEFAULT_FFT_ZOOM, panX: 0, panY: 0 }; - return { - zoom: fftLinkedZoom ? linkedFftZoomState.zoom : per.zoom, - panX: fftLinkPan ? linkedFftZoomState.panX : per.panX, - panY: fftLinkPan ? linkedFftZoomState.panY : per.panY, - }; - }, [fftLinkedZoom, fftLinkPan, linkedFftZoomState, galleryFftStates]); - const setGalleryFftState = React.useCallback((idx: number, state: ZoomState) => { - if (fftLinkedZoom || fftLinkPan) { - setLinkedFftZoomState(prev => ({ - zoom: fftLinkedZoom ? state.zoom : prev.zoom, - panX: fftLinkPan ? state.panX : prev.panX, - panY: fftLinkPan ? state.panY : prev.panY, - })); - } - if (!fftLinkedZoom || !fftLinkPan) { - setGalleryFftStates(prev => { - const cur = prev.get(idx) || { zoom: DEFAULT_FFT_ZOOM, panX: 0, panY: 0 }; - const next = new Map(prev); - next.set(idx, { - zoom: fftLinkedZoom ? cur.zoom : state.zoom, - panX: fftLinkPan ? cur.panX : state.panX, - panY: fftLinkPan ? cur.panY : state.panY, - }); - return next; - }); - } - }, [fftLinkedZoom, fftLinkPan]); - - // Resizable state (gallery starts smaller) - const [canvasSize, setCanvasSize] = React.useState(nImages > 1 ? GALLERY_IMAGE_TARGET : SINGLE_IMAGE_TARGET); - - // Sync initial sizes from traits - React.useEffect(() => { - if (canvasSizeTrait > 0) setCanvasSize(canvasSizeTrait); - }, [canvasSizeTrait]); - - const [isResizingCanvas, setIsResizingCanvas] = React.useState(false); - const [resizeStart, setResizeStart] = React.useState<{ x: number, y: number, size: number } | null>(null); - - // Profile height resize - const [profileHeight, setProfileHeight] = React.useState(76); - const [isResizingProfile, setIsResizingProfile] = React.useState(false); - const [profileResizeStart, setProfileResizeStart] = React.useState<{ y: number; height: number } | null>(null); - - // WebGPU FFT - const gpuFFTRef = React.useRef(null); - const gpuReadyRef = React.useRef(false); - const rawDataRef = React.useRef(null); - const diffCanvasRefs = React.useRef<(HTMLCanvasElement | null)[]>([]); - const diffFftCanvasRef = React.useRef(null); - const diffFftMagRef = React.useRef(null); - - // WebGPU colormap engine — uses refs (not state) to avoid re-triggering - // effects when GPU initializes. Effects check refs opportunistically: - // on first render they use CPU, on subsequent renders (data/slider change) - // they use GPU if available. No double computation. - const gpuCmapRef = React.useRef(null); - const gpuCmapReadyRef = React.useRef(false); - - // Cached offscreen canvases for main image rendering (avoids per-zoom/pan recompute) - const mainOffscreensRef = React.useRef([]); - const mainImgDatasRef = React.useRef([]); - const logBufferRef = React.useRef(null); - const colorbarVminRef = React.useRef(0); - const colorbarVmaxRef = React.useRef(1); - const [offscreenVersion, setOffscreenVersion] = React.useState(0); - - // Truthful first-render signal: flipped ONCE after the first colormap pass has - // actually painted. Python side observes `_js_rendered` and prints the real - // end-to-end wall clock. Two rAFs ensure the browser has composited before we - // fire, so the printed time reflects "user can see the widget," not "data arrived." - const [, setJsRendered] = useModelState("_js_rendered"); - const firstRenderFiredRef = React.useRef(false); - React.useEffect(() => { - if (firstRenderFiredRef.current) return; - if (offscreenVersion === 0) return; - firstRenderFiredRef.current = true; - requestAnimationFrame(() => requestAnimationFrame(() => setJsRendered(true))); - }, [offscreenVersion, setJsRendered]); - - // Inline FFT refs for gallery mode - const fftCanvasRefs = React.useRef<(HTMLCanvasElement | null)[]>([]); - const fftOffscreensRef = React.useRef<(HTMLCanvasElement | null)[]>([]); - const fftMagCacheGalleryRef = React.useRef<(Float32Array | null)[]>([]); - const galleryFftDimsRef = React.useRef<{ w: number; h: number } | null>(null); - const [galleryFftMagVersion, setGalleryFftMagVersion] = React.useState(0); - - // Cached FFT magnitude for single image mode (avoids recomputing on zoom/pan) - const fftMagCacheRef = React.useRef(null); - const [fftMagVersion, setFftMagVersion] = React.useState(0); - // Generation counter for FFT — coalesces rapid ROI drag events to ≤1 FFT/frame - const fftGenRef = React.useRef(0); - - // Cached FFT offscreen canvas for single mode (avoids reprocessing on zoom/pan) - const fftOffscreenRef = React.useRef(null); - // Caches transformed magnitude + range + stats so contrast slider drag - // doesn't re-run log/power/findDataRange/autoEnhance on every tick. - const fftPipelineRef = React.useRef<{ - magnitude: Float32Array; - displayMin: number; - displayMax: number; - magVersion: number; - scaleMode: string; - fftAuto: boolean; - } | null>(null); - const [fftOffscreenVersion, setFftOffscreenVersion] = React.useState(0); - - // ROI FFT state: when ROI + FFT are both active, compute FFT of cropped ROI region - const [fftCropDims, setFftCropDims] = React.useState<{ cropWidth: number; cropHeight: number; fftWidth: number; fftHeight: number } | null>(null); - - // Layout calculations - const isGallery = nImages > 1; - const showDiffPanel = diffMode && nImages >= 2; - const diffPanelCount = showDiffPanel ? Math.max(0, nImages - 1) : 0; - const effectiveNcols = Math.min(ncols, nImages) + diffPanelCount; - const diffOtherIndices = React.useMemo( - () => Array.from({ length: nImages }, (_, i) => i).filter(i => i !== diffReference), - [nImages, diffReference] - ); - const displayScale = canvasSize / Math.max(width, height); - const canvasW = Math.round(width * displayScale); - const canvasH = Math.round(height * displayScale); - - // Initial pan from zoom_row/zoom_col — runs once after first render with valid canvas dims. - // panX/panY computed so target image (zoomRow, zoomCol) lands at canvas center after transform: - // ctx.translate(cx+panX, cy+panY) ⋅ scale(zoom) ⋅ translate(-cx,-cy) - // target screen = cx + panX + zoom * (target_canvas - cx) = cx - // ⟹ panX = zoom * (cx - target_canvas) = zoom * canvasW * (0.5 - col/width) - const initialPanAppliedRef = React.useRef(false); - React.useEffect(() => { - if (initialPanAppliedRef.current) return; - if (zoomRowTrait == null && zoomColTrait == null) return; - if (canvasW <= 0 || canvasH <= 0 || width <= 0 || height <= 0) return; - const z = initialZoomState.zoom; - const panX = zoomColTrait != null ? z * canvasW * (0.5 - zoomColTrait / width) : 0; - const panY = zoomRowTrait != null ? z * canvasH * (0.5 - zoomRowTrait / height) : 0; - setLinkedZoomState({ zoom: z, panX, panY }); - setZoomStates(prev => { - const m = new Map(prev); - for (let i = 0; i < nImages; i++) m.set(i, { zoom: z, panX, panY }); - return m; - }); - initialPanAppliedRef.current = true; - }, [zoomRowTrait, zoomColTrait, canvasW, canvasH, width, height, nImages, initialZoomState.zoom]); - const floatsPerImage = width * height; - const galleryGridWidth = isGallery ? effectiveNcols * canvasW + (effectiveNcols - 1) * 8 : canvasW; - const profileCanvasWidth = galleryGridWidth; - - // ROI FFT active: both ROI and FFT on, with a selected ROI - const roiFftActive = effectiveShowFft && roiActive && roiSelectedIdx >= 0 && roiSelectedIdx < (roiList?.length ?? 0); - - // Stable key for ROI geometry — only changes when the selected ROI's geometry changes, - // not when other ROIs move or roiList gets a new reference from unrelated edits. - // Shared by both ROI FFT and preview panel to avoid redundant recomputes. - const selectedRoiKey = React.useMemo(() => { - if (!roiList || roiSelectedIdx < 0 || roiSelectedIdx >= roiList.length) return ""; - const r = roiList[roiSelectedIdx]; - return `${r.row},${r.col},${r.radius},${r.radius_inner},${r.width},${r.height},${r.shape}`; - }, [roiList, roiSelectedIdx]); - const roiFftKey = roiFftActive ? selectedRoiKey : ""; - - // Extract raw float32 bytes and parse into Float32Arrays - const allFloats = React.useMemo(() => extractFloat32(frameBytes), [frameBytes]); - - // Initialize WebGPU FFT + colormap engine on mount. - // Sets refs (not state) — no effect re-triggers on GPU init. - // Effects pick up GPU on their next natural re-run (data/slider change). - React.useEffect(() => { - getWebGPUFFT().then(fft => { - if (fft) { - gpuFFTRef.current = fft; - gpuReadyRef.current = true; - const info = getGPUInfo(); - console.log(`[Show2D] WebGPU FFT initialized — ${info || "GPU"}`); - } else { - console.log("[Show2D] WebGPU unavailable — using CPU Worker fallback"); - } - }); - getGPUColormapEngine().then(engine => { - if (engine) { - gpuCmapRef.current = engine; - gpuCmapReadyRef.current = true; - console.log("[Show2D] WebGPU colormap engine initialized"); - // Report GPU memory to Python for auto-bin budget - getGPUMaxBufferSize().then(bytes => { - if (bytes > 0) setGpuMaxBufferMB(Math.floor(bytes / (1024 * 1024))); - }); - // Upload data if already parsed (GPU init may be slower than data arrival). - // Do NOT call setState — that would re-trigger effects and cause double - // computation. Instead, upload data and do a warm-up render via rAF. - // This compiles the GPU pipeline in the background so the first user - // interaction is fast (~100ms instead of ~750ms cold start). - if (rawDataRef.current && rawDataRef.current.length > 0) { - const nImg = rawDataRef.current.length; - for (let i = 0; i < nImg; i++) { - const d = rawDataRef.current[i]; - if (d) engine.uploadData(i, d, width, height); - } - const lut = COLORMAPS[cmap] || COLORMAPS.inferno; - engine.uploadLUT(cmap, lut); - gpuDataVersionRef.current++; - // Warm-up: render once to compile GPU pipeline + fill canvases. - // Uses full data range (no slider adjustment) for the initial frame. - requestAnimationFrame(async () => { - const offscreens = mainOffscreensRef.current; - const imgDatas = mainImgDatasRef.current; - if (offscreens.length === 0 || imgDatas.length === 0) return; - const cachedRanges = dataRangesRef.current; - if (cachedRanges.length === 0) return; - const indices = Array.from({ length: nImg }, (_, i) => i); - const ranges = cachedRanges.map(r => ({ vmin: r.min, vmax: r.max })); - const ofs = indices.map(i => offscreens[i] || null); - const ids = indices.map(i => imgDatas[i] || null); - const logSc = logScaleRef.current ?? false; - await engine.renderSlots(indices, ranges, ofs, ids, logSc); - setOffscreenVersion(v => v + 1); - }); - } - } - }); - }, []); - - const [dataVersion, setDataVersion] = React.useState(0); - - // Keep inline FFT ref arrays in sync with nImages - React.useEffect(() => { - fftCanvasRefs.current = fftCanvasRefs.current.slice(0, nImages); - fftOffscreensRef.current = fftOffscreensRef.current.slice(0, nImages); - }, [nImages]); - - // FFT of diff (n=2 only). Computes A − B in JS at full image resolution from rawDataRef, - // feeds to FFT pipeline. Recomputes when raw data changes. - React.useEffect(() => { - if (!effectiveShowFft || !showDiffPanel || nImages !== 2) return; - const raw = rawDataRef.current; - if (!raw || raw.length < 2 || !raw[0] || !raw[1]) return; - const a = raw[0], b = raw[1]; - const bytes = new Float32Array(width * height); - for (let i = 0; i < bytes.length; i++) bytes[i] = a[i] - b[i]; - const canvas = diffFftCanvasRef.current; - if (!canvas) return; - const fftW = nextPow2(width), fftH = nextPow2(height); - const real = new Float32Array(fftW * fftH); - const imag = new Float32Array(fftW * fftH); - const src = new Float32Array(bytes); - if (fftWindow) applyHannWindow2D(src, width, height); - const padR = Math.floor((fftH - height) / 2), padC = Math.floor((fftW - width) / 2); - for (let r = 0; r < height; r++) { - for (let c = 0; c < width; c++) real[(r + padR) * fftW + c + padC] = src[r * width + c]; - } - let cancelled = false; - (async () => { - // WebGPU primary (matches main + gallery FFT paths). CPU worker fallback - // for browsers without WebGPU (Safari <17, FF behind flag). - const result = (gpuFFTRef.current && gpuReadyRef.current) - ? await gpuFFTRef.current.fft2D(real, imag, fftW, fftH, false) - : await fft2dAsync(real, imag, fftW, fftH, false); - if (cancelled) return; - const mag = computeMagnitude(result.real, result.imag); - fftshift(mag, fftW, fftH); - diffFftMagRef.current = mag; - const { min, max } = autoEnhanceFFT(mag, fftW, fftH); - const off = renderToOffscreen(mag, fftW, fftH, COLORMAPS[fftColormap] || COLORMAPS.inferno, min, max); - if (!off) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - ctx.imageSmoothingEnabled = fftSmooth; - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(off, 0, 0, fftW, fftH, 0, 0, canvasW, canvasH); - })(); - return () => { cancelled = true; }; - }, [effectiveShowFft, showDiffPanel, nImages, dataVersion, width, height, fftWindow, fftColormap, canvasW, canvasH, fftSmooth]); - - // Diff panels render — DYNAMIC. One per non-reference image: image[ref] − image[i]. - // Computed at canvas resolution from raw float data, re-running on zoom/pan/align change. - // For n=2: alignDy/dx applied to non-ref image. For n>2: no align (per-pair align not yet supported). - React.useEffect(() => { - if (!showDiffPanel) return; - const raw = rawDataRef.current; - if (!raw || raw.length < 2) return; - const ref = diffReference; - const a = raw[ref]; - if (!a) return; - diffOtherIndices.forEach((otherIdx, slot) => { - renderDiffPanel(slot, a, raw[otherIdx], otherIdx); - }); - // forEach inlines below — extracted as effect helper. - function renderDiffPanel(slot: number, refData: Float32Array, otherData: Float32Array | undefined, otherIdx: number) { - if (!otherData) return; - const canvas = diffCanvasRefs.current[slot]; - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - const zs0 = getZoomState(ref); - const zs1 = getZoomState(otherIdx); - const useAlign = nImages === 2; - const adY = useAlign ? alignDy : 0; - const adX = useAlign ? alignDx : 0; - const a = refData, b = otherData; - const cw = canvasW, ch = canvasH; - const cx = cw / 2, cy = ch / 2; - const sx = width / cw, sy = height / ch; - const diff = new Float32Array(cw * ch); - let mn = Infinity, mx = -Infinity; - // Smooth: bilinear (slower, sub-pixel correct). !Smooth: nearest neighbor (faster, pixelated). - const Hm1 = height - 1, Wm1 = width - 1; - const a_panX = zs0.panX, a_panY = zs0.panY, a_zoom = zs0.zoom; - const b_panX = zs1.panX, b_panY = zs1.panY, b_zoom = zs1.zoom; - if (smooth) { - for (let y = 0; y < ch; y++) { - const ayu = (y - cy - a_panY) / a_zoom + cy; - const byu = (y - cy - b_panY) / b_zoom + cy; - const aRowF = ayu * sy; - const bRowF = byu * sy - adY; - const aR0 = aRowF | 0, bR0 = bRowF | 0; - const aFr = aRowF - aR0, bFr = bRowF - bR0; - const aRowOOB = aR0 < 0 || aR0 >= Hm1; - const bRowOOB = bR0 < 0 || bR0 >= Hm1; - const aRowOff = aR0 * width; - const bRowOff = bR0 * width; - const rowOff = y * cw; - for (let x = 0; x < cw; x++) { - const axu = (x - cx - a_panX) / a_zoom + cx; - const bxu = (x - cx - b_panX) / b_zoom + cx; - const aColF = axu * sx; - const bColF = bxu * sx - adX; - const aC0 = aColF | 0, bC0 = bColF | 0; - let v = 0; - if (!aRowOOB && !bRowOOB && aC0 >= 0 && aC0 < Wm1 && bC0 >= 0 && bC0 < Wm1) { - const aFc = aColF - aC0, bFc = bColF - bC0; - const ai = aRowOff + aC0; - const bi = bRowOff + bC0; - const aV = (a[ai] * (1 - aFc) + a[ai + 1] * aFc) * (1 - aFr) + - (a[ai + width] * (1 - aFc) + a[ai + width + 1] * aFc) * aFr; - const bV = (b[bi] * (1 - bFc) + b[bi + 1] * bFc) * (1 - bFr) + - (b[bi + width] * (1 - bFc) + b[bi + width + 1] * bFc) * bFr; - v = aV - bV; - } - diff[rowOff + x] = v; - if (v < mn) mn = v; - if (v > mx) mx = v; - } - } - } else { - for (let y = 0; y < ch; y++) { - const ayu = (y - cy - a_panY) / a_zoom + cy; - const byu = (y - cy - b_panY) / b_zoom + cy; - const aRow = (ayu * sy + 0.5) | 0; - const bRow = (byu * sy - adY + 0.5) | 0; - const aRowOK = aRow >= 0 && aRow < height; - const bRowOK = bRow >= 0 && bRow < height; - const aRowOff = aRow * width; - const bRowOff = bRow * width; - const rowOff = y * cw; - for (let x = 0; x < cw; x++) { - const axu = (x - cx - a_panX) / a_zoom + cx; - const bxu = (x - cx - b_panX) / b_zoom + cx; - const aCol = (axu * sx + 0.5) | 0; - const bCol = (bxu * sx - adX + 0.5) | 0; - let v = 0; - if (aRowOK && bRowOK && aCol >= 0 && aCol < width && bCol >= 0 && bCol < width) { - v = a[aRowOff + aCol] - b[bRowOff + bCol]; - } - diff[rowOff + x] = v; - if (v < mn) mn = v; - if (v > mx) mx = v; - } - } - } - const sym = Math.max(Math.abs(mn), Math.abs(mx)); - // Diff is signed-around-zero — use diverging cmap (RdBu) if user picked a sequential one. - const sequentialCmaps = new Set(["inferno", "viridis", "plasma", "magma", "hot", "gray", "turbo"]); - const diffCmap = sequentialCmaps.has(cmap) ? "RdBu" : cmap; - const off = renderToOffscreen(diff, cw, ch, COLORMAPS[diffCmap] || COLORMAPS.RdBu, -sym, sym); - if (!off) return; - ctx.imageSmoothingEnabled = smooth; - if (smooth) ctx.imageSmoothingQuality = "high"; - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(off, 0, 0); - } - }, [showDiffPanel, diffOtherIndices, diffReference, nImages, dataVersion, width, height, cmap, smooth, canvasW, canvasH, - alignDy, alignDx, getZoomState, linkedZoom, linkPan, linkedZoomState, zoomStates]); - - React.useEffect(() => { - if (!allFloats || allFloats.length === 0) return; - const dataArrays: Float32Array[] = []; - for (let i = 0; i < nImages; i++) { - const start = i * floatsPerImage; - const imageData = allFloats.subarray(start, start + floatsPerImage); - dataArrays.push(new Float32Array(imageData)); - } - rawDataRef.current = dataArrays; - // Upload to GPU colormap engine if available (ref check, no state trigger) - const engine = gpuCmapRef.current; - if (engine && gpuCmapReadyRef.current) { - for (let i = 0; i < dataArrays.length; i++) engine.uploadData(i, dataArrays[i], width, height); - gpuDataVersionRef.current++; - } - setDataVersion(v => v + 1); - }, [allFloats, nImages, floatsPerImage]); - - // Initialize reusable offscreen canvases (one per image, resized when dimensions change) - React.useEffect(() => { - if (width <= 0 || height <= 0 || nImages <= 0) return; - const canvases: HTMLCanvasElement[] = []; - const imgDatas: ImageData[] = []; - for (let i = 0; i < nImages; i++) { - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - canvases.push(canvas); - imgDatas.push(canvas.getContext("2d")!.createImageData(width, height)); - } - mainOffscreensRef.current = canvases; - mainImgDatasRef.current = imgDatas; - logBufferRef.current = new Float32Array(width * height); - }, [width, height, nImages]); - - // Compute histogram data for the displayed image (reflects log scale) - // GPU path: uses persistent per-slot histogram buffers — no CPU data scan - // CPU fallback: computeHistogramFromBytes (before GPU ready) - React.useEffect(() => { - if (!rawDataRef.current) return; - const idx = nImages > 1 ? selectedIdx : 0; - const raw = rawDataRef.current[idx]; - if (!raw) return; - - // Use cached ranges (no CPU findDataRange scan) - const cachedRaw = rawRangesRef.current[idx]; - const rawRange = cachedRaw || findDataRange(raw); // fallback if cache miss - const range = logScale - ? { min: Math.log1p(Math.max(rawRange.min, 0)), max: Math.log1p(Math.max(rawRange.max, 0)) } - : rawRange; - setImageDataRange(range); - - const engine = gpuCmapRef.current; - if (engine && gpuCmapReadyRef.current && engine.slotCount > idx) { - // GPU histogram — single image, persistent buffers - engine.computeHistogramWithRange(idx, range.min, range.max, logScale).then(bins => { - setImageHistogramBins(bins); - setImageHistogramData(null); - }); - } else { - // CPU fallback (before GPU ready) - const d = logScale ? applyLogScale(raw) : raw; - setImageHistogramBins(null); - setImageHistogramData(d); - } - }, [allFloats, nImages, floatsPerImage, logScale, selectedIdx]); - - // Prevent page scroll when scrolling on canvases (must use native listener with passive: false) - // In gallery mode, only block scroll on the selected image (or all if linkedZoom) - React.useEffect(() => { - const preventDefault = (e: WheelEvent) => e.preventDefault(); - const elements: (HTMLElement | null)[] = isGallery - ? (linkedZoom - ? [ - ...imageContainerRefs.current, - ...(effectiveShowFft ? fftContainerRefs.current : []), - ] - : [ - imageContainerRefs.current[selectedIdx], - ...(effectiveShowFft ? [fftContainerRefs.current[selectedIdx]] : []), - ]) - : [ - imageContainerRefs.current[0], - ...(effectiveShowFft ? [singleFftContainerRef.current] : []), - ]; - elements.forEach(el => el?.addEventListener("wheel", preventDefault, { passive: false })); - return () => elements.forEach(el => el?.removeEventListener("wheel", preventDefault)); - }, [canvasReady, effectiveShowFft, isGallery, selectedIdx, linkedZoom]); - - const gpuDataVersionRef = React.useRef(0); - // Generation counter for colormap — coalesces rapid slider events to ≤1 render per frame - // Cached per-image data ranges — only recomputed when data or logScale changes, NOT on slider drag - const dataRangesRef = React.useRef<{ min: number; max: number }[]>([]); - // Cached log-transformed data — avoids 12×16M log1p calls per slider tick - const logDataCacheRef = React.useRef([]); - // Ref mirrors for async GPU callbacks (avoid stale closures) - const logScaleRef = React.useRef(logScale); - logScaleRef.current = logScale; - const cmapRef = React.useRef(cmap); - cmapRef.current = cmap; - // Auto-contrast cache: GPU-computed percentile ranges per image - const autoContrastCacheRef = React.useRef<{ vmin: number; vmax: number }[]>([]); - - // Cache per-image data ranges (raw AND log) on data change only. - // Log ranges are derived mathematically: log1p(rawMin), log1p(rawMax). - // NO applyLogScale here — GPU shader handles log1p per pixel. - // Log toggle is now free: just pick the right cached ranges. - const rawRangesRef = React.useRef<{ min: number; max: number }[]>([]); - React.useEffect(() => { - if (!rawDataRef.current || rawDataRef.current.length === 0) return; - const engine = gpuCmapRef.current; - const nImg = rawDataRef.current.length; - - if (engine && gpuCmapReadyRef.current && engine.slotCount >= nImg) { - // GPU path: batch compute min/max on GPU (async, updates refs when done) - const indices = Array.from({ length: nImg }, (_, i) => i); - engine.computeRangeBatch(indices).then(rawRanges => { - rawRangesRef.current = rawRanges; - const logRanges = rawRanges.map(r => ({ - min: Math.log1p(Math.max(r.min, 0)), - max: Math.log1p(Math.max(r.max, 0)), - })); - dataRangesRef.current = logScaleRef.current ? logRanges : rawRanges; - }); - } else { - // CPU fallback: scan each image for min/max - const rawRanges: { min: number; max: number }[] = []; - for (let i = 0; i < nImg; i++) { - const rawData = rawDataRef.current[i]; - if (!rawData) { rawRanges.push({ min: 0, max: 1 }); continue; } - rawRanges.push(findDataRange(rawData)); - } - rawRangesRef.current = rawRanges; - const logRanges = rawRanges.map(r => ({ - min: Math.log1p(Math.max(r.min, 0)), - max: Math.log1p(Math.max(r.max, 0)), - })); - dataRangesRef.current = logScale ? logRanges : rawRanges; - } - logDataCacheRef.current = rawDataRef.current.slice(); - }, [dataVersion]); - - // When logScale toggles, just swap cached ranges (no data scan) - React.useEffect(() => { - if (rawRangesRef.current.length === 0) return; - const logRanges = rawRangesRef.current.map(r => ({ - min: Math.log1p(Math.max(r.min, 0)), - max: Math.log1p(Math.max(r.max, 0)), - })); - dataRangesRef.current = logScale ? logRanges : rawRangesRef.current; - }, [logScale]); - - // GPU auto-contrast: batch-compute percentile ranges from GPU histograms. - // One GPU submission for all images. Caches results for synchronous use in render. - React.useEffect(() => { - if (!autoContrast) { autoContrastCacheRef.current = []; return; } - const engine = gpuCmapRef.current; - if (!engine || !gpuCmapReadyRef.current || !rawDataRef.current) return; - const cachedRanges = dataRangesRef.current; - if (cachedRanges.length === 0) return; - const ls = logScale; - const nImg = Math.min(rawDataRef.current.length, engine.slotCount); - if (nImg === 0) return; - - (async () => { - const indices = Array.from({ length: nImg }, (_, i) => i); - const histRanges = indices.map(i => cachedRanges[i] || { min: 0, max: 1 }); - const allBins = await engine.computeHistogramBatch(indices, histRanges, ls); - - const pLow = 2, pHigh = 98; - const acRanges: { vmin: number; vmax: number }[] = []; - for (let k = 0; k < allBins.length; k++) { - const bins = allBins[k]; - const cr = histRanges[k]; - // Percentile from normalized histogram CDF - let sum = 0; - for (let b = 0; b < 256; b++) sum += bins[b]; - let binLow = 0, binHigh = 255; - const targetLow = sum * pLow / 100; - const targetHigh = sum * pHigh / 100; - let running = 0; - for (let b = 0; b < 256; b++) { - running += bins[b]; - if (running >= targetLow && binLow === 0) binLow = b; - if (running >= targetHigh) { binHigh = b; break; } - } - const range = cr.max - cr.min; - acRanges.push({ vmin: cr.min + (binLow / 255) * range, vmax: cr.min + (binHigh / 255) * range }); - } - autoContrastCacheRef.current = acRanges; - console.log(`[Show2D] GPU auto-contrast: ${nImg} images, ${allBins.length} histograms`); - setOffscreenVersion(v => v + 1); - })(); - }, [autoContrast, dataVersion, logScale]); - - // ------------------------------------------------------------------------- - // Data effect: normalize + colormap → reusable offscreen canvases - // GPU path: runs compute shader for all images in one submission - // CPU fallback: per-image applyColormap loop - // (does NOT depend on zoom/pan — avoids recomputing 16M pixels on every pan/zoom) - // ------------------------------------------------------------------------- - React.useEffect(() => { - if (!dataVersion || !rawDataRef.current || rawDataRef.current.length === 0) return; - if (mainOffscreensRef.current.length === 0 || mainImgDatasRef.current.length === 0) return; - - const lut = COLORMAPS[cmap] || COLORMAPS.inferno; - - // Compute per-image vmin/vmax from CACHED data ranges (no findDataRange per tick). - // dataRangesRef is precomputed when data or logScale changes. - const cachedRanges = dataRangesRef.current; - const hasAbsoluteRange = traitVmin != null && traitVmax != null; - const ranges: { vmin: number; vmax: number }[] = []; - for (let i = 0; i < nImages; i++) { - let vmin: number, vmax: number; - const cs = linkedContrast ? linkedContrastState : (contrastStates.get(i) || { vminPct: 0, vmaxPct: 100 }); - - // Per-image absolute range (vmins/vmaxs) takes precedence over scalar (vmin/vmax) - const perI_min = traitVmins && traitVmins[i] != null ? traitVmins[i] : null; - const perI_max = traitVmaxs && traitVmaxs[i] != null ? traitVmaxs[i] : null; - const hasPerImage = perI_min != null && perI_max != null; - const isDiffSlot = false; - const diffSym = 0; - - let rangeMin: number, rangeMax: number; - if (isDiffSlot) { - rangeMin = -diffSym; - rangeMax = diffSym; - } else if (hasPerImage) { - rangeMin = logScale ? Math.log1p(Math.max(perI_min!, 0)) : perI_min!; - rangeMax = logScale ? Math.log1p(Math.max(perI_max!, 0)) : perI_max!; - } else if (hasAbsoluteRange) { - rangeMin = logScale ? Math.log1p(Math.max(traitVmin!, 0)) : traitVmin!; - rangeMax = logScale ? Math.log1p(Math.max(traitVmax!, 0)) : traitVmax!; - } else { - // GPU range compute is async — when cache missing OR collapsed (min==max from race), - // sync findDataRange on raw data to ensure non-degenerate range. - let cached = cachedRanges[i]; - if (!cached || cached.min === cached.max) { - if (rawDataRef.current && rawDataRef.current[i]) { - cached = findDataRange(rawDataRef.current[i]); - } - } - cached = cached || { min: 0, max: 1 }; - rangeMin = cached.min; - rangeMax = cached.max; - } - - if (!hasAbsoluteRange && !hasPerImage && autoContrast) { - // Auto-contrast: use GPU-precomputed percentile ranges. - // If GPU cache not ready yet, use full data range as placeholder - // (GPU auto-contrast effect will fire async and trigger re-render). - const acCache = autoContrastCacheRef.current[i]; - if (acCache) { - vmin = acCache.vmin; vmax = acCache.vmax; - } else { - vmin = rangeMin; vmax = rangeMax; - } - } else if (rangeMin !== rangeMax && (cs.vminPct > 0 || cs.vmaxPct < 100)) { - ({ vmin, vmax } = sliderRange(rangeMin, rangeMax, cs.vminPct, cs.vmaxPct)); - } else { - vmin = rangeMin; vmax = rangeMax; - } - ranges.push({ vmin, vmax }); - } - - // Cache first image's vmin/vmax for colorbar/lens - if (ranges.length > 0) { - colorbarVminRef.current = ranges[0].vmin; - colorbarVmaxRef.current = ranges[0].vmax; - } - - // GPU colormap — first-class citizen. - // Try zero-copy path (OffscreenCanvas → ImageBitmap, no mapAsync). - // Falls back to renderSlots (mapAsync + putImageData) if zero-copy fails. - const engine = gpuCmapRef.current; - const gpuReady = engine && gpuCmapReadyRef.current && engine.slotCount >= nImages; - if (gpuReady) { - engine!.uploadLUT(cmap, lut); - const capturedRanges = ranges.slice(); - const capturedLogScale = logScale; - const capturedNImages = nImages; - requestAnimationFrame(async () => { - const indices = Array.from({ length: capturedNImages }, (_, i) => i); - - // Zero-copy path: GPU → OffscreenCanvas → ImageBitmap → drawImage - const bitmaps = engine!.renderSlotsToImageBitmap(indices, capturedRanges, capturedLogScale); - if (bitmaps && bitmaps.length > 0 && bitmaps[0]) { - for (let i = 0; i < bitmaps.length; i++) { - const offscreen = mainOffscreensRef.current[i]; - if (!offscreen || !bitmaps[i]) continue; - const ctx = offscreen.getContext("2d"); - if (ctx) ctx.drawImage(bitmaps[i], 0, 0); - } - setOffscreenVersion(v => v + 1); - return; - } - - // Fallback: renderSlots (mapAsync + copy to ImageData) - const offscreens = indices.map(i => mainOffscreensRef.current[i] || null); - const imgDatas = indices.map(i => mainImgDatasRef.current[i] || null); - const rendered = await engine!.renderSlots(indices, capturedRanges, offscreens, imgDatas, capturedLogScale); - if (rendered === 0) { - for (let i = 0; i < capturedNImages; i++) { - const offscreen = mainOffscreensRef.current[i]; - const imgData = mainImgDatasRef.current[i]; - if (!offscreen || !imgData) continue; - const raw = rawDataRef.current?.[i]; - if (!raw) continue; - const processed = capturedLogScale ? applyLogScale(raw) : raw; - renderToOffscreenReuse(processed, lut, capturedRanges[i].vmin, capturedRanges[i].vmax, offscreen, imgData); - } - } - setOffscreenVersion(v => v + 1); - }); - } else { - // CPU fallback: initial render or no WebGPU - // CPU must do log transform itself (GPU shader would handle it) - for (let i = 0; i < nImages; i++) { - const offscreen = mainOffscreensRef.current[i]; - const imgData = mainImgDatasRef.current[i]; - if (!offscreen || !imgData) continue; - const raw = rawDataRef.current?.[i]; - if (!raw) continue; - const processed = logScale ? applyLogScale(raw) : raw; - renderToOffscreenReuse(processed, lut, ranges[i].vmin, ranges[i].vmax, offscreen, imgData); - } - setOffscreenVersion(v => v + 1); - } - }, [dataVersion, nImages, width, height, cmap, logScale, autoContrast, linkedContrast, linkedContrastState, contrastStates, traitVmin, traitVmax, traitVmins, traitVmaxs, diffMode]); - - // ------------------------------------------------------------------------- - // Draw effect: zoom/pan changes — cheap, just drawImage from cached offscreens - // useLayoutEffect prevents black flash when canvas dimensions change (resize) - // ------------------------------------------------------------------------- - React.useLayoutEffect(() => { - if (mainOffscreensRef.current.length === 0) return; - - for (let i = 0; i < nImages; i++) { - const canvas = canvasRefs.current[i]; - const offscreen = mainOffscreensRef.current[i]; - if (!canvas || !offscreen) continue; - const ctx = canvas.getContext("2d"); - if (!ctx) continue; - - ctx.imageSmoothingEnabled = smooth; - if (smooth) ctx.imageSmoothingQuality = "high"; - ctx.clearRect(0, 0, canvas.width, canvas.height); - - const zs = getZoomState(i); - const { zoom, panX, panY } = zs; - - if (zoom !== 1 || panX !== 0 || panY !== 0) { - ctx.save(); - const cx = canvasW / 2; - const cy = canvasH / 2; - ctx.translate(cx + panX, cy + panY); - ctx.scale(zoom, zoom); - ctx.translate(-cx, -cy); - ctx.drawImage(offscreen, 0, 0, width, height, 0, 0, canvasW, canvasH); - ctx.restore(); - } else { - ctx.drawImage(offscreen, 0, 0, width, height, 0, 0, canvasW, canvasH); - } - } - }, [offscreenVersion, nImages, width, height, displayScale, canvasW, canvasH, canvasReady, linkedZoom, linkedZoomState, zoomStates, smooth]); - - // ------------------------------------------------------------------------- - // Render Overlays (scale bar, colorbar, zoom indicator) - // ------------------------------------------------------------------------- - React.useEffect(() => { - for (let i = 0; i < nImages; i++) { - const overlay = overlayRefs.current[i]; - if (!overlay) continue; - const ctx = overlay.getContext("2d"); - if (!ctx) continue; - - if (scaleBarVisible) { - const zs = getZoomState(i); - const unit = pixelSize > 0 ? pixelUnit : "px"; - const pxSize = pixelSize > 0 ? pixelSize : 1; - drawScaleBarHiDPI(overlay, DPR, zs.zoom, pxSize, unit, width); - } else { - ctx.clearRect(0, 0, overlay.width, overlay.height); - } - - // Colorbar (single image mode only) — uses cached vmin/vmax from data effect - if (showColorbar && !isGallery) { - const lut = COLORMAPS[cmap] || COLORMAPS.inferno; - const cssW = overlay.width / DPR; - const cssH = overlay.height / DPR; - const vmin = colorbarVminRef.current; - const vmax = colorbarVmaxRef.current; - - ctx.save(); - ctx.scale(DPR, DPR); - drawColorbar(ctx, cssW, cssH, lut, vmin, vmax, logScale); - ctx.restore(); - } - - // ROI overlay — draw all ROIs - if (roiActive && roiList && roiList.length > 0) { - const zs = getZoomState(i); - const { zoom, panX, panY } = zs; - const cx = canvasW / 2; - const cy = canvasH / 2; - - // Highlight mask: dim everything outside highlighted ROIs - const highlightedRois = roiList.filter(r => r.highlight); - if (highlightedRois.length > 0) { - ctx.save(); - ctx.scale(DPR, DPR); - ctx.fillStyle = "rgba(0,0,0,0.6)"; - ctx.fillRect(0, 0, canvasW, canvasH); - ctx.globalCompositeOperation = "destination-out"; - for (const roi of highlightedRois) { - const sx = (roi.col * displayScale - cx) * zoom + cx + panX; - const sy = (roi.row * displayScale - cy) * zoom + cy + panY; - const sr = roi.radius * displayScale * zoom; - const shape = roi.shape || "circle"; - ctx.fillStyle = "rgba(0,0,0,1)"; - if (shape === "circle") { - ctx.beginPath(); ctx.arc(sx, sy, sr, 0, Math.PI * 2); ctx.fill(); - } else if (shape === "square") { - ctx.fillRect(sx - sr, sy - sr, sr * 2, sr * 2); - } else if (shape === "rectangle") { - const sw = roi.width * displayScale * zoom; - const sh = roi.height * displayScale * zoom; - ctx.fillRect(sx - sw / 2, sy - sh / 2, sw, sh); - } else if (shape === "annular") { - ctx.beginPath(); ctx.arc(sx, sy, sr, 0, Math.PI * 2); ctx.fill(); - // Re-darken inner ring - ctx.globalCompositeOperation = "source-over"; - ctx.fillStyle = "rgba(0,0,0,0.6)"; - const sir = roi.radius_inner * displayScale * zoom; - ctx.beginPath(); ctx.arc(sx, sy, sir, 0, Math.PI * 2); ctx.fill(); - ctx.globalCompositeOperation = "destination-out"; - } - } - ctx.restore(); - } - - ctx.save(); - ctx.scale(DPR, DPR); - for (let ri = 0; ri < roiList.length; ri++) { - const roi = roiList[ri]; - const isSelected = ri === roiSelectedIdx; - const screenX = (roi.col * displayScale - cx) * zoom + cx + panX; - const screenY = (roi.row * displayScale - cy) * zoom + cy + panY; - const screenRadius = roi.radius * displayScale * zoom; - const screenW = roi.width * displayScale * zoom; - const screenH = roi.height * displayScale * zoom; - const screenRadiusInner = roi.radius_inner * displayScale * zoom; - const shape = (roi.shape || "circle") as "circle" | "square" | "rectangle" | "annular"; - ctx.lineWidth = roi.line_width || 2; - drawROI(ctx, screenX, screenY, shape, screenRadius, screenW, screenH, roi.color || ROI_COLORS[ri % ROI_COLORS.length], roi.color || ROI_COLORS[ri % ROI_COLORS.length], isSelected && isDraggingROI, screenRadiusInner); - if (isSelected) { - ctx.setLineDash([4, 3]); - ctx.strokeStyle = "#fff"; - ctx.lineWidth = 1; - if (shape === "circle" || shape === "annular") { - ctx.beginPath(); ctx.arc(screenX, screenY, screenRadius + 3, 0, Math.PI * 2); ctx.stroke(); - } else if (shape === "square") { - ctx.strokeRect(screenX - screenRadius - 3, screenY - screenRadius - 3, (screenRadius + 3) * 2, (screenRadius + 3) * 2); - } else if (shape === "rectangle") { - ctx.strokeRect(screenX - screenW / 2 - 3, screenY - screenH / 2 - 3, screenW + 6, screenH + 6); - } - ctx.setLineDash([]); - } - } - ctx.restore(); - } - - // Line profile overlay - if (profileActive && profilePoints.length > 0) { - const zs = getZoomState(i); - const { zoom, panX, panY } = zs; - ctx.save(); - ctx.scale(DPR, DPR); - - // Transform image coords to screen coords - const cx = canvasW / 2; - const cy = canvasH / 2; - const toScreenX = (ix: number) => (ix * displayScale - cx) * zoom + cx + panX; - const toScreenY = (iy: number) => (iy * displayScale - cy) * zoom + cy + panY; - - // Draw point A - const ax = toScreenX(profilePoints[0].col); - const ay = toScreenY(profilePoints[0].row); - ctx.fillStyle = themeColors.accent; - ctx.beginPath(); - ctx.arc(ax, ay, 4, 0, Math.PI * 2); - ctx.fill(); - - // Draw line and point B if complete - if (profilePoints.length === 2) { - const bx = toScreenX(profilePoints[1].col); - const by = toScreenY(profilePoints[1].row); - - ctx.strokeStyle = themeColors.accent; - ctx.lineWidth = 1.5; - ctx.setLineDash([4, 3]); - ctx.beginPath(); - ctx.moveTo(ax, ay); - ctx.lineTo(bx, by); - ctx.stroke(); - ctx.setLineDash([]); - ctx.fillStyle = themeColors.accent; - ctx.beginPath(); - ctx.arc(bx, by, 4, 0, Math.PI * 2); - ctx.fill(); - } - - ctx.restore(); - } - - // Distance measurement overlay - if (measureActive && measurePoints.length >= 1) { - const zs = getZoomState(i); - const { zoom, panX, panY } = zs; - ctx.save(); - ctx.scale(DPR, DPR); - const cx = canvasW / 2; - const cy = canvasH / 2; - const toSX = (ix: number) => (ix * displayScale - cx) * zoom + cx + panX; - const toSY = (iy: number) => (iy * displayScale - cy) * zoom + cy + panY; - - ctx.shadowColor = "rgba(0,0,0,0.6)"; - ctx.shadowBlur = 3; - - // Endpoint A - const ax = toSX(measurePoints[0].col); - const ay = toSY(measurePoints[0].row); - ctx.fillStyle = "#fff"; - ctx.beginPath(); - ctx.arc(ax, ay, 4, 0, Math.PI * 2); - ctx.fill(); - - if (measurePoints.length === 2) { - const bx = toSX(measurePoints[1].col); - const by = toSY(measurePoints[1].row); - - // Solid white line (distinct from profile's dashed accent line) - ctx.strokeStyle = "#fff"; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.moveTo(ax, ay); - ctx.lineTo(bx, by); - ctx.stroke(); - - // Endpoint B - ctx.beginPath(); - ctx.arc(bx, by, 4, 0, Math.PI * 2); - ctx.fill(); - - // Distance label - const dc = measurePoints[1].col - measurePoints[0].col; - const dr = measurePoints[1].row - measurePoints[0].row; - const distPx = Math.sqrt(dc * dc + dr * dr); - let label: string; - if (pixelSize > 0) { - const distA = distPx * pixelSize; - label = distA >= 10 ? `${(distA / 10).toFixed(2)} nm` : `${distA.toFixed(2)} Å`; - } else { - label = `${distPx.toFixed(1)} px`; - } - - const mx = (ax + bx) / 2; - const my = (ay + by) / 2; - ctx.font = "bold 13px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; - ctx.textAlign = "center"; - ctx.textBaseline = "bottom"; - ctx.fillStyle = "#fff"; - ctx.fillText(label, mx, my - 8); - } - - ctx.shadowBlur = 0; - ctx.restore(); - } - } - }, [nImages, pixelSize, scaleBarVisible, selectedIdx, isGallery, canvasW, canvasH, width, displayScale, linkedZoom, linkedZoomState, zoomStates, dataVersion, showColorbar, cmap, offscreenVersion, logScale, profileActive, profilePoints, roiActive, roiList, roiSelectedIdx, isDraggingROI, themeColors, measureActive, measurePoints]); - - // ------------------------------------------------------------------------- - // Inset magnifier (lens) — renders magnified region at cursor in bottom-left - // ------------------------------------------------------------------------- - React.useEffect(() => { - const lensCanvas = lensCanvasRef.current; - if (lensCanvas) { - const lctx = lensCanvas.getContext("2d"); - if (lctx) lctx.clearRect(0, 0, lensCanvas.width, lensCanvas.height); - } - if (!showLens || isGallery || !lensPos || !rawDataRef.current?.[0]) return; - if (!lensCanvas) return; - const ctx = lensCanvas.getContext("2d"); - if (!ctx) return; - - const raw = rawDataRef.current[0]; - const lut = COLORMAPS[cmap] || COLORMAPS.inferno; - // Use cached vmin/vmax from data effect (avoids full-image applyLogScale + findDataRange) - const vmin = colorbarVminRef.current; - const vmax = colorbarVmaxRef.current; - - // Extract region around cursor — regionSize = displaySize / magnification - const regionSize = Math.max(4, Math.round(lensDisplaySize / lensMag)); - const lensSize = lensDisplaySize; - const margin = 12; - const half = Math.floor(regionSize / 2); - const r0 = lensPos.row - half; - const c0 = lensPos.col - half; - - // Create small offscreen canvas for the region - const regionCanvas = document.createElement("canvas"); - regionCanvas.width = regionSize; - regionCanvas.height = regionSize; - const rctx = regionCanvas.getContext("2d"); - if (!rctx) return; - const imgData = rctx.createImageData(regionSize, regionSize); - const range = vmax - vmin || 1; - for (let dr = 0; dr < regionSize; dr++) { - for (let dc = 0; dc < regionSize; dc++) { - const sr = r0 + dr; - const sc = c0 + dc; - const idx = (dr * regionSize + dc) * 4; - if (sr < 0 || sr >= height || sc < 0 || sc >= width) { - imgData.data[idx] = 0; imgData.data[idx + 1] = 0; imgData.data[idx + 2] = 0; imgData.data[idx + 3] = 255; - } else { - // Apply log scale inline per-pixel (only for the small region, not full image) - const rawVal = raw[sr * width + sc]; - const val = logScale ? Math.log1p(rawVal) : rawVal; - const t = Math.max(0, Math.min(1, (val - vmin) / range)); - const li = Math.round(t * 255); - imgData.data[idx] = lut[li * 3]; imgData.data[idx + 1] = lut[li * 3 + 1]; imgData.data[idx + 2] = lut[li * 3 + 2]; imgData.data[idx + 3] = 255; - } - } - } - rctx.putImageData(imgData, 0, 0); - - // Draw lens inset on overlay — use custom anchor or default bottom-left - ctx.save(); - ctx.scale(DPR, DPR); - const lx = lensAnchor ? lensAnchor.x : margin; - const ly = lensAnchor ? lensAnchor.y : canvasH - lensSize - margin - 20; - ctx.imageSmoothingEnabled = false; - ctx.drawImage(regionCanvas, lx, ly, lensSize, lensSize); - ctx.strokeStyle = themeColors.accent; - ctx.lineWidth = 2; - ctx.strokeRect(lx, ly, lensSize, lensSize); - // Crosshair at center - const cx = lx + lensSize / 2; - const cy = ly + lensSize / 2; - ctx.strokeStyle = "rgba(255,255,255,0.5)"; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(cx - 8, cy); ctx.lineTo(cx + 8, cy); - ctx.moveTo(cx, cy - 8); ctx.lineTo(cx, cy + 8); - ctx.stroke(); - // Magnification label - ctx.fillStyle = "rgba(255,255,255,0.7)"; - ctx.font = "10px monospace"; - ctx.fillText(`${lensMag}×`, lx + 4, ly + lensSize - 4); - ctx.restore(); - }, [showLens, lensPos, isGallery, cmap, logScale, offscreenVersion, width, height, canvasH, themeColors, lensMag, lensDisplaySize, lensAnchor]); - - // ------------------------------------------------------------------------- - // Auto-compute profile when profile_line is set (e.g. from Python) - // ------------------------------------------------------------------------- - React.useEffect(() => { - if (profilePoints.length === 2 && rawDataRef.current) { - const p0 = profilePoints[0], p1 = profilePoints[1]; - const allProfiles: (Float32Array | null)[] = []; - for (let i = 0; i < rawDataRef.current.length; i++) { - const raw = rawDataRef.current[i]; - allProfiles.push(raw ? sampleLineProfile(raw, width, height, p0.row, p0.col, p1.row, p1.col) : null); - } - setProfileDataAll(allProfiles); - if (!profileActive) setProfileActive(true); - } - }, [profilePoints, dataVersion, profileActive]); - - // ------------------------------------------------------------------------- - // Render sparkline for line profile - // ------------------------------------------------------------------------- - React.useEffect(() => { - const canvas = profileCanvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const dpr = window.devicePixelRatio || 1; - const cssW = profileCanvasWidth; - const cssH = profileHeight; - canvas.width = cssW * dpr; - canvas.height = cssH * dpr; - ctx.scale(dpr, dpr); - - const isDark = themeInfo.theme === "dark"; - ctx.fillStyle = isDark ? "#1a1a1a" : "#f0f0f0"; - ctx.fillRect(0, 0, cssW, cssH); - - const hasData = profileDataAll.some(d => d && d.length >= 2); - if (!hasData) { - ctx.font = "10px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; - ctx.fillStyle = isDark ? "#555" : "#999"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText("Click two points on the image to draw a profile", cssW / 2, cssH / 2); - return; - } - - const padLeft = 40; - const padRight = 8; - const padTop = 6; - const padBottom = 18; - const plotW = cssW - padLeft - padRight; - const plotH = cssH - padTop - padBottom; - - // Find global min/max across all profiles - let gMin = Infinity, gMax = -Infinity; - for (const d of profileDataAll) { - if (!d) continue; - for (let i = 0; i < d.length; i++) { - if (d[i] < gMin) gMin = d[i]; - if (d[i] > gMax) gMax = d[i]; - } - } - const range = gMax - gMin || 1; - - // Draw each profile - const colors = profileDataAll.length === 1 ? [themeColors.accent] : PROFILE_COLORS; - for (let pIdx = 0; pIdx < profileDataAll.length; pIdx++) { - const d = profileDataAll[pIdx]; - if (!d || d.length < 2) continue; - ctx.strokeStyle = colors[pIdx % colors.length]; - ctx.lineWidth = pIdx === selectedIdx || profileDataAll.length === 1 ? 1.5 : 1; - ctx.globalAlpha = pIdx === selectedIdx || profileDataAll.length === 1 ? 1 : 0.5; - ctx.beginPath(); - for (let i = 0; i < d.length; i++) { - const x = padLeft + (i / (d.length - 1)) * plotW; - const y = padTop + plotH - ((d[i] - gMin) / range) * plotH; - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - } - ctx.stroke(); - } - ctx.globalAlpha = 1; - - // Compute total distance for x-axis - const firstProfile = profileDataAll.find(d => d); - let totalDist = (firstProfile?.length ?? 2) - 1; - let xUnit = "px"; - if (profilePoints.length === 2) { - const dx = profilePoints[1].col - profilePoints[0].col; - const dy = profilePoints[1].row - profilePoints[0].row; - const distPx = Math.sqrt(dx * dx + dy * dy); - if (pixelSize > 0) { - totalDist = distPx * pixelSize; - xUnit = pixelUnit; - } else { - totalDist = distPx; - } - } - - // Draw x-axis ticks - const tickY = padTop + plotH; - ctx.strokeStyle = isDark ? "#555" : "#bbb"; - ctx.lineWidth = 0.5; - const idealTicks = Math.max(2, Math.floor(plotW / 70)); - const tickStep = roundToNiceValue(totalDist / idealTicks); - ctx.font = "9px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; - ctx.fillStyle = isDark ? "#888" : "#666"; - ctx.textBaseline = "top"; - const ticks: number[] = []; - for (let v = 0; v <= totalDist + tickStep * 0.01; v += tickStep) { - if (v > totalDist * 1.001) break; - ticks.push(v); - } - for (let i = 0; i < ticks.length; i++) { - const v = ticks[i]; - const frac = totalDist > 0 ? v / totalDist : 0; - const x = padLeft + frac * plotW; - ctx.beginPath(); ctx.moveTo(x, tickY); ctx.lineTo(x, tickY + 3); ctx.stroke(); - ctx.textAlign = frac < 0.05 ? "left" : frac > 0.95 ? "right" : "center"; - const valStr = v % 1 === 0 ? v.toFixed(0) : v.toFixed(1); - ctx.fillText(i === ticks.length - 1 ? `${valStr} ${xUnit}` : valStr, x, tickY + 4); - } - - // Draw y-axis min/max labels - ctx.font = "9px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; - ctx.fillStyle = isDark ? "#888" : "#666"; - ctx.textAlign = "right"; - ctx.textBaseline = "top"; - ctx.fillText(formatNumber(gMax), padLeft - 3, padTop); - ctx.textBaseline = "bottom"; - ctx.fillText(formatNumber(gMin), padLeft - 3, padTop + plotH); - - // Draw axis lines - ctx.strokeStyle = isDark ? "#555" : "#bbb"; - ctx.lineWidth = 0.5; - ctx.beginPath(); - ctx.moveTo(padLeft, padTop); - ctx.lineTo(padLeft, padTop + plotH); - ctx.lineTo(padLeft + plotW, padTop + plotH); - ctx.stroke(); - - // Legend (gallery mode with multiple images) - if (profileDataAll.length > 1) { - ctx.textAlign = "right"; - ctx.textBaseline = "top"; - ctx.font = "9px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; - let legendX = cssW - 4; - for (let pIdx = profileDataAll.length - 1; pIdx >= 0; pIdx--) { - if (!profileDataAll[pIdx]) continue; - const label = labels?.[pIdx] || `#${pIdx + 1}`; - const color = colors[pIdx % colors.length]; - const textW = ctx.measureText(label).width; - ctx.globalAlpha = pIdx === selectedIdx ? 1 : 0.5; - ctx.fillStyle = color; - ctx.fillRect(legendX - textW - 10, 2, 6, 6); - ctx.fillStyle = isDark ? "#aaa" : "#555"; - ctx.fillText(label, legendX, 1); - legendX -= textW + 16; - } - ctx.globalAlpha = 1; - } - - // Save base rendering + layout for hover overlay - profileBaseImageRef.current = ctx.getImageData(0, 0, canvas.width, canvas.height); - profileLayoutRef.current = { padLeft, plotW, padTop, plotH, gMin, gMax, totalDist, xUnit }; - }, [profileDataAll, themeInfo.theme, themeColors.accent, profilePoints, pixelSize, selectedIdx, labels, profileCanvasWidth, profileHeight]); - - // Profile hover handler — draws crosshair + value readout - const handleProfileMouseMove = React.useCallback((e: React.MouseEvent) => { - const canvas = profileCanvasRef.current; - const base = profileBaseImageRef.current; - const layout = profileLayoutRef.current; - if (!canvas || !base || !layout) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const rect = canvas.getBoundingClientRect(); - const cssX = e.clientX - rect.left; - const { padLeft, plotW, padTop, plotH, gMin, gMax, totalDist, xUnit } = layout; - const range = gMax - gMin || 1; - - // Restore base image - ctx.putImageData(base, 0, 0); - - if (cssX < padLeft || cssX > padLeft + plotW) return; - const frac = (cssX - padLeft) / plotW; - - const dpr = window.devicePixelRatio || 1; - ctx.save(); - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - - // Vertical crosshair - ctx.strokeStyle = themeInfo.theme === "dark" ? "rgba(255,255,255,0.3)" : "rgba(0,0,0,0.3)"; - ctx.lineWidth = 1; - ctx.setLineDash([2, 2]); - ctx.beginPath(); - ctx.moveTo(cssX, padTop); - ctx.lineTo(cssX, padTop + plotH); - ctx.stroke(); - ctx.setLineDash([]); - - // Dot on each profile line + collect values - const colors = profileDataAll.length === 1 ? [themeColors.accent] : PROFILE_COLORS; - const activeIdx = isGallery ? selectedIdx : 0; - let displayVal: number | null = null; - for (let pIdx = 0; pIdx < profileDataAll.length; pIdx++) { - const d = profileDataAll[pIdx]; - if (!d || d.length < 2) continue; - const dataIdx = Math.min(d.length - 1, Math.max(0, Math.round(frac * (d.length - 1)))); - const val = d[dataIdx]; - const y = padTop + plotH - ((val - gMin) / range) * plotH; - ctx.fillStyle = colors[pIdx % colors.length]; - ctx.globalAlpha = pIdx === activeIdx || profileDataAll.length === 1 ? 1 : 0.5; - ctx.beginPath(); - ctx.arc(cssX, y, 3, 0, Math.PI * 2); - ctx.fill(); - if (pIdx === activeIdx || profileDataAll.length === 1) displayVal = val; - } - ctx.globalAlpha = 1; - - // Value readout label - if (displayVal !== null) { - const dist = frac * totalDist; - const label = `${formatNumber(displayVal)} @ ${dist.toFixed(1)} ${xUnit}`; - const isDark = themeInfo.theme === "dark"; - ctx.font = "bold 9px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; - const textW = ctx.measureText(label).width; - const labelX = Math.min(cssX + 6, padLeft + plotW - textW - 2); - const labelY = padTop + 2; - ctx.fillStyle = isDark ? "rgba(0,0,0,0.7)" : "rgba(255,255,255,0.8)"; - ctx.fillRect(labelX - 2, labelY - 1, textW + 4, 11); - ctx.fillStyle = isDark ? "#fff" : "#000"; - ctx.textAlign = "left"; - ctx.textBaseline = "top"; - ctx.fillText(label, labelX, labelY); - } - - ctx.restore(); - }, [profileDataAll, themeInfo.theme, themeColors.accent, isGallery, selectedIdx]); - - const handleProfileMouseLeave = React.useCallback(() => { - const canvas = profileCanvasRef.current; - const base = profileBaseImageRef.current; - if (!canvas || !base) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - ctx.putImageData(base, 0, 0); - }, []); - - // ------------------------------------------------------------------------- - // Compute FFT magnitude (cached — only recomputes when data changes) - // Supports ROI-scoped FFT: when ROI is active with a selected ROI, compute - // FFT of the cropped region instead of the full image. - // ------------------------------------------------------------------------- - React.useEffect(() => { - if (!effectiveShowFft || isGallery || !rawDataRef.current) return; - if (!rawDataRef.current[selectedIdx]) return; - // Generation counter: coalesces rapid ROI drag events so at most one - // FFT runs per animation frame. The rAF yield lets the browser paint - // the ROI position update before the (potentially blocking) FFT runs. - const gen = ++fftGenRef.current; - - const doCompute = async () => { - // Yield to next animation frame — browser paints updated ROI first, - // and stale requests (from earlier drag events) are discarded below. - await new Promise(r => requestAnimationFrame(() => r())); - if (gen !== fftGenRef.current) return; - - // Wait for WebGPU init if it's still in flight — avoids first-call CPU race. - if (!gpuReadyRef.current) { - try { - const fft = await getWebGPUFFT(); - if (fft) { gpuFFTRef.current = fft; gpuReadyRef.current = true; } - } catch (_e) { /* fall to CPU */ } - if (gen !== fftGenRef.current) return; - } - const backend = gpuFFTRef.current && gpuReadyRef.current ? "WebGPU" : "CPU Worker"; - setFftComputing(true); - setFftProgress(`Computing FFT… (${backend})`); - const t0 = performance.now(); - const data = rawDataRef.current![selectedIdx]; - let fftW = width; - let fftH = height; - let inputData = data; - - // ROI crop: extract bounding box and optionally zero-mask outside radius - let origCropW = 0, origCropH = 0; - if (roiFftActive && roiList && roiSelectedIdx >= 0 && roiSelectedIdx < roiList.length) { - const roi = roiList[roiSelectedIdx]; - const crop = cropROIRegion(data, width, height, roi); - if (crop) { - origCropW = crop.cropW; - origCropH = crop.cropH; - // Apply Hann window to crop at native dimensions BEFORE zero-padding - if (fftWindow) applyHannWindow2D(crop.cropped, crop.cropW, crop.cropH); - // Pad to next power-of-2 so fft2d doesn't truncate frequency data - const padW = nextPow2(crop.cropW); - const padH = nextPow2(crop.cropH); - const padded = new Float32Array(padW * padH); - for (let y = 0; y < crop.cropH; y++) { - for (let x = 0; x < crop.cropW; x++) { - padded[y * padW + x] = crop.cropped[y * crop.cropW + x]; - } - } - inputData = padded; - fftW = padW; - fftH = padH; - } - } - - // Pre-pad non-power-of-2 full images so fft2d doesn't truncate frequency data - if (origCropW === 0) { - const padW = nextPow2(fftW); - const padH = nextPow2(fftH); - if (padW !== fftW || padH !== fftH) { - const padded = new Float32Array(padW * padH); - for (let y = 0; y < fftH; y++) { - for (let x = 0; x < fftW; x++) { - padded[y * padW + x] = inputData[y * fftW + x]; - } - } - inputData = padded; - fftW = padW; - fftH = padH; - } - } - - const tCrop = performance.now(); - const real = inputData.slice(); - const imag = new Float32Array(inputData.length); - - if (gpuFFTRef.current && gpuReadyRef.current) { - const result = await gpuFFTRef.current.fft2D(real, imag, fftW, fftH, false); - if (gen !== fftGenRef.current) return; - const tGpu = performance.now(); - fftshift(result.real, fftW, fftH); - fftshift(result.imag, fftW, fftH); - fftMagCacheRef.current = computeMagnitude(result.real, result.imag); - console.log(`[Show2D FFT] GPU ${fftW}×${fftH}: crop=${(tCrop-t0).toFixed(1)}ms gpu=${(tGpu-tCrop).toFixed(1)}ms post=${(performance.now()-tGpu).toFixed(1)}ms`); - } else { - // CPU fallback: run in Web Worker to avoid blocking the main thread - const result = await fft2dAsync(real, imag, fftW, fftH, false); - if (gen !== fftGenRef.current) return; - fftMagCacheRef.current = result.magnitude; - console.log(`[Show2D FFT] Worker ${fftW}×${fftH}: crop=${(tCrop-t0).toFixed(1)}ms worker=${(performance.now()-tCrop).toFixed(1)}ms`); - } - // Track FFT dimensions when they differ from image dimensions (ROI crop or non-pow2 padding) - if (origCropW > 0) { - setFftCropDims({ cropWidth: origCropW, cropHeight: origCropH, fftWidth: fftW, fftHeight: fftH }); - } else if (fftW !== width || fftH !== height) { - setFftCropDims({ cropWidth: width, cropHeight: height, fftWidth: fftW, fftHeight: fftH }); - } else { - setFftCropDims(null); - } - setFftMagVersion(v => v + 1); - setFftComputing(false); - setFftProgress(""); - }; - - doCompute(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [effectiveShowFft, isGallery, selectedIdx, width, height, dataVersion, roiFftKey, fftWindow]); - - // Clear FFT measurement when image, FFT state, or ROI changes - React.useEffect(() => { setFftClickInfo(null); }, [selectedIdx, effectiveShowFft, roiFftActive, roiSelectedIdx]); - - // ------------------------------------------------------------------------- - // FFT data effect: normalize + colormap → cached offscreen canvas - // (does NOT depend on fftZoom/fftPanX/fftPanY — avoids reprocessing on zoom/pan) - // ------------------------------------------------------------------------- - React.useEffect(() => { - if (!effectiveShowFft || isGallery || !fftMagCacheRef.current) return; - - const fftMag = fftMagCacheRef.current; - const lut = COLORMAPS[fftColormap] || COLORMAPS.inferno; - - // Use crop dimensions when ROI FFT is active - const fftW = fftCropDims?.fftWidth ?? width; - const fftH = fftCropDims?.fftHeight ?? height; - - // Heavy steps (log/power transform, range, stats, histogram-data copy) only - // when source magnitude OR scale-mode changed — NOT on every contrast slider tick. - // Cached values live in fftPipelineRef for cheap re-renders. - const sourceChanged = ( - fftPipelineRef.current?.magVersion !== fftMagVersion || - fftPipelineRef.current?.scaleMode !== fftScaleMode || - fftPipelineRef.current?.fftAuto !== fftAuto - ); - if (sourceChanged) { - const magnitude = new Float32Array(fftMag.length); - for (let i = 0; i < fftMag.length; i++) { - if (fftScaleMode === "log") magnitude[i] = Math.log1p(fftMag[i]); - else if (fftScaleMode === "power") magnitude[i] = Math.pow(fftMag[i], 0.5); - else magnitude[i] = fftMag[i]; - } - let displayMin: number, displayMax: number; - if (fftAuto) ({ min: displayMin, max: displayMax } = autoEnhanceFFT(magnitude, fftW, fftH)); - else ({ min: displayMin, max: displayMax } = findDataRange(magnitude)); - const { mean, std } = computeStats(magnitude); - setFftStats([mean, displayMin, displayMax, std]); - setFftHistogramData(magnitude); // no .slice() — magnitude is fresh - setFftDataRange({ min: displayMin, max: displayMax }); - fftPipelineRef.current = { magnitude, displayMin, displayMax, magVersion: fftMagVersion, scaleMode: fftScaleMode, fftAuto }; - } - - const cache = fftPipelineRef.current!; - const { vmin, vmax } = sliderRange(cache.displayMin, cache.displayMax, fftVminPct, fftVmaxPct); - - // GPU colormap path for FFT — uses dedicated slot at index nImages. - // Uploads magnitude only when source changed; contrast/cmap drag triggers cheap re-render. - const engine = gpuCmapRef.current; - const fftSlot = nImages; // dedicate slot just past main image slots - if (engine && gpuCmapReadyRef.current) { - try { - if (sourceChanged) engine.uploadData(fftSlot, cache.magnitude, fftW, fftH); - engine.uploadLUT(fftColormap, lut); - const bitmaps = engine.renderSlotsToImageBitmap([fftSlot], [{ vmin, vmax }], false); - if (bitmaps && bitmaps[0]) { - const oc = fftOffscreenRef.current && fftOffscreenRef.current.width === fftW && fftOffscreenRef.current.height === fftH - ? fftOffscreenRef.current - : Object.assign(document.createElement("canvas"), { width: fftW, height: fftH }); - const ctx = oc.getContext("2d"); - if (ctx) { - ctx.drawImage(bitmaps[0], 0, 0); - fftOffscreenRef.current = oc; - setFftOffscreenVersion(v => v + 1); - return; - } - } - } catch (_e) { /* fall through to CPU */ } - } - // CPU fallback - const offscreen = renderToOffscreen(cache.magnitude, fftW, fftH, lut, vmin, vmax); - if (!offscreen) return; - fftOffscreenRef.current = offscreen; - setFftOffscreenVersion(v => v + 1); - }, [effectiveShowFft, isGallery, fftMagVersion, fftVminPct, fftVmaxPct, fftColormap, fftScaleMode, fftAuto, width, height, fftCropDims, nImages]); - - // ------------------------------------------------------------------------- - // FFT draw effect: cheap drawImage from cached offscreen (zoom/pan changes) - // ------------------------------------------------------------------------- - React.useLayoutEffect(() => { - if (!effectiveShowFft || isGallery || !fftCanvasRef.current || !fftOffscreenRef.current) return; - - const canvas = fftCanvasRef.current; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const offscreen = fftOffscreenRef.current; - const fftW = offscreen.width; - const fftH = offscreen.height; - - // Use bilinear smoothing when FFT is smaller than canvas (avoids blocky upscaling) - ctx.imageSmoothingEnabled = fftSmooth || (fftW < canvasW || fftH < canvasH); - ctx.clearRect(0, 0, canvasW, canvasH); - ctx.save(); - - const centerOffsetX = (canvasW - canvasW * fftZoom) / 2 + fftPanX; - const centerOffsetY = (canvasH - canvasH * fftZoom) / 2 + fftPanY; - - ctx.translate(centerOffsetX, centerOffsetY); - ctx.scale(fftZoom, fftZoom); - // Stretch cropped FFT to fill the full canvas (no layout change during drag) - ctx.drawImage(offscreen, 0, 0, fftW, fftH, 0, 0, canvasW, canvasH); - ctx.restore(); - }, [effectiveShowFft, isGallery, fftOffscreenVersion, canvasW, canvasH, fftZoom, fftPanX, fftPanY, fftSmooth]); - - // ------------------------------------------------------------------------- - // Render FFT overlay (scale bar + colorbar + d-spacing marker) - // ------------------------------------------------------------------------- - React.useEffect(() => { - const overlay = fftOverlayRef.current; - if (!overlay || !effectiveShowFft || isGallery) return; - const ctx = overlay.getContext("2d"); - if (!ctx) return; - ctx.clearRect(0, 0, overlay.width, overlay.height); - - // Use crop dimensions for reciprocal-space calculations - const fftW = fftCropDims?.fftWidth ?? width; - - // FFT colorbar - if (fftShowColorbar && fftDataRange.min !== fftDataRange.max) { - const { vmin, vmax } = sliderRange(fftDataRange.min, fftDataRange.max, fftVminPct, fftVmaxPct); - const lut = COLORMAPS[fftColormap] || COLORMAPS.inferno; - ctx.save(); - ctx.scale(DPR, DPR); - const cssW = overlay.width / DPR; - const cssH = overlay.height / DPR; - drawColorbar(ctx, cssW, cssH, lut, vmin, vmax, fftScaleMode === "log"); - ctx.restore(); - } - - // D-spacing crosshair marker — use crop dims for coordinate mapping - const fftH = fftCropDims?.fftHeight ?? height; - if (fftClickInfo) { - ctx.save(); - ctx.scale(DPR, DPR); - const centerOffsetX = (canvasW - canvasW * fftZoom) / 2 + fftPanX; - const centerOffsetY = (canvasH - canvasH * fftZoom) / 2 + fftPanY; - const screenX = centerOffsetX + fftZoom * (fftClickInfo.col / fftW * canvasW); - const screenY = centerOffsetY + fftZoom * (fftClickInfo.row / fftH * canvasH); - ctx.strokeStyle = "rgba(255, 255, 255, 0.9)"; - ctx.shadowColor = "rgba(0, 0, 0, 0.6)"; - ctx.shadowBlur = 2; - ctx.lineWidth = 1.5; - const r = 8; - ctx.beginPath(); - ctx.moveTo(screenX - r, screenY); ctx.lineTo(screenX - 3, screenY); - ctx.moveTo(screenX + 3, screenY); ctx.lineTo(screenX + r, screenY); - ctx.moveTo(screenX, screenY - r); ctx.lineTo(screenX, screenY - 3); - ctx.moveTo(screenX, screenY + 3); ctx.lineTo(screenX, screenY + r); - ctx.stroke(); - ctx.beginPath(); - ctx.arc(screenX, screenY, 4, 0, Math.PI * 2); - ctx.stroke(); - if (fftClickInfo.dSpacing != null) { - const d = fftClickInfo.dSpacing; - const label = d >= 10 ? `d = ${(d / 10).toFixed(2)} nm` : `d = ${d.toFixed(2)} Å`; - ctx.font = "bold 11px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; - ctx.fillStyle = "white"; - ctx.textAlign = "left"; - ctx.textBaseline = "bottom"; - ctx.fillText(label, screenX + 10, screenY - 4); - } - ctx.restore(); - } - }, [effectiveShowFft, isGallery, fftClickInfo, canvasW, canvasH, fftZoom, fftPanX, fftPanY, width, height, pixelSize, fftDataRange, fftVminPct, fftVmaxPct, fftColormap, fftScaleMode, fftShowColorbar, fftCropDims]); - - // ------------------------------------------------------------------------- - // Compute FFT magnitudes for gallery mode (cache raw magnitudes) - // ------------------------------------------------------------------------- - React.useEffect(() => { - if (!effectiveShowFft || !isGallery || !rawDataRef.current) return; - if (rawDataRef.current.length === 0) return; - let cancelled = false; - - const computeAllFFTs = async () => { - // Wait for WebGPU init if it's still in flight — avoids first-call CPU race. - if (!gpuReadyRef.current) { - try { - const fft = await getWebGPUFFT(); - if (fft) { gpuFFTRef.current = fft; gpuReadyRef.current = true; } - } catch (_e) { /* fall to CPU */ } - if (cancelled) return; - } - // Initialize cache; preserve existing entries (only recompute missing) - if (fftMagCacheGalleryRef.current.length !== nImages) { - fftMagCacheGalleryRef.current = new Array(nImages).fill(null); - } - setFftComputing(true); - const useGPU = !!(gpuFFTRef.current && gpuReadyRef.current); - const backend = useGPU ? "WebGPU" : "CPU Worker"; - setFftProgress(`FFT (${backend})`); - await new Promise(r => requestAnimationFrame(() => r())); - if (cancelled) { setFftComputing(false); return; } - - const useRoiCrop = roiFftActive && roiList && roiSelectedIdx >= 0 && roiSelectedIdx < roiList.length; - const roi = useRoiCrop ? roiList[roiSelectedIdx] : null; - const t0 = performance.now(); - - // Helper: prep one image for FFT (crop, pad, window) - const prepOne = (idx: number): { real: Float32Array; imag: Float32Array; w: number; h: number } | null => { - const data = rawDataRef.current![idx]; - if (!data) return null; - let inputData = data; - let curW = width, curH = height; - if (roi) { - const crop = cropROIRegion(data, width, height, roi); - if (crop) { - if (fftWindow) applyHannWindow2D(crop.cropped, crop.cropW, crop.cropH); - const padW = nextPow2(crop.cropW), padH = nextPow2(crop.cropH); - const padded = new Float32Array(padW * padH); - for (let y = 0; y < crop.cropH; y++) - for (let x = 0; x < crop.cropW; x++) - padded[y * padW + x] = crop.cropped[y * crop.cropW + x]; - inputData = padded; curW = padW; curH = padH; - } - } else { - const padW = nextPow2(curW), padH = nextPow2(curH); - if (padW !== curW || padH !== curH) { - const padded = new Float32Array(padW * padH); - for (let y = 0; y < curH; y++) - for (let x = 0; x < curW; x++) - padded[y * padW + x] = inputData[y * curW + x]; - inputData = padded; curW = padW; curH = padH; - } - } - return { real: inputData.slice(), imag: new Float32Array(inputData.length), w: curW, h: curH }; - }; - - // ── Prep all images ── - const inputs: { real: Float32Array; imag: Float32Array }[] = []; - let fftW = width, fftH = height; - for (let idx = 0; idx < nImages; idx++) { - const input = prepOne(idx); - if (input) { - fftW = input.w; fftH = input.h; - inputs.push({ real: input.real, imag: input.imag }); - } else { - inputs.push({ real: new Float32Array(0), imag: new Float32Array(0) }); - } - } - galleryFftDimsRef.current = { w: fftW, h: fftH }; - const tPrep = performance.now() - t0; - if (cancelled) { setFftComputing(false); return; } - - // ── Batched progressive FFT: batch BATCH_SIZE at a time, display after each batch ── - const BATCH_SIZE = 4; - const tFFT0 = performance.now(); - for (let batchStart = 0; batchStart < nImages; batchStart += BATCH_SIZE) { - if (cancelled) { setFftComputing(false); return; } - const batchEnd = Math.min(batchStart + BATCH_SIZE, nImages); - const batchInputs = inputs.slice(batchStart, batchEnd).filter(inp => inp.real.length > 0); - setFftProgress(`FFT ${batchStart + 1}–${batchEnd}/${nImages} (${backend})`); - - if (useGPU && batchInputs.length > 1) { - // GPU batch: one submission for BATCH_SIZE images - const batchResults = await gpuFFTRef.current!.fft2DBatch(batchInputs, fftW, fftH); - if (cancelled) { setFftComputing(false); return; } - let ri = 0; - for (let idx = batchStart; idx < batchEnd; idx++) { - if (inputs[idx].real.length === 0) continue; - fftshift(batchResults[ri].real, fftW, fftH); - fftshift(batchResults[ri].imag, fftW, fftH); - fftMagCacheGalleryRef.current[idx] = computeMagnitude(batchResults[ri].real, batchResults[ri].imag); - ri++; - } - } else { - // CPU or single image - for (let idx = batchStart; idx < batchEnd; idx++) { - if (inputs[idx].real.length === 0) continue; - if (cancelled) { setFftComputing(false); return; } - const { real, imag } = inputs[idx]; - if (useGPU) { - const result = await gpuFFTRef.current!.fft2D(real, imag, fftW, fftH, false); - fftshift(result.real, fftW, fftH); - fftshift(result.imag, fftW, fftH); - fftMagCacheGalleryRef.current[idx] = computeMagnitude(result.real, result.imag); - } else { - fft2d(real, imag, fftW, fftH, false); - fftshift(real, fftW, fftH); - fftshift(imag, fftW, fftH); - fftMagCacheGalleryRef.current[idx] = computeMagnitude(real, imag); - } - } - } - // Show this batch immediately (progressive top-to-bottom) - setGalleryFftMagVersion(v => v + 1); - // Yield to let the browser paint the batch - await new Promise(r => requestAnimationFrame(() => r())); - } - const tFFT = performance.now() - tFFT0; - const tTotal = performance.now() - t0; - if (!cancelled) { - console.log(`[Show2D FFT] Gallery ${nImages}×${fftW}×${fftH}: prep=${tPrep.toFixed(0)}ms fft=${tFFT.toFixed(0)}ms total=${tTotal.toFixed(0)}ms (${backend} batch=${BATCH_SIZE})`); - } - setFftComputing(false); - setFftProgress(""); - }; - - computeAllFFTs(); - - return () => { cancelled = true; setFftComputing(false); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [effectiveShowFft, isGallery, nImages, width, height, dataVersion, roiFftKey, fftWindow]); - - // Gallery FFT data effect: normalize + colormap → cached offscreen canvases - // (does NOT depend on gallery zoom/pan states) - const [galleryFftOffscreenVersion, setGalleryFftOffscreenVersion] = React.useState(0); - React.useEffect(() => { - if (!effectiveShowFft || !isGallery) return; - const lut = COLORMAPS[fftColormap] || COLORMAPS.inferno; - const fftW = galleryFftDimsRef.current?.w ?? width; - const fftH = galleryFftDimsRef.current?.h ?? height; - - for (let idx = 0; idx < nImages; idx++) { - const magnitude = fftMagCacheGalleryRef.current[idx]; - if (!magnitude) continue; - - // Apply scale transform (same logic as single mode) - let displayData: Float32Array; - let displayMin: number, displayMax: number; - if (fftScaleMode === "log") { - displayData = applyLogScale(magnitude); - } else if (fftScaleMode === "power") { - displayData = new Float32Array(magnitude.length); - for (let j = 0; j < magnitude.length; j++) displayData[j] = Math.sqrt(magnitude[j]); - } else { - displayData = magnitude; - } - if (fftAuto) { - ({ min: displayMin, max: displayMax } = autoEnhanceFFT(magnitude, fftW, fftH)); - if (fftScaleMode === "log") { displayMin = Math.log1p(displayMin); displayMax = Math.log1p(displayMax); } - else if (fftScaleMode === "power") { displayMin = Math.sqrt(displayMin); displayMax = Math.sqrt(displayMax); } - } else { - ({ min: displayMin, max: displayMax } = findDataRange(displayData)); - } - const fc = fftContrastFor(idx); - const { vmin, vmax } = sliderRange(displayMin, displayMax, fc.vminPct, fc.vmaxPct); - - const offscreen = renderToOffscreen(displayData, fftW, fftH, lut, vmin, vmax); - if (!offscreen) continue; - fftOffscreensRef.current[idx] = offscreen; - } - - // Update FFT histogram from selected image - const selMag = fftMagCacheGalleryRef.current[selectedIdx]; - if (selMag) { - let histData: Float32Array; - if (fftScaleMode === "log") histData = applyLogScale(selMag); - else if (fftScaleMode === "power") { histData = new Float32Array(selMag.length); for (let j = 0; j < selMag.length; j++) histData[j] = Math.sqrt(selMag[j]); } - else histData = selMag; - setFftHistogramData(histData); - setFftDataRange(findDataRange(histData)); - } - setGalleryFftOffscreenVersion(v => v + 1); - }, [effectiveShowFft, isGallery, nImages, width, height, galleryFftMagVersion, fftColormap, fftScaleMode, fftAuto, fftVminPct, fftVmaxPct, selectedIdx, fftLinkedContrast, fftContrastStates]); - - // Gallery FFT draw effect: cheap drawImage from cached offscreens (zoom/pan changes) - React.useLayoutEffect(() => { - if (!effectiveShowFft || !isGallery) return; - const fftW = galleryFftDimsRef.current?.w ?? width; - const fftH = galleryFftDimsRef.current?.h ?? height; - - for (let idx = 0; idx < nImages; idx++) { - const offscreen = fftOffscreensRef.current[idx]; - const canvas = fftCanvasRefs.current[idx]; - if (!offscreen || !canvas) continue; - const ctx = canvas.getContext("2d"); - if (!ctx) continue; - - const { zoom, panX, panY } = getGalleryFftState(idx); - ctx.imageSmoothingEnabled = fftSmooth; - ctx.clearRect(0, 0, canvasW, canvasH); - ctx.save(); - const cx = canvasW / 2; - const cy = canvasH / 2; - ctx.translate(cx + panX, cy + panY); - ctx.scale(zoom, zoom); - ctx.translate(-cx, -cy); - ctx.drawImage(offscreen, 0, 0, fftW, fftH, 0, 0, canvasW, canvasH); - ctx.restore(); - } - }, [effectiveShowFft, isGallery, nImages, canvasW, canvasH, width, height, galleryFftOffscreenVersion, galleryFftStates, fftLinkedZoom, linkedFftZoomState, fftSmooth]); - - // ------------------------------------------------------------------------- - // Mouse Handlers for Zoom/Pan - // ------------------------------------------------------------------------- - const handleWheel = (e: React.WheelEvent, idx: number) => { - // In gallery mode, only allow zoom on the selected image (unless linked) - if (isGallery && idx !== selectedIdx && !linkedZoom) return; - e.preventDefault(); // Prevent page scroll when zooming - - const canvas = canvasRefs.current[idx]; - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - - // Get current zoom state - const zs = getZoomState(idx); - - // Mouse position relative to canvas (in canvas pixel coordinates) - const mouseCanvasX = (e.clientX - rect.left) * (canvas.width / rect.width); - const mouseCanvasY = (e.clientY - rect.top) * (canvas.height / rect.height); - - // Canvas center - const cx = canvas.width / 2; - const cy = canvas.height / 2; - - // Mouse position relative to the current view (accounting for pan and zoom) - // The transformation is: translate(cx + panX, cy + panY) -> scale(zoom) -> translate(-cx, -cy) - // So a point on screen at (screenX, screenY) maps to image space as: - // imageX = (screenX - cx - panX) / zoom + cx - const mouseImageX = (mouseCanvasX - cx - zs.panX) / zs.zoom + cx; - const mouseImageY = (mouseCanvasY - cy - zs.panY) / zs.zoom + cy; - - const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; - const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zs.zoom * zoomFactor)); - - // Calculate new pan to keep the mouse position fixed on the same image point - // After zoom: screenX = (imageX - cx) * newZoom + cx + newPanX - // We want screenX to stay at mouseCanvasX, so: - // newPanX = mouseCanvasX - (imageX - cx) * newZoom - cx - const newPanX = mouseCanvasX - (mouseImageX - cx) * newZoom - cx; - const newPanY = mouseCanvasY - (mouseImageY - cy) * newZoom - cy; - - setZoomState(idx, { zoom: newZoom, panX: newPanX, panY: newPanY }); - }; - - const handleDoubleClick = (idx: number) => { - setZoomState(idx, initialZoomState); - }; - - // Reset view (zoom/pan only — preserves profile, FFT state, etc.) - const handleResetAll = () => { - setZoomStates(new Map()); - setLinkedZoomState(initialZoomState); - setGalleryFftStates(new Map()); - setLinkedFftZoomState({ zoom: DEFAULT_FFT_ZOOM, panX: 0, panY: 0 }); - setFftZoom(DEFAULT_FFT_ZOOM); - setFftPanX(0); - setFftPanY(0); - }; - - // FFT zoom/pan — cursor-anchored zoom matching FFT's own canvas transform. - // FFT render: translate(centerOffsetX, centerOffsetY) → scale(zoom) where - // centerOffsetX = (canvasW - canvasW*zoom)/2 + panX - // Solving for image-space u in [0,1]: - // u = (screenX - centerOffsetX) / (zoom * canvasW) - // After zoom change, keep screenX of mouse at u: - // newPanX = mouseX - (canvasW - canvasW*newZoom)/2 - newZoom*u*canvasW - const handleFftWheel = (e: React.WheelEvent) => { - e.preventDefault(); - const canvas = fftCanvasRef.current; - if (!canvas) { - const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; - setFftZoom(Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, fftZoom * zoomFactor))); - return; - } - const rect = canvas.getBoundingClientRect(); - const mouseX = (e.clientX - rect.left) * (canvas.width / rect.width); - const mouseY = (e.clientY - rect.top) * (canvas.height / rect.height); - const cw = canvas.width, ch = canvas.height; - const cOffX = (cw - cw * fftZoom) / 2 + fftPanX; - const cOffY = (ch - ch * fftZoom) / 2 + fftPanY; - const u = (mouseX - cOffX) / (fftZoom * cw); - const v = (mouseY - cOffY) / (fftZoom * ch); - const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; - const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, fftZoom * zoomFactor)); - const newPanX = mouseX - (cw - cw * newZoom) / 2 - newZoom * u * cw; - const newPanY = mouseY - (ch - ch * newZoom) / 2 - newZoom * v * ch; - setFftZoom(newZoom); - setFftPanX(newPanX); - setFftPanY(newPanY); - }; - - const handleFftDoubleClick = () => { - setFftZoom(DEFAULT_FFT_ZOOM); - setFftPanX(0); - setFftPanY(0); - setFftClickInfo(null); - }; - - // Convert FFT canvas mouse position to FFT image pixel coordinates - const fftScreenToImg = (e: React.MouseEvent): { col: number; row: number } | null => { - const canvas = fftCanvasRef.current; - if (!canvas) return null; - const rect = canvas.getBoundingClientRect(); - const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; - const cOffX = (canvasW - canvasW * fftZoom) / 2 + fftPanX; - const cOffY = (canvasH - canvasH * fftZoom) / 2 + fftPanY; - const fftW = fftCropDims?.fftWidth ?? width; - const fftH = fftCropDims?.fftHeight ?? height; - const imgCol = ((mouseX - cOffX) / fftZoom) / canvasW * fftW; - const imgRow = ((mouseY - cOffY) / fftZoom) / canvasH * fftH; - if (imgCol >= 0 && imgCol < fftW && imgRow >= 0 && imgRow < fftH) { - return { col: imgCol, row: imgRow }; - } - return null; - }; - - const handleFftMouseDown = (e: React.MouseEvent) => { - fftClickStartRef.current = { x: e.clientX, y: e.clientY }; - setIsDraggingFftPan(true); - setFftPanStart({ x: e.clientX, y: e.clientY, pX: fftPanX, pY: fftPanY }); - }; - - const handleFftMouseMove = (e: React.MouseEvent) => { - if (!isDraggingFftPan || !fftPanStart) return; - const dx = e.clientX - fftPanStart.x; - const dy = e.clientY - fftPanStart.y; - setFftPanX(fftPanStart.pX + dx); - setFftPanY(fftPanStart.pY + dy); - }; - - const handleFftMouseUp = (e: React.MouseEvent) => { - // Click detection for d-spacing measurement - if (fftClickStartRef.current) { - const dx = e.clientX - fftClickStartRef.current.x; - const dy = e.clientY - fftClickStartRef.current.y; - if (Math.sqrt(dx * dx + dy * dy) < 3) { - const pos = fftScreenToImg(e); - if (pos) { - // Use crop dimensions when ROI FFT is active - const fftW = fftCropDims?.fftWidth ?? width; - const fftH = fftCropDims?.fftHeight ?? height; - let imgCol = pos.col; - let imgRow = pos.row; - // Snap to nearest Bragg spot (local max in FFT magnitude) - if (fftMagCacheRef.current) { - const snapped = findFFTPeak(fftMagCacheRef.current, fftW, fftH, imgCol, imgRow, FFT_SNAP_RADIUS); - imgCol = snapped.col; - imgRow = snapped.row; - } - const halfW = Math.floor(fftW / 2); - const halfH = Math.floor(fftH / 2); - const dcol = imgCol - halfW; - const drow = imgRow - halfH; - const distPx = Math.sqrt(dcol * dcol + drow * drow); - if (distPx < 1) { - setFftClickInfo(null); - } else { - let spatialFreq: number | null = null; - let dSpacing: number | null = null; - if (pixelSize > 0) { - const paddedW = nextPow2(fftW); - const paddedH = nextPow2(fftH); - const binC = ((Math.round(imgCol) - halfW) % fftW + fftW) % fftW; - const binR = ((Math.round(imgRow) - halfH) % fftH + fftH) % fftH; - const freqC = binC <= paddedW / 2 ? binC / (paddedW * pixelSize) : (binC - paddedW) / (paddedW * pixelSize); - const freqR = binR <= paddedH / 2 ? binR / (paddedH * pixelSize) : (binR - paddedH) / (paddedH * pixelSize); - spatialFreq = Math.sqrt(freqC * freqC + freqR * freqR); - dSpacing = spatialFreq > 0 ? 1 / spatialFreq : null; - } - setFftClickInfo({ row: imgRow, col: imgCol, distPx, spatialFreq, dSpacing }); - } - } - } - fftClickStartRef.current = null; - } - setIsDraggingFftPan(false); - setFftPanStart(null); - }; - - const handleFftMouseLeave = () => { - fftClickStartRef.current = null; - setIsDraggingFftPan(false); - setFftPanStart(null); - }; - - // Gallery FFT zoom/pan handlers (only selected image's FFT responds) - const handleGalleryFftWheel = (e: React.WheelEvent, idx: number) => { - if (isGallery && idx !== selectedIdx && !fftLinkedZoom) return; - e.preventDefault(); // Prevent page scroll when zooming FFT - const zs = getGalleryFftState(idx); - const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; - setGalleryFftState(idx, { ...zs, zoom: Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zs.zoom * zoomFactor)) }); - }; - - const handleGalleryFftMouseDown = (e: React.MouseEvent, idx: number) => { - if (isGallery && idx !== selectedIdx) { - setSelectedIdx(idx); - return; // Select first, don't start panning - } - const zs = getGalleryFftState(idx); - setFftPanningIdx(idx); - setIsDraggingFftPan(true); - setFftPanStart({ x: e.clientX, y: e.clientY, pX: zs.panX, pY: zs.panY }); - }; - - const handleGalleryFftMouseMove = (e: React.MouseEvent, idx: number) => { - if (!isDraggingFftPan || !fftPanStart || fftPanningIdx !== idx) return; - const dx = e.clientX - fftPanStart.x; - const dy = e.clientY - fftPanStart.y; - const zs = getGalleryFftState(idx); - setGalleryFftState(idx, { ...zs, panX: fftPanStart.pX + dx, panY: fftPanStart.pY + dy }); - }; - - const handleGalleryFftMouseUp = () => { - setIsDraggingFftPan(false); - setFftPanStart(null); - setFftPanningIdx(null); - }; - - // Track which image is being panned - const [panningIdx, setPanningIdx] = React.useState(null); - const clickStartRef = React.useRef<{ x: number; y: number } | null>(null); - const [draggingProfileEndpoint, setDraggingProfileEndpoint] = React.useState<0 | 1 | null>(null); - const [isDraggingProfileLine, setIsDraggingProfileLine] = React.useState(false); - const [hoveredProfileEndpoint, setHoveredProfileEndpoint] = React.useState<0 | 1 | null>(null); - const [isHoveringProfileLine, setIsHoveringProfileLine] = React.useState(false); - const profileDragStartRef = React.useRef<{ row: number; col: number; p0: { row: number; col: number }; p1: { row: number; col: number } } | null>(null); - - const screenToImg = (e: React.MouseEvent, idx: number): { imgCol: number; imgRow: number } => { - const canvas = canvasRefs.current[idx]; - if (!canvas) return { imgCol: 0, imgRow: 0 }; - const rect = canvas.getBoundingClientRect(); - const mouseCanvasX = (e.clientX - rect.left) * (canvas.width / rect.width); - const mouseCanvasY = (e.clientY - rect.top) * (canvas.height / rect.height); - const zs = getZoomState(idx); - const cx = canvasW / 2; - const cy = canvasH / 2; - return { - imgCol: ((mouseCanvasX - cx - zs.panX) / zs.zoom + cx) / displayScale, - imgRow: ((mouseCanvasY - cy - zs.panY) / zs.zoom + cy) / displayScale, - }; - }; - - const updateAllProfileData = (p0: { row: number; col: number }, p1: { row: number; col: number }) => { - if (!rawDataRef.current) return; - const allProfiles: (Float32Array | null)[] = []; - for (let j = 0; j < rawDataRef.current.length; j++) { - const raw = rawDataRef.current[j]; - allProfiles.push(raw ? sampleLineProfile(raw, width, height, p0.row, p0.col, p1.row, p1.col) : null); - } - setProfileDataAll(allProfiles); - }; - - const updateROI = (e: React.MouseEvent, idx: number) => { - const { imgCol, imgRow } = screenToImg(e, idx); - updateSelectedRoi({ col: Math.max(0, Math.min(width - 1, Math.floor(imgCol))), row: Math.max(0, Math.min(height - 1, Math.floor(imgRow))) }); - }; - - const hitTestROI = (imgCol: number, imgRow: number): number => { - if (!roiActive || !roiList) return -1; - for (let ri = roiList.length - 1; ri >= 0; ri--) { - const roi = roiList[ri]; - const shape = roi.shape || "circle"; - if (shape === "circle" || shape === "annular") { - if (Math.sqrt((imgCol - roi.col) ** 2 + (imgRow - roi.row) ** 2) <= roi.radius) return ri; - } else if (shape === "square") { - if (Math.abs(imgCol - roi.col) <= roi.radius && Math.abs(imgRow - roi.row) <= roi.radius) return ri; - } else if (shape === "rectangle") { - if (Math.abs(imgCol - roi.col) <= roi.width / 2 && Math.abs(imgRow - roi.row) <= roi.height / 2) return ri; - } - } - return -1; - }; - - const getHitArea = () => { - const zoom = (getZoomState(selectedIdx)).zoom; - return RESIZE_HIT_AREA_PX / (displayScale * zoom); - }; - - const isNearEdge = (imgCol: number, imgRow: number, roi: ROIItem): boolean => { - const hitArea = getHitArea(); - const shape = roi.shape || "circle"; - if (shape === "circle" || shape === "annular") { - const dist = Math.sqrt((imgCol - roi.col) ** 2 + (imgRow - roi.row) ** 2); - return Math.abs(dist - roi.radius) < hitArea; - } - if (shape === "square") { - const dx = Math.abs(imgCol - roi.col); - const dy = Math.abs(imgRow - roi.row); - const r = roi.radius; - return (dx <= r + hitArea && dy <= r + hitArea) && (Math.abs(dx - r) < hitArea || Math.abs(dy - r) < hitArea); - } - if (shape === "rectangle") { - const dx = Math.abs(imgCol - roi.col); - const dy = Math.abs(imgRow - roi.row); - const hw = roi.width / 2; - const hh = roi.height / 2; - return (dx <= hw + hitArea && dy <= hh + hitArea) && (Math.abs(dx - hw) < hitArea || Math.abs(dy - hh) < hitArea); - } - return false; - }; - - const isNearResizeHandle = (imgCol: number, imgRow: number): boolean => { - if (!roiActive || !selectedRoi) return false; - return isNearEdge(imgCol, imgRow, selectedRoi); - }; - - const isNearAnyEdge = (imgCol: number, imgRow: number): boolean => { - if (!roiActive || !roiList) return false; - return roiList.some(roi => isNearEdge(imgCol, imgRow, roi)); - }; - - const isNearResizeHandleInner = (imgCol: number, imgRow: number): boolean => { - if (!roiActive || !selectedRoi || selectedRoi.shape !== "annular") return false; - const hitArea = getHitArea(); - const dist = Math.sqrt((imgCol - selectedRoi.col) ** 2 + (imgRow - selectedRoi.row) ** 2); - return Math.abs(dist - selectedRoi.radius_inner) < hitArea; - }; - - const handleMouseDown = (e: React.MouseEvent, idx: number) => { - const zs = getZoomState(idx); - if (isGallery && idx !== selectedIdx) { - setSelectedIdx(idx); - // Continue to pan setup so click-drag on unselected panel pans immediately - // (no double-click required to select first then drag). - } - // Check if click is on the lens inset — edge = resize, interior = drag - if (showLens && !isGallery && idx === 0) { - const canvas = canvasRefs.current[0]; - if (canvas) { - const rect = canvas.getBoundingClientRect(); - const cssX = e.clientX - rect.left; - const cssY = e.clientY - rect.top; - const margin = 12; - const lx = lensAnchor ? lensAnchor.x : margin; - const ly = lensAnchor ? lensAnchor.y : canvasH - lensDisplaySize - margin - 20; - if (cssX >= lx && cssX <= lx + lensDisplaySize && cssY >= ly && cssY <= ly + lensDisplaySize) { - const edgeHit = 8; - const nearEdge = cssX - lx < edgeHit || lx + lensDisplaySize - cssX < edgeHit || cssY - ly < edgeHit || ly + lensDisplaySize - cssY < edgeHit; - if (nearEdge) { - setIsResizingLens(true); - lensResizeStartRef.current = { my: e.clientY, startSize: lensDisplaySize }; - } else { - setIsDraggingLens(true); - lensDragStartRef.current = { mx: e.clientX, my: e.clientY, ax: lx, ay: ly }; - } - e.preventDefault(); - return; - } - } - } - clickStartRef.current = { x: e.clientX, y: e.clientY }; - if (profileActive) { - const { imgCol, imgRow } = screenToImg(e, idx); - if (profilePoints.length === 2) { - const p0 = profilePoints[0]; - const p1 = profilePoints[1]; - const hitRadius = 10 / (displayScale * zs.zoom); - const d0 = Math.sqrt((imgCol - p0.col) ** 2 + (imgRow - p0.row) ** 2); - const d1 = Math.sqrt((imgCol - p1.col) ** 2 + (imgRow - p1.row) ** 2); - if (d0 <= hitRadius || d1 <= hitRadius) { - setDraggingProfileEndpoint(d0 <= d1 ? 0 : 1); - setIsDraggingPan(false); - setPanStart(null); - setPanningIdx(null); - return; - } - if (pointToSegmentDistance(imgCol, imgRow, p0.col, p0.row, p1.col, p1.row) <= hitRadius) { - setIsDraggingProfileLine(true); - profileDragStartRef.current = { - row: imgRow, - col: imgCol, - p0: { row: p0.row, col: p0.col }, - p1: { row: p1.row, col: p1.col }, - }; - setIsDraggingPan(false); - setPanStart(null); - setPanningIdx(null); - return; - } - } - setIsDraggingPan(true); - setPanningIdx(idx); - setPanStart({ x: e.clientX, y: e.clientY, pX: zs.panX, pY: zs.panY }); - return; - } - if (roiActive) { - const { imgCol, imgRow } = screenToImg(e, idx); - // Check resize handles on selected ROI first - if (isNearResizeHandleInner(imgCol, imgRow)) { - setIsDraggingResizeInner(true); - return; - } - if (isNearResizeHandle(imgCol, imgRow)) { - e.preventDefault(); - resizeAspectRef.current = selectedRoi && (selectedRoi.shape === "rectangle") && selectedRoi.width > 0 && selectedRoi.height > 0 ? selectedRoi.width / selectedRoi.height : null; - setIsDraggingResize(true); - return; - } - // Check edge of any ROI — auto-select and start resize - if (roiList) { - for (let ri = 0; ri < roiList.length; ri++) { - if (isNearEdge(imgCol, imgRow, roiList[ri])) { - e.preventDefault(); - const roi = roiList[ri]; - resizeAspectRef.current = roi && (roi.shape === "rectangle") && roi.width > 0 && roi.height > 0 ? roi.width / roi.height : null; - setRoiSelectedIdx(ri); - setIsDraggingResize(true); - return; - } - } - } - // Hit-test existing ROIs (click inside to select + drag) - const hitIdx = hitTestROI(imgCol, imgRow); - if (hitIdx >= 0) { - setRoiSelectedIdx(hitIdx); - setIsDraggingROI(true); - return; - } - // Click on empty space — deselect and allow panning - setRoiSelectedIdx(-1); - } - // Start panning (works in both ROI-active and normal modes) - { - setIsDraggingPan(true); - setPanningIdx(idx); - setPanStart({ x: e.clientX, y: e.clientY, pX: zs.panX, pY: zs.panY }); - } - }; - - const handleMouseMove = (e: React.MouseEvent, idx: number) => { - // Fast path: during pan drag, skip all cursor/hover/lens work — just update pan - if (isDraggingPan && panStart && panningIdx !== null) { - const canvas = canvasRefs.current[idx]; - if (!canvas || idx !== panningIdx) return; - const rect = canvas.getBoundingClientRect(); - const scaleX = canvas.width / rect.width; - const scaleY = canvas.height / rect.height; - const dx = (e.clientX - panStart.x) * scaleX; - const dy = (e.clientY - panStart.y) * scaleY; - const zs = getZoomState(idx); - setZoomState(idx, { ...zs, panX: panStart.pX + dx, panY: panStart.pY + dy }); - return; - } - - // Cursor readout: convert screen position to image pixel coordinates - const canvas = canvasRefs.current[idx]; - if (canvas && rawDataRef.current) { - const rect = canvas.getBoundingClientRect(); - const mouseCanvasX = (e.clientX - rect.left) * (canvas.width / rect.width); - const mouseCanvasY = (e.clientY - rect.top) * (canvas.height / rect.height); - const zs = getZoomState(idx); - const cx = canvasW / 2; - const cy = canvasH / 2; - const imageCanvasX = (mouseCanvasX - cx - zs.panX) / zs.zoom + cx; - const imageCanvasY = (mouseCanvasY - cy - zs.panY) / zs.zoom + cy; - const imgX = Math.floor(imageCanvasX / displayScale); - const imgY = Math.floor(imageCanvasY / displayScale); - if (imgX >= 0 && imgX < width && imgY >= 0 && imgY < height) { - const rawData = rawDataRef.current[idx]; - if (rawData) setCursorInfo({ row: imgY, col: imgX, value: rawData[imgY * width + imgX] }); - if (showLens && !isGallery) setLensPos({ row: imgY, col: imgX }); - } else { - setCursorInfo(null); - // Don't clear lensPos — lens stays at last position when toggle is on - } - } - - // Lens drag - if (isDraggingLens && lensDragStartRef.current) { - const dx = e.clientX - lensDragStartRef.current.mx; - const dy = e.clientY - lensDragStartRef.current.my; - setLensAnchor({ x: lensDragStartRef.current.ax + dx, y: lensDragStartRef.current.ay + dy }); - return; - } - // Lens resize drag - if (isResizingLens && lensResizeStartRef.current) { - const dy = e.clientY - lensResizeStartRef.current.my; - setLensDisplaySize(Math.max(64, Math.min(256, lensResizeStartRef.current.startSize + dy))); - return; - } - - if (profileActive && profilePoints.length === 2) { - const { imgCol, imgRow } = screenToImg(e, idx); - const p0 = profilePoints[0]; - const p1 = profilePoints[1]; - const activeZoom = linkedZoom ? linkedZoomState.zoom : (zoomStates.get(idx) || initialZoomState).zoom; - const hitRadius = 10 / (displayScale * activeZoom); - const d0 = Math.sqrt((imgCol - p0.col) ** 2 + (imgRow - p0.row) ** 2); - const d1 = Math.sqrt((imgCol - p1.col) ** 2 + (imgRow - p1.row) ** 2); - if (draggingProfileEndpoint !== null) { - const clampedRow = Math.max(0, Math.min(height - 1, imgRow)); - const clampedCol = Math.max(0, Math.min(width - 1, imgCol)); - const next = [ - draggingProfileEndpoint === 0 ? { row: clampedRow, col: clampedCol } : profilePoints[0], - draggingProfileEndpoint === 1 ? { row: clampedRow, col: clampedCol } : profilePoints[1], - ]; - setProfilePoints(next); - updateAllProfileData(next[0], next[1]); - return; - } - if (isDraggingProfileLine && profileDragStartRef.current) { - const drag = profileDragStartRef.current; - let deltaRow = imgRow - drag.row; - let deltaCol = imgCol - drag.col; - const minRow = Math.min(drag.p0.row, drag.p1.row); - const maxRow = Math.max(drag.p0.row, drag.p1.row); - const minCol = Math.min(drag.p0.col, drag.p1.col); - const maxCol = Math.max(drag.p0.col, drag.p1.col); - deltaRow = Math.max(deltaRow, -minRow); - deltaRow = Math.min(deltaRow, (height - 1) - maxRow); - deltaCol = Math.max(deltaCol, -minCol); - deltaCol = Math.min(deltaCol, (width - 1) - maxCol); - const next = [ - { row: drag.p0.row + deltaRow, col: drag.p0.col + deltaCol }, - { row: drag.p1.row + deltaRow, col: drag.p1.col + deltaCol }, - ]; - setProfilePoints(next); - updateAllProfileData(next[0], next[1]); - return; - } - const nextHoveredEndpoint: 0 | 1 | null = d0 <= hitRadius ? 0 : d1 <= hitRadius ? 1 : null; - const nextHoverLine = nextHoveredEndpoint === null && pointToSegmentDistance(imgCol, imgRow, p0.col, p0.row, p1.col, p1.row) <= hitRadius; - setHoveredProfileEndpoint(nextHoveredEndpoint); - setIsHoveringProfileLine(nextHoverLine); - } else { - if (hoveredProfileEndpoint !== null) setHoveredProfileEndpoint(null); - if (isHoveringProfileLine) setIsHoveringProfileLine(false); - } - - // ROI resize drag (inner annular ring) - if (isDraggingResizeInner && selectedRoi) { - const { imgCol: ic, imgRow: ir } = screenToImg(e, idx); - const newR = Math.sqrt((ic - selectedRoi.col) ** 2 + (ir - selectedRoi.row) ** 2); - updateSelectedRoi({ radius_inner: Math.max(1, Math.min(selectedRoi.radius - 1, Math.round(newR))) }); - return; - } - // ROI resize drag (outer) - if (isDraggingResize && selectedRoi) { - const { imgCol: ic, imgRow: ir } = screenToImg(e, idx); - const shape = selectedRoi.shape || "circle"; - if (shape === "rectangle") { - let newW = Math.max(2, Math.round(Math.abs(ic - selectedRoi.col) * 2)); - let newH = Math.max(2, Math.round(Math.abs(ir - selectedRoi.row) * 2)); - if (e.shiftKey && resizeAspectRef.current != null) { - const aspect = resizeAspectRef.current; - if (newW / newH > aspect) newH = Math.max(2, Math.round(newW / aspect)); - else newW = Math.max(2, Math.round(newH * aspect)); - } - updateSelectedRoi({ width: newW, height: newH }); - } else { - const newR = shape === "square" ? Math.max(Math.abs(ic - selectedRoi.col), Math.abs(ir - selectedRoi.row)) : Math.sqrt((ic - selectedRoi.col) ** 2 + (ir - selectedRoi.row) ** 2); - const minR = shape === "annular" ? selectedRoi.radius_inner + 1 : 1; - updateSelectedRoi({ radius: Math.max(minR, Math.round(newR)) }); - } - return; - } - // ROI drag (move center) - if (isDraggingROI) { - updateROI(e, idx); - return; - } - // Lens edge hover detection - if (showLens && !isGallery && canvas) { - const rect = canvas.getBoundingClientRect(); - const cssX = e.clientX - rect.left; - const cssY = e.clientY - rect.top; - const margin = 12; - const lx = lensAnchor ? lensAnchor.x : margin; - const ly = lensAnchor ? lensAnchor.y : canvasH - lensDisplaySize - margin - 20; - const inside = cssX >= lx && cssX <= lx + lensDisplaySize && cssY >= ly && cssY <= ly + lensDisplaySize; - const edgeHit = 8; - const nearEdge = inside && (cssX - lx < edgeHit || lx + lensDisplaySize - cssX < edgeHit || cssY - ly < edgeHit || ly + lensDisplaySize - cssY < edgeHit); - setIsHoveringLensEdge(nearEdge); - } else { - setIsHoveringLensEdge(false); - } - // Hover detection for resize handles (show cursor on any ROI edge) - if (roiActive && !isDraggingPan) { - const { imgCol: ic, imgRow: ir } = screenToImg(e, idx); - setIsHoveringResizeInner(isNearResizeHandleInner(ic, ir)); - setIsHoveringResize(isNearAnyEdge(ic, ir)); - } - - // Panning - if (!isDraggingPan || !panStart || panningIdx === null) return; - if (idx !== panningIdx) return; - if (!canvas) return; - const rect2 = canvas.getBoundingClientRect(); - const scaleX = canvas.width / rect2.width; - const scaleY = canvas.height / rect2.height; - const dx = (e.clientX - panStart.x) * scaleX; - const dy = (e.clientY - panStart.y) * scaleY; - - const zs = getZoomState(idx); - setZoomState(idx, { ...zs, panX: panStart.pX + dx, panY: panStart.pY + dy }); - }; - - const handleMouseUp = (e: React.MouseEvent, idx: number) => { - if (isDraggingLens) { - setIsDraggingLens(false); - lensDragStartRef.current = null; - return; - } - if (isResizingLens) { - setIsResizingLens(false); - lensResizeStartRef.current = null; - return; - } - if (draggingProfileEndpoint !== null || isDraggingProfileLine) { - setDraggingProfileEndpoint(null); - setIsDraggingProfileLine(false); - profileDragStartRef.current = null; - clickStartRef.current = null; - setIsDraggingROI(false); - setIsDraggingResize(false); - setIsDraggingResizeInner(false); - setIsDraggingPan(false); - setPanStart(null); - setPanningIdx(null); - setHoveredProfileEndpoint(null); - setIsHoveringProfileLine(false); - return; - } - // Detect click (vs drag) for profile mode - if (profileActive && clickStartRef.current) { - const dx = e.clientX - clickStartRef.current.x; - const dy = e.clientY - clickStartRef.current.y; - if (Math.sqrt(dx * dx + dy * dy) < 3) { - // It's a click — compute image coordinates - const canvas = canvasRefs.current[idx]; - if (canvas && rawDataRef.current) { - const rect = canvas.getBoundingClientRect(); - const mouseCanvasX = (e.clientX - rect.left) * (canvas.width / rect.width); - const mouseCanvasY = (e.clientY - rect.top) * (canvas.height / rect.height); - const zs = getZoomState(idx); - const cx = canvasW / 2; - const cy = canvasH / 2; - const imgX = ((mouseCanvasX - cx - zs.panX) / zs.zoom + cx) / displayScale; - const imgY = ((mouseCanvasY - cy - zs.panY) / zs.zoom + cy) / displayScale; - if (imgX >= 0 && imgX < width && imgY >= 0 && imgY < height) { - const pt = { row: imgY, col: imgX }; - if (profilePoints.length === 0 || profilePoints.length === 2) { - // Start new line - setProfilePoints([pt]); - setProfileDataAll([]); - } else { - // Complete the line - const p0 = profilePoints[0]; - setProfilePoints([p0, pt]); - updateAllProfileData(p0, pt); - } - } - } - } - } - // Detect click for measurement mode (only when profile is not active) - if (measureActive && !profileActive && clickStartRef.current) { - const dx = e.clientX - clickStartRef.current.x; - const dy = e.clientY - clickStartRef.current.y; - if (Math.sqrt(dx * dx + dy * dy) < 3) { - const canvas = canvasRefs.current[idx]; - if (canvas) { - const rect = canvas.getBoundingClientRect(); - const mouseCanvasX = (e.clientX - rect.left) * (canvas.width / rect.width); - const mouseCanvasY = (e.clientY - rect.top) * (canvas.height / rect.height); - const zs = getZoomState(idx); - const cx = canvasW / 2; - const cy = canvasH / 2; - const imgX = ((mouseCanvasX - cx - zs.panX) / zs.zoom + cx) / displayScale; - const imgY = ((mouseCanvasY - cy - zs.panY) / zs.zoom + cy) / displayScale; - if (imgX >= 0 && imgX < width && imgY >= 0 && imgY < height) { - const pt = { row: imgY, col: imgX }; - if (measurePoints.length < 2) { - setMeasurePoints([...measurePoints, pt]); - } else { - setMeasurePoints([pt]); - } - } - } - } - } - clickStartRef.current = null; - setDraggingProfileEndpoint(null); - setIsDraggingProfileLine(false); - profileDragStartRef.current = null; - setIsDraggingROI(false); - setIsDraggingResize(false); - setIsDraggingResizeInner(false); - setIsDraggingPan(false); - setPanStart(null); - setPanningIdx(null); - setHoveredProfileEndpoint(null); - setIsHoveringProfileLine(false); - }; - - const handleMouseLeave = (idx: number) => { - setCursorInfo(null); - // Don't clear lensPos — lens stays at last position when toggle is on - setIsDraggingLens(false); - setIsResizingLens(false); - lensDragStartRef.current = null; - lensResizeStartRef.current = null; - setIsHoveringLensEdge(false); - setIsDraggingROI(false); - setIsDraggingResize(false); - setIsDraggingResizeInner(false); - setDraggingProfileEndpoint(null); - setIsDraggingProfileLine(false); - setHoveredProfileEndpoint(null); - setIsHoveringProfileLine(false); - profileDragStartRef.current = null; - setIsHoveringResize(false); - setIsHoveringResizeInner(false); - if (panningIdx === idx) { - setIsDraggingPan(false); - setPanStart(null); - setPanningIdx(null); - } - }; - - // ------------------------------------------------------------------------- - // Copy to clipboard handler - const handleCopy = React.useCallback(async () => { - const canvas = canvasRefs.current[isGallery ? selectedIdx : 0]; - if (!canvas) return; - try { - const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png")); - if (!blob) return; - await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); - } catch { - // Fallback: download if clipboard API unavailable - canvas.toBlob((b) => { if (b) downloadBlob(b, `show2d_${labels?.[selectedIdx] || "image"}.png`); }, "image/png"); - } - }, [isGallery, selectedIdx, labels]); - - // Export publication-quality figure with scale bar, colorbar, annotations - const handleExportFigure = React.useCallback((withScaleBar: boolean, withColorbar: boolean) => { - setExportAnchor(null); - const idx = isGallery ? selectedIdx : 0; - const rawData = rawDataRef.current?.[idx]; - if (!rawData) return; - - const processed = logScale ? applyLogScale(rawData) : rawData; - const lut = COLORMAPS[cmap] || COLORMAPS.inferno; - - let vmin: number, vmax: number; - const hasAbsRange = traitVmin != null && traitVmax != null; - const rMin = hasAbsRange ? (logScale ? Math.log1p(Math.max(traitVmin!, 0)) : traitVmin!) : imageDataRange.min; - const rMax = hasAbsRange ? (logScale ? Math.log1p(Math.max(traitVmax!, 0)) : traitVmax!) : imageDataRange.max; - if (rMin !== rMax && (imageVminPct > 0 || imageVmaxPct < 100)) { - ({ vmin, vmax } = sliderRange(rMin, rMax, imageVminPct, imageVmaxPct)); - } else if (!hasAbsRange && autoContrast) { - ({ vmin, vmax } = percentileClip(processed, 2, 98)); - } else { - vmin = rMin; - vmax = rMax; - } - - const offscreen = renderToOffscreen(processed, width, height, lut, vmin, vmax); - if (!offscreen) return; - - const figCanvas = exportFigure({ - imageCanvas: offscreen, - title: title || undefined, - lut, - vmin, - vmax, - logScale, - pixelSize: pixelSize > 0 ? pixelSize : undefined, - showColorbar: withColorbar, - showScaleBar: withScaleBar && pixelSize > 0, - drawAnnotations: (ctx) => { - // ROI highlight mask - if (roiActive && roiList) { - const hlRois = roiList.filter(r => r.highlight); - if (hlRois.length > 0) { - ctx.save(); - ctx.fillStyle = "rgba(0,0,0,0.6)"; - ctx.fillRect(0, 0, width, height); - ctx.globalCompositeOperation = "destination-out"; - for (const roi of hlRois) { - ctx.fillStyle = "rgba(0,0,0,1)"; - const shape = roi.shape || "circle"; - if (shape === "circle") { ctx.beginPath(); ctx.arc(roi.col, roi.row, roi.radius, 0, Math.PI * 2); ctx.fill(); } - else if (shape === "square") { ctx.fillRect(roi.col - roi.radius, roi.row - roi.radius, roi.radius * 2, roi.radius * 2); } - else if (shape === "rectangle") { ctx.fillRect(roi.col - roi.width / 2, roi.row - roi.height / 2, roi.width, roi.height); } - else if (shape === "annular") { - ctx.beginPath(); ctx.arc(roi.col, roi.row, roi.radius, 0, Math.PI * 2); ctx.fill(); - ctx.globalCompositeOperation = "source-over"; - ctx.fillStyle = "rgba(0,0,0,0.6)"; - ctx.beginPath(); ctx.arc(roi.col, roi.row, roi.radius_inner, 0, Math.PI * 2); ctx.fill(); - ctx.globalCompositeOperation = "destination-out"; - } - } - ctx.restore(); - } - // ROI outlines - for (const roi of roiList) { - const shape = (roi.shape || "circle") as "circle" | "square" | "rectangle" | "annular"; - ctx.lineWidth = roi.line_width || 2; - drawROI(ctx, roi.col, roi.row, shape, roi.radius, roi.width, roi.height, roi.color, roi.color, false, roi.radius_inner); - } - } - // Profile line - if (profileActive && profilePoints.length === 2) { - ctx.strokeStyle = "#4fc3f7"; - ctx.lineWidth = 2; - ctx.setLineDash([4, 3]); - ctx.beginPath(); - ctx.moveTo(profilePoints[0].col, profilePoints[0].row); - ctx.lineTo(profilePoints[1].col, profilePoints[1].row); - ctx.stroke(); - ctx.setLineDash([]); - ctx.fillStyle = "#4fc3f7"; - ctx.beginPath(); - ctx.arc(profilePoints[0].col, profilePoints[0].row, 3, 0, Math.PI * 2); - ctx.fill(); - ctx.beginPath(); - ctx.arc(profilePoints[1].col, profilePoints[1].row, 3, 0, Math.PI * 2); - ctx.fill(); - } - }, - }); - - canvasToPDF(figCanvas).then((blob) => downloadBlob(blob, `show2d_figure_${labels?.[selectedIdx] || "image"}.pdf`)); - }, [isGallery, selectedIdx, labels, width, height, cmap, logScale, autoContrast, imageDataRange, imageVminPct, imageVmaxPct, pixelSize, title, roiActive, roiList, profileActive, profilePoints]); - - // Export all variants (PNG + PDF) as zip - const handleExportAll = React.useCallback(async () => { - setExportAnchor(null); - const idx = isGallery ? selectedIdx : 0; - const rawData = rawDataRef.current?.[idx]; - if (!rawData) return; - - const processed = logScale ? applyLogScale(rawData) : rawData; - const lut = COLORMAPS[cmap] || COLORMAPS.inferno; - - let vmin: number, vmax: number; - const hasAbsRange2 = traitVmin != null && traitVmax != null; - const rMin2 = hasAbsRange2 ? (logScale ? Math.log1p(Math.max(traitVmin!, 0)) : traitVmin!) : imageDataRange.min; - const rMax2 = hasAbsRange2 ? (logScale ? Math.log1p(Math.max(traitVmax!, 0)) : traitVmax!) : imageDataRange.max; - if (rMin2 !== rMax2 && (imageVminPct > 0 || imageVmaxPct < 100)) { - ({ vmin, vmax } = sliderRange(rMin2, rMax2, imageVminPct, imageVmaxPct)); - } else if (!hasAbsRange2 && autoContrast) { - ({ vmin, vmax } = percentileClip(processed, 2, 98)); - } else { - vmin = rMin2; - vmax = rMax2; - } - - const offscreen = renderToOffscreen(processed, width, height, lut, vmin, vmax); - if (!offscreen) return; - - const drawAnnotations = (ctx: CanvasRenderingContext2D) => { - if (roiActive && roiList) { - const hlRois = roiList.filter(r => r.highlight); - if (hlRois.length > 0) { - ctx.save(); - ctx.fillStyle = "rgba(0,0,0,0.6)"; - ctx.fillRect(0, 0, width, height); - ctx.globalCompositeOperation = "destination-out"; - for (const roi of hlRois) { - ctx.fillStyle = "rgba(0,0,0,1)"; - const shape = roi.shape || "circle"; - if (shape === "circle") { ctx.beginPath(); ctx.arc(roi.col, roi.row, roi.radius, 0, Math.PI * 2); ctx.fill(); } - else if (shape === "square") { ctx.fillRect(roi.col - roi.radius, roi.row - roi.radius, roi.radius * 2, roi.radius * 2); } - else if (shape === "rectangle") { ctx.fillRect(roi.col - roi.width / 2, roi.row - roi.height / 2, roi.width, roi.height); } - else if (shape === "annular") { - ctx.beginPath(); ctx.arc(roi.col, roi.row, roi.radius, 0, Math.PI * 2); ctx.fill(); - ctx.globalCompositeOperation = "source-over"; - ctx.fillStyle = "rgba(0,0,0,0.6)"; - ctx.beginPath(); ctx.arc(roi.col, roi.row, roi.radius_inner, 0, Math.PI * 2); ctx.fill(); - ctx.globalCompositeOperation = "destination-out"; - } - } - ctx.restore(); - for (const roi of roiList) { - const shape = (roi.shape || "circle") as "circle" | "square" | "rectangle" | "annular"; - ctx.lineWidth = roi.line_width || 2; - drawROI(ctx, roi.col, roi.row, shape, roi.radius, roi.width, roi.height, roi.color, roi.color, false, roi.radius_inner); - } - } - } - if (profileActive && profilePoints.length === 2) { - ctx.strokeStyle = "#4fc3f7"; - ctx.lineWidth = 2; - ctx.setLineDash([4, 3]); - ctx.beginPath(); - ctx.moveTo(profilePoints[0].col, profilePoints[0].row); - ctx.lineTo(profilePoints[1].col, profilePoints[1].row); - ctx.stroke(); - ctx.setLineDash([]); - ctx.fillStyle = "#4fc3f7"; - ctx.beginPath(); ctx.arc(profilePoints[0].col, profilePoints[0].row, 3, 0, Math.PI * 2); ctx.fill(); - ctx.beginPath(); ctx.arc(profilePoints[1].col, profilePoints[1].row, 3, 0, Math.PI * 2); ctx.fill(); - } - }; - - const hasScale = pixelSize > 0; - const baseOpts = { - imageCanvas: offscreen, - title: title || undefined, - lut, - vmin, - vmax, - logScale, - pixelSize: hasScale ? pixelSize : undefined, - drawAnnotations, - }; - - const variants: { name: string; showScaleBar: boolean; showColorbar: boolean }[] = [ - { name: "figure", showScaleBar: false, showColorbar: false }, - { name: "figure_scalebar", showScaleBar: true, showColorbar: false }, - { name: "figure_scalebar_colorbar", showScaleBar: true, showColorbar: true }, - ]; - - const zip = new JSZip(); - const prefix = `show2d_${labels?.[selectedIdx] || "image"}`; - const metadata = { - metadata_version: "1.0", - widget_name: "Show2D", - widget_version: widgetVersion || "unknown", - exported_at: new Date().toISOString(), - format: "zip", - export_kind: "figure_variants", - selected_idx: idx, - image_shape: { rows: height, cols: width }, - display: { - cmap, - log_scale: logScale, - auto_contrast: autoContrast, - vmin_pct: imageVminPct, - vmax_pct: imageVmaxPct, - }, - variants, - }; - zip.file("metadata.json", JSON.stringify(metadata, null, 2)); - - for (const v of variants) { - const figCanvas = exportFigure({ ...baseOpts, showScaleBar: v.showScaleBar && hasScale, showColorbar: v.showColorbar }); - const pngBlob = await new Promise((resolve) => figCanvas.toBlob((b) => resolve(b!), "image/png")); - zip.file(`${prefix}_${v.name}.png`, pngBlob); - const pdfBlob = await canvasToPDF(figCanvas); - zip.file(`${prefix}_${v.name}.pdf`, pdfBlob); - } - - const blob = await zip.generateAsync({ type: "blob" }); - downloadBlob(blob, `${prefix}_all.zip`); - }, [isGallery, selectedIdx, labels, width, height, cmap, logScale, autoContrast, imageDataRange, imageVminPct, imageVmaxPct, pixelSize, title, roiActive, roiList, profileActive, profilePoints, widgetVersion]); - - // Resize Handlers - // ------------------------------------------------------------------------- - const handleCanvasResizeStart = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - setIsResizingCanvas(true); - setResizeStart({ x: e.clientX, y: e.clientY, size: canvasSize }); - }; - - React.useEffect(() => { - if (!isResizingCanvas) return; - let rafId = 0; - let latestSize = resizeStart ? resizeStart.size : canvasSize; - - const handleMouseMove = (e: MouseEvent) => { - if (!resizeStart) return; - const delta = Math.max(e.clientX - resizeStart.x, e.clientY - resizeStart.y); - latestSize = Math.max(200, resizeStart.size + delta); - if (!rafId) { - rafId = requestAnimationFrame(() => { - rafId = 0; - setCanvasSize(latestSize); - }); - } - }; - - const handleMouseUp = () => { - cancelAnimationFrame(rafId); - setCanvasSize(latestSize); - setIsResizingCanvas(false); - setResizeStart(null); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - return () => { - cancelAnimationFrame(rafId); - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [isResizingCanvas, resizeStart]); - - // Profile height resize - React.useEffect(() => { - if (!isResizingProfile) return; - const handleMouseMove = (e: MouseEvent) => { - if (!profileResizeStart) return; - const delta = e.clientY - profileResizeStart.y; - setProfileHeight(Math.max(40, Math.min(300, profileResizeStart.height + delta))); - }; - const handleMouseUp = () => { - setIsResizingProfile(false); - setProfileResizeStart(null); - }; - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [isResizingProfile, profileResizeStart]); - - // ------------------------------------------------------------------------- - // Keyboard shortcuts - // ------------------------------------------------------------------------- - const handleKeyDown = (e: React.KeyboardEvent) => { - // Number keys 1-9 select gallery images (avoids arrow key conflicts with Jupyter) - if (isGallery && e.key >= "1" && e.key <= "9") { - const idx = parseInt(e.key) - 1; - if (idx < nImages) { e.preventDefault(); setSelectedIdx(idx); } - return; - } - switch (e.key) { - case "ArrowLeft": - if (isGallery) { e.preventDefault(); setSelectedIdx(Math.max(0, selectedIdx - 1)); } - break; - case "ArrowRight": - if (isGallery) { e.preventDefault(); setSelectedIdx(Math.min(nImages - 1, selectedIdx + 1)); } - break; - case "r": - case "R": - handleResetAll(); - break; - case "m": - case "M": - if (measureActive) { - setMeasureActive(false); - setMeasurePoints([]); - } else { - setMeasureActive(true); - setMeasurePoints([]); - } - break; - case "Escape": - if (measureActive) { - setMeasureActive(false); - setMeasurePoints([]); - } - break; - case "]": - { - e.preventDefault(); - const rIdx = isGallery ? selectedIdx : 0; - const rots = [...(imageRotations || [])]; - while (rots.length <= rIdx) rots.push(0); - rots[rIdx] = (rots[rIdx] + 3) % 4; - setImageRotations(rots); - } - break; - case "[": - { - e.preventDefault(); - const rIdx2 = isGallery ? selectedIdx : 0; - const rots2 = [...(imageRotations || [])]; - while (rots2.length <= rIdx2) rots2.push(0); - rots2[rIdx2] = (rots2[rIdx2] + 1) % 4; - setImageRotations(rots2); - } - break; - case "Delete": - case "Backspace": - if (roiActive && roiSelectedIdx >= 0 && roiList && roiSelectedIdx < roiList.length) { - e.preventDefault(); - const newList = roiList.filter((_, i) => i !== roiSelectedIdx); - setRoiList(newList); - setRoiSelectedIdx(newList.length > 0 ? Math.min(roiSelectedIdx, newList.length - 1) : -1); - } - break; - } - }; - - // ------------------------------------------------------------------------- - // Render (Show3D-style layout) - // ------------------------------------------------------------------------- - const needsReset = getZoomState(isGallery ? selectedIdx : 0).zoom !== 1 || getZoomState(isGallery ? selectedIdx : 0).panX !== 0 || getZoomState(isGallery ? selectedIdx : 0).panY !== 0; - const statsIdx = isGallery ? selectedIdx : 0; - - // Calibrated cursor position - unit is whatever the user passed via sampling/units. - const calibratedUnit = pixelSize > 0 ? pixelUnit : ""; - const calibratedFactor = pixelSize; - - return ( - - - {/* Main panel */} - - {/* Title row */} - - {title || (isGallery ? "Gallery" : "Image")} - {displayBinFactor > 1 && ( - - {displayBinFactor}× binned - - )} - {(() => { const rk = (imageRotations?.[isGallery ? selectedIdx : 0] ?? 0) % 4; return rk !== 0 ? ( - { - const ri = isGallery ? selectedIdx : 0; - const rots = [...(imageRotations || [])]; - while (rots.length <= ri) rots.push(0); - rots[ri] = (rots[ri] + 3) % 4; - setImageRotations(rots); - }} - sx={{ ml: 0.5, color: themeColors.accent, cursor: "pointer", fontSize: "inherit", "&:hover": { opacity: 0.7 } }} - > - ({rk * 90}°) - - ) : null; })()} - - Controls - FFT: Show power spectrum (Fourier transform) alongside image. - Profile: Click two points on image to draw a line intensity profile. - ROI: Region of Interest — click to place, drag to move. - {!isGallery && Lens: Magnifier inset that follows the cursor.} - Auto: Percentile-based contrast (2nd–98th percentile). FFT Auto masks DC + clips to 99.9th. - {isGallery && Link Zoom / Contrast: Sync zoom or histogram range across all gallery images.} - Keyboard - - } theme={themeInfo.theme} /> - - {/* Controls row: Profile, ROI, Lens, FFT, Export, Reset, Copy */} - - {( - <> - Profile: - { - const on = e.target.checked; - setProfileActive(on); - if (on) { - setRoiActive(false); - } else { - setProfilePoints([]); - setProfileDataAll([]); - setHoveredProfileEndpoint(null); - setIsHoveringProfileLine(false); - } - }} - size="small" - sx={switchStyles.small} - /> - - )} - {!isGallery && ( - <> - ROI: - { - const on = e.target.checked; - setRoiActive(on); - if (on) { - setProfileActive(false); - setProfilePoints([]); - setProfileDataAll([]); - setHoveredProfileEndpoint(null); - setIsHoveringProfileLine(false); - } else { - setRoiSelectedIdx(-1); - } - }} - size="small" - sx={switchStyles.small} - /> - - )} - {( - <> - {!isGallery && ( - <> - Lens: - { - if (!showLens) { - setShowLens(true); - setLensPos({ row: Math.floor(height / 2), col: Math.floor(width / 2) }); - } else { - setShowLens(false); - setLensPos(null); - } - }} - - size="small" - sx={switchStyles.small} - /> - - )} - FFT: - { - const on = e.target.checked; - if (on && width * height > 2048 * 2048) { - console.warn(`Show2D: FFT on ${width}×${height} image (${(width * height / 1e6).toFixed(1)}M pixels) may be slow`); - } - setShowFft(on); - }} - - size="small" - sx={switchStyles.small} - /> - {nImages === 2 && ( - <> - Diff: - { setDiffMode(!diffMode); }} size="small" sx={switchStyles.small} /> - - )} - - )} - - {( - - )} - {( - <> - - setExportAnchor(null)} anchorOrigin={{ vertical: "bottom", horizontal: "left" }} transformOrigin={{ vertical: "top", horizontal: "left" }} sx={{ zIndex: 9999 }}> - handleExportFigure(true, true)} sx={{ fontSize: 12 }}>PDF + scalebar + colorbar - handleExportFigure(true, false)} sx={{ fontSize: 12 }}>PDF + scalebar - handleExportFigure(false, false)} sx={{ fontSize: 12 }}>PDF - All (PNG + PDF) - - - - )} - - - {isGallery ? ( - /* Gallery mode */ - - {Array.from({ length: nImages }).map((_, i) => ( - - { imageContainerRefs.current[i] = el; }} - sx={{ position: "relative", bgcolor: "#000", border: `2px solid ${i === selectedIdx ? themeColors.accent : themeColors.border}`, borderRadius: 0, width: canvasW, height: canvasH }} - onMouseDown={(e) => handleMouseDown(e, i)} - onMouseMove={(e) => handleMouseMove(e, i)} - onMouseUp={(e) => handleMouseUp(e, i)} - onMouseLeave={() => handleMouseLeave(i)} - onWheel={(i === selectedIdx || linkedZoom) ? (e) => handleWheel(e, i) : undefined} - onDoubleClick={() => handleDoubleClick(i)} - > - { if (el && canvasRefs.current[i] !== el) { canvasRefs.current[i] = el; setCanvasReady(c => c + 1); } }} - width={canvasW} height={canvasH} - style={{ width: canvasW, height: canvasH, imageRendering: imageRenderingStyle }} - /> - { overlayRefs.current[i] = el; }} - width={Math.round(canvasW * DPR)} height={Math.round(canvasH * DPR)} - style={{ position: "absolute", top: 0, left: 0, width: canvasW, height: canvasH, pointerEvents: "none" }} - /> - {( - - )} - - - {labels?.[i] || `Image ${i + 1}`} - {(imageRotations?.[i] ?? 0) % 4 !== 0 && ( - { - e.stopPropagation(); - const rots = [...(imageRotations || [])]; - while (rots.length <= i) rots.push(0); - rots[i] = (rots[i] + 3) % 4; - setImageRotations(rots); - }} - sx={{ ml: 0.5, color: themeColors.accent, cursor: "pointer", "&:hover": { opacity: 0.7 } }} - > - ({(imageRotations[i] % 4) * 90}°) - - )} - - {effectiveShowFft && ( - { fftContainerRefs.current[i] = el; }} - sx={{ mt: 0.5, position: "relative", border: `2px solid ${i === selectedIdx ? themeColors.accent : themeColors.border}`, borderRadius: 0, bgcolor: "#000", cursor: "grab" }} - onWheel={(i === selectedIdx || fftLinkedZoom) ? (e) => handleGalleryFftWheel(e, i) : undefined} - onDoubleClick={() => setGalleryFftState(i, { zoom: DEFAULT_FFT_ZOOM, panX: 0, panY: 0 })} - onMouseDown={(e) => handleGalleryFftMouseDown(e, i)} - onMouseMove={(e) => handleGalleryFftMouseMove(e, i)} - onMouseUp={handleGalleryFftMouseUp} - onMouseLeave={handleGalleryFftMouseUp} - > - { fftCanvasRefs.current[i] = el; }} - width={canvasW} height={canvasH} - style={{ width: canvasW, height: canvasH, imageRendering: imageRenderingStyle, display: "block" }} - /> - {fftComputing && !fftMagCacheGalleryRef.current[i] && ( - - FFT… - - )} - - )} - - ))} - {showDiffPanel && diffOtherIndices.map((otherIdx, slot) => ( - - - { diffCanvasRefs.current[slot] = el; }} - width={canvasW} height={canvasH} - style={{ width: canvasW, height: canvasH, imageRendering: imageRenderingStyle }} - /> - - - {nImages === 2 ? "Diff (A − B)" : `Diff (#${diffReference + 1} − #${otherIdx + 1})`} - - {/* FFT of diff (n=2 only) */} - {effectiveShowFft && nImages === 2 && slot === 0 && ( - - { diffFftCanvasRef.current = el; }} - width={canvasW} height={canvasH} - style={{ width: canvasW, height: canvasH, imageRendering: imageRenderingStyle, display: "block" }} - /> - - )} - - ))} - - ) : ( - /* Single image mode */ - { imageContainerRefs.current[0] = el; }} - sx={{ position: "relative", bgcolor: "#000", border: `1px solid ${themeColors.border}`, width: canvasW, height: canvasH, cursor: isHoveringLensEdge ? "nwse-resize" : isDraggingROI ? "move" : (isDraggingResize || isDraggingResizeInner || isHoveringResize || isHoveringResizeInner) ? "nwse-resize" : (draggingProfileEndpoint !== null || isDraggingProfileLine) ? "grabbing" : (profileActive && (hoveredProfileEndpoint !== null || isHoveringProfileLine)) ? "grab" : (profileActive || roiActive || measureActive) ? "crosshair" : "grab" }} - onMouseDown={(e) => handleMouseDown(e, 0)} - onMouseMove={(e) => handleMouseMove(e, 0)} - onMouseUp={(e) => handleMouseUp(e, 0)} - onMouseLeave={() => handleMouseLeave(0)} - onWheel={(e) => handleWheel(e, 0)} - onDoubleClick={() => handleDoubleClick(0)} - > - { if (el && canvasRefs.current[0] !== el) { canvasRefs.current[0] = el; setCanvasReady(c => c + 1); } }} - width={canvasW} height={canvasH} - style={{ width: canvasW, height: canvasH, imageRendering: imageRenderingStyle }} - /> - { overlayRefs.current[0] = el; }} - width={Math.round(canvasW * DPR)} height={Math.round(canvasH * DPR)} - style={{ position: "absolute", top: 0, left: 0, width: canvasW, height: canvasH, pointerEvents: "none" }} - /> - - {cursorInfo && ( - - - ({cursorInfo.row}, {cursorInfo.col}){pixelSize > 0 ? ` = (${(cursorInfo.row * calibratedFactor).toFixed(1)}, ${(cursorInfo.col * calibratedFactor).toFixed(1)} ${calibratedUnit})` : ""} {formatNumber(cursorInfo.value)} - - - )} - {( - - )} - - )} - - {/* Stats bar - right below canvas (Show3D style) */} - {showStats && ( - - {isGallery && ( - {labels?.[statsIdx] || `#${statsIdx + 1}`} - )} - Mean {formatNumber(statsMean?.[statsIdx] ?? 0)} - Min {formatNumber(statsMin?.[statsIdx] ?? 0)} - Max {formatNumber(statsMax?.[statsIdx] ?? 0)} - Std {formatNumber(statsStd?.[statsIdx] ?? 0)} - {measureActive && ( - <> - - Measuring - - )} - - )} - - {/* Gallery FFT Controls - below gallery grid */} - {effectiveShowFft && isGallery && ( - - - - FFT Scale: - - {roiFftActive && fftCropDims && ( - <> - Win: - { setFftWindow(e.target.checked); }} size="small" sx={switchStyles.small} /> - - )} - Color: - - - {/* FFT Row 2: Auto + Smooth + Link Zoom/Pan/Contrast (mirrors main image Row 2) */} - - Auto: - { setFftAuto(e.target.checked); }} size="small" sx={switchStyles.small} /> - Smooth: - { setFftSmooth(e.target.checked); }} size="small" sx={switchStyles.small} /> - {isGallery && ( - <> - Link: - Zoom - { setFftLinkedZoom(!fftLinkedZoom); }} size="small" sx={switchStyles.small} /> - Pan - { setFftLinkPan(!fftLinkPan); }} size="small" sx={switchStyles.small} /> - Contrast - { setFftLinkedContrast(!fftLinkedContrast); }} size="small" sx={switchStyles.small} /> - - )} - - - {( - - {fftHistogramData && ( - !fftLinkedContrast && isGallery ? ( - - {Array.from({ length: nImages }).map((_, i) => { - const fc = fftContrastFor(i); - const mag = fftMagCacheGalleryRef.current[i]; - let perData: Float32Array | null = null; - if (mag) { - if (fftScaleMode === "log") perData = applyLogScale(mag); - else if (fftScaleMode === "power") { - perData = new Float32Array(mag.length); - for (let j = 0; j < mag.length; j++) perData[j] = Math.sqrt(mag[j]); - } else perData = mag; - } - const dr = perData ? findDataRange(perData) : fftDataRange; - return ( - { setFftContrastFor(i, { vminPct: min, vmaxPct: max }); }} - width={110} height={58} - theme={themeInfo.theme === "dark" ? "dark" : "light"} - dataMin={dr.min} dataMax={dr.max} - /> - ); - })} - - ) : (() => { - const fc = fftContrastFor(selectedIdx); - return ( - { setFftContrastFor(selectedIdx, { vminPct: min, vmaxPct: max }); }} - width={110} height={58} - theme={themeInfo.theme === "dark" ? "dark" : "light"} - dataMin={fftDataRange.min} dataMax={fftDataRange.max} - /> - ); - })() - )} - - )} - - )} - - {/* Line profile sparkline — always reserve space when profile is active */} - {profileActive && ( - - -
{ - e.preventDefault(); - setIsResizingProfile(true); - setProfileResizeStart({ y: e.clientY, height: profileHeight }); - }} - style={{ width: profileCanvasWidth, height: 4, cursor: "ns-resize", borderLeft: `1px solid ${themeColors.border}`, borderRight: `1px solid ${themeColors.border}`, borderBottom: `1px solid ${themeColors.border}`, background: `linear-gradient(to bottom, ${themeColors.border}, transparent)`, opacity: 1, pointerEvents: "auto" }} - /> - - )} - - {/* Controls: two rows left + histogram right, ROI below */} - {showControls && ( - - {/* Top: control rows + histogram side by side */} - - - {/* Row 1: Scale + Color */} - {( - - Scale: - - Color: - - {!isGallery && ( - <> - Colorbar: - { setShowColorbar(!showColorbar); }} size="small" sx={switchStyles.small} /> - - )} - - )} - {/* Row 2: Auto + Lens settings + Link Zoom (gallery) + zoom indicator */} - {( - - Auto: - { setAutoContrast(!autoContrast); }} size="small" sx={switchStyles.small} /> - Smooth: - { setSmooth(!smooth); }} size="small" sx={switchStyles.small} /> - {!isGallery && showLens && ( - <> - Lens {lensMag}× - setLensMag(v as number)} size="small" sx={{ ...sliderStyles.small, width: 35 }} /> - {lensDisplaySize}px - setLensDisplaySize(v as number)} size="small" sx={{ ...sliderStyles.small, width: 35 }} /> - - )} - {isGallery && ( - <> - Link: - Zoom - { setLinkedZoom(!linkedZoom); }} size="small" sx={switchStyles.small} /> - Pan - { setLinkPan(!linkPan); }} size="small" sx={switchStyles.small} /> - Contrast - { setLinkedContrast(!linkedContrast); }} size="small" sx={switchStyles.small} /> - - )} - {getZoomState(isGallery ? selectedIdx : 0).zoom !== 1 && ( - {getZoomState(isGallery ? selectedIdx : 0).zoom.toFixed(1)}x - )} - - )} - - {/* Right: histograms. Unlinked + gallery → grid matching gallery layout - (same effectiveNcols × rows). Linked or single image → one histogram. */} - {(imageHistogramData || imageHistogramBins || (isGallery && !linkedContrast && rawDataRef.current)) && ( - - {(!linkedContrast && isGallery && rawDataRef.current) ? ( - - {Array.from({ length: nImages }).map((_, i) => { - const cs = contrastStates.get(i) || { vminPct: 0, vmaxPct: 100 }; - const raw = rawDataRef.current?.[i] || null; - return ( - { setContrastState(i, { vminPct: min, vmaxPct: max }); }} - width={110} height={58} theme={themeInfo.theme === "dark" ? "dark" : "light"} - dataMin={dataRangesRef.current[i]?.min ?? imageDataRange.min} - dataMax={dataRangesRef.current[i]?.max ?? imageDataRange.max} /> - ); - })} - - ) : ( - { setContrastState(activeContrastIdx, { vminPct: min, vmaxPct: max }); }} width={110} height={58} theme={themeInfo.theme === "dark" ? "dark" : "light"} dataMin={traitVmin != null && traitVmax != null ? (logScale ? Math.log1p(Math.max(traitVmin, 0)) : traitVmin) : imageDataRange.min} dataMax={traitVmin != null && traitVmax != null ? (logScale ? Math.log1p(Math.max(traitVmax, 0)) : traitVmax) : imageDataRange.max} /> - )} - - )} - - {/* ROI Section (own box, below control rows) */} - {roiActive && ( - - {/* ROI: shape + ADD + CLEAR */} - - ROI: - - - - - - {/* Selected ROI details */} - {selectedRoi && ( - - #{roiSelectedIdx + 1}/{roiList?.length ?? 0} - - {selectedRoi.shape === "rectangle" && ( - <> - W - updateSelectedRoi({ width: v as number })} size="small" sx={{ ...sliderStyles.small, width: 40 }} /> - H - updateSelectedRoi({ height: v as number })} size="small" sx={{ ...sliderStyles.small, width: 40 }} /> - - )} - {selectedRoi.shape === "annular" && ( - <> - Inner - updateSelectedRoi({ radius_inner: v as number })} size="small" sx={{ ...sliderStyles.small, width: 40 }} /> - Outer - updateSelectedRoi({ radius: v as number })} size="small" sx={{ ...sliderStyles.small, width: 40 }} /> - - )} - {selectedRoi.shape !== "rectangle" && selectedRoi.shape !== "annular" && ( - <> - Size - updateSelectedRoi({ radius: v as number })} size="small" sx={{ ...sliderStyles.small, width: 50 }} /> - - )} - - {ROI_COLORS.map(c => ( - updateSelectedRoi({ color: c })} sx={{ width: 12, height: 12, bgcolor: c, cursor: "pointer", border: c === selectedRoi.color ? `2px solid ${themeColors.text}` : "1px solid transparent", "&:hover": { opacity: 0.8 } }} /> - ))} - - Border - updateSelectedRoi({ line_width: v as number })} size="small" sx={{ ...sliderStyles.small, width: 30 }} /> - updateSelectedRoi({ highlight: !selectedRoi.highlight })} - sx={{ cursor: "pointer", fontSize: 10, color: selectedRoi.highlight ? themeColors.accentGreen : themeColors.textMuted, "&:hover": { opacity: 0.8 } }} - title="Focus (dim outside)" - >{selectedRoi.highlight ? "\u25C9 Focus" : "\u25CB Focus"} - - - )} - {/* ROI list */} - {roiList && roiList.length > 0 && ( - - {roiList.map((roi, i) => { - const c = roi.color || ROI_COLORS[i % ROI_COLORS.length]; - const isSelected = i === roiSelectedIdx; - const shapeLabel = roi.shape === "rectangle" ? `${roi.width}×${roi.height}` : roi.shape === "annular" ? `r${roi.radius_inner}-${roi.radius}` : `r${roi.radius}`; - return ( - setRoiSelectedIdx(i)} sx={{ display: "flex", alignItems: "center", gap: "3px", lineHeight: 1.6, cursor: "pointer", "&:hover .roi-delete": { opacity: 1 } }}> - - - {i + 1}{" "} - {roi.shape} ({roi.row}, {roi.col}) {shapeLabel} - - { e.stopPropagation(); const newList = roiList.map((r, j) => ({ ...r, highlight: j === i ? !r.highlight : false })); setRoiList(newList); }} - sx={{ cursor: "pointer", fontSize: 10, color: roi.highlight ? themeColors.accentGreen : themeColors.textMuted, lineHeight: 1, opacity: roi.highlight ? 1 : 0.5, "&:hover": { opacity: 1 } }} - title="Focus (dim outside)" - >{roi.highlight ? "\u25C9" : "\u25CB"} - { e.stopPropagation(); const newList = roiList.filter((_, j) => j !== i); setRoiList(newList); setRoiSelectedIdx(newList.length > 0 ? Math.min(roiSelectedIdx, newList.length - 1) : -1); }} - sx={{ opacity: 0, cursor: "pointer", fontSize: 10, color: themeColors.textMuted, ml: 0.5, lineHeight: 1, "&:hover": { color: "#f44336" } }} - >× - - ); - })} - - )} - - )} - - )} - - - {/* FFT Panel - canvas + stats (single mode only) */} - {effectiveShowFft && !isGallery && ( - - {/* Spacer — matches main panel title row height for canvas alignment */} - - {/* Controls row — matches main panel controls row height */} - - {fftComputing ? ( - - {fftProgress || "Computing FFT…"} - ) : roiFftActive && fftCropDims ? ( - - ROI FFT ({fftCropDims.cropWidth}×{fftCropDims.cropHeight}) - - ) : } - {( - - )} - - - - - {fftComputing && ( - - - {fftProgress || "Computing FFT…"} - - - )} - {( - - )} - - {/* FFT Stats Bar */} - {fftStats && fftStats.length === 4 && ( - - Mean {formatNumber(fftStats[0])} - Min {formatNumber(fftStats[1])} - Max {formatNumber(fftStats[2])} - Std {formatNumber(fftStats[3])} - {fftClickInfo && ( - <> - - - {fftClickInfo.dSpacing != null ? ( - <>d = {fftClickInfo.dSpacing >= 10 ? `${(fftClickInfo.dSpacing / 10).toFixed(2)} nm` : `${fftClickInfo.dSpacing.toFixed(2)} Å`}{" | |g| = "}{fftClickInfo.spatialFreq!.toFixed(4)} Å⁻¹ - ) : ( - <>dist = {fftClickInfo.distPx.toFixed(1)} px - )} - - - )} - - )} - {/* FFT Controls - two rows + histogram (matching main panel layout) */} - - - - {/* Row 1: Scale + Color + Colorbar */} - - Scale: - - Color: - - Colorbar: - { setFftShowColorbar(e.target.checked); }} size="small" sx={switchStyles.small} /> - - {/* Row 2: Auto + zoom indicator */} - - Auto: - { setFftAuto(e.target.checked); }} size="small" sx={switchStyles.small} /> - {fftCropDims && ( - <> - Win: - { setFftWindow(e.target.checked); }} size="small" sx={switchStyles.small} /> - - )} - {fftZoom !== DEFAULT_FFT_ZOOM && ( - {fftZoom.toFixed(1)}x - )} - - - {/* Right: FFT Histogram */} - {( - - {fftHistogramData && ( - { setFftVminPct(min); setFftVmaxPct(max); }} width={110} height={58} theme={themeInfo.theme === "dark" ? "dark" : "light"} dataMin={fftDataRange.min} dataMax={fftDataRange.max} /> - )} - - )} - - - - )} - - - ); -} - -export const render = createRender(Show2D); diff --git a/widget/js/show4dstem/index.tsx b/widget/js/show4dstem/index.tsx deleted file mode 100644 index 2a78455b7..000000000 --- a/widget/js/show4dstem/index.tsx +++ /dev/null @@ -1,4066 +0,0 @@ -/// -import * as React from "react"; -import { createRender, useModelState, useModel } from "@anywidget/react"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import Stack from "@mui/material/Stack"; -import Select from "@mui/material/Select"; -import MenuItem from "@mui/material/MenuItem"; -import Menu from "@mui/material/Menu"; -import Slider from "@mui/material/Slider"; -import Button from "@mui/material/Button"; -import Switch from "@mui/material/Switch"; -import Tooltip from "@mui/material/Tooltip"; -import IconButton from "@mui/material/IconButton"; -import PlayArrowIcon from "@mui/icons-material/PlayArrow"; -import PauseIcon from "@mui/icons-material/Pause"; -import StopIcon from "@mui/icons-material/Stop"; -import FastRewindIcon from "@mui/icons-material/FastRewind"; -import FastForwardIcon from "@mui/icons-material/FastForward"; -import JSZip from "jszip"; -import { useTheme } from "../theme"; -import { COLORMAPS, applyColormap, renderToOffscreen } from "../colormaps"; -import { WebGPUFFT, getWebGPUFFT, fft2d, fftshift, autoEnhanceFFT, nextPow2, applyHannWindow2D } from "../fft"; -import { drawScaleBarHiDPI, drawColorbar, roundToNiceValue, exportFigure, canvasToPDF } from "../figure"; -import { findDataRange, sliderRange, computeStats, applyLogScale, computeHistogramFromBytes, percentileClip } from "../stats"; -import { downloadBlob, formatNumber, downloadDataView } from "../format"; - -const MIN_ZOOM = 0.5; -const MAX_ZOOM = 10; - -// ============================================================================ -// UI Styles - component styling helpers -// ============================================================================ -const typography = { - label: { fontSize: 11 }, - labelSmall: { fontSize: 10 }, - value: { fontSize: 10, fontFamily: "monospace" }, - title: { fontWeight: "bold" as const }, -}; - -const controlPanel = { - select: { minWidth: 90, fontSize: 11, "& .MuiSelect-select": { py: 0.5 } }, -}; - -const container = { - root: { p: 2, bgcolor: "transparent", color: "inherit", fontFamily: "monospace", overflow: "visible" }, - imageBox: { bgcolor: "#000", border: "1px solid #444", overflow: "hidden", position: "relative" as const }, -}; - -const upwardMenuProps = { - anchorOrigin: { vertical: "top" as const, horizontal: "left" as const }, - transformOrigin: { vertical: "bottom" as const, horizontal: "left" as const }, - sx: { zIndex: 9999 }, -}; - -const switchStyles = { - small: { '& .MuiSwitch-thumb': { width: 12, height: 12 }, '& .MuiSwitch-switchBase': { padding: '4px' } }, - medium: { '& .MuiSwitch-thumb': { width: 14, height: 14 }, '& .MuiSwitch-switchBase': { padding: '4px' } }, -}; - -const sliderStyles = { - small: { - "& .MuiSlider-thumb": { width: 12, height: 12 }, - "& .MuiSlider-rail": { height: 3 }, - "& .MuiSlider-track": { height: 3 }, - }, -}; - -// ============================================================================ -// Layout Constants - consistent spacing throughout -// ============================================================================ -const SPACING = { - XS: 4, // Extra small gap - SM: 8, // Small gap (default between elements) - MD: 12, // Medium gap (between control groups) - LG: 16, // Large gap (between major sections) -}; - -const CANVAS_SIZE = 450; // Both DP and VI canvases - -// Theme-aware ROI colors for DP detector overlay -interface RoiColors { - stroke: string; - strokeDragging: string; - fill: string; - fillDragging: string; - handleFill: string; - innerStroke: string; - innerStrokeDragging: string; - innerHandleFill: string; - textColor: string; -} -const DARK_ROI_COLORS: RoiColors = { - stroke: "rgba(0, 255, 0, 0.9)", - strokeDragging: "rgba(255, 255, 0, 0.9)", - fill: "rgba(0, 255, 0, 0.12)", - fillDragging: "rgba(255, 255, 0, 0.12)", - handleFill: "rgba(0, 255, 0, 0.8)", - innerStroke: "rgba(0, 220, 255, 0.9)", - innerStrokeDragging: "rgba(255, 200, 0, 0.9)", - innerHandleFill: "rgba(0, 220, 255, 0.8)", - textColor: "#0f0", -}; -const LIGHT_ROI_COLORS: RoiColors = { - stroke: "rgba(0, 140, 0, 0.9)", - strokeDragging: "rgba(200, 160, 0, 0.9)", - fill: "rgba(0, 140, 0, 0.15)", - fillDragging: "rgba(200, 160, 0, 0.15)", - handleFill: "rgba(0, 140, 0, 0.85)", - innerStroke: "rgba(0, 160, 200, 0.9)", - innerStrokeDragging: "rgba(200, 160, 0, 0.9)", - innerHandleFill: "rgba(0, 160, 200, 0.85)", - textColor: "#0a0", -}; - -// Interaction constants -const RESIZE_HIT_AREA_PX = 10; -const CIRCLE_HANDLE_ANGLE = 0.707; // cos(45°) -// Compact button style for Reset/Export -const compactButton = { - fontSize: 10, - py: 0.25, - px: 1, - minWidth: 0, - "&.Mui-disabled": { - color: "#666", - borderColor: "#444", - }, -}; - -// Control row style — bordered container per row. -const controlRow = { - display: "flex", - alignItems: "center", - gap: `${SPACING.SM}px`, - px: 1, - py: 0.5, - width: "fit-content", -}; - -/** Format stat value for display (compact scientific notation for small values) */ -function formatStat(value: number): string { - if (value === 0) return "0"; - const abs = Math.abs(value); - if (abs < 0.001 || abs >= 10000) { - return value.toExponential(2); - } - if (abs < 0.01) return value.toFixed(4); - if (abs < 1) return value.toFixed(3); - return value.toFixed(2); -} - - -// ============================================================================ -// FFT peak finder (snap to Bragg spot with sub-pixel centroid refinement) -// ============================================================================ -function findFFTPeak(mag: Float32Array, width: number, height: number, col: number, row: number, radius: number): { row: number; col: number } { - const c0 = Math.max(0, Math.floor(col) - radius); - const r0 = Math.max(0, Math.floor(row) - radius); - const c1 = Math.min(width - 1, Math.floor(col) + radius); - const r1 = Math.min(height - 1, Math.floor(row) + radius); - let bestCol = Math.round(col), bestRow = Math.round(row), bestVal = -Infinity; - for (let ir = r0; ir <= r1; ir++) { - for (let ic = c0; ic <= c1; ic++) { - const val = mag[ir * width + ic]; - if (val > bestVal) { bestVal = val; bestCol = ic; bestRow = ir; } - } - } - const wc0 = Math.max(0, bestCol - 1), wc1 = Math.min(width - 1, bestCol + 1); - const wr0 = Math.max(0, bestRow - 1), wr1 = Math.min(height - 1, bestRow + 1); - let sumW = 0, sumWC = 0, sumWR = 0; - for (let ir = wr0; ir <= wr1; ir++) { - for (let ic = wc0; ic <= wc1; ic++) { - const w = mag[ir * width + ic]; - sumW += w; sumWC += w * ic; sumWR += w * ir; - } - } - if (sumW > 0) return { row: sumWR / sumW, col: sumWC / sumW }; - return { row: bestRow, col: bestCol }; -} -const FFT_SNAP_RADIUS = 5; - -/** - * Draw VI crosshair on high-DPI canvas (crisp regardless of image resolution) - * Note: Does NOT clear canvas - should be called after drawScaleBarHiDPI - */ -function drawViPositionMarker( - canvas: HTMLCanvasElement, - dpr: number, - posRow: number, // Position in image coordinates - posCol: number, - zoom: number, - panX: number, - panY: number, - imageWidth: number, - imageHeight: number, - isDragging: boolean -) { - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - ctx.save(); - ctx.scale(dpr, dpr); - - const cssWidth = canvas.width / dpr; - const cssHeight = canvas.height / dpr; - const scaleX = cssWidth / imageWidth; - const scaleY = cssHeight / imageHeight; - - // Convert image coordinates to CSS pixel coordinates - const screenX = posCol * zoom * scaleX + panX * scaleX; - const screenY = posRow * zoom * scaleY + panY * scaleY; - - // Simple crosshair (no circle) - const crosshairSize = 12; - const lineWidth = 1.5; - - ctx.shadowColor = "rgba(0, 0, 0, 0.5)"; - ctx.shadowBlur = 2; - ctx.shadowOffsetX = 1; - ctx.shadowOffsetY = 1; - - ctx.strokeStyle = isDragging ? "rgba(255, 255, 0, 0.9)" : "rgba(255, 100, 100, 0.9)"; - ctx.lineWidth = lineWidth; - - // Draw crosshair lines only - ctx.beginPath(); - ctx.moveTo(screenX - crosshairSize, screenY); - ctx.lineTo(screenX + crosshairSize, screenY); - ctx.moveTo(screenX, screenY - crosshairSize); - ctx.lineTo(screenX, screenY + crosshairSize); - ctx.stroke(); - - ctx.restore(); -} - -/** - * Draw VI ROI overlay on high-DPI canvas for real-space region selection - * Note: Does NOT clear canvas - should be called after drawViPositionMarker - */ -function drawViRoiOverlayHiDPI( - canvas: HTMLCanvasElement, - dpr: number, - roiMode: string, - centerRow: number, - centerCol: number, - radius: number, - roiWidth: number, - roiHeight: number, - zoom: number, - panX: number, - panY: number, - imageWidth: number, - imageHeight: number, - isDragging: boolean, - isDraggingResize: boolean, - isHoveringResize: boolean -) { - if (roiMode === "off") return; - - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - ctx.save(); - ctx.scale(dpr, dpr); - - const cssWidth = canvas.width / dpr; - const cssHeight = canvas.height / dpr; - const scaleX = cssWidth / imageWidth; - const scaleY = cssHeight / imageHeight; - - // Convert image coordinates to screen coordinates (row→screenY, col→screenX) - const screenX = centerCol * zoom * scaleX + panX * scaleX; - const screenY = centerRow * zoom * scaleY + panY * scaleY; - - const lineWidth = 2.5; - const crosshairSize = 10; - const handleRadius = 6; - - ctx.shadowColor = "rgba(0, 0, 0, 0.4)"; - ctx.shadowBlur = 2; - ctx.shadowOffsetX = 1; - ctx.shadowOffsetY = 1; - - // Helper to draw resize handle (purple color for VI ROI to differentiate from DP) - const drawResizeHandle = (handleX: number, handleY: number) => { - let handleFill: string; - let handleStroke: string; - - if (isDraggingResize) { - handleFill = "rgba(180, 100, 255, 1)"; - handleStroke = "rgba(255, 255, 255, 1)"; - } else if (isHoveringResize) { - handleFill = "rgba(220, 150, 255, 1)"; - handleStroke = "rgba(255, 255, 255, 1)"; - } else { - handleFill = "rgba(160, 80, 255, 0.8)"; - handleStroke = "rgba(255, 255, 255, 0.8)"; - } - ctx.beginPath(); - ctx.arc(handleX, handleY, handleRadius, 0, 2 * Math.PI); - ctx.fillStyle = handleFill; - ctx.fill(); - ctx.strokeStyle = handleStroke; - ctx.lineWidth = 1.5; - ctx.stroke(); - }; - - // Helper to draw center crosshair (purple/magenta for VI ROI) - const drawCenterCrosshair = () => { - ctx.strokeStyle = isDragging ? "rgba(255, 200, 0, 0.9)" : "rgba(180, 80, 255, 0.9)"; - ctx.lineWidth = lineWidth; - ctx.beginPath(); - ctx.moveTo(screenX - crosshairSize, screenY); - ctx.lineTo(screenX + crosshairSize, screenY); - ctx.moveTo(screenX, screenY - crosshairSize); - ctx.lineTo(screenX, screenY + crosshairSize); - ctx.stroke(); - }; - - // Purple/magenta color for VI ROI to differentiate from green DP detector - const strokeColor = isDragging ? "rgba(255, 200, 0, 0.9)" : "rgba(180, 80, 255, 0.9)"; - const fillColor = isDragging ? "rgba(255, 200, 0, 0.15)" : "rgba(180, 80, 255, 0.15)"; - - if (roiMode === "circle" && radius > 0) { - const screenRadiusX = radius * zoom * scaleX; - const screenRadiusY = radius * zoom * scaleY; - - ctx.strokeStyle = strokeColor; - ctx.lineWidth = lineWidth; - ctx.beginPath(); - ctx.ellipse(screenX, screenY, screenRadiusX, screenRadiusY, 0, 0, 2 * Math.PI); - ctx.stroke(); - - ctx.fillStyle = fillColor; - ctx.fill(); - - drawCenterCrosshair(); - - // Resize handle at 45° diagonal - const handleOffsetX = screenRadiusX * CIRCLE_HANDLE_ANGLE; - const handleOffsetY = screenRadiusY * CIRCLE_HANDLE_ANGLE; - drawResizeHandle(screenX + handleOffsetX, screenY + handleOffsetY); - - } else if (roiMode === "square" && radius > 0) { - // Square uses radius as half-size - const screenHalfW = radius * zoom * scaleX; - const screenHalfH = radius * zoom * scaleY; - const left = screenX - screenHalfW; - const top = screenY - screenHalfH; - - ctx.strokeStyle = strokeColor; - ctx.lineWidth = lineWidth; - ctx.beginPath(); - ctx.rect(left, top, screenHalfW * 2, screenHalfH * 2); - ctx.stroke(); - - ctx.fillStyle = fillColor; - ctx.fill(); - - drawCenterCrosshair(); - drawResizeHandle(screenX + screenHalfW, screenY + screenHalfH); - - } else if (roiMode === "rect" && roiWidth > 0 && roiHeight > 0) { - const screenHalfW = (roiWidth / 2) * zoom * scaleX; - const screenHalfH = (roiHeight / 2) * zoom * scaleY; - const left = screenX - screenHalfW; - const top = screenY - screenHalfH; - - ctx.strokeStyle = strokeColor; - ctx.lineWidth = lineWidth; - ctx.beginPath(); - ctx.rect(left, top, screenHalfW * 2, screenHalfH * 2); - ctx.stroke(); - - ctx.fillStyle = fillColor; - ctx.fill(); - - drawCenterCrosshair(); - drawResizeHandle(screenX + screenHalfW, screenY + screenHalfH); - } - - ctx.restore(); -} - -/** - * Draw DP crosshair on high-DPI canvas (crisp regardless of detector resolution) - * Note: Does NOT clear canvas - should be called after drawScaleBarHiDPI - */ -function drawDpCrosshairHiDPI( - canvas: HTMLCanvasElement, - dpr: number, - kCol: number, // Column position in detector coordinates - kRow: number, // Row position in detector coordinates - zoom: number, - panX: number, - panY: number, - detWidth: number, - detHeight: number, - isDragging: boolean, - roiColors: RoiColors = DARK_ROI_COLORS -) { - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - ctx.save(); - ctx.scale(dpr, dpr); - - const cssWidth = canvas.width / dpr; - const cssHeight = canvas.height / dpr; - // Use separate X/Y scale factors (canvas stretches to fill container) - const scaleX = cssWidth / detWidth; - const scaleY = cssHeight / detHeight; - - // Convert detector coordinates to CSS pixel coordinates - const screenX = kCol * zoom * scaleX + panX * scaleX; - const screenY = kRow * zoom * scaleY + panY * scaleY; - - // Fixed UI sizes in CSS pixels (consistent with VI crosshair) - const crosshairSize = 18; - const lineWidth = 3; - const dotRadius = 6; - - ctx.shadowColor = "rgba(0, 0, 0, 0.5)"; - ctx.shadowBlur = 2; - ctx.shadowOffsetX = 1; - ctx.shadowOffsetY = 1; - - ctx.strokeStyle = isDragging ? roiColors.strokeDragging : roiColors.stroke; - ctx.lineWidth = lineWidth; - - // Draw crosshair - ctx.beginPath(); - ctx.moveTo(screenX - crosshairSize, screenY); - ctx.lineTo(screenX + crosshairSize, screenY); - ctx.moveTo(screenX, screenY - crosshairSize); - ctx.lineTo(screenX, screenY + crosshairSize); - ctx.stroke(); - - // Draw center dot - ctx.beginPath(); - ctx.arc(screenX, screenY, dotRadius, 0, 2 * Math.PI); - ctx.stroke(); - - ctx.restore(); -} - -/** - * Draw ROI overlay (circle, square, rect, annular) on high-DPI canvas - * Note: Does NOT clear canvas - should be called after drawScaleBarHiDPI - */ -function drawRoiOverlayHiDPI( - canvas: HTMLCanvasElement, - dpr: number, - roiMode: string, - centerCol: number, - centerRow: number, - radius: number, - radiusInner: number, - roiWidth: number, - roiHeight: number, - zoom: number, - panX: number, - panY: number, - detWidth: number, - detHeight: number, - isDragging: boolean, - isDraggingResize: boolean, - isDraggingResizeInner: boolean, - isHoveringResize: boolean, - isHoveringResizeInner: boolean, - roiColors: RoiColors = DARK_ROI_COLORS -) { - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - ctx.save(); - ctx.scale(dpr, dpr); - - const cssWidth = canvas.width / dpr; - const cssHeight = canvas.height / dpr; - // Use separate X/Y scale factors (canvas stretches to fill container) - const scaleX = cssWidth / detWidth; - const scaleY = cssHeight / detHeight; - - // Convert detector coordinates to CSS pixel coordinates - const screenX = centerCol * zoom * scaleX + panX * scaleX; - const screenY = centerRow * zoom * scaleY + panY * scaleY; - - // Fixed UI sizes in CSS pixels - const lineWidth = 2.5; - const crosshairSizeSmall = 10; - const handleRadius = 6; - - ctx.shadowColor = "rgba(0, 0, 0, 0.4)"; - ctx.shadowBlur = 2; - ctx.shadowOffsetX = 1; - ctx.shadowOffsetY = 1; - - // Helper to draw resize handle - const drawResizeHandle = (handleX: number, handleY: number, isInner: boolean = false) => { - let handleFill: string; - let handleStroke: string; - const dragging = isInner ? isDraggingResizeInner : isDraggingResize; - const hovering = isInner ? isHoveringResizeInner : isHoveringResize; - - if (dragging) { - handleFill = "rgba(0, 200, 255, 1)"; - handleStroke = "rgba(255, 255, 255, 1)"; - } else if (hovering) { - handleFill = "rgba(255, 100, 100, 1)"; - handleStroke = "rgba(255, 255, 255, 1)"; - } else { - handleFill = isInner ? roiColors.innerHandleFill : roiColors.handleFill; - handleStroke = "rgba(255, 255, 255, 0.8)"; - } - ctx.beginPath(); - ctx.arc(handleX, handleY, handleRadius, 0, 2 * Math.PI); - ctx.fillStyle = handleFill; - ctx.fill(); - ctx.strokeStyle = handleStroke; - ctx.lineWidth = 1.5; - ctx.stroke(); - }; - - // Helper to draw center crosshair - const drawCenterCrosshair = () => { - ctx.strokeStyle = isDragging ? roiColors.strokeDragging : roiColors.stroke; - ctx.lineWidth = lineWidth; - ctx.beginPath(); - ctx.moveTo(screenX - crosshairSizeSmall, screenY); - ctx.lineTo(screenX + crosshairSizeSmall, screenY); - ctx.moveTo(screenX, screenY - crosshairSizeSmall); - ctx.lineTo(screenX, screenY + crosshairSizeSmall); - ctx.stroke(); - }; - - if (roiMode === "circle" && radius > 0) { - // Use separate X/Y radii for ellipse (handles non-square detectors) - const screenRadiusX = radius * zoom * scaleX; - const screenRadiusY = radius * zoom * scaleY; - - // Draw ellipse (becomes circle if scaleX === scaleY) - ctx.strokeStyle = isDragging ? roiColors.strokeDragging : roiColors.stroke; - ctx.lineWidth = lineWidth; - ctx.beginPath(); - ctx.ellipse(screenX, screenY, screenRadiusX, screenRadiusY, 0, 0, 2 * Math.PI); - ctx.stroke(); - - // Semi-transparent fill - ctx.fillStyle = isDragging ? roiColors.fillDragging : roiColors.fill; - ctx.fill(); - - drawCenterCrosshair(); - - // Resize handle at 45° diagonal - const handleOffsetX = screenRadiusX * CIRCLE_HANDLE_ANGLE; - const handleOffsetY = screenRadiusY * CIRCLE_HANDLE_ANGLE; - drawResizeHandle(screenX + handleOffsetX, screenY + handleOffsetY); - - } else if (roiMode === "square" && radius > 0) { - // Square in detector space uses same half-size in both dimensions - const screenHalfW = radius * zoom * scaleX; - const screenHalfH = radius * zoom * scaleY; - const left = screenX - screenHalfW; - const top = screenY - screenHalfH; - - ctx.strokeStyle = isDragging ? roiColors.strokeDragging : roiColors.stroke; - ctx.lineWidth = lineWidth; - ctx.beginPath(); - ctx.rect(left, top, screenHalfW * 2, screenHalfH * 2); - ctx.stroke(); - - ctx.fillStyle = isDragging ? roiColors.fillDragging : roiColors.fill; - ctx.fill(); - - drawCenterCrosshair(); - drawResizeHandle(screenX + screenHalfW, screenY + screenHalfH); - - } else if (roiMode === "rect" && roiWidth > 0 && roiHeight > 0) { - const screenHalfW = (roiWidth / 2) * zoom * scaleX; - const screenHalfH = (roiHeight / 2) * zoom * scaleY; - const left = screenX - screenHalfW; - const top = screenY - screenHalfH; - - ctx.strokeStyle = isDragging ? roiColors.strokeDragging : roiColors.stroke; - ctx.lineWidth = lineWidth; - ctx.beginPath(); - ctx.rect(left, top, screenHalfW * 2, screenHalfH * 2); - ctx.stroke(); - - ctx.fillStyle = isDragging ? roiColors.fillDragging : roiColors.fill; - ctx.fill(); - - drawCenterCrosshair(); - drawResizeHandle(screenX + screenHalfW, screenY + screenHalfH); - - } else if (roiMode === "annular" && radius > 0) { - // Use separate X/Y radii for ellipses - const screenRadiusOuterX = radius * zoom * scaleX; - const screenRadiusOuterY = radius * zoom * scaleY; - const screenRadiusInnerX = (radiusInner || 0) * zoom * scaleX; - const screenRadiusInnerY = (radiusInner || 0) * zoom * scaleY; - - // Outer ellipse - ctx.strokeStyle = isDragging ? roiColors.strokeDragging : roiColors.stroke; - ctx.lineWidth = lineWidth; - ctx.beginPath(); - ctx.ellipse(screenX, screenY, screenRadiusOuterX, screenRadiusOuterY, 0, 0, 2 * Math.PI); - ctx.stroke(); - - // Inner ellipse - ctx.strokeStyle = isDragging ? roiColors.innerStrokeDragging : roiColors.innerStroke; - ctx.beginPath(); - ctx.ellipse(screenX, screenY, screenRadiusInnerX, screenRadiusInnerY, 0, 0, 2 * Math.PI); - ctx.stroke(); - - // Fill annular region - ctx.fillStyle = isDragging ? roiColors.fillDragging : roiColors.fill; - ctx.beginPath(); - ctx.ellipse(screenX, screenY, screenRadiusOuterX, screenRadiusOuterY, 0, 0, 2 * Math.PI); - ctx.ellipse(screenX, screenY, screenRadiusInnerX, screenRadiusInnerY, 0, 0, 2 * Math.PI, true); - ctx.fill(); - - drawCenterCrosshair(); - - // Outer handle at 45° diagonal - const handleOffsetOuterX = screenRadiusOuterX * CIRCLE_HANDLE_ANGLE; - const handleOffsetOuterY = screenRadiusOuterY * CIRCLE_HANDLE_ANGLE; - drawResizeHandle(screenX + handleOffsetOuterX, screenY + handleOffsetOuterY); - - // Inner handle at 45° diagonal - const handleOffsetInnerX = screenRadiusInnerX * CIRCLE_HANDLE_ANGLE; - const handleOffsetInnerY = screenRadiusInnerY * CIRCLE_HANDLE_ANGLE; - drawResizeHandle(screenX + handleOffsetInnerX, screenY + handleOffsetInnerY, true); - } - - ctx.restore(); -} - -// ============================================================================ -// Histogram Component -// ============================================================================ - -interface HistogramProps { - data: Float32Array | null; - vminPct: number; - vmaxPct: number; - onRangeChange: (min: number, max: number) => void; - width?: number; - height?: number; - theme?: "light" | "dark"; - dataMin?: number; - dataMax?: number; -} - -/** - * Info tooltip component - small ⓘ icon with hover tooltip - */ -function InfoTooltip({ text, theme = "dark" }: { text: React.ReactNode; theme?: "light" | "dark" }) { - const isDark = theme === "dark"; - const content = typeof text === "string" - ? {text} - : text; - return ( - - - ⓘ - - - ); -} - -function KeyboardShortcuts({ items }: { items: [string, string][] }) { - return ( - - - {items.map(([key, desc], i) => ( - {key}{desc} - ))} - - - ); -} - -/** - * Histogram component with integrated vmin/vmax slider and statistics. - * Shows data distribution with adjustable clipping. - */ -function Histogram({ - data, - vminPct, - vmaxPct, - onRangeChange, - width = 120, - height = 40, - theme = "dark", - dataMin = 0, - dataMax = 1, -}: HistogramProps) { - const canvasRef = React.useRef(null); - const bins = React.useMemo(() => computeHistogramFromBytes(data), [data]); - - // Theme-aware colors - const colors = theme === "dark" ? { - bg: "#1a1a1a", - barActive: "#888", - barInactive: "#444", - border: "#333", - } : { - bg: "#f0f0f0", - barActive: "#666", - barInactive: "#bbb", - border: "#ccc", - }; - - // Draw histogram (vertical gray bars) - React.useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const dpr = window.devicePixelRatio || 1; - canvas.width = width * dpr; - canvas.height = height * dpr; - ctx.scale(dpr, dpr); - - // Clear with theme background - ctx.fillStyle = colors.bg; - ctx.fillRect(0, 0, width, height); - - // Reduce to fewer bins for cleaner display - const displayBins = 64; - const binRatio = Math.floor(bins.length / displayBins); - const reducedBins: number[] = []; - for (let i = 0; i < displayBins; i++) { - let sum = 0; - for (let j = 0; j < binRatio; j++) { - sum += bins[i * binRatio + j] || 0; - } - reducedBins.push(sum / binRatio); - } - - // Normalize - const maxVal = Math.max(...reducedBins, 0.001); - const barWidth = width / displayBins; - - // Calculate which bins are in the clipped range - const vminBin = Math.floor((vminPct / 100) * displayBins); - const vmaxBin = Math.floor((vmaxPct / 100) * displayBins); - - // Draw histogram bars - for (let i = 0; i < displayBins; i++) { - const barHeight = (reducedBins[i] / maxVal) * (height - 2); - const x = i * barWidth; - - // Bars inside range are highlighted, outside are dimmed - const inRange = i >= vminBin && i <= vmaxBin; - ctx.fillStyle = inRange ? colors.barActive : colors.barInactive; - ctx.fillRect(x + 0.5, height - barHeight, Math.max(1, barWidth - 1), barHeight); - } - - }, [bins, vminPct, vmaxPct, width, height, colors]); - - return ( - - - { - const [newMin, newMax] = v as number[]; - onRangeChange(Math.min(newMin, newMax - 1), Math.max(newMax, newMin + 1)); - }} - min={0} - max={100} - size="small" - valueLabelDisplay="auto" - valueLabelFormat={(pct) => { - const val = dataMin + (pct / 100) * (dataMax - dataMin); - return val >= 1000 ? val.toExponential(1) : val.toFixed(1); - }} - sx={{ - width, - py: 0, - "& .MuiSlider-thumb": { width: 8, height: 8 }, - "& .MuiSlider-rail": { height: 2 }, - "& .MuiSlider-track": { height: 2 }, - "& .MuiSlider-valueLabel": { fontSize: 10, padding: "2px 4px" }, - }} - /> - {(() => { const v = dataMin + (vminPct / 100) * (dataMax - dataMin); return v >= 1000 ? v.toExponential(1) : v.toFixed(1); })()}{(() => { const v = dataMin + (vmaxPct / 100) * (dataMax - dataMin); return v >= 1000 ? v.toExponential(1) : v.toFixed(1); })()} - - ); -} - -// ============================================================================ -// Line Profile Sampling -// ============================================================================ - -function sampleSingleLine(data: Float32Array, w: number, h: number, row0: number, col0: number, row1: number, col1: number): Float32Array { - const dc = col1 - col0; - const dr = row1 - row0; - const len = Math.sqrt(dc * dc + dr * dr); - const n = Math.max(2, Math.ceil(len)); - const out = new Float32Array(n); - for (let i = 0; i < n; i++) { - const t = i / (n - 1); - const c = col0 + t * dc; - const r = row0 + t * dr; - const ci = Math.floor(c), ri = Math.floor(r); - const cf = c - ci, rf = r - ri; - const c0c = Math.max(0, Math.min(w - 1, ci)); - const c1c = Math.max(0, Math.min(w - 1, ci + 1)); - const r0c = Math.max(0, Math.min(h - 1, ri)); - const r1c = Math.max(0, Math.min(h - 1, ri + 1)); - out[i] = data[r0c * w + c0c] * (1 - cf) * (1 - rf) + - data[r0c * w + c1c] * cf * (1 - rf) + - data[r1c * w + c0c] * (1 - cf) * rf + - data[r1c * w + c1c] * cf * rf; - } - return out; -} - -function sampleLineProfile(data: Float32Array, w: number, h: number, row0: number, col0: number, row1: number, col1: number, profileWidth: number = 1): Float32Array { - if (profileWidth <= 1) return sampleSingleLine(data, w, h, row0, col0, row1, col1); - const dc = col1 - col0; - const dr = row1 - row0; - const len = Math.sqrt(dc * dc + dr * dr); - if (len < 1e-8) return sampleSingleLine(data, w, h, row0, col0, row1, col1); - const perpR = -dc / len; - const perpC = dr / len; - const half = (profileWidth - 1) / 2; - let accumulated: Float32Array | null = null; - for (let k = 0; k < profileWidth; k++) { - const off = -half + k; - const vals = sampleSingleLine(data, w, h, row0 + off * perpR, col0 + off * perpC, row1 + off * perpR, col1 + off * perpC); - if (!accumulated) { - accumulated = vals; - } else { - for (let i = 0; i < vals.length; i++) accumulated[i] += vals[i]; - } - } - if (accumulated) for (let i = 0; i < accumulated.length; i++) accumulated[i] /= profileWidth; - return accumulated || new Float32Array(0); -} - -function pointToSegmentDistance(col: number, row: number, col0: number, row0: number, col1: number, row1: number): number { - const dc = col1 - col0; - const dr = row1 - row0; - const lenSq = dc * dc + dr * dr; - if (lenSq <= 1e-12) return Math.sqrt((col - col0) ** 2 + (row - row0) ** 2); - const tRaw = ((col - col0) * dc + (row - row0) * dr) / lenSq; - const t = Math.max(0, Math.min(1, tRaw)); - const projCol = col0 + t * dc; - const projRow = row0 + t * dr; - return Math.sqrt((col - projCol) ** 2 + (row - projRow) ** 2); -} - -// ============================================================================ -// Crop single-mode ROI region from raw float32 data for ROI-scoped FFT -// ============================================================================ -function cropSingleROI( - data: Float32Array, imgW: number, imgH: number, - mode: string, centerRow: number, centerCol: number, - radius: number, roiW: number, roiH: number, -): { cropped: Float32Array; cropW: number; cropH: number } | null { - if (mode === "off") return null; - let x0: number, y0: number, x1: number, y1: number; - - if (mode === "rect") { - const hw = roiW / 2, hh = roiH / 2; - x0 = Math.max(0, Math.floor(centerCol - hw)); - y0 = Math.max(0, Math.floor(centerRow - hh)); - x1 = Math.min(imgW, Math.ceil(centerCol + hw)); - y1 = Math.min(imgH, Math.ceil(centerRow + hh)); - } else { - x0 = Math.max(0, Math.floor(centerCol - radius)); - y0 = Math.max(0, Math.floor(centerRow - radius)); - x1 = Math.min(imgW, Math.ceil(centerCol + radius)); - y1 = Math.min(imgH, Math.ceil(centerRow + radius)); - } - - const cropW = x1 - x0, cropH = y1 - y0; - if (cropW < 2 || cropH < 2) return null; - - const cropped = new Float32Array(cropW * cropH); - if (mode === "circle") { - const rSq = radius * radius; - for (let dy = 0; dy < cropH; dy++) { - for (let dx = 0; dx < cropW; dx++) { - const ix = x0 + dx, iy = y0 + dy; - const distSq = (ix - centerCol) * (ix - centerCol) + (iy - centerRow) * (iy - centerRow); - cropped[dy * cropW + dx] = distSq <= rSq ? data[iy * imgW + ix] : 0; - } - } - } else { - for (let dy = 0; dy < cropH; dy++) { - const srcOff = (y0 + dy) * imgW + x0; - cropped.set(data.subarray(srcOff, srcOff + cropW), dy * cropW); - } - } - return { cropped, cropW, cropH }; -} - -// ============================================================================ -// Main Component -// ============================================================================ -function Show4DSTEM() { - // Direct model access for batched updates - const model = useModel(); - - // ───────────────────────────────────────────────────────────────────────── - // Model State (synced with Python) - // ───────────────────────────────────────────────────────────────────────── - const [shapeRows] = useModelState("shape_rows"); - const [shapeCols] = useModelState("shape_cols"); - const [detRows] = useModelState("det_rows"); - const [detCols] = useModelState("det_cols"); - - const [posRow, setPosRow] = useModelState("pos_row"); - const [posCol, setPosCol] = useModelState("pos_col"); - const [roiCenterCol, setRoiCenterCol] = useModelState("roi_center_col"); - const [roiCenterRow, setRoiCenterRow] = useModelState("roi_center_row"); - const [pixelSize] = useModelState("pixel_size"); - const [pixelUnit] = useModelState("pixel_unit"); - const [kPixelSize] = useModelState("k_pixel_size"); - const [kPixelUnit] = useModelState("k_pixel_unit"); - const [kCalibrated] = useModelState("k_calibrated"); - const [widgetVersion] = useModelState("widget_version"); - const [title] = useModelState("title"); - - const [frameBytes] = useModelState("frame_bytes"); - const [virtualImageBytes] = useModelState("virtual_image_bytes"); - - // ROI state - const [roiRadius, setRoiRadius] = useModelState("roi_radius"); - const [roiRadiusInner, setRoiRadiusInner] = useModelState("roi_radius_inner"); - const [roiMode, setRoiMode] = useModelState("roi_mode"); - const [roiWidth, setRoiWidth] = useModelState("roi_width"); - const [roiHeight, setRoiHeight] = useModelState("roi_height"); - - // Global min/max for DP normalization (from Python) - const [dpGlobalMin] = useModelState("dp_global_min"); - const [dpGlobalMax] = useModelState("dp_global_max"); - - // VI min/max for normalization (from Python) - // viDataMin/viDataMax are derived JS-side from virtual_image_bytes (computed below). - // Keeping them out of Python traits avoids a comm-message ordering race where - // bytes from click N arrive with min/max from click N-1. - - // Detector calibration (for presets) - const [bfRadius] = useModelState("bf_radius"); - const [centerCol] = useModelState("center_col"); - const [centerRow] = useModelState("center_row"); - - // Path animation state - const [pathPlaying, setPathPlaying] = useModelState("path_playing"); - const [pathIndex, setPathIndex] = useModelState("path_index"); - const [pathLength] = useModelState("path_length"); - const [pathIntervalMs] = useModelState("path_interval_ms"); - const [pathLoop] = useModelState("path_loop"); - - // Frame animation state (5D time/tilt series) - const [frameIdx, setFrameIdx] = useModelState("frame_idx"); - const [nFrames] = useModelState("n_frames"); - const [frameDimLabel] = useModelState("frame_dim_label"); - const [frameLabels] = useModelState("frame_labels"); - const [framePlaying, setFramePlaying] = useModelState("frame_playing"); - const [frameLoop, setFrameLoop] = useModelState("frame_loop"); - const [frameFps, setFrameFps] = useModelState("frame_fps"); - const [frameReverse, setFrameReverse] = useModelState("frame_reverse"); - const [frameBoomerang, setFrameBoomerang] = useModelState("frame_boomerang"); - - // Profile line state (synced with Python) - const [profileLine, setProfileLine] = useModelState<{row: number; col: number}[]>("profile_line"); - const [profileWidth] = useModelState("profile_width"); - - // Auto-detection trigger - // ───────────────────────────────────────────────────────────────────────── - // Local State (UI-only, not synced to Python) - // ───────────────────────────────────────────────────────────────────────── - const [localKCol, setLocalKCol] = React.useState(roiCenterCol); - const [localKRow, setLocalKRow] = React.useState(roiCenterRow); - const [localPosRow, setLocalPosRow] = React.useState(posRow); - const [localPosCol, setLocalPosCol] = React.useState(posCol); - const [isDraggingDP, setIsDraggingDP] = React.useState(false); - // rAF coalescing for ROI drag: collapse rapid mousemove events into ≤1 - // Python comm message per animation frame. Without this, drag fires 60+ - // events/sec at >100ms Python compute each → queue piles up → laggy UX. - const roiCenterPendingRef = React.useRef<[number, number] | null>(null); - const roiCenterRafRef = React.useRef(null); - const flushRoiCenter = React.useCallback(() => { - if (roiCenterPendingRef.current) { - const [r, c] = roiCenterPendingRef.current; - model.set("roi_center", [r, c]); - model.save_changes(); - roiCenterPendingRef.current = null; - } - roiCenterRafRef.current = null; - }, [model]); - const queueRoiCenter = React.useCallback((row: number, col: number) => { - roiCenterPendingRef.current = [row, col]; - if (roiCenterRafRef.current === null) { - roiCenterRafRef.current = requestAnimationFrame(flushRoiCenter); - } - }, [flushRoiCenter]); - const [isDraggingVI, setIsDraggingVI] = React.useState(false); - const [isDraggingFFT, setIsDraggingFFT] = React.useState(false); - const [fftDragStart, setFftDragStart] = React.useState<{ x: number, y: number, panX: number, panY: number } | null>(null); - const [isDraggingResize, setIsDraggingResize] = React.useState(false); - const [isDraggingResizeInner, setIsDraggingResizeInner] = React.useState(false); // For annular inner handle - const [isHoveringResize, setIsHoveringResize] = React.useState(false); - const [isHoveringResizeInner, setIsHoveringResizeInner] = React.useState(false); - const resizeAspectRef = React.useRef(null); - // VI ROI drag/resize states (same pattern as DP) - const [isDraggingViRoi, setIsDraggingViRoi] = React.useState(false); - const [isDraggingViRoiResize, setIsDraggingViRoiResize] = React.useState(false); - const [isHoveringViRoiResize, setIsHoveringViRoiResize] = React.useState(false); - // Independent colormaps for DP and VI panels - const [showDpColorbar, setShowDpColorbar] = useModelState("dp_show_colorbar"); - const [dpColormap, setDpColormap] = useModelState("dp_colormap"); - const [viColormap, setViColormap] = useModelState("vi_colormap"); - // vmin/vmax percentile clipping (0-100) - const [dpVminPct, setDpVminPct] = useModelState("dp_vmin_pct"); - const [dpVmaxPct, setDpVmaxPct] = useModelState("dp_vmax_pct"); - const [viVminPct, setViVminPct] = useModelState("vi_vmin_pct"); - const [viVmaxPct, setViVmaxPct] = useModelState("vi_vmax_pct"); - // Absolute intensity bounds (override percentile sliders when both set) - const [traitDpVmin] = useModelState("dp_vmin"); - const [traitDpVmax] = useModelState("dp_vmax"); - const [traitViVmin] = useModelState("vi_vmin"); - const [traitViVmax] = useModelState("vi_vmax"); - // Scale mode: "linear" | "log" - const [dpScaleMode, setDpScaleMode] = useModelState<"linear" | "log">("dp_scale_mode"); - const [viScaleMode, setViScaleMode] = useModelState<"linear" | "log">("vi_scale_mode"); - // VI auto-contrast (1st/99th percentile clip) + Smooth (CSS bilinear blit). - // DP doesn't need them — Bragg spots read best with the slider's percentile - // range and nearest-neighbor blit. - const [viAutoContrast, setViAutoContrast] = useModelState("vi_auto_contrast"); - const [viSmooth, setViSmooth] = useModelState("vi_smooth"); - - // VI ROI state (real-space region selection for summed DP) - synced with Python - const [viRoiMode, setViRoiMode] = useModelState("vi_roi_mode"); - const [viRoiCenterRow, setViRoiCenterRow] = useModelState("vi_roi_center_row"); - const [viRoiCenterCol, setViRoiCenterCol] = useModelState("vi_roi_center_col"); - const [viRoiRadius, setViRoiRadius] = useModelState("vi_roi_radius"); - const [viRoiWidth, setViRoiWidth] = useModelState("vi_roi_width"); - const [viRoiHeight, setViRoiHeight] = useModelState("vi_roi_height"); - // Local VI ROI center for smooth dragging - const [localViRoiCenterRow, setLocalViRoiCenterRow] = React.useState(viRoiCenterRow || 0); - const [localViRoiCenterCol, setLocalViRoiCenterCol] = React.useState(viRoiCenterCol || 0); - const [viRoiDpBytes] = useModelState("vi_roi_dp_bytes"); - const [viRoiReduce, setViRoiReduce] = useModelState("vi_roi_reduce"); - // dp_stats are computed in JS from frameBytes (Python side no longer - // syncs a dp_stats trait — saves 4 trait sync round-trips per click). - const [viStats, setViStats] = React.useState([0, 0, 0, 0]); - const [viDataMin, setViDataMin] = React.useState(0); - const [viDataMax, setViDataMax] = React.useState(1); - const [showFft, setShowFft] = useModelState("show_fft"); - const [fftWindow, setFftWindow] = useModelState("fft_window"); - const [showControls] = useModelState("show_controls"); - - const effectiveShowFft = showFft; - - // ROI FFT state (VI ROI crops virtual image for FFT) - const [fftCropDims, setFftCropDims] = React.useState<{ cropWidth: number; cropHeight: number; fftWidth: number; fftHeight: number } | null>(null); - const roiFftActive = effectiveShowFft && viRoiMode !== "off"; - - // Canvas resize state - const [canvasSize, setCanvasSize] = React.useState(CANVAS_SIZE); - const [isResizingCanvas, setIsResizingCanvas] = React.useState(false); - const [resizeCanvasStart, setResizeCanvasStart] = React.useState<{ x: number; y: number; size: number } | null>(null); - - // Export - const [, setGifExportRequested] = useModelState("_gif_export_requested"); - const [gifData] = useModelState("_gif_data"); - const [gifMetadataJson] = useModelState("_gif_metadata_json"); - const [exporting, setExporting] = React.useState(false); - const [dpExportAnchor, setDpExportAnchor] = React.useState(null); - const [viExportAnchor, setViExportAnchor] = React.useState(null); - - // Cursor readout state - const [cursorInfo, setCursorInfo] = React.useState<{ row: number; col: number; value: number; panel: string } | null>(null); - - // DP Line profile state - const [profileActive, setProfileActive] = React.useState(false); - const [profileData, setProfileData] = React.useState(null); - const [profileHeight, setProfileHeight] = React.useState(76); - const [isResizingProfile, setIsResizingProfile] = React.useState(false); - const profileResizeStart = React.useRef<{ startY: number; startHeight: number } | null>(null); - const profileCanvasRef = React.useRef(null); - const profileBaseImageRef = React.useRef(null); - const profileLayoutRef = React.useRef<{ padLeft: number; plotW: number; padTop: number; plotH: number; gMin: number; gMax: number; totalDist: number; xUnit: string } | null>(null); - const profilePoints = profileLine || []; - const rawDpDataRef = React.useRef(null); - const dpClickStartRef = React.useRef<{ x: number; y: number } | null>(null); - const [draggingDpProfileEndpoint, setDraggingDpProfileEndpoint] = React.useState<0 | 1 | null>(null); - const [isDraggingDpProfileLine, setIsDraggingDpProfileLine] = React.useState(false); - const [hoveredDpProfileEndpoint, setHoveredDpProfileEndpoint] = React.useState<0 | 1 | null>(null); - const [isHoveringDpProfileLine, setIsHoveringDpProfileLine] = React.useState(false); - const dpProfileDragStartRef = React.useRef<{ row: number; col: number; p0: { row: number; col: number }; p1: { row: number; col: number } } | null>(null); - const dpDragOffsetRef = React.useRef<{ dRow: number; dCol: number }>({ dRow: 0, dCol: 0 }); - - // VI Line profile state - const [viProfileActive, setViProfileActive] = React.useState(false); - const [viProfileData, setViProfileData] = React.useState(null); - const [viProfilePoints, setViProfilePoints] = React.useState>([]); - const [viProfileHeight, setViProfileHeight] = React.useState(76); - const [isResizingViProfile, setIsResizingViProfile] = React.useState(false); - const viProfileResizeStart = React.useRef<{ startY: number; startHeight: number } | null>(null); - const viProfileCanvasRef = React.useRef(null); - const viProfileBaseImageRef = React.useRef(null); - const viProfileLayoutRef = React.useRef<{ padLeft: number; plotW: number; padTop: number; plotH: number; gMin: number; gMax: number; totalDist: number; xUnit: string } | null>(null); - const rawViDataRef = React.useRef(null); - const viClickStartRef = React.useRef<{ x: number; y: number } | null>(null); - const [draggingViProfileEndpoint, setDraggingViProfileEndpoint] = React.useState<0 | 1 | null>(null); - const [isDraggingViProfileLine, setIsDraggingViProfileLine] = React.useState(false); - const [hoveredViProfileEndpoint, setHoveredViProfileEndpoint] = React.useState<0 | 1 | null>(null); - const [isHoveringViProfileLine, setIsHoveringViProfileLine] = React.useState(false); - const viProfileDragStartRef = React.useRef<{ row: number; col: number; p0: { row: number; col: number }; p1: { row: number; col: number } } | null>(null); - const viRoiDragOffsetRef = React.useRef<{ dRow: number; dCol: number }>({ dRow: 0, dCol: 0 }); - - // Theme detection - const { themeInfo, colors: themeColors } = useTheme(); - const roiColors = themeInfo.theme === "dark" ? DARK_ROI_COLORS : LIGHT_ROI_COLORS; - const accentGreen = themeInfo.theme === "dark" ? "#0f0" : "#1a7a1a"; - - // Themed typography — applies theme colors to module-level font sizes - const typo = React.useMemo(() => ({ - label: { ...typography.label, color: themeColors.textMuted }, - labelSmall: { ...typography.labelSmall, color: themeColors.textMuted }, - value: { ...typography.value, color: themeColors.textMuted }, - title: { ...typography.title, color: themeColors.accent }, - }), [themeColors]); - - // Compute VI canvas dimensions to respect aspect ratio of rectangular scans - const viCanvasWidth = shapeRows > shapeCols ? Math.round(canvasSize * (shapeCols / shapeRows)) : canvasSize; - const viCanvasHeight = shapeCols > shapeRows ? Math.round(canvasSize * (shapeRows / shapeCols)) : canvasSize; - - // Histogram data - use state to ensure re-renders (both are Float32Array now) - const [dpHistogramData, setDpHistogramData] = React.useState(null); - const [viHistogramData, setViHistogramData] = React.useState(null); - - // DP stats computed JS-side from frame_bytes (was Python trait pre-refactor; - // moving to JS skips 4 sync trait round-trips per scan-position click). - const [dpStats, setDpStats] = React.useState([0, 0, 0, 0]); - - // Parse DP frame bytes for histogram (float32 now) - React.useEffect(() => { - if (!frameBytes) return; - // Parse as Float32Array since Python now sends raw float32 - const rawData = new Float32Array(frameBytes.buffer, frameBytes.byteOffset, frameBytes.byteLength / 4); - // Store raw data for profile sampling - if (!rawDpDataRef.current || rawDpDataRef.current.length !== rawData.length) { - rawDpDataRef.current = new Float32Array(rawData.length); - } - rawDpDataRef.current.set(rawData); - // Compute stats JS-side (replaces removed Python dp_stats trait) - const s = computeStats(rawData); - setDpStats([s.mean, s.min, s.max, s.std]); - // Apply scale transformation for histogram display - const scaledData = new Float32Array(rawData.length); - if (dpScaleMode === "log") { - for (let i = 0; i < rawData.length; i++) { - scaledData[i] = Math.log1p(Math.max(0, rawData[i])); - } - } else { - scaledData.set(rawData); - } - setDpHistogramData(scaledData); - }, [frameBytes, dpScaleMode]); - - // GPU FFT state - const gpuFFTRef = React.useRef(null); - const [gpuReady, setGpuReady] = React.useState(false); - - // Path animation timer - React.useEffect(() => { - if (!pathPlaying || pathLength === 0) return; - - const timer = setInterval(() => { - setPathIndex((prev: number) => { - const next = prev + 1; - if (next >= pathLength) { - if (pathLoop) { - return 0; // Loop back to start - } else { - setPathPlaying(false); // Stop at end - return prev; - } - } - return next; - }); - }, pathIntervalMs); - - return () => clearInterval(timer); - }, [pathPlaying, pathLength, pathIntervalMs, pathLoop, setPathIndex, setPathPlaying]); - - // Frame animation timer (5D time/tilt series) - const frameBounceDir = React.useRef(1); - React.useEffect(() => { - frameBounceDir.current = frameReverse ? -1 : 1; - }, [frameReverse]); - - React.useEffect(() => { - if (!framePlaying || nFrames <= 1) return; - - const intervalMs = 1000 / Math.max(0.1, frameFps); - const timer = setInterval(() => { - setFrameIdx((prev: number) => { - let next: number; - if (frameBoomerang) { - next = prev + frameBounceDir.current; - if (next >= nFrames) { frameBounceDir.current = -1; next = nFrames - 2; } - if (next < 0) { frameBounceDir.current = 1; next = 1; } - next = Math.max(0, Math.min(nFrames - 1, next)); - } else { - next = prev + (frameReverse ? -1 : 1); - if (next >= nFrames) { - if (frameLoop) return 0; - setFramePlaying(false); - return prev; - } - if (next < 0) { - if (frameLoop) return nFrames - 1; - setFramePlaying(false); - return prev; - } - } - return next; - }); - }, intervalMs); - - return () => clearInterval(timer); - }, [framePlaying, nFrames, frameFps, frameLoop, frameReverse, frameBoomerang, setFrameIdx, setFramePlaying]); - - // Initialize WebGPU FFT on mount - React.useEffect(() => { - getWebGPUFFT().then(fft => { - if (fft) { - gpuFFTRef.current = fft; - setGpuReady(true); - } - }); - }, []); - - // Root element ref (theme-aware styling handled via CSS variables) - const rootRef = React.useRef(null); - - // Zoom state - const [dpZoom, setDpZoom] = React.useState(1); - const [dpPanX, setDpPanX] = React.useState(0); - const [dpPanY, setDpPanY] = React.useState(0); - const [viZoom, setViZoom] = React.useState(1); - const [viPanX, setViPanX] = React.useState(0); - const [viPanY, setViPanY] = React.useState(0); - const [fftZoom, setFftZoom] = React.useState(1); - const [fftPanX, setFftPanX] = React.useState(0); - const [fftPanY, setFftPanY] = React.useState(0); - const [fftScaleMode, setFftScaleMode] = useModelState<"linear" | "log">("fft_scale_mode"); - const [fftColormap, setFftColormap] = useModelState("fft_colormap"); - const [fftAuto, setFftAuto] = useModelState("fft_auto"); - const [fftVminPct, setFftVminPct] = useModelState("fft_vmin_pct"); - const [fftVmaxPct, setFftVmaxPct] = useModelState("fft_vmax_pct"); - const [fftStats, setFftStats] = React.useState(null); // [mean, min, max, std] - const [fftHistogramData, setFftHistogramData] = React.useState(null); - const [fftDataMin, setFftDataMin] = React.useState(0); - const [fftDataMax, setFftDataMax] = React.useState(1); - const [fftClickInfo, setFftClickInfo] = React.useState<{ - row: number; col: number; distPx: number; - spatialFreq: number | null; dSpacing: number | null; - } | null>(null); - const fftClickStartRef = React.useRef<{ x: number; y: number } | null>(null); - - const isTypingTarget = React.useCallback((target: EventTarget | null): boolean => { - if (!(target instanceof HTMLElement)) return false; - if (target.isContentEditable) return true; - return target.closest("input, textarea, select, [role='textbox'], [contenteditable='true']") !== null; - }, []); - - const handleRootMouseDownCapture = React.useCallback((e: React.MouseEvent) => { - const target = e.target as HTMLElement | null; - if (target?.closest("canvas")) rootRef.current?.focus(); - }, []); - - const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => { - if (isTypingTarget(e.target)) return; - - const step = e.shiftKey ? 10 : 1; - let handled = false; - - switch (e.key) { - case "ArrowUp": - setPosRow(Math.max(0, posRow - step)); - handled = true; - break; - case "ArrowDown": - setPosRow(Math.min(shapeRows - 1, posRow + step)); - handled = true; - break; - case "ArrowLeft": - setPosCol(Math.max(0, posCol - step)); - handled = true; - break; - case "ArrowRight": - setPosCol(Math.min(shapeCols - 1, posCol + step)); - handled = true; - break; - case " ": // Space bar - if (pathLength > 0) { - setPathPlaying(!pathPlaying); - handled = true; - } - break; - case "r": - case "R": - setDpZoom(1); setDpPanX(0); setDpPanY(0); - setViZoom(1); setViPanX(0); setViPanY(0); - setFftZoom(1); setFftPanX(0); setFftPanY(0); - handled = true; - break; - case "[": - if (nFrames > 1) { - setFrameIdx(Math.max(0, frameIdx - 1)); - handled = true; - } - break; - case "]": - if (nFrames > 1) { - setFrameIdx(Math.min(nFrames - 1, frameIdx + 1)); - handled = true; - } - break; - case "Escape": - rootRef.current?.blur(); - handled = true; - break; - } - - if (handled) { - e.preventDefault(); - e.stopPropagation(); - } - }, [ - frameIdx, isTypingTarget, nFrames, pathLength, - pathPlaying, posCol, posRow, setFrameIdx, setPathPlaying, setPosCol, setPosRow, shapeCols, shapeRows, - ]); - - // Sync local state - React.useEffect(() => { - if (!isDraggingDP && !isDraggingResize) { setLocalKCol(roiCenterCol); setLocalKRow(roiCenterRow); } - }, [roiCenterCol, roiCenterRow, isDraggingDP, isDraggingResize]); - - React.useEffect(() => { - if (!isDraggingVI) { setLocalPosRow(posRow); setLocalPosCol(posCol); } - }, [posRow, posCol, isDraggingVI]); - - // Sync VI ROI local state - React.useEffect(() => { - if (!isDraggingViRoi && !isDraggingViRoiResize) { - setLocalViRoiCenterRow(viRoiCenterRow || shapeRows / 2); - setLocalViRoiCenterCol(viRoiCenterCol || shapeCols / 2); - } - }, [viRoiCenterRow, viRoiCenterCol, isDraggingViRoi, isDraggingViRoiResize, shapeRows, shapeCols]); - - // Canvas refs - const dpCanvasRef = React.useRef(null); - const dpOverlayRef = React.useRef(null); - const dpUiRef = React.useRef(null); // High-DPI UI overlay for scale bar - const dpOffscreenRef = React.useRef(null); - const dpImageDataRef = React.useRef(null); - const virtualCanvasRef = React.useRef(null); - const virtualOverlayRef = React.useRef(null); - const viUiRef = React.useRef(null); // High-DPI UI overlay for scale bar - const viOffscreenRef = React.useRef(null); - const viImageDataRef = React.useRef(null); - const fftCanvasRef = React.useRef(null); - const fftOverlayRef = React.useRef(null); - const fftOffscreenRef = React.useRef(null); - const fftImageDataRef = React.useRef(null); - - // Offscreen version counters — bump when colormap/data changes, cheap draw effects depend on these - const [dpOffscreenVersion, setDpOffscreenVersion] = React.useState(0); - const [viOffscreenVersion, setViOffscreenVersion] = React.useState(0); - const [fftOffscreenVersion, setFftOffscreenVersion] = React.useState(0); - - // Cached colorbar vmin/vmax — computed in expensive DP effect, reused in UI overlay without recomputing - const dpColorbarVminRef = React.useRef(0); - const dpColorbarVmaxRef = React.useRef(1); - - // Device pixel ratio for high-DPI UI overlays - const DPR = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; - - // ───────────────────────────────────────────────────────────────────────── - // Effects: Canvas Rendering & Animation - // ───────────────────────────────────────────────────────────────────────── - - // Prevent page scroll when scrolling on canvases - // Re-run when showFft changes since FFT canvas is conditionally rendered - React.useEffect(() => { - const preventDefault = (e: WheelEvent) => e.preventDefault(); - const overlays = [dpOverlayRef.current, virtualOverlayRef.current, fftOverlayRef.current]; - overlays.forEach(el => el?.addEventListener("wheel", preventDefault, { passive: false })); - return () => overlays.forEach(el => el?.removeEventListener("wheel", preventDefault)); - }, [effectiveShowFft]); - - // Store raw data for filtering/FFT - const rawVirtualImageRef = React.useRef(null); - const fftWorkRealRef = React.useRef(null); - const fftWorkImagRef = React.useRef(null); - const fftMagnitudeRef = React.useRef(null); - const fftMagCacheRef = React.useRef(null); - - // Parse virtual image bytes into Float32Array and apply scale for histogram - React.useEffect(() => { - if (!virtualImageBytes) return; - // Parse as Float32Array - const numFloats = virtualImageBytes.byteLength / 4; - const rawData = new Float32Array(virtualImageBytes.buffer, virtualImageBytes.byteOffset, numFloats); - - // Store a copy for filtering/FFT (rawData is a view, we need a copy) - let storedData = rawVirtualImageRef.current; - if (!storedData || storedData.length !== numFloats) { - storedData = new Float32Array(numFloats); - rawVirtualImageRef.current = storedData; - } - storedData.set(rawData); - - // Also store for VI profile sampling - if (!rawViDataRef.current || rawViDataRef.current.length !== numFloats) { - rawViDataRef.current = new Float32Array(numFloats); - } - rawViDataRef.current.set(rawData); - - // Compute stats + min/max JS-side (replaces removed Python vi_stats / vi_data_min / vi_data_max traits). - // Python sending bytes + 4 separate stat traits caused a comm-message ordering race on rapid - // preset clicks: bytes from click N could arrive with min/max from click N-1, normalizing - // the colormap to the wrong range and producing a uniform-color VI flash. - const s = computeStats(rawData); - setViStats([s.mean, s.min, s.max, s.std]); - setViDataMin(s.min); - setViDataMax(s.max); - - // Apply scale transformation for histogram display - const scaledData = new Float32Array(numFloats); - if (viScaleMode === "log") { - for (let i = 0; i < numFloats; i++) { - scaledData[i] = Math.log1p(Math.max(0, rawData[i])); - } - } else { - scaledData.set(rawData); - } - setViHistogramData(scaledData); - }, [virtualImageBytes, viScaleMode]); - - // Render DP with zoom (use summed DP when VI ROI is active) - // Expensive: colormap + data processing → cached offscreen canvas - React.useEffect(() => { - // Determine which bytes to display: summed DP (if VI ROI active) or single frame - const usesViRoiDp = viRoiMode && viRoiMode !== "off" && viRoiDpBytes && viRoiDpBytes.byteLength > 0; - const sourceBytes = usesViRoiDp ? viRoiDpBytes : frameBytes; - if (!sourceBytes) return; - - const lut = COLORMAPS[dpColormap] || COLORMAPS.inferno; - - // Parse raw float32 data and apply scale transformation - const rawData = new Float32Array(sourceBytes.buffer, sourceBytes.byteOffset, sourceBytes.byteLength / 4); - let scaled: Float32Array; - if (dpScaleMode === "log") { - scaled = new Float32Array(rawData.length); - for (let i = 0; i < rawData.length; i++) { - scaled[i] = Math.log1p(Math.max(0, rawData[i])); - } - } else { - scaled = rawData; - } - - const { min: dataMin, max: dataMax } = findDataRange(scaled); - - let vmin: number, vmax: number; - if (traitDpVmin != null && traitDpVmax != null) { - if (dpScaleMode === "log") { - vmin = Math.log1p(Math.max(traitDpVmin, 0)); - vmax = Math.log1p(Math.max(traitDpVmax, 0)); - } else { - vmin = traitDpVmin; - vmax = traitDpVmax; - } - } else { - ({ vmin, vmax } = sliderRange(dataMin, dataMax, dpVminPct, dpVmaxPct)); - } - - let offscreen = dpOffscreenRef.current; - if (!offscreen) { - offscreen = document.createElement("canvas"); - dpOffscreenRef.current = offscreen; - } - const sizeChanged = offscreen.width !== detCols || offscreen.height !== detRows; - if (sizeChanged) { - offscreen.width = detCols; - offscreen.height = detRows; - dpImageDataRef.current = null; - } - const offCtx = offscreen.getContext("2d"); - if (!offCtx) return; - - let imgData = dpImageDataRef.current; - if (!imgData) { - imgData = offCtx.createImageData(detCols, detRows); - dpImageDataRef.current = imgData; - } - applyColormap(scaled, imgData.data, lut, vmin, vmax); - offCtx.putImageData(imgData, 0, 0); - // Cache colorbar range for the UI overlay (avoids recomputing findDataRange on every zoom/pan) - dpColorbarVminRef.current = vmin; - dpColorbarVmaxRef.current = vmax; - setDpOffscreenVersion(v => v + 1); - }, [frameBytes, viRoiDpBytes, viRoiMode, detRows, detCols, dpColormap, dpVminPct, dpVmaxPct, dpScaleMode, traitDpVmin, traitDpVmax]); - - // Cheap: zoom/pan redraw — just drawImage from cached offscreen - // useLayoutEffect prevents black flash when canvas dimensions change (resize) - React.useLayoutEffect(() => { - const offscreen = dpOffscreenRef.current; - if (!offscreen || !dpCanvasRef.current) return; - const canvas = dpCanvasRef.current; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - ctx.imageSmoothingEnabled = false; - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.save(); - ctx.translate(dpPanX, dpPanY); - ctx.scale(dpZoom, dpZoom); - ctx.drawImage(offscreen, 0, 0); - ctx.restore(); - }, [dpOffscreenVersion, dpZoom, dpPanX, dpPanY]); - - // Render DP overlay - just clear (ROI shapes now drawn on high-DPI UI canvas) - React.useEffect(() => { - if (!dpOverlayRef.current) return; - const canvas = dpOverlayRef.current; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - ctx.clearRect(0, 0, canvas.width, canvas.height); - // All visual overlays (crosshair, ROI shapes, scale bar) are now on dpUiRef for crisp rendering - }, [localKCol, localKRow, isDraggingDP, isDraggingResize, isDraggingResizeInner, isHoveringResize, isHoveringResizeInner, dpZoom, dpPanX, dpPanY, roiMode, roiRadius, roiRadiusInner, roiWidth, roiHeight, detRows, detCols]); - - // Expensive: VI colormap + data processing → cached offscreen canvas - React.useEffect(() => { - if (!rawVirtualImageRef.current) return; - - const width = shapeCols; - const height = shapeRows; - const filtered = rawVirtualImageRef.current; - - let scaled = filtered; - if (viScaleMode === "log") { - scaled = new Float32Array(filtered.length); - for (let i = 0; i < filtered.length; i++) { - scaled[i] = Math.log1p(Math.max(0, filtered[i])); - } - } - - // Compute min/max from the data we just received. Do NOT use Python's - // viDataMin/viDataMax traits here: they arrive as separate comm messages - // and can be stale on rapid preset clicks (BF↔ABF), causing the render - // to apply the WRONG normalization range and produce a uniform white/black - // VI panel until comm catches up. findDataRange on a scan-shape buffer - // (~64K-256K floats) is sub-millisecond. - const r = findDataRange(scaled); - const dataMin = r.min; - const dataMax = r.max; - - // Apply absolute bounds or percentile clipping - let vmin: number, vmax: number; - if (traitViVmin != null && traitViVmax != null) { - if (viScaleMode === "log") { - vmin = Math.log1p(Math.max(traitViVmin, 0)); - vmax = Math.log1p(Math.max(traitViVmax, 0)); - } else { - vmin = traitViVmin; - vmax = traitViVmax; - } - } else if (viAutoContrast) { - ({ vmin, vmax } = percentileClip(scaled, 1, 99)); - } else { - ({ vmin, vmax } = sliderRange(dataMin, dataMax, viVminPct, viVmaxPct)); - } - - const lut = COLORMAPS[viColormap] || COLORMAPS.inferno; - let offscreen = viOffscreenRef.current; - if (!offscreen) { - offscreen = document.createElement("canvas"); - viOffscreenRef.current = offscreen; - } - const sizeChanged = offscreen.width !== width || offscreen.height !== height; - if (sizeChanged) { - offscreen.width = width; - offscreen.height = height; - viImageDataRef.current = null; - } - const offCtx = offscreen.getContext("2d"); - if (!offCtx) return; - - let imageData = viImageDataRef.current; - if (!imageData) { - imageData = offCtx.createImageData(width, height); - viImageDataRef.current = imageData; - } - applyColormap(scaled, imageData.data, lut, vmin, vmax); - offCtx.putImageData(imageData, 0, 0); - setViOffscreenVersion(v => v + 1); - }, [virtualImageBytes, shapeRows, shapeCols, viColormap, viVminPct, viVmaxPct, viScaleMode, traitViVmin, traitViVmax, viAutoContrast]); - - // Cheap: VI zoom/pan redraw — just drawImage from cached offscreen - React.useLayoutEffect(() => { - const offscreen = viOffscreenRef.current; - if (!offscreen || !virtualCanvasRef.current) return; - const canvas = virtualCanvasRef.current; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - ctx.imageSmoothingEnabled = viSmooth; - if (viSmooth) ctx.imageSmoothingQuality = "high"; - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.save(); - ctx.translate(viPanX, viPanY); - ctx.scale(viZoom, viZoom); - ctx.drawImage(offscreen, 0, 0); - ctx.restore(); - }, [viOffscreenVersion, viZoom, viPanX, viPanY, viSmooth]); - - // Render virtual image overlay (just clear - crosshair drawn on high-DPI UI canvas) - React.useEffect(() => { - if (!virtualOverlayRef.current) return; - const canvas = virtualOverlayRef.current; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - ctx.clearRect(0, 0, canvas.width, canvas.height); - // Crosshair and scale bar now drawn on high-DPI UI canvas (viUiRef) - }, [localPosRow, localPosCol, isDraggingVI, viZoom, viPanX, viPanY, pixelSize, shapeRows, shapeCols]); - - // Compute FFT (expensive, async — only re-run on data/GPU changes) - const fftRealRef = React.useRef(null); - const fftImagRef = React.useRef(null); - const [fftVersion, setFftVersion] = React.useState(0); - - React.useEffect(() => { - if (!rawVirtualImageRef.current || !effectiveShowFft) { setFftCropDims(null); return; } - let cancelled = false; - let width = shapeCols; - let height = shapeRows; - let sourceData = rawVirtualImageRef.current; - let origCropW = 0, origCropH = 0; - - // ROI FFT: crop virtual image to VI ROI region and pre-pad to power-of-2. - // Use localViRoiCenter* (updated immediately on drag) instead of the synced - // model traits, which lag by one comm roundtrip after a compound trait write. - // Without this, FFT visibly stalls during rapid VI ROI drag. - if (roiFftActive) { - const cRow = localViRoiCenterRow ?? viRoiCenterRow; - const cCol = localViRoiCenterCol ?? viRoiCenterCol; - const crop = cropSingleROI(sourceData, shapeCols, shapeRows, viRoiMode, cRow, cCol, viRoiRadius, viRoiWidth, viRoiHeight); - if (crop) { - origCropW = crop.cropW; - origCropH = crop.cropH; - // Apply Hann window to crop at native dimensions BEFORE zero-padding - if (fftWindow) applyHannWindow2D(crop.cropped, crop.cropW, crop.cropH); - const padW = nextPow2(crop.cropW); - const padH = nextPow2(crop.cropH); - const padded = new Float32Array(padW * padH); - for (let y = 0; y < crop.cropH; y++) { - for (let x = 0; x < crop.cropW; x++) { - padded[y * padW + x] = crop.cropped[y * crop.cropW + x]; - } - } - sourceData = padded; - width = padW; - height = padH; - } - } - - // Pre-pad non-power-of-2 full images so fft2d doesn't truncate frequency data - if (!roiFftActive) { - const padW = nextPow2(width); - const padH = nextPow2(height); - if (padW !== width || padH !== height) { - const padded = new Float32Array(padW * padH); - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - padded[y * padW + x] = sourceData[y * width + x]; - } - } - sourceData = padded; - width = padW; - height = padH; - } - } - - const fftW = width, fftH = height; - if (gpuFFTRef.current && gpuReady) { - const runGpuFFT = async () => { - const real = sourceData.slice(); - const imag = new Float32Array(real.length); - const { real: fReal, imag: fImag } = await gpuFFTRef.current!.fft2D(real, imag, fftW, fftH, false); - if (cancelled) return; - fftshift(fReal, fftW, fftH); - fftshift(fImag, fftW, fftH); - fftRealRef.current = fReal; - fftImagRef.current = fImag; - if (origCropW > 0) { - setFftCropDims({ cropWidth: origCropW, cropHeight: origCropH, fftWidth: fftW, fftHeight: fftH }); - } else if (fftW !== shapeCols || fftH !== shapeRows) { - setFftCropDims({ cropWidth: shapeCols, cropHeight: shapeRows, fftWidth: fftW, fftHeight: fftH }); - } else { - setFftCropDims(null); - } - setFftVersion(v => v + 1); - }; - runGpuFFT(); - return () => { cancelled = true; }; - } else { - const len = sourceData.length; - let real = fftWorkRealRef.current; - if (!real || real.length !== len) { real = new Float32Array(len); fftWorkRealRef.current = real; } - real.set(sourceData); - let imag = fftWorkImagRef.current; - if (!imag || imag.length !== len) { imag = new Float32Array(len); fftWorkImagRef.current = imag; } else { imag.fill(0); } - fft2d(real, imag, fftW, fftH, false); - fftshift(real, fftW, fftH); - fftshift(imag, fftW, fftH); - fftRealRef.current = real; - fftImagRef.current = imag; - if (origCropW > 0) { - setFftCropDims({ cropWidth: origCropW, cropHeight: origCropH, fftWidth: fftW, fftHeight: fftH }); - } else if (fftW !== shapeCols || fftH !== shapeRows) { - setFftCropDims({ cropWidth: shapeCols, cropHeight: shapeRows, fftWidth: fftW, fftHeight: fftH }); - } else { - setFftCropDims(null); - } - setFftVersion(v => v + 1); - } - }, [virtualImageBytes, shapeRows, shapeCols, gpuReady, effectiveShowFft, roiFftActive, viRoiMode, viRoiCenterRow, viRoiCenterCol, localViRoiCenterRow, localViRoiCenterCol, viRoiRadius, viRoiWidth, viRoiHeight, fftWindow]); - - // Expensive: FFT magnitude + histogram + colormap → cached offscreen canvas - React.useEffect(() => { - if (!fftRealRef.current || !fftImagRef.current) return; - if (!effectiveShowFft) return; - - const width = fftCropDims?.fftWidth ?? shapeCols; - const height = fftCropDims?.fftHeight ?? shapeRows; - const real = fftRealRef.current; - const imag = fftImagRef.current; - const lut = COLORMAPS[fftColormap] || COLORMAPS.inferno; - - // Compute magnitude with scale mode - let magnitude = fftMagnitudeRef.current; - if (!magnitude || magnitude.length !== real.length) { - magnitude = new Float32Array(real.length); - fftMagnitudeRef.current = magnitude; - } - // Cache raw magnitude for peak-snap before applying scale transform - let rawMag = fftMagCacheRef.current; - if (!rawMag || rawMag.length !== real.length) { - rawMag = new Float32Array(real.length); - fftMagCacheRef.current = rawMag; - } - for (let i = 0; i < real.length; i++) { - const mag = Math.sqrt(real[i] * real[i] + imag[i] * imag[i]); - rawMag[i] = mag; - if (fftScaleMode === "log") { magnitude[i] = Math.log1p(mag); } - else { magnitude[i] = mag; } - } - - let displayMin: number, displayMax: number; - if (fftAuto) { - ({ min: displayMin, max: displayMax } = autoEnhanceFFT(magnitude, width, height)); - } else { - ({ min: displayMin, max: displayMax } = findDataRange(magnitude)); - } - setFftDataMin(displayMin); - setFftDataMax(displayMax); - const magStats = computeStats(magnitude); - setFftStats([magStats.mean, displayMin, displayMax, magStats.std]); - setFftHistogramData(magnitude.slice()); - - // Render to offscreen canvas - let offscreen = fftOffscreenRef.current; - if (!offscreen) { offscreen = document.createElement("canvas"); fftOffscreenRef.current = offscreen; } - if (offscreen.width !== width || offscreen.height !== height) { - offscreen.width = width; offscreen.height = height; fftImageDataRef.current = null; - } - const offCtx = offscreen.getContext("2d"); - if (!offCtx) return; - let imgData = fftImageDataRef.current; - if (!imgData) { imgData = offCtx.createImageData(width, height); fftImageDataRef.current = imgData; } - - const { vmin, vmax } = sliderRange(displayMin, displayMax, fftVminPct, fftVmaxPct); - applyColormap(magnitude, imgData.data, lut, vmin, vmax); - offCtx.putImageData(imgData, 0, 0); - setFftOffscreenVersion(v => v + 1); - }, [effectiveShowFft, fftVersion, fftScaleMode, fftAuto, fftVminPct, fftVmaxPct, fftColormap, shapeRows, shapeCols, fftCropDims]); - - // Cheap: FFT zoom/pan redraw — just drawImage from cached offscreen - React.useLayoutEffect(() => { - if (!fftCanvasRef.current) return; - const canvas = fftCanvasRef.current; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - const offscreen = fftOffscreenRef.current; - if (!offscreen || !effectiveShowFft) { ctx.clearRect(0, 0, canvas.width, canvas.height); return; } - const fftW = offscreen.width; - const fftH = offscreen.height; - const canvasW = canvas.width; - const canvasH = canvas.height; - // Use bilinear smoothing when FFT dims differ from canvas (non-pow2 padding or ROI crop). - // Stretch offscreen to fill canvas via the 9-arg drawImage form: ROI FFT crops produce a - // small offscreen (e.g. 64×64) that would otherwise blit at native size in the corner. - ctx.imageSmoothingEnabled = fftW !== canvasW || fftH !== canvasH; - ctx.clearRect(0, 0, canvasW, canvasH); - ctx.save(); - ctx.translate(fftPanX, fftPanY); - ctx.scale(fftZoom, fftZoom); - ctx.drawImage(offscreen, 0, 0, fftW, fftH, 0, 0, canvasW, canvasH); - ctx.restore(); - }, [fftOffscreenVersion, fftZoom, fftPanX, fftPanY, effectiveShowFft]); - - // Render FFT overlay with d-spacing crosshair marker - React.useEffect(() => { - if (!fftOverlayRef.current) return; - const canvas = fftOverlayRef.current; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - ctx.clearRect(0, 0, canvas.width, canvas.height); - - // D-spacing crosshair marker - if (fftClickInfo && effectiveShowFft) { - const fftW = fftCropDims?.fftWidth ?? shapeCols; - const fftH = fftCropDims?.fftHeight ?? shapeRows; - ctx.save(); - // Forward mapping: image col/row → canvas x/y (matches stretched drawImage). - const screenX = fftPanX + fftZoom * (fftClickInfo.col * canvas.width / fftW); - const screenY = fftPanY + fftZoom * (fftClickInfo.row * canvas.height / fftH); - ctx.strokeStyle = "rgba(255, 255, 255, 0.9)"; - ctx.shadowColor = "rgba(0, 0, 0, 0.6)"; - ctx.shadowBlur = 2; - ctx.lineWidth = 1.5; - // Scale crosshair size relative to canvas (not zoom-dependent) - const r = 8 * Math.max(fftW, fftH) / 450; - const gap = 3 * Math.max(fftW, fftH) / 450; - const dotR = 4 * Math.max(fftW, fftH) / 450; - ctx.beginPath(); - ctx.moveTo(screenX - r, screenY); ctx.lineTo(screenX - gap, screenY); - ctx.moveTo(screenX + gap, screenY); ctx.lineTo(screenX + r, screenY); - ctx.moveTo(screenX, screenY - r); ctx.lineTo(screenX, screenY - gap); - ctx.moveTo(screenX, screenY + gap); ctx.lineTo(screenX, screenY + r); - ctx.stroke(); - ctx.beginPath(); - ctx.arc(screenX, screenY, dotR, 0, Math.PI * 2); - ctx.stroke(); - if (fftClickInfo.dSpacing != null) { - const d = fftClickInfo.dSpacing; - const label = d >= 10 ? `d = ${(d / 10).toFixed(2)} nm` : `d = ${d.toFixed(2)} \u00C5`; - const fontSize = Math.max(10, Math.round(11 * Math.max(fftW, fftH) / 450)); - ctx.font = `bold ${fontSize}px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`; - ctx.fillStyle = "white"; - ctx.textAlign = "left"; - ctx.textBaseline = "bottom"; - ctx.fillText(label, screenX + r + 4, screenY - gap); - } - ctx.restore(); - } - }, [fftZoom, fftPanX, fftPanY, effectiveShowFft, fftClickInfo, shapeCols, shapeRows, fftCropDims]); - - // Clear FFT click info when virtual image changes (scan position, VI ROI, etc.) - React.useEffect(() => { - setFftClickInfo(null); - }, [virtualImageBytes]); - - // ───────────────────────────────────────────────────────────────────────── - // High-DPI Scale Bar UI Overlays - // ───────────────────────────────────────────────────────────────────────── - - // DP scale bar + crosshair + ROI overlay + profile line (high-DPI) - React.useEffect(() => { - if (!dpUiRef.current) return; - // Draw scale bar first (clears canvas) - const kUnit = kCalibrated ? kPixelUnit : "px"; - drawScaleBarHiDPI(dpUiRef.current, DPR, dpZoom, kPixelSize || 1, kUnit, detCols); - // Draw ROI overlay (circle, square, rect, annular) or point crosshair - if (roiMode === "point") { - drawDpCrosshairHiDPI(dpUiRef.current, DPR, localKCol, localKRow, dpZoom, dpPanX, dpPanY, detCols, detRows, isDraggingDP, roiColors); - } else { - drawRoiOverlayHiDPI( - dpUiRef.current, DPR, roiMode, - localKCol, localKRow, roiRadius, roiRadiusInner, roiWidth, roiHeight, - dpZoom, dpPanX, dpPanY, detCols, detRows, - isDraggingDP, isDraggingResize, isDraggingResizeInner, isHoveringResize, isHoveringResizeInner, - roiColors - ); - } - - // Profile line overlay - if (profileActive && profilePoints.length > 0) { - const canvas = dpUiRef.current; - const ctx = canvas.getContext("2d"); - if (ctx) { - ctx.save(); - ctx.scale(DPR, DPR); - const cssW = canvas.width / DPR; - const cssH = canvas.height / DPR; - const scaleX = cssW / detCols; - const scaleY = cssH / detRows; - const toScreenX = (col: number) => col * dpZoom * scaleX + dpPanX * scaleX; - const toScreenY = (row: number) => row * dpZoom * scaleY + dpPanY * scaleY; - - // Draw point A - const ax = toScreenX(profilePoints[0].col); - const ay = toScreenY(profilePoints[0].row); - ctx.fillStyle = themeColors.accent; - ctx.beginPath(); - ctx.arc(ax, ay, 4, 0, Math.PI * 2); - ctx.fill(); - - if (profilePoints.length === 2) { - const bx = toScreenX(profilePoints[1].col); - const by = toScreenY(profilePoints[1].row); - - // Draw band when profile width > 1 - if (profileWidth > 1) { - const dc = profilePoints[1].col - profilePoints[0].col; - const dr = profilePoints[1].row - profilePoints[0].row; - const lineLen = Math.sqrt(dc * dc + dr * dr); - if (lineLen > 0) { - const halfW = (profileWidth - 1) / 2; - const perpR = -dc / lineLen * halfW; - const perpC = dr / lineLen * halfW; - ctx.fillStyle = themeColors.accent + "20"; - ctx.strokeStyle = themeColors.accent; - ctx.lineWidth = 1; - ctx.setLineDash([3, 3]); - ctx.beginPath(); - ctx.moveTo(toScreenX(profilePoints[0].col + perpC), toScreenY(profilePoints[0].row + perpR)); - ctx.lineTo(toScreenX(profilePoints[1].col + perpC), toScreenY(profilePoints[1].row + perpR)); - ctx.lineTo(toScreenX(profilePoints[1].col - perpC), toScreenY(profilePoints[1].row - perpR)); - ctx.lineTo(toScreenX(profilePoints[0].col - perpC), toScreenY(profilePoints[0].row - perpR)); - ctx.closePath(); - ctx.fill(); - ctx.stroke(); - ctx.setLineDash([]); - } - } - - // Draw line A->B - ctx.strokeStyle = themeColors.accent; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.moveTo(ax, ay); - ctx.lineTo(bx, by); - ctx.stroke(); - - // Draw point B - ctx.fillStyle = themeColors.accent; - ctx.beginPath(); - ctx.arc(bx, by, 4, 0, Math.PI * 2); - ctx.fill(); - } - ctx.restore(); - } - } - - // Colorbar overlay — uses cached vmin/vmax from the expensive DP offscreen effect - if (showDpColorbar) { - const canvas = dpUiRef.current; - const ctx = canvas.getContext("2d"); - if (ctx) { - ctx.save(); - ctx.scale(DPR, DPR); - const cssW = canvas.width / DPR; - const cssH = canvas.height / DPR; - const lut = COLORMAPS[dpColormap] || COLORMAPS.inferno; - drawColorbar(ctx, cssW, cssH, lut, dpColorbarVminRef.current, dpColorbarVmaxRef.current, dpScaleMode === "log"); - ctx.restore(); - } - } - }, [dpZoom, dpPanX, dpPanY, kPixelSize, kCalibrated, detRows, detCols, roiMode, roiRadius, roiRadiusInner, roiWidth, roiHeight, localKCol, localKRow, isDraggingDP, isDraggingResize, isDraggingResizeInner, isHoveringResize, isHoveringResizeInner, - profileActive, profilePoints, profileWidth, themeColors, showDpColorbar, dpColormap, dpScaleMode, dpVminPct, dpVmaxPct, canvasSize, roiColors]); - - // VI scale bar + crosshair + ROI + profile lines (high-DPI) - React.useEffect(() => { - if (!viUiRef.current) return; - // Draw scale bar first (clears canvas) - drawScaleBarHiDPI(viUiRef.current, DPR, viZoom, pixelSize || 1, pixelUnit || "px", shapeCols); - // Draw crosshair only when ROI is off (ROI replaces the crosshair) - if (!viRoiMode || viRoiMode === "off") { - drawViPositionMarker(viUiRef.current, DPR, localPosRow, localPosCol, viZoom, viPanX, viPanY, shapeCols, shapeRows, isDraggingVI); - } else { - // Draw VI ROI instead of crosshair - drawViRoiOverlayHiDPI( - viUiRef.current, DPR, viRoiMode, - localViRoiCenterRow, localViRoiCenterCol, viRoiRadius || 5, viRoiWidth || 10, viRoiHeight || 10, - viZoom, viPanX, viPanY, shapeCols, shapeRows, - isDraggingViRoi, isDraggingViRoiResize, isHoveringViRoiResize - ); - } - // Draw VI profile lines - if (viProfileActive && viProfilePoints.length > 0) { - const canvas = viUiRef.current; - const ctx = canvas.getContext("2d"); - if (ctx) { - const cssW = canvas.width / DPR; - const cssH = canvas.height / DPR; - const scaleX = cssW / shapeCols; - const scaleY = cssH / shapeRows; - ctx.save(); - ctx.scale(DPR, DPR); - ctx.strokeStyle = "#a0f"; - ctx.lineWidth = 2; - ctx.shadowColor = "rgba(0,0,0,0.5)"; - ctx.shadowBlur = 2; - if (viProfilePoints.length >= 1) { - const p0 = viProfilePoints[0]; - const x0 = p0.col * viZoom * scaleX + viPanX * scaleX; - const y0 = p0.row * viZoom * scaleY + viPanY * scaleY; - ctx.beginPath(); - ctx.arc(x0, y0, 4, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = "#fff"; - ctx.fillText("1", x0 + 6, y0 - 6); - } - if (viProfilePoints.length === 2) { - const p0 = viProfilePoints[0], p1 = viProfilePoints[1]; - const x0 = p0.col * viZoom * scaleX + viPanX * scaleX; - const y0 = p0.row * viZoom * scaleY + viPanY * scaleY; - const x1 = p1.col * viZoom * scaleX + viPanX * scaleX; - const y1 = p1.row * viZoom * scaleY + viPanY * scaleY; - ctx.beginPath(); - ctx.moveTo(x0, y0); - ctx.lineTo(x1, y1); - ctx.stroke(); - ctx.beginPath(); - ctx.arc(x1, y1, 4, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = "#fff"; - ctx.fillText("2", x1 + 6, y1 - 6); - } - ctx.restore(); - } - } - }, [viZoom, viPanX, viPanY, pixelSize, shapeRows, shapeCols, localPosRow, localPosCol, isDraggingVI, - viRoiMode, localViRoiCenterRow, localViRoiCenterCol, viRoiRadius, viRoiWidth, viRoiHeight, - isDraggingViRoi, isDraggingViRoiResize, isHoveringViRoiResize, canvasSize, viProfileActive, viProfilePoints]); - - // ── DP Profile computation ── - React.useEffect(() => { - if (profilePoints.length === 2 && rawDpDataRef.current) { - const p0 = profilePoints[0], p1 = profilePoints[1]; - setProfileData(sampleLineProfile(rawDpDataRef.current, detCols, detRows, p0.row, p0.col, p1.row, p1.col, profileWidth)); - if (!profileActive) setProfileActive(true); - } else { - setProfileData(null); - } - }, [profilePoints, profileWidth, frameBytes]); - - // ── VI Profile computation ── - React.useEffect(() => { - if (viProfilePoints.length === 2 && rawViDataRef.current && shapeCols > 0 && shapeRows > 0) { - const p0 = viProfilePoints[0], p1 = viProfilePoints[1]; - setViProfileData(sampleLineProfile(rawViDataRef.current, shapeCols, shapeRows, p0.row, p0.col, p1.row, p1.col, 1)); - } else { - setViProfileData(null); - } - }, [viProfilePoints, virtualImageBytes, shapeCols, shapeRows]); - - // ── Profile sparkline rendering ── - React.useEffect(() => { - const canvas = profileCanvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const dpr = window.devicePixelRatio || 1; - const cssW = canvasSize; - const cssH = profileHeight; - canvas.width = cssW * dpr; - canvas.height = cssH * dpr; - ctx.scale(dpr, dpr); - - const isDark = themeInfo.theme === "dark"; - ctx.fillStyle = isDark ? "#1a1a1a" : "#f0f0f0"; - ctx.fillRect(0, 0, cssW, cssH); - - if (!profileData || profileData.length < 2) { - ctx.font = "10px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; - ctx.fillStyle = isDark ? "#555" : "#999"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText("Click two points on the DP to draw a profile", cssW / 2, cssH / 2); - profileBaseImageRef.current = null; - profileLayoutRef.current = null; - return; - } - - const padLeft = 40; - const padRight = 8; - const padTop = 6; - const padBottom = 18; - const plotW = cssW - padLeft - padRight; - const plotH = cssH - padTop - padBottom; - - let gMin = Infinity, gMax = -Infinity; - for (let i = 0; i < profileData.length; i++) { - if (profileData[i] < gMin) gMin = profileData[i]; - if (profileData[i] > gMax) gMax = profileData[i]; - } - const range = gMax - gMin || 1; - - // X-axis: calibrated distance - let totalDist = profileData.length - 1; - let xUnit = "px"; - if (profilePoints.length === 2) { - const dx = profilePoints[1].col - profilePoints[0].col; - const dy = profilePoints[1].row - profilePoints[0].row; - const distPx = Math.sqrt(dx * dx + dy * dy); - if (kCalibrated && kPixelSize > 0) { - totalDist = distPx * kPixelSize; - xUnit = kPixelUnit; - } else { - totalDist = distPx; - } - } - - // Draw axes - ctx.strokeStyle = isDark ? "#555" : "#bbb"; - ctx.lineWidth = 0.5; - ctx.beginPath(); - ctx.moveTo(padLeft, padTop); - ctx.lineTo(padLeft, padTop + plotH); - ctx.lineTo(padLeft + plotW, padTop + plotH); - ctx.stroke(); - - // Draw profile curve - ctx.strokeStyle = themeColors.accent; - ctx.lineWidth = 1.5; - ctx.beginPath(); - for (let i = 0; i < profileData.length; i++) { - const x = padLeft + (i / (profileData.length - 1)) * plotW; - const y = padTop + plotH - ((profileData[i] - gMin) / range) * plotH; - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - } - ctx.stroke(); - - // Draw x-axis ticks - const tickY = padTop + plotH; - ctx.strokeStyle = isDark ? "#555" : "#bbb"; - ctx.lineWidth = 0.5; - const idealTicks = Math.max(2, Math.floor(plotW / 70)); - const tickStep = roundToNiceValue(totalDist / idealTicks); - ctx.font = "9px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; - ctx.fillStyle = isDark ? "#888" : "#666"; - ctx.textBaseline = "top"; - const ticks: number[] = []; - for (let v = 0; v <= totalDist + tickStep * 0.01; v += tickStep) { - if (v > totalDist * 1.001) break; - ticks.push(v); - } - for (let i = 0; i < ticks.length; i++) { - const v = ticks[i]; - const frac = totalDist > 0 ? v / totalDist : 0; - const x = padLeft + frac * plotW; - ctx.beginPath(); ctx.moveTo(x, tickY); ctx.lineTo(x, tickY + 3); ctx.stroke(); - ctx.textAlign = frac < 0.05 ? "left" : frac > 0.95 ? "right" : "center"; - const label = v % 1 === 0 ? v.toFixed(0) : v.toFixed(1); - ctx.fillText(i === ticks.length - 1 ? `${label} ${xUnit}` : label, x, tickY + 4); - } - - // Y-axis min/max labels (left margin) - ctx.font = "9px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; - ctx.fillStyle = isDark ? "#888" : "#666"; - ctx.textAlign = "left"; - ctx.textBaseline = "top"; - ctx.fillText(formatNumber(gMax), 2, padTop); - ctx.textBaseline = "bottom"; - ctx.fillText(formatNumber(gMin), 2, padTop + plotH); - - // Save base image and layout for hover - profileBaseImageRef.current = ctx.getImageData(0, 0, canvas.width, canvas.height); - profileLayoutRef.current = { padLeft, plotW, padTop, plotH, gMin, gMax, totalDist, xUnit }; - }, [profileData, profilePoints, kPixelSize, kCalibrated, themeInfo.theme, themeColors.accent, canvasSize, profileHeight]); - - // DP Profile hover handlers - const handleProfileMouseMove = React.useCallback((e: React.MouseEvent) => { - const canvas = profileCanvasRef.current; - const base = profileBaseImageRef.current; - const layout = profileLayoutRef.current; - if (!canvas || !base || !layout || !profileData) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const rect = canvas.getBoundingClientRect(); - const cssX = e.clientX - rect.left; - const { padLeft, plotW, padTop, plotH, gMin, gMax, totalDist, xUnit } = layout; - const range = gMax - gMin || 1; - - // Restore base image - ctx.putImageData(base, 0, 0); - - if (cssX < padLeft || cssX > padLeft + plotW) return; - const frac = (cssX - padLeft) / plotW; - - const dpr = window.devicePixelRatio || 1; - ctx.save(); - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - - // Vertical crosshair - const isDark = themeInfo.theme === "dark"; - ctx.strokeStyle = isDark ? "rgba(255,255,255,0.3)" : "rgba(0,0,0,0.3)"; - ctx.lineWidth = 1; - ctx.setLineDash([2, 2]); - ctx.beginPath(); - ctx.moveTo(cssX, padTop); - ctx.lineTo(cssX, padTop + plotH); - ctx.stroke(); - ctx.setLineDash([]); - - // Dot on curve + value - const dataIdx = Math.min(profileData.length - 1, Math.max(0, Math.round(frac * (profileData.length - 1)))); - const val = profileData[dataIdx]; - const y = padTop + plotH - ((val - gMin) / range) * plotH; - ctx.fillStyle = themeColors.accent; - ctx.beginPath(); - ctx.arc(cssX, y, 3, 0, Math.PI * 2); - ctx.fill(); - - // Value readout label - const dist = frac * totalDist; - const label = `${formatNumber(val)} @ ${dist.toFixed(1)} ${xUnit}`; - ctx.font = "bold 9px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; - const textW = ctx.measureText(label).width; - const labelX = Math.min(cssX + 6, padLeft + plotW - textW - 2); - const labelY = padTop + 2; - ctx.fillStyle = isDark ? "rgba(0,0,0,0.7)" : "rgba(255,255,255,0.8)"; - ctx.fillRect(labelX - 2, labelY - 1, textW + 4, 11); - ctx.fillStyle = isDark ? "#fff" : "#000"; - ctx.textAlign = "left"; - ctx.textBaseline = "top"; - ctx.fillText(label, labelX, labelY); - - ctx.restore(); - }, [profileData, themeInfo.theme, themeColors.accent]); - - const handleProfileMouseLeave = React.useCallback(() => { - const canvas = profileCanvasRef.current; - const base = profileBaseImageRef.current; - if (!canvas || !base) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - ctx.putImageData(base, 0, 0); - }, []); - - // DP Profile resize handlers - React.useEffect(() => { - if (!isResizingProfile) return; - const handleMouseMove = (e: MouseEvent) => { - if (!profileResizeStart.current) return; - const deltaY = e.clientY - profileResizeStart.current.startY; - const newHeight = Math.max(40, Math.min(300, profileResizeStart.current.startHeight + deltaY)); - setProfileHeight(newHeight); - }; - const handleMouseUp = () => { - setIsResizingProfile(false); - profileResizeStart.current = null; - }; - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [isResizingProfile]); - - // ── VI Profile sparkline rendering ── - React.useEffect(() => { - const canvas = viProfileCanvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const dpr = window.devicePixelRatio || 1; - const cssW = viCanvasWidth; - const cssH = viProfileHeight; - canvas.width = cssW * dpr; - canvas.height = cssH * dpr; - ctx.scale(dpr, dpr); - - const isDark = themeInfo.theme === "dark"; - ctx.fillStyle = isDark ? "#1a1a1a" : "#f0f0f0"; - ctx.fillRect(0, 0, cssW, cssH); - - if (!viProfileData || viProfileData.length < 2) { - ctx.font = "10px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; - ctx.fillStyle = isDark ? "#555" : "#999"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText("Click two points on the VI to draw a profile", cssW / 2, cssH / 2); - viProfileBaseImageRef.current = null; - viProfileLayoutRef.current = null; - return; - } - - const padLeft = 40; - const padRight = 8; - const padTop = 6; - const padBottom = 18; - const plotW = cssW - padLeft - padRight; - const plotH = cssH - padTop - padBottom; - - let gMin = Infinity, gMax = -Infinity; - for (let i = 0; i < viProfileData.length; i++) { - if (viProfileData[i] < gMin) gMin = viProfileData[i]; - if (viProfileData[i] > gMax) gMax = viProfileData[i]; - } - const range = gMax - gMin || 1; - - // X-axis: calibrated distance - let totalDist = viProfileData.length - 1; - let xUnit = "px"; - if (viProfilePoints.length === 2 && pixelSize > 0) { - const dx = viProfilePoints[1].col - viProfilePoints[0].col; - const dy = viProfilePoints[1].row - viProfilePoints[0].row; - const distPx = Math.sqrt(dx * dx + dy * dy); - totalDist = distPx * pixelSize; - xUnit = pixelUnit; - } - - // Draw axes - ctx.strokeStyle = isDark ? "#555" : "#bbb"; - ctx.lineWidth = 0.5; - ctx.beginPath(); - ctx.moveTo(padLeft, padTop); - ctx.lineTo(padLeft, padTop + plotH); - ctx.lineTo(padLeft + plotW, padTop + plotH); - ctx.stroke(); - - // Draw profile curve - ctx.strokeStyle = themeColors.accent; - ctx.lineWidth = 1.5; - ctx.beginPath(); - for (let i = 0; i < viProfileData.length; i++) { - const x = padLeft + (i / (viProfileData.length - 1)) * plotW; - const y = padTop + plotH - ((viProfileData[i] - gMin) / range) * plotH; - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - } - ctx.stroke(); - - // Draw x-axis ticks - const tickY = padTop + plotH; - ctx.strokeStyle = isDark ? "#555" : "#bbb"; - ctx.lineWidth = 0.5; - const idealTicks = Math.max(2, Math.floor(plotW / 70)); - const tickStep = roundToNiceValue(totalDist / idealTicks); - ctx.font = "9px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; - ctx.fillStyle = isDark ? "#888" : "#666"; - ctx.textBaseline = "top"; - const ticks: number[] = []; - for (let v = 0; v <= totalDist + tickStep * 0.01; v += tickStep) { - if (v > totalDist * 1.001) break; - ticks.push(v); - } - for (let i = 0; i < ticks.length; i++) { - const v = ticks[i]; - const frac = totalDist > 0 ? v / totalDist : 0; - const x = padLeft + frac * plotW; - ctx.beginPath(); ctx.moveTo(x, tickY); ctx.lineTo(x, tickY + 3); ctx.stroke(); - ctx.textAlign = frac < 0.05 ? "left" : frac > 0.95 ? "right" : "center"; - const label = v % 1 === 0 ? v.toFixed(0) : v.toFixed(1); - ctx.fillText(i === ticks.length - 1 ? `${label} ${xUnit}` : label, x, tickY + 4); - } - - // Y-axis min/max labels (left margin) - ctx.font = "9px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; - ctx.fillStyle = isDark ? "#888" : "#666"; - ctx.textAlign = "left"; - ctx.textBaseline = "top"; - ctx.fillText(formatNumber(gMax), 2, padTop); - ctx.textBaseline = "bottom"; - ctx.fillText(formatNumber(gMin), 2, padTop + plotH); - - // Save base image and layout for hover - viProfileBaseImageRef.current = ctx.getImageData(0, 0, canvas.width, canvas.height); - viProfileLayoutRef.current = { padLeft, plotW, padTop, plotH, gMin, gMax, totalDist, xUnit }; - }, [viProfileData, viProfilePoints, pixelSize, themeInfo.theme, themeColors.accent, viCanvasWidth, viProfileHeight]); - - // VI Profile hover handlers - const handleViProfileMouseMove = React.useCallback((e: React.MouseEvent) => { - const canvas = viProfileCanvasRef.current; - const base = viProfileBaseImageRef.current; - const layout = viProfileLayoutRef.current; - if (!canvas || !base || !layout || !viProfileData) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const rect = canvas.getBoundingClientRect(); - const cssX = e.clientX - rect.left; - const { padLeft, plotW, padTop, plotH, gMin, gMax, totalDist, xUnit } = layout; - const range = gMax - gMin || 1; - - // Restore base image - ctx.putImageData(base, 0, 0); - - if (cssX < padLeft || cssX > padLeft + plotW) return; - const frac = (cssX - padLeft) / plotW; - - const dpr = window.devicePixelRatio || 1; - ctx.save(); - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - - // Vertical crosshair - const isDark = themeInfo.theme === "dark"; - ctx.strokeStyle = isDark ? "rgba(255,255,255,0.3)" : "rgba(0,0,0,0.3)"; - ctx.lineWidth = 1; - ctx.setLineDash([2, 2]); - ctx.beginPath(); - ctx.moveTo(cssX, padTop); - ctx.lineTo(cssX, padTop + plotH); - ctx.stroke(); - ctx.setLineDash([]); - - // Dot on curve + value - const dataIdx = Math.min(viProfileData.length - 1, Math.max(0, Math.round(frac * (viProfileData.length - 1)))); - const val = viProfileData[dataIdx]; - const y = padTop + plotH - ((val - gMin) / range) * plotH; - ctx.fillStyle = themeColors.accent; - ctx.beginPath(); - ctx.arc(cssX, y, 3, 0, Math.PI * 2); - ctx.fill(); - - // Value readout label - const dist = frac * totalDist; - const label = `${formatNumber(val)} @ ${dist.toFixed(1)} ${xUnit}`; - ctx.font = "bold 9px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; - const textW = ctx.measureText(label).width; - const labelX = Math.min(cssX + 6, padLeft + plotW - textW - 2); - const labelY = padTop + 2; - ctx.fillStyle = isDark ? "rgba(0,0,0,0.7)" : "rgba(255,255,255,0.8)"; - ctx.fillRect(labelX - 2, labelY - 1, textW + 4, 11); - ctx.fillStyle = isDark ? "#fff" : "#000"; - ctx.textAlign = "left"; - ctx.textBaseline = "top"; - ctx.fillText(label, labelX, labelY); - - ctx.restore(); - }, [viProfileData, themeInfo.theme, themeColors.accent]); - - const handleViProfileMouseLeave = React.useCallback(() => { - const canvas = viProfileCanvasRef.current; - const base = viProfileBaseImageRef.current; - if (!canvas || !base) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - ctx.putImageData(base, 0, 0); - }, []); - - // VI Profile resize handlers - React.useEffect(() => { - if (!isResizingViProfile) return; - const handleMouseMove = (e: MouseEvent) => { - if (!viProfileResizeStart.current) return; - const deltaY = e.clientY - viProfileResizeStart.current.startY; - const newHeight = Math.max(40, Math.min(300, viProfileResizeStart.current.startHeight + deltaY)); - setViProfileHeight(newHeight); - }; - const handleMouseUp = () => { - setIsResizingViProfile(false); - viProfileResizeStart.current = null; - }; - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [isResizingViProfile]); - - // Generic zoom handler - const createZoomHandler = ( - setZoom: React.Dispatch>, - setPanX: React.Dispatch>, - setPanY: React.Dispatch>, - zoom: number, panX: number, panY: number, - canvasRef: React.RefObject, - ) => (e: React.WheelEvent) => { - e.preventDefault(); - const canvas = canvasRef.current; - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const mouseX = (e.clientX - rect.left) * (canvas.width / rect.width); - const mouseY = (e.clientY - rect.top) * (canvas.height / rect.height); - const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; - const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom * zoomFactor)); - const zoomRatio = newZoom / zoom; - setZoom(newZoom); - setPanX(mouseX - (mouseX - panX) * zoomRatio); - setPanY(mouseY - (mouseY - panY) * zoomRatio); - }; - - // ───────────────────────────────────────────────────────────────────────── - // Mouse Handlers - // ───────────────────────────────────────────────────────────────────────── - - // Helper: convert screen-pixel hit radius to image-pixel radius - // handleRadius=6 CSS px drawn, hit area ~10 CSS px → convert to image coords - const dpHitRadius = RESIZE_HIT_AREA_PX * Math.max(detCols, detRows) / canvasSize / dpZoom; - - // Helper: check if point is near the outer resize handle - const isNearResizeHandle = (imgX: number, imgY: number): boolean => { - if (roiMode === "rect") { - // For rectangle, check near bottom-right corner - const handleX = roiCenterCol + roiWidth / 2; - const handleY = roiCenterRow + roiHeight / 2; - const dist = Math.sqrt((imgX - handleX) ** 2 + (imgY - handleY) ** 2); - return dist < dpHitRadius; - } - if ((roiMode !== "circle" && roiMode !== "square" && roiMode !== "annular") || !roiRadius) return false; - const offset = roiMode === "square" ? roiRadius : roiRadius * CIRCLE_HANDLE_ANGLE; - const handleX = roiCenterCol + offset; - const handleY = roiCenterRow + offset; - const dist = Math.sqrt((imgX - handleX) ** 2 + (imgY - handleY) ** 2); - return dist < dpHitRadius; - }; - - // Helper: check if point is near the inner resize handle (annular mode only) - const isNearResizeHandleInner = (imgX: number, imgY: number): boolean => { - if (roiMode !== "annular" || !roiRadiusInner) return false; - const offset = roiRadiusInner * CIRCLE_HANDLE_ANGLE; - const handleX = roiCenterCol + offset; - const handleY = roiCenterRow + offset; - const dist = Math.sqrt((imgX - handleX) ** 2 + (imgY - handleY) ** 2); - return dist < dpHitRadius; - }; - - // Helper: check if point is near VI ROI resize handle (same logic as DP) - // Hit area is capped to avoid overlap with center for small ROIs - const viHitRadius = RESIZE_HIT_AREA_PX * Math.max(shapeRows, shapeCols) / canvasSize / viZoom; - const isNearViRoiResizeHandle = (imgX: number, imgY: number): boolean => { - if (!viRoiMode || viRoiMode === "off") return false; - if (viRoiMode === "rect") { - const halfH = (viRoiHeight || 10) / 2; - const halfW = (viRoiWidth || 10) / 2; - const handleX = localViRoiCenterRow + halfH; - const handleY = localViRoiCenterCol + halfW; - const dist = Math.sqrt((imgX - handleX) ** 2 + (imgY - handleY) ** 2); - const cornerDist = Math.sqrt(halfW ** 2 + halfH ** 2); - const hitArea = Math.min(viHitRadius, cornerDist * 0.5); - return dist < hitArea; - } - if (viRoiMode === "circle" || viRoiMode === "square") { - const radius = viRoiRadius || 5; - const offset = viRoiMode === "square" ? radius : radius * CIRCLE_HANDLE_ANGLE; - const handleX = localViRoiCenterRow + offset; - const handleY = localViRoiCenterCol + offset; - const dist = Math.sqrt((imgX - handleX) ** 2 + (imgY - handleY) ** 2); - // Cap hit area to 50% of radius so center remains draggable - const hitArea = Math.min(viHitRadius, radius * 0.5); - return dist < hitArea; - } - return false; - }; - - // Helper: check if point is inside the DP ROI area - const isInsideDpRoi = (imgX: number, imgY: number): boolean => { - if (roiMode === "point") return false; - const dx = imgX - roiCenterCol; - const dy = imgY - roiCenterRow; - if (roiMode === "circle") return Math.sqrt(dx * dx + dy * dy) <= (roiRadius || 5); - if (roiMode === "square") return Math.abs(dx) <= (roiRadius || 5) && Math.abs(dy) <= (roiRadius || 5); - if (roiMode === "annular") { const d = Math.sqrt(dx * dx + dy * dy); return d <= (roiRadius || 20) && d >= (roiRadiusInner || 5); } - if (roiMode === "rect") return Math.abs(dx) <= (roiWidth || 10) / 2 && Math.abs(dy) <= (roiHeight || 10) / 2; - return false; - }; - - // Helper: check if point is inside the VI ROI area - const isInsideViRoi = (imgX: number, imgY: number): boolean => { - if (!viRoiMode || viRoiMode === "off") return false; - const dx = imgY - localViRoiCenterCol; - const dy = imgX - localViRoiCenterRow; - if (viRoiMode === "circle") return Math.sqrt(dx * dx + dy * dy) <= (viRoiRadius || 5); - if (viRoiMode === "square") return Math.abs(dx) <= (viRoiRadius || 5) && Math.abs(dy) <= (viRoiRadius || 5); - if (viRoiMode === "rect") return Math.abs(dx) <= (viRoiWidth || 10) / 2 && Math.abs(dy) <= (viRoiHeight || 10) / 2; - return false; - }; - - // Mouse handlers - const handleDpMouseDown = (e: React.MouseEvent) => { - dpClickStartRef.current = { x: e.clientX, y: e.clientY }; - const canvas = dpOverlayRef.current; - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const screenX = (e.clientX - rect.left) * (canvas.width / rect.width); - const screenY = (e.clientY - rect.top) * (canvas.height / rect.height); - const imgX = (screenX - dpPanX) / dpZoom; - const imgY = (screenY - dpPanY) / dpZoom; - - // When profile mode is active, use profile interactions only - if (profileActive) { - if (profilePoints.length === 2) { - const p0 = profilePoints[0]; - const p1 = profilePoints[1]; - const hitRadius = 10 / dpZoom; - const d0 = Math.sqrt((imgX - p0.col) ** 2 + (imgY - p0.row) ** 2); - const d1 = Math.sqrt((imgX - p1.col) ** 2 + (imgY - p1.row) ** 2); - if (d0 <= hitRadius || d1 <= hitRadius) { - setDraggingDpProfileEndpoint(d0 <= d1 ? 0 : 1); - setIsDraggingDP(false); - return; - } - if (pointToSegmentDistance(imgX, imgY, p0.col, p0.row, p1.col, p1.row) <= hitRadius) { - setIsDraggingDpProfileLine(true); - dpProfileDragStartRef.current = { - row: imgY, - col: imgX, - p0: { row: p0.row, col: p0.col }, - p1: { row: p1.row, col: p1.col }, - }; - setIsDraggingDP(false); - return; - } - } - setIsDraggingDP(false); - return; - } - - // Check if clicking on resize handle (inner first, then outer) - if (isNearResizeHandleInner(imgX, imgY)) { - setIsDraggingResizeInner(true); - return; - } - if (isNearResizeHandle(imgX, imgY)) { - e.preventDefault(); - resizeAspectRef.current = roiMode === "rect" && roiWidth > 0 && roiHeight > 0 ? roiWidth / roiHeight : null; - setIsDraggingResize(true); - return; - } - - setIsDraggingDP(true); - // If clicking inside the ROI, drag with offset (grab-and-drag) - if (roiMode !== "off" && roiMode !== "point" && isInsideDpRoi(imgX, imgY)) { - dpDragOffsetRef.current = { dRow: imgY - roiCenterRow, dCol: imgX - roiCenterCol }; - return; - } - // Clicking outside ROI — teleport center to click position - dpDragOffsetRef.current = { dRow: 0, dCol: 0 }; - setLocalKCol(imgX); setLocalKRow(imgY); - // Use compound roi_center trait [row, col] - single observer fires in Python - const newCol = Math.round(Math.max(0, Math.min(detCols - 1, imgX))); - const newRow = Math.round(Math.max(0, Math.min(detRows - 1, imgY))); - model.set("roi_active", true); - model.set("roi_center", [newRow, newCol]); - model.save_changes(); - }; - - const handleDpMouseMove = (e: React.MouseEvent) => { - const canvas = dpOverlayRef.current; - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const screenX = (e.clientX - rect.left) * (canvas.width / rect.width); - const screenY = (e.clientY - rect.top) * (canvas.height / rect.height); - const imgX = (screenX - dpPanX) / dpZoom; - const imgY = (screenY - dpPanY) / dpZoom; - - // Fast path: skip cursor readout during any active drag — avoids setCursorInfo re-renders - const anyDrag = isDraggingDP || isDraggingResize || isDraggingResizeInner - || draggingDpProfileEndpoint !== null || isDraggingDpProfileLine; - - // Cursor readout: look up raw DP value at pixel position - if (!anyDrag) { - const pxCol = Math.floor(imgX); - const pxRow = Math.floor(imgY); - if (pxCol >= 0 && pxCol < detCols && pxRow >= 0 && pxRow < detRows && frameBytes) { - const usesViRoiDp = viRoiMode && viRoiMode !== "off" && viRoiDpBytes && viRoiDpBytes.byteLength > 0; - const sourceBytes = usesViRoiDp ? viRoiDpBytes : frameBytes; - const raw = new Float32Array(sourceBytes.buffer, sourceBytes.byteOffset, sourceBytes.byteLength / 4); - setCursorInfo({ row: pxRow, col: pxCol, value: raw[pxRow * detCols + pxCol], panel: "DP" }); - } else { - setCursorInfo(null); - } - } - - if (profileActive && profilePoints.length === 2) { - const p0 = profilePoints[0]; - const p1 = profilePoints[1]; - const hitRadius = 10 / dpZoom; - const d0 = Math.sqrt((imgX - p0.col) ** 2 + (imgY - p0.row) ** 2); - const d1 = Math.sqrt((imgX - p1.col) ** 2 + (imgY - p1.row) ** 2); - if (draggingDpProfileEndpoint !== null) { - if (!rawDpDataRef.current) return; - const clampedRow = Math.max(0, Math.min(detRows - 1, imgY)); - const clampedCol = Math.max(0, Math.min(detCols - 1, imgX)); - const next = [ - draggingDpProfileEndpoint === 0 ? { row: clampedRow, col: clampedCol } : profilePoints[0], - draggingDpProfileEndpoint === 1 ? { row: clampedRow, col: clampedCol } : profilePoints[1], - ]; - setProfileLine(next); - setProfileData(sampleLineProfile(rawDpDataRef.current, detCols, detRows, next[0].row, next[0].col, next[1].row, next[1].col, profileWidth)); - return; - } - if (isDraggingDpProfileLine && dpProfileDragStartRef.current) { - if (!rawDpDataRef.current) return; - const drag = dpProfileDragStartRef.current; - let deltaRow = imgY - drag.row; - let deltaCol = imgX - drag.col; - const minRow = Math.min(drag.p0.row, drag.p1.row); - const maxRow = Math.max(drag.p0.row, drag.p1.row); - const minCol = Math.min(drag.p0.col, drag.p1.col); - const maxCol = Math.max(drag.p0.col, drag.p1.col); - deltaRow = Math.max(deltaRow, -minRow); - deltaRow = Math.min(deltaRow, (detRows - 1) - maxRow); - deltaCol = Math.max(deltaCol, -minCol); - deltaCol = Math.min(deltaCol, (detCols - 1) - maxCol); - const next = [ - { row: drag.p0.row + deltaRow, col: drag.p0.col + deltaCol }, - { row: drag.p1.row + deltaRow, col: drag.p1.col + deltaCol }, - ]; - setProfileLine(next); - setProfileData(sampleLineProfile(rawDpDataRef.current, detCols, detRows, next[0].row, next[0].col, next[1].row, next[1].col, profileWidth)); - return; - } - const nextHoveredEndpoint: 0 | 1 | null = d0 <= hitRadius ? 0 : d1 <= hitRadius ? 1 : null; - const nextHoverLine = nextHoveredEndpoint === null && pointToSegmentDistance(imgX, imgY, p0.col, p0.row, p1.col, p1.row) <= hitRadius; - setHoveredDpProfileEndpoint(nextHoveredEndpoint); - setIsHoveringDpProfileLine(nextHoverLine); - return; - } else { - if (hoveredDpProfileEndpoint !== null) setHoveredDpProfileEndpoint(null); - if (isHoveringDpProfileLine) setIsHoveringDpProfileLine(false); - } - - // Handle inner resize dragging (annular mode) - if (isDraggingResizeInner) { - const dx = Math.abs(imgX - roiCenterCol); - const dy = Math.abs(imgY - roiCenterRow); - const newRadius = Math.sqrt(dx ** 2 + dy ** 2); - // Inner radius must be less than outer radius - setRoiRadiusInner(Math.max(1, Math.min(roiRadius - 1, Math.round(newRadius)))); - return; - } - - // Handle outer resize dragging - use model state center, not local values - if (isDraggingResize) { - const dx = Math.abs(imgX - roiCenterCol); - const dy = Math.abs(imgY - roiCenterRow); - if (roiMode === "rect") { - let newW = Math.max(2, Math.round(dx * 2)); - let newH = Math.max(2, Math.round(dy * 2)); - if (e.shiftKey && resizeAspectRef.current != null) { - const aspect = resizeAspectRef.current; - if (newW / newH > aspect) newH = Math.max(2, Math.round(newW / aspect)); - else newW = Math.max(2, Math.round(newH * aspect)); - } - setRoiWidth(newW); - setRoiHeight(newH); - } else { - const newRadius = roiMode === "square" ? Math.max(dx, dy) : Math.sqrt(dx ** 2 + dy ** 2); - // For annular mode, outer radius must be greater than inner radius - const minRadius = roiMode === "annular" ? (roiRadiusInner || 0) + 1 : 1; - setRoiRadius(Math.max(minRadius, Math.round(newRadius))); - } - return; - } - - // Check hover state for resize handles - if (!isDraggingDP) { - setIsHoveringResizeInner(isNearResizeHandleInner(imgX, imgY)); - setIsHoveringResize(isNearResizeHandle(imgX, imgY)); - return; - } - - const centerCol = imgX - dpDragOffsetRef.current.dCol; - const centerRow = imgY - dpDragOffsetRef.current.dRow; - setLocalKCol(centerCol); setLocalKRow(centerRow); - // rAF-coalesced — sends only the latest roi_center per frame. - const newCol = Math.round(Math.max(0, Math.min(detCols - 1, centerCol))); - const newRow = Math.round(Math.max(0, Math.min(detRows - 1, centerRow))); - queueRoiCenter(newRow, newCol); - }; - - const handleDpMouseUp = (e: React.MouseEvent) => { - if (draggingDpProfileEndpoint !== null || isDraggingDpProfileLine) { - setDraggingDpProfileEndpoint(null); - setIsDraggingDpProfileLine(false); - dpProfileDragStartRef.current = null; - dpClickStartRef.current = null; - setIsDraggingDP(false); - setIsDraggingResize(false); - setIsDraggingResizeInner(false); - setHoveredDpProfileEndpoint(null); - setIsHoveringDpProfileLine(false); - return; - } - - // Profile click capture - if (profileActive && dpClickStartRef.current) { - const dx = e.clientX - dpClickStartRef.current.x; - const dy = e.clientY - dpClickStartRef.current.y; - if (Math.sqrt(dx * dx + dy * dy) < 3) { - const canvas = dpOverlayRef.current; - if (canvas && rawDpDataRef.current) { - const rect = canvas.getBoundingClientRect(); - const screenX = (e.clientX - rect.left) * (canvas.width / rect.width); - const screenY = (e.clientY - rect.top) * (canvas.height / rect.height); - const imgCol = (screenX - dpPanX) / dpZoom; - const imgRow = (screenY - dpPanY) / dpZoom; - if (imgCol >= 0 && imgCol < detCols && imgRow >= 0 && imgRow < detRows) { - const pt = { row: imgRow, col: imgCol }; - if (profilePoints.length === 0 || profilePoints.length === 2) { - setProfileLine([pt]); - setProfileData(null); - } else { - const p0 = profilePoints[0]; - setProfileLine([p0, pt]); - setProfileData(sampleLineProfile(rawDpDataRef.current, detCols, detRows, p0.row, p0.col, pt.row, pt.col, profileWidth)); - } - } - } - } - } - dpClickStartRef.current = null; - setIsDraggingDP(false); setIsDraggingResize(false); setIsDraggingResizeInner(false); - setDraggingDpProfileEndpoint(null); - setIsDraggingDpProfileLine(false); - setHoveredDpProfileEndpoint(null); - setIsHoveringDpProfileLine(false); - dpProfileDragStartRef.current = null; - }; - const handleDpMouseLeave = () => { - dpClickStartRef.current = null; - setIsDraggingDP(false); setIsDraggingResize(false); setIsDraggingResizeInner(false); - setDraggingDpProfileEndpoint(null); - setIsDraggingDpProfileLine(false); - setHoveredDpProfileEndpoint(null); - setIsHoveringDpProfileLine(false); - dpProfileDragStartRef.current = null; - setIsHoveringResize(false); setIsHoveringResizeInner(false); - setCursorInfo(prev => prev?.panel === "DP" ? null : prev); - }; - const handleDpDoubleClick = () => { - setDpZoom(1); - setDpPanX(0); - setDpPanY(0); - }; - - const handleViMouseDown = (e: React.MouseEvent) => { - const canvas = virtualOverlayRef.current; - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const screenX = (e.clientX - rect.left) * (canvas.width / rect.width); - const screenY = (e.clientY - rect.top) * (canvas.height / rect.height); - const imgX = (screenY - viPanY) / viZoom; - const imgY = (screenX - viPanX) / viZoom; - - // VI Profile mode - click to set points - if (viProfileActive) { - viClickStartRef.current = { x: screenX, y: screenY }; - if (viProfilePoints.length === 2) { - const p0 = viProfilePoints[0]; - const p1 = viProfilePoints[1]; - const hitRadius = 10 / viZoom; - const d0 = Math.sqrt((imgY - p0.col) ** 2 + (imgX - p0.row) ** 2); - const d1 = Math.sqrt((imgY - p1.col) ** 2 + (imgX - p1.row) ** 2); - if (d0 <= hitRadius || d1 <= hitRadius) { - setDraggingViProfileEndpoint(d0 <= d1 ? 0 : 1); - setIsDraggingVI(false); - return; - } - if (pointToSegmentDistance(imgY, imgX, p0.col, p0.row, p1.col, p1.row) <= hitRadius) { - setIsDraggingViProfileLine(true); - viProfileDragStartRef.current = { - row: imgX, - col: imgY, - p0: { row: p0.row, col: p0.col }, - p1: { row: p1.row, col: p1.col }, - }; - setIsDraggingVI(false); - return; - } - } - return; - } - - // Check if VI ROI mode is active - same logic as DP - if (viRoiMode && viRoiMode !== "off") { - // Check if clicking on resize handle - if (isNearViRoiResizeHandle(imgX, imgY)) { - setIsDraggingViRoiResize(true); - return; - } - - // Grab-and-drag if clicking inside VI ROI, otherwise teleport - setIsDraggingViRoi(true); - if (isInsideViRoi(imgX, imgY)) { - viRoiDragOffsetRef.current = { dRow: imgX - localViRoiCenterRow, dCol: imgY - localViRoiCenterCol }; - } else { - viRoiDragOffsetRef.current = { dRow: 0, dCol: 0 }; - setLocalViRoiCenterRow(imgX); - setLocalViRoiCenterCol(imgY); - setViRoiCenterRow(Math.round(Math.max(0, Math.min(shapeRows - 1, imgX)))); - setViRoiCenterCol(Math.round(Math.max(0, Math.min(shapeCols - 1, imgY)))); - } - return; - } - - // Regular position selection (when ROI is off) - setIsDraggingVI(true); - setLocalPosRow(imgX); setLocalPosCol(imgY); - // Batch X and Y updates into a single sync - const newX = Math.round(Math.max(0, Math.min(shapeRows - 1, imgX))); - const newY = Math.round(Math.max(0, Math.min(shapeCols - 1, imgY))); - model.set("pos_row", newX); - model.set("pos_col", newY); - model.save_changes(); - }; - - const handleViMouseMove = (e: React.MouseEvent) => { - const canvas = virtualOverlayRef.current; - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const screenX = (e.clientX - rect.left) * (canvas.width / rect.width); - const screenY = (e.clientY - rect.top) * (canvas.height / rect.height); - const imgX = (screenY - viPanY) / viZoom; - const imgY = (screenX - viPanX) / viZoom; - - // Fast path: skip cursor readout during any active drag — avoids setCursorInfo re-renders - const anyViDrag = isDraggingVI || isDraggingViRoi || isDraggingViRoiResize - || draggingViProfileEndpoint !== null || isDraggingViProfileLine; - - // Cursor readout: look up raw VI value at pixel position - // imgX = row, imgY = col (swapped coordinate convention) - if (!anyViDrag) { - const pxRow = Math.floor(imgX); - const pxCol = Math.floor(imgY); - if (pxRow >= 0 && pxRow < shapeRows && pxCol >= 0 && pxCol < shapeCols && rawVirtualImageRef.current) { - const raw = rawVirtualImageRef.current; - setCursorInfo({ row: pxRow, col: pxCol, value: raw[pxRow * shapeCols + pxCol], panel: "VI" }); - } else { - setCursorInfo(prev => prev?.panel === "VI" ? null : prev); - } - } - - if (viProfileActive && viProfilePoints.length === 2) { - const p0 = viProfilePoints[0]; - const p1 = viProfilePoints[1]; - const hitRadius = 10 / viZoom; - const d0 = Math.sqrt((imgY - p0.col) ** 2 + (imgX - p0.row) ** 2); - const d1 = Math.sqrt((imgY - p1.col) ** 2 + (imgX - p1.row) ** 2); - if (draggingViProfileEndpoint !== null) { - const clampedRow = Math.max(0, Math.min(shapeRows - 1, imgX)); - const clampedCol = Math.max(0, Math.min(shapeCols - 1, imgY)); - const next = [ - draggingViProfileEndpoint === 0 ? { row: clampedRow, col: clampedCol } : viProfilePoints[0], - draggingViProfileEndpoint === 1 ? { row: clampedRow, col: clampedCol } : viProfilePoints[1], - ]; - setViProfilePoints(next); - return; - } - if (isDraggingViProfileLine && viProfileDragStartRef.current) { - const drag = viProfileDragStartRef.current; - let deltaRow = imgX - drag.row; - let deltaCol = imgY - drag.col; - const minRow = Math.min(drag.p0.row, drag.p1.row); - const maxRow = Math.max(drag.p0.row, drag.p1.row); - const minCol = Math.min(drag.p0.col, drag.p1.col); - const maxCol = Math.max(drag.p0.col, drag.p1.col); - deltaRow = Math.max(deltaRow, -minRow); - deltaRow = Math.min(deltaRow, (shapeRows - 1) - maxRow); - deltaCol = Math.max(deltaCol, -minCol); - deltaCol = Math.min(deltaCol, (shapeCols - 1) - maxCol); - const next = [ - { row: drag.p0.row + deltaRow, col: drag.p0.col + deltaCol }, - { row: drag.p1.row + deltaRow, col: drag.p1.col + deltaCol }, - ]; - setViProfilePoints(next); - return; - } - const nextHoveredEndpoint: 0 | 1 | null = d0 <= hitRadius ? 0 : d1 <= hitRadius ? 1 : null; - const nextHoverLine = nextHoveredEndpoint === null && pointToSegmentDistance(imgY, imgX, p0.col, p0.row, p1.col, p1.row) <= hitRadius; - setHoveredViProfileEndpoint(nextHoveredEndpoint); - setIsHoveringViProfileLine(nextHoverLine); - return; - } else { - if (hoveredViProfileEndpoint !== null) setHoveredViProfileEndpoint(null); - if (isHoveringViProfileLine) setIsHoveringViProfileLine(false); - } - - // Handle VI ROI resize dragging (same pattern as DP) - if (isDraggingViRoiResize) { - const dx = Math.abs(imgX - localViRoiCenterRow); - const dy = Math.abs(imgY - localViRoiCenterCol); - if (viRoiMode === "rect") { - setViRoiWidth(Math.max(2, Math.round(dy * 2))); - setViRoiHeight(Math.max(2, Math.round(dx * 2))); - } else if (viRoiMode === "square") { - const newHalfSize = Math.max(dx, dy); - setViRoiRadius(Math.max(1, Math.round(newHalfSize))); - } else { - // circle - const newRadius = Math.sqrt(dx ** 2 + dy ** 2); - setViRoiRadius(Math.max(1, Math.round(newRadius))); - } - return; - } - - // Check hover state for resize handles (same as DP) - if (!isDraggingViRoi) { - setIsHoveringViRoiResize(isNearViRoiResizeHandle(imgX, imgY)); - if (viRoiMode && viRoiMode !== "off") return; // Don't update position when ROI active - } - - // Handle VI ROI center dragging (same as DP — with offset) - if (isDraggingViRoi) { - const centerRow = imgX - viRoiDragOffsetRef.current.dRow; - const centerCol = imgY - viRoiDragOffsetRef.current.dCol; - setLocalViRoiCenterRow(centerRow); - setLocalViRoiCenterCol(centerCol); - // Compound trait update — single observer fires Python-side; reduced DP is - // never computed against split-trait state (old col + new row, or vice versa). - const newViX = Math.round(Math.max(0, Math.min(shapeRows - 1, centerRow))); - const newViY = Math.round(Math.max(0, Math.min(shapeCols - 1, centerCol))); - model.set("vi_roi_center", [newViX, newViY]); - model.save_changes(); - return; - } - - // Handle regular position dragging (when ROI is off) - if (!isDraggingVI) return; - setLocalPosRow(imgX); setLocalPosCol(imgY); - // Batch position updates into a single sync - const newX = Math.round(Math.max(0, Math.min(shapeRows - 1, imgX))); - const newY = Math.round(Math.max(0, Math.min(shapeCols - 1, imgY))); - model.set("pos_row", newX); - model.set("pos_col", newY); - model.save_changes(); - }; - - const handleViMouseUp = (e: React.MouseEvent) => { - if (draggingViProfileEndpoint !== null || isDraggingViProfileLine) { - setDraggingViProfileEndpoint(null); - setIsDraggingViProfileLine(false); - viProfileDragStartRef.current = null; - viClickStartRef.current = null; - setIsDraggingVI(false); - setIsDraggingViRoi(false); - setIsDraggingViRoiResize(false); - setHoveredViProfileEndpoint(null); - setIsHoveringViProfileLine(false); - return; - } - - // VI Profile mode - complete point selection - if (viProfileActive && viClickStartRef.current) { - const canvas = virtualOverlayRef.current; - if (canvas) { - const rect = canvas.getBoundingClientRect(); - const endX = (e.clientX - rect.left) * (canvas.width / rect.width); - const endY = (e.clientY - rect.top) * (canvas.height / rect.height); - const dx = endX - viClickStartRef.current.x; - const dy = endY - viClickStartRef.current.y; - const wasDrag = Math.sqrt(dx * dx + dy * dy) > 3; - - if (!wasDrag) { - // Click to add point - const imgX = (endY - viPanY) / viZoom; - const imgY = (endX - viPanX) / viZoom; - const pt = { row: Math.round(Math.max(0, Math.min(shapeRows - 1, imgX))), col: Math.round(Math.max(0, Math.min(shapeCols - 1, imgY))) }; - if (viProfilePoints.length < 2) { - setViProfilePoints([...viProfilePoints, pt]); - } else { - setViProfilePoints([pt]); - } - } - } - viClickStartRef.current = null; - } - - setDraggingViProfileEndpoint(null); - setIsDraggingViProfileLine(false); - setHoveredViProfileEndpoint(null); - setIsHoveringViProfileLine(false); - viProfileDragStartRef.current = null; - setIsDraggingVI(false); - setIsDraggingViRoi(false); - setIsDraggingViRoiResize(false); - }; - const handleViMouseLeave = () => { - viClickStartRef.current = null; - setDraggingViProfileEndpoint(null); - setIsDraggingViProfileLine(false); - setHoveredViProfileEndpoint(null); - setIsHoveringViProfileLine(false); - viProfileDragStartRef.current = null; - setIsDraggingVI(false); - setIsDraggingViRoi(false); - setIsDraggingViRoiResize(false); - setIsHoveringViRoiResize(false); - setCursorInfo(prev => prev?.panel === "VI" ? null : prev); - }; - const handleViDoubleClick = () => { - setViZoom(1); - setViPanX(0); - setViPanY(0); - }; - const handleFftDoubleClick = () => { - setFftZoom(1); - setFftPanX(0); - setFftPanY(0); - setFftClickInfo(null); - }; - - // FFT drag-to-pan handlers - const handleFftMouseDown = (e: React.MouseEvent) => { - fftClickStartRef.current = { x: e.clientX, y: e.clientY }; - setIsDraggingFFT(true); - setFftDragStart({ x: e.clientX, y: e.clientY, panX: fftPanX, panY: fftPanY }); - }; - - const handleFftMouseMove = (e: React.MouseEvent) => { - if (!isDraggingFFT || !fftDragStart) return; - const canvas = fftOverlayRef.current; - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const scaleX = canvas.width / rect.width; - const scaleY = canvas.height / rect.height; - const dx = (e.clientX - fftDragStart.x) * scaleX; - const dy = (e.clientY - fftDragStart.y) * scaleY; - setFftPanX(fftDragStart.panX + dx); - setFftPanY(fftDragStart.panY + dy); - }; - - const handleFftMouseUp = (e: React.MouseEvent) => { - // Click detection for d-spacing measurement - if (fftClickStartRef.current) { - const dx = e.clientX - fftClickStartRef.current.x; - const dy = e.clientY - fftClickStartRef.current.y; - if (Math.sqrt(dx * dx + dy * dy) < 3) { - // Convert screen coords to FFT image coords - const canvas = fftOverlayRef.current; - if (canvas) { - const rect = canvas.getBoundingClientRect(); - const scaleX = canvas.width / rect.width; - const scaleY = canvas.height / rect.height; - const canvasX = (e.clientX - rect.left) * scaleX; - const canvasY = (e.clientY - rect.top) * scaleY; - const fftW = fftCropDims?.fftWidth ?? shapeCols; - const fftH = fftCropDims?.fftHeight ?? shapeRows; - // Reverse the render transform: canvas coords -> image coords. - // Render: translate(panX, panY); scale(zoom); drawImage(offscreen, 0,0,fftW,fftH, 0,0,canvasW,canvasH) - // So: canvasX = panX + zoom * (imgCol * canvasW / fftW) → imgCol = (canvasX - panX) / zoom * fftW / canvasW - let imgCol = ((canvasX - fftPanX) / fftZoom) * (fftW / canvas.width); - let imgRow = ((canvasY - fftPanY) / fftZoom) * (fftH / canvas.height); - // Bounds check - if (imgCol >= 0 && imgCol < fftW && imgRow >= 0 && imgRow < fftH) { - // Snap to nearest peak in FFT magnitude - if (fftMagCacheRef.current) { - const snapped = findFFTPeak(fftMagCacheRef.current, fftW, fftH, imgCol, imgRow, FFT_SNAP_RADIUS); - imgCol = snapped.col; - imgRow = snapped.row; - } - const halfW = Math.floor(fftW / 2); - const halfH = Math.floor(fftH / 2); - const dcol = imgCol - halfW; - const drow = imgRow - halfH; - const distPx = Math.sqrt(dcol * dcol + drow * drow); - if (distPx < 1) { - setFftClickInfo(null); // Clicked on DC center - } else { - let spatialFreq: number | null = null; - let dSpacing: number | null = null; - if (pixelSize > 0) { - const paddedW = nextPow2(fftW); - const paddedH = nextPow2(fftH); - const binC = ((Math.round(imgCol) - halfW) % fftW + fftW) % fftW; - const binR = ((Math.round(imgRow) - halfH) % fftH + fftH) % fftH; - const freqC = binC <= paddedW / 2 ? binC / (paddedW * pixelSize) : (binC - paddedW) / (paddedW * pixelSize); - const freqR = binR <= paddedH / 2 ? binR / (paddedH * pixelSize) : (binR - paddedH) / (paddedH * pixelSize); - spatialFreq = Math.sqrt(freqC * freqC + freqR * freqR); - dSpacing = spatialFreq > 0 ? 1 / spatialFreq : null; - } - setFftClickInfo({ row: imgRow, col: imgCol, distPx, spatialFreq, dSpacing }); - } - } - } - } - fftClickStartRef.current = null; - } - setIsDraggingFFT(false); - setFftDragStart(null); - }; - const handleFftMouseLeave = () => { fftClickStartRef.current = null; setIsDraggingFFT(false); setFftDragStart(null); }; - - // ── Canvas resize handlers ── - const handleCanvasResizeStart = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - setIsResizingCanvas(true); - setResizeCanvasStart({ x: e.clientX, y: e.clientY, size: canvasSize }); - }; - - React.useEffect(() => { - if (!isResizingCanvas) return; - let rafId = 0; - let latestSize = resizeCanvasStart ? resizeCanvasStart.size : canvasSize; - const handleMouseMove = (e: MouseEvent) => { - if (!resizeCanvasStart) return; - const delta = Math.max(e.clientX - resizeCanvasStart.x, e.clientY - resizeCanvasStart.y); - latestSize = Math.max(CANVAS_SIZE, resizeCanvasStart.size + delta); - if (!rafId) { - rafId = requestAnimationFrame(() => { - rafId = 0; - setCanvasSize(latestSize); - }); - } - }; - const handleMouseUp = () => { - cancelAnimationFrame(rafId); - setCanvasSize(latestSize); - setIsResizingCanvas(false); - setResizeCanvasStart(null); - }; - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - return () => { - cancelAnimationFrame(rafId); - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [isResizingCanvas, resizeCanvasStart]); - - // ───────────────────────────────────────────────────────────────────────── - // Render - // ───────────────────────────────────────────────────────────────────────── - - // Export DP handler - const handleExportDP = async () => { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); - const zip = new JSZip(); - const metadata = { - metadata_version: "1.0", - widget_name: "Show4DSTEM", - widget_version: widgetVersion || "unknown", - exported_at: new Date().toISOString(), - view: "diffraction", - format: "zip", - export_kind: "single_view_png_zip", - position: { row: posRow, col: posCol }, - frame_idx: frameIdx, - n_frames: nFrames, - scan_shape: { rows: shapeRows, cols: shapeCols }, - detector_shape: { rows: detRows, cols: detCols }, - roi: { - active: roiMode !== "off", - mode: roiMode, - center_row: roiCenterRow, - center_col: roiCenterCol, - radius: roiRadius, - radius_inner: roiRadiusInner, - width: roiWidth, - height: roiHeight, - }, - vi_roi: { - mode: viRoiMode, - center_row: viRoiCenterRow, - center_col: viRoiCenterCol, - radius: viRoiRadius, - width: viRoiWidth, - height: viRoiHeight, - }, - calibration: { - pixel_size_angstrom: pixelSize, - pixel_size_unit: "Å/px", - k_pixel_size: kPixelSize, - k_pixel_size_unit: kCalibrated ? "mrad/px" : "px/px", - k_calibrated: kCalibrated, - center_row: centerRow, - center_col: centerCol, - bf_radius: bfRadius, - }, - display: { - diffraction: { - colormap: dpColormap, - scale_mode: dpScaleMode, - vmin_pct: dpVminPct, - vmax_pct: dpVmaxPct, - }, - }, - }; - zip.file("metadata.json", JSON.stringify(metadata, null, 2)); - const canvasToBlob = (canvas: HTMLCanvasElement): Promise => new Promise((resolve) => canvas.toBlob((blob) => resolve(blob!), 'image/png')); - if (dpCanvasRef.current) zip.file("diffraction_pattern.png", await canvasToBlob(dpCanvasRef.current)); - const zipBlob = await zip.generateAsync({ type: "blob" }); - downloadBlob(zipBlob, `dp_export_${timestamp}.zip`); - }; - - // Export VI handler - const handleExportVI = async () => { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); - const zip = new JSZip(); - const metadata = { - metadata_version: "1.0", - widget_name: "Show4DSTEM", - widget_version: widgetVersion || "unknown", - exported_at: new Date().toISOString(), - view: "all", - format: "zip", - export_kind: "multi_panel_png_zip", - position: { row: posRow, col: posCol }, - frame_idx: frameIdx, - n_frames: nFrames, - scan_shape: { rows: shapeRows, cols: shapeCols }, - detector_shape: { rows: detRows, cols: detCols }, - roi: { - active: roiMode !== "off", - mode: roiMode, - center_row: roiCenterRow, - center_col: roiCenterCol, - radius: roiRadius, - radius_inner: roiRadiusInner, - width: roiWidth, - height: roiHeight, - }, - vi_roi: { - mode: viRoiMode, - center_row: viRoiCenterRow, - center_col: viRoiCenterCol, - radius: viRoiRadius, - width: viRoiWidth, - height: viRoiHeight, - }, - calibration: { - pixel_size_angstrom: pixelSize, - pixel_size_unit: "Å/px", - k_pixel_size: kPixelSize, - k_pixel_size_unit: kCalibrated ? "mrad/px" : "px/px", - k_calibrated: kCalibrated, - center_row: centerRow, - center_col: centerCol, - bf_radius: bfRadius, - }, - display: { - diffraction: { - colormap: dpColormap, - scale_mode: dpScaleMode, - vmin_pct: dpVminPct, - vmax_pct: dpVmaxPct, - }, - virtual: { - colormap: viColormap, - scale_mode: viScaleMode, - vmin_pct: viVminPct, - vmax_pct: viVmaxPct, - }, - fft: { - colormap: fftColormap, - scale_mode: fftScaleMode, - auto: fftAuto, - vmin_pct: fftVminPct, - vmax_pct: fftVmaxPct, - }, - }, - }; - zip.file("metadata.json", JSON.stringify(metadata, null, 2)); - const canvasToBlob = (canvas: HTMLCanvasElement): Promise => new Promise((resolve) => canvas.toBlob((blob) => resolve(blob!), 'image/png')); - if (virtualCanvasRef.current) zip.file("virtual_image.png", await canvasToBlob(virtualCanvasRef.current)); - if (dpCanvasRef.current) zip.file("diffraction_pattern.png", await canvasToBlob(dpCanvasRef.current)); - if (fftCanvasRef.current) zip.file("fft.png", await canvasToBlob(fftCanvasRef.current)); - const zipBlob = await zip.generateAsync({ type: "blob" }); - downloadBlob(zipBlob, `4dstem_export_${timestamp}.zip`); - }; - - // ── DP Figure Export ── - const handleDpExportFigure = (withColorbar: boolean) => { - setDpExportAnchor(null); - const frameData = rawDpDataRef.current; - if (!frameData) return; - const processed = dpScaleMode === "log" ? applyLogScale(frameData) : frameData; - const lut = COLORMAPS[dpColormap] || COLORMAPS.inferno; - const { min: dMin, max: dMax } = findDataRange(processed); - let vmin: number, vmax: number; - if (traitDpVmin != null && traitDpVmax != null) { - if (dpScaleMode === "log") { - vmin = Math.log1p(Math.max(traitDpVmin, 0)); - vmax = Math.log1p(Math.max(traitDpVmax, 0)); - } else { - vmin = traitDpVmin; - vmax = traitDpVmax; - } - } else { - ({ vmin, vmax } = sliderRange(dMin, dMax, dpVminPct, dpVmaxPct)); - } - const offscreen = renderToOffscreen(processed, detCols, detRows, lut, vmin, vmax); - if (!offscreen) return; - const kPxAngstrom = kPixelSize > 0 && kCalibrated ? kPixelSize : 0; - const figCanvas = exportFigure({ - imageCanvas: offscreen, - title: `DP at (${posRow}, ${posCol})`, - lut, - vmin, - vmax, - logScale: dpScaleMode === "log", - pixelSize: kPxAngstrom > 0 ? kPxAngstrom : undefined, - showColorbar: withColorbar, - showScaleBar: kPxAngstrom > 0, - }); - canvasToPDF(figCanvas).then((blob) => downloadBlob(blob, "show4dstem_dp_figure.pdf")).catch(console.error); - }; - - const handleDpExportPng = () => { - setDpExportAnchor(null); - if (!dpCanvasRef.current) return; - dpCanvasRef.current.toBlob((b) => { if (b) downloadBlob(b, "show4dstem_dp.png"); }, "image/png"); - }; - - const handleDpExportGif = () => { - setDpExportAnchor(null); - setExporting(true); - setGifExportRequested(true); - }; - - // ── VI Figure Export ── - const handleViExportFigure = (withColorbar: boolean) => { - setViExportAnchor(null); - if (!virtualCanvasRef.current) return; - const viCanvas = virtualCanvasRef.current; - const pixelSizeAngstrom = pixelSize > 0 ? pixelSize : 0; - const figCanvas = exportFigure({ - imageCanvas: viCanvas, - title: "Virtual Image", - showColorbar: withColorbar, - showScaleBar: pixelSizeAngstrom > 0, - pixelSize: pixelSizeAngstrom > 0 ? pixelSizeAngstrom : undefined, - }); - canvasToPDF(figCanvas).then((blob) => downloadBlob(blob, "show4dstem_vi_figure.pdf")).catch(console.error); - }; - - const handleViExportPng = () => { - setViExportAnchor(null); - if (!virtualCanvasRef.current) return; - virtualCanvasRef.current.toBlob((b) => { if (b) downloadBlob(b, "show4dstem_vi.png"); }, "image/png"); - }; - - // Download GIF when data arrives from Python - React.useEffect(() => { - if (!gifData || gifData.byteLength === 0) return; - downloadDataView(gifData, "show4dstem_dp_animation.gif", "image/gif"); - const metaText = (gifMetadataJson || "").trim(); - if (metaText) { - downloadBlob(new Blob([metaText], { type: "application/json" }), "show4dstem_dp_animation.json"); - } - setExporting(false); - }, [gifData, gifMetadataJson]); - - - // Theme-aware select style - const themedSelect = { - ...controlPanel.select, - bgcolor: themeColors.controlBg, - color: themeColors.text, - "& .MuiSelect-select": { py: 0.5 }, - "& .MuiOutlinedInput-notchedOutline": { borderColor: themeColors.border }, - "&:hover .MuiOutlinedInput-notchedOutline": { borderColor: themeColors.accent }, - }; - - const themedMenuProps = { - ...upwardMenuProps, - PaperProps: { sx: { bgcolor: themeColors.controlBg, color: themeColors.text, border: `1px solid ${themeColors.border}` } }, - }; - - const keyboardShortcutItems: [string, string][] = [ - ["↑ / ↓", "Move scan row"], - ["← / →", "Move scan col"], - ["Shift+Arrows", "Move ×10"], - ...(nFrames > 1 ? [["[ / ]", `Prev / next ${frameDimLabel.toLowerCase()}`] as [string, string]] : []), - ["Space", "Play / pause"], - ["R", "Reset all zoom/pan"], - ["Esc", "Release keyboard focus"], - ["Scroll", "Zoom"], - ["Dbl-click", "Reset view"], - ]; - - return ( - - {/* HEADER */} - - {title || "4D-STEM Explorer"} - {nFrames > 1 && ({frameLabels && frameLabels.length > frameIdx ? frameLabels[frameIdx] : `${frameDimLabel} ${frameIdx + 1}/${nFrames}`})} - - Controls - DP: Diffraction pattern I(kx,ky) at scan position. Drag to move ROI center. - Detector: ROI mask shape — defines which DP pixels are integrated for the virtual image. - BF/ABF/ADF: Preset detector configurations (bright-field, annular bright-field, annular dark-field). - Image: Virtual image — integrated intensity within detector ROI at each scan position. - FFT: Spatial frequency content of the virtual image. Auto masks DC + clips to 99.9th percentile. - Smooth: CSS bilinear blit on the VI canvas. No data change — browser smooths the upscale visually. Off = nearest-neighbor (sharp pixel boundaries). - Auto: Percentile contrast (1st–99th). Clips outliers automatically. - Profile: Click two points on DP to draw a line intensity profile. - {nFrames > 1 && <> - Frame Playback ({frameDimLabel}) - Loop: Loop playback. Bounce: Ping-pong — alternates forward and reverse. - FPS: Adjust playback speed (1–30 frames per second). - } - Keyboard - - } theme={themeInfo.theme} /> - - - {/* MAIN CONTENT: DP | VI | FFT (three columns when FFT shown) */} - - {/* LEFT COLUMN: DP Panel */} - - {/* DP Header */} - - - DP at ({Math.round(localPosRow)}, {Math.round(localPosCol)}) - k: ({Math.round(localKRow)}, {Math.round(localKCol)}) - - - Profile: - { - const on = e.target.checked; - setProfileActive(on); - if (!on) { - setProfileLine([]); - setProfileData(null); - setHoveredDpProfileEndpoint(null); - setIsHoveringDpProfileLine(false); - } - }} size="small" sx={switchStyles.small} /> - - - - setDpExportAnchor(null)} anchorOrigin={{ vertical: "bottom", horizontal: "left" }} transformOrigin={{ vertical: "top", horizontal: "left" }} sx={{ zIndex: 9999 }}> - handleDpExportFigure(true)} sx={{ fontSize: 12 }}>PDF + colorbar - handleDpExportFigure(false)} sx={{ fontSize: 12 }}>PDF - PNG - { setDpExportAnchor(null); handleExportDP(); }} sx={{ fontSize: 12 }}>ZIP (PNG + metadata) - {pathLength > 0 && GIF (path animation)} - - - - - {/* DP Canvas */} - - - - - {cursorInfo && cursorInfo.panel === "DP" && ( - - - ({cursorInfo.row}, {cursorInfo.col}) {formatNumber(cursorInfo.value)} - - - )} - - - - {/* DP Stats Bar */} - {dpStats && dpStats.length === 4 && ( - - Mean {formatStat(dpStats[0])} - Min {formatStat(dpStats[1])} - Max {formatStat(dpStats[2])} - Std {formatStat(dpStats[3])} - - { model.set("_preset_request", "bf"); model.save_changes(); }} sx={{ color: roiColors.textColor, fontSize: 11, fontWeight: "bold", cursor: "pointer", "&:hover": { textDecoration: "underline" } }}>BF - { model.set("_preset_request", "abf"); model.save_changes(); }} sx={{ color: "#4af", fontSize: 11, fontWeight: "bold", cursor: "pointer", "&:hover": { textDecoration: "underline" } }}>ABF - { model.set("_preset_request", "adf"); model.save_changes(); }} sx={{ color: "#fa4", fontSize: 11, fontWeight: "bold", cursor: "pointer", "&:hover": { textDecoration: "underline" } }}>ADF - - )} - - {/* Profile sparkline */} - {profileActive && ( - - - { - setIsResizingProfile(true); - profileResizeStart.current = { startY: e.clientY, startHeight: profileHeight }; - }} - sx={{ width: canvasSize, height: 4, cursor: "ns-resize", borderTop: `1px solid ${themeColors.border}`, borderLeft: `1px solid ${themeColors.border}`, borderRight: `1px solid ${themeColors.border}`, borderBottom: `1px solid ${themeColors.border}`, bgcolor: themeColors.controlBg, "&:hover": { bgcolor: themeColors.accent } }} - /> - - )} - - {/* DP Controls - two rows with histogram on right */} - {showControls && ( - - {/* Left: two rows of controls */} - - {/* Row 1: Detector + slider */} - - Detector: - - {(roiMode === "circle" || roiMode === "square" || roiMode === "annular") && ( - <> - { - if (roiMode === "annular") { - const [inner, outer] = v as number[]; - setRoiRadiusInner(Math.min(inner, outer - 1)); - setRoiRadius(Math.max(outer, inner + 1)); - } else { - const next = Array.isArray(v) ? v[0] : v; - setRoiRadius(next); - } - }} - min={1} - max={Math.min(detRows, detCols) / 2} - size="small" - sx={{ ...sliderStyles.small, width: roiMode === "annular" ? 67 : 47, mx: 1 }} - /> - - {roiMode === "annular" ? `${Math.round(roiRadiusInner)}-${Math.round(roiRadius)}px` : `${Math.round(roiRadius)}px`} - - - )} - - {/* Row 2: Color + Scale + Colorbar */} - - Color: - - Scale: - - Colorbar: - setShowDpColorbar(e.target.checked)} size="small" sx={switchStyles.small} /> - - - {/* Right: Histogram spanning both rows */} - - { setDpVminPct(min); setDpVmaxPct(max); }} width={110} height={58} theme={themeInfo.theme} dataMin={dpGlobalMin} dataMax={dpGlobalMax} /> - - - )} - - - {/* SECOND COLUMN: VI Panel */} - - {/* VI Header */} - - - {shapeRows}×{shapeCols} | {detRows}×{detCols} - - - FFT: - setShowFft(e.target.checked)} size="small" sx={switchStyles.small} /> - Profile: - { - const on = e.target.checked; - setViProfileActive(on); - if (!on) { - setViProfilePoints([]); - setHoveredViProfileEndpoint(null); - setIsHoveringViProfileLine(false); - } - }} size="small" sx={switchStyles.small} /> - - - - setViExportAnchor(null)} anchorOrigin={{ vertical: "bottom", horizontal: "left" }} transformOrigin={{ vertical: "top", horizontal: "left" }} sx={{ zIndex: 9999 }}> - handleViExportFigure(true)} sx={{ fontSize: 12 }}>PDF + colorbar - handleViExportFigure(false)} sx={{ fontSize: 12 }}>PDF - PNG - { setViExportAnchor(null); handleExportVI(); }} sx={{ fontSize: 12 }}>ZIP (all panels + metadata) - - - - - {/* VI Canvas */} - - - - - {cursorInfo && cursorInfo.panel === "VI" && ( - - - ({cursorInfo.row}, {cursorInfo.col}) {formatNumber(cursorInfo.value)} - - - )} - - - - {/* VI Stats Bar — stats on left, Auto/Smooth toggles on right edge */} - {viStats && viStats.length === 4 && ( - - Mean {formatStat(viStats[0])} - Min {formatStat(viStats[1])} - Max {formatStat(viStats[2])} - Std {formatStat(viStats[3])} - - Auto: - setViAutoContrast(e.target.checked)} size="small" sx={switchStyles.small} /> - Smooth: - setViSmooth(e.target.checked)} size="small" sx={switchStyles.small} /> - - - )} - - {/* VI Profile sparkline */} - {viProfileActive && ( - - - { - setIsResizingViProfile(true); - viProfileResizeStart.current = { startY: e.clientY, startHeight: viProfileHeight }; - }} - sx={{ width: viCanvasWidth, height: 4, cursor: "ns-resize", borderTop: `1px solid ${themeColors.border}`, borderLeft: `1px solid ${themeColors.border}`, borderRight: `1px solid ${themeColors.border}`, borderBottom: `1px solid ${themeColors.border}`, bgcolor: themeColors.controlBg, "&:hover": { bgcolor: themeColors.accent } }} - /> - - )} - - {/* VI Controls - Two rows with histogram on right */} - {showControls && ( - - {/* Left: Two rows of controls */} - - {/* Row 1: ROI selector */} - - ROI: - - {viRoiMode && viRoiMode !== "off" && ( - <> - {(viRoiMode === "circle" || viRoiMode === "square") && ( - <> - setViRoiRadius(v as number)} - min={1} - max={Math.min(shapeRows, shapeCols) / 2} - size="small" - sx={{ ...sliderStyles.small, width: 53, mx: 1 }} - /> - - {Math.round(viRoiRadius || 5)}px - - - )} - - - )} - - {/* Row 2: Color + Scale */} - - Color: - - Scale: - - - - {/* Right: Histogram spanning both rows */} - - { setViVminPct(min); setViVmaxPct(max); }} width={110} height={58} theme={themeInfo.theme} dataMin={viDataMin} dataMax={viDataMax} /> - - - )} - - - {/* THIRD COLUMN: FFT Panel (conditionally shown) */} - {effectiveShowFft && ( - - {/* FFT Header */} - - {roiFftActive && fftCropDims ? `ROI FFT (${fftCropDims.cropWidth}\u00D7${fftCropDims.cropHeight})` : "FFT"} - - - - - - {/* FFT Canvas */} - - - - - - - {/* FFT Stats Bar */} - {fftStats && fftStats.length === 4 && ( - - Mean {formatStat(fftStats[0])} - Min {formatStat(fftStats[1])} - Max {formatStat(fftStats[2])} - Std {formatStat(fftStats[3])} - - )} - - {/* FFT D-spacing readout */} - {fftClickInfo && ( - - - Spot ({fftClickInfo.row.toFixed(1)}, {fftClickInfo.col.toFixed(1)}) - - - dist {fftClickInfo.distPx.toFixed(1)} px - - {fftClickInfo.dSpacing != null && ( - - d = {fftClickInfo.dSpacing >= 10 ? `${(fftClickInfo.dSpacing / 10).toFixed(2)} nm` : `${fftClickInfo.dSpacing.toFixed(2)} \u00C5`} - - )} - {fftClickInfo.spatialFreq != null && ( - - q = {fftClickInfo.spatialFreq.toFixed(4)} {"\u00C5\u207B\u00B9"} - - )} - - )} - - {/* FFT Controls - Two rows with histogram on right */} - {showControls && ( - - {/* Left: Two rows of controls */} - - {/* Row 1: Scale + Clip */} - - Scale: - - Auto: - setFftAuto(e.target.checked)} size="small" sx={switchStyles.small} /> - {fftCropDims && ( - <> - Win: - setFftWindow(e.target.checked)} size="small" sx={switchStyles.small} /> - - )} - - {/* Row 2: Color */} - - Color: - - - - {/* Right: Histogram spanning both rows */} - - {fftHistogramData && ( - { setFftVminPct(min); setFftVmaxPct(max); }} width={110} height={58} theme={themeInfo.theme} dataMin={fftDataMin} dataMax={fftDataMax} /> - )} - - - )} - - )} - - - {/* BOTTOM CONTROLS */} - - {/* Frame controls (5D time/tilt series) — matches Show3D playback */} - {showControls && nFrames > 1 && (<> - - {frameDimLabel}: - - { setFrameReverse(true); setFramePlaying(true); }} sx={{ color: frameReverse && framePlaying ? themeColors.accent : themeColors.textMuted, p: 0.25 }}> - - - setFramePlaying(!framePlaying)} sx={{ color: themeColors.accent, p: 0.25 }}> - {framePlaying ? : } - - { setFrameReverse(false); setFramePlaying(true); }} sx={{ color: !frameReverse && framePlaying ? themeColors.accent : themeColors.textMuted, p: 0.25 }}> - - - { setFramePlaying(false); setFrameIdx(0); }} sx={{ color: themeColors.textMuted, p: 0.25 }}> - - - - { setFramePlaying(false); setFrameIdx(v as number); }} min={0} max={Math.max(0, nFrames - 1)} size="small" sx={{ flex: 1, minWidth: 60, "& .MuiSlider-thumb": { width: 10, height: 10 } }} /> - {frameLabels && frameLabels.length > frameIdx ? frameLabels[frameIdx] : `${frameIdx + 1}/${nFrames}`} - - - fps - setFrameFps(v as number)} size="small" sx={{ ...sliderStyles.small, width: 35, flexShrink: 0 }} /> - {Math.round(frameFps)} - Loop - setFrameLoop(!frameLoop)} sx={{ ...switchStyles.small, flexShrink: 0 }} /> - Bounce - setFrameBoomerang(!frameBoomerang)} sx={{ ...switchStyles.small, flexShrink: 0 }} /> - - )} - - {/* Path animation slider */} - {showControls && pathLength > 0 && ( - - - setPathPlaying(!pathPlaying)} sx={{ color: themeColors.accent, p: 0.25 }}> - {pathPlaying ? : } - - { setPathPlaying(false); setPathIndex(0); }} sx={{ color: themeColors.textMuted, p: 0.25 }}> - - - - { setPathPlaying(false); setPathIndex(v as number); }} min={0} max={Math.max(0, pathLength - 1)} size="small" sx={{ flex: 1, minWidth: 60, "& .MuiSlider-thumb": { width: 10, height: 10 } }} /> - {pathIndex + 1}/{pathLength} - Loop: - { model.set("path_loop", v); model.save_changes(); }} size="small" sx={switchStyles.small} /> - - )} - - ); -} - -export const render = createRender(Show4DSTEM); diff --git a/widget/js/stats.ts b/widget/js/stats.ts deleted file mode 100644 index 36ce661cf..000000000 --- a/widget/js/stats.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** Find min/max range of a Float32Array, filtering out NaN and Infinity. */ -export function findDataRange(data: Float32Array): { min: number; max: number } { - let min = Infinity, max = -Infinity; - for (let i = 0; i < data.length; i++) { - const v = data[i]; - if (!isFinite(v)) continue; - if (v < min) min = v; - if (v > max) max = v; - } - // If no finite values found, return zeros - if (min === Infinity) return { min: 0, max: 0 }; - return { min, max }; -} - -/** Apply log1p scale: result[i] = log(1 + max(0, data[i])). Returns a new array. */ -export function applyLogScale(data: Float32Array): Float32Array { - const result = new Float32Array(data.length); - for (let i = 0; i < data.length; i++) { - result[i] = Math.log1p(Math.max(0, data[i])); - } - return result; -} - -/** Apply log1p scale into a pre-allocated buffer. Avoids per-frame allocation. */ -export function applyLogScaleInPlace(data: Float32Array, out: Float32Array): Float32Array { - for (let i = 0; i < data.length; i++) { - out[i] = Math.log1p(Math.max(0, data[i])); - } - return out; -} - -/** Percentile-based clipping using O(n) histogram approach. - * Also returns data min/max so callers can skip a redundant findDataRange scan. */ -export function percentileClip( - data: Float32Array, pLow: number, pHigh: number, -): { vmin: number; vmax: number; min: number; max: number } { - const len = data.length; - if (len === 0) return { vmin: 0, vmax: 0, min: 0, max: 0 }; - - // Pass 1: find min/max - let min = Infinity, max = -Infinity; - for (let i = 0; i < len; i++) { - const v = data[i]; - if (v < min) min = v; - if (v > max) max = v; - } - if (min === max) return { vmin: min, vmax: max, min, max }; - - // Pass 2: build histogram - const NUM_BINS = 1024; - const bins = new Uint32Array(NUM_BINS); - const range = max - min; - const scale = (NUM_BINS - 1) / range; - for (let i = 0; i < len; i++) { - bins[Math.floor((data[i] - min) * scale)]++; - } - - // Walk cumulative histogram to find percentile values - const lowCount = Math.floor(len * (pLow / 100)); - const highCount = Math.ceil(len * (pHigh / 100)); - let cumSum = 0; - let vmin = min, vmax = max; - for (let i = 0; i < NUM_BINS; i++) { - cumSum += bins[i]; - if (cumSum >= lowCount) { vmin = min + (i / (NUM_BINS - 1)) * range; break; } - } - cumSum = 0; - for (let i = 0; i < NUM_BINS; i++) { - cumSum += bins[i]; - if (cumSum >= highCount) { vmax = min + (i / (NUM_BINS - 1)) * range; break; } - } - return { vmin, vmax, min, max }; -} - -/** Compute mean, min, max, and standard deviation of a Float32Array. */ -export function computeStats(data: Float32Array): { mean: number; min: number; max: number; std: number } { - if (data.length === 0) return { mean: 0, min: 0, max: 0, std: 0 }; - let sum = 0, min = Infinity, max = -Infinity; - for (let i = 0; i < data.length; i++) { - const v = data[i]; - sum += v; - if (v < min) min = v; - if (v > max) max = v; - } - const mean = sum / data.length; - let variance = 0; - for (let i = 0; i < data.length; i++) variance += (data[i] - mean) ** 2; - const std = Math.sqrt(variance / data.length); - return { mean, min, max, std }; -} - -/** Convert histogram slider percentages (0-100) to vmin/vmax in data space. */ -export function sliderRange( - dataMin: number, dataMax: number, vminPct: number, vmaxPct: number, -): { vmin: number; vmax: number } { - const range = dataMax - dataMin; - return { - vmin: dataMin + (vminPct / 100) * range, - vmax: dataMin + (vmaxPct / 100) * range, - }; -} - -/** Compute normalized histogram bins from Float32Array. Returns array of 0-1 values. */ -export function computeHistogramFromBytes(data: Float32Array | null, numBins = 256): number[] { - if (!data || data.length === 0) return new Array(numBins).fill(0); - const bins = new Array(numBins).fill(0); - let min = Infinity, max = -Infinity; - for (let i = 0; i < data.length; i++) { - const v = data[i]; - if (isFinite(v)) { if (v < min) min = v; if (v > max) max = v; } - } - if (!isFinite(min) || !isFinite(max) || min === max) return bins; - const range = max - min; - for (let i = 0; i < data.length; i++) { - const v = data[i]; - if (isFinite(v)) bins[Math.min(numBins - 1, Math.floor(((v - min) / range) * numBins))]++; - } - const maxCount = Math.max(...bins); - if (maxCount > 0) for (let i = 0; i < numBins; i++) bins[i] /= maxCount; - return bins; -} diff --git a/widget/js/theme.ts b/widget/js/theme.ts deleted file mode 100644 index f13123d5c..000000000 --- a/widget/js/theme.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Shared theme detection and color system for all widgets. - * Detects JupyterLab, VS Code, Colab, Classic Jupyter, and OS preferences. - */ - -import { useState, useEffect, useMemo } from "react"; - -// ============================================================================ -// Types -// ============================================================================ -export type Environment = "jupyterlab" | "vscode" | "colab" | "jupyter-classic" | "unknown"; -export type Theme = "light" | "dark"; - -export interface ThemeInfo { - environment: Environment; - theme: Theme; -} - -export interface ThemeColors { - bg: string; - bgAlt: string; - text: string; - textMuted: string; - border: string; - controlBg: string; - accent: string; -} - -// ============================================================================ -// Color palettes -// ============================================================================ -export const DARK_COLORS: ThemeColors = { - bg: "#1e1e1e", - bgAlt: "#1a1a1a", - text: "#e0e0e0", - textMuted: "#888", - border: "#3a3a3a", - controlBg: "#252525", - accent: "#5af", -}; - -export const LIGHT_COLORS: ThemeColors = { - bg: "#ffffff", - bgAlt: "#f5f5f5", - text: "#1e1e1e", - textMuted: "#666", - border: "#ccc", - controlBg: "#f0f0f0", - accent: "#0066cc", -}; - -export function getThemeColors(theme: Theme): ThemeColors { - return theme === "dark" ? DARK_COLORS : LIGHT_COLORS; -} - -// ============================================================================ -// Theme detection -// ============================================================================ - -/** Check if a CSS color string is dark (luminance < 0.5) */ -export function isColorDark(color: string): boolean { - const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); - if (!match) return true; - const [, r, g, b] = match.map(Number); - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; - return luminance < 0.5; -} - -export function detectTheme(): ThemeInfo { - // 1. JupyterLab - has data-jp-theme-light attribute - const jpThemeLight = document.body.dataset.jpThemeLight; - if (jpThemeLight !== undefined) { - return { - environment: "jupyterlab", - theme: jpThemeLight === "true" ? "light" : "dark", - }; - } - - // 2. VS Code - has vscode-* classes on body or html - const bodyClasses = document.body.className; - const htmlClasses = document.documentElement.className; - if (bodyClasses.includes("vscode-") || htmlClasses.includes("vscode-")) { - const isDark = bodyClasses.includes("vscode-dark") || htmlClasses.includes("vscode-dark"); - return { - environment: "vscode", - theme: isDark ? "dark" : "light", - }; - } - - // 3. Google Colab - has specific markers - if (document.querySelector('colab-shaded-scroller') || document.body.classList.contains('colaboratory')) { - const bg = getComputedStyle(document.body).backgroundColor; - return { - environment: "colab", - theme: isColorDark(bg) ? "dark" : "light", - }; - } - - // 4. Classic Jupyter Notebook - has #notebook element - if (document.getElementById('notebook')) { - const bodyBg = getComputedStyle(document.body).backgroundColor; - return { - environment: "jupyter-classic", - theme: isColorDark(bodyBg) ? "dark" : "light", - }; - } - - // 5. Fallback: check OS preference, then computed background - const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)')?.matches; - if (prefersDark !== undefined) { - return { - environment: "unknown", - theme: prefersDark ? "dark" : "light", - }; - } - - // Final fallback: check body background luminance - const bg = getComputedStyle(document.body).backgroundColor; - return { - environment: "unknown", - theme: isColorDark(bg) ? "dark" : "light", - }; -} - -// ============================================================================ -// React hook -// ============================================================================ -export function useTheme(): { themeInfo: ThemeInfo; colors: ThemeColors } { - const [themeInfo, setThemeInfo] = useState(() => detectTheme()); - - useEffect(() => { - const mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)'); - const handleChange = () => setThemeInfo(detectTheme()); - mediaQuery?.addEventListener?.('change', handleChange); - - const observer = new MutationObserver(() => setThemeInfo(detectTheme())); - observer.observe(document.body, { attributes: true, attributeFilter: ['data-jp-theme-light', 'class'] }); - - return () => { - mediaQuery?.removeEventListener?.('change', handleChange); - observer.disconnect(); - }; - }, []); - - // Memoize by theme string so `colors` is referentially stable across renders — - // effects/components that depend on `colors` only re-run when the theme flips. - const colors = useMemo(() => getThemeColors(themeInfo.theme), [themeInfo.theme]); - return { themeInfo, colors }; -} diff --git a/widget/package-lock.json b/widget/package-lock.json deleted file mode 100644 index ff1510fd5..000000000 --- a/widget/package-lock.json +++ /dev/null @@ -1,1637 +0,0 @@ -{ - "name": "quantem-widget-frontend", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "quantem-widget-frontend", - "dependencies": { - "@anywidget/react": "^0.2.0", - "@emotion/react": "^11.14.0", - "@emotion/styled": "^11.14.1", - "@mui/icons-material": "^7.3.7", - "@mui/material": "^7.3.6", - "jszip": "^3.10.1", - "react": "^19.1.0", - "react-dom": "^19.1.0" - }, - "devDependencies": { - "@types/react": "^19.1.3", - "@types/react-dom": "^19.1.4", - "@webgpu/types": "^0.1.68", - "esbuild": "^0.21.3", - "typescript": "^5.8.3" - } - }, - "node_modules/@anywidget/react": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@anywidget/react/-/react-0.2.2.tgz", - "integrity": "sha512-MjrbbUimaX72ATL9IrrZeMZL+IHVzeW5lMlgZywDAMj4/zUVQ7MeBSaijq7V9genze7sM3Y0WnUyB+Mc7KnWWw==", - "license": "MIT", - "dependencies": { - "@anywidget/types": "^0.4.0" - }, - "peerDependencies": { - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@anywidget/types": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@anywidget/types/-/types-0.4.0.tgz", - "integrity": "sha512-Qno/7V0lKHCMq3DJuSKKHMwilFKPSe8wFftL5xWmgaMCc938mNTtv+i19UrvDfpj9cQTnlPqyXy8t3JOgQ8laA==", - "license": "MIT" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@emotion/babel-plugin": { - "version": "11.13.5", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", - "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.16.7", - "@babel/runtime": "^7.18.3", - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/serialize": "^1.3.3", - "babel-plugin-macros": "^3.1.0", - "convert-source-map": "^1.5.0", - "escape-string-regexp": "^4.0.0", - "find-root": "^1.1.0", - "source-map": "^0.5.7", - "stylis": "4.2.0" - } - }, - "node_modules/@emotion/cache": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", - "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", - "license": "MIT", - "dependencies": { - "@emotion/memoize": "^0.9.0", - "@emotion/sheet": "^1.4.0", - "@emotion/utils": "^1.4.2", - "@emotion/weak-memoize": "^0.4.0", - "stylis": "4.2.0" - } - }, - "node_modules/@emotion/hash": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", - "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", - "license": "MIT" - }, - "node_modules/@emotion/is-prop-valid": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", - "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", - "license": "MIT", - "dependencies": { - "@emotion/memoize": "^0.9.0" - } - }, - "node_modules/@emotion/memoize": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", - "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT" - }, - "node_modules/@emotion/react": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", - "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.13.5", - "@emotion/cache": "^11.14.0", - "@emotion/serialize": "^1.3.3", - "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", - "@emotion/utils": "^1.4.2", - "@emotion/weak-memoize": "^0.4.0", - "hoist-non-react-statics": "^3.3.1" - }, - "peerDependencies": { - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@emotion/serialize": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", - "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", - "license": "MIT", - "dependencies": { - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/unitless": "^0.10.0", - "@emotion/utils": "^1.4.2", - "csstype": "^3.0.2" - } - }, - "node_modules/@emotion/sheet": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", - "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", - "license": "MIT" - }, - "node_modules/@emotion/styled": { - "version": "11.14.1", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", - "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.13.5", - "@emotion/is-prop-valid": "^1.3.0", - "@emotion/serialize": "^1.3.3", - "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", - "@emotion/utils": "^1.4.2" - }, - "peerDependencies": { - "@emotion/react": "^11.0.0-rc.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@emotion/unitless": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", - "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", - "license": "MIT" - }, - "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", - "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", - "license": "MIT", - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@emotion/utils": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", - "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", - "license": "MIT" - }, - "node_modules/@emotion/weak-memoize": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", - "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", - "license": "MIT" - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@mui/core-downloads-tracker": { - "version": "7.3.10", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.10.tgz", - "integrity": "sha512-vrOpWRmPJSuwLo23J62wggEm/jvGdzqctej+UOCtgDUz6nZJQuj3ByPccVyaa7eQmwAzUwKN56FQPMKkqbj1GA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - } - }, - "node_modules/@mui/icons-material": { - "version": "7.3.10", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.10.tgz", - "integrity": "sha512-Au0ma4NSKGKNiimukj8UT/W1x2Qx6Qwn2RvFGykiSqVLYBNlIOPbjnIMvrwLGLu89EEpTVdu/ys/OduZR+tWqw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.6" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@mui/material": "^7.3.10", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/material": { - "version": "7.3.10", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.10.tgz", - "integrity": "sha512-cHvGOk2ZEfbQt3LnGe0ZKd/ETs9gsUpkW66DCO+GSjMZhpdKU4XsuIr7zJ/B/2XaN8ihxuzHfYAR4zPtCN4RYg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.6", - "@mui/core-downloads-tracker": "^7.3.10", - "@mui/system": "^7.3.10", - "@mui/types": "^7.4.12", - "@mui/utils": "^7.3.10", - "@popperjs/core": "^2.11.8", - "@types/react-transition-group": "^4.4.12", - "clsx": "^2.1.1", - "csstype": "^3.2.3", - "prop-types": "^15.8.1", - "react-is": "^19.2.3", - "react-transition-group": "^4.4.5" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^7.3.10", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "@mui/material-pigment-css": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/private-theming": { - "version": "7.3.10", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.10.tgz", - "integrity": "sha512-j3EZN+zOctxUISvJSmsEPo5o2F8zse4l5vRkBY+ps6UtnL6J7o14kUaI4w7gwo73id9e3cDNMVQK/9BVaMHVBw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.6", - "@mui/utils": "^7.3.10", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/styled-engine": { - "version": "7.3.10", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.10.tgz", - "integrity": "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.6", - "@emotion/cache": "^11.14.0", - "@emotion/serialize": "^1.3.3", - "@emotion/sheet": "^1.4.0", - "csstype": "^3.2.3", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.4.1", - "@emotion/styled": "^11.3.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - } - } - }, - "node_modules/@mui/system": { - "version": "7.3.10", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.10.tgz", - "integrity": "sha512-/sfPpdpJaQn7BSF+avjIdHSYmxHp0UOBYNxSG9QGKfMOD6sLANCpRPCnanq1Pe0lFf0NHkO2iUk0TNzdWC1USQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.6", - "@mui/private-theming": "^7.3.10", - "@mui/styled-engine": "^7.3.10", - "@mui/types": "^7.4.12", - "@mui/utils": "^7.3.10", - "clsx": "^2.1.1", - "csstype": "^3.2.3", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/types": { - "version": "7.4.12", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", - "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.6" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/utils": { - "version": "7.3.10", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.10.tgz", - "integrity": "sha512-7y2eIfy0h7JPz+Yy4pS+wgV68d46PuuxDqKBN4Q8VlPQSsCAGwroMCV6xWyc7g9dvEp8ZNFsknc59GHWO+r6Ow==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.6", - "@mui/types": "^7.4.12", - "@types/prop-types": "^15.7.15", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^19.2.3" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, - "node_modules/@types/parse-json": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", - "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "license": "MIT" - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@types/react-transition-group": { - "version": "4.4.12", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", - "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*" - } - }, - "node_modules/@webgpu/types": { - "version": "0.1.69", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", - "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/babel-plugin-macros": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", - "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5", - "cosmiconfig": "^7.0.0", - "resolve": "^1.19.0" - }, - "engines": { - "node": ">=10", - "npm": ">=6" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "license": "MIT" - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, - "node_modules/cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "license": "MIT", - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/find-root": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "license": "MIT" - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "license": "BSD-3-Clause", - "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/hoist-non-react-statics/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT" - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT" - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" - }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.5" - } - }, - "node_modules/react-is": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", - "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", - "license": "MIT" - }, - "node_modules/react-transition-group": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "license": "MIT" - }, - "node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/stylis": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", - "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", - "license": "MIT" - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", - "license": "ISC", - "engines": { - "node": ">= 6" - } - } - } -} diff --git a/widget/package.json b/widget/package.json deleted file mode 100644 index 8ceb653b2..000000000 --- a/widget/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "quantem-widget-frontend", - "type": "module", - "scripts": { - "dev": "npm run build -- --watch", - "build": "node scripts/build.mjs", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@anywidget/react": "^0.2.0", - "@emotion/react": "^11.14.0", - "@emotion/styled": "^11.14.1", - "@mui/icons-material": "^7.3.7", - "@mui/material": "^7.3.6", - "jszip": "^3.10.1", - "react": "^19.1.0", - "react-dom": "^19.1.0" - }, - "devDependencies": { - "@types/react": "^19.1.3", - "@types/react-dom": "^19.1.4", - "@webgpu/types": "^0.1.68", - "esbuild": "^0.21.3", - "typescript": "^5.8.3" - } -} diff --git a/widget/pyproject.toml b/widget/pyproject.toml deleted file mode 100644 index 738c7bc98..000000000 --- a/widget/pyproject.toml +++ /dev/null @@ -1,22 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "quantem.widget" -version = "0.0.1" -description = "Interactive custom Python widgets for electron microscopy" -license = "MIT" -requires-python = ">=3.11" -dependencies = [ - "anywidget>=0.9.0", - "numpy>=2.0.0", - "traitlets>=5.0.0", - "torch>=2.0.0", - "matplotlib>=3.7.0", - "Pillow>=10.0.0", -] - -[tool.hatch.build.targets.wheel] -packages = ["src/quantem"] -artifacts = ["src/quantem/widget/static/**"] diff --git a/widget/scripts/build.mjs b/widget/scripts/build.mjs deleted file mode 100644 index 7c8daea95..000000000 --- a/widget/scripts/build.mjs +++ /dev/null @@ -1,46 +0,0 @@ -// Bundle each widget as a self-contained ESM file. -// anywidget loads bundles via Blob URL; relative imports break in that context. -// esbuild flattens everything into one file per widget. - -import { build, context } from "esbuild"; -import { rmSync, copyFileSync, mkdirSync, existsSync } from "fs"; - -const watch = process.argv.includes("--watch"); -const widgets = [ - { name: "show2d" }, - { name: "show4dstem" }, -]; - -rmSync("src/quantem/widget/static", { recursive: true, force: true }); -mkdirSync("src/quantem/widget/static", { recursive: true }); - -const baseOpts = { - bundle: true, - format: "esm", - jsx: "automatic", - target: "es2022", - define: { "process.env.NODE_ENV": '"production"' }, - loader: { ".css": "text" }, - minify: true, - sourcemap: false, - legalComments: "none", -}; - -for (const w of widgets) { - const opts = { - ...baseOpts, - entryPoints: [`js/${w.name}/index.tsx`], - outfile: `src/quantem/widget/static/${w.name}.js`, - }; - if (watch) { - const ctx = await context(opts); - await ctx.watch(); - console.log(`watching ${w.name}...`); - } else { - const start = Date.now(); - await build(opts); - console.log(`built ${w.name}.js (${Date.now() - start}ms)`); - } -} - -if (!watch) console.log("done."); diff --git a/widget/src/quantem/widget/__init__.py b/widget/src/quantem/widget/__init__.py deleted file mode 100644 index 96d8aebc3..000000000 --- a/widget/src/quantem/widget/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from importlib.metadata import PackageNotFoundError, version - -from quantem.widget.show2d import Show2D -from quantem.widget.show4dstem import Show4DSTEM - -try: - __version__ = version("quantem.widget") -except PackageNotFoundError: - # Source-tree imports (e.g. `PYTHONPATH=src pytest`) skip pip install. - __version__ = "0.0.0+local" - -__all__ = ["Show2D", "Show4DSTEM"] diff --git a/widget/src/quantem/widget/array_utils.py b/widget/src/quantem/widget/array_utils.py deleted file mode 100644 index f4ff75924..000000000 --- a/widget/src/quantem/widget/array_utils.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Array utilities for widgets. NumPy + PyTorch input.""" -import numpy as np - - -def to_numpy(data, dtype: np.dtype | None = None) -> np.ndarray: - """Convert NumPy / PyTorch / Dataset to NumPy.""" - try: - import torch - is_tensor = isinstance(data, torch.Tensor) - except ImportError: - is_tensor = False - if is_tensor: - result = data.detach().cpu().numpy() - elif isinstance(data, np.ndarray): - result = data - else: - # Last-resort fallback covers Dataset.__array__, dlpack-compatible objects, etc. - try: - result = np.asarray(data) - except Exception as e: - raise TypeError( - f"to_numpy expected a NumPy array or PyTorch tensor, got {type(data).__name__}." - ) from e - if dtype is not None: - result = np.asarray(result, dtype=dtype) - return result - - -def _resize_image(img: np.ndarray, target_h: int, target_w: int) -> np.ndarray: - """Center-pad image to (target_h, target_w) with zeros. For gallery alignment.""" - h, w = img.shape[-2:] - if h == target_h and w == target_w: - return img - pad_top = (target_h - h) // 2 - pad_bot = target_h - h - pad_top - pad_left = (target_w - w) // 2 - pad_right = target_w - w - pad_left - return np.pad(img, ((pad_top, pad_bot), (pad_left, pad_right)), mode="constant", constant_values=0) - - -def bin2d(img: np.ndarray, factor: int, mode: str = "mean") -> np.ndarray: - """Reduce 2D image by integer binning factor. mean or sum of f×f blocks.""" - if factor <= 1: - return img - h, w = img.shape[-2:] - h2, w2 = h - h % factor, w - w % factor - img = img[..., :h2, :w2] - blocks = img.reshape(*img.shape[:-2], h2 // factor, factor, w2 // factor, factor) - if mode == "sum": - return blocks.sum(axis=(-3, -1)) - return blocks.mean(axis=(-3, -1)) diff --git a/widget/src/quantem/widget/show2d.py b/widget/src/quantem/widget/show2d.py deleted file mode 100644 index e243a51d4..000000000 --- a/widget/src/quantem/widget/show2d.py +++ /dev/null @@ -1,1174 +0,0 @@ -""" -show2d: Static 2D image viewer with optional FFT and histogram analysis. - -For displaying a single image or a static gallery of multiple images. -Unlike Show3D (interactive), Show2D focuses on static visualization. -""" - -import base64 -import io -import json -import math -import os -import pathlib -import warnings -from enum import StrEnum -from typing import Self - -import anywidget -import matplotlib -import matplotlib.patheffects -import matplotlib.pyplot as plt -import numpy as np -import traitlets -from quantem.widget.array_utils import _resize_image, to_numpy -from quantem.widget.state import resolve_widget_version, save_state_file, unwrap_state_payload - -from quantem.core.datastructures import Dataset2d, Dataset3d - - -def _reject_unknown_kwargs(cls, kwargs: dict) -> None: - """Raise TypeError if kwargs contains any key that isn't a declared trait. - - anywidget/traitlets silently accept unknown keys, which let stale notebooks - pass obsolete params like ``pixel_size_angstrom=0.5`` with no warning. This - helper catches typos and renamed-trait references at construction time. - """ - traits = set(cls.class_trait_names()) - unknown = [k for k in kwargs if k not in traits] - if unknown: - key = sorted(unknown)[0] - raise TypeError( - f"{cls.__name__}() got unexpected keyword argument {key!r}. " - f"Check for typos or a renamed parameter (e.g. canvas_size → size, " - f"image_width_px → size, pixel_size_angstrom → pixel_size)." - ) - - -def _round_to_nice(value: float) -> float: - """Round a physical length to a 'nice' value (1, 2, 5, 10, 20, 50, ...).""" - if value <= 0: - return 1.0 - exp = math.floor(math.log10(value)) - base = 10 ** exp - mantissa = value / base - if mantissa < 1.5: - return base - elif mantissa < 3.5: - return 2 * base - elif mantissa < 7.5: - return 5 * base - else: - return 10 * base - - -class Colormap(StrEnum): - INFERNO = "inferno" - VIRIDIS = "viridis" - MAGMA = "magma" - PLASMA = "plasma" - GRAY = "gray" - - -class Show2D(anywidget.AnyWidget): - """ - Static 2D image viewer with optional FFT and histogram analysis. - - Display a single image or multiple images in a gallery layout. - For interactive stack viewing with playback, use Show3D instead. - - Parameters - ---------- - data : array_like - 2D array (height, width) for single image, or - 3D array (N, height, width) for multiple images displayed as gallery. - labels : list of str, optional - Labels for each image in gallery mode. - title : str, optional - Title to display above the image(s). - cmap : str, default "inferno" - Colormap name ("magma", "viridis", "gray", "inferno", "plasma"). - sampling : float or tuple of float, optional - Pixel size per axis ``(row, col)``. Scalar broadcasts to both axes. - Used for scale bar display. Defaults to ``(1, 1)``. - units : str or list of str, optional - Unit string per axis. Scalar broadcasts to both. Common: ``"A"``, - ``"nm"``, ``"pixels"``. Defaults to ``["pixels", "pixels"]``. - show_fft : bool, default False - Show FFT and histogram panels. - show_stats : bool, default True - Show statistics (mean, min, max, std). - log_scale : bool, default False - Use log scale for intensity mapping. - auto_contrast : bool, default False - Use percentile-based contrast. - vmin : float, optional - Absolute minimum intensity for color mapping. When both vmin and vmax - are set, all gallery images share the same intensity scale: essential - for A/B visual comparison. - vmax : float, optional - Absolute maximum intensity for color mapping. - ncols : int, default 3 - Number of columns in gallery mode. - size : int, default 0 - Canvas rendering size in CSS pixels (the on-screen width of each image). - ``0`` uses the frontend default: 500 px for a single image, 300 px per - image in gallery mode. Pass e.g. ``size=800`` to enlarge for a - presentation, or ``size=200`` to compress alongside a control panel. - This controls **display only**: the underlying image resolution is - never resampled; zooming into a 4K image preserves every pixel. - Attributes - ---------- - render_total_ms : int or None - End-to-end wall clock from constructor start to first browser paint, - populated by a JS→Python round-trip after the first canvas render. - ``None`` until the browser has actually painted; also printed to stdout - when it fires. Use to triage "is it Python, wire, or the browser?" - during live acquisitions. - render_python_build_ms : int or None - Subset of ``render_total_ms`` covering Python ``__init__`` only. - render_wire_js_ms : int or None - Subset covering everything after Python returns: Comm transfer, JS - decode, colormap, and canvas paint. - - Examples - -------- - >>> import numpy as np - >>> from quantem.widget import Show2D - - Single 2D NumPy array: - - >>> Show2D(np.random.rand(512, 512)) - - PyTorch tensor (CPU or GPU, any dtype): - - >>> import torch - >>> Show2D(torch.rand(512, 512)) - - 3D NumPy stack ``(N, H, W)`` rendered as a gallery: - - >>> Show2D(np.random.rand(6, 256, 256), ncols=3) - - List of arrays with different shapes (center-padded to a common canvas): - - >>> Show2D([np.random.rand(256, 256), np.random.rand(300, 400)]) - - quantem ``Dataset2d``: title, sampling, units auto-extracted: - - >>> from quantem.core.datastructures import Dataset2d - >>> ds = Dataset2d.from_array(np.random.rand(512, 512)) - >>> Show2D(ds) - - quantem ``Dataset3d``: gallery view of N frames with calibration: - - >>> from quantem.core.datastructures import Dataset3d - >>> ds = Dataset3d.from_array(np.random.rand(6, 256, 256)) - >>> Show2D(ds, ncols=3) - - A/B comparison with shared contrast and linked zoom/pan: - - >>> a, b = np.random.rand(512, 512), np.random.rand(512, 512) - >>> Show2D([a, b], vmin=0, vmax=1, link_zoom=True, link_pan=True) - - Per-image absolute contrast (one ``vmin``/``vmax`` per image): - - >>> Show2D([a, b], vmin=[0.0, 0.2], vmax=[1.0, 0.8]) - - Drift comparison: diff mode adds a ``A - B`` panel alongside the originals - (gallery becomes ``[A, B, A - B]``): - - >>> Show2D([a, b], diff_mode=True, link_zoom=True, link_pan=True) - - Large image: display-only canvas size (full resolution preserved): - - >>> Show2D(np.random.rand(4096, 4096), size=800) - - Static export to PDF or PNG (vector PDF for publication figures): - - >>> w = Show2D(np.random.rand(512, 512), sampling=0.5, units="nm") - >>> w.save_image("figure.pdf", dpi=150) - """ - - _esm = pathlib.Path(__file__).parent / "static" / "show2d.js" - - # ========================================================================= - # Core State - # GPU memory budget for display buffers (MB). Each 4K image needs ~192 MB. - # 12×4K = 2304 MB fits. 16+ triggers auto-bin. - _GPU_DISPLAY_BUDGET_MB = 2500 - - # ========================================================================= - widget_version = traitlets.Unicode("unknown").tag(sync=True) - n_images = traitlets.Int(1).tag(sync=True) - height = traitlets.Int(1).tag(sync=True) - width = traitlets.Int(1).tag(sync=True) - _display_bin_factor = traitlets.Int(1).tag(sync=True) # 1 = full-res, 2/4/8 = binned - _gpu_max_buffer_mb = traitlets.Int(0).tag(sync=True) # GPU reports maxBufferSize (JS→Python) - # Flipped True by JS after the first colormap pass has painted to canvas. - # Used by the Python-side truthful timing print (end-to-end wall clock, not just __init__). - _js_rendered = traitlets.Bool(False).tag(sync=True) - frame_bytes = traitlets.Bytes(b"").tag(sync=True) - labels = traitlets.List(traitlets.Unicode()).tag(sync=True) - title = traitlets.Unicode("").tag(sync=True) - cmap = traitlets.Unicode("inferno").tag(sync=True) - ncols = traitlets.Int(3).tag(sync=True) - - # ========================================================================= - # Display Options - # ========================================================================= - log_scale = traitlets.Bool(False).tag(sync=True) - auto_contrast = traitlets.Bool(False).tag(sync=True) - vmin = traitlets.Float(None, allow_none=True).tag(sync=True) - vmax = traitlets.Float(None, allow_none=True).tag(sync=True) - vmins = traitlets.List(trait=traitlets.Float(allow_none=True), allow_none=True, default_value=None).tag(sync=True) - vmaxs = traitlets.List(trait=traitlets.Float(allow_none=True), allow_none=True, default_value=None).tag(sync=True) - - # ========================================================================= - # Scale Bar - # ========================================================================= - pixel_size = traitlets.Float(0.0).tag(sync=True) - pixel_unit = traitlets.Unicode("pixels").tag(sync=True) - scale_bar_visible = traitlets.Bool(True).tag(sync=True) - size = traitlets.Int(0).tag(sync=True) # Canvas rendering size in CSS pixels; 0 = frontend default - smooth = traitlets.Bool(False).tag(sync=True) - initial_zoom = traitlets.Float(1.0).tag(sync=True) - zoom_row = traitlets.Float(None, allow_none=True).tag(sync=True) - zoom_col = traitlets.Float(None, allow_none=True).tag(sync=True) - link_zoom = traitlets.Bool(False).tag(sync=True) - link_pan = traitlets.Bool(False).tag(sync=True) - link_contrast = traitlets.Bool(True).tag(sync=True) - diff_mode = traitlets.Bool(False).tag(sync=True) - diff_reference = traitlets.Int(0).tag(sync=True) - - # ========================================================================= - # UI Visibility - # ========================================================================= - show_controls = traitlets.Bool(True).tag(sync=True) - show_stats = traitlets.Bool(True).tag(sync=True) - stats_mean = traitlets.List(traitlets.Float()).tag(sync=True) - stats_min = traitlets.List(traitlets.Float()).tag(sync=True) - stats_max = traitlets.List(traitlets.Float()).tag(sync=True) - stats_std = traitlets.List(traitlets.Float()).tag(sync=True) - - # ========================================================================= - # Analysis Panels (FFT + Histogram shown together) - # ========================================================================= - show_fft = traitlets.Bool(False).tag(sync=True) - fft_window = traitlets.Bool(True).tag(sync=True) - - # ========================================================================= - # Selected Image (for single-image analysis display) - # ========================================================================= - selected_idx = traitlets.Int(0).tag(sync=True) - - # ========================================================================= - # ROI Selection - # ========================================================================= - roi_active = traitlets.Bool(False).tag(sync=True) - roi_list = traitlets.List([]).tag(sync=True) - roi_selected_idx = traitlets.Int(-1).tag(sync=True) - - # ========================================================================= - # Line Profile - # ========================================================================= - profile_line = traitlets.List(traitlets.Dict()).tag(sync=True) - - # ========================================================================= - # Per-Image Rotation - # ========================================================================= - image_rotations = traitlets.List(traitlets.Int(), []).tag(sync=True) - - def __init__( - self, - data: np.ndarray | list[np.ndarray], - labels: list[str | None] = None, - title: str = "", - cmap: str | Colormap = Colormap.INFERNO, - sampling: float | tuple[float, float] | list[float] | None = None, - units: str | list[str] | None = None, - scale_bar_visible: bool = True, - show_fft: bool = False, - fft_window: bool = True, - show_controls: bool = True, - show_stats: bool = True, - verbose: bool = True, - log_scale: bool = False, - auto_contrast: bool = False, - vmin: float | list | None = None, - vmax: float | list | None = None, - ncols: int = 3, - size: int = 0, - smooth: bool = False, - zoom: float = 1.0, - zoom_row: float | None = None, - zoom_col: float | None = None, - link_zoom: bool | None = None, - link_pan: bool | None = None, - link_contrast: bool = True, - diff_mode: bool = False, - view_box: tuple | list | None = None, - display_bin: int | str = "auto", - state=None, - **kwargs, - ): - import time as _time - _t0 = _time.perf_counter() - # Reject typos and stale kwargs (e.g. image_width_px, pixel_size_angstrom). - # anywidget/traitlets silently ignores unknown keys, which hid the - # pixel_size_angstrom bug in show2d_all_features.ipynb for months. - _reject_unknown_kwargs(type(self), kwargs) - super().__init__(**kwargs) - # hold_sync() batches ALL traitlet assignments into a single comm message - # sent when the context manager exits. Without this, each self.x = y - # fires a separate round-trip over the ZMQ/websocket channel, which - # can add 20+ seconds for a 30-image gallery in VS Code Jupyter. - with self.hold_sync(): - self._init_sync( - data=data, labels=labels, title=title, cmap=cmap, - sampling=sampling, units=units, scale_bar_visible=scale_bar_visible, - show_fft=show_fft, fft_window=fft_window, - show_controls=show_controls, show_stats=show_stats, - log_scale=log_scale, auto_contrast=auto_contrast, - vmin=vmin, vmax=vmax, - ncols=ncols, size=size, smooth=smooth, zoom=zoom, - zoom_row=zoom_row, zoom_col=zoom_col, - link_zoom=link_zoom, link_pan=link_pan, link_contrast=link_contrast, - diff_mode=diff_mode, view_box=view_box, - display_bin=display_bin, verbose=verbose, state=state, _t0=_t0) - - def _init_sync(self, *, data, labels, title, cmap, sampling, units, - scale_bar_visible, show_fft, fft_window, - show_controls, show_stats, log_scale, auto_contrast, - vmin, vmax, - ncols, size, smooth, zoom, zoom_row, zoom_col, - link_zoom, link_pan, link_contrast, diff_mode, view_box, - display_bin, verbose, state, _t0): - import time as _time - self._verbose = verbose - self.widget_version = resolve_widget_version() - self._display_data = None # initialized after data setup - self._display_bin = 1 - - # First-class support for quantem Dataset2d / Dataset3d: - # auto-extract array + sampling + units from the dataset object. - if isinstance(data, (Dataset2d, Dataset3d)) or ( - hasattr(data, "array") and hasattr(data, "name") and hasattr(data, "sampling") - ): - if not title and data.name: - title = data.name - if sampling is None: - sampling = tuple(float(s) for s in data.sampling[-2:]) - if units is None and hasattr(data, "units"): - units = list(data.units[-2:]) - data = data.array - # Same auto-extract for list/tuple of Dataset2d (gallery from per-file load). - elif isinstance(data, (list, tuple)) and len(data) > 0 and ( - isinstance(data[0], (Dataset2d, Dataset3d)) or - (hasattr(data[0], "array") and hasattr(data[0], "sampling")) - ): - first = data[0] - if sampling is None: - sampling = tuple(float(s) for s in first.sampling[-2:]) - if units is None and hasattr(first, "units"): - units = list(first.units[-2:]) - data = [d.array for d in data] - - # Convert NumPy / PyTorch / list inputs to a NumPy array. - if isinstance(data, list): - images = [to_numpy(d) for d in data] - - # Check if all images have the same shape - shapes = [img.shape for img in images] - if len(set(shapes)) > 1: - # Different sizes - resize all to the largest - max_h = max(s[0] for s in shapes) - max_w = max(s[1] for s in shapes) - images = [_resize_image(img, max_h, max_w) for img in images] - - data = np.stack(images) - else: - data = to_numpy(data) - - # Ensure 3D shape (N, H, W) - if data.ndim == 2: - data = data[np.newaxis, ...] - - # Avoid redundant copy: np.asarray is a no-op when already float32 + contiguous - if data.dtype == np.float32: - self._data = np.array(data, dtype=np.float32, copy=True) - else: - self._data = np.asarray(data, dtype=np.float32) - # Store originals for rotation reset: views into _data (no copy). - # Only materialized as independent copies when a rotation is applied. - self._data_original = [self._data[i] for i in range(self._data.shape[0])] - self._originals_are_views = True - self.n_images = int(data.shape[0]) - self.height = int(data.shape[1]) - self.width = int(data.shape[2]) - self.image_rotations = [0] * self.n_images - - # Labels - if labels is None: - self.labels = [f"Image {i+1}" for i in range(self.n_images)] - else: - self.labels = list(labels) - - # Options - self.title = title - self.cmap = cmap - # Resolve sampling + units to scalar pixel_size + pixel_unit (column axis). - # Scalar shorthand: sampling=0.5 → (0.5, 0.5). units="nm" → ["nm", "nm"]. - if sampling is None: - self.pixel_size = 0.0 - elif isinstance(sampling, (int, float)): - self.pixel_size = float(sampling) - else: - self.pixel_size = float(sampling[-1]) - if units is None: - self.pixel_unit = "pixels" - elif isinstance(units, str): - self.pixel_unit = units - else: - self.pixel_unit = str(units[-1]) - self.scale_bar_visible = scale_bar_visible - self.size = size - self.smooth = smooth - # view_box sugar: sets zoom + zoom_row/col to center on box - if view_box is not None: - r0, r1, c0, c1 = [float(v) for v in view_box] - box_h = max(1.0, r1 - r0) - box_w = max(1.0, c1 - c0) - zoom = float(min(self.height / box_h, self.width / box_w)) - zoom_row = (r0 + r1) / 2 - zoom_col = (c0 + c1) / 2 - self.initial_zoom = zoom - self.zoom_row = zoom_row - self.zoom_col = zoom_col - # Auto-link zoom + pan in gallery (n_images >= 2) so dragging one panel - # follows the other — typical compare/diff workflow. Single image: no-op. - self.link_zoom = (self.n_images >= 2) if link_zoom is None else link_zoom - self.link_pan = (self.n_images >= 2) if link_pan is None else link_pan - self.link_contrast = link_contrast - self.diff_mode = diff_mode if self.n_images >= 2 else False - if show_fft and self.height * self.width > 2048 * 2048: - warnings.warn( - f"FFT on {self.height}×{self.width} image ({self.height * self.width / 1e6:.1f}M pixels) " - f"may be slow. Consider using ROI FFT for a sub-region.", - stacklevel=2, - ) - self.show_fft = show_fft - self.fft_window = fft_window - self.show_controls = show_controls - self.show_stats = show_stats - self.log_scale = log_scale - self.auto_contrast = auto_contrast - # Accept scalar OR list for vmin/vmax. List → per-image (vmins/vmaxs). - if isinstance(vmin, (list, tuple)) or isinstance(vmax, (list, tuple)): - n = self.n_images - def _expand(v): - if v is None: - return [None] * n - if isinstance(v, (list, tuple)): - if len(v) != n: - raise ValueError(f"vmin/vmax list has length {len(v)} but n_images is {n}. Pass a list of length {n} or a scalar to apply uniformly.") - return [None if x is None else float(x) for x in v] - return [float(v)] * n - self.vmins = _expand(vmin) - self.vmaxs = _expand(vmax) - self.vmin = None - self.vmax = None - else: - self.vmin = vmin - self.vmax = vmax - self.ncols = ncols - - # Auto-bin for display: keep full-res in _data, send binned to JS. - # GPU memory budget: ~2 GB for display buffers (128 MB per image at 4K). - # At 4K: max ~16 full-res. Beyond that, auto-downsample. - if display_bin == "auto": - # Each 4K image needs ~192 MB GPU buffers (float32 + RGBA + read) - # Tested: 12×4K (2.3 GB) works, 24×4K (4.6 GB) OOMs - # Budget: 2.5 GB allows 12×4K full-res, bins above that - gpu_budget_mb = self._GPU_DISPLAY_BUDGET_MB - per_image_mb = (self.height * self.width * 4 * 3) / (1024 * 1024) # 3 buffers - total_mb = self.n_images * per_image_mb - if total_mb > gpu_budget_mb: - # Find minimum bin factor to fit - for bf in [2, 4, 8]: - binned_mb = self.n_images * per_image_mb / (bf * bf) - if binned_mb <= gpu_budget_mb: - self._display_bin = bf - break - else: - self._display_bin = 8 - elif isinstance(display_bin, int) and display_bin > 1: - self._display_bin = display_bin - - if self._display_bin > 1: - from quantem.widget.array_utils import bin2d - orig_h, orig_w = self._data.shape[1], self._data.shape[2] - self._display_data = bin2d(self._data, factor=self._display_bin, mode="mean") - self.height = int(self._display_data.shape[1]) - self.width = int(self._display_data.shape[2]) - if self.pixel_size > 0: - self.pixel_size = self.pixel_size * self._display_bin - self._display_bin_factor = self._display_bin - if verbose: - print(f" Display bin {self._display_bin}×: {orig_h}×{orig_w} → {self.height}×{self.width} ({self._display_data.nbytes // 1024 // 1024} MB)") - else: - self._display_data = self._data - self._display_bin_factor = 1 - - # Compute initial stats (from full-res data) - self._compute_all_stats() - - # Send display data to JS (possibly binned) - self._update_all_frames() - - self.selected_idx = 0 - - if state is not None: - if isinstance(state, (str, pathlib.Path)): - state = unwrap_state_payload( - json.loads(pathlib.Path(state).read_text()), - require_envelope=True, - ) - else: - state = unwrap_state_payload(state) - self.load_state_dict(state) - - # Stash wall-clock start on the instance; the observer below prints the - # TRUE end-to-end time after JS signals first paint. The Python-only - # __init__ number is misleading for widget UX: a widget is not "done" - # until the browser has painted its first frame. - self._init_t0 = _t0 - self._init_py_elapsed_ms = (_time.perf_counter() - _t0) * 1000 - self.observe(self._on_first_render, names=["_js_rendered"]) - - def _on_first_render(self, change): - import time as _time - if not change.get("new"): - return - total_ms = (_time.perf_counter() - self._init_t0) * 1000 - py_ms = self._init_py_elapsed_ms - shape = (f"{self.n_images}×{self.height}×{self.width}" - if self.n_images > 1 else f"{self.height}×{self.width}") - mem = self._data.nbytes - mem_str = f"{mem / (1 << 20):.0f} MB" if mem >= 1 << 20 else f"{mem / (1 << 10):.0f} KB" - # Expose as attributes so tests and notebooks can assert on them. - # These are the ground truth for "did JS actually paint": if they're - # None, the JS side never signaled first render. - self.render_total_ms = int(total_ms) - self.render_python_build_ms = int(py_ms) - self.render_wire_js_ms = int(total_ms - py_ms) - if not getattr(self, "_verbose", True): - return - print( - f"Show2D: {shape} {mem_str}: " - f"rendered in {total_ms:.0f} ms (Python build {py_ms:.0f} ms, " - f"wire+JS {total_ms - py_ms:.0f} ms)", - flush=True, - ) - # Detach observer: one-shot, we only care about the first paint. - try: - self.unobserve(self._on_first_render, names=["_js_rendered"]) - except (ValueError, KeyError): - pass - - def __repr__(self) -> str: - if self.n_images > 1: - shape = f"{self.n_images}×{self.height}×{self.width}" - return f"Show2D({shape}, idx={self.selected_idx}, cmap={self.cmap})" - return f"Show2D({self.height}×{self.width}, cmap={self.cmap})" - - def _repr_mimebundle_(self, **kwargs): - """Return widget view + (optionally) static PNG fallback. - - Live Jupyter renders the interactive widget; the PNG fallback is only - consumed by nbsphinx / GitHub / nbviewer when the widget view cannot be - rendered. Building the fallback runs matplotlib over every gallery image - (~1.7 s for a 30×512² stack) and that cost pays off only in static builds. - Gate it behind ``QUANTEM_WIDGET_STATIC_FALLBACK=1`` so interactive sessions - return immediately. - """ - bundle = super()._repr_mimebundle_(**kwargs) - if not os.environ.get("QUANTEM_WIDGET_STATIC_FALLBACK"): - return bundle - data_dict = bundle[0] if isinstance(bundle, tuple) else bundle - n = self.n_images - ncols = min(self.ncols, n) - nrows = math.ceil(n / ncols) - cell = 4 - fig, axes = plt.subplots( - nrows, ncols, - figsize=(cell * ncols, cell * nrows), - squeeze=False, - ) - max_preview = 256 - for i in range(nrows * ncols): - r, c = divmod(i, ncols) - ax = axes[r][c] - if i < n: - img = self._data[i] - h, w = img.shape - if h > max_preview or w > max_preview: - step = max(h // max_preview, w // max_preview, 1) - img = img[::step, ::step] - ax.imshow(img, cmap=self.cmap, origin="upper") - ax.set_title(self.labels[i], fontsize=10) - ax.axis("off") - if self.title: - fig.suptitle(self.title, fontsize=12) - fig.tight_layout() - buf = io.BytesIO() - fig.savefig(buf, format="png", dpi=120, bbox_inches="tight") - plt.close(fig) - data_dict["image/png"] = base64.b64encode(buf.getvalue()).decode("ascii") - if isinstance(bundle, tuple): - return (data_dict, bundle[1]) - return data_dict - - def _normalize_frame(self, frame: np.ndarray) -> np.ndarray: - if self.log_scale: - frame = np.log1p(np.maximum(frame, 0)) - if self.vmin is not None and self.vmax is not None: - vmin = float(self.vmin) - vmax = float(self.vmax) - if self.log_scale: - vmin = float(np.log1p(max(vmin, 0))) - vmax = float(np.log1p(max(vmax, 0))) - elif self.auto_contrast: - vmin = float(np.percentile(frame, 2)) - vmax = float(np.percentile(frame, 98)) - else: - vmin = float(frame.min()) - vmax = float(frame.max()) - if vmax > vmin: - normalized = np.clip((frame - vmin) / (vmax - vmin) * 255, 0, 255) - return normalized.astype(np.uint8) - return np.zeros(frame.shape, dtype=np.uint8) - - def save_image( - self, - path: str | pathlib.Path, - *, - idx: int | None = None, - format: str | None = None, - dpi: int = 150, - title: bool | str = False, - colorbar: bool = False, - scalebar: bool = False, - ) -> pathlib.Path: - """Save current image as PNG, PDF, or TIFF. - - When ``title``, ``colorbar``, or ``scalebar`` are enabled, the output - is a publication-quality figure rendered via matplotlib. Otherwise a - raw colormapped image is saved directly (faster, exact pixel output). - - Parameters - ---------- - path : str or pathlib.Path - Output file path. - idx : int, optional - Image index in gallery mode. Defaults to current selected_idx. - format : str, optional - 'png', 'pdf', or 'tiff'. If omitted, inferred from file extension. - dpi : int, default 150 - Output DPI. - title : bool or str, default False - ``True`` uses the widget title, a string sets a custom title. - colorbar : bool, default False - Include a colorbar showing the intensity mapping. - scalebar : bool, default False - Include a scale bar (requires ``pixel_size > 0``). - - Returns - ------- - pathlib.Path - The written file path. - """ - from matplotlib import colormaps - from PIL import Image - - path = pathlib.Path(path) - fmt = (format or path.suffix.lstrip(".").lower() or "png").lower() - if fmt not in ("png", "pdf", "tiff", "tif"): - raise ValueError(f"Unsupported format: {fmt!r}. Use 'png', 'pdf', or 'tiff'.") - - i = idx if idx is not None else self.selected_idx - if i < 0 or i >= self.n_images: - raise IndexError(f"Image index {i} out of range [0, {self.n_images})") - - frame = self._data[i] - normalized = self._normalize_frame(frame) - cmap_fn = colormaps.get_cmap(self.cmap) - path.parent.mkdir(parents=True, exist_ok=True) - - use_figure = title or colorbar or scalebar - if not use_figure: - rgba = (cmap_fn(normalized / 255.0) * 255).astype(np.uint8) - img = Image.fromarray(rgba) - if fmt == "pdf": - Image.init() - img = img.convert("RGB") - img.save(str(path), dpi=(dpi, dpi)) - return path - - # Publication-quality figure via matplotlib - h, w = frame.shape - aspect = h / w - fig_w = 6 - fig, ax = plt.subplots(figsize=(fig_w, fig_w * aspect)) - im = ax.imshow(normalized, cmap=cmap_fn, vmin=0, vmax=255, origin="upper") - ax.axis("off") - - if title: - label = title if isinstance(title, str) else self.title - if label: - ax.set_title(label, fontsize=14, fontweight="bold", pad=8) - - if colorbar: - # Map 0–255 back to data-space values for tick labels - if self.log_scale: - frame_proc = np.log1p(np.maximum(frame, 0)) - else: - frame_proc = frame - if self.vmin is not None and self.vmax is not None: - dmin = float(self.vmin) - dmax = float(self.vmax) - if self.log_scale: - dmin = float(np.log1p(max(dmin, 0))) - dmax = float(np.log1p(max(dmax, 0))) - elif self.auto_contrast: - dmin = float(np.percentile(frame_proc, 2)) - dmax = float(np.percentile(frame_proc, 98)) - else: - dmin = float(frame_proc.min()) - dmax = float(frame_proc.max()) - cb = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04) - n_ticks = 5 - tick_positions = np.linspace(0, 255, n_ticks) - tick_labels = [f"{dmin + (dmax - dmin) * t / 255:.4g}" for t in tick_positions] - cb.set_ticks(tick_positions) - cb.set_ticklabels(tick_labels) - - if scalebar and self.pixel_size > 0: - # Compute a nice scale bar length - target_frac = 0.2 # ~20% of image width - raw_length_px = target_frac * w - raw_length_phys = raw_length_px * self.pixel_size # in Å - nice = _round_to_nice(raw_length_phys) - bar_px = nice / self.pixel_size - if nice >= 10: - label_text = f"{nice / 10:.4g} nm" - else: - label_text = f"{nice:.4g} Å" - margin = 0.03 - bar_y = h * (1 - margin) - 2 - bar_x = w * (1 - margin) - bar_px - ax.plot([bar_x, bar_x + bar_px], [bar_y, bar_y], - color="white", linewidth=3, solid_capstyle="butt") - ax.plot([bar_x, bar_x + bar_px], [bar_y, bar_y], - color="black", linewidth=1, solid_capstyle="butt") - ax.text(bar_x + bar_px / 2, bar_y - h * 0.02, label_text, - color="white", fontsize=10, fontweight="bold", - ha="center", va="bottom", - path_effects=[ - matplotlib.patheffects.withStroke(linewidth=2, foreground="black") - ]) - - fig.savefig(str(path), dpi=dpi, bbox_inches="tight", - facecolor="white", pad_inches=0.1) - plt.close(fig) - return path - - def state_dict(self): - return { - "title": self.title, - "cmap": self.cmap, - "log_scale": self.log_scale, - "auto_contrast": self.auto_contrast, - "vmin": self.vmin, - "vmax": self.vmax, - "show_stats": self.show_stats, - "show_fft": self.show_fft, - "fft_window": self.fft_window, - "show_controls": self.show_controls, - "pixel_size": self.pixel_size, - "pixel_unit": self.pixel_unit, - "scale_bar_visible": self.scale_bar_visible, - "size": self.size, - "smooth": self.smooth, - "initial_zoom": self.initial_zoom, - "vmins": self.vmins, - "vmaxs": self.vmaxs, - "link_zoom": self.link_zoom, - "link_pan": self.link_pan, - "link_contrast": self.link_contrast, - "zoom_row": self.zoom_row, - "zoom_col": self.zoom_col, - "diff_mode": self.diff_mode, - "ncols": self.ncols, - "selected_idx": self.selected_idx, - "roi_active": self.roi_active, - "roi_list": self.roi_list, - "roi_selected_idx": self.roi_selected_idx, - "profile_line": self.profile_line, - "image_rotations": list(self.image_rotations), - "display_bin": self._display_bin, - } - - def save(self, path: str): - save_state_file(path, "Show2D", self.state_dict()) - - def load_state_dict(self, state): - for key, val in state.items(): - # Silent migrations for renamed keys in older saved state files. - if key == "pixel_size_angstrom": - key = "pixel_size" - elif key == "canvas_size": - key = "size" - if key == "display_bin": - self._display_bin = val - continue - if hasattr(self, key): - setattr(self, key, val) - - def summary(self): - """Print a human-readable snapshot of the widget's current state. - - Reports image dimensions and pixel size, data min/max/mean, display - settings (colormap, contrast, scale, FFT), active ROIs and profile - line, per-image rotations, and the most recent render timings. - """ - lines = [self.title or "Show2D", "═" * 32] - if self.n_images > 1: - lines.append(f"Image: {self.n_images}×{self.height}×{self.width} ({self.ncols} cols)") - else: - lines.append(f"Image: {self.height}×{self.width}") - if self.pixel_size > 0: - ps = self.pixel_size - if ps >= 10: - lines[-1] += f" ({ps / 10:.2f} nm/px)" - else: - lines[-1] += f" ({ps:.2f} Å/px)" - if hasattr(self, "_data") and self._data is not None: - arr = self._data - lines.append(f"Data: min={float(arr.min()):.4g} max={float(arr.max()):.4g} mean={float(arr.mean()):.4g}") - cmap = self.cmap - scale = "log" if self.log_scale else "linear" - if self.vmin is not None and self.vmax is not None: - contrast = f"vmin={self.vmin:.4g}, vmax={self.vmax:.4g}" - elif self.auto_contrast: - contrast = "auto contrast" - else: - contrast = "manual contrast" - display = f"{cmap} | {contrast} | {scale}" - if self.show_fft: - display += " | FFT" - if not self.fft_window: - display += " (no window)" - lines.append(f"Display: {display}") - if self.roi_active and self.roi_list: - lines.append(f"ROI: {len(self.roi_list)} region(s)") - if self.profile_line: - p0, p1 = self.profile_line[0], self.profile_line[1] - lines.append(f"Profile: ({p0['row']:.0f}, {p0['col']:.0f}) → ({p1['row']:.0f}, {p1['col']:.0f})") - non_zero = [(i, r * 90) for i, r in enumerate(self.image_rotations) if r % 4 != 0] - if non_zero: - parts = [f"#{i}={deg}°" for i, deg in non_zero] - lines.append(f"Rotated: {', '.join(parts)}") - rt = getattr(self, "render_total_ms", None) - if rt is not None: - pb = getattr(self, "render_python_build_ms", 0) - wj = getattr(self, "render_wire_js_ms", 0) - lines.append(f"Rendered: {rt} ms total (Python build {pb} ms, wire+JS {wj} ms)") - else: - lines.append("Rendered: (pending first browser paint)") - print("\n".join(lines)) - - def _compute_all_stats(self): - """Compute statistics for all images (vectorized over all frames).""" - # Vectorized reduction over (H, W) is faster than per-image loops - # for large galleries (e.g. 12×4096×4096: 164ms vs 191ms). - axes = (1, 2) if self._data.ndim == 3 else None - self.stats_mean = np.mean(self._data, axis=axes).ravel().tolist() - self.stats_min = np.min(self._data, axis=axes).ravel().tolist() - self.stats_max = np.max(self._data, axis=axes).ravel().tolist() - self.stats_std = np.std(self._data, axis=axes).ravel().tolist() - - def _update_all_frames(self): - """Send display data to JS (possibly binned for large galleries).""" - data = self._display_data if self._display_data is not None else self._data - self.frame_bytes = data.tobytes() - - def _apply_rotations(self): - """Re-rotate each displayed image from its original by ``image_rotations[i] * 90°``. - - This is purely a display-time reorientation of each 2D image via - ``np.rot90`` — it is NOT scan rotation (which would rotate the - scan grid in a 4D-STEM dataset). Originals are kept in - ``_data_original`` so successive rotations compose from the - unrotated source rather than accumulating interpolation error. - Mixed shapes after rotation are center-padded to a common size. - """ - # Materialize originals as independent copies only when a non-zero - # rotation exists (they start as views into _data to avoid 800MB copy at init) - has_rotation = any( - (self.image_rotations[i] if i < len(self.image_rotations) else 0) % 4 != 0 - for i in range(len(self._data_original)) - ) - # No-rotation fast path: skip 30+ MB of redundant tobytes + stats recomputation - # on every widget init. The observer fires once when image_rotations = [0]*n - # is assigned in __init__; without this guard that triggered a full frame - # rebuild + stats recompute for a no-op. - if not has_rotation and self._originals_are_views: - return - if self._originals_are_views and has_rotation: - self._data_original = [img.copy() for img in self._data_original] - self._originals_are_views = False - rotated = [] - for i, orig in enumerate(self._data_original): - k = self.image_rotations[i] if i < len(self.image_rotations) else 0 - k = k % 4 - if k == 0: - rotated.append(orig) - else: - rotated.append(np.rot90(orig, k=k)) - # If shapes differ after rotation, center-pad all to max dims - shapes = [img.shape for img in rotated] - if len(set(shapes)) > 1: - max_h = max(s[0] for s in shapes) - max_w = max(s[1] for s in shapes) - padded = [] - for img in rotated: - h, w = img.shape - pad_top = (max_h - h) // 2 - pad_bot = max_h - h - pad_top - pad_left = (max_w - w) // 2 - pad_right = max_w - w - pad_left - padded.append(np.pad(img, ((pad_top, pad_bot), (pad_left, pad_right)), mode="constant", constant_values=0)) - rotated = padded - self._data = np.stack(rotated).astype(np.float32) - # Recompute display data if binning is active - if self._display_bin > 1: - from quantem.widget.array_utils import bin2d - self._display_data = bin2d(self._data, factor=self._display_bin, mode="mean") - else: - self._display_data = self._data - display = self._display_data if self._display_data is not None else self._data - self.height = int(display.shape[1]) - self.width = int(display.shape[2]) - self._compute_all_stats() - self._update_all_frames() - - @traitlets.observe("image_rotations") - def _on_image_rotations_changed(self, change): - if hasattr(self, "_data_original"): - self._apply_rotations() - - def rotate(self, idx: int, angle: int) -> Self: - """Rotate image ``idx`` by ``angle`` degrees (CCW-positive, matches np.rot90). - - Rotation convention follows ``np.rot90``:: - - angle | image_rotations | np.rot90 k | direction - ------+-----------------+------------+---------- - 90 | 1 | 1 | 90° CCW - 180 | 2 | 2 | 180° - -90 | 3 | 3 | 90° CW - 360 | 0 | 0 | identity - - Parameters - ---------- - idx : int - Image index in the gallery (0-based). - angle : int - Rotation angle in degrees (must be a multiple of 90). - Positive = counter-clockwise, negative = clockwise. - - Returns - ------- - Self - """ - if angle % 90 != 0: - raise ValueError(f"Rotation angle must be a multiple of 90 (got {angle}). Use 0, 90, 180, 270, or -90, -180, -270.") - if idx < 0 or idx >= self.n_images: - raise IndexError(f"Image index {idx} out of range [0, {self.n_images})") - k = (angle // 90) % 4 - rots = list(self.image_rotations) - while len(rots) < self.n_images: - rots.append(0) - rots[idx] = (rots[idx] + k) % 4 - self.image_rotations = rots - return self - - def _sample_profile(self, row0, col0, row1, col1): - img = self._data[self.selected_idx] - h, w = img.shape - dc, dr = col1 - col0, row1 - row0 - length = (dc**2 + dr**2) ** 0.5 - n = max(2, int(np.ceil(length))) - t = np.linspace(0, 1, n) - cs = col0 + t * dc - rs = row0 + t * dr - ci = np.floor(cs).astype(int) - ri = np.floor(rs).astype(int) - cf = cs - ci - rf = rs - ri - c0c = np.clip(ci, 0, w - 1) - c1c = np.clip(ci + 1, 0, w - 1) - r0c = np.clip(ri, 0, h - 1) - r1c = np.clip(ri + 1, 0, h - 1) - return (img[r0c, c0c] * (1 - cf) * (1 - rf) + - img[r0c, c1c] * cf * (1 - rf) + - img[r1c, c0c] * (1 - cf) * rf + - img[r1c, c1c] * cf * rf).astype(np.float32) - - def set_profile(self, start: tuple, end: tuple): - """Set a line profile between two points (image pixel coordinates). - - Parameters - ---------- - start : tuple of (row, col) - Start point in pixel coordinates. - end : tuple of (row, col) - End point in pixel coordinates. - """ - row0, col0 = start - row1, col1 = end - self.profile_line = [ - {"row": float(row0), "col": float(col0)}, - {"row": float(row1), "col": float(col1)}, - ] - - def clear_profile(self): - """Clear the current line profile.""" - self.profile_line = [] - - def _upsert_selected_roi(self, updates: dict): - rois = list(self.roi_list) - color_cycle = ["#4fc3f7", "#81c784", "#ffb74d", "#ce93d8", "#ef5350", "#ffd54f", "#90a4ae", "#a1887f"] - defaults = { - "shape": "square", - "row": int(self.height // 2), - "col": int(self.width // 2), - "radius": 10, - "radius_inner": 5, - "width": 20, - "height": 20, - "line_width": 2, - "highlight": False, - "visible": True, - "locked": False, - } - if self.roi_selected_idx >= 0 and self.roi_selected_idx < len(rois): - current = {**defaults, **rois[self.roi_selected_idx]} - if not current.get("color"): - current["color"] = color_cycle[self.roi_selected_idx % len(color_cycle)] - rois[self.roi_selected_idx] = {**current, **updates} - else: - rois.append({**defaults, "color": color_cycle[len(rois) % len(color_cycle)], **updates}) - self.roi_selected_idx = len(rois) - 1 - self.roi_list = rois - self.roi_active = True - - def add_roi(self, row: int | None = None, col: int | None = None, shape: str = "square") -> Self: - with self.hold_sync(): - self.roi_selected_idx = -1 - self._upsert_selected_roi({ - "shape": shape, - "row": int(self.height // 2 if row is None else row), - "col": int(self.width // 2 if col is None else col), - }) - return self - - def clear_rois(self) -> Self: - with self.hold_sync(): - self.roi_list = [] - self.roi_selected_idx = -1 - self.roi_active = False - return self - - def delete_selected_roi(self) -> Self: - idx = int(self.roi_selected_idx) - if idx < 0 or idx >= len(self.roi_list): - return self - with self.hold_sync(): - rois = [roi for i, roi in enumerate(self.roi_list) if i != idx] - self.roi_list = rois - self.roi_selected_idx = min(idx, len(rois) - 1) if rois else -1 - if not rois: - self.roi_active = False - return self - - def set_roi(self, row: int, col: int, radius: int = 10) -> Self: - with self.hold_sync(): - self._upsert_selected_roi({"shape": "circle", "row": int(row), "col": int(col), "radius": int(radius)}) - return self - - def roi_circle(self, radius: int = 10) -> Self: - with self.hold_sync(): - self._upsert_selected_roi({"shape": "circle", "radius": int(radius)}) - return self - - def roi_square(self, half_size: int = 10) -> Self: - with self.hold_sync(): - self._upsert_selected_roi({"shape": "square", "radius": int(half_size)}) - return self - - def roi_rectangle(self, width: int = 20, height: int = 10) -> Self: - with self.hold_sync(): - self._upsert_selected_roi({"shape": "rectangle", "width": int(width), "height": int(height)}) - return self - - def roi_annular(self, inner: int = 5, outer: int = 10) -> Self: - with self.hold_sync(): - self._upsert_selected_roi({"shape": "annular", "radius_inner": int(inner), "radius": int(outer)}) - return self - - @property - def profile(self): - """Get profile line endpoints as [(row0, col0), (row1, col1)] or []. - - Returns - ------- - list of tuple - Line endpoints in pixel coordinates, or empty list if no profile. - """ - return [(p["row"], p["col"]) for p in self.profile_line] - - @property - def profile_values(self): - """Get intensity values along the profile line as a numpy array. - - Returns - ------- - np.ndarray or None - Float32 array of sampled intensities, or None if no profile. - """ - if len(self.profile_line) < 2: - return None - p0, p1 = self.profile_line - return self._sample_profile(p0["row"], p0["col"], p1["row"], p1["col"]) - - @property - def profile_distance(self): - """Get total distance of the profile line in calibrated units. - - Returns - ------- - float or None - Distance in angstroms (if pixel_size > 0) or pixels. - None if no profile line is set. - """ - if len(self.profile_line) < 2: - return None - p0, p1 = self.profile_line - dc = p1["col"] - p0["col"] - dr = p1["row"] - p0["row"] - dist_px = (dc**2 + dr**2) ** 0.5 - if self.pixel_size > 0: - return dist_px * self.pixel_size - return dist_px - diff --git a/widget/src/quantem/widget/show4dstem.py b/widget/src/quantem/widget/show4dstem.py deleted file mode 100644 index c3ee61a70..000000000 --- a/widget/src/quantem/widget/show4dstem.py +++ /dev/null @@ -1,2334 +0,0 @@ -""" -show4dstem: Fast interactive 4D-STEM viewer widget. - -Single chunked-torch path on every device (CUDA / MPS / CPU). Reductions cast -uint16 → float32 in scan-row chunks bounded by _CHUNK_BYTE_BUDGET, so transient -memory stays the same regardless of total dataset size. - -To reduce data size, bin k-space at the dataset level before viewing: - - dataset = dataset.bin(2, axes=(2, 3)) # 2x2 k-space binning - widget = Show4DSTEM(dataset) -""" - -import json -import math -import pathlib -import time -from typing import TYPE_CHECKING, Any, Self - -if TYPE_CHECKING: - from quantem.core.datastructures import Dataset4dstem - -import anywidget -import numpy as np -import torch -import traitlets -from quantem.widget.array_utils import to_numpy -from quantem.widget.state import ( - build_json_header, - resolve_widget_version, - save_state_file, - unwrap_state_payload, -) - -from quantem.core.config import validate_device - -# Cap transient chunk memory at ~600 MB regardless of detector size. -_CHUNK_BYTE_BUDGET = 600 * 1024 * 1024 - - -def _format_memory(nbytes: int) -> str: - if nbytes >= 1 << 30: - return f"{nbytes / (1 << 30):.1f} GB" - if nbytes >= 1 << 20: - return f"{nbytes / (1 << 20):.0f} MB" - if nbytes >= 1 << 10: - return f"{nbytes / (1 << 10):.0f} KB" - return f"{nbytes} B" - - -# ============================================================================ -# Constants -# ============================================================================ -DEFAULT_BF_RATIO = 0.125 # BF disk radius as fraction of detector size (1/8) -MIN_LOG_VALUE = 1e-10 # Minimum value for log scale to avoid log(0) -DEFAULT_VI_ROI_RATIO = 0.15 # Default VI ROI size as fraction of scan dimension - -class Show4DSTEM(anywidget.AnyWidget): - """ - Fast interactive 4D-STEM viewer with advanced features. - - Optimized for speed with binary transfer and pre-normalization. - Works with NumPy and PyTorch arrays. - - Parameters - ---------- - data : Dataset4dstem or array_like - Dataset4dstem object (calibration auto-extracted), 4D array - of shape (scan_rows, scan_cols, det_rows, det_cols), or 5D array - of shape (n_frames, scan_rows, scan_cols, det_rows, det_cols) - for time-series or tilt-series data. - scan_shape : tuple, optional - If data is flattened (N, det_rows, det_cols), provide scan dimensions. - sampling : tuple of 4 floats, optional - Pixel size per axis ``(scan_row, scan_col, k_row, k_col)``. Scalar - broadcasts to all four axes. Defaults to ``(1, 1, 1, 1)``. - Auto-extracted from ``Dataset4dstem`` if not provided. - units : list of 4 str, optional - Unit string per axis. Common: ``["A", "A", "mrad", "mrad"]``. - Defaults to ``["pixels"] * 4``. Auto-extracted from - ``Dataset4dstem`` if not provided. - center : tuple[float, float], optional - (center_row, center_col) of the diffraction pattern in pixels. - If not provided, defaults to detector center. - bf_radius : float, optional - Bright field disk radius in pixels. If not provided, estimated as 1/8 of detector size. - precompute_virtual_images : bool, default True - Precompute BF/ABF/LAADF/HAADF virtual images for preset switching. - frame_dim_label : str, optional - Label for the frame dimension when 5D data is provided. - Defaults to "Frame". Common values: "Tilt", "Time", "Focus". - Examples - -------- - >>> import numpy as np - >>> from quantem.widget import Show4DSTEM - - 4D NumPy array ``(scan_rows, scan_cols, det_rows, det_cols)``: - - >>> Show4DSTEM(np.random.rand(64, 64, 128, 128)) - - PyTorch tensor (CPU or GPU): - - >>> import torch - >>> Show4DSTEM(torch.rand(64, 64, 128, 128)) - - With explicit calibration (real-space Å, k-space mrad): - - >>> Show4DSTEM(np.random.rand(64, 64, 128, 128), - ... sampling=(2.39, 2.39, 0.46, 0.46), - ... units=["A", "A", "mrad", "mrad"]) - - quantem ``Dataset4dstem`` — calibration + units auto-extracted: - - >>> from quantem.core.datastructures import Dataset4dstem - >>> ds = Dataset4dstem.from_array(np.random.rand(64, 64, 128, 128)) - >>> Show4DSTEM(ds) - - Flattened scan ``(N, det_rows, det_cols)`` with explicit scan shape: - - >>> Show4DSTEM(np.random.rand(4096, 128, 128), scan_shape=(64, 64)) - - Custom BF disk center and radius (overrides auto-detection): - - >>> Show4DSTEM(np.random.rand(64, 64, 128, 128), - ... center=(64, 64), bf_radius=12) - - 5D time-series or tilt-series ``(n_frames, scan_r, scan_c, det_r, det_c)``: - - >>> Show4DSTEM(np.random.rand(20, 64, 64, 128, 128), frame_dim_label="Tilt") - - Raster animation (scan path through 4D dataset): - - >>> w = Show4DSTEM(np.random.rand(64, 64, 128, 128)) - >>> w.raster(step=2, interval_ms=50) - - Static export to PDF or PNG (single panel or all four): - - >>> w = Show4DSTEM(np.random.rand(64, 64, 128, 128)) - >>> w.save_image("dp.pdf", view="diffraction") - >>> w.save_image("all.pdf", view="all") - """ - - _esm = pathlib.Path(__file__).parent / "static" / "show4dstem.js" - - # Position in scan space - widget_version = traitlets.Unicode("unknown").tag(sync=True) - title = traitlets.Unicode("").tag(sync=True) - pos_row = traitlets.Int(0).tag(sync=True) - pos_col = traitlets.Int(0).tag(sync=True) - - # Shape of scan space (for slider bounds) - shape_rows = traitlets.Int(1).tag(sync=True) - shape_cols = traitlets.Int(1).tag(sync=True) - - # Detector shape for frontend - det_rows = traitlets.Int(1).tag(sync=True) - det_cols = traitlets.Int(1).tag(sync=True) - - # Raw float32 frame as bytes (JS handles scale/colormap for real-time interactivity) - frame_bytes = traitlets.Bytes(b"").tag(sync=True) - - # Global min/max for DP normalization (computed once from sampled frames) - dp_global_min = traitlets.Float(0.0).tag(sync=True) - dp_global_max = traitlets.Float(1.0).tag(sync=True) - - # ========================================================================= - # Detector Calibration (for presets and scale bar) - # ========================================================================= - center_col = traitlets.Float(0.0).tag(sync=True) # Detector center col - center_row = traitlets.Float(0.0).tag(sync=True) # Detector center row - bf_radius = traitlets.Float(0.0).tag(sync=True) # BF disk radius (pixels) - - # ========================================================================= - # ROI Drawing (for virtual imaging) - # roi_radius is multi-purpose by mode: - # - circle: radius of circle - # - square: half-size (distance from center to edge) - # - annular: outer radius (roi_radius_inner = inner radius) - # - rect: uses roi_width/roi_height instead - # ========================================================================= - roi_active = traitlets.Bool(False).tag(sync=True) - roi_mode = traitlets.Unicode("point").tag(sync=True) - roi_center_col = traitlets.Float(0.0).tag(sync=True) - roi_center_row = traitlets.Float(0.0).tag(sync=True) - # Compound trait for batched row+col updates (JS sends both at once, 1 observer fires) - roi_center = traitlets.List(traitlets.Float(), default_value=[0.0, 0.0]).tag(sync=True) - roi_radius = traitlets.Float(10.0).tag(sync=True) - roi_radius_inner = traitlets.Float(5.0).tag(sync=True) - roi_width = traitlets.Float(20.0).tag(sync=True) - roi_height = traitlets.Float(10.0).tag(sync=True) - - # ========================================================================= - # Virtual Image (ROI-based, updates as you drag ROI on DP) - # ========================================================================= - virtual_image_bytes = traitlets.Bytes(b"").tag(sync=True) # Raw float32 (JS computes stats + range) - - # ========================================================================= - # VI ROI (real-space region selection for summed DP) - # ========================================================================= - vi_roi_mode = traitlets.Unicode("off").tag(sync=True) # "off", "circle", "rect" - vi_roi_center_row = traitlets.Float(0.0).tag(sync=True) - vi_roi_center_col = traitlets.Float(0.0).tag(sync=True) - # Compound (row, col) trait — JS sets in one call; one observer fires; bytes - # never compute against split-trait state (old col + new row, or vice versa). - vi_roi_center = traitlets.List(traitlets.Float(), default_value=[0.0, 0.0]).tag(sync=True) - vi_roi_radius = traitlets.Float(5.0).tag(sync=True) - vi_roi_width = traitlets.Float(10.0).tag(sync=True) - vi_roi_height = traitlets.Float(10.0).tag(sync=True) - # Reduction over scan positions inside vi_roi: mean is default (size-invariant DP), - # sum scales with area (quantitative counts), max picks brightest position per detector pixel. - vi_roi_reduce = traitlets.Unicode("mean").tag(sync=True) - vi_roi_dp_bytes = traitlets.Bytes(b"").tag(sync=True) # Reduced DP from VI ROI - - # ========================================================================= - # Scale Bar - # ========================================================================= - pixel_size = traitlets.Float(1.0).tag(sync=True) # real-space pixel size (col axis) - pixel_unit = traitlets.Unicode("pixels").tag(sync=True) - k_pixel_size = traitlets.Float(1.0).tag(sync=True) # k-space pixel size (col axis) - k_pixel_unit = traitlets.Unicode("pixels").tag(sync=True) - k_calibrated = traitlets.Bool(False).tag(sync=True) # True if k-space has real units - - # ========================================================================= - # Path Animation (programmatic crosshair control) - # ========================================================================= - path_playing = traitlets.Bool(False).tag(sync=True) - path_index = traitlets.Int(0).tag(sync=True) - path_length = traitlets.Int(0).tag(sync=True) - path_interval_ms = traitlets.Int(100).tag(sync=True) # ms between frames - path_loop = traitlets.Bool(True).tag(sync=True) # loop when reaching end - - # ========================================================================= - # Auto-detection trigger (frontend sets to True, backend resets to False) - # ========================================================================= - - # ========================================================================= - # Statistics for display (mean, min, max, std) - # ========================================================================= - # dp_stats and vi_stats are computed JS-side from frame_bytes / virtual_image_bytes. - # Keeping them out of Python traits eliminates a 4-message comm race that produced - # mismatched bytes/min/max on rapid preset/ROI changes. - - # ========================================================================= - # Display settings (synced for programmatic export parity) - # ========================================================================= - dp_colormap = traitlets.Unicode("inferno").tag(sync=True) - vi_colormap = traitlets.Unicode("inferno").tag(sync=True) - fft_colormap = traitlets.Unicode("inferno").tag(sync=True) - - dp_scale_mode = traitlets.Unicode("linear").tag(sync=True) # "linear" | "log" - vi_scale_mode = traitlets.Unicode("linear").tag(sync=True) # "linear" | "log" - fft_scale_mode = traitlets.Unicode("linear").tag(sync=True) # "linear" | "log" - - dp_vmin_pct = traitlets.Float(0.0).tag(sync=True) - dp_vmax_pct = traitlets.Float(100.0).tag(sync=True) - vi_vmin_pct = traitlets.Float(0.0).tag(sync=True) - vi_vmax_pct = traitlets.Float(100.0).tag(sync=True) - fft_vmin_pct = traitlets.Float(0.0).tag(sync=True) - fft_vmax_pct = traitlets.Float(100.0).tag(sync=True) - - # Absolute intensity bounds (override percentile sliders when both set) - dp_vmin = traitlets.Float(None, allow_none=True).tag(sync=True) - dp_vmax = traitlets.Float(None, allow_none=True).tag(sync=True) - vi_vmin = traitlets.Float(None, allow_none=True).tag(sync=True) - vi_vmax = traitlets.Float(None, allow_none=True).tag(sync=True) - - fft_auto = traitlets.Bool(True).tag(sync=True) - show_fft = traitlets.Bool(False).tag(sync=True) - # Single-trait preset request: JS sets to "bf"/"abf"/"adf"/"haadf" → Python - # observer calls apply_preset() which batches the 5 ROI trait writes - # atomically. Avoids the JS-side ordering race where individual roi_mode/ - # radius/center traits would commit in separate comm messages. - _preset_request = traitlets.Unicode("").tag(sync=True) - fft_window = traitlets.Bool(True).tag(sync=True) - show_controls = traitlets.Bool(True).tag(sync=True) - dp_show_colorbar = traitlets.Bool(False).tag(sync=True) - # VI panel auto-contrast (1st/99th percentile clip) and CSS smoothing. - # DP panel doesn't need either — Bragg spots are best read with nearest- - # neighbor + the slider's percentile range. - vi_auto_contrast = traitlets.Bool(False).tag(sync=True) - vi_smooth = traitlets.Bool(False).tag(sync=True) - - # ========================================================================= - # Frame Animation (5D time/tilt series) - # ========================================================================= - frame_idx = traitlets.Int(0).tag(sync=True) - n_frames = traitlets.Int(1).tag(sync=True) - frame_dim_label = traitlets.Unicode("Frame").tag(sync=True) - frame_labels = traitlets.List(traitlets.Unicode(), []).tag(sync=True) - frame_playing = traitlets.Bool(False).tag(sync=True) - frame_loop = traitlets.Bool(True).tag(sync=True) - frame_fps = traitlets.Float(5.0).tag(sync=True) - frame_reverse = traitlets.Bool(False).tag(sync=True) - frame_boomerang = traitlets.Bool(False).tag(sync=True) - - # Export (GIF) - _gif_export_requested = traitlets.Bool(False).tag(sync=True) - _gif_data = traitlets.Bytes(b"").tag(sync=True) - _gif_metadata_json = traitlets.Unicode("").tag(sync=True) - - # Line Profile (for DP panel) - profile_line = traitlets.List(traitlets.Dict()).tag(sync=True) - profile_width = traitlets.Int(1).tag(sync=True) - - # ========================================================================= - def __init__( - self, - data: "Dataset4dstem | np.ndarray", - scan_shape: tuple[int, int] | None = None, - sampling: tuple[float, ...] | list[float] | None = None, - units: list[str] | tuple[str, ...] | None = None, - center: tuple[float, float] | None = None, - bf_radius: float | None = None, - precompute_virtual_images: bool = True, - frame_dim_label: str | None = None, - frame_labels: list[str] | None = None, - title: str = "", - show_fft: bool = False, - fft_window: bool = True, - show_controls: bool = True, - dp_vmin: float | None = None, - dp_vmax: float | None = None, - vi_vmin: float | None = None, - vi_vmax: float | None = None, - verbose: bool = True, - state=None, - **kwargs, - ): - super().__init__(**kwargs) - self.widget_version = resolve_widget_version() - _t0 = time.perf_counter() - _verbose = verbose - - _io_labels = None - - # Extract underlying array / tensor + auto-calibrate from Dataset input - # (duck-typed via the dual-slot private attributes _tensor / _array). - tensor = getattr(data, "_tensor", None) - array = getattr(data, "_array", None) - if tensor is not None or array is not None: - if not title and getattr(data, "name", ""): - title = str(data.name) - if sampling is None: - sampling = tuple(float(s) for s in data.sampling) - if units is None: - units = list(data.units) - data = tensor if tensor is not None else array - - # Resolve sampling + units (4 axes for 4D-STEM): - # [scan_row, scan_col, k_row, k_col]. Scalar/None broadcast to (1, 1, 1, 1). - if sampling is None: - sampling = (1.0, 1.0, 1.0, 1.0) - elif isinstance(sampling, (int, float)): - sampling = (float(sampling),) * 4 - else: - sampling = tuple(float(s) for s in sampling) - if units is None: - units = ["pixels"] * 4 - elif isinstance(units, str): - units = [units] * 4 - else: - units = [str(u) for u in units] - - self.title = title - self.pixel_size = sampling[1] # scan_col axis (horizontal scale bar) - self.pixel_unit = units[1] if len(units) > 1 else "pixels" - self.k_pixel_size = sampling[3] if len(sampling) > 3 else 1.0 - self.k_pixel_unit = units[3] if len(units) > 3 else "pixels" - # k-space considered calibrated when its unit is real (mrad, 1/Å, etc.). - self.k_calibrated = self.k_pixel_unit not in ("pixels", "") - self.show_fft = show_fft - self.fft_window = fft_window - self.show_controls = show_controls - self.dp_vmin = dp_vmin - self.dp_vmax = dp_vmax - self.vi_vmin = vi_vmin - self.vi_vmax = vi_vmax - # Path animation (configured via set_path() or raster()) - self._path_points: list[tuple[int, int]] = [] - # Suppress per-trait recompute during apply_preset batch writes - self._suppress_roi_recompute = False - # Torch tensor input keeps its device (lets user pin a specific GPU via - # `data.cuda(1)`). NumPy / Dataset input gets default-validated device. - if isinstance(data, torch.Tensor): - self._device = data.device - self._data_pre = data - data_np = None - else: - device_str, _ = validate_device(None) - self._device = torch.device(device_str) - data_np = to_numpy(data) - self._data_pre = None - self._saturation_value = ( - 65535 if data_np.dtype == np.uint16 - else 255 if data_np.dtype == np.uint8 - else None - ) - # Handle dimensionality — 5D loads eagerly for instant frame switching - # Resolve shape from whichever input path we took - shape = tuple(self._data_pre.shape) if self._data_pre is not None else data_np.shape - ndim = len(shape) - _tc = time.perf_counter() - if ndim == 5: - self.n_frames = shape[0] - self._scan_shape = (shape[1], shape[2]) - self._det_shape = (shape[3], shape[4]) - elif ndim == 3: - self.n_frames = 1 - if scan_shape is not None: - self._scan_shape = scan_shape - else: - n = shape[0] - side = int(n ** 0.5) - if side * side != n: - raise ValueError( - f"Cannot infer square scan_shape from N={n}. " - f"Provide scan_shape explicitly." - ) - self._scan_shape = (side, side) - self._det_shape = (shape[1], shape[2]) - elif ndim == 4: - self.n_frames = 1 - self._scan_shape = (shape[0], shape[1]) - self._det_shape = (shape[2], shape[3]) - else: - raise ValueError(f"Show4DSTEM expects a 3D ((N, det_h, det_w) flat-scan), 4D ((scan_h, scan_w, det_h, det_w)), or 5D ((n_frames, scan_h, scan_w, det_h, det_w)) array. Got {ndim}D.") - if self._data_pre is not None: - self._data = self._data_pre if self._data_pre.device == self._device else self._data_pre.to(self._device) - del self._data_pre - else: - self._data = torch.from_numpy(data_np).to(self._device) - # Saturation filter: zero detector pixels at full-scale (65535 / 255). - # PyTorch lacks unsigned int comparison kernels, but uint16 viewed - # as int16 has identical bytes (65535 → -1) and int16 comparison - # works on every device. Apply in scan-row chunks so the transient - # bool mask stays bounded (≤600 MB) and fits constrained-VRAM - # devices (Mac 24 GB unified, etc.). View-write keeps native dtype. - sat = getattr(self, "_saturation_value", None) - view_dtype = ( - torch.int16 if sat is not None and self._data.dtype == torch.uint16 - else torch.int8 if sat is not None and self._data.dtype == torch.uint8 - else None - ) - if view_dtype is not None: - view = self._data.view(view_dtype).reshape(-1, *self._det_shape) - rows = view.shape[0] - # Bool mask transient = positions × det_h × det_w bytes; cap at budget. - pos_per_chunk = max(1, _CHUNK_BYTE_BUDGET // max(1, self._det_shape[0] * self._det_shape[1])) - for i in range(0, rows, pos_per_chunk): - chunk = view[i:i + pos_per_chunk] - chunk.masked_fill_(chunk == -1, 0) - # Keep native dtype (uint8/uint16) to bound memory at ~ data_size. - # Reductions cast in chunks (bounded transient). - if _verbose: - if str(self._device) == "mps": - torch.mps.synchronize() - n_bytes = self._data.element_size() * self._data.numel() - print(f" to {self._device}: {time.perf_counter() - _tc:.2f}s ({n_bytes / 1e9:.1f} GB)") - - self.shape_rows = self._scan_shape[0] - self.shape_cols = self._scan_shape[1] - self.det_rows = self._det_shape[0] - self.det_cols = self._det_shape[1] - # Initial position at center - self.pos_row = self.shape_rows // 2 - self.pos_col = self.shape_cols // 2 - # Frame dimension label (for 5D time/tilt series UI) - self.frame_dim_label = frame_dim_label if frame_dim_label is not None else "Frame" - # Per-frame labels: explicit param > inferred > empty - resolved_labels = frame_labels or _io_labels or [] - self._frame_labels = resolved_labels - if resolved_labels: - self.frame_labels = list(resolved_labels) - # Histogram axis range — first frame is enough (JS does per-frame percentile clipping). - # Cast to float for min/max reductions: PyTorch CUDA lacks integer min/max kernels, - # and the first slice is tiny (144 KB at 192×192) so the cast is free. - first_frame = self._data[0] if self._data.ndim == 5 else self._data - first_frame_sample = first_frame[0] if first_frame.ndim >= 3 else first_frame - if not torch.is_floating_point(first_frame_sample): - first_frame_sample = first_frame_sample.float() - self.dp_global_min = max(float(first_frame_sample.min()), MIN_LOG_VALUE) - self.dp_global_max = float(first_frame_sample.max()) - # Cache coordinate tensors for mask creation (avoid repeated torch.arange) - self._det_row_coords = torch.arange(self.det_rows, device=self._device, dtype=torch.float32)[:, None] - self._det_col_coords = torch.arange(self.det_cols, device=self._device, dtype=torch.float32)[None, :] - self._scan_row_coords = torch.arange(self.shape_rows, device=self._device, dtype=torch.float32)[:, None] - self._scan_col_coords = torch.arange(self.shape_cols, device=self._device, dtype=torch.float32)[None, :] - # Setup center and BF radius - det_size = min(self.det_rows, self.det_cols) - if center is not None and bf_radius is not None: - self.center_row = float(center[0]) - self.center_col = float(center[1]) - self.bf_radius = float(bf_radius) - elif center is not None: - self.center_row = float(center[0]) - self.center_col = float(center[1]) - self.bf_radius = det_size * DEFAULT_BF_RATIO - elif bf_radius is not None: - self.center_col = float(self.det_cols / 2) - self.center_row = float(self.det_rows / 2) - self.bf_radius = float(bf_radius) - else: - # Neither provided - auto-detect from data - # Set defaults first (will be overwritten by auto-detect) - self.center_col = float(self.det_cols / 2) - self.center_row = float(self.det_rows / 2) - self.bf_radius = det_size * DEFAULT_BF_RATIO - # Auto-detect center and bf_radius from the data - _tc = time.perf_counter() - self.auto_detect_center(update_roi=False) - if _verbose: - print(f" auto_detect_center: {time.perf_counter() - _tc:.2f}s") - - # Pre-compute and cache common virtual images (BF, ABF, ADF) - # Each cache stores (bytes, stats) tuple - self._cached_bf_virtual = None - self._cached_abf_virtual = None - self._cached_adf_virtual = None - self._cached_haadf_virtual = None - if precompute_virtual_images and self.n_frames == 1: - self._precompute_common_virtual_images() - - # Update frame when position changes (scale/colormap handled in JS) - self.observe(self._update_frame, names=["pos_row", "pos_col"]) - # Observe individual ROI params - self.observe(self._on_roi_change, names=[ - "roi_center_col", "roi_center_row", "roi_radius", "roi_radius_inner", - "roi_active", "roi_mode", "roi_width", "roi_height" - ]) - # Observe compound roi_center for batched updates from JS - self.observe(self._on_roi_center_change, names=["roi_center"]) - # Invalidate precomputed virtual image caches when calibration changes - self.observe(self._on_calibration_change, names=["center_row", "center_col", "bf_radius"]) - - # Initialize default ROI at BF center — batch to avoid redundant observer callbacks - with self.hold_trait_notifications(): - self.roi_center_col = self.center_col - self.roi_center_row = self.center_row - self.roi_center = [self.center_row, self.center_col] - self.roi_radius = self.bf_radius * 0.5 # Start with half BF radius - self.roi_active = True - - # Compute initial virtual image and frame (once, after all ROI traits are set) - _tc = time.perf_counter() - self._compute_virtual_image_from_roi() - self._update_frame() - if _verbose: - print(f" virtual image + frame: {time.perf_counter() - _tc:.2f}s") - - # Path animation: observe index changes from frontend - self.observe(self._on_path_index_change, names=["path_index"]) - self.observe(self._on_gif_export, names=["_gif_export_requested"]) - - # Frame animation (5D): observe frame_idx changes from frontend - self.observe(self._on_frame_idx_change, names=["frame_idx"]) - self.observe(self._on_preset_request, names=["_preset_request"]) - - # Auto-detect trigger: observe changes from frontend - - # VI ROI: observe changes for summed DP computation - # Initialize VI ROI center to scan center with reasonable default sizes - self.vi_roi_center_row = float(self.shape_rows / 2) - self.vi_roi_center_col = float(self.shape_cols / 2) - # Set initial ROI size based on scan dimension - default_roi_size = max(3, min(self.shape_rows, self.shape_cols) * DEFAULT_VI_ROI_RATIO) - self.vi_roi_radius = float(default_roi_size) - self.vi_roi_width = float(default_roi_size * 2) - self.vi_roi_height = float(default_roi_size) - self.observe(self._on_vi_roi_change, names=[ - "vi_roi_mode", "vi_roi_center_row", "vi_roi_center_col", - "vi_roi_radius", "vi_roi_width", "vi_roi_height", "vi_roi_reduce" - ]) - self.observe(self._on_vi_roi_center_change, names=["vi_roi_center"]) - - if state is not None: - if isinstance(state, (str, pathlib.Path)): - state = unwrap_state_payload( - json.loads(pathlib.Path(state).read_text()), - require_envelope=True, - ) - else: - state = unwrap_state_payload(state) - self.load_state_dict(state) - - if _verbose: - shape = "x".join(str(s) for s in self._data.shape) - print(f"Show4DSTEM: {shape} {self._device}, {time.perf_counter() - _t0:.2f}s total") - - def __repr__(self) -> str: - shape = ( - f"({self.n_frames}, {self.shape_rows}, {self.shape_cols}, {self.det_rows}, {self.det_cols})" - if self.n_frames > 1 - else f"({self.shape_rows}, {self.shape_cols}, {self.det_rows}, {self.det_cols})" - ) - frame_info = f", {self.frame_dim_label.lower()}={self.frame_idx}" if self.n_frames > 1 else "" - title_info = f", title='{self.title}'" if self.title else "" - return ( - f"Show4DSTEM(shape={shape}, " - f"sampling=({self.pixel_size} {self.pixel_unit}, {self.k_pixel_size} {self.k_pixel_unit}), " - f"pos=({self.pos_row}, {self.pos_col}){frame_info}{title_info})" - ) - - def state_dict(self): - return { - "title": self.title, - "pos_row": self.pos_row, - "pos_col": self.pos_col, - "pixel_size": self.pixel_size, - "pixel_unit": self.pixel_unit, - "k_pixel_size": self.k_pixel_size, - "k_pixel_unit": self.k_pixel_unit, - "k_calibrated": self.k_calibrated, - "center_row": self.center_row, - "center_col": self.center_col, - "bf_radius": self.bf_radius, - "roi_active": self.roi_active, - "roi_mode": self.roi_mode, - "roi_center_row": self.roi_center_row, - "roi_center_col": self.roi_center_col, - "roi_radius": self.roi_radius, - "roi_radius_inner": self.roi_radius_inner, - "roi_width": self.roi_width, - "roi_height": self.roi_height, - "vi_roi_mode": self.vi_roi_mode, - "vi_roi_center_row": self.vi_roi_center_row, - "vi_roi_center_col": self.vi_roi_center_col, - "vi_roi_radius": self.vi_roi_radius, - "vi_roi_width": self.vi_roi_width, - "vi_roi_height": self.vi_roi_height, - "vi_roi_reduce": self.vi_roi_reduce, - "dp_colormap": self.dp_colormap, - "vi_colormap": self.vi_colormap, - "fft_colormap": self.fft_colormap, - "dp_scale_mode": self.dp_scale_mode, - "vi_scale_mode": self.vi_scale_mode, - "fft_scale_mode": self.fft_scale_mode, - "dp_vmin_pct": self.dp_vmin_pct, - "dp_vmax_pct": self.dp_vmax_pct, - "vi_vmin_pct": self.vi_vmin_pct, - "vi_vmax_pct": self.vi_vmax_pct, - "fft_vmin_pct": self.fft_vmin_pct, - "fft_vmax_pct": self.fft_vmax_pct, - "dp_vmin": self.dp_vmin, - "dp_vmax": self.dp_vmax, - "vi_vmin": self.vi_vmin, - "vi_vmax": self.vi_vmax, - "fft_auto": self.fft_auto, - "show_fft": self.show_fft, - "fft_window": self.fft_window, - "show_controls": self.show_controls, - "dp_show_colorbar": self.dp_show_colorbar, - "vi_auto_contrast": self.vi_auto_contrast, - "vi_smooth": self.vi_smooth, - "path_interval_ms": self.path_interval_ms, - "path_loop": self.path_loop, - "profile_line": self.profile_line, - "profile_width": self.profile_width, - "frame_idx": self.frame_idx, - "frame_dim_label": self.frame_dim_label, - "frame_labels": list(self.frame_labels), - "frame_loop": self.frame_loop, - "frame_fps": self.frame_fps, - "frame_reverse": self.frame_reverse, - "frame_boomerang": self.frame_boomerang, - } - - def save(self, path: str): - save_state_file(path, "Show4DSTEM", self.state_dict()) - - def load_state_dict(self, state): - allowed_keys = set(self.state_dict().keys()) - pending_pos_row = state.get("pos_row", None) - pending_pos_col = state.get("pos_col", None) - pending_frame_idx = state.get("frame_idx", None) - for key, val in state.items(): - if key in {"pos_row", "pos_col", "frame_idx"}: - continue - if key in allowed_keys: - setattr(self, key, val) - if pending_frame_idx is not None: - self.frame_idx = int(max(0, min(int(pending_frame_idx), self.n_frames - 1))) - if pending_pos_row is not None or pending_pos_col is not None: - row = int(self.pos_row if pending_pos_row is None else pending_pos_row) - col = int(self.pos_col if pending_pos_col is None else pending_pos_col) - self.pos_row = int(max(0, min(row, self.shape_rows - 1))) - self.pos_col = int(max(0, min(col, self.shape_cols - 1))) - - def free(self): - """Free GPU memory held by this widget. - - Deletes the internal data tensor, runs garbage collection, and - flushes the MPS allocator cache. Call this before loading a new - dataset to avoid running out of GPU memory. - - Examples - -------- - >>> w.free() # release ~9 GB of MPS memory - >>> del result # free the source numpy array - """ - import gc - - device = str(self._device) if hasattr(self, "_device") else "" - nbytes = self._data.nbytes if hasattr(self._data, "nbytes") else 0 - self._data = None - gc.collect() - if device == "mps": - try: - torch.mps.empty_cache() - except AttributeError: - pass - elif device.startswith("cuda"): - torch.cuda.empty_cache() - if nbytes > 0: - print(f"freed {_format_memory(nbytes)} ({device})") - - def summary(self): - name = self.title if self.title else "Show4DSTEM" - lines = [name, "═" * 32] - if self.n_frames > 1: - parts = [f"{self.n_frames} ({self.frame_dim_label}), current: {self.frame_idx}"] - parts.append(f"{self.frame_fps} fps") - if self.frame_loop: - parts.append("loop") - if self.frame_reverse: - parts.append("reverse") - if self.frame_boomerang: - parts.append("bounce") - lines.append(f"Frames: {' | '.join(parts)}") - if self._frame_labels: - if len(self._frame_labels) <= 4: - lines.append(f"Labels: {self._frame_labels}") - else: - lines.append(f"Labels: {self._frame_labels[:3]} ... ({len(self._frame_labels)} total)") - lines.append(f"Scan: {self.shape_rows}×{self.shape_cols} ({self.pixel_size:.2f} {self.pixel_unit}/px)") - lines.append(f"Detector: {self.det_rows}×{self.det_cols} ({self.k_pixel_size:.4f} {self.k_pixel_unit}/px)") - lines.append(f"Position: ({self.pos_row}, {self.pos_col})") - lines.append(f"Center: ({self.center_row:.1f}, {self.center_col:.1f}) BF r={self.bf_radius:.1f} px") - if self.roi_active: - lines.append(f"ROI: {self.roi_mode} at ({self.roi_center_row:.1f}, {self.roi_center_col:.1f}) r={self.roi_radius:.1f}") - if self.vi_roi_mode != "off": - lines.append(f"VI ROI: {self.vi_roi_mode} at ({self.vi_roi_center_row:.1f}, {self.vi_roi_center_col:.1f}) r={self.vi_roi_radius:.1f}") - dp_contrast = f"{self.dp_vmin_pct:.1f}-{self.dp_vmax_pct:.1f}%" - if self.dp_vmin is not None and self.dp_vmax is not None: - dp_contrast += f", dp_vmin={self.dp_vmin:.4g}, dp_vmax={self.dp_vmax:.4g}" - lines.append( - f"DP view: {self.dp_colormap}, {self.dp_scale_mode}, {dp_contrast}" - ) - vi_contrast = f"{self.vi_vmin_pct:.1f}-{self.vi_vmax_pct:.1f}%" - if self.vi_vmin is not None and self.vi_vmax is not None: - vi_contrast += f", vi_vmin={self.vi_vmin:.4g}, vi_vmax={self.vi_vmax:.4g}" - lines.append( - f"VI view: {self.vi_colormap}, {self.vi_scale_mode}, {vi_contrast}" - ) - if self.show_fft: - fft_parts = [f"{self.fft_colormap}, {self.fft_scale_mode}, {self.fft_vmin_pct:.1f}-{self.fft_vmax_pct:.1f}%, auto={self.fft_auto}"] - if not self.fft_window: - fft_parts.append("no window") - lines.append(f"FFT view: {', '.join(fft_parts)}") - if self.profile_line and len(self.profile_line) == 2: - p0, p1 = self.profile_line[0], self.profile_line[1] - lines.append(f"Profile: ({p0['row']:.0f}, {p0['col']:.0f}) -> ({p1['row']:.0f}, {p1['col']:.0f}) width={self.profile_width}") - print("\n".join(lines)) - - # ========================================================================= - # Convenience Properties - # ========================================================================= - - @property - def position(self) -> tuple[int, int]: - """Current scan position as (row, col) tuple.""" - return (self.pos_row, self.pos_col) - - @position.setter - def position(self, value: tuple[int, int]) -> None: - """Set scan position from (row, col) tuple.""" - self.pos_row, self.pos_col = value - - @property - def scan_shape(self) -> tuple[int, int]: - """Scan dimensions as (rows, cols) tuple.""" - return (self.shape_rows, self.shape_cols) - - @property - def detector_shape(self) -> tuple[int, int]: - """Detector dimensions as (rows, cols) tuple.""" - return (self.det_rows, self.det_cols) - - @property - def _frame_data(self) -> torch.Tensor: - """Per-frame data (4D or 3D flattened), accounting for 5D time/tilt series.""" - if self.n_frames > 1: - return self._data[self.frame_idx] - return self._data - - # ========================================================================= - # Line Profile - # ========================================================================= - - def set_profile(self, start: tuple, end: tuple) -> Self: - row0, col0 = start - row1, col1 = end - self.profile_line = [ - {"row": float(row0), "col": float(col0)}, - {"row": float(row1), "col": float(col1)}, - ] - return self - - def clear_profile(self) -> Self: - self.profile_line = [] - return self - - @property - def profile(self) -> list[tuple[float, float]]: - if len(self.profile_line) == 2: - p0, p1 = self.profile_line[0], self.profile_line[1] - return [(p0["row"], p0["col"]), (p1["row"], p1["col"])] - return [] - - @property - def profile_values(self): - if len(self.profile_line) != 2: - return None - p0, p1 = self.profile_line[0], self.profile_line[1] - frame = self._get_frame(self.pos_row, self.pos_col) - return self._sample_line(frame, p0["row"], p0["col"], p1["row"], p1["col"]) - - @property - def profile_distance(self) -> float: - if len(self.profile_line) != 2: - return 0.0 - p0, p1 = self.profile_line[0], self.profile_line[1] - dist_px = np.sqrt((p1["row"] - p0["row"]) ** 2 + (p1["col"] - p0["col"]) ** 2) - if self.k_calibrated: - return float(dist_px * self.k_pixel_size) - return float(dist_px) - - def _sample_line(self, frame, row0, col0, row1, col1): - h, w = frame.shape[:2] - dc = col1 - col0 - dr = row1 - row0 - length = np.sqrt(dc * dc + dr * dr) - n = max(2, int(np.ceil(length))) - t = np.linspace(0.0, 1.0, n) - c = col0 + t * dc - r = row0 + t * dr - ci = np.floor(c).astype(np.intp) - ri = np.floor(r).astype(np.intp) - cf = c - ci - rf = r - ri - c0 = np.clip(ci, 0, w - 1) - c1 = np.clip(ci + 1, 0, w - 1) - r0 = np.clip(ri, 0, h - 1) - r1 = np.clip(ri + 1, 0, h - 1) - return ( - frame[r0, c0] * (1 - cf) * (1 - rf) - + frame[r0, c1] * cf * (1 - rf) - + frame[r1, c0] * (1 - cf) * rf - + frame[r1, c1] * cf * rf - ).astype(np.float32) - - # ========================================================================= - # Path Animation Methods - # ========================================================================= - - def set_path( - self, - points: list[tuple[int, int]], - interval_ms: int = 100, - loop: bool = True, - autoplay: bool = True, - ) -> Self: - """ - Set a custom path of scan positions to animate through. - - Parameters - ---------- - points : list[tuple[int, int]] - List of (row, col) scan positions to visit. - interval_ms : int, default 100 - Time between frames in milliseconds. - loop : bool, default True - Whether to loop when reaching end. - autoplay : bool, default True - Start playing immediately. - - Returns - ------- - Show4DSTEM - Self for method chaining. - - Examples - -------- - >>> widget.set_path([(0, 0), (10, 10), (20, 20), (30, 30)]) - >>> widget.set_path([(i, i) for i in range(48)], interval_ms=50) - """ - self._path_points = list(points) - self.path_length = len(self._path_points) - self.path_index = 0 - self.path_interval_ms = interval_ms - self.path_loop = loop - if autoplay and self.path_length > 0: - self.path_playing = True - return self - - def play(self) -> Self: - """Start playing the path animation.""" - if self.path_length > 0: - self.path_playing = True - return self - - def pause(self) -> Self: - """Pause the path animation.""" - self.path_playing = False - return self - - def stop(self) -> Self: - """Stop and reset path animation to beginning.""" - self.path_playing = False - self.path_index = 0 - return self - - def goto(self, index: int) -> Self: - """Jump to a specific index in the path.""" - if 0 <= index < self.path_length: - self.path_index = index - return self - - def _on_path_index_change(self, change): - """Called when path_index changes (from frontend timer).""" - idx = change["new"] - if 0 <= idx < len(self._path_points): - row, col = self._path_points[idx] - # Clamp to valid range - self.pos_row = max(0, min(self.shape_rows - 1, row)) - self.pos_col = max(0, min(self.shape_cols - 1, col)) - - def _on_preset_request(self, change): - """JS preset shortcut → atomic apply_preset (no per-trait race).""" - name = (change.get("new") or "").strip().lower() - if name in ("bf", "abf", "adf", "haadf"): - self.apply_preset(name) - self._preset_request = "" # consume trigger - - def _on_frame_idx_change(self, change=None): - """Called when frame_idx changes (5D time/tilt series). - - Recomputes virtual image and diffraction pattern for the new frame. - Invalidates precomputed caches since they are per-frame. - """ - if self.n_frames <= 1: - return - # Invalidate precomputed caches (they were for a different frame) - self._cached_bf_virtual = None - self._cached_abf_virtual = None - self._cached_adf_virtual = None - self._cached_haadf_virtual = None - # Recompute virtual image and displayed frame - self._compute_virtual_image_from_roi() - self._update_frame() - # Recompute reduced DP if VI ROI is active - if self.vi_roi_mode != "off": - self._compute_vi_roi_dp() - - # ========================================================================= - # Path Animation Patterns - # ========================================================================= - - def raster( - self, - step: int = 1, - bidirectional: bool = False, - interval_ms: int = 100, - loop: bool = True, - ) -> Self: - """ - Play a raster scan path (row by row, left to right). - - This mimics real STEM scanning: left→right, step down, left→right, etc. - - Parameters - ---------- - step : int, default 1 - Step size between positions. - bidirectional : bool, default False - If True, use snake/boustrophedon pattern (alternating direction). - If False (default), always scan left→right like real STEM. - interval_ms : int, default 100 - Time between frames in milliseconds. - loop : bool, default True - Whether to loop when reaching the end. - - Returns - ------- - Show4DSTEM - Self for method chaining. - """ - points = [] - for r in range(0, self.shape_rows, step): - cols = list(range(0, self.shape_cols, step)) - if bidirectional and (r // step % 2 == 1): - cols = cols[::-1] # Alternate direction for snake pattern - for c in cols: - points.append((r, c)) - return self.set_path(points=points, interval_ms=interval_ms, loop=loop) - - # ========================================================================= - # ROI Mode Methods - # ========================================================================= - - def roi_circle(self, radius: float | None = None) -> Self: - """ - Switch to circle ROI mode for virtual imaging. - - In circle mode, the virtual image integrates over a circular region - centered at the current ROI position (like a virtual bright field detector). - - Parameters - ---------- - radius : float, optional - Radius of the circle in pixels. If not provided, uses current value - or defaults to half the BF radius. - - Returns - ------- - Show4DSTEM - Self for method chaining. - - Examples - -------- - >>> widget.roi_circle(20) # 20px radius circle - >>> widget.roi_circle() # Use default radius - """ - self.roi_mode = "circle" - if radius is not None: - self.roi_radius = float(radius) - return self - - def roi_point(self) -> Self: - """ - Switch to point ROI mode (single-pixel indexing). - - In point mode, the virtual image shows intensity at the exact ROI position. - This is the default mode. - - Returns - ------- - Show4DSTEM - Self for method chaining. - """ - self.roi_mode = "point" - return self - - def roi_square(self, half_size: float | None = None) -> Self: - """ - Switch to square ROI mode for virtual imaging. - - In square mode, the virtual image integrates over a square region - centered at the current ROI position. - - Parameters - ---------- - half_size : float, optional - Half-size of the square in pixels (distance from center to edge). - A half_size of 15 creates a 30x30 pixel square. - If not provided, uses current roi_radius value. - - Returns - ------- - Show4DSTEM - Self for method chaining. - - Examples - -------- - >>> widget.roi_square(15) # 30x30 pixel square (half_size=15) - >>> widget.roi_square() # Use default size - """ - self.roi_mode = "square" - if half_size is not None: - self.roi_radius = float(half_size) - return self - - def roi_annular( - self, inner_radius: float | None = None, outer_radius: float | None = None - ) -> Self: - """ - Set ROI mode to annular (donut-shaped) for ADF/HAADF imaging. - - Parameters - ---------- - inner_radius : float, optional - Inner radius in pixels. If not provided, uses current roi_radius_inner. - outer_radius : float, optional - Outer radius in pixels. If not provided, uses current roi_radius. - - Returns - ------- - Show4DSTEM - Self for method chaining. - - Examples - -------- - >>> widget.roi_annular(20, 50) # ADF: inner=20px, outer=50px - >>> widget.roi_annular(30, 80) # HAADF: larger angles - """ - self.roi_mode = "annular" - if inner_radius is not None: - self.roi_radius_inner = float(inner_radius) - if outer_radius is not None: - self.roi_radius = float(outer_radius) - return self - - def roi_rect( - self, width: float | None = None, height: float | None = None - ) -> Self: - """ - Set ROI mode to rectangular. - - Parameters - ---------- - width : float, optional - Width in pixels. If not provided, uses current roi_width. - height : float, optional - Height in pixels. If not provided, uses current roi_height. - - Returns - ------- - Show4DSTEM - Self for method chaining. - - Examples - -------- - >>> widget.roi_rect(30, 20) # 30px wide, 20px tall - >>> widget.roi_rect(40, 40) # 40x40 rectangle - """ - self.roi_mode = "rect" - if width is not None: - self.roi_width = float(width) - if height is not None: - self.roi_height = float(height) - return self - - def auto_detect_center(self, update_roi: bool = True) -> Self: - """ - Automatically detect BF disk center and radius using centroid. - - This method analyzes the summed diffraction pattern to find the - bright field disk center and estimate its radius. The detected - values are applied to the widget's calibration (center_row, center_col, - bf_radius). - - Parameters - ---------- - update_roi : bool, default True - If True, also update ROI center and recompute cached virtual images. - Set to False during __init__ when ROI is not yet initialized. - - Returns - ------- - Show4DSTEM - Self for method chaining. - - Examples - -------- - >>> widget = Show4DSTEM(data) - >>> widget.auto_detect_center() # Auto-detect and apply - """ - # Sum diffraction patterns over scan positions to find BF disk centroid. - # Single chunked torch float path: works identically on CUDA / MPS / CPU. - # Each chunk casts uint16 → float32 transiently (~600 MB max), accumulates. - data_flat = self._data.reshape(-1, *self._det_shape) - n_pos = data_flat.shape[0] - mean_dp = torch.zeros(self._det_shape, dtype=torch.float32, device=self._device) - # Float32 cast transient = positions × det_h × det_w × 4 bytes; cap at budget. - pos_per_chunk = max(1, _CHUNK_BYTE_BUDGET // max(1, self._det_shape[0] * self._det_shape[1] * 4)) - for i in range(0, n_pos, pos_per_chunk): - mean_dp += data_flat[i:i + pos_per_chunk].sum(dim=0, dtype=torch.float32) - - threshold = mean_dp.mean() + mean_dp.std() - mask = mean_dp > threshold - - total = mask.sum() - if total == 0: - return self - - cx = float((self._det_col_coords * mask).sum() / total) - cy = float((self._det_row_coords * mask).sum() / total) - radius = float(torch.sqrt(total / torch.pi)) - - # Apply detected values - self.center_col = cx - self.center_row = cy - self.bf_radius = radius - - if update_roi: - # Also update ROI to center - self.roi_center_col = cx - self.roi_center_row = cy - # Recompute cached virtual images with new calibration - self._precompute_common_virtual_images() - - return self - - def _get_frame(self, row: int, col: int) -> np.ndarray: - """Get single diffraction frame at position (row, col) as numpy array.""" - if self._data is None: - return np.zeros((self.det_rows, self.det_cols), dtype=np.float32) - data = self._frame_data - if data.ndim == 3: - idx = row * self.shape_cols + col - return data[idx].cpu().numpy() - else: - return data[row, col].cpu().numpy() - - def _apply_scale_mode(self, data: np.ndarray, mode: str) -> np.ndarray: - arr = np.asarray(data, dtype=np.float32) - if mode == "log": - return np.log1p(np.maximum(arr, 0.0)).astype(np.float32) - return arr.astype(np.float32) - - def _slider_range( - self, - data_min: float, - data_max: float, - vmin_pct: float, - vmax_pct: float, - ) -> tuple[float, float]: - v0 = float(max(0.0, min(100.0, vmin_pct))) - v1 = float(max(0.0, min(100.0, vmax_pct))) - if v1 < v0: - v0, v1 = v1, v0 - rng = float(data_max - data_min) - return ( - float(data_min + (v0 / 100.0) * rng), - float(data_min + (v1 / 100.0) * rng), - ) - - def _render_colormap_rgb( - self, - data: np.ndarray, - cmap_name: str, - vmin: float, - vmax: float, - ) -> np.ndarray: - from matplotlib import colormaps - - arr = np.asarray(data, dtype=np.float32) - if vmax <= vmin: - normalized = np.zeros_like(arr, dtype=np.float32) - else: - normalized = np.clip((arr - vmin) / (vmax - vmin), 0.0, 1.0) - rgba = colormaps.get_cmap(cmap_name)(normalized) - return (rgba[..., :3] * 255).astype(np.uint8) - - def _get_virtual_image_array(self) -> np.ndarray: - if not self.virtual_image_bytes: - return np.zeros((self.shape_rows, self.shape_cols), dtype=np.float32) - arr = np.frombuffer(self.virtual_image_bytes, dtype=np.float32) - expected = self.shape_rows * self.shape_cols - if arr.size != expected: - return np.zeros((self.shape_rows, self.shape_cols), dtype=np.float32) - return arr.reshape(self.shape_rows, self.shape_cols).copy() - - def _get_vi_roi_dp_array(self) -> np.ndarray | None: - if self.vi_roi_mode == "off": - return None - self._compute_vi_roi_dp() - if not self.vi_roi_dp_bytes: - return None - arr = np.frombuffer(self.vi_roi_dp_bytes, dtype=np.float32) - expected = self.det_rows * self.det_cols - if arr.size != expected: - return None - return arr.reshape(self.det_rows, self.det_cols).copy() - - def _fft_enhanced_range(self, mag: np.ndarray) -> tuple[float, float]: - arr = np.asarray(mag, dtype=np.float32).copy() - if arr.size == 0: - return 0.0, 0.0 - center_row = arr.shape[0] // 2 - center_col = arr.shape[1] // 2 - neighbors = [] - if center_col - 1 >= 0: - neighbors.append(arr[center_row, center_col - 1]) - if center_col + 1 < arr.shape[1]: - neighbors.append(arr[center_row, center_col + 1]) - if center_row - 1 >= 0: - neighbors.append(arr[center_row - 1, center_col]) - if center_row + 1 < arr.shape[0]: - neighbors.append(arr[center_row + 1, center_col]) - if neighbors: - arr[center_row, center_col] = float(np.mean(neighbors)) - dmin = float(arr.min()) - dmax = float(arr.max()) - if dmax <= dmin: - return dmin, dmax - pmax = float(np.percentile(arr, 99.9)) - if pmax <= dmin: - pmax = dmax - return dmin, pmax - - def _render_dp_rgb(self) -> tuple[np.ndarray, dict]: - vi_roi_arr = self._get_vi_roi_dp_array() - if vi_roi_arr is not None: - raw = vi_roi_arr - source = "vi_roi_dp" - else: - raw = self._get_frame(self.pos_row, self.pos_col).astype(np.float32) - source = "single_frame" - - scale_mode = self.dp_scale_mode - scaled = self._apply_scale_mode(raw, scale_mode) - data_min = float(scaled.min()) if scaled.size else 0.0 - data_max = float(scaled.max()) if scaled.size else 0.0 - if self.dp_vmin is not None and self.dp_vmax is not None: - vmin = float(self._apply_scale_mode( - np.array([max(self.dp_vmin, 0)], dtype=np.float32), scale_mode - )[0]) - vmax = float(self._apply_scale_mode( - np.array([max(self.dp_vmax, 0)], dtype=np.float32), scale_mode - )[0]) - else: - vmin, vmax = self._slider_range(data_min, data_max, self.dp_vmin_pct, self.dp_vmax_pct) - rgb = self._render_colormap_rgb(scaled, self.dp_colormap, vmin, vmax) - metadata = { - "source": source, - "colormap": self.dp_colormap, - "scale_mode": scale_mode, - "vmin_pct": float(self.dp_vmin_pct), - "vmax_pct": float(self.dp_vmax_pct), - "vmin": float(vmin), - "vmax": float(vmax), - } - return rgb, metadata - - def _render_virtual_rgb(self) -> tuple[np.ndarray, dict]: - raw = self._get_virtual_image_array() - scaled = self._apply_scale_mode(raw, self.vi_scale_mode) - data_min = float(scaled.min()) if scaled.size else 0.0 - data_max = float(scaled.max()) if scaled.size else 0.0 - if self.vi_vmin is not None and self.vi_vmax is not None: - vmin = float(self._apply_scale_mode( - np.array([max(self.vi_vmin, 0)], dtype=np.float32), self.vi_scale_mode - )[0]) - vmax = float(self._apply_scale_mode( - np.array([max(self.vi_vmax, 0)], dtype=np.float32), self.vi_scale_mode - )[0]) - else: - vmin, vmax = self._slider_range(data_min, data_max, self.vi_vmin_pct, self.vi_vmax_pct) - rgb = self._render_colormap_rgb(scaled, self.vi_colormap, vmin, vmax) - metadata = { - "colormap": self.vi_colormap, - "scale_mode": self.vi_scale_mode, - "vmin_pct": float(self.vi_vmin_pct), - "vmax_pct": float(self.vi_vmax_pct), - "vmin": float(vmin), - "vmax": float(vmax), - } - return rgb, metadata - - def _render_fft_rgb(self) -> tuple[np.ndarray, dict]: - virtual_raw = self._get_virtual_image_array() - fft = np.fft.fftshift(np.fft.fft2(virtual_raw)) - mag = np.abs(fft).astype(np.float32) - scaled = self._apply_scale_mode(mag, self.fft_scale_mode) - if self.fft_auto: - display_min, display_max = self._fft_enhanced_range(scaled) - else: - display_min = float(scaled.min()) if scaled.size else 0.0 - display_max = float(scaled.max()) if scaled.size else 0.0 - vmin, vmax = self._slider_range(display_min, display_max, self.fft_vmin_pct, self.fft_vmax_pct) - rgb = self._render_colormap_rgb(scaled, self.fft_colormap, vmin, vmax) - metadata = { - "colormap": self.fft_colormap, - "scale_mode": self.fft_scale_mode, - "auto": bool(self.fft_auto), - "vmin_pct": float(self.fft_vmin_pct), - "vmax_pct": float(self.fft_vmax_pct), - "vmin": float(vmin), - "vmax": float(vmax), - } - return rgb, metadata - - _EXPORT_VIEWS = ("diffraction", "virtual", "fft", "all") - _EXPORT_FORMATS = ("png", "pdf") - - def _validate_export_view(self, view: str | None) -> str: - view_key = (view or "all").strip().lower() - if view_key not in self._EXPORT_VIEWS: - raise ValueError(f"Unsupported view '{view}'. Supported: {', '.join(self._EXPORT_VIEWS)}") - return view_key - - def _validate_frame_idx(self, frame_idx: int | None) -> int: - if frame_idx is None: - return int(self.frame_idx) - idx = int(frame_idx) - if idx < 0 or idx >= self.n_frames: - raise ValueError( - f"frame_idx={idx} is out of range [0, {self.n_frames - 1}]" - ) - return idx - - def _validate_position(self, position: tuple[int, int] | None) -> tuple[int, int]: - if position is None: - return int(self.pos_row), int(self.pos_col) - if len(position) != 2: - raise ValueError( - "position must be a (row, col) tuple with exactly two values" - ) - row = int(position[0]) - col = int(position[1]) - if row < 0 or row >= self.shape_rows or col < 0 or col >= self.shape_cols: - raise ValueError( - f"position=({row}, {col}) is out of range for " - f"scan_shape=({self.shape_rows}, {self.shape_cols})" - ) - return row, col - - def _resolve_export_format(self, path: pathlib.Path, fmt: str | None) -> str: - resolved = (fmt or path.suffix.lstrip(".") or "png").strip().lower() - if resolved not in self._EXPORT_FORMATS: - raise ValueError(f"Unsupported format '{resolved}'. Supported: {', '.join(self._EXPORT_FORMATS)}") - return resolved - - @staticmethod - def _round_to_nice_value(value: float) -> float: - if value <= 0: - return 1.0 - magnitude = 10 ** math.floor(math.log10(value)) - normalized = value / magnitude - if normalized < 1.5: - return float(magnitude) - if normalized < 3.5: - return float(2 * magnitude) - if normalized < 7.5: - return float(5 * magnitude) - return float(10 * magnitude) - - def _format_scale_label(self, value: float, unit: str) -> str: - nice = self._round_to_nice_value(value) - if unit == "Å": - if nice >= 10: - return f"{int(round(nice / 10))} nm" - if nice >= 1: - return f"{int(round(nice))} Å" - return f"{nice:.2f} Å" - if unit == "mrad": - if nice >= 1000: - return f"{int(round(nice / 1000))} rad" - if nice >= 1: - return f"{int(round(nice))} mrad" - return f"{nice:.2f} mrad" - if nice >= 1: - return f"{int(round(nice))} px" - return f"{nice:.1f} px" - - @staticmethod - def _draw_crosshair(draw, x: float, y: float, size: float, color, width: int) -> None: - draw.line([(x - size, y), (x + size, y)], fill=color, width=width) - draw.line([(x, y - size), (x, y + size)], fill=color, width=width) - - def _draw_scalebar_overlay(self, image, pixel_size: float, unit: str) -> None: - from PIL import ImageDraw, ImageFont - - if pixel_size <= 0: - return - - draw = ImageDraw.Draw(image, mode="RGBA") - font = ImageFont.load_default() - width, height = image.size - margin = max(8, int(min(width, height) * 0.04)) - thickness = max(2, int(height * 0.01)) - target_bar_px = max(36, int(width * 0.15)) - target_physical = float(target_bar_px) * float(pixel_size) - nice_physical = self._round_to_nice_value(target_physical) - bar_px = max(12, int(round(nice_physical / float(pixel_size)))) - bar_px = min(bar_px, max(12, int(width * 0.8))) - - x1 = width - margin - x0 = x1 - bar_px - y1 = height - margin - y0 = y1 - thickness - - draw.rectangle([(x0 + 1, y0 + 1), (x1 + 1, y1 + 1)], fill=(0, 0, 0, 180)) - draw.rectangle([(x0, y0), (x1, y1)], fill=(255, 255, 255, 255)) - - label = self._format_scale_label(nice_physical, unit) - label_bbox = draw.textbbox((0, 0), label, font=font) - label_w = label_bbox[2] - label_bbox[0] - label_h = label_bbox[3] - label_bbox[1] - tx = x0 + (bar_px - label_w) / 2 - ty = y0 - label_h - 4 - draw.text((tx + 1, ty + 1), label, fill=(0, 0, 0, 220), font=font) - draw.text((tx, ty), label, fill=(255, 255, 255, 255), font=font) - - zoom_label = "1.0x" - zoom_bbox = draw.textbbox((0, 0), zoom_label, font=font) - zoom_h = zoom_bbox[3] - zoom_bbox[1] - zx = margin - zy = height - margin - zoom_h - draw.text((zx + 1, zy + 1), zoom_label, fill=(0, 0, 0, 220), font=font) - draw.text((zx, zy), zoom_label, fill=(255, 255, 255, 255), font=font) - - def _draw_dp_overlays(self, image) -> None: - from PIL import ImageDraw - - draw = ImageDraw.Draw(image, mode="RGBA") - width, height = image.size - scale_x = float(width) / float(max(1, self.det_cols)) - scale_y = float(height) / float(max(1, self.det_rows)) - cx = float(self.roi_center_col) * scale_x - cy = float(self.roi_center_row) * scale_y - - if self.roi_active and self.roi_mode != "point": - stroke = (0, 220, 0, 240) - fill = (0, 220, 0, 45) - if self.roi_mode == "circle": - rx = float(self.roi_radius) * scale_x - ry = float(self.roi_radius) * scale_y - draw.ellipse([(cx - rx, cy - ry), (cx + rx, cy + ry)], outline=stroke, fill=fill, width=2) - elif self.roi_mode == "square": - rx = float(self.roi_radius) * scale_x - ry = float(self.roi_radius) * scale_y - draw.rectangle([(cx - rx, cy - ry), (cx + rx, cy + ry)], outline=stroke, fill=fill, width=2) - elif self.roi_mode == "rect": - rx = (float(self.roi_width) / 2.0) * scale_x - ry = (float(self.roi_height) / 2.0) * scale_y - draw.rectangle([(cx - rx, cy - ry), (cx + rx, cy + ry)], outline=stroke, fill=fill, width=2) - elif self.roi_mode == "annular": - outer_rx = float(self.roi_radius) * scale_x - outer_ry = float(self.roi_radius) * scale_y - inner_rx = float(self.roi_radius_inner) * scale_x - inner_ry = float(self.roi_radius_inner) * scale_y - draw.ellipse( - [(cx - outer_rx, cy - outer_ry), (cx + outer_rx, cy + outer_ry)], - outline=stroke, - fill=fill, - width=2, - ) - draw.ellipse( - [(cx - inner_rx, cy - inner_ry), (cx + inner_rx, cy + inner_ry)], - outline=stroke, - fill=(0, 0, 0, 0), - width=2, - ) - - marker_color = (0, 220, 0, 255) if self.roi_active else (255, 100, 100, 255) - self._draw_crosshair(draw, cx, cy, size=max(6, int(min(width, height) * 0.03)), color=marker_color, width=2) - - if len(self.profile_line) == 2: - p0, p1 = self.profile_line[0], self.profile_line[1] - x0 = float(p0["col"]) * scale_x - y0 = float(p0["row"]) * scale_y - x1 = float(p1["col"]) * scale_x - y1 = float(p1["row"]) * scale_y - draw.line([(x0, y0), (x1, y1)], fill=(0, 200, 255, 240), width=max(1, int(self.profile_width))) - r = 3 - draw.ellipse([(x0 - r, y0 - r), (x0 + r, y0 + r)], fill=(0, 200, 255, 255)) - draw.ellipse([(x1 - r, y1 - r), (x1 + r, y1 + r)], fill=(0, 200, 255, 255)) - - def _draw_vi_overlays(self, image) -> None: - from PIL import ImageDraw - - draw = ImageDraw.Draw(image, mode="RGBA") - width, height = image.size - scale_x = float(width) / float(max(1, self.shape_cols)) - scale_y = float(height) / float(max(1, self.shape_rows)) - - px = float(self.pos_col) * scale_x - py = float(self.pos_row) * scale_y - self._draw_crosshair( - draw, - px, - py, - size=max(6, int(min(width, height) * 0.03)), - color=(255, 100, 100, 240), - width=2, - ) - - if self.vi_roi_mode == "off": - return - - cx = float(self.vi_roi_center_col) * scale_x - cy = float(self.vi_roi_center_row) * scale_y - stroke = (180, 80, 255, 240) - fill = (180, 80, 255, 45) - if self.vi_roi_mode == "circle": - rx = float(self.vi_roi_radius) * scale_x - ry = float(self.vi_roi_radius) * scale_y - draw.ellipse([(cx - rx, cy - ry), (cx + rx, cy + ry)], outline=stroke, fill=fill, width=2) - elif self.vi_roi_mode == "square": - rx = float(self.vi_roi_radius) * scale_x - ry = float(self.vi_roi_radius) * scale_y - draw.rectangle([(cx - rx, cy - ry), (cx + rx, cy + ry)], outline=stroke, fill=fill, width=2) - elif self.vi_roi_mode == "rect": - rx = (float(self.vi_roi_width) / 2.0) * scale_x - ry = (float(self.vi_roi_height) / 2.0) * scale_y - draw.rectangle([(cx - rx, cy - ry), (cx + rx, cy + ry)], outline=stroke, fill=fill, width=2) - - self._draw_crosshair( - draw, - cx, - cy, - size=max(6, int(min(width, height) * 0.03)), - color=(180, 80, 255, 240), - width=2, - ) - - def _decorate_panel( - self, - image, - panel_key: str, - include_overlays: bool, - include_scalebar: bool, - ): - out = image.copy() - if include_overlays: - if panel_key == "diffraction": - self._draw_dp_overlays(out) - elif panel_key == "virtual": - self._draw_vi_overlays(out) - if include_scalebar: - if panel_key == "diffraction": - unit = "mrad" if self.k_calibrated else "px" - self._draw_scalebar_overlay(out, float(self.k_pixel_size), unit) - elif panel_key == "virtual": - self._draw_scalebar_overlay(out, float(self.pixel_size), "Å") - return out - - def _render_panel_image( - self, - panel_key: str, - include_overlays: bool, - include_scalebar: bool, - ) -> tuple[Any, dict[str, Any]]: - from PIL import Image - - if panel_key == "diffraction": - rgb, render_meta = self._render_dp_rgb() - elif panel_key == "virtual": - rgb, render_meta = self._render_virtual_rgb() - elif panel_key == "fft": - rgb, render_meta = self._render_fft_rgb() - else: - raise ValueError(f"Unsupported panel {panel_key!r}. Valid options: 'diffraction', 'virtual', 'fft', 'all'.") - - panel = Image.fromarray(rgb, mode="RGB") - panel = self._decorate_panel(panel, panel_key, include_overlays, include_scalebar) - return panel, render_meta - - def _compose_horizontal(self, panels: list[Any]): - from PIL import Image - - height = max(panel.height for panel in panels) - width = sum(panel.width for panel in panels) - composite = Image.new("RGB", (width, height), color=(0, 0, 0)) - x0 = 0 - for panel in panels: - composite.paste(panel, (x0, 0)) - x0 += panel.width - return composite - - def _calibration_metadata(self) -> dict[str, Any]: - return { - "pixel_size_angstrom": float(self.pixel_size), - "pixel_size_unit": "Å/px", - "k_pixel_size": float(self.k_pixel_size), - "k_pixel_size_unit": "mrad/px" if self.k_calibrated else "px/px", - "k_calibrated": bool(self.k_calibrated), - "center_row": float(self.center_row), - "center_col": float(self.center_col), - "bf_radius": float(self.bf_radius), - } - - def _roi_metadata(self) -> dict[str, Any]: - return { - "active": bool(self.roi_active), - "mode": self.roi_mode, - "center_row": float(self.roi_center_row), - "center_col": float(self.roi_center_col), - "radius": float(self.roi_radius), - "radius_inner": float(self.roi_radius_inner), - "width": float(self.roi_width), - "height": float(self.roi_height), - } - - def _vi_roi_metadata(self) -> dict[str, Any]: - return { - "mode": self.vi_roi_mode, - "center_row": float(self.vi_roi_center_row), - "center_col": float(self.vi_roi_center_col), - "radius": float(self.vi_roi_radius), - "width": float(self.vi_roi_width), - "height": float(self.vi_roi_height), - } - - def _build_image_export_metadata( - self, - export_path: pathlib.Path, - view_key: str, - fmt: str, - render_meta: dict[str, Any], - include_overlays: bool, - include_scalebar: bool, - export_kind: str, - extra: dict[str, Any] | None = None, - ) -> dict[str, Any]: - metadata: dict[str, Any] = { - **build_json_header("Show4DSTEM"), - "view": view_key, - "format": fmt, - "export_kind": export_kind, - "path": str(export_path), - "position": {"row": int(self.pos_row), "col": int(self.pos_col)}, - "frame_idx": int(self.frame_idx), - "n_frames": int(self.n_frames), - "scan_shape": {"rows": int(self.shape_rows), "cols": int(self.shape_cols)}, - "detector_shape": {"rows": int(self.det_rows), "cols": int(self.det_cols)}, - "roi": self._roi_metadata(), - "vi_roi": self._vi_roi_metadata(), - "calibration": self._calibration_metadata(), - "display": render_meta, - "include_overlays": bool(include_overlays), - "include_scalebar": bool(include_scalebar), - } - if extra: - metadata.update(extra) - return metadata - - def save_image( - self, - path: str | pathlib.Path, - view: str | None = None, - position: tuple[int, int] | None = None, - frame_idx: int | None = None, - format: str | None = None, - include_metadata: bool = True, - metadata_path: str | pathlib.Path | None = None, - include_overlays: bool | None = None, - include_scalebar: bool | None = None, - restore_state: bool = True, - dpi: int | None = None, - ) -> pathlib.Path: - """ - Save the current visualization as PNG or PDF. - - Parameters - ---------- - path : str or pathlib.Path - Output image path. - view : str, optional - One of: "diffraction", "virtual", "fft", "all". - position : tuple[int, int], optional - Temporary scan position override as (row, col) for this export. - frame_idx : int, optional - Temporary frame index override for 5D data. - format : str, optional - "png" or "pdf". If omitted, inferred from file extension. - include_metadata : bool, default True - If True, writes JSON metadata next to the image. - metadata_path : str or pathlib.Path, optional - Override metadata JSON path. - include_overlays : bool, default True - Draw ROI/profile/crosshair overlays on exported panels. - include_scalebar : bool, default True - Draw panel scale bars on exported panels. - restore_state : bool, default True - If True, temporary position/frame overrides are reverted after export. - dpi : int, optional - Export DPI metadata. - - Returns - ------- - pathlib.Path - The written image path. - """ - from PIL import Image - - export_path = pathlib.Path(path) - view_key = self._validate_export_view(view) - fmt = self._resolve_export_format(export_path, format) - dpi_value = 300 if dpi is None else int(dpi) - overlays_enabled = True if include_overlays is None else bool(include_overlays) - scalebar_enabled = True if include_scalebar is None else bool(include_scalebar) - if dpi_value <= 0: - raise ValueError(f"dpi must be > 0, got {dpi_value}") - - export_path.parent.mkdir(parents=True, exist_ok=True) - - prev_row, prev_col = self.pos_row, self.pos_col - prev_frame = self.frame_idx - meta_path: pathlib.Path | None = None - - try: - if frame_idx is not None: - self.frame_idx = self._validate_frame_idx(frame_idx) - if position is not None: - row, col = self._validate_position(position) - self.pos_row = row - self.pos_col = col - - if view_key == "diffraction": - image, dp_meta = self._render_panel_image( - "diffraction", overlays_enabled, scalebar_enabled - ) - render_meta = {"diffraction": dp_meta} - elif view_key == "virtual": - image, vi_meta = self._render_panel_image( - "virtual", overlays_enabled, scalebar_enabled - ) - render_meta = {"virtual": vi_meta} - elif view_key == "fft": - image, fft_meta = self._render_panel_image( - "fft", overlays_enabled, scalebar_enabled - ) - render_meta = {"fft": fft_meta} - else: - panel_images = [] - render_meta = {} - dp_img, dp_meta = self._render_panel_image( - "diffraction", overlays_enabled, scalebar_enabled - ) - vi_img, vi_meta = self._render_panel_image( - "virtual", overlays_enabled, scalebar_enabled - ) - panel_images.extend([dp_img, vi_img]) - render_meta = {"diffraction": dp_meta, "virtual": vi_meta} - if self.show_fft: - fft_img, fft_meta = self._render_panel_image( - "fft", overlays_enabled, scalebar_enabled - ) - panel_images.append(fft_img) - render_meta["fft"] = fft_meta - image = self._compose_horizontal(panel_images) - - if fmt == "pdf": - Image.init() - image = image.convert("RGB") - image.save(export_path, format="PDF", resolution=dpi_value) - else: - image.save(export_path, format="PNG", dpi=(dpi_value, dpi_value)) - - if include_metadata: - meta_path = ( - pathlib.Path(metadata_path) - if metadata_path is not None - else export_path.with_suffix(".json") - ) - metadata = self._build_image_export_metadata( - export_path=export_path, - view_key=view_key, - fmt=fmt, - render_meta=render_meta, - include_overlays=overlays_enabled, - include_scalebar=scalebar_enabled, - export_kind="single_view_image", - extra={"dpi": int(dpi_value)}, - ) - meta_path.write_text(json.dumps(metadata, indent=2)) - finally: - if restore_state: - self.frame_idx = prev_frame - self.pos_row = prev_row - self.pos_col = prev_col - - return export_path - - def apply_preset(self, name: str) -> Self: - preset_name = str(name).strip().lower() - # Batch all trait writes atomically. Without this, each individual - # trait change fires _on_roi_change, and intermediate states (e.g. mode - # just switched to "annular" but radius_inner still stale from the - # previous preset) compute a wrong mask -> black VI flashes before the - # final correct frame. hold_trait_notifications defers observers until - # all 5 traits have committed. - bf = self.bf_radius - center_row = float(self.center_row) - center_col = float(self.center_col) - self._suppress_roi_recompute = True - try: - if preset_name == "bf": - with self.hold_trait_notifications(): - self.roi_active = True - self.roi_mode = "circle" - self.roi_center_row = center_row - self.roi_center_col = center_col - self.roi_radius = float(max(1.0, bf)) - elif preset_name == "abf": - with self.hold_trait_notifications(): - self.roi_active = True - self.roi_mode = "annular" - self.roi_center_row = center_row - self.roi_center_col = center_col - self.roi_radius_inner = float(max(0.5, bf * 0.5)) - self.roi_radius = float(max(1.0, bf)) - elif preset_name == "adf": - with self.hold_trait_notifications(): - self.roi_active = True - self.roi_mode = "annular" - self.roi_center_row = center_row - self.roi_center_col = center_col - self.roi_radius_inner = float(max(1.0, bf)) - self.roi_radius = float(max(bf + 1.0, bf * 2.0)) - elif preset_name == "haadf": - with self.hold_trait_notifications(): - self.roi_active = True - self.roi_mode = "annular" - self.roi_center_row = center_row - self.roi_center_col = center_col - self.roi_radius_inner = float(max(1.0, bf * 2.0)) - self.roi_radius = float(max(bf * 2.0 + 1.0, bf * 4.0)) - else: - raise ValueError( - f"Unknown preset {name!r}. Choices: 'bf', 'abf', 'adf', 'haadf'." - ) - finally: - self._suppress_roi_recompute = False - # Single recompute with final, consistent state. - self._compute_virtual_image_from_roi() - return self - - - def _normalize_frame(self, frame: np.ndarray) -> np.ndarray: - mode = self.dp_scale_mode - scaled = self._apply_scale_mode(frame, mode) - if self.dp_vmin is not None and self.dp_vmax is not None: - fmin = float(self._apply_scale_mode( - np.array([max(self.dp_vmin, 0)], dtype=np.float32), mode - )[0]) - fmax = float(self._apply_scale_mode( - np.array([max(self.dp_vmax, 0)], dtype=np.float32), mode - )[0]) - else: - fmin = float(scaled.min()) - fmax = float(scaled.max()) - fmin, fmax = self._slider_range(fmin, fmax, self.dp_vmin_pct, self.dp_vmax_pct) - if fmax > fmin: - return np.clip((scaled - fmin) / (fmax - fmin) * 255, 0, 255).astype(np.uint8) - return np.zeros(frame.shape, dtype=np.uint8) - - def _on_gif_export(self, change=None): - if not self._gif_export_requested: - return - self._gif_export_requested = False - self._generate_gif() - - def _generate_gif(self): - import io - - from matplotlib import colormaps - from PIL import Image - - if not self._path_points: - with self.hold_sync(): - self._gif_data = b"" - self._gif_metadata_json = "" - return - - cmap_fn = colormaps.get_cmap(self.dp_colormap) - duration_ms = max(10, self.path_interval_ms) - - pil_frames = [] - for row, col in self._path_points: - row = max(0, min(self.shape_rows - 1, row)) - col = max(0, min(self.shape_cols - 1, col)) - frame = self._get_frame(row, col).astype(np.float32) - normalized = self._normalize_frame(frame) - rgba = cmap_fn(normalized / 255.0) - rgb = (rgba[:, :, :3] * 255).astype(np.uint8) - pil_frames.append(Image.fromarray(rgb)) - - if not pil_frames: - return - - buf = io.BytesIO() - pil_frames[0].save( - buf, - format="GIF", - save_all=True, - append_images=pil_frames[1:], - duration=duration_ms, - loop=0, - ) - metadata = { - **build_json_header("Show4DSTEM"), - "view": "diffraction", - "format": "gif", - "export_kind": "path_animation", - "n_frames": int(len(pil_frames)), - "duration_ms": int(duration_ms), - "path_loop": bool(self.path_loop), - "path_points": [{"row": int(row), "col": int(col)} for row, col in self._path_points], - "frame_idx": int(self.frame_idx), - "n_frames_total": int(self.n_frames), - "scan_shape": {"rows": int(self.shape_rows), "cols": int(self.shape_cols)}, - "detector_shape": {"rows": int(self.det_rows), "cols": int(self.det_cols)}, - "calibration": self._calibration_metadata(), - "display": { - "diffraction": { - "colormap": self.dp_colormap, - "scale_mode": self.dp_scale_mode, - "vmin_pct": float(self.dp_vmin_pct), - "vmax_pct": float(self.dp_vmax_pct), - } - }, - } - with self.hold_sync(): - self._gif_metadata_json = json.dumps(metadata, indent=2) - self._gif_data = buf.getvalue() - - def _update_frame(self, change=None): - """Send raw float32 frame to frontend (JS handles scale/colormap).""" - if self._data is None: - return - # Get frame as tensor (stays on device) - data = self._frame_data - if data.ndim == 3: - idx = self.pos_row * self.shape_cols + self.pos_col - frame = data[idx] - else: - frame = data[self.pos_row, self.pos_col] - - # Cast small frame to float32 for stats and JS transfer. Bulk data - # stays in native dtype; only this single 192×192 (~144 KB) frame - # gets promoted. - if frame.dtype != torch.float32: - frame = frame.float() - # Stats compute moved to JS (frontend has frame_bytes; computeStats() in - # js/stats.ts does mean/min/max/std on the Float32Array directly, - # avoiding 4 sync trait round-trips per scan-position click). - self.frame_bytes = frame.cpu().numpy().tobytes() - - def _on_roi_change(self, change=None): - """Recompute virtual image when individual ROI params change. - - High-frequency drag updates use the compound roi_center trait instead. - """ - if not self.roi_active: - return - if getattr(self, "_suppress_roi_recompute", False): - return - self._compute_virtual_image_from_roi() - - def _on_roi_center_change(self, change=None): - """Handle batched roi_center updates from JS (single observer for row+col). - - This is the fast path for drag operations. JS sends [row, col] as a single - compound trait, so only one observer fires per mouse move. - """ - if not self.roi_active: - return - if getattr(self, "_suppress_roi_recompute", False): - return - if change and "new" in change: - row, col = change["new"] - # Sync to individual traits (without triggering _on_roi_change observers) - self.unobserve(self._on_roi_change, names=["roi_center_col", "roi_center_row"]) - self.roi_center_row = row - self.roi_center_col = col - self.observe(self._on_roi_change, names=["roi_center_col", "roi_center_row"]) - self._compute_virtual_image_from_roi() - - def _on_vi_roi_center_change(self, change=None): - """Apply compound (row, col) update atomically (avoids split-trait race).""" - if change and "new" in change: - row, col = change["new"] - self.unobserve(self._on_vi_roi_change, names=["vi_roi_center_row", "vi_roi_center_col"]) - self.vi_roi_center_row = float(row) - self.vi_roi_center_col = float(col) - self.observe(self._on_vi_roi_change, names=["vi_roi_center_row", "vi_roi_center_col"]) - if self.vi_roi_mode == "off": - self.vi_roi_dp_bytes = b"" - return - self._compute_vi_roi_dp() - - def _on_vi_roi_change(self, change=None): - """Recompute reduced DP when VI ROI or reduction changes.""" - if self.vi_roi_mode == "off": - self.vi_roi_dp_bytes = b"" - return - self._compute_vi_roi_dp() - - def _compute_vi_roi_dp(self): - """Reduce diffraction patterns over scan positions inside VI ROI. - - Reduction selected by `vi_roi_reduce`: - - "mean": average DP (size-invariant, default for region-of-interest analysis) - - "sum": total counts (scales with ROI area; use for quantitative integration) - - "max": brightest pixel per detector position across the region - """ - if self._data is None: - return - if self.vi_roi_mode == "circle": - mask = (self._scan_row_coords - self.vi_roi_center_row) ** 2 + (self._scan_col_coords - self.vi_roi_center_col) ** 2 <= self.vi_roi_radius ** 2 - elif self.vi_roi_mode == "square": - half_size = self.vi_roi_radius - mask = (torch.abs(self._scan_row_coords - self.vi_roi_center_row) <= half_size) & (torch.abs(self._scan_col_coords - self.vi_roi_center_col) <= half_size) - elif self.vi_roi_mode == "rect": - half_w = self.vi_roi_width / 2 - half_h = self.vi_roi_height / 2 - mask = (torch.abs(self._scan_row_coords - self.vi_roi_center_row) <= half_h) & (torch.abs(self._scan_col_coords - self.vi_roi_center_col) <= half_w) - else: - return - - n_positions = int(mask.sum()) - if n_positions == 0: - self.vi_roi_dp_bytes = b"" - return - - reduce = self.vi_roi_reduce - data = self._frame_data - # Single chunked torch path. For each scan-row chunk: cast to float32 and - # broadcast-multiply by the mask (no `chunk[row_mask]` slab, which would - # roughly duplicate the chunk in memory when the mask is dense). Sum/mean - # use einsum over scan dims; max masks zero rows then takes amax. - data_4d = data if data.ndim == 4 else data.reshape(self._scan_shape[0], self._scan_shape[1], *self._det_shape) - rows_per_chunk = self._chunk_rows() - if reduce == "sum" or reduce == "mean": - dp = torch.zeros(self._det_shape, dtype=torch.float32, device=self._device) - else: # max - dp = torch.full(self._det_shape, -float("inf"), dtype=torch.float32, device=self._device) - for i in range(0, self._scan_shape[0], rows_per_chunk): - row_mask = mask[i:i + rows_per_chunk] - if not bool(row_mask.any()): - continue - chunk = data_4d[i:i + rows_per_chunk] - if not torch.is_floating_point(chunk): - chunk = chunk.float() - row_mask_f = row_mask.float() - if reduce == "max": - # Outside-mask positions become 0; doesn't affect amax provided - # the data has any non-negative pixels (true for detector counts). - dp = torch.maximum(dp, (chunk * row_mask_f[..., None, None]).amax(dim=(0, 1))) - else: - dp += torch.einsum("rcij,rc->ij", chunk, row_mask_f) - if reduce == "mean": - dp /= float(n_positions) - - self.vi_roi_dp_bytes = dp.cpu().numpy().tobytes() - - def _create_circular_mask(self, cx: float, cy: float, radius: float): - """Create circular mask (boolean tensor on device).""" - mask = (self._det_col_coords - cx) ** 2 + (self._det_row_coords - cy) ** 2 <= radius ** 2 - return mask - - def _create_square_mask(self, cx: float, cy: float, half_size: float): - """Create square mask (boolean tensor on device).""" - mask = (torch.abs(self._det_col_coords - cx) <= half_size) & (torch.abs(self._det_row_coords - cy) <= half_size) - return mask - - def _create_annular_mask( - self, cx: float, cy: float, inner: float, outer: float - ): - """Create annular (donut) mask (boolean tensor on device).""" - dist_sq = (self._det_col_coords - cx) ** 2 + (self._det_row_coords - cy) ** 2 - mask = (dist_sq >= inner ** 2) & (dist_sq <= outer ** 2) - return mask - - def _create_rect_mask(self, cx: float, cy: float, half_width: float, half_height: float): - """Create rectangular mask (boolean tensor on device).""" - mask = (torch.abs(self._det_col_coords - cx) <= half_width) & (torch.abs(self._det_row_coords - cy) <= half_height) - return mask - - def _on_calibration_change(self, change=None): - self._cached_bf_virtual = None - self._cached_abf_virtual = None - self._cached_adf_virtual = None - self._cached_haadf_virtual = None - - def _precompute_common_virtual_images(self): - """Pre-compute BF/ABF/ADF/HAADF virtual image bytes. Annular ranges match - apply_preset() so the cache always hits on preset clicks.""" - cx, cy, bf = self.center_col, self.center_row, self.bf_radius - self._cached_bf_virtual = self._to_float32_bytes( - self._fast_masked_sum(self._create_circular_mask(cx, cy, bf)) - ) - self._cached_abf_virtual = self._to_float32_bytes( - self._fast_masked_sum(self._create_annular_mask(cx, cy, bf * 0.5, bf)) - ) - self._cached_adf_virtual = self._to_float32_bytes( - self._fast_masked_sum(self._create_annular_mask(cx, cy, bf, bf * 2.0)) - ) - self._cached_haadf_virtual = self._to_float32_bytes( - self._fast_masked_sum(self._create_annular_mask(cx, cy, bf * 2.0, bf * 4.0)) - ) - - def _get_cached_preset(self) -> bytes | None: - """Return cached preset bytes if current ROI matches BF/ABF/ADF preset shape.""" - # Must be centered on detector center - if abs(self.roi_center_col - self.center_col) >= 1 or abs(self.roi_center_row - self.center_row) >= 1: - return None - - bf = self.bf_radius - - # BF: circle at bf_radius - if (self.roi_mode == "circle" and abs(self.roi_radius - bf) < 1): - return self._cached_bf_virtual - - # ABF: annular at 0.5*bf to bf - if (self.roi_mode == "annular" and - abs(self.roi_radius_inner - bf * 0.5) < 1 and - abs(self.roi_radius - bf) < 1): - return self._cached_abf_virtual - - # ADF: annular at bf to 2*bf - if (self.roi_mode == "annular" and - abs(self.roi_radius_inner - bf) < 1 and - abs(self.roi_radius - bf * 2.0) < 1): - return self._cached_adf_virtual - - # HAADF: annular at 2*bf to 4*bf - if (self.roi_mode == "annular" and - abs(self.roi_radius_inner - bf * 2.0) < 1 and - abs(self.roi_radius - bf * 4.0) < 1): - return self._cached_haadf_virtual - - return None - - def _virtual_image_for_frame(self, frame_idx: int) -> np.ndarray: - """Compute virtual image for a specific 5D frame without mutating traits. - - Single chunked-torch path matching _fast_masked_sum. - """ - data = self._data[frame_idx] if self.n_frames > 1 else self._data - cx, cy = self.roi_center_col, self.roi_center_row - if self.roi_mode == "circle" and self.roi_radius > 0: - mask = self._create_circular_mask(cx, cy, self.roi_radius) - elif self.roi_mode == "square" and self.roi_radius > 0: - mask = self._create_square_mask(cx, cy, self.roi_radius) - elif self.roi_mode == "annular" and self.roi_radius > 0: - mask = self._create_annular_mask(cx, cy, self.roi_radius_inner, self.roi_radius) - elif self.roi_mode == "rect" and self.roi_width > 0 and self.roi_height > 0: - mask = self._create_rect_mask(cx, cy, self.roi_width / 2, self.roi_height / 2) - else: - row = int(max(0, min(round(cy), self._det_shape[0] - 1))) - col = int(max(0, min(round(cx), self._det_shape[1] - 1))) - if data.ndim == 4: - vi = data[:, :, row, col] - else: - vi = data[:, row, col].reshape(self._scan_shape) - return vi.cpu().numpy().astype(np.float32, copy=False) - data_4d = data if data.ndim == 4 else data.reshape(self._scan_shape[0], self._scan_shape[1], *self._det_shape) - mask_f = mask.float() - rows_per_chunk = self._chunk_rows() - out = torch.zeros(self._scan_shape, dtype=torch.float32, device=self._device) - for i in range(0, data_4d.shape[0], rows_per_chunk): - chunk = data_4d[i:i + rows_per_chunk] - if not torch.is_floating_point(chunk): - chunk = chunk.float() - out[i:i + rows_per_chunk] = torch.tensordot(chunk, mask_f, dims=([2, 3], [0, 1])) - return out.cpu().numpy().astype(np.float32, copy=False) - - def _chunk_rows(self) -> int: - """Pick rows-per-chunk so float32 transient stays under _CHUNK_BYTE_BUDGET. - - Float32 cast of one chunk = rows × scan_cols × det_h × det_w × 4 bytes. - Selected slabs (e.g. vi_roi reduce) inherit the same per-row budget. - """ - per_row = self._scan_shape[1] * self._det_shape[0] * self._det_shape[1] * 4 - return max(1, _CHUNK_BYTE_BUDGET // max(1, per_row)) - - def _fast_masked_sum(self, mask: torch.Tensor) -> torch.Tensor: - """Sum data over scan positions weighted by detector mask. - - Chunked tensordot. Per-chunk float32 cast bounded by _CHUNK_BYTE_BUDGET. - Identical math on CUDA / MPS / CPU. - """ - data = self._frame_data - if data.ndim == 3: - data_4d = data.reshape(self._scan_shape[0], self._scan_shape[1], *self._det_shape) - else: - data_4d = data - # Single chunked torch path. Per scan-row chunk: cast to float32, contract - # with mask via tensordot. Transient memory bounded by chunk size. Same - # code on CUDA / MPS / CPU. Identical results regardless of device. - mask_f = mask.float() - n_rows = data_4d.shape[0] - out = torch.zeros(self._scan_shape, dtype=torch.float32, device=self._device) - # Convert positions chunk size to row chunks based on scan width. - rows_per_chunk = self._chunk_rows() - for i in range(0, n_rows, rows_per_chunk): - chunk = data_4d[i:i + rows_per_chunk] - if not torch.is_floating_point(chunk): - chunk = chunk.float() - out[i:i + rows_per_chunk] = torch.tensordot(chunk, mask_f, dims=([2, 3], [0, 1])) - return out - - def _to_float32_bytes(self, arr: torch.Tensor) -> bytes: - """Convert tensor (any numeric dtype) to float32 bytes for JS transfer. - - Cast to float32 only at the small output. Integer reductions (uint16 sums, - int64 accumulators) get promoted here so the multi-GB raw data never gets - copied to float. Stats (min/max/mean/std) are computed JS-side from the - same Float32Array — keeping them out of separate traits avoids a - comm-message ordering race where bytes from click N arrive with stats - from click N-1, producing a wrong colormap normalization (uniform white - flash on rapid preset switching). - """ - if arr.dtype != torch.float32: - arr = arr.float() - return arr.cpu().numpy().tobytes() - - def _compute_virtual_image_from_roi(self): - """Compute virtual image based on ROI mode.""" - if self._data is None: - return - cached = self._get_cached_preset() - if cached is not None: - self.virtual_image_bytes = cached - return - - cx, cy = self.roi_center_col, self.roi_center_row - - if self.roi_mode == "circle" and self.roi_radius > 0: - mask = self._create_circular_mask(cx, cy, self.roi_radius) - elif self.roi_mode == "square" and self.roi_radius > 0: - mask = self._create_square_mask(cx, cy, self.roi_radius) - elif self.roi_mode == "annular" and self.roi_radius > 0: - mask = self._create_annular_mask(cx, cy, self.roi_radius_inner, self.roi_radius) - elif self.roi_mode == "rect" and self.roi_width > 0 and self.roi_height > 0: - mask = self._create_rect_mask(cx, cy, self.roi_width / 2, self.roi_height / 2) - else: - # Point mode: single-pixel indexing - row = int(max(0, min(round(cy), self._det_shape[0] - 1))) - col = int(max(0, min(round(cx), self._det_shape[1] - 1))) - data = self._frame_data - if data.ndim == 4: - virtual_image = data[:, :, row, col] - else: - virtual_image = data[:, row, col].reshape(self._scan_shape) - self.virtual_image_bytes = self._to_float32_bytes(virtual_image) - return - - self.virtual_image_bytes = self._to_float32_bytes(self._fast_masked_sum(mask)) - diff --git a/widget/src/quantem/widget/state.py b/widget/src/quantem/widget/state.py deleted file mode 100644 index c1750dc2f..000000000 --- a/widget/src/quantem/widget/state.py +++ /dev/null @@ -1,46 +0,0 @@ -import importlib.metadata -import json -import pathlib -from typing import Any - -JSON_METADATA_VERSION = "1.0" - - -def resolve_widget_version() -> str: - try: - return importlib.metadata.version("quantem-widget") - except importlib.metadata.PackageNotFoundError: - return "unknown" - - -def build_json_header(widget_name: str) -> dict[str, Any]: - return { - "metadata_version": JSON_METADATA_VERSION, - "widget_name": widget_name, - "widget_version": resolve_widget_version(), - } - - -def wrap_state_dict(widget_name: str, state: dict[str, Any]) -> dict[str, Any]: - envelope = build_json_header(widget_name) - envelope["state"] = state - return envelope - - -def unwrap_state_payload(payload: dict[str, Any], *, require_envelope: bool = False) -> dict[str, Any]: - if not isinstance(payload, dict): - raise ValueError("State payload must be a dict.") - if "state" in payload: - state = payload["state"] - if not isinstance(state, dict): - raise ValueError("State envelope field 'state' must be a dict.") - return state - if require_envelope: - raise ValueError("State JSON file must be a versioned envelope with top-level 'state'.") - return payload - - -def save_state_file(path: str | pathlib.Path, widget_name: str, state: dict[str, Any]) -> None: - p = pathlib.Path(path) - p.parent.mkdir(parents=True, exist_ok=True) - p.write_text(json.dumps(wrap_state_dict(widget_name, state), indent=2)) diff --git a/widget/tests/test_fft_parity.py b/widget/tests/test_fft_parity.py deleted file mode 100644 index 4e38b85f7..000000000 --- a/widget/tests/test_fft_parity.py +++ /dev/null @@ -1,200 +0,0 @@ -"""FFT parity: JS fft1d/fft2d/fftshift line-ported to Python, validated against numpy. - -Why ports instead of running the JS directly: pytest can't drive a TypeScript -module without a Node bridge or browser harness, both of which add fragility -and slow CI. Instead we mirror js/fft.ts:14-82 line-for-line in Python below -and assert against numpy.fft. If the JS algorithm has a bug, the line-port -inherits it and this test fails — surfacing the bug at unit-test speed. - -When js/fft.ts changes, update the ports here in the same commit. The -side-by-side structure makes drift visually obvious during review. -""" -import numpy as np - - -def _next_pow2(n: int) -> int: - p = 1 - while p < n: - p <<= 1 - return p - - -def _js_fft1d(real: np.ndarray, imag: np.ndarray, inverse: bool = False) -> None: - """Line-port of js/fft.ts fft1d. In-place. Iterative radix-2 Cooley-Tukey.""" - n = real.size - if n <= 1: - return - # Bit-reversal permutation. - j = 0 - for i in range(n - 1): - if i < j: - real[i], real[j] = real[j], real[i] - imag[i], imag[j] = imag[j], imag[i] - k = n >> 1 - while k <= j: - j -= k - k >>= 1 - j += k - sign = 1 if inverse else -1 - length = 2 - while length <= n: - half = length >> 1 - angle = (sign * 2 * np.pi) / length - w_real = np.cos(angle) - w_imag = np.sin(angle) - for i in range(0, n, length): - cur_real = 1.0 - cur_imag = 0.0 - for k in range(half): - even = i + k - odd = i + k + half - t_real = cur_real * real[odd] - cur_imag * imag[odd] - t_imag = cur_real * imag[odd] + cur_imag * real[odd] - real[odd] = real[even] - t_real - imag[odd] = imag[even] - t_imag - real[even] += t_real - imag[even] += t_imag - new_real = cur_real * w_real - cur_imag * w_imag - cur_imag = cur_real * w_imag + cur_imag * w_real - cur_real = new_real - length <<= 1 - if inverse: - real /= n - imag /= n - - -def _js_fft2d(real: np.ndarray, imag: np.ndarray, width: int, height: int, inverse: bool = False) -> None: - """Line-port of js/fft.ts fft2d. In-place on (height*width) flattened arrays.""" - padded_w = _next_pow2(width) - padded_h = _next_pow2(height) - needs_padding = padded_w != width or padded_h != height - if needs_padding: - work_real = np.zeros(padded_w * padded_h, dtype=np.float64) - work_imag = np.zeros(padded_w * padded_h, dtype=np.float64) - for y in range(height): - for x in range(width): - work_real[y * padded_w + x] = real[y * width + x] - work_imag[y * padded_w + x] = imag[y * width + x] - else: - work_real = real - work_imag = imag - row_real = np.empty(padded_w, dtype=np.float64) - row_imag = np.empty(padded_w, dtype=np.float64) - for y in range(padded_h): - offset = y * padded_w - row_real[:] = work_real[offset:offset + padded_w] - row_imag[:] = work_imag[offset:offset + padded_w] - _js_fft1d(row_real, row_imag, inverse) - work_real[offset:offset + padded_w] = row_real - work_imag[offset:offset + padded_w] = row_imag - col_real = np.empty(padded_h, dtype=np.float64) - col_imag = np.empty(padded_h, dtype=np.float64) - for x in range(padded_w): - for y in range(padded_h): - col_real[y] = work_real[y * padded_w + x] - col_imag[y] = work_imag[y * padded_w + x] - _js_fft1d(col_real, col_imag, inverse) - for y in range(padded_h): - work_real[y * padded_w + x] = col_real[y] - work_imag[y * padded_w + x] = col_imag[y] - if needs_padding: - for y in range(height): - for x in range(width): - real[y * width + x] = work_real[y * padded_w + x] - imag[y * width + x] = work_imag[y * padded_w + x] - - -def _js_fftshift(data: np.ndarray, width: int, height: int) -> None: - """Line-port of js/fft.ts fftshift. In-place.""" - half_w = width >> 1 - half_h = height >> 1 - temp = np.empty(width * height, dtype=data.dtype) - for y in range(height): - for x in range(width): - temp[((y + half_h) % height) * width + ((x + half_w) % width)] = data[y * width + x] - data[:] = temp - - -# --------------------------------------------------------------------------- - -def test_fft1d_matches_numpy_pow2(): - """1D FFT on power-of-2 input matches numpy.fft.fft.""" - rng = np.random.default_rng(0) - n = 64 - x = rng.standard_normal(n) - real = x.astype(np.float64).copy() - imag = np.zeros(n, dtype=np.float64) - _js_fft1d(real, imag, inverse=False) - js = real + 1j * imag - expected = np.fft.fft(x) - np.testing.assert_allclose(js, expected, atol=1e-9) - - -def test_fft1d_inverse_roundtrip(): - """fft1d(fft1d(x), inverse=True) ≈ x.""" - rng = np.random.default_rng(1) - n = 128 - x = rng.standard_normal(n) - real = x.astype(np.float64).copy() - imag = np.zeros(n, dtype=np.float64) - _js_fft1d(real, imag, inverse=False) - _js_fft1d(real, imag, inverse=True) - np.testing.assert_allclose(real, x, atol=1e-9) - np.testing.assert_allclose(imag, np.zeros(n), atol=1e-9) - - -def test_fft2d_matches_numpy_pow2(): - """2D FFT on power-of-2 dims matches numpy.fft.fft2.""" - rng = np.random.default_rng(2) - h, w = 32, 64 - img = rng.standard_normal((h, w)) - real = img.astype(np.float64).flatten() - imag = np.zeros(h * w, dtype=np.float64) - _js_fft2d(real, imag, w, h, inverse=False) - js = (real + 1j * imag).reshape(h, w) - expected = np.fft.fft2(img) - np.testing.assert_allclose(js, expected, atol=1e-9) - - -def test_fft2d_non_pow2_zero_pads(): - """Non-power-of-2 input gets zero-padded; FFT of padded matches numpy of padded.""" - rng = np.random.default_rng(3) - h, w = 30, 50 - img = rng.standard_normal((h, w)) - real = img.astype(np.float64).flatten() - imag = np.zeros(h * w, dtype=np.float64) - _js_fft2d(real, imag, w, h, inverse=False) - # JS contract: only the (h, w) region of the result is written back to the input arrays. - js = (real + 1j * imag).reshape(h, w) - pw, ph = _next_pow2(w), _next_pow2(h) - padded = np.zeros((ph, pw)) - padded[:h, :w] = img - expected = np.fft.fft2(padded)[:h, :w] - np.testing.assert_allclose(js, expected, atol=1e-9) - - -def test_fftshift_matches_numpy(): - """fftshift matches numpy.fft.fftshift on 2D data.""" - rng = np.random.default_rng(4) - h, w = 16, 16 - img = rng.standard_normal((h, w)) - flat = img.flatten().copy() - _js_fftshift(flat, w, h) - js_shifted = flat.reshape(h, w) - expected = np.fft.fftshift(img) - np.testing.assert_array_equal(js_shifted, expected) - - -def test_fft2d_then_fftshift_matches_numpy(): - """Combined FFT + fftshift matches numpy reference.""" - rng = np.random.default_rng(5) - h, w = 32, 32 - img = rng.standard_normal((h, w)) - real = img.astype(np.float64).flatten() - imag = np.zeros(h * w, dtype=np.float64) - _js_fft2d(real, imag, w, h, inverse=False) - _js_fftshift(real, w, h) - _js_fftshift(imag, w, h) - js = (real + 1j * imag).reshape(h, w) - expected = np.fft.fftshift(np.fft.fft2(img)) - np.testing.assert_allclose(js, expected, atol=1e-9) diff --git a/widget/tests/test_state_dict.py b/widget/tests/test_state_dict.py deleted file mode 100644 index 606149411..000000000 --- a/widget/tests/test_state_dict.py +++ /dev/null @@ -1,166 +0,0 @@ -"""state_dict roundtrip tests for Show2D and Show4DSTEM. - -For each widget: -1. Construct with default data. -2. Mutate every trait in state_dict() to a non-default value. -3. Get state_dict. -4. Construct a fresh widget and load_state_dict. -5. Assert every trait on the restored widget equals what we set. - -Catches silent regressions when traits are added, renamed, or dropped without -updating the state_dict roundtrip path. -""" -import json - -import numpy as np -import pytest -from quantem.widget import Show2D, Show4DSTEM - - -def _flip_value(default): - """Return a value distinct from `default` for the same type.""" - if isinstance(default, bool): - return not default - if isinstance(default, int): - return int(default) + 7 - if isinstance(default, float): - return float(default) + 0.123 - if isinstance(default, str): - return default + "_x" if default else "x" - if isinstance(default, list): - return [_flip_value(default[0])] if default else [0] - return default - - -def _mutate_state(state: dict) -> dict: - """Build a new state dict with every key changed to a non-default value.""" - out = {} - for k, v in state.items(): - # Skip values our flipper can't safely tweak (None defaults, nested dicts/lists-of-dicts, bytes). - if v is None or isinstance(v, (dict, bytes)): - out[k] = v - continue - # Lists hold structured items (dicts, tuples) for ROI / profile / labels; - # mutating them generically is fragile. The roundtrip-defaults test already - # covers list trait persistence — here we only mutate scalars. - if isinstance(v, list): - out[k] = v - continue - out[k] = _flip_value(v) - return out - - -# --------------------------------------------------------------------------- -# Show4DSTEM -# --------------------------------------------------------------------------- - -@pytest.fixture -def show4dstem_widget(): - data = np.random.default_rng(0).poisson(5, (8, 8, 16, 16)).astype(np.uint16) - data[:, :, 6:10, 6:10] += 500 # synthetic BF disk - return Show4DSTEM(data, verbose=False) - - -def test_show4dstem_state_dict_keys(show4dstem_widget): - """state_dict returns a non-empty dict of public traits.""" - s = show4dstem_widget.state_dict() - assert isinstance(s, dict) - assert len(s) > 10 - # Required keys for the widget's user-facing display state - for required in ("title", "dp_colormap", "vi_colormap", "roi_mode", "vi_roi_reduce"): - assert required in s, f"state_dict missing key {required!r}" - - -def test_show4dstem_state_dict_roundtrip_defaults(show4dstem_widget): - """save → load on default widget preserves state.""" - original = show4dstem_widget.state_dict() - data = np.random.default_rng(0).poisson(5, (8, 8, 16, 16)).astype(np.uint16) - data[:, :, 6:10, 6:10] += 500 - fresh = Show4DSTEM(data, state=original, verbose=False) - restored = fresh.state_dict() - for k in original: - assert restored[k] == original[k], f"{k}: {original[k]!r} -> {restored[k]!r}" - - -def test_show4dstem_state_dict_roundtrip_mutated(show4dstem_widget): - """Mutating every trait then roundtripping preserves the mutations.""" - # Position / frame indices are clamped to valid range by trait validators - # against the data dimensions; mutating them generically is meaningless here. - skip = {"pos_row", "pos_col", "frame_idx", "path_index", "path_length", - "vi_roi_center_row", "vi_roi_center_col"} - mutated = _mutate_state(show4dstem_widget.state_dict()) - show4dstem_widget.load_state_dict(mutated) - out = show4dstem_widget.state_dict() - for k, v in mutated.items(): - if k in skip: - continue - if isinstance(v, float): - assert abs(out[k] - v) < 1e-3, f"{k}: expected {v}, got {out[k]}" - else: - assert out[k] == v, f"{k}: expected {v!r}, got {out[k]!r}" - - -def test_show4dstem_save_and_load(tmp_path, show4dstem_widget): - """save() writes a versioned envelope JSON, state= kwarg loads it.""" - show4dstem_widget.dp_colormap = "viridis" - show4dstem_widget.vi_colormap = "magma" - show4dstem_widget.show_fft = True - path = tmp_path / "show4dstem_state.json" - show4dstem_widget.save(str(path)) - - payload = json.loads(path.read_text()) - assert payload["widget_name"] == "Show4DSTEM" - assert "metadata_version" in payload - assert "state" in payload - - data = np.random.default_rng(0).poisson(5, (8, 8, 16, 16)).astype(np.uint16) - data[:, :, 6:10, 6:10] += 500 - fresh = Show4DSTEM(data, state=str(path), verbose=False) - assert fresh.dp_colormap == "viridis" - assert fresh.vi_colormap == "magma" - assert fresh.show_fft is True - - -# --------------------------------------------------------------------------- -# Show2D -# --------------------------------------------------------------------------- - -@pytest.fixture -def show2d_widget(): - return Show2D(np.random.default_rng(0).standard_normal((32, 32)).astype(np.float32), verbose=False) - - -def test_show2d_state_dict_keys(show2d_widget): - s = show2d_widget.state_dict() - assert isinstance(s, dict) - assert len(s) > 5 - for required in ("cmap", "log_scale"): - assert required in s, f"state_dict missing key {required!r}" - - -def test_show2d_state_dict_roundtrip_defaults(show2d_widget): - original = show2d_widget.state_dict() - fresh = Show2D(np.random.default_rng(0).standard_normal((32, 32)).astype(np.float32), - state=original, verbose=False) - restored = fresh.state_dict() - for k in original: - if isinstance(original[k], float): - assert abs(restored[k] - original[k]) < 1e-3, f"{k}: {original[k]} -> {restored[k]}" - else: - assert restored[k] == original[k], f"{k}: {original[k]!r} -> {restored[k]!r}" - - -def test_show2d_save_and_load(tmp_path, show2d_widget): - show2d_widget.cmap = "viridis" - show2d_widget.log_scale = True - path = tmp_path / "show2d_state.json" - show2d_widget.save(str(path)) - - payload = json.loads(path.read_text()) - assert payload["widget_name"] == "Show2D" - assert "state" in payload - - fresh = Show2D(np.random.default_rng(0).standard_normal((32, 32)).astype(np.float32), - state=str(path), verbose=False) - assert fresh.cmap == "viridis" - assert fresh.log_scale is True diff --git a/widget/tests/test_widget.py b/widget/tests/test_widget.py deleted file mode 100644 index bd1ba5179..000000000 --- a/widget/tests/test_widget.py +++ /dev/null @@ -1,9 +0,0 @@ -import quantem.widget - - -def test_version_exists(): - assert hasattr(quantem.widget, "__version__") - - -def test_version_is_string(): - assert isinstance(quantem.widget.__version__, str) diff --git a/widget/tsconfig.json b/widget/tsconfig.json deleted file mode 100644 index 8b4afe790..000000000 --- a/widget/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "include": [ - "js" - ], - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "lib": [ - "ES2020", - "DOM", - "DOM.Iterable" - ], - "skipLibCheck": true, - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react", - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - } -} \ No newline at end of file From d693c3f2ea75ab5c5029050a7e986b60cd67b5fd Mon Sep 17 00:00:00 2001 From: Sangjoon Bob Lee Date: Fri, 7 Aug 2026 13:18:35 -0700 Subject: [PATCH 2/3] Update CONTRIBUTORS.md to remove setup instructions Removed setup instructions for development environment. --- CONTRIBUTORS.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 84a1bc3a8..f016dcd5c 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -5,11 +5,6 @@ Thank you to everyone who has contributed code, ideas, bug reports, and tutorial Contributions are broken down by module: -- [install uv](https://docs.astral.sh/uv/getting-started/installation/) -- `git clone` the repo and `cd` into the directory -- run `uv sync` to install all the dependencies in an editable environment -The following will set up the pre-commit and [ruff](https://github.com/astral-sh/ruff) for linting and formatting. These commands only need to be run once when first setting up your dev environment: - ## Core - [Georgios Varnavides](https://github.com/gvarnavi) - [Arthur McCray](https://github.com/arthurmccray) @@ -48,4 +43,4 @@ see the [GitHub contributors graph](https://github.com/electronmicroscopy/quante --- This list is maintained by hand and grouped by contribution history. If your name is missing, -listed incorrectly, or you would like it shown differently, please open a pull request or issue. \ No newline at end of file +listed incorrectly, or you would like it shown differently, please open a pull request or issue. From 25bb707ed87120d7ad77cea20f9a595a446808a8 Mon Sep 17 00:00:00 2001 From: Sangjoon Bob Lee Date: Sun, 9 Aug 2026 15:26:15 -0700 Subject: [PATCH 3/3] docs: document external widget repository and citation --- CONTRIBUTING.md | 7 ++----- README.md | 5 ++++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 670ce9f6c..ad2f0930d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,13 +18,10 @@ Getting started: - [install uv](https://docs.astral.sh/uv/getting-started/installation/) - `git clone` the repo and `cd` into the directory - run `uv sync` to install all the dependencies in an editable environment -- run `uv sync --all-packages` to also install `quantem.widget` (optional) -For widget developers (requires [Node.js](https://nodejs.org/)): +### Interactive visualization and widget development -- `cd widget && npm install` to install JS dependencies -- `npm run build` to build the widget -- `npm run dev` to watch for changes during development +The interactive visualization package is maintained separately in the [quantem.widget](https://github.com/electronmicroscopy/quantem.widget) repository. To run the widgets, follow its [installation guide](https://electronmicroscopy.github.io/quantem.widget/install.html). To contribute to the Python, TypeScript, or WebGPU widget code, clone that repository and follow its [contributing guide](https://github.com/electronmicroscopy/quantem.widget/blob/main/CONTRIBUTING.md) for the development setup and checks. The following will set up the pre-commit and [ruff](https://github.com/astral-sh/ruff) for linting and formatting. These commands only need to be run once when first setting up your dev environment: diff --git a/README.md b/README.md index 489291c0b..f9d5978f6 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ - **Tomography**: fast and accurate HAADF tomography using implicit representations ([Lim et al., 2025](https://arxiv.org/abs/2512.08113)). - **Imaging**: drift correction and lattice analysis for (S)TEM images. - **Data structures & I/O**: a unified `Dataset` hierarchy that reads common electron-microscopy formats and serializes to [Zarr](https://zarr.dev/). -- **Visualization**: publication-quality figures with perceptually-uniform colormaps. +- **Visualization**: publication-quality figures with perceptually-uniform colormaps; for interactive, GPU-accelerated visualization, see the companion [quantem.widget](https://github.com/electronmicroscopy/quantem.widget) repository. - **GPU-accelerated & ML-ready**: a PyTorch backend with neural object representations and multi-GPU / multi-node reconstruction. - **Spectroscopy**: GPU-accelerated spectra fitting for EDS and EELS, under development. - **Diffraction**: multi-angle precession electron diffraction (MAPED), under development ([Ribet et al., 2025](https://doi.org/10.1093/mam/ozaf103)). @@ -39,6 +39,8 @@ For custom CUDA kernels that accelerate tomography, ptychography, and io behind The [quantem-tutorials](https://github.com/electronmicroscopy/quantem-tutorials) repository contains Jupyter notebooks that walk through the main workflows for each module. +For interactive visualization in notebooks, command-line workflows, standalone HTML exports, and browser WebGPU, see [quantem.widget](https://github.com/electronmicroscopy/quantem.widget). Its [documentation](https://electronmicroscopy.github.io/quantem.widget/) covers installation, tutorials, supported backends, and complete visualization workflows. + ## Citing If you use quantem in your research, please cite this repository as well as the relevant paper(s) for any module(s) that you used: @@ -49,6 +51,7 @@ If you use quantem in your research, please cite this repository as well as the - **Direct ptychography**: Varnavides, G., Bekkevold, J. M., Ribet, S. M., Scott, M. C., Jones, L., & Ophus, C. (2026). *Relaxing Direct Ptychography Sampling Requirements via Parallax Imaging Insights.* Microscopy and Microanalysis, 32(2), ozaf139. https://doi.org/10.1093/mam/ozaf139 - **Electron tomography (implicit neural representations)**: Lim, C., Casert, C., McCray, A. R. C., Lee, S., Barnum, A., Dionne, J., & Ophus, C. (2025). *Missing Wedge Inpainting and Joint Alignment in Electron Tomography through Implicit Neural Representations.* arXiv:2512.08113. https://arxiv.org/abs/2512.08113 - **Multi-angle precession electron diffraction (MAPED)**: Ribet, S. M., Dhall, R., Ophus, C., & Bustillo, K. C. (2025). *Multi-angle Precession Electron Diffraction (MAPED): A Versatile Approach to 4D-STEM Precession.* Microscopy and Microanalysis, 31(6), ozaf103. https://doi.org/10.1093/mam/ozaf103 +- **quantEM interactive visualization framework**: Lee, S., et al. (2026). *Interactive Framework for Real-Time 4DSTEM Analysis and Reconstruction.* Microscopy and Microanalysis, 32(Supplement 1), ozag053.941. https://doi.org/10.1093/mam/ozag053.941 ## Contributing