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, - }, - }, - }, -});