diff --git a/js/deck-gl/layers/nbhd_layer.js b/js/deck-gl/layers/nbhd_layer.js index f6e470338..2f0681c10 100644 --- a/js/deck-gl/layers/nbhd_layer.js +++ b/js/deck-gl/layers/nbhd_layer.js @@ -13,24 +13,69 @@ const selected_nbhds_include = (selected_nbhds, feature) => { return selected_nbhds.includes(name) || selected_nbhds.includes(cat); }; +/** + * Get color for a neighborhood feature based on current color mode + * + * Supports two color modes: + * - 'cluster' (default): Color by categorical attribute (cat/leiden) using the feature's color property + * - 'gene': Color by gene expression using red intensity (similar to cell gene coloring) + * + * @param {object} d - The GeoJSON feature + * @param {object} viz_state - The visualization state + * @returns {Array} RGBA color array + */ const get_nbhd_color = (d, viz_state) => { - const inst_color = hexToRgb(d.properties.color); + const colorMode = viz_state.nbhd.color_mode || 'cluster'; const selected_nbhds = viz_state.obs_store.selected_nbhds.get(); - let inst_opacity; - // if viz_state.obs_store.selected_nbhds is not empty // then check if the neighborhood identity is in the selected_nbhds - if (selected_nbhds.length > 0) { - inst_opacity = selected_nbhds_include(selected_nbhds, d) ? 255 : 0; + if (selected_nbhds.length > 0 && !selected_nbhds_include(selected_nbhds, d)) { + return [0, 0, 0, 0]; // Fully transparent for non-selected + } + + let inst_color; + let inst_opacity = 255; + + if (colorMode === 'gene' && viz_state.nbhd.gene_expression) { + // Gene/attribute expression mode: use red intensity like cell layer + // Try multiple keys for lookup: cat (primary), name (fallback) + const gene_expression = viz_state.nbhd.gene_expression; + const cat_key = d.properties.cat; + const name_key = d.properties.name; + + // Look up expression value - try cat first (matches bar graph), then name + let expression = 0; + if (cat_key !== undefined && gene_expression[cat_key] !== undefined) { + expression = gene_expression[cat_key]; + } else if (name_key !== undefined && gene_expression[name_key] !== undefined) { + expression = gene_expression[name_key]; + } else if (cat_key !== undefined && gene_expression[String(cat_key)] !== undefined) { + expression = gene_expression[String(cat_key)]; + } + + const max_exp = viz_state.nbhd.gene_max_exp || 1; + + // Normalize expression to 0-255 range using log scale (similar to cell layer) + let normalized_exp; + if (expression > 0 && max_exp > 0) { + const log_exp = Math.log1p(expression); + const log_max = Math.log1p(max_exp); + normalized_exp = Math.round((log_exp / log_max) * 255); + } else { + normalized_exp = 0; + } + + // Red color with expression-based intensity + // Zero expression = transparent (no minimum opacity) + inst_color = [255, 0, 0]; + inst_opacity = normalized_exp; } else { - // if selected_nbhds is empty, set the opacity to 255 - inst_opacity = 255; + // Default cluster/categorical mode + inst_color = hexToRgb(d.properties.color); } - // add the opacity to the color inst_color.push(inst_opacity); - return inst_color; }; diff --git a/js/deck-gl/matrix/dendro_layers.js b/js/deck-gl/matrix/dendro_layers.js index fd4d68adf..2220a45ad 100644 --- a/js/deck-gl/matrix/dendro_layers.js +++ b/js/deck-gl/matrix/dendro_layers.js @@ -277,7 +277,7 @@ const dendro_layer_onclick = (event, deck_mat, layers_mat, viz_state, axis) => { } } - if (Object.keys(viz_state.model).length > 0) { + if (viz_state.model && typeof viz_state.model.set === 'function') { viz_state.model.set('click_info', null); viz_state.model.set('click_info', viz_state.click); viz_state.model.save_changes(); @@ -286,6 +286,7 @@ const dendro_layer_onclick = (event, deck_mat, layers_mat, viz_state, axis) => { // Sync selected rows/cols to Python model // If unselecting, clear the selections const names_to_sync = is_unselecting ? [] : selected_names; + if (axis === 'row') { sync_selected_rows(viz_state, names_to_sync); // Also sync to selected_genes for backwards compatibility diff --git a/js/global_variables/selected_genes.js b/js/global_variables/selected_genes.js index 85bc8bf8e..6b021386a 100644 --- a/js/global_variables/selected_genes.js +++ b/js/global_variables/selected_genes.js @@ -33,19 +33,38 @@ export const update_selected_genes = (genes, new_selected_genes, obs_store) => { } }; +/** + * Check if a row entity represents gene data (for enrichment purposes). + * This checks both entity === 'gene' and data_type === 'gene'. + * The data_type field allows entities like 'nbhd_gene' to enable enrichment + * even though their spatial context is neighborhoods. + */ +const isGeneEntity = (row_entity) => { + if (!row_entity) return false; + // Check entity name directly + if (row_entity.entity === 'gene') return true; + // Check entity names that imply gene data + if (row_entity.entity === 'nbhd_gene') return true; + // Check explicit data_type field (most flexible) + if (row_entity.data_type === 'gene') return true; + return false; +}; + export const sync_selected_genes = (viz_state, genes) => { const selectedGenes = Array.isArray(genes) ? genes : []; if (viz_state.model && typeof viz_state.model.set === 'function') { viz_state.model.set('selected_genes', selectedGenes); - // Also sync to selected_rows if row entity is 'gene' + // Also sync to selected_rows if row entity represents genes const { row_entity } = viz_state; - if (row_entity?.entity === 'gene') { + if (isGeneEntity(row_entity)) { viz_state.model.set('selected_rows', selectedGenes); } - viz_state.model.save_changes(); + if (typeof viz_state.model.save_changes === 'function') { + viz_state.model.save_changes(); + } } updateSelectedGeneState(viz_state.genes, selectedGenes); @@ -57,15 +76,16 @@ export const sync_selected_genes = (viz_state, genes) => { /** * Sync selected rows to the Python model. - * Also syncs to selected_genes if row entity is 'gene'. + * Also syncs to selected_genes if row entity represents genes + * (entity === 'gene' OR data_type === 'gene'). */ export const sync_selected_rows = (viz_state, rows) => { if (viz_state.model && typeof viz_state.model.set === 'function') { viz_state.model.set('selected_rows', rows); - // Also sync to selected_genes if row entity is 'gene' + // Also sync to selected_genes if row entity represents genes const { row_entity } = viz_state; - if (row_entity?.entity === 'gene') { + if (isGeneEntity(row_entity)) { viz_state.model.set('selected_genes', rows); } diff --git a/js/matrix/set_constants.js b/js/matrix/set_constants.js index 6b2d8ae35..3cfb83f98 100644 --- a/js/matrix/set_constants.js +++ b/js/matrix/set_constants.js @@ -5,14 +5,14 @@ import { initialize_attr_state } from './attr_state'; /** * Parse entity specification from string or object. - * Handles both legacy string format and new {entity, attr} format. + * Handles both legacy string format and new {entity, attr, data_type} format. * * @param {string|object} value - Entity specification - * @returns {{entity: string, attr: string}} Normalized entity object + * @returns {{entity: string, attr: string, data_type?: string}} Normalized entity object */ const parseEntitySpec = (value) => { if (!value) { - return { entity: 'gene', attr: 'name' }; + return { entity: 'gene', attr: 'name', data_type: 'gene' }; } // If it's a string, try to parse as JSON first @@ -20,20 +20,30 @@ const parseEntitySpec = (value) => { try { const parsed = JSON.parse(value); if (parsed && typeof parsed === 'object') { - return { + const result = { entity: parsed.entity || 'custom', attr: parsed.attr || 'name', }; + // Preserve data_type if provided + if (parsed.data_type) { + result.data_type = parsed.data_type; + } + return result; } } catch { // Not JSON, handle as legacy string const legacyMapping = { - gene: { entity: 'gene', attr: 'name' }, + gene: { entity: 'gene', attr: 'name', data_type: 'gene' }, cell_cluster: { entity: 'cell', attr: 'leiden' }, cluster: { entity: 'cell', attr: 'leiden' }, nbhd: { entity: 'nbhd', attr: 'name' }, cell: { entity: 'cell', attr: 'name' }, hextile: { entity: 'hextile', attr: 'name' }, + // nbhd_gene: gene data at neighborhood level (enables enrichment) + nbhd_gene: { entity: 'nbhd_gene', attr: 'name', data_type: 'gene' }, + // nbhd_var: generic neighborhood variables (no enrichment) + nbhd_var: { entity: 'nbhd_var', attr: 'name' }, + nbhd_attr: { entity: 'nbhd_var', attr: 'name' }, }; return legacyMapping[value] || { entity: value, attr: 'name' }; } @@ -41,10 +51,15 @@ const parseEntitySpec = (value) => { // Already an object if (typeof value === 'object') { - return { + const result = { entity: value.entity || 'custom', attr: value.attr || 'name', }; + // Preserve data_type if provided + if (value.data_type) { + result.data_type = value.data_type; + } + return result; } return { entity: 'custom', attr: 'name' }; diff --git a/js/ui/bar_plot.js b/js/ui/bar_plot.js index 5d48b0b00..76825c0df 100644 --- a/js/ui/bar_plot.js +++ b/js/ui/bar_plot.js @@ -65,18 +65,66 @@ export const bar_callback_gene = async ( _layers_obj, _viz_state ) => { - // ensure that trx button, slider, and bars are active - _viz_state.buttons?.buttons?.trx?.style?.('color', 'blue'); + const inst_gene = d.name; + const reset_gene = inst_gene === _viz_state.cats.cat; - toggle_slider(_viz_state.sliders.trx, true); + // Check if NBHD layer is active - mutually exclusive behavior + const nbhd_is_active = _viz_state.obs_store.viz_nbhd_layer.get(); + const nbhd_has_adata = _viz_state.nbhd?.has_nbhd_adata; + + // Update gene selection UI _viz_state.genes.svg_bar_gene.selectAll('rect').style('opacity', 1.0); + update_selected_genes(_viz_state.genes, [inst_gene], _viz_state.obs_store); + + if (reset_gene) { + // Reset to cluster mode + _viz_state.cats.svg_bar_cluster.selectAll('rect').style('opacity', 1.0); + + if (_viz_state.nbhd?.is_nbhd) { + _viz_state.nbhd.color_mode = 'cluster'; + _viz_state.nbhd.gene_expression = null; + _viz_state.nbhd.current_gene = null; + } + + update_cat(_viz_state.cats, 'cluster'); + update_selected_cats(_viz_state.cats, [], _viz_state.obs_store); + + // Show both layers in cluster mode + _viz_state.buttons?.buttons?.trx?.style?.('color', 'blue'); + toggle_slider(_viz_state.sliders.trx, true); + toggle_trx_layer_visibility(_layers_obj, true); + + return; + } + + _viz_state.cats.svg_bar_cluster.selectAll('rect').style('opacity', 0.2); + + if (nbhd_is_active && nbhd_has_adata) { + // MUTUALLY EXCLUSIVE: Color neighborhoods by gene + _viz_state.nbhd.color_mode = 'gene'; + + // Request neighborhood attribute data from Python + if (_viz_state.model && typeof _viz_state.model.set === 'function') { + _viz_state.model.set('nbhd_attr_request', inst_gene); + _viz_state.model.save_changes(); + } + + // Keep cells in cluster mode + update_cat(_viz_state.cats, 'cluster'); + update_selected_cats(_viz_state.cats, [], _viz_state.obs_store); + // Keep nbhd layer visible, hide cell layer gene coloring + toggle_slider(_viz_state.sliders.nbhd, true); + } else { + // MUTUALLY EXCLUSIVE: Color cells by gene expression + _viz_state.buttons?.buttons?.trx?.style?.('color', 'blue'); + toggle_slider(_viz_state.sliders.trx, true); toggle_trx_layer_visibility(_layers_obj, true); - if (_viz_state.nbhd.is_nbhd) { + // Hide neighborhood layer + if (_viz_state.nbhd?.is_nbhd) { _viz_state.obs_store.viz_nbhd_layer.set(false); _viz_state.obs_store.viz_edit_layer.set(false); - // wrap in try try { _viz_state.buttons?.buttons?.nbhd?.style?.('color', 'gray'); toggle_slider(_viz_state.sliders.nbhd, false); @@ -85,16 +133,7 @@ export const bar_callback_gene = async ( } } - const inst_gene = d.name; - const reset_gene = inst_gene === _viz_state.cats.cat; - const new_cat = reset_gene ? 'cluster' : inst_gene; - - if (reset_gene) { - _viz_state.cats.svg_bar_cluster.selectAll('rect').style('opacity', 1.0); - } else { - _viz_state.cats.svg_bar_cluster.selectAll('rect').style('opacity', 0.2); - } - + const new_cat = inst_gene; update_cat(_viz_state.cats, new_cat); _viz_state.obs_store.deck_check.set({ @@ -103,7 +142,6 @@ export const bar_callback_gene = async ( trx_layer: false, }); - update_selected_genes(_viz_state.genes, [inst_gene], _viz_state.obs_store); await update_cell_exp_array( _viz_state.cats, _viz_state.genes, @@ -115,9 +153,8 @@ export const bar_callback_gene = async ( _viz_state.row_group_readers?.cbg ); - // update selected_cats after update_cell_exp_array has been run - // can clean up and move more logic to observability update_selected_cats(_viz_state.cats, [inst_gene], _viz_state.obs_store); + } }; export const bar_callback_nbhd = ( diff --git a/js/ui/nbhd_attr_dropdown.js b/js/ui/nbhd_attr_dropdown.js new file mode 100644 index 000000000..a6f6f98df --- /dev/null +++ b/js/ui/nbhd_attr_dropdown.js @@ -0,0 +1,183 @@ +import { toggle_slider } from './sliders'; + +/** + * Refresh the nbhd layer by cloning it with a new ID. + * This is necessary for deck.gl to recognize the layer has changed and re-render. + * + * @param {object} viz_state - The visualization state + * @param {object} layers_obj - The deck.gl layers object + * @param {string} attr_name - The attribute name for the new layer ID + */ +const refresh_nbhd_layer = (viz_state, layers_obj, attr_name) => { + // Clone the layer with a new ID to trigger deck.gl re-render + layers_obj.nbhd_layer = layers_obj.nbhd_layer.clone({ + id: `nbhd-layer-attr-${attr_name}-${Date.now()}`, + }); + + // Toggle deck_check to trigger the layer list update + viz_state.obs_store.deck_check.set({ + ...viz_state.obs_store.deck_check.get(), + nbhd_layer: false, + }); + viz_state.obs_store.deck_check.set({ + ...viz_state.obs_store.deck_check.get(), + nbhd_layer: true, + }); +}; + +/** + * Create a compact dropdown for selecting neighborhood GDF attributes to color by. + * This allows coloring neighborhoods by numerical attributes like 'area' from the GDF. + * + * @param {object} viz_state - The visualization state + * @param {object} layers_obj - The deck.gl layers object + * @returns {HTMLElement} The dropdown container element + */ +export const make_nbhd_attr_dropdown = (viz_state, layers_obj) => { + const container = document.createElement('div'); + container.style.display = 'flex'; + container.style.alignItems = 'center'; + container.style.marginTop = '2px'; + container.style.marginBottom = '4px'; + container.style.marginLeft = '5px'; + + const label = document.createElement('span'); + label.textContent = 'color:'; + label.style.fontSize = '9px'; + label.style.color = '#888'; + label.style.marginRight = '4px'; + + const select = document.createElement('select'); + select.id = 'nbhd-attr-select'; + + // Compact styling to match the discrete look + select.style.width = '70px'; + select.style.height = '18px'; + select.style.fontSize = '10px'; + select.style.padding = '0 2px'; + select.style.border = '1px solid #ccc'; + select.style.borderRadius = '3px'; + select.style.backgroundColor = '#fafafa'; + select.style.cursor = 'pointer'; + select.style.outline = 'none'; + + // Default option (categorical coloring) + const defaultOption = document.createElement('option'); + defaultOption.value = 'cluster'; + defaultOption.textContent = 'cluster'; + select.appendChild(defaultOption); + + // Add GDF attributes from viz_state (populated from Python traitlet) + const gdf_attrs = viz_state.nbhd?.gdf_attrs || []; + for (const attr of gdf_attrs) { + const option = document.createElement('option'); + option.value = attr; + option.textContent = attr; + select.appendChild(option); + } + + // Handle attribute selection + select.addEventListener('change', (e) => { + const selectedAttr = e.target.value; + + // Get bar graph container to show/hide + const barContainer = viz_state.containers?.bar_nbhd; + + if (selectedAttr === 'cluster') { + // Reset to categorical coloring + viz_state.nbhd.color_mode = 'cluster'; + viz_state.nbhd.gene_expression = null; + viz_state.nbhd.current_gene = null; + + // Show bar graph and slider for categorical mode + if (barContainer) { + barContainer.style.display = 'block'; + } + if (viz_state.sliders?.nbhd) { + toggle_slider(viz_state.sliders.nbhd, true); + } + } else { + // Color by GDF attribute - extract values from GeoJSON properties + const featureCollection = viz_state.nbhd.feature_collection; + if (featureCollection && featureCollection.features) { + const values = {}; + let maxVal = -Infinity; + let minVal = Infinity; + + for (const feature of featureCollection.features) { + // Store by both cat and cat as string to handle numeric keys + const cat = feature.properties.cat; + const val = feature.properties[selectedAttr]; + if (typeof val === 'number' && !isNaN(val)) { + // Store with multiple key formats for robust lookup + values[cat] = val; + values[String(cat)] = val; + if (feature.properties.name) { + values[feature.properties.name] = val; + } + if (val > maxVal) maxVal = val; + if (val < minVal) minVal = val; + } + } + + console.log('NBHD attr dropdown - selected:', selectedAttr); + console.log('NBHD attr dropdown - values:', values); + console.log('NBHD attr dropdown - max:', maxVal, 'min:', minVal); + + // Store attribute data using existing gene expression infrastructure + viz_state.nbhd.gene_expression = values; + viz_state.nbhd.gene_max_exp = maxVal; + viz_state.nbhd.gene_min_exp = minVal; + viz_state.nbhd.current_gene = selectedAttr; + viz_state.nbhd.color_mode = 'gene'; + + // Hide bar graph and slider for numerical attribute mode + // (opacity is used to encode the attribute value) + if (barContainer) { + barContainer.style.display = 'none'; + } + if (viz_state.sliders?.nbhd) { + toggle_slider(viz_state.sliders.nbhd, false); + } + + // Set layer opacity to 1 so the value encoding opacity shows correctly + layers_obj.nbhd_layer = layers_obj.nbhd_layer.clone({ + opacity: 1.0, + }); + } + } + + // Refresh the nbhd layer by cloning it + refresh_nbhd_layer(viz_state, layers_obj, selectedAttr); + }); + + container.appendChild(label); + container.appendChild(select); + + return container; +}; + +/** + * Update the dropdown options when gdf_attrs changes + * + * @param {object} viz_state - The visualization state + */ +export const update_nbhd_attr_dropdown = (viz_state) => { + const select = document.getElementById('nbhd-attr-select'); + if (!select) return; + + // Clear existing options except the first (cluster) + while (select.options.length > 1) { + select.remove(1); + } + + // Add new options from gdf_attrs + const gdf_attrs = viz_state.nbhd?.gdf_attrs || []; + for (const attr of gdf_attrs) { + const option = document.createElement('option'); + option.value = attr; + option.textContent = attr; + select.appendChild(option); + } +}; + diff --git a/js/ui/ui_containers.js b/js/ui/ui_containers.js index 7dab98676..4909bdff2 100644 --- a/js/ui/ui_containers.js +++ b/js/ui/ui_containers.js @@ -36,6 +36,7 @@ import { bar_callback_gene, } from './bar_plot'; import { make_dataset_dropdown } from './dataset_dropdown'; +import { make_nbhd_attr_dropdown } from './nbhd_attr_dropdown'; import { set_gene_search } from './gene_search'; import { logo } from './logo'; import { init_matrix_cat_bars } from './matrix_cat_bars'; @@ -1144,6 +1145,14 @@ export const make_ist_ui_container = ( viz_state.containers.bar_nbhd.style.marginLeft = '0px'; nbhd_container.appendChild(nbhd_ctrl_container); + + // Add GDF attribute dropdown if there are numerical attributes available + const gdf_attrs = viz_state.nbhd?.gdf_attrs || []; + if (gdf_attrs.length > 0) { + const nbhd_attr_dropdown = make_nbhd_attr_dropdown(viz_state, layers_obj); + nbhd_container.appendChild(nbhd_attr_dropdown); + } + nbhd_container.appendChild(viz_state.containers.bar_nbhd); ctrl_container.appendChild(nbhd_container); diff --git a/js/viz/landscape_ist.js b/js/viz/landscape_ist.js index d2e08f302..e6d7c0a4e 100644 --- a/js/viz/landscape_ist.js +++ b/js/viz/landscape_ist.js @@ -313,6 +313,7 @@ export const landscape_ist = async ( }; viz_state.nbhd.feature_collection = viz_state.nbhd.ini_feature_collection; + viz_state.nbhd.gdf_attrs = []; } else { viz_state.nbhd.is_nbhd = true; @@ -353,6 +354,10 @@ export const landscape_ist = async ( type: 'FeatureCollection', features: nbhd.features, }; + + // Get available GDF attributes for coloring dropdown + // These are numerical columns from the nbhd GeoDataFrame (excluding geometry/color) + viz_state.nbhd.gdf_attrs = ini_model?.get('nbhd_gdf_attrs') || []; } viz_state.containers = {}; @@ -853,6 +858,84 @@ export const landscape_ist = async ( const cells = viz_state.model.get('selected_cells') || []; viz_state.obs_store.selected_cells.set(cells); }); + + // Handle neighborhood attribute data from Python backend (new format) + viz_state.model.on('change:nbhd_attr_data', () => { + const attr_data = viz_state.model.get('nbhd_attr_data') || {}; + if (attr_data.attr && attr_data.values) { + // Store attribute data for neighborhood layer coloring + viz_state.nbhd.gene_expression = attr_data.values; + viz_state.nbhd.gene_max_exp = attr_data.max_val || 1; + viz_state.nbhd.gene_min_exp = attr_data.min_val || 0; + viz_state.nbhd.current_gene = attr_data.attr; + viz_state.nbhd.color_mode = 'gene'; + + // Hide bar graph and slider when showing numerical attribute + // (opacity encodes the attribute value) + if (viz_state.containers?.bar_nbhd) { + viz_state.containers.bar_nbhd.style.display = 'none'; + } + if (viz_state.sliders?.nbhd) { + toggle_slider(viz_state.sliders.nbhd, false); + } + + // Clone layer with opacity 1 and new ID to trigger re-render + layers_obj.nbhd_layer = layers_obj.nbhd_layer.clone({ + id: `nbhd-layer-attr-${attr_data.attr}-${Date.now()}`, + opacity: 1.0, + }); + + // Use same pattern as refresh_layer: toggle + set layers_obj + viz_state.obs_store.deck_check.set({ + ...viz_state.obs_store.deck_check.get(), + nbhd_layer: false, + }); + viz_state.layers_obj = layers_obj; + viz_state.obs_store.deck_check.set({ + ...viz_state.obs_store.deck_check.get(), + nbhd_layer: true, + }); + } + }); + + // Handle legacy traitlet for backwards compatibility + viz_state.model.on('change:nbhd_gene_expression', () => { + const expression_data = viz_state.model.get('nbhd_gene_expression') || {}; + if (expression_data.gene && expression_data.expression) { + viz_state.nbhd.gene_expression = expression_data.expression; + viz_state.nbhd.gene_max_exp = expression_data.max_exp || 1; + viz_state.nbhd.current_gene = expression_data.gene; + viz_state.nbhd.color_mode = 'gene'; + + // Hide bar graph and slider when showing numerical attribute + if (viz_state.containers?.bar_nbhd) { + viz_state.containers.bar_nbhd.style.display = 'none'; + } + if (viz_state.sliders?.nbhd) { + toggle_slider(viz_state.sliders.nbhd, false); + } + + // Clone layer with opacity 1 and new ID to trigger re-render + layers_obj.nbhd_layer = layers_obj.nbhd_layer.clone({ + id: `nbhd-layer-gene-${expression_data.gene}-${Date.now()}`, + opacity: 1.0, + }); + + // Use same pattern as refresh_layer: toggle + set layers_obj + viz_state.obs_store.deck_check.set({ + ...viz_state.obs_store.deck_check.get(), + nbhd_layer: false, + }); + viz_state.layers_obj = layers_obj; + viz_state.obs_store.deck_check.set({ + ...viz_state.obs_store.deck_check.get(), + nbhd_layer: true, + }); + } + }); + + // Check if nbhd_adata is available via synced traitlet + viz_state.nbhd.has_nbhd_adata = viz_state.model.get('has_nbhd_adata') || false; } const ui_container = make_ist_ui_container( diff --git a/js/widget_interactions/update_ist_landscape_from_cgm.js b/js/widget_interactions/update_ist_landscape_from_cgm.js index 0ebc40c38..4d4c1b550 100644 --- a/js/widget_interactions/update_ist_landscape_from_cgm.js +++ b/js/widget_interactions/update_ist_landscape_from_cgm.js @@ -38,6 +38,39 @@ const reset_to_cluster_mode = (viz_state, layers_obj) => { refresh_layer(viz_state, layers_obj, 'cell_layer'); }; +/** + * Helper to reset neighborhood layer to categorical cluster coloring. + * Call this when selecting neighborhoods from dendrogram (not by attribute). + */ +const reset_nbhd_to_cluster_mode = (viz_state) => { + if (!viz_state.nbhd) return; + + // Reset to categorical coloring + viz_state.nbhd.color_mode = 'cluster'; + viz_state.nbhd.gene_expression = null; + viz_state.nbhd.current_gene = null; + + // Show bar graph if it exists + if (viz_state.containers?.bar_nbhd) { + viz_state.containers.bar_nbhd.style.display = 'flex'; + } + + // Show opacity slider + if (viz_state.sliders?.nbhd) { + // Import toggle_slider dynamically is tricky, so just show the container + const slider_container = viz_state.sliders.nbhd?.container; + if (slider_container) { + slider_container.style.display = 'flex'; + } + } + + // Reset the dropdown to 'cluster' if it exists + const dropdown = document.getElementById('nbhd-attr-dropdown'); + if (dropdown) { + dropdown.value = 'cluster'; + } +}; + /** * Check if a click value represents a cell cluster selection. * Supports both legacy format (row_entity === 'cell_cluster') and @@ -71,8 +104,39 @@ export const is_neighborhood = (clickValue) => { return clickValue.entity === 'nbhd' || clickValue.entity === 'hextile'; }; + +/** + * Check if a click value represents a nbhd_var entity (or nbhd_gene). + * Rows are attributes from nbhd_adata.var - clicking ALWAYS colors neighborhoods. + * nbhd_gene is a special case where rows are genes aggregated at neighborhood level. + */ +export const is_nbhd_var = (clickValue) => { + if (!clickValue) return false; + return ( + clickValue.entity === 'nbhd_var' || + clickValue.entity === 'nbhd_attr' || + clickValue.entity === 'nbhd_gene' + ); +}; + /** - * Check if a click value represents a gene selection. + * Check if a click value represents gene data (for enrichment purposes). + * This is true for entity='gene' OR entity='nbhd_gene' OR data_type='gene'. + */ +export const is_gene_data = (clickValue) => { + if (!clickValue) return false; + // Direct gene entity + if (clickValue.entity === 'gene') return true; + // nbhd_gene - genes at neighborhood level + if (clickValue.entity === 'nbhd_gene') return true; + // Explicit data_type (most flexible) + if (clickValue.data_type === 'gene') return true; + return false; +}; + +/** + * Check if a click value represents a gene entity. + * For strict entity check only - use is_gene_data() for enrichment purposes. */ export const is_gene = (clickValue) => { if (!clickValue) return false; @@ -119,8 +183,44 @@ export const update_ist_landscape_from_cgm = async ( // add try catch block try { if (click_type === 'row_label') { + // Check if this is a nbhd_var/nbhd_gene entity (attribute from nbhd_adata.var) + // This ALWAYS colors neighborhoods - no fallback to cells + if (is_nbhd_var(click_info.value)) { + const attr_name = click_info.value.name; + const has_nbhd_adata = viz_state.nbhd?.has_nbhd_adata; + + if (has_nbhd_adata) { + // Send request to Python backend to get nbhd attribute data + if (viz_state.model && typeof viz_state.model.set === 'function') { + viz_state.model.set('nbhd_attr_request', attr_name); + viz_state.model.save_changes(); + } + + // Show neighborhood layer, hide cell layer coloring + viz_state.obs_store.viz_nbhd_layer.set(true); + viz_state.buttons?.buttons?.nbhd?.style?.('color', 'blue'); + + // Clear cell selections + update_cat(viz_state.cats, 'cluster'); + update_selected_cats(viz_state.cats, [], viz_state.obs_store); + + // Update gene display name (for UI purposes only) + if (viz_state.genes) { + viz_state.genes.inst_gene = attr_name; + } + + // If this is gene data (nbhd_gene or data_type='gene'), update local state + // Note: Don't sync to Python here - the Clustergram handles that + if (is_gene_data(click_info.value) && viz_state.genes) { + viz_state.genes.selected_genes = [attr_name]; + viz_state.obs_store.selected_genes.set([attr_name]); + } + + // DON'T refresh layer here - the traitlet handler will do it + // after data arrives from Python via change:nbhd_attr_data + } // Check if this is a neighborhood selection - if (is_neighborhood(click_info.value)) { + } else if (is_neighborhood(click_info.value)) { const new_nbhd = click_info.value.name; const prev_selected = viz_state.obs_store.selected_nbhds.get(); @@ -157,29 +257,91 @@ export const update_ist_landscape_from_cgm = async ( refresh_layer(viz_state, layers_obj, 'cell_layer'); refresh_layer(viz_state, layers_obj, 'trx_layer'); } else if (is_cell_cluster(click_info.value)) { - // Cell cluster selection - inst_gene = 'cluster'; - new_cat = click_info.value.name; - - // Clear selected cells when switching to cluster mode - viz_state.obs_store.selected_cells.set([]); + console.log('Entered is_cell_cluster block'); + // Cell cluster/population entity - can color nbhds OR highlight cells + const attr_name = click_info.value.name; + console.log('attr_name:', attr_name); + const nbhd_is_active = viz_state.obs_store?.viz_nbhd_layer?.get() || false; + console.log('nbhd_is_active:', nbhd_is_active); + const has_nbhd_adata = viz_state.nbhd?.has_nbhd_adata || false; + console.log('has_nbhd_adata:', has_nbhd_adata); + + console.log('row_label click - is_cell_cluster:', { + attr_name, + nbhd_is_active, + has_nbhd_adata, + entity: click_info.value.entity, + attr: click_info.value.attr, + }); + + if (nbhd_is_active && has_nbhd_adata) { + // NBHD active: Color neighborhoods by this attribute + console.log('Sending nbhd_attr_request:', attr_name); + update_selected_genes(viz_state.genes, [attr_name], viz_state.obs_store); + + if (viz_state.model && typeof viz_state.model.set === 'function') { + viz_state.model.set('nbhd_attr_request', attr_name); + viz_state.model.save_changes(); + } update_cat(viz_state.cats, 'cluster'); - update_selected_cats(viz_state.cats, [new_cat], viz_state.obs_store); + update_selected_cats(viz_state.cats, [], viz_state.obs_store); + refresh_layer(viz_state, layers_obj, 'cell_layer'); + refresh_layer(viz_state, layers_obj, 'nbhd_layer'); + } else { + // CELL active: Highlight cells with this population/cluster + update_cat(viz_state.cats, 'cluster'); + update_selected_cats(viz_state.cats, [attr_name], viz_state.obs_store); update_selected_genes(viz_state.genes, [], viz_state.obs_store); viz_state.obs_store.viz_nbhd_layer.set(false); viz_state.buttons?.buttons?.nbhd?.style?.('color', 'gray'); refresh_layer(viz_state, layers_obj, 'cell_layer'); + refresh_layer(viz_state, layers_obj, 'trx_layer'); + } } else { - // Treat as gene selection + // Treat as gene selection (or nbhd attribute if NBHD is active) inst_gene = click_info.value.name; + // Check if NBHD layer is active - if so, color nbhds instead of cells + const nbhd_is_active = viz_state.obs_store.viz_nbhd_layer.get(); + const nbhd_has_adata = viz_state.nbhd?.has_nbhd_adata; + + console.log('row_label click - else block (gene/other):', { + inst_gene, + nbhd_is_active, + nbhd_has_adata, + entity: click_info.value.entity, + attr: click_info.value.attr, + }); + + if (nbhd_is_active && nbhd_has_adata) { + // MUTUALLY EXCLUSIVE: Color neighborhoods by this attribute + console.log('Sending nbhd_attr_request from else block:', inst_gene); + update_selected_genes( + viz_state.genes, + [inst_gene], + viz_state.obs_store + ); + + // Request neighborhood attribute data from Python + if (viz_state.model && typeof viz_state.model.set === 'function') { + viz_state.model.set('nbhd_attr_request', inst_gene); + viz_state.model.save_changes(); + } + + // Keep cells in cluster mode (don't color by gene) + update_cat(viz_state.cats, 'cluster'); + update_selected_cats(viz_state.cats, [], viz_state.obs_store); + + refresh_layer(viz_state, layers_obj, 'cell_layer'); + refresh_layer(viz_state, layers_obj, 'nbhd_layer'); + } else { + // MUTUALLY EXCLUSIVE: Color cells by gene expression new_cat = inst_gene === viz_state.cats.cat ? 'cluster' : inst_gene; - // Clear highlighted cells immediately (without triggering subscription refresh) - // This prevents the old gene data from showing during loading + // Clear highlighted cells immediately viz_state.highlighted_cells = new Set(); update_cat(viz_state.cats, new_cat); @@ -189,8 +351,7 @@ export const update_ist_landscape_from_cgm = async ( viz_state.obs_store ); - // Load gene expression data BEFORE updating selected_cats - // This ensures cell_exp_array is populated before the cell layer refreshes + // Load gene expression data for cells await update_cell_exp_array( viz_state.cats, viz_state.genes, @@ -202,21 +363,21 @@ export const update_ist_landscape_from_cgm = async ( viz_state.row_group_readers?.cbg ); - // Clear selected cells in obs_store (after data is loaded to avoid flash) viz_state.obs_store.selected_cells.set([]); - // Update selected_cats after cell_exp_array has been populated update_selected_cats( viz_state.cats, new_cat === 'cluster' ? [] : [inst_gene], viz_state.obs_store ); + // Hide nbhd layer when coloring cells viz_state.obs_store.viz_nbhd_layer.set(false); viz_state.buttons?.buttons?.nbhd?.style?.('color', 'gray'); refresh_layer(viz_state, layers_obj, 'cell_layer'); refresh_layer(viz_state, layers_obj, 'trx_layer'); + } } } else if (click_type === 'col_label') { // Check if this is a neighborhood selection @@ -301,6 +462,9 @@ export const update_ist_landscape_from_cgm = async ( const col_entity_full = click_info.value.col_entity_full || click_info.value; if (is_neighborhood(col_entity_full)) { + // Reset to categorical cluster coloring (not attribute coloring) + reset_nbhd_to_cluster_mode(viz_state); + viz_state.obs_store.selected_nbhds.set(new_cats); viz_state.obs_store.viz_nbhd_layer.set(true); viz_state.buttons?.buttons?.nbhd?.style?.('color', 'blue'); @@ -380,6 +544,39 @@ export const update_ist_landscape_from_cgm = async ( .selectAll('rect') .style('opacity', (d) => (new_cats.includes(d.name) ? 1.0 : 0.2)); } + } else if (is_nbhd_var(row_entity_full)) { + // nbhd_var/nbhd_gene selection from row dendrogram - ALWAYS color neighborhoods + const has_nbhd_adata = viz_state.nbhd?.has_nbhd_adata; + + if (has_nbhd_adata) { + // Send attr names to Python (comma-separated for averaging if multiple) + if (viz_state.model && typeof viz_state.model.set === 'function') { + viz_state.model.set('nbhd_attr_request', new_cats.join(',')); + viz_state.model.save_changes(); + } + + // Show neighborhood layer + viz_state.obs_store.viz_nbhd_layer.set(true); + viz_state.buttons?.buttons?.nbhd?.style?.('color', 'blue'); + + // Update gene display name (for UI purposes) + if (viz_state.genes) { + viz_state.genes.inst_gene = new_cats.length === 1 + ? new_cats[0] + : `avg(${new_cats.length} attrs)`; + } + + // Clear cell selections + update_cat(viz_state.cats, 'cluster'); + update_selected_cats(viz_state.cats, [], viz_state.obs_store); + + // If this is gene data (nbhd_gene or data_type='gene'), update local state + // Note: Don't sync to Python here - the Clustergram handles that + if (is_gene_data(row_entity_full) && viz_state.genes) { + viz_state.genes.selected_genes = new_cats; + viz_state.obs_store.selected_genes.set(new_cats); + } + } } else if (is_cell_cluster(row_entity_full)) { viz_state.highlighted_cells = new Set(); viz_state.obs_store.selected_cells.set([]); @@ -400,16 +597,32 @@ export const update_ist_landscape_from_cgm = async ( viz_state.genes.selected_genes = new_cats; viz_state.obs_store.selected_genes.set(new_cats); + // Check if NBHD layer is active - mutually exclusive behavior + const nbhd_is_active = viz_state.obs_store.viz_nbhd_layer.get(); + const nbhd_has_adata = viz_state.nbhd?.has_nbhd_adata; + + if (nbhd_is_active && nbhd_has_adata) { + // MUTUALLY EXCLUSIVE: Color neighborhoods by gene(s) + if (viz_state.model && typeof viz_state.model.set === 'function') { + viz_state.model.set('nbhd_attr_request', new_cats.join(',')); + viz_state.model.save_changes(); + } + + // Keep cells in cluster mode + update_cat(viz_state.cats, 'cluster'); + update_selected_cats(viz_state.cats, [], viz_state.obs_store); + + refresh_layer(viz_state, layers_obj, 'cell_layer'); + refresh_layer(viz_state, layers_obj, 'nbhd_layer'); + } else { + // MUTUALLY EXCLUSIVE: Color cells by gene expression if (new_cats.length === 1) { inst_gene = new_cats[0]; new_cat = inst_gene === viz_state.cats.cat ? 'cluster' : inst_gene; - // Clear highlighted cells immediately (without triggering subscription refresh) viz_state.highlighted_cells = new Set(); - update_cat(viz_state.cats, new_cat); - // Load gene expression data BEFORE updating selected_cats await update_cell_exp_array( viz_state.cats, viz_state.genes, @@ -420,28 +633,46 @@ export const update_ist_landscape_from_cgm = async ( viz_state.aws ); - // Clear selected cells in obs_store (after data is loaded) viz_state.obs_store.selected_cells.set([]); - - // Update selected_cats after cell_exp_array has been populated update_selected_cats( viz_state.cats, new_cat === 'cluster' ? [] : [inst_gene], viz_state.obs_store ); } else { - // Multiple genes selected - just switch to cluster mode for now + // Multiple genes - switch to cluster mode for cells viz_state.highlighted_cells = new Set(); viz_state.obs_store.selected_cells.set([]); update_cat(viz_state.cats, 'cluster'); update_selected_cats(viz_state.cats, [], viz_state.obs_store); } + // Hide nbhd layer when coloring cells viz_state.obs_store.viz_nbhd_layer.set(false); viz_state.buttons?.buttons?.nbhd?.style?.('color', 'gray'); refresh_layer(viz_state, layers_obj, 'cell_layer'); refresh_layer(viz_state, layers_obj, 'trx_layer'); + } + } else { + // Other entity types (population, etc.) - treat as neighborhood attribute + // This handles nbhd x population matrices where clicking population colors nbhds + const nbhd_has_adata = viz_state.nbhd?.has_nbhd_adata; + + if (nbhd_has_adata && new_cats.length > 0) { + // Request neighborhood attribute data for selected items + if (viz_state.model && typeof viz_state.model.set === 'function') { + viz_state.model.set('nbhd_attr_request', new_cats.join(',')); + viz_state.model.save_changes(); + } + + // Show neighborhood layer and set to attribute mode + viz_state.nbhd.color_mode = 'gene'; + viz_state.obs_store.viz_nbhd_layer.set(true); + viz_state.buttons?.buttons?.nbhd?.style?.('color', 'blue'); + + refresh_layer(viz_state, layers_obj, 'nbhd_layer'); + } } } else if (click_type === 'cat_value') { // Category bar/tile click - highlight cells in that category diff --git a/src/celldega/clust/constants.py b/src/celldega/clust/constants.py index e20b306a0..68c420f54 100644 --- a/src/celldega/clust/constants.py +++ b/src/celldega/clust/constants.py @@ -21,11 +21,18 @@ class AxisEntity(TypedDict, total=False): Describes what entity a clustergram axis represents. Attributes: - entity: The type of entity (cell, gene, nbhd, cluster, etc.) + entity: The type of entity (cell, gene, nbhd, nbhd_gene, cluster, etc.) attr: The attribute of that entity (name, leiden, custom_column, etc.) - For cells: 'leiden' means cell clusters, 'name' means individual cells - For nbhd: 'name' means specific neighborhoods - For genes: typically 'name' + data_type: The underlying data type for analysis purposes (optional). + - 'gene': Row/col names are gene symbols (enables enrichment analysis) + - 'population': Row/col names are population/cluster names + - None or omitted: Inferred from entity (gene entity → gene data_type) + This is useful when the spatial context differs from the analysis type. + For example, nbhd_gene has entity='nbhd_gene' (spatial context is + neighborhood), but data_type='gene' (for enrichment purposes). Examples: # Clustergram with cell clusters on rows (cells grouped by leiden) @@ -40,6 +47,9 @@ class AxisEntity(TypedDict, total=False): # Clustergram with genes on rows {"entity": "gene", "attr": "name"} + # Neighborhoods with gene expression (enables enrichment) + {"entity": "nbhd_gene", "attr": "name", "data_type": "gene"} + # Hextile neighborhoods by cell clusters row: {"entity": "cell", "attr": "leiden"} col: {"entity": "hextile", "attr": "nbhd_cluster"} @@ -47,6 +57,7 @@ class AxisEntity(TypedDict, total=False): entity: str attr: str + data_type: str # Optional: 'gene', 'population', etc. def normalize_axis_entity(value: str | tuple | dict | AxisEntity | None) -> AxisEntity: @@ -59,30 +70,37 @@ def normalize_axis_entity(value: str | tuple | dict | AxisEntity | None) -> Axis Args: value: Entity specification - can be: - str: Shorthand format with implicit attr (see mapping below) - - tuple: Compact format (entity, attr) e.g., ("nbhd", "name") - - dict/AxisEntity: Full format with entity and attr keys - - None: Returns default {"entity": "gene", "attr": "name"} + - tuple: Compact format (entity, attr) or (entity, attr, data_type) + - dict/AxisEntity: Full format with entity, attr, and optional data_type + - None: Returns default {"entity": "gene", "attr": "name", "data_type": "gene"} String Shorthand Mapping: When a string is provided, the following implicit attr values are used: - - "gene" → {"entity": "gene", "attr": "name"} + - "gene" → {"entity": "gene", "attr": "name", "data_type": "gene"} - "nbhd" → {"entity": "nbhd", "attr": "name"} - "cell" → {"entity": "cell", "attr": "name"} - "hextile" → {"entity": "hextile", "attr": "name"} - "cell_cluster" or "cluster" → {"entity": "cell", "attr": "leiden"} + - "nbhd_gene" → {"entity": "nbhd_gene", "attr": "name", "data_type": "gene"} + (neighborhoods with gene data - enables enrichment analysis) + - "nbhd_var" → {"entity": "nbhd_var", "attr": "name"} + (generic neighborhood variables - no enrichment) - any other string → {"entity": , "attr": "name"} Returns: - AxisEntity with entity and attr keys + AxisEntity with entity, attr, and optionally data_type keys Examples: # String shorthand (attr is implicit) >>> normalize_axis_entity("gene") - {"entity": "gene", "attr": "name"} + {"entity": "gene", "attr": "name", "data_type": "gene"} >>> normalize_axis_entity("nbhd") {"entity": "nbhd", "attr": "name"} + >>> normalize_axis_entity("nbhd_gene") + {"entity": "nbhd_gene", "attr": "name", "data_type": "gene"} + >>> normalize_axis_entity("cell_cluster") {"entity": "cell", "attr": "leiden"} @@ -96,25 +114,43 @@ def normalize_axis_entity(value: str | tuple | dict | AxisEntity | None) -> Axis # Dict format (most explicit) >>> normalize_axis_entity({"entity": "cell", "attr": "leiden"}) {"entity": "cell", "attr": "leiden"} + + # Dict with data_type for enrichment + >>> normalize_axis_entity({"entity": "nbhd_var", "attr": "name", "data_type": "gene"}) + {"entity": "nbhd_var", "attr": "name", "data_type": "gene"} """ if value is None: - return {"entity": "gene", "attr": "name"} + return {"entity": "gene", "attr": "name", "data_type": "gene"} if isinstance(value, str): # Legacy string format - convert to new structure # Map legacy strings to new entity/attr pairs legacy_mapping: dict[str, AxisEntity] = { - "gene": {"entity": "gene", "attr": "name"}, + "gene": {"entity": "gene", "attr": "name", "data_type": "gene"}, "cell_cluster": {"entity": "cell", "attr": "leiden"}, "cluster": {"entity": "cell", "attr": "leiden"}, "nbhd": {"entity": "nbhd", "attr": "name"}, "cell": {"entity": "cell", "attr": "name"}, "hextile": {"entity": "hextile", "attr": "name"}, + # nbhd_gene: Rows are genes aggregated at neighborhood level + # This enables enrichment analysis when rows are selected + "nbhd_gene": {"entity": "nbhd_gene", "attr": "name", "data_type": "gene"}, + # nbhd_var: Generic rows from nbhd_adata.var - no enrichment + # Clicking ALWAYS looks up in nbhd_adata and colors neighborhoods + "nbhd_var": {"entity": "nbhd_var", "attr": "name"}, + "nbhd_attr": {"entity": "nbhd_var", "attr": "name"}, + # Population shorthand - also treated as nbhd_var + "population": {"entity": "nbhd_var", "attr": "name"}, } return legacy_mapping.get(value, {"entity": value, "attr": "name"}) if isinstance(value, tuple): - # Compact tuple format: (entity, attr) + # Compact tuple format: (entity, attr) or (entity, attr, data_type) + if len(value) >= 3: + result: AxisEntity = {"entity": str(value[0]), "attr": str(value[1])} + if value[2]: + result["data_type"] = str(value[2]) + return result if len(value) >= 2: return {"entity": str(value[0]), "attr": str(value[1])} if len(value) == 1: @@ -125,7 +161,11 @@ def normalize_axis_entity(value: str | tuple | dict | AxisEntity | None) -> Axis # Already in dict format, ensure it has required keys entity = value.get("entity", "custom") attr = value.get("attr", "name") - return {"entity": entity, "attr": attr} + result: AxisEntity = {"entity": entity, "attr": attr} + # Preserve data_type if provided + if "data_type" in value and value["data_type"]: + result["data_type"] = value["data_type"] + return result # Fallback return {"entity": "custom", "attr": "name"} diff --git a/src/celldega/clust/matrix.py b/src/celldega/clust/matrix.py index c7d322e9b..a61f8c8fa 100644 --- a/src/celldega/clust/matrix.py +++ b/src/celldega/clust/matrix.py @@ -602,6 +602,34 @@ def norm(self, axis: AxisInput, by: NormType) -> None: self._clustered = False self._invalidate_cache(CacheLevel.DATA.value) + def jitter(self, scale: float = 0.001, seed: int = 42) -> None: + """ + Add small random noise to break ties in identical values. + + This helps prevent scrambled dendrograms when columns/rows have + exactly the same values, which causes zero distances and undefined + ordering in hierarchical clustering. + + Args: + scale: Maximum noise magnitude (default: 0.001) + seed: Random seed for reproducibility (default: 42) + + Example: + >>> mat = Matrix(adata) + >>> mat.norm(axis='row', by='zscore') + >>> mat.jitter() # Add tiny noise to break ties + >>> mat.cluster() + """ + if self.data is None: + raise ValueError(ERRORS["no_data"]) + + np.random.seed(seed) + noise = np.random.uniform(0, scale, size=self.data.shape) + self.data = self.data + noise + + self._clustered = False + self._invalidate_cache(CacheLevel.DATA.value) + def clust( self, dist_type: DistanceType = "cosine", diff --git a/src/celldega/viz/__init__.py b/src/celldega/viz/__init__.py index af665bb14..462033f8d 100644 --- a/src/celldega/viz/__init__.py +++ b/src/celldega/viz/__init__.py @@ -35,13 +35,14 @@ def landscape_clustergram( height: str = "700px", *, enrich: bool | Enrich = False, - row_enrich: bool = True, - col_enrich: bool = False, enrich_kwargs: dict | None = None, ) -> HBox: """ Display a `Landscape` widget and a `Clustergram` widget side by side. + When genes are selected in the Clustergram (via dendrogram clicks), + the Enrich widget automatically updates to show enrichment results. + Args: landscape (Landscape): A `Landscape` widget. mat (Clustergram): A `Clustergram` widget. @@ -50,19 +51,12 @@ def landscape_clustergram( enrich (bool | Enrich): If True, create an `Enrich` widget; if an `Enrich` instance is provided, use it directly. If False, no enrichment widget is shown. - row_enrich (bool): If True (default), run enrichment analysis when - row dendrogram clusters are selected. - col_enrich (bool): If True, run enrichment analysis when column - dendrogram clusters are selected. enrich_kwargs (dict | None): Optional kwargs passed to `Enrich` when `enrich=True`. Returns: HBox: Visualization display containing the widgets. """ - # Link clustergram click_info to landscape update_trigger - jslink((mat, "click_info"), (landscape, "update_trigger")) - # Layouts mat.layout = Layout(width=width) landscape.layout = Layout(width=width, height=height) @@ -76,19 +70,14 @@ def landscape_clustergram( config.setdefault("width", 250) enrich_widget = Enrich(**config) - if enrich_widget is not None: - - def _forward_gene_to_landscape(gene: str) -> None: - if gene: - landscape.trigger_update({"type": "row_label", "value": {"name": gene}}) + # Link clustergram click_info to landscape update_trigger using jslink + # This works even when Python kernel is not running + jslink((mat, "click_info"), (landscape, "update_trigger")) - _link_clustergram_to_enrich( - mat, - enrich_widget, - row_enrich=row_enrich, - col_enrich=col_enrich, - gene_focus_callback=_forward_gene_to_landscape, - ) + if enrich_widget is not None: + # Link selected_genes directly to enrich gene_list via jslink + # This is more reliable than Python observers + jslink((mat, "selected_genes"), (enrich_widget, "gene_list")) children = [landscape, mat] if enrich_widget is not None: @@ -97,90 +86,15 @@ def _forward_gene_to_landscape(gene: str) -> None: return HBox(children) -def _link_clustergram_to_enrich( - cgm: Clustergram, - enrich: Enrich, - *, - row_enrich: bool = True, - col_enrich: bool = False, - gene_focus_callback=None, -) -> None: - enrich_colors = {"In term": "#2f74ff", "Out of term": "#ffffff"} - - def _record_colors() -> None: - if hasattr(cgm, "_record_category_colors"): - cgm._record_category_colors(enrich_colors) - - _record_colors() - - def _set_gene_list(genes) -> None: - enrich.gene_list = list(genes) if genes else [] - - def _on_selected_genes(change) -> None: - genes = change["new"] or [] - - click_info = getattr(cgm, "click_info", {}) or {} - click_type = (click_info.get("type") or "").lower() - selected_names = (click_info.get("value") or {}).get("selected_names") or [] - - is_dendro = click_type.startswith(("row", "col")) - matches_click = ( - bool(selected_names) - and len(selected_names) == len(genes) - and set(selected_names) == set(genes) - ) - - if is_dendro and matches_click: - if click_type.startswith("row"): - if not row_enrich: - _set_gene_list([]) - return - elif click_type.startswith("col") and not col_enrich: - _set_gene_list([]) - return - - _set_gene_list(genes) - - def _on_click_info(change) -> None: - info = change["new"] or {} - click_type = (info.get("type") or "").lower() - selected_names = (info.get("value") or {}).get("selected_names") or [] - - if click_type.startswith("col"): - if not col_enrich: - return - if selected_names: - cgm.selected_genes = list(selected_names) - elif click_type.startswith("row"): - if not row_enrich: - _set_gene_list([]) - - def _on_focused_gene(change) -> None: - if gene_focus_callback is None: - return - gene = change["new"] or "" - gene_focus_callback(gene) - - cgm.observe(_on_selected_genes, names="selected_genes") - cgm.observe(_on_click_info, names="click_info") - enrich.observe(_on_focused_gene, names="focused_gene") - - -def clustergram_enrich( - cgm: Clustergram, - *, - row_enrich: bool = True, - col_enrich: bool = False, -) -> HBox: +def clustergram_enrich(cgm: Clustergram) -> HBox: """ Display a `Clustergram` widget and an `Enrich` widget side by side. + When genes are selected in the Clustergram (via dendrogram clicks), + the Enrich widget automatically updates to show enrichment results. + Args: cgm (Clustergram): A `Clustergram` widget. - row_enrich (bool): If True (default), run enrichment analysis when - row dendrogram clusters are selected. - col_enrich (bool): If True, run enrichment analysis when column - dendrogram clusters are selected. Returns: HBox: Visualization display containing both widgets. @@ -189,12 +103,8 @@ def clustergram_enrich( enrich = Enrich(gene_list=[], width=250) - _link_clustergram_to_enrich( - cgm, - enrich, - row_enrich=row_enrich, - col_enrich=col_enrich, - ) + # Link selected_genes directly to enrich gene_list via jslink + jslink((cgm, "selected_genes"), (enrich, "gene_list")) return HBox([cgm, enrich], layout=Layout(width="1000px")) diff --git a/src/celldega/viz/widget.py b/src/celldega/viz/widget.py index d9ad9c0d4..0e24ca86e 100644 --- a/src/celldega/viz/widget.py +++ b/src/celldega/viz/widget.py @@ -231,6 +231,15 @@ class Landscape(anywidget.AnyWidget): cell_name_prefix (bool, optional): If True, cell names in adata.obs.index are assumed to have a dataset prefix (e.g., "dataset-name_cell-name") that should be trimmed when mapping to LandscapeFiles. Default: False. + nbhd_adata (AnnData, optional): Neighborhood attribute data matrix. + Neighborhoods as obs (rows), attributes as var (columns). + Can contain genes (from ``calc_nbhd_by_gene()``), populations + (from ``calc_nbhd_by_pop()``), or any numerical attributes. + When linked to a Clustergram, clicking row labels looks up the + attribute and colors neighborhoods. The semantic meaning of rows + comes from the Clustergram's row_entity setting. A + dropdown will appear in the UI to switch between cluster-based and + gene-expression-based neighborhood coloring. The AnnData input automatically extracts cell attributes (e.g., ``leiden`` clusters), the corresponding colors (or derives them when missing), and any @@ -264,11 +273,37 @@ class Landscape(anywidget.AnyWidget): nbhd = traitlets.Instance(gpd.GeoDataFrame, allow_none=True) nbhd_geojson = traitlets.Dict({}).tag(sync=True) + # List of numerical attributes available in nbhd GeoDataFrame for coloring + # Automatically extracted, excluding geometry/color columns + nbhd_gdf_attrs = traitlets.List( + trait=traitlets.Unicode(), default_value=[] + ).tag(sync=True) + # Enable editing of neighborhoods when True nbhd_edit = traitlets.Bool(False).tag(sync=True) meta_nbhd = traitlets.Instance(pd.DataFrame, allow_none=True) + # Neighborhood AnnData - neighborhoods as obs, attributes as var + # Can contain genes, populations, or any numerical attributes + # The semantic meaning comes from the Clustergram's row entity + _nbhd_adata = traitlets.Any(allow_none=True) + + # Flag indicating nbhd_adata is available (synced to frontend) + has_nbhd_adata = traitlets.Bool(False).tag(sync=True) + + # Traitlet for requesting neighborhood attribute data from frontend + # Can be a gene name, comma-separated genes, or other attribute names + nbhd_attr_request = traitlets.Unicode("").tag(sync=True) + + # Traitlet for sending neighborhood attribute data to frontend + # Format: {attr: str, values: {nbhd_name: float}, max_val: float, min_val: float} + nbhd_attr_data = traitlets.Dict({}).tag(sync=True) + + # Legacy alias for backwards compatibility + nbhd_gene_request = traitlets.Unicode("").tag(sync=True) + nbhd_gene_expression = traitlets.Dict({}).tag(sync=True) + meta_cluster = traitlets.Dict({}).tag(sync=True) selected_cells = traitlets.List(trait=traitlets.Unicode(), default_value=[]).tag(sync=True) landscape_state = traitlets.Unicode("spatial").tag(sync=True) @@ -302,6 +337,8 @@ def __init__(self, **kwargs): transform = kwargs.pop("transform", None) image_scale = kwargs.pop("image_scale", None) nbhd_edit = kwargs.pop("nbhd_edit", False) + nbhd_adata = kwargs.pop("nbhd_adata", None) + meta_cluster_df = None # cell_attr = kwargs.pop("cell_attr", ["leiden"]) cell_attr = list(kwargs.pop("cell_attr", ["leiden"])) @@ -519,6 +556,11 @@ def _reset_index_for_parquet(df): self.nbhd = nbhd_gdf self.nbhd_edit = nbhd_edit self.umap = umap_df + + # Store neighborhood AnnData (neighborhoods as obs, any attributes as var) + self._nbhd_adata = nbhd_adata + self.has_nbhd_adata = nbhd_adata is not None + if meta_cluster_df is not None: self.meta_cluster_df = meta_cluster_df @@ -538,6 +580,20 @@ def _reset_index_for_parquet(df): gdf_viz.drop(columns=["geometry_pixel"], inplace=True) self.nbhd_geojson = json.loads(gdf_viz.to_json()) + + # Extract numerical attributes from nbhd GDF for coloring dropdown + # Exclude geometry-related and color columns + exclude_patterns = ["geometry", "color", "cat", "name"] + numeric_cols = [] + for col in self.nbhd.columns: + # Skip if column name contains excluded patterns + if any(pat in col.lower() for pat in exclude_patterns): + continue + # Only include numeric columns + if pd.api.types.is_numeric_dtype(self.nbhd[col]): + numeric_cols.append(col) + self.nbhd_gdf_attrs = numeric_cols + elif self.nbhd_edit: self.nbhd_geojson = {"type": "FeatureCollection", "features": []} @@ -577,6 +633,169 @@ def _on_nbhd_geojson_change(self, change): self.nbhd = gdf + @traitlets.observe("nbhd_gene_request") + def _on_nbhd_gene_request(self, change): + """Legacy handler - forwards to nbhd_attr_request.""" + if change["new"]: + self.nbhd_attr_request = change["new"] + + @traitlets.observe("nbhd_attr_request") + def _on_nbhd_attr_request(self, change): + """Provide neighborhood attribute data when requested from frontend. + + Supports: + - Single attribute: "GENE1" or "area" or "cluster_5" + - Multi-attribute averaging: "GENE1,GENE2,GENE3" (comma-separated) + + Looks up attributes in nbhd_adata.var (columns) first, then nbhd_adata.obs. + """ + attr_request = change["new"] + if not attr_request or self._nbhd_adata is None: + return + + try: + adata = self._nbhd_adata + nbhd_names = list(adata.obs.index) + + # Check if this is a multi-attribute request (comma-separated) + attr_names = [a.strip() for a in attr_request.split(",") if a.strip()] + + if len(attr_names) == 0: + return + + # Try to find attributes in var (columns) or obs + values = None + display_name = None + + if len(attr_names) == 1: + attr_name = attr_names[0] + + # Check var_names first (genes, populations, etc.) + if attr_name in adata.var_names: + attr_idx = list(adata.var_names).index(attr_name) + if hasattr(adata.X, "toarray"): + values = adata.X[:, attr_idx].toarray().flatten() + else: + values = adata.X[:, attr_idx].flatten() + display_name = attr_name + + # Check obs columns (area, n_cells, etc.) + elif attr_name in adata.obs.columns: + values = adata.obs[attr_name].values.astype(float) + display_name = attr_name + + else: + self.nbhd_attr_data = {"error": f"Attribute '{attr_name}' not found"} + self.nbhd_gene_expression = self.nbhd_attr_data # Legacy + return + + else: + # Multi-attribute averaging - only works for var attributes + valid_attrs = [a for a in attr_names if a in adata.var_names] + if len(valid_attrs) == 0: + self.nbhd_attr_data = {"error": "No valid attributes found in matrix"} + self.nbhd_gene_expression = self.nbhd_attr_data + return + + attr_indices = [list(adata.var_names).index(a) for a in valid_attrs] + + if hasattr(adata.X, "toarray"): + attr_matrix = adata.X[:, attr_indices].toarray() + else: + attr_matrix = adata.X[:, attr_indices] + + values = attr_matrix.mean(axis=1).flatten() + + if len(valid_attrs) <= 3: + display_name = f"avg({','.join(valid_attrs)})" + else: + display_name = f"avg({len(valid_attrs)} attrs)" + + # Create dictionary mapping neighborhood names to values + values_dict = { + str(name): float(val) + for name, val in zip(nbhd_names, values, strict=False) + } + + max_val = float(values.max()) if len(values) > 0 else 1.0 + min_val = float(values.min()) if len(values) > 0 else 0.0 + + result = { + "attr": display_name, + "values": values_dict, + "max_val": max_val, + "min_val": min_val, + } + + self.nbhd_attr_data = result + + # Also set legacy traitlet for backwards compatibility + self.nbhd_gene_expression = { + "gene": display_name, + "expression": values_dict, + "max_exp": max_val, + } + + except Exception as e: + error_result = {"error": str(e)} + self.nbhd_attr_data = error_result + self.nbhd_gene_expression = error_result + + def get_nbhd_gene_expression(self, gene_names: str | list[str]) -> dict: + """ + Get neighborhood gene expression data for one or more genes. + + When multiple genes are provided, returns the averaged expression + across all valid genes. + + Args: + gene_names: Single gene name (str) or list of gene names to average. + + Returns: + Dictionary with 'gene' (display name), 'expression' mapping + neighborhood names to values, and 'max_exp' for normalization. + """ + if self._nbhd_adata is None: + return {"error": "No nbhd_adata provided"} + + adata = self._nbhd_adata + + # Normalize input to list + if isinstance(gene_names, str): + genes = [gene_names] + else: + genes = list(gene_names) + + valid_genes = [g for g in genes if g in adata.var_names] + if len(valid_genes) == 0: + return {"error": "No valid genes found"} + + gene_indices = [list(adata.var_names).index(g) for g in valid_genes] + + if hasattr(adata.X, "toarray"): + expr_matrix = adata.X[:, gene_indices].toarray() + else: + expr_matrix = adata.X[:, gene_indices] + + if len(valid_genes) == 1: + expression_values = expr_matrix.flatten() + display_name = valid_genes[0] + else: + expression_values = expr_matrix.mean(axis=1).flatten() + if len(valid_genes) <= 3: + display_name = f"avg({','.join(valid_genes)})" + else: + display_name = f"avg({len(valid_genes)} genes)" + + nbhd_names = list(adata.obs.index) + expression_dict = { + str(name): float(val) + for name, val in zip(nbhd_names, expression_values, strict=False) + } + max_exp = float(expression_values.max()) if len(expression_values) > 0 else 1.0 + + return {"gene": display_name, "expression": expression_dict, "max_exp": max_exp} + def close(self): # pragma: no cover - cleanup depends on JS """Close the widget and notify the frontend to release resources.""" with suppress(Exception):