// Particle explorer inline script fragment (colour legend + montage + volumes). function handleExplorerLegendFilter(filter) { if (!filter) return; if (filter.kind === "discrete") { applyExplorerDiscreteFilter(filter); } else { applyExplorerContinuousFilter(filter); } } function onExplorerContinuousHistogramLayout() { syncParticleExplorerRowExpandForColorHist(); } function syncColorCovariateSidePanel() { if (!colorHistPanel) return; if (!plotHasScatterData() || !sc.value || sc.value === "none") { hideColorHistogram(); return; } if (explorerColorLegend && typeof explorerColorLegend.refresh === "function") { explorerColorLegend.refresh().then(function() { if (explorerColorLegend && typeof explorerColorLegend.syncThresholdMirrorFromHost === "function") { explorerColorLegend.syncThresholdMirrorFromHost({ thresholdCol: sc.value, level: colorThresholdLevel, range: colorThresholdRange, useMaxChecked: colorHistModeCheckOn() }); } }).catch(function() { /* refresh failed */ }); } syncParticleExplorerRowExpandForColorHist(); } function colorHistContinuousRangeModeActive() { return colorThresholdRange != null && colorThresholdColorCol === sc.value; } function colorHistModeCheckOn() { return !!(colorHistModeCheck && colorHistModeCheck.checked); } /** One shared checkbox: threshold mode → “use as maximum (≤)”; range mode → invert outside range. */ function syncColorHistModeCheckboxCaption() { var rangeMode = colorHistContinuousRangeModeActive(); if (colorHistModeCheckCaption) { colorHistModeCheckCaption.textContent = rangeMode ? "Invert range selection" : "Use as maximum (≤)"; } if (colorHistModeCheckWasRange !== rangeMode) { if (colorHistModeCheck) colorHistModeCheck.checked = false; } colorHistModeCheckWasRange = rangeMode; } /** * Lasso/box selection invalidates color-threshold mode. Drop the threshold guide * and status text without rebuilding the violin (avoids O(n log n) histogram work). */ function clearColorHistogramThresholdForGeometricSelection() { colorThresholdLevel = null; colorThresholdRange = null; colorThresholdColorCol = null; if (explorerColorLegend) explorerColorLegend.clearThreshold(); if (colorThresholdStatus) colorThresholdStatus.textContent = ""; syncColorHistModeCheckboxCaption(); if (explorerColorLegend) explorerColorLegend.setDiscreteCheckedKeys([]); } function traceCustomdataColorDisp(ti) { var cd = gd.data[0].customdata; if (!cd || ti < 0 || ti >= cd.length) return null; var row = cd[ti]; if (row && row.length > 2 && row[2] != null && row[2] !== "") return row[2]; return null; } /** Parse #rgb / #rrggbb → {r,g,b} or null. */ function parseMarkerHexRgb(hex) { if (typeof hex !== "string") return null; var h = hex.trim(); if (h.charAt(0) !== "#") return null; h = h.slice(1); var r; var g; var b; if (h.length === 3) { r = parseInt(h.charAt(0) + h.charAt(0), 16); g = parseInt(h.charAt(1) + h.charAt(1), 16); b = parseInt(h.charAt(2) + h.charAt(2), 16); } else if (h.length === 6) { r = parseInt(h.slice(0, 2), 16); g = parseInt(h.slice(2, 4), 16); b = parseInt(h.slice(4, 6), 16); } else { return null; } if (!isFinite(r) || !isFinite(g) || !isFinite(b)) return null; return { r: r, g: g, b: b }; } /** * k-means / discrete ChimeraX colors: lightly pastel cell fill (small blend toward white) * plus a darker saturated tint for the covariate line so it stays readable on the chip. */ function discreteLabelMontageStyles(hex) { var rgb = parseMarkerHexRgb(hex); if (!rgb) return { bg: "", covarColor: "" }; var r = rgb.r; var g = rgb.g; var b = rgb.b; var towardWhite = 0.3; var rf = Math.round(r * (1 - towardWhite) + 255 * towardWhite); var gf = Math.round(g * (1 - towardWhite) + 255 * towardWhite); var bf = Math.round(b * (1 - towardWhite) + 255 * towardWhite); rf = Math.max(0, Math.min(255, rf)); gf = Math.max(0, Math.min(255, gf)); bf = Math.max(0, Math.min(255, bf)); var textDim = 0.46; var rt = Math.max(0, Math.min(255, Math.round(r * textDim))); var gt = Math.max(0, Math.min(255, Math.round(g * textDim))); var bt = Math.max(0, Math.min(255, Math.round(b * textDim))); return { bg: "rgb(" + rf + "," + gf + "," + bf + ")", covarColor: "rgb(" + rt + "," + gt + "," + bt + ")" }; } /** Parse ``rgb(r, g, b)`` from a CSS colour string. */ function parseRgbCssTriplet(css) { var m = String(css || "").trim().match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)\s*$/i); if (!m) return null; return { r: +m[1], g: +m[2], b: +m[3] }; } /** Pastel cell fill + readable annotation tints from a continuous-palette sample (same blend as discrete). */ function continuousMontageStylesFromT(t) { var css = covariateScaleCSS(t); var rgb = parseRgbCssTriplet(css); if (!rgb) return { bg: css, covarColor: "", borderCss: css, idxColor: "" }; var r = rgb.r; var g = rgb.g; var b = rgb.b; var towardWhite = 0.3; var rf = Math.round(r * (1 - towardWhite) + 255 * towardWhite); var gf = Math.round(g * (1 - towardWhite) + 255 * towardWhite); var bf = Math.round(b * (1 - towardWhite) + 255 * towardWhite); rf = Math.max(0, Math.min(255, rf)); gf = Math.max(0, Math.min(255, gf)); bf = Math.max(0, Math.min(255, bf)); var textDim = 0.46; var rt = Math.max(0, Math.min(255, Math.round(r * textDim))); var gt = Math.max(0, Math.min(255, Math.round(g * textDim))); var bt = Math.max(0, Math.min(255, Math.round(b * textDim))); var cov = "rgb(" + rt + "," + gt + "," + bt + ")"; return { bg: "rgb(" + rf + "," + gf + "," + bf + ")", covarColor: cov, borderCss: css, idxColor: cov }; } function discreteMontageSortKey(ti) { var d = traceCustomdataColorDisp(ti); if (d == null) return { n: NaN, s: "" }; if (typeof d === "number" && isFinite(d)) return { n: d, s: String(d) }; var sn = String(d); var pn = parseFloat(sn); if (sn !== "" && isFinite(pn) && String(pn) === sn) return { n: pn, s: sn }; return { n: NaN, s: sn }; } /** Covariate colour for montage card border + scatter grid-letter marker outline (CSS colour string). */ function montageCovariateBorderCss(ti, colorMode, markerColors, cmin, cmax) { if (!Array.isArray(markerColors) || ti < 0 || ti >= markerColors.length) return ACCENT; if (colorMode === "discrete") { var hx = markerColors[ti]; return typeof hx === "string" && hx.charAt(0) === "#" ? hx : String(hx || ACCENT); } var val = markerColors[ti]; var t = cmax > cmin && typeof val === "number" && isFinite(val) ? (val - cmin) / (cmax - cmin) : 0.5; return covariateScaleCSS(t); } /** * Grid-letter scatter overlay (trace 1): constant marker opacity; selection is shown only by filled vs hollow * markers; each point keeps a white marker ring; fill reflects covariate / selection. * When no selection exists yet, every annotated grid point is drawn filled (same as “in selection”). * (cryo-grid-highlight-marker-policy) */ var GRID_HIGHLIGHT_CLEAR_FILL = "rgba(0,0,0,0)"; var GRID_HIGHLIGHT_LINE_WIDTH = 2.5; /** White ring around grid-letter marker circles (fill still reflects covariate / selection). */ var GRID_HIGHLIGHT_MARKER_LINE_COLOR = "#ffffff"; /** Match ``scatter_json`` marker opacity for the main explorer scatter trace. */ var EXPLORER_SCATTER_MARKER_OPACITY = 0.35; var GRID_HIGHLIGHT_MARKER_OPACITY = 0.53; /** Grid letters on the scatter plot (HTML overlay + Plotly trace) are always white. */ var GRID_HIGHLIGHT_TEXT_COLOR = "#ffffff"; var gridLetterGlyphsOverlayEl = document.getElementById("scatter-grid-letter-glyphs-overlay"); var gridLetterGlyphHookWired = false; /** * Keep grid-letter trace (index 1) from inheriting scattergl selection dimming when trace 0 has * ``selectedpoints`` — use SVG scatter + explicit ``unselected`` opacities (cryo-grid-highlight-no-dim). */ /** Image grid row count for highlight sizing (1–8). */ function gridHighlightRowsForSizing(gridSz) { var n = parseInt(gridSz, 10); if (!isFinite(n) || n < 1) n = 1; if (n > 8) n = 8; return n; } /** Marker diameter: 24 at 1×1, −1 per row (17 at 8×8). */ function gridHighlightMarkerSizePx(gridSz) { return 25 - gridHighlightRowsForSizing(gridSz); } /** Plot letter size: 16 at 1×1, −1 per row (9 at 8×8). */ function gridHighlightTextSizePx(gridSz) { return 17 - gridHighlightRowsForSizing(gridSz); } function explorerDataToPlotStackXY(dataX, dataY) { var fl = gd && gd._fullLayout; if (!fl || !fl.xaxis || !fl.yaxis || !gridLetterGlyphsOverlayEl || !gd) return null; var plotArea = gd.querySelector(".nsewdrag"); if (!plotArea) return null; var plotRect = plotArea.getBoundingClientRect(); var stackEl = gridLetterGlyphsOverlayEl.parentElement; if (!stackEl) return null; var stackRect = stackEl.getBoundingClientRect(); var xr = fl.xaxis.range; var yr = fl.yaxis.range; if (!xr || xr.length < 2 || !yr || yr.length < 2) return null; if (xr[1] === xr[0] || yr[1] === yr[0]) return null; var fracX = (dataX - xr[0]) / (xr[1] - xr[0]); var fracY = (yr[1] - dataY) / (yr[1] - yr[0]); return [ plotRect.left - stackRect.left + fracX * plotRect.width, plotRect.top - stackRect.top + fracY * plotRect.height ]; } function clearGridLetterGlyphOverlay() { if (gridLetterGlyphsOverlayEl) gridLetterGlyphsOverlayEl.innerHTML = ""; } function syncGridLetterGlyphOverlay() { clearGridLetterGlyphOverlay(); if (!gridLetterGlyphsOverlayEl || !gd || !gd._fullLayout || !showGridHighlightsOnPlot()) { if (gridLetterGlyphsOverlayEl) { gridLetterGlyphsOverlayEl.setAttribute("aria-hidden", "true"); } return; } var tr = gd.data && gd.data[1]; if (!tr || !Array.isArray(tr.x) || !tr.x.length) { gridLetterGlyphsOverlayEl.setAttribute("aria-hidden", "true"); return; } gridLetterGlyphsOverlayEl.setAttribute("aria-hidden", "false"); var fontSize = gridHighlightTextSizePx(gridSize); if (tr.textfont && typeof tr.textfont.size === "number" && isFinite(tr.textfont.size)) { fontSize = tr.textfont.size; } var fontFamily = (tr.textfont && tr.textfont.family) ? String(tr.textfont.family) : "Georgia, 'Times New Roman', serif"; for (var gi = 0; gi < tr.x.length; gi++) { var lab = ""; if (Array.isArray(tr.text) && tr.text[gi] != null) { lab = String(tr.text[gi]); } else if (gi < labels.length) { lab = String(labels[gi]); } if (!lab) continue; var pt = explorerDataToPlotStackXY(tr.x[gi], tr.y[gi]); if (!pt) continue; var glyph = document.createElement("span"); glyph.className = "cryo-explorer-grid-letter-glyph"; glyph.style.left = pt[0].toFixed(2) + "px"; glyph.style.top = pt[1].toFixed(2) + "px"; glyph.style.fontSize = fontSize + "px"; glyph.style.fontFamily = fontFamily; glyph.style.color = GRID_HIGHLIGHT_TEXT_COLOR; glyph.textContent = lab; gridLetterGlyphsOverlayEl.appendChild(glyph); } } function wireGridLetterGlyphOverlayHook() { if (gridLetterGlyphHookWired || !gd) return; gridLetterGlyphHookWired = true; gd.on("plotly_afterplot", syncGridLetterGlyphOverlay); gd.on("plotly_relayout", syncGridLetterGlyphOverlay); } function stampHighlightTraceAntiDim(restyleData) { if (!restyleData) return; restyleData.selectedpoints = [null]; restyleData["unselected.marker.opacity"] = [1]; restyleData["unselected.textfont.opacity"] = [1]; } function nudgeHighlightTraceAfterScatterglSelectRestyle() { if (!highlightTraceAdded || !gd || !gd.data || gd.data.length < 2) return; try { Plotly.restyle( gd, { type: "scatter", selectedpoints: [null], "unselected.marker.opacity": [1], "unselected.textfont.opacity": [1] }, [1] ); } catch (e) { try { Plotly.restyle( gd, { selectedpoints: [null], "unselected.marker.opacity": [1], "unselected.textfont.opacity": [1] }, [1] ); } catch (e2) { /* ignore */ } } } function appendGridHighlightMarkerRestyle( restyleData, nbs, hasColor, colorMode, markerColors, cmin, cmax ) { var selRows = saveSelectionRows || []; var treatAllGridPointsAsSelected = !selRows.length; var selSet = null; if (!treatAllGridPointsAsSelected) { selSet = new Set(); for (var sri = 0; sri < selRows.length; sri++) { var sn = Number(selRows[sri]); if (isFinite(sn)) selSet.add(sn); } if (!selSet.size) selSet = null; } var treatAll = !selSet; var fillColors = []; var lineColors = []; var textFontColors = []; var multiGeom = committedScatterRegions && committedScatterRegions.length >= 2; for (var hk = 0; hk < nbs.length; hk++) { var tiH = nbs[hk].ti; var rowH = nbs[hk].row; var rowN = Number(rowH); var inSel = treatAll || (isFinite(rowN) && selSet.has(rowN)); var rIdxgeom = (!hasColor && multiGeom) ? selectionRegionIndexForRow(rowH) : -1; lineColors.push(GRID_HIGHLIGHT_MARKER_LINE_COLOR); if (!hasColor) { if (multiGeom && rIdxgeom >= 0) { var stRg = selectionRegionMontageStyles(rIdxgeom); fillColors.push(inSel ? stRg.bg : GRID_HIGHLIGHT_CLEAR_FILL); textFontColors.push(GRID_HIGHLIGHT_TEXT_COLOR); } else if (multiGeom && rIdxgeom < 0) { fillColors.push(GRID_HIGHLIGHT_CLEAR_FILL); textFontColors.push(GRID_HIGHLIGHT_TEXT_COLOR); } else { fillColors.push(inSel ? ACCENT : GRID_HIGHLIGHT_CLEAR_FILL); textFontColors.push(GRID_HIGHLIGHT_TEXT_COLOR); } } else if (colorMode === "discrete") { fillColors.push(inSel ? String(markerColors[tiH]) : GRID_HIGHLIGHT_CLEAR_FILL); textFontColors.push(GRID_HIGHLIGHT_TEXT_COLOR); } else { fillColors.push( inSel ? montageCovariateBorderCss(tiH, colorMode, markerColors, cmin, cmax) : GRID_HIGHLIGHT_CLEAR_FILL ); textFontColors.push(GRID_HIGHLIGHT_TEXT_COLOR); } } restyleData["marker.color"] = [fillColors]; restyleData["marker.line.color"] = [lineColors]; restyleData["marker.line.width"] = [GRID_HIGHLIGHT_LINE_WIDTH]; restyleData["marker.size"] = [gridHighlightMarkerSizePx(gridSize)]; restyleData["marker.opacity"] = [GRID_HIGHLIGHT_MARKER_OPACITY]; restyleData["textfont.size"] = [gridHighlightTextSizePx(gridSize)]; restyleData["textfont.color"] = [textFontColors]; stampHighlightTraceAntiDim(restyleData); } function montageItemsFromLastRows() { var out = []; if (!lastMontageRows || !lastMontageRows.length) return out; for (var li = 0; li < lastMontageRows.length; li++) { var it = montageItemForRow(lastMontageRows[li]); if (it) out.push(it); } return out; } /** * After lasso/threshold selection changes without replacing montage rows, keep grid-letter marker fill/hollow * state in sync with ``saveSelectionRows``. */ function refreshGridHighlightMarkerStylesFromLastRows() { if (!showGridHighlightsOnPlot()) return; if (!highlightTraceAdded || !gd || !gd.data || !gd.data[0] || !gd.data[1]) return; var items = montageItemsFromLastRows(); if (!items.length) return; var markerColors = gd.data[0].marker ? gd.data[0].marker.color : null; var colorCol = sc.value; var hasColor = colorCol !== "none" && Array.isArray(markerColors); var colorMode = hasColor ? scatterTraceColorMode() : "continuous"; var cmin = Infinity; var cmax = -Infinity; if (hasColor && colorMode === "continuous") { for (var ci = 0; ci < markerColors.length; ci++) { var vci = markerColors[ci]; if (typeof vci === "number" && isFinite(vci)) { if (vci < cmin) cmin = vci; if (vci > cmax) cmax = vci; } } } var patch = {}; appendGridHighlightMarkerRestyle(patch, items, hasColor, colorMode, markerColors, cmin, cmax); queueHighlightRestyle(patch); } // row (plot_df row index) → trace index lookup for the current scatter load. // This removes repeated O(N) scans during selection + geometry bucketing. var rowToTraceIndexMap = null; function buildTraceMapFromRows(rows) { rows = rows || []; rowToTraceIndexMap = new Map(); for (var i = 0; i < rows.length; i++) { var rowN = Number(rows[i]); if (isFinite(rowN)) rowToTraceIndexMap.set(rowN, i); } } function buildTraceMap() { if (!gd || !gd.data || !gd.data[0] || !gd.data[0].customdata) return; var cd = gd.data[0].customdata; rowToTraceIndexMap = new Map(); var tm = []; var hasPreloaded = !!preloaded; for (var i = 0; i < cd.length; i++) { var row = cd[i][1]; var rowN = Number(row); if (isFinite(rowN)) rowToTraceIndexMap.set(rowN, i); if (hasPreloaded && preloaded.rows.has(row)) { tm.push({ti: i, src: preloaded.rowToSrc.get(row), row: row}); } } if (hasPreloaded) { preloaded.traceMap = tm; syncMontageResampleFromCacheButton(); } } /** Montage chip border: top stays thin; left, right, and bottom 50% thicker (3px vs 2px). */ function setMontageCellSelectionBorderStyle(cellEl, colorCss) { cellEl.style.boxSizing = "border-box"; cellEl.style.borderStyle = "solid"; cellEl.style.borderColor = colorCss; cellEl.style.borderWidth = "2px 3px 3px 3px"; } /** * Pick up to ``want`` cached scatter points: shuffle selected-in-cache first, then pad from the * rest of the cache so the grid stays full when the selection has fewer cached thumbnails. */ function pickMontageItemsSelectionFirstFromCache(want) { if (!preloaded || !preloaded.traceMap || !preloaded.traceMap.length) return []; var w = Math.floor(Number(want)); if (!isFinite(w) || w < 1) w = neighborK; w = Math.min(w, neighborK, preloaded.traceMap.length); var tm = preloaded.traceMap; var rowSet = null; if (saveSelectionRows && saveSelectionRows.length) { rowSet = new Set(); for (var rs = 0; rs < saveSelectionRows.length; rs++) { var rsn = Number(saveSelectionRows[rs]); if (isFinite(rsn)) rowSet.add(rsn); } if (!rowSet.size) rowSet = null; } var primary = []; if (rowSet && rowSet.size) { for (var pi = 0; pi < tm.length; pi++) { var rtm = Number(tm[pi].row); if (isFinite(rtm) && rowSet.has(rtm)) primary.push(tm[pi]); } shuffleInPlace(primary); } var out = []; var seen = new Set(); for (var a = 0; a < primary.length && out.length < w; a++) { var rpa = Number(primary[a].row); if (!isFinite(rpa) || seen.has(rpa)) continue; seen.add(rpa); out.push(primary[a]); } if (out.length < w) { var fallback = tm.slice(); shuffleInPlace(fallback); for (var b = 0; b < fallback.length && out.length < w; b++) { var rfb = Number(fallback[b].row); if (!isFinite(rfb) || seen.has(rfb)) continue; seen.add(rfb); out.push(fallback[b]); } } return out; } /** * Keep existing montage items (e.g. after axis reload) and top up to ``want`` using the same * selection-first + cache-fill rule, without duplicating rows already in ``haveItems``. */ function completeMontageItemsSelectionFirstPartial(haveItems, want) { if (!preloaded || !preloaded.traceMap) return (haveItems || []).slice(); want = Math.min(Math.floor(Number(want)) || neighborK, neighborK, preloaded.traceMap.length); var out = (haveItems || []).slice(); var seen = new Set(); for (var u = 0; u < out.length; u++) { var ru = Number(out[u].row); if (isFinite(ru)) seen.add(ru); } var rowSet = null; if (saveSelectionRows && saveSelectionRows.length) { rowSet = new Set(); for (var rs2 = 0; rs2 < saveSelectionRows.length; rs2++) { var rsn2 = Number(saveSelectionRows[rs2]); if (isFinite(rsn2)) rowSet.add(rsn2); } if (!rowSet.size) rowSet = null; } var primary = []; if (rowSet && rowSet.size) { for (var pi = 0; pi < preloaded.traceMap.length; pi++) { var it = preloaded.traceMap[pi]; var rIt = Number(it.row); if (isFinite(rIt) && rowSet.has(rIt) && !seen.has(rIt)) primary.push(it); } shuffleInPlace(primary); } for (var a = 0; a < primary.length && out.length < want; a++) { var rpa2 = Number(primary[a].row); if (!isFinite(rpa2)) continue; seen.add(rpa2); out.push(primary[a]); } if (out.length < want) { var rest = []; for (var ri = 0; ri < preloaded.traceMap.length; ri++) { var it2 = preloaded.traceMap[ri]; var r2n = Number(it2.row); if (!isFinite(r2n) || seen.has(r2n)) continue; rest.push(it2); } shuffleInPlace(rest); for (var b = 0; b < rest.length && out.length < want; b++) { var rbn = Number(rest[b].row); if (!isFinite(rbn)) continue; seen.add(rbn); out.push(rest[b]); } } return out; } function montageResampleSuppressed() { return Date.now() < suppressMontageResampleUntil; } function armMontageResampleSuppress(extraMs) { var ms = extraMs != null ? Number(extraMs) : 0; if (!isFinite(ms) || ms < 0) ms = 0; suppressMontageResampleUntil = Math.max(suppressMontageResampleUntil, Date.now() + ms); } function showRandomPreloaded() { if (montageResampleSuppressed()) return; if (!preloaded || !preloaded.traceMap) return; var items = pickMontageItemsSelectionFirstFromCache(neighborK); if (!items.length) return; updateMontage(items); } function updateMontage(nbs) { if (montageResampleSuppressed()) return; nbs = nbs.slice(); if (!showGridHighlightsEnabled()) { updateMontageOrdered([]); return; } var markerColors = gd.data && gd.data[0] && gd.data[0].marker ? gd.data[0].marker.color : null; var colorCol = sc.value; var hasColor = colorCol !== "none" && Array.isArray(markerColors); var colorMode = hasColor ? scatterTraceColorMode() : "continuous"; if (hasColor && colorMode === "discrete") { nbs = nbs.slice().sort(function(a, b) { var ka = discreteMontageSortKey(a.ti); var kb = discreteMontageSortKey(b.ti); if (isFinite(ka.n) && isFinite(kb.n) && ka.n !== kb.n) return ka.n - kb.n; if (ka.s !== kb.s) return ka.s < kb.s ? -1 : ka.s > kb.s ? 1 : 0; return a.row - b.row; }); } else if ( !hasColor && committedScatterRegions.length > 1 && imagesViewEnabled && preloaded && preloaded.traceMap ) { nbs = nbs.slice().sort(function(a, b) { var ra = selectionRegionIndexForRow(a.row); var rb = selectionRegionIndexForRow(b.row); if (ra !== rb) return ra - rb; return a.row - b.row; }); } else if (hasColor) { nbs = nbs.slice().sort(function(a, b) { return markerColors[a.ti] - markerColors[b.ti]; }); } else { var xs = gd.data && gd.data[0] ? gd.data[0].x : null; nbs = nbs.slice().sort(function(a, b) { var ax = xs ? xs[a.ti] : 0; var bx = xs ? xs[b.ti] : 0; if (ax !== bx) return ax - bx; return a.row - b.row; }); } updateMontageOrdered(nbs); } /** Paint montage cells in caller order (no covariate / region re-sort). */ function montageCellStyleContext() { var colorCol = sc.value; var colorColLabel = covariateDisplayMap[colorCol] || colorCol; var markerColors = gd.data && gd.data[0] && gd.data[0].marker ? gd.data[0].marker.color : null; var hasColor = colorCol !== "none" && Array.isArray(markerColors); var colorMode = hasColor ? scatterTraceColorMode() : "continuous"; var cmin = Infinity; var cmax = -Infinity; if (hasColor && colorMode === "continuous") { for (var ci = 0; ci < markerColors.length; ci++) { var vci = markerColors[ci]; if (typeof vci === "number" && isFinite(vci)) { if (vci < cmin) cmin = vci; if (vci > cmax) cmax = vci; } } } var selMem = null; if (saveSelectionRows && saveSelectionRows.length) { selMem = new Set(); for (var sm = 0; sm < saveSelectionRows.length; sm++) { var snm = Number(saveSelectionRows[sm]); if (isFinite(snm)) selMem.add(snm); } if (!selMem.size) selMem = null; } return { colorCol: colorCol, colorColLabel: colorColLabel, markerColors: markerColors, hasColor: hasColor, colorMode: colorMode, cmin: cmin, cmax: cmax, selMem: selMem }; } function clearMontageCellAtIndex(i) { if (i < 0 || i >= montageCells.length) return; montageCells[i].img.src = ""; montageCells[i].img.style.opacity = ""; montageCells[i].idx.textContent = ""; montageCells[i].idx.style.color = ""; montageCells[i].covar.textContent = ""; montageCells[i].covar.style.color = ""; montageCells[i].cell.style.background = ""; montageCells[i].cell.style.border = ""; if (montageCells[i].lbl) montageCells[i].lbl.style.color = ""; } function paintMontageCellAtIndex(i, item, ctx) { if (i < 0 || i >= montageCells.length || !item) return; ctx = ctx || montageCellStyleContext(); var hasColor = ctx.hasColor; var colorMode = ctx.colorMode; var markerColors = ctx.markerColors; var cmin = ctx.cmin; var cmax = ctx.cmax; var selMem = ctx.selMem; var colorCol = ctx.colorCol; var colorColLabel = ctx.colorColLabel; montageCells[i].img.src = item.src || ""; montageCells[i].idx.textContent = "idx " + item.row; montageCells[i].cell.classList.add("cryo-montage-cell--light"); if (montageCells[i].lbl) montageCells[i].lbl.style.color = ""; if (hasColor) { var ti = item.ti; var dispVal = traceCustomdataColorDisp(ti); if (dispVal == null && colorMode === "continuous") { var vmc = markerColors[ti]; dispVal = typeof vmc === "number" && isFinite(vmc) ? vmc : vmc; } var dispStr = dispVal == null ? "—" : ( typeof dispVal === "number" && isFinite(dispVal) ? dispVal.toFixed(2) : String(dispVal) ); montageCells[i].covar.textContent = colorCol === "labels" ? ("k-means cluster = " + dispStr) : (colorColLabel + "=" + dispStr); if (colorMode === "discrete") { var hexCol = markerColors[ti]; var dStyles = discreteLabelMontageStyles(String(hexCol)); montageCells[i].cell.style.background = dStyles.bg; setMontageCellSelectionBorderStyle(montageCells[i].cell, String(hexCol)); montageCells[i].covar.style.color = dStyles.covarColor || ""; montageCells[i].idx.style.color = dStyles.covarColor || ""; } else { var valC = markerColors[ti]; var tCell = cmax > cmin && typeof valC === "number" && isFinite(valC) ? (valC - cmin) / (cmax - cmin) : 0.5; var cStyles = continuousMontageStylesFromT(tCell); montageCells[i].cell.style.background = cStyles.bg; setMontageCellSelectionBorderStyle( montageCells[i].cell, cStyles.borderCss || cStyles.bg ); montageCells[i].covar.style.color = cStyles.covarColor || ""; montageCells[i].idx.style.color = cStyles.idxColor || cStyles.covarColor || ""; } } else { montageCells[i].covar.textContent = ""; montageCells[i].covar.style.color = ""; montageCells[i].idx.style.color = ""; montageCells[i].cell.style.background = ""; var borderNoCov = ACCENT; if (imagesViewEnabled && preloaded && preloaded.traceMap && committedScatterRegions.length) { var ridxM = selectionRegionIndexForRow(item.row); if (ridxM >= 0) { borderNoCov = scatterRegionPlotStyle(ridxM).line; var rsMont = selectionRegionMontageStyles(ridxM); montageCells[i].cell.style.background = rsMont.bg; montageCells[i].covar.style.color = rsMont.covarColor || ""; montageCells[i].idx.style.color = rsMont.covarColor || ""; if (committedScatterRegions.length > 1) { montageCells[i].covar.textContent = "Region " + (ridxM + 1); } if (montageCells[i].lbl && committedScatterRegions.length >= 2) { montageCells[i].lbl.style.color = rsMont.covarColor || ""; } } else if (committedScatterRegions.length > 1) { borderNoCov = "#94a3b8"; montageCells[i].cell.style.background = ""; montageCells[i].covar.textContent = ""; montageCells[i].covar.style.color = ""; montageCells[i].idx.style.color = "#64748b"; if (montageCells[i].lbl) montageCells[i].lbl.style.color = "#64748b"; } } setMontageCellSelectionBorderStyle(montageCells[i].cell, borderNoCov); } var rowNj = Number(item.row); var inScatterSel = !selMem || (isFinite(rowNj) && selMem.has(rowNj)); if (imagesViewEnabled && selMem) { montageCells[i].img.style.opacity = inScatterSel ? "1" : "0.5"; if (!inScatterSel) { setMontageCellSelectionBorderStyle(montageCells[i].cell, "#94a3b8"); if (montageCells[i].lbl) montageCells[i].lbl.style.color = "#64748b"; montageCells[i].idx.style.color = "#64748b"; if (hasColor) { montageCells[i].covar.style.color = "#64748b"; } } else if (montageCells[i].lbl && hasColor) { montageCells[i].lbl.style.color = ""; } else { if (montageCells[i].lbl && !hasColor && committedScatterRegions.length <= 1) { montageCells[i].lbl.style.color = ""; } } } else { montageCells[i].img.style.opacity = ""; } } function syncHighlightTraceFromMontageItems(items) { if (!highlightTraceAdded || !gd.data || !gd.data[0] || !items.length) return; var ctx = montageCellStyleContext(); var nx = [], ny = [], txt = []; var showPlotHighlights = showGridHighlightsOnPlot(); for (var k = 0; k < items.length; k++) { nx.push(gd.data[0].x[items[k].ti]); ny.push(gd.data[0].y[items[k].ti]); if (showPlotHighlights) txt.push(labels[k]); } var restyleData = { x: [nx], y: [ny], mode: ["markers"], text: [txt], visible: [showPlotHighlights] }; appendGridHighlightMarkerRestyle( restyleData, items, ctx.hasColor, ctx.colorMode, ctx.markerColors, ctx.cmin, ctx.cmax ); queueHighlightRestyle(restyleData); syncGridLetterGlyphOverlay(); } function updateMontageOrdered(nbs) { nbs = nbs.slice(); cancelPendingHighlightRestyle(); if (!showGridHighlightsEnabled()) { nbs = []; } var newOrdered = nbs.map(function(x) { return x.row; }).join(","); var oldOrdered = lastMontageRows.join(","); var newStable = nbs.map(function(x) { return x.row; }).slice().sort(function(a, b) { return a - b; }).join(","); var oldStable = lastMontageRows.length ? lastMontageRows.slice().sort(function(a, b) { return a - b; }).join(",") : ""; if (newOrdered !== oldOrdered || newStable !== oldStable) { invalidateVolumeArtifacts(); } var ctx = montageCellStyleContext(); for (var i = 0; i < montageCells.length; i++) { if (i < nbs.length) { paintMontageCellAtIndex(i, nbs[i], ctx); } else { clearMontageCellAtIndex(i); } } syncHighlightTraceFromMontageItems(nbs); lastMontageRows = []; for (var mi = 0; mi < nbs.length; mi++) { lastMontageRows.push(nbs[mi].row); } if (montageDisplayMode === "volumes") { var vkey = montageRowsOrderKey(); if (volumeStaticDataUrls && volumeStaticKey === vkey && volumeStaticDataUrls.length) { showVolumesInMontage(); } else { syncVolumeExploreButtons(); } } else { syncVolumeExploreButtons(); } scheduleSyncMontageGridRegionLayout(); syncExplorerMontagePanelReadiness(); } /** Replace one montage letter slot; other cells are left unchanged (double-click → A). */ function replaceMontageSlotAt(slotIndex, item) { if (!item || slotIndex !== 0 || !showGridHighlightsEnabled()) return; if (slotIndex >= montageCells.length) return; var prevRow = lastMontageRows.length ? lastMontageRows[0] : undefined; if (prevRow != null && Number(prevRow) !== Number(item.row)) { invalidateVolumeArtifacts(); } if (!lastMontageRows.length) { lastMontageRows = [item.row]; } else { lastMontageRows[0] = item.row; } var ctx = montageCellStyleContext(); paintMontageCellAtIndex(slotIndex, item, ctx); if (montageDisplayMode === "volumes") { var vkey = montageRowsOrderKey(); if (volumeStaticDataUrls && volumeStaticKey === vkey && volumeStaticDataUrls.length) { montageCells[slotIndex].img.src = volumeStaticDataUrls[slotIndex] || ""; } else { syncVolumeExploreButtons(); } } syncHighlightTraceAfterSlotReplace(slotIndex, item); syncVolumeExploreButtons(); } /** Update grid-letter overlay for one slot; keep other highlight positions when possible. */ function syncHighlightTraceAfterSlotReplace(slotIndex, item) { if (!highlightTraceAdded || !gd.data || !gd.data[0] || !gd.data[1] || !item) return; var n = lastMontageRows.length; if (!n) return; var trace = gd.data[1]; var oldX = Array.isArray(trace.x) ? trace.x.slice() : []; var oldY = Array.isArray(trace.y) ? trace.y.slice() : []; var oldText = Array.isArray(trace.text) ? trace.text.slice() : []; if (oldX.length !== n || slotIndex >= n) { syncHighlightTraceFromMontageItems(montageItemsFromLastRows()); return; } var items = []; for (var hi = 0; hi < n; hi++) { if (hi === slotIndex) { items.push(item); continue; } var keptHi = montageItemForRow(lastMontageRows[hi]); if (!keptHi) { syncHighlightTraceFromMontageItems(montageItemsFromLastRows()); return; } items.push(keptHi); } oldX[slotIndex] = gd.data[0].x[item.ti]; oldY[slotIndex] = gd.data[0].y[item.ti]; var showPlotHighlights = showGridHighlightsOnPlot(); var ctx = montageCellStyleContext(); var restyleData = { x: [oldX], y: [oldY], mode: ["markers"], text: [showPlotHighlights ? oldText : []], visible: [showPlotHighlights] }; appendGridHighlightMarkerRestyle( restyleData, items, ctx.hasColor, ctx.colorMode, ctx.markerColors, ctx.cmin, ctx.cmax ); queueHighlightRestyle(restyleData); syncGridLetterGlyphOverlay(); } function rowFromPlotlyPoint(pt) { if (!pt) return NaN; if (pt.customdata != null && pt.customdata.length > 1) { var r0 = Number(pt.customdata[1]); if (isFinite(r0)) return r0; } var pi = pt.pointIndex; if (pi != null && gd && gd.data && gd.data[0] && gd.data[0].customdata) { var cd = gd.data[0].customdata[pi]; if (cd && cd.length > 1) { var r1 = Number(cd[1]); if (isFinite(r1)) return r1; } } return NaN; } function ensureRowCachedForMontage(row) { row = Number(row); if (!isFinite(row)) return Promise.reject(new Error("invalid row")); if (preloaded && preloaded.rows && preloaded.rows.has(row)) { buildTraceMap(); return Promise.resolve(); } var have = preloaded ? cachedImageCount() : 0; return preloadImagesInChunksToTarget({ finalTarget: have + 1, overlayLabel: "Caching 1 image…", enableImages: true, selectedRows: [row], restrictToScatterPlot: true, suppressMontageRefresh: true }); } function assignScatterRowToMontageSlotA(row) { return ensureRowCachedForMontage(row).then(function() { if (!showGridHighlightsEnabled()) return; var item = montageItemForRow(row); if (item) replaceMontageSlotAt(0, item); }); } /** After color-only scatter reload: same particles, pad from lasso pool or full cache if grid needs more. */ function restoreMontageAfterScatterReload() { if (!preloaded || !preloaded.traceMap) return; var rowToItem = new Map(); for (var ri = 0; ri < preloaded.traceMap.length; ri++) { var it0 = preloaded.traceMap[ri]; rowToItem.set(it0.row, it0); } var want = neighborK; var items = []; if (lastMontageRows && lastMontageRows.length) { for (var i = 0; i < lastMontageRows.length && items.length < want; i++) { var found = rowToItem.get(lastMontageRows[i]); if (found) items.push(found); } } items = completeMontageItemsSelectionFirstPartial(items, want); if (items.length) { updateMontage(items); } else { showRandomPreloaded(); } } /** After grid size change: keep current rows where possible; top up from cache (selection-first when applicable). */ function refillMontageForNewGridSize() { var want = neighborK; if (!preloaded || !preloaded.traceMap) { if (!plotHasScatterData() || !lastMontageRows.length) return; ensureHighlightTrace(); var picked = pickRandomMontageItemsFilled(want); if (picked.length) updateMontage(picked); return; } if (!preloaded.traceMap.length) return; var byRow = new Map(); for (var pi = 0; pi < preloaded.traceMap.length; pi++) { byRow.set(preloaded.traceMap[pi].row, preloaded.traceMap[pi]); } var items = []; if (lastMontageRows && lastMontageRows.length) { for (var i = 0; i < lastMontageRows.length && items.length < want; i++) { var it = byRow.get(lastMontageRows[i]); if (it) items.push(it); } } items = completeMontageItemsSelectionFirstPartial(items, want); if (items.length) { updateMontage(items); } else if (preloaded.traceMap.length >= want) { showRandomPreloaded(); } if (highlightTraceAdded && lastMontageRows && lastMontageRows.length) { refreshGridHighlightMarkerStylesFromLastRows(); syncGridLetterGlyphOverlay(); } } /* ---------- rendering overlay ---------- */ function cancelPendingScatterAfterPlot() { if (pendingScatterAfterPlot) { gd.removeListener("plotly_afterplot", pendingScatterAfterPlot); pendingScatterAfterPlot = null; } } function clearScatterPlotWatchdog() { if (scatterPlotWatchdog) { clearTimeout(scatterPlotWatchdog); scatterPlotWatchdog = null; } } function discreteLegendWrapIsActive() { if (!colorDiscreteWrap) return false; return colorDiscreteWrap.classList.contains("cryo-cc-discrete-wrap--show") || colorDiscreteWrap.classList.contains("cryo-color-discrete-wrap--show"); } /** True whenever the sequential palette UI is irrelevant (K-means, labels, …) — not only ``labels``. */ function scatterColorByIsDiscrete() { if (discreteLegendWrapIsActive()) return true; try { if (gd && gd.data && gd.data[0]) { return scatterTraceColorMode() === "discrete"; } } catch (e) { /* ignore */ } return sc.value === "labels"; } function syncScatterLegendToggleCaption() { if (!scatterPaletteToggle) return; var labelSpan = document.getElementById("scatter-palette-toggle-label"); if (labelSpan) labelSpan.textContent = "Choose palette"; scatterPaletteToggle.setAttribute("aria-controls", "scatter-palette-options"); } function syncScatterPaletteFieldset() { if (!paletteFieldset) return; var rendering = !!(overlay && overlay.classList.contains("cryo-plot-rendering-overlay--show")); var hasColor = sc.value && sc.value !== "none"; var suppress = rendering || !hasColor; paletteFieldset.classList.toggle("scatter-palette-radios--suppressed", suppress); paletteFieldset.classList.toggle( "cryo-explorer-scatter-palette-heading-toggle-host--suppressed", suppress ); syncScatterLegendToggleCaption(); syncScatterPaletteOptions(); if (colorHistPanel) { colorHistPanel.classList.toggle( "cryo-explorer-discrete-hides-scatter-palette-radios", hasColor && !rendering && scatterColorByIsDiscrete() ); var paletteExpanded = !!(scatterPaletteToggle && scatterPaletteToggle.getAttribute("aria-expanded") === "true"); colorHistPanel.classList.toggle( "cryo-explorer-continuous-palette-pane-collapsed", hasColor && !rendering && !scatterColorByIsDiscrete() && !paletteExpanded ); } } function syncScatterPaletteOptions() { if (!scatterPaletteOptions || !paletteFieldset) return; if (paletteFieldset.classList.contains("scatter-palette-radios--suppressed")) { scatterPaletteOptions.classList.add("cryo-palette-select__options--collapsed"); return; } var expanded = !!(scatterPaletteToggle && scatterPaletteToggle.getAttribute("aria-expanded") === "true"); if (scatterColorByIsDiscrete()) { scatterPaletteOptions.classList.add("cryo-palette-select__options--collapsed"); return; } if (explorerColorLegend) { explorerColorLegend.setDiscretePanelCollapsed(false); } else if (colorDiscreteWrap) { colorDiscreteWrap.classList.remove("cryo-discrete-legend--collapsed"); colorDiscreteWrap.classList.remove("cryo-cc-discrete-panel--collapsed"); } scatterPaletteOptions.classList.toggle("cryo-palette-select__options--collapsed", !expanded); } function setRendering(on) { if (overlay) { if (on) { if (scatterRenderingEverCompleted) { overlay.classList.add("cryo-plot-rendering-overlay--nonblocking"); } else { overlay.classList.remove("cryo-plot-rendering-overlay--nonblocking"); } } else { overlay.classList.remove("cryo-plot-rendering-overlay--nonblocking"); } overlay.classList.toggle("cryo-plot-rendering-overlay--show", on); overlay.setAttribute("aria-hidden", on ? "false" : "true"); } syncScatterPaletteFieldset(); } var scatterSelectionRenderingDepth = 0; function beginScatterSelectionRenderingOverlay() { if (!overlay || !scatterRenderingEverCompleted) return; scatterSelectionRenderingDepth++; if (scatterSelectionRenderingDepth !== 1) return; overlay.classList.add("cryo-plot-rendering-overlay--nonblocking"); overlay.classList.add("cryo-plot-rendering-overlay--show"); overlay.setAttribute("aria-hidden", "false"); var lab = overlay.querySelector(".cryo-plot-rendering-overlay__label"); if (lab) lab.textContent = "Rendering\u2026"; } function endScatterSelectionRenderingOverlay() { if (!scatterRenderingEverCompleted) { scatterSelectionRenderingDepth = Math.max(0, scatterSelectionRenderingDepth - 1); return; } scatterSelectionRenderingDepth = Math.max(0, scatterSelectionRenderingDepth - 1); if (scatterSelectionRenderingDepth > 0) return; window.requestAnimationFrame(function() { window.requestAnimationFrame(function() { if (scatterSelectionRenderingDepth > 0 || !overlay) return; if (!overlay.classList.contains("cryo-plot-rendering-overlay--nonblocking")) return; overlay.classList.remove("cryo-plot-rendering-overlay--show"); overlay.classList.remove("cryo-plot-rendering-overlay--nonblocking"); overlay.setAttribute("aria-hidden", "true"); syncScatterPaletteFieldset(); }); }); } /* ---------- lasso / selection state (shared by scatter load + events) ---------- */ var activePool = null; var lassoSelectedRowSet = null; var lassoSelectionDebounceTimer = null; var LASSO_SELECTION_DEBOUNCE_MS = 200; /** While set, skip full-grid montage resamples (e.g. during double-click → slot A). */ var suppressMontageResampleUntil = 0; var highlightRestyleRaf = null; var pendingHighlightRestyle = null; /* ---------- scattergl selection highlight (``selectedpoints`` dimming; trace map for O(1) row→index) ---------- */ /** * Finished lasso/box regions: raw ``layout.selections``-style objects plus particle count. * Drawn as ``layout.shapes`` / ``layout.annotations`` so they survive while a new selection is in progress. */ var committedScatterRegions = []; var committedScatterOverlayRaf = null; function cloneScatterSelectionShapes(arr) { try { return JSON.parse(JSON.stringify(arr || [])); } catch (e) { return []; } } function scatterSelectionShapesEqual(a, b) { try { return JSON.stringify(a) === JSON.stringify(b); } catch (e) { return false; } } function scatterSelectionsPrefixMatches(full, prefix) { if (prefix.length > full.length) return false; for (var i = 0; i < prefix.length; i++) { if (!scatterSelectionShapesEqual(full[i], prefix[i])) return false; } return true; } /** Drop consecutive duplicate shapes (Plotly sometimes re-emits an unchanged lasso path). */ function dedupeConsecutiveEqualScatterShapes(arr) { if (!arr || !arr.length) return []; var out = [cloneScatterSelectionShapes([arr[0]])[0]]; for (var i = 1; i < arr.length; i++) { if (!scatterSelectionShapesEqual(arr[i], out[out.length - 1])) { out.push(cloneScatterSelectionShapes([arr[i]])[0]); } } return out; } /** * Plotly may either append to ``layout.selections`` or replace it with the latest shape only. * Merge ``fromPlotly`` into ``prevPersisted`` so we keep every completed region on screen. */ function mergePersistedScatterSelectionShapes(prevPersisted, fromPlotly) { fromPlotly = fromPlotly || []; prevPersisted = prevPersisted || []; if (!fromPlotly.length) return cloneScatterSelectionShapes(prevPersisted); if (fromPlotly.length >= prevPersisted.length && scatterSelectionsPrefixMatches(fromPlotly, prevPersisted)) { return cloneScatterSelectionShapes(fromPlotly); } return prevPersisted.concat(cloneScatterSelectionShapes(fromPlotly)); } /** * True for filled region overlays we draw on the scatter (``scatterRawSelectionToCommitShape``): * named ``cdrgn_commit_shape_*``, or ``layer: "between"`` ``path``/``rect`` on the main ``x``/``y`` axes * (the explorer does not use other ``between`` shapes on that subplot). */ function shapeMatchesCryoCommittedScatterRegion(sh) { if (!sh) return false; var nm = String(sh.name || ""); if (nm.indexOf("cdrgn_commit_shape") === 0) return true; if (sh.layer !== "between") return false; if (sh.type !== "path" && sh.type !== "rect") return false; var xr = String(sh.xref || ""); var yr = String(sh.yref || ""); var onAxes = (xr === "x" || (xr.length && xr.charAt(0) === "x")) && (yr === "y" || (yr.length && yr.charAt(0) === "y")); return onAxes; } function scatterExplorerBaseShapes() { var raw = (gd && gd.layout && gd.layout.shapes) ? gd.layout.shapes : []; var out = []; for (var si = 0; si < raw.length; si++) { var sh = raw[si]; if (!sh) continue; if (String(sh.name || "").indexOf("cdrgn_commit") === 0) continue; if (shapeMatchesCryoCommittedScatterRegion(sh)) continue; out.push(sh); } return out; } /** * Drop transient scatter selection paths, explorer-committed region fills, and anything * matching our committed overlay signature so ``layout.shapes`` cannot keep drawing after a clear. */ function scatterExplorerShapesPurgeForGeometryClear(rawShapes) { rawShapes = rawShapes || []; var out = []; for (var si = 0; si < rawShapes.length; si++) { var sh = rawShapes[si]; if (!sh) continue; if (String(sh.name || "").indexOf("cdrgn_commit") === 0) continue; if (shapeMatchesCryoCommittedScatterRegion(sh)) continue; var nm = String(sh.name || ""); if (!nm && (sh.type === "path" || sh.type === "rect")) { var xr = String(sh.xref || ""); var yr = String(sh.yref || ""); var onAxes = (xr === "x" || (xr.length && xr.charAt(0) === "x")) && (yr === "y" || (yr.length && yr.charAt(0) === "y")); if (onAxes) continue; } out.push(sh); } return out; } /** Region count badges on the scatter (``cdrgn_commit_lbl_*`` or same styling without ``name``). */ function annotationMatchesCryoCommittedRegionLabel(an) { if (!an) return false; var nm = String(an.name || ""); if (nm.indexOf("cdrgn_commit_lbl_") === 0) return true; if (an.showarrow) return false; var fn = an.font && an.font.family; if (!fn || String(fn).indexOf("Barlow") === -1) return false; var sz = an.font && an.font.size; if (Number(sz) !== 8 && String(sz) !== "8") return false; if (an.bgcolor && an.xref === "x" && an.yref === "y") return true; return false; } function scatterExplorerBaseAnnotations() { var raw = (gd && gd.layout && gd.layout.annotations) ? gd.layout.annotations : []; var out = []; for (var ai = 0; ai < raw.length; ai++) { var an = raw[ai]; if (!an) continue; if (String(an.name || "").indexOf("cdrgn_commit") === 0) continue; if (annotationMatchesCryoCommittedRegionLabel(an)) continue; out.push(an); } return out; } function scatterRawSelectionCentroid(raw) { if (!raw) return null; if (raw.type === "rect" || (raw.x0 != null && raw.x1 != null && raw.y0 != null && raw.y1 != null)) { var x0 = Number(raw.x0), x1 = Number(raw.x1), y0 = Number(raw.y0), y1 = Number(raw.y1); if (!isFinite(x0 + x1 + y0 + y1)) return null; return { x: (x0 + x1) / 2, y: (y0 + y1) / 2 }; } if (raw.type === "path" && raw.path) { var nums = String(raw.path).match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi); if (!nums || nums.length < 2) return null; var sx = 0, sy = 0, nk = 0; for (var k = 0; k + 1 < nums.length; k += 2) { var px = parseFloat(nums[k]), py = parseFloat(nums[k + 1]); if (isFinite(px) && isFinite(py)) { sx += px; sy += py; nk++; } } if (!nk) return null; return { x: sx / nk, y: sy / nk }; } return null; } /** * Ordered palette: region 0 always gets index 0, region 1 index 1, … (same colours every time). * Used for scatter overlay shapes/labels and for montage chips when colour covariate is ``none``. */ var SCATTER_REGION_GRID_HEX = [ "#b45309", "#1d4ed8", "#0f766e", "#86198f", "#b91c1c", "#365314", "#a16207" ]; function buildMultiRegionSelPieBackground() { if (!committedScatterRegions || committedScatterRegions.length < 2) { return null; } if (!totalParticles || totalParticles <= 0) return null; var stops = ["from -90deg"]; var acc = 0; for (var ri = 0; ri < committedScatterRegions.length; ri++) { var nPr = committedScatterRegions[ri].nParticles || 0; if (!nPr) continue; var fracR = Math.min(1, Math.max(0, nPr / totalParticles)); var col = scatterRegionPlotStyle(ri).line; var startPct = acc * 100; acc += fracR; var endPct = acc * 100; stops.push(col + " " + startPct + "% " + endPct + "%"); } if (stops.length < 2) return null; var grey = "rgba(36, 59, 83, 0.14)"; var unionFrac = 0; if (saveSelectionRows && saveSelectionRows.length && totalParticles > 0) { unionFrac = Math.min(1, Math.max(0, saveSelectionRows.length / totalParticles)); } if (unionFrac < 1) { stops.push(grey + " " + (unionFrac * 100) + "% 100%"); } return "conic-gradient(" + stops.join(", ") + ")"; } function applySelectionPieVisual(frac, pctStr) { if (!selPieEl) return; var multi = committedScatterRegions && committedScatterRegions.length >= 2; var bg = multi ? buildMultiRegionSelPieBackground() : null; if (multi && bg) { selPieEl.classList.add("cryo-explorer-sel-pie--multi-region"); selPieEl.style.background = bg; selPieEl.style.removeProperty("--sel-frac"); } else { selPieEl.classList.remove("cryo-explorer-sel-pie--multi-region"); selPieEl.style.removeProperty("background"); selPieEl.style.setProperty("--sel-frac", String(frac)); } selPieEl.title = pctStr + " of dataset particles selected"; if (selPiePctEl) { selPiePctEl.style.color = multi ? "#1a2332" : ""; } } function scatterRegionPlotStyle(regionIdx) { var hex = ""; try { if ( committedScatterRegions[regionIdx] && committedScatterRegions[regionIdx].colorHex ) { hex = CryoColorCovariateLegend.normalizeDiscreteLegendHex( String(committedScatterRegions[regionIdx].colorHex) ) || ""; } } catch (eHx) { hex = ""; } if (!hex) { hex = SCATTER_REGION_GRID_HEX[ Math.max(0, regionIdx) % SCATTER_REGION_GRID_HEX.length ]; } var rgb = parseMarkerHexRgb(hex); var border = "rgba(255, 255, 255, 0.5)"; if (!rgb) { return { line: "#b45309", fill: "rgba(180, 83, 9, 0.1)", badgeBg: "rgba(180, 83, 9, 0.9)", labelFont: "rgba(255, 255, 255, 0.98)", border: border }; } return { line: hex, fill: "rgba(" + rgb.r + "," + rgb.g + "," + rgb.b + ",0.11)", badgeBg: "rgba(" + rgb.r + "," + rgb.g + "," + rgb.b + ",0.88)", labelFont: "#ffffff", border: border }; } /** Ray-cast ``(x,y)`` vs closed polygon (Plotly lasso ``path`` vertices in data space). */ function pointInPolygonXY(x, y, polyX, polyY) { var n = polyX.length; if (n < 3) return false; var inside = false; for (var i = 0, j = n - 1; i < n; j = i++) { var xi = polyX[i], yi = polyY[i]; var xj = polyX[j], yj = polyY[j]; var dy = yj - yi; if (Math.abs(dy) < 1e-12) continue; if ((yi > y) === (yj > y)) continue; var xinters = ((xj - xi) * (y - yi)) / dy + xi; if (x < xinters) inside = !inside; } return inside; } /** * Whether ``(xd,yd)`` lies inside a committed lasso/box shape (``layout.selections``-style). */ function scatterRawShapeContainsDataXY(raw, xd, yd) { if (!raw || !isFinite(xd) || !isFinite(yd)) return false; if (raw.type === "rect" || (raw.x0 != null && raw.x1 != null && raw.y0 != null && raw.y1 != null)) { var x0 = Number(raw.x0), x1 = Number(raw.x1), y0 = Number(raw.y0), y1 = Number(raw.y1); if (!isFinite(x0 + x1 + y0 + y1)) return false; var xmin = Math.min(x0, x1), xmax = Math.max(x0, x1); var ymin = Math.min(y0, y1), ymax = Math.max(y0, y1); return xd >= xmin && xd <= xmax && yd >= ymin && yd <= ymax; } if (raw.type === "path" && raw.path) { var nums = String(raw.path).match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi); if (!nums || nums.length < 6) return false; var vx = [], vy = []; for (var k = 0; k + 1 < nums.length; k += 2) { var px = parseFloat(nums[k]), py = parseFloat(nums[k + 1]); if (isFinite(px) && isFinite(py)) { vx.push(px); vy.push(py); } } return pointInPolygonXY(xd, yd, vx, vy); } return false; } function traceIndexForPlotDfRow(row) { if (!rowToTraceIndexMap) return -1; var r = Number(row); if (!isFinite(r)) return -1; var ti = rowToTraceIndexMap.get(r); return ti === undefined ? -1 : ti; } /** * Fill ``committedScatterRegions[*].rows`` from ``saveSelectionRows`` + region geometry. * Plotly merge logic only tags ``rowsSnap`` onto the last shape; without this, montage / * grid-letter colours cannot resolve which region a particle belongs to after multi-lasso. * Overlapping lassos: each region keeps every selected particle inside its geometry (no * redistribution when a newer lasso overlaps an older one). */ function recomputeCommittedScatterRegionRowsFromGeometry() { if (!gd || !gd.data || !gd.data[0] || !committedScatterRegions || !committedScatterRegions.length) { return; } var rowsSel = saveSelectionRows && saveSelectionRows.length ? saveSelectionRows : []; var nReg = committedScatterRegions.length; var buckets = new Array(nReg); for (var bi = 0; bi < nReg; bi++) { buckets[bi] = []; } if (!rowsSel.length) { for (var ej = 0; ej < nReg; ej++) { committedScatterRegions[ej].rows = []; committedScatterRegions[ej].nParticles = 0; } return; } for (var ri = 0; ri < nReg; ri++) { var raw = committedScatterRegions[ri].raw; for (var si = 0; si < rowsSel.length; si++) { var row = rowsSel[si]; var rNum = Number(row); if (!isFinite(rNum)) continue; var ti = traceIndexForPlotDfRow(row); if (ti < 0) continue; var xd = gd.data[0].x[ti]; var yd = gd.data[0].y[ti]; if (!isFinite(xd) || !isFinite(yd)) continue; if (scatterRawShapeContainsDataXY(raw, xd, yd)) { buckets[ri].push(rNum); } } } for (var rj = 0; rj < nReg; rj++) { committedScatterRegions[rj].rows = buckets[rj]; committedScatterRegions[rj].nParticles = buckets[rj].length; } } /** Union of ``committedScatterRegions[*].rows`` after geometry bucketing (drops off-shape rows). */ function rowsUnionFromCommittedScatterRegions() { if (!committedScatterRegions || !committedScatterRegions.length) return []; var u = new Set(); for (var ri = 0; ri < committedScatterRegions.length; ri++) { var rw = committedScatterRegions[ri].rows; if (!rw || !rw.length) continue; for (var j = 0; j < rw.length; j++) u.add(rw[j]); } return Array.from(u); } function selectionRegionIndexForRow(row) { if (!committedScatterRegions.length) return -1; var r = Number(row); if (!isFinite(r)) return -1; for (var ri = committedScatterRegions.length - 1; ri >= 0; ri--) { var rs = committedScatterRegions[ri].rows; if (!rs || !rs.length) continue; for (var j = 0; j < rs.length; j++) { if (Number(rs[j]) === r) return ri; } } return -1; } function selectionRegionMontageStyles(regionIdx) { var lineHex = scatterRegionPlotStyle(regionIdx).line; return discreteLabelMontageStyles(lineHex); } /** Completed lasso/box overlay outline (~30% thinner than the prior 2.25px stroke). */ var CDRGN_COMMIT_REGION_LINE_WIDTH = 1.575; function scatterRawSelectionToCommitShape(raw, idx) { if (!raw) return null; var st = scatterRegionPlotStyle(idx); var base = { name: "cdrgn_commit_shape_" + idx, /* Draw selection fill between grid and traces so grid-letter markers stay visible on top. */ layer: "between", line: { color: st.line, width: CDRGN_COMMIT_REGION_LINE_WIDTH, dash: "6 4" }, fillcolor: st.fill, xref: raw.xref || "x", yref: raw.yref || "y" }; if (raw.type === "path" && raw.path) { return Object.assign({}, base, { type: "path", path: raw.path }); } return Object.assign({}, base, { type: "rect", x0: raw.x0, x1: raw.x1, y0: raw.y0, y1: raw.y1 }); } /** * Plotly.js can leave stale SVG shape nodes if ``shapes`` is only patched in place. * Clear the array first, then apply the intended ``layout`` patch (Plotly 2.35.x CDN). */ function plotlyRelayoutShapesHardReplaceThenPatch(layoutPatch) { if (!gd || !layoutPatch) return; try { var cur = (gd.layout && gd.layout.shapes) ? gd.layout.shapes : []; if (cur && cur.length) { var patchDel = {}; for (var si = cur.length - 1; si >= 0; si--) { patchDel["shapes[" + si + "]"] = null; } Plotly.relayout(gd, patchDel); } else { Plotly.relayout(gd, { shapes: [] }); } } catch (e0) { try { Plotly.relayout(gd, { shapes: [] }); } catch (e0b) { /* ignore */ } } try { Plotly.relayout(gd, layoutPatch); } catch (e1) { try { Plotly.relayout(gd, layoutPatch); } catch (e2) { /* ignore */ } } } /** * opts: optional ``clearSelections`` (boolean) — when true, clear Plotly ``layout.selections``. */ function syncCommittedScatterRegionOverlays(opts) { if (!gd) return; opts = opts || {}; var clearSel = !!opts.clearSelections; var nReg = committedScatterRegions.length; var baseShapes; if (clearSel) { var raw0 = (gd.layout && gd.layout.shapes) ? gd.layout.shapes.slice() : []; baseShapes = scatterExplorerShapesPurgeForGeometryClear(raw0); } else { baseShapes = scatterExplorerBaseShapes(); } var overlayShapes = []; for (var ri = 0; ri < nReg; ri++) { var rawDraw = committedScatterRegions[ri].raw; if (!rawDraw) continue; var sh = scatterRawSelectionToCommitShape(rawDraw, ri); if (sh) overlayShapes.push(sh); } var shapesOut = baseShapes.concat(overlayShapes); var annBase = scatterExplorerBaseAnnotations(); /* Multi-region counts + solo / colour controls: HTML chips (``#scatter-region-chips-overlay``), not Plotly text. */ var annOver = []; var patch = { shapes: shapesOut, annotations: annBase.concat(annOver), selectionrevision: (gd.layout && gd.layout.selectionrevision != null ? gd.layout.selectionrevision + 1 : Date.now()) }; if (clearSel) patch.selections = []; try { if (clearSel) { plotlyRelayoutShapesHardReplaceThenPatch(patch); } else { Plotly.relayout(gd, patch); } } catch (e) { /* ignore */ } nudgeHighlightTraceAfterScatterglSelectRestyle(); scheduleScatterRegionLabelChipsSync(); } var scatterRegionChipsOverlay = null; var scatterRegionChipsSyncQueued = false; function scheduleScatterRegionLabelChipsSync() { if (scatterRegionChipsSyncQueued) return; scatterRegionChipsSyncQueued = true; requestAnimationFrame(function() { scatterRegionChipsSyncQueued = false; syncScatterRegionLabelChips(); }); } function soloCommittedScatterRegion(regionIdx) { if (!committedScatterRegions.length) return; if (regionIdx < 0 || regionIdx >= committedScatterRegions.length) return; var keep = committedScatterRegions[regionIdx]; var rawClone = cloneScatterSelectionShapes([keep.raw])[0]; committedScatterRegions = [{ raw: rawClone, nParticles: 0, rows: [], labelX: keep.labelX, labelY: keep.labelY, colorHex: keep.colorHex }]; recomputeCommittedScatterRegionRowsFromGeometry(); var rows = (committedScatterRegions[0].rows && committedScatterRegions[0].rows.slice) ? committedScatterRegions[0].rows.slice() : []; applyRowsSelection(rows, "Lasso", LASSO_SELECTION_DEBOUNCE_MS + 120); recomputeCommittedScatterRegionRowsFromGeometry(); syncCommittedScatterRegionOverlays({ clearSelections: true }); } function removeCommittedScatterRegion(regionIdx) { if (!committedScatterRegions.length) return; if (regionIdx < 0 || regionIdx >= committedScatterRegions.length) return; committedScatterRegions.splice(regionIdx, 1); if (!committedScatterRegions.length) { applyRowsSelection([], "Lasso", LASSO_SELECTION_DEBOUNCE_MS + 120); syncCommittedScatterRegionOverlays({ clearSelections: true }); return; } recomputeCommittedScatterRegionRowsFromGeometry(); var merged = new Set(); for (var ri = 0; ri < committedScatterRegions.length; ri++) { var rs = committedScatterRegions[ri].rows; if (!rs || !rs.length) continue; for (var rj = 0; rj < rs.length; rj++) merged.add(rs[rj]); } applyRowsSelection(Array.from(merged), "Lasso", LASSO_SELECTION_DEBOUNCE_MS + 120); recomputeCommittedScatterRegionRowsFromGeometry(); syncCommittedScatterRegionOverlays({ clearSelections: true }); } function syncScatterRegionLabelChips() { if (!scatterRegionChipsOverlay) { scatterRegionChipsOverlay = document.getElementById("scatter-region-chips-overlay"); } if (!scatterRegionChipsOverlay || !gd) return; scatterRegionChipsOverlay.innerHTML = ""; var nReg = committedScatterRegions.length; if (nReg < 2) { scatterRegionChipsOverlay.setAttribute("aria-hidden", "true"); return; } scatterRegionChipsOverlay.setAttribute("aria-hidden", "false"); var rectO = scatterRegionChipsOverlay.getBoundingClientRect(); if (!rectO.width || !rectO.height) return; for (var aj = 0; aj < nReg; aj++) { (function (regionIdx) { var reg = committedScatterRegions[regionIdx]; var c0 = scatterRawSelectionCentroid(reg.raw); if (!c0 || !isFinite(c0.x) || !isFinite(c0.y)) return; var useX = reg.labelX != null && isFinite(reg.labelX) ? reg.labelX : c0.x; var useY = reg.labelY != null && isFinite(reg.labelY) ? reg.labelY : c0.y; var px = scatterPlotClientXYFromData(useX, useY); if (!px) return; var pst = scatterRegionPlotStyle(regionIdx); var catKey = "__cdrgnScatterRegion:" + regionIdx; var cell = document.createElement("div"); cell.className = "cryo-cc-discrete-cell cryo-cc-discrete-cell--plastic cryo-explorer-scatter-region-chip"; cell.setAttribute("data-region-idx", String(regionIdx)); cell.title = "Region " + (regionIdx + 1) + " — drag count to reposition"; var rowInner = document.createElement("div"); rowInner.className = "cryo-explorer-scatter-region-chip__row"; var sw = document.createElement("div"); sw.className = "cryo-cc-discrete-switch"; var sp = document.createElement("span"); sp.className = "cryo-cc-discrete-switch-label"; sp.setAttribute("aria-hidden", "true"); sp.textContent = "\u00a0"; var cn = document.createElement("span"); cn.className = "cryo-cc-discrete-switch-count"; cn.textContent = fmtRegionPtsLabel(reg.nParticles); sw.appendChild(sp); sw.appendChild(cn); var actions = document.createElement("div"); actions.className = "cryo-explorer-scatter-region-chip__actions"; var wheelSolo = document.createElement("div"); wheelSolo.className = "cryo-explorer-scatter-region-chip__wheel-solo"; var wheelSlot = document.createElement("div"); wheelSlot.className = "cryo-cc-discrete-wheel-slot"; var pick = document.createElement("button"); pick.type = "button"; pick.className = "cryo-cc-discrete-colorwheel-btn"; pick.title = "Choose colour…"; pick.setAttribute( "aria-label", "Choose colour for selection region " + (regionIdx + 1) ); pick.innerHTML = typeof CryoColorCovariateLegend !== "undefined" && typeof CryoColorCovariateLegend.discreteColorWheelSvgInnerHTML === "function" ? (function () { var gid = "cryo-cc-rainbow-r" + regionIdx + "-" + String(Math.random()).slice(2, 8); return CryoColorCovariateLegend.discreteColorWheelSvgInnerHTML().replace( /cryo-cc-rainbow/g, gid ); })() : ""; pick.addEventListener("mousedown", function (ev) { ev.preventDefault(); ev.stopPropagation(); }); pick.addEventListener("click", function (ev) { ev.preventDefault(); ev.stopPropagation(); if ( !explorerColorLegend || typeof explorerColorLegend._openDiscreteColorPopover !== "function" ) { return; } var hx = pst.line; explorerColorLegend._openDiscreteColorPopover(pick, cell, catKey, hx); }); wheelSlot.appendChild(pick); var soloBtn = document.createElement("button"); soloBtn.type = "button"; soloBtn.className = "cryo-cc-discrete-solo-btn"; soloBtn.title = "Keep only this lasso/box region"; soloBtn.setAttribute( "aria-label", "Select only region " + (regionIdx + 1) ); soloBtn.textContent = "1"; soloBtn.addEventListener("mousedown", function (ev) { ev.preventDefault(); ev.stopPropagation(); }); soloBtn.addEventListener("click", function (ev) { ev.preventDefault(); ev.stopPropagation(); soloCommittedScatterRegion(regionIdx); }); wheelSolo.appendChild(wheelSlot); wheelSolo.appendChild(soloBtn); var saveBtn = document.createElement("button"); saveBtn.type = "button"; saveBtn.className = "cryo-explorer-scatter-region-chip__save"; saveBtn.title = "Save this region's indices as…"; saveBtn.setAttribute( "aria-label", "Save indices for region " + (regionIdx + 1) + " as…" ); if ( typeof CryoDashboardIcons !== "undefined" && typeof CryoDashboardIcons.decorateSaveButton === "function" ) { CryoDashboardIcons.decorateSaveButton(saveBtn, { iconOnly: true }); } saveBtn.addEventListener("mousedown", function (ev) { ev.preventDefault(); ev.stopPropagation(); }); saveBtn.addEventListener("click", function (ev) { ev.preventDefault(); ev.stopPropagation(); saveCommittedScatterRegion(regionIdx); }); var rmBtn = document.createElement("button"); rmBtn.type = "button"; rmBtn.className = "cryo-explorer-scatter-region-chip__remove"; rmBtn.title = "Remove this region from the selection"; rmBtn.setAttribute("aria-label", "Remove region " + (regionIdx + 1) + " from selection"); rmBtn.textContent = "\u00d7"; rmBtn.addEventListener("mousedown", function (ev) { ev.preventDefault(); ev.stopPropagation(); }); rmBtn.addEventListener("click", function (ev) { ev.preventDefault(); ev.stopPropagation(); removeCommittedScatterRegion(regionIdx); }); actions.appendChild(wheelSolo); actions.appendChild(saveBtn); actions.appendChild(rmBtn); rowInner.appendChild(sw); rowInner.appendChild(actions); cell.appendChild(rowInner); if ( explorerColorLegend && typeof explorerColorLegend._applyDiscreteCellPaint === "function" ) { explorerColorLegend._applyDiscreteCellPaint(cell, pst.line); } cell.addEventListener("pointerdown", scatterRegionChipPointerDown); var left = px.clientX - rectO.left; var top = px.clientY - rectO.top; cell.style.position = "absolute"; cell.style.left = left + "px"; cell.style.top = top + "px"; cell.style.transform = "translate(-50%, -50%)"; scatterRegionChipsOverlay.appendChild(cell); })(aj); } } function scheduleCommittedScatterRegionOverlaySync() { if (committedScatterOverlayRaf != null) return; committedScatterOverlayRaf = requestAnimationFrame(function() { committedScatterOverlayRaf = null; syncCommittedScatterRegionOverlays({ clearSelections: false }); }); } var scatterglSelectionPaintFlushTok = 0; /** * Scattergl often keeps lasso/box selection paint until the drag layer resets. * Nudge ``dragmode`` through ``false`` then back so the WebGL selection overlay is dropped. */ function pulseScatterglDragmodeToFlushSelectionPaint() { if (!gd || !gd.data || !gd.data[0] || gd.data[0].type !== "scattergl") return; var dm = gd.layout && gd.layout.dragmode != null ? gd.layout.dragmode : "zoom"; try { Plotly.relayout(gd, { selections: [], dragmode: false }); Plotly.relayout(gd, { dragmode: dm }); } catch (e) { try { Plotly.relayout(gd, { dragmode: "pan" }); Plotly.relayout(gd, { dragmode: dm }); } catch (e2) { /* ignore */ } } nudgeHighlightTraceAfterScatterglSelectRestyle(); } /** After ``relayout``, scattergl may repaint the selection mask on the next tick — pulse a few times. */ function scheduleScatterglSelectionPaintFlushAfterClear() { scatterglSelectionPaintFlushTok++; var tok = scatterglSelectionPaintFlushTok; function run() { if (tok !== scatterglSelectionPaintFlushTok) return; pulseScatterglDragmodeToFlushSelectionPaint(); } window.requestAnimationFrame(function() { window.requestAnimationFrame(function() { run(); window.setTimeout(run, 0); window.setTimeout(run, 95); window.setTimeout(run, 210); window.setTimeout(function() { if (tok !== scatterglSelectionPaintFlushTok || !gd) return; try { if (Plotly.Plots && typeof Plotly.Plots.redraw === "function") { Plotly.Plots.redraw(gd); } } catch (eR) { /* ignore */ } }, 320); }); }); } function clearScatterGeometricSelection() { if (committedScatterOverlayRaf != null) { cancelAnimationFrame(committedScatterOverlayRaf); committedScatterOverlayRaf = null; } committedScatterRegions = []; if (!gd) return; var rawShapes = (gd.layout && gd.layout.shapes) ? gd.layout.shapes.slice() : []; var basesh = scatterExplorerShapesPurgeForGeometryClear(rawShapes); var annb = scatterExplorerBaseAnnotations(); var selRev = (gd.layout && gd.layout.selectionrevision != null ? gd.layout.selectionrevision + 1 : Date.now()); try { plotlyRelayoutShapesHardReplaceThenPatch({ shapes: basesh, annotations: annb, selections: [], selectionrevision: selRev }); } catch (e) { try { Plotly.relayout(gd, { selections: [], selectionrevision: Date.now() }); } catch (e2) {} } nudgeHighlightTraceAfterScatterglSelectRestyle(); scheduleScatterglSelectionPaintFlushAfterClear(); scheduleScatterRegionLabelChipsSync(); } /** * Clear geometric selections without allowing a follow-up ``plotly_deselect`` * to reset legend-driven threshold / range state. */ function clearScatterGeometricSelectionSuppressed() { var prevSuppress = suppressSelectionEvents; suppressSelectionEvents = true; clearScatterGeometricSelection(); setTimeout(function() { suppressSelectionEvents = prevSuppress; }, LASSO_SELECTION_DEBOUNCE_MS + 80); } function applyPreselectedTraceIndices() { var meta = gd.layout && gd.layout.meta; var pre = meta && meta.cdrgn_preselected; if (!pre || !pre.length) return; try { applyScatterSelectionHighlight(pre); } catch (err) { console.error(err); } nudgeHighlightTraceAfterScatterglSelectRestyle(); } function applySavedSelectionToCurrentTrace() { if (!gd || !gd.data || !gd.data[0] || !gd.data[0].customdata) return; var rows = saveSelectionRows && saveSelectionRows.length ? saveSelectionRows : []; if (!rows.length) { beginScatterSelectionRenderingOverlay(); try { clearScatterSelectionHighlight(false); } catch (err) {} finally { endScatterSelectionRenderingOverlay(); } lassoSelectedRowSet = null; nudgeHighlightTraceAfterScatterglSelectRestyle(); refreshGridHighlightMarkerStylesFromLastRows(); return; } var cd = gd.data[0].customdata; var selected = selectionRowsToTraceIndices(rows); beginScatterSelectionRenderingOverlay(); try { applyScatterSelectionHighlight(selected); } catch (err) { console.error(err); } finally { endScatterSelectionRenderingOverlay(); } lassoSelectedRowSet = new Set(); for (var si = 0; si < selected.length; si++) { var r = cd[selected[si]][1]; lassoSelectedRowSet.add(r); } nudgeHighlightTraceAfterScatterglSelectRestyle(); refreshGridHighlightMarkerStylesFromLastRows(); } /* ---------- highlight trace + montage grid coupling ---------- */ function showGridHighlightsEnabled() { if (!imageGridMenuIsOpen()) return false; return !!(imagesViewEnabled && preloaded && preloaded.traceMap && preloaded.traceMap.length); } function showGridHighlightsOnPlot() { return !suppressPlotGridHighlights && showGridHighlightsEnabled(); } function syncHighlightTraceAnnotations() { if (!highlightTraceAdded || !gd.data || !gd.data[1]) return; var showHighlights = showGridHighlightsOnPlot(); var text = []; if (showHighlights && lastMontageRows && lastMontageRows.length) { for (var i = 0; i < lastMontageRows.length && i < labels.length; i++) { text.push(labels[i]); } } try { Plotly.restyle(gd, { mode: ["markers"], text: [text], visible: [showHighlights], selectedpoints: [null], "unselected.marker.opacity": [1], "unselected.textfont.opacity": [1] }, [1]); } catch (err) { console.error(err); } refreshGridHighlightMarkerStylesFromLastRows(); syncGridLetterGlyphOverlay(); } function ensureHighlightTrace() { if (highlightTraceAdded) return; highlightTraceAdded = true; wireGridLetterGlyphOverlayHook(); Plotly.addTraces(gd, [{ type: "scatter", x: [], y: [], mode: "markers", text: [], textposition: "middle center", textfont: { size: gridHighlightTextSizePx(gridSize), color: GRID_HIGHLIGHT_TEXT_COLOR, family: "Georgia, 'Times New Roman', serif" }, selectedpoints: null, unselected: { marker: { opacity: 1 }, textfont: { opacity: 1 } }, marker: { size: gridHighlightMarkerSizePx(gridSize), opacity: GRID_HIGHLIGHT_MARKER_OPACITY, color: ACCENT, line: {width: GRID_HIGHLIGHT_LINE_WIDTH, color: GRID_HIGHLIGHT_MARKER_LINE_COLOR} }, visible: showGridHighlightsOnPlot(), hoverinfo: "skip", showlegend: false }]); syncGridLetterGlyphOverlay(); } /* ---------- scatter load ---------- */ function loadPlot(axesChanged) { var gen = ++scatterLoadGeneration; if (axesChanged) { committedScatterRegions = []; } var preservedDragMode = ( gd && gd.layout && gd.layout.dragmode ) ? String(gd.layout.dragmode) : ""; var hasActiveSelection = !!(saveSelectionRows && saveSelectionRows.length); suppressSelectionEvents = true; highlightTraceAdded = false; clearGridLetterGlyphOverlay(); cancelLassoSelectionDebounce(); cancelPendingHighlightRestyle(); cancelPendingScatterAfterPlot(); clearScatterPlotWatchdog(); if (plotStatus) plotStatus.textContent = ""; hideColorHistogram(); setRendering(true); scatterPlotWatchdog = setTimeout(function() { scatterPlotWatchdog = null; if (!overlay || !overlay.classList.contains("cryo-plot-rendering-overlay--show")) return; cancelPendingScatterAfterPlot(); setRendering(false); suppressSelectionEvents = false; if (plotStatus) plotStatus.textContent = "Plot took too long — try a hard refresh."; }, SCATTER_PLOT_TIMEOUT_MS); var x = sx.value, y = sy.value, c = sc.value; var q = "x=" + encodeURIComponent(x) + "&y=" + encodeURIComponent(y) + "&color=" + encodeURIComponent(c) + "&explorer_scatter=1" + "&marker_size=2" + "&palette=" + encodeURIComponent(selectedScatterPalette()); if (preselectRowsForNextFetch.length) { q += "&preselect_rows=" + preselectRowsForNextFetch.map(Number).join(","); } requestAnimationFrame(function() { requestAnimationFrame(function() { fetch("{{ url_for('api_scatter') }}?" + q) .then(function(r) { return r.text().then(function(text) { var j; try { j = JSON.parse(text); } catch (e) { throw new Error("Scatter API did not return JSON"); } if (!r.ok) throw new Error(j.error || r.statusText); return j; }); }) .then(function(fig) { fig.layout.title = ""; fig.layout.margin = Object.assign({}, fig.layout.margin || {}, { l: 38, r: 10, t: 4, b: 38, pad: 0 }); var nextDragMode = preservedDragMode; if (hasActiveSelection) { if (nextDragMode !== "lasso" && nextDragMode !== "select") { nextDragMode = "select"; } } if (nextDragMode) { fig.layout.dragmode = nextDragMode; } var axFont = {size: 21.45, family: "Barlow, sans-serif", color: "#243b53", weight: 700}; fig.layout.xaxis = Object.assign(fig.layout.xaxis || {}, { title: { text: "" + escapeHtmlText(x) + "", font: axFont } }); fig.layout.yaxis = Object.assign(fig.layout.yaxis || {}, { title: { text: "" + escapeHtmlText(y) + "", font: axFont } }); if (/^umap/i.test(x)) fig.layout.xaxis.showticklabels = false; if (/^umap/i.test(y)) fig.layout.yaxis.showticklabels = false; function finishScatterDraw() { if (gen !== scatterLoadGeneration) return; cancelPendingScatterAfterPlot(); wireUpPlotlyEvents(); suppressSelectionEvents = true; renderedScatterAxes = {x: x, y: y}; clearScatterPlotWatchdog(); scatterRenderingEverCompleted = true; setRendering(false); buildTraceMap(); updateParticleSelFieldset(); if (preselectRowsForNextFetch.length) { saveSelectionRows = preselectRowsForNextFetch.slice(); updateParticleSelFieldset(); applyPreselectedTraceIndices(); preselectRowsForNextFetch = []; } applySavedSelectionToCurrentTrace(); syncCommittedScatterRegionOverlays({ clearSelections: true }); syncColorCovariateSidePanel(); if (imagesViewEnabled && preloaded && preloaded.traceMap) { if (lassoSelectedRowSet && lassoSelectedRowSet.size) { activePool = preloaded.traceMap.filter(function(item) { return lassoSelectedRowSet.has(item.row); }); if (preloadStatus) { if (activePool.length) { preloadStatus.textContent = formatCachedSelectionStatusCore(activePool.length) + "."; } else { setPreloadStatusCachedTotalOnly(); } } } if (lastMontageRows.length) { restoreMontageAfterScatterReload(); } else { showRandomPreloaded(); } } else if (!imagesViewEnabled) { if (lastMontageRows.length) { ensureHighlightTrace(); var keepItems = []; for (var li = 0; li < lastMontageRows.length; li++) { var itKeep = montageItemForRow(lastMontageRows[li]); if (itKeep) keepItems.push(itKeep); } if (keepItems.length) updateMontage(keepItems); } else { updateMontage([]); } } if (showVolumeExplorer) syncVolumeExploreButtons(); var montageOverlayBusy = montagePreloadOverlayEl && montagePreloadOverlayEl.classList.contains("cryo-plot-rendering-overlay--show"); if (!montageOverlayBusy) syncImageCacheButton(false); syncScatterControlsAlignment(); requestAnimationFrame(function() { try { Plotly.Plots.resize(gd); } catch (e) {} if (colorHistPanel && !colorHistPanel.hidden && colorHistDiv) { try { Plotly.Plots.resize(colorHistDiv); } catch (e2) {} } }); explorerApplyDiscreteMarkerColoursFromOverrides(); setTimeout(function() { suppressSelectionEvents = false; }, 0); } var opts = {responsive: true, doubleClick: false}; var p; try { if (!gd.data || gd.data.length === 0) { p = Plotly.newPlot(gd, fig.data, fig.layout, opts); } else { p = Plotly.react(gd, fig.data, fig.layout, opts); } } catch (err) { throw err; } pendingScatterAfterPlot = finishScatterDraw; gd.on("plotly_afterplot", pendingScatterAfterPlot); setTimeout(function() { if (gen !== scatterLoadGeneration) return; if (pendingScatterAfterPlot && gd.data && gd.data.length) finishScatterDraw(); }, 800); if (p && typeof p.then === "function") { return p.catch(function(err) { cancelPendingScatterAfterPlot(); throw err; }); } return Promise.resolve(); }) .catch(function(e) { console.error(e); cancelPendingScatterAfterPlot(); clearScatterPlotWatchdog(); setRendering(false); suppressSelectionEvents = false; if (plotStatus) plotStatus.textContent = e.message || "Could not load scatter."; }); }); }); } function cancelLassoSelectionDebounce() { if (lassoSelectionDebounceTimer) { clearTimeout(lassoSelectionDebounceTimer); lassoSelectionDebounceTimer = null; } } function cancelPendingHighlightRestyle() { if (highlightRestyleRaf != null) { cancelAnimationFrame(highlightRestyleRaf); highlightRestyleRaf = null; } pendingHighlightRestyle = null; } /** Copy indices immediately — Plotly may reuse the event object on the next tick. */ function snapshotSelectedPoints(ev) { if (!ev || !ev.points || !ev.points.length) return []; var pts = ev.points; var out = new Array(pts.length); for (var i = 0; i < pts.length; i++) { var p = pts[i]; out[i] = {curveNumber: p.curveNumber, pointIndex: p.pointIndex}; } return out; } function snapshotRowsFromPoints(ev) { var set = new Set(); if (ev && ev.points) { for (var i = 0; i < ev.points.length; i++) { var p = ev.points[i]; if (p.curveNumber !== 0) continue; if (p.customdata != null && p.customdata.length > 1) { var rSnap = Number(p.customdata[1]); if (isFinite(rSnap)) set.add(rSnap); } } } return Array.from(set); } function selectionRowsToTraceIndices(rows) { rows = rows || []; if (rowToTraceIndexMap) { var selected = []; var seen = new Set(); for (var rti = 0; rti < rows.length; rti++) { var rtv = Number(rows[rti]); if (!isFinite(rtv)) continue; var ti = rowToTraceIndexMap.get(rtv); if (ti === undefined) continue; if (seen.has(ti)) continue; seen.add(ti); selected.push(ti); } return selected; } if (!gd || !gd.data || !gd.data[0] || !gd.data[0].customdata) return []; // Fallback: O(N) scan when the lookup map hasn't been built yet. var rowSet = new Set(); for (var rti = 0; rti < rows.length; rti++) { var rtv = Number(rows[rti]); if (isFinite(rtv)) rowSet.add(rtv); } var selected = []; var cd = gd.data[0].customdata; for (var i = 0; i < cd.length; i++) { var row = cd[i] && cd[i].length > 1 ? cd[i][1] : null; var rowN = row == null ? NaN : Number(row); if (isFinite(rowN) && rowSet.has(rowN)) selected.push(i); } return selected; } /** Scattergl dimming via ``selectedpoints`` (compact index list; preserves per-point colours). */ function applyScatterSelectionHighlight(traceIndices) { if (!gd || !gd.data || !gd.data[0]) return null; traceIndices = traceIndices || []; try { return Plotly.restyle( gd, { selectedpoints: [traceIndices.length ? traceIndices : null] }, [0] ); } catch (e) { return null; } } function clearScatterSelectionHighlight(flushScatterglPaint) { if (!gd || !gd.data || !gd.data[0]) return null; var ret = null; try { ret = Plotly.restyle(gd, { selectedpoints: [null] }, [0]); } catch (e) { /* ignore */ } if (flushScatterglPaint && gd.data[0].type === "scattergl") { scheduleScatterglSelectionPaintFlushAfterClear(); } return ret; } function applyRowsSelection(rows, statusPrefix, holdSuppressMs) { rows = rows || []; cancelLassoSelectionDebounce(); var rowSet = new Set(rows); var mode = ""; if (rowSet.size) { if (statusPrefix === "Color threshold") mode = "threshold"; else if (statusPrefix === "Discrete") mode = "discrete"; else mode = "lasso"; } setActiveSelectionMode(mode); saveSelectionRows = Array.from(rowSet); selectionTooltipText = ""; updateParticleSelFieldset(); var selectedTi = selectionRowsToTraceIndices(saveSelectionRows); suppressSelectionEvents = true; beginScatterSelectionRenderingOverlay(); var restyleRet = null; try { if (!saveSelectionRows.length) { restyleRet = clearScatterSelectionHighlight(true); } else { restyleRet = applyScatterSelectionHighlight(selectedTi); } } catch (err) { console.error(err); } finally { endScatterSelectionRenderingOverlay(); } var sp = gd && gd.data && gd.data[0] ? gd.data[0].selectedpoints : null; if (sp) nudgeHighlightTraceAfterScatterglSelectRestyle(); /* Legend-driven discrete/threshold restyles still emit plotly_selected after a tick; its debounced handler (200ms) would overwrite saveSelectionRows and clear toggle checkboxes unless we keep suppression past that window. */ var suppressClearMs = 0; if (selectedTi.length && (statusPrefix === "Discrete" || statusPrefix === "Color threshold")) { suppressClearMs = LASSO_SELECTION_DEBOUNCE_MS + 100; } if (holdSuppressMs != null && isFinite(Number(holdSuppressMs))) { suppressClearMs = Math.max(suppressClearMs, Number(holdSuppressMs)); } setTimeout(function() { suppressSelectionEvents = false; }, suppressClearMs); if (!saveSelectionRows.length) { activePool = null; lassoSelectedRowSet = null; if (imagesViewEnabled && preloaded && preloadStatus) { setPreloadStatusCachedTotalOnly(); } invalidateVolumeArtifacts(); syncMontageResampleFromCacheButton(); return; } lassoSelectedRowSet = new Set(saveSelectionRows); if (imagesViewEnabled && preloaded && preloaded.traceMap) { activePool = preloaded.traceMap.filter(function(item) { return rowSet.has(item.row); }); if (preloadStatus) { preloadStatus.textContent = (statusPrefix || "Selection") + ": " + formatCachedSelectionStatusCore(activePool.length) + "."; } if (activePool.length) { showRandomFromPool(); } else { invalidateVolumeArtifacts(); var fillSel = pickMontageItemsSelectionFirstFromCache(neighborK); if (fillSel.length) updateMontage(fillSel); } } else if (lastMontageRows.length || montageDisplayMode === "volumes") { ensureHighlightTrace(); var picked = pickRandomMontageItemsFilled(neighborK); if (picked.length) updateMontage(picked); else invalidateVolumeArtifacts(); } else { invalidateVolumeArtifacts(); } syncMontageResampleFromCacheButton(); } function clearExplorerSelection() { if (!gd) return; cancelLassoSelectionDebounce(); hideBoxSelectTooltip(); selectionTooltipText = ""; setActiveSelectionMode(""); colorThresholdLevel = null; colorThresholdRange = null; colorThresholdColorCol = null; if (explorerColorLegend) { explorerColorLegend.clearThreshold(); explorerColorLegend.setDiscreteCheckedKeys([]); } if (colorThresholdStatus) colorThresholdStatus.textContent = ""; syncColorHistModeCheckboxCaption(); /* Empty rows first so deselect/selected handlers see no saved selection; hold ``suppress`` past the lasso debounce so a stale ``plotly_selected`` timer cannot re-commit regions. */ applyRowsSelection([], undefined, LASSO_SELECTION_DEBOUNCE_MS + 120); clearScatterGeometricSelection(); syncCommittedScatterRegionOverlays({ clearSelections: true }); if (imagesViewEnabled && preloaded) { showRandomPreloaded(); } else if (imagesViewEnabled && lastMontageRows.length) { ensureHighlightTrace(); var pickedClearMontage = pickRandomMontageItemsFilled(neighborK); if (pickedClearMontage.length) updateMontage(pickedClearMontage); } syncMontageResampleFromCacheButton(); updateParticleSelFieldset(); } function rowsMatchingColorThresholdFromTrace(items, level, useMax) { var rows = []; for (var i = 0; i < items.length; i++) { if (useMax ? items[i].value <= level : items[i].value >= level) rows.push(items[i].row); } return rows; } function rowsMatchingColorRangeFromTrace(items, lo, hi, invert) { var rows = []; var a = Math.min(lo, hi); var b = Math.max(lo, hi); for (var j = 0; j < items.length; j++) { var v = items[j].value; var inside = v >= a && v <= b; if (invert ? !inside : inside) rows.push(items[j].row); } return rows; } function queueHighlightRestyle(restyleData) { if (!restyleData) return; /* * A later call may enqueue only a styling "patch" (e.g. * refreshGridHighlightMarkerStylesFromLastRows()) right after updateMontage() * enqueued a full payload (including x/y positions). * * If we replaced pendingHighlightRestyle, we'd drop the x/y update and the * highlighted points would no longer correspond to the image-grid rows. */ if (highlightRestyleRaf != null && pendingHighlightRestyle) { for (var k in restyleData) { if (Object.prototype.hasOwnProperty.call(restyleData, k)) { pendingHighlightRestyle[k] = restyleData[k]; } } } else { pendingHighlightRestyle = restyleData; } if (highlightRestyleRaf != null) return; highlightRestyleRaf = requestAnimationFrame(function() { highlightRestyleRaf = null; var payload = pendingHighlightRestyle; pendingHighlightRestyle = null; if (!payload || !highlightTraceAdded || !gd.data || !gd.data[0]) return; stampHighlightTraceAntiDim(payload); beginScatterSelectionRenderingOverlay(); try { Plotly.restyle(gd, payload, [1]); } catch (err) { console.error(err); } finally { endScatterSelectionRenderingOverlay(); syncGridLetterGlyphOverlay(); } }); } function formatSelectionBound(v) { var n = Number(v); if (!isFinite(n)) return String(v); return Number(n.toPrecision(6)).toString(); } function boxSelectionTooltip(ev) { if (!ev || !ev.range || !ev.range.x || !ev.range.y) return ""; var xr = ev.range.x.slice().sort(function(a, b) { return a - b; }); var yr = ev.range.y.slice().sort(function(a, b) { return a - b; }); if (xr.length < 2 || yr.length < 2) return ""; return "Box filter: " + formatSelectionBound(xr[0]) + " <= val_x <= " + formatSelectionBound(xr[1]) + "; " + formatSelectionBound(yr[0]) + " <= val_y <= " + formatSelectionBound(yr[1]) + " (val_x = " + sx.value + ", val_y = " + sy.value + ")"; } function hideBoxSelectTooltip() { if (!boxSelectTooltipEl) return; boxSelectTooltipEl.hidden = true; boxSelectTooltipEl.textContent = ""; } function showBoxSelectTooltip(ev) { if (!boxSelectTooltipEl || !scatterPlotStack) return; var text = boxSelectionTooltip(ev); if (!text) { hideBoxSelectTooltip(); return; } var rect = scatterPlotStack.getBoundingClientRect(); var src = ev && ev.event ? ev.event : null; var left = src && typeof src.clientX === "number" ? src.clientX - rect.left : 12; var top = src && typeof src.clientY === "number" ? src.clientY - rect.top : 12; left = Math.max(8, Math.min(left, rect.width - 24)); top = Math.max(8, Math.min(top, rect.height - 8)); boxSelectTooltipEl.textContent = text; boxSelectTooltipEl.style.left = left + "px"; boxSelectTooltipEl.style.top = top + "px"; boxSelectTooltipEl.hidden = false; } function findKNearestInPool(px, py, k) { var items = (preloaded && preloaded.traceMap && preloaded.traceMap.length) ? preloaded.traceMap : (activePool || []); if (!items.length) return []; var xs = gd.data[0].x, ys = gd.data[0].y; var best = []; for (var j = 0; j < items.length; j++) { var ti = items[j].ti; var ddx = xs[ti] - px, ddy = ys[ti] - py; var d2 = ddx * ddx + ddy * ddy; if (best.length < k) { best.push({j: j, d: d2}); if (best.length === k) best.sort(function(a, b) { return a.d - b.d; }); } else if (d2 < best[k - 1].d) { best[k - 1] = {j: j, d: d2}; best.sort(function(a, b) { return a.d - b.d; }); } } return best.map(function(b) { return items[b.j]; }); } var scatterRegionLabelDrag = null; var scatterRegionLabelDragRaf = false; var scatterRegionLabelDocListeners = false; function scatterPlotDataXYFromClient(clientX, clientY) { if (!gd || !gd._fullLayout) return null; var rect = gd.getBoundingClientRect(); var xpx = clientX - rect.left; var ypx = clientY - rect.top; var xa = gd._fullLayout.xaxis; var ya = gd._fullLayout.yaxis; if (!xa || !ya || !isFinite(xa._length) || !isFinite(ya._length) || xa._length <= 0 || ya._length <= 0) { return null; } var xfrac = (xpx - xa._offset) / xa._length; var yfrac = (ypx - ya._offset) / ya._length; if (xfrac < -0.02 || xfrac > 1.02 || yfrac < -0.02 || yfrac > 1.02) return null; xfrac = Math.max(0, Math.min(1, xfrac)); yfrac = Math.max(0, Math.min(1, yfrac)); var xr = Array.isArray(xa.range) ? xa.range.map(Number) : [0, 1]; var yr = Array.isArray(ya.range) ? ya.range.map(Number) : [0, 1]; var x0 = xr[0]; var x1 = xr[1]; var y0 = yr[0]; var y1 = yr[1]; var dx = x1 - x0; var dy = y1 - y0; var xd = dx === 0 ? x0 : x0 + xfrac * dx; var yd = dy === 0 ? y0 : y1 - yfrac * dy; return { x: xd, y: yd }; } function scatterPlotClientXYFromData(xd, yd) { if (!gd || !gd._fullLayout) return null; var rect = gd.getBoundingClientRect(); var xa = gd._fullLayout.xaxis; var ya = gd._fullLayout.yaxis; if (!xa || !ya || !isFinite(xa._length) || !isFinite(ya._length) || xa._length <= 0 || ya._length <= 0) { return null; } var xr = xa.range.map(Number); var yr = ya.range.map(Number); var x0 = xr[0]; var x1 = xr[1]; var y0 = yr[0]; var y1 = yr[1]; var dx = x1 - x0; var dy = y1 - y0; var xfrac = dx === 0 ? 0.5 : (xd - x0) / dx; var yfrac = dy === 0 ? 0.5 : (y1 - yd) / dy; xfrac = Math.max(0, Math.min(1, xfrac)); yfrac = Math.max(0, Math.min(1, yfrac)); return { clientX: rect.left + xa._offset + xfrac * xa._length, clientY: rect.top + ya._offset + yfrac * ya._length }; } function scatterRegionChipPointerDown(ev) { if (suppressSelectionEvents || !gd || committedScatterRegions.length < 2) return; if (ev.pointerType === "mouse" && ev.button !== 0) return; if ( ev.target && (ev.target.closest(".cryo-cc-discrete-colorwheel-btn") || ev.target.closest(".cryo-cc-discrete-solo-btn") || ev.target.closest(".cryo-explorer-scatter-region-chip__save") || ev.target.closest(".cryo-explorer-scatter-region-chip__remove")) ) { return; } var chip = ev.currentTarget; var idx = parseInt(chip.getAttribute("data-region-idx"), 10); if (!isFinite(idx) || idx < 0 || idx >= committedScatterRegions.length) return; ev.preventDefault(); ev.stopPropagation(); scatterRegionLabelDrag = { idx: idx, pointerId: ev.pointerId, captureEl: chip }; try { chip.setPointerCapture(ev.pointerId); } catch (eCap) { /* ignore */ } ensureScatterRegionLabelDocListeners(); } function onScatterRegionLabelPointerMove(ev) { if (!scatterRegionLabelDrag || ev.pointerId !== scatterRegionLabelDrag.pointerId) return; var xy = scatterPlotDataXYFromClient(ev.clientX, ev.clientY); if (!xy) return; committedScatterRegions[scatterRegionLabelDrag.idx].labelX = xy.x; committedScatterRegions[scatterRegionLabelDrag.idx].labelY = xy.y; if (!scatterRegionLabelDragRaf) { scatterRegionLabelDragRaf = true; requestAnimationFrame(function() { scatterRegionLabelDragRaf = false; if (scatterRegionLabelDrag) { syncCommittedScatterRegionOverlays({ clearSelections: false }); } }); } } function onScatterRegionLabelPointerUp(ev) { if (!scatterRegionLabelDrag) return; if (ev.pointerId != null && ev.pointerId !== scatterRegionLabelDrag.pointerId) return; var cap = scatterRegionLabelDrag.captureEl; scatterRegionLabelDrag = null; try { if (ev.pointerId != null && cap) cap.releasePointerCapture(ev.pointerId); } catch (eRel) { /* ignore */ } syncCommittedScatterRegionOverlays({ clearSelections: false }); } function bindScatterRegionPlotPostRenderChipsOnce() { if (!gd || gd._cdrgnRegionChipsAfterPlotBound) return; gd._cdrgnRegionChipsAfterPlotBound = true; gd.on("plotly_afterplot", function() { scheduleScatterRegionLabelChipsSync(); }); } function ensureScatterRegionLabelDocListeners() { if (scatterRegionLabelDocListeners) return; scatterRegionLabelDocListeners = true; document.addEventListener("pointermove", onScatterRegionLabelPointerMove, true); document.addEventListener("pointerup", onScatterRegionLabelPointerUp, true); document.addEventListener("pointercancel", onScatterRegionLabelPointerUp, true); } /* ---------- plot events ---------- */ var plotlyEventsWired = false; var scatterClickTimer = null; var scatterClickLastRow = null; var scatterClickPendingXY = null; var scatterBlockSingleClickUntil = 0; function suppressSelectionAfterScatterDoubleClick() { suppressSelectionEvents = true; setTimeout(function() { suppressSelectionEvents = false; }, LASSO_SELECTION_DEBOUNCE_MS + 500); } function handleScatterPointDoubleClick(pt) { var row = rowFromPlotlyPoint(pt); if (!isFinite(row)) return; cancelLassoSelectionDebounce(); armMontageResampleSuppress(LASSO_SELECTION_DEBOUNCE_MS + 800); scatterBlockSingleClickUntil = Date.now() + 400; suppressSelectionAfterScatterDoubleClick(); assignScatterRowToMontageSlotA(row) .finally(function() { armMontageResampleSuppress(400); }) .catch(function(e) { console.error("montage slot A assign failed", e); }); } function wireUpPlotlyEvents() { if (plotlyEventsWired) return; plotlyEventsWired = true; ensureHighlightTrace(); bindScatterRegionPlotPostRenderChipsOnce(); gd.on("plotly_click", function(ev) { if (!ev.points || !ev.points.length) return; var pt = ev.points[0]; if (pt.curveNumber !== 0) return; var row = rowFromPlotlyPoint(pt); if (!isFinite(row)) return; if (scatterClickTimer !== null && scatterClickLastRow === row) { clearTimeout(scatterClickTimer); scatterClickTimer = null; scatterClickLastRow = null; scatterClickPendingXY = null; handleScatterPointDoubleClick(pt); return; } scatterClickLastRow = row; scatterClickPendingXY = { x: pt.x, y: pt.y }; if (scatterClickTimer) clearTimeout(scatterClickTimer); scatterClickTimer = setTimeout(function() { scatterClickTimer = null; scatterClickLastRow = null; var snap = scatterClickPendingXY; scatterClickPendingXY = null; if (!snap) return; if (Date.now() < scatterBlockSingleClickUntil) return; if (!imagesViewEnabled || !preloaded || !preloaded.traceMap) return; if (!showGridHighlightsEnabled()) return; var nbs = findKNearestInPool(snap.x, snap.y, neighborK); if (nbs.length) updateMontage(nbs); }, 320); }); gd.on("plotly_doubleclick", function() { suppressSelectionAfterScatterDoubleClick(); }); gd.on("plotly_selecting", function(ev) { if (suppressSelectionEvents) return; showBoxSelectTooltip(ev); if (committedScatterRegions.length) scheduleCommittedScatterRegionOverlaySync(); }); function applyLassoSelectionFromSnapshot() { if (!preloaded || !preloaded.traceMap) return; if (!saveSelectionRows || !saveSelectionRows.length) { activePool = null; lassoSelectedRowSet = null; if (preloadStatus) setPreloadStatusCachedTotalOnly(); return; } var rowSet = new Set(); for (var rai = 0; rai < saveSelectionRows.length; rai++) { var rv = Number(saveSelectionRows[rai]); if (isFinite(rv)) rowSet.add(rv); } activePool = preloaded.traceMap.filter(function(item) { return rowSet.has(Number(item.row)); }); lassoSelectedRowSet = new Set(); for (var lp = 0; lp < activePool.length; lp++) { lassoSelectedRowSet.add(Number(activePool[lp].row)); } if (preloadStatus) { preloadStatus.textContent = formatCachedSelectionStatusCore(activePool.length) + "."; } ensureHighlightTrace(); if (montageResampleSuppressed()) { if (lastMontageRows.length && highlightTraceAdded) { refreshGridHighlightMarkerStylesFromLastRows(); } return; } if (activePool.length) { showRandomFromPool(); } else { var fillLasso = pickMontageItemsSelectionFirstFromCache(neighborK); if (fillLasso.length) updateMontage(fillLasso); } } gd.on("plotly_selected", function(ev) { if (suppressSelectionEvents) return; clearColorHistogramThresholdForGeometricSelection(); var snap = snapshotSelectedPoints(ev); var rowsSnap = snapshotRowsFromPoints(ev); /* ``layout.selections`` is often cleared or replaced after ``Plotly.restyle(selectedpoints)``. Snapshot synchronously on ``plotly_selected`` so the debounced merge still sees every lasso/box geometry when appending a new region to an existing multi-region selection. */ var selectionsSnapshotForCommit = cloneScatterSelectionShapes(gd.layout && gd.layout.selections); /* Lasso stroke in progress: geometry exists but no plot_df rows yet — do not debounce-clear. */ if ( !rowsSnap.length && selectionsSnapshotForCommit.length && !(saveSelectionRows && saveSelectionRows.length) ) { return; } if ( !rowsSnap.length && !selectionsSnapshotForCommit.length && !(saveSelectionRows && saveSelectionRows.length) ) { return; } var prevMode = activeSelectionMode || ""; var baseRows = prevMode === "lasso" && saveSelectionRows && saveSelectionRows.length ? saveSelectionRows.slice() : []; var willHaveSelection = baseRows.length || rowsSnap.length; selectionTooltipText = boxSelectionTooltip(ev); setActiveSelectionMode(willHaveSelection ? "lasso" : ""); hideBoxSelectTooltip(); cancelLassoSelectionDebounce(); lassoSelectionDebounceTimer = setTimeout(function() { lassoSelectionDebounceTimer = null; var mergedSet = new Set(baseRows); for (var i = 0; i < rowsSnap.length; i++) mergedSet.add(rowsSnap[i]); saveSelectionRows = Array.from(mergedSet); if (!saveSelectionRows.length) { clearScatterGeometricSelection(); setActiveSelectionMode(""); } else { var fromPlotly = selectionsSnapshotForCommit.length ? selectionsSnapshotForCommit : cloneScatterSelectionShapes(gd.layout && gd.layout.selections); var prevSnap = committedScatterRegions.map(function(r) { return { raw: cloneScatterSelectionShapes([r.raw])[0], n: r.nParticles, rows: (r.rows && r.rows.slice) ? r.rows.slice() : [], labelX: r.labelX, labelY: r.labelY, colorHex: r.colorHex }; }); var mergedRaw = dedupeConsecutiveEqualScatterShapes( mergePersistedScatterSelectionShapes( prevSnap.map(function(s) { return s.raw; }), fromPlotly ) ); var uniqSnapCount = rowsSnap.length; committedScatterRegions = []; for (var ci = 0; ci < mergedRaw.length; ci++) { var rawI = cloneScatterSelectionShapes([mergedRaw[ci]])[0]; var np; var rowArr; var labelX; var labelY; var colorHex; if (ci < prevSnap.length && scatterSelectionShapesEqual(prevSnap[ci].raw, rawI)) { labelX = prevSnap[ci].labelX; labelY = prevSnap[ci].labelY; colorHex = prevSnap[ci].colorHex; rowArr = prevSnap[ci].rows.slice(); np = prevSnap[ci].n; } else { labelX = undefined; labelY = undefined; colorHex = undefined; if (ci === mergedRaw.length - 1) { rowArr = rowsSnap.slice(); np = uniqSnapCount; } else { rowArr = []; np = 0; } } committedScatterRegions.push({ raw: rawI, nParticles: np, rows: rowArr, labelX: labelX, labelY: labelY, colorHex: colorHex }); } saveSelectionRows = rowsUnionFromCommittedScatterRegions(); if (!saveSelectionRows.length) { clearScatterGeometricSelection(); setActiveSelectionMode(""); } else { syncCommittedScatterRegionOverlays({ clearSelections: true }); } } updateParticleSelFieldset(); /* Re-apply accumulated union after region geometry is committed (restyle can clear ``layout.selections``). */ var selectedTi = selectionRowsToTraceIndices(saveSelectionRows); suppressSelectionEvents = true; beginScatterSelectionRenderingOverlay(); try { var geomAtCommit = gd.layout && gd.layout.selections && gd.layout.selections.length; if (!saveSelectionRows.length) { /* Empty enclosure with shape still on canvas — still drawing, not a user clear. */ if (!(geomAtCommit && !rowsSnap.length)) { clearScatterSelectionHighlight(true); } } else { applyScatterSelectionHighlight(selectedTi); } } catch (err) { console.error(err); } finally { endScatterSelectionRenderingOverlay(); } nudgeHighlightTraceAfterScatterglSelectRestyle(); setTimeout(function() { suppressSelectionEvents = false; }, 0); if (scatterTraceColorMode() === "discrete") setExplorerLegendDiscreteKeysForSelection(); if (imagesViewEnabled && preloaded && preloaded.traceMap) { applyLassoSelectionFromSnapshot(); } if (lastMontageRows && lastMontageRows.length && highlightTraceAdded) { refreshGridHighlightMarkerStylesFromLastRows(); } }, LASSO_SELECTION_DEBOUNCE_MS); }); gd.on("plotly_deselect", function() { if (suppressSelectionEvents) return; hideBoxSelectTooltip(); // Keep the accumulated lasso/box selection unless the explicit "Clear selection" // button clears it (Plotly emits deselect events as part of selection edits). if (saveSelectionRows && saveSelectionRows.length) { var selectedTi = selectionRowsToTraceIndices(saveSelectionRows); suppressSelectionEvents = true; beginScatterSelectionRenderingOverlay(); try { applyScatterSelectionHighlight(selectedTi); } catch (err) { console.error(err); } finally { endScatterSelectionRenderingOverlay(); } nudgeHighlightTraceAfterScatterglSelectRestyle(); setTimeout(function() { suppressSelectionEvents = false; }, 0); syncCommittedScatterRegionOverlays({ clearSelections: false }); } }); gd.addEventListener("mouseleave", hideBoxSelectTooltip); } function showRandomFromPool() { showRandomPreloaded(); } function triggerRedraw(preserveSelectionAndMontage) { var axesChanged = sx.value !== renderedScatterAxes.x || sy.value !== renderedScatterAxes.y; var keepState = !!preserveSelectionAndMontage && !axesChanged; if (keepState) { /* Re-apply selection highlight after color-only redraw (keep URI size bounded). */ preselectRowsForNextFetch = (saveSelectionRows && saveSelectionRows.length && saveSelectionRows.length <= 5000) ? saveSelectionRows.slice() : []; } else { saveSelectionRows = []; preselectRowsForNextFetch = []; activePool = null; lassoSelectedRowSet = null; selectionTooltipText = ""; setActiveSelectionMode(""); clearScatterGeometricSelectionSuppressed(); lastMontageRows = []; colorThresholdLevel = null; colorThresholdRange = null; colorThresholdColorCol = null; } updateParticleSelFieldset(); invalidateVolumeArtifacts(); if (axesChanged) { setImageCacheProgress(false); if (preloaded) { syncImageCacheButton(false); if (preloadStatus) setPreloadStatusCachedTotalOnly(); } else { imagesViewEnabled = false; gridSizeSelect.disabled = true; setImageGridMenuOpen(false); syncImageCacheButton(false); if (preloadStatus) preloadStatus.textContent = ""; } } cancelLassoSelectionDebounce(); cancelPendingHighlightRestyle(); plotlyEventsWired = false; if (axesChanged) initializeExplorerView(); else loadPlot(axesChanged); } function saveInverseChecked() { var el = document.getElementById("save-inverse"); return el && el.checked; } function clearPendingRegionSave() { pendingRegionSaveRows = null; } function saveCommittedScatterRegion(regionIdx) { if (!committedScatterRegions || regionIdx < 0 || regionIdx >= committedScatterRegions.length) { return; } var reg = committedScatterRegions[regionIdx]; var regRows = reg && reg.rows && reg.rows.length ? reg.rows.slice() : []; if (!regRows.length) { var statusEmpty = document.getElementById("save-status"); if (statusEmpty) { statusEmpty.textContent = "Region " + (regionIdx + 1) + " has no particles to save."; statusEmpty.title = statusEmpty.textContent; } return; } openRegionSelectionFileBrowser(regionIdx, regRows); } var selFbModalTitle = document.getElementById("sel-fb-modal-title"); var selFbModalBackdrop = selFileBrowserPanel ? selFileBrowserPanel.querySelector("[data-sel-fb-close]") : null; var selectionSaveModalTriggerEl = null; function syncSelectionSaveModalTitle() { if (!selFbModalTitle) return; if (pendingRegionSaveRows && pendingRegionSaveRows.length) { selFbModalTitle.textContent = "Save region selection"; } else { selFbModalTitle.textContent = "Save selection"; } } function onSelectionSaveModalKeydown(ev) { if (ev.key !== "Escape" || !selFileBrowserPanel || selFileBrowserPanel.hidden) return; ev.preventDefault(); closeSelectionFileBrowser(); } function openRegionSelectionFileBrowser(regionIdx, regRows) { pendingRegionSaveRows = regRows.slice(); if (selFbSaveName) { selFbSaveName.value = "indices_region_" + (regionIdx + 1) + ".pkl"; } var status = document.getElementById("save-status"); if (status) { status.textContent = ""; status.title = ""; } openSelectionFileBrowser(workdirForSaveHint || "", { keepPendingRegionRows: true }); } function openSelectionFileBrowser(startDir, opts) { opts = opts || {}; if (!opts.keepPendingRegionRows) { clearPendingRegionSave(); } if (!selFileBrowserPanel || !selFbList || !selFbPath) return; if (selFbSaveName && !String(selFbSaveName.value || "").trim()) { selFbSaveName.value = "indices.pkl"; } syncSelectionSaveModalTitle(); selectionSaveModalTriggerEl = document.activeElement; selFileBrowserPanel.hidden = false; selFileBrowserPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("cryo-explorer-save-modal-open"); loadSelectionFileBrowserDir(startDir || ""); requestAnimationFrame(function() { if (selFbSaveName) { try { selFbSaveName.focus(); selFbSaveName.select(); } catch (eFocus) { /* ignore */ } } }); } function closeSelectionFileBrowser() { if (!selFileBrowserPanel) return; selFileBrowserPanel.hidden = true; selFileBrowserPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("cryo-explorer-save-modal-open"); selFbCurrentDir = null; clearPendingRegionSave(); var restore = selectionSaveModalTriggerEl; selectionSaveModalTriggerEl = null; if (restore && typeof restore.focus === "function") { try { restore.focus(); } catch (eRestore) { /* ignore */ } } } function normalizedSelectionBasename() { var raw = String((selFbSaveName && selFbSaveName.value) || "").trim(); if (!raw) raw = "indices.pkl"; if (raw.indexOf("/") >= 0 || raw.indexOf("\\") >= 0) return null; if (!/\.pkl$/i.test(raw)) raw += ".pkl"; var base = raw.replace(/\.pkl$/i, ""); return base || "indices"; } function loadSelectionFileBrowserDir(dir) { if (!selFbList || !selFbPath) return; selFbList.innerHTML = "
  • Loading…
  • "; var q = dir ? ("?dir=" + encodeURIComponent(dir)) : ""; fetch("{{ url_for('api_list_server_files') }}" + q) .then(function(r) { return r.json(); }) .then(function(j) { if (!j.ok) { selFbList.innerHTML = "
  • " + (j.error || "Error") + "
  • "; return; } selFbCurrentDir = j.dir; selFbPath.textContent = j.dir; selFbPath.title = j.dir; selFbList.innerHTML = ""; if (!j.entries || !j.entries.length) { selFbList.innerHTML = "
  • No entries here
  • "; return; } j.entries.forEach(function(ent) { var li = document.createElement("li"); var icon = document.createElement("span"); icon.className = "fb-icon"; icon.textContent = ent.type === "dir" ? "\uD83D\uDCC1" : "\uD83D\uDCC4"; var name = document.createElement("span"); name.className = "fb-name"; name.textContent = ent.name; li.appendChild(icon); li.appendChild(name); if (ent.type === "dir") { li.addEventListener("click", function() { loadSelectionFileBrowserDir(j.dir + "/" + ent.name); }); } else { li.addEventListener("click", function() { if (selFbSaveName) selFbSaveName.value = ent.name; }); } selFbList.appendChild(li); }); }) .catch(function() { selFbList.innerHTML = "
  • Could not list directory
  • "; }); } function saveSelectionViaBrowser() { var status = document.getElementById("save-status"); if (!selFbCurrentDir) { status.textContent = "Choose a destination folder first."; return; } var basename = normalizedSelectionBasename(); if (!basename) { status.textContent = "Filename cannot contain path separators."; return; } var payload = { force: false, sel_dir: selFbCurrentDir, basename: basename }; if (pendingRegionSaveRows && pendingRegionSaveRows.length) { payload.rows = pendingRegionSaveRows.slice(); payload.save_inverse = false; } clearPendingRegionSave(); closeSelectionFileBrowser(); postSaveSelection(payload); } function postSaveSelection(payload) { var status = document.getElementById("save-status"); status.textContent = ""; status.title = ""; var rows = payload.rows != null ? payload.rows : saveSelectionRows; if (!rows || !rows.length) { status.textContent = "Select particles (plot, color histogram, or discrete toggles) before saving."; status.title = status.textContent; return; } payload.rows = rows; if (payload.save_inverse == null) { payload.save_inverse = saveInverseChecked(); } fetch("{{ url_for('api_save_selection') }}", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }) .then(function(r) { return r.json().then(function(j) { return { ok: r.ok, j: j }; }); }) .then(function(res) { if (!res.ok) { status.textContent = res.j.error || "Save failed."; status.title = status.textContent; return; } var msg = "Saved " + res.j.n_selected + " indices to " + res.j.path; if (res.j.inverse_path) { msg += "; inverse to " + res.j.inverse_path; } status.textContent = msg + "."; status.title = status.textContent; }) .catch(function() { status.textContent = "Request failed."; status.title = status.textContent; }); } document.getElementById("save-indices-pkl").addEventListener("click", function() { postSaveSelection({ force: true, sel_dir: "", basename: "indices" }); }); if (clearExplorerSelectionBtn) { clearExplorerSelectionBtn.addEventListener("click", function() { clearExplorerSelection(); }); } document.getElementById("save-indices-custom").addEventListener("click", function() { if (selFbSaveName) selFbSaveName.value = "indices.pkl"; openSelectionFileBrowser(workdirForSaveHint || ""); }); if (selFbUp) { selFbUp.addEventListener("click", function() { if (selFbCurrentDir) { var parent = selFbCurrentDir.replace(/\/[^\/]*\/?$/, "") || "/"; loadSelectionFileBrowserDir(parent); } }); } if (selFbCancel) { selFbCancel.addEventListener("click", function() { closeSelectionFileBrowser(); }); } if (selFbModalBackdrop) { selFbModalBackdrop.addEventListener("click", function() { closeSelectionFileBrowser(); }); } document.addEventListener("keydown", onSelectionSaveModalKeydown); if (selFbSaveBtn) { selFbSaveBtn.addEventListener("click", function() { saveSelectionViaBrowser(); }); } if (selFbSaveName) { selFbSaveName.addEventListener("keydown", function(evt) { if (evt.key === "Enter") { evt.preventDefault(); saveSelectionViaBrowser(); } }); } explorerColorLegend = new CryoColorCovariateLegend({ legendContextUrl: "{{ url_for('api_covariate_legend_context') }}", getLegendContextData: explorerLegendContextData, getDiscreteColorOverrides: function() { return explorerDiscreteLabelColors; }, discreteChipMatplotlibBlend: false, covariateDisplayMap: covariateDisplayMap, regionHeadingEl: document.getElementById("color-legend-region-heading"), regionHeadingContinuousText: "Range selection", discreteCollapseHeading: false, getColorColumn: function() { return sc.value; }, getPaletteName: selectedScatterPalette, panel: colorHistPanel, continuousWrap: colorContinuousWrap, discreteWrap: colorDiscreteWrap, histDiv: colorHistDiv, thresholdUseMax: colorHistModeCheck, thresholdModeCaption: colorHistModeCheckCaption, thresholdStatus: colorThresholdStatus, discreteSwitches: colorDiscreteSwitches, invertHeadingSlotEl: document.getElementById("color-legend-region-invert-slot"), invertBtn: btnDiscreteInvertSelection, vertical: false, histPlotVertical: false, discreteCheckedDefault: false, allDiscreteSelectedIsNull: false, noDiscreteSelectionText: "no selection made!", thresholdEmptyStatusText: "no selection made!", notifyOnRefresh: false, onContinuousHistogramLayout: onExplorerContinuousHistogramLayout, onDiscreteLayout: syncParticleExplorerRowExpandForColorHist, onModeChange: function(mode) { var prev = explorerCcLegendPrevMode; explorerCcLegendPrevMode = mode; if (prev === "discrete" && mode === "continuous") { if (activeSelectionMode === "discrete") { applyRowsSelection([], "Covariate change"); } } else if (prev === "continuous" && mode === "discrete") { if (activeSelectionMode === "threshold") { applyRowsSelection([], "Covariate change"); } colorThresholdLevel = null; colorThresholdRange = null; colorThresholdColorCol = null; if (colorThresholdStatus) colorThresholdStatus.textContent = ""; syncColorHistModeCheckboxCaption(); } if (mode === "discrete") { setExplorerLegendDiscreteKeysForSelection(); if (prev !== "discrete" && explorerColorLegend) { explorerColorLegend.setDiscretePanelCollapsed(false); } } syncScatterPaletteFieldset(); syncExplorerMontagePanelReadiness(); }, onDiscreteColorChange: function(evt) { if (!evt || !evt.catKey || !evt.hex) return; var ck = String(evt.catKey); if (ck.indexOf("__cdrgnScatterRegion:") === 0) { var rj = parseInt(ck.slice("__cdrgnScatterRegion:".length), 10); if (isFinite(rj) && committedScatterRegions[rj]) { committedScatterRegions[rj].colorHex = String(evt.hex).toLowerCase(); syncCommittedScatterRegionOverlays({ clearSelections: false }); } return; } explorerDiscreteLabelColors[ck] = String(evt.hex); triggerRedraw(true); }, onFilterChange: handleExplorerLegendFilter }); [sx, sy].forEach(function(s) { s.addEventListener("change", function() { triggerRedraw(false); }); }); sc.addEventListener("change", function() { colorThresholdLevel = null; colorThresholdRange = null; colorThresholdColorCol = null; explorerDiscreteLabelColors = {}; if (activeSelectionMode === "discrete" || activeSelectionMode === "threshold") { applyRowsSelection([], "Covariate change"); } triggerRedraw(true); syncExplorerMontagePanelReadiness(); }); document.querySelectorAll("input[name=\"scatter_palette\"]").forEach(function(inp) { inp.addEventListener("change", function() { triggerRedraw(true); }); }); if (scatterPaletteToggle) { scatterPaletteToggle.addEventListener("click", function() { var expanded = scatterPaletteToggle.getAttribute("aria-expanded") === "true"; scatterPaletteToggle.setAttribute("aria-expanded", expanded ? "false" : "true"); syncScatterPaletteFieldset(); }); } wireExplorerPanelToggle(cachePanelToggle, cachePanelBody, null, true); wireExplorerPanelToggle( colorSelectionPanelToggle, colorSelectionPanelBody, colorSelectionPanelActivated, colorSelectionPanelActivated(), function(open) { if (open) syncParticleExplorerRowExpandForColorHist(); } ); if (volumesPanelToggle && volumesPanelBody) { wireExplorerPanelToggle(volumesPanelToggle, volumesPanelBody, volumesPanelActivated, false, function(open) { if (open) ensureLoadImagesPanelOpen(); }); } if (imageGridMenuToggle) { imageGridMenuToggle.addEventListener("click", function() { if (!imageGridPanelActivated()) return; setImageGridMenuOpen(!imageGridMenuIsOpen()); }); } syncExplorerMontagePanelReadiness(); if (btnMontageResampleCache) { btnMontageResampleCache.addEventListener("click", function() { if (btnMontageResampleCache.disabled) return; showRandomPreloaded(); }); } updateParticleSelFieldset(); if (showVolumeExplorer) syncVolumeExploreButtons(); syncScatterControlsAlignment(); equalizeMontageActionButtonWidths(); syncMontageResampleFromCacheButton(); syncMontagePanelGridSplit(); scheduleSyncMontageGridRegionLayout(); window.addEventListener("resize", equalizeMontageActionButtonWidths); window.addEventListener("resize", scheduleFitMontageResampleCacheButton); window.addEventListener("resize", scheduleSyncMontageGridRegionLayout); window.addEventListener("resize", scheduleScatterRegionLabelChipsSync); syncScatterPaletteFieldset(); initializeExplorerView();