From 11225bbb722881f3694bd39c2db746c0d34be6bf Mon Sep 17 00:00:00 2001 From: Ehrdt Ehrhardt Date: Wed, 10 Jun 2026 21:54:51 -0700 Subject: [PATCH 1/4] add ShowPDF widget --- widget/js/showpdf/index.tsx | 1574 +++++++++++++++++++++++++ widget/scripts/build.mjs | 1 + widget/src/quantem/widget/__init__.py | 3 +- widget/src/quantem/widget/detector.py | 136 +++ widget/src/quantem/widget/showpdf.py | 1260 ++++++++++++++++++++ 5 files changed, 2973 insertions(+), 1 deletion(-) create mode 100644 widget/js/showpdf/index.tsx create mode 100644 widget/src/quantem/widget/detector.py create mode 100644 widget/src/quantem/widget/showpdf.py diff --git a/widget/js/showpdf/index.tsx b/widget/js/showpdf/index.tsx new file mode 100644 index 000000000..f4ad8d9e8 --- /dev/null +++ b/widget/js/showpdf/index.tsx @@ -0,0 +1,1574 @@ +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 Button from "@mui/material/Button"; +import Switch from "@mui/material/Switch"; +import Select from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; +import Slider from "@mui/material/Slider"; +import CircularProgress from "@mui/material/CircularProgress"; +import { useTheme } from "../theme"; +import { COLORMAPS, renderToOffscreen, renderToOffscreenReuse } from "../colormaps"; + +const CMAP_OPTIONS = ["gray", "inferno", "viridis", "magma"] as const; +import { + findDataRange, + percentileClip, + applyLogScale, +} from "../stats"; +import { extractFloat32, formatNumber } from "../format"; +import { roundToNiceValue, drawScaleBarHiDPI } from "../figure"; + +// ============================================================================ +// Constants +// ============================================================================ +const DPR = Math.max(1, window.devicePixelRatio || 1); +const SPACING = { XS: 4, SM: 8, MD: 12, LG: 16 }; +const FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; +const MONO = "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace"; +const MIN_ZOOM = 0.5; +const MAX_ZOOM = 32; + +const TRACE_COLORS = { + ik: "#4fc3f7", + bg: "#ef5350", + fk: "#66bb6a", + gr: "#4fc3f7", + pdf: "#ba68c8", +}; + +const MARGIN_TOP = 12; +const MARGIN_RIGHT = 16; +const MARGIN_BOTTOM = 56; +const MARGIN_LEFT_MIN = 72; +const AXIS_TICK_PX = 4; +const TICK_LABEL_W = 50; + +// ============================================================================ +// Helpers +// ============================================================================ +function snap(v: number): number { + return Math.round(v) + 0.5; +} + +function computeTicks(min: number, max: number, maxTicks = 8): number[] { + const range = max - min; + if (range <= 0 || !isFinite(range)) return [min, max]; + const step = roundToNiceValue(range / maxTicks); + if (step <= 0) return [min, max]; + const start = Math.ceil(min / step) * step; + const ticks: number[] = []; + for (let v = start; v <= max + step * 0.001; v += step) { + if (v >= min - step * 0.001) ticks.push(v); + } + return ticks.length > 0 ? ticks : [min, max]; +} + +function fillRectMask( + mask: Uint8Array, w: number, h: number, + r0: number, c0: number, r1: number, c1: number, value: number, +) { + const minR = Math.max(0, Math.min(r0, r1)); + const maxR = Math.min(h - 1, Math.max(r0, r1)); + const minC = Math.max(0, Math.min(c0, c1)); + const maxC = Math.min(w - 1, Math.max(c0, c1)); + for (let r = minR; r <= maxR; r++) + for (let c = minC; c <= maxC; c++) + mask[r * w + c] = value; +} + +function fillCircleMask( + mask: Uint8Array, w: number, h: number, + cr: number, cc: number, radius: number, value: number, +) { + const r2 = radius * radius; + const rMin = Math.max(0, Math.floor(cr - radius)); + const rMax = Math.min(h - 1, Math.ceil(cr + radius)); + const cMin = Math.max(0, Math.floor(cc - radius)); + const cMax = Math.min(w - 1, Math.ceil(cc + radius)); + for (let r = rMin; r <= rMax; r++) + for (let c = cMin; c <= cMax; c++) + if ((r - cr) ** 2 + (c - cc) ** 2 <= r2) + mask[r * w + c] = value; +} + +// ============================================================================ +// Main component +// ============================================================================ +function ShowPDFWidget() { + const model = useModel(); + const { colors } = useTheme(); + const isDark = colors.bg === "#1e1e1e"; + + // --- Model state --- + const [title] = useModelState("title"); + const [scanRows] = useModelState("scan_rows"); + const [scanCols] = useModelState("scan_cols"); + const [navImageBytes] = useModelState("nav_image_bytes"); + const [navPixelSize] = useModelState("nav_pixel_size"); + const [navUnit] = useModelState("nav_unit"); + const [maskPixelCount] = useModelState("mask_pixel_count"); + const [maskFraction] = useModelState("mask_fraction"); + const [maskVersion, setMaskVersion_model] = useModelState("mask_version"); + const [maskTool, setMaskTool] = useModelState("mask_tool"); + const [maskBrushSize, setMaskBrushSize] = useModelState("mask_brush_size"); + const [ikXBytes] = useModelState("ik_x_bytes"); + const [ikYBytes] = useModelState("ik_y_bytes"); + const [ikBgYBytes] = useModelState("ik_bg_y_bytes"); + const [fkXBytes] = useModelState("fk_x_bytes"); + const [fkYBytes] = useModelState("fk_y_bytes"); + const [grXBytes] = useModelState("gr_x_bytes"); + const [grYBytes] = useModelState("gr_y_bytes"); + const [pdfXBytes] = useModelState("pdf_x_bytes"); + const [pdfYBytes] = useModelState("pdf_y_bytes"); + const [kMinFit, setKMinFit] = useModelState("k_min_fit"); + const [kMaxFit, setKMaxFit] = useModelState("k_max_fit"); + const [kMinWindow, setKMinWindow] = useModelState("k_min_window"); + const [kMaxWindow, setKMaxWindow] = useModelState("k_max_window"); + const [rMax, setRMax] = useModelState("r_max"); + const [kLowpass, setKLowpass] = useModelState("k_lowpass"); + const [kHighpass, setKHighpass] = useModelState("k_highpass"); + const [dampOrigin, setDampOrigin] = useModelState("damp_origin_oscillations"); + const [rCut, setRCut] = useModelState("r_cut"); + const [densityMode, setDensityMode] = useModelState("density_mode"); + const [densityValue, setDensityValue] = useModelState("density_value"); + const [kMinAvail] = useModelState("k_min_available"); + const [kMaxAvail] = useModelState("k_max_available"); + const [plotMode, setPlotMode] = useModelState("plot_mode"); + const [showBackground, setShowBackground] = useModelState("show_background"); + const [cmap, setCmap] = useModelState("cmap"); + const [logScale] = useModelState("log_scale"); + const [autoContrast] = useModelState("auto_contrast"); + const [showStats] = useModelState("show_stats"); + const [showControls] = useModelState("show_controls"); + const [computing] = useModelState("computing"); + const [statusMessage] = useModelState("status_message"); + const [analysisMode, setAnalysisMode] = useModelState("analysis_mode"); + // ShowPDF no longer surfaces registry-driven tool-visibility traits; all + // UI groups are always visible/enabled. Constants kept as `false` here so + // the downstream JSX conditionals (`disabled={lockMask}`, etc.) stay valid + // without rewriting every call site. If per-feature visibility traits are + // ever wanted, expose them in showpdf.py with the show2d trait style. + const lockMask = false; + const hideMask = false; + const lockDisplay = false; + const lockParameters = false; + const hideParameters = false; + const hideStats = false; + const [probeRow, setProbeRow] = useModelState("probe_row"); + const [probeCol, setProbeCol] = useModelState("probe_col"); + const [probeSize, setProbeSize] = useModelState("probe_size"); + const [lineRow0] = useModelState("line_row0"); + const [lineCol0] = useModelState("line_col0"); + const [lineRow1] = useModelState("line_row1"); + const [lineCol1] = useModelState("line_col1"); + const [lineWidth, setLineWidth] = useModelState("line_width"); + const [lineActive] = useModelState("line_active"); + const [lineMode, setLineMode] = useModelState("line_mode"); + const [linePerp, setLinePerp] = useModelState("line_perpendicular"); + const [linescanBytes] = useModelState("linescan_bytes"); + const [nLinescan] = useModelState("n_linescan"); + const [linescanNPoints] = useModelState("linescan_n_points"); + const [linescanAxisBytes] = useModelState("linescan_axis_bytes"); + + // --- Local state --- + const PLOT_H = 360; + const NAV_SIZE = PLOT_H; + const PLOT_W = 520; + const navH = Math.round(NAV_SIZE * (scanRows / Math.max(scanCols, 1))); + const [navZoom, setNavZoom] = React.useState(1); + const [navPanX, setNavPanX] = React.useState(0); + const [navPanY, setNavPanY] = React.useState(0); + const [maskAction, setMaskAction] = React.useState<"add" | "subtract">("add"); + const [maskRenderVersion, setMaskRenderVersion] = React.useState(0); + const [shapePreview, setShapePreview] = React.useState<{ r0: number; c0: number; r1: number; c1: number } | null>(null); + const [plotXMin, setPlotXMin] = React.useState(0); + const [plotXMax, setPlotXMax] = React.useState(10); + const [plotYMin, setPlotYMin] = React.useState(-1); + const [plotYMax, setPlotYMax] = React.useState(1); + const [ikLogScale, setIkLogScale] = React.useState(true); + const [cursorData, setCursorData] = React.useState<{ x: number; y: number } | null>(null); + // Heatmap hover readout: position along line, r/k, and the stack value there. + const [hmHover, setHmHover] = React.useState<{ pos: number; r: number; val: number } | null>(null); + const [localProbe, setLocalProbe] = React.useState<{ row: number; col: number }>({ row: probeRow, col: probeCol }); + const probeGenRef = React.useRef(0); + const isDraggingProbeRef = React.useRef(false); + // Line: local endpoints (scan coords) mirror the model for smooth dragging. + const [localLine, setLocalLine] = React.useState<{ r0: number; c0: number; r1: number; c1: number }>( + { r0: lineRow0, c0: lineCol0, r1: lineRow1, c1: lineCol1 }); + const localLineRef = React.useRef(localLine); + const lineGenRef = React.useRef(0); + const lineVersionRef = React.useRef(0); + const [localLineWidth, setLocalLineWidth] = React.useState(lineWidth); + const isDrawingLineRef = React.useRef(false); + const isDraggingLineBodyRef = React.useRef(false); + const draggingEndpointRef = React.useRef<0 | 1 | null>(null); + const lineDrawStartRef = React.useRef<{ row: number; col: number } | null>(null); + const lineBodyStartRef = React.useRef<{ imgX: number; imgY: number; line: { r0: number; c0: number; r1: number; c1: number } } | null>(null); + // Heatmap vertical reference guides (constant r/k) + geometry for click→r mapping. + const [hmGuides, setHmGuides] = React.useState([]); + const heatmapGeomRef = React.useRef<{ rLo: number; rHi: number; pwH: number; lenUnits: number; N: number; M: number; data: Float32Array } | null>(null); + const draggingGuideRef = React.useRef<{ index: number; moved: boolean; added: boolean } | null>(null); + const [localKFit, setLocalKFit] = React.useState<[number, number]>([kMinFit, kMaxFit]); + const [localKWin, setLocalKWin] = React.useState<[number, number]>([kMinWindow, kMaxWindow]); + const [localRMax, setLocalRMax] = React.useState(rMax); + const [localKLowpass, setLocalKLowpass] = React.useState(kLowpass); + const [localKHighpass, setLocalKHighpass] = React.useState(kHighpass); + const [localRCut, setLocalRCut] = React.useState(rCut); + const [localDensity, setLocalDensity] = React.useState(String(densityValue)); + const [dataVersion, setDataVersion] = React.useState(0); + + React.useEffect(() => { setLocalKFit([kMinFit, kMaxFit]); }, [kMinFit, kMaxFit]); + React.useEffect(() => { setLocalKWin([kMinWindow, kMaxWindow]); }, [kMinWindow, kMaxWindow]); + React.useEffect(() => { setLocalRMax(rMax); }, [rMax]); + React.useEffect(() => { setLocalKLowpass(kLowpass); }, [kLowpass]); + React.useEffect(() => { setLocalKHighpass(kHighpass); }, [kHighpass]); + React.useEffect(() => { setLocalRCut(rCut); }, [rCut]); + React.useEffect(() => { setLocalDensity(densityValue.toPrecision(4)); }, [densityValue]); + React.useEffect(() => { setLocalProbe({ row: probeRow, col: probeCol }); }, [probeRow, probeCol]); + React.useEffect(() => { + const nl = { r0: lineRow0, c0: lineCol0, r1: lineRow1, c1: lineCol1 }; + localLineRef.current = nl; + setLocalLine(nl); + }, [lineRow0, lineCol0, lineRow1, lineCol1]); + React.useEffect(() => { setLocalLineWidth(lineWidth); }, [lineWidth]); + // Line length (scan px) and undirected orientation (0°=horizontal, 90°=vertical). + const lineLenPx = Math.hypot(localLine.r1 - localLine.r0, localLine.c1 - localLine.c0); + const lineOrientDeg = ((Math.atan2(-(localLine.r1 - localLine.r0), localLine.c1 - localLine.c0) * 180 / Math.PI) % 180 + 180) % 180; + // The line actually sampled: perpendicular bisector of the drawn line when + // line_perpendicular is on, else the drawn line itself (mirrors Python). + function effectiveLine(l: { r0: number; c0: number; r1: number; c1: number }) { + if (!linePerp) return l; + const dr = l.r1 - l.r0, dc = l.c1 - l.c0, L = Math.hypot(dr, dc); + if (L === 0) return l; + const mr = (l.r0 + l.r1) / 2, mc = (l.c0 + l.c1) / 2, pr = -dc / L, pc = dr / L, half = L / 2; + return { r0: mr - pr * half, c0: mc - pc * half, r1: mr + pr * half, c1: mc + pc * half }; + } + + const userZoomedRef = React.useRef(false); + + // --- Refs --- + const navCanvasRef = React.useRef(null); + const navUiRef = React.useRef(null); // High-DPI overlay for scale bar + const navOverlayRef = React.useRef(null); + const navOffscreenRef = React.useRef(null); + const navImgDataRef = React.useRef(null); + const rawNavRef = React.useRef(null); + const maskRef = React.useRef(null); + const plotCanvasRef = React.useRef(null); + const ikXRef = React.useRef(null); + const ikYRef = React.useRef(null); + const ikBgRef = React.useRef(null); + const fkXRef = React.useRef(null); + const fkYRef = React.useRef(null); + const grXRef = React.useRef(null); + const grYRef = React.useRef(null); + const pdfXRef = React.useRef(null); + const pdfYRef = React.useRef(null); + const isPanningRef = React.useRef(false); + const panStartRef = React.useRef<{ x: number; y: number; px: number; py: number } | null>(null); + const isPaintingRef = React.useRef(false); + const shapeStartRef = React.useRef<{ row: number; col: number } | null>(null); + const isDraggingShapeRef = React.useRef(false); + const isPlotPanRef = React.useRef(false); + const plotPanStartRef = React.useRef<{ mx: number; my: number; xMin: number; xMax: number; yMin: number; yMax: number } | null>(null); + + // ========================================================================= + // Effect 1: Parse nav image + // ========================================================================= + React.useEffect(() => { + if (!navImageBytes || navImageBytes.byteLength < 4) return; + const raw = extractFloat32(navImageBytes); + if (!raw) return; + rawNavRef.current = raw; + if (!navOffscreenRef.current || navOffscreenRef.current.width !== scanCols || navOffscreenRef.current.height !== scanRows) { + const oc = document.createElement("canvas"); + oc.width = scanCols; + oc.height = scanRows; + navOffscreenRef.current = oc; + navImgDataRef.current = new ImageData(scanCols, scanRows); + } + if (!maskRef.current || maskRef.current.length !== scanRows * scanCols) { + maskRef.current = new Uint8Array(scanRows * scanCols).fill(1); + } + setDataVersion((v) => v + 1); + }, [navImageBytes, scanRows, scanCols]); + + // Effect 1b: Init mask from Python + React.useEffect(() => { + const raw = model.get("mask_bytes") as DataView | undefined; + if (!raw || raw.byteLength === 0) { + if (scanRows > 0 && scanCols > 0) + maskRef.current = new Uint8Array(scanRows * scanCols).fill(1); + } else { + maskRef.current = new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength).slice(); + } + setMaskRenderVersion((v) => v + 1); + }, [model, scanRows, scanCols]); + + // ========================================================================= + // Effect 2: Render nav to offscreen (expensive) + // ========================================================================= + React.useEffect(() => { + const raw = rawNavRef.current; + const oc = navOffscreenRef.current; + const imgData = navImgDataRef.current; + if (!raw || !oc || !imgData) return; + const lut = COLORMAPS[cmap] || COLORMAPS.inferno; + const processed = logScale ? applyLogScale(raw) : raw; + let vmin: number, vmax: number; + if (autoContrast) { + ({ vmin, vmax } = percentileClip(processed, 2, 98)); + } else { + const range = findDataRange(processed); + vmin = range.min; + vmax = range.max; + } + renderToOffscreenReuse(processed, lut, vmin, vmax, oc, imgData); + setDataVersion((v) => v + 1); + }, [cmap, logScale, autoContrast, dataVersion]); + + // ========================================================================= + // Effect 3: Draw nav canvas (cheap) + // ========================================================================= + React.useLayoutEffect(() => { + const cvs = navCanvasRef.current; + const oc = navOffscreenRef.current; + if (!cvs || !oc || oc.width === 0) return; + const w = NAV_SIZE * DPR; + const h = navH * DPR; + cvs.width = w; + cvs.height = h; + const ctx = cvs.getContext("2d"); + if (!ctx) return; + ctx.clearRect(0, 0, w, h); + ctx.save(); + ctx.translate(w / 2 + navPanX * DPR, h / 2 + navPanY * DPR); + ctx.scale(navZoom, navZoom); + ctx.translate(-w / 2, -h / 2); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(oc, 0, 0, scanCols, scanRows, 0, 0, w, h); + ctx.restore(); + }, [dataVersion, navZoom, navPanX, navPanY, navH, scanCols, scanRows]); + + // ========================================================================= + // Effect 3b: Nav scale bar (HiDPI overlay) + // ========================================================================= + React.useEffect(() => { + const cvs = navUiRef.current; + if (!cvs) return; + cvs.width = NAV_SIZE * DPR; + cvs.height = navH * DPR; + const unit = (navUnit === "Å" ? "Å" : "px") as "Å" | "px"; + drawScaleBarHiDPI(cvs, DPR, navZoom, navPixelSize || 1, unit, scanCols); + }, [navZoom, navPanX, navPanY, navPixelSize, navUnit, scanCols, scanRows, navH]); + + // ========================================================================= + // Effect 4: Render mask overlay + // ========================================================================= + React.useLayoutEffect(() => { + const cvs = navOverlayRef.current; + if (!cvs) return; + const w = NAV_SIZE * DPR; + const h = navH * DPR; + cvs.width = w; + cvs.height = h; + const ctx = cvs.getContext("2d"); + if (!ctx) return; + ctx.clearRect(0, 0, w, h); + const scX = (NAV_SIZE * DPR) / Math.max(scanCols, 1); + const scY = (navH * DPR) / Math.max(scanRows, 1); + + // Probe mode: draw a single circle inscribed in the synthetic square at (localProbe). + if (analysisMode === "probe") { + const half = Math.max(0, probeSize - 1); + const r0 = localProbe.row - half; + const c0 = localProbe.col - half; + const sideScan = 2 * half + 1; + const sx = c0 * scX; + const sy = r0 * scY; + const sw = sideScan * scX; + const sh = sideScan * scY; + ctx.save(); + ctx.translate(w / 2 + navPanX * DPR, h / 2 + navPanY * DPR); + ctx.scale(navZoom, navZoom); + ctx.translate(-w / 2, -h / 2); + // Translucent fill of the square footprint (data actually used) + ctx.fillStyle = "rgba(100,200,255,0.18)"; + ctx.fillRect(sx, sy, sw, sh); + // Inscribed circle outline (visual probe) + ctx.strokeStyle = "rgba(100,200,255,1.0)"; + ctx.lineWidth = 2 / navZoom; + ctx.setLineDash([]); + ctx.beginPath(); + ctx.ellipse(sx + sw / 2, sy + sh / 2, sw / 2, sh / 2, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + return; + } + + // Line mode: draw the reference line (the drawn line — its endpoints are the + // drag handles) and the scan line (perpendicular bisector when ⟂ is on). + if (analysisMode === "line") { + const ll = localLine; + const degenerate = ll.r0 === ll.r1 && ll.c0 === ll.c1; + if (!lineActive && !isDrawingLineRef.current && degenerate) return; + const sl = effectiveLine(ll); + const refAx = (ll.c0 + 0.5) * scX, refAy = (ll.r0 + 0.5) * scY; + const refBx = (ll.c1 + 0.5) * scX, refBy = (ll.r1 + 0.5) * scY; + const ax = (sl.c0 + 0.5) * scX, ay = (sl.r0 + 0.5) * scY; // scan start (A) + const bx = (sl.c1 + 0.5) * scX, by = (sl.r1 + 0.5) * scY; // scan end (B) + const bandPx = Math.max(0.5, localLineWidth) * (scX + scY) / 2; + ctx.save(); + ctx.translate(w / 2 + navPanX * DPR, h / 2 + navPanY * DPR); + ctx.scale(navZoom, navZoom); + ctx.translate(-w / 2, -h / 2); + // Reference line (dimmed, dashed) + its drag handles — only when ⟂, since + // otherwise the reference and scan lines coincide. + if (linePerp) { + ctx.strokeStyle = "rgba(180,180,180,0.75)"; + ctx.lineWidth = 1.5 / navZoom; + ctx.setLineDash([4 / navZoom, 3 / navZoom]); + ctx.beginPath(); ctx.moveTo(refAx, refAy); ctx.lineTo(refBx, refBy); ctx.stroke(); + ctx.setLineDash([]); + const rh = 3.5 / navZoom; + ctx.fillStyle = "rgba(200,200,200,0.95)"; + for (const [ex, ey] of [[refAx, refAy], [refBx, refBy]] as const) { + ctx.beginPath(); ctx.rect(ex - rh, ey - rh, rh * 2, rh * 2); ctx.fill(); + } + } + // Translucent band footprint (selected positions) along the scan line — + // flat ends (butt) so it's a rectangle of even width, matching the mask. + ctx.strokeStyle = "rgba(100,200,255,0.20)"; + ctx.lineWidth = bandPx; + ctx.lineCap = "butt"; + ctx.beginPath(); + ctx.moveTo(ax, ay); + ctx.lineTo(bx, by); + ctx.stroke(); + // Center segment + ctx.strokeStyle = "rgba(100,200,255,1.0)"; + ctx.lineWidth = 2 / navZoom; + ctx.lineCap = "butt"; + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(ax, ay); + ctx.lineTo(bx, by); + ctx.stroke(); + // Arrowhead at the end (B) pointing start→end, so direction is unambiguous. + const segLen = Math.hypot(bx - ax, by - ay); + if (segLen > 0) { + const ux = (bx - ax) / segLen, uy = (by - ay) / segLen; + const ah = 9 / navZoom; + const px = -uy, py = ux; // perpendicular + ctx.fillStyle = "rgba(255,110,110,1)"; + ctx.beginPath(); + ctx.moveTo(bx, by); + ctx.lineTo(bx - ah * ux + ah * 0.5 * px, by - ah * uy + ah * 0.5 * py); + ctx.lineTo(bx - ah * ux - ah * 0.5 * px, by - ah * uy - ah * 0.5 * py); + ctx.closePath(); + ctx.fill(); + } + // Scan endpoints: start (A) green, end (B) red, with labels. When ⟂ these + // are not the drag handles (those are on the dimmed reference line above). + const hr = 4 / navZoom; + const ends = [ + [ax, ay, "rgba(100,225,140,1)", "A"], + [bx, by, "rgba(255,110,110,1)", "B"], + ] as const; + for (const [ex, ey, col, lbl] of ends) { + ctx.fillStyle = col; + ctx.beginPath(); + ctx.ellipse(ex, ey, hr, hr, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.font = `${12 / navZoom}px ${FONT}`; + ctx.textAlign = "left"; ctx.textBaseline = "bottom"; + ctx.fillText(lbl, ex + hr + 1 / navZoom, ey - hr); + } + ctx.restore(); + return; + } + + // Mask mode: paint excluded pixels with a translucent overlay. + const mask = maskRef.current; + if (!mask || mask.length === 0) return; + const mc = document.createElement("canvas"); + mc.width = scanCols; + mc.height = scanRows; + const mctx = mc.getContext("2d")!; + const mimg = mctx.createImageData(scanCols, scanRows); + for (let i = 0; i < mask.length; i++) { + if (mask[i] === 0) { + mimg.data[i * 4] = 255; + mimg.data[i * 4 + 1] = 0; + mimg.data[i * 4 + 2] = 0; + mimg.data[i * 4 + 3] = 110; + } + } + mctx.putImageData(mimg, 0, 0); + ctx.save(); + ctx.translate(w / 2 + navPanX * DPR, h / 2 + navPanY * DPR); + ctx.scale(navZoom, navZoom); + ctx.translate(-w / 2, -h / 2); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(mc, 0, 0, scanCols, scanRows, 0, 0, w, h); + ctx.restore(); + // Shape preview + if (shapePreview && isDraggingShapeRef.current) { + const { r0, c0, r1, c1 } = shapePreview; + const scX = (NAV_SIZE * DPR) / Math.max(scanCols, 1); + const scY = (navH * DPR) / Math.max(scanRows, 1); + ctx.save(); + ctx.translate(w / 2 + navPanX * DPR, h / 2 + navPanY * DPR); + ctx.scale(navZoom, navZoom); + ctx.translate(-w / 2, -h / 2); + ctx.strokeStyle = maskAction === "add" ? "rgba(100,200,255,0.9)" : "rgba(255,100,100,0.9)"; + ctx.lineWidth = 2 / navZoom; + ctx.setLineDash([4 / navZoom, 4 / navZoom]); + const sx = Math.min(c0, c1) * scX; + const sy = Math.min(r0, r1) * scY; + const sw = Math.abs(c1 - c0) * scX; + const sh = Math.abs(r1 - r0) * scY; + if (maskTool === "circle") { + ctx.beginPath(); + ctx.ellipse((c0 + c1) / 2 * scX, (r0 + r1) / 2 * scY, sw / 2, sh / 2, 0, 0, Math.PI * 2); + ctx.stroke(); + } else { + ctx.strokeRect(sx, sy, sw, sh); + } + ctx.restore(); + } + }, [maskRenderVersion, navZoom, navPanX, navPanY, navH, scanRows, scanCols, shapePreview, maskAction, maskTool, analysisMode, localProbe, probeSize, localLine, localLineWidth, lineActive, linePerp]); + + // ========================================================================= + // Effect 5: Parse curve bytes + auto-fit + // ========================================================================= + React.useEffect(() => { + ikXRef.current = ikXBytes ? extractFloat32(ikXBytes) : null; + ikYRef.current = ikYBytes ? extractFloat32(ikYBytes) : null; + ikBgRef.current = ikBgYBytes ? extractFloat32(ikBgYBytes) : null; + fkXRef.current = fkXBytes ? extractFloat32(fkXBytes) : null; + fkYRef.current = fkYBytes ? extractFloat32(fkYBytes) : null; + grXRef.current = grXBytes ? extractFloat32(grXBytes) : null; + grYRef.current = grYBytes ? extractFloat32(grYBytes) : null; + pdfXRef.current = pdfXBytes ? extractFloat32(pdfXBytes) : null; + pdfYRef.current = pdfYBytes ? extractFloat32(pdfYBytes) : null; + if (!userZoomedRef.current) autoFitPlot(); + }, [ikXBytes, ikYBytes, ikBgYBytes, fkXBytes, fkYBytes, grXBytes, grYBytes, pdfXBytes, pdfYBytes]); + + React.useEffect(() => { userZoomedRef.current = false; autoFitPlot(); }, [plotMode]); + // r/k units change with plot mode, so existing guide positions become invalid. + React.useEffect(() => { setHmGuides([]); }, [plotMode]); + + function autoFitPlot() { + let xArr: Float32Array | null = null; + let yArr: Float32Array | null = null; + if (plotMode === "Ik") { xArr = ikXRef.current; yArr = ikYRef.current; } + else if (plotMode === "Fk") { xArr = fkXRef.current; yArr = fkYRef.current; } + else if (plotMode === "gr") { xArr = pdfXRef.current; yArr = pdfYRef.current; } + else { xArr = grXRef.current; yArr = grYRef.current; } + if (!xArr || !yArr || xArr.length === 0) return; + const xR = findDataRange(xArr); + const useLog = plotMode === "Ik" && ikLogScale; + const yR = findDataRange(yArr); + setPlotXMin(xR.min); + setPlotXMax(xR.max); + if (useLog) { + const logMin = yR.min > 0 ? Math.log10(yR.min) : 0; + const logMax = yR.max > 0 ? Math.log10(yR.max) : 1; + const logPad = (logMax - logMin) * 0.05 || 0.1; + setPlotYMin(logMin - logPad); + setPlotYMax(logMax + logPad); + } else { + const yPad = (yR.max - yR.min) * 0.05 || 0.1; + setPlotYMin(yR.min - yPad); + setPlotYMax(yR.max + yPad); + } + } + + // Value of the currently displayed 1D curve at a given x (linear interp). + function curveValueAt(x: number): number | null { + let xa: Float32Array | null, ya: Float32Array | null; + if (plotMode === "Ik") { xa = ikXRef.current; ya = ikYRef.current; } + else if (plotMode === "Fk") { xa = fkXRef.current; ya = fkYRef.current; } + else if (plotMode === "gr") { xa = pdfXRef.current; ya = pdfYRef.current; } + else { xa = grXRef.current; ya = grYRef.current; } + if (!xa || !ya || xa.length === 0) return null; + const n = Math.min(xa.length, ya.length); + if (x <= xa[0]) return ya[0]; + if (x >= xa[n - 1]) return ya[n - 1]; + let lo = 0, hi = n - 1; + while (hi - lo > 1) { const m = (lo + hi) >> 1; if (xa[m] <= x) lo = m; else hi = m; } + const span = xa[hi] - xa[lo]; + const t = span !== 0 ? (x - xa[lo]) / span : 0; + return ya[lo] + t * (ya[hi] - ya[lo]); + } + + // ========================================================================= + // Effect 6: Render 1D plot + // ========================================================================= + React.useLayoutEffect(() => { + const cvs = plotCanvasRef.current; + if (!cvs) return; + const cw = PLOT_W * DPR; + const ch = PLOT_H * DPR; + cvs.width = cw; + cvs.height = ch; + const ctx = cvs.getContext("2d"); + if (!ctx) return; + ctx.scale(DPR, DPR); + const mL = MARGIN_LEFT_MIN, mT = MARGIN_TOP, mR = MARGIN_RIGHT, mB = MARGIN_BOTTOM; + const pw = PLOT_W - mL - mR; + const ph = PLOT_H - mT - mB; + if (pw <= 0 || ph <= 0) return; + + // Line-scan heatmap: r/k (x) vs position-along-line (y), color = curve value. + if (analysisMode === "line" && lineMode === "linescan") { + const CB_RESERVE = 52; // right-side room for the colorbar + labels + const pwH = pw - CB_RESERVE; // heatmap plot width (x = r/k) + ctx.fillStyle = isDark ? "#1a1a1a" : "#f8f8f8"; + ctx.fillRect(0, 0, PLOT_W, PLOT_H); + const N = nLinescan, M = linescanNPoints; + const ready = N > 0 && M > 0 && !!linescanBytes && linescanBytes.byteLength >= N * M * 4; + if (!ready) { + heatmapGeomRef.current = null; + ctx.fillStyle = isDark ? "#888" : "#777"; + ctx.font = `14px ${FONT}`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; + ctx.fillText(lineActive ? "Computing line-scan…" : "Draw a line to compute the line-scan", mL + pwH / 2, mT + ph / 2); + return; + } + const data = extractFloat32(linescanBytes); + if (!data) return; + const axis = linescanAxisBytes && linescanAxisBytes.byteLength >= M * 4 ? extractFloat32(linescanAxisBytes) : null; + let nf = 0; + for (let i = 0; i < N * M; i++) if (isFinite(data[i])) nf++; + const finite = new Float32Array(nf); + let fi = 0; + for (let i = 0; i < N * M; i++) { const v = data[i]; if (isFinite(v)) finite[fi++] = v; } + const { vmin, vmax } = percentileClip(finite, 1, 99); + const lut = COLORMAPS.viridis || COLORMAPS.inferno; + // Offscreen M (x = r/k) × N (y = position); col 0 = smallest r/k (left), + // row 0 = top = end of line (position increases upward, start (A) at bottom). + const img = new Float32Array(M * N); + for (let bin = 0; bin < N; bin++) + for (let j = 0; j < M; j++) { const v = data[bin * M + j]; img[(N - 1 - bin) * M + j] = isFinite(v) ? v : vmin; } + const off = renderToOffscreen(img, M, N, lut, vmin, vmax); + if (off) { ctx.imageSmoothingEnabled = true; ctx.drawImage(off, 0, 0, M, N, mL, mT, pwH, ph); } + const lenScan = Math.hypot(localLine.r1 - localLine.r0, localLine.c1 - localLine.c0); + const calibrated = navUnit === "Å" && navPixelSize > 0; + const lenUnits = calibrated ? lenScan * navPixelSize : lenScan; + const rLo = axis ? axis[0] : 0, rHi = axis ? axis[M - 1] : 1; + heatmapGeomRef.current = { rLo, rHi, pwH, lenUnits, N, M, data }; + const hx = (rv: number) => mL + ((rv - rLo) / ((rHi - rLo) || 1)) * pwH; // r/k → x + const hy = (pos: number) => mT + ph - (pos / (lenUnits || 1)) * ph; // position → y + // Vertical guide lines (constant r/k) for checking how straight features are. + if (hmGuides.length) { + for (const gv of hmGuides) { + const cx = snap(hx(gv)); + if (cx < mL || cx > mL + pwH) continue; + ctx.strokeStyle = isDark ? "rgba(255,255,255,0.85)" : "rgba(0,0,0,0.8)"; + ctx.lineWidth = 1; ctx.setLineDash([5, 4]); + ctx.beginPath(); ctx.moveTo(cx, mT); ctx.lineTo(cx, mT + ph); ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = isDark ? "#eee" : "#222"; ctx.font = `10px ${MONO}`; + ctx.textAlign = "center"; ctx.textBaseline = "top"; + ctx.fillText(formatNumber(gv), cx, mT + 2); + } + } + // Axes + ctx.strokeStyle = isDark ? "#666" : "#999"; ctx.lineWidth = 1; ctx.setLineDash([]); + ctx.beginPath(); ctx.moveTo(snap(mL), mT); ctx.lineTo(snap(mL), snap(mT + ph)); ctx.lineTo(mL + pwH, snap(mT + ph)); ctx.stroke(); + ctx.fillStyle = isDark ? "#aaa" : "#555"; ctx.font = `13px ${FONT}`; + ctx.textAlign = "center"; ctx.textBaseline = "top"; // X ticks: r/k + for (const tv of computeTicks(rLo, rHi, Math.max(3, Math.floor(pwH / TICK_LABEL_W)))) { + const cx = snap(hx(tv)); if (cx < mL - 1 || cx > mL + pwH + 1) continue; + ctx.beginPath(); ctx.moveTo(cx, mT + ph); ctx.lineTo(cx, mT + ph + AXIS_TICK_PX); ctx.stroke(); + ctx.fillText(formatNumber(tv), cx, mT + ph + AXIS_TICK_PX + 2); + } + ctx.textAlign = "right"; ctx.textBaseline = "middle"; // Y ticks: position + for (const tv of computeTicks(0, lenUnits, Math.max(3, Math.floor(ph / 40)))) { + const cy = snap(hy(tv)); if (cy < mT - 1 || cy > mT + ph + 1) continue; + ctx.beginPath(); ctx.moveTo(mL, cy); ctx.lineTo(mL - AXIS_TICK_PX, cy); ctx.stroke(); + ctx.fillText(formatNumber(tv), mL - AXIS_TICK_PX - 2, cy); + } + ctx.font = `14px ${FONT}`; ctx.fillStyle = isDark ? "#ccc" : "#333"; ctx.textAlign = "center"; ctx.textBaseline = "top"; + ctx.fillText((plotMode === "Gr" || plotMode === "gr") ? "r (Å)" : "k (Å⁻¹)", mL + pwH / 2, mT + ph + AXIS_TICK_PX + 26); + ctx.save(); ctx.translate(14, mT + ph / 2); ctx.rotate(-Math.PI / 2); ctx.textAlign = "center"; ctx.textBaseline = "top"; + ctx.fillText(calibrated ? "position (Å)" : "position (px)", 0, 0); ctx.restore(); + // Start/end markers tying the heatmap to the nav line endpoints. + ctx.font = `11px ${MONO}`; ctx.textAlign = "left"; + ctx.fillStyle = "rgba(255,110,110,1)"; ctx.textBaseline = "top"; + ctx.fillText("B", mL + 4, mT + 3); + ctx.fillStyle = "rgba(100,225,140,1)"; ctx.textBaseline = "bottom"; + ctx.fillText("A", mL + 4, mT + ph - 3); + // Colorbar + const barX = mL + pwH + 14, barY = mT, barH = ph, barW = 12; + for (let row = 0; row < barH; row++) { + const t = 1 - row / Math.max(1, barH - 1); const li = Math.round(t * 255) * 3; + ctx.fillStyle = `rgb(${lut[li]},${lut[li + 1]},${lut[li + 2]})`; + ctx.fillRect(barX, barY + row, barW, 1); + } + ctx.strokeStyle = isDark ? "#666" : "#999"; ctx.lineWidth = 1; ctx.strokeRect(snap(barX), snap(barY), barW, barH); + ctx.fillStyle = isDark ? "#aaa" : "#555"; ctx.font = `10px ${MONO}`; ctx.textAlign = "left"; + ctx.textBaseline = "top"; ctx.fillText(formatNumber(vmax), barX + barW + 3, barY); + ctx.textBaseline = "middle"; ctx.fillText(formatNumber((vmin + vmax) / 2), barX + barW + 3, barY + barH / 2); + ctx.textBaseline = "bottom"; ctx.fillText(formatNumber(vmin), barX + barW + 3, barY + barH); + // Hover crosshair + value readout (position, r, stack value). + if (hmHover) { + const cx = snap(hx(hmHover.r)), cy = snap(hy(hmHover.pos)); + if (cx >= mL && cx <= mL + pwH && cy >= mT && cy <= mT + ph) { + ctx.strokeStyle = isDark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)"; + ctx.lineWidth = 1; ctx.setLineDash([3, 3]); + ctx.beginPath(); ctx.moveTo(cx, mT); ctx.lineTo(cx, mT + ph); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(mL, cy); ctx.lineTo(mL + pwH, cy); ctx.stroke(); + ctx.setLineDash([]); + const label = `${formatNumber(hmHover.r)}, ${formatNumber(hmHover.pos)} → ${formatNumber(hmHover.val)}`; + ctx.font = `12px ${MONO}`; + const tw = ctx.measureText(label).width + 8; + let bx = cx + 10; if (bx + tw > mL + pwH) bx = cx - tw - 10; + let by = Math.max(mT, Math.min(mT + ph - 18, cy - 22)); + ctx.fillStyle = isDark ? "rgba(30,30,30,0.92)" : "rgba(255,255,255,0.92)"; + ctx.fillRect(bx, by, tw, 18); + ctx.strokeStyle = isDark ? "#555" : "#ccc"; ctx.lineWidth = 1; ctx.strokeRect(bx, by, tw, 18); + ctx.fillStyle = isDark ? "#eee" : "#333"; ctx.textAlign = "left"; ctx.textBaseline = "middle"; + ctx.fillText(label, bx + 4, by + 9); + } + } + if (computing) { ctx.fillStyle = isDark ? "rgba(0,0,0,0.3)" : "rgba(255,255,255,0.3)"; ctx.fillRect(mL, mT, pwH, ph); } + return; + } + + const xMin = plotXMin, xMax = plotXMax, yMin = plotYMin, yMax = plotYMax; + const xRange = xMax - xMin || 1; + const yRange = yMax - yMin || 1; + const d2cx = (dx: number) => mL + ((dx - xMin) / xRange) * pw; + const useLogY = plotMode === "Ik" && ikLogScale; + // Maps axis-space value to canvas y (yMin/yMax are already in log10 space when useLogY) + const d2cy = (dy: number) => mT + ph - ((dy - yMin) / yRange) * ph; + // Maps raw data value to canvas y, applying log10 when needed + const d2cyData = (dy: number) => { + const v = useLogY ? (dy > 0 ? Math.log10(dy) : yMin) : dy; + return mT + ph - ((v - yMin) / yRange) * ph; + }; + + // Background + ctx.fillStyle = isDark ? "#1a1a1a" : "#f8f8f8"; + ctx.fillRect(0, 0, PLOT_W, PLOT_H); + // Grid + ctx.strokeStyle = isDark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)"; + ctx.lineWidth = 1; + ctx.setLineDash([2, 3]); + const xTicks = computeTicks(xMin, xMax, Math.max(3, Math.floor(pw / TICK_LABEL_W))); + for (const tv of xTicks) { const cx = snap(d2cx(tv)); ctx.beginPath(); ctx.moveTo(cx, mT); ctx.lineTo(cx, mT + ph); ctx.stroke(); } + const yTicks = computeTicks(yMin, yMax, Math.max(3, Math.floor(ph / 40))); + for (const tv of yTicks) { const cy = snap(d2cy(tv)); ctx.beginPath(); ctx.moveTo(mL, cy); ctx.lineTo(mL + pw, cy); ctx.stroke(); } + ctx.setLineDash([]); + // Axes + ctx.strokeStyle = isDark ? "#666" : "#999"; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(snap(mL), mT); ctx.lineTo(snap(mL), snap(mT + ph)); ctx.lineTo(mL + pw, snap(mT + ph)); ctx.stroke(); + // X ticks + ctx.fillStyle = isDark ? "#aaa" : "#555"; + ctx.font = `13px ${FONT}`; + ctx.textAlign = "center"; ctx.textBaseline = "top"; + for (const tv of xTicks) { const cx = snap(d2cx(tv)); ctx.beginPath(); ctx.moveTo(cx, mT + ph); ctx.lineTo(cx, mT + ph + AXIS_TICK_PX); ctx.stroke(); ctx.fillText(formatNumber(tv), cx, mT + ph + AXIS_TICK_PX + 2); } + // Y ticks + ctx.textAlign = "right"; ctx.textBaseline = "middle"; + for (const tv of yTicks) { const cy = snap(d2cy(tv)); ctx.beginPath(); ctx.moveTo(mL, cy); ctx.lineTo(mL - AXIS_TICK_PX, cy); ctx.stroke(); ctx.fillText(useLogY ? formatNumber(Math.pow(10, tv)) : formatNumber(tv), mL - AXIS_TICK_PX - 2, cy); } + // Axis labels + ctx.font = `14px ${FONT}`; ctx.fillStyle = isDark ? "#ccc" : "#333"; ctx.textAlign = "center"; ctx.textBaseline = "top"; + const xAxisLabel = (plotMode === "Gr" || plotMode === "gr") ? "r (Å)" : "k (Å⁻¹)"; + ctx.fillText(xAxisLabel, mL + pw / 2, mT + ph + AXIS_TICK_PX + 26); + ctx.save(); ctx.translate(14, mT + ph / 2); ctx.rotate(-Math.PI / 2); ctx.textAlign = "center"; ctx.textBaseline = "top"; + const yAxisLabel = plotMode === "Ik" ? "I(k)" : plotMode === "Fk" ? "F(k)" : plotMode === "gr" ? "g(r)" : "G(r)"; + ctx.fillText(yAxisLabel, 0, 0); ctx.restore(); + // Clip + ctx.save(); ctx.beginPath(); ctx.rect(mL, mT, pw, ph); ctx.clip(); + // Draw traces + const drawLine = (xD: Float32Array | null, yD: Float32Array | null, color: string, dashed = false, lw = 1.5) => { + if (!xD || !yD) return; + ctx.strokeStyle = color; ctx.lineWidth = lw; + if (dashed) ctx.setLineDash([4, 3]); else ctx.setLineDash([]); + ctx.beginPath(); + let started = false; + const len = Math.min(xD.length, yD.length); + for (let i = 0; i < len; i++) { + if (!isFinite(yD[i])) continue; + const cx = d2cx(xD[i]), cy = d2cyData(yD[i]); + if (!started) { ctx.moveTo(cx, cy); started = true; } else ctx.lineTo(cx, cy); + } + ctx.stroke(); ctx.setLineDash([]); + }; + const drawKRange = (k0: number, k1: number, color: string, label0: string, label1: string) => { + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = 2.5; + ctx.setLineDash([14, 6]); + ctx.fillStyle = color; + ctx.font = `11px ${MONO}`; + ctx.textBaseline = "top"; + for (const [kv, lbl, align] of [[k0, label0, "left"], [k1, label1, "right"]] as const) { + if (!isFinite(kv)) continue; + const cx = snap(d2cx(kv)); + if (cx < mL || cx > mL + pw) continue; + ctx.beginPath(); + ctx.moveTo(cx, mT); + ctx.lineTo(cx, mT + ph); + ctx.stroke(); + ctx.textAlign = align; + const tx = align === "left" ? cx + 3 : cx - 3; + ctx.fillText(lbl, tx, mT + 2); + } + ctx.restore(); + }; + if (plotMode === "Ik") { + drawLine(ikXRef.current, ikYRef.current, TRACE_COLORS.ik); + if (showBackground) drawLine(ikXRef.current, ikBgRef.current, TRACE_COLORS.bg, true); + drawKRange(kMinFit, kMaxFit, "#ffb300", "k fit", ""); + } else if (plotMode === "Fk") { + drawLine(fkXRef.current, fkYRef.current, TRACE_COLORS.fk); + drawKRange(kMinWindow, kMaxWindow, "#ffb300", "k window", ""); + } else if (plotMode === "gr") { + ctx.strokeStyle = isDark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.2)"; + ctx.lineWidth = 1; ctx.setLineDash([3, 3]); + const oneY = d2cy(1); ctx.beginPath(); ctx.moveTo(mL, oneY); ctx.lineTo(mL + pw, oneY); ctx.stroke(); ctx.setLineDash([]); + drawLine(pdfXRef.current, pdfYRef.current, TRACE_COLORS.pdf); + } else { + ctx.strokeStyle = isDark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.2)"; + ctx.lineWidth = 1; ctx.setLineDash([3, 3]); + const zy = d2cy(0); ctx.beginPath(); ctx.moveTo(mL, zy); ctx.lineTo(mL + pw, zy); ctx.stroke(); ctx.setLineDash([]); + drawLine(grXRef.current, grYRef.current, TRACE_COLORS.gr); + } + ctx.restore(); + // Crosshair — the readout snaps to the displayed curve's value at cursor x. + if (cursorData) { + const cval = curveValueAt(cursorData.x); + const cx = d2cx(cursorData.x); + if (cval !== null && isFinite(cval) && cx >= mL && cx <= mL + pw) { + const cy = d2cyData(cval); + ctx.strokeStyle = isDark ? "rgba(255,255,255,0.25)" : "rgba(0,0,0,0.25)"; + ctx.lineWidth = 1; ctx.setLineDash([3, 3]); + ctx.beginPath(); ctx.moveTo(snap(cx), mT); ctx.lineTo(snap(cx), mT + ph); ctx.stroke(); + ctx.setLineDash([]); + if (cy >= mT && cy <= mT + ph) { + ctx.fillStyle = isDark ? "#eee" : "#333"; + ctx.beginPath(); ctx.ellipse(cx, cy, 3, 3, 0, 0, Math.PI * 2); ctx.fill(); + } + const label = `${formatNumber(cursorData.x)}, ${formatNumber(cval)}`; + ctx.font = `12px ${MONO}`; + const tw = ctx.measureText(label).width + 8; + let bx = cx + 10; if (bx + tw > mL + pw) bx = cx - tw - 10; + let by = Math.max(mT, Math.min(mT + ph - 18, cy - 22)); + ctx.fillStyle = isDark ? "rgba(30,30,30,0.9)" : "rgba(255,255,255,0.9)"; + ctx.fillRect(bx, by, tw, 18); + ctx.strokeStyle = isDark ? "#555" : "#ccc"; ctx.lineWidth = 1; ctx.strokeRect(bx, by, tw, 18); + ctx.fillStyle = isDark ? "#eee" : "#333"; ctx.textAlign = "left"; ctx.textBaseline = "middle"; + ctx.fillText(label, bx + 4, by + 9); + } + } + if (computing) { + ctx.fillStyle = isDark ? "rgba(0,0,0,0.3)" : "rgba(255,255,255,0.3)"; + ctx.fillRect(mL, mT, pw, ph); + } + }, [PLOT_W, PLOT_H, plotXMin, plotXMax, plotYMin, plotYMax, plotMode, showBackground, ikLogScale, cursorData, computing, isDark, + ikXBytes, ikYBytes, ikBgYBytes, fkXBytes, fkYBytes, grXBytes, grYBytes, pdfXBytes, pdfYBytes, + kMinFit, kMaxFit, kMinWindow, kMaxWindow, + analysisMode, lineMode, nLinescan, linescanNPoints, linescanBytes, linescanAxisBytes, localLine, navPixelSize, navUnit, lineActive, hmGuides, hmHover]); + + // ========================================================================= + // Nav mouse handlers + // ========================================================================= + function screenToImage(e: React.MouseEvent) { + const cvs = navCanvasRef.current; + if (!cvs) return { row: 0, col: 0 }; + const rect = cvs.getBoundingClientRect(); + const sx = e.clientX - rect.left; + const sy = e.clientY - rect.top; + const cx = NAV_SIZE / 2, cy = navH / 2; + const imgX = (sx - cx - navPanX) / navZoom + cx; + const imgY = (sy - cy - navPanY) / navZoom + cy; + return { row: Math.round((imgY / navH) * scanRows), col: Math.round((imgX / NAV_SIZE) * scanCols) }; + } + + const maskVersionRef = React.useRef(maskVersion ?? 0); + function syncMaskToPython() { + if (!maskRef.current) return; + const copy = maskRef.current.slice(); + // Encode mask as base64 string (Bytes traits don't sync JS→Python in anywidget) + let binary = ""; + for (let i = 0; i < copy.length; i++) binary += String.fromCharCode(copy[i]); + model.set("mask_b64", btoa(binary)); + model.save_changes(); + // Increment mask_version to trigger Python observer + maskVersionRef.current += 1; + setMaskVersion_model(maskVersionRef.current); + } + + // Live probe move with rAF coalescing — accumulates rapid mousemoves and + // sends at most one model.set per display frame. + function syncProbeRAF(row: number, col: number) { + const r = Math.max(0, Math.min(scanRows - 1, row)); + const c = Math.max(0, Math.min(scanCols - 1, col)); + setLocalProbe({ row: r, col: c }); + const gen = ++probeGenRef.current; + requestAnimationFrame(() => { + if (gen !== probeGenRef.current) return; + if (r !== probeRow) setProbeRow(r); + if (c !== probeCol) setProbeCol(c); + }); + } + + // Mouse position in un-zoomed canvas (image) coordinates — used for + // hit-testing line endpoints/body in the same space the overlay draws in. + function screenToImgXY(e: React.MouseEvent) { + const cvs = navCanvasRef.current; + if (!cvs) return { imgX: 0, imgY: 0 }; + const rect = cvs.getBoundingClientRect(); + const sx = e.clientX - rect.left, sy = e.clientY - rect.top; + const cx = NAV_SIZE / 2, cy = navH / 2; + return { imgX: (sx - cx - navPanX) / navZoom + cx, imgY: (sy - cy - navPanY) / navZoom + cy }; + } + // Scan (row,col) → image-space pixel center (matches the overlay transform). + function scanToImgXY(row: number, col: number) { + return { imgX: (col + 0.5) / Math.max(scanCols, 1) * NAV_SIZE, imgY: (row + 0.5) / Math.max(scanRows, 1) * navH }; + } + function distToSegmentImg(px: number, py: number, ax: number, ay: number, bx: number, by: number) { + const dx = bx - ax, dy = by - ay; + const lenSq = dx * dx + dy * dy; + let t = lenSq > 0 ? ((px - ax) * dx + (py - ay) * dy) / lenSq : 0; + t = Math.max(0, Math.min(1, t)); + return Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); + } + + function applyLineLocal(line: { r0: number; c0: number; r1: number; c1: number }) { + const clampR = (v: number) => Math.max(0, Math.min(scanRows - 1, v)); + const clampC = (v: number) => Math.max(0, Math.min(scanCols - 1, v)); + const nl = { r0: clampR(line.r0), c0: clampC(line.c0), r1: clampR(line.r1), c1: clampC(line.c1) }; + localLineRef.current = nl; + setLocalLine(nl); + return nl; + } + // Push endpoints + a single line_version bump → one Python recompute. + function commitLine(nl: { r0: number; c0: number; r1: number; c1: number }) { + model.set("line_row0", nl.r0); model.set("line_col0", nl.c0); + model.set("line_row1", nl.r1); model.set("line_col1", nl.c1); + if (!lineActive) model.set("line_active", true); + lineVersionRef.current = (model.get("line_version") as number ?? lineVersionRef.current) + 1; + model.set("line_version", lineVersionRef.current); + model.save_changes(); + } + // During a drag: update the local overlay every frame. In averaged mode also + // commit live (rAF-coalesced, one recompute per frame). In line-scan mode the + // N-PDF compute is heavy, so commit is deferred to mouse-up. + function dragLine(line: { r0: number; c0: number; r1: number; c1: number }) { + const nl = applyLineLocal(line); + if (lineMode !== "linescan") { + const gen = ++lineGenRef.current; + requestAnimationFrame(() => { if (gen === lineGenRef.current) commitLine(nl); }); + } + } + + function clearLine() { + isDrawingLineRef.current = false; + isDraggingLineBodyRef.current = false; + draggingEndpointRef.current = null; + const nl = { r0: 0, c0: 0, r1: 0, c1: 0 }; + localLineRef.current = nl; + setLocalLine(nl); + model.set("line_row0", 0); model.set("line_col0", 0); + model.set("line_row1", 0); model.set("line_col1", 0); + model.set("line_active", false); + lineVersionRef.current = (model.get("line_version") as number ?? 0) + 1; + model.set("line_version", lineVersionRef.current); + model.save_changes(); + } + + const handleNavMouseDown = (e: React.MouseEvent) => { + if (!maskRef.current) return; + e.preventDefault(); + if (e.button === 1 || (e.button === 0 && e.altKey)) { + if (lockDisplay) return; // pan locked + isPanningRef.current = true; + panStartRef.current = { x: e.clientX, y: e.clientY, px: navPanX, py: navPanY }; + return; + } + if (lockMask) return; // mask group locked: blocks paint AND probe drag + const { row, col } = screenToImage(e); + if (analysisMode === "line") { + const hit = 8 / navZoom; // endpoint/body hit radius in image px + const { imgX, imgY } = screenToImgXY(e); + if (lineActive) { + const e0 = scanToImgXY(localLineRef.current.r0, localLineRef.current.c0); + const e1 = scanToImgXY(localLineRef.current.r1, localLineRef.current.c1); + const d0 = Math.hypot(imgX - e0.imgX, imgY - e0.imgY); + const d1 = Math.hypot(imgX - e1.imgX, imgY - e1.imgY); + if (d0 < hit || d1 < hit) { + draggingEndpointRef.current = d0 <= d1 ? 0 : 1; + return; + } + if (distToSegmentImg(imgX, imgY, e0.imgX, e0.imgY, e1.imgX, e1.imgY) < hit) { + isDraggingLineBodyRef.current = true; + lineBodyStartRef.current = { imgX, imgY, line: { ...localLineRef.current } }; + return; + } + } + // Otherwise start drawing a fresh line from this point. + isDrawingLineRef.current = true; + lineDrawStartRef.current = { row, col }; + dragLine({ r0: row, c0: col, r1: row, c1: col }); + return; + } + if (analysisMode === "probe") { + isDraggingProbeRef.current = true; + syncProbeRAF(row, col); + return; + } + // "+" adds to the mask (excludes the area from analysis = 0); + // "-" removes from the mask (re-includes the area = 1). + const value = maskAction === "add" ? 0 : 1; + if (maskTool === "freeform") { + isPaintingRef.current = true; + const half = maskBrushSize - 1; + fillRectMask(maskRef.current, scanCols, scanRows, row - half, col - half, row + half, col + half, value); + setMaskRenderVersion((v) => v + 1); + } else { + isDraggingShapeRef.current = true; + shapeStartRef.current = { row, col }; + setShapePreview({ r0: row, c0: col, r1: row, c1: col }); + } + }; + + const handleNavMouseMove = (e: React.MouseEvent) => { + if (isPanningRef.current && panStartRef.current) { + setNavPanX(panStartRef.current.px + e.clientX - panStartRef.current.x); + setNavPanY(panStartRef.current.py + e.clientY - panStartRef.current.y); + return; + } + const { row, col } = screenToImage(e); + if (isDraggingProbeRef.current) { + syncProbeRAF(row, col); + return; + } + if (isDrawingLineRef.current && lineDrawStartRef.current) { + const s = lineDrawStartRef.current; + dragLine({ r0: s.row, c0: s.col, r1: row, c1: col }); + return; + } + if (draggingEndpointRef.current !== null) { + const upd = draggingEndpointRef.current === 0 ? { r0: row, c0: col } : { r1: row, c1: col }; + dragLine({ ...localLineRef.current, ...upd }); + return; + } + if (isDraggingLineBodyRef.current && lineBodyStartRef.current) { + const { imgX, imgY } = screenToImgXY(e); + const start = lineBodyStartRef.current; + const dRow = (imgY - start.imgY) / navH * scanRows; + const dCol = (imgX - start.imgX) / NAV_SIZE * scanCols; + dragLine({ + r0: start.line.r0 + dRow, c0: start.line.c0 + dCol, + r1: start.line.r1 + dRow, c1: start.line.c1 + dCol, + }); + return; + } + if (isPaintingRef.current && maskRef.current) { + const half = maskBrushSize - 1; + fillRectMask(maskRef.current, scanCols, scanRows, row - half, col - half, row + half, col + half, maskAction === "add" ? 0 : 1); + setMaskRenderVersion((v) => v + 1); + } + if (isDraggingShapeRef.current && shapeStartRef.current) + setShapePreview({ r0: shapeStartRef.current.row, c0: shapeStartRef.current.col, r1: row, c1: col }); + }; + + const handleNavMouseUp = () => { + if (isPanningRef.current) { isPanningRef.current = false; panStartRef.current = null; return; } + if (isDraggingProbeRef.current) { isDraggingProbeRef.current = false; return; } + if (isDrawingLineRef.current) { isDrawingLineRef.current = false; lineDrawStartRef.current = null; commitLine(localLineRef.current); return; } + if (draggingEndpointRef.current !== null) { draggingEndpointRef.current = null; commitLine(localLineRef.current); return; } + if (isDraggingLineBodyRef.current) { isDraggingLineBodyRef.current = false; lineBodyStartRef.current = null; commitLine(localLineRef.current); return; } + if (isPaintingRef.current) { isPaintingRef.current = false; syncMaskToPython(); return; } + if (isDraggingShapeRef.current && shapeStartRef.current && maskRef.current && shapePreview) { + const value = maskAction === "add" ? 0 : 1; + const { r0, c0, r1, c1 } = shapePreview; + if (maskTool === "circle") { + fillCircleMask(maskRef.current, scanCols, scanRows, (r0 + r1) / 2, (c0 + c1) / 2, Math.max(Math.abs(c1 - c0), Math.abs(r1 - r0)) / 2, value); + } else { + fillRectMask(maskRef.current, scanCols, scanRows, r0, c0, r1, c1, value); + } + setMaskRenderVersion((v) => v + 1); + syncMaskToPython(); + } + isDraggingShapeRef.current = false; shapeStartRef.current = null; setShapePreview(null); + }; + + const handleNavWheel = (e: React.WheelEvent) => { + e.preventDefault(); e.stopPropagation(); + if (lockDisplay) return; // zoom locked + setNavZoom((z) => Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z * (e.deltaY > 0 ? 0.9 : 1.1)))); + }; + + // ========================================================================= + // Plot mouse handlers + // ========================================================================= + function plotScreenToData(e: React.MouseEvent) { + const cvs = plotCanvasRef.current; + if (!cvs) return null; + const rect = cvs.getBoundingClientRect(); + const sx = e.clientX - rect.left, sy = e.clientY - rect.top; + const pw = PLOT_W - MARGIN_LEFT_MIN - MARGIN_RIGHT; + const ph = PLOT_H - MARGIN_TOP - MARGIN_BOTTOM; + return { + x: plotXMin + ((sx - MARGIN_LEFT_MIN) / pw) * (plotXMax - plotXMin), + y: plotYMin + ((MARGIN_TOP + ph - sy) / ph) * (plotYMax - plotYMin), + }; + } + + const handlePlotMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + // Line-scan heatmap: click empty space adds a vertical guide; click on an + // existing guide removes it; drag a guide to fine-tune its r/k position. + if (analysisMode === "line" && lineMode === "linescan") { + const g = heatmapGeomRef.current; + const cvs = plotCanvasRef.current; + if (g && cvs) { + const rect = cvs.getBoundingClientRect(); + const sx = e.clientX - rect.left, sy = e.clientY - rect.top; + const mL = MARGIN_LEFT_MIN, mT = MARGIN_TOP; + const ph = PLOT_H - mT - MARGIN_BOTTOM; + if (sx >= mL && sx <= mL + g.pwH && sy >= mT && sy <= mT + ph) { + const r = g.rLo + ((sx - mL) / g.pwH) * (g.rHi - g.rLo); + const tolR = ((g.rHi - g.rLo) / g.pwH) * 6; + const near = hmGuides.findIndex((v) => Math.abs(v - r) <= tolR); + if (near >= 0) { + draggingGuideRef.current = { index: near, moved: false, added: false }; + } else { + draggingGuideRef.current = { index: hmGuides.length, moved: false, added: true }; + setHmGuides((prev) => [...prev, r]); + } + } + } + return; + } + if (lockDisplay) return; // pan locked + isPlotPanRef.current = true; + plotPanStartRef.current = { mx: e.clientX, my: e.clientY, xMin: plotXMin, xMax: plotXMax, yMin: plotYMin, yMax: plotYMax }; + }; + + const handlePlotMouseMove = (e: React.MouseEvent) => { + // Dragging a heatmap guide line to fine-tune its r/k position. + if (draggingGuideRef.current) { + const g = heatmapGeomRef.current; + const cvs = plotCanvasRef.current; + if (g && cvs) { + const rect = cvs.getBoundingClientRect(); + const sx = e.clientX - rect.left; + const mL = MARGIN_LEFT_MIN; + const r = Math.max(g.rLo, Math.min(g.rHi, g.rLo + ((sx - mL) / g.pwH) * (g.rHi - g.rLo))); + const idx = draggingGuideRef.current.index; + draggingGuideRef.current.moved = true; + setHmGuides((prev) => { const c = [...prev]; if (idx < c.length) c[idx] = r; return c; }); + } + return; + } + // Heatmap hover: report position, r/k, and the stack value under the cursor. + if (analysisMode === "line" && lineMode === "linescan") { + const g = heatmapGeomRef.current; + const cvs = plotCanvasRef.current; + if (g && cvs) { + const rect = cvs.getBoundingClientRect(); + const sx = e.clientX - rect.left, sy = e.clientY - rect.top; + const mL = MARGIN_LEFT_MIN, mT = MARGIN_TOP, ph = PLOT_H - mT - MARGIN_BOTTOM; + if (sx >= mL && sx <= mL + g.pwH && sy >= mT && sy <= mT + ph) { + const r = g.rLo + ((sx - mL) / g.pwH) * (g.rHi - g.rLo); + const pos = (1 - (sy - mT) / ph) * g.lenUnits; // 0 at bottom + const j = Math.max(0, Math.min(g.M - 1, Math.round(((r - g.rLo) / ((g.rHi - g.rLo) || 1)) * (g.M - 1)))); + const bin = Math.max(0, Math.min(g.N - 1, Math.floor((pos / (g.lenUnits || 1)) * g.N))); + const val = g.data[bin * g.M + j]; + setHmHover({ pos, r, val }); + } else { + setHmHover(null); + } + } + return; + } + if (isPlotPanRef.current && plotPanStartRef.current) { + const dx = e.clientX - plotPanStartRef.current.mx; + const dy = e.clientY - plotPanStartRef.current.my; + const pw = PLOT_W - MARGIN_LEFT_MIN - MARGIN_RIGHT; + const ph = PLOT_H - MARGIN_TOP - MARGIN_BOTTOM; + const dxD = -(dx / pw) * (plotPanStartRef.current.xMax - plotPanStartRef.current.xMin); + const dyD = (dy / ph) * (plotPanStartRef.current.yMax - plotPanStartRef.current.yMin); + setPlotXMin(plotPanStartRef.current.xMin + dxD); setPlotXMax(plotPanStartRef.current.xMax + dxD); + setPlotYMin(plotPanStartRef.current.yMin + dyD); setPlotYMax(plotPanStartRef.current.yMax + dyD); + userZoomedRef.current = true; + return; + } + const d = plotScreenToData(e); + if (d) setCursorData(d); + }; + + const handlePlotMouseUp = () => { + const d = draggingGuideRef.current; + if (d) { + draggingGuideRef.current = null; + // A click (no drag) on an existing guide removes it; a click on empty + // space already added one, so leave it. + if (!d.moved && !d.added) setHmGuides((prev) => prev.filter((_, i) => i !== d.index)); + return; + } + isPlotPanRef.current = false; plotPanStartRef.current = null; + }; + + const handlePlotWheel = (e: React.WheelEvent) => { + e.preventDefault(); e.stopPropagation(); + if (lockDisplay) return; // zoom locked + const d = plotScreenToData(e); + if (!d) return; + const f = e.deltaY > 0 ? 1.1 : 1 / 1.1; + setPlotXMin((p) => d.x - (d.x - p) * f); setPlotXMax((p) => d.x + (p - d.x) * f); + setPlotYMin((p) => d.y - (d.y - p) * f); setPlotYMax((p) => d.y + (p - d.y) * f); + userZoomedRef.current = true; + }; + + // ========================================================================= + // Keyboard + // ========================================================================= + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + switch (e.key.toLowerCase()) { + case "r": if (!lockDisplay) { setNavZoom(1); setNavPanX(0); setNavPanY(0); userZoomedRef.current = false; autoFitPlot(); } break; + case "x": if (!lockMask) setMaskAction((a) => { + if (maskRef.current) { + maskRef.current.fill(a === "add" ? 0 : 1); + setMaskRenderVersion((v) => v + 1); + syncMaskToPython(); + } + return a === "add" ? "subtract" : "add"; + }); break; + case "c": + if (!lockMask && maskRef.current) { maskRef.current.fill(1); setMaskRenderVersion((v) => v + 1); syncMaskToPython(); } + break; + case "i": + if (!lockMask && maskRef.current) { for (let i = 0; i < maskRef.current.length; i++) maskRef.current[i] = maskRef.current[i] ? 0 : 1; setMaskRenderVersion((v) => v + 1); syncMaskToPython(); } + break; + case "1": setPlotMode("Ik"); break; + case "2": setPlotMode("Fk"); break; + case "3": setPlotMode("Gr"); break; + } + }; + + // Prevent scroll + React.useEffect(() => { + const h = (e: WheelEvent) => e.preventDefault(); + const opts: AddEventListenerOptions = { passive: false }; + const n1 = navCanvasRef.current?.parentElement; + const n2 = plotCanvasRef.current?.parentElement; + n1?.addEventListener("wheel", h, opts); + n2?.addEventListener("wheel", h, opts); + return () => { n1?.removeEventListener("wheel", h); n2?.removeEventListener("wheel", h); }; + }, []); + + // ========================================================================= + // Styles + // ========================================================================= + const typo = { + title: { fontSize: 15, fontWeight: 600, color: colors.accent, fontFamily: FONT }, + label: { fontSize: 13, color: colors.text, fontFamily: FONT }, + labelSmall: { fontSize: 12, fontWeight: 600, color: colors.textMuted, fontFamily: FONT }, + value: { fontSize: 12, fontFamily: MONO, color: colors.text }, + }; + const switchSmall = { "& .MuiSwitch-thumb": { width: 12, height: 12 }, "& .MuiSwitch-switchBase": { padding: "4px" } }; + const compactBtn = { fontSize: 12, minWidth: 36, px: 1, py: 0.25, textTransform: "none" as const }; + const imageBox = { position: "relative" as const, border: `1px solid ${colors.border}`, overflow: "hidden", bgcolor: isDark ? "#111" : "#eee" }; + + // ========================================================================= + // JSX + // ========================================================================= + return ( + + + {title || "PDF"} + + {computing && } + {statusMessage && {statusMessage}} + + + + + {/* LEFT: Nav + Mask */} + + + Scan ({scanRows}×{scanCols}) + + {(["mask", "probe", "line"] as const).map((m) => ( + + ))} + + + {analysisMode === "mask" && !hideMask && ( + + {(["rectangle", "circle", "freeform"] as const).map((tool) => ( + + ))} + + + + + )} + + + + +
{ isPaintingRef.current = false; isDraggingShapeRef.current = false; isDraggingProbeRef.current = false; setShapePreview(null); }} + onWheel={handleNavWheel} /> + + {showStats && !hideStats &&
{maskPixelCount} / {scanRows * scanCols} included ({(maskFraction * 100).toFixed(1)}%)
} + {showControls && ( + + {analysisMode === "mask" && maskTool === "freeform" && ( + + Brush: + setMaskBrushSize(v as number)} disabled={lockMask} min={1} max={20} size="small" + sx={{ width: 80, "& .MuiSlider-thumb": { width: 10, height: 10 } }} /> + {maskBrushSize} + + )} + {analysisMode === "probe" && ( + + Probe: + setProbeSize(v as number)} disabled={lockMask} min={1} max={100} size="small" + sx={{ width: 80, "& .MuiSlider-thumb": { width: 10, height: 10 } }} /> + {probeSize} + + @({localProbe.row}, {localProbe.col}) + + + )} + {analysisMode === "line" && ( + + Width: + setLocalLineWidth(v as number)} + onChangeCommitted={(_, v) => setLineWidth(v as number)} disabled={lockMask} min={1} max={100} size="small" + sx={{ width: 80, "& .MuiSlider-thumb": { width: 10, height: 10 } }} /> + {localLineWidth} + + {(["averaged", "linescan"] as const).map((lm) => ( + + ))} + + {lineActive ? `len ${lineLenPx.toFixed(1)} px · θ ${lineOrientDeg.toFixed(1)}°` : "draw a line…"} + + + + )} + + Cmap: + + + + )} + + + {/* RIGHT: Curves */} + + {/* Spacer matching the height of the left-panel mask-tools row, so + both canvases sit at the same vertical position in mask mode. */} + {analysisMode === "mask" && ( + + + + )} + + + {([["Ik", "I(k)"], ["Fk", "F(k)"], ["Gr", "G(r)"], ["gr", "g(r)"]] as const).map(([mode, label]) => ( + + ))} + + + {plotMode === "Ik" && (<>Log: + { setIkLogScale(e.target.checked); userZoomedRef.current = false; setTimeout(autoFitPlot, 0); }} size="small" sx={switchSmall} /> + Background: + setShowBackground(e.target.checked)} size="small" sx={switchSmall} />)} + + + + + { isPlotPanRef.current = false; draggingGuideRef.current = null; setCursorData(null); setHmHover(null); }} + onDoubleClick={() => { + if (analysisMode === "line" && lineMode === "linescan") { setHmGuides([]); return; } + if (!lockDisplay) { userZoomedRef.current = false; autoFitPlot(); } + }} onWheel={handlePlotWheel} /> + + {showStats && !hideStats && (() => { + const xLab = (plotMode === "Gr" || plotMode === "gr") ? "r" : "k"; + const yLab = plotMode === "Ik" ? "I" : plotMode === "Fk" ? "F" : plotMode === "gr" ? "g" : "G"; + if (analysisMode === "line" && lineMode === "linescan") { + if (!hmHover) return null; + return + {xLab} = {formatNumber(hmHover.r, 4)}, position = {formatNumber(hmHover.pos, 2)}, {yLab} = {formatNumber(hmHover.val, 4)} + ; + } + if (!cursorData) return null; + const v = curveValueAt(cursorData.x); + if (v === null) return null; + return + {xLab} = {formatNumber(cursorData.x, 4)}, {yLab} = {formatNumber(v, 4)} + ; + })()} + {showControls && !hideParameters && ( + + + k fit: + setLocalKFit(v as [number, number])} + onChangeCommitted={(_, v) => { const val = v as [number, number]; setKMinFit(val[0]); setKMaxFit(val[1]); }} + disabled={lockParameters} min={kMinAvail} max={kMaxAvail} step={0.01} size="small" + sx={{ flex: 1, "& .MuiSlider-thumb": { width: 10, height: 10 } }} /> + [{localKFit[0].toFixed(2)}, {localKFit[1].toFixed(2)}] + + + k window: + setLocalKWin(v as [number, number])} + onChangeCommitted={(_, v) => { const val = v as [number, number]; setKMinWindow(val[0]); setKMaxWindow(val[1]); }} + disabled={lockParameters} min={kMinAvail} max={kMaxAvail} step={0.01} size="small" + sx={{ flex: 1, "& .MuiSlider-thumb": { width: 10, height: 10 } }} /> + [{localKWin[0].toFixed(2)}, {localKWin[1].toFixed(2)}] + + + r max: + setLocalRMax(v as number)} + onChangeCommitted={(_, v) => setRMax(v as number)} disabled={lockParameters} min={1} max={50} step={0.5} size="small" + sx={{ flex: 1, "& .MuiSlider-thumb": { width: 10, height: 10 } }} /> + {localRMax.toFixed(1)} Å + + + k lowpass: + setLocalKLowpass(v as number)} + onChangeCommitted={(_, v) => setKLowpass(v as number)} disabled={lockParameters} min={0} max={0.1} step={0.001} size="small" + sx={{ flex: 1, "& .MuiSlider-thumb": { width: 10, height: 10 } }} /> + {localKLowpass > 0 ? localKLowpass.toFixed(3) : "off"} + + + k highpass: + setLocalKHighpass(v as number)} + onChangeCommitted={(_, v) => setKHighpass(v as number)} disabled={lockParameters} min={0} max={0.1} step={0.001} size="small" + sx={{ flex: 1, "& .MuiSlider-thumb": { width: 10, height: 10 } }} /> + {localKHighpass > 0 ? localKHighpass.toFixed(3) : "off"} + + + Damp: + setDampOrigin(e.target.checked)} size="small" sx={switchSmall} /> + {dampOrigin && (<> + r_cut: + setLocalRCut(v as number)} + onChangeCommitted={(_, v) => setRCut(v as number)} disabled={lockParameters} min={0.1} max={5} step={0.1} size="small" + sx={{ width: 80, "& .MuiSlider-thumb": { width: 10, height: 10 } }} /> + {localRCut.toFixed(1)} Å + )} + + {plotMode === "gr" && ( + + Density: + Estimated + setDensityMode(e.target.checked ? "manual" : "estimated")} + size="small" + sx={switchSmall} + /> + Manual + setLocalDensity(e.target.value)} + onBlur={() => { + const v = parseFloat(localDensity); + if (!isNaN(v) && v > 0 && v !== densityValue) setDensityValue(v); + else setLocalDensity(densityValue.toPrecision(4)); + }} + onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} + style={{ + width: 90, fontSize: 12, padding: "3px 6px", marginLeft: SPACING.LG, fontFamily: MONO, + border: `1px solid ${colors.border}`, + background: densityMode === "estimated" ? (isDark ? "#222" : "#f0f0f0") : (isDark ? "#1a1a1a" : "#fff"), + color: colors.textMuted, + outline: "none", + }} + /> + Å⁻³ + + )} + + )} + + + + ); +} + +export const render = createRender(ShowPDFWidget); diff --git a/widget/scripts/build.mjs b/widget/scripts/build.mjs index 7c8daea95..8d1fa963c 100644 --- a/widget/scripts/build.mjs +++ b/widget/scripts/build.mjs @@ -9,6 +9,7 @@ const watch = process.argv.includes("--watch"); const widgets = [ { name: "show2d" }, { name: "show4dstem" }, + { name: "showpdf" }, ]; rmSync("src/quantem/widget/static", { recursive: true, force: true }); diff --git a/widget/src/quantem/widget/__init__.py b/widget/src/quantem/widget/__init__.py index 96d8aebc3..64847fc0b 100644 --- a/widget/src/quantem/widget/__init__.py +++ b/widget/src/quantem/widget/__init__.py @@ -2,6 +2,7 @@ from quantem.widget.show2d import Show2D from quantem.widget.show4dstem import Show4DSTEM +from quantem.widget.showpdf import ShowPDF try: __version__ = version("quantem.widget") @@ -9,4 +10,4 @@ # Source-tree imports (e.g. `PYTHONPATH=src pytest`) skip pip install. __version__ = "0.0.0+local" -__all__ = ["Show2D", "Show4DSTEM"] +__all__ = ["Show2D", "Show4DSTEM", "ShowPDF"] diff --git a/widget/src/quantem/widget/detector.py b/widget/src/quantem/widget/detector.py new file mode 100644 index 000000000..3f55c990d --- /dev/null +++ b/widget/src/quantem/widget/detector.py @@ -0,0 +1,136 @@ +"""Detector utilities for 4D-STEM virtual imaging. + +Provides BF disk detection and BF/ADF/HAADF virtual image computation. +The detection algorithm matches Show4DSTEM.auto_detect_center (sum all +diffraction patterns, threshold at mean+std, centroid, radius from area). +""" + +from typing import Any + +import numpy as np + +from quantem.widget.array_utils import to_numpy + + +def detect_bf_disk(data: Any) -> tuple[float, float, float]: + """Detect BF disk center and radius from a 4D-STEM dataset. + + Uses the same algorithm as Show4DSTEM.auto_detect_center: + sum all diffraction patterns, threshold at mean+std, compute centroid, + estimate radius from disk area (A = pi * r^2). + + Parameters + ---------- + data : array-like + 4D array ``(scan_rows, scan_cols, det_rows, det_cols)`` or + 5D array ``(n_frames, scan_rows, scan_cols, det_rows, det_cols)``. + Accepts NumPy, PyTorch, or CuPy. + + Returns + ------- + center_row, center_col, bf_radius : float + Detected center position and estimated BF disk radius in pixels. + """ + arr = to_numpy(data) + det_rows, det_cols = arr.shape[-2], arr.shape[-1] + summed = arr.reshape(-1, det_rows, det_cols).sum(axis=0) + threshold = summed.mean() + summed.std() + disk = summed > threshold + total = disk.sum() + if total > 0: + row_coords = np.arange(det_rows, dtype=np.float64).reshape(-1, 1) + col_coords = np.arange(det_cols, dtype=np.float64).reshape(1, -1) + center_row = float((row_coords * disk).sum() / total) + center_col = float((col_coords * disk).sum() / total) + bf_radius = float(np.sqrt(total / np.pi)) + else: + center_row = det_rows / 2.0 + center_col = det_cols / 2.0 + bf_radius = det_rows / 6.0 + return center_row, center_col, bf_radius + + +def make_virtual_masks( + det_rows: int, + det_cols: int, + center_row: float, + center_col: float, + bf_radius: float, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Create BF, ADF, HAADF annular masks for virtual imaging. + + - BF: r < bf_radius + - ADF: bf_radius <= r < 2 * bf_radius + - HAADF: r >= 2 * bf_radius + + Parameters + ---------- + det_rows, det_cols : int + Detector dimensions. + center_row, center_col : float + BF disk center in (row, col) coordinates. + bf_radius : float + Estimated BF disk radius. + + Returns + ------- + bf_mask, adf_mask, haadf_mask : np.ndarray + Float32 masks with shape ``(det_rows, det_cols)``. + """ + row_coords = np.arange(det_rows, dtype=np.float64).reshape(-1, 1) + col_coords = np.arange(det_cols, dtype=np.float64).reshape(1, -1) + rad = np.sqrt((row_coords - center_row) ** 2 + (col_coords - center_col) ** 2) + bf_mask = (rad < bf_radius).astype(np.float32) + adf_mask = ((rad >= bf_radius) & (rad < bf_radius * 2)).astype(np.float32) + haadf_mask = (rad >= bf_radius * 2).astype(np.float32) + return bf_mask, adf_mask, haadf_mask + + +def virtual_images( + data: Any, + center: tuple[float, float] | None = None, + bf_radius: float | None = None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Compute BF, ADF, HAADF virtual images from 4D/5D-STEM data. + + Auto-detects the BF disk center and radius if not provided. + + Parameters + ---------- + data : array-like + 4D ``(scan_r, scan_c, det_r, det_c)`` or + 5D ``(n_frames, scan_r, scan_c, det_r, det_c)``. + center : (row, col), optional + BF disk center. Auto-detected if None. + bf_radius : float, optional + BF disk radius. Auto-detected if None. + + Returns + ------- + bf, adf, haadf : np.ndarray + Virtual images. Same leading dimensions as input minus the + last two (detector) axes. + + Examples + -------- + >>> from quantem.widget import virtual_images + >>> bf, adf, haadf = virtual_images(data_4d) + >>> Show2D(bf, title="BF") + """ + arr = to_numpy(data, dtype=np.float32) + if center is None or bf_radius is None: + cr, cc, br = detect_bf_disk(arr) + if center is not None: + cr, cc = center + if bf_radius is not None: + br = bf_radius + else: + cr, cc = center + br = bf_radius + bf_mask, adf_mask, haadf_mask = make_virtual_masks( + arr.shape[-2], arr.shape[-1], cr, cc, br, + ) + bf = (arr * bf_mask).mean(axis=(-2, -1)) + adf = (arr * adf_mask).mean(axis=(-2, -1)) + haadf = (arr * haadf_mask).mean(axis=(-2, -1)) + return bf, adf, haadf diff --git a/widget/src/quantem/widget/showpdf.py b/widget/src/quantem/widget/showpdf.py new file mode 100644 index 000000000..7e837fa9d --- /dev/null +++ b/widget/src/quantem/widget/showpdf.py @@ -0,0 +1,1260 @@ +import base64 +import json +import pathlib +import time +from typing import Self + +import anywidget +import numpy as np +import traitlets + +from quantem.core.datastructures import Dataset2d, Dataset4dstem +from quantem.diffraction import PairDistributionFunction +from quantem.widget.array_utils import to_numpy +from quantem.widget.detector import virtual_images +from quantem.widget.state import ( + resolve_widget_version, + save_state_file, + unwrap_state_payload, +) + + +class ShowPDF(anywidget.AnyWidget): + """Interactive pair distribution function (PDF) analysis widget for 4D-STEM data. + + Wraps ``PairDistributionFunction`` from ``quantem.diffraction``. This widget provides: + - A real-space navigation image with masking function (left panel) + - Real-time 1D curve plots for I(k)+B(k), windowed F(k), and G(r) (right panel) + - Tunable PDF parameters (k-range, window, damping) with live feedback + """ + + _esm = pathlib.Path(__file__).parent / "static" / "showpdf.js" + + # ========================================================================= + # Version + # ========================================================================= + widget_version = traitlets.Unicode("unknown").tag(sync=True) + + # ========================================================================= + # Shape / data (read-only from JS) + # ========================================================================= + title = traitlets.Unicode("PDF").tag(sync=True) + scan_rows = traitlets.Int(1).tag(sync=True) + scan_cols = traitlets.Int(1).tag(sync=True) + nav_image_bytes = traitlets.Bytes(b"").tag(sync=True) + nav_data_min = traitlets.Float(0.0).tag(sync=True) + nav_data_max = traitlets.Float(1.0).tag(sync=True) + # Real-space scan calibration for the nav panel scalebar. Set from + # input_data.sampling[0] / units[0] when the dataset is loaded; defaults + # to "px" with size 1.0 when no calibration is present. + nav_pixel_size = traitlets.Float(1.0).tag(sync=True) + nav_unit = traitlets.Unicode("px").tag(sync=True) + + # ========================================================================= + # Mask (bidirectional — JS paints, Python reads) + # Convention: 1 = include, 0 = exclude (matches calculate_radial_mean) + # ========================================================================= + mask_bytes = traitlets.Bytes(b"").tag(sync=True) # Python→JS only + mask_b64 = traitlets.Unicode("").tag(sync=True) # JS→Python (base64-encoded mask) + mask_version = traitlets.Int(0).tag(sync=True) # JS increments to trigger recompute + mask_tool = traitlets.Unicode("rectangle").tag(sync=True) + mask_brush_size = traitlets.Int(5).tag(sync=True) + mask_pixel_count = traitlets.Int(0).tag(sync=True) + mask_fraction = traitlets.Float(0.0).tag(sync=True) + + # ========================================================================= + # Analysis mode: "mask" (existing painted mask) or "probe" (single moveable + # square region of side 2*probe_size-1 centered on the scan grid). + # In probe mode, mask_b64 is ignored and a synthetic mask is built from + # probe_row/probe_col/probe_size. + # ========================================================================= + analysis_mode = traitlets.Unicode("mask").tag(sync=True) + probe_row = traitlets.Int(0).tag(sync=True) + probe_col = traitlets.Int(0).tag(sync=True) + probe_size = traitlets.Int(1).tag(sync=True) + + # ========================================================================= + # Line mode: a straight selection band across the scan. The two endpoints + # (row0,col0)-(row1,col1) and line_width (band thickness in scan px) define + # a boolean mask of every scan position within line_width/2 of the segment. + # line_active is False until a line is drawn. line_mode selects "averaged" + # (one PDF from the whole band) or "linescan" (per-position PDFs — Phase 2). + # JS bumps line_version after editing endpoints to trigger a single recompute. + # ========================================================================= + line_row0 = traitlets.Float(0.0).tag(sync=True) + line_col0 = traitlets.Float(0.0).tag(sync=True) + line_row1 = traitlets.Float(0.0).tag(sync=True) + line_col1 = traitlets.Float(0.0).tag(sync=True) + line_width = traitlets.Int(3).tag(sync=True) + line_active = traitlets.Bool(False).tag(sync=True) + line_mode = traitlets.Unicode("averaged").tag(sync=True) + line_version = traitlets.Int(0).tag(sync=True) + # When True the drawn line is a reference (e.g. traced along a feature) and + # the band/line-scan is sampled along its perpendicular bisector instead — + # so the scan is perpendicular to the traced feature regardless of frame. + line_perpendicular = traitlets.Bool(False).tag(sync=True) + + # Line-scan output (line_mode="linescan"): a per-position PDF stack along + # the line. linescan_bytes is float32 (n_linescan × linescan_n_points), + # row-major by bin; linescan_axis_bytes is the shared r/k axis (the curve + # selected by plot_mode). Empty until a line is drawn in linescan mode. + linescan_bytes = traitlets.Bytes(b"").tag(sync=True) + n_linescan = traitlets.Int(0).tag(sync=True) + linescan_n_points = traitlets.Int(0).tag(sync=True) + linescan_axis_bytes = traitlets.Bytes(b"").tag(sync=True) + linescan_max_bins = traitlets.Int(256).tag(sync=True) + + # ========================================================================= + # 1D curve data (synced to JS, all updated on every recompute) + # ========================================================================= + # I(k) radial mean + background fit + ik_x_bytes = traitlets.Bytes(b"").tag(sync=True) + ik_y_bytes = traitlets.Bytes(b"").tag(sync=True) + ik_bg_y_bytes = traitlets.Bytes(b"").tag(sync=True) + n_points_ik = traitlets.Int(0).tag(sync=True) + # F(k) windowed (Lorch) + fk_x_bytes = traitlets.Bytes(b"").tag(sync=True) + fk_y_bytes = traitlets.Bytes(b"").tag(sync=True) + n_points_fk = traitlets.Int(0).tag(sync=True) + # G(r) reduced PDF + gr_x_bytes = traitlets.Bytes(b"").tag(sync=True) + gr_y_bytes = traitlets.Bytes(b"").tag(sync=True) + n_points_gr = traitlets.Int(0).tag(sync=True) + # g(r) pair distribution function + pdf_x_bytes = traitlets.Bytes(b"").tag(sync=True) + pdf_y_bytes = traitlets.Bytes(b"").tag(sync=True) + n_points_pdf = traitlets.Int(0).tag(sync=True) + + # ========================================================================= + # PDF parameters (user-tunable, trigger recompute) + # 0.0 sentinel means "auto" or "disabled" + # ========================================================================= + k_min_fit = traitlets.Float(0.0).tag(sync=True) + k_max_fit = traitlets.Float(0.0).tag(sync=True) + k_min_window = traitlets.Float(0.0).tag(sync=True) + k_max_window = traitlets.Float(0.0).tag(sync=True) + k_lowpass = traitlets.Float(0.0).tag(sync=True) + k_highpass = traitlets.Float(0.0).tag(sync=True) + r_min = traitlets.Float(0.0).tag(sync=True) + r_max = traitlets.Float(20.0).tag(sync=True) + r_step = traitlets.Float(0.02).tag(sync=True) + damp_origin_oscillations = traitlets.Bool(False).tag(sync=True) + r_cut = traitlets.Float(1.0).tag(sync=True) + + # Density for g(r) computation. mode: "estimated" (auto via estimate_density, + # shares r_cut with damping) or "manual" (user-supplied density_value). + density_mode = traitlets.Unicode("estimated").tag(sync=True) + density_value = traitlets.Float(0.05).tag(sync=True) + + # K-range metadata (read-only, set once from data, used for slider bounds) + k_min_available = traitlets.Float(0.0).tag(sync=True) + k_max_available = traitlets.Float(10.0).tag(sync=True) + + # ========================================================================= + # Display + # ========================================================================= + plot_mode = traitlets.Unicode("Gr").tag(sync=True) + show_background = traitlets.Bool(True).tag(sync=True) + cmap = traitlets.Unicode("gray").tag(sync=True) + log_scale = traitlets.Bool(False).tag(sync=True) + auto_contrast = traitlets.Bool(True).tag(sync=True) + show_stats = traitlets.Bool(True).tag(sync=True) + show_controls = traitlets.Bool(True).tag(sync=True) + + # Status + computing = traitlets.Bool(False).tag(sync=True) + status_message = traitlets.Unicode("").tag(sync=True) + + # ========================================================================= + # Constructor + # ========================================================================= + def __init__( + self, + data, + *, + nav_image=None, + title="", + # PDF parameters + k_min_fit=0.0, + k_max_fit=0.0, + k_min_window=0.0, + k_max_window=0.0, + k_lowpass=0.0, + k_highpass=0.0, + r_min=0.0, + r_max=20.0, + r_step=0.02, + damp_origin_oscillations=False, + r_cut=1.0, + # Density (for g(r)) + density_mode="estimated", + density_value=0.05, + # Analysis mode + probe geometry + analysis_mode="mask", + probe_row=None, + probe_col=None, + probe_size=1, + # Line geometry (analysis_mode="line") + line_row0=0.0, + line_col0=0.0, + line_row1=0.0, + line_col1=0.0, + line_width=3, + line_mode="averaged", + line_perpendicular=False, + line_max_bins=256, + # Display + plot_mode="Gr", + show_background=True, + cmap="gray", + log_scale=False, + auto_contrast=True, + show_stats=True, + show_controls=True, + # PDF construction params (only used when data is not already a PDF) + find_origin=True, + origin_row=None, + origin_col=None, + num_annular_bins=180, + radial_min=0.0, + radial_max=None, + radial_step=1.0, + two_fold_rotation_symmetry=False, + device=None, + # Real-space scale bar (overrides auto-detect from ds.sampling/units) + pixel_size=None, + # State + state=None, + **kwargs, + ): + super().__init__(**kwargs) + self._initializing = True + self.widget_version = resolve_widget_version() + _t0 = time.perf_counter() + + # --- Input dispatch --- + _extracted_title = "" + if isinstance(data, PairDistributionFunction): + self._pdf = data + else: + # Duck-type IOResult / Dataset + if hasattr(data, "title") and hasattr(data, "data"): + _extracted_title = data.title or "" + data = data.data + if hasattr(data, "array") and hasattr(data, "name"): + _extracted_title = _extracted_title or (data.name or "") + + # Wrap raw NumPy/Torch/CuPy arrays in a Dataset so PairDistributionFunction + # can consume them. Datasets (which carry .array) pass through unchanged. + if not hasattr(data, "array"): + arr = to_numpy(data) + if arr.ndim == 4: + data = Dataset4dstem.from_array(arr) + elif arr.ndim == 2: + data = Dataset2d.from_array(arr) + else: + raise ValueError( + f"ShowPDF expects 4D (4D-STEM) or 2D input; got {arr.ndim}D array." + ) + + # Resolve device. PairDistributionFunction uses float64 tensors + # internally, which MPS does not support and which AMD/embedded + # GPU drivers handle inconsistently. The compute is light (1D + # FFTs, radial mean, iterative density solver), so default to CPU + # for cross-platform reliability. Users can still pass + # device="cuda" explicitly if they have a confirmed-working GPU. + if device is None: + device_str = "cpu" + else: + device_str = device + + self._pdf = PairDistributionFunction.from_data( + data, + find_origin=find_origin, + origin_row=origin_row, + origin_col=origin_col, + num_annular_bins=num_annular_bins, + radial_min=radial_min, + radial_max=radial_max, + radial_step=radial_step, + two_fold_rotation_symmetry=two_fold_rotation_symmetry, + device=device_str, + ) + + # Clear any cached intermediates carried in from preprocessing or a + # serialized cache. calculate_Gr reuses a cached background/radial mean + # when no mask is passed, so stale caches would make the initial compute + # ignore this widget's k-fit/window settings. Clearing guarantees a fresh + # recompute with the widget's own parameters (a loaded rdf then behaves + # identically to a freshly constructed one). + for _attr in ("Ik", "bg", "f", "Sk", "Fk", "Fk_masked", "rho0", + "_reduced_pdf", "_r", "_pdf", "reduced_pdf_damped"): + if hasattr(self._pdf, _attr): + setattr(self._pdf, _attr, None) + + # --- Extract shape and k-range from polar data --- + polar_shape = self._pdf.polar.shape + self.scan_rows = int(polar_shape[0]) + self.scan_cols = int(polar_shape[1]) + + qq = np.asarray(self._pdf.qq, dtype=np.float32) + self.k_min_available = float(qq[0]) + self.k_max_available = float(qq[-1]) + + # --- Default k fit/window range to the full data range if not set --- + if k_min_fit == 0.0: + k_min_fit = float(qq[0]) + if k_max_fit == 0.0: + k_max_fit = float(qq[-1]) + if k_min_window == 0.0: + k_min_window = float(qq[0]) + if k_max_window == 0.0: + k_max_window = float(qq[-1]) + + # --- Set parameter traits --- + self.title = title or _extracted_title or "PDF" + self.k_min_fit = k_min_fit + self.k_max_fit = k_max_fit + self.k_min_window = k_min_window + self.k_max_window = k_max_window + self.k_lowpass = k_lowpass + self.k_highpass = k_highpass + self.r_min = r_min + self.r_max = r_max + self.r_step = r_step + self.damp_origin_oscillations = damp_origin_oscillations + self.r_cut = r_cut + self.density_mode = density_mode + self.density_value = float(density_value) + self.analysis_mode = analysis_mode + self.probe_row = int(probe_row) if probe_row is not None else self.scan_rows // 2 + self.probe_col = int(probe_col) if probe_col is not None else self.scan_cols // 2 + self.probe_size = int(probe_size) + self.line_row0 = float(line_row0) + self.line_col0 = float(line_col0) + self.line_row1 = float(line_row1) + self.line_col1 = float(line_col1) + self.line_width = int(line_width) + self.line_mode = line_mode + self.line_perpendicular = bool(line_perpendicular) + self.linescan_max_bins = int(line_max_bins) + # A line is "active" once its endpoints span a nonzero length. + self.line_active = (line_row0, line_col0) != (line_row1, line_col1) + + # --- Set display traits --- + self.plot_mode = plot_mode + self.show_background = show_background + self.cmap = cmap + self.log_scale = log_scale + self.auto_contrast = auto_contrast + self.show_stats = show_stats + self.show_controls = show_controls + + # --- Navigation image --- + if nav_image is not None: + nav_img = to_numpy(nav_image).astype(np.float32) + else: + nav_img = self._compute_nav_image() + self._nav_image = nav_img + self.nav_data_min = float(nav_img.min()) + self.nav_data_max = float(nav_img.max()) + self.nav_image_bytes = nav_img.tobytes() + self.nav_pixel_size, self.nav_unit = self._resolve_nav_calibration() + if pixel_size is not None: + self.nav_pixel_size = float(pixel_size) + self.nav_unit = "Å" + + # --- Register observers --- + self.observe(self._on_mask_change, names=["mask_version"]) + self.observe( + self._on_fit_params_change, + names=[ + "k_min_fit", + "k_max_fit", + "k_min_window", + "k_max_window", + "k_lowpass", + "k_highpass", + ], + ) + self.observe( + self._on_output_params_change, + names=[ + "r_min", + "r_max", + "r_step", + "damp_origin_oscillations", + "r_cut", + ], + ) + self.observe( + self._on_density_change, + names=["density_mode", "density_value"], + ) + self.observe( + self._on_probe_change, + names=["analysis_mode", "probe_row", "probe_col", "probe_size"], + ) + # Endpoints sync without observers; JS bumps line_version once per edit + # so a drag triggers a single recompute (not one per endpoint trait). + self.observe( + self._on_line_change, + names=["line_version", "line_width", "line_mode", "line_perpendicular"], + ) + # In line-scan mode the displayed curve drives which PDF the stack holds, + # so a plot_mode change must recompute the stack. + self.observe(self._on_plot_mode_change, names=["plot_mode"]) + + # --- Initial computation --- + self._initializing = False + self._update_mask_stats() + self._recompute_full() + + # Preserve any error message _recompute_full set instead of clobbering it. + _elapsed = time.perf_counter() - _t0 + if not self.status_message.startswith("Error"): + self.status_message = f"Ready ({_elapsed:.1f}s)" + + # --- Restore state --- + if state is not None: + if isinstance(state, (str, pathlib.Path)): + state = unwrap_state_payload( + json.loads(pathlib.Path(state).read_text()), + require_envelope=True, + ) + else: + state = unwrap_state_payload(state) + self.load_state_dict(state) + + # ========================================================================= + # Observers + # ========================================================================= + def _compute_nav_image(self) -> np.ndarray: + """Compute a BF virtual image for the nav panel. + + Uses the original 4D-STEM data if available (via input_data), + otherwise falls back to summing the polar data over angle and radius. + """ + input_data = getattr(self._pdf, "input_data", None) + if input_data is not None: + arr = getattr(input_data, "array", None) + if arr is not None and arr.ndim == 4: + bf, _, _ = virtual_images(arr) + return bf.astype(np.float32) + # Fallback: sum polar data over phi and r + return self._pdf.polar.numpy().sum(axis=(-2, -1)).astype(np.float32) + + def _resolve_nav_calibration(self) -> tuple[float, str]: + """Resolve real-space scan calibration from the input dataset. + + Returns (pixel_size, unit) where unit is "Å" if the dataset's first + axis is in Å/A/angstrom/nm (with nm→Å conversion), else "px". + """ + input_data = getattr(self._pdf, "input_data", None) + sampling = getattr(input_data, "sampling", None) + units = getattr(input_data, "units", None) + if sampling is None or units is None or len(units) == 0: + return 1.0, "px" + unit0 = str(units[0]).lower() + size0 = float(sampling[0]) + if unit0 in ("å", "a", "angstrom", "angstroms"): + return size0, "Å" + if unit0 == "nm": + return size0 * 10.0, "Å" + return 1.0, "px" + + def _on_mask_change(self, change=None): + if self._initializing: + return + self._pdf.Ik = None + self._pdf.bg = None + self._update_mask_stats() + self._recompute_full() + + def _on_fit_params_change(self, change=None): + if self._initializing: + return + self._pdf.bg = None + self._recompute_full() + + def _on_output_params_change(self, change=None): + if self._initializing: + return + self._pdf.bg = None + self._recompute_full() + + def _on_density_change(self, change=None): + if self._initializing: + return + # In manual mode the user-supplied value is used directly; in estimated + # mode we invalidate the cached rho0 so estimate_density runs again + # against the current G(r). + if self.density_mode == "estimated": + self._pdf.rho0 = None + self._compute_gr() + + def _on_probe_change(self, change=None): + if self._initializing: + return + # Probe defines a synthetic mask; treat as a mask change and recompute. + self._pdf.Ik = None + self._pdf.bg = None + self._update_mask_stats() + self._recompute_full() + + def _on_line_change(self, change=None): + if self._initializing: + return + # The line defines a synthetic band mask; treat as a mask change. + self._pdf.Ik = None + self._pdf.bg = None + self._update_mask_stats() + self._recompute_full() + + def _on_plot_mode_change(self, change=None): + if self._initializing: + return + # Only line-scan mode depends on plot_mode for its compute; the 1D + # paths just re-draw the already-synced curves on the JS side. + if self.analysis_mode == "line" and self.line_mode == "linescan": + self._compute_linescan() + + # ========================================================================= + # Core computation + # ========================================================================= + def _effective_line(self): + """Endpoints actually sampled: the perpendicular bisector of the drawn + line when line_perpendicular is set, else the drawn line itself. + + The bisector keeps the drawn line's length and is centered on its + midpoint, rotated 90° — so tracing along a feature samples across it. + """ + r0, c0 = self.line_row0, self.line_col0 + r1, c1 = self.line_row1, self.line_col1 + if not self.line_perpendicular: + return r0, c0, r1, c1 + dr, dc = r1 - r0, c1 - c0 + length = float(np.hypot(dr, dc)) + if length == 0: + return r0, c0, r1, c1 + mr, mc = (r0 + r1) / 2.0, (c0 + c1) / 2.0 + pr, pc = -dc / length, dr / length # unit vector ⟂ to the drawn line + half = length / 2.0 + return mr - pr * half, mc - pc * half, mr + pr * half, mc + pc * half + + def _line_band_mask(self): + """Boolean mask of scan positions within line_width/2 of the line segment. + + Returns None when no line is active or the segment has zero length. + """ + if not self.line_active: + return None + r0, c0, r1, c1 = self._effective_line() + dr, dc = r1 - r0, c1 - c0 + seg_len_sq = dr * dr + dc * dc + if seg_len_sq <= 0: + return None + length = np.sqrt(seg_len_sq) + rows = np.arange(self.scan_rows, dtype=np.float64)[:, None] + cols = np.arange(self.scan_cols, dtype=np.float64)[None, :] + pr = rows - r0 + pc = cols - c0 + # Position along the line (0 = start, 1 = end) and perpendicular distance + # to the *infinite* line. Requiring t in [0, 1] gives flat ends (no + # caps); the perpendicular test gives a constant width → a rectangle. + t = (pr * dr + pc * dc) / seg_len_sq + perp = np.abs(pr * dc - pc * dr) / length + half_w = max(0.5, self.line_width / 2.0) + mask = (t >= 0.0) & (t <= 1.0) & (perp <= half_w) + if not mask.any(): + return None + return mask + + def _get_mask(self): + # Line mode: synthesize a band mask along the drawn segment. + if self.analysis_mode == "line": + return self._line_band_mask() + # Probe mode: synthesize a square mask centered at (probe_row, probe_col) + # with side 2*probe_size-1 (matching the freeform brush sizing convention). + if self.analysis_mode == "probe": + half = max(0, int(self.probe_size) - 1) + r0 = max(0, int(self.probe_row) - half) + r1 = min(self.scan_rows, int(self.probe_row) + half + 1) + c0 = max(0, int(self.probe_col) - half) + c1 = min(self.scan_cols, int(self.probe_col) + half + 1) + if r1 <= r0 or c1 <= c0: + return None + mask = np.zeros((self.scan_rows, self.scan_cols), dtype=bool) + mask[r0:r1, c0:c1] = True + return mask + # Mask mode: decode painted mask + if not self.mask_b64: + return None + raw = base64.b64decode(self.mask_b64) + mask = np.frombuffer(raw, dtype=np.uint8).reshape( + self.scan_rows, self.scan_cols + ) + # All-ones mask is equivalent to no mask + if mask.all(): + return None + bool_mask = mask > 0 + if not bool_mask.any(): + return None + return bool_mask + + def _update_mask_stats(self): + mask = self._get_mask() + total = self.scan_rows * self.scan_cols + if mask is not None: + count = int(mask.sum()) + self.mask_pixel_count = count + self.mask_fraction = count / total + elif self.analysis_mode in ("probe", "line"): + # Probe out of bounds / no line drawn — nothing selected + self.mask_pixel_count = 0 + self.mask_fraction = 0.0 + elif not self.mask_b64: + self.mask_pixel_count = total + self.mask_fraction = 1.0 + else: + raw = base64.b64decode(self.mask_b64) + count = int(np.frombuffer(raw, dtype=np.uint8).sum()) + self.mask_pixel_count = count if count > 0 else total + self.mask_fraction = (count / total) if count > 0 else 1.0 + + def _curve_params(self): + """k-range sentinels (0.0 → None) shared by every calculate_Gr call.""" + return dict( + k_min_fit=self.k_min_fit if self.k_min_fit > 0 else None, + k_max_fit=self.k_max_fit if self.k_max_fit > 0 else None, + k_min_window=self.k_min_window if self.k_min_window > 0 else None, + k_max_window=self.k_max_window if self.k_max_window > 0 else None, + k_lowpass=self.k_lowpass if self.k_lowpass > 0 else None, + k_highpass=self.k_highpass if self.k_highpass > 0 else None, + r_min=self.r_min, + r_max=self.r_max, + r_step=self.r_step, + damp_origin_oscillations=self.damp_origin_oscillations, + r_cut=self.r_cut, + ) + + def _compute_curve_for_mask(self, mask): + """Compute the curve selected by plot_mode for one real-space mask. + + Returns (y, x) float32 arrays, or (None, None) if unavailable. + """ + self._pdf.Ik = None + self._pdf.bg = None + self._pdf.calculate_Gr(mask_realspace=mask, **self._curve_params()) + return self._extract_plot_curve() + + def _extract_plot_curve(self): + """Extract (y, x) for the current plot_mode from the already-computed + PDF state (Ik / Fk_masked / reduced_pdf). Returns (None, None) if the + needed quantity has not been computed.""" + mode = self.plot_mode + if mode == "Ik": + x = np.asarray(self._pdf.qq, dtype=np.float32) + y = to_numpy(self._pdf.Ik).astype(np.float32) + elif mode == "Fk": + if self._pdf.Fk_masked is None: + return None, None + qq = np.asarray(self._pdf.qq, dtype=np.float32) + y = to_numpy(self._pdf.Fk_masked).astype(np.float32) + x = qq[: len(y)] + elif mode == "gr": + if self._pdf._reduced_pdf is None or self._pdf._r is None: + return None, None + density = float(self.density_value) if self.density_mode == "manual" else None + self._pdf.calculate_gr(density=density, r_cut=self.r_cut) + x = to_numpy(self._pdf._r).astype(np.float32) + y = to_numpy(self._pdf._pdf).astype(np.float32) + else: # "Gr" + if self._pdf._r is None or self._pdf._reduced_pdf is None: + return None, None + Gr = ( + self._pdf.reduced_pdf_damped + if self._pdf.reduced_pdf_damped is not None + else self._pdf._reduced_pdf + ) + x = to_numpy(self._pdf._r).astype(np.float32) + y = to_numpy(Gr).astype(np.float32) + return y, x + + def _clear_linescan(self): + self.n_linescan = 0 + self.linescan_n_points = 0 + self.linescan_bytes = b"" + self.linescan_axis_bytes = b"" + + def _linescan_bin_radial_means(self, band, bin_idx, n_bins): + """All bins' radial means I(k) in a single pass over the polar data. + + Returns (Ik_all, valid_bins): Ik_all is an (n_valid, Nk) torch tensor on + the PDF device, valid_bins the matching non-empty bin indices. This is + equivalent to calling calculate_radial_mean once per bin mask, but reads + the 4D polar array only once (chunked) instead of n_bins times. + """ + import torch + + polar = self._pdf.polar.numpy() # (Sr, Sc, phi, Nk) + scan_row, scan_col, _n_phi, n_k = polar.shape + device = self._pdf.device + # Per-position bin label, -1 outside the band. + labels = np.where(band, bin_idx, -1).astype(np.int64) + sums = torch.zeros(n_bins, n_k, dtype=torch.float64, device=device) + counts = torch.zeros(n_bins, dtype=torch.float64, device=device) + chunk = 16 + for row0 in range(0, scan_row, chunk): + row1 = min(row0 + chunk, scan_row) + arr = torch.as_tensor( + np.ascontiguousarray(polar[row0:row1]), device=device + ) + # mean over phi, flatten scan positions, accumulate into bins. + rad = arr.mean(dim=2).reshape(-1, n_k).double() + lbl = torch.as_tensor(labels[row0:row1].reshape(-1), device=device) + valid = lbl >= 0 + if bool(valid.any()): + v = lbl[valid] + sums.index_add_(0, v, rad[valid]) + counts.index_add_( + 0, v, torch.ones(int(valid.sum()), dtype=torch.float64, device=device) + ) + nonempty = counts > 0 + valid_bins = torch.nonzero(nonempty, as_tuple=False).flatten().tolist() + Ik_all = sums[nonempty] / counts[nonempty][:, None] + return Ik_all, valid_bins + + def _compute_linescan(self): + """Compute a per-position PDF stack along the drawn line. + + The band is split into n_linescan bins along the line direction; each + bin averages the scan positions whose projection falls in that bin and + whose perpendicular distance is within line_width/2. All bins' radial + means are computed in one pass and all backgrounds are fit at once + (batched LM); only the cheap F(k) -> windowing -> sine transform runs + per bin. The curve selected by plot_mode is stacked into linescan_bytes + (n_bins × n_points). + """ + self.computing = True + self.status_message = "Computing line-scan..." + try: + band = self._line_band_mask() + if band is None: + self._clear_linescan() + self.status_message = "" + return + r0, c0, r1, c1 = self._effective_line() + dr, dc = r1 - r0, c1 - c0 + seg_len_sq = dr * dr + dc * dc + length = float(np.sqrt(seg_len_sq)) + n_bins = max(1, min(int(self.linescan_max_bins), int(round(length)) + 1)) + + rows = np.arange(self.scan_rows, dtype=np.float64)[:, None] + cols = np.arange(self.scan_cols, dtype=np.float64)[None, :] + t = np.clip(((rows - r0) * dr + (cols - c0) * dc) / seg_len_sq, 0.0, 1.0) + bin_idx = np.minimum((t * n_bins).astype(int), n_bins - 1) + + # One pass for every bin's I(k), then fit all backgrounds at once. + Ik_all, valid_bins = self._linescan_bin_radial_means(band, bin_idx, n_bins) + if not valid_bins: + self._clear_linescan() + self.status_message = "" + return + params = self._curve_params() + bg_all, f_all = self._pdf.fit_bg_batched( + Ik_all, kmin=params["k_min_fit"], kmax=params["k_max_fit"] + ) + + # Per-bin downstream reuses the precomputed I(k)/bg/f (mask=None), so + # calculate_Gr skips the radial mean and the background refit and only + # does F(k) -> windowing -> sine transform. + stack = None + axis = None + for j, bin_i in enumerate(valid_bins): + self._pdf.Ik = Ik_all[j] + self._pdf.bg = bg_all[j] + self._pdf.f = f_all[j] + self._pdf.calculate_Gr(mask_realspace=None, **params) + y, x = self._extract_plot_curve() + if y is None: + continue + if stack is None: + axis = x + stack = np.full((n_bins, len(y)), np.nan, dtype=np.float32) + n = min(len(y), stack.shape[1]) + stack[bin_i, :n] = y[:n] + + if stack is None: + self._clear_linescan() + self.status_message = "" + return + self.n_linescan = int(n_bins) + self.linescan_n_points = int(stack.shape[1]) + self.linescan_bytes = stack.tobytes() + self.linescan_axis_bytes = np.asarray(axis, dtype=np.float32).tobytes() + self.status_message = "" + except Exception as e: + self.status_message = f"Line-scan error: {e}" + self._clear_linescan() + finally: + self.computing = False + + def _recompute_full(self): + # Line-scan: compute a per-position PDF stack instead of one curve set. + if self.analysis_mode == "line" and self.line_mode == "linescan": + self._compute_linescan() + return + self.computing = True + self.status_message = "Computing..." + try: + mask = self._get_mask() + + # Convert 0.0 sentinels to None for the PDF API + k_min_fit = self.k_min_fit if self.k_min_fit > 0 else None + k_max_fit = self.k_max_fit if self.k_max_fit > 0 else None + k_min_window = self.k_min_window if self.k_min_window > 0 else None + k_max_window = self.k_max_window if self.k_max_window > 0 else None + k_lowpass = self.k_lowpass if self.k_lowpass > 0 else None + k_highpass = self.k_highpass if self.k_highpass > 0 else None + + self._pdf.calculate_Gr( + k_min_fit=k_min_fit, + k_max_fit=k_max_fit, + k_min_window=k_min_window, + k_max_window=k_max_window, + k_lowpass=k_lowpass, + k_highpass=k_highpass, + r_min=self.r_min, + r_max=self.r_max, + r_step=self.r_step, + mask_realspace=mask, + damp_origin_oscillations=self.damp_origin_oscillations, + r_cut=self.r_cut, + ) + self._sync_curves_to_js() + self._compute_gr() + self.status_message = "" + except Exception as e: + self.status_message = f"Error: {e}" + finally: + self.computing = False + + def _compute_gr(self): + """Compute g(r) via PairDistributionFunction.calculate_gr and sync to JS. + + Density choice follows ``self.density_mode``: + - "manual": pass ``self.density_value`` to calculate_gr. + - "estimated": density=None → calculate_gr uses cached rho0 or runs + estimate_density (with the shared ``r_cut``). Result is written back + to ``self.density_value`` so the JS readout reflects the estimate. + """ + if self._pdf._reduced_pdf is None or self._pdf._r is None: + self.n_points_pdf = 0 + self.pdf_x_bytes = b"" + self.pdf_y_bytes = b"" + return + try: + if self.density_mode == "manual": + self._pdf.calculate_gr( + density=float(self.density_value), + r_cut=self.r_cut, + ) + else: + self._pdf.calculate_gr(density=None, r_cut=self.r_cut) + if self._pdf.rho0 is not None: + rho_est = float(self._pdf.rho0) + if rho_est != self.density_value: + # Update the displayed value without re-triggering the + # density observer. + self._initializing = True + self.density_value = rho_est + self._initializing = False + except Exception as e: + self.status_message = f"g(r) error: {e}" + self.n_points_pdf = 0 + self.pdf_x_bytes = b"" + self.pdf_y_bytes = b"" + return + + r = to_numpy(self._pdf._r).astype(np.float32) + gr_arr = to_numpy(self._pdf._pdf).astype(np.float32) + self.n_points_pdf = len(r) + self.pdf_x_bytes = r.tobytes() + self.pdf_y_bytes = gr_arr.tobytes() + + def _sync_curves_to_js(self): + qq = np.asarray(self._pdf.qq, dtype=np.float32) + self.n_points_ik = len(qq) + self.ik_x_bytes = qq.tobytes() + self.ik_y_bytes = ( + to_numpy(self._pdf.Ik).astype(np.float32).tobytes() + ) + + # Background fit B(k) + if self._pdf.bg is not None: + self.ik_bg_y_bytes = ( + to_numpy(self._pdf.bg).astype(np.float32).tobytes() + ) + else: + self.ik_bg_y_bytes = b"" + + # F(k) windowed (Lorch window applied) + if self._pdf.Fk_masked is not None: + fk = to_numpy(self._pdf.Fk_masked).astype(np.float32) + self.n_points_fk = len(fk) + self.fk_x_bytes = qq[: len(fk)].tobytes() + self.fk_y_bytes = fk.tobytes() + else: + self.n_points_fk = 0 + self.fk_x_bytes = b"" + self.fk_y_bytes = b"" + + # G(r) + if self._pdf._r is not None and self._pdf._reduced_pdf is not None: + Gr = ( + self._pdf.reduced_pdf_damped + if self._pdf.reduced_pdf_damped is not None + else self._pdf._reduced_pdf + ) + r = to_numpy(self._pdf._r).astype(np.float32) + gr = to_numpy(Gr).astype(np.float32) + self.n_points_gr = len(r) + self.gr_x_bytes = r.tobytes() + self.gr_y_bytes = gr.tobytes() + else: + self.n_points_gr = 0 + self.gr_x_bytes = b"" + self.gr_y_bytes = b"" + + # ========================================================================= + # Public API + # ========================================================================= + @property + def pdf(self) -> PairDistributionFunction: + return self._pdf + + @property + def mask(self) -> np.ndarray: + m = self._get_mask() + if m is None: + return np.ones((self.scan_rows, self.scan_cols), dtype=bool) + return m + + def set_mask(self, mask) -> Self: + mask_np = to_numpy(mask).astype(bool) + if mask_np.shape != (self.scan_rows, self.scan_cols): + raise ValueError( + f"Mask shape {mask_np.shape} does not match scan shape " + f"({self.scan_rows}, {self.scan_cols})" + ) + raw = mask_np.astype(np.uint8).tobytes() + self.mask_bytes = raw + # Mirror to mask_b64 so _get_mask() (analysis read path) sees it. + self.mask_b64 = base64.b64encode(raw).decode("ascii") + self._on_mask_change() + return self + + def clear_mask(self) -> Self: + self.mask_bytes = b"" + self.mask_b64 = "" + self._on_mask_change() + return self + + def set_line( + self, + row0: float, + col0: float, + row1: float, + col1: float, + width: int | None = None, + ) -> Self: + """Set the line-band selection and switch to line mode. + + Endpoints are in scan (row, col) coordinates. ``width`` is the band + thickness in scan pixels; when omitted the current ``line_width`` is kept. + """ + self._initializing = True + self.line_row0 = float(row0) + self.line_col0 = float(col0) + self.line_row1 = float(row1) + self.line_col1 = float(col1) + if width is not None: + self.line_width = int(width) + self.line_active = True + self.analysis_mode = "line" + self._initializing = False + self._on_line_change() + return self + + def clear_line(self) -> Self: + self._initializing = True + self.line_active = False + self.line_row0 = self.line_col0 = self.line_row1 = self.line_col1 = 0.0 + self._initializing = False + self._on_line_change() + return self + + def set_data(self, data, *, nav_image=None, find_origin=True, **pdf_kwargs) -> Self: + if isinstance(data, PairDistributionFunction): + self._pdf = data + else: + if hasattr(data, "data") and hasattr(data, "title"): + data = data.data + if not hasattr(data, "array"): + arr = to_numpy(data) + if arr.ndim == 4: + data = Dataset4dstem.from_array(arr) + elif arr.ndim == 2: + data = Dataset2d.from_array(arr) + else: + raise ValueError( + f"ShowPDF expects 4D (4D-STEM) or 2D input; got {arr.ndim}D array." + ) + self._pdf = PairDistributionFunction.from_data( + data, find_origin=find_origin, **pdf_kwargs + ) + + polar_shape = self._pdf.polar.shape + self.scan_rows = int(polar_shape[0]) + self.scan_cols = int(polar_shape[1]) + + qq = np.asarray(self._pdf.qq, dtype=np.float32) + self.k_min_available = float(qq[0]) + self.k_max_available = float(qq[-1]) + + # Nav image + if nav_image is not None: + nav_img = to_numpy(nav_image).astype(np.float32) + else: + nav_img = self._compute_nav_image() + self._nav_image = nav_img + self.nav_data_min = float(nav_img.min()) + self.nav_data_max = float(nav_img.max()) + self.nav_image_bytes = nav_img.tobytes() + self.nav_pixel_size, self.nav_unit = self._resolve_nav_calibration() + + # Reset mask and recompute + self.mask_bytes = b"" + self._update_mask_stats() + self._pdf.Ik = None + self._pdf.bg = None + self._recompute_full() + return self + + def save_image( + self, + path: str | pathlib.Path, + *, + plot_mode: str | None = None, + format: str | None = None, + dpi: int = 150, + ) -> pathlib.Path: + """Save the current PDF curve as PNG, PDF, or TIFF. + + Parameters + ---------- + path : str or pathlib.Path + Output file path. + plot_mode : {"Ik", "Fk", "Gr", "gr"}, optional + Curve to render. Defaults to the widget's current ``plot_mode``. + format : {"png", "pdf", "tiff"}, optional + Inferred from the file extension when omitted. + dpi : int, default 150 + Output resolution. + + Returns + ------- + pathlib.Path + The written file path. + """ + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + path = pathlib.Path(path) + fmt = (format or path.suffix.lstrip(".").lower() or "png").lower() + if fmt in ("tif",): + fmt = "tiff" + if fmt not in ("png", "pdf", "tiff"): + raise ValueError(f"Unsupported format: {fmt!r}. Use 'png', 'pdf', or 'tiff'.") + + mode = plot_mode if plot_mode is not None else self.plot_mode + if mode not in ("Ik", "Fk", "Gr", "gr"): + raise ValueError(f"Unknown plot_mode: {mode!r}. Use 'Ik', 'Fk', 'Gr', or 'gr'.") + + bg = None + if mode == "Ik": + if self._pdf.Ik is None: + raise ValueError("I(k) is not computed yet — set a mask or call _recompute_full().") + x = np.asarray(self._pdf.qq, dtype=np.float32) + y = to_numpy(self._pdf.Ik).astype(np.float32) + if self._pdf.bg is not None and self.show_background: + bg = to_numpy(self._pdf.bg).astype(np.float32) + xlabel, ylabel = "k (1/Å)", "I(k)" + elif mode == "Fk": + if self._pdf.Fk_masked is None: + raise ValueError("F(k) is not computed yet.") + qq = np.asarray(self._pdf.qq, dtype=np.float32) + fk = to_numpy(self._pdf.Fk_masked).astype(np.float32) + x = qq[: len(fk)] + y = fk + xlabel, ylabel = "k (1/Å)", "F(k)" + elif mode == "Gr": + if self._pdf._r is None or self._pdf._reduced_pdf is None: + raise ValueError("G(r) is not computed yet.") + Gr = ( + self._pdf.reduced_pdf_damped + if self._pdf.reduced_pdf_damped is not None + else self._pdf._reduced_pdf + ) + x = to_numpy(self._pdf._r).astype(np.float32) + y = to_numpy(Gr).astype(np.float32) + xlabel, ylabel = "r (Å)", "G(r)" + else: # "gr" + if not self.pdf_x_bytes or not self.pdf_y_bytes: + raise ValueError("g(r) is not computed yet.") + x = np.frombuffer(self.pdf_x_bytes, dtype=np.float32) + y = np.frombuffer(self.pdf_y_bytes, dtype=np.float32) + xlabel, ylabel = "r (Å)", "g(r)" + + fig, ax = plt.subplots(figsize=(6, 3.5), dpi=dpi) + ax.plot(x, y, color="steelblue", linewidth=1.0, label=ylabel) + if bg is not None: + ax.plot(x, bg, color="orange", linewidth=1.0, linestyle="--", label="B(k)") + ax.legend(fontsize=8, framealpha=0.8) + ax.set_xlabel(xlabel) + ax.set_ylabel(ylabel) + if self.title: + ax.set_title(self.title, fontsize=11) + ax.grid(True, alpha=0.3, linestyle="--") + fig.tight_layout() + + path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(str(path), format=fmt, dpi=dpi, bbox_inches="tight") + plt.close(fig) + return path + + # ========================================================================= + # State persistence + # ========================================================================= + def state_dict(self) -> dict: + return { + "title": self.title, + "k_min_fit": self.k_min_fit, + "k_max_fit": self.k_max_fit, + "k_min_window": self.k_min_window, + "k_max_window": self.k_max_window, + "k_lowpass": self.k_lowpass, + "k_highpass": self.k_highpass, + "r_min": self.r_min, + "r_max": self.r_max, + "r_step": self.r_step, + "damp_origin_oscillations": self.damp_origin_oscillations, + "r_cut": self.r_cut, + "density_mode": self.density_mode, + "density_value": self.density_value, + "analysis_mode": self.analysis_mode, + "probe_row": self.probe_row, + "probe_col": self.probe_col, + "probe_size": self.probe_size, + "line_row0": self.line_row0, + "line_col0": self.line_col0, + "line_row1": self.line_row1, + "line_col1": self.line_col1, + "line_width": self.line_width, + "line_active": self.line_active, + "line_mode": self.line_mode, + "line_perpendicular": self.line_perpendicular, + "linescan_max_bins": self.linescan_max_bins, + # Painted mask at save time (base64 uint8, scan_rows*scan_cols). + # Empty string means "no mask / full scan". + "mask_b64": self.mask_b64, + "plot_mode": self.plot_mode, + "show_background": self.show_background, + "cmap": self.cmap, + "log_scale": self.log_scale, + "auto_contrast": self.auto_contrast, + "show_stats": self.show_stats, + "show_controls": self.show_controls, + } + + def save(self, path: str) -> None: + save_state_file(path, "ShowPDF", self.state_dict()) + + def load_state_dict(self, state: dict) -> None: + self._initializing = True + allowed_keys = set(self.state_dict().keys()) + for key, val in state.items(): + if key in allowed_keys and hasattr(self, key): + setattr(self, key, val) + # Restore the painted mask to the frontend canvas. mask_b64 is the + # JS→Python read path; mask_bytes is the Python→JS path the canvas + # reads on mount, so both must be set for the mask to repaint and to + # feed _get_mask(). Drop a saved mask whose size no longer matches the + # current scan shape (e.g. restoring onto a different dataset). + if self.mask_b64: + try: + raw = base64.b64decode(self.mask_b64) + except Exception: + raw = b"" + if len(raw) == self.scan_rows * self.scan_cols: + self.mask_bytes = raw + else: + self.mask_b64 = "" + self.mask_bytes = b"" + self._initializing = False + # Recompute with all restored parameters + self._update_mask_stats() + self._pdf.Ik = None + self._pdf.bg = None + self._recompute_full() + + def summary(self) -> None: + name = self.title or "ShowPDF" + lines = [name, "═" * 32] + lines.append(f"Scan: {self.scan_rows} × {self.scan_cols}") + lines.append( + f"k range: [{self.k_min_available:.2f}, {self.k_max_available:.2f}] Å⁻¹" + ) + lines.append(f"Fit: k=[{self.k_min_fit:.2f}, {self.k_max_fit:.2f}]") + lines.append( + f"Output: r=[{self.r_min:.2f}, {self.r_max:.2f}], step={self.r_step}" + ) + lines.append(f"Plot: {self.plot_mode}") + if self.analysis_mode == "line": + if self.line_active: + length = float( + np.hypot( + self.line_row1 - self.line_row0, + self.line_col1 - self.line_col0, + ) + ) + lines.append( + f"Line: ({self.line_row0:.1f}, {self.line_col0:.1f}) → " + f"({self.line_row1:.1f}, {self.line_col1:.1f})" + ) + lines.append( + f" len={length:.1f} px, width={self.line_width} px, " + f"{self.mask_pixel_count} px ({self.mask_fraction * 100:.1f}%)" + ) + else: + lines.append("Line: none drawn") + elif self.mask_bytes: + lines.append( + f"Mask: {self.mask_pixel_count} px ({self.mask_fraction * 100:.1f}%)" + ) + else: + lines.append("Mask: full scan (no mask)") + if self.damp_origin_oscillations: + lines.append(f"Damping: ON (r_cut={self.r_cut})") + print("\n".join(lines)) + + def __repr__(self) -> str: + mask_info = ( + f", mask={self.mask_pixel_count}px" if self.mask_bytes else "" + ) + return ( + f"ShowPDF(scan=({self.scan_rows}, {self.scan_cols}), " + f"k=[{self.k_min_fit:.1f}, {self.k_max_fit:.1f}]{mask_info})" + ) From ae9d7a3a68d87d81084898d50ed761e70228d285 Mon Sep 17 00:00:00 2001 From: Ehrdt Ehrhardt Date: Thu, 11 Jun 2026 12:41:21 -0700 Subject: [PATCH 2/4] add file save from widget --- widget/src/quantem/widget/showpdf.py | 627 +++++++++++++++++++++++++++ 1 file changed, 627 insertions(+) diff --git a/widget/src/quantem/widget/showpdf.py b/widget/src/quantem/widget/showpdf.py index 7e837fa9d..7e74703a5 100644 --- a/widget/src/quantem/widget/showpdf.py +++ b/widget/src/quantem/widget/showpdf.py @@ -800,6 +800,124 @@ def _compute_linescan(self): finally: self.computing = False + def _compute_linescan_all_modes(self): + """Per-bin curves for all four plot modes plus the fitted background. + + Mirrors ``_compute_linescan`` but, for each bin, captures + (I(k), B(k), F(k), G(r), g(r)) instead of just the active plot_mode. + Used by ``save_results`` to export the full line scan. Does NOT update + any trait-synced bytes — the live widget keeps its single-mode stack. + + Returns ``None`` if no line is drawn or no bin has valid data; else a + dict with the per-bin stacks and shared axes. + """ + import torch + + band = self._line_band_mask() + if band is None: + return None + r0, c0, r1, c1 = self._effective_line() + dr, dc = r1 - r0, c1 - c0 + seg_len_sq = dr * dr + dc * dc + length = float(np.sqrt(seg_len_sq)) + n_bins = max(1, min(int(self.linescan_max_bins), int(round(length)) + 1)) + + rows = np.arange(self.scan_rows, dtype=np.float64)[:, None] + cols = np.arange(self.scan_cols, dtype=np.float64)[None, :] + t = np.clip(((rows - r0) * dr + (cols - c0) * dc) / seg_len_sq, 0.0, 1.0) + bin_idx = np.minimum((t * n_bins).astype(int), n_bins - 1) + + Ik_all, valid_bins = self._linescan_bin_radial_means(band, bin_idx, n_bins) + if not valid_bins: + return None + params = self._curve_params() + bg_all, f_all = self._pdf.fit_bg_batched( + Ik_all, kmin=params["k_min_fit"], kmax=params["k_max_fit"] + ) + + k_axis = np.asarray(self._pdf.qq, dtype=np.float32) + n_k = int(k_axis.size) + Ik_stack = np.full((n_bins, n_k), np.nan, dtype=np.float32) + bg_stack = np.full((n_bins, n_k), np.nan, dtype=np.float32) + Fk_stack = None # allocated once we know n_k_fk + Gr_stack = None # allocated once we know n_r + gr_stack = None # allocated only if any bin produces a g(r) + r_axis = None + k_fk_axis = None + density_mode = self.density_mode + try_gr = density_mode in ("manual", "estimated") + + for j, bin_i in enumerate(valid_bins): + self._pdf.Ik = Ik_all[j] + self._pdf.bg = bg_all[j] + self._pdf.f = f_all[j] + self._pdf.calculate_Gr(mask_realspace=None, **params) + + Ik_stack[bin_i] = to_numpy(Ik_all[j]).astype(np.float32) + bg_stack[bin_i] = to_numpy(bg_all[j]).astype(np.float32) + + if self._pdf.Fk_masked is not None: + fk_j = to_numpy(self._pdf.Fk_masked).astype(np.float32) + if Fk_stack is None: + n_k_fk = int(fk_j.size) + k_fk_axis = k_axis[:n_k_fk] + Fk_stack = np.full((n_bins, n_k_fk), np.nan, dtype=np.float32) + m = min(int(fk_j.size), Fk_stack.shape[1]) + Fk_stack[bin_i, :m] = fk_j[:m] + + if self._pdf._r is not None and self._pdf._reduced_pdf is not None: + Gr_j = ( + self._pdf.reduced_pdf_damped + if self._pdf.reduced_pdf_damped is not None + else self._pdf._reduced_pdf + ) + Gr_j = to_numpy(Gr_j).astype(np.float32) + if Gr_stack is None: + r_axis = to_numpy(self._pdf._r).astype(np.float32) + Gr_stack = np.full((n_bins, int(r_axis.size)), np.nan, dtype=np.float32) + m = min(int(Gr_j.size), Gr_stack.shape[1]) + Gr_stack[bin_i, :m] = Gr_j[:m] + + if try_gr: + try: + density = ( + float(self.density_value) + if density_mode == "manual" + else None + ) + self._pdf.calculate_gr(density=density, r_cut=self.r_cut) + if self._pdf._pdf is not None: + gr_j = to_numpy(self._pdf._pdf).astype(np.float32) + if gr_stack is None: + gr_stack = np.full( + (n_bins, Gr_stack.shape[1]), np.nan, dtype=np.float32 + ) + m = min(int(gr_j.size), gr_stack.shape[1]) + gr_stack[bin_i, :m] = gr_j[:m] + except Exception: + # If g(r) fails for this bin (e.g. density estimation), + # leave NaN and keep going — G(r) is still valid. + pass + + # Bin-center position along the line in scan pixels. + bin_centers_t = (np.arange(n_bins, dtype=np.float64) + 0.5) / float(n_bins) + bin_positions_px = (bin_centers_t * length).astype(np.float32) + + return { + "n_bins": int(n_bins), + "valid_bins": list(valid_bins), + "bin_positions_px": bin_positions_px, + "line_length_px": float(length), + "k": k_axis, + "Ik": Ik_stack, + "bg": bg_stack, + "k_fk": k_fk_axis if k_fk_axis is not None else np.zeros(0, dtype=np.float32), + "Fk": Fk_stack if Fk_stack is not None else np.zeros((n_bins, 0), dtype=np.float32), + "r": r_axis if r_axis is not None else np.zeros(0, dtype=np.float32), + "Gr": Gr_stack if Gr_stack is not None else np.zeros((n_bins, 0), dtype=np.float32), + "gr": gr_stack, # None if no density set / never produced + } + def _recompute_full(self): # Line-scan: compute a per-position PDF stack instead of one curve set. if self.analysis_mode == "line" and self.line_mode == "linescan": @@ -1135,6 +1253,274 @@ def save_image( plt.close(fig) return path + def save_results( + self, + path: str | pathlib.Path, + *, + overwrite: bool = False, + ) -> pathlib.Path: + """Save the current PDF results to a self-contained zip file. + + The zip is laid out to be openable with very little Python knowledge: + a plaintext README, a JSON metadata file, and CSV curve files. The mask + (for mask mode) is the one exception — saved as ``mask.npy`` because a + scan-shaped boolean CSV is unwieldy. + + Layout (always present): + README.txt + metadata.txt + curves_k.csv k, I(k), B(k), F(k) (selected region) + curves_r.csv r, G(r), g(r) (if computed) + + Extra files by mode: + mask mode: mask.npy + line/linescan: linescan_position.csv, linescan_Ik.csv, + linescan_Ik_bg.csv, linescan_Fk.csv, + linescan_Gr.csv, linescan_gr.csv (if g(r) computed) + + Parameters + ---------- + path : str or pathlib.Path + Output zip path. Should end in ``.zip``; the extension is added if + missing. + overwrite : bool, default False + If False and the file already exists, raises FileExistsError. + + Returns + ------- + pathlib.Path + The written zip path. + """ + import csv + import datetime + import io + import zipfile + + path = pathlib.Path(path) + if path.suffix.lower() != ".zip": + path = path.with_suffix(path.suffix + ".zip" if path.suffix else ".zip") + if path.exists() and not overwrite: + raise FileExistsError( + f"{path} already exists; pass overwrite=True to replace it." + ) + + is_linescan = ( + self.analysis_mode == "line" and self.line_mode == "linescan" + ) + + # ---- Gather data ---------------------------------------------------- + # For single-curve modes (mask / probe / line-averaged) the curves are + # already in self._pdf after the most recent _recompute_full(). For + # linescan we run the all-modes helper so every per-bin curve is saved. + if is_linescan: + ls = self._compute_linescan_all_modes() + if ls is None: + raise RuntimeError( + "Line scan has no valid bins yet — draw a line and let the " + "widget compute before saving." + ) + # For the single-curve "curves_*.csv" files we still emit the + # whole-band-averaged curves so the zip always has a 1D summary. + band_mask = self._line_band_mask() + if band_mask is None: + raise RuntimeError("Line is no longer active; cannot save.") + saved_state = (self._pdf.Ik, self._pdf.bg, getattr(self._pdf, "f", None)) + try: + self._pdf.Ik = None + self._pdf.bg = None + self._pdf.calculate_Gr( + mask_realspace=band_mask, **self._curve_params() + ) + if self.density_mode in ("manual", "estimated"): + try: + density = ( + float(self.density_value) + if self.density_mode == "manual" + else None + ) + self._pdf.calculate_gr(density=density, r_cut=self.r_cut) + except Exception: + pass + k1d, ik1d, bg1d, fk1d, r1d, gr1d, grden1d = self._extract_1d_curves() + finally: + # Restore whatever the widget was last showing — leaving self._pdf + # in a half-mutated state would break the next slider event. + self._pdf.Ik, self._pdf.bg = saved_state[0], saved_state[1] + if saved_state[2] is not None: + self._pdf.f = saved_state[2] + else: + if self._pdf.Ik is None: + raise RuntimeError( + "Curves not yet computed — interact with the widget once " + "(or call set_mask / set_line / change a slider) before saving." + ) + k1d, ik1d, bg1d, fk1d, r1d, gr1d, grden1d = self._extract_1d_curves() + + # ---- Build metadata -------------------------------------------------- + metadata = { + "widget_version": str(self.widget_version), + "saved_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "title": self.title, + "scan_shape": [int(self.scan_rows), int(self.scan_cols)], + "analysis_mode": self.analysis_mode, + "line_mode": self.line_mode if self.analysis_mode == "line" else None, + "plot_mode_at_save": self.plot_mode, + "region": self._region_metadata(), + "fit_parameters": { + "k_min_fit": float(self.k_min_fit), + "k_max_fit": float(self.k_max_fit), + "k_min_window": float(self.k_min_window), + "k_max_window": float(self.k_max_window), + "k_lowpass": float(self.k_lowpass), + "k_highpass": float(self.k_highpass), + "r_min": float(self.r_min), + "r_max": float(self.r_max), + "r_step": float(self.r_step), + "damp_origin_oscillations": bool(self.damp_origin_oscillations), + "r_cut": float(self.r_cut), + "density_mode": self.density_mode, + "density_value": float(self.density_value), + }, + } + if is_linescan: + metadata["region"]["n_linescan_bins"] = int(ls["n_bins"]) + metadata["region"]["line_length_px"] = float(ls["line_length_px"]) + + # ---- Write into the zip --------------------------------------------- + path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + zf.writestr("metadata.txt", _format_metadata_txt(metadata)) + zf.writestr( + "curves_k.csv", + _columns_to_csv( + ["k", "Ik", "bg", "Fk"], + [k1d, ik1d, bg1d, fk1d], + ), + ) + zf.writestr( + "curves_r.csv", + _columns_to_csv( + ["r", "Gr", "gr"], + [r1d, gr1d, grden1d], + ), + ) + + if self.analysis_mode == "mask": + mask = self._get_mask() + if mask is None: + mask = np.ones((self.scan_rows, self.scan_cols), dtype=bool) + buf = io.BytesIO() + np.save(buf, mask.astype(bool), allow_pickle=False) + zf.writestr("mask.npy", buf.getvalue()) + + if is_linescan: + pos_buf = io.StringIO() + w = csv.writer(pos_buf) + w.writerow(["bin_index", "position_px"]) + for i, p in enumerate(ls["bin_positions_px"]): + w.writerow([i, f"{float(p):.6g}"]) + zf.writestr("linescan_position.csv", pos_buf.getvalue()) + + zf.writestr( + "linescan_Ik.csv", + _stack_to_csv_with_axis(ls["k"], ls["Ik"]), + ) + zf.writestr( + "linescan_Ik_bg.csv", + _stack_to_csv_with_axis(ls["k"], ls["bg"]), + ) + if ls["Fk"].size: + zf.writestr( + "linescan_Fk.csv", + _stack_to_csv_with_axis(ls["k_fk"], ls["Fk"]), + ) + if ls["Gr"].size: + zf.writestr( + "linescan_Gr.csv", + _stack_to_csv_with_axis(ls["r"], ls["Gr"]), + ) + if ls["gr"] is not None and ls["gr"].size: + zf.writestr( + "linescan_gr.csv", + _stack_to_csv_with_axis(ls["r"], ls["gr"]), + ) + + zf.writestr("README.txt", _build_readme(is_linescan, self.analysis_mode)) + + return path.resolve() + + def _extract_1d_curves(self): + """Return (k, I(k), B(k), F(k), r, G(r), g(r)) for the current state. + + Each array is float32. F(k) may be shorter than I(k); the caller is + responsible for padding when writing aligned CSVs. r-axis curves are + empty arrays when G(r) has not been computed. + """ + k = np.asarray(self._pdf.qq, dtype=np.float32) + ik = ( + to_numpy(self._pdf.Ik).astype(np.float32) + if self._pdf.Ik is not None + else np.zeros(0, dtype=np.float32) + ) + bg = ( + to_numpy(self._pdf.bg).astype(np.float32) + if self._pdf.bg is not None + else np.zeros(0, dtype=np.float32) + ) + fk = ( + to_numpy(self._pdf.Fk_masked).astype(np.float32) + if self._pdf.Fk_masked is not None + else np.zeros(0, dtype=np.float32) + ) + if self._pdf._r is not None and self._pdf._reduced_pdf is not None: + r_axis = to_numpy(self._pdf._r).astype(np.float32) + Gr_arr = ( + self._pdf.reduced_pdf_damped + if self._pdf.reduced_pdf_damped is not None + else self._pdf._reduced_pdf + ) + gr_curve = to_numpy(Gr_arr).astype(np.float32) + else: + r_axis = np.zeros(0, dtype=np.float32) + gr_curve = np.zeros(0, dtype=np.float32) + if self._pdf._pdf is not None and r_axis.size: + grden = to_numpy(self._pdf._pdf).astype(np.float32) + else: + grden = np.zeros(0, dtype=np.float32) + return k, ik, bg, fk, r_axis, gr_curve, grden + + def _region_metadata(self): + """Mode-specific region selection for metadata.txt.""" + if self.analysis_mode == "mask": + return { + "kind": "mask", + "mask_pixel_count": int(self.mask_pixel_count), + "mask_fraction": float(self.mask_fraction), + "mask_file": "mask.npy", + } + if self.analysis_mode == "probe": + return { + "kind": "probe", + "probe_row": int(self.probe_row), + "probe_col": int(self.probe_col), + "probe_size": int(self.probe_size), + "mask_pixel_count": int(self.mask_pixel_count), + "mask_fraction": float(self.mask_fraction), + } + if self.analysis_mode == "line": + return { + "kind": "line", + "line_row0": float(self.line_row0), + "line_col0": float(self.line_col0), + "line_row1": float(self.line_row1), + "line_col1": float(self.line_col1), + "line_width": int(self.line_width), + "line_perpendicular": bool(self.line_perpendicular), + "mask_pixel_count": int(self.mask_pixel_count), + "mask_fraction": float(self.mask_fraction), + } + return {"kind": self.analysis_mode} + # ========================================================================= # State persistence # ========================================================================= @@ -1258,3 +1644,244 @@ def __repr__(self) -> str: f"ShowPDF(scan=({self.scan_rows}, {self.scan_cols}), " f"k=[{self.k_min_fit:.1f}, {self.k_max_fit:.1f}]{mask_info})" ) + + +# ============================================================================= +# save_results helpers (module-level so they stay testable / out of the trait +# class). All CSV output uses comma separators, dot-decimals, and empty cells +# for missing values so the files open cleanly in Excel / pandas. +# ============================================================================= +def _format_cell(v) -> str: + """Float → short decimal; NaN / empty → empty cell.""" + import math + try: + f = float(v) + except (TypeError, ValueError): + return "" + if math.isnan(f): + return "" + return f"{f:.7g}" + + +def _columns_to_csv(headers, columns) -> str: + """Write ragged 1D columns as a CSV, padding shorter columns with empty cells. + + Used for ``curves_k.csv`` (F(k) typically shorter than I(k)) and + ``curves_r.csv`` (g(r) absent if no density). + """ + import csv + import io + + n_rows = max((len(c) for c in columns), default=0) + buf = io.StringIO() + w = csv.writer(buf) + w.writerow(headers) + for i in range(n_rows): + row = [] + for col in columns: + row.append(_format_cell(col[i]) if i < len(col) else "") + w.writerow(row) + return buf.getvalue() + + +def _stack_to_csv_with_axis(axis, stack) -> str: + """2D stack as CSV with the axis values as a header row. + + Layout: + axis_0, axis_1, ..., axis_{n_cols-1} + stack[0, 0], stack[0, 1], ... + stack[1, 0], ... + ... + + NaN cells are written empty (linescan stacks NaN-fill invalid bins). + """ + import csv + import io + + n_cols = int(stack.shape[1]) + axis_arr = np.asarray(axis).reshape(-1)[:n_cols] + buf = io.StringIO() + w = csv.writer(buf) + w.writerow([_format_cell(v) for v in axis_arr]) + for row in stack: + w.writerow([_format_cell(v) for v in row]) + return buf.getvalue() + + +def _format_metadata_txt(meta: dict) -> str: + """Render the metadata dict as a human-readable plaintext file. + + Layout: title + underlined section headers, two-column ``key: value`` + pairs inside each section, no quotes / braces / brackets. Sentinel 0.0 + values for k-range parameters render as ``disabled``. + """ + def _section(title: str, char: str = "-") -> list[str]: + return [title, char * len(title)] + + def _k_val(v) -> str: + return "disabled" if float(v) == 0.0 else f"{float(v):g}" + + def _bool_val(v) -> str: + return "on" if v else "off" + + lines: list[str] = [] + lines += [ + "quantem ShowPDF — exported results", + "===================================", + "", + f"Saved: {meta['saved_at']}", + f"Widget version: {meta['widget_version']}", + f"Title: {meta['title']}", + "", + ] + + lines += _section("Scan") + sr, sc = meta["scan_shape"] + lines.append(f" shape: {sr} x {sc} pixels") + lines.append("") + + lines += _section("Analysis") + lines.append(f" mode: {meta['analysis_mode']}") + if meta.get("line_mode"): + lines.append(f" line mode: {meta['line_mode']}") + lines.append(f" plot mode at save: {meta['plot_mode_at_save']}") + lines.append("") + + region = meta["region"] + kind = region.get("kind", "unknown") + lines += _section(f"Region ({kind})") + if kind == "mask": + lines.append( + f" pixels selected: {region.get('mask_pixel_count', 0)} " + f"({region.get('mask_fraction', 0.0) * 100:.1f}% of scan)" + ) + lines.append(f" mask file: {region.get('mask_file', 'mask.npy')}") + elif kind == "probe": + lines.append( + f" centre (row, col): ({region['probe_row']}, {region['probe_col']})" + ) + side = 2 * int(region["probe_size"]) - 1 + lines.append(f" size: {side} x {side} px") + lines.append( + f" pixels selected: {region.get('mask_pixel_count', 0)} " + f"({region.get('mask_fraction', 0.0) * 100:.1f}% of scan)" + ) + elif kind == "line": + lines.append( + f" start (row, col): " + f"({region['line_row0']:.2f}, {region['line_col0']:.2f})" + ) + lines.append( + f" end (row, col): " + f"({region['line_row1']:.2f}, {region['line_col1']:.2f})" + ) + lines.append(f" width: {region['line_width']} px") + lines.append( + f" perpendicular: " + f"{'yes' if region['line_perpendicular'] else 'no'}" + ) + lines.append( + f" pixels selected: {region.get('mask_pixel_count', 0)} " + f"({region.get('mask_fraction', 0.0) * 100:.1f}% of scan)" + ) + if "n_linescan_bins" in region: + lines.append(f" linescan bins: {region['n_linescan_bins']}") + lines.append(f" line length: {region['line_length_px']:.2f} px") + lines.append("") + + lines += _section("Fit parameters") + fp = meta["fit_parameters"] + lines.append(f" k_min_fit: {_k_val(fp['k_min_fit'])}") + lines.append(f" k_max_fit: {_k_val(fp['k_max_fit'])}") + lines.append(f" k_min_window: {_k_val(fp['k_min_window'])}") + lines.append(f" k_max_window: {_k_val(fp['k_max_window'])}") + lines.append(f" k_lowpass: {_k_val(fp['k_lowpass'])}") + lines.append(f" k_highpass: {_k_val(fp['k_highpass'])}") + lines.append(f" r_min: {float(fp['r_min']):g}") + lines.append(f" r_max: {float(fp['r_max']):g}") + lines.append(f" r_step: {float(fp['r_step']):g}") + lines.append( + f" damp_origin_oscillations: {_bool_val(fp['damp_origin_oscillations'])}" + ) + lines.append(f" r_cut: {float(fp['r_cut']):g}") + lines.append(f" density_mode: {fp['density_mode']}") + lines.append(f" density_value: {float(fp['density_value']):g}") + lines.append("") + + return "\n".join(lines) + + +def _build_readme(is_linescan: bool, analysis_mode: str) -> str: + lines = [ + "quantem ShowPDF — exported results", + "===================================", + "", + "Files in this archive:", + "", + " metadata.txt Mode, region selection, fit parameters, axis info,", + " timestamp. Plain text — open in any editor.", + "", + " curves_k.csv Reciprocal-space curves for the selected region:", + " columns are k, I(k), B(k), F(k).", + " Background B(k) is the fit; F(k) is the windowed", + " reduced structure factor (may end before I(k) does).", + "", + " curves_r.csv Real-space curves: columns are r, G(r), g(r).", + " g(r) is only present when a density was set.", + "", + ] + if analysis_mode == "mask": + lines += [ + " mask.npy Boolean 2D array (scan_rows × scan_cols) marking", + " the scan positions used. Load with numpy:", + " import numpy as np", + " mask = np.load('mask.npy')", + "", + ] + if is_linescan: + lines += [ + " linescan_position.csv", + " Two columns: bin_index, position_px.", + " position_px is the bin center along the drawn", + " line, in scan pixels.", + "", + " linescan_Ik.csv, linescan_Ik_bg.csv, linescan_Fk.csv,", + " linescan_Gr.csv, linescan_gr.csv", + " 2D stacks of per-bin curves. The FIRST row of", + " each file is the axis (k for Ik/Ik_bg/Fk; r for", + " Gr/gr). Each subsequent row is one bin (same", + " order as linescan_position.csv). Empty cells", + " indicate bins with no scan positions inside.", + "", + ] + lines += [ + "Quick load in Python:", + "", + " import numpy as np", + "", + " # 1D curves (Excel-style: skip the header row, blank cells → NaN)", + " curves_k = np.genfromtxt('curves_k.csv', delimiter=',',", + " names=True, missing_values='', filling_values=np.nan)", + " k = curves_k['k']", + " Ik = curves_k['Ik']", + " bg = curves_k['bg']", + " Fk = curves_k['Fk']", + "", + " # metadata.txt is plain text — read it with open() or just look at it", + " # in a text editor; the values are also visible in any browser.", + "", + ] + if is_linescan: + lines += [ + " # 2D linescan stack — first row is the axis, rest is data", + " raw = np.genfromtxt('linescan_Gr.csv', delimiter=',',", + " missing_values='', filling_values=np.nan)", + " r_axis = raw[0]", + " Gr_stack = raw[1:] # shape (n_bins, n_r)", + "", + ] + lines += [ + "Excel / Google Sheets: open any .csv file directly.", + "", + ] + return "\n".join(lines) From 258b4dbbf465a03906f583f218af9dd554095a12 Mon Sep 17 00:00:00 2001 From: Ehrdt Ehrhardt Date: Thu, 11 Jun 2026 13:22:50 -0700 Subject: [PATCH 3/4] complete merge --- widget/src/quantem/widget/showpdf.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/widget/src/quantem/widget/showpdf.py b/widget/src/quantem/widget/showpdf.py index 7e74703a5..030b35f04 100644 --- a/widget/src/quantem/widget/showpdf.py +++ b/widget/src/quantem/widget/showpdf.py @@ -1253,6 +1253,7 @@ def save_image( plt.close(fig) return path +<<<<<<< HEAD def save_results( self, path: str | pathlib.Path, @@ -1521,6 +1522,8 @@ def _region_metadata(self): } return {"kind": self.analysis_mode} +======= +>>>>>>> origin/showpdf-widget # ========================================================================= # State persistence # ========================================================================= @@ -1644,6 +1647,7 @@ def __repr__(self) -> str: f"ShowPDF(scan=({self.scan_rows}, {self.scan_cols}), " f"k=[{self.k_min_fit:.1f}, {self.k_max_fit:.1f}]{mask_info})" ) +<<<<<<< HEAD # ============================================================================= @@ -1885,3 +1889,5 @@ def _build_readme(is_linescan: bool, analysis_mode: str) -> str: "", ] return "\n".join(lines) +======= +>>>>>>> origin/showpdf-widget From 57e752f04a7e0b39e36ee45ce64466c7f1924ce0 Mon Sep 17 00:00:00 2001 From: Ehrdt Ehrhardt Date: Thu, 11 Jun 2026 13:35:27 -0700 Subject: [PATCH 4/4] resolve stale conflict markers in showpdf.py --- widget/src/quantem/widget/showpdf.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/widget/src/quantem/widget/showpdf.py b/widget/src/quantem/widget/showpdf.py index 030b35f04..7e74703a5 100644 --- a/widget/src/quantem/widget/showpdf.py +++ b/widget/src/quantem/widget/showpdf.py @@ -1253,7 +1253,6 @@ def save_image( plt.close(fig) return path -<<<<<<< HEAD def save_results( self, path: str | pathlib.Path, @@ -1522,8 +1521,6 @@ def _region_metadata(self): } return {"kind": self.analysis_mode} -======= ->>>>>>> origin/showpdf-widget # ========================================================================= # State persistence # ========================================================================= @@ -1647,7 +1644,6 @@ def __repr__(self) -> str: f"ShowPDF(scan=({self.scan_rows}, {self.scan_cols}), " f"k=[{self.k_min_fit:.1f}, {self.k_max_fit:.1f}]{mask_info})" ) -<<<<<<< HEAD # ============================================================================= @@ -1889,5 +1885,3 @@ def _build_readme(is_linescan: bool, analysis_mode: str) -> str: "", ] return "\n".join(lines) -======= ->>>>>>> origin/showpdf-widget