Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/api/show1d.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
76 changes: 53 additions & 23 deletions js/show1d/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ type Show1DInitialInteractiveState = {
trialFilterText: string;
topTrialCount: number;
plotHeightPx: number;
plotWidthPx: number;
sidePanelWidthPx: number;
snapshotPanelWidthPx: number;
focusedTrace: number;
Expand Down Expand Up @@ -2698,6 +2699,11 @@ function Show1DWidget() {
const controlsVisible = Boolean(showControls) && !Boolean(controlsCollapsed);
const [lineWidth] = useModelState<number>("line_width");
const [plotHeightPx, setPlotHeightPx] = useModelState<number>("plot_height_px");
const [plotWidthPx, setPlotWidthPx] = useModelState<number>("plot_width_px");
const [maxWidth] = useModelState<number>("max_width");
const [resizePreview, setResizePreview] = React.useState<{
height: number; width?: number; sideWidth?: number;
} | null>(null);
const [sidePanelWidthPx, setSidePanelWidthPx] = useModelState<number>("side_panel_width_px");
const [focusedTrace, setFocusedTrace] = useModelState<number>("focused_trace");
const [xRange, setXRange] = useModelState<number[]>("x_range");
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -3718,6 +3744,7 @@ function Show1DWidget() {
imageCmap,
logScale,
plotHeightPx,
plotWidthPx,
selectedSnapshotGroupIdx,
selectedSnapshotIdx,
showLegend,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -4714,6 +4743,7 @@ function Show1DWidget() {
imageCmap,
logScale,
plotHeightPx,
plotWidthPx,
resetBaselineReady,
selectedSnapshotGroupIdx,
selectedSnapshotIdx,
Expand Down Expand Up @@ -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 ? (
<Box data-testid="show1d-stats-table" sx={{ width: "100%", mb: 0.5 }}>
<Box
Expand Down Expand Up @@ -5333,8 +5363,8 @@ function Show1DWidget() {
ref={rootRef}
data-testid="show1d-root"
sx={{
width: "100%",
maxWidth: "100%",
width: (resizePreview?.width ?? plotWidthPx) > 0 ? `${resizePreview?.width ?? plotWidthPx}px` : "100%",
maxWidth: maxWidth > 0 ? `min(100%, ${maxWidth}px)` : "100%",
bgcolor: themeColors.bg,
color: themeColors.text,
border: "none",
Expand Down
16 changes: 16 additions & 0 deletions src/quantem/widget/show1d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions tests/test_widget_show1d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down