diff --git a/CHANGELOG.md b/CHANGELOG.md index b0053fed..5157523f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ new `rcN` heading when that rc is published to TestPyPI. ## Unreleased +- Show4DSTEM compares native diffraction patterns side by side in live + Multiple view, with shared scan-region reductions and live detector dragging. + Playback controls are reserved for Single view. + - Add `Plot2D` for scalar maps with calibrated Cartesian axes, colormap selection, viewport controls, and editable Matplotlib figure export. diff --git a/docs/api/show4dstem.md b/docs/api/show4dstem.md index c008d58c..d2e6b57f 100644 --- a/docs/api/show4dstem.md +++ b/docs/api/show4dstem.md @@ -1,5 +1,23 @@ # Show4DSTEM +## Compare diffraction patterns side by side + +For a live notebook with several datasets, use +`Show4DSTEM(stack, view_mode="multiple", compare_dp_mode="all")`. +Each visible dataset shows its native diffraction pattern at the same scan +position. Circle, Square or Rect scan selection with Mean compares each +dataset over identical scan positions, never an average across datasets. +Dragging the detector in any diffraction tile updates the shared virtual +detector and all virtual images before release. + +The diffraction grid shares the virtual-image grid's zoom, pan, reset, +scale bars and column layout. Playback is available in Single view only. +The `all` mode requires a live kernel; standalone export is not qualified for +this layout. Existing `selected` and `average` modes remain available. +Packed sources require their own reduction support and are not established +by this dense-array feature. See the +[interaction contract](../maintainer/all-diffraction-comparison.md). + Public import: ```python diff --git a/docs/maintainer/all-diffraction-comparison.md b/docs/maintainer/all-diffraction-comparison.md new file mode 100644 index 00000000..9ed3961a --- /dev/null +++ b/docs/maintainer/all-diffraction-comparison.md @@ -0,0 +1,43 @@ +# All diffraction comparison (live) + +Given a live Show4DSTEM with multiple datasets, when `compare_dp_mode="all"` +is selected, show each visible dataset's native diffraction pattern at the +same scan position. Keep signed values intact and use one absolute display +range across the grid. Scan selection updates every pattern. Clicking a grid +pattern moves the common detector center; the primary diffraction panel retains +the aperture geometry, radius, zoom and other tools. + +The diffraction row uses the same comparison component as the virtual-image +row: wheel zoom, Shift-drag pan, Reset/double-click, scale bars, responsive +columns, panel selection, hide/star/reorder and resize. Diffraction scale bars +use detector pixels unless reciprocal-space calibration is provided. Color and +linear/signed-log settings follow the main diffraction controls. FFT/profile +tools remain in the primary diffraction panel, not each comparison tile. + +Select Circle, Square or Rect in the scan ROI controls below the virtual +images, with Mean as the reduction. Each method is reduced independently over +identical scan positions. No averaging across methods occurs. Dragging updates +the shared outline on animation frames and requests live native reductions. +One kernel request is in flight; intermediate positions coalesce to the latest +pointer position. A `vi_roi_receipt` acknowledges each completed reduction, +including unchanged patterns, before the next pending position is sent. The +final pointer position is retained. Off restores the point patterns. This does +not establish 120 FPS derived-data updates: measure actual five-panel paints +separately from animation-frame cadence and backend execution. The receipt +contains row, col, backend wall milliseconds and revision; it is not a GPU-only +timer or proof that the browser painted the result. Native arrays remain signed and +unchanged. An empty selection must not be presented as a valid mean. + +This additive layout reuses QuantEM.GPU's WebGPU range/colormap/compositing +engine. It does not apply denoising or change the original data. The existing +average/selected modes are unchanged. Python transfers only the requested +native patterns, not a second 4D cube. The all-pattern grid is live-kernel-only +at this stage; kernel-free exports, lazy-page stress and physical-phone behavior +require separate qualification. Do not claim these from a live dense-cube test. + +Example: + +```python +Show4DSTEM(stack, view_mode="multiple", compare_dp_mode="all", + frame_labels=["Noisy", "SHINE", "Fourier", "Sadri", "Fusion"]) +``` diff --git a/docs/maintainer/storyboard-show4dstem.md b/docs/maintainer/storyboard-show4dstem.md index 7b0c2dfd..17e05b3a 100644 --- a/docs/maintainer/storyboard-show4dstem.md +++ b/docs/maintainer/storyboard-show4dstem.md @@ -1,5 +1,21 @@ # Show4DSTEM Storyboard +### S4D-21: Compare Native Patterns With Shared Live Controls + +- Open a live multiple-dataset viewer with `compare_dp_mode="all"`. +- Select a scan point and confirm that every diffraction tile follows it. +- Select Circle/Mean in scan space; drag while holding the pointer and confirm + that all patterns update over the same region before release. +- Drag in each diffraction tile and confirm that the common virtual detector + and every virtual image update before release. Repeat with Point and Annular. +- Change columns, zoom, Shift-drag pan and reset; retain calibrated or pixel + scale bars and shared contrast. FFT/profile remain primary-panel tools. +- Switch Single to Multiple during playback. Playback must stop and its controls + disappear; returning to Single must remain paused. +- Check signed-array preservation independently of the display transform. + Do not count these live dense-array checks as packed, portable HTML, physical + phone or 120-FPS qualification. + Use with [Storyboard](storyboard). MacBook support is a first-class Show4DSTEM target, not an afterthought. For diff --git a/js/show4dstem/AllDiffractionGrid.tsx b/js/show4dstem/AllDiffractionGrid.tsx new file mode 100644 index 00000000..4e777a0d --- /dev/null +++ b/js/show4dstem/AllDiffractionGrid.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; +import { GPUColormapEngine } from "../colormaps"; +import { extractBytes } from "../format"; +import { getGPUDevice, isSoftwareGPUAdapter } from "../.generated/engine/device/webgpu"; + +type DiffractionSlots = { + engine: GPUColormapEngine; + slots: Map; + ranges: Map; +}; + +/** Upload native patterns once; the common grid owns every interaction. */ +export function AllDiffractionGrid({bytes, indices, rows, cols, selectionLabel, renderGrid}: { + bytes: DataView | undefined; indices: number[]; rows: number; cols: number; + selectionLabel: string; renderGrid: (data: DiffractionSlots) => React.ReactNode; +}) { + const [engine, setEngine] = React.useState(null); + const [data, setData] = React.useState(null); + const [error, setError] = React.useState(""); + React.useEffect(() => { + let disposed = false; + let renderer: GPUColormapEngine | null = null; + void (async () => { + try { + const device = await getGPUDevice(); + if (!device || isSoftwareGPUAdapter()) throw new Error("Hardware WebGPU is required for the all-pattern display."); + renderer = new GPUColormapEngine(device); + if (disposed) renderer.destroy(); else setEngine(renderer); + } catch (cause) { if (!disposed) setError(String(cause)); } + })(); + return () => { disposed = true; renderer?.destroy(); }; + }, []); + React.useEffect(() => { + if (!engine || !bytes || !indices.length) return; + const payload = extractBytes(bytes); + const length = rows * cols; + if (payload.byteLength !== indices.length * length * 4) return; + let cancelled = false; + const values = new Float32Array(payload.buffer, payload.byteOffset, payload.byteLength / 4); + const slots = new Map(indices.map((frame, index) => [frame, index])); + indices.forEach((_, index) => engine.uploadData(index, values.subarray(index * length, (index + 1) * length), cols, rows)); + void (async () => { + try { + const ranges = await engine.computeRangeBatch([...slots.values()]); + if (cancelled) return; + const range = {min: Math.min(...ranges.map(r => r.min)), max: Math.max(...ranges.map(r => r.max))}; + setData({engine, slots, ranges: new Map(indices.map(frame => [frame, range]))}); + } catch (cause) { if (!cancelled) setError(String(cause)); } + })(); + return () => { cancelled = true; }; + }, [engine, bytes, indices, rows, cols]); + return
+
Diffraction patterns · {selectionLabel} · shared contrast
+ {error &&
{error}
} + {!indices.length &&
No scan positions selected.
} + {indices.length > 0 && data && renderGrid(data)} +
; +} diff --git a/js/show4dstem/index.tsx b/js/show4dstem/index.tsx index f56a4d58..710034ff 100644 --- a/js/show4dstem/index.tsx +++ b/js/show4dstem/index.tsx @@ -42,6 +42,8 @@ import { } from "../.generated/engine/io/backends/webgpu/local-h5"; import { getGPUInfo, isSoftwareGPUAdapter } from "../.generated/engine/device/webgpu"; import { LazyShow4DSTEM } from "./lazy"; +import { AllDiffractionGrid } from "./AllDiffractionGrid"; +import { latestRegion } from "./latestRegion"; import { drawScaleBarHiDPI, drawColorbar, roundToNiceValue } from "../figure"; import { findDataRange, sliderRange, computeStats, computeHistogramFromBytes, percentileClip } from "../stats"; import { downloadBlob, extractBytes, formatNumber, preserveRestoredWidgetModelsOnSave } from "../format"; @@ -1490,6 +1492,8 @@ function cropSingleROI( } interface CompareVirtualGridProps { + kind?: "virtual" | "diffraction"; + scanRegion?: {mode: string; row: number; col: number; radius: number; width: number; height: number}; bytes: DataView | null | undefined; count: number; indices: number[]; @@ -1544,6 +1548,8 @@ interface CompareVirtualGridProps { } function CompareVirtualGrid({ + kind = "virtual", + scanRegion, bytes, count, indices, @@ -1591,6 +1597,10 @@ function CompareVirtualGrid({ onGpuPaint, onGpuRendererReady, }: CompareVirtualGridProps) { + const positionRafRef = React.useRef(0); + const panDragRef = React.useRef<{col:number; row:number; panX:number; panY:number} | null>(null); + const pendingPositionRef = React.useRef<[number, number]>([0, 0]); + React.useEffect(() => () => cancelAnimationFrame(positionRafRef.current), []); const canvasRefs = React.useRef<(HTMLCanvasElement | null)[]>([]); const gpuCanvasRefs = React.useRef<(HTMLCanvasElement | null)[]>([]); const gpuRenderGenerationRef = React.useRef(0); @@ -1717,9 +1727,9 @@ function CompareVirtualGrid({ gap: 0, bgRgb: 0, transforms: panels.map(() => ({ - zoom: compareZoom, - panX: comparePanX, - panY: comparePanY, + zoom: 1, + panX: 0, + panY: 0, })), smooth, }, @@ -1754,7 +1764,7 @@ function CompareVirtualGrid({ if (painted > 0) onGpuPaint?.(painted); })(); return panels.length; - }, [colormap, comparePanX, comparePanY, compareZoom, gpuEngine, gpuRanges, gpuSlots, onGpuPaint, renderEntries, scaleMode, shapeCols, shapeRows, smooth, vmaxPct, vminPct]); + }, [colormap, gpuEngine, gpuRanges, gpuSlots, onGpuPaint, renderEntries, scaleMode, shapeCols, shapeRows, smooth, vmaxPct, vminPct]); React.useEffect(() => { onGpuRendererReady?.(renderGpuSlotsNow); @@ -1877,7 +1887,7 @@ function CompareVirtualGrid({ }, [autoContrast, colormap, gpuEngine, gpuSlots, gpuVersion, onFreshVisiblePaint, renderEntries, scaleMode, shapeCols, shapeRows, smooth, vmaxPct, vminPct]); React.useEffect(() => { - if (!gpuEngine || !gpuSlots) return; + if (!gpuEngine || !gpuSlots || kind === "diffraction") return; const gpuEntries = renderEntries .map((entry, localIdx) => ({ frame: entry.frame, @@ -1929,6 +1939,7 @@ function CompareVirtualGrid({ if (painted > 0) onGpuPaint?.(painted); })(); }, [ + kind, colormap, gpuEngine, gpuSlots, @@ -2063,7 +2074,17 @@ function CompareVirtualGrid({ const tileY = ((clientY - rect.top) / rect.height) * shapeRows; const col = Math.round(Math.max(0, Math.min(shapeCols - 1, (tileX - comparePanX) / compareZoom))); const row = Math.round(Math.max(0, Math.min(shapeRows - 1, (tileY - comparePanY) / compareZoom))); - onPositionChange(row, col, commit); + pendingPositionRef.current = [row, col]; + if (commit) { + cancelAnimationFrame(positionRafRef.current); + positionRafRef.current = 0; + onPositionChange(row, col, true); + } else if (!positionRafRef.current) { + positionRafRef.current = requestAnimationFrame(() => { + positionRafRef.current = 0; + onPositionChange(...pendingPositionRef.current, false); + }); + } }, [comparePanX, comparePanY, compareZoom, onPositionChange, shapeCols, shapeRows]); React.useLayoutEffect(() => { @@ -2109,8 +2130,15 @@ function CompareVirtualGrid({ isDraggingPosition, idx === 0, ); + if (scanRegion && scanRegion.mode !== "off") { + ctx?.clearRect(0, 0, overlay.width, overlay.height); + if (showScaleBar) drawScaleBarHiDPI(overlay, dpr, compareZoom, pixelSize || 1, pixelUnit || "px", shapeCols); + drawViRoiOverlayHiDPI(overlay, dpr, scanRegion.mode, + scanRegion.row, scanRegion.col, scanRegion.radius, scanRegion.width, scanRegion.height, + compareZoom, comparePanX, comparePanY, shapeCols, shapeRows, isDraggingPosition, false, false); + } }); - }, [comparePanX, comparePanY, compareZoom, cursorCol, cursorRow, isDraggingPosition, overlayVersion, pixelSize, pixelUnit, renderEntries, shapeCols, shapeRows, showScaleBar]); + }, [comparePanX, comparePanY, compareZoom, cursorCol, cursorRow, isDraggingPosition, overlayVersion, pixelSize, pixelUnit, renderEntries, shapeCols, shapeRows, showScaleBar, scanRegion]); if (renderEntries.length === 0) { return ( @@ -2124,6 +2152,14 @@ function CompareVirtualGrid({ return ( 0 ? `${maxWidthPx}px` : "100%", position: "relative", "@media (max-width: 700px)": { maxWidth: "100%" } }}> + + Scroll to zoom · Shift-drag to pan + + {compareZoom.toFixed(1)}× + {cacheBadge && ( { tileRefs.current[localIdx] = node; }} role="button" - aria-label={`Show4DSTEM multiple panel ${frame + 1}${panelPresentation.labelSuffix}`} + aria-label={`Show4DSTEM ${kind === "diffraction" ? "diffraction" : "multiple"} panel ${frame + 1}${panelPresentation.labelSuffix}`} + data-comparison-zoom={compareZoom} + data-comparison-pan={`${comparePanX},${comparePanY}`} aria-busy={panelPresentation.busy} aria-disabled={panelPresentation.disabled} data-show4dstem-panel-cache={panelPresentation.cached ? "cached" : loaded ? "fresh" : "empty"} @@ -2204,23 +2242,41 @@ function CompareVirtualGrid({ const target = event.target instanceof Element ? event.target : null; if (!loaded || reorderMode || target?.closest("button")) return; try { event.currentTarget.setPointerCapture(event.pointerId); } catch {} + if (event.shiftKey) { + panDragRef.current={col:event.clientX,row:event.clientY,panX:comparePanX,panY:comparePanY}; + return; + } isDraggingPositionRef.current = true; setIsDraggingPosition(true); updatePositionFromPointer(event.currentTarget, event.clientX, event.clientY); onSelect(frame); }} onPointerMove={(event) => { + const pan = panDragRef.current; + if (pan) { + event.preventDefault(); + const rect=event.currentTarget.getBoundingClientRect(); + const view=compareViewRef.current; + view.panX=pan.panX+(event.clientX-pan.col)*shapeCols/rect.width; + view.panY=pan.panY+(event.clientY-pan.row)*shapeRows/rect.height; + if (!view.raf) view.raf=requestAnimationFrame(() => { + view.raf=0;setComparePanX(view.panX);setComparePanY(view.panY); + }); + return; + } if (!isDraggingPositionRef.current || reorderMode) return; event.preventDefault(); updatePositionFromPointer(event.currentTarget, event.clientX, event.clientY); }} onPointerUp={(event) => { + if (panDragRef.current) {panDragRef.current=null;return;} if (!isDraggingPositionRef.current) return; updatePositionFromPointer(event.currentTarget, event.clientX, event.clientY, true); isDraggingPositionRef.current = false; setIsDraggingPosition(false); }} onPointerCancel={(event) => { + if (panDragRef.current) {panDragRef.current=null;return;} if (!isDraggingPositionRef.current) return; updatePositionFromPointer(event.currentTarget, event.clientX, event.clientY, true); isDraggingPositionRef.current = false; @@ -2360,6 +2416,7 @@ function CompareVirtualGrid({ }} /> { gpuCanvasRefs.current[localIdx] = node; }} @@ -2665,6 +2722,8 @@ function Show4DSTEM() { const [comparePanelIndices] = useModelState("compare_panel_indices"); const [compareStatus] = useModelState("compare_status"); const [compareDpMode, setCompareDpMode] = useModelState("compare_dp_mode"); + const [compareDiffractionBytes] = useModelState("compare_diffraction_bytes"); + const [compareDiffractionIndices] = useModelState("compare_diffraction_indices"); const [compareGroupMode, setCompareGroupMode] = useModelState("compare_group_mode"); const [comparePageIdx, setComparePageIdx] = useModelState("compare_page_idx"); const [comparePageCount] = useModelState("compare_page_count"); @@ -3176,6 +3235,15 @@ function Show4DSTEM() { const [localViRoiCenterCol, setLocalViRoiCenterCol] = React.useState(viRoiCenterCol || 0); const [viRoiDpBytes] = useModelState("vi_roi_dp_bytes"); const [viRoiReduce, setViRoiReduce] = useModelState("vi_roi_reduce"); + const regionRequests = React.useMemo(() => latestRegion((row, col) => { + model.set("vi_roi_center", [row, col]); + model.save_changes(); + }, model.get("vi_roi_center") || []), [model]); + React.useEffect(() => { + const acknowledge = () => regionRequests.acknowledge(); + model.on("change:vi_roi_receipt", acknowledge); + return () => { model.off("change:vi_roi_receipt", acknowledge); regionRequests.clear(); }; + }, [model, regionRequests]); const [webgpuDpcReady, setWebgpuDpcReady] = React.useState(false); const [viGpuVersion, setViGpuVersion] = React.useState(0); const [viGpuRetainedReady, setViGpuRetainedReady] = React.useState(false); @@ -4767,7 +4835,11 @@ function Show4DSTEM() { // Do not turn a progressive refresh into an all-volume decode. // A completed panel keeps its GPU display slot even after the // native uint16 source volume has been evicted. - if (getVol && !volIsResident(idx)) continue; + // A settled/initial refresh must materialise every visible panel so + // later detector drags can update the whole comparison grid live. + // During the drag itself, keep the existing no-paging rule and only + // touch volumes that are already resident. + if (interactiveDrag && getVol && !volIsResident(idx)) continue; const panelCompute = getVol ? await getVol(idx) : compute; if (!(panelCompute instanceof DetectorCompute)) continue; batchComputes.push(panelCompute); @@ -6027,9 +6099,8 @@ function Show4DSTEM() { }); return items; }, [activeComparePageCount, activeComparePageIdx]); - const frameSliderLabel = compareMode ? "Panel" : frameDimLabel; - const frameSliderAriaLabel = compareMode ? "Show4DSTEM active multiple panel" : `Show4DSTEM ${frameDimLabel.toLowerCase()}`; - const [comparePagePlaying, setComparePagePlaying] = React.useState(false); + const frameSliderLabel = frameDimLabel; + const frameSliderAriaLabel = `Show4DSTEM ${frameDimLabel.toLowerCase()}`; const compareGridWidth = compareGridPreviewWidth ?? (compareGridWidthPx > 0 ? compareGridWidthPx : COMPARE_GRID_DEFAULT_WIDTH); React.useEffect(() => { if (!compareMode || compareAllGroups) { @@ -6037,13 +6108,9 @@ function Show4DSTEM() { setCompareDraggingFrame(null); setComparePendingMoveFrame(null); setCompareGridPreviewWidth(null); - setComparePagePlaying(false); compareGridResizeCleanupRef.current?.(); } }, [compareAllGroups, compareMode]); - React.useEffect(() => { - if (activeComparePageCount <= 1 || compareAllGroups) setComparePagePlaying(false); - }, [activeComparePageCount, compareAllGroups]); const compareHiddenCount = React.useMemo(() => { const seen = new Set(); (compareHiddenPanels || []).forEach((idx) => { @@ -6170,7 +6237,6 @@ function Show4DSTEM() { setCompareStarredPanels([]); setCompareGroupMode("paged"); setComparePageIdx(0); - setComparePagePlaying(false); setComparePendingMoveFrame(null); setCompareDraggingFrame(null); setCompareHiddenMenuAnchor(null); @@ -6607,6 +6673,10 @@ function Show4DSTEM() { // Path animation timer React.useEffect(() => { + if (compareMode) { + if (pathPlaying) setPathPlaying(false); + return; + } if (!pathPlaying || pathLength === 0) return; const timer = setInterval(() => { @@ -6625,7 +6695,7 @@ function Show4DSTEM() { }, pathIntervalMs); return () => clearInterval(timer); - }, [pathPlaying, pathLength, pathIntervalMs, pathLoop, setPathIndex, setPathPlaying]); + }, [compareMode, pathPlaying, pathLength, pathIntervalMs, pathLoop, setPathIndex, setPathPlaying]); // Frame animation timer (5D time/tilt series) const frameBounceDir = React.useRef(1); @@ -6634,6 +6704,10 @@ function Show4DSTEM() { }, [frameReverse]); React.useEffect(() => { + if (compareMode) { + if (framePlaying) setFramePlaying(false); + return; + } if (!framePlaying || nFrames <= 1) return; const intervalMs = 1000 / Math.max(0.1, frameFps); @@ -6663,24 +6737,7 @@ function Show4DSTEM() { }, intervalMs); return () => clearInterval(timer); - }, [framePlaying, nFrames, frameFps, frameLoop, frameReverse, frameBoomerang, setFrameIdx, setFramePlaying]); - - React.useEffect(() => { - if (!comparePagePlaying || activeComparePageCount <= 1) return; - const timer = setInterval(() => { - setComparePageIdx((prev: number) => { - const current = Math.max(0, Math.min(activeComparePageCount - 1, Math.round(Number(prev) || 0))); - const next = current + 1; - if (next >= activeComparePageCount) { - setComparePagePlaying(false); - return current; - } - return next; - }); - }, 700); - - return () => clearInterval(timer); - }, [activeComparePageCount, comparePagePlaying, setComparePageIdx]); + }, [compareMode, framePlaying, nFrames, frameFps, frameLoop, frameReverse, frameBoomerang, setFrameIdx, setFramePlaying]); // Initialize WebGPU FFT on mount React.useEffect(() => { @@ -6780,7 +6837,7 @@ function Show4DSTEM() { handled = true; break; case " ": // Space bar - if (pathLength > 0) { + if (!compareMode && pathLength > 0) { setPathPlaying(!pathPlaying); handled = true; } @@ -6815,7 +6872,7 @@ function Show4DSTEM() { e.stopPropagation(); } }, [ - frameIdx, isTypingTarget, nFrames, pathLength, + compareMode, frameIdx, isTypingTarget, nFrames, pathLength, pathPlaying, posCol, posRow, setFrameIdx, setPathPlaying, setPosCol, setPosRow, shapeCols, shapeRows, ]); @@ -6845,9 +6902,9 @@ function Show4DSTEM() { // Sync VI ROI local state React.useEffect(() => { - if (!isDraggingViRoi && !isDraggingViRoiResize) { - setLocalViRoiCenterRow(viRoiCenterRow || shapeRows / 2); - setLocalViRoiCenterCol(viRoiCenterCol || shapeCols / 2); + if (!isDraggingViRoi && !isDraggingViRoiResize && !regionRequests.isPending()) { + setLocalViRoiCenterRow(viRoiCenterRow ?? shapeRows / 2); + setLocalViRoiCenterCol(viRoiCenterCol ?? shapeCols / 2); } }, [viRoiCenterRow, viRoiCenterCol, isDraggingViRoi, isDraggingViRoiResize, shapeRows, shapeCols]); @@ -10439,7 +10496,6 @@ function Show4DSTEM() { aria-label={`Use ${label.toLowerCase()} Show4DSTEM multiple groups`} aria-pressed={active} onClick={() => { - setComparePagePlaying(false); setCompareGroupMode(value); }} sx={{ @@ -10464,30 +10520,12 @@ function Show4DSTEM() { aria-label="Previous Show4DSTEM multiple group" disabled={activeComparePageIdx <= 0} onClick={() => { - setComparePagePlaying(false); requestComparePage(activeComparePageIdx - 1); }} sx={{ color: activeComparePageIdx <= 0 ? themeColors.textMuted : themeColors.accent, p: 0.2 }} > - { - if (comparePagePlaying) { - setComparePagePlaying(false); - return; - } - if (activeComparePageIdx >= activeComparePageCount - 1) { - requestComparePage(0); - } - setComparePagePlaying(true); - }} - sx={{ color: comparePagePlaying ? themeColors.accent : themeColors.textMuted, p: 0.2 }} - > - {comparePagePlaying ? : } - { - setComparePagePlaying(false); requestComparePage(item); }} sx={{ @@ -10543,7 +10580,6 @@ function Show4DSTEM() { aria-label="Next Show4DSTEM multiple group" disabled={activeComparePageIdx >= activeComparePageCount - 1} onClick={() => { - setComparePagePlaying(false); requestComparePage(activeComparePageIdx + 1); }} sx={{ color: activeComparePageIdx >= activeComparePageCount - 1 ? themeColors.textMuted : themeColors.accent, p: 0.2 }} @@ -10659,8 +10695,49 @@ function Show4DSTEM() { {/* VI Canvas */} + {compareMode && compareDpMode === "all" &&