diff --git a/js/ui/text_buttons.js b/js/ui/text_buttons.js index 8650be5d..0e678afe 100644 --- a/js/ui/text_buttons.js +++ b/js/ui/text_buttons.js @@ -357,6 +357,16 @@ const make_ist_img_layer_button_callback = ( toggle_slider(inst_slider, is_visible); + if (!viz_state.img.visible_layers) { + viz_state.img.visible_layers = new Set(); + } + + if (is_visible) { + viz_state.img.visible_layers.add(text); + } else { + viz_state.img.visible_layers.delete(text); + } + viz_state.obs_store.deck_check.set({ ...viz_state.obs_store.deck_check.get(), image_layers: false, diff --git a/js/ui/ui_containers.js b/js/ui/ui_containers.js index 49e02b60..4edb2b9d 100644 --- a/js/ui/ui_containers.js +++ b/js/ui/ui_containers.js @@ -379,6 +379,73 @@ export const make_ist_ui_container = ( 'row' ); + const datasetDropdown = (() => { + const options = viz_state.dataset_options || []; + + if (!Array.isArray(options) || options.length === 0) { + return null; + } + + const wrapper = document.createElement('div'); + wrapper.style.display = 'flex'; + wrapper.style.alignItems = 'center'; + wrapper.style.gap = '4px'; + wrapper.style.marginLeft = '6px'; + wrapper.style.fontSize = '12px'; + + const label = document.createElement('span'); + label.textContent = ''; + label.style.color = '#47515b'; + + const select = document.createElement('select'); + select.style.fontSize = '12px'; + select.style.padding = '2px 4px'; + select.style.borderRadius = '6px'; + select.style.border = '1px solid #d3d3d3'; + select.style.background = '#fff'; + select.style.cursor = 'pointer'; + select.style.maxWidth = '120px'; + select.style.whiteSpace = 'nowrap'; + select.style.textOverflow = 'ellipsis'; + select.style.overflow = 'hidden'; + + options.forEach((option) => { + const opt = document.createElement('option'); + opt.value = option.base_url; + opt.textContent = option.name || option.base_url; + select.appendChild(opt); + }); + + const currentBaseUrl = viz_state.model?.get('base_url'); + if (currentBaseUrl) { + select.value = currentBaseUrl; + } + + select.addEventListener('change', () => { + const selectedOption = options.find( + (opt) => opt.base_url === select.value + ); + if (!selectedOption || !viz_state.model?.set) { + return; + } + + viz_state.model.set('base_url', selectedOption.base_url); + viz_state.model.set( + 'dataset_name', + selectedOption.name || selectedOption.base_url + ); + + if (viz_state.model.save_changes) { + viz_state.model.save_changes(); + } + }); + + wrapper.appendChild(label); + wrapper.appendChild(select); + + return wrapper; + })(); + if (isChromium) { make_button( spatial_toggle_container, @@ -442,6 +509,10 @@ export const make_ist_ui_container = ( ); } + if (datasetDropdown) { + spatial_toggle_container.appendChild(datasetDropdown); + } + viz_state.containers.image.appendChild(spatial_toggle_container); const get_slider_by_name = (img, name) => { @@ -513,7 +584,11 @@ export const make_ist_ui_container = ( toggle_slider(slider, viz_image_layers) ); - toggle_visibility_image_layers(layers_obj, viz_image_layers); + if (viz_image_layers && viz_state.img.enforce_visibility) { + viz_state.img.enforce_visibility(); + } else { + toggle_visibility_image_layers(layers_obj, viz_image_layers); + } refresh_layer(viz_state, layers_obj, 'image_layers'); diff --git a/js/viz/landscape_ist.js b/js/viz/landscape_ist.js index bbc39406..edf43a95 100644 --- a/js/viz/landscape_ist.js +++ b/js/viz/landscape_ist.js @@ -26,7 +26,10 @@ import { update_edit_visitility, update_edit_layer_mode, } from '../deck-gl/layers/edit_layer'; -import { make_image_layers } from '../deck-gl/layers/image_layers'; +import { + make_image_layers, + toggle_visibility_single_image_layer, +} from '../deck-gl/layers/image_layers'; import { ini_nbhd_layer, set_nbhd_layer_onclick, @@ -76,25 +79,25 @@ const PIXEL_SIZE_MICRONS = { MERSCOPE: 0.108, }; -const create_scale_bar = (micronsPerPixel, tech) => { - const techKey = tech || ''; - const blackLabelTechs = ['Visium-HD']; - const whiteLabelTechs = ['Xenium', 'MERSCOPE']; +const create_scale_bar = (microns_per_pixel, tech) => { + const tech_key = tech || ''; + const black_label_techs = ['Visium-HD']; + const white_label_techs = ['Xenium', 'MERSCOPE']; - const labelColor = blackLabelTechs.includes(techKey) + const label_color = black_label_techs.includes(tech_key) ? 'black' - : whiteLabelTechs.includes(techKey) + : white_label_techs.includes(tech_key) ? 'white' : 'white'; - const rev_labelColor = labelColor === 'white' ? 'black' : 'white'; + const reverse_label_color = label_color === 'white' ? 'black' : 'white'; const container = document.createElement('div'); container.style.position = 'absolute'; container.style.bottom = '10px'; container.style.left = '10px'; container.style.backgroundColor = 'transparent'; - container.style.color = labelColor; + container.style.color = label_color; container.style.padding = '6px 8px'; container.style.fontSize = '12px'; container.style.lineHeight = '1.2'; @@ -110,19 +113,19 @@ const create_scale_bar = (micronsPerPixel, tech) => { const bar = document.createElement('div'); bar.style.height = '2px'; - bar.style.backgroundColor = labelColor; - bar.style.outline = `1px solid ${rev_labelColor}`; + bar.style.backgroundColor = label_color; + bar.style.outline = `1px solid ${reverse_label_color}`; bar.style.marginTop = '4px'; bar.style.width = '80px'; - if (labelColor === 'white') { + if (label_color === 'white') { container.style.textShadow = '0 0 3px black'; } container.appendChild(label); container.appendChild(bar); - const formatLabel = (microns) => { + const format_label = (microns) => { if (microns >= 1000) { const millimeters = microns / 1000; if (millimeters >= 10) { @@ -142,37 +145,37 @@ const create_scale_bar = (micronsPerPixel, tech) => { return `${Number(microns.toPrecision(2))} µm`; }; - const setVisible = (visible) => { + const set_visible = (visible) => { container.style.display = visible ? 'flex' : 'none'; }; const update = ({ zoom }) => { - const zoomFactor = Math.pow(2, zoom || 0); - const micronsPerScreenPixel = micronsPerPixel / zoomFactor; - const targetPixelWidth = 100; - const rawMicrons = micronsPerScreenPixel * targetPixelWidth; - const cappedMicrons = Math.min(rawMicrons, 1000); + const zoom_factor = Math.pow(2, zoom || 0); + const microns_per_screen_pixel = microns_per_pixel / zoom_factor; + const target_pixel_width = 100; + const raw_microns = microns_per_screen_pixel * target_pixel_width; + const capped_microns = Math.min(raw_microns, 1000); - const magnitude = Math.pow(10, Math.floor(Math.log10(cappedMicrons))); - const normalized = cappedMicrons / magnitude; + const magnitude = Math.pow(10, Math.floor(Math.log10(capped_microns))); + const normalized = capped_microns / magnitude; - let niceNormalized = 1; + let normalized_target = 1; if (normalized > 5) { - niceNormalized = 10; + normalized_target = 10; } else if (normalized > 2) { - niceNormalized = 5; + normalized_target = 5; } else if (normalized > 1) { - niceNormalized = 2; + normalized_target = 2; } - const barMicrons = niceNormalized * magnitude; - const barPixelWidth = barMicrons / micronsPerScreenPixel; + const bar_microns = normalized_target * magnitude; + const bar_pixel_width = bar_microns / microns_per_screen_pixel; - label.textContent = formatLabel(barMicrons); - bar.style.width = `${barPixelWidth}px`; + label.textContent = format_label(bar_microns); + bar.style.width = `${bar_pixel_width}px`; }; - return { container, update, setVisible }; + return { container, update, set_visible }; }; export const landscape_ist = async ( @@ -267,6 +270,94 @@ export const landscape_ist = async ( viz_state.nbhd = {}; viz_state.nbhd.visible = false; viz_state.nbhd.edit = nbhd_edit; + viz_state.dataset_options = ini_model?.get('base_url_options') || []; + + const datasetLabel = dataset_name ? String(dataset_name) : ''; + const datasetPrefixSeparator = '_'; + const prefixAttr = ini_model?.get('cell_name_prefix_col'); + const baseIdAttr = '__cell_base_id__'; + + const filteredMeta = (() => { + if (!Array.isArray(meta_cell_attr)) { + return { + metaCell: meta_cell, + metaAttr: meta_cell_attr, + idMap: [], + }; + } + + if (!prefixAttr && datasetLabel) { + const metaCell = {}; + const idMap = []; + + Object.entries(meta_cell || {}).forEach(([key, values]) => { + const keyStr = String(key); + const sepIdx = keyStr.indexOf(datasetPrefixSeparator); + + if (sepIdx <= 0) return; + + const datasetValue = keyStr.slice(0, sepIdx); + if (datasetValue !== datasetLabel) return; + + const baseId = keyStr.slice(sepIdx + 1); + metaCell[baseId] = values; + idMap.push({ sourceId: key, baseId }); + }); + + if (idMap.length) { + return { metaCell, metaAttr: meta_cell_attr, idMap }; + } + } + + const prefixIdx = meta_cell_attr.indexOf(prefixAttr); + if (prefixIdx === -1) { + return { + metaCell: meta_cell, + metaAttr: meta_cell_attr, + idMap: [], + }; + } + + const baseIdx = meta_cell_attr.indexOf(baseIdAttr); + const metaAttr = meta_cell_attr.filter((attr) => attr !== baseIdAttr); + const metaCell = {}; + const idMap = []; + + Object.entries(meta_cell || {}).forEach(([key, values]) => { + const datasetValue = values?.[prefixIdx]; + if (datasetLabel && String(datasetValue) !== datasetLabel) { + return; + } + + const baseId = baseIdx >= 0 ? String(values[baseIdx]) : key; + const cleanedValues = + baseIdx >= 0 + ? values.filter((_, idx) => idx !== baseIdx) + : values.slice(); + + metaCell[baseId] = cleanedValues; + idMap.push({ sourceId: key, baseId }); + }); + + return { metaCell, metaAttr, idMap }; + })(); + + const filteredUmap = (() => { + if (!filteredMeta.idMap.length) { + return prefixAttr ? {} : umap; + } + + const mapped = {}; + filteredMeta.idMap.forEach(({ sourceId, baseId }) => { + if (umap?.[sourceId]) { + mapped[baseId] = umap[sourceId]; + } else if (umap?.[baseId]) { + mapped[baseId] = umap[baseId]; + } + }); + + return mapped; + })(); viz_state.spatial = {}; @@ -377,17 +468,21 @@ export const landscape_ist = async ( viz_state.cats.cluster_counts = []; viz_state.cats.polygon_cell_names = []; - if (Object.keys(meta_cell).length === 0) { + if (Object.keys(filteredMeta.metaCell).length === 0) { viz_state.cats.has_meta_cell = false; } else { viz_state.cats.has_meta_cell = true; } - viz_state.cats.meta_cell = meta_cell; - viz_state.cats.meta_cell_attr = meta_cell_attr; + viz_state.cats.meta_cell = filteredMeta.metaCell; + viz_state.cats.meta_cell_attr = filteredMeta.metaAttr; viz_state.cats.meta_cell_id_set = new Set( - Object.keys(meta_cell || {}).map((cell_id) => String(cell_id)) + Object.keys(filteredMeta.metaCell || {}).map((cell_id) => String(cell_id)) ); - viz_state.cats.inst_cell_attr = meta_cell_attr[0] || 'N.A.'; + viz_state.cats.inst_cell_attr = filteredMeta.metaAttr?.[0] || 'N.A.'; + + if (viz_state.cats.selected_cats.length > 0) { + viz_state.obs_store.selected_cats.set(viz_state.cats.selected_cats); + } if (Object.keys(meta_cluster).length === 0) { viz_state.cats.has_meta_cluster = false; @@ -399,12 +494,12 @@ export const landscape_ist = async ( viz_state.cats.inst_cluster_attr = meta_cluster_attr[0] || 'N.A.'; viz_state.umap = {}; - if (Object.keys(umap).length === 0) { + if (Object.keys(filteredUmap).length === 0) { viz_state.umap.has_umap = false; } else { viz_state.umap.has_umap = true; } - viz_state.umap.umap = umap; + viz_state.umap.umap = filteredUmap; const isUmapInit = landscape_state === 'umap'; viz_state.obs_store.umap_state.set(isUmapInit); @@ -423,6 +518,10 @@ export const landscape_ist = async ( viz_state.genes.trx_slider = document.createElement('input'); viz_state.genes.gene_search = document.createElement('div'); + if (viz_state.genes.selected_genes.length > 0) { + viz_state.obs_store.selected_genes.set(viz_state.genes.selected_genes); + } + viz_state.cats.cell_exp_array = []; viz_state.cats.cell_names_array = []; viz_state.cats.cell_name_to_index_map = new Map(); @@ -459,24 +558,30 @@ export const landscape_ist = async ( viz_state.img.image_info ); + const all_image_layer_names = viz_state.img.image_info.map( + (info) => info.button_name + ); + + viz_state.img.visible_layers = new Set(all_image_layer_names); + // Create and append the visualization. const root = document.createElement('div'); root.style.position = 'relative'; root.style.height = `${height}px`; root.style.border = '1px solid #d3d3d3'; - const userMicronsPerPixel = + const user_microns_per_pixel = typeof scale_bar_microns_per_pixel === 'number' && !Number.isNaN(scale_bar_microns_per_pixel) && scale_bar_microns_per_pixel > 0 ? scale_bar_microns_per_pixel : null; - const defaultMicronsPerPixel = PIXEL_SIZE_MICRONS[tech]; - const micronsPerPixel = defaultMicronsPerPixel ?? userMicronsPerPixel; + const default_microns_per_pixel = PIXEL_SIZE_MICRONS[tech]; + const microns_per_pixel = default_microns_per_pixel ?? user_microns_per_pixel; - if (micronsPerPixel) { - viz_state.scale_bar = create_scale_bar(micronsPerPixel, tech); + if (microns_per_pixel) { + viz_state.scale_bar = create_scale_bar(microns_per_pixel, tech); root.appendChild(viz_state.scale_bar.container); } @@ -497,13 +602,13 @@ export const landscape_ist = async ( await set_dimensions(viz_state, base_url, image_name_for_dim); } - const centerX = viz_state.dimensions?.width + const center_x = viz_state.dimensions?.width ? viz_state.dimensions.width / 2 : 0; - const centerY = viz_state.dimensions?.height + const center_y = viz_state.dimensions?.height ? viz_state.dimensions.height / 2 : 0; - viz_state.rotation = build_rotation_state(rotate, [centerX, centerY]); + viz_state.rotation = build_rotation_state(rotate, [center_x, center_y]); await set_meta_gene( viz_state.genes, @@ -576,6 +681,26 @@ export const landscape_ist = async ( edit_layer, }; + const enforce_image_layer_visibility = () => { + viz_state.img.image_info.forEach(({ button_name }) => { + const should_show = viz_state.img.visible_layers.has(button_name); + toggle_visibility_single_image_layer(layers_obj, button_name, should_show); + + const slider = viz_state.img.image_layer_sliders.find( + (instSlider) => instSlider.name === button_name + ); + + toggle_slider( + slider, + should_show && viz_state.obs_store.viz_image_layers.get() + ); + }); + }; + + viz_state.img.enforce_visibility = enforce_image_layer_visibility; + + enforce_image_layer_visibility(); + const refresh_cell_layer = () => { const selected_cats_name = viz_state.cats.selected_cats.join('-'); @@ -774,6 +899,9 @@ export const landscape_ist = async ( set_deck_on_view_state_change(deck_ist, layers_obj, viz_state); + const updateTriggerHandler = null; + const cellClusterHandler = null; + if (Object.keys(viz_state.model).length > 0) { viz_state.model.on('change:update_trigger', () => update_ist_landscape_from_cgm(deck_ist, layers_obj, viz_state) @@ -798,23 +926,23 @@ export const landscape_ist = async ( el.appendChild(ui_container); el.appendChild(root); - const isChromium = ['Chromium', 'point-cloud'].includes( + const is_chromium = ['Chromium', 'point-cloud'].includes( viz_state.img.landscape_parameters.technology ); viz_state.obs_store.landscape_view.subscribe( (view) => { - const isUmap = view === 'umap'; - viz_state.obs_store.umap_state.set(isUmap); + const is_umap = view === 'umap'; + viz_state.obs_store.umap_state.set(is_umap); if (viz_state.scale_bar) { - viz_state.scale_bar.setVisible(!isUmap); + viz_state.scale_bar.set_visible(!is_umap); } toggle_spatial_umap(deck_ist, layers_obj, viz_state); - if (isUmap) { + if (is_umap) { viz_state.buttons.buttons.umap.style('color', 'blue'); - if (!isChromium) { + if (!is_chromium) { viz_state.buttons.buttons.spatial.style('color', 'gray'); viz_state.buttons.buttons.img.style('color', 'gray'); } @@ -873,6 +1001,18 @@ export const landscape_ist = async ( { immediate: false } ); + const get_state_snapshot = () => { + return { + selected_cats: [...(viz_state.obs_store.selected_cats.get() || [])], + selected_genes: [...(viz_state.obs_store.selected_genes.get() || [])], + landscape_view: viz_state.obs_store.landscape_view.get(), + viz_image_layers: viz_state.obs_store.viz_image_layers.get(), + visible_images: viz_state.img?.visible_layers + ? Array.from(viz_state.img.visible_layers) + : [], + }; + }; + const landscape = { update_matrix_gene: async (inst_gene) => { const reset_gene = inst_gene === viz_state.cats.cat; @@ -958,7 +1098,16 @@ export const landscape_ist = async ( viz_state.layers_obj = layers_obj; }, update_layers: () => {}, + get_state: get_state_snapshot, finalize: () => { + if (updateTriggerHandler) { + viz_state.model.off('change:update_trigger', updateTriggerHandler); + } + + if (cellClusterHandler) { + viz_state.model.off('change:cell_clusters', cellClusterHandler); + } + deck_ist.finalize(); }, }; diff --git a/js/widget.js b/js/widget.js index 790cf1fa..c521f8b6 100644 --- a/js/widget.js +++ b/js/widget.js @@ -14,82 +14,135 @@ import { render_enrich } from './widgets/enrich_widget'; // Remove export keywords from render functions const render_landscape_ist = async ({ model, el }) => { - const token = model.get('token'); - const creds = model.get('creds'); - const ini_x = model.get('ini_x'); - const ini_y = model.get('ini_y'); - const ini_z = model.get('ini_z'); - const ini_zoom = model.get('ini_zoom'); - const base_url = model.get('base_url'); - const dataset_name = model.get('dataset_name'); - const width = model.get('width'); - const height = model.get('height'); - const rotation_orbit = model.get('rotation_orbit') ?? 0; - const rotation_x = model.get('rotation_x') ?? 0; - const rotate = model.get('rotate') ?? 0; - const nbhd = model.get('nbhd_geojson'); - const max_tiles_to_view = model.get('max_tiles_to_view'); - const nbhd_edit = model.get('nbhd_edit'); - const scale_bar_microns_per_pixel = model.get('scale_bar_microns_per_pixel'); - - let meta_cell_data = { result: {}, attr: [] }; - let meta_cluster_data = { result: {}, attr: [] }; - let umap_data = {}; - - const metaCellBytes = model.get('meta_cell_parquet'); - if (metaCellBytes && metaCellBytes.byteLength > 0) { - meta_cell_data = await objects_from_parquet(metaCellBytes, 'cell_id'); - } + let cleanup = null; + let build_chain = Promise.resolve(); - const metaClusterBytes = model.get('meta_cluster_parquet'); - if (metaClusterBytes && metaClusterBytes.byteLength > 0) { - meta_cluster_data = await objects_from_parquet(metaClusterBytes, 'leiden'); - } + const build_landscape = () => { + build_chain = build_chain.then(async () => { + if (cleanup?.finalize) { + cleanup.finalize(); + } - const umapBytes = model.get('umap_parquet'); - if (umapBytes && umapBytes.byteLength > 0) { - umap_data = (await objects_from_parquet(umapBytes, 'cell_id')).result; - } + el.innerHTML = ''; - const technology = model.get('technology'); - let landscape_state = model.get('landscape_state'); - if (technology === 'Chromium') { - landscape_state = 'umap'; - } else if (technology === 'point-cloud') { - landscape_state = 'spatial'; - } - const segmentation = model.get('segmentation'); + const loading = document.createElement('div'); + loading.textContent = 'Loading dataset…'; + loading.style.padding = '12px'; + loading.style.color = '#47515b'; + loading.style.fontSize = '13px'; + loading.style.fontWeight = '600'; + loading.style.fontFamily = 'sans-serif'; + el.appendChild(loading); - return landscape_ist( - el, - model, - token, - ini_x, - ini_y, - ini_z, - ini_zoom, - base_url, - dataset_name, - 0.25, - width, - height, - meta_cell_data.result, - meta_cell_data.attr, - meta_cluster_data.result, - meta_cluster_data.attr, - umap_data, - nbhd, - nbhd_edit, - landscape_state, - segmentation, - creds, - null, - rotation_orbit, - rotation_x, - rotate, - max_tiles_to_view, - scale_bar_microns_per_pixel - ); + const token = model.get('token'); + const creds = model.get('creds'); + const ini_x = model.get('ini_x'); + const ini_y = model.get('ini_y'); + const ini_z = model.get('ini_z'); + const ini_zoom = model.get('ini_zoom'); + const base_url = model.get('base_url'); + const dataset_name = model.get('dataset_name'); + const width = model.get('width'); + const height = model.get('height'); + const rotation_orbit = model.get('rotation_orbit') ?? 0; + const rotation_x = model.get('rotation_x') ?? 0; + const rotate = model.get('rotate') ?? 0; + const nbhd = model.get('nbhd_geojson'); + const max_tiles_to_view = model.get('max_tiles_to_view'); + const nbhd_edit = model.get('nbhd_edit'); + const scale_bar_microns_per_pixel = model.get( + 'scale_bar_microns_per_pixel' + ); + + let meta_cell_data = { result: {}, attr: [] }; + let meta_cluster_data = { result: {}, attr: [] }; + let umap_data = {}; + + const metaCellBytes = model.get('meta_cell_parquet'); + if (metaCellBytes && metaCellBytes.byteLength > 0) { + meta_cell_data = await objects_from_parquet(metaCellBytes, 'cell_id'); + } + + const metaClusterBytes = model.get('meta_cluster_parquet'); + if (metaClusterBytes && metaClusterBytes.byteLength > 0) { + meta_cluster_data = await objects_from_parquet( + metaClusterBytes, + 'leiden' + ); + } + + const umapBytes = model.get('umap_parquet'); + if (umapBytes && umapBytes.byteLength > 0) { + umap_data = (await objects_from_parquet(umapBytes, 'cell_id')).result; + } + + const technology = model.get('technology'); + let landscape_state = model.get('landscape_state'); + if (technology === 'Chromium') { + landscape_state = 'umap'; + } else if (technology === 'point-cloud') { + landscape_state = 'spatial'; + } + const segmentation = model.get('segmentation'); + + try { + cleanup = await landscape_ist( + el, + model, + token, + ini_x, + ini_y, + ini_z, + ini_zoom, + base_url, + dataset_name, + 0.25, + width, + height, + meta_cell_data.result, + meta_cell_data.attr, + meta_cluster_data.result, + meta_cluster_data.attr, + umap_data, + nbhd, + nbhd_edit, + landscape_state, + segmentation, + creds, + null, + rotation_orbit, + rotation_x, + rotate, + max_tiles_to_view, + scale_bar_microns_per_pixel + ); + } finally { + loading.remove(); + } + }); + + return build_chain; + }; + + await build_landscape(); + + const handleDatasetChange = () => { + void build_landscape(); + }; + + model.on('change:base_url', handleDatasetChange); + model.on('change:dataset_name', handleDatasetChange); + + return () => { + build_chain.finally(() => { + if (cleanup?.finalize) { + cleanup.finalize(); + } + + model.off('change:base_url', handleDatasetChange); + model.off('change:dataset_name', handleDatasetChange); + }); + }; }; const render_landscape_sst = async ({ model, el }) => { diff --git a/src/celldega/viz/widget.py b/src/celldega/viz/widget.py index cd35b46b..ebcb0401 100644 --- a/src/celldega/viz/widget.py +++ b/src/celldega/viz/widget.py @@ -3,6 +3,7 @@ import colorsys from contextlib import suppress from copy import deepcopy +import io import json from pathlib import Path import urllib.error @@ -27,6 +28,8 @@ } _MANUAL_FILL_VALUE = "N.A." +_CELL_BASE_ID_COLUMN = "__cell_base_id__" + def _hsv_to_hex(h: float) -> str: """Convert HSV color to hex string.""" @@ -34,6 +37,17 @@ def _hsv_to_hex(h: float) -> str: return f"#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}" +def _df_to_bytes(df: pd.DataFrame) -> bytes: + """Convert a DataFrame to zstd-compressed parquet bytes.""" + import pyarrow as pa + import pyarrow.parquet as pq + + df.columns = df.columns.map(str) + buf = io.BytesIO() + pq.write_table(pa.Table.from_pandas(df), buf, compression="zstd") + return buf.getvalue() + + class Landscape(anywidget.AnyWidget): """ A widget for interactive visualization of spatial omics data. This widget @@ -50,6 +64,15 @@ class Landscape(anywidget.AnyWidget): point-cloud views. token (str): The token traitlet. base_url (str): The base URL for the widget. + base_urls (list[str | dict], optional): Multiple base URLs to make + available through a dataset selector. If dictionaries are + provided, they can include ``name``/``label`` and ``base_url``/``url`` + keys to customize the dropdown label; otherwise, names are + inferred from the URL. + cell_name_prefix_col (str, optional): Column in ``adata.obs`` that + contains a dataset-specific prefix used to make cell IDs unique + across datasets. The prefix is preserved for disambiguation but the + original cell IDs remain available for Landscape file lookups. rotate (float, optional): Degrees to rotate the 2D landscape visualization. AnnData (AnnData, optional): AnnData object to derive metadata from. dataset_name (str, optional): The name of the dataset to visualize. This @@ -67,6 +90,7 @@ class Landscape(anywidget.AnyWidget): technology = traitlets.Unicode("Xenium").tag(sync=True) base_url = traitlets.Unicode("").tag(sync=True) token = traitlets.Unicode("").tag(sync=True) + base_url_options = traitlets.List(trait=traitlets.Dict(), default_value=[]).tag(sync=True) creds = traitlets.Dict({}).tag(sync=True) max_tiles_to_view = traitlets.Int(50).tag(sync=True) ini_x = traitlets.Float().tag(sync=True) @@ -103,6 +127,7 @@ class Landscape(anywidget.AnyWidget): trait=traitlets.Unicode(), default_value=["leiden"], ).tag(sync=True) + cell_name_prefix_col = traitlets.Unicode("").tag(sync=True) segmentation = traitlets.Unicode("default").tag(sync=True) @@ -115,6 +140,10 @@ def __init__(self, **kwargs): pq_meta_cluster = kwargs.pop("meta_cluster_parquet", None) pq_umap = kwargs.pop("umap_parquet", None) pq_meta_nbhd = kwargs.pop("meta_nbhd_parquet", None) + base_urls = kwargs.pop("base_urls", None) + cell_name_prefix_col = kwargs.pop("cell_name_prefix_col", None) or kwargs.pop( + "cell_name_prefix", None + ) meta_cell_df = kwargs.pop("meta_cell", None) meta_cluster = kwargs.pop("meta_cluster", None) @@ -125,9 +154,39 @@ def __init__(self, **kwargs): meta_cluster_df = None cell_attr = kwargs.pop("cell_attr", ["leiden"]) + if cell_name_prefix_col: + kwargs.setdefault("cell_name_prefix_col", cell_name_prefix_col) + kwargs.setdefault("cell_attr", cell_attr) + if nbhd_gdf is not None and nbhd_edit: raise ValueError("nbhd_edit cannot be True when nbhd data is provided") + dataset_options = [] + if base_urls: + for item in base_urls: + name = None + url = None + + if isinstance(item, dict): + url = item.get("base_url") or item.get("url") or item.get("value") + name = item.get("name") or item.get("label") or item.get("dataset_name") + else: + url = str(item) + + if not url: + continue + + url = url.rstrip("/") + if not name: + name = Path(url).name or url + + dataset_options.append({"name": name, "base_url": url}) + + if dataset_options: + kwargs.setdefault("base_url", dataset_options[0]["base_url"]) + kwargs.setdefault("dataset_name", dataset_options[0]["name"]) + kwargs.setdefault("base_url_options", dataset_options) + base_path = (kwargs.get("base_url") or "") + "/" path_transformation_matrix = base_path + "micron_to_image_transform.csv" @@ -153,24 +212,35 @@ def __init__(self, **kwargs): stacklevel=2, ) - def _df_to_bytes(df): - import io - - import pyarrow as pa - import pyarrow.parquet as pq - - df.columns = df.columns.map(str) - buf = io.BytesIO() - pq.write_table(pa.Table.from_pandas(df), buf, compression="zstd") - return buf.getvalue() - if adata is not None: # if cell_id is in the adata.obs, use it as index if "cell_id" in adata.obs.columns: adata.obs.set_index("cell_id", inplace=True) + if cell_name_prefix_col and cell_name_prefix_col not in adata.obs.columns: + warnings.warn( + f"cell_name_prefix_col='{cell_name_prefix_col}' not found in adata.obs. " + "Ignoring prefix handling.", + stacklevel=2, + ) + cell_name_prefix_col = None + + if cell_name_prefix_col and cell_name_prefix_col not in cell_attr: + cell_attr = [*cell_attr, cell_name_prefix_col] + + base_cell_ids = adata.obs.index.to_series().astype(str) + prefix_series = ( + adata.obs[cell_name_prefix_col].astype(str) if cell_name_prefix_col else None + ) + meta_cell_df = adata.obs[cell_attr].copy() + if prefix_series is not None: + meta_cell_df[_CELL_BASE_ID_COLUMN] = base_cell_ids.values + meta_cell_df.index = prefix_series.str.cat(base_cell_ids, sep="_") + else: + meta_cell_df.index = base_cell_ids + if meta_cell_df.index.name is None: meta_cell_df.index.name = "cell_id" @@ -202,8 +272,9 @@ def _df_to_bytes(df): pq_meta_cluster = _df_to_bytes(meta_cluster_df) if "X_umap" in adata.obsm: + umap_index = meta_cell_df.index if prefix_series is not None else adata.obs.index umap_df = ( - pd.DataFrame(adata.obsm["X_umap"], index=adata.obs.index) + pd.DataFrame(adata.obsm["X_umap"], index=umap_index) .reset_index() .rename(columns={"index": "cell_id", 0: "umap_0", 1: "umap_1"}) ) @@ -308,7 +379,6 @@ def close(self): # pragma: no cover - cleanup depends on JS self.send({"event": "finalize"}) super().close() - class ManualAttributeTrait(traitlets.Unicode): """Traitlet for configuring manual attribute names via bools or strings."""