// Particle explorer inline script fragment (sidebar + scatter viewport).
var cols = {{ numeric_cols | tojson }};
var covariateDisplayMap = {{ covariate_display_map | tojson }};
var dx = {{ default_x | tojson }};
var dy = {{ default_y | tojson }};
var initialRows = {{ initial_rows | tojson }};
var totalParticles = {{ total_particles | tojson }};
var workdirForSaveHint = {{ workdir | tojson }};
var showVolumeExplorer = {{ show_volume_explorer | default(false) | tojson }};
var preloadCpus = {{ preload_cpus | tojson }};
/** Match ``api_scatter`` when ``explorer_scatter=1`` (``--filter-max`` or 200k default). */
var explorerScatterMaxPoints = {{ explorer_scatter_max_points | tojson }};
var explorerScatterCapFromEnv = {{ explorer_scatter_cap_from_env | tojson }};
/** While montage preload overlay is up: show this requested ``cache_size`` in the number field. */
var preloadRequestedCacheTarget = null;
/** Original selection-add count; frozen label on the button until that preload finishes. */
var selectionCacheAdditionFreezeCount = null;
var intFormatter = new Intl.NumberFormat("en-US", {maximumFractionDigits: 0});
function fmtInt(value) {
var num = Number(value);
if (!Number.isFinite(num)) return "0";
return intFormatter.format(Math.round(num));
}
/** Thousands-formatted count for scatter region badges (e.g. ``4,678 pts``). */
function fmtRegionPtsLabel(n) {
return fmtInt(n) + " pts";
}
var preselectRowsForNextFetch = (initialRows && initialRows.length && initialRows.length <= 5000)
? initialRows.slice() : [];
var saveSelectionRows = [];
var selectionTooltipText = "";
var activeSelectionMode = "";
var imagesViewEnabled = false;
var particleSelFs = document.getElementById("particle-sel-fieldset");
var clearExplorerSelectionBtn = document.getElementById("clear-explorer-selection");
var selCountRowEl = document.getElementById("sel-count-row");
var selCountEl = document.getElementById("sel-count");
var selCountFootnoteEl = document.getElementById("sel-count-footnote");
var selPieEl = document.getElementById("sel-pie");
var selPiePctEl = document.getElementById("sel-pie-pct");
var imageCacheProgressEl = document.getElementById("image-cache-progress");
var imageCacheProgressBarEl = document.getElementById("image-cache-progress-bar");
var imageCacheProgressLabelEl = document.getElementById("image-cache-progress-label");
var montageGridRegionEl = document.querySelector(".cryo-dash-montage-grid-region");
var montagePreloadOverlayEl = document.getElementById("montage-preload-overlay");
var montagePreloadOverlayLabel = document.getElementById("montage-preload-overlay-label");
var btnExpandCache = document.getElementById("btn-expand-cache");
var btnViewImages = document.getElementById("btn-view-images");
var btnVolumeGenerate = document.getElementById("btn-volume-generate");
var btnVolumeAnimate = document.getElementById("btn-volume-animate");
var volumeStatusEl = document.getElementById("volume-status");
var volumeStatusProgressEl = document.getElementById("volume-status-progress");
var gridSizeSelect = document.getElementById("grid-size");
var cachePanelToggle = document.getElementById("cache-panel-toggle");
var cachePanelBody = document.getElementById("cryo-explorer-cache-panel-body");
var colorSelectionPanelToggle = document.getElementById("color-selection-panel-toggle");
var colorSelectionPanelBody = document.getElementById("color-selection-panel-body");
var colorSelectionInactiveNote = document.getElementById("color-selection-inactive-note");
var volumesPanelToggle = document.getElementById("volumes-panel-toggle");
var volumesPanelBody = document.getElementById("volumes-panel-body");
var volumesInactiveNote = document.getElementById("volumes-inactive-note");
var colorSelectionPanelShell = document.getElementById("color-selection-panel-shell");
var volumesPanelShell = document.getElementById("volumes-panel-shell");
var imageGridPanelShell = document.getElementById("image-grid-panel-shell");
var imageGridMenuToggle = document.getElementById("image-grid-menu-toggle");
var imageGridToolbarControls = document.getElementById("image-grid-toolbar-controls");
var btnMontageResampleCache = document.getElementById("btn-montage-resample-cache");
var montageResampleFitTimer = null;
var imageGridMenuBody = document.getElementById("image-grid-menu-body");
var imageGridInactiveNote = document.getElementById("image-grid-inactive-note");
var scatterControlsCard = document.getElementById("scatter-controls-card");
var scatterControlsSide = scatterControlsCard ? scatterControlsCard.closest(".cryo-dash-side") : null;
var scatterPlotStack = document.getElementById("scatter-plot-stack");
var boxSelectTooltipEl = document.getElementById("box-select-tooltip");
var montageActionsEl = document.getElementById("montage-actions");
var montageAsideEl = document.querySelector(".cryo-dash-page--particle-explorer .cryo-dash-montage");
var montageDisplayMode = "images";
var volumeStaticDataUrls = null;
var volumeStaticKey = null;
var volumeGifDataUrls = null;
var volumeGifsKey = null;
var EXPLORER_CHIMERAX_CPUS = 16;
/** Must match ``EXPLORER_GIF_DURATION_S`` in ``chimerax_animation.py`` (seconds). */
var EXPLORER_GIF_LOOP_MS = 4000;
var volumeGifLeaderStartMs = null;
var volumeGifSyncTimerIds = [];
var lastMontageRows = [];
var volumeCacheId = null;
var volumeAnimateGeneration = 0;
var colorSelectionPanelWasActivated = false;
function explorerPanelIsOpen(toggleEl) {
return !!(toggleEl && toggleEl.getAttribute("aria-expanded") === "true");
}
function syncExplorerPanelInactiveNote(inactiveNoteEl, activated) {
if (!inactiveNoteEl) return;
inactiveNoteEl.hidden = !!activated;
}
function syncExplorerPanelShellInactive(shellEl, activated) {
if (!shellEl) return;
shellEl.classList.toggle("cryo-explorer-panel-shell--inactive", !activated);
}
function syncExplorerPanelToggleInactive(toggleEl, shellEl, activated) {
if (!toggleEl) return;
if (shellEl) syncExplorerPanelShellInactive(shellEl, activated);
toggleEl.setAttribute("aria-disabled", activated ? "false" : "true");
}
function colorSelectionPanelActivated() {
return !!(sc && sc.value && sc.value !== "none");
}
function imageGridPanelActivated() {
return !!(preloaded || imagesViewEnabled);
}
function volumesPanelActivated() {
return imageGridPanelActivated() && !!(lastMontageRows && lastMontageRows.length);
}
function syncExplorerMontagePanelReadiness() {
var colorAct = colorSelectionPanelActivated();
syncExplorerPanelToggleInactive(
colorSelectionPanelToggle,
colorSelectionPanelShell,
colorAct
);
syncExplorerPanelInactiveNote(colorSelectionInactiveNote, colorAct);
if (!colorAct && explorerPanelIsOpen(colorSelectionPanelToggle)) {
setExplorerPanelOpen(colorSelectionPanelToggle, colorSelectionPanelBody, false);
} else if (
colorAct
&& !colorSelectionPanelWasActivated
&& colorSelectionPanelToggle
&& colorSelectionPanelBody
) {
setExplorerPanelOpen(colorSelectionPanelToggle, colorSelectionPanelBody, true);
if (typeof syncParticleExplorerRowExpandForColorHist === "function") {
syncParticleExplorerRowExpandForColorHist();
}
}
colorSelectionPanelWasActivated = colorAct;
var gridAct = imageGridPanelActivated();
syncExplorerPanelToggleInactive(imageGridMenuToggle, imageGridPanelShell, gridAct);
syncExplorerPanelInactiveNote(imageGridInactiveNote, gridAct);
if (!gridAct && imageGridMenuIsOpen()) {
setImageGridMenuOpen(false);
}
if (volumesPanelToggle && volumesPanelBody) {
var volAct = volumesPanelActivated();
syncExplorerPanelToggleInactive(volumesPanelToggle, volumesPanelShell, volAct);
syncExplorerPanelInactiveNote(volumesInactiveNote, volAct);
if (!volAct && explorerPanelIsOpen(volumesPanelToggle)) {
setExplorerPanelOpen(volumesPanelToggle, volumesPanelBody, false);
}
}
}
function setExplorerPanelOpen(toggleEl, bodyEl, open) {
if (!toggleEl || !bodyEl) return;
var isOpen = !!open;
toggleEl.setAttribute("aria-expanded", isOpen ? "true" : "false");
bodyEl.hidden = !isOpen;
}
function ensureLoadImagesPanelOpen() {
if (cachePanelToggle && cachePanelBody && !explorerPanelIsOpen(cachePanelToggle)) {
setExplorerPanelOpen(cachePanelToggle, cachePanelBody, true);
}
}
function wireExplorerPanelToggle(toggleEl, bodyEl, isReadyFn, defaultOpen, onChange) {
if (!toggleEl || !bodyEl) return;
setExplorerPanelOpen(toggleEl, bodyEl, !!defaultOpen);
toggleEl.addEventListener("click", function() {
if (isReadyFn && !isReadyFn()) return;
var next = !explorerPanelIsOpen(toggleEl);
setExplorerPanelOpen(toggleEl, bodyEl, next);
if (onChange) onChange(next);
});
}
function imageGridMenuIsOpen() {
return !!(imageGridMenuToggle && imageGridMenuToggle.getAttribute("aria-expanded") === "true");
}
function syncMontagePanelGridSplit() {
if (!montageAsideEl) return;
montageAsideEl.classList.toggle(
"cryo-dash-montage--image-grid-expanded",
imageGridMenuIsOpen()
);
}
function setImageGridMenuOpen(open) {
var isOpen = !!open;
if (isOpen) ensureLoadImagesPanelOpen();
if (imageGridMenuToggle) imageGridMenuToggle.setAttribute("aria-expanded", isOpen ? "true" : "false");
if (imageGridToolbarControls) imageGridToolbarControls.hidden = !isOpen;
if (imageGridMenuBody) imageGridMenuBody.hidden = !isOpen;
syncMontagePanelGridSplit();
if (!isOpen) {
updateMontage([]);
} else if (imagesViewEnabled && preloaded && preloaded.traceMap) {
showRandomPreloaded();
}
syncHighlightTraceAnnotations();
if (isOpen) {
requestAnimationFrame(function() {
requestAnimationFrame(function() {
syncMontageResampleFromCacheButton();
scheduleFitMontageResampleCacheButton();
scheduleSyncMontageGridRegionLayout();
});
});
} else {
scheduleSyncMontageGridRegionLayout();
}
}
function syncImageGridInactiveNote() {
syncExplorerMontagePanelReadiness();
}
function montageResampleFromCachePool() {
if (!preloaded || !preloaded.traceMap || !preloaded.traceMap.length) return null;
return preloaded.traceMap;
}
var RESAMPLE_CACHE_BTN_LINE_HEIGHT = 1;
var RESAMPLE_CACHE_BTN_MAX_LINES = 2;
var RESAMPLE_CACHE_BTN_TOOLBAR_WIDTH_FRAC = 0.23;
var resampleCacheMeasureCanvas = null;
function resampleCacheToolbarButtonWidthPx() {
if (!imageGridToolbarControls) return 0;
var tw = imageGridToolbarControls.clientWidth;
if (tw < 8) return 0;
return Math.max(1, Math.floor(tw * RESAMPLE_CACHE_BTN_TOOLBAR_WIDTH_FRAC));
}
function resampleCacheLabelLineEls(labelEl) {
if (!labelEl) return [];
var lines = labelEl.querySelectorAll(".cryo-image-grid-resample-btn__line");
if (lines.length) return Array.prototype.slice.call(lines);
return [labelEl];
}
function resampleCacheBtnFontSpec(btnEl) {
var btnStyle = window.getComputedStyle(btnEl);
var fontSize = parseFloat(btnStyle.fontSize) || 12;
var fontWeight = btnStyle.fontWeight || "600";
var fontFamily = btnStyle.fontFamily || "sans-serif";
return { fontSize: fontSize, fontWeight: fontWeight, fontFamily: fontFamily };
}
function measureResampleCacheTextWidth(text, fontPx, fontWeight, fontFamily) {
if (!text) return 0;
if (!resampleCacheMeasureCanvas) {
resampleCacheMeasureCanvas = document.createElement("canvas");
}
var ctx = resampleCacheMeasureCanvas.getContext("2d");
if (!ctx) return text.length * fontPx * 0.55;
ctx.font = fontWeight + " " + fontPx + "px " + fontFamily;
return ctx.measureText(text).width;
}
function applyResampleCacheLabelFontPx(labelEl, lineEls, fontPx) {
var px = Math.max(4, Math.round(fontPx)) + "px";
var lh = String(RESAMPLE_CACHE_BTN_LINE_HEIGHT);
labelEl.style.fontSize = px;
labelEl.style.lineHeight = lh;
labelEl.style.maxHeight = "";
for (var i = 0; i < lineEls.length; i++) {
lineEls[i].style.fontSize = px;
lineEls[i].style.lineHeight = lh;
}
}
function resampleCacheLabelFits(btnEl, labelEl, lineEls, fontPx, maxW, maxH, fontWeight, fontFamily) {
applyResampleCacheLabelFontPx(labelEl, lineEls, fontPx);
var textW = 0;
for (var i = 0; i < lineEls.length; i++) {
var lineText = (lineEls[i].textContent || "").trim();
textW = Math.max(textW, measureResampleCacheTextWidth(lineText, fontPx, fontWeight, fontFamily));
}
var lineCount = Math.max(1, lineEls.length);
var textH = fontPx * RESAMPLE_CACHE_BTN_LINE_HEIGHT * lineCount;
if (textW > maxW + 0.1) return false;
if (textH > maxH + 0.1) return false;
return true;
}
function fitMontageResampleCacheButtonFont() {
if (!btnMontageResampleCache || !imageGridToolbarControls || imageGridToolbarControls.hidden) return;
var label = btnMontageResampleCache.querySelector(".cryo-image-grid-resample-btn__label");
if (!label) return;
var lineEls = resampleCacheLabelLineEls(label);
var btnW = resampleCacheToolbarButtonWidthPx();
if (btnW >= 8) {
btnMontageResampleCache.style.flex = "0 0 auto";
btnMontageResampleCache.style.width = btnW + "px";
btnMontageResampleCache.style.maxWidth = btnW + "px";
} else {
btnMontageResampleCache.style.flex = "";
btnMontageResampleCache.style.maxWidth = "";
btnMontageResampleCache.style.width = "";
}
label.style.fontSize = "";
label.style.maxHeight = "";
label.style.lineHeight = "";
btnMontageResampleCache.style.height = "";
for (var li = 0; li < lineEls.length; li++) {
lineEls[li].style.fontSize = "";
lineEls[li].style.lineHeight = "";
}
void btnMontageResampleCache.offsetWidth;
var btnStyle = window.getComputedStyle(btnMontageResampleCache);
var bw = btnMontageResampleCache.clientWidth;
if (bw < 10) return;
var padX = (parseFloat(btnStyle.paddingLeft) || 0) + (parseFloat(btnStyle.paddingRight) || 0);
var padY = (parseFloat(btnStyle.paddingTop) || 0) + (parseFloat(btnStyle.paddingBottom) || 0);
var maxHCap = parseFloat(btnStyle.maxHeight);
if (!(maxHCap > 0)) {
maxHCap = 26.4;
}
var maxW = Math.max(1, bw - padX);
var maxH = Math.max(1, maxHCap - padY);
var fontSpec = resampleCacheBtnFontSpec(btnMontageResampleCache);
var hi = Math.min(
72,
Math.max(4, Math.floor(maxH / (RESAMPLE_CACHE_BTN_LINE_HEIGHT * RESAMPLE_CACHE_BTN_MAX_LINES)))
);
var lo = 4;
var best = lo;
while (lo <= hi) {
var mid = Math.floor((lo + hi) / 2);
if (resampleCacheLabelFits(
btnMontageResampleCache,
label,
lineEls,
mid,
maxW,
maxH,
fontSpec.fontWeight,
fontSpec.fontFamily
)) {
best = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
while (best > 4 && !resampleCacheLabelFits(
btnMontageResampleCache,
label,
lineEls,
best,
maxW,
maxH,
fontSpec.fontWeight,
fontSpec.fontFamily
)) {
best -= 1;
}
applyResampleCacheLabelFontPx(label, lineEls, best);
var lineCount = Math.max(1, lineEls.length);
var contentH = best * RESAMPLE_CACHE_BTN_LINE_HEIGHT * lineCount;
var fitH = Math.ceil(contentH + padY);
if (fitH > Math.ceil(maxHCap)) {
fitH = Math.ceil(maxHCap);
}
if (fitH < 1) {
fitH = 1;
}
btnMontageResampleCache.style.height = fitH + "px";
void btnMontageResampleCache.offsetWidth;
}
function syncMontageResampleFromCacheButton() {
if (!btnMontageResampleCache) return;
var pool = montageResampleFromCachePool();
var poolOk = !!(pool && pool.length);
btnMontageResampleCache.disabled = !!(gridSizeSelect && gridSizeSelect.disabled) || !poolOk;
scheduleFitMontageResampleCacheButton();
}
function scheduleFitMontageResampleCacheButton() {
if (montageResampleFitTimer) clearTimeout(montageResampleFitTimer);
montageResampleFitTimer = setTimeout(function() {
montageResampleFitTimer = null;
requestAnimationFrame(function() {
requestAnimationFrame(fitMontageResampleCacheButtonFont);
});
}, 50);
}
function montageRowsOrderKey() {
return lastMontageRows.length ? lastMontageRows.join(",") : "";
}
function montagePreloadOverlayIsShown() {
return !!(montagePreloadOverlayEl &&
montagePreloadOverlayEl.classList.contains("cryo-plot-rendering-overlay--show"));
}
function setMontagePreloadOverlay(on, label) {
if (!montagePreloadOverlayEl) return;
if (label && montagePreloadOverlayLabel) {
montagePreloadOverlayLabel.textContent = label;
}
montagePreloadOverlayEl.classList.toggle("cryo-plot-rendering-overlay--show", !!on);
montagePreloadOverlayEl.setAttribute("aria-hidden", on ? "false" : "true");
if (!on) preloadRequestedCacheTarget = null;
}
function setVolumeStatus(msg, showProgress) {
if (volumeStatusEl) volumeStatusEl.textContent = msg || "";
if (volumeStatusProgressEl) volumeStatusProgressEl.hidden = !showProgress;
}
function invalidateVolumeArtifacts() {
if (!showVolumeExplorer) return;
volumeStaticDataUrls = null;
volumeStaticKey = null;
volumeGifDataUrls = null;
volumeGifsKey = null;
volumeGifLeaderStartMs = null;
clearVolumeGifSyncTimers();
volumeCacheId = null;
volumeAnimateGeneration++;
montageDisplayMode = "images";
setVolumeStatus("", false);
syncVolumeExploreButtons();
}
function syncVolumeExploreButtons() {
if (!showVolumeExplorer) return;
var hasGrid = lastMontageRows && lastMontageRows.length > 0;
var key = montageRowsOrderKey();
var hasStatic = volumeStaticDataUrls && volumeStaticKey === key && volumeStaticDataUrls.length;
var hasCache = !!(volumeCacheId && volumeStaticKey === key);
if (btnVolumeGenerate) {
btnVolumeGenerate.disabled = !plotHasScatterData();
}
if (btnVolumeAnimate) {
btnVolumeAnimate.disabled = !hasGrid || !hasStatic || !hasCache;
btnVolumeAnimate.title = btnVolumeAnimate.disabled
? "Generate volumes for the current image grid before animating."
: "";
}
}
function showImagesInMontageFromCache() {
if (!preloaded || !lastMontageRows.length) return;
for (var i = 0; i < montageCells.length; i++) {
if (i < lastMontageRows.length) {
var r = lastMontageRows[i];
var src = preloaded.rowToSrc.get(r);
if (src) montageCells[i].img.src = src;
}
}
montageDisplayMode = "images";
syncVolumeExploreButtons();
}
function clearVolumeGifSyncTimers() {
for (var ti = 0; ti < volumeGifSyncTimerIds.length; ti++) {
clearTimeout(volumeGifSyncTimerIds[ti]);
}
volumeGifSyncTimerIds = [];
}
function preloadMontageGifUrl(url) {
return new Promise(function(resolve, reject) {
if (!url) {
resolve();
return;
}
var img = new Image();
img.onload = function() { resolve(); };
img.onerror = function() { reject(new Error("GIF preload failed")); };
img.src = url;
});
}
function msUntilNextGifLoopPhase(leaderStartMs) {
var elapsed = (Date.now() - leaderStartMs) % EXPLORER_GIF_LOOP_MS;
return elapsed === 0 ? 0 : EXPLORER_GIF_LOOP_MS - elapsed;
}
/** Cell 0: show GIF immediately and record loop phase for followers. */
function startMontageGifLeader(cellIndex, url, generation) {
return preloadMontageGifUrl(url).then(function() {
if (generation != null && generation !== volumeAnimateGeneration) return;
return new Promise(function(resolve) {
requestAnimationFrame(function() {
if (generation != null && generation !== volumeAnimateGeneration) {
resolve();
return;
}
if (cellIndex < montageCells.length) montageCells[cellIndex].img.src = url;
volumeGifLeaderStartMs = Date.now();
montageDisplayMode = "volumes";
syncVolumeExploreButtons();
resolve();
});
});
});
}
/** Later cells: wait until the leader's next frame-0 tick, then assign ``src``. */
function showMontageCellGifSynced(cellIndex, url, leaderStartMs, generation) {
return preloadMontageGifUrl(url).then(function() {
if (generation != null && generation !== volumeAnimateGeneration) return;
var delay = msUntilNextGifLoopPhase(leaderStartMs);
return new Promise(function(resolve) {
var tid = setTimeout(function() {
requestAnimationFrame(function() {
if (generation != null && generation !== volumeAnimateGeneration) {
resolve();
return;
}
if (cellIndex < montageCells.length) montageCells[cellIndex].img.src = url;
montageDisplayMode = "volumes";
syncVolumeExploreButtons();
resolve();
});
}, delay);
volumeGifSyncTimerIds.push(tid);
});
});
}
function showAllCachedVolumeGifsPhaseSynced(urls, generation) {
var n = Math.min(
montageCells.length,
lastMontageRows.length,
urls ? urls.length : 0
);
if (!n || !urls[0]) return Promise.resolve();
return startMontageGifLeader(0, urls[0], generation).then(function() {
if (generation != null && generation !== volumeAnimateGeneration) return;
var leader = volumeGifLeaderStartMs;
if (leader == null) return;
var jobs = [];
for (var ci = 1; ci < n; ci++) {
if (urls[ci]) jobs.push(showMontageCellGifSynced(ci, urls[ci], leader, generation));
}
return Promise.all(jobs);
});
}
function showVolumesInMontage() {
if (!lastMontageRows.length) return;
var key = montageRowsOrderKey();
if (!(volumeStaticDataUrls && volumeStaticKey === key && volumeStaticDataUrls.length)) return;
if (volumeGifDataUrls && volumeGifsKey === key) {
showAllCachedVolumeGifsPhaseSynced(volumeGifDataUrls).catch(function() {
setVolumeStatus("Could not load volume GIFs.", false);
syncVolumeExploreButtons();
});
return;
}
var n = Math.min(montageCells.length, lastMontageRows.length, volumeStaticDataUrls.length);
for (var i = 0; i < montageCells.length; i++) {
if (i < n) {
var g = volumeGifDataUrls && volumeGifDataUrls[i];
var s = volumeStaticDataUrls[i];
montageCells[i].img.src = g || s || "";
} else {
montageCells[i].img.src = "";
}
}
montageDisplayMode = "volumes";
syncVolumeExploreButtons();
}
function updateParticleSelFieldset() {
var n = saveSelectionRows.length;
var nFmt = fmtInt(n);
var totalFmt = fmtInt(totalParticles);
var plotCap = scatterSubsetRowCount();
var plotCapFmt = fmtInt(plotCap);
var curtailed = totalParticles > plotCap;
if (selCountEl) {
selCountEl.textContent = "Selected: " + nFmt + "/" + totalFmt + " particles";
}
if (selCountFootnoteEl) {
if (curtailed) {
var foot = explorerScatterCapFromEnv
? "Scatter only shows " + plotCapFmt + " particles due to `--filter-max`."
: "Scatter only shows " + plotCapFmt + " particles (default cap).";
selCountFootnoteEl.textContent = foot;
selCountFootnoteEl.hidden = false;
} else {
selCountFootnoteEl.textContent = "";
selCountFootnoteEl.hidden = true;
}
}
var frac = 0;
var pctStr = "0.0%";
if (totalParticles > 0) {
frac = Math.min(1, Math.max(0, n / totalParticles));
pctStr = (100 * frac).toFixed(1) + "%";
}
if (selPiePctEl) selPiePctEl.textContent = pctStr;
if (typeof applySelectionPieVisual === "function") {
applySelectionPieVisual(frac, pctStr);
} else if (selPieEl) {
selPieEl.style.setProperty("--sel-frac", String(frac));
selPieEl.title = pctStr + " of dataset particles selected";
}
if (selCountRowEl) {
var scatterHint = "";
if (curtailed) {
scatterHint = "Lasso and box apply only to plotted points.";
}
if (selectionTooltipText && scatterHint) {
selCountRowEl.title = selectionTooltipText + " — " + scatterHint;
} else {
selCountRowEl.title = selectionTooltipText || scatterHint;
}
var aria = "Selected " + nFmt + " of " + totalFmt + " particles, " + pctStr;
if (curtailed) {
aria += "; scatter " + plotCapFmt + " of " + totalFmt;
}
selCountRowEl.setAttribute("aria-label", aria);
}
if (particleSelFs) particleSelFs.disabled = (n === 0);
if (clearExplorerSelectionBtn) {
var nRegClear = 0;
try {
if (typeof committedScatterRegions !== "undefined" && committedScatterRegions
&& committedScatterRegions.length) {
nRegClear = committedScatterRegions.length;
}
} catch (eReg) { /* ignore */ }
var hasLegendFilter = colorThresholdLevel != null || colorThresholdRange != null;
var canClear = n > 0 || nRegClear > 0 || hasLegendFilter;
clearExplorerSelectionBtn.disabled = !canClear;
clearExplorerSelectionBtn.title = canClear
? "Clear plot selection, geometric regions, and colour-threshold filters."
: "No selection or region filters to clear.";
}
syncCacheSelectionUncachedButton();
syncLeadSelectionModeHighlight();
}
function countUncachedSelectionRows() {
if (!saveSelectionRows || !saveSelectionRows.length) return 0;
var n = 0;
for (var i = 0; i < saveSelectionRows.length; i++) {
var r = saveSelectionRows[i];
if (!preloaded || !preloaded.rows || !preloaded.rows.has(r)) n++;
}
return n;
}
function syncCacheSelectionUncachedButton() {
if (!btnCacheSelectionUncached) return;
if (selectionCacheAdditionFreezeCount != null && !montagePreloadOverlayIsShown()) {
selectionCacheAdditionFreezeCount = null;
btnCacheSelectionUncached.classList.remove("cryo-explorer-cache-selection-btn--busy");
}
var busy = montagePreloadOverlayIsShown();
var nUnc = countUncachedSelectionRows();
if (selectionCacheAdditionFreezeCount != null && busy) {
var nf = fmtInt(selectionCacheAdditionFreezeCount);
var addingText = "Adding " + nf + " selection images to the cache";
var addingHtml = "Adding " + nf + "
selection images to the cache";
btnCacheSelectionUncached.disabled = true;
btnCacheSelectionUncached.innerHTML = addingHtml;
btnCacheSelectionUncached.setAttribute("aria-label", addingText);
btnCacheSelectionUncached.title = "Loading thumbnails for " + nf + " selected particle"
+ (selectionCacheAdditionFreezeCount === 1 ? "" : "s") + "…";
btnCacheSelectionUncached.classList.add("cryo-explorer-cache-selection-btn--busy");
return;
}
btnCacheSelectionUncached.classList.remove("cryo-explorer-cache-selection-btn--busy");
var can = !!(
saveSelectionRows
&& saveSelectionRows.length
&& nUnc > 0
&& totalParticles
&& plotHasScatterData()
&& !busy
);
btnCacheSelectionUncached.disabled = !can;
var addText = "Add " + fmtInt(nUnc) + " selection images to cache";
btnCacheSelectionUncached.innerHTML = "Add " + fmtInt(nUnc) + "
selection images to cache";
btnCacheSelectionUncached.setAttribute("aria-label", addText);
btnCacheSelectionUncached.title = !saveSelectionRows || !saveSelectionRows.length
? "Select particles on the plot or via color controls first."
: nUnc === 0
? "All selected particles are already in the image cache."
: "Fetch thumbnails for " + fmtInt(nUnc) + " selected particle"
+ (nUnc === 1 ? "" : "s") + " not yet cached.";
}
function loadUncachedSelectionIntoCache() {
if (!saveSelectionRows || !saveSelectionRows.length) return;
var uncached = [];
for (var i = 0; i < saveSelectionRows.length; i++) {
var r = saveSelectionRows[i];
if (!preloaded || !preloaded.rows || !preloaded.rows.has(r)) uncached.push(r);
}
if (!uncached.length) return;
var have = preloaded ? cachedImageCount() : 0;
var target = have + uncached.length;
var overlayLabel = "Caching " + fmtInt(uncached.length) + " selected image"
+ (uncached.length === 1 ? "" : "s") + "…";
preloadImagesInChunksToTarget({
finalTarget: target,
overlayLabel: overlayLabel,
enableImages: true,
restrictToScatterPlot: true,
selectedRows: uncached,
selectionAdditionTotal: uncached.length
}).catch(function(e) {
console.error("cache selection failed", e);
});
}
/** A–Z omitting I, O, U (easier to read vs 1/0 and avoids ambiguous glyphs). */
var SAFE_LETTERS = (function() {
var out = [];
for (var c = 65; c <= 90; c++) {
if (c !== 73 && c !== 79 && c !== 85) out.push(String.fromCharCode(c));
}
return out;
})();
/**
* Generate labels for the current grid.
* ≤26 images: first labels are single safe letters; any past 23 use pairs (AA, AB, …).
* >26 images: row letter from safe alphabet × column number (e.g. A1…An, B1…Bn).
*/
function makeLabels(gridSz) {
var k = gridSz * gridSz;
function labelAt(idx) {
if (idx < SAFE_LETTERS.length) return SAFE_LETTERS[idx];
var j = idx - SAFE_LETTERS.length;
return SAFE_LETTERS[Math.floor(j / SAFE_LETTERS.length)]
+ SAFE_LETTERS[j % SAFE_LETTERS.length];
}
if (k <= 26) {
var out = [];
for (var i = 0; i < k; i++) out.push(labelAt(i));
return out;
}
var out2 = [];
for (var row = 0; row < gridSz; row++) {
var letter = SAFE_LETTERS[row];
for (var col = 0; col < gridSz; col++) {
out2.push(letter + (col + 1));
}
}
return out2;
}
function selectedScatterPalette() {
var r = document.querySelector("input[name=\"scatter_palette\"]:checked");
var v = r && r.value ? String(r.value) : "Viridis";
return CryoColorCovariateLegend.hasPalette(v) ? v : "Viridis";
}
function covariateScaleCSS(t) {
return CryoColorCovariateLegend.paletteScaleCSS(selectedScatterPalette(), t);
}
var ACCENT = "#c4703a";
var gridSize = 3;
var neighborK = gridSize * gridSize;
var labels = makeLabels(gridSize);
/** Top annotation band height as a fraction of square thumbnail width (see particle_explorer.css 19cqw). */
var MONTAGE_META_TOP_FRAC = 0.19;
/** Grid letter label height as a fraction of the meta band (see .cryo-montage-label height). */
var MONTAGE_LABEL_META_HEIGHT_FRAC = 0.7885; /* 95% × 0.83 — 17% shorter than prior target */
/** Approx cap-height / font-size for Georgia on grid letters. */
var MONTAGE_GEORGIA_CAP_HEIGHT_EM = 0.715;
/** Vertical padding on light montage cells (1px top + bottom). */
var MONTAGE_CELL_PAD_Y = 2;
/** Gap between meta band and thumbnail in the fitted grid. */
var MONTAGE_META_IMG_GAP = 1;
/* ---------- montage grid DOM ---------- */
var gridEl = document.getElementById("montage-grid");
var montageCells = []; /* [{img, idx, covar, cell}] */
function rebuildGrid() {
neighborK = gridSize * gridSize;
labels = makeLabels(gridSize);
gridEl.innerHTML = "";
gridEl.style.gridTemplateColumns = "repeat(" + gridSize + ", 1fr)";
gridEl.style.gridTemplateRows = "";
montageCells = [];
for (var i = 0; i < neighborK; i++) {
var cell = document.createElement("div");
cell.className = "cryo-montage-cell cryo-montage-cell--light";
var meta = document.createElement("div");
meta.className = "cryo-montage-meta";
meta.style.boxSizing = "border-box";
meta.style.alignItems = "center";
meta.style.gap = "0";
var lbl = document.createElement("span");
lbl.className = "cryo-montage-label";
var labStr = labels[i];
lbl.textContent = labStr;
lbl.style.lineHeight = String(MONTAGE_GEORGIA_CAP_HEIGHT_EM);
lbl.style.display = "inline-flex";
lbl.style.alignItems = "center";
lbl.style.alignSelf = "center";
lbl.style.boxSizing = "border-box";
var rightMeta = document.createElement("div");
rightMeta.className = "cryo-montage-meta-right";
var covar = document.createElement("span");
covar.className = "cryo-montage-covar";
var idxSpan = document.createElement("div");
idxSpan.className = "cryo-montage-idx";
rightMeta.appendChild(covar);
rightMeta.appendChild(idxSpan);
meta.appendChild(lbl);
meta.appendChild(rightMeta);
var wrap = document.createElement("div");
wrap.className = "cryo-montage-img-wrap";
var img = document.createElement("img");
img.src = ""; img.alt = "";
wrap.appendChild(img);
cell.appendChild(meta);
cell.appendChild(wrap);
gridEl.appendChild(cell);
montageCells.push({
img: img, idx: idxSpan, covar: covar, cell: cell, lbl: lbl, wrap: wrap, meta: meta
});
}
scheduleSyncMontageGridRegionLayout();
}
rebuildGrid();
function applyMontageMetaTypography(cellW) {
cellW = Math.max(1, Math.round(Number(cellW) || 0));
var metaH = cellW * MONTAGE_META_TOP_FRAC;
var letterBandPx = metaH * MONTAGE_LABEL_META_HEIGHT_FRAC;
var annPx = metaH * 0.418;
for (var mi = 0; mi < montageCells.length; mi++) {
var mc = montageCells[mi];
if (!mc.lbl) continue;
var labStr = mc.lbl.textContent || "";
var letterFontPx = letterBandPx / MONTAGE_GEORGIA_CAP_HEIGHT_EM;
if (labStr.length > 1) {
letterFontPx *= Math.min(0.92, 1.22 / labStr.length);
}
mc.lbl.style.height = letterBandPx.toFixed(2) + "px";
mc.lbl.style.maxHeight = letterBandPx.toFixed(2) + "px";
mc.lbl.style.fontSize = letterFontPx.toFixed(2) + "px";
mc.lbl.style.lineHeight = String(MONTAGE_GEORGIA_CAP_HEIGHT_EM);
mc.lbl.style.transform = "translate(" + (letterFontPx * 0.11).toFixed(2) + "px,0)";
if (mc.covar) mc.covar.style.fontSize = annPx.toFixed(2) + "px";
if (mc.idx) mc.idx.style.fontSize = annPx.toFixed(2) + "px";
}
}
var montageGridLayoutTimer = null;
function resetMontageGridRegionLayout() {
if (!gridEl) return;
gridEl.style.width = "";
gridEl.style.maxWidth = "";
gridEl.style.marginLeft = "";
gridEl.style.marginRight = "";
gridEl.style.gridTemplateColumns = "repeat(" + gridSize + ", 1fr)";
gridEl.style.gridTemplateRows = "";
for (var ri = 0; ri < montageCells.length; ri++) {
var mc = montageCells[ri];
if (mc.wrap) {
mc.wrap.style.width = "";
mc.wrap.style.height = "";
mc.wrap.style.flex = "";
}
if (mc.meta) {
mc.meta.style.height = "";
mc.meta.style.minHeight = "";
mc.meta.style.maxHeight = "";
}
}
}
function montageRowExtraPx() {
return MONTAGE_CELL_PAD_Y + MONTAGE_META_IMG_GAP;
}
function montageGridLayoutAvailWidth() {
if (!imageGridMenuBody) return 0;
var availW = imageGridMenuBody.clientWidth;
if (availW < 8 && montageGridRegionEl) {
availW = montageGridRegionEl.clientWidth;
}
if (availW < 8 && montageAsideEl) {
var asideStyle = window.getComputedStyle(montageAsideEl);
var asidePadX = (parseFloat(asideStyle.paddingLeft) || 0)
+ (parseFloat(asideStyle.paddingRight) || 0);
availW = Math.max(0, montageAsideEl.clientWidth - asidePadX);
}
return availW;
}
function syncMontageGridRegionLayout() {
if (!montageAsideEl || !gridEl) return;
syncMontagePanelGridSplit();
if (!imageGridMenuBody) return;
if (!imageGridMenuIsOpen() || imageGridMenuBody.hidden) {
resetMontageGridRegionLayout();
return;
}
resetMontageGridRegionLayout();
var n = gridSize;
var availW = montageGridLayoutAvailWidth();
if (availW < 8 || !montageCells.length) return;
var gridStyle = window.getComputedStyle(gridEl);
var gap = parseFloat(gridStyle.columnGap || gridStyle.gap) || 4;
var rowExtra = montageRowExtraPx();
var cellW = Math.floor((availW - gap * (n - 1)) / n);
if (cellW < 1) cellW = 1;
var metaH = Math.round(cellW * MONTAGE_META_TOP_FRAC);
var rowH = cellW + metaH + rowExtra;
var gridW = cellW * n + gap * (n - 1);
gridEl.style.width = gridW + "px";
gridEl.style.maxWidth = "100%";
gridEl.style.gridTemplateColumns = "repeat(" + n + ", " + cellW + "px)";
gridEl.style.gridTemplateRows = "repeat(" + n + ", " + rowH + "px)";
for (var gi = 0; gi < montageCells.length; gi++) {
var mcLay = montageCells[gi];
if (mcLay.meta) {
mcLay.meta.style.height = metaH + "px";
mcLay.meta.style.minHeight = metaH + "px";
mcLay.meta.style.maxHeight = metaH + "px";
}
if (mcLay.wrap) {
mcLay.wrap.style.width = cellW + "px";
mcLay.wrap.style.height = cellW + "px";
mcLay.wrap.style.flex = "0 0 auto";
}
}
applyMontageMetaTypography(cellW);
}
function scheduleSyncMontageGridRegionLayout() {
if (montageGridLayoutTimer) clearTimeout(montageGridLayoutTimer);
montageGridLayoutTimer = setTimeout(function() {
montageGridLayoutTimer = null;
window.requestAnimationFrame(function() {
window.requestAnimationFrame(syncMontageGridRegionLayout);
});
}, 50);
}
gridSizeSelect.addEventListener("change", function() {
gridSize = parseInt(this.value) || 3;
rebuildGrid();
refillMontageForNewGridSize();
scheduleFitMontageResampleCacheButton();
});
function fillSelect(sel, includeNone) {
sel.innerHTML = "";
if (includeNone) {
var o = document.createElement("option");
o.value = "none"; o.textContent = "None";
sel.appendChild(o);
}
cols.forEach(function(c) {
var o = document.createElement("option");
o.value = c;
o.textContent = covariateDisplayMap[c] || c;
sel.appendChild(o);
});
}
var sx = document.getElementById("sx");
var sy = document.getElementById("sy");
var sc = document.getElementById("sc");
fillSelect(sx, false);
fillSelect(sy, false);
fillSelect(sc, true);
sx.value = cols.indexOf(dx) >= 0 ? dx : cols[0];
sy.value = cols.indexOf(dy) >= 0 ? dy : cols[Math.min(1, cols.length - 1)];
var renderedScatterAxes = {x: sx.value, y: sy.value};
var gd = document.getElementById("scatter");
var colorHistPanel = document.getElementById("color-hist-panel");
var colorHistDiv = document.getElementById("color-hist");
var colorContinuousWrap = document.getElementById("color-continuous-hist-wrap");
var colorDiscreteWrap = document.getElementById("color-discrete-wrap");
var colorDiscreteSwitches = document.getElementById("color-discrete-switches");
var btnDiscreteInvertSelection = document.getElementById("btn-discrete-invert-selection");
var btnCacheSelectionUncached = document.getElementById("btn-cache-selection-uncached");
var btnClearImageCache = document.getElementById("btn-clear-image-cache");
var colorHistModeCheck = document.getElementById("color-hist-mode-check");
var colorHistModeCheckCaption = document.getElementById("color-hist-mode-check-caption");
var colorHistModeCheckWasRange = false;
var colorThresholdStatus = document.getElementById("color-threshold-status");
var scatterPaletteToggle = document.getElementById("scatter-palette-toggle");
var scatterPaletteOptions = document.getElementById("scatter-palette-options");
var colorThresholdLevel = null;
var colorThresholdRange = null;
var colorThresholdColorCol = null;
var explorerColorLegend = null;
var explorerDiscreteLabelColors = {};
var explorerCcLegendPrevMode = null;
var leadModeLassoEl = document.getElementById("lead-mode-lasso");
var leadModeThresholdEl = document.getElementById("lead-mode-threshold");
var leadModeDiscreteEl = document.getElementById("lead-mode-discrete");
function syncLeadSelectionModeHighlight() {
var mode = activeSelectionMode || "";
if (leadModeLassoEl) {
leadModeLassoEl.classList.toggle("cryo-explorer-lead-item--active", mode === "lasso");
}
if (leadModeThresholdEl) {
leadModeThresholdEl.classList.toggle("cryo-explorer-lead-item--active", mode === "threshold");
}
if (leadModeDiscreteEl) {
leadModeDiscreteEl.classList.toggle("cryo-explorer-lead-item--active", mode === "discrete");
}
}
function setActiveSelectionMode(mode) {
activeSelectionMode = mode || "";
syncLeadSelectionModeHighlight();
}
function syncScatterControlsAlignment() {
if (!scatterControlsCard || !scatterControlsSide || !scatterPlotStack) return;
var minGapPx = 8;
var stackRect = scatterPlotStack.getBoundingClientRect();
var sideRect = scatterControlsSide.getBoundingClientRect();
if (!stackRect || stackRect.height <= 0 || !sideRect) {
scatterControlsCard.style.marginTop = "0px";
return;
}
var previousMargin = scatterControlsCard.style.marginTop;
scatterControlsCard.style.marginTop = "0px";
var baseTop = scatterControlsCard.offsetTop;
var cardHalf = scatterControlsCard.offsetHeight * 0.5;
scatterControlsCard.style.marginTop = previousMargin;
var plotCenterLocal = (stackRect.top + stackRect.height * 0.5) - sideRect.top;
var desiredTop = plotCenterLocal - cardHalf;
var px = Math.max(minGapPx, desiredTop - baseTop);
scatterControlsCard.style.marginTop = Math.round(px) + "px";
}
function equalizeMontageActionButtonWidths() {
if (!montageActionsEl) return;
/* Load-images toolbar: equal width via flex CSS; volume buttons via .cryo-explorer-volume-actions grid. */
var clearWidthBtns = montageActionsEl.querySelectorAll(
".cryo-explorer-load-images-toolbar .cryo-explorer-action-row__btn .btn,"
+ " .cryo-explorer-volume-actions .cryo-explorer-action-row__btn .btn"
);
for (var li = 0; li < clearWidthBtns.length; li++) {
clearWidthBtns[li].style.width = "";
clearWidthBtns[li].style.maxWidth = "";
}
}
var plotlyStackEl = gd && gd.parentElement;
if (montageAsideEl && window.CryoResizeObserverUtils) {
window.CryoResizeObserverUtils.observeResize(
[
montageAsideEl,
imageGridPanelShell,
imageGridToolbarControls,
btnMontageResampleCache,
imageGridMenuBody,
montageGridRegionEl
],
function() {
scheduleSyncMontageGridRegionLayout();
scheduleFitMontageResampleCacheButton();
}
);
}
if (plotlyStackEl && window.CryoResizeObserverUtils) {
window.CryoResizeObserverUtils.observeResize(
[plotlyStackEl],
function() {
window.requestAnimationFrame(function() {
syncScatterControlsAlignment();
scheduleSyncMontageGridRegionLayout();
equalizeMontageActionButtonWidths();
if (!gd || !gd.data || !gd.data.length) return;
try { Plotly.Plots.resize(gd); } catch (e) {}
if (colorHistPanel && !colorHistPanel.hidden && colorHistDiv) {
try { Plotly.Plots.resize(colorHistDiv); } catch (e2) {}
}
});
}
);
}
var overlay = document.getElementById("scatter-rendering-overlay");
var scatterRenderingEverCompleted = false;
var paletteFieldset = document.getElementById("scatter-palette-radios");
var plotStatus = document.getElementById("scatter-plot-status");
var preloadStatus = document.getElementById("preload-status");
var scatterPlotWatchdog = null;
var SCATTER_PLOT_TIMEOUT_MS = 90000;
var scatterLoadGeneration = 0;
var pendingScatterAfterPlot = null;
var highlightTraceAdded = false;
var suppressPlotGridHighlights = false;
var suppressSelectionEvents = false;
var selFileBrowserPanel = document.getElementById("sel-file-browser-panel");
var selFbList = document.getElementById("sel-fb-list");
var selFbPath = document.getElementById("sel-fb-path");
var selFbUp = document.getElementById("sel-fb-up");
var selFbCancel = document.getElementById("sel-fb-cancel");
var selFbSaveName = document.getElementById("sel-fb-save-name");
var selFbSaveBtn = document.getElementById("sel-fb-save-btn");
var selFbCurrentDir = null;
/** When set, ``saveSelectionViaBrowser`` saves these rows (single region) instead of full selection. */
var pendingRegionSaveRows = null;
function plotHasScatterData() {
return !!(gd && gd.data && gd.data[0] && gd.data[0].customdata && gd.data[0].customdata.length);
}
function shuffleInPlace(arr) {
for (var i = arr.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
return arr;
}
function allPlotRowsFromTrace() {
var cd = gd.data[0].customdata;
var out = [];
for (var i = 0; i < cd.length; i++) {
out.push(cd[i][1]);
}
return out;
}
function scatterPlottedRowBudget() {
var n = Math.max(0, Number(totalParticles) || 0);
return Math.min(n, explorerScatterMaxPoints);
}
/** Rows in the scatter subsample (matches server ``restrict_to_scatter_plot`` pool). */
function scatterSubsetRowCount() {
if (plotHasScatterData()) return gd.data[0].customdata.length;
return scatterPlottedRowBudget();
}
/** Largest power of 10 ≤ 5% of plotted count (matches ``explorer_cache_size_power10_step``). */
function cacheSizePower10StepFromCap(cap) {
cap = Math.max(0, Math.floor(Number(cap) || 0));
if (cap === 0) return 1;
var x = 0.05 * cap;
if (x < 1) return 1;
var k = Math.floor(Math.log10(x));
var step = Math.pow(10, k);
return Math.max(1, Math.floor(step));
}
/**
* ```` only steps correctly when (value − min) is a multiple of step.
* We keep min/step/value on that lattice so browser spinners use the power-of-ten step.
*/
function montageCacheSizeStepperGrid(cap) {
cap = Math.max(0, Math.floor(Number(cap) || 0));
if (cap === 0) return { minG: 0, step: 1 };
var step = cacheSizePower10StepFromCap(cap);
if (!preloaded) {
var stepUse = Math.min(step, cap);
return { minG: 0, step: Math.max(1, stepUse) };
}
var have = cachedImageCount();
var floor = Math.min(have + 1, cap);
if (have >= cap) return { minG: cap, step: 1 };
var minAligned = Math.ceil(floor / step) * step;
if (minAligned > cap) return { minG: floor, step: 1 };
return { minG: minAligned, step: step };
}
function snapMontageCacheSizeToGridNearest(val, cap, grid) {
var minG = grid.minG;
var step = grid.step;
cap = Math.max(0, Math.floor(Number(cap) || 0));
if (cap === 0) return 0;
if (!isFinite(val)) val = minG;
val = Math.floor(Number(val));
val = Math.min(cap, Math.max(minG, val));
if (step <= 1) return val;
var k = Math.round((val - minG) / step);
if (!isFinite(k)) k = 0;
var out = minG + k * step;
if (out > cap) out = minG + Math.floor((cap - minG) / step) * step;
if (out < minG) out = minG;
return out;
}
/** Largest grid-aligned montage total ≤ bound (same lattice as ``snapMontageCacheSizeToGridNearest``). */
function largestSnappedMontageTotalNotAbove(bound, cap, grid) {
cap = Math.max(0, Math.floor(Number(cap) || 0));
bound = Math.min(Math.floor(Number(bound) || 0), cap);
var minG = grid.minG;
var step = grid.step;
if (bound < minG) return null;
if (step <= 1) return bound;
var k = Math.floor((bound - minG) / step);
return minG + k * step;
}
/** Default first-build target: step above, capped at plotted count (matches server ``preload_image_limit``). */
function firstBuildCacheSizeDefault() {
var cap = scatterSubsetRowCount();
if (cap === 0) return 1;
var step = cacheSizePower10StepFromCap(cap);
return Math.min(cap, Math.max(1, step));
}
/**
* Suggested new cache total when expanding: largest snap-to value strictly below 2× current cache
* (i.e. ≤ 2×have − 1 on the integer lattice), capped at plotted count; else minimum valid expansion.
*/
function defaultMontageNewCacheSizeSuggestion() {
if (!preloaded) return firstBuildCacheSizeDefault();
var have = cachedImageCount();
var cap = scatterSubsetRowCount();
if (have >= cap) return cap;
var grid = montageCacheSizeStepperGrid(cap);
var floor = Math.min(have + 1, cap);
var bound = Math.min(cap, 2 * have - 1);
var cand = largestSnappedMontageTotalNotAbove(bound, cap, grid);
if (cand == null || cand < floor) {
return snapMontageCacheSizeToGridNearest(floor, cap, grid);
}
return cand;
}
function syncMontageCacheSizeLabelEl() {
var sp = document.getElementById("montage-cache-size-label-text");
var inp = document.getElementById("montage-cache-size-input");
if (sp) sp.textContent = preloaded ? "New size" : "Cache size";
if (inp) {
inp.title = preloaded
? "Total cached images to grow toward (must be greater than the current cache; capped at plotted points)"
: "Number of thumbnails to fetch when you build new cache (capped at plotted scatter points)";
}
}
/** Keep min/max/value in range; during preload overlay show requested target (disabled). */
function syncMontageNewCacheSizeField() {
var el = document.getElementById("montage-cache-size-input");
if (!el) return;
var cap = scatterSubsetRowCount();
var grid = montageCacheSizeStepperGrid(cap);
el.setAttribute("min", String(grid.minG));
el.setAttribute("step", String(grid.step));
el.setAttribute("max", String(cap));
syncMontageCacheSizeLabelEl();
if (montagePreloadOverlayIsShown()) {
el.disabled = true;
if (preloadRequestedCacheTarget != null) {
var tgt = snapMontageCacheSizeToGridNearest(preloadRequestedCacheTarget, cap, grid);
el.value = String(tgt);
}
return;
}
var have = cachedImageCount();
if (preloaded && have >= cap) {
el.disabled = true;
el.setAttribute("min", String(cap));
el.setAttribute("step", "1");
el.value = String(cap);
return;
}
el.disabled = false;
if (!preloaded) {
var d0 = firstBuildCacheSizeDefault();
var raw0 = parseInt(String(el.value).replace(/\s/g, "").replace(/,/g, ""), 10);
if (!isFinite(raw0) || raw0 < 0 || raw0 > cap) raw0 = d0;
var snapped0 = snapMontageCacheSizeToGridNearest(raw0, cap, grid);
if (snapped0 < 1) snapped0 = snapMontageCacheSizeToGridNearest(d0, cap, grid);
if (snapped0 < 1) snapped0 = 1;
el.value = String(Math.min(cap, snapped0));
return;
}
var suggestion = defaultMontageNewCacheSizeSuggestion();
var raw = parseInt(String(el.value).replace(/\s/g, "").replace(/,/g, ""), 10);
var floor = Math.min(have + 1, cap);
if (!isFinite(raw) || raw <= have || raw > cap) raw = suggestion;
var snapped = snapMontageCacheSizeToGridNearest(raw, cap, grid);
if (snapped < floor) snapped = snapMontageCacheSizeToGridNearest(suggestion, cap, grid);
el.value = String(Math.min(cap, Math.max(floor, snapped)));
}
/** Target cache size from field (first build: [1, cap]; expand: (have, cap]). */
function readMontageCacheSizeInput() {
var cap = scatterSubsetRowCount();
var el = document.getElementById("montage-cache-size-input");
var raw = el ? parseInt(String(el.value).replace(/\s/g, "").replace(/,/g, ""), 10) : NaN;
var grid = montageCacheSizeStepperGrid(cap);
if (!preloaded) {
if (!isFinite(raw)) raw = firstBuildCacheSizeDefault();
var v = snapMontageCacheSizeToGridNearest(raw, cap, grid);
if (v < 1) v = snapMontageCacheSizeToGridNearest(firstBuildCacheSizeDefault(), cap, grid);
return Math.min(cap, Math.max(1, v));
}
var have = cachedImageCount();
var floor = have >= cap ? cap : have + 1;
if (!isFinite(raw)) raw = defaultMontageNewCacheSizeSuggestion();
var v2 = snapMontageCacheSizeToGridNearest(raw, cap, grid);
return Math.min(cap, Math.max(floor, v2));
}
function rowToTraceIndex(row) {
var want = Number(row);
if (!isFinite(want)) return -1;
var cd = gd.data[0].customdata;
for (var i = 0; i < cd.length; i++) {
if (Number(cd[i][1]) === want) return i;
}
return -1;
}
function montageItemForRow(r) {
var rn = Number(r);
if (!isFinite(rn)) return null;
var ti = rowToTraceIndex(rn);
if (ti < 0) return null;
var src = "";
if (preloaded && preloaded.rowToSrc) {
src = preloaded.rowToSrc.get(rn) || preloaded.rowToSrc.get(r) || "";
}
return { ti: ti, row: rn, src: src };
}
/** Random grid: prefer lasso/box selection, pad from full plot if needed. */
function pickRandomMontageItemsFilled(want) {
if (!plotHasScatterData()) return [];
var primary = [];
if (saveSelectionRows && saveSelectionRows.length) {
primary = saveSelectionRows.slice();
shuffleInPlace(primary);
} else {
primary = allPlotRowsFromTrace();
shuffleInPlace(primary);
}
var seen = new Set();
var out = [];
for (var a = 0; a < primary.length && out.length < want; a++) {
var r = primary[a];
if (seen.has(r)) continue;
var it = montageItemForRow(r);
if (!it) continue;
seen.add(r);
out.push(it);
}
if (out.length < want) {
var fallback = allPlotRowsFromTrace();
shuffleInPlace(fallback);
for (var b = 0; b < fallback.length && out.length < want; b++) {
var r2 = fallback[b];
if (seen.has(r2)) continue;
var it2 = montageItemForRow(r2);
if (!it2) continue;
seen.add(r2);
out.push(it2);
}
}
return out;
}
/* ---------- preloaded image cache ---------- */
var preloaded = null;
function cachedImageCount() {
return preloaded && preloaded.rows ? preloaded.rows.size : 0;
}
/** Cached rows that also appear on the current scatter trace (subsample may omit most dataset rows). */
function traceMapCachedCount() {
return preloaded && preloaded.traceMap ? preloaded.traceMap.length : 0;
}
function formatCachedSelectionStatusCore(activeInSelection) {
var nPlot = traceMapCachedCount();
var nTot = cachedImageCount();
var nSelFmt = fmtInt(activeInSelection);
var nPlotFmt = fmtInt(nPlot);
var nTotFmt = fmtInt(nTot);
if (!nTot) return "";
if (nPlot === nTot) {
return nSelFmt + " of " + nTotFmt + " cached images in selection";
}
return nSelFmt + " of " + nPlotFmt + " on-scatter cached in selection ("
+ nTotFmt + " thumbnails loaded)";
}
function setPreloadStatusCachedTotalOnly() {
if (!preloadStatus || !preloaded) return;
var n = cachedImageCount();
preloadStatus.textContent = n ? fmtInt(n) + " images cached." : "";
}
function allImagesCached() {
return cachedImageCount() >= scatterSubsetRowCount();
}
function syncExpandCacheButton(loading) {
if (!btnExpandCache) return;
syncMontageCacheSizeLabelEl();
if (loading) {
btnExpandCache.disabled = true;
return;
}
if (!preloaded) {
btnExpandCache.classList.add("cryo-explorer-expand-cache-btn--compact");
btnExpandCache.textContent = "Build new cache\nwith " + fmtInt(readMontageCacheSizeInput()) + " images";
var capB = scatterSubsetRowCount();
var canBuild = !!(totalParticles && plotHasScatterData() && capB > 0);
btnExpandCache.disabled = !canBuild;
btnExpandCache.title = !totalParticles
? "No particles in this experiment."
: !plotHasScatterData()
? "Wait for the scatter plot to finish loading."
: "Fetch up to " + fmtInt(capB)
+ " thumbnails (see cache size).";
return;
}
btnExpandCache.classList.add("cryo-explorer-expand-cache-btn--compact");
btnExpandCache.textContent = "Expand cache by\n" + fmtInt(Math.max(0,
readMontageCacheSizeInput() - cachedImageCount()
)) + " random images";
var have = cachedImageCount();
var want = readMontageCacheSizeInput();
var cap = scatterSubsetRowCount();
var canExpand = !!(want > have && have < cap);
btnExpandCache.disabled = !canExpand;
if (have >= cap) {
btnExpandCache.title = "All plotted particle images are already cached.";
} else if (want <= have) {
btnExpandCache.title = "Set new cache size above " + fmtInt(have) + " to expand.";
} else {
btnExpandCache.title = "Fetch thumbnails up to the new cache size (now " + fmtInt(have) +
" cached).";
}
}
function remainingScatterImagesToLoad() {
var cap = scatterSubsetRowCount();
var have = cachedImageCount();
return Math.max(0, cap - have);
}
function syncClearCacheButton(loading) {
if (!btnClearImageCache) return;
var cacheReadyToClear = !!(preloaded && !montagePreloadOverlayIsShown());
btnClearImageCache.disabled = !!(loading || !cacheReadyToClear);
btnClearImageCache.title = !preloaded
? "Build new cache before you can clear one."
: montagePreloadOverlayIsShown()
? "Wait until loading finishes."
: "Discard all downloaded thumbnails from this explorer session.";
}
/** Drop server PRELOAD_CACHE for this epoch so chunked rebuild stays in sync after a clear. */
function invalidateServerPreloadCache() {
return fetch("{{ url_for('api_preload_images') }}", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ invalidate_cache: true }),
keepalive: true
}).catch(function() {});
}
function clearExplorerImageCache() {
if (!preloaded) return;
invalidateServerPreloadCache().finally(function flushLocalExplorerPreloadAfterServer() {
if (!preloaded) return;
selectionCacheAdditionFreezeCount = null;
if (btnCacheSelectionUncached) {
btnCacheSelectionUncached.classList.remove("cryo-explorer-cache-selection-btn--busy");
}
preloaded = null;
imagesViewEnabled = false;
gridSizeSelect.disabled = true;
setImageGridMenuOpen(false);
lastMontageRows = [];
activePool = null;
preloadRequestedCacheTarget = null;
setMontagePreloadOverlay(false);
setImageCacheProgress(false);
if (preloadStatus) preloadStatus.textContent = "";
montageDisplayMode = "images";
invalidateVolumeArtifacts();
updateMontage([]);
syncMontageNewCacheSizeField();
syncImageCacheButton(false);
syncHighlightTraceAnnotations();
syncVolumeExploreButtons();
});
}
function syncImageCacheButton(loading) {
if (!btnViewImages) return;
if (!preloaded) {
btnViewImages.textContent = "Load all\n" + fmtInt(scatterSubsetRowCount()) + " images";
} else if (allImagesCached()) {
btnViewImages.textContent = "Load remaining\n" + fmtInt(0) + " images";
} else {
var rem = remainingScatterImagesToLoad();
btnViewImages.textContent = "Load remaining\n" + fmtInt(rem) + " images";
}
btnViewImages.disabled = !!loading || !preloaded || allImagesCached();
btnViewImages.setAttribute("aria-label", btnViewImages.textContent);
btnViewImages.title = !preloaded
? "Build new cache first."
: allImagesCached()
? "All particle images on the current scatter are loaded."
: (btnViewImages.disabled ? "Finish loading cache first." : "");
syncMontageNewCacheSizeField();
syncExpandCacheButton(loading);
syncClearCacheButton(loading);
syncCacheSelectionUncachedButton();
syncImageGridInactiveNote();
syncMontageResampleFromCacheButton();
}
function syncImageCacheLoadPanelVsProgress() {
if (!imageCacheProgressEl) return;
var fs = document.getElementById("cryo-explorer-image-cache-fieldset");
var wrap = document.getElementById("cryo-explorer-cache-load-progress-wrap");
var show = !imageCacheProgressEl.hidden;
if (fs) fs.classList.toggle("cryo-explorer-montage-group--image-cache-loading", show);
if (wrap) {
wrap.hidden = !show;
wrap.setAttribute("aria-hidden", show ? "false" : "true");
}
imageCacheProgressEl.setAttribute("aria-hidden", show ? "false" : "true");
}
function setImageCacheProgress(on, current, total) {
if (!imageCacheProgressEl) return;
if (!on) {
imageCacheProgressEl.hidden = true;
imageCacheProgressEl.setAttribute("aria-valuenow", "0");
if (imageCacheProgressBarEl) imageCacheProgressBarEl.style.width = "0%";
if (imageCacheProgressLabelEl) imageCacheProgressLabelEl.textContent = "0%";
syncImageCacheLoadPanelVsProgress();
return;
}
total = Math.max(1, Number(total) || 1);
current = Math.max(0, Math.min(Number(current) || 0, total));
var pct = Math.round((current / total) * 100);
imageCacheProgressEl.hidden = false;
imageCacheProgressEl.setAttribute("aria-valuenow", String(pct));
if (imageCacheProgressBarEl) imageCacheProgressBarEl.style.width = pct + "%";
if (imageCacheProgressLabelEl) {
imageCacheProgressLabelEl.textContent = pct + "% loaded ("
+ fmtInt(current) + " / "
+ fmtInt(total) + ")";
}
syncImageCacheLoadPanelVsProgress();
}
/** Max new thumbnails per HTTP preload round-trip (JSON + base64 payload size). */
var IMAGE_CACHE_HTTP_CHUNK_MAX = 400;
function imageCacheChunkSize(total) {
total = Math.max(0, Number(total) || 0);
if (!total) return 0;
var cpus = Math.max(1, Number(preloadCpus) || 1);
var parallelThreshold = Math.max(128, cpus * 32);
var minChunk = cpus > 1 ? parallelThreshold : 250;
var targetChunk = Math.ceil(total / 200);
return Math.min(IMAGE_CACHE_HTTP_CHUNK_MAX, 5000, Math.max(minChunk, targetChunk));
}
function preloadFetchErrorMessage(err) {
var msg = err && err.message ? String(err.message) : "";
if (msg === "Failed to fetch" || /networkerror/i.test(msg)) {
return "Image cache request failed (network or timeout). Try a smaller new cache size.";
}
return msg || "Image cache failed.";
}
/** Next absolute ``cache_size`` for one preload request when growing toward ``finalTarget``. */
function computeNextCacheChunkTarget(before, finalTarget, scatterTotalForStepping) {
if (before >= finalTarget) return before;
var step;
if (scatterTotalForStepping != null) {
var st = scatterTotalForStepping;
step = imageCacheChunkSize(st);
var userTarget = readMontageCacheSizeInput();
var roomTowardUser = userTarget - before;
if (roomTowardUser > 0) {
step = Math.max(1, Math.min(step, roomTowardUser));
} else {
step = Math.max(1, Math.min(step, st - before));
}
return Math.min(finalTarget, Math.min(st, before + step));
}
step = imageCacheChunkSize(finalTarget);
step = Math.max(1, Math.min(step, finalTarget - before));
return Math.min(finalTarget, before + step);
}
function refreshPreloadStatusAfterChunkedLoad() {
setPreloadStatusCachedTotalOnly();
}
/**
* Grow the server+client cache toward ``finalTarget`` with chunked requests so the
* progress bar updates during build, expand, selection fetch, and load-all.
*/
function preloadImagesInChunksToTarget(opts) {
opts = opts || {};
var finalTarget = Math.max(0, Number(opts.finalTarget) || 0);
var overlayLabel = opts.overlayLabel || "Loading image cache...";
var scatterTotalForStepping = opts.scatterTotalForStepping != null
? opts.scatterTotalForStepping
: null;
var enableImages = opts.enableImages !== false;
var restrictToScatterPlot = !!opts.restrictToScatterPlot;
var scatterMaxPoints = opts.scatterMaxPoints;
var selectedRows = opts.selectedRows || null;
var suppressHighlightsDuring = !!opts.suppressHighlightsDuring;
var suppressMontageRefresh = !!opts.suppressMontageRefresh;
var fullLoad = !!opts.fullLoad;
var selAddN = opts.selectionAdditionTotal != null ? Math.round(Number(opts.selectionAdditionTotal)) : 0;
var trackSelectionAdditionFreeze = selAddN > 0;
if (finalTarget < 1) {
return Promise.resolve(null);
}
var progressStartCount = cachedImageCount();
var progressTotal = progressStartCount > 0
? Math.max(1, finalTarget - progressStartCount)
: Math.max(1, finalTarget);
function updateImageCacheChunkProgress() {
var added = Math.max(0, cachedImageCount() - progressStartCount);
setImageCacheProgress(true, added, progressTotal);
}
if (trackSelectionAdditionFreeze) {
selectionCacheAdditionFreezeCount = selAddN;
}
setMontagePreloadOverlay(true, overlayLabel);
if (opts.clearStatusAtStart && preloadStatus) preloadStatus.textContent = "";
updateImageCacheChunkProgress();
if (suppressHighlightsDuring) {
suppressPlotGridHighlights = true;
syncHighlightTraceAnnotations();
}
syncImageCacheButton(true);
function doneWithSelectionAdditionFreeze() {
if (trackSelectionAdditionFreeze) {
selectionCacheAdditionFreezeCount = null;
}
}
function finishSuccess() {
doneWithSelectionAdditionFreeze();
setMontagePreloadOverlay(false);
setImageCacheProgress(false);
if (suppressHighlightsDuring) {
suppressPlotGridHighlights = false;
syncHighlightTraceAnnotations();
}
refreshPreloadStatusAfterChunkedLoad();
if (!suppressMontageRefresh) showRandomPreloaded();
syncImageCacheButton(false);
syncVolumeExploreButtons();
}
/** Server returned OK but added no new thumbnails (common for selection vs scatter subsample). */
function finishPlateauNoNewImages() {
doneWithSelectionAdditionFreeze();
setMontagePreloadOverlay(false);
var n = cachedImageCount();
setImageCacheProgress(false);
if (suppressHighlightsDuring) {
suppressPlotGridHighlights = false;
syncHighlightTraceAnnotations();
}
refreshPreloadStatusAfterChunkedLoad();
if (preloadStatus && n < finalTarget) {
preloadStatus.textContent = fmtInt(n) + " images cached.";
preloadStatus.title = "Could not add more (off-scatter selection, already cached, or server cache cap).";
} else if (preloadStatus) {
preloadStatus.title = "";
}
if (!suppressMontageRefresh) showRandomPreloaded();
syncImageCacheButton(false);
syncVolumeExploreButtons();
}
function nextChunk() {
var before = cachedImageCount();
if (before >= finalTarget) {
finishSuccess();
return Promise.resolve(null);
}
var target = computeNextCacheChunkTarget(before, finalTarget, scatterTotalForStepping);
if (target <= before) {
finishSuccess();
return Promise.resolve(null);
}
var isDelta = !!preloaded;
return fetchPreload(sx.value, sy.value, null, {
cacheSize: target,
enableImages: enableImages,
delta: isDelta,
fullLoad: fullLoad,
keepOverlayAfterSuccess: true,
suppressStatus: true,
suppressMontageUpdate: true,
restrictToScatterPlot: restrictToScatterPlot,
scatterMaxPoints: scatterMaxPoints,
selectedRows: selectedRows,
overlayLabel: overlayLabel
}).then(function() {
if (cachedImageCount() <= before) {
if (isDelta) {
finishPlateauNoNewImages();
return Promise.resolve(null);
}
throw new Error("Image cache did not advance.");
}
updateImageCacheChunkProgress();
return nextChunk();
});
}
return nextChunk()
.catch(function(e) {
console.error("chunked preload failed", e);
doneWithSelectionAdditionFreeze();
setImageCacheProgress(false);
if (suppressHighlightsDuring) {
suppressPlotGridHighlights = false;
syncHighlightTraceAnnotations();
}
if (preloadStatus) preloadStatus.textContent = preloadFetchErrorMessage(e);
setMontagePreloadOverlay(false);
syncImageCacheButton(false);
syncVolumeExploreButtons();
throw e;
});
}
function fetchPreload(xcol, ycol, onDone, opts) {
opts = opts || {};
var cacheSize = opts.cacheSize || scatterSubsetRowCount();
preloadRequestedCacheTarget = cacheSize;
var plottedCap = scatterSubsetRowCount();
var overlayLabel = opts.overlayLabel
? opts.overlayLabel
: (opts.fullLoad
? "Loading all " + fmtInt(plottedCap) + " plotted particle images..."
: "Loading image cache...");
setMontagePreloadOverlay(true, overlayLabel);
syncImageCacheButton(true);
var body = { x: xcol, y: ycol, cache_size: cacheSize };
if (opts.delta) {
body.response_mode = "delta";
}
if (opts.restrictToScatterPlot) {
body.restrict_to_scatter_plot = true;
body.scatter_max_points = opts.scatterMaxPoints != null
? opts.scatterMaxPoints
: explorerScatterMaxPoints;
}
if (opts.initialRows && opts.initialRows.length) {
body.initial_rows = opts.initialRows.slice();
}
if (opts.selectedRows && opts.selectedRows.length) {
body.selected_rows = opts.selectedRows.map(Number).filter(function(x) {
return isFinite(x);
});
}
return fetch("{{ url_for('api_preload_images') }}", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
.then(function(r) {
return r.json().then(function(j) { return { ok: r.ok, j: j }; });
})
.then(function(res) {
if (!res.ok) {
var msg = (res.j && res.j.error) ? res.j.error : "Image cache request failed.";
if (preloadStatus) preloadStatus.textContent = msg;
setMontagePreloadOverlay(false);
if (onDone) onDone(new Error(msg));
throw new Error(msg);
}
var data = res.j;
if (!data.rows || !data.images || (!data.rows.length && !opts.delta)) {
if (preloadStatus) preloadStatus.textContent = "No images available.";
setMontagePreloadOverlay(false);
if (onDone) onDone(new Error("no images"));
throw new Error("no images");
}
var rowToSrc = (opts.delta && preloaded && preloaded.rowToSrc)
? new Map(preloaded.rowToSrc)
: new Map();
var rowsSet = (opts.delta && preloaded && preloaded.rows)
? new Set(preloaded.rows)
: new Set();
data.rows.forEach(function(r, i) {
rowToSrc.set(r, "data:image/jpeg;base64," + data.images[i]);
rowsSet.add(r);
});
preloaded = {rows: rowsSet, rowToSrc: rowToSrc, traceMap: null};
if (opts.enableImages) {
imagesViewEnabled = true;
gridSizeSelect.disabled = false;
setImageGridMenuOpen(true);
}
buildTraceMap();
/* Lasso/box before image display only set saveSelectionRows — rebuild activePool for montage + clicks. */
if (saveSelectionRows && saveSelectionRows.length) {
var sset = new Set(saveSelectionRows);
var ap = preloaded.traceMap.filter(function(item) {
return sset.has(item.row);
});
if (ap.length) {
activePool = ap;
lassoSelectedRowSet = new Set();
for (var li = 0; li < ap.length; li++) {
lassoSelectedRowSet.add(ap[li].row);
}
}
}
if (preloadStatus && !opts.suppressStatus) {
setPreloadStatusCachedTotalOnly();
preloadStatus.title = "";
}
if (!opts.keepOverlayAfterSuccess) {
setMontagePreloadOverlay(false);
}
if (!opts.suppressMontageUpdate) {
showRandomPreloaded();
}
syncImageCacheButton(!!opts.keepOverlayAfterSuccess);
if (onDone) onDone(null);
return data;
})
.catch(function(e) {
console.error("preload failed", e);
if (preloadStatus) preloadStatus.textContent = preloadFetchErrorMessage(e);
setMontagePreloadOverlay(false);
syncImageCacheButton(false);
if (onDone) onDone(e);
if (onDone) return null;
throw e;
});
}
function expandImageCacheToTarget() {
if (!preloaded) return;
var target = readMontageCacheSizeInput();
var before = cachedImageCount();
if (target <= before) return;
var label = "Expanding cache to " + fmtInt(target) + " images…";
preloadImagesInChunksToTarget({
finalTarget: target,
overlayLabel: label,
enableImages: true,
restrictToScatterPlot: true
}).catch(function(e) {
console.error("expand cache failed", e);
});
}
function buildInitialImageCache() {
if (preloaded || !totalParticles || !plotHasScatterData()) return;
if (scatterSubsetRowCount() < 1) return;
var sz = readMontageCacheSizeInput();
preloadImagesInChunksToTarget({
finalTarget: sz,
overlayLabel: "Building cache (" + fmtInt(sz) + " images)…",
enableImages: true,
restrictToScatterPlot: true
}).catch(function(e) {
console.error("build cache failed", e);
});
}
function initializeExplorerView() {
loadPlot(true);
}
function loadAllImagesInChunks() {
var total = scatterSubsetRowCount();
return preloadImagesInChunksToTarget({
finalTarget: total,
scatterTotalForStepping: total,
overlayLabel: "Loading all " + fmtInt(total) + " plotted particle images...",
enableImages: true,
restrictToScatterPlot: true,
suppressHighlightsDuring: true,
fullLoad: true,
clearStatusAtStart: true
})
.catch(function(e) {
console.error("load all images failed", e);
});
}
if (btnExpandCache) {
btnExpandCache.addEventListener("click", function() {
if (!preloaded) {
buildInitialImageCache();
} else {
expandImageCacheToTarget();
}
});
}
if (btnClearImageCache) {
btnClearImageCache.addEventListener("click", function() {
clearExplorerImageCache();
});
}
if (btnCacheSelectionUncached) {
btnCacheSelectionUncached.addEventListener("click", function() {
loadUncachedSelectionIntoCache();
});
}
var montageCacheSizeInput = document.getElementById("montage-cache-size-input");
if (montageCacheSizeInput) {
var montageCacheOverlayBusy = function() {
return !!(montagePreloadOverlayEl &&
montagePreloadOverlayEl.classList.contains("cryo-plot-rendering-overlay--show"));
};
montageCacheSizeInput.addEventListener("input", function() {
syncExpandCacheButton(montageCacheOverlayBusy());
});
montageCacheSizeInput.addEventListener("change", function() {
syncMontageNewCacheSizeField();
syncExpandCacheButton(montageCacheOverlayBusy());
});
montageCacheSizeInput.addEventListener("blur", function() {
syncMontageNewCacheSizeField();
syncExpandCacheButton(montageCacheOverlayBusy());
});
}
btnViewImages.addEventListener("click", function() {
if (imagesViewEnabled && preloaded && montageDisplayMode === "volumes" && allImagesCached()) {
showImagesInMontageFromCache();
return;
}
if (imagesViewEnabled && preloaded && allImagesCached()) {
gridSizeSelect.disabled = false;
setImageGridMenuOpen(true);
buildTraceMap();
if (montageDisplayMode === "images") {
restoreMontageAfterScatterReload();
}
if (preloadStatus && preloaded.rows) {
setPreloadStatusCachedTotalOnly();
preloadStatus.title = "Images already in cache (no reload).";
}
syncVolumeExploreButtons();
return;
}
btnViewImages.disabled = true;
loadAllImagesInChunks();
});
function postVolumeStatic() {
return fetch("{{ url_for('api_explorer_volume_media') }}", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
rows: lastMontageRows.slice(),
mode: "static",
chimerax_cpus: EXPLORER_CHIMERAX_CPUS,
}),
})
.then(function(r) { return r.json().then(function(j) { return { ok: r.ok, j: j }; }); })
.then(function(res) {
if (!res.ok) {
if (res.j.need_chimerax) {
window.alert(res.j.error || "Set CHIMERAX_PATH to your ChimeraX executable and try again.");
setVolumeStatus("", false);
} else {
setVolumeStatus(res.j.error || "Volume request failed.", false);
}
return null;
}
return res.j;
});
}
function postVolumeAnimateCell(cacheId, cellIndex) {
return fetch("{{ url_for('api_explorer_volume_media') }}", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "animate",
volume_cache_id: cacheId,
cell_index: cellIndex,
rows: lastMontageRows.slice(),
chimerax_cpus: EXPLORER_CHIMERAX_CPUS,
}),
})
.then(function(r) { return r.json().then(function(j) { return { ok: r.ok, j: j }; }); })
.then(function(res) {
if (!res.ok) {
if (res.j.need_chimerax) {
window.alert(res.j.error || "Set CHIMERAX_PATH to your ChimeraX executable and try again.");
setVolumeStatus("", false);
} else {
setVolumeStatus(res.j.error || "Animate request failed.", false);
}
return null;
}
return res.j;
});
}
function runVolumeAnimateSequential() {
var key = montageRowsOrderKey();
if (!volumeCacheId || !lastMontageRows.length || volumeStaticKey !== key) return;
var n = lastMontageRows.length;
volumeAnimateGeneration++;
var myGen = volumeAnimateGeneration;
clearVolumeGifSyncTimers();
volumeGifLeaderStartMs = null;
btnVolumeAnimate.disabled = true;
if (btnVolumeGenerate) btnVolumeGenerate.disabled = true;
volumeGifDataUrls = new Array(n);
volumeGifsKey = null;
montageDisplayMode = "volumes";
showVolumesInMontage();
var i = 0;
function step() {
if (myGen !== volumeAnimateGeneration) return;
if (i >= n) {
volumeGifsKey = key;
setVolumeStatus("", false);
syncVolumeExploreButtons();
return;
}
var cellIndex = i;
setVolumeStatus("GIF " + (cellIndex + 1) + " / " + n + "…", false);
postVolumeAnimateCell(volumeCacheId, cellIndex)
.then(function(j) {
if (myGen !== volumeAnimateGeneration) return;
if (!j || !j.image) {
syncVolumeExploreButtons();
return;
}
var url = "data:image/gif;base64," + j.image;
volumeGifDataUrls[cellIndex] = url;
var showP;
if (cellIndex === 0) {
showP = startMontageGifLeader(cellIndex, url, myGen);
} else if (volumeGifLeaderStartMs != null) {
showP = showMontageCellGifSynced(
cellIndex, url, volumeGifLeaderStartMs, myGen
);
} else {
showP = Promise.resolve();
}
showP.catch(function() {
if (myGen === volumeAnimateGeneration) {
setVolumeStatus("Could not show volume GIF.", false);
}
}).then(function() {
if (myGen !== volumeAnimateGeneration) return;
i++;
step();
});
})
.catch(function() {
if (myGen === volumeAnimateGeneration) {
setVolumeStatus("Request failed.", false);
}
syncVolumeExploreButtons();
});
}
step();
}
if (showVolumeExplorer && btnVolumeGenerate) {
btnVolumeGenerate.addEventListener("click", function() {
if (!plotHasScatterData()) return;
if (!lastMontageRows.length) {
ensureHighlightTrace();
var picked = pickRandomMontageItemsFilled(neighborK);
if (!picked.length) return;
gridSizeSelect.disabled = false;
updateMontage(picked);
}
if (!lastMontageRows.length) return;
var key = montageRowsOrderKey();
btnVolumeGenerate.disabled = true;
if (btnVolumeAnimate) btnVolumeAnimate.disabled = true;
setVolumeStatus("Decoding volumes and rendering (ChimeraX)…", true);
postVolumeStatic()
.then(function(j) {
if (!j || !j.images || !j.volume_cache_id) {
syncVolumeExploreButtons();
return;
}
volumeStaticDataUrls = j.images.map(function(b) { return "data:image/png;base64," + b; });
volumeStaticKey = key;
volumeGifDataUrls = null;
volumeGifsKey = null;
volumeCacheId = j.volume_cache_id;
showVolumesInMontage();
setVolumeStatus("", false);
})
.catch(function() {
setVolumeStatus("Request failed.", false);
})
.then(function() { syncVolumeExploreButtons(); });
});
}
if (showVolumeExplorer && btnVolumeAnimate) {
btnVolumeAnimate.addEventListener("click", function() {
runVolumeAnimateSequential();
});
}
function scatterTraceColorMode() {
var meta = gd.layout && gd.layout.meta;
var m = meta && meta.cdrgn_color_mode;
if (m === "discrete" || m === "continuous") return m;
var mc = gd.data && gd.data[0] && gd.data[0].marker && gd.data[0].marker.color;
if (Array.isArray(mc) && mc.length && typeof mc[0] === "string" && /^#/.test(String(mc[0]))) {
return "discrete";
}
return "continuous";
}
function escapeHtmlText(s) {
return String(s).replace(/[&<>"']/g, function(ch) {
return {"&": "&", "<": "<", ">": ">", "\"": """, "'": "'"}[ch];
});
}
function traceColorNumericValue(ti) {
if (!gd || !gd.data || !gd.data[0]) return null;
var trace = gd.data[0];
var markerColors = trace.marker ? trace.marker.color : null;
if (Array.isArray(markerColors)) {
var mv = Number(markerColors[ti]);
if (isFinite(mv)) return mv;
}
var cd = trace.customdata;
if (cd && cd[ti] && cd[ti].length > 2) {
var dv = Number(cd[ti][2]);
if (isFinite(dv)) return dv;
}
return null;
}
function currentColorValueItems() {
if (!gd || !gd.data || !gd.data[0] || !gd.data[0].customdata) return [];
if (!sc.value || sc.value === "none") return [];
var cd = gd.data[0].customdata;
var out = [];
for (var i = 0; i < cd.length; i++) {
var row = cd[i] && cd[i].length > 1 ? cd[i][1] : null;
var val = traceColorNumericValue(i);
if (row != null && val != null) out.push({ti: i, row: row, value: val});
}
return out;
}
function syncParticleExplorerRowExpandForColorHist() {
window.requestAnimationFrame(function() {
syncScatterControlsAlignment();
scheduleSyncMontageGridRegionLayout();
try {
if (gd && gd.data && gd.data.length) Plotly.Plots.resize(gd);
} catch (e) {}
if (colorHistPanel && !colorHistPanel.hidden && colorHistDiv) {
try {
Plotly.Plots.resize(colorHistDiv);
} catch (e2) {}
}
equalizeMontageActionButtonWidths();
if (explorerColorLegend) {
if (typeof explorerColorLegend.fitDiscreteSwitchColumnWidths === "function") {
explorerColorLegend.fitDiscreteSwitchColumnWidths();
}
if (typeof explorerColorLegend.fitDiscreteLegendScrollRegion === "function") {
explorerColorLegend.fitDiscreteLegendScrollRegion();
}
if (typeof explorerColorLegend.measureDiscreteInvertAsideColumn === "function") {
explorerColorLegend.measureDiscreteInvertAsideColumn();
}
}
});
}
function hideColorHistogram() {
if (explorerColorLegend) {
explorerColorLegend.hide();
}
if (colorHistPanel) {
colorHistPanel.hidden = true;
colorHistPanel.setAttribute("aria-hidden", "true");
}
if (colorThresholdStatus) colorThresholdStatus.textContent = "";
if (colorContinuousWrap) colorContinuousWrap.hidden = false;
if (colorDiscreteWrap) {
colorDiscreteWrap.hidden = true;
colorDiscreteWrap.classList.remove("cryo-color-discrete-wrap--show");
colorDiscreteWrap.classList.remove("cryo-cc-discrete-wrap--show");
}
if (colorDiscreteSwitches) colorDiscreteSwitches.innerHTML = "";
if (colorDiscreteWrap) colorDiscreteWrap.classList.remove("cryo-discrete-legend--collapsed");
syncDiscreteInvertSelectionButton();
syncParticleExplorerRowExpandForColorHist();
}
function discreteCategoryKeyFromCustomRow(cdrow) {
if (!cdrow || cdrow.length < 3) return "__na__";
var v = cdrow[2];
if (v == null || v === "") return "__na__";
if (typeof v === "number" && !isFinite(v)) return "__na__";
return String(v);
}
function syncDiscreteInvertSelectionButton() {
if (!btnDiscreteInvertSelection || !colorDiscreteSwitches) return;
var inputs = colorDiscreteSwitches.querySelectorAll("input[type=\"checkbox\"]");
var anyChecked = false;
for (var sx = 0; sx < inputs.length; sx++) {
if (inputs[sx].checked) {
anyChecked = true;
break;
}
}
if (!anyChecked) {
btnDiscreteInvertSelection.disabled = true;
btnDiscreteInvertSelection.textContent = "no selection made!";
return;
}
btnDiscreteInvertSelection.disabled = false;
btnDiscreteInvertSelection.textContent = "Invert selection";
}
function explorerApplyDiscreteMarkerColoursFromOverrides() {
if (!gd || !gd.data || !gd.data[0] || scatterTraceColorMode() !== "discrete") return;
var tr = gd.data[0];
var cd = tr.customdata || [];
var mcSrc = tr.marker && tr.marker.color ? tr.marker.color : null;
if (!cd.length || !Array.isArray(mcSrc) || mcSrc.length !== cd.length) return;
var mc = mcSrc.slice();
var changed = false;
for (var pi = 0; pi < cd.length; pi++) {
var pk = discreteCategoryKeyFromCustomRow(cd[pi]);
var cand = explorerDiscreteLabelColors[String(pk)];
var hx =
cand
? CryoColorCovariateLegend.normalizeDiscreteLegendHex(String(cand))
: "";
if (hx && mc[pi] !== hx) {
mc[pi] = hx;
changed = true;
}
}
if (!changed) return;
try {
Plotly.restyle(gd, { "marker.color": [mc] }, [0]);
} catch (re0) {}
explorerMontageReloadAfterDiscretePaletteEdit();
}
function explorerMontageReloadAfterDiscretePaletteEdit() {
if (!lastMontageRows || !lastMontageRows.length) return;
var items = [];
for (var mm = 0; mm < lastMontageRows.length; mm++) {
var mw = montageItemForRow(lastMontageRows[mm]);
if (mw) items.push(mw);
}
if (items.length) updateMontage(items);
}
function explorerLegendContextData() {
if (!gd || !gd.data || !gd.data[0] || !sc.value || sc.value === "none") return {};
if (scatterTraceColorMode() === "discrete") {
var meta = gd.layout && gd.layout.meta;
var datasetCounts = (meta && meta.cdrgn_discrete_category_counts) || {};
var cd = gd.data[0].customdata || [];
var markerColors = (gd.data[0].marker && gd.data[0].marker.color) || null;
var colorByKey = {};
var tracedCountByKey = {};
for (var i = 0; i < cd.length; i++) {
var fk = discreteCategoryKeyFromCustomRow(cd[i]);
tracedCountByKey[fk] = (tracedCountByKey[fk] || 0) + 1;
if (!colorByKey[fk] && Array.isArray(markerColors)) {
var cval = markerColors[i];
if (typeof cval === "string" && /^#/.test(cval)) colorByKey[fk] = cval;
}
}
var keyUnion = {};
var kk;
for (kk in datasetCounts) {
if (Object.prototype.hasOwnProperty.call(datasetCounts, kk)) keyUnion[kk] = true;
}
for (kk in tracedCountByKey) {
if (Object.prototype.hasOwnProperty.call(tracedCountByKey, kk)) keyUnion[kk] = true;
}
var allKeys = Object.keys(keyUnion);
var sortFn =
CryoColorCovariateLegend.sortDiscreteKeys ||
function (keys) {
return keys.slice().sort();
};
var sortedKeys = sortFn(allKeys);
var cats = [];
for (var j = 0; j < sortedKeys.length; j++) {
var k = sortedKeys[j];
var cnt =
datasetCounts[k] != null
? Number(datasetCounts[k])
: tracedCountByKey[k] || 0;
var colHx = colorByKey[k];
var ovh = explorerDiscreteLabelColors[String(k)];
if (ovh) colHx = String(ovh);
cats.push({ key: k, color: colHx || "#aab4bf", count: cnt });
}
return {mode: "discrete", categories: cats};
}
return {
mode: "continuous",
values: currentColorValueItems().map(function(item) { return item.value; })
};
}
function setExplorerLegendDiscreteKeysForSelection() {
if (!explorerColorLegend || !gd || !gd.data || !gd.data[0] || scatterTraceColorMode() !== "discrete") return;
var cd = gd.data[0].customdata || [];
var sel = new Set(saveSelectionRows || []);
var keyToRows = new Map();
for (var i = 0; i < cd.length; i++) {
var k = discreteCategoryKeyFromCustomRow(cd[i]);
var row = cd[i][1];
if (!keyToRows.has(k)) keyToRows.set(k, []);
keyToRows.get(k).push(row);
}
var checkedKeys = [];
keyToRows.forEach(function(rows, key) {
if (rows.length && rows.every(function(r) { return sel.has(r); })) checkedKeys.push(key);
});
explorerColorLegend.setDiscreteCheckedKeys(checkedKeys);
}
function applyExplorerDiscreteFilter(filter) {
if (!gd || !gd.data || !gd.data[0] || scatterTraceColorMode() !== "discrete") return;
var keys = filter && Array.isArray(filter.keys) ? filter.keys : [];
var keySet = new Set(keys.map(function(k) { return String(k); }));
clearScatterGeometricSelectionSuppressed();
colorThresholdLevel = null;
colorThresholdRange = null;
colorThresholdColorCol = null;
if (colorThresholdStatus) colorThresholdStatus.textContent = "";
if (!keySet.size) {
applyRowsSelection([], "Discrete");
return;
}
var cd = gd.data[0].customdata || [];
var rows = [];
for (var i = 0; i < cd.length; i++) {
var k = discreteCategoryKeyFromCustomRow(cd[i]);
if (keySet.has(k)) rows.push(cd[i][1]);
}
applyRowsSelection(rows, "Discrete");
setExplorerLegendDiscreteKeysForSelection();
}
function applyExplorerContinuousFilter(filter) {
var items = currentColorValueItems();
if (!filter || !items.length) return;
cancelLassoSelectionDebounce();
if (explorerColorLegend) explorerColorLegend.setDiscreteCheckedKeys([]);
clearScatterGeometricSelectionSuppressed();
var body = {column: sc.value};
var fallbackRows;
if (filter.kind === "threshold") {
var level = Number(filter.level);
if (!isFinite(level)) return;
colorThresholdLevel = level;
colorThresholdRange = null;
colorThresholdColorCol = sc.value;
body.level = level;
body.use_max = !!filter.use_max;
fallbackRows = function() {
return rowsMatchingColorThresholdFromTrace(items, level, !!filter.use_max);
};
} else if (filter.kind === "range") {
var lo = Number(filter.range_min);
var hi = Number(filter.range_max);
if (!isFinite(lo) || !isFinite(hi)) return;
var loN = Math.min(lo, hi);
var hiN = Math.max(lo, hi);
colorThresholdLevel = null;
colorThresholdRange = {lo: loN, hi: hiN};
colorThresholdColorCol = sc.value;
body.range_min = loN;
body.range_max = hiN;
body.invert_range = !!filter.invert_range;
fallbackRows = function() {
return rowsMatchingColorRangeFromTrace(items, loN, hiN, !!filter.invert_range);
};
} else {
return;
}
syncColorHistModeCheckboxCaption();
fetch("{{ url_for('api_covariate_threshold_rows') }}", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(body)
})
.then(function(r) {
return r.json().then(function(j) { return {ok: r.ok, j: j}; });
})
.then(function(res) {
var rows = (res.ok && res.j && Array.isArray(res.j.rows)) ? res.j.rows : fallbackRows();
applyRowsSelection(rows, "Color threshold");
})
.catch(function() {
applyRowsSelection(fallbackRows(), "Color threshold");
});
}