Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 54 additions & 9 deletions js/deck-gl/layers/nbhd_layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>} 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;
};

Expand Down
3 changes: 2 additions & 1 deletion js/deck-gl/matrix/dendro_layers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand Down
32 changes: 26 additions & 6 deletions js/global_variables/selected_genes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}

Expand Down
27 changes: 21 additions & 6 deletions js/matrix/set_constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,46 +5,61 @@ 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
if (typeof value === 'string') {
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' };
}
}

// 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' };
Expand Down
73 changes: 55 additions & 18 deletions js/ui/bar_plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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({
Expand All @@ -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,
Expand All @@ -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 = (
Expand Down
Loading
Loading