function enqueueTrajGifScatterCapture(task) { var run = trajGifScatterCaptureChain.then(function() { return task(); }); trajGifScatterCaptureChain = run.catch(function() {}); return run; } function stashTrajGifLiveLayoutBaseline() { if (!trajGifLiveLayoutBaseline) { trajGifLiveLayoutBaseline = snapshotTrajGifScatterLayout(); } } function restoreTrajGifLiveLayoutBaseline() { if (!trajGifLiveLayoutBaseline) return Promise.resolve(); var snap = trajGifLiveLayoutBaseline; trajGifLiveLayoutBaseline = null; return restoreTrajGifScatterLayout(snap, { skipHighlight: true }); } function trajGifScatterStackDims() { if (trajGifLockedScatterSize) return trajGifLockedScatterSize; return { w: (scatterPlotStack && scatterPlotStack.offsetWidth) || (gd && gd.offsetWidth) || 640, h: (scatterPlotStack && scatterPlotStack.offsetHeight) || (gd && gd.offsetHeight) || 480 }; } function snapshotTrajGifPlotStackMap() { if (!gd || !gd._fullLayout || !gd._fullLayout.xaxis || !gd._fullLayout.yaxis || !scatterPlotStack) { return null; } var plotArea = gd.querySelector(".nsewdrag"); if (!plotArea) return null; var plotRect = plotArea.getBoundingClientRect(); var stackRect = scatterPlotStack.getBoundingClientRect(); var xr = gd._fullLayout.xaxis.range; var yr = gd._fullLayout.yaxis.range; if (!xr || xr.length < 2 || !yr || yr.length < 2) return null; if (xr[1] === xr[0] || yr[1] === yr[0]) return null; return { plotLeft: plotRect.left - stackRect.left, plotTop: plotRect.top - stackRect.top, plotW: plotRect.width, plotH: plotRect.height, xr: xr.slice(), yr: yr.slice() }; } function ensureTrajGifPlotStackMap() { if (trajGifPlotStackMap) return trajGifPlotStackMap; trajGifPlotStackMap = snapshotTrajGifPlotStackMap(); return trajGifPlotStackMap; } function trajGifPanelTargetSize() { var ref = trajGifVolumeDisplayRegionRect(); return { w: Math.max(64, Math.round(ref.w * TRAJ_GIF_PANEL_SCALE)), h: Math.max(64, Math.round(ref.h * TRAJ_GIF_PANEL_SCALE)) }; } function trajGifStackPixelScale(exportW) { var dims = trajGifScatterStackDims(); return exportW / Math.max(1, dims.w); } function trajGifPlotLayoutMetrics(exportW, exportH) { return { drawScale: trajGifStackPixelScale(exportW) }; } function dataToTrajGifExportXY(dataX, dataY, exportW, exportH) { var m = trajGifPlotStackMap || ensureTrajGifPlotStackMap(); if (m) { var fracX = (dataX - m.xr[0]) / (m.xr[1] - m.xr[0]); var fracY = (m.yr[1] - dataY) / (m.yr[1] - m.yr[0]); var s = trajGifStackPixelScale(exportW); return [ (m.plotLeft + fracX * m.plotW) * s, (m.plotTop + fracY * m.plotH) * s ]; } var pt = dataToPlotStackXY(dataX, dataY); if (!pt) return null; var sFallback = trajGifStackPixelScale(exportW); return [pt[0] * sFallback, pt[1] * sFallback]; } function loadTrajGifPngB64(b64) { return new Promise(function(resolve, reject) { var img = new Image(); img.onload = function() { resolve(img); }; img.onerror = function() { reject(new Error("Could not load PNG frame.")); }; img.src = "data:image/png;base64," + b64; }); } function trajGifPngContentBounds(imageData, imgW, imgH) { var bgR = TRAJ_GIF_BG_RGB[0]; var bgG = TRAJ_GIF_BG_RGB[1]; var bgB = TRAJ_GIF_BG_RGB[2]; var tol = 14; var minX = imgW; var minY = imgH; var maxX = 0; var maxY = 0; var found = false; for (var y = 0; y < imgH; y++) { for (var x = 0; x < imgW; x++) { var idx = (y * imgW + x) * 4; var r = imageData[idx]; var g = imageData[idx + 1]; var b = imageData[idx + 2]; var a = imageData[idx + 3]; if (a < 8) continue; if (Math.abs(r - bgR) <= tol && Math.abs(g - bgG) <= tol && Math.abs(b - bgB) <= tol) continue; found = true; if (x < minX) minX = x; if (y < minY) minY = y; if (x > maxX) maxX = x; if (y > maxY) maxY = y; } } if (!found) return null; return { minX: minX, minY: minY, maxX: maxX, maxY: maxY }; } function unionTrajGifContentBounds(a, b) { if (!a) { return b ? { minX: b.minX, minY: b.minY, maxX: b.maxX, maxY: b.maxY } : null; } if (!b) return { minX: a.minX, minY: a.minY, maxX: a.maxX, maxY: a.maxY }; return { minX: Math.min(a.minX, b.minX), minY: Math.min(a.minY, b.minY), maxX: Math.max(a.maxX, b.maxX), maxY: Math.max(a.maxY, b.maxY) }; } function trajGifBoundsToCropRect(bounds, imgW, imgH, paddingFrac) { if (!bounds || imgW <= 0 || imgH <= 0) return null; paddingFrac = (paddingFrac == null) ? TRAJ_GIF_VOLUME_PADDING : paddingFrac; var cw = bounds.maxX - bounds.minX + 1; var ch = bounds.maxY - bounds.minY + 1; var padX = Math.max(1, Math.round(cw * paddingFrac)); var padY = Math.max(1, Math.round(ch * paddingFrac)); var cropX = Math.max(0, bounds.minX - padX); var cropY = Math.max(0, bounds.minY - padY); return { x: cropX, y: cropY, w: Math.min(imgW - cropX, cw + (2 * padX)), h: Math.min(imgH - cropY, ch + (2 * padY)) }; } function trajGifPngContentCropRect(imageData, imgW, imgH, paddingFrac) { return trajGifBoundsToCropRect( trajGifPngContentBounds(imageData, imgW, imgH), imgW, imgH, paddingFrac ); } function computeTrajGifVolumeFrameBoundsList(volumeImages) { if (!volumeImages || !volumeImages.length) return Promise.resolve([]); var boundsList = new Array(volumeImages.length); var chain = Promise.resolve(); for (var i = 0; i < volumeImages.length; i++) { (function(idx, b64) { chain = chain.then(function() { return loadTrajGifPngB64(b64).then(function(img) { var canvas = document.createElement("canvas"); canvas.width = img.width; canvas.height = img.height; var ctx = canvas.getContext("2d"); if (!ctx) { boundsList[idx] = null; return; } ctx.drawImage(img, 0, 0); var data = ctx.getImageData(0, 0, img.width, img.height); boundsList[idx] = trajGifPngContentBounds(data.data, img.width, img.height); }); }); })(i, volumeImages[i]); } return chain.then(function() { return boundsList; }); } function trajGifContentSizeFromBounds(bounds) { if (!bounds) return { w: 0, h: 0 }; return { w: bounds.maxX - bounds.minX + 1, h: bounds.maxY - bounds.minY + 1 }; } function computeTrajGifLockedVolumeContentScale(frameBoundsList, panelW, panelH, paddingFrac) { if (!frameBoundsList || !frameBoundsList.length) return null; paddingFrac = (paddingFrac == null) ? TRAJ_GIF_VOLUME_PADDING : paddingFrac; var union = null; for (var i = 0; i < frameBoundsList.length; i++) { union = unionTrajGifContentBounds(union, frameBoundsList[i]); } if (!union) return null; var maxW = union.maxX - union.minX + 1; var maxH = union.maxY - union.minY + 1; if (maxW < 1 || maxH < 1) return null; var availW = panelW * (1 - (2 * paddingFrac)); var availH = panelH * (1 - (2 * paddingFrac)); return { scale: Math.min(availW / maxW, availH / maxH), maxContentW: maxW, maxContentH: maxH, // Shared crop window so every frame has identical whitespace dimensions. unionBounds: { minX: union.minX, minY: union.minY, maxX: union.maxX, maxY: union.maxY } }; } function lockTrajGifVolumeAlignment(volumeImages, panel) { clearTrajGifVolumeAlignmentLocks(); return computeTrajGifVolumeFrameBoundsList(volumeImages).then(function(boundsList) { trajGifLockedVolumeFrameBounds = boundsList; trajGifLockedVolumeContentScale = computeTrajGifLockedVolumeContentScale( boundsList, panel.w, panel.h, TRAJ_GIF_VOLUME_PADDING ); }); } function cropTrajGifImageToCanvas(img, rect) { if (!rect || rect.w <= 0 || rect.h <= 0) { var full = document.createElement("canvas"); full.width = img.width; full.height = img.height; var fctx = full.getContext("2d"); if (!fctx) throw new Error("Could not crop GIF frame."); fctx.drawImage(img, 0, 0); return full; } var cropX = Math.max(0, Math.min(rect.x, img.width - 1)); var cropY = Math.max(0, Math.min(rect.y, img.height - 1)); var cropW = Math.max(1, Math.min(rect.w, img.width - cropX)); var cropH = Math.max(1, Math.min(rect.h, img.height - cropY)); var out = document.createElement("canvas"); out.width = cropW; out.height = cropH; var octx = out.getContext("2d"); if (!octx) throw new Error("Could not crop GIF frame."); octx.fillStyle = "#faf8f4"; octx.fillRect(0, 0, cropW, cropH); octx.drawImage(img, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH); return out; } function cropTrajGifImageToRect(img, rect) { return gifDataUrlToBareB64(cropTrajGifImageToCanvas(img, rect).toDataURL("image/png")); } function cropTrajGifPngB64ToContent(b64, paddingFrac) { paddingFrac = (paddingFrac == null) ? TRAJ_GIF_VOLUME_PADDING : paddingFrac; return loadTrajGifPngB64(b64).then(function(img) { var canvas = document.createElement("canvas"); canvas.width = img.width; canvas.height = img.height; var ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Could not crop GIF frame."); ctx.drawImage(img, 0, 0); var data = ctx.getImageData(0, 0, img.width, img.height); var rect = trajGifPngContentCropRect(data.data, img.width, img.height, paddingFrac); return cropTrajGifImageToRect(img, rect); }); } function cropVolumePngB64ToContent(b64, paddingFrac) { return cropTrajGifPngB64ToContent(b64, paddingFrac); } function panelCanvasFromCroppedVolume(croppedCanvas, panelW, panelH, fit) { var canvas = document.createElement("canvas"); canvas.width = panelW; canvas.height = panelH; var ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Could not scale GIF panel."); ctx.fillStyle = "#faf8f4"; ctx.fillRect(0, 0, panelW, panelH); if (fit) { ctx.drawImage(croppedCanvas, fit.dx, fit.dy, fit.dw, fit.dh); } else { var scale = Math.min(panelW / croppedCanvas.width, panelH / croppedCanvas.height); var dw = Math.max(1, Math.round(croppedCanvas.width * scale)); var dh = Math.max(1, Math.round(croppedCanvas.height * scale)); var dx = Math.round((panelW - dw) / 2); var dy = Math.round((panelH - dh) / 2); ctx.drawImage(croppedCanvas, dx, dy, dw, dh); } return gifDataUrlToBareB64(canvas.toDataURL("image/png")); } function scalePngB64ToPanel(b64, panelW, panelH) { return loadTrajGifPngB64(b64).then(function(img) { var canvas = document.createElement("canvas"); canvas.width = img.width; canvas.height = img.height; var ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Could not scale GIF panel."); ctx.drawImage(img, 0, 0); return panelCanvasFromCroppedVolume(canvas, panelW, panelH, null); }); } function renderVolumeFrameForGif(img, bounds, placement, panelW, panelH) { var canvas = document.createElement("canvas"); canvas.width = panelW; canvas.height = panelH; var ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Could not render GIF volume frame."); ctx.fillStyle = "#faf8f4"; ctx.fillRect(0, 0, panelW, panelH); if (!placement || !placement.scale) { return gifDataUrlToBareB64(canvas.toDataURL("image/png")); } // Only crop when a shared union window was locked for the batch. Per-frame // content trims produce different whitespace margins and break centering // across ChimeraX trajectory frames in the volume viewer / GIF. var crop = placement.unionBounds || null; var minX, minY, cropW, cropH; if (crop) { minX = Math.max(0, crop.minX); minY = Math.max(0, crop.minY); cropW = Math.max(1, Math.min(crop.maxX - crop.minX + 1, img.width - minX)); cropH = Math.max(1, Math.min(crop.maxY - crop.minY + 1, img.height - minY)); } else { minX = 0; minY = 0; cropW = img.width; cropH = img.height; } var dw = cropW * placement.scale; var dh = cropH * placement.scale; var dx = (panelW - dw) / 2; var dy = (panelH - dh) / 2; ctx.drawImage(img, minX, minY, cropW, cropH, dx, dy, dw, dh); return gifDataUrlToBareB64(canvas.toDataURL("image/png")); } function prepareVolumeFrameForGif(b64, panel, frameIndex) { return loadTrajGifPngB64(b64).then(function(img) { var bounds = (trajGifLockedVolumeFrameBounds && frameIndex != null) ? trajGifLockedVolumeFrameBounds[frameIndex] : null; return renderVolumeFrameForGif( img, bounds, trajGifLockedVolumeContentScale, panel.w, panel.h ); }); } function stripTrajGifGlyphLabelHtml(html) { return String(html || "").replace(/<[^>]+>/g, ""); } function drawTrajGlyphPathOnGifCanvas(ctx, exportW, exportH) { var pathPts = trajectoryOverlayPathPoints(); if (!pathPts || pathPts.length < 2) return; var metrics = trajGifPlotLayoutMetrics(exportW, exportH); if (!metrics) return; var drawScale = metrics.drawScale || 1; var linePts = []; for (var pi = 0; pi < pathPts.length; pi++) { var pt = dataToTrajGifExportXY(pathPts[pi][0], pathPts[pi][1], exportW, exportH); if (pt) linePts.push(pt); } if (linePts.length < 2) return; ctx.save(); ctx.strokeStyle = "#c4703a"; ctx.lineWidth = 3 * drawScale; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.setLineDash([7 * drawScale, 5 * drawScale]); ctx.shadowColor = "rgba(255, 255, 255, 0.85)"; ctx.shadowBlur = 1 * drawScale; ctx.beginPath(); ctx.moveTo(linePts[0][0], linePts[0][1]); for (var li = 1; li < linePts.length; li++) { ctx.lineTo(linePts[li][0], linePts[li][1]); } ctx.stroke(); ctx.restore(); } function trajGifGlyphLabelFontSize(ctx, label, sizePx, borderW) { // Largest bold digit size whose text bbox still fits inside the circle. var innerDiam = Math.max(4, sizePx - (2 * borderW) - 1); var maxR = innerDiam * 0.5; var maxFs = Math.max(6, Math.floor(innerDiam)); for (var fs = maxFs; fs >= 6; fs--) { ctx.font = "700 " + fs + 'px "Barlow", sans-serif'; var tw = ctx.measureText(label).width; var th = fs * 0.82; var halfDiag = 0.5 * Math.sqrt((tw * tw) + (th * th)); if (halfDiag <= maxR) return fs; } return 6; } function drawTrajGlyphSpecOnGifCanvas(ctx, spec, exportW, exportH) { var pt = dataToTrajGifExportXY(spec.x, spec.y, exportW, exportH); if (!pt) return; var metrics = trajGifPlotLayoutMetrics(exportW, exportH); var drawScale = (metrics && metrics.drawScale) ? metrics.drawScale : 1; var gifNeutral = trajGifGenerationActive(); var sizePx = Number(spec.size) || TRAJ_MARKER_SIZE_WAYPOINT; if (!gifNeutral && spec.focused) { sizePx = Math.max(sizePx + 4, Math.round(sizePx * 1.2)); } sizePx *= drawScale; var color = spec.color || "#243b53"; var fillColor = color; if (spec.focused) { fillColor = trajGlyphFocusHighlightColor(color); } var borderW = (spec.focused ? 1 : 1.25) * drawScale; var half = sizePx * 0.5; ctx.save(); ctx.fillStyle = fillColor; ctx.strokeStyle = spec.focused ? "#000000" : "#ffffff"; ctx.lineWidth = borderW; ctx.beginPath(); ctx.arc(pt[0], pt[1], half, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.restore(); var label = stripTrajGifGlyphLabelHtml(spec.labelHtml); if (!label) return; var fontSize = trajGifGlyphLabelFontSize(ctx, label, sizePx, borderW); ctx.save(); ctx.font = "700 " + fontSize + 'px "Barlow", sans-serif'; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillStyle = "#ffffff"; ctx.fillText(label, pt[0], pt[1] + (fontSize * 0.04)); ctx.restore(); } function drawTrajGlyphsOnGifCanvas(ctx, exportW, exportH) { drawTrajGlyphPathOnGifCanvas(ctx, exportW, exportH); var specs = collectTrajGlyphSpecs(); for (var gi = 0; gi < specs.length; gi++) { drawTrajGlyphSpecOnGifCanvas(ctx, specs[gi], exportW, exportH); } } function trajGifCloneScaledFont(font, baseSize) { var out = {}; if (font && typeof font === "object") { Object.keys(font).forEach(function(key) { out[key] = font[key]; }); } var sz = (out.size != null) ? Number(out.size) : Number(baseSize || 15); if (!isFinite(sz)) sz = Number(baseSize || 15); out.size = Math.max(8, Math.round(sz * TRAJ_GIF_AXIS_FONT_MULT)); if (!out.family) out.family = "Barlow, sans-serif"; if (!out.color) out.color = "#243b53"; return out; } function trajGifScatterDataPoints() { var pts = []; function addPt(x, y) { x = Number(x); y = Number(y); if (isFinite(x) && isFinite(y)) pts.push([x, y]); } if (gd && gd.data && gd.data[0]) { var tr = gd.data[0]; var n = plotlyArrayLength(tr.x); for (var i = 0; i < n; i++) { addPt(plotlyAt(tr.x, i), plotlyAt(tr.y, i)); } } var pathPts = trajectoryOverlayPathPoints(); if (pathPts) { for (var j = 0; j < pathPts.length; j++) { addPt(pathPts[j][0], pathPts[j][1]); } } return pts; } function trajGifScatterDataRange(paddingFrac) { var pts = trajGifScatterDataPoints(); if (!pts.length) return null; var minX = Infinity; var maxX = -Infinity; var minY = Infinity; var maxY = -Infinity; for (var i = 0; i < pts.length; i++) { minX = Math.min(minX, pts[i][0]); maxX = Math.max(maxX, pts[i][0]); minY = Math.min(minY, pts[i][1]); maxY = Math.max(maxY, pts[i][1]); } var spanX = maxX - minX; var spanY = maxY - minY; if (!isFinite(spanX) || spanX <= 0) spanX = 1; if (!isFinite(spanY) || spanY <= 0) spanY = 1; var padX = spanX * paddingFrac; var padY = spanY * paddingFrac; return { x: [minX - padX, maxX + padX], y: [minY - padY, maxY + padY] }; } function snapshotTrajGifScatterLayout() { var fl = gd && gd._fullLayout; if (!fl) return null; return { xRange: fl.xaxis && fl.xaxis.range ? fl.xaxis.range.slice() : null, yRange: fl.yaxis && fl.yaxis.range ? fl.yaxis.range.slice() : null, xAutorange: !!(fl.xaxis && fl.xaxis.autorange), yAutorange: !!(fl.yaxis && fl.yaxis.autorange), xTitleFont: JSON.parse(JSON.stringify( (fl.xaxis && fl.xaxis.title && fl.xaxis.title.font) || {} )), yTitleFont: JSON.parse(JSON.stringify( (fl.yaxis && fl.yaxis.title && fl.yaxis.title.font) || {} )), xTickFont: JSON.parse(JSON.stringify((fl.xaxis && fl.xaxis.tickfont) || {})), yTickFont: JSON.parse(JSON.stringify((fl.yaxis && fl.yaxis.tickfont) || {})), margin: JSON.parse(JSON.stringify(fl.margin || {})) }; } function restoreTrajGifScatterLayout(snap, opts) { opts = opts || {}; if (!snap || !gd || typeof Plotly === "undefined" || !Plotly.relayout) { return Promise.resolve(); } var patch = { "xaxis.title.font": snap.xTitleFont || {}, "yaxis.title.font": snap.yTitleFont || {}, "xaxis.tickfont": snap.xTickFont || {}, "yaxis.tickfont": snap.yTickFont || {} }; if (snap.margin) { patch.margin = snap.margin; } if (snap.xAutorange) patch["xaxis.autorange"] = true; else if (snap.xRange) patch["xaxis.range"] = snap.xRange.slice(); if (snap.yAutorange) patch["yaxis.autorange"] = true; else if (snap.yRange) patch["yaxis.range"] = snap.yRange.slice(); return Plotly.relayout(gd, patch).catch(function() {}).then(function() { syncTrajGlyphOverlay(); if (opts.skipHighlight) return waitGifPaintFrames(2); return waitTrajScatterHighlightPaint(); }); } function clearTrajGifScatterHighlightForCapture() { if (!manualScatterHighlightActive) return waitGifPaintFrames(2); resetVolumeScatterMarkerAesthetics(); return waitGifPaintFrames(4); } function applyTrajGifScatterExportLayout(opts) { opts = opts || {}; var fl = gd._fullLayout; var range = trajGifScatterDataRange(TRAJ_GIF_VOLUME_PADDING); var xTitleFont = trajGifCloneScaledFont( fl.xaxis && fl.xaxis.title && fl.xaxis.title.font, 15 ); var yTitleFont = trajGifCloneScaledFont( fl.yaxis && fl.yaxis.title && fl.yaxis.title.font, 15 ); var xTickFont = trajGifCloneScaledFont(fl.xaxis && fl.xaxis.tickfont, 12); var yTickFont = trajGifCloneScaledFont(fl.yaxis && fl.yaxis.tickfont, 12); var margin = fl.margin || {}; var patch = { "xaxis.title.font": xTitleFont, "yaxis.title.font": yTitleFont, "xaxis.tickfont": xTickFont, "yaxis.tickfont": yTickFont, "margin.l": Math.round((margin.l || 50) * TRAJ_GIF_AXIS_FONT_MULT), "margin.r": Math.round((margin.r || 20) * 1.15), "margin.t": Math.round((margin.t || 16) * 1.2), "margin.b": Math.round((margin.b || 50) * TRAJ_GIF_AXIS_FONT_MULT) }; if (range) { patch["xaxis.range"] = range.x.slice(); patch["yaxis.range"] = range.y.slice(); patch["xaxis.autorange"] = false; patch["yaxis.autorange"] = false; } return Plotly.relayout(gd, patch).then(function() { return waitGifPaintFrames(3); }).then(function() { if (!trajGifGenerationActive()) { applyTrajScatterLegendPosition(); } syncTrajGlyphOverlay(); if (trajGifGenerationActive()) ensureTrajGifPlotStackMap(); if (opts.skipHighlight) return waitGifPaintFrames(2); return waitTrajScatterHighlightPaint(); }); } function captureTrajScatterPngB64() { if (typeof Plotly === "undefined" || !Plotly.toImage || !gd || !gd._fullLayout) { return Promise.reject(new Error("Scatter plot is not ready for capture.")); } return enqueueTrajGifScatterCapture(function() { var scale = TRAJ_GIF_PLOT_IMAGE_SCALE; var stackDims = trajGifScatterStackDims(); var stackW = stackDims.w; var stackH = stackDims.h; var exportW = Math.round(stackW * scale); var exportH = Math.round(stackH * scale); var layoutSnap = snapshotTrajGifScatterLayout(); var captureOpts = { skipHighlight: true }; return clearTrajGifScatterHighlightForCapture() .then(function() { return applyTrajGifScatterExportLayout(captureOpts); }) .then(function() { return Plotly.toImage(gd, { format: "png", width: stackW, height: stackH, imageScale: scale }); }) .then(function(dataUrl) { return new Promise(function(resolve, reject) { var img = new Image(); img.onload = function() { var canvas = document.createElement("canvas"); canvas.width = exportW; canvas.height = exportH; var ctx = canvas.getContext("2d"); if (!ctx) { reject(new Error("Could not render scatter frame.")); return; } ctx.fillStyle = "#faf8f4"; ctx.fillRect(0, 0, exportW, exportH); ctx.drawImage(img, 0, 0, exportW, exportH); drawTrajGlyphsOnGifCanvas(ctx, exportW, exportH); drawTrajScatterColorLegendOnGifCanvas(ctx, exportW, exportH); resolve(gifDataUrlToBareB64(canvas.toDataURL("image/png"))); }; img.onerror = function() { reject(new Error("Could not load scatter frame.")); }; img.src = (String(dataUrl).indexOf("data:") === 0) ? dataUrl : ("data:image/png;base64," + dataUrl); }); }) .catch(function(err) { return restoreTrajGifScatterLayout(layoutSnap, captureOpts).then(function() { throw err; }); }) .then(function(frameB64) { return restoreTrajGifScatterLayout(layoutSnap, captureOpts).then(function() { return frameB64; }); }); }); } function compositeTrajGifPanels(leftB64, rightB64, panelW, panelH) { return Promise.all([ loadTrajGifPngB64(leftB64), loadTrajGifPngB64(rightB64) ]).then(function(imgs) { var canvas = document.createElement("canvas"); canvas.width = panelW * 2; canvas.height = panelH; var ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Could not composite GIF frames."); ctx.fillStyle = "#faf8f4"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(imgs[0], 0, 0, panelW, panelH); ctx.drawImage(imgs[1], panelW, 0, panelW, panelH); return gifDataUrlToBareB64(canvas.toDataURL("image/png")); }); } function setTrajGifMainVeilVisible(visible) { if (trajGifMainVeil) { trajGifMainVeil.hidden = !visible; trajGifMainVeil.setAttribute("aria-hidden", visible ? "false" : "true"); } if (trajGifRecordModal) { trajGifRecordModal.classList.toggle("cryo-traj-gif-modal--generating", !!visible); } } function beginTrajGifGeneration() { trajGifGenerationDepth++; if (trajGifGenerationDepth === 1) setTrajGifMainVeilVisible(true); } function endTrajGifGeneration() { if (trajGifGenerationDepth > 0) trajGifGenerationDepth--; if (trajGifGenerationDepth === 0) setTrajGifMainVeilVisible(false); } function trajGifGenerationActive() { return trajGifGenerationDepth > 0; } function buildTrajectoryGifFrames(volumeImages, includeScatter) { var panel = trajGifPanelTargetSize(); var needsMainVeil = !!(includeScatter && canIncludeScatterInTrajectoryGif()); if (needsMainVeil) { clearTrajGifScatterCaptureLocks(); trajGifLockedScatterSize = trajGifScatterStackDims(); stashTrajGifLiveLayoutBaseline(); beginTrajGifGeneration(); } function finishGifFrames(out) { clearTrajGifVolumeAlignmentLocks(); if (needsMainVeil) { clearTrajGifScatterCaptureLocks(); endTrajGifGeneration(); return restoreTrajGifLiveLayoutBaseline().then(function() { return out; }); } return out; } function failGifFrames(err) { clearTrajGifVolumeAlignmentLocks(); if (needsMainVeil) { clearTrajGifScatterCaptureLocks(); endTrajGifGeneration(); return restoreTrajGifLiveLayoutBaseline().then(function() { throw err; }); } throw err; } if (!includeScatter || !canIncludeScatterInTrajectoryGif()) { return lockTrajGifVolumeAlignment(volumeImages, panel).then(function() { var volChain = Promise.resolve(); var volOut = []; for (var vi = 0; vi < volumeImages.length; vi++) { (function(frameB64, frameIdx) { volChain = volChain.then(function() { return prepareVolumeFrameForGif(frameB64, panel, frameIdx); }).then(function(panelB64) { volOut.push(panelB64); }); })(volumeImages[vi], vi); } return volChain.then(function() { return volOut; }); }).then(finishGifFrames).catch(failGifFrames); } return lockTrajGifVolumeAlignment(volumeImages, panel).then(function() { var savedFocus = currentVolumeFocusIndex(); var hadFocusEngaged = volumeDisplayFocusEngaged; engageVolumeDisplayFocus(); var chain = Promise.resolve(); var out = []; for (var i = 0; i < volumeImages.length; i++) { (function(frameIdx) { chain = chain .then(function() { if (trajVolDisplay && typeof trajVolDisplay.setFocusIndex === "function") { trajVolDisplay.setFocusIndex(frameIdx); } return waitGifPaintFrames(2); }) .then(function() { return captureTrajScatterPngB64(); }) .then(function(scatterRawB64) { return scalePngB64ToPanel(scatterRawB64, panel.w, panel.h) .then(function(scatterPanelB64) { return prepareVolumeFrameForGif(volumeImages[frameIdx], panel, frameIdx) .then(function(volumePanelB64) { return compositeTrajGifPanels( scatterPanelB64, volumePanelB64, panel.w, panel.h ); }); }); }) .then(function(compositeB64) { out.push(compositeB64); }); })(i); } return chain.then(function() { if (trajVolDisplay && typeof trajVolDisplay.setFocusIndex === "function") { trajVolDisplay.setFocusIndex(savedFocus); } if (!hadFocusEngaged) resetVolumeDisplayFocusEngaged(); else engageVolumeDisplayFocus(); syncManualVolumeScatterHighlight(); syncTrajGlyphOverlay(); return out; }).then(finishGifFrames).catch(failGifFrames); }); } function trajGifIncludeScatterSelected() { return !!(trajGifIncludeScatterEl && trajGifIncludeScatterEl.checked && canIncludeScatterInTrajectoryGif()); } function trajGifCurrentFps() { var fps = parseFloat((trajGifFpsEl && trajGifFpsEl.value) || "4"); if (!Number.isFinite(fps) || fps <= 0 || fps > 60) return null; return fps; } function trajGifPreviewSettingsKey() { var fps = trajGifCurrentFps(); return String(fps == null ? "" : fps) + "|" + trajGifPreviewScatterKey(); } function trajGifPreviewScatterKey() { return trajGifIncludeScatterSelected() ? "1" : "0"; } function setTrajGifPreviewStatus(msg, isError) { if (!trajGifPreviewStatus) return; trajGifPreviewStatus.textContent = msg || ""; trajGifPreviewStatus.classList.toggle("is-error", !!isError); } function clearTrajGifPreviewDisplay() { lastTrajGifPreviewB64 = null; lastTrajGifPreviewFrames = null; lastTrajGifPreviewKey = ""; lastTrajGifPreviewScatterKey = ""; if (trajGifPreviewImg) { trajGifPreviewImg.removeAttribute("src"); trajGifPreviewImg.hidden = true; } setTrajGifPreviewStatus("", false); } function cancelTrajGifPreview() { trajGifPreviewGen++; if (trajGifPreviewDebounceTimer) { clearTimeout(trajGifPreviewDebounceTimer); trajGifPreviewDebounceTimer = 0; } } function showTrajGifPreviewB64(gifB64) { if (!trajGifPreviewImg) return; lastTrajGifPreviewB64 = gifB64; trajGifPreviewImg.src = "data:image/gif;base64," + gifB64; trajGifPreviewImg.hidden = false; setTrajGifPreviewStatus("", false); } function assembleTrajectoryGifB64(frames, fps) { var durationMs = Math.max(1, Math.round(1000.0 / fps)); return fetch(TRAJ_GIF_ASSEMBLE_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ frames: frames, durations_ms: durationMs }) }) .then(function(r) { return r.json().then(function(j) { return { ok: r.ok, j: j }; }); }) .then(function(res) { if (!res.ok) { throw new Error((res.j && res.j.error) ? res.j.error : "Could not assemble GIF preview."); } if (!res.j || !res.j.gif_b64) { throw new Error("Server did not return a GIF preview."); } return res.j.gif_b64; }); } function collectTrajectoryGifFramesForExport() { var scatterKey = trajGifPreviewScatterKey(); if (lastTrajGifPreviewFrames && lastTrajGifPreviewFrames.length >= 2 && lastTrajGifPreviewScatterKey === scatterKey) { return Promise.resolve(lastTrajGifPreviewFrames); } return fetchChimeraxImagesForGif().then(function(images) { return buildTrajectoryGifFrames(images, trajGifIncludeScatterSelected()); }).then(function(frames) { lastTrajGifPreviewFrames = frames; lastTrajGifPreviewScatterKey = scatterKey; return frames; }); } function refreshTrajGifPreview() { if (!trajGifRecordModal || trajGifRecordModal.hidden) return; var fps = trajGifCurrentFps(); if (fps == null) { clearTrajGifPreviewDisplay(); setTrajGifPreviewStatus("Enter a valid frames-per-second value (0.5–60).", true); if (trajGifSaveBtn) trajGifSaveBtn.disabled = true; return; } var scatterKey = trajGifPreviewScatterKey(); var key = String(fps) + "|" + scatterKey; if (key === lastTrajGifPreviewKey && lastTrajGifPreviewB64) { showTrajGifPreviewB64(lastTrajGifPreviewB64); if (trajGifSaveBtn) trajGifSaveBtn.disabled = !!trajGifSaveInFlight; return; } var myGen = ++trajGifPreviewGen; if (trajGifPreviewImg) trajGifPreviewImg.hidden = true; var rebuildingFrames = !( lastTrajGifPreviewFrames && lastTrajGifPreviewFrames.length >= 2 && lastTrajGifPreviewScatterKey === scatterKey ); setTrajGifPreviewStatus( rebuildingFrames && trajGifIncludeScatterSelected() ? "Generating preview with scatter plot…" : (rebuildingFrames ? "Generating preview…" : "Updating preview timing…"), false ); if (trajGifSaveBtn) trajGifSaveBtn.disabled = true; var framePromise; if (!rebuildingFrames) { framePromise = Promise.resolve(lastTrajGifPreviewFrames); } else { framePromise = fetchChimeraxImagesForGif().then(function(images) { return buildTrajectoryGifFrames(images, trajGifIncludeScatterSelected()); }); } framePromise .then(function(frames) { if (myGen !== trajGifPreviewGen) return null; if (!frames || frames.length < 2) { throw new Error("Need at least two frames to preview the GIF."); } lastTrajGifPreviewFrames = frames; lastTrajGifPreviewScatterKey = scatterKey; return assembleTrajectoryGifB64(frames, fps); }) .then(function(gifB64) { if (!gifB64 || myGen !== trajGifPreviewGen) return; lastTrajGifPreviewKey = key; showTrajGifPreviewB64(gifB64); if (trajGifSaveBtn) trajGifSaveBtn.disabled = !!trajGifSaveInFlight; }) .catch(function(err) { if (myGen !== trajGifPreviewGen) return; console.error(err); clearTrajGifPreviewDisplay(); setTrajGifPreviewStatus(err.message || "Could not generate GIF preview.", true); if (trajGifSaveBtn) trajGifSaveBtn.disabled = true; }); } function scheduleTrajGifPreviewRefresh() { if (trajGifPreviewDebounceTimer) clearTimeout(trajGifPreviewDebounceTimer); trajGifPreviewDebounceTimer = setTimeout(function() { trajGifPreviewDebounceTimer = 0; refreshTrajGifPreview(); }, 400); } function openTrajGifRecordModal() { if (!trajGifRecordModal || !canRecordTrajectoryGif()) return; trajGifGenerationDepth = 0; setTrajGifMainVeilVisible(false); if (trajGifSaveName) trajGifSaveName.value = "trajectory.gif"; if (trajGifFpsEl && !trajGifFpsEl.value) trajGifFpsEl.value = "4"; syncTrajGifScatterOption(); clearTrajGifPreviewDisplay(); if (window.CryoDashModal) { CryoDashModal.open(trajGifRecordModal); } else { trajGifRecordModal.hidden = false; trajGifRecordModal.setAttribute("aria-hidden", "false"); } loadTrajGifFileBrowserDir(expWorkdir || ""); refreshTrajGifPreview(); } function closeTrajGifRecordModal() { if (!trajGifRecordModal) return; if (trajGifGenerationActive()) return; cancelTrajGifPreview(); if (window.CryoDashModal) { CryoDashModal.close(trajGifRecordModal); } else { trajGifRecordModal.hidden = true; trajGifRecordModal.setAttribute("aria-hidden", "true"); } trajGifFbCurrentDir = null; clearTrajGifVolumeAlignmentLocks(); clearTrajGifPreviewDisplay(); } function normalizedTrajGifSaveFilename() { var name = String((trajGifSaveName && trajGifSaveName.value) || "").trim(); if (!name) name = "trajectory.gif"; if (name.indexOf("/") >= 0 || name.indexOf("\\") >= 0) return null; if (!/\.gif$/i.test(name)) name = name + ".gif"; return name; } function loadTrajGifFileBrowserDir(dir) { if (!trajGifFbList || !trajGifFbPath) return; if (window.CryoFileBrowser) { CryoFileBrowser.loadDir({ listUrl: "{{ url_for('api_list_server_files') }}", dir: dir || null, listEl: trajGifFbList, pathEl: trajGifFbPath, onDir: function(next) { loadTrajGifFileBrowserDir(next); }, onFile: function(path, ent) { if (trajGifSaveName) trajGifSaveName.value = ent.name; }, onLoaded: function(j) { trajGifFbCurrentDir = j.dir; } }); return; } trajGifFbList.innerHTML = "