diff --git a/.gitignore b/.gitignore
index d87d85c8..e51818a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -191,3 +191,9 @@ ipynb-playground/
# widget (JS build artifacts)
node_modules/
widget/src/quantem/widget/static/
+
+# Playwright testing
+playwright-report/
+playwright/
+test-results/
+playwright*.config.ts
diff --git a/src/quantem/core/io/file_readers.py b/src/quantem/core/io/file_readers.py
index cb36f1de..91d93eeb 100644
--- a/src/quantem/core/io/file_readers.py
+++ b/src/quantem/core/io/file_readers.py
@@ -138,6 +138,41 @@ def read_2d(
return dataset
+def _find_4d_dataset(group: h5py.Group, path: list[str] | None = None) -> tuple[list[str], h5py.Dataset] | None:
+ """Recursively search for a 4D dataset in an HDF5 group."""
+ if path is None:
+ path = []
+ for key in group.keys():
+ item = group[key]
+ current_path = path + [key]
+ if isinstance(item, h5py.Dataset):
+ if item.ndim == 4:
+ return current_path, item
+ elif isinstance(item, h5py.Group):
+ result = _find_4d_dataset(item, current_path)
+ if result is not None:
+ return result
+ return None
+
+
+def _find_calibration(group: h5py.Group, path: list[str] | None = None) -> tuple[list[str], h5py.Group] | None:
+ """Recursively search for a calibration group containing R_pixel_size and Q_pixel_size."""
+ if path is None:
+ path = []
+ for key in group.keys():
+ item = group[key]
+ current_path = path + [key]
+ if isinstance(item, h5py.Group):
+ # Check if this group has calibration keys
+ if "R_pixel_size" in item and "Q_pixel_size" in item:
+ return current_path, item
+ # Recurse into subgroups
+ result = _find_calibration(item, current_path)
+ if result is not None:
+ return result
+ return None
+
+
def read_emdfile_to_4dstem(
file_path: str | PathLike,
data_keys: list[str] | None = None,
@@ -146,42 +181,73 @@ def read_emdfile_to_4dstem(
"""
File reader for legacy `emdFile` / `py4DSTEM` files.
+ If data_keys and calibration_keys are not provided, the function will
+ automatically search for a 4D dataset and calibration metadata.
+
Parameters
----------
file_path: str | PathLike
Path to data
+ data_keys: list[str], optional
+ List of keys to navigate to the data. If None, auto-detects.
+ calibration_keys: list[str], optional
+ List of keys to navigate to calibration. If None, auto-detects.
Returns
--------
Dataset4dstem
"""
with h5py.File(file_path, "r") as file:
- # Access the data directly
- data_keys = ["datacube_root", "datacube", "data"] if data_keys is None else data_keys
- print("keys: ", data_keys)
- try:
- data = file
- for key in data_keys:
- data = data[key] # type: ignore
- except KeyError:
- raise KeyError(f"Could not find key {data_keys} in {file_path}")
-
- # Access calibration values directly
- calibration_keys = (
- ["datacube_root", "metadatabundle", "calibration"]
- if calibration_keys is None
- else calibration_keys
- )
- try:
- calibration = file
- for key in calibration_keys:
- calibration = calibration[key] # type: ignore
- except KeyError:
- raise KeyError(f"Could not find calibration key {calibration_keys} in {file_path}")
- r_pixel_size = calibration["R_pixel_size"][()] # type: ignore
- q_pixel_size = calibration["Q_pixel_size"][()] # type: ignore
- r_pixel_units = calibration["R_pixel_units"][()] # type: ignore
- q_pixel_units = calibration["Q_pixel_units"][()] # type: ignore
+ # Auto-detect or use provided data keys
+ if data_keys is None:
+ result = _find_4d_dataset(file)
+ if result is None:
+ raise KeyError(f"Could not find any 4D dataset in {file_path}")
+ data_keys, data = result
+ else:
+ try:
+ data = file
+ for key in data_keys:
+ data = data[key] # type: ignore
+ except KeyError:
+ raise KeyError(f"Could not find key {data_keys} in {file_path}")
+
+ # Auto-detect or use provided calibration keys
+ if calibration_keys is None:
+ result = _find_calibration(file)
+ if result is None:
+ # No calibration found, use defaults
+ r_pixel_size = 1.0
+ q_pixel_size = 1.0
+ r_pixel_units = "pixels"
+ q_pixel_units = "pixels"
+ else:
+ calibration_keys, calibration = result
+ r_pixel_size = calibration["R_pixel_size"][()] # type: ignore
+ q_pixel_size = calibration["Q_pixel_size"][()] # type: ignore
+ r_pixel_units = calibration.get("R_pixel_units", [()])
+ if hasattr(r_pixel_units, "__getitem__"):
+ r_pixel_units = r_pixel_units[()]
+ q_pixel_units = calibration.get("Q_pixel_units", [()])
+ if hasattr(q_pixel_units, "__getitem__"):
+ q_pixel_units = q_pixel_units[()]
+ else:
+ try:
+ calibration = file
+ for key in calibration_keys:
+ calibration = calibration[key] # type: ignore
+ except KeyError:
+ raise KeyError(f"Could not find calibration key {calibration_keys} in {file_path}")
+ r_pixel_size = calibration["R_pixel_size"][()] # type: ignore
+ q_pixel_size = calibration["Q_pixel_size"][()] # type: ignore
+ r_pixel_units = calibration["R_pixel_units"][()] # type: ignore
+ q_pixel_units = calibration["Q_pixel_units"][()] # type: ignore
+
+ # Decode bytes to string if needed
+ if isinstance(r_pixel_units, bytes):
+ r_pixel_units = r_pixel_units.decode("utf-8")
+ if isinstance(q_pixel_units, bytes):
+ q_pixel_units = q_pixel_units.decode("utf-8")
dataset = Dataset4dstem.from_array(
array=data,
diff --git a/src/quantem/core/utils/validators.py b/src/quantem/core/utils/validators.py
index 02f8a935..33c2c83e 100644
--- a/src/quantem/core/utils/validators.py
+++ b/src/quantem/core/utils/validators.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
import os
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional, TypeAlias, Union, overload
@@ -12,6 +14,7 @@
if TYPE_CHECKING:
import cupy as cp
import torch
+
TensorLike: TypeAlias = ArrayLike | torch.Tensor
else:
TensorLike: TypeAlias = ArrayLike
@@ -20,6 +23,7 @@
if config.get("has_cupy"):
import cupy as cp
+
# --- Dataset Validation Functions ---
def ensure_valid_array(
array: "np.ndarray | cp.ndarray", dtype: DTypeLike = None, ndim: int | None = None
diff --git a/uv.lock b/uv.lock
index 29931da7..66714a13 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2619,10 +2619,16 @@ version = "0.0.1"
source = { editable = "widget" }
dependencies = [
{ name = "anywidget" },
+ { name = "numpy" },
+ { name = "traitlets" },
]
[package.metadata]
-requires-dist = [{ name = "anywidget", specifier = ">=0.9.0" }]
+requires-dist = [
+ { name = "anywidget", specifier = ">=0.9.0" },
+ { name = "numpy", specifier = ">=2.0.0" },
+ { name = "traitlets", specifier = ">=5.0.0" },
+]
[[package]]
name = "referencing"
diff --git a/widget/js/index.jsx b/widget/js/index.jsx
deleted file mode 100644
index a3341f63..00000000
--- a/widget/js/index.jsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import * as React from "react";
-import * as ReactDOM from "react-dom/client";
-
-function Widget({ model }) {
- const [count, setCount] = React.useState(model.get("count"));
-
- React.useEffect(() => {
- const onChange = () => setCount(model.get("count"));
- model.on("change:count", onChange);
- return () => model.off("change:count", onChange);
- }, [model]);
-
- const handleClick = () => {
- model.set("count", count + 1);
- model.save_changes();
- };
-
- return (
-
-
quantem.widget
-
Count: {count}
-
-
- );
-}
-
-function render({ model, el }) {
- const root = ReactDOM.createRoot(el);
- root.render();
- return () => root.unmount();
-}
-
-export default { render };
diff --git a/widget/js/show2d/index.tsx b/widget/js/show2d/index.tsx
new file mode 100644
index 00000000..6a42d9ff
--- /dev/null
+++ b/widget/js/show2d/index.tsx
@@ -0,0 +1,17 @@
+// Placeholder for Show2D widget
+// TODO: Implement 2D image viewer widget
+
+import * as React from "react";
+import { createRender } from "@anywidget/react";
+import Box from "@mui/material/Box";
+import Typography from "@mui/material/Typography";
+
+function Show2D() {
+ return (
+
+ Show2D - Coming Soon
+
+ );
+}
+
+export const render = createRender(Show2D);
diff --git a/widget/js/show3d/index.tsx b/widget/js/show3d/index.tsx
new file mode 100644
index 00000000..62d95a35
--- /dev/null
+++ b/widget/js/show3d/index.tsx
@@ -0,0 +1,17 @@
+// Placeholder for Show3D widget
+// TODO: Implement 3D volume viewer widget
+
+import * as React from "react";
+import { createRender } from "@anywidget/react";
+import Box from "@mui/material/Box";
+import Typography from "@mui/material/Typography";
+
+function Show3D() {
+ return (
+
+ Show3D - Coming Soon
+
+ );
+}
+
+export const render = createRender(Show3D);
diff --git a/widget/js/show4dstem/index.tsx b/widget/js/show4dstem/index.tsx
new file mode 100644
index 00000000..4ac54e64
--- /dev/null
+++ b/widget/js/show4dstem/index.tsx
@@ -0,0 +1,2683 @@
+///
+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 Slider from "@mui/material/Slider";
+import Button from "@mui/material/Button";
+import Switch from "@mui/material/Switch";
+import Tooltip from "@mui/material/Tooltip";
+import JSZip from "jszip";
+import "./styles.css";
+
+// ============================================================================
+// Theme Detection - detect environment and light/dark mode
+// ============================================================================
+type Environment = "jupyterlab" | "vscode" | "colab" | "jupyter-classic" | "unknown";
+type Theme = "light" | "dark";
+
+interface ThemeInfo {
+ environment: Environment;
+ theme: Theme;
+}
+
+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')) {
+ // Colab: check computed background color
+ 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",
+ };
+}
+
+/** Check if a CSS color string is dark (luminance < 0.5) */
+function isColorDark(color: string): boolean {
+ // Parse rgb(r, g, b) or rgba(r, g, b, a)
+ const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
+ if (!match) return true; // Default to dark if can't parse
+ const [, r, g, b] = match.map(Number);
+ // Relative luminance formula (simplified)
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+ return luminance < 0.5;
+}
+
+// ============================================================================
+// Colormaps - pre-computed LUTs for image display
+// ============================================================================
+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]],
+};
+
+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;
+}
+
+const COLORMAPS: Record = Object.fromEntries(
+ Object.entries(COLORMAP_POINTS).map(([name, points]) => [name, createColormapLUT(points)])
+);
+
+// ============================================================================
+// FFT Utilities - CPU implementation with WebGPU acceleration
+// ============================================================================
+const MIN_ZOOM = 0.5;
+const MAX_ZOOM = 10;
+
+function nextPow2(n: number): number { return Math.pow(2, Math.ceil(Math.log2(n))); }
+
+function fft1dPow2(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; } }
+}
+
+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]; }
+ fft1dPow2(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]; }
+ fft1dPow2(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];
+ }
+ }
+}
+
+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);
+}
+
+// ============================================================================
+// WebGPU FFT - GPU-accelerated FFT when available
+// ============================================================================
+const FFT_SHADER = `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 FFTParams { N: u32, log2N: u32, stage: u32, inverse: f32, }
+@group(0) @binding(0) var params: FFTParams;
+@group(0) @binding(1) var data: array>;
+@compute @workgroup_size(256) fn bitReversePermute(@builtin(global_invocation_id) gid: vec3) { let idx = gid.x; if (idx >= params.N) { return; } let rev = bitReverse(idx, params.log2N); if (idx < rev) { let temp = data[idx]; data[idx] = data[rev]; data[rev] = temp; } }
+@compute @workgroup_size(256) fn butterflyStage(@builtin(global_invocation_id) gid: vec3) { let idx = gid.x; if (idx >= params.N / 2u) { return; } let stage = params.stage; let halfSize = 1u << stage; let fullSize = halfSize << 1u; let group = idx / halfSize; let pos = idx % halfSize; let i = group * fullSize + pos; let j = i + halfSize; let w = twiddle(pos, fullSize, params.inverse); let u = data[i]; let t = cmul(w, data[j]); data[i] = u + t; data[j] = u - t; }
+@compute @workgroup_size(256) fn normalize(@builtin(global_invocation_id) gid: vec3) { let idx = gid.x; if (idx >= params.N) { return; } let scale = 1.0 / f32(params.N); data[idx] = data[idx] * scale; }`;
+
+const FFT_2D_SHADER = `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; }`;
+
+class WebGPUFFT {
+ private device: GPUDevice;
+ private pipelines1D: { bitReverse: GPUComputePipeline; butterfly: GPUComputePipeline; normalize: GPUComputePipeline } | null = null;
+ 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 module1D = this.device.createShaderModule({ code: FFT_SHADER });
+ this.pipelines1D = {
+ bitReverse: this.device.createComputePipeline({ layout: 'auto', compute: { module: module1D, entryPoint: 'bitReversePermute' } }),
+ butterfly: this.device.createComputePipeline({ layout: 'auto', compute: { module: module1D, entryPoint: 'butterflyStage' } }),
+ normalize: this.device.createComputePipeline({ layout: 'auto', compute: { module: module1D, entryPoint: 'normalize' } })
+ };
+ 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 };
+ }
+ destroy(): void { this.initialized = false; }
+}
+
+let gpuFFT: WebGPUFFT | null = null;
+async function getWebGPUFFT(): Promise {
+ if (gpuFFT) return gpuFFT;
+ if (!navigator.gpu) { console.warn('WebGPU not supported, using CPU FFT'); return null; }
+ try {
+ const adapter = await navigator.gpu.requestAdapter();
+ if (!adapter) return null;
+ const device = await adapter.requestDevice();
+ gpuFFT = new WebGPUFFT(device); await gpuFFT.init();
+ return gpuFFT;
+ } catch (e) { console.warn('WebGPU init failed:', e); return null; }
+}
+
+// ============================================================================
+// UI Styles - component styling helpers
+// ============================================================================
+const typography = {
+ label: { color: "#aaa", fontSize: 11 },
+ labelSmall: { color: "#888", fontSize: 10 },
+ value: { color: "#888", fontSize: 10, fontFamily: "monospace" },
+ title: { color: "#0af", fontWeight: "bold" as const },
+};
+
+const controlPanel = {
+ group: { bgcolor: "#222", px: 1.5, py: 0.5, borderRadius: 1, border: "1px solid #444", height: 32 },
+ button: { color: "#888", fontSize: 10, cursor: "pointer", "&:hover": { color: "#fff" }, bgcolor: "#222", px: 1, py: 0.25, borderRadius: 0.5, border: "1px solid #444" },
+ select: { minWidth: 90, bgcolor: "#333", color: "#fff", fontSize: 11, "& .MuiSelect-select": { py: 0.5 } },
+};
+
+const container = {
+ root: { p: 2, bgcolor: "transparent", color: "inherit", fontFamily: "monospace", borderRadius: 1, 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' } },
+};
+
+// ============================================================================
+// 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
+
+// Interaction constants
+const RESIZE_HIT_AREA_PX = 10;
+const CIRCLE_HANDLE_ANGLE = 0.707; // cos(45°)
+const LINE_WIDTH_FRACTION = 0.015;
+const LINE_WIDTH_MIN_PX = 1.5;
+const LINE_WIDTH_MAX_PX = 3;
+
+// 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 for each row
+const controlRow = {
+ display: "flex",
+ alignItems: "center",
+ gap: `${SPACING.SM}px`,
+ borderRadius: "2px",
+ px: 1,
+ py: 0.5,
+ width: "fit-content",
+};
+
+/** Round to a nice value (1, 2, 5, 10, 20, 50, etc.) */
+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 with appropriate unit */
+function formatScaleLabel(value: number, unit: "Å" | "mrad" | "px"): string {
+ const nice = roundToNiceValue(value);
+ if (unit === "Å") {
+ if (nice >= 10) return `${Math.round(nice / 10)} nm`;
+ return nice >= 1 ? `${Math.round(nice)} Å` : `${nice.toFixed(2)} Å`;
+ }
+ if (unit === "px") {
+ return nice >= 1 ? `${Math.round(nice)} px` : `${nice.toFixed(1)} px`;
+ }
+ if (nice >= 1000) return `${Math.round(nice / 1000)} rad`;
+ return nice >= 1 ? `${Math.round(nice)} mrad` : `${nice.toFixed(2)} mrad`;
+}
+
+/** 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);
+}
+
+/**
+ * Draw scale bar and zoom indicator on a high-DPI UI canvas.
+ * This renders crisp text/lines independent of the image resolution.
+ */
+function drawScaleBarHiDPI(
+ canvas: HTMLCanvasElement,
+ dpr: number,
+ zoom: number,
+ pixelSize: number,
+ unit: "Å" | "mrad" | "px",
+ imageWidth: number,
+ imageHeight: number
+) {
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ // Clear canvas
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ // Scale context for device pixel ratio
+ ctx.save();
+ ctx.scale(dpr, dpr);
+
+ // CSS pixel dimensions
+ const cssWidth = canvas.width / dpr;
+ const cssHeight = canvas.height / dpr;
+
+ // Calculate separate X/Y scale factors (canvas stretches to fill, not aspect-preserving)
+ const scaleX = cssWidth / imageWidth;
+ // Use X scale for horizontal measurements (scale bar is horizontal)
+ const effectiveZoom = zoom * scaleX;
+
+ // Fixed UI sizes in CSS pixels (always crisp)
+ const targetBarPx = 60; // Target bar length in CSS pixels
+ const barThickness = 5;
+ const fontSize = 16;
+ const margin = 12;
+
+ // Calculate what physical size the target bar represents
+ const targetPhysical = (targetBarPx / effectiveZoom) * pixelSize;
+
+ // Round to a nice value
+ const nicePhysical = roundToNiceValue(targetPhysical);
+
+ // Calculate actual bar length for the nice value (in CSS pixels)
+ const barPx = (nicePhysical / pixelSize) * effectiveZoom;
+
+ const barY = cssHeight - margin;
+ const barX = cssWidth - barPx - margin;
+
+ // Draw bar with shadow for visibility
+ 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);
+
+ // Draw label (centered above bar)
+ const label = formatScaleLabel(nicePhysical, unit);
+ ctx.font = `${fontSize}px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`;
+ ctx.fillStyle = "white";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "bottom";
+ ctx.fillText(label, barX + barPx / 2, barY - 4);
+
+ // Draw zoom indicator (bottom left)
+ ctx.textAlign = "left";
+ ctx.textBaseline = "bottom";
+ ctx.fillText(`${zoom.toFixed(1)}×`, margin, cssHeight - margin + barThickness);
+
+ ctx.restore();
+}
+
+/**
+ * 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,
+ posX: number, // Position in image coordinates
+ posY: 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 = posY * zoom * scaleX + panX * scaleX;
+ const screenY = posX * 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,
+ centerX: number,
+ centerY: 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 (note: Y is row, X is col in image)
+ const screenX = centerY * zoom * scaleX + panX * scaleX;
+ const screenY = centerX * 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,
+ kx: number, // Position in detector coordinates
+ ky: number,
+ zoom: number,
+ panX: number,
+ panY: number,
+ detWidth: number,
+ detHeight: 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;
+ // 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 (no swap - kx is X, ky is Y)
+ const screenX = kx * zoom * scaleX + panX * scaleX;
+ const screenY = ky * 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 ? "rgba(255, 255, 0, 0.9)" : "rgba(0, 255, 0, 0.9)";
+ 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,
+ centerX: number,
+ centerY: 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
+) {
+ 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 = centerX * zoom * scaleX + panX * scaleX;
+ const screenY = centerY * 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 ? "rgba(0, 220, 255, 0.8)" : "rgba(0, 255, 0, 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
+ const drawCenterCrosshair = () => {
+ ctx.strokeStyle = isDragging ? "rgba(255, 255, 0, 0.9)" : "rgba(0, 255, 0, 0.9)";
+ 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 ? "rgba(255, 255, 0, 0.9)" : "rgba(0, 255, 0, 0.9)";
+ ctx.lineWidth = lineWidth;
+ ctx.beginPath();
+ ctx.ellipse(screenX, screenY, screenRadiusX, screenRadiusY, 0, 0, 2 * Math.PI);
+ ctx.stroke();
+
+ // Semi-transparent fill
+ ctx.fillStyle = isDragging ? "rgba(255, 255, 0, 0.12)" : "rgba(0, 255, 0, 0.12)";
+ 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 ? "rgba(255, 255, 0, 0.9)" : "rgba(0, 255, 0, 0.9)";
+ ctx.lineWidth = lineWidth;
+ ctx.beginPath();
+ ctx.rect(left, top, screenHalfW * 2, screenHalfH * 2);
+ ctx.stroke();
+
+ ctx.fillStyle = isDragging ? "rgba(255, 255, 0, 0.12)" : "rgba(0, 255, 0, 0.12)";
+ 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 ? "rgba(255, 255, 0, 0.9)" : "rgba(0, 255, 0, 0.9)";
+ ctx.lineWidth = lineWidth;
+ ctx.beginPath();
+ ctx.rect(left, top, screenHalfW * 2, screenHalfH * 2);
+ ctx.stroke();
+
+ ctx.fillStyle = isDragging ? "rgba(255, 255, 0, 0.12)" : "rgba(0, 255, 0, 0.12)";
+ 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 (green)
+ ctx.strokeStyle = isDragging ? "rgba(255, 255, 0, 0.9)" : "rgba(0, 255, 0, 0.9)";
+ ctx.lineWidth = lineWidth;
+ ctx.beginPath();
+ ctx.ellipse(screenX, screenY, screenRadiusOuterX, screenRadiusOuterY, 0, 0, 2 * Math.PI);
+ ctx.stroke();
+
+ // Inner ellipse (cyan)
+ ctx.strokeStyle = isDragging ? "rgba(255, 200, 0, 0.9)" : "rgba(0, 220, 255, 0.9)";
+ ctx.beginPath();
+ ctx.ellipse(screenX, screenY, screenRadiusInnerX, screenRadiusInnerY, 0, 0, 2 * Math.PI);
+ ctx.stroke();
+
+ // Fill annular region
+ ctx.fillStyle = isDragging ? "rgba(255, 255, 0, 0.12)" : "rgba(0, 255, 0, 0.12)";
+ 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
+// ============================================================================
+
+/**
+ * Compute histogram from byte data (0-255).
+ * Returns 256 bins normalized to 0-1 range.
+ */
+function computeHistogramFromBytes(data: Uint8Array | Float32Array | null, numBins = 256): number[] {
+ if (!data || data.length === 0) {
+ return new Array(numBins).fill(0);
+ }
+
+ const bins = new Array(numBins).fill(0);
+
+ // For Float32Array, find min/max and bin accordingly
+ if (data instanceof Float32Array) {
+ 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)) {
+ const binIdx = Math.min(numBins - 1, Math.floor(((v - min) / range) * numBins));
+ bins[binIdx]++;
+ }
+ }
+ } else {
+ // Uint8Array - values are already 0-255
+ for (let i = 0; i < data.length; i++) {
+ const binIdx = Math.min(numBins - 1, data[i]);
+ bins[binIdx]++;
+ }
+ }
+
+ // Normalize bins to 0-1
+ const maxCount = Math.max(...bins);
+ if (maxCount > 0) {
+ for (let i = 0; i < numBins; i++) {
+ bins[i] /= maxCount;
+ }
+ }
+
+ return bins;
+}
+
+interface HistogramProps {
+ data: Uint8Array | Float32Array | null;
+ colormap: string;
+ 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: string; theme?: "light" | "dark" }) {
+ const isDark = theme === "dark";
+ return (
+ {text}}
+ arrow
+ placement="bottom"
+ componentsProps={{
+ tooltip: {
+ sx: {
+ bgcolor: isDark ? "#333" : "#fff",
+ color: isDark ? "#ddd" : "#333",
+ border: `1px solid ${isDark ? "#555" : "#ccc"}`,
+ maxWidth: 280,
+ p: 1,
+ },
+ },
+ arrow: {
+ sx: {
+ color: isDark ? "#333" : "#fff",
+ "&::before": { border: `1px solid ${isDark ? "#555" : "#ccc"}` },
+ },
+ },
+ }}
+ >
+
+ ⓘ
+
+
+ );
+}
+
+/**
+ * Histogram component with integrated vmin/vmax slider and statistics.
+ * Shows data distribution with colormap gradient and adjustable clipping.
+ */
+function Histogram({
+ data,
+ colormap,
+ 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, colormap, 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" },
+ }}
+ />
+
+ );
+}
+
+// ============================================================================
+// Main Component
+// ============================================================================
+function Show4DSTEM() {
+ // Direct model access for batched updates
+ const model = useModel();
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Model State (synced with Python)
+ // ─────────────────────────────────────────────────────────────────────────
+ const [shapeX] = useModelState("shape_x");
+ const [shapeY] = useModelState("shape_y");
+ const [detX] = useModelState("det_x");
+ const [detY] = useModelState("det_y");
+
+ const [posX, setPosX] = useModelState("pos_x");
+ const [posY, setPosY] = useModelState("pos_y");
+ const [roiCenterX, setRoiCenterX] = useModelState("roi_center_x");
+ const [roiCenterY, setRoiCenterY] = useModelState("roi_center_y");
+ const [, setRoiActive] = useModelState("roi_active");
+
+ const [pixelSize] = useModelState("pixel_size");
+ const [kPixelSize] = useModelState("k_pixel_size");
+ const [kCalibrated] = useModelState("k_calibrated");
+
+ 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)
+ const [viDataMin] = useModelState("vi_data_min");
+ const [viDataMax] = useModelState("vi_data_max");
+
+ // Detector calibration (for presets)
+ const [bfRadius] = useModelState("bf_radius");
+ const [centerX] = useModelState("center_x");
+ const [centerY] = useModelState("center_y");
+
+ // 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");
+
+ // Auto-detection trigger
+ const [, setAutoDetectTrigger] = useModelState("auto_detect_trigger");
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Local State (UI-only, not synced to Python)
+ // ─────────────────────────────────────────────────────────────────────────
+ const [localKx, setLocalKx] = React.useState(roiCenterX);
+ const [localKy, setLocalKy] = React.useState(roiCenterY);
+ const [localPosX, setLocalPosX] = React.useState(posX);
+ const [localPosY, setLocalPosY] = React.useState(posY);
+ const [isDraggingDP, setIsDraggingDP] = React.useState(false);
+ 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);
+ // 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 [dpColormap, setDpColormap] = React.useState("inferno");
+ const [viColormap, setViColormap] = React.useState("inferno");
+ // vmin/vmax percentile clipping (0-100)
+ const [dpVminPct, setDpVminPct] = React.useState(0);
+ const [dpVmaxPct, setDpVmaxPct] = React.useState(100);
+ const [viVminPct, setViVminPct] = React.useState(0);
+ const [viVmaxPct, setViVmaxPct] = React.useState(100);
+ // Scale mode: "linear" | "log" | "power"
+ const [dpScaleMode, setDpScaleMode] = React.useState<"linear" | "log" | "power">("linear");
+ const dpPowerExp = 0.5;
+ const [viScaleMode, setViScaleMode] = React.useState<"linear" | "log" | "power">("linear");
+ const viPowerExp = 0.5;
+
+ // VI ROI state (real-space region selection for summed DP) - synced with Python
+ const [viRoiMode, setViRoiMode] = useModelState("vi_roi_mode");
+ const [viRoiCenterX, setViRoiCenterX] = useModelState("vi_roi_center_x");
+ const [viRoiCenterY, setViRoiCenterY] = useModelState("vi_roi_center_y");
+ 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 [localViRoiCenterX, setLocalViRoiCenterX] = React.useState(viRoiCenterX || 0);
+ const [localViRoiCenterY, setLocalViRoiCenterY] = React.useState(viRoiCenterY || 0);
+ const [summedDpBytes] = useModelState("summed_dp_bytes");
+ const [summedDpCount] = useModelState("summed_dp_count");
+ const [dpStats] = useModelState("dp_stats"); // [mean, min, max, std]
+ const [viStats] = useModelState("vi_stats"); // [mean, min, max, std]
+ const [showFft, setShowFft] = React.useState(false); // Hidden by default per feedback
+
+ // Theme detection - detect environment and light/dark mode
+ const [themeInfo, setThemeInfo] = React.useState(() => detectTheme());
+
+ // Re-detect theme on mount and when OS preference changes
+ React.useEffect(() => {
+ setThemeInfo(detectTheme());
+
+ // Listen for OS preference changes
+ const mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)');
+ const handleChange = () => setThemeInfo(detectTheme());
+ mediaQuery?.addEventListener?.('change', handleChange);
+
+ // Also observe body attributes for JupyterLab theme changes
+ 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();
+ };
+ }, []);
+
+ // Theme colors based on detected theme
+ const themeColors = themeInfo.theme === "dark" ? {
+ bg: "#1e1e1e",
+ bgAlt: "#1a1a1a",
+ text: "#e0e0e0",
+ textMuted: "#888",
+ border: "#3a3a3a",
+ controlBg: "#252525",
+ accent: "#5af",
+ } : {
+ bg: "#ffffff",
+ bgAlt: "#f5f5f5",
+ text: "#1e1e1e",
+ textMuted: "#666",
+ border: "#ccc",
+ controlBg: "#f0f0f0",
+ accent: "#0066cc",
+ };
+
+ // Compute VI canvas dimensions to respect aspect ratio of rectangular scans
+ // The longer dimension gets CANVAS_SIZE, the shorter scales proportionally
+ const viCanvasWidth = shapeX > shapeY ? Math.round(CANVAS_SIZE * (shapeY / shapeX)) : CANVAS_SIZE;
+ const viCanvasHeight = shapeY > shapeX ? Math.round(CANVAS_SIZE * (shapeX / shapeY)) : CANVAS_SIZE;
+
+ // Histogram data - use state to ensure re-renders (both are Float32Array now)
+ const [dpHistogramData, setDpHistogramData] = React.useState(null);
+ const [viHistogramData, setViHistogramData] = React.useState(null);
+
+ // 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);
+ // 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 if (dpScaleMode === "power") {
+ for (let i = 0; i < rawData.length; i++) {
+ scaledData[i] = Math.pow(Math.max(0, rawData[i]), dpPowerExp);
+ }
+ } else {
+ scaledData.set(rawData);
+ }
+ setDpHistogramData(scaledData);
+ }, [frameBytes, dpScaleMode, dpPowerExp]);
+
+ // 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]);
+
+ // Keyboard shortcuts
+ React.useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ // Ignore if typing in an input
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
+
+ const step = e.shiftKey ? 10 : 1;
+
+ switch (e.key) {
+ case 'ArrowUp':
+ e.preventDefault();
+ setPosX(Math.max(0, posX - step));
+ break;
+ case 'ArrowDown':
+ e.preventDefault();
+ setPosX(Math.min(shapeX - 1, posX + step));
+ break;
+ case 'ArrowLeft':
+ e.preventDefault();
+ setPosY(Math.max(0, posY - step));
+ break;
+ case 'ArrowRight':
+ e.preventDefault();
+ setPosY(Math.min(shapeY - 1, posY + step));
+ break;
+ case ' ': // Space bar
+ e.preventDefault();
+ if (pathLength > 0) {
+ setPathPlaying(!pathPlaying);
+ }
+ break;
+ case 'r': // Reset view
+ case 'R':
+ setDpZoom(1); setDpPanX(0); setDpPanY(0);
+ setViZoom(1); setViPanX(0); setViPanY(0);
+ setFftZoom(1); setFftPanX(0); setFftPanY(0);
+ break;
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [posX, posY, shapeX, shapeY, pathPlaying, pathLength, setPosX, setPosY, setPathPlaying]);
+
+ // 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] = React.useState<"linear" | "log" | "power">("linear");
+ const [fftColormap, setFftColormap] = React.useState("inferno");
+ const [fftAuto, setFftAuto] = React.useState(true); // Auto: mask DC + 99.9% clipping
+ const [fftVminPct, setFftVminPct] = React.useState(0);
+ const [fftVmaxPct, setFftVmaxPct] = React.useState(100);
+ 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);
+
+ // Sync local state
+ React.useEffect(() => {
+ if (!isDraggingDP && !isDraggingResize) { setLocalKx(roiCenterX); setLocalKy(roiCenterY); }
+ }, [roiCenterX, roiCenterY, isDraggingDP, isDraggingResize]);
+
+ React.useEffect(() => {
+ if (!isDraggingVI) { setLocalPosX(posX); setLocalPosY(posY); }
+ }, [posX, posY, isDraggingVI]);
+
+ // Sync VI ROI local state
+ React.useEffect(() => {
+ if (!isDraggingViRoi && !isDraggingViRoiResize) {
+ setLocalViRoiCenterX(viRoiCenterX || shapeX / 2);
+ setLocalViRoiCenterY(viRoiCenterY || shapeY / 2);
+ }
+ }, [viRoiCenterX, viRoiCenterY, isDraggingViRoi, isDraggingViRoiResize, shapeX, shapeY]);
+
+ // 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);
+
+ // 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));
+ }, [showFft]);
+
+ // 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);
+
+ // 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);
+
+ // 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 if (viScaleMode === "power") {
+ for (let i = 0; i < numFloats; i++) {
+ scaledData[i] = Math.pow(Math.max(0, rawData[i]), viPowerExp);
+ }
+ } else {
+ scaledData.set(rawData);
+ }
+ setViHistogramData(scaledData);
+ }, [virtualImageBytes, viScaleMode, viPowerExp]);
+
+ // Render DP with zoom (use summed DP when VI ROI is active)
+ React.useEffect(() => {
+ if (!dpCanvasRef.current) return;
+
+ // Determine which bytes to display: summed DP (if VI ROI active) or single frame
+ const usesSummedDp = viRoiMode && viRoiMode !== "off" && summedDpBytes && summedDpBytes.byteLength > 0;
+ const sourceBytes = usesSummedDp ? summedDpBytes : frameBytes;
+ if (!sourceBytes) return;
+
+ const canvas = dpCanvasRef.current;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ const lut = COLORMAPS[dpColormap] || COLORMAPS.inferno;
+
+ // Parse data based on source (summedDp is still uint8, frame is now float32)
+ let scaled: Float32Array;
+ if (usesSummedDp) {
+ // Summed DP is still uint8 from Python
+ const bytes = new Uint8Array(sourceBytes.buffer, sourceBytes.byteOffset, sourceBytes.byteLength);
+ scaled = new Float32Array(bytes.length);
+ for (let i = 0; i < bytes.length; i++) {
+ scaled[i] = bytes[i];
+ }
+ } else {
+ // Frame is now float32 from Python - parse and apply scale transformation
+ const rawData = new Float32Array(sourceBytes.buffer, sourceBytes.byteOffset, sourceBytes.byteLength / 4);
+ scaled = new Float32Array(rawData.length);
+
+ if (dpScaleMode === "log") {
+ for (let i = 0; i < rawData.length; i++) {
+ scaled[i] = Math.log1p(Math.max(0, rawData[i]));
+ }
+ } else if (dpScaleMode === "power") {
+ for (let i = 0; i < rawData.length; i++) {
+ scaled[i] = Math.pow(Math.max(0, rawData[i]), dpPowerExp);
+ }
+ } else {
+ scaled.set(rawData);
+ }
+ }
+
+ // Compute actual min/max of scaled data for normalization
+ let dataMin = Infinity, dataMax = -Infinity;
+ for (let i = 0; i < scaled.length; i++) {
+ if (scaled[i] < dataMin) dataMin = scaled[i];
+ if (scaled[i] > dataMax) dataMax = scaled[i];
+ }
+
+ // Apply vmin/vmax percentile clipping
+ const dataRange = dataMax - dataMin;
+ const vmin = dataMin + dataRange * dpVminPct / 100;
+ const vmax = dataMin + dataRange * dpVmaxPct / 100;
+ const range = vmax > vmin ? vmax - vmin : 1;
+
+ let offscreen = dpOffscreenRef.current;
+ if (!offscreen) {
+ offscreen = document.createElement("canvas");
+ dpOffscreenRef.current = offscreen;
+ }
+ const sizeChanged = offscreen.width !== detY || offscreen.height !== detX;
+ if (sizeChanged) {
+ offscreen.width = detY;
+ offscreen.height = detX;
+ dpImageDataRef.current = null;
+ }
+ const offCtx = offscreen.getContext("2d");
+ if (!offCtx) return;
+
+ let imgData = dpImageDataRef.current;
+ if (!imgData) {
+ imgData = offCtx.createImageData(detY, detX);
+ dpImageDataRef.current = imgData;
+ }
+ const rgba = imgData.data;
+
+ for (let i = 0; i < scaled.length; i++) {
+ // Clamp to vmin/vmax and rescale to 0-255 for colormap lookup
+ const clamped = Math.max(vmin, Math.min(vmax, scaled[i]));
+ const v = Math.floor(((clamped - vmin) / range) * 255);
+ const j = i * 4;
+ const lutIdx = Math.max(0, Math.min(255, v)) * 3;
+ rgba[j] = lut[lutIdx];
+ rgba[j + 1] = lut[lutIdx + 1];
+ rgba[j + 2] = lut[lutIdx + 2];
+ rgba[j + 3] = 255;
+ }
+ offCtx.putImageData(imgData, 0, 0);
+
+ 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();
+ }, [frameBytes, summedDpBytes, viRoiMode, detX, detY, dpColormap, dpVminPct, dpVmaxPct, dpScaleMode, dpPowerExp, 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
+ }, [localKx, localKy, isDraggingDP, isDraggingResize, isDraggingResizeInner, isHoveringResize, isHoveringResizeInner, dpZoom, dpPanX, dpPanY, roiMode, roiRadius, roiRadiusInner, roiWidth, roiHeight, detX, detY]);
+
+ // Render filtered virtual image
+ React.useEffect(() => {
+ if (!rawVirtualImageRef.current || !virtualCanvasRef.current) return;
+ const canvas = virtualCanvasRef.current;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ const width = shapeY;
+ const height = shapeX;
+
+ const renderData = (filtered: Float32Array) => {
+ // Normalize and render
+ // Apply scale transformation first
+ 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]));
+ }
+ } else if (viScaleMode === "power") {
+ scaled = new Float32Array(filtered.length);
+ for (let i = 0; i < filtered.length; i++) {
+ scaled[i] = Math.pow(Math.max(0, filtered[i]), viPowerExp);
+ }
+ }
+
+ // Use Python's pre-computed min/max when valid, fallback to computing from data
+ let dataMin: number, dataMax: number;
+ const hasValidMinMax = viDataMin !== undefined && viDataMax !== undefined && viDataMax > viDataMin;
+ if (hasValidMinMax) {
+ // Apply scale transform to Python's values
+ if (viScaleMode === "log") {
+ dataMin = Math.log1p(Math.max(0, viDataMin));
+ dataMax = Math.log1p(Math.max(0, viDataMax));
+ } else if (viScaleMode === "power") {
+ dataMin = Math.pow(Math.max(0, viDataMin), viPowerExp);
+ dataMax = Math.pow(Math.max(0, viDataMax), viPowerExp);
+ } else {
+ dataMin = viDataMin;
+ dataMax = viDataMax;
+ }
+ } else {
+ // Fallback: compute from scaled data
+ dataMin = Infinity; dataMax = -Infinity;
+ for (let i = 0; i < scaled.length; i++) {
+ if (scaled[i] < dataMin) dataMin = scaled[i];
+ if (scaled[i] > dataMax) dataMax = scaled[i];
+ }
+ }
+
+ // Apply vmin/vmax percentile clipping
+ const dataRange = dataMax - dataMin;
+ const vmin = dataMin + dataRange * viVminPct / 100;
+ const vmax = dataMin + dataRange * viVmaxPct / 100;
+ const range = vmax > vmin ? vmax - vmin : 1;
+
+ 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;
+ }
+ for (let i = 0; i < scaled.length; i++) {
+ // Clamp to vmin/vmax and rescale to 0-255
+ const clamped = Math.max(vmin, Math.min(vmax, scaled[i]));
+ const val = Math.floor(((clamped - vmin) / range) * 255);
+ imageData.data[i * 4] = lut[val * 3];
+ imageData.data[i * 4 + 1] = lut[val * 3 + 1];
+ imageData.data[i * 4 + 2] = lut[val * 3 + 2];
+ imageData.data[i * 4 + 3] = 255;
+ }
+ offCtx.putImageData(imageData, 0, 0);
+
+ ctx.imageSmoothingEnabled = false;
+ 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();
+ };
+
+ if (!rawVirtualImageRef.current) return;
+ renderData(rawVirtualImageRef.current);
+ // Note: viDataMin/viDataMax intentionally not in deps - they arrive with virtualImageBytes
+ // and we have a fallback if they're stale
+ }, [virtualImageBytes, shapeX, shapeY, viColormap, viVminPct, viVmaxPct, viScaleMode, viPowerExp, viZoom, viPanX, viPanY]);
+
+ // 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)
+ }, [localPosX, localPosY, isDraggingVI, viZoom, viPanX, viPanY, pixelSize, shapeX, shapeY]);
+
+ // Render FFT (WebGPU when available, CPU fallback)
+ React.useEffect(() => {
+ if (!rawVirtualImageRef.current || !fftCanvasRef.current) return;
+ const canvas = fftCanvasRef.current;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ if (!showFft) {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ return;
+ }
+
+ const width = shapeY;
+ const height = shapeX;
+ const sourceData = rawVirtualImageRef.current;
+ const lut = COLORMAPS[fftColormap] || COLORMAPS.inferno;
+
+ // Helper to render magnitude to canvas
+ const renderMagnitude = (real: Float32Array, imag: Float32Array) => {
+ // Compute magnitude (log or linear)
+ let magnitude = fftMagnitudeRef.current;
+ if (!magnitude || magnitude.length !== real.length) {
+ magnitude = new Float32Array(real.length);
+ fftMagnitudeRef.current = magnitude;
+ }
+ for (let i = 0; i < real.length; i++) {
+ const mag = Math.sqrt(real[i] * real[i] + imag[i] * imag[i]);
+ if (fftScaleMode === "log") {
+ magnitude[i] = Math.log1p(mag);
+ } else if (fftScaleMode === "power") {
+ magnitude[i] = Math.pow(mag, 0.5); // gamma = 0.5
+ } else {
+ magnitude[i] = mag;
+ }
+ }
+
+ // Auto mode: mask DC component + 99.9% percentile clipping
+ let displayMin: number, displayMax: number;
+ if (fftAuto) {
+ // Mask DC (center pixel) by replacing with neighbor average
+ const centerIdx = Math.floor(height / 2) * width + Math.floor(width / 2);
+ const neighbors = [
+ magnitude[centerIdx - 1],
+ magnitude[centerIdx + 1],
+ magnitude[centerIdx - width],
+ magnitude[centerIdx + width]
+ ];
+ magnitude[centerIdx] = neighbors.reduce((a, b) => a + b, 0) / 4;
+
+ // Apply 99.9% percentile clipping for display range
+ const sorted = magnitude.slice().sort((a, b) => a - b);
+ displayMin = sorted[0];
+ displayMax = sorted[Math.floor(sorted.length * 0.999)];
+ } else {
+ // No auto: use actual min/max
+ displayMin = Infinity;
+ displayMax = -Infinity;
+ for (let i = 0; i < magnitude.length; i++) {
+ if (magnitude[i] < displayMin) displayMin = magnitude[i];
+ if (magnitude[i] > displayMax) displayMax = magnitude[i];
+ }
+ }
+ setFftDataMin(displayMin);
+ setFftDataMax(displayMax);
+
+ // Stats use same values
+ const actualMin = displayMin;
+ const actualMax = displayMax;
+ let sum = 0;
+ for (let i = 0; i < magnitude.length; i++) {
+ sum += magnitude[i];
+ }
+ const mean = sum / magnitude.length;
+ let sumSq = 0;
+ for (let i = 0; i < magnitude.length; i++) {
+ const diff = magnitude[i] - mean;
+ sumSq += diff * diff;
+ }
+ const std = Math.sqrt(sumSq / magnitude.length);
+ setFftStats([mean, actualMin, actualMax, std]);
+
+ // Store histogram data (copy of magnitude for histogram component)
+ setFftHistogramData(magnitude.slice());
+
+ let offscreen = fftOffscreenRef.current;
+ if (!offscreen) {
+ offscreen = document.createElement("canvas");
+ fftOffscreenRef.current = offscreen;
+ }
+ const sizeChanged = offscreen.width !== width || offscreen.height !== height;
+ if (sizeChanged) {
+ 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 rgba = imgData.data;
+
+ // Apply histogram slider range on top of percentile clipping
+ const dataRange = displayMax - displayMin;
+ const vmin = displayMin + (fftVminPct / 100) * dataRange;
+ const vmax = displayMin + (fftVmaxPct / 100) * dataRange;
+ const range = vmax > vmin ? vmax - vmin : 1;
+
+ for (let i = 0; i < magnitude.length; i++) {
+ const v = Math.round(((magnitude[i] - vmin) / range) * 255);
+ const j = i * 4;
+ const lutIdx = Math.max(0, Math.min(255, v)) * 3;
+ rgba[j] = lut[lutIdx];
+ rgba[j + 1] = lut[lutIdx + 1];
+ rgba[j + 2] = lut[lutIdx + 2];
+ rgba[j + 3] = 255;
+ }
+ offCtx.putImageData(imgData, 0, 0);
+
+ ctx.imageSmoothingEnabled = false;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.save();
+ ctx.translate(fftPanX, fftPanY);
+ ctx.scale(fftZoom, fftZoom);
+ ctx.drawImage(offscreen, 0, 0);
+ ctx.restore();
+ };
+
+ // Try WebGPU first, fall back to CPU
+ if (gpuFFTRef.current && gpuReady) {
+ // WebGPU path (async)
+ let isCancelled = false;
+ const runGpuFFT = async () => {
+ const real = sourceData.slice();
+ const imag = new Float32Array(real.length);
+
+ const { real: fReal, imag: fImag } = await gpuFFTRef.current!.fft2D(real, imag, width, height, false);
+ if (isCancelled) return;
+
+ // Shift in CPU (TODO: move to GPU shader)
+ fftshift(fReal, width, height);
+ fftshift(fImag, width, height);
+
+ renderMagnitude(fReal, fImag);
+ };
+ runGpuFFT();
+ return () => { isCancelled = true; };
+ } else {
+ // CPU fallback (sync)
+ 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, width, height, false);
+ fftshift(real, width, height);
+ fftshift(imag, width, height);
+ renderMagnitude(real, imag);
+ }
+ }, [virtualImageBytes, shapeX, shapeY, fftColormap, fftZoom, fftPanX, fftPanY, gpuReady, showFft, fftScaleMode, fftAuto, fftVminPct, fftVmaxPct]);
+
+ // Render FFT overlay with high-pass filter circle
+ 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);
+ }, [fftZoom, fftPanX, fftPanY, showFft]);
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // High-DPI Scale Bar UI Overlays
+ // ─────────────────────────────────────────────────────────────────────────
+
+ // DP scale bar + crosshair + ROI overlay (high-DPI)
+ React.useEffect(() => {
+ if (!dpUiRef.current) return;
+ // Draw scale bar first (clears canvas)
+ const kUnit = kCalibrated ? "mrad" : "px";
+ drawScaleBarHiDPI(dpUiRef.current, DPR, dpZoom, kPixelSize || 1, kUnit, detY, detX);
+ // Draw ROI overlay (circle, square, rect, annular) or point crosshair
+ if (roiMode === "point") {
+ drawDpCrosshairHiDPI(dpUiRef.current, DPR, localKx, localKy, dpZoom, dpPanX, dpPanY, detY, detX, isDraggingDP);
+ } else {
+ drawRoiOverlayHiDPI(
+ dpUiRef.current, DPR, roiMode,
+ localKx, localKy, roiRadius, roiRadiusInner, roiWidth, roiHeight,
+ dpZoom, dpPanX, dpPanY, detY, detX,
+ isDraggingDP, isDraggingResize, isDraggingResizeInner, isHoveringResize, isHoveringResizeInner
+ );
+ }
+ }, [dpZoom, dpPanX, dpPanY, kPixelSize, kCalibrated, detX, detY, roiMode, roiRadius, roiRadiusInner, roiWidth, roiHeight, localKx, localKy, isDraggingDP, isDraggingResize, isDraggingResizeInner, isHoveringResize, isHoveringResizeInner]);
+
+ // VI scale bar + crosshair + ROI (high-DPI)
+ React.useEffect(() => {
+ if (!viUiRef.current) return;
+ // Draw scale bar first (clears canvas)
+ drawScaleBarHiDPI(viUiRef.current, DPR, viZoom, pixelSize || 1, "Å", shapeY, shapeX);
+ // Draw crosshair only when ROI is off (ROI replaces the crosshair)
+ if (!viRoiMode || viRoiMode === "off") {
+ drawViPositionMarker(viUiRef.current, DPR, localPosX, localPosY, viZoom, viPanX, viPanY, shapeY, shapeX, isDraggingVI);
+ } else {
+ // Draw VI ROI instead of crosshair
+ drawViRoiOverlayHiDPI(
+ viUiRef.current, DPR, viRoiMode,
+ localViRoiCenterX, localViRoiCenterY, viRoiRadius || 5, viRoiWidth || 10, viRoiHeight || 10,
+ viZoom, viPanX, viPanY, shapeY, shapeX,
+ isDraggingViRoi, isDraggingViRoiResize, isHoveringViRoiResize
+ );
+ }
+ }, [viZoom, viPanX, viPanY, pixelSize, shapeX, shapeY, localPosX, localPosY, isDraggingVI,
+ viRoiMode, localViRoiCenterX, localViRoiCenterY, viRoiRadius, viRoiWidth, viRoiHeight,
+ isDraggingViRoi, isDraggingViRoiResize, isHoveringViRoiResize]);
+
+ // 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: 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 = roiCenterX + roiWidth / 2;
+ const handleY = roiCenterY + roiHeight / 2;
+ const dist = Math.sqrt((imgX - handleX) ** 2 + (imgY - handleY) ** 2);
+ return dist < RESIZE_HIT_AREA_PX / dpZoom;
+ }
+ if ((roiMode !== "circle" && roiMode !== "square" && roiMode !== "annular") || !roiRadius) return false;
+ const offset = roiMode === "square" ? roiRadius : roiRadius * CIRCLE_HANDLE_ANGLE;
+ const handleX = roiCenterX + offset;
+ const handleY = roiCenterY + offset;
+ const dist = Math.sqrt((imgX - handleX) ** 2 + (imgY - handleY) ** 2);
+ return dist < RESIZE_HIT_AREA_PX / dpZoom;
+ };
+
+ // 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 = roiCenterX + offset;
+ const handleY = roiCenterY + offset;
+ const dist = Math.sqrt((imgX - handleX) ** 2 + (imgY - handleY) ** 2);
+ return dist < RESIZE_HIT_AREA_PX / dpZoom;
+ };
+
+ // 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 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 = localViRoiCenterX + halfH;
+ const handleY = localViRoiCenterY + halfW;
+ const dist = Math.sqrt((imgX - handleX) ** 2 + (imgY - handleY) ** 2);
+ const cornerDist = Math.sqrt(halfW ** 2 + halfH ** 2);
+ const hitArea = Math.min(RESIZE_HIT_AREA_PX / viZoom, 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 = localViRoiCenterX + offset;
+ const handleY = localViRoiCenterY + 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(RESIZE_HIT_AREA_PX / viZoom, radius * 0.5);
+ return dist < hitArea;
+ }
+ return false;
+ };
+
+ // Mouse handlers
+ const handleDpMouseDown = (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;
+
+ // Check if clicking on resize handle (inner first, then outer)
+ if (isNearResizeHandleInner(imgX, imgY)) {
+ setIsDraggingResizeInner(true);
+ return;
+ }
+ if (isNearResizeHandle(imgX, imgY)) {
+ setIsDraggingResize(true);
+ return;
+ }
+
+ setIsDraggingDP(true);
+ setLocalKx(imgX); setLocalKy(imgY);
+ // Use compound roi_center trait - single observer fires in Python
+ const newX = Math.round(Math.max(0, Math.min(detY - 1, imgX)));
+ const newY = Math.round(Math.max(0, Math.min(detX - 1, imgY)));
+ model.set("roi_active", true);
+ model.set("roi_center", [newX, newY]);
+ 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;
+
+ // Handle inner resize dragging (annular mode)
+ if (isDraggingResizeInner) {
+ const dx = Math.abs(imgX - roiCenterX);
+ const dy = Math.abs(imgY - roiCenterY);
+ 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 - roiCenterX);
+ const dy = Math.abs(imgY - roiCenterY);
+ if (roiMode === "rect") {
+ // For rectangle, update width and height independently
+ setRoiWidth(Math.max(2, Math.round(dx * 2)));
+ setRoiHeight(Math.max(2, Math.round(dy * 2)));
+ } 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;
+ }
+
+ setLocalKx(imgX); setLocalKy(imgY);
+ // Use compound roi_center trait - single observer fires in Python
+ const newX = Math.round(Math.max(0, Math.min(detY - 1, imgX)));
+ const newY = Math.round(Math.max(0, Math.min(detX - 1, imgY)));
+ model.set("roi_center", [newX, newY]);
+ model.save_changes();
+ };
+
+ const handleDpMouseUp = () => {
+ setIsDraggingDP(false); setIsDraggingResize(false); setIsDraggingResizeInner(false);
+ };
+ const handleDpMouseLeave = () => {
+ setIsDraggingDP(false); setIsDraggingResize(false); setIsDraggingResizeInner(false);
+ setIsHoveringResize(false); setIsHoveringResizeInner(false);
+ };
+ 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;
+
+ // 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;
+ }
+
+ // Otherwise, move ROI center to click position (same as DP)
+ setIsDraggingViRoi(true);
+ setLocalViRoiCenterX(imgX);
+ setLocalViRoiCenterY(imgY);
+ setViRoiCenterX(Math.round(Math.max(0, Math.min(shapeX - 1, imgX))));
+ setViRoiCenterY(Math.round(Math.max(0, Math.min(shapeY - 1, imgY))));
+ return;
+ }
+
+ // Regular position selection (when ROI is off)
+ setIsDraggingVI(true);
+ setLocalPosX(imgX); setLocalPosY(imgY);
+ // Batch X and Y updates into a single sync
+ const newX = Math.round(Math.max(0, Math.min(shapeX - 1, imgX)));
+ const newY = Math.round(Math.max(0, Math.min(shapeY - 1, imgY)));
+ model.set("pos_x", newX);
+ model.set("pos_y", 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;
+
+ // Handle VI ROI resize dragging (same pattern as DP)
+ if (isDraggingViRoiResize) {
+ const dx = Math.abs(imgX - localViRoiCenterX);
+ const dy = Math.abs(imgY - localViRoiCenterY);
+ 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)
+ if (isDraggingViRoi) {
+ setLocalViRoiCenterX(imgX);
+ setLocalViRoiCenterY(imgY);
+ // Batch VI ROI center updates
+ const newViX = Math.round(Math.max(0, Math.min(shapeX - 1, imgX)));
+ const newViY = Math.round(Math.max(0, Math.min(shapeY - 1, imgY)));
+ model.set("vi_roi_center_x", newViX);
+ model.set("vi_roi_center_y", newViY);
+ model.save_changes();
+ return;
+ }
+
+ // Handle regular position dragging (when ROI is off)
+ if (!isDraggingVI) return;
+ setLocalPosX(imgX); setLocalPosY(imgY);
+ // Batch position updates into a single sync
+ const newX = Math.round(Math.max(0, Math.min(shapeX - 1, imgX)));
+ const newY = Math.round(Math.max(0, Math.min(shapeY - 1, imgY)));
+ model.set("pos_x", newX);
+ model.set("pos_y", newY);
+ model.save_changes();
+ };
+
+ const handleViMouseUp = () => {
+ setIsDraggingVI(false);
+ setIsDraggingViRoi(false);
+ setIsDraggingViRoiResize(false);
+ };
+ const handleViMouseLeave = () => {
+ setIsDraggingVI(false);
+ setIsDraggingViRoi(false);
+ setIsDraggingViRoiResize(false);
+ setIsHoveringViRoiResize(false);
+ };
+ const handleViDoubleClick = () => { setViZoom(1); setViPanX(0); setViPanY(0); };
+ const handleFftDoubleClick = () => { setFftZoom(1); setFftPanX(0); setFftPanY(0); };
+
+ // FFT drag-to-pan handlers
+ const handleFftMouseDown = (e: React.MouseEvent) => {
+ 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 = () => { setIsDraggingFFT(false); setFftDragStart(null); };
+ const handleFftMouseLeave = () => { setIsDraggingFFT(false); setFftDragStart(null); };
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Render
+ // ─────────────────────────────────────────────────────────────────────────
+
+ // Export DP handler
+ const handleExportDP = async () => {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+ const zip = new JSZip();
+ const metadata = {
+ exported_at: new Date().toISOString(),
+ type: "diffraction_pattern",
+ scan_position: { x: posX, y: posY },
+ scan_shape: { x: shapeX, y: shapeY },
+ detector_shape: { x: detX, y: detY },
+ roi: { mode: roiMode, center_x: roiCenterX, center_y: roiCenterY, radius_outer: roiRadius, radius_inner: roiRadiusInner },
+ display: { colormap: dpColormap, vmin_pct: dpVminPct, vmax_pct: dpVmaxPct, scale_mode: dpScaleMode },
+ calibration: { bf_radius: bfRadius, center_x: centerX, center_y: centerY, k_pixel_size: kPixelSize, k_calibrated: kCalibrated },
+ };
+ 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" });
+ const link = document.createElement('a');
+ link.download = `dp_export_${timestamp}.zip`;
+ link.href = URL.createObjectURL(zipBlob);
+ link.click();
+ URL.revokeObjectURL(link.href);
+ };
+
+ // Export VI handler
+ const handleExportVI = async () => {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+ const zip = new JSZip();
+ const metadata = {
+ exported_at: new Date().toISOString(),
+ scan_position: { x: posX, y: posY },
+ scan_shape: { x: shapeX, y: shapeY },
+ detector_shape: { x: detX, y: detY },
+ roi: { mode: roiMode, center_x: roiCenterX, center_y: roiCenterY, radius_outer: roiRadius, radius_inner: roiRadiusInner },
+ display: { dp_colormap: dpColormap, vi_colormap: viColormap, dp_scale_mode: dpScaleMode, vi_scale_mode: viScaleMode },
+ calibration: { bf_radius: bfRadius, center_x: centerX, center_y: centerY, pixel_size: pixelSize, k_pixel_size: kPixelSize },
+ };
+ 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" });
+ const link = document.createElement('a');
+ link.download = `4dstem_export_${timestamp}.zip`;
+ link.href = URL.createObjectURL(zipBlob);
+ link.click();
+ URL.revokeObjectURL(link.href);
+ };
+
+ // Common styles for panel control groups (fills parent width = canvas width)
+ const panelControlStyle = {
+ display: "flex",
+ alignItems: "center",
+ gap: `${SPACING.SM}px`,
+ width: "100%",
+ boxSizing: "border-box",
+ };
+
+ // 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 },
+ };
+
+ return (
+
+ {/* HEADER */}
+
+ 4D-STEM Explorer
+
+
+ {/* MAIN CONTENT: DP | VI | FFT (three columns when FFT shown) */}
+
+ {/* LEFT COLUMN: DP Panel */}
+
+ {/* DP Header */}
+
+
+ DP at ({Math.round(localPosX)}, {Math.round(localPosY)})
+ k: ({Math.round(localKx)}, {Math.round(localKy)})
+
+
+
+
+
+
+
+
+ {/* DP Canvas */}
+
+
+
+
+
+
+ {/* DP Stats Bar */}
+ {dpStats && dpStats.length === 4 && (
+
+ Mean {formatStat(dpStats[0])}
+ Min {formatStat(dpStats[1])}
+ Max {formatStat(dpStats[2])}
+ Std {formatStat(dpStats[3])}
+
+ )}
+
+ {/* DP Controls - two rows with histogram on right */}
+
+ {/* 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 {
+ setRoiRadius(v as number);
+ }
+ }}
+ min={1}
+ max={Math.min(detX, detY) / 2}
+ size="small"
+ sx={{
+ width: roiMode === "annular" ? 100 : 70,
+ mx: 1,
+ "& .MuiSlider-thumb": { width: 14, height: 14 }
+ }}
+ />
+
+ {roiMode === "annular" ? `${Math.round(roiRadiusInner)}-${Math.round(roiRadius)}px` : `${Math.round(roiRadius)}px`}
+
+ >
+ )}
+
+ {/* Row 2: Presets + Color + Scale */}
+
+ { setRoiMode("circle"); setRoiRadius(bfRadius || 10); setRoiCenterX(centerX); setRoiCenterY(centerY); }} sx={{ color: "#4f4", fontSize: 11, fontWeight: "bold", cursor: "pointer", "&:hover": { textDecoration: "underline" } }}>BF
+ { setRoiMode("annular"); setRoiRadiusInner((bfRadius || 10) * 0.5); setRoiRadius(bfRadius || 10); setRoiCenterX(centerX); setRoiCenterY(centerY); }} sx={{ color: "#4af", fontSize: 11, fontWeight: "bold", cursor: "pointer", "&:hover": { textDecoration: "underline" } }}>ABF
+ { setRoiMode("annular"); setRoiRadiusInner(bfRadius || 10); setRoiRadius(Math.min((bfRadius || 10) * 3, Math.min(detX, detY) / 2 - 2)); setRoiCenterX(centerX); setRoiCenterY(centerY); }} sx={{ color: "#fa4", fontSize: 11, fontWeight: "bold", cursor: "pointer", "&:hover": { textDecoration: "underline" } }}>ADF
+ Color:
+
+ Scale:
+
+
+
+ {/* 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 */}
+
+ Image
+
+
+ {shapeX}×{shapeY} | {detX}×{detY}
+
+ FFT:
+ setShowFft(e.target.checked)} size="small" sx={switchStyles.small} />
+
+
+
+
+
+ {/* VI Canvas */}
+
+
+
+
+
+
+ {/* VI Stats Bar */}
+ {viStats && viStats.length === 4 && (
+
+ Mean {formatStat(viStats[0])}
+ Min {formatStat(viStats[1])}
+ Max {formatStat(viStats[2])}
+ Std {formatStat(viStats[3])}
+
+ )}
+
+ {/* VI Controls - Two rows with histogram on right */}
+
+ {/* 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(shapeX, shapeY) / 2}
+ size="small"
+ sx={{ width: 80, mx: 1 }}
+ />
+
+ {Math.round(viRoiRadius || 5)}px
+
+ >
+ )}
+ {summedDpCount > 0 && (
+
+ {summedDpCount} pos
+
+ )}
+ >
+ )}
+
+ {/* 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) */}
+ {showFft && (
+
+ {/* FFT Header */}
+
+ 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 Controls - Two rows with histogram on right */}
+
+ {/* Left: Two rows of controls */}
+
+ {/* Row 1: Scale + Clip */}
+
+ Scale:
+
+ Auto:
+ setFftAuto(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 - Path only (FFT toggle moved to VI panel) */}
+ {pathLength > 0 && (
+
+
+ Path:
+ { setPathPlaying(false); setPathIndex(0); }} sx={{ color: themeColors.textMuted, fontSize: 14, cursor: "pointer", "&:hover": { color: "#fff" }, px: 0.5 }} title="Stop">⏹
+ setPathPlaying(!pathPlaying)} sx={{ color: pathPlaying ? "#0f0" : "#888", fontSize: 14, cursor: "pointer", "&:hover": { color: "#fff" }, px: 0.5 }} title={pathPlaying ? "Pause" : "Play"}>{pathPlaying ? "⏸" : "▶"}
+ {pathIndex + 1}/{pathLength}
+ { setPathPlaying(false); setPathIndex(v as number); }} min={0} max={Math.max(0, pathLength - 1)} size="small" sx={{ width: 100 }} />
+
+
+ )}
+
+ );
+}
+
+export const render = createRender(Show4DSTEM);
diff --git a/widget/js/show4dstem/styles.css b/widget/js/show4dstem/styles.css
new file mode 100644
index 00000000..61876cde
--- /dev/null
+++ b/widget/js/show4dstem/styles.css
@@ -0,0 +1,5 @@
+/* Theme-aware styles - minimal, let JS handle most theming */
+.show4dstem-root {
+ border-radius: 2px;
+ padding: 16px;
+}
diff --git a/widget/package-lock.json b/widget/package-lock.json
index 4e039394..32128b87 100644
--- a/widget/package-lock.json
+++ b/widget/package-lock.json
@@ -6,20 +6,28 @@
"": {
"name": "quantem-widget-frontend",
"dependencies": {
- "@anywidget/react": "^0.1.0",
+ "@anywidget/react": "^0.2.0",
+ "@emotion/react": "^11.14.0",
+ "@emotion/styled": "^11.14.1",
+ "@mui/material": "^6.4.0",
+ "jszip": "^3.10.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@anywidget/vite": "^0.2.0",
+ "@types/react": "^18.2.0",
+ "@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.3.0",
+ "@webgpu/types": "^0.1.68",
+ "typescript": "^5.8.3",
"vite": "^5.2.0"
}
},
"node_modules/@anywidget/react": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/@anywidget/react/-/react-0.1.0.tgz",
- "integrity": "sha512-Hh6wbMGXsgTz2xz1I9/h40M3b6uDWLjmh3sJ4tNjfppDl5y9Sw1jyuQGhNzwViOtszmyjYEJZ4kWHdPFUXUanw==",
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/@anywidget/react/-/react-0.2.0.tgz",
+ "integrity": "sha512-7jmyfEeKDzMAOmdvzQ/KNtct72lqR1j6KYtb3RtrnHoDMMO5aymqbXib5bvDdnZQOiqlTU+B+cKxtBpYc0W4hg==",
"dependencies": {
"@anywidget/types": "^0.2.0"
},
@@ -49,7 +57,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.27.1",
@@ -106,7 +113,6 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
"integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
@@ -140,7 +146,6 @@
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -150,7 +155,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.27.1",
@@ -192,7 +196,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -202,7 +205,6 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -236,7 +238,6 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.5"
@@ -280,11 +281,19 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
+ "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
@@ -299,7 +308,6 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
"integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
@@ -318,7 +326,6 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -328,6 +335,160 @@
"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/babel-plugin/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/@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",
+ "peer": true,
+ "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",
+ "peer": true,
+ "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",
@@ -723,7 +884,6 @@
"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==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -745,7 +905,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -755,20 +914,232 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
- "dev": true,
"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==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@mui/core-downloads-tracker": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.5.0.tgz",
+ "integrity": "sha512-LGb8t8i6M2ZtS3Drn3GbTI1DVhDY6FJ9crEey2lZ0aN2EMZo8IZBZj9wRf4vqbZHaWjsYgtbOnJw5V8UWbmK2Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ }
+ },
+ "node_modules/@mui/material": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.5.0.tgz",
+ "integrity": "sha512-yjvtXoFcrPLGtgKRxFaH6OQPtcLPhkloC0BML6rBG5UeldR0nPULR/2E2BfXdo5JNV7j7lOzrrLX2Qf/iSidow==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.26.0",
+ "@mui/core-downloads-tracker": "^6.5.0",
+ "@mui/system": "^6.5.0",
+ "@mui/types": "~7.2.24",
+ "@mui/utils": "^6.4.9",
+ "@popperjs/core": "^2.11.8",
+ "@types/react-transition-group": "^4.4.12",
+ "clsx": "^2.1.1",
+ "csstype": "^3.1.3",
+ "prop-types": "^15.8.1",
+ "react-is": "^19.0.0",
+ "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": "^6.5.0",
+ "@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": "6.4.9",
+ "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.9.tgz",
+ "integrity": "sha512-LktcVmI5X17/Q5SkwjCcdOLBzt1hXuc14jYa7NPShog0GBDCDvKtcnP0V7a2s6EiVRlv7BzbWEJzH6+l/zaCxw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.26.0",
+ "@mui/utils": "^6.4.9",
+ "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": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.5.0.tgz",
+ "integrity": "sha512-8woC2zAqF4qUDSPIBZ8v3sakj+WgweolpyM/FXf8jAx6FMls+IE4Y8VDZc+zS805J7PRz31vz73n2SovKGaYgw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.26.0",
+ "@emotion/cache": "^11.13.5",
+ "@emotion/serialize": "^1.3.3",
+ "@emotion/sheet": "^1.4.0",
+ "csstype": "^3.1.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": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.5.0.tgz",
+ "integrity": "sha512-XcbBYxDS+h/lgsoGe78ExXFZXtuIlSBpn/KsZq8PtZcIkUNJInkuDqcLd2rVBQrDC1u+rvVovdaWPf2FHKJf3w==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.26.0",
+ "@mui/private-theming": "^6.4.9",
+ "@mui/styled-engine": "^6.5.0",
+ "@mui/types": "~7.2.24",
+ "@mui/utils": "^6.4.9",
+ "clsx": "^2.1.1",
+ "csstype": "^3.1.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.2.24",
+ "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz",
+ "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@mui/utils": {
+ "version": "6.4.9",
+ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz",
+ "integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.26.0",
+ "@mui/types": "~7.2.24",
+ "@types/prop-types": "^15.7.14",
+ "clsx": "^2.1.1",
+ "prop-types": "^15.8.1",
+ "react-is": "^19.0.0"
+ },
+ "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/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1178,24 +1549,46 @@
"dev": true,
"license": "MIT"
},
+ "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.8",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
- "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
+ "version": "18.3.27",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
+ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"license": "MIT",
"peer": true,
"dependencies": {
+ "@types/prop-types": "*",
"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==",
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
- "@types/react": "^19.2.0"
+ "@types/react": "^18.0.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/@vitejs/plugin-react": {
@@ -1219,6 +1612,28 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@webgpu/types": {
+ "version": "0.1.68",
+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.68.tgz",
+ "integrity": "sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA==",
+ "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/baseline-browser-mapping": {
"version": "2.9.14",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz",
@@ -1264,6 +1679,15 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "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/caniuse-lite": {
"version": "1.0.30001764",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz",
@@ -1285,6 +1709,15 @@
],
"license": "CC-BY-4.0"
},
+ "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": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1292,6 +1725,28 @@
"dev": true,
"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",
@@ -1302,7 +1757,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -1316,6 +1770,16 @@
}
}
},
+ "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/electron-to-chromium": {
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
@@ -1323,6 +1787,15 @@
"dev": true,
"license": "ISC"
},
+ "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/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1372,6 +1845,24 @@
"node": ">=6"
}
},
+ "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/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1387,6 +1878,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "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/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -1397,6 +1897,88 @@
"node": ">=6.9.0"
}
},
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "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",
@@ -1407,7 +1989,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
- "dev": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
@@ -1416,6 +1997,12 @@
"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/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -1429,6 +2016,33 @@
"node": ">=6"
}
},
+ "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",
@@ -1455,7 +2069,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -1484,11 +2097,70 @@
"dev": true,
"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==",
- "dev": true,
"license": "ISC"
},
"node_modules/postcss": {
@@ -1520,6 +2192,29 @@
"node": "^10 || ^12 || >=14"
}
},
+ "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": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1547,6 +2242,12 @@
"react": "^18.3.1"
}
},
+ "node_modules/react-is": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
+ "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==",
+ "license": "MIT"
+ },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -1557,6 +2258,66 @@
"node": ">=0.10.0"
}
},
+ "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.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "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/rollup": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
@@ -1602,6 +2363,12 @@
"fsevents": "~2.3.2"
}
},
+ "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.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -1621,6 +2388,21 @@
"semver": "bin/semver.js"
}
},
+ "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/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1631,6 +2413,47 @@
"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/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -1662,6 +2485,12 @@
"browserslist": ">= 4.21.0"
}
},
+ "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/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -1729,6 +2558,15 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
+ },
+ "node_modules/yaml": {
+ "version": "1.10.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+ "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 6"
+ }
}
}
}
diff --git a/widget/package.json b/widget/package.json
index 4abc343d..dbe065f2 100644
--- a/widget/package.json
+++ b/widget/package.json
@@ -1,18 +1,23 @@
{
- "name": "quantem-widget-frontend",
- "type": "module",
"scripts": {
- "dev": "vite build --watch",
- "build": "vite build"
+ "dev": "npm run build -- --sourcemap=inline --watch",
+ "build": "esbuild js/show4dstem/index.tsx --minify --format=esm --bundle --outdir=src/quantem/widget/static --outbase=js --entry-names=[dir]",
+ "typecheck": "tsc --noEmit"
},
"dependencies": {
- "react": "^18.2.0",
- "react-dom": "^18.2.0",
- "@anywidget/react": "^0.1.0"
+ "@anywidget/react": "^0.2.0",
+ "@emotion/react": "^11.14.0",
+ "@emotion/styled": "^11.14.1",
+ "@mui/material": "^7.3.6",
+ "jszip": "^3.10.1",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0"
},
"devDependencies": {
- "vite": "^5.2.0",
- "@anywidget/vite": "^0.2.0",
- "@vitejs/plugin-react": "^4.3.0"
+ "@types/react": "^19.1.3",
+ "@types/react-dom": "^19.1.4",
+ "@webgpu/types": "^0.1.68",
+ "esbuild": "^0.25.4",
+ "typescript": "^5.8.3"
}
}
diff --git a/widget/pyproject.toml b/widget/pyproject.toml
index c0fb7a63..570d994f 100644
--- a/widget/pyproject.toml
+++ b/widget/pyproject.toml
@@ -10,6 +10,8 @@ license = "MIT"
requires-python = ">=3.11"
dependencies = [
"anywidget>=0.9.0",
+ "numpy>=2.0.0",
+ "traitlets>=5.0.0",
]
[tool.hatch.build.targets.wheel]
diff --git a/widget/src/quantem/__init__.py b/widget/src/quantem/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/widget/src/quantem/widget/__init__.py b/widget/src/quantem/widget/__init__.py
index d4ca85a7..4d75aa24 100644
--- a/widget/src/quantem/widget/__init__.py
+++ b/widget/src/quantem/widget/__init__.py
@@ -1,24 +1,14 @@
-from importlib.metadata import version
-import pathlib
-import anywidget
-import traitlets
+"""
+quantem.widget: Interactive Jupyter widgets using anywidget + React.
+"""
-__version__ = version("quantem.widget")
+import importlib.metadata
-_static = pathlib.Path(__file__).parent / "static"
+try:
+ __version__ = importlib.metadata.version("quantem-widget")
+except importlib.metadata.PackageNotFoundError:
+ __version__ = "unknown"
+from quantem.widget.show4dstem import Show4DSTEM
-class CounterWidget(anywidget.AnyWidget):
- _esm = _static / "index.js"
-
- count = traitlets.Int(0).tag(sync=True)
-
-
-def show4dstem():
- # TODO: Implement 4D-STEM visualization widget
- print("show4dstem: not yet implemented")
-
-
-def counter():
- """Create a minimal counter widget for testing."""
- return CounterWidget()
+__all__ = ["Show4DSTEM"]
diff --git a/widget/src/quantem/widget/array_utils.py b/widget/src/quantem/widget/array_utils.py
new file mode 100644
index 00000000..5287ca0a
--- /dev/null
+++ b/widget/src/quantem/widget/array_utils.py
@@ -0,0 +1,109 @@
+"""
+Array utilities for handling NumPy, CuPy, and PyTorch arrays uniformly.
+
+This module provides utilities to convert arrays from different backends
+into NumPy arrays for widget processing.
+"""
+
+from typing import Any, Literal
+import numpy as np
+
+
+ArrayBackend = Literal["numpy", "cupy", "torch", "unknown"]
+
+
+def get_array_backend(data: Any) -> ArrayBackend:
+ """
+ Detect the array backend of the input data.
+
+ Parameters
+ ----------
+ data : array-like
+ Input array (NumPy, CuPy, PyTorch, or other).
+
+ Returns
+ -------
+ str
+ One of: "numpy", "cupy", "torch", "unknown"
+ """
+ # Check PyTorch first (has both .numpy and .detach methods)
+ if hasattr(data, "detach") and hasattr(data, "numpy"):
+ return "torch"
+ # Check CuPy (has .get() or __cuda_array_interface__)
+ if hasattr(data, "__cuda_array_interface__"):
+ return "cupy"
+ if hasattr(data, "get") and hasattr(data, "__array__"):
+ # CuPy arrays have .get() to transfer to CPU
+ type_name = type(data).__module__
+ if "cupy" in type_name:
+ return "cupy"
+ # Check NumPy
+ if isinstance(data, np.ndarray):
+ return "numpy"
+ return "unknown"
+
+
+def to_numpy(data: Any, dtype: np.dtype | None = None) -> np.ndarray:
+ """
+ Convert any array-like (NumPy, CuPy, PyTorch) to a NumPy array.
+
+ Parameters
+ ----------
+ data : array-like
+ Input array from any supported backend.
+ dtype : np.dtype, optional
+ Target dtype for the output array. If None, preserves original dtype.
+
+ Returns
+ -------
+ np.ndarray
+ NumPy array with the same data.
+
+ Examples
+ --------
+ >>> import numpy as np
+ >>> from quantem.widget.array_utils import to_numpy
+ >>>
+ >>> # NumPy passthrough
+ >>> arr = np.random.rand(10, 10)
+ >>> result = to_numpy(arr)
+ >>>
+ >>> # CuPy conversion (if available)
+ >>> import cupy as cp
+ >>> gpu_arr = cp.random.rand(10, 10)
+ >>> cpu_arr = to_numpy(gpu_arr)
+ >>>
+ >>> # PyTorch conversion (if available)
+ >>> import torch
+ >>> tensor = torch.rand(10, 10)
+ >>> arr = to_numpy(tensor)
+ """
+ backend = get_array_backend(data)
+
+ if backend == "torch":
+ # PyTorch tensor: detach from graph, move to CPU, convert to numpy
+ result = data.detach().cpu().numpy()
+
+ elif backend == "cupy":
+ # CuPy array: use .get() to transfer to CPU
+ if hasattr(data, "get"):
+ result = data.get()
+ else:
+ # Fallback for __cuda_array_interface__
+ import cupy as cp
+
+ result = cp.asnumpy(data)
+
+ elif backend == "numpy":
+ # NumPy array: passthrough (may copy if dtype changes)
+ result = data
+
+ else:
+ # Unknown backend: try np.asarray as fallback
+ result = np.asarray(data)
+
+ # Apply dtype conversion if specified
+ if dtype is not None:
+ result = np.asarray(result, dtype=dtype)
+
+ return result
diff --git a/widget/src/quantem/widget/show4dstem.py b/widget/src/quantem/widget/show4dstem.py
new file mode 100644
index 00000000..0c0c843f
--- /dev/null
+++ b/widget/src/quantem/widget/show4dstem.py
@@ -0,0 +1,955 @@
+"""
+show4d: Fast interactive 4D-STEM viewer widget with advanced features.
+
+Features:
+- Binary transfer (no base64 overhead)
+- ROI drawing tools
+- Path animation (raster scan, custom paths)
+"""
+
+import pathlib
+
+import anywidget
+import numpy as np
+import torch
+import traitlets
+
+from quantem.core.config import validate_device
+from quantem.widget.array_utils import to_numpy
+
+
+# ============================================================================
+# Constants
+# ============================================================================
+DEFAULT_BF_RATIO = 0.125 # BF disk radius as fraction of detector size (1/8)
+SPARSE_MASK_THRESHOLD = 0.2 # Use sparse indexing below this mask coverage
+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) or 4D array
+ of shape (scan_x, scan_y, det_x, det_y).
+ scan_shape : tuple, optional
+ If data is flattened (N, det_x, det_y), provide scan dimensions.
+ pixel_size : float, optional
+ Pixel size in Å (real-space). Used for scale bar.
+ Auto-extracted from Dataset4dstem if not provided.
+ k_pixel_size : float, optional
+ Detector pixel size in mrad (k-space). Used for scale bar.
+ Auto-extracted from Dataset4dstem if not provided.
+ center : tuple[float, float], optional
+ (center_x, center_y) 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.
+ log_scale : bool, default False
+ Use log scale for better dynamic range visualization.
+
+ Examples
+ --------
+ >>> # From Dataset4dstem (calibration auto-extracted)
+ >>> from quantem.core.io.file_readers import read_emdfile_to_4dstem
+ >>> dataset = read_emdfile_to_4dstem("data.h5")
+ >>> Show4DSTEM(dataset)
+
+ >>> # From raw array with manual calibration
+ >>> import numpy as np
+ >>> data = np.random.rand(64, 64, 128, 128)
+ >>> Show4DSTEM(data, pixel_size=2.39, k_pixel_size=0.46)
+
+ >>> # With raster animation
+ >>> widget = Show4DSTEM(dataset)
+ >>> widget.raster(step=2, interval_ms=50)
+ """
+
+ _esm = pathlib.Path(__file__).parent / "static" / "show4dstem.js"
+ _css = pathlib.Path(__file__).parent / "static" / "show4dstem.css"
+
+ # Position in scan space
+ pos_x = traitlets.Int(0).tag(sync=True)
+ pos_y = traitlets.Int(0).tag(sync=True)
+
+ # Shape of scan space (for slider bounds)
+ shape_x = traitlets.Int(1).tag(sync=True)
+ shape_y = traitlets.Int(1).tag(sync=True)
+
+ # Detector shape for frontend
+ det_x = traitlets.Int(1).tag(sync=True)
+ det_y = 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_x = traitlets.Float(0.0).tag(sync=True) # Detector center X
+ center_y = traitlets.Float(0.0).tag(sync=True) # Detector center Y
+ 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_x = traitlets.Float(0.0).tag(sync=True)
+ roi_center_y = traitlets.Float(0.0).tag(sync=True)
+ # Compound trait for batched X+Y 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
+ vi_data_min = traitlets.Float(0.0).tag(sync=True) # Min of current VI for normalization
+ vi_data_max = traitlets.Float(1.0).tag(sync=True) # Max of current VI for normalization
+
+ # =========================================================================
+ # VI ROI (real-space region selection for summed DP)
+ # =========================================================================
+ vi_roi_mode = traitlets.Unicode("off").tag(sync=True) # "off", "circle", "rect"
+ vi_roi_center_x = traitlets.Float(0.0).tag(sync=True)
+ vi_roi_center_y = traitlets.Float(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)
+ summed_dp_bytes = traitlets.Bytes(b"").tag(sync=True) # Summed DP from VI ROI
+ summed_dp_count = traitlets.Int(0).tag(sync=True) # Number of positions summed
+
+ # =========================================================================
+ # Scale Bar
+ # =========================================================================
+ pixel_size = traitlets.Float(1.0).tag(sync=True) # Å per pixel (real-space)
+ k_pixel_size = traitlets.Float(1.0).tag(sync=True) # mrad per pixel (k-space)
+ k_calibrated = traitlets.Bool(False).tag(sync=True) # True if k-space has mrad calibration
+
+ # =========================================================================
+ # 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)
+ # =========================================================================
+ auto_detect_trigger = traitlets.Bool(False).tag(sync=True)
+
+ # =========================================================================
+ # Statistics for display (mean, min, max, std)
+ # =========================================================================
+ dp_stats = traitlets.List(traitlets.Float(), default_value=[0.0, 0.0, 0.0, 0.0]).tag(sync=True)
+ vi_stats = traitlets.List(traitlets.Float(), default_value=[0.0, 0.0, 0.0, 0.0]).tag(sync=True)
+ mask_dc = traitlets.Bool(True).tag(sync=True) # Mask center pixel for DP stats
+
+ def __init__(
+ self,
+ data: "Dataset4dstem | np.ndarray",
+ scan_shape: tuple[int, int] | None = None,
+ pixel_size: float | None = None,
+ k_pixel_size: float | None = None,
+ center: tuple[float, float] | None = None,
+ bf_radius: float | None = None,
+ precompute_virtual_images: bool = False,
+ log_scale: bool = False,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+
+ self.log_scale = log_scale
+
+ # Extract calibration from Dataset4dstem if provided
+ k_calibrated = False
+ if hasattr(data, "sampling") and hasattr(data, "array"):
+ # Dataset4dstem: extract calibration and array
+ # sampling = [scan_x, scan_y, det_x, det_y]
+ units = getattr(data, "units", ["pixels"] * 4)
+ if pixel_size is None and units[0] in ("Å", "angstrom", "A", "nm"):
+ pixel_size = float(data.sampling[0])
+ if units[0] == "nm":
+ pixel_size *= 10 # Convert nm to Å
+ if k_pixel_size is None and units[2] in ("mrad", "1/Å", "1/A"):
+ k_pixel_size = float(data.sampling[2])
+ k_calibrated = True
+ data = data.array
+
+ # Store calibration values (default to 1.0 if not provided)
+ self.pixel_size = pixel_size if pixel_size is not None else 1.0
+ self.k_pixel_size = k_pixel_size if k_pixel_size is not None else 1.0
+ self.k_calibrated = k_calibrated or (k_pixel_size is not None)
+ # Path animation (configured via set_path() or raster())
+ self._path_points: list[tuple[int, int]] = []
+ # Convert to NumPy then PyTorch tensor using quantem device config
+ data_np = to_numpy(data)
+ device_str, _ = validate_device(None) # Get device from quantem config
+ self._device = torch.device(device_str)
+ self._data = torch.from_numpy(data_np.astype(np.float32)).to(self._device)
+ # Remove saturated hot pixels (65535 for uint16, 255 for uint8)
+ saturated_value = 65535.0 if data_np.dtype == np.uint16 else 255.0 if data_np.dtype == np.uint8 else None
+ if saturated_value is not None:
+ self._data[self._data >= saturated_value] = 0
+ # Handle flattened data
+ if data.ndim == 3:
+ if scan_shape is not None:
+ self._scan_shape = scan_shape
+ else:
+ # Infer square scan shape from N
+ n = data.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 = (data.shape[1], data.shape[2])
+ elif data.ndim == 4:
+ self._scan_shape = (data.shape[0], data.shape[1])
+ self._det_shape = (data.shape[2], data.shape[3])
+ else:
+ raise ValueError(f"Expected 3D or 4D array, got {data.ndim}D")
+
+ self.shape_x = self._scan_shape[0]
+ self.shape_y = self._scan_shape[1]
+ self.det_x = self._det_shape[0]
+ self.det_y = self._det_shape[1]
+ # Initial position at center
+ self.pos_x = self.shape_x // 2
+ self.pos_y = self.shape_y // 2
+ # Precompute global range for consistent scaling (hot pixels already removed)
+ self.dp_global_min = max(float(self._data.min()), MIN_LOG_VALUE)
+ self.dp_global_max = float(self._data.max())
+ # Cache coordinate tensors for mask creation (avoid repeated torch.arange)
+ self._det_row_coords = torch.arange(self.det_x, device=self._device, dtype=torch.float32)[:, None]
+ self._det_col_coords = torch.arange(self.det_y, device=self._device, dtype=torch.float32)[None, :]
+ self._scan_row_coords = torch.arange(self.shape_x, device=self._device, dtype=torch.float32)[:, None]
+ self._scan_col_coords = torch.arange(self.shape_y, device=self._device, dtype=torch.float32)[None, :]
+ # Setup center and BF radius
+ # If user provides explicit values, use them
+ # Otherwise, auto-detect from the data for accurate presets
+ det_size = min(self.det_x, self.det_y)
+ if center is not None and bf_radius is not None:
+ # User provided both - use explicit values
+ self.center_x = float(center[0])
+ self.center_y = float(center[1])
+ self.bf_radius = float(bf_radius)
+ elif center is not None:
+ # User provided center only - use it with default bf_radius
+ self.center_x = float(center[0])
+ self.center_y = float(center[1])
+ self.bf_radius = det_size * DEFAULT_BF_RATIO
+ elif bf_radius is not None:
+ # User provided bf_radius only - use detector center
+ self.center_x = float(self.det_y / 2)
+ self.center_y = float(self.det_x / 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_x = float(self.det_y / 2)
+ self.center_y = float(self.det_x / 2)
+ self.bf_radius = det_size * DEFAULT_BF_RATIO
+ # Auto-detect center and bf_radius from the data
+ self.auto_detect_center(update_roi=False)
+
+ # 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
+ if precompute_virtual_images:
+ self._precompute_common_virtual_images()
+
+ # Update frame when position changes (scale/colormap handled in JS)
+ self.observe(self._update_frame, names=["pos_x", "pos_y"])
+ # Observe individual ROI params (for backward compatibility)
+ self.observe(self._on_roi_change, names=[
+ "roi_center_x", "roi_center_y", "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"])
+
+ # Initialize default ROI at BF center
+ self.roi_center_x = self.center_x
+ self.roi_center_y = self.center_y
+ self.roi_center = [self.center_x, self.center_y]
+ self.roi_radius = self.bf_radius * 0.5 # Start with half BF radius
+ self.roi_active = True
+
+ # Compute initial virtual image and frame
+ self._compute_virtual_image_from_roi()
+ self._update_frame()
+
+ # Path animation: observe index changes from frontend
+ self.observe(self._on_path_index_change, names=["path_index"])
+
+ # Auto-detect trigger: observe changes from frontend
+ self.observe(self._on_auto_detect_trigger, names=["auto_detect_trigger"])
+
+ # VI ROI: observe changes for summed DP computation
+ # Initialize VI ROI center to scan center with reasonable default sizes
+ self.vi_roi_center_x = float(self.shape_x / 2)
+ self.vi_roi_center_y = float(self.shape_y / 2)
+ # Set initial ROI size based on scan dimension
+ default_roi_size = max(3, min(self.shape_x, self.shape_y) * 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_x", "vi_roi_center_y",
+ "vi_roi_radius", "vi_roi_width", "vi_roi_height"
+ ])
+
+ def __repr__(self) -> str:
+ k_unit = "mrad" if self.k_calibrated else "px"
+ return (
+ f"Show4DSTEM(shape=({self.shape_x}, {self.shape_y}, {self.det_x}, {self.det_y}), "
+ f"sampling=({self.pixel_size} Å, {self.k_pixel_size} {k_unit}), "
+ f"pos=({self.pos_x}, {self.pos_y}))"
+ )
+
+ # =========================================================================
+ # Convenience Properties
+ # =========================================================================
+
+ @property
+ def position(self) -> tuple[int, int]:
+ """Current scan position as (x, y) tuple."""
+ return (self.pos_x, self.pos_y)
+
+ @position.setter
+ def position(self, value: tuple[int, int]) -> None:
+ """Set scan position from (x, y) tuple."""
+ self.pos_x, self.pos_y = value
+
+ @property
+ def scan_shape(self) -> tuple[int, int]:
+ """Scan dimensions as (shape_x, shape_y) tuple."""
+ return (self.shape_x, self.shape_y)
+
+ @property
+ def detector_shape(self) -> tuple[int, int]:
+ """Detector dimensions as (det_x, det_y) tuple."""
+ return (self.det_x, self.det_y)
+
+ # =========================================================================
+ # Path Animation Methods
+ # =========================================================================
+
+ def set_path(
+ self,
+ points: list[tuple[int, int]],
+ interval_ms: int = 100,
+ loop: bool = True,
+ autoplay: bool = True,
+ ) -> "Show4DSTEM":
+ """
+ Set a custom path of scan positions to animate through.
+
+ Parameters
+ ----------
+ points : list[tuple[int, int]]
+ List of (x, y) 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) -> "Show4DSTEM":
+ """Start playing the path animation."""
+ if self.path_length > 0:
+ self.path_playing = True
+ return self
+
+ def pause(self) -> "Show4DSTEM":
+ """Pause the path animation."""
+ self.path_playing = False
+ return self
+
+ def stop(self) -> "Show4DSTEM":
+ """Stop and reset path animation to beginning."""
+ self.path_playing = False
+ self.path_index = 0
+ return self
+
+ def goto(self, index: int) -> "Show4DSTEM":
+ """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):
+ x, y = self._path_points[idx]
+ # Clamp to valid range
+ self.pos_x = max(0, min(self.shape_x - 1, x))
+ self.pos_y = max(0, min(self.shape_y - 1, y))
+
+ def _on_auto_detect_trigger(self, change):
+ """Called when auto_detect_trigger is set to True from frontend."""
+ if change["new"]:
+ self.auto_detect_center()
+ # Reset trigger to allow re-triggering
+ self.auto_detect_trigger = False
+
+ # =========================================================================
+ # Path Animation Patterns
+ # =========================================================================
+
+ def raster(
+ self,
+ step: int = 1,
+ bidirectional: bool = False,
+ interval_ms: int = 100,
+ loop: bool = True,
+ ) -> "Show4DSTEM":
+ """
+ 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 x in range(0, self.shape_x, step):
+ row = list(range(0, self.shape_y, step))
+ if bidirectional and (x // step % 2 == 1):
+ row = row[::-1] # Alternate direction for snake pattern
+ for y in row:
+ points.append((x, y))
+ return self.set_path(points=points, interval_ms=interval_ms, loop=loop)
+
+ # =========================================================================
+ # ROI Mode Methods
+ # =========================================================================
+
+ def roi_circle(self, radius: float | None = None) -> "Show4DSTEM":
+ """
+ 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) -> "Show4DSTEM":
+ """
+ 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) -> "Show4DSTEM":
+ """
+ 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
+ ) -> "Show4DSTEM":
+ """
+ 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
+ ) -> "Show4DSTEM":
+ """
+ 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) -> "Show4DSTEM":
+ """
+ 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_x, center_y,
+ 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 all diffraction patterns to get average (PyTorch)
+ if self._data.ndim == 4:
+ summed_dp = self._data.sum(dim=(0, 1))
+ else:
+ summed_dp = self._data.sum(dim=0)
+
+ # Threshold at mean + std to isolate BF disk
+ threshold = summed_dp.mean() + summed_dp.std()
+ mask = summed_dp > threshold
+
+ # Avoid division by zero
+ total = mask.sum()
+ if total == 0:
+ return self
+
+ # Calculate centroid using cached coordinate grids
+ cx = float((self._det_col_coords * mask).sum() / total)
+ cy = float((self._det_row_coords * mask).sum() / total)
+
+ # Estimate radius from mask area (A = pi*r^2)
+ radius = float(torch.sqrt(total / torch.pi))
+
+ # Apply detected values
+ self.center_x = cx
+ self.center_y = cy
+ self.bf_radius = radius
+
+ if update_roi:
+ # Also update ROI to center
+ self.roi_center_x = cx
+ self.roi_center_y = cy
+ # Recompute cached virtual images with new calibration
+ self._precompute_common_virtual_images()
+
+ return self
+
+ def _get_frame(self, x: int, y: int) -> np.ndarray:
+ """Get single diffraction frame at position (x, y) as numpy array."""
+ if self._data.ndim == 3:
+ idx = x * self.shape_y + y
+ return self._data[idx].cpu().numpy()
+ else:
+ return self._data[x, y].cpu().numpy()
+
+ def _update_frame(self, change=None):
+ """Send raw float32 frame to frontend (JS handles scale/colormap)."""
+ # Get frame as tensor (stays on device)
+ if self._data.ndim == 3:
+ idx = self.pos_x * self.shape_y + self.pos_y
+ frame = self._data[idx]
+ else:
+ frame = self._data[self.pos_x, self.pos_y]
+
+ # Apply log scale if enabled
+ if self.log_scale:
+ frame = torch.log1p(frame)
+
+ # Compute stats from frame (optionally mask DC component)
+ if self.mask_dc and self.det_x > 3 and self.det_y > 3:
+ # Mask center 3x3 region for stats (only for detectors > 3x3)
+ cx, cy = self.det_x // 2, self.det_y // 2
+ mask = torch.ones_like(frame, dtype=torch.bool)
+ mask[max(0, cx-1):cx+2, max(0, cy-1):cy+2] = False
+ masked_vals = frame[mask]
+ self.dp_stats = [
+ float(masked_vals.mean()),
+ float(masked_vals.min()),
+ float(masked_vals.max()),
+ float(masked_vals.std()),
+ ]
+ else:
+ self.dp_stats = [
+ float(frame.mean()),
+ float(frame.min()),
+ float(frame.max()),
+ float(frame.std()),
+ ]
+
+ # Convert to numpy only for sending bytes to frontend
+ self.frame_bytes = frame.cpu().numpy().astype(np.float32).tobytes()
+
+ def _on_roi_change(self, change=None):
+ """Recompute virtual image when individual ROI params change.
+
+ This handles legacy setters (setRoiCenterX/Y) from button handlers.
+ High-frequency updates use the compound roi_center trait instead.
+ """
+ if not self.roi_active:
+ 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 X+Y).
+
+ This is the fast path for drag operations. JS sends [x, y] as a single
+ compound trait, so only one observer fires per mouse move.
+ """
+ if not self.roi_active:
+ return
+ if change and "new" in change:
+ x, y = change["new"]
+ # Sync to individual traits (without triggering _on_roi_change observers)
+ self.unobserve(self._on_roi_change, names=["roi_center_x", "roi_center_y"])
+ self.roi_center_x = x
+ self.roi_center_y = y
+ self.observe(self._on_roi_change, names=["roi_center_x", "roi_center_y"])
+ self._compute_virtual_image_from_roi()
+
+ def _on_vi_roi_change(self, change=None):
+ """Compute summed DP when VI ROI changes."""
+ if self.vi_roi_mode == "off":
+ self.summed_dp_bytes = b""
+ self.summed_dp_count = 0
+ return
+ self._compute_summed_dp_from_vi_roi()
+
+ def _compute_summed_dp_from_vi_roi(self):
+ """Sum diffraction patterns from positions inside VI ROI (PyTorch)."""
+ # Create mask in scan space using cached coordinates
+ if self.vi_roi_mode == "circle":
+ mask = (self._scan_row_coords - self.vi_roi_center_x) ** 2 + (self._scan_col_coords - self.vi_roi_center_y) ** 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_x) <= half_size) & (torch.abs(self._scan_col_coords - self.vi_roi_center_y) <= 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_x) <= half_h) & (torch.abs(self._scan_col_coords - self.vi_roi_center_y) <= half_w)
+ else:
+ return
+
+ # Count positions in mask
+ n_positions = int(mask.sum())
+ if n_positions == 0:
+ self.summed_dp_bytes = b""
+ self.summed_dp_count = 0
+ return
+
+ self.summed_dp_count = n_positions
+
+ # Compute average DP using masked sum (vectorized)
+ if self._data.ndim == 4:
+ # (scan_x, scan_y, det_x, det_y) - sum over masked scan positions
+ avg_dp = self._data[mask].mean(dim=0)
+ else:
+ # Flattened: (N, det_x, det_y) - need to convert mask indices
+ flat_indices = torch.nonzero(mask.flatten(), as_tuple=True)[0]
+ avg_dp = self._data[flat_indices].mean(dim=0)
+
+ # Normalize to 0-255 for display
+ vmin, vmax = float(avg_dp.min()), float(avg_dp.max())
+ if vmax > vmin:
+ normalized = torch.clamp((avg_dp - vmin) / (vmax - vmin) * 255, 0, 255)
+ normalized = normalized.cpu().numpy().astype(np.uint8)
+ else:
+ normalized = np.zeros((self.det_x, self.det_y), dtype=np.uint8)
+
+ self.summed_dp_bytes = normalized.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 _precompute_common_virtual_images(self):
+ """Pre-compute BF/ABF/ADF virtual images for instant preset switching."""
+ cx, cy, bf = self.center_x, self.center_y, self.bf_radius
+ # Cache (bytes, stats, min, max) for each preset
+ bf_arr = self._fast_masked_sum(self._create_circular_mask(cx, cy, bf))
+ abf_arr = self._fast_masked_sum(self._create_annular_mask(cx, cy, bf * 0.5, bf))
+ adf_arr = self._fast_masked_sum(self._create_annular_mask(cx, cy, bf, bf * 4.0))
+
+ self._cached_bf_virtual = (
+ self._to_float32_bytes(bf_arr, update_vi_stats=False),
+ [float(bf_arr.mean()), float(bf_arr.min()), float(bf_arr.max()), float(bf_arr.std())],
+ float(bf_arr.min()), float(bf_arr.max())
+ )
+ self._cached_abf_virtual = (
+ self._to_float32_bytes(abf_arr, update_vi_stats=False),
+ [float(abf_arr.mean()), float(abf_arr.min()), float(abf_arr.max()), float(abf_arr.std())],
+ float(abf_arr.min()), float(abf_arr.max())
+ )
+ self._cached_adf_virtual = (
+ self._to_float32_bytes(adf_arr, update_vi_stats=False),
+ [float(adf_arr.mean()), float(adf_arr.min()), float(adf_arr.max()), float(adf_arr.std())],
+ float(adf_arr.min()), float(adf_arr.max())
+ )
+
+ def _get_cached_preset(self) -> tuple[bytes, list[float], float, float] | None:
+ """Check if current ROI matches a cached preset and return (bytes, stats, min, max) tuple."""
+ # Must be centered on detector center
+ if abs(self.roi_center_x - self.center_x) >= 1 or abs(self.roi_center_y - self.center_y) >= 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 4*bf (combines LAADF + HAADF)
+ if (self.roi_mode == "annular" and
+ abs(self.roi_radius_inner - bf) < 1 and
+ abs(self.roi_radius - bf * 4.0) < 1):
+ return self._cached_adf_virtual
+
+ return None
+
+ def _fast_masked_sum(self, mask: torch.Tensor) -> torch.Tensor:
+ """Compute masked sum using PyTorch.
+
+ Uses sparse indexing for small masks (<20% coverage) which is faster
+ because it only processes non-zero pixels:
+ - r=10 (1%): ~0.8ms (sparse) vs ~13ms (full)
+ - r=30 (8%): ~4ms (sparse) vs ~13ms (full)
+
+ For large masks (≥20%), uses full tensordot which has constant ~13ms.
+ """
+ mask_float = mask.float()
+ n_det = self._det_shape[0] * self._det_shape[1]
+ n_nonzero = int(mask.sum())
+ coverage = n_nonzero / n_det
+
+ if coverage < SPARSE_MASK_THRESHOLD:
+ # Sparse: faster for small masks
+ indices = torch.nonzero(mask_float.flatten(), as_tuple=True)[0]
+ n_scan = self._scan_shape[0] * self._scan_shape[1]
+ data_flat = self._data.reshape(n_scan, n_det)
+ result = data_flat[:, indices].sum(dim=1).reshape(self._scan_shape)
+ else:
+ # Tensordot: faster for large masks
+ result = torch.tensordot(self._data, mask_float, dims=([2, 3], [0, 1]))
+
+ return result
+
+ def _to_float32_bytes(self, arr: torch.Tensor, update_vi_stats: bool = True) -> bytes:
+ """Convert tensor to float32 bytes."""
+ # Compute min/max (fast on GPU)
+ vmin = float(arr.min())
+ vmax = float(arr.max())
+ self.vi_data_min = vmin
+ self.vi_data_max = vmax
+
+ # Compute full stats if requested
+ if update_vi_stats:
+ self.vi_stats = [float(arr.mean()), vmin, vmax, float(arr.std())]
+
+ return arr.cpu().numpy().astype(np.float32).tobytes()
+
+ def _compute_virtual_image_from_roi(self):
+ """Compute virtual image based on ROI mode."""
+ cached = self._get_cached_preset()
+ if cached is not None:
+ # Cached preset returns (bytes, stats, min, max) tuple
+ vi_bytes, vi_stats, vi_min, vi_max = cached
+ self.virtual_image_bytes = vi_bytes
+ self.vi_stats = vi_stats
+ self.vi_data_min = vi_min
+ self.vi_data_max = vi_max
+ return
+
+ cx, cy = self.roi_center_x, self.roi_center_y
+
+ 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)))
+ if self._data.ndim == 4:
+ virtual_image = self._data[:, :, row, col]
+ else:
+ virtual_image = self._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/tests/test_widget.py b/widget/tests/test_widget.py
deleted file mode 100644
index bd1ba517..00000000
--- 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/tests/test_widget_show4dstem.py b/widget/tests/test_widget_show4dstem.py
new file mode 100644
index 00000000..413d6470
--- /dev/null
+++ b/widget/tests/test_widget_show4dstem.py
@@ -0,0 +1,140 @@
+import numpy as np
+import quantem.widget
+from quantem.widget import Show4DSTEM
+
+
+def test_version_exists():
+ assert hasattr(quantem.widget, "__version__")
+
+
+def test_version_is_string():
+ assert isinstance(quantem.widget.__version__, str)
+
+
+def test_show4dstem_loads():
+ """Widget can be created from mock 4D data."""
+ data = np.random.rand(8, 8, 16, 16).astype(np.float32)
+ widget = Show4DSTEM(data)
+ assert widget is not None
+
+
+def test_show4dstem_flattened_scan_shape_mapping():
+ """Test flattened 3D data with explicit scan shape."""
+ data = np.zeros((6, 2, 2), dtype=np.float32)
+ for idx in range(data.shape[0]):
+ data[idx] = idx
+ widget = Show4DSTEM(data, scan_shape=(2, 3))
+ assert (widget.shape_x, widget.shape_y) == (2, 3)
+ assert (widget.det_x, widget.det_y) == (2, 2)
+ frame = widget._get_frame(1, 2)
+ assert np.array_equal(frame, np.full((2, 2), 5, dtype=np.float32))
+
+
+def test_show4dstem_log_scale():
+ """Test that log scale changes frame bytes."""
+ data = np.random.rand(2, 2, 8, 8).astype(np.float32) * 100 + 1
+ widget = Show4DSTEM(data, log_scale=True)
+ log_bytes = bytes(widget.frame_bytes)
+ widget.log_scale = False
+ widget._update_frame()
+ linear_bytes = bytes(widget.frame_bytes)
+ assert log_bytes != linear_bytes
+
+
+def test_show4dstem_auto_detect_center():
+ """Test automatic center spot detection using centroid."""
+ data = np.zeros((2, 2, 7, 7), dtype=np.float32)
+ for i in range(7):
+ for j in range(7):
+ dist = np.sqrt((i - 3) ** 2 + (j - 3) ** 2)
+ if dist <= 1.5:
+ data[:, :, i, j] = 100.0
+ widget = Show4DSTEM(data, precompute_virtual_images=False)
+ widget.auto_detect_center()
+ assert abs(widget.center_x - 3.0) < 0.5
+ assert abs(widget.center_y - 3.0) < 0.5
+ assert widget.bf_radius > 0
+
+
+def test_show4dstem_adf_preset_cache():
+ """Test that ADF preset cache works when precompute is enabled."""
+ data = np.random.rand(4, 4, 16, 16).astype(np.float32)
+ widget = Show4DSTEM(data, center=(8, 8), bf_radius=2, precompute_virtual_images=True)
+ assert widget._cached_adf_virtual is not None
+ widget.roi_mode = "annular"
+ widget.roi_center_x = 8
+ widget.roi_center_y = 8
+ widget.roi_radius_inner = 2
+ widget.roi_radius = 8
+ cached = widget._get_cached_preset()
+ assert cached == widget._cached_adf_virtual
+
+
+def test_show4dstem_rectangular_scan_shape():
+ """Test that rectangular (non-square) scans work correctly."""
+ data = np.random.rand(4, 8, 16, 16).astype(np.float32)
+ widget = Show4DSTEM(data)
+ assert widget.shape_x == 4
+ assert widget.shape_y == 8
+ assert widget.det_x == 16
+ assert widget.det_y == 16
+ frame_00 = widget._get_frame(0, 0)
+ frame_37 = widget._get_frame(3, 7)
+ assert frame_00.shape == (16, 16)
+ assert frame_37.shape == (16, 16)
+
+
+def test_show4dstem_hot_pixel_removal_uint16():
+ """Test that saturated uint16 hot pixels are removed at init."""
+ data = np.zeros((4, 4, 8, 8), dtype=np.uint16)
+ data[:, :, :, :] = 100
+ data[:, :, 3, 5] = 65535
+ data[:, :, 1, 2] = 65535
+ widget = Show4DSTEM(data)
+ assert widget.dp_global_max < 65535
+ assert widget.dp_global_max == 100.0
+ frame = widget._get_frame(0, 0)
+ assert frame[3, 5] == 0
+ assert frame[1, 2] == 0
+ assert frame[0, 0] == 100
+
+
+def test_show4dstem_hot_pixel_removal_uint8():
+ """Test that saturated uint8 hot pixels are removed at init."""
+ data = np.zeros((4, 4, 8, 8), dtype=np.uint8)
+ data[:, :, :, :] = 50
+ data[:, :, 2, 3] = 255
+ widget = Show4DSTEM(data)
+ assert widget.dp_global_max == 50.0
+ frame = widget._get_frame(0, 0)
+ assert frame[2, 3] == 0
+
+
+def test_show4dstem_no_hot_pixel_removal_float32():
+ """Test that float32 data is not modified (no saturated value)."""
+ data = np.ones((4, 4, 8, 8), dtype=np.float32) * 1000
+ widget = Show4DSTEM(data)
+ assert widget.dp_global_max == 1000.0
+
+
+def test_show4dstem_roi_modes():
+ """Test all ROI modes compute virtual images correctly."""
+ data = np.random.rand(8, 8, 16, 16).astype(np.float32)
+ widget = Show4DSTEM(data, center=(8, 8), bf_radius=3)
+ for mode in ["point", "circle", "square", "annular", "rect"]:
+ widget.roi_mode = mode
+ widget.roi_active = True
+ assert len(widget.vi_stats) == 4
+ assert widget.vi_stats[2] >= widget.vi_stats[1]
+
+
+def test_show4dstem_virtual_image_excludes_hot_pixels():
+ """Test that virtual images don't include hot pixel contributions."""
+ data = np.ones((4, 4, 8, 8), dtype=np.uint16) * 10
+ data[:, :, 4, 4] = 65535
+ widget = Show4DSTEM(data, center=(4, 4), bf_radius=2)
+ widget.roi_mode = "circle"
+ widget.roi_center_x = 4
+ widget.roi_center_y = 4
+ widget.roi_radius = 3
+ assert widget.vi_stats[2] < 1000
diff --git a/widget/vite.config.js b/widget/vite.config.js
deleted file mode 100644
index 8f303083..00000000
--- a/widget/vite.config.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { defineConfig } from "vite";
-import anywidget from "@anywidget/vite";
-import react from "@vitejs/plugin-react";
-
-export default defineConfig({
- plugins: [anywidget(), react()],
- define: {
- "process.env.NODE_ENV": JSON.stringify("production"),
- },
- build: {
- outDir: "src/quantem/widget/static",
- lib: {
- entry: "js/index.jsx",
- formats: ["es"],
- fileName: "index",
- },
- rollupOptions: {
- output: {
- inlineDynamicImports: true,
- },
- },
- },
-});