diff --git a/js/deck-gl/core/deck_ist.js b/js/deck-gl/core/deck_ist.js index ad30f31e..f5674c91 100644 --- a/js/deck-gl/core/deck_ist.js +++ b/js/deck-gl/core/deck_ist.js @@ -11,19 +11,58 @@ const getCursor = ({ isDragging }) => { return 'pointer'; }; -export const ini_deck = (root, width, height, technology = '') => { +export const ini_deck = ( + root, + width, + height, + technology = '', + reuseContext = null, + reuseDeck = null +) => { const controller = { doubleClickZoom: false }; if (technology === 'point-cloud') { controller.type = OrbitController; } - const deck_ist = new Deck({ + const deckProps = { parent: root, controller, getCursor, width, height, - }); + }; + + const canvas = reuseContext?.canvas; + if (canvas) { + if (canvas.parentNode !== root) { + root.appendChild(canvas); + } + deckProps.canvas = canvas; + } + + if (reuseContext?.gl) { + deckProps.gl = reuseContext.gl; + } + + if (reuseDeck) { + const canvas = reuseDeck.canvas; + + if (canvas && canvas.parentNode !== root) { + root.appendChild(canvas); + } + + reuseDeck.setProps({ + parent: root, + controller, + getCursor, + width, + height, + }); + + return reuseDeck; + } + + const deck_ist = new Deck(deckProps); return deck_ist; }; diff --git a/js/deck-gl/layers/cell_layer.js b/js/deck-gl/layers/cell_layer.js index 55ef066d..2ce173ef 100644 --- a/js/deck-gl/layers/cell_layer.js +++ b/js/deck-gl/layers/cell_layer.js @@ -57,13 +57,20 @@ export const get_cell_color = (cats, highlighted_cells, i, d) => { if (cats.cat === 'cluster') { try { const inst_cat = cats.cell_cats[d.index]; + const normalizedInstCat = inst_cat == null ? inst_cat : String(inst_cat); + const normalizedSelectedCats = cats.selected_cats.map((cat) => + cat == null ? cat : String(cat) + ); + + const isSelected = + normalizedSelectedCats.length === 0 || + normalizedSelectedCats.some( + (cat) => cat === normalizedInstCat || (cat == null && inst_cat == null) + ); let inst_color = cats.color_dict_cluster[inst_cat]; - let inst_opacity = - cats.selected_cats.length === 0 || cats.selected_cats.includes(inst_cat) - ? 255 - : 10; + let inst_opacity = isSelected ? 255 : 10; // Check if inst_color is an array and log an error if it's not if (!Array.isArray(inst_color)) { @@ -113,6 +120,11 @@ export const ini_cell_layer = async (base_url, viz_state) => { set_cell_names_array(viz_state.cats, cell_arrow_table); + // Ensure gene expression array starts with valid opacity values + viz_state.cats.cell_exp_array = new Array( + viz_state.cats.cell_names_array.length + ).fill(0); + viz_state.spatial.cell_scatter_data = get_scatter_data(cell_arrow_table); await set_color_dict_gene( diff --git a/js/viz/landscape_ist.js b/js/viz/landscape_ist.js index edf43a95..4539436d 100644 --- a/js/viz/landscape_ist.js +++ b/js/viz/landscape_ist.js @@ -206,7 +206,9 @@ export const landscape_ist = async ( rotation_x = 0, rotate = 0, max_tiles_to_view = 50, - scale_bar_microns_per_pixel = null + scale_bar_microns_per_pixel = null, + reuseContext = null, + reuseDeck = null ) => { if (width === 0) { width = '100%'; @@ -276,6 +278,8 @@ export const landscape_ist = async ( const datasetPrefixSeparator = '_'; const prefixAttr = ini_model?.get('cell_name_prefix_col'); const baseIdAttr = '__cell_base_id__'; + const autoStripPrefix = prefixAttr === true; + const hasPrefixColumn = typeof prefixAttr === 'string' && prefixAttr.length > 0; const filteredMeta = (() => { if (!Array.isArray(meta_cell_attr)) { @@ -286,7 +290,26 @@ export const landscape_ist = async ( }; } - if (!prefixAttr && datasetLabel) { + if (autoStripPrefix) { + const metaCell = {}; + const idMap = []; + + Object.entries(meta_cell || {}).forEach(([key, values]) => { + const sepIdx = String(key).indexOf(datasetPrefixSeparator); + if (sepIdx <= 0) { + metaCell[key] = values; + return; + } + + const baseId = String(key).slice(sepIdx + 1); + metaCell[baseId] = values; + idMap.push({ sourceId: key, baseId }); + }); + + return { metaCell, metaAttr: meta_cell_attr, idMap }; + } + + if (!hasPrefixColumn && datasetLabel) { const metaCell = {}; const idMap = []; @@ -309,6 +332,14 @@ export const landscape_ist = async ( } } + if (!hasPrefixColumn) { + return { + metaCell: meta_cell, + metaAttr: meta_cell_attr, + idMap: [], + }; + } + const prefixIdx = meta_cell_attr.indexOf(prefixAttr); if (prefixIdx === -1) { return { @@ -621,7 +652,19 @@ export const landscape_ist = async ( viz_state.views = set_views(tech); - const deck_ist = await ini_deck(root, width, height, tech); + const deck_ist = await ini_deck( + root, + width, + height, + tech, + reuseContext, + reuseDeck + ); + + viz_state.gl_context = { + canvas: reuseContext?.canvas || deck_ist?.canvas || null, + gl: reuseContext?.gl || deck_ist?.animationLoop?.gl || null, + }; // set_initial_view_state(deck_ist, ini_x, ini_y, ini_z, ini_zoom) set_views_prop(deck_ist, viz_state.views); @@ -899,20 +942,21 @@ export const landscape_ist = async ( set_deck_on_view_state_change(deck_ist, layers_obj, viz_state); - const updateTriggerHandler = null; - const cellClusterHandler = null; + const updateTriggerHandler = () => + update_ist_landscape_from_cgm(deck_ist, layers_obj, viz_state); + + const cellClusterHandler = () => + update_cell_clusters(deck_ist, layers_obj, viz_state); + + const selectedCellsHandler = () => { + const cells = viz_state.model.get('selected_cells') || []; + viz_state.obs_store.selected_cells.set(cells); + }; 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) - ); - viz_state.model.on('change:cell_clusters', () => - update_cell_clusters(deck_ist, layers_obj, viz_state) - ); - viz_state.model.on('change:selected_cells', () => { - const cells = viz_state.model.get('selected_cells') || []; - viz_state.obs_store.selected_cells.set(cells); - }); + viz_state.model.on('change:update_trigger', updateTriggerHandler); + viz_state.model.on('change:cell_clusters', cellClusterHandler); + viz_state.model.on('change:selected_cells', selectedCellsHandler); } const ui_container = make_ist_ui_container( @@ -1099,7 +1143,12 @@ export const landscape_ist = async ( }, update_layers: () => {}, get_state: get_state_snapshot, - finalize: () => { + getContext: () => viz_state.gl_context, + getDeck: () => deck_ist, + finalize: (options = {}) => { + const preserveContext = Boolean(options.preserveContext); + const keepDeck = Boolean(options.keepDeck); + if (updateTriggerHandler) { viz_state.model.off('change:update_trigger', updateTriggerHandler); } @@ -1108,7 +1157,23 @@ export const landscape_ist = async ( viz_state.model.off('change:cell_clusters', cellClusterHandler); } - deck_ist.finalize(); + if (selectedCellsHandler) { + viz_state.model.off('change:selected_cells', selectedCellsHandler); + } + + if (!preserveContext) { + const gl = deck_ist?.animationLoop?.gl; + const loseCtxExtension = gl?.getExtension('WEBGL_lose_context'); + if (loseCtxExtension) { + loseCtxExtension.loseContext(); + } + } + + if (keepDeck) { + deck_ist.setProps({ layers: [] }); + } else { + deck_ist.finalize(); + } }, }; diff --git a/js/widget.js b/js/widget.js index c521f8b6..f40e5b89 100644 --- a/js/widget.js +++ b/js/widget.js @@ -16,11 +16,22 @@ import { render_enrich } from './widgets/enrich_widget'; const render_landscape_ist = async ({ model, el }) => { let cleanup = null; let build_chain = Promise.resolve(); + let retainedContext = null; + let retainedDeck = null; const build_landscape = () => { build_chain = build_chain.then(async () => { if (cleanup?.finalize) { - cleanup.finalize(); + const nextContext = cleanup?.getContext ? cleanup.getContext() : null; + const nextDeck = cleanup?.getDeck ? cleanup.getDeck() : null; + + cleanup.finalize({ + preserveContext: true, + keepDeck: Boolean(nextDeck), + }); + + retainedContext = nextContext; + retainedDeck = nextDeck; } el.innerHTML = ''; @@ -114,8 +125,13 @@ const render_landscape_ist = async ({ model, el }) => { rotation_x, rotate, max_tiles_to_view, - scale_bar_microns_per_pixel + scale_bar_microns_per_pixel, + retainedContext, + retainedDeck ); + + retainedContext = cleanup?.getContext ? cleanup.getContext() : null; + retainedDeck = cleanup?.getDeck ? cleanup.getDeck() : null; } finally { loading.remove(); } @@ -138,6 +154,8 @@ const render_landscape_ist = async ({ model, el }) => { if (cleanup?.finalize) { cleanup.finalize(); } + retainedContext = null; + retainedDeck = null; model.off('change:base_url', handleDatasetChange); model.off('change:dataset_name', handleDatasetChange); diff --git a/src/celldega/viz/widget.py b/src/celldega/viz/widget.py index ebcb0401..a346af8f 100644 --- a/src/celldega/viz/widget.py +++ b/src/celldega/viz/widget.py @@ -69,10 +69,13 @@ class Landscape(anywidget.AnyWidget): 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. + cell_name_prefix_col (str | bool, optional): When a string, the column + in ``adata.obs`` that contains a dataset-specific prefix used to + make cell IDs unique across datasets. When ``True``, the widget will + strip the portion of each cell ID before the first underscore when + referencing Landscape files. 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 @@ -127,7 +130,9 @@ class Landscape(anywidget.AnyWidget): trait=traitlets.Unicode(), default_value=["leiden"], ).tag(sync=True) - cell_name_prefix_col = traitlets.Unicode("").tag(sync=True) + cell_name_prefix_col = traitlets.Union( + [traitlets.Unicode(), traitlets.Bool()], default_value=False + ).tag(sync=True) segmentation = traitlets.Unicode("default").tag(sync=True) @@ -141,8 +146,11 @@ def __init__(self, **kwargs): 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 + prefix = kwargs.pop("prefix", None) + cell_name_prefix_col = ( + kwargs.pop("cell_name_prefix_col", None) + or kwargs.pop("cell_name_prefix", None) + or prefix ) meta_cell_df = kwargs.pop("meta_cell", None) @@ -154,7 +162,7 @@ def __init__(self, **kwargs): meta_cluster_df = None cell_attr = kwargs.pop("cell_attr", ["leiden"]) - if cell_name_prefix_col: + if cell_name_prefix_col is not None: kwargs.setdefault("cell_name_prefix_col", cell_name_prefix_col) kwargs.setdefault("cell_attr", cell_attr) @@ -217,32 +225,40 @@ def __init__(self, **kwargs): 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: + has_prefix_column = isinstance(cell_name_prefix_col, str) and bool( + cell_name_prefix_col + ) + use_underscore_prefix = cell_name_prefix_col is True + + if has_prefix_column 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 + cell_name_prefix_col = False + + if cell_name_prefix_col is False: + has_prefix_column = False + use_underscore_prefix = False - if cell_name_prefix_col and cell_name_prefix_col not in cell_attr: + if has_prefix_column 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 use_underscore_prefix: + meta_cell_df[_CELL_BASE_ID_COLUMN] = base_cell_ids.str.split( + "_", n=1, expand=True + ).iloc[:, 1].fillna(base_cell_ids) + elif has_prefix_column: + meta_cell_df[_CELL_BASE_ID_COLUMN] = base_cell_ids.str.split( + "_", n=1, expand=True + ).iloc[:, 1].fillna(base_cell_ids) - if meta_cell_df.index.name is None: - meta_cell_df.index.name = "cell_id" + meta_cell_df.index = base_cell_ids + meta_cell_df.index.name = "cell_id" pq_meta_cell = _df_to_bytes(meta_cell_df) @@ -272,9 +288,8 @@ def __init__(self, **kwargs): 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=umap_index) + pd.DataFrame(adata.obsm["X_umap"], index=meta_cell_df.index) .reset_index() .rename(columns={"index": "cell_id", 0: "umap_0", 1: "umap_1"}) )