viewof aesthetics_raw = Inputs.form({
volcano_fc_range: Inputs.range([0, 10], {step: 0.001, label: "Abs. log2FC", value: 1}),
volcano_pval_threshold: Inputs.range([0, 1], {step: 0.00001, label: "P-value threshold", value: 0.05}),
volcano_pt_size: Inputs.range([0.1, 5], {step: 0.1, label: "Pt. Size"}),
volcano_alpha: Inputs.range([0, 1], {step: 0.01, label: "Pt. Alpha", value: 1.0}),
volcano_pt_color: Inputs.color({label: "Pt. Color", value: "#3C3F40"}),
volcano_sig_color: Inputs.color({label: "Significant targets", value: "#b44697"}),
volcano_high_fc_label: Inputs.range([0, 10], {step: 1, label: "Label high FC", value: 3}),
volcano_low_fc_label: Inputs.range([0, 10], {step: 1, label: "Label low FC", value: 3}),
volcano_pval_label: Inputs.range([0, 10], {step: 1, label: "Label Sig.", value: 3}),
volcano_include_selected_genes: Inputs.radio(["Yes", "No"], {label: "Label selected?", value: "Yes"})
})
aesthetics_debounced = {
const raw = aesthetics_raw;
return new Promise(resolve => {
const timer = setTimeout(() => resolve(raw), 600);
invalidation.then(() => clearTimeout(timer));
});
}
aesthetics = aesthetics_debounced
volcano_fc_range = aesthetics.volcano_fc_range
volcano_pval_threshold = aesthetics.volcano_pval_threshold
volcano_pt_size = aesthetics.volcano_pt_size
volcano_alpha = aesthetics.volcano_alpha
volcano_pt_color = aesthetics.volcano_pt_color
volcano_sig_color = aesthetics.volcano_sig_color
volcano_high_fc_label = aesthetics.volcano_high_fc_label
volcano_low_fc_label = aesthetics.volcano_low_fc_label
volcano_pval_label = aesthetics.volcano_pval_label
volcano_include_selected_genes = aesthetics.volcano_include_selected_genesvolcano_data_js = {
const payload = study_data_payload;
const contrast = contrast_selection;
const term = term_selection;
// 1. DONT PROCEED if essential selections are missing
if (!payload || !contrast || !term || payload.mode === "local") return null;
if (payload.mode === "remote_js_bridge") {
try {
const conn = await duck_db_client;
const tableSource = payload.isAtoMx ? `pairwise_harmonized` : `"${payload.url_pairwise}"`;
// 2. CHECK IF VIEW EXISTS (For AtoMx)
if (payload.isAtoMx) {
const viewCheck = await conn.query(`SELECT count(*) as n FROM ${tableSource}`);
if (viewCheck.toArray()[0].n === 0) return null; // Wait for data
}
const query = `
SELECT term, contrast, target, log2fc, "p.value", ncells_1, ncells_2
FROM ${tableSource}
WHERE contrast = '${contrast}' AND term = '${term}'
`;
const result = await conn.query(query);
const data = result.toArray().map(row => row.toJSON());
// 3. ONLY RETURN IF WE HAVE DATA
if (data.length === 0) return null;
console.log(`✅ Pairwise data fetched from ${tableSource} (${data.length} rows)`);
return data;
} catch (e) {
console.error("❌ Failed to fetch pairwise data:", e);
return null; // Return null so webR doesn't try to process an error
}
}
}viewof pairwise_table = Inputs.table(pairwise_search, {
multiple: true,
rows: 20,
format: {
log2fc: sparkbar(d3.max(pairwise_search, d => d.log2fc))
}})selected_genes = {
if (!current_volcano_data || !pairwise_table) return ['notagene'];
if (pairwise_table.length === current_volcano_data) {
return ['notagene'];
}
if (pairwise_table.length > 30) {
return ['notagene'];
}
return pairwise_table.map(d => d.target);
}
selectionWarning = {
// 1. Wait for data
if (!pairwise_table || !current_volcano_data) return `Select between 1 and 30 genes to label.`;
const count = pairwise_table.length;
const total = current_volcano_data.length;
const isSelectAll = (count === total);
if(isSelectAll){
return `Select up to 30 genes to label in the volcano plot.`;
}
if (count > 30) {
return `⚠️ Too many targets selected (${count}). Limit is 30.`;
}
return `Currently selected: ${count} gene${count === 1 ? '' : 's'}.`;
}viewof term_selection = {
if (!available_terms || available_terms.length === 0) return md`_Loading terms..._`;
return Inputs.select(available_terms, {
label: "Available Terms:",
multiple: false,
size: 3
});
}
viewof contrast_selection = {
if (!available_contrasts || available_contrasts.length === 0) return md`_Loading contrasts..._`;
return Inputs.select(available_contrasts, {
label: "Available Contrasts:",
format: d => d.replace(" / ", " vs. "),
multiple: false,
size: 3
});
}viewof save_settings = Inputs.form({
width: Inputs.number({label: "Width", value: 6, step: 0.5}),
height: Inputs.number({label: "Height", value: 4, step: 0.5}),
units: Inputs.select(["in", "cm", "mm", "px"], {label: "Units", value: "in"}),
format: Inputs.select(["png", "svg"], {label: "Format", value: "png"})
})
viewof save_btn = Inputs.button("Download Plot", {value: 0, color: "#2C3E50"})
save_request = {
save_btn;
return save_settings;
}{
if (downloaded_file) {
const blob = new Blob([new Uint8Array(downloaded_file.data)], { type: downloaded_file.mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = downloaded_file.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}This plot shows the estimated mean expression of a gene (i.e., marginal means) between the pairwise contrast that is currently selected. This can help you gauge the magnitude of the difference between the two groups after accounting for any other variables in the model.
Like the volcano plot, the x-axis shows the log2 fold change. The y-axis shows marginal mean response (log2 transformed). Each gene is represented by up to two points (one for each level of the contrast) with error bars. When “vs rest” or “vs all” is selected, only a single level is shown.
available_levels = {
const payload = study_data_payload;
// Prevents OJS from crashing on local VFS paths
if (!payload || payload.type === "local") return null;
if (payload.mode === "remote_js_bridge") {
try {
const conn = await duck_db_client;
const query = `SELECT DISTINCT level FROM '${payload.url_emmeans}' ORDER BY level`;
const result = await conn.query(query);
return result.toArray().map(row => row.level).filter(t => !t.startsWith("otherct_"));
} catch (e) {
console.error("❌ Available Levels Fetch Error:", e);
return [];
}
}
}viewof term_copy = {
if (!available_terms || available_terms.length === 0) return md`_Loading..._`;
const mirror_input = Inputs.select(available_terms, {
label: "Available Terms:",
multiple: false,
size: 3
});
return Inputs.bind(mirror_input, viewof term_selection);
}
viewof contrast_copy = {
if (!available_contrasts || available_contrasts.length === 0) return md`_Loading..._`;
const mirror_input = Inputs.select(available_contrasts, {
label: "Available Contrasts:",
format: d => d.replace(" / ", " vs. "),
multiple: false,
size: 3
});
return Inputs.bind(mirror_input, viewof contrast_selection);
}viewof means_raw = Inputs.form({
means_highlight: Inputs.text({label: "Highlight Target:", placeholder: "e.g. A1BG,A1CF"}),
means_show_labeled: Inputs.toggle({label: "Also highlight Volcano-labeled Genes", value: true}),
means_alpha: Inputs.range([0.1, 1], {value: 0.8, step: 0.1, label: "Dot Transparency"}),
means_color_1: Inputs.color({label: "Level 1 Color", value: "#e15759"}),
means_color_2: Inputs.color({label: "Level 2 Color", value: "#4e79a7"})
})
means_debounced = {
const raw = means_raw;
return new Promise(resolve => {
const timer = setTimeout(() => resolve(raw), 600);
invalidation.then(() => clearTimeout(timer));
});
}
means_highlight = means_debounced.means_highlight
means_show_labeled = means_debounced.means_show_labeled
means_alpha = means_debounced.means_alpha
means_color_1 = means_debounced.means_color_1
means_color_2 = means_debounced.means_color_2
combined_selection = {
const manual = means_highlight
? means_highlight.toUpperCase().split(',').map(s => s.trim()).filter(s => s.length > 0)
: [];
let from_volcano = [];
if (means_show_labeled && typeof vplot_labels !== "undefined") {
if(Array.isArray(vplot_labels)) {
from_volcano = vplot_labels;
} else if (typeof vplot_labels === 'string') {
from_volcano = [vplot_labels];
}
}
return Array.from(new Set([...manual, ...from_volcano]));
}
means_level = {
const contrast = contrast_selection;
if (!contrast) return [];
return contrast.split(/\/|vs\./).map(s => s.trim());
}{
if (!means_search || means_search.length === 0) {
yield html`<div style="height:400px; display:flex; align-items:center; justify-content:center; background:#f9f9f9; border:1px border-radius:8px;">
<p style="color:#888;">Select levels in the sidebar to visualize...</p>
</div>`;
} else {
const colorScale = d3.schemeTableau10;
let table_targets = [];
if (typeof means_table !== "undefined" && means_table && means_search) {
if (means_table.length !== means_search.length) {
table_targets = means_table.map(d => d.target);
}
}
const manual_targets = combined_selection || [];
const highlight_list = Array.from(new Set([...table_targets, ...manual_targets]));
const targetGroups = d3.groups(means_search, d => d.target);
const levels = [...new Set(means_search.map(d => d.level))].sort();
const traces = [];
const alpha_scaler = means_alpha;
const line_segments_high = { x: [], y: [], hoverinfo: 'skip', showlegend: false, mode: 'lines', line: {color: '#888', width: 1.5}, opacity: 0.8 * alpha_scaler, type: 'scatter' };
const line_segments_med = { x: [], y: [], hoverinfo: 'skip', showlegend: false, mode: 'lines', line: {color: '#888', width: 1}, opacity: 0.4 * alpha_scaler, type: 'scatter' };
const line_segments_low = { x: [], y: [], hoverinfo: 'skip', showlegend: false, mode: 'lines', line: {color: '#ccc', width: 1}, opacity: 0.3 * alpha_scaler, type: 'scatter' };
targetGroups.forEach(([target, values]) => {
if(values.length < 2) return;
const v1 = values[0];
const x = v1.log2fc;
if(x == null || isNaN(x)) return;
// Transform Response to Log2 for Y-axis
const ys = values.map(d => Math.log2(d.response));
const min_y = Math.min(...ys);
const max_y = Math.max(...ys);
const val_p = v1["p.value"];
const p = val_p != null ? val_p : 1;
let seg = line_segments_low;
if(p < 0.001) seg = line_segments_high;
else if(p < 0.05) seg = line_segments_med;
seg.x.push(x, x, null);
seg.y.push(min_y, max_y, null);
});
traces.push(line_segments_low, line_segments_med, line_segments_high);
levels.forEach((lvl, i) => {
const color = (i === 0) ? means_color_1 : means_color_2;
const lvl_trace = {
x: [], y: [], text: [], ids: [],
mode: 'markers',
type: 'scatter',
name: lvl,
marker: { color: color, size: 6, opacity: means_alpha },
error_y: {
type: 'data', symmetric: false, array: [], arrayminus: [],
visible: true, color: color, thickness: 1, width: 2, opacity: 0.6
}
};
const lvlData = means_search.filter(d => d.level === lvl);
lvlData.forEach(d => {
if (d.log2fc == null || isNaN(d.log2fc)) return;
// Log2 Transformation
const y_log = Math.log2(d.response);
const ucl_log = Math.log2(d.UCL);
const lcl_log = Math.log2(d.LCL);
lvl_trace.x.push(d.log2fc);
lvl_trace.y.push(y_log);
lvl_trace.text.push(`<b>${d.target}</b><br>Level: ${d.level}<br>Log2 Resp: ${y_log.toFixed(2)}<br>FC: ${d.log2fc.toFixed(2)}`);
lvl_trace.ids.push(`${d.target}_${d.level}`);
lvl_trace.error_y.array.push(ucl_log - y_log);
lvl_trace.error_y.arrayminus.push(y_log - lcl_log);
});
traces.push(lvl_trace);
});
if(highlight_list.length > 0) {
const highlightData = means_search.filter(d => highlight_list.includes(d.target) && d.log2fc != null);
traces.push({
x: highlightData.map(d => d.log2fc),
y: highlightData.map(d => Math.log2(d.response)),
mode: 'markers+text',
type: 'scatter',
name: 'Highlights',
text: highlightData.map(d => d.target),
textposition: 'top center',
textfont: { color: 'black', size: 11, weight: 'bold' },
marker: { color: 'black', size: 10, symbol: 'circle-open', line: {width: 2} },
hoverinfo: 'skip',
cliponaxis: false
});
}
const layout = {
height: 500,
margin: { t: 30, b: 50, l: 60, r: 20 },
hovermode: 'closest',
title: { text: 'Marginal Means vs Fold Change', font: {size: 14} },
xaxis: {
title: 'Log2 Fold Change (Contrast)',
zeroline: true,
zerolinecolor: '#eee',
gridcolor: '#f9f9f9'
},
yaxis: {
title: 'Log2 Expression (Response)',
gridcolor: '#f9f9f9',
zeroline: false
},
legend: { orientation: 'h', y: -0.15 },
showlegend: true
};
const div = document.createElement("div");
Plotly.newPlot(div, traces, layout, { responsive: true, displayModeBar: true });
yield div;
}
}viewof means_table = Inputs.table(means_search, {
multiple: true,
value: combined_selection, // Pre-check the combined selection
rows: 20,
format: {
response: sparkbar(d3.max(means_search, d => d.response))
}})means_data_js = {
const payload = study_data_payload;
const lvl = means_level;
if (!payload || !lvl || lvl.length === 0 || payload.mode === "local") return null;
if (payload.mode === "remote_js_bridge") {
try {
const conn = await duck_db_client;
const formattedLevels = lvl.map(d => `'${d}'`).join(',');
const query = `
SELECT level, target, response, "asymp.LCL" as LCL, "asymp.UCL" as UCL
FROM '${payload.url_emmeans}'
WHERE level IN (${formattedLevels})
`;
const result = await conn.query(query);
return result.toArray().map(row => row.toJSON());
} catch (e) {
console.error("Mean Fetch Error:", e);
return [];
}
}
}viewof spatial_options = Inputs.form({
pt_size: Inputs.range([0.5, 10], {value: 2, step: 0.5, label: "Point Size"}),
pt_opacity: Inputs.range([0.1, 1], {value: 0.7, step: 0.1, label: "Opacity"}),
bg_color: Inputs.color({label: "Background Color", value: "#e0e0e0"}),
// Scale Bar
scale_len: Inputs.number({label: "Scale Bar (mm)", value: 1, step: 0.1}),
scale_color: Inputs.color({label: "Scale Color", value: "#000000"}),
show_scale: Inputs.toggle({label: "Show Scale Bar", value: true})
})
viewof log_scale_opt = {
if(!spatial_data || spatial_data.length === 0) return md``;
const val = spatial_data[0].group_id;
if (typeof val === 'number') {
return Inputs.toggle({label: "Log Scale Color (n+1)", value: false});
} else {
return Inputs.toggle({label: "Log Scale Color (n+1)", value: false, disabled: true});
}
}
spatial_colors = {
return {
l1: means_color_1 || "#e15759",
l2: means_color_2 || "#4e79a7",
other: spatial_options.bg_color
}
}Metadata Filtering
viewof filter_col = Inputs.select(spatial_meta_columns, {label: "Filter By:", value: null, multiple: false})
is_numeric_filter = {
if(!spatial_data || !filter_col) return false;
const val = spatial_data[0][filter_col];
return typeof val === 'number';
}
viewof filter_vals = {
if(!filter_col || !spatial_data) return md`_Select a column..._`;
if (is_numeric_filter) {
const extent = d3.extent(spatial_data, d => d[filter_col]);
return Inputs.form({
min: Inputs.number({label: "Min", value: extent[0]}),
max: Inputs.number({label: "Max", value: extent[1]})
});
} else {
const unique_vals = [...new Set(spatial_data.map(d => d[filter_col]))].sort();
return Inputs.select(unique_vals, {
label: "Include Values:",
multiple: true,
sort: true,
unique: true
});
}
}
Stats
md`
- **Total Loaded**: ${spatial_data ? spatial_data.length.toLocaleString() : "..."}
- **Visible**: ${spatial_filtered ? spatial_filtered.length.toLocaleString() : "..."}
`spatial_data = {
const payload = study_data_payload;
const r_push = r_spatial_data_export; // Reactive link to the webR block above
if (!payload) return null;
// 1. LOCAL MODE: Always use the R-side DuckDB data
if (payload.type === "local") {
if (r_push && r_push.length > 0) {
console.log("📍 Spatial Data: Received via R-Side DuckDB Bridge");
return r_push;
}
// Return empty array to clear the canvas during transitions
return [];
}
// 2. REMOTE MODE: Keep the URL-based JS-bridge for manifest studies
const term = term_selection;
const slide = slide_selection;
if (!term || slide === null) return null;
try {
const conn = await duck_db_client;
const result = await conn.query(`SELECT * FROM "${payload.url_meta}" WHERE slide_id_numeric = ${slide}`);
const rows = result.toArray().map(row => row.toJSON());
rows.forEach(r => { r.group_id = r[term]; });
return rows;
} catch (e) {
console.error("❌ Remote Spatial Fetch Error:", e);
return [];
}
}
spatial_meta_columns = {
if (!spatial_data || spatial_data.length === 0) return [];
// Dynamically grab columns from whatever study is currently loaded
const keys = Object.keys(spatial_data[0]);
return keys.filter(k =>
!["x_slide_mm", "y_slide_mm", "group_id", "__index_level_0__", "slide_id_numeric"].includes(k)
);
}
spatial_filtered = {
if(!spatial_data) return [];
if(!filter_col || !filter_vals) return spatial_data;
// LOGIC SWITCH:
if(is_numeric_filter) {
// Numeric Filter: filter_vals is an object {min: x, max: y}
const { min, max } = filter_vals;
return spatial_data.filter(d => d[filter_col] >= min && d[filter_col] <= max);
} else {
// Categorical Filter: filter_vals is an array of selected strings
if(filter_vals.length === 0) return spatial_data;
return spatial_data.filter(d => filter_vals.includes(d[filter_col]));
}
}viewof slide_selection = {
if (!available_slides || available_slides.length === 0) return md`_Loading slides..._`;
return Inputs.select(available_slides, {
label: "Available Slides:",
multiple: false,
size: 3
});
}
viewof term_copy2 = {
if (!available_terms || available_terms.length === 0) return md`_Loading..._`;
const mirror_input3 = Inputs.select(available_terms, {
label: "Available Terms:",
multiple: false,
size: 3
});
return Inputs.bind(mirror_input3, viewof term_selection);
}
viewof contrast_copy2 = {
if (!available_contrasts || available_contrasts.length === 0) return md`_Loading..._`;
const mirror_input2 = Inputs.select(available_contrasts, {
label: "Available Contrasts:",
format: d => d.replace(" / ", " vs. "),
multiple: false,
size: 3
});
return Inputs.bind(mirror_input2, viewof contrast_selection);
}{
const data = spatial_filtered;
const levels = means_level;
const opts = spatial_options;
const colors = spatial_colors;
const width = 1200;
const height = 900;
const useLog = log_scale_opt === true;
//if (!data || data.length === 0) return md`_Loading spatial data..._`;
const isMock = !data || data.length === 0 ||
(data.length === 4 && d3.max(data, d => d.x_slide_mm) <= 1);
if (isMock) {
return html`
<div style="height: 600px; display: flex; flex-direction: column; align-items: center; justify-content: center; background: #fdfdfd; border: 2px dashed #dee2e6; border-radius: 12px; text-align: center; padding: 40px;">
<div style="font-size: 3em; opacity: 0.4;">📍</div>
<h3 style="color: #3C3F40; margin-top: 15px;">Spatial Plot Unavailable</h3>
<p style="max-width: 440px; color: #888; font-size: 0.9em; line-height: 1.6;">
This study (AtoMx SIP or smiDE) does not contain any cell-level spatial metadata to display.
</p>
<div style="margin-top: 20px; font-size: 0.75em; border-top: 1px solid #eee; padding-top: 15px; font-style: italic; color: #b44697;">
R users: Use <code>create_mock_sp_data()</code> to generate placeholder coordinates.
</div>
</div>
`;
}
const isContinuous = typeof data[0].group_id === 'number';
let colorScale = null;
let termExtent = [0, 1];
if(isContinuous) {
termExtent = d3.extent(data, d => d.group_id);
if (useLog) {
// Log Scale: Map Log(Min+1) -> Log(Max+1)
const logMin = Math.log(termExtent[0] + 1);
const logMax = Math.log(termExtent[1] + 1);
colorScale = d3.scaleSequential(d3.interpolateRgb(colors.l2, colors.l1))
.domain([logMin, logMax]);
} else {
// Linear Scale
colorScale = d3.scaleLinear()
.domain(termExtent)
.range([colors.l2, colors.l1]);
}
}
const xExtent = d3.extent(data, d => d.x_slide_mm);
const yExtent = d3.extent(data, d => d.y_slide_mm);
const xMin = xExtent[0], xMax = xExtent[1];
const yMin = yExtent[0], yMax = yExtent[1];
const dataWidth = xMax - xMin;
const dataHeight = yMax - yMin;
const xScaleFactor = width / dataWidth;
const yScaleFactor = height / dataHeight;
const scale = Math.min(xScaleFactor, yScaleFactor) * 0.95;
const cx = width / 2;
const cy = height / 2;
const dx = (xMin + xMax) / 2;
const dy = (yMin + yMax) / 2;
const project = (x, y) => {
return [
cx + (x - dx) * scale,
cy - (y - dy) * scale
];
}
const pixelsPerMm = scale;
// --- 3. DOM Setup ---
const div = document.createElement("div");
div.style.position = "relative";
div.style.width = "100%";
div.style.maxWidth = "100%";
div.style.height = "auto";
div.style.border = "1px solid #eee";
div.style.background = "#fff";
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
canvas.style.width = "100%"; // Responsive CSS
canvas.style.height = "auto";
canvas.style.display = "block";
div.appendChild(canvas);
// Legend
const legend = document.createElement("div");
legend.style.position = "absolute";
legend.style.top = "10px";
legend.style.right = "10px";
legend.style.background = "rgba(255, 255, 255, 0.9)";
legend.style.padding = "8px";
legend.style.borderRadius = "4px";
legend.style.border = "1px solid #ccc";
legend.style.fontSize = "12px";
legend.style.fontFamily = "sans-serif";
if (isContinuous) {
const label = useLog ? `Log(${term_selection} + 1)` : term_selection;
const minLabel = useLog ? Math.log(termExtent[0] + 1).toFixed(2) : termExtent[0].toFixed(2);
const maxLabel = useLog ? Math.log(termExtent[1] + 1).toFixed(2) : termExtent[1].toFixed(2);
legend.innerHTML = `
<strong>${label}</strong><br>
<div style="display:flex; align-items:center; gap:5px; margin-top:4px;">
<span>${minLabel}</span>
<div style="width:60px; height:10px; background: linear-gradient(to right, ${colors.l2}, ${colors.l1});"></div>
<span>${maxLabel}</span>
</div>
`;
} else {
const L1 = (levels && levels.length > 0) ? levels[0] : "Level 1";
const L2 = (levels && levels.length > 1) ? levels[1] : "Level 2";
legend.innerHTML = `
<strong>${term_selection}</strong><br>
<div style="display:flex; align-items:center; gap:5px; margin-top:2px;">
<span style="display:inline-block; width:10px; height:10px; background:${colors.l1};"></span> ${L1}<br>
</div>
<div style="display:flex; align-items:center; gap:5px; margin-top:2px;">
<span style="display:inline-block; width:10px; height:10px; background:${colors.l2};"></span> ${L2}
</div>
`;
}
div.appendChild(legend);
const ctx = canvas.getContext("2d");
let transform = d3.zoomIdentity;
function draw() {
ctx.save();
ctx.clearRect(0, 0, width, height);
ctx.translate(transform.x, transform.y);
ctx.scale(transform.k, transform.k);
const radius = opts.pt_size / transform.k;
ctx.globalAlpha = opts.pt_opacity;
const cOther = colors.other;
const cL1 = colors.l1;
const cL2 = colors.l2;
const L1 = (levels && levels.length > 0) ? levels[0] : null;
const L2 = (levels && levels.length > 1) ? levels[1] : null;
for (let i = 0; i < data.length; i++) {
const d = data[i];
let fill = cOther;
if (isContinuous) {
if (d.group_id != null) {
const val = useLog ? Math.log(d.group_id + 1) : d.group_id;
fill = colorScale(val);
}
} else {
if (L1 && d.group_id === L1) fill = cL1;
else if (L2 && d.group_id === L2) fill = cL2;
}
const xy = project(d.x_slide_mm, d.y_slide_mm);
ctx.beginPath();
ctx.fillStyle = fill;
ctx.fillRect(xy[0] - radius, xy[1] - radius, radius*2, radius*2);
}
ctx.restore();
if (opts.show_scale) {
const barLenMm = opts.scale_len;
const barLenPx = barLenMm * pixelsPerMm * transform.k;
const margin = 20;
const barX = width - margin - barLenPx;
const barY = height - margin;
ctx.save();
ctx.beginPath();
ctx.strokeStyle = opts.scale_color;
ctx.lineWidth = 4;
ctx.moveTo(barX, barY);
ctx.lineTo(barX + barLenPx, barY);
ctx.stroke();
ctx.fillStyle = opts.scale_color;
ctx.font = "bold 14px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillText(`${barLenMm} mm`, barX + barLenPx/2, barY - 5);
ctx.restore();
}
}
draw();
const zoom = d3.zoom()
.scaleExtent([0.5, 50])
.on("zoom", (event) => {
transform = event.transform;
requestAnimationFrame(draw);
});
d3.select(canvas).call(zoom);
return div;
}In AtoMx SIP, when your differential expression (DE) results are ready you can download them. This download will be a zip file named something like output_results.zip. This file is relatively large but luckily for this “Byte-sized” dashboard we only need a few of the columns. You can use this section to format that large zip file into a dashboard-ready zip file that contains essential information. See the card below to load your AtoMx-derived DE zip file. You’ll be given the option to add optional study description and model formula that can be handy when you are comparing multiple studies at once. When the processing is finished, you will have the option to download the dashboard-ready and formatted zip file, load it directly into this dashboard, or both. One thing to note is that the DE zip file that is downloaded from AtoMx SIP does not contain cell-level metadata and so the Spatial Plots section will only contain a placeholder dataset (just four ‘cells’) and not actual data.
viewof atomx_zip_file = Inputs.file({
label: "Import AtoMx SIP DE Results (.zip):",
accept: ".zip"
})
atomx_surgical_ingestor = {
if (!atomx_zip_file) return null;
try {
console.log("🛠️ INGESTOR START: Reading ArrayBuffer...");
const buffer = await atomx_zip_file.arrayBuffer();
const z = await import("https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.7.34/+esm");
// Create the reader from the Uint8Array of the full zip
const reader = new z.ZipReader(new z.Uint8ArrayReader(new Uint8Array(buffer)));
const entries = await reader.getEntries();
const targets = ["emmeans.csv", "pairwise.csv", "one.vs.rest.csv", "one.vs.all.csv"];
const conn = await duck_db_client;
const db = conn.instance || conn.db || conn;
for (const entry of entries) {
const match = targets.find(t => entry.filename.toLowerCase().includes(t));
if (match) {
console.log(`📦 ZIP ENTRY FOUND: ${entry.filename}`);
// FIX: Ensure we are awaiting the full data extraction
const uint8 = await entry.getData(new z.Uint8ArrayWriter());
if (uint8 && uint8.length > 0) {
await db.registerFileBuffer(match, uint8);
console.log(`✅ REGISTERED: ${match} (${uint8.length.toLocaleString()} bytes)`);
} else {
console.warn(`⚠️ WARNING: ${match} extracted as 0 bytes!`);
}
}
}
await reader.close();
return { status: "ready", timestamp: Date.now() };
} catch (err) {
console.error("❌ INGESTOR CRITICAL ERROR:", err);
return { status: "error", message: err.message };
}
}atomx_parquet_generator = {
if (!atomx_surgical_ingestor || atomx_surgical_ingestor.status !== "ready") return null;
if (!atomx_metadata || atomx_metadata.process_btn === 0) return null;
const conn = await duck_db_client;
console.log("🪄 GENERATOR START: Building SQL Views...");
try {
// FIX: Use DuckDB's internal schema field mapping
console.log("🔍 Probing columns in pairwise.csv...");
const schemaRes = await conn.query("SELECT * FROM read_csv_auto('pairwise.csv') LIMIT 0");
const cols = schemaRes.schema.fields.map(f => f.name);
console.log("📋 Detected Columns:", cols);
const n1 = cols.includes("ncells_1") ? "ncells_1" : "NULL as ncells_1";
const n2 = cols.includes("ncells_2") ? "ncells_2" : "NULL as ncells_2";
// 1. Harmonize EMMEANS
await conn.query(`
CREATE OR REPLACE VIEW emmeans_final AS
WITH base AS (
SELECT *,
regexp_replace(level, term, '') as suffix_str,
TRY_CAST(regexp_replace(level, term, '') AS DOUBLE) as suffix_val,
(level LIKE term || '%') AND (TRY_CAST(regexp_replace(level, term, '') AS DOUBLE) IS NOT NULL) as is_continuous
FROM read_csv_auto('emmeans.csv')
WHERE term != 'otherct_expr'
)
SELECT
term,
CASE
WHEN is_continuous AND category = 'c1' THEN 'mean + 1SD (' || printf('%.2f', suffix_val) || ')'
WHEN is_continuous AND category = 'c2' THEN 'mean (' || printf('%.2f', suffix_val) || ')'
ELSE level
END as level,
target, response, "asymp.LCL", "asymp.UCL"
FROM base
`);
// 2. Build Master PAIRWISE Union
await conn.query(`
CREATE OR REPLACE VIEW pairwise_final AS
SELECT 'pairwise' as contrast_type, term, contrast, target, log2(fold_change) as log2fc, "p.value", ${n1}, ${n2} FROM read_csv_auto('pairwise.csv')
UNION ALL
SELECT 'one_vs_rest', term, contrast, target, log2(fold_change), "p.value", ${n1}, ${n2} FROM read_csv_auto('one.vs.rest.csv')
UNION ALL
SELECT 'one_vs_all', term, contrast, target, log2(fold_change), "p.value", ${n1}, ${n2} FROM read_csv_auto('one.vs.all.csv')
`);
console.log("✅ GENERATOR SUCCESS!");
return { ready: true, timestamp: Date.now() };
} catch (err) {
console.error("❌ GENERATOR SQL ERROR:", err);
return { ready: false, error: err.message };
}
}viewof atomx_metadata = {
if (!atomx_surgical_ingestor || atomx_surgical_ingestor.status !== "ready") {
return html`
<div style="margin-top: 10px; padding: 15px; border: 1px dashed #ddd; border-radius: 8px; background: #fafafa; color: #666;">
<h5 style="text-transform: uppercase; color: #888; font-size: 0.75em; margin-bottom: 8px;">AtoMx Ingestor Status</h5>
<div style="font-size: 0.9em; display: flex; align-items: center; gap: 10px;">
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
<span>Waiting for AtoMx SIP results import from Step 1...</span>
</div>
</div>
`;
}
const form = Inputs.form({
study_name: Inputs.text({label: "Study Name", value: "AtoMx Analysis"}),
description: Inputs.text({label: "Description", value: "AtoMx SIP Export"}),
formula: Inputs.text({label: "Model Formula", placeholder: "e.g. ~annotated_domain"}),
process_btn: Inputs.button("🚀 Format Results", {value: 0})
});
form.style.border = "1px solid #eee";
form.style.borderRadius = "8px";
form.style.padding = "20px";
form.style.background = "#fff";
form.style.marginTop = "10px";
// Prepend the header inside the form card
const header = html`
<h5 style="text-transform: uppercase; color: #888; font-size: 0.75em; margin-bottom: 12px; border-bottom: 1px solid #eee; padding-bottom: 8px;">
Optional Study Metadata
</h5>
`;
form.prepend(header);
return form;
}atomx_downloader_manager = {
const container = d3.select("#atomx-download-container");
// Guard: Hide container if data isn't processed yet
if (!atomx_parquet_generator || !atomx_parquet_generator.ready) {
container.style("display", "none");
return null;
}
container.style("display", "block").html(""); // Clear and show
const ui = html`
<h5 style="margin-top: 0;">🚀 Formatting Complete</h5>
<p style="font-size: 0.9em; color: #555;">Choose how you would like to proceed with your harmonized study:</p>
<div class="d-grid gap-2 d-md-block">
<button id="btn-atomx-download-only" class="btn btn-outline-primary">💾 Download Only</button>
<button id="btn-atomx-launch-only" class="btn btn-outline-success">⚡ Launch Only</button>
<button id="btn-atomx-both" class="btn btn-primary">🚀 Download & Launch</button>
</div>
<div id="atomx-download-status" style="margin-top: 12px; font-weight: 500;"></div>
`;
container.node().appendChild(ui);
const status = ui.querySelector("#atomx-download-status");
const btnDownload = ui.querySelector("#btn-atomx-download-only");
const btnLaunch = ui.querySelector("#btn-atomx-launch-only");
const btnBoth = ui.querySelector("#btn-atomx-both");
const runConversion = async () => {
const conn = await duck_db_client;
const db = conn.instance || conn.db || conn;
await conn.query(`
CREATE OR REPLACE VIEW pairwise_scrubbed AS
SELECT * FROM pairwise_final WHERE term != 'otherct_expr'
`);
await conn.query(`
CREATE OR REPLACE VIEW emmeans_scrubbed AS
SELECT * FROM emmeans_final WHERE term != 'otherct_expr'
`);
const termRes = await conn.query("SELECT DISTINCT term FROM pairwise_scrubbed LIMIT 1");
const activeTerm = termRes.toArray()[0]?.term || "Term";
const levelRes = await conn.query("SELECT DISTINCT level FROM emmeans_scrubbed LIMIT 2");
const levels = levelRes.toArray().map(row => row.level);
const l1 = levels[0] || 'Level_A';
const l2 = levels[1] || l1;
console.log(`🧼 FILTERING: Removed 'otherct_expr'. Active Term: ${activeTerm}`);
await conn.query("COPY (SELECT * FROM pairwise_scrubbed) TO 'pairwise.parquet' (FORMAT 'PARQUET')");
await conn.query("COPY (SELECT * FROM emmeans_scrubbed) TO 'emmeans.parquet' (FORMAT 'PARQUET')");
await conn.query(`
CREATE OR REPLACE VIEW spatial_smart_placeholder AS
SELECT 0.0 as x_slide_mm, 0.0 as y_slide_mm, 1 as slide_id_numeric, '${l1}' as "${activeTerm}" UNION ALL
SELECT 0.0, 1.0, 1, '${l1}' UNION ALL
SELECT 1.0, 0.0, 1, '${l2}' UNION ALL
SELECT 1.0, 1.0, 1, '${l2}'
`);
await conn.query("COPY (SELECT * FROM spatial_smart_placeholder) TO 'cell_metadata.parquet' (FORMAT 'PARQUET')");
const countRes = await conn.query("SELECT COUNT(DISTINCT target) as n FROM pairwise_scrubbed");
const targetCount = Number(countRes.toArray()[0].n);
await conn.query(`
COPY (SELECT '${atomx_metadata.study_name}' as Name, '${atomx_metadata.description}' as Description,
'${atomx_metadata.formula}' as Formala, ${targetCount} as "Targets tested")
TO 'study_header.parquet' (FORMAT 'PARQUET')`);
return {
p: await db.copyFileToBuffer('pairwise.parquet'),
e: await db.copyFileToBuffer('emmeans.parquet'),
m: await db.copyFileToBuffer('cell_metadata.parquet'),
h: await db.copyFileToBuffer('study_header.parquet')
};
};
const createZipBlob = async (buffers) => {
const z = await import("https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.7.34/+esm");
const zipWriter = new z.ZipWriter(new z.BlobWriter("application/zip"));
await zipWriter.add("pairwise.parquet", new z.Uint8ArrayReader(new Uint8Array(buffers.p)));
await zipWriter.add("emmeans.parquet", new z.Uint8ArrayReader(new Uint8Array(buffers.e)));
await zipWriter.add("cell_metadata.parquet", new z.Uint8ArrayReader(new Uint8Array(buffers.m)));
await zipWriter.add("study_header.parquet", new z.Uint8ArrayReader(new Uint8Array(buffers.h)));
return await zipWriter.close();
};
const triggerDownload = (blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = `${atomx_metadata.study_name}_smiDE.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
btnDownload.onclick = async () => {
status.innerHTML = "⚙️ Generating Zip...";
const buffers = await runConversion();
const blob = await createZipBlob(buffers);
triggerDownload(blob);
status.innerHTML = "✅ Download Complete.";
};
btnLaunch.onclick = async () => {
status.innerHTML = "🚀 Launching Study...";
const buffers = await runConversion();
const blob = await createZipBlob(buffers);
mutable new_zip_data = {
id: Date.now(),
filename: `${atomx_metadata.study_name}_smiDE.zip`,
data: new Uint8Array(await blob.arrayBuffer())
};
status.innerHTML = "✅ Study Launched! Check the sidebar.";
};
btnBoth.onclick = async () => {
status.innerHTML = "⚙️ Processing Both...";
const buffers = await runConversion();
const blob = await createZipBlob(buffers);
triggerDownload(blob);
mutable new_zip_data = {
id: Date.now(),
filename: `${atomx_metadata.study_name}_smiDE.zip`,
data: new Uint8Array(await blob.arrayBuffer())
};
status.innerHTML = "✅ Success! Zip downloaded and Study launched.";
};
return ui;
}You can use this dashboard to explore your own smiDE results. For now this dashboard only works using differential expression results obtained directly from the smiDE R package. So far I have tested it with two cases: a discrete/ categorical grouping variable and a continuous one.
Let’s say you have a differential expression object obtained from calling the smiDE::smi_de function:
library(smiDE)
# Example function call below that examines tumor cell expression across
# different spatial domains (annotated_domains).
de_obj <-
smi_de(assay_matrix = assay_matrix_use
,metadata = meta_use
,formula = ~RankNorm(otherct_expr) + annotated_domain + offset(log(nCount_RNA))
,pre_de_obj = pre_de_obj
,neighbor_expr_cell_type_metadata_colname = "celltype_broad"
,neighbor_expr_overlap_weight_colname = NULL
,neighbor_expr_overlap_agg ="sum"
,neighbor_expr_totalcount_normalize = TRUE
,neighbor_expr_totalcount_scalefactor = tc_scalefactors
,family="nbinom2"
,cellid_colname = "cell_id_numeric"
,targets=genes_to_analyze,
nCores=30
)
res_list <- results(de_obj)Since the output structure differs slighlty depending on whether your DE results are based on discrete or continous terms, we’ll use this harmonizing function to format the results into a single format that the dashboard can understand. Specifically, the function below converts the pairwise results and the emmeans results.
Now convert the res_list into the formatted parts.
The pairwise table and the emmeans table two of the four required components. To create the study_header – which is simply a description data set that helps provide a little more context to the data displayed on the dashboard itself, we’ll create it like this:
Note: The column names must match exactly as given.
And finally, it can be useful view the cells in space to get a sense of the spatial structure of groupings. We’ll do this by selecting columns within our cell-level metadata object. Keep in mind that this dataset is loaded fully in your browser’s memory and so very large dataframes might slow down or even crash your browser.
In the code snippet below, I’m interested in looking at the broad cell types and the annotated domain. Note that there are a minimum of four required columns that are expected in the dashboard. The first three are x_slide_mm, y_slide_mm, and slide_id_numeric. These are used to facet the spatial plot. The other required columns are the metadata columns used for your grouping variable. In the example above, that grouping column was named annotated_domain but this can vary based on the study. These columns are used to color the spatial plots when a given contrast is selected. Other columns can optionally be added. For example, celltype_broad is useful to include here is we used that column to filter our data and in the spatial plots section we can filter the cells to include only cells that have a celltype_broad value of tumor to match our DE analysis.
# meta_df == the full cell-level metadata (i.e., obs)
meta_display <- data.table(meta_df)
meta_display <- meta_display[, .(
x_slide_mm = sdimx,
y_slide_mm = sdimy,
celltype_broad,
annotated_domain
)]
if('slide_id_numeric' %in% colnames(meta_df)){
meta_display$slide_id_numeric <- meta_df$slide_id_numeric
} else {
meta_display$slide_id_numeric <- 1L
}Alternatively, if you just want the volcano plots and emmeans and want to skip the spatial plotting together, just create and pass a placeholder data.frame like so:
create_mock_sp_data <- function(emmeans_formatted) {
terms <- emmeans_formatted %>%
select(term) %>%
pull()
mock_template <- data.frame(
'x_slide_mm' = c(0, 0, 1, 1),
'y_slide_mm' = c(0, 1, 0, 1),
'slide_id_numeric' = rep(1L, 4)
)
for(i in seq_len(length(terms))){
mock_template[[terms[i]]] <- rep(NA, 4)
}
return(mock_template)
}
# this will just add 4 rows data which the dashboard uses as a signature for
# "Not Applicable"
meta_display <- create_mock_sp_data(emmeans)At this point we are ready to package these four components so they can be loaded in your browser’s tab (specifically, in the virtual file system [VFS] that webR uses). The two steps to this procedure is:
- convert data to parquet.
- zip up all parquet files into a single zip file.
When converting data to parquet, we’ll use this write_opt_parquet function. It’s just a wrapper function for writing to parquet but, to save memory, one could reduce the numeric precision of columns from R’s float64 to either 32 bit or 16 bit, if desired. Keep in mind that things like p-values will likely need greater precision.
Write the parquet files to disk. Note that the folder names can vary but the file names must match exactly.
parquet_dir <- "./your_parquet_folder"
dir.create(parquet_dir)
write_opt_parquet(study_header, file.path(parquet_dir, "study_header.parquet"))
write_opt_parquet(
pairwise,
file.path(parquet_dir, "pairwise.parquet")
)
write_opt_parquet(
emmeans,
file.path(parquet_dir, "emmeans.parquet")
)
write_opt_parquet(
meta_display,
file.path(parquet_dir, "cell_metadata.parquet"),
f32_cols = c('x_slide_mm', 'y_slide_mm'),
f16_cols = NULL
)And finally, zip it up! You can name the zip file whatever you like.
And that should be it. You should be able to “upload” that zip file on the left side panel of the dashboard.
This is the code that generates the volcano plot. For advanced users who wish to modify the volcano plot aesthetics beyond what is available within ADJUST IMAGE AESTHETICS, you can adjust the code and click Run Code. This will, in turn, use your custom code reactively. If any errors occur, you can simply click START OVER or simply refresh your browser, to return the plotting function back to its original state. You can install and use additional R packages if you like if they are already compiled to WebAssembly.
. This is “living code” is used to generate the and adjustmentused to reactively generate the plots of the dashboard. You can modify them to generate completely custom aesthetics. If run into issues, you can always press ‘START OVER’ to return the plotting function back to its original form.
This webR console is available for anything from quick calculations to creating additional plots. For a list of pre-compiled R packages that are available take a look at https://repo.r-wasm.org/.
{
const catchRuntimeLoadingErrors = () => {
// Target the specific Quarto callout structure you captured
const errors = document.querySelectorAll('.observablehq--error, .ojs-in-a-box-waiting-for-module-import');
errors.forEach(el => {
const callout = el.querySelector('.callout-important');
if (callout) {
callout.style.display = 'none';
if (!el.querySelector('.custom-loading-label')) {
const label = document.createElement('div');
label.className = 'custom-loading-label';
label.innerHTML = `
<div style="padding: 2rem; border: 1px dashed #ccc; border-radius: 8px;
background: #f9f9f9; color: #888; text-align: center; font-style: italic;">
⌛ Initializing Dashboard Components...
</div>`;
el.prepend(label);
}
}
});
};
catchRuntimeLoadingErrors();
const observer = new MutationObserver(catchRuntimeLoadingErrors);
observer.observe(document.body, { childList: true, subtree: true });
invalidation.then(() => observer.disconnect());
}