diff --git a/docs/api/show1d.md b/docs/api/show1d.md index 06e1d601..0c11338b 100644 --- a/docs/api/show1d.md +++ b/docs/api/show1d.md @@ -1,5 +1,15 @@ # Show1D +## Resizing a scientific curve + +Use `plot_width_px=700`, `plot_height_px=300`, and `max_width=900` to set a +bounded initial size. Width values of zero keep the existing responsive layout. +The bottom-right corner handle resizes a standalone curve horizontally and +vertically. With a snapshot/stats side panel, horizontal dragging redistributes +the plot and side-panel space. Preview stays in the browser during dragging; +width and height are saved on release and retained in widget state and HTML +exports. The Reset toolbar action restores the initial plot dimensions. + Interactive 1D traces for live reconstruction metrics, line profiles, and linked image snapshots. Use it for loss curves, Adam/optimizer diagnostics, joint-time ptychography comparisons, and image-derived profiles that need a diff --git a/js/show1d/index.tsx b/js/show1d/index.tsx index f9a36073..9262fb7b 100644 --- a/js/show1d/index.tsx +++ b/js/show1d/index.tsx @@ -204,6 +204,7 @@ type Show1DInitialInteractiveState = { trialFilterText: string; topTrialCount: number; plotHeightPx: number; + plotWidthPx: number; sidePanelWidthPx: number; snapshotPanelWidthPx: number; focusedTrace: number; @@ -2698,6 +2699,11 @@ function Show1DWidget() { const controlsVisible = Boolean(showControls) && !Boolean(controlsCollapsed); const [lineWidth] = useModelState("line_width"); const [plotHeightPx, setPlotHeightPx] = useModelState("plot_height_px"); + const [plotWidthPx, setPlotWidthPx] = useModelState("plot_width_px"); + const [maxWidth] = useModelState("max_width"); + const [resizePreview, setResizePreview] = React.useState<{ + height: number; width?: number; sideWidth?: number; + } | null>(null); const [sidePanelWidthPx, setSidePanelWidthPx] = useModelState("side_panel_width_px"); const [focusedTrace, setFocusedTrace] = useModelState("focused_trace"); const [xRange, setXRange] = useModelState("x_range"); @@ -3183,7 +3189,7 @@ function Show1DWidget() { MIN_SIDE_PANEL_WIDTH, MAX_SIDE_PANEL_WIDTH, )); - const sidePanelWidth = Math.round(clampValue(requestedSidePanelWidth, MIN_SIDE_PANEL_WIDTH, availableSidePanelWidth)); + const sidePanelWidth = Math.round(clampValue(resizePreview?.sideWidth ?? requestedSidePanelWidth, MIN_SIDE_PANEL_WIDTH, availableSidePanelWidth)); const availableSnapshotViewportWidth = Math.round(clampValue( Math.min(MAX_SNAPSHOT_VIEWPORT_WIDTH, sidePanelWidth), MIN_SNAPSHOT_VIEWPORT_WIDTH, @@ -3484,48 +3490,67 @@ function Show1DWidget() { nonPlotWidth: Math.max(0, gridWidth - plotRect.width - (sidePanelVisible ? sidePanelWidth : 0)), }; event.currentTarget.setPointerCapture(pointerId); + let frame = 0; + let pending: { height: number; width?: number; sideWidth?: number } | null = null; + const availableWidth = rootRef.current?.parentElement?.getBoundingClientRect().width ?? gridWidth; + const widthLimit = Math.min(availableWidth, maxWidth > 0 ? maxWidth : availableWidth); const handleWindowPointerMove = (moveEvent: PointerEvent) => { const start = plotResizeStartRef.current; if (plotResizePointerIdRef.current !== pointerId || !start) return; moveEvent.preventDefault(); const dx = moveEvent.clientX - start.x; const dy = moveEvent.clientY - start.y; - setPlotHeightPx(Math.round(clampValue(start.plotHeight + dy, MIN_PLOT_HEIGHT, MAX_PLOT_HEIGHT))); - if (!sidePanelVisible) return; - const minPlotWidth = MIN_PLOT_WIDTH; - const minSidePanelWidth = MIN_SIDE_PANEL_WIDTH; - const maxSidePanelWidth = Math.min(MAX_SIDE_PANEL_WIDTH, start.gridWidth - start.nonPlotWidth - minPlotWidth); - const maxPlotWidth = start.gridWidth - start.nonPlotWidth - minSidePanelWidth; - if (maxSidePanelWidth < minSidePanelWidth || maxPlotWidth < minPlotWidth) return; - const nextPlotWidth = clampValue(start.plotWidth + dx, minPlotWidth, maxPlotWidth); - const nextSidePanelWidth = clampValue( - start.gridWidth - start.nonPlotWidth - nextPlotWidth, - minSidePanelWidth, - maxSidePanelWidth, - ); - setSidePanelWidthUserAdjusted(true); - setSidePanelWidthPx(Math.round(nextSidePanelWidth)); - if (showSnapshots && hasSnapshots) { - setSnapshotPanelWidthPx(Math.round(nextSidePanelWidth)); + pending = { height: Math.round(clampValue(start.plotHeight + dy, MIN_PLOT_HEIGHT, MAX_PLOT_HEIGHT)) }; + if (!sidePanelVisible) { + pending.width = Math.round(clampValue(start.gridWidth + dx, Math.min(MIN_PLOT_WIDTH, widthLimit), widthLimit)); + } else { + const minPlotWidth = MIN_PLOT_WIDTH; + const minSidePanelWidth = MIN_SIDE_PANEL_WIDTH; + const maxSidePanelWidth = Math.min(MAX_SIDE_PANEL_WIDTH, start.gridWidth - start.nonPlotWidth - minPlotWidth); + const maxPlotWidth = start.gridWidth - start.nonPlotWidth - minSidePanelWidth; + if (maxSidePanelWidth >= minSidePanelWidth && maxPlotWidth >= minPlotWidth) { + const nextPlotWidth = clampValue(start.plotWidth + dx, minPlotWidth, maxPlotWidth); + pending.sideWidth = Math.round(clampValue( + start.gridWidth - start.nonPlotWidth - nextPlotWidth, + minSidePanelWidth, + maxSidePanelWidth, + )); + } } + // Browser-local preview: never send model updates for raw pointer moves. + if (!frame) frame = requestAnimationFrame(() => { + frame = 0; + setResizePreview(pending); + }); }; const handleWindowPointerUp = (upEvent: PointerEvent) => { if (plotResizePointerIdRef.current !== pointerId) return; upEvent.preventDefault(); + if (pending) { + setPlotHeightPx(pending.height); + if (pending.width !== undefined) setPlotWidthPx(pending.width); + if (pending.sideWidth !== undefined) { + setSidePanelWidthUserAdjusted(true); + setSidePanelWidthPx(pending.sideWidth); + if (showSnapshots && hasSnapshots) setSnapshotPanelWidthPx(pending.sideWidth); + } + } plotResizeCleanupRef.current?.(); + setResizePreview(null); plotResizeCleanupRef.current = null; }; window.addEventListener("pointermove", handleWindowPointerMove, { capture: true }); window.addEventListener("pointerup", handleWindowPointerUp, { capture: true }); window.addEventListener("pointercancel", handleWindowPointerUp, { capture: true }); plotResizeCleanupRef.current = () => { + if (frame) cancelAnimationFrame(frame); window.removeEventListener("pointermove", handleWindowPointerMove, { capture: true }); window.removeEventListener("pointerup", handleWindowPointerUp, { capture: true }); window.removeEventListener("pointercancel", handleWindowPointerUp, { capture: true }); plotResizePointerIdRef.current = null; plotResizeStartRef.current = null; }; - }, [hasSnapshots, setPlotHeightPx, setSidePanelWidthPx, setSnapshotPanelWidthPx, showSnapshots, sidePanelVisible, sidePanelWidth]); + }, [hasSnapshots, maxWidth, setPlotHeightPx, setPlotWidthPx, setSidePanelWidthPx, setSnapshotPanelWidthPx, showSnapshots, sidePanelVisible, sidePanelWidth]); const setSnapshotViewportWidth = React.useCallback((width: number) => { const maximumResizableWidth = Math.min( @@ -3678,6 +3703,7 @@ function Show1DWidget() { trialFilterText: String(trialFilterText || ""), topTrialCount: Math.max(0, Math.round(optionalFiniteNumber(topTrialCount) || 0)), plotHeightPx: Number.isFinite(plotHeightPx) ? plotHeightPx : DEFAULT_PLOT_HEIGHT, + plotWidthPx: Number.isFinite(plotWidthPx) ? plotWidthPx : 0, sidePanelWidthPx: Number.isFinite(sidePanelWidthPx) ? sidePanelWidthPx : 360, snapshotPanelWidthPx: Number.isFinite(snapshotPanelWidthPx) ? snapshotPanelWidthPx : 0, focusedTrace: Number.isFinite(focusedTrace) ? focusedTrace : -1, @@ -3718,6 +3744,7 @@ function Show1DWidget() { imageCmap, logScale, plotHeightPx, + plotWidthPx, selectedSnapshotGroupIdx, selectedSnapshotIdx, showLegend, @@ -4578,6 +4605,7 @@ function Show1DWidget() { setTrialFilterText(initial.trialFilterText); setTopTrialCount(initial.topTrialCount); setPlotHeightPx(initial.plotHeightPx); + setPlotWidthPx(initial.plotWidthPx); setSidePanelWidthPx(initial.sidePanelWidthPx); setSnapshotPanelWidthPx(initial.snapshotPanelWidthPx); setSidePanelWidthUserAdjusted(false); @@ -4676,6 +4704,7 @@ function Show1DWidget() { || trialFilterText !== initial.trialFilterText || topTrialCount !== initial.topTrialCount || plotHeightPx !== initial.plotHeightPx + || (plotWidthPx || 0) !== initial.plotWidthPx || sidePanelWidthPx !== initial.sidePanelWidthPx || snapshotPanelWidthPx !== initial.snapshotPanelWidthPx || sidePanelWidthUserAdjusted @@ -4714,6 +4743,7 @@ function Show1DWidget() { imageCmap, logScale, plotHeightPx, + plotWidthPx, resetBaselineReady, selectedSnapshotGroupIdx, selectedSnapshotIdx, @@ -4918,13 +4948,13 @@ function Show1DWidget() { + (showStats ? 130 : 0) : 0; const plotNonCanvasHeightEstimate = showLegend && visibleTraceIndices.length > 0 ? 54 : 12; - const effectivePlotHeight = snapshotOverview && !plotHeightExplicit + const effectivePlotHeight = resizePreview?.height ?? (snapshotOverview && !plotHeightExplicit ? Math.round(clampValue( Math.max(plotHeight, snapshotPanelContentHeight - plotNonCanvasHeightEstimate), 260, MAX_PLOT_HEIGHT, )) - : plotHeight; + : plotHeight); const statsPanel = showStats && visibleTraceIndices.length > 0 ? ( 0 ? `${resizePreview?.width ?? plotWidthPx}px` : "100%", + maxWidth: maxWidth > 0 ? `min(100%, ${maxWidth}px)` : "100%", bgcolor: themeColors.bg, color: themeColors.text, border: "none", diff --git a/src/quantem/widget/show1d.py b/src/quantem/widget/show1d.py index e0d2d676..3ceac29d 100644 --- a/src/quantem/widget/show1d.py +++ b/src/quantem/widget/show1d.py @@ -265,6 +265,10 @@ class Show1D(StaticFallbackMixin, anywidget.AnyWidget): button in the frontend. plot_height_px, side_panel_width_px : int, optional Initial plot height and snapshot/stats side-panel width in pixels. + plot_width_px, max_width : int, optional + Viewer width and maximum width in pixels. Zero preserves responsive + full width. A standalone plot's corner handle changes width and height; + with a side panel, horizontal dragging reallocates space between panels. image_cmap : str, default "viridis" Colormap used for profile and snapshot images. review_mode : {"trace", "optimization"}, optional @@ -381,6 +385,8 @@ class Show1D(StaticFallbackMixin, anywidget.AnyWidget): controls_collapsed = traitlets.Bool(False).tag(sync=True) line_width = traitlets.Float(1.5).tag(sync=True) plot_height_px = traitlets.Int(390).tag(sync=True) + plot_width_px = traitlets.Int(0, min=0).tag(sync=True) + max_width = traitlets.Int(0, min=0).tag(sync=True) side_panel_width_px = traitlets.Int(360).tag(sync=True) focused_trace = traitlets.Int(-1).tag(sync=True) x_range = traitlets.List(traitlets.Float()).tag(sync=True) @@ -602,6 +608,8 @@ def __init__( controls_collapsed: bool | None = None, line_width: float = 1.5, plot_height_px: int = 390, + plot_width_px: int = 0, + max_width: int = 0, side_panel_width_px: int = 360, profile_image: Any = None, profile_line: Sequence[Sequence[float]] | None = None, @@ -720,6 +728,8 @@ def __init__( self.controls_collapsed = bool(ui["controls_collapsed"]) self.line_width = float(line_width) self.plot_height_px = max(220, min(_MAX_PLOT_HEIGHT_PX, int(plot_height_px))) + self.plot_width_px = plot_width_px + self.max_width = max_width self.side_panel_width_px = max(300, min(_MAX_SIDE_PANEL_WIDTH_PX, int(side_panel_width_px))) self.profile_width = max(1, int(profile_width)) self.image_cmap = self._normalise_image_cmap(image_cmap) @@ -2283,6 +2293,8 @@ def refresh_monitor(self, *, incremental: bool = True) -> Self: y_label=self.y_label or "loss", log_scale=self.log_scale, plot_height_px=self.plot_height_px, + plot_width_px=self.plot_width_px, + max_width=self.max_width, side_panel_width_px=self.side_panel_width_px, image_cmap=self.image_cmap, snapshot_contrast_preset=self.snapshot_contrast_preset, @@ -2440,6 +2452,8 @@ def state_dict(self) -> dict[str, Any]: "controls_collapsed": self.controls_collapsed, "line_width": self.line_width, "plot_height_px": self.plot_height_px, + "plot_width_px": self.plot_width_px, + "max_width": self.max_width, "side_panel_width_px": self.side_panel_width_px, "focused_trace": self.focused_trace, "x_range": list(self.x_range), @@ -3754,6 +3768,8 @@ def _clone_for_html_export(self, *, downsample: int = 1) -> Self: controls_collapsed=self.controls_collapsed, line_width=self.line_width, plot_height_px=self.plot_height_px, + plot_width_px=self.plot_width_px, + max_width=self.max_width, side_panel_width_px=self.side_panel_width_px, image_cmap=self.image_cmap, snapshot_contrast_preset=self.snapshot_contrast_preset, diff --git a/tests/test_widget_show1d.py b/tests/test_widget_show1d.py index ba0cb267..553c9a20 100644 --- a/tests/test_widget_show1d.py +++ b/tests/test_widget_show1d.py @@ -9,6 +9,22 @@ from quantem.widget.show1d import Show1D, sample_line_profile +def test_show1d_resize_dimensions_survive_state_and_html_clone() -> None: + widget = Show1D(np.arange(12), plot_width_px=640, max_width=900, + plot_height_px=280, show_stats=False, show_review=False, + save_state=True) + original = widget._data.copy() + widget.plot_width_px = 720 + widget.plot_height_px = 340 + state = widget.get_state() + assert state["plot_width_px"] == 720 + assert state["max_width"] == 900 + assert state["plot_height_px"] == 340 + clone = widget._clone_for_html_export(downsample=1) + assert (clone.plot_width_px, clone.plot_height_px, clone.max_width) == (720, 340, 900) + np.testing.assert_array_equal(widget._data, original) + + def _wait_until(predicate, *, timeout_s: float = 5.0) -> None: deadline = time.time() + timeout_s while time.time() < deadline: