Watch your single cells migrate between expression space (e.g. UMAP) and physical space.
Choose from an existing dataset or bring your own data with umap coordinates, spatial positions, and cell annotation.
// --- DuckDB-WASM client (used for both remote parquet and local uploads) ---duck_db_client = {const duckdb =awaitimport("https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.28.0/+esm");const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles();const bundle =await duckdb.selectBundle(JSDELIVR_BUNDLES);const worker_url = URL.createObjectURL(newBlob([`importScripts("${bundle.mainWorker}");`], { type:"text/javascript" }) );const worker =newWorker(worker_url);// Safe to revoke once the Worker has been constructed; the browser has// already fetched the blob contents. URL.revokeObjectURL(worker_url);const logger =new duckdb.ConsoleLogger();const db =new duckdb.AsyncDuckDB(logger, worker);await db.instantiate(bundle.mainModule, bundle.pthreadWorker);const conn =await db.connect(); conn.instance= db;console.log("✅ DuckDB-Wasm Ready");// Tear down the worker / DB on OJS cell invalidation (e.g. hot reload,// navigation) so we don't leak a running Worker. invalidation.then(async () => {try { await conn.close(); } catch (_) {}try { await db.terminate(); } catch (_) {}try { worker.terminate(); } catch (_) {} });return conn;}
// --- Handle file upload ---// CSV/TSV are parsed in pure JS with D3 (no DuckDB VFS needed).// Parquet still uses DuckDB-WASM's registerFileBuffer because there's no// lightweight pure-JS parquet decoder.{const file = user_upload_file;if (file) {// Stable id so the duplicate guard below actually works on re-evals.// (Using Date.now() here would cause an infinite loop: this cell reads// `available_studies`, so writing to it re-triggers the cell, and a// fresh timestamp would defeat the guard every time.)const stable_id =`upload_${file.name}_${file.size}_${file.lastModified}`;if (!available_studies.find(d => d.id=== stable_id)) {const ext = file.name.split(".").pop().toLowerCase();let new_record;if (ext ==="parquet") {const conn =await duck_db_client;const buf =newUint8Array(await file.arrayBuffer());await conn.instance.registerFileBuffer(stable_id, buf); new_record = {id: stable_id,name: file.name,description:"User uploaded",url_data: stable_id,type:"local_parquet",valid:true }; } else {// CSV / TSV — parse directly with D3.// Sniff the delimiter from the first line so we handle comma-,// semicolon-, and tab-separated files transparently.const text =await file.text();const newlineIndex = text.indexOf("\n");const firstLine = newlineIndex ===-1? text : text.slice(0, newlineIndex);const counts = { ",":0,";":0,"\t":0,"|":0 };let inQuote =false;for (const ch of firstLine) {if (ch ==='"') inQuote =!inQuote;elseif (!inQuote && ch in counts) counts[ch]++; }// Default by extension, but override if a different delimiter dominates.let delim = ext ==="tsv"?"\t":",";const best =Object.entries(counts).sort((a, b) => b[1] - a[1])[0];if (best && best[1] >0) delim = best[0];const rows = d3.dsvFormat(delim).parse(text, d3.autoType); new_record = {id: stable_id,name: file.name,description:"User uploaded",url_data:null,type:"local_inline", rows,// pre-parsed rowscolumns: rows.columns|| (rows[0] ?Object.keys(rows[0]) : []),valid:true }; } mutable available_studies = [...available_studies, new_record]; mutable latest_upload = new_record;console.log("✅ Registered upload:", file.name,"("+ new_record.type+")"); } }}
// --- Sampled query on the selected dataset ---study_data = {const selection = selected_study;const rate = sample_rate;if (!selection) returnnull;// Inline (CSV/TSV upload, already parsed in JS) — no DuckDB needed.if (selection.type==="local_inline") {const all = selection.rows|| [];const keep =Math.max(1,Math.round(all.length* rate /100));let rows;if (keep >= all.length) { rows = all; } else {// Bernoulli-style sample to match remote behavior.const p = rate /100; rows = all.filter(() =>Math.random() < p);if (!rows.length) rows = [all[0]]; }const columns = selection.columns|| (rows[0] ?Object.keys(rows[0]) : []);return { name: selection.name, rows, columns }; }// Remote parquet or local_parquet (DuckDB VFS) path.const conn =await duck_db_client;// Escape single quotes so user-supplied filenames (used as the DuckDB VFS// id for local uploads) can't break out of the SQL string literal.const escapedUrlData =String(selection.url_data).replace(/'/g,"''");const reader =`read_parquet('${escapedUrlData}')`;const sql =`SELECT * FROM ${reader} USING SAMPLE ${rate} PERCENT (bernoulli)`;try {const result =await conn.query(sql);const rows = result.toArray().map(r => r.toJSON());const columns = rows.length?Object.keys(rows[0]) : [];return { name: selection.name, rows, columns }; } catch (e) {console.error("❌ Query error:", e);return { name: selection.name,rows: [],columns: [],error:String(e) }; }}
df_columns = study_data ? study_data.columns: []
// --- Build per-point animation arrays (normalized + colored) ---animation_data = {if (!study_data ||!study_data.rows.length||!column_mapping) returnnull;const m = column_mapping;const rows = study_data.rows;const n = rows.length;const ex =newFloat32Array(n), ey =newFloat32Array(n);const sx =newFloat32Array(n), sy =newFloat32Array(n);const ann =newArray(n);for (let i =0; i < n; i++) { ex[i] =+rows[i][m.expression_x]; ey[i] =+rows[i][m.expression_y]; sx[i] =+rows[i][m.spatial_x]; sy[i] =+rows[i][m.spatial_y]; ann[i] = rows[i][m.annotation]; }const norm = (arr) => {let lo =Infinity, hi =-Infinity;for (let i =0; i < arr.length; i++) {const v = arr[i];if (Number.isFinite(v)) { if (v < lo) lo = v;if (v > hi) hi = v; } }const out =newFloat32Array(arr.length);// Guard against the no-finite-values case so we don't propagate NaNs into// canvas drawing routines (e.g. ctx.arc throws on NaN in some browsers).if (!Number.isFinite(lo) ||!Number.isFinite(hi)) { out.fill(0.5);return out; }const span = hi - lo ||1;for (let i =0; i < arr.length; i++) out[i] = (arr[i] - lo) / span;return out; };const categories =Array.from(newSet(ann));const palette = d3.schemeTableau10.concat(d3.schemeSet3, d3.schemeSet2);const colorScale = d3.scaleOrdinal().domain(categories).range(palette);const colors = ann.map(a =>colorScale(a));return { n,ex:norm(ex),ey:norm(ey),sx:norm(sx),sy:norm(sy), ann, colors, categories, colorScale };}
Live View
Animation
animation_view = {const data = animation_data;const params = trajectory_params;const startProj = start_direction;const isPlaying = play_state ==="Play";const halfCycleSec = speed_seconds;const dwellSec = dwell_seconds;const userPointRadius = point_size;const pointAlpha = point_opacity;const bgColor = background_color;const [W, H] = (() => {const k = export_resolution;if (k ==="1280×720") return [1280,720];if (k ==="1920×1080") return [1920,1080];if (k ==="2560×1440") return [2560,1440];return [1000,620]; })();const MARGIN =Math.round(W *0.03);const canvas =html`<canvas width="${W}" height="${H}" class="projection-canvas"></canvas>`;const ctx = canvas.getContext("2d");if (!data) { ctx.fillStyle= bgColor; ctx.fillRect(0,0, W, H); ctx.fillStyle="#94a3b8"; ctx.font="16px 'Noto Sans', sans-serif"; ctx.textAlign="center"; ctx.fillText("Loading dataset…", W /2, H /2);return canvas; }const xPix = u => MARGIN + u * (W -2* MARGIN);const yPix = u => (H - MARGIN) - u * (H -2* MARGIN);// Each projection sits centered at its trajectory anchor.const ax1 = params.x1, ay1 = params.y1;const ax2 = params.x2, ay2 = params.y2;// Fit-to-canvas scale: the largest cluster footprint that keeps every// point of both projections fully on-canvas. Each projection's data is// normalized to [0,1], centered at its anchor, so the half-extent on each// side equals projScale/2. To stay on-canvas, the anchor must be ≥ that// far from every edge — hence 2 × min(distance from anchor to edge).const edgeMargin =Math.min( ax1,1- ax1, ay1,1- ay1, ax2,1- ax2, ay2,1- ay2 );// Floor so anchors at the corners still get a visible cluster (clipped).// Cap so two centered anchors don't blow up to fill everything.const projScale =Math.max(0.22,Math.min(0.7, edgeMargin *2));// Point size: user-controlled, but multiplied by a fit-to-canvas factor so// bigger clusters get chunkier dots (stays readable, never blobs).const sizeFactor =Math.max(0.7,Math.min(1.6, projScale *2.6));const pointRadius =Math.max(0.4, userPointRadius * sizeFactor);// Great-circle ("airplane flight path") interpolation.// Map canvas (u,v) to (lon, lat) to leverage spherical // interpolation (Slerp) for curved trajectories.// Both projections occupy the SAME latitude band, but live at different// longitudes (one west of the prime meridian, one east). Slerping on the// unit sphere makes points near the equator (mid-y of their cluster)// travel almost-straight, while points near the poles (top/bottom of// their cluster) follow a noticeably curved great-circle path.// The `arc` slider scales how "spherical" the geometry is.// Clamp the angular ranges well below ±90° / ±180° to avoid the// antipodal singularity in slerp.const arc = params.arc;const useSlerp = arc >0.05;// Keep the angular ranges below the antipodal singularity.// Two anchors at opposite halves can be separated by up to 2*lonHalf// in longitude — keeping that ≤ ~160° is safe for slerp.const lonHalf =Math.min(80, arc *14);// cluster centers stay within ±80° lonconst latHalf =Math.min(80, arc *11);// points stay within ±80° latconst D2R =Math.PI/180;const R2D =180/Math.PI;// Convert anchored normalized canvas (u in [0,1], v in [0,1] with v=1 at top)// into a (lon, lat) point in degrees.functionuvToLonLat(u, v) {return [(u -0.5) *2* lonHalf, (v -0.5) *2* latHalf]; }functiontoSphere(lonDeg, latDeg) {const lon = lonDeg * D2R, lat = latDeg * D2R;const c =Math.cos(lat);return [c *Math.cos(lon), c *Math.sin(lon),Math.sin(lat)]; }functionfromSphere(v) {return [Math.atan2(v[1], v[0]) * R2D,Math.asin(Math.max(-1,Math.min(1, v[2]))) * R2D]; }functionslerp(v0, v1, tt) {let dot = v0[0]*v1[0] + v0[1]*v1[1] + v0[2]*v1[2]; dot =Math.max(-1,Math.min(1, dot));const omega =Math.acos(dot);if (omega <1e-6) {return [v0[0]*(1-tt)+v1[0]*tt, v0[1]*(1-tt)+v1[1]*tt, v0[2]*(1-tt)+v1[2]*tt]; }const so =Math.sin(omega);const a =Math.sin((1- tt) * omega) / so;const b =Math.sin(tt * omega) / so;return [a*v0[0]+b*v1[0], a*v0[1]+b*v1[1], a*v0[2]+b*v1[2]]; }// Pre-compute the two endpoint sphere positions for every cell so the// per-frame inner loop is cheap.const v0x =newFloat32Array(data.n);const v0y =newFloat32Array(data.n);const v0z =newFloat32Array(data.n);const v1x =newFloat32Array(data.n);const v1y =newFloat32Array(data.n);const v1z =newFloat32Array(data.n);// Linear-fallback endpoints in normalized canvas space:const e0u =newFloat32Array(data.n);const e0v =newFloat32Array(data.n);const e1u =newFloat32Array(data.n);const e1v =newFloat32Array(data.n);for (let i =0; i < data.n; i++) {const exU = ax1 + (data.ex[i] -0.5) * projScale;const eyU = ay1 + (data.ey[i] -0.5) * projScale;const sxU = ax2 + (data.sx[i] -0.5) * projScale;const syU = ay2 + (data.sy[i] -0.5) * projScale; e0u[i] = exU; e0v[i] = eyU; e1u[i] = sxU; e1v[i] = syU;if (useSlerp) {const [lon0, lat0] =uvToLonLat(exU, eyU);const [lon1, lat1] =uvToLonLat(sxU, syU);const a =toSphere(lon0, lat0);const b =toSphere(lon1, lat1); v0x[i] = a[0]; v0y[i] = a[1]; v0z[i] = a[2]; v1x[i] = b[0]; v1y[i] = b[1]; v1z[i] = b[2]; } }let t = startProj ==="Expression"?0:1;let dir = startProj ==="Expression"?+1:-1;let dwellRemaining =0;let lastTs =null;let raf =null;// Expose a reset hook so the download handler can rewind the animation// to the start of the chosen direction before recording, so the saved// video always begins cleanly at t=0 rather than mid-sweep.window.__projAnim_resetToStart= () => { t = startProj ==="Expression"?0:1; dir = startProj ==="Expression"?+1:-1; dwellRemaining =0; lastTs =null;draw(t); };functiondraw(tt) { ctx.fillStyle= bgColor; ctx.fillRect(0,0, W, H);// Read the live legend selection (mutated outside OJS reactivity so// toggling categories doesn't rebuild this canvas + reset the loop).const selected =window.__projAnim_selected||newSet();const hasSel = selected.size>0;const dimColor ="rgba(148, 163, 184, 0.18)";// slate-400 @ low alpha ctx.globalAlpha= pointAlpha;if (useSlerp) {// Per-point great-circle slerp.for (let i =0; i < data.n; i++) {const dot =Math.max(-1,Math.min(1, v0x[i]*v1x[i] + v0y[i]*v1y[i] + v0z[i]*v1z[i]));const omega =Math.acos(dot);let ux, uy;if (omega <1e-6|| omega >Math.PI-1e-3) {// Degenerate (coincident or antipodal) — fall back to linear. ux = e0u[i] * (1- tt) + e1u[i] * tt; uy = e0v[i] * (1- tt) + e1v[i] * tt; } else {const so =Math.sin(omega);const a =Math.sin((1- tt) * omega) / so;const b =Math.sin(tt * omega) / so;const px = a * v0x[i] + b * v1x[i];const py = a * v0y[i] + b * v1y[i];const pz = a * v0z[i] + b * v1z[i];const lon =Math.atan2(py, px) * R2D;const lat =Math.asin(Math.max(-1,Math.min(1, pz))) * R2D; ux = lon / (2* lonHalf) +0.5; uy = lat / (2* latHalf) +0.5; }const isOn =!hasSel || selected.has(data.ann[i]); ctx.fillStyle= isOn ? data.colors[i] : dimColor; ctx.beginPath(); ctx.arc(xPix(ux),yPix(uy), pointRadius,0,6.283185); ctx.fill(); } } else {// arc ≈ 0 → linear interpolationconst om =1- tt;for (let i =0; i < data.n; i++) {const ux = e0u[i] * om + e1u[i] * tt;const uy = e0v[i] * om + e1v[i] * tt;const isOn =!hasSel || selected.has(data.ann[i]); ctx.fillStyle= isOn ? data.colors[i] : dimColor; ctx.beginPath(); ctx.arc(xPix(ux),yPix(uy), pointRadius,0,6.283185); ctx.fill(); } } ctx.globalAlpha=1;// Status text — dark on light bg, light on dark bg. {// Pick a readable text color based on bg luminance.const c = bgColor.replace("#","");const r =parseInt(c.slice(0,2),16), g =parseInt(c.slice(2,4),16), b =parseInt(c.slice(4,6),16);const lum =0.2126* r +0.7152* g +0.0722* b;const dark = lum <140; ctx.fillStyle= dark ?"#f1f5f9":"#0f172a"; ctx.font=`600 ${Math.max(11,Math.round(W /76))}px 'Space Grotesk', sans-serif`; ctx.textAlign="left";const dirLabel = dir >0?"Expression → Spatial":"Spatial → Expression"; ctx.fillText(dirLabel,14,22); ctx.fillStyle= dark ?"#94a3b8":"#64748b"; ctx.font=`500 ${Math.max(10,Math.round(W /90))}px 'Space Grotesk', sans-serif`;const status = dwellRemaining >0?`t = ${tt.toFixed(2)} · dwell ${dwellRemaining.toFixed(1)}s`:`t = ${tt.toFixed(2)}`; ctx.fillText(status,14,40); } }functionframe(ts) {if (!isPlaying) { lastTs =null;return; }if (lastTs ===null) lastTs = ts;const dt = (ts - lastTs) /1000; lastTs = ts;if (dwellRemaining >0) { dwellRemaining =Math.max(0, dwellRemaining - dt); } else {const speed =1/Math.max(0.1, halfCycleSec); t += dir * speed * dt;if (t >=1) { t =1; dir =-1; dwellRemaining = dwellSec; }elseif (t <=0) { t =0; dir =+1; dwellRemaining = dwellSec; } }draw(t); raf =requestAnimationFrame(frame); }draw(t);if (isPlaying) raf =requestAnimationFrame(frame); invalidation.then(() => { if (raf) cancelAnimationFrame(raf); });return canvas;}
legend_view = {const data = animation_data;if (!data) returnhtml`<div class="text-xs text-slate-400">Legend will appear once data loads.</div>`;// Reset the global selection set whenever the dataset/categories change.window.__projAnim_selected=newSet();// Persist user color overrides across re-renders of this cell so picking// a new dataset doesn't wipe a re-color the user already set. Keyed by// `${dataset}::${category}` to avoid collisions across datasets.window.__projAnim_colorOverrides=window.__projAnim_colorOverrides|| {};const datasetKey =String(study_data?.name||"default");const overrideKey = (c) =>`${datasetKey}::${c}`;const colorOf = (c) =>window.__projAnim_colorOverrides[overrideKey(c)] ?? data.colorScale(c);// Apply any pre-existing overrides to data.colors so the very first frame// already reflects them (e.g., if the user picks a new dataset and the// colors had been customized previously, those overrides still apply if// the categories match).functionrecolorCategory(cat, hex) {for (let i =0; i < data.n; i++) {if (data.ann[i] === cat) data.colors[i] = hex; } }for (const c of data.categories) {const ov =window.__projAnim_colorOverrides[overrideKey(c)];if (ov) recolorCategory(c, ov); }const wrap =html`<div class="flex flex-wrap items-center"></div>`;const hint =html`<div class="w-full text-[11px] text-slate-400 mb-2">Click a label to isolate · click more to add · double-click to reset · <strong>${navigator.platform.toLowerCase().includes("mac") ?"⌘":"Ctrl"}+click</strong> a swatch to recolor</div>`; wrap.appendChild(hint);// One hidden color input shared across all swatches; we re-target it per// click. Browsers don't allow programmatically opening a color picker// without a real input, so we keep one in the DOM.const picker =html`<input type="color" style="position:absolute; width:0; height:0; padding:0; border:0; opacity:0; pointer-events:none;" tabindex="-1" aria-hidden="true">`; wrap.appendChild(picker);let pickerCategory =null; picker.addEventListener("input", () => {if (!pickerCategory) return;const hex = picker.value;window.__projAnim_colorOverrides[overrideKey(pickerCategory)] = hex;recolorCategory(pickerCategory, hex);const el = itemEls.get(pickerCategory);if (el) {const swatch = el.querySelector(".legend-swatch");if (swatch) swatch.style.background= hex; }repaint(); });const itemEls =newMap();functionrepaint() {const sel =window.__projAnim_selected;const hasSel = sel.size>0;for (const [c, el] of itemEls) {const on =!hasSel || sel.has(c); el.style.opacity= on ?"1":"0.32"; el.style.borderColor= sel.has(c) ?colorOf(c) :"#e2e8f0"; el.style.boxShadow= sel.has(c) ?`inset 0 0 0 1px ${colorOf(c)}`:"none"; } }for (const c of data.categories) {const el =html`<button type="button" class="inline-flex items-center gap-1.5 mr-2 mb-1.5 px-2 py-1 rounded-full bg-slate-50 border border-slate-200 cursor-pointer select-none" title="Click to isolate · ${navigator.platform.toLowerCase().includes("mac") ?"⌘":"Ctrl"}+click to recolor" style="transition: opacity 120ms, border-color 120ms;"> <span class="legend-swatch" style="display:inline-block; width:10px; height:10px; border-radius:50%; background:${colorOf(c)};"></span> <span class="text-xs text-slate-700">${c}</span> </button>`; el.addEventListener("click", (ev) => { ev.preventDefault();// Ctrl+click (or ⌘+click on macOS) opens the color picker for this// label. Plain click toggles the selection filter as before.if (ev.ctrlKey|| ev.metaKey) { pickerCategory = c; picker.value=colorOf(c); picker.click();return; }const sel =window.__projAnim_selected;if (sel.has(c)) sel.delete(c);else sel.add(c);repaint(); }); el.addEventListener("contextmenu", (ev) => {// Right-click also opens the color picker — discoverable on touch// devices via long-press, and matches the ⌘/Ctrl+click affordance. ev.preventDefault(); pickerCategory = c; picker.value=colorOf(c); picker.click(); }); el.addEventListener("dblclick", (ev) => { ev.preventDefault();window.__projAnim_selected=newSet();repaint(); }); itemEls.set(c, el); wrap.appendChild(el); }repaint();return wrap;}
Step 1
Pick a dataset
viewof selected_study = Inputs.select(available_studies, {label:"Example or your own:",format: d => d.name,value: latest_upload || (available_studies[0])})
infoBitrate per second of videoStandard — smaller files, great for streaming or embedding on a website.High — balanced; great for sharing in slide decks or Slack.Maximum — near-lossless; great for archival, presentations on large screens, or further editing.Higher quality means a larger file. Resolution is set separately above.
download_view = {// A self-managed button that disables itself + shows progress text while// the MediaRecorder is capturing, then re-enables when the file is ready.const wrap =html`<div class="flex items-center gap-3"></div>`;const btn =html`<button type="button" class="px-4 py-2 rounded-md bg-primary text-white font-semibold text-sm cursor-pointer hover:opacity-90 transition" style="background:#0077c8;">⬇ Download video</button>`;const status =html`<span class="text-xs text-slate-500"></span>`; wrap.appendChild(btn); wrap.appendChild(status);functionsetBusy(msg) { btn.disabled=true; btn.style.opacity="0.55"; btn.style.cursor="not-allowed"; btn.textContent="Preparing…"; status.textContent= msg; }functionsetIdle(msg) { btn.disabled=false; btn.style.opacity="1"; btn.style.cursor="pointer"; btn.textContent="⬇ Download video"; status.textContent= msg ||""; } btn.addEventListener("click",async () => {if (btn.disabled) return;const canvas =document.querySelector(".projection-canvas");if (!canvas) { status.textContent="Canvas not ready.";return; }// Choose MIME based on user preference + browser support.const mp4Candidates = ["video/mp4;codecs=avc1.42E01E","video/mp4;codecs=avc1","video/mp4" ];const webmCandidates = ["video/webm;codecs=vp9","video/webm;codecs=vp8","video/webm" ];const wantMp4 = export_format.startsWith("MP4");const ordered = wantMp4 ? mp4Candidates.concat(webmCandidates) : webmCandidates.concat(mp4Candidates);const mime = ordered.find(m =>MediaRecorder.isTypeSupported(m));if (!mime) { setIdle("This browser doesn't support MediaRecorder.");return; }const ext = mime.startsWith("video/mp4") ?"mp4":"webm";const cycleMs = (speed_seconds *2+ dwell_seconds *2) *1000;setBusy(`Please wait while we prepare your file (≈ ${(cycleMs /1000).toFixed(1)}s)…`);try {// Rewind animation to the start of the chosen direction so the saved// video always begins cleanly rather than mid-sweep.if (typeofwindow.__projAnim_resetToStart==="function") {window.__projAnim_resetToStart(); }const stream = canvas.captureStream(30);// Quality presets map to a per-pixel bitrate (bits per pixel per second// at 30 fps), then are scaled by the chosen output resolution. Capped// so we don't ask the encoder for unrealistic targets at 1440p.const W = canvas.width, H = canvas.height;const pixelsPerSecond = W * H *30;const bppsByQuality = { "Standard":0.06,"High":0.14,"Maximum":0.28 };const bpps = bppsByQuality[export_quality] ?? bppsByQuality.High;const bitrate =Math.min(40_000_000,Math.max(1_500_000,Math.round(pixelsPerSecond * bpps)));const recorder =newMediaRecorder(stream, { mimeType: mime,videoBitsPerSecond: bitrate });const chunks = []; recorder.ondataavailable= e => { if (e.data.size) chunks.push(e.data); };const done =newPromise(res => recorder.onstop= res); recorder.start();// Live countdown.const startedAt =performance.now();const tick =setInterval(() => {const remaining =Math.max(0, cycleMs - (performance.now() - startedAt)); status.textContent=`Recording… ${(remaining /1000).toFixed(1)}s remaining`; },200);awaitnewPromise(r =>setTimeout(r, cycleMs));clearInterval(tick); status.textContent="Encoding…"; recorder.stop();await done;const blob =newBlob(chunks, { type: mime });const url = URL.createObjectURL(blob);const fname =`projection_animation.${ext}`;const sizeMb = (blob.size/1024/1024).toFixed(1);// We can't reliably auto-trigger a download here: the click happens// after several seconds of awaits, so the user-gesture context has// expired and Chromium / Safari will silently suppress a synthetic// a.click(). Instead we render an inline save link the user clicks// themselves — that's a real user gesture and works in every browser. status.innerHTML="";const link =document.createElement("a"); link.href= url; link.download= fname; link.textContent=`\u2b07 Save ${fname} (${sizeMb} MB)`; link.className="text-xs font-semibold text-primary hover:underline"; link.style.color="#0077c8"; status.appendChild(link);// Also try a programmatic click — works in some browsers (e.g.// recent Chrome desktop) so the file saves immediately, with the// visible link as a guaranteed fallback.const ghost =document.createElement("a"); ghost.href= url; ghost.download= fname; ghost.style.display="none";document.body.appendChild(ghost);try { ghost.click(); } catch (_) { /* fall back to inline link */ }setTimeout(() => { document.body.removeChild(ghost); },100);// Keep the blob URL alive long enough for the user to click the// visible link if needed.setTimeout(() => URL.revokeObjectURL(url),5*60_000);setIdle(null);// Re-attach the link after setIdle clears the status. status.appendChild(link); } catch (err) {console.error(err);setIdle(`Error: ${err.message|| err}`); } });return wrap;}
Advanced
Trajectory shape
Drag the green and red handles to set where each projection sits on the canvas. The yellow handle controls the arc — how curved the per-point flight paths are. Bigger arc ⇒ high‑y points bow more (great-circle effect).
Your data is queried in-browser with DuckDB-WASM (sampled to maintain high performance with large datasets).
Points are drawn to an HTML canvas with D3.
Every point smoothly interpolates between its expression coordinates and its spatial coordinates with an optional arc.
Upload a dataset with five columns: dimensional reduction coordinates (e.g., umap1, umap2), spatial coordinates (e.g., x_slide_mm, y_slide_mm), and a categorical column for the cell label (e.g., celltype).
Privacy
Uploaded files are loaded into the DuckDB-WASM virtual file system inside your browser tab.
Nothing is sent to a server.
Tips
Lower the sample rate for very large datasets or when viewing on a mobile device.
Open Trajectory shape to control the arc.
Use Download video to capture one full bounce cycle.