From d1821d6329a54d645fa3f38a8153cf370122b4b6 Mon Sep 17 00:00:00 2001 From: Jaspreet Ishar <103258471+jaspreetishar@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:21:01 -0400 Subject: [PATCH 01/11] Allow CELL and NBHD layers to be visible together --- js/ui/text_buttons.js | 6 ------ js/viz/landscape_ist.js | 13 +------------ 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/js/ui/text_buttons.js b/js/ui/text_buttons.js index 2b2bf4d34..3b9089a7f 100644 --- a/js/ui/text_buttons.js +++ b/js/ui/text_buttons.js @@ -251,14 +251,8 @@ const cell_button_callback = async (event, deck_ist, layers_obj, viz_state) => { toggle_path_layer_visibility(layers_obj, is_visible); if (is_visible) { - toggle_nbhd_layer_visibility(layers_obj, false); - viz_state.obs_store.viz_nbhd_layer.set(false); viz_state.obs_store.viz_edit_layer.set(false); - if (viz_state.nbhd.is_nbhd) { - viz_state.buttons?.buttons?.nbhd?.style?.('color', 'gray'); - } - 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); diff --git a/js/viz/landscape_ist.js b/js/viz/landscape_ist.js index 13cd34875..edba2e2a4 100644 --- a/js/viz/landscape_ist.js +++ b/js/viz/landscape_ist.js @@ -14,7 +14,6 @@ import { set_views } from '../deck-gl/core/views'; import { ini_background_layer } from '../deck-gl/layers/background_layer'; import { ini_cell_layer, - new_toggle_cell_layer_visibility, set_cell_layer_onclick, toggle_spatial_umap, update_cell_pickable_state, @@ -657,35 +656,25 @@ export const landscape_ist = async ( viz_state.obs_store.viz_nbhd_layer.subscribe( (visible) => { if (visible) { - // set cell layer to not visible - new_toggle_cell_layer_visibility(viz_state.layers_obj, false); - + // keep CELL layer independent from NBHD visibility // set gene/cat bars to disabled color viz_state.genes.svg_bar_gene.selectAll('rect').style('opacity', 0.2); - viz_state.cats.svg_bar_cluster.selectAll('rect').style('opacity', 0.2); viz_state.nbhd.svg_bar_nbhd.selectAll('rect').style('opacity', 1.0); - viz_state.buttons.buttons.cell.style('color', 'gray'); viz_state.buttons.buttons.trx.style('color', 'gray'); viz_state.buttons.buttons.nbhd?.style('color', 'blue'); - toggle_slider(viz_state.sliders.cell, false); toggle_slider(viz_state.sliders.trx, false); if (viz_state.nbhd.is_nbhd) { toggle_slider(viz_state.sliders.nbhd, true); } } else { - new_toggle_cell_layer_visibility(viz_state.layers_obj, true); - viz_state.genes.svg_bar_gene.selectAll('rect').style('opacity', 1.0); - viz_state.cats.svg_bar_cluster.selectAll('rect').style('opacity', 1.0); viz_state.nbhd.svg_bar_nbhd.selectAll('rect').style('opacity', 0.2); - viz_state.buttons.buttons.cell.style('color', 'blue'); viz_state.buttons.buttons.trx.style('color', 'blue'); viz_state.buttons.buttons.nbhd?.style('color', 'gray'); - toggle_slider(viz_state.sliders.cell, true); toggle_slider(viz_state.sliders.trx, true); if (viz_state.nbhd.is_nbhd) { toggle_slider(viz_state.sliders.nbhd, false); From 073cb160cfa725d306c2691fadb0b8e806c22b6c Mon Sep 17 00:00:00 2001 From: Jaspreet Ishar <103258471+jaspreetishar@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:03:35 -0400 Subject: [PATCH 02/11] Fix NBHD render when stale neighborhood selection exists --- js/ui/text_buttons.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/js/ui/text_buttons.js b/js/ui/text_buttons.js index 3b9089a7f..28d311138 100644 --- a/js/ui/text_buttons.js +++ b/js/ui/text_buttons.js @@ -280,6 +280,24 @@ const nbhd_button_callback = async (event, deck_ist, layers_obj, viz_state) => { toggle_slider(viz_state.sliders.nbhd, is_visible); + if (is_visible) { + const selected_nbhds = viz_state.obs_store.selected_nbhds.get(); + const available_nbhds = new Set( + (viz_state.nbhd?.feature_collection?.features || []).map( + (feature) => feature.properties.cat + ) + ); + const has_matching_selection = selected_nbhds.some((nbhd) => + available_nbhds.has(nbhd) + ); + + // If a stale neighborhood selection is active, clear it so polygons render. + if (selected_nbhds.length > 0 && !has_matching_selection) { + viz_state.obs_store.selected_nbhds.set([]); + viz_state.nbhd?.svg_bar_nbhd?.selectAll?.('rect')?.style?.('opacity', 1.0); + } + } + toggle_nbhd_layer_visibility(layers_obj, is_visible); viz_state.obs_store.viz_nbhd_layer.set(is_visible); From 2b0da52091f7b6fb2780e736a10d93e9bcf1d1d0 Mon Sep 17 00:00:00 2001 From: Jaspreet Ishar <103258471+jaspreetishar@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:03:40 -0400 Subject: [PATCH 03/11] Allow CELL slider to affect point size when zoomed out --- js/deck-gl/layers/cell_layer.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/js/deck-gl/layers/cell_layer.js b/js/deck-gl/layers/cell_layer.js index f13ff294a..ff58f5868 100644 --- a/js/deck-gl/layers/cell_layer.js +++ b/js/deck-gl/layers/cell_layer.js @@ -435,6 +435,9 @@ export const update_cell_layer_radius = (layers_obj, radius, viz_state) => { }); } else { layers_obj.cell_layer = layers_obj.cell_layer.clone({ + // Keep marker size adjustable when zoomed out by tying the + // minimum on-screen pixel size to the slider radius. + radiusMinPixels: Math.max(1, radius), getRadius: radius, }); } From 48e98574c5b5bdd84a433db645cfcbe5d9fc0b34 Mon Sep 17 00:00:00 2001 From: Jaspreet Ishar <103258471+jaspreetishar@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:25:47 -0400 Subject: [PATCH 04/11] Add Landscape parameter for cell centroid size --- js/celldega.js | 2 ++ js/deck-gl/layers/cell_layer.js | 5 +++-- js/ui/sliders.js | 13 ++++++------- js/viz/landscape_ist.js | 2 ++ src/celldega/viz/widget.py | 1 + 5 files changed, 14 insertions(+), 9 deletions(-) diff --git a/js/celldega.js b/js/celldega.js index 99c19e1e3..7b86e2521 100644 --- a/js/celldega.js +++ b/js/celldega.js @@ -32,6 +32,7 @@ const render_landscape_ist = async ({ model, el }) => { 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 cell_centroid_size = model.get('cell_centroid_size') ?? 5; const scale_bar_microns_per_pixel = model.get('scale_bar_microns_per_pixel'); let meta_cell_data = { result: {}, attr: [] }; @@ -90,6 +91,7 @@ const render_landscape_ist = async ({ model, el }) => { rotation_x, rotate, max_tiles_to_view, + cell_centroid_size, scale_bar_microns_per_pixel, base_urls, cell_name_prefix diff --git a/js/deck-gl/layers/cell_layer.js b/js/deck-gl/layers/cell_layer.js index ff58f5868..8696260f3 100644 --- a/js/deck-gl/layers/cell_layer.js +++ b/js/deck-gl/layers/cell_layer.js @@ -391,10 +391,11 @@ export const ini_cell_layer = async (base_url, viz_state) => { ...getModelMatrixProps(viz_state.rotation), }); } else { + const initial_cell_radius = viz_state.genes?.cell_ini_radius ?? 5; cell_layer = new ScatterplotLayer({ id: 'cell-layer', - radiusMinPixels: 1, - getRadius: 5.0, + radiusMinPixels: Math.max(1, initial_cell_radius), + getRadius: initial_cell_radius, pickable: true, getFillColor: (i, d) => get_cell_color(viz_state.cats, viz_state.highlighted_cells, i, d), diff --git a/js/ui/sliders.js b/js/ui/sliders.js index 59ecb9e43..ba676d50c 100644 --- a/js/ui/sliders.js +++ b/js/ui/sliders.js @@ -5,6 +5,9 @@ import { square_scatter_layer_opacity } from '../deck-gl/layers/square_scatter_l import { update_trx_layer_radius } from '../deck-gl/layers/trx_layer'; import { refresh_layer } from '../utils/refresh_layer'; +const CELL_RADIUS_SLIDER_SCALE = 5; +const TRX_RADIUS_SLIDER_SCALE = 100; + const clamp_to_byte = (value) => { return Math.max(0, Math.min(255, Math.round(value))); }; @@ -208,11 +211,9 @@ const tile_slider_callback = async (deck_sst, viz_state, layers_sst) => { }; const cell_slider_callback = async (deck_ist, layers_obj, viz_state) => { - const scale_down_cell_radius = 5; - update_cell_layer_radius( layers_obj, - viz_state.sliders.cell.value / scale_down_cell_radius, + viz_state.sliders.cell.value / CELL_RADIUS_SLIDER_SCALE, viz_state ); @@ -220,11 +221,9 @@ const cell_slider_callback = async (deck_ist, layers_obj, viz_state) => { }; const trx_slider_callback = async (deck_ist, layers_obj, viz_state) => { - const scale_down_trx_radius = 100; - update_trx_layer_radius( layers_obj, - viz_state.sliders.trx.value / scale_down_trx_radius + viz_state.sliders.trx.value / TRX_RADIUS_SLIDER_SCALE ); refresh_layer(viz_state, layers_obj, 'trx_layer'); @@ -302,7 +301,7 @@ export const ini_slider = (slider_type, inst_deck, layers_obj, viz_state) => { callback = () => tile_slider_callback(inst_deck, viz_state, layers_obj); break; case 'cell': - ini_value = viz_state.genes.trx_ini_raidus * 100; + ini_value = viz_state.genes.cell_ini_radius * CELL_RADIUS_SLIDER_SCALE; callback = () => cell_slider_callback(inst_deck, layers_obj, viz_state); break; case 'trx': diff --git a/js/viz/landscape_ist.js b/js/viz/landscape_ist.js index edba2e2a4..6d6933e56 100644 --- a/js/viz/landscape_ist.js +++ b/js/viz/landscape_ist.js @@ -206,6 +206,7 @@ export const landscape_ist = async ( rotation_x = 0, rotate = 0, max_tiles_to_view = 50, + cell_centroid_size = 5, scale_bar_microns_per_pixel = null, base_urls = [], cell_name_prefix = false @@ -401,6 +402,7 @@ export const landscape_ist = async ( viz_state.genes.gene_counts = []; viz_state.genes.selected_genes = []; viz_state.genes.trx_ini_radius = trx_radius; + viz_state.genes.cell_ini_radius = cell_centroid_size; viz_state.genes.trx_names_array = []; viz_state.genes.trx_data = []; viz_state.genes.gene_text_box = ''; diff --git a/src/celldega/viz/widget.py b/src/celldega/viz/widget.py index ca5a627f9..8126b6d8f 100644 --- a/src/celldega/viz/widget.py +++ b/src/celldega/viz/widget.py @@ -93,6 +93,7 @@ class Landscape(anywidget.AnyWidget): scale_bar_microns_per_pixel = traitlets.Float(default_value=None, allow_none=True).tag( sync=True ) + cell_centroid_size = traitlets.Float(5.0).tag(sync=True) nbhd = traitlets.Instance(gpd.GeoDataFrame, allow_none=True) nbhd_geojson = traitlets.Dict({}).tag(sync=True) From e1447d9ec2ffab78f278a362d2b30e85f2a6c8b0 Mon Sep 17 00:00:00 2001 From: Jaspreet Ishar <103258471+jaspreetishar@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:23:22 -0400 Subject: [PATCH 05/11] Support right-click multi-select for NBHD bar controls --- js/ui/bar_plot.js | 79 +++++++++++++++++++++++++++++++---------------- 1 file changed, 53 insertions(+), 26 deletions(-) diff --git a/js/ui/bar_plot.js b/js/ui/bar_plot.js index 5d48b0b00..dbc066694 100644 --- a/js/ui/bar_plot.js +++ b/js/ui/bar_plot.js @@ -127,6 +127,9 @@ export const bar_callback_nbhd = ( _layers_obj, _viz_state ) => { + const is_multi_select = + _event?.type === 'contextmenu' || _event?.button === 2; + if (_viz_state.nbhd.edit) { _viz_state.obs_store.viz_edit_layer.set(true); @@ -143,37 +146,46 @@ export const bar_callback_nbhd = ( toggle_slider(_viz_state.sliders.nbhd, true); const prev_selected_nbhds = _viz_state.obs_store.selected_nbhds.get(); - if ( - prev_selected_nbhds[0] === _d.name && - prev_selected_nbhds.length === 1 + let next_selected_nbhds; + if (is_multi_select) { + if (prev_selected_nbhds.includes(_d.name)) { + next_selected_nbhds = prev_selected_nbhds.filter((n) => n !== _d.name); + } else { + next_selected_nbhds = [...prev_selected_nbhds, _d.name]; + } + } else if ( + prev_selected_nbhds.length === 1 && + prev_selected_nbhds[0] === _d.name ) { - _viz_state.obs_store.selected_nbhds.set([]); - _layers_obj.edit_layer = _layers_obj.edit_layer.clone({ - selectedFeatureIndexes: [], - }); + next_selected_nbhds = []; } else { - _viz_state.obs_store.selected_nbhds.set([_d.name]); - // Use edit.feature_collection for edit mode - const features = _viz_state.edit?.feature_collection?.features || []; - const featureIndex = features.findIndex( - (f) => f.properties.name === _d.name || f.properties.cat === _d.name - ); - if (featureIndex >= 0) { - _layers_obj.edit_layer = _layers_obj.edit_layer.clone({ - selectedFeatureIndexes: [featureIndex], - }); - } + next_selected_nbhds = [_d.name]; } + _viz_state.obs_store.selected_nbhds.set(next_selected_nbhds); + + // Use edit.feature_collection for edit mode + const features = _viz_state.edit?.feature_collection?.features || []; + const selectedFeatureIndexes = features + .map((f, idx) => + next_selected_nbhds.includes(f.properties.name) || + next_selected_nbhds.includes(f.properties.cat) + ? idx + : -1 + ) + .filter((idx) => idx >= 0); + _layers_obj.edit_layer = _layers_obj.edit_layer.clone({ + selectedFeatureIndexes, + }); refresh_layer(_viz_state, _layers_obj, 'edit_layer'); if (_viz_state.obs_store.selected_nbhds.get().length > 0) { + const selected_nbhds = _viz_state.obs_store.selected_nbhds.get(); _viz_state.nbhd.svg_bar_nbhd.selectAll('rect').style('opacity', (d) => { - if (d.name === _d.name) { + if (selected_nbhds.includes(d.name)) { return 1.0; - } else { - return 0.2; } + return 0.2; }); } else { _viz_state.nbhd.svg_bar_nbhd.selectAll('rect').style('opacity', 1.0); @@ -194,7 +206,15 @@ export const bar_callback_nbhd = ( } const prev_selected_nbhds = _viz_state.obs_store.selected_nbhds.get(); - if ( + if (is_multi_select) { + if (prev_selected_nbhds.includes(_d.name)) { + _viz_state.obs_store.selected_nbhds.set( + prev_selected_nbhds.filter((n) => n !== _d.name) + ); + } else { + _viz_state.obs_store.selected_nbhds.set([...prev_selected_nbhds, _d.name]); + } + } else if ( prev_selected_nbhds[0] === _d.name && prev_selected_nbhds.length === 1 ) { @@ -206,12 +226,12 @@ export const bar_callback_nbhd = ( refresh_layer(_viz_state, _layers_obj, 'nbhd_layer'); if (_viz_state.obs_store.selected_nbhds.get().length > 0) { + const selected_nbhds = _viz_state.obs_store.selected_nbhds.get(); _viz_state.nbhd.svg_bar_nbhd.selectAll('rect').style('opacity', (d) => { - if (d.name === _d.name) { + if (selected_nbhds.includes(d.name)) { return 1.0; - } else { - return 0.2; } + return 0.2; }); } else { _viz_state.nbhd.svg_bar_nbhd.selectAll('rect').style('opacity', 1.0); @@ -279,7 +299,14 @@ export const make_bar_graph = ( .attr('transform', (d, i) => `translate(2,${y_new(i) + 2})`) .on('click', (event, d) => click_callback(event, d, deck_ist, layers_obj, viz_state) - ); + ) + .on('contextmenu', (event, d) => { + // Right-click multi-select for neighborhood bar controls. + if (click_callback === bar_callback_nbhd) { + event.preventDefault(); + click_callback(event, d, deck_ist, layers_obj, viz_state); + } + }); bar .append('rect') From c8c0dce0069373276e75dbef0a7380ebed4fc8da Mon Sep 17 00:00:00 2001 From: Jaspreet Ishar <103258471+jaspreetishar@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:41:20 -0400 Subject: [PATCH 06/11] Expand clustergram row-label region based on label length --- js/matrix/set_constants.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/js/matrix/set_constants.js b/js/matrix/set_constants.js index 6b2d8ae35..71d3de0cf 100644 --- a/js/matrix/set_constants.js +++ b/js/matrix/set_constants.js @@ -50,6 +50,20 @@ const parseEntitySpec = (value) => { return { entity: 'custom', attr: 'name' }; }; +const estimate_row_label_width = (row_nodes = []) => { + const min_width = 75; + const max_width = 260; + const avg_char_width_px = 7; + + const max_label_length = row_nodes.reduce((max_len, node) => { + const name = String(node?.name ?? ''); + return Math.max(max_len, name.length); + }, 0); + + const estimated_width = Math.ceil(max_label_length * avg_char_width_px); + return Math.max(min_width, Math.min(max_width, estimated_width)); +}; + export const set_mat_constants = ( model, network, @@ -141,7 +155,7 @@ export const set_mat_constants = ( viz_state.viz.base_font_size = 125; viz_state.viz.col_label = 75; // 40 - viz_state.viz.row_label = 75; // 35 + viz_state.viz.row_label = estimate_row_label_width(network.row_nodes); viz_state.viz.extra_space = {}; viz_state.viz.extra_space.row = 5; // 10; From 823c404b943499b3c1e33b70fbb44fa7aa9d1d2d Mon Sep 17 00:00:00 2001 From: Jaspreet Ishar <103258471+jaspreetishar@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:47:45 -0400 Subject: [PATCH 07/11] Add interactive column attribute label width control --- js/deck-gl/matrix/views.js | 2 +- js/matrix/set_constants.js | 1 + js/ui/ui_containers.js | 48 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/js/deck-gl/matrix/views.js b/js/deck-gl/matrix/views.js index cafdc3563..54b8b34f8 100644 --- a/js/deck-gl/matrix/views.js +++ b/js/deck-gl/matrix/views.js @@ -78,7 +78,7 @@ export const ini_views = (viz_state) => { id: 'col_attr_labels', x: `${viz_state.viz.row_region + viz_state.viz.label_buffer + viz_state.viz.mat_width}px`, y: '0px', - width: `${viz_state.viz.dendrogram_width + 60}px`, + width: `${viz_state.viz.dendrogram_width + viz_state.viz.col_attr_label_width}px`, height: `${viz_state.viz.col_region}px`, controller: false, }), diff --git a/js/matrix/set_constants.js b/js/matrix/set_constants.js index 71d3de0cf..49232d48e 100644 --- a/js/matrix/set_constants.js +++ b/js/matrix/set_constants.js @@ -178,6 +178,7 @@ export const set_mat_constants = ( viz_state.viz.cat_shift_row = 30; viz_state.viz.label_buffer = 1; + viz_state.viz.col_attr_label_width = 100; viz_state.animate = {}; viz_state.animate.duration = 2500; diff --git a/js/ui/ui_containers.js b/js/ui/ui_containers.js index f57382308..79f575d49 100644 --- a/js/ui/ui_containers.js +++ b/js/ui/ui_containers.js @@ -14,6 +14,7 @@ import { update_path_pickable_state } from '../deck-gl/layers/path_layer'; import { update_trx_pickable_state } from '../deck-gl/layers/trx_layer'; import { update_dendro_layer_data } from '../deck-gl/matrix/dendro_layers'; import { get_mat_layers_list } from '../deck-gl/matrix/matrix_layers'; +import { ini_views } from '../deck-gl/matrix/views'; import { get_layers_list } from '../deck-gl/utils/layers_ist'; import { uniprot_data, @@ -230,6 +231,33 @@ export const make_matrix_ui_container = (deck_mat, layers_mat, viz_state) => { viz_state.dendro.sliders.col.style.marginTop = '3px'; viz_state.dendro.sliders.row.style.marginTop = '10px'; + const col_attr_width_slider = document.createElement('input'); + const min_col_attr_width = 40; + const max_col_attr_width = 220; + const initial_col_attr_width = Math.max( + min_col_attr_width, + Math.min(max_col_attr_width, viz_state.viz.col_attr_label_width || 100) + ); + + ini_slider_params( + col_attr_width_slider, + ((initial_col_attr_width - min_col_attr_width) * 100) / + (max_col_attr_width - min_col_attr_width), + (event) => { + const slider_value = Number(event.target.value) || 0; + const width_value = + min_col_attr_width + + ((max_col_attr_width - min_col_attr_width) * slider_value) / 100; + + viz_state.viz.col_attr_label_width = Math.round(width_value); + ini_views(viz_state); + deck_mat.setProps({ + views: viz_state.views.views_list, + }); + } + ); + col_attr_width_slider.style.marginTop = '10px'; + d3.select(slider_container) .append('div') .text('Dendro') @@ -253,8 +281,28 @@ export const make_matrix_ui_container = (deck_mat, layers_mat, viz_state) => { '-apple-system, BlinkMacSystemFont, "San Francisco", "Helvetica Neue", Helvetica, Arial, sans-serif' ); + d3.select(slider_container) + .append('div') + .text('Attr W') + .style('width', '40px') + .style('height', '14px') + .style('display', 'inline-flex') + .style('align-items', 'center') + .style('justify-content', 'center') + .style('text-align', 'center') + .style('font-size', '9px') + .style('font-weight', 'bold') + .style('color', '#47515b') + .style('border-color', 'white') + .style('margin-left', '10px') + .style( + 'font-family', + '-apple-system, BlinkMacSystemFont, "San Francisco", "Helvetica Neue", Helvetica, Arial, sans-serif' + ); + slider_container.appendChild(viz_state.dendro.sliders.col); slider_container.appendChild(viz_state.dendro.sliders.row); + slider_container.appendChild(col_attr_width_slider); // add top margin to ctrl_container and slider_container ctrl_container.style.marginTop = '10px'; From 794a7599a5b5c8d73560849bdf68482fdd39c5a7 Mon Sep 17 00:00:00 2001 From: Jaspreet Ishar <103258471+jaspreetishar@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:53:48 -0400 Subject: [PATCH 08/11] Add resizable widget boxes and screenshot buttons --- js/ui/ui_containers.js | 24 ++++++++++++++++++++++++ js/utils/screenshot.js | 39 +++++++++++++++++++++++++++++++++++++++ js/viz/landscape_ist.js | 4 ++++ js/viz/matrix_viz.js | 4 ++++ 4 files changed, 71 insertions(+) create mode 100644 js/utils/screenshot.js diff --git a/js/ui/ui_containers.js b/js/ui/ui_containers.js index 79f575d49..72efc2084 100644 --- a/js/ui/ui_containers.js +++ b/js/ui/ui_containers.js @@ -27,6 +27,7 @@ import { } from '../matrix/dendro'; import { debounce } from '../utils/debounce'; import { refresh_layer } from '../utils/refresh_layer'; +import { capture_deck_screenshot } from '../utils/screenshot'; import { make_bar_graph, @@ -77,6 +78,26 @@ export const make_ctrl_container = () => { return ctrl_container; }; +const append_screenshot_button = (container, deck, prefix) => { + const button = document.createElement('button'); + button.textContent = '📷 PNG'; + button.title = 'Download high-resolution screenshot'; + button.style.marginTop = '6px'; + button.style.marginLeft = '8px'; + button.style.height = '22px'; + button.style.fontSize = '11px'; + button.style.cursor = 'pointer'; + button.style.borderRadius = '6px'; + button.style.border = '1px solid #d3d3d3'; + button.style.backgroundColor = '#f8f9fa'; + button.style.color = '#47515b'; + button.style.padding = '0 8px'; + button.addEventListener('click', () => + capture_deck_screenshot(deck, prefix, 3) + ); + container.appendChild(button); +}; + export const flex_container = (class_name, flex_direction, height = null) => { const container = document.createElement('div'); container.className = class_name; @@ -303,6 +324,7 @@ export const make_matrix_ui_container = (deck_mat, layers_mat, viz_state) => { slider_container.appendChild(viz_state.dendro.sliders.col); slider_container.appendChild(viz_state.dendro.sliders.row); slider_container.appendChild(col_attr_width_slider); + append_screenshot_button(slider_container, deck_mat, 'clustergram'); // add top margin to ctrl_container and slider_container ctrl_container.style.marginTop = '10px'; @@ -396,6 +418,7 @@ export const make_sst_ui_container = (deck_sst, layers_sst, viz_state) => { ctrl_container.appendChild(image_container); ctrl_container.appendChild(tile_container); ctrl_container.appendChild(viz_state.genes.gene_search); + append_screenshot_button(ctrl_container, deck_sst, 'landscape'); return ui_container; }; @@ -970,6 +993,7 @@ export const make_ist_ui_container = ( } ctrl_container.appendChild(cell_container); ctrl_container.appendChild(gene_container); + append_screenshot_button(ctrl_container, deck_ist, 'landscape'); viz_state.genes.gene_search.style.width = '160px'; viz_state.genes.gene_search.style.marginLeft = '5px'; diff --git a/js/utils/screenshot.js b/js/utils/screenshot.js new file mode 100644 index 000000000..6ca7f37e4 --- /dev/null +++ b/js/utils/screenshot.js @@ -0,0 +1,39 @@ +export const capture_deck_screenshot = ( + deck, + file_prefix = 'celldega', + scale = 3 +) => { + const canvas = deck?.canvas; + if (!canvas) { + return; + } + + const safe_scale = Math.max(1, Number(scale) || 1); + const export_canvas = document.createElement('canvas'); + export_canvas.width = Math.max(1, Math.floor(canvas.width * safe_scale)); + export_canvas.height = Math.max(1, Math.floor(canvas.height * safe_scale)); + + const ctx = export_canvas.getContext('2d'); + if (!ctx) { + return; + } + + ctx.setTransform(safe_scale, 0, 0, safe_scale, 0, 0); + ctx.drawImage(canvas, 0, 0); + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `${file_prefix}-${timestamp}.png`; + + export_canvas.toBlob((blob) => { + if (!blob) { + return; + } + + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }, 'image/png'); +}; diff --git a/js/viz/landscape_ist.js b/js/viz/landscape_ist.js index 6d6933e56..8ee9a1ea0 100644 --- a/js/viz/landscape_ist.js +++ b/js/viz/landscape_ist.js @@ -452,6 +452,10 @@ export const landscape_ist = async ( const root = document.createElement('div'); root.style.position = 'relative'; root.style.height = `${height}px`; + root.style.resize = 'both'; + root.style.overflow = 'auto'; + root.style.minWidth = '500px'; + root.style.minHeight = '400px'; root.style.border = '1px solid #d3d3d3'; const userMicronsPerPixel = diff --git a/js/viz/matrix_viz.js b/js/viz/matrix_viz.js index e1efc9217..4a8ba5cf0 100644 --- a/js/viz/matrix_viz.js +++ b/js/viz/matrix_viz.js @@ -81,6 +81,10 @@ export const matrix_viz = async ( ) => { const root = document.createElement('div'); root.style.border = '1px solid #d3d3d3'; + root.style.resize = 'both'; + root.style.overflow = 'auto'; + root.style.minWidth = '500px'; + root.style.minHeight = '500px'; const deck_mat = ini_deck(root, width, height); const row_entity = model.get('row_entity'); From 476628cd52040f9812cd22873f7d9a39eb10eee9 Mon Sep 17 00:00:00 2001 From: Jaspreet Ishar <103258471+jaspreetishar@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:02:08 -0400 Subject: [PATCH 09/11] Fix screenshot export and add explicit widget size controls --- js/deck-gl/core/deck_ist.js | 3 ++ js/deck-gl/matrix/deck_mat.js | 3 ++ js/ui/ui_containers.js | 62 +++++++++++++++++++++++++++++++++++ js/utils/screenshot.js | 35 +++++++++++--------- 4 files changed, 88 insertions(+), 15 deletions(-) diff --git a/js/deck-gl/core/deck_ist.js b/js/deck-gl/core/deck_ist.js index ad30f31ee..084a6bb04 100644 --- a/js/deck-gl/core/deck_ist.js +++ b/js/deck-gl/core/deck_ist.js @@ -23,6 +23,9 @@ export const ini_deck = (root, width, height, technology = '') => { getCursor, width, height, + glOptions: { + preserveDrawingBuffer: true, + }, }); return deck_ist; diff --git a/js/deck-gl/matrix/deck_mat.js b/js/deck-gl/matrix/deck_mat.js index 596f39bdd..a3ef4d37e 100644 --- a/js/deck-gl/matrix/deck_mat.js +++ b/js/deck-gl/matrix/deck_mat.js @@ -20,6 +20,9 @@ export const ini_deck = (root, width, height) => { getCursor, width, height, + glOptions: { + preserveDrawingBuffer: true, + }, }); return deck_ist; diff --git a/js/ui/ui_containers.js b/js/ui/ui_containers.js index 72efc2084..015dea869 100644 --- a/js/ui/ui_containers.js +++ b/js/ui/ui_containers.js @@ -98,6 +98,65 @@ const append_screenshot_button = (container, deck, prefix) => { container.appendChild(button); }; +const append_size_controls = (container, deck, viz_state) => { + if (!viz_state?.root) { + return; + } + + const controls = document.createElement('div'); + controls.style.display = 'flex'; + controls.style.flexDirection = 'column'; + controls.style.marginLeft = '8px'; + controls.style.marginTop = '4px'; + controls.style.gap = '4px'; + + const width_slider = document.createElement('input'); + const height_slider = document.createElement('input'); + + const current_width = Math.round(viz_state.root.clientWidth || 900); + const current_height = Math.round(viz_state.root.clientHeight || 700); + + const setup_slider = (slider, initial_value) => { + ini_slider_params(slider, initial_value, () => {}); + slider.min = '300'; + slider.max = '2000'; + slider.value = String(initial_value); + slider.style.width = '85px'; + }; + + setup_slider(width_slider, current_width); + setup_slider(height_slider, current_height); + + const apply_size = () => { + const new_width = Number(width_slider.value) || current_width; + const new_height = Number(height_slider.value) || current_height; + viz_state.root.style.width = `${new_width}px`; + viz_state.root.style.height = `${new_height}px`; + deck.setProps({ + width: new_width, + height: new_height, + }); + }; + + width_slider.addEventListener('input', apply_size); + height_slider.addEventListener('input', apply_size); + + const w_label = document.createElement('div'); + w_label.textContent = 'W'; + w_label.style.fontSize = '10px'; + w_label.style.color = '#47515b'; + const h_label = document.createElement('div'); + h_label.textContent = 'H'; + h_label.style.fontSize = '10px'; + h_label.style.color = '#47515b'; + + controls.appendChild(w_label); + controls.appendChild(width_slider); + controls.appendChild(h_label); + controls.appendChild(height_slider); + container.appendChild(controls); +}; + export const flex_container = (class_name, flex_direction, height = null) => { const container = document.createElement('div'); container.className = class_name; @@ -324,6 +383,7 @@ export const make_matrix_ui_container = (deck_mat, layers_mat, viz_state) => { slider_container.appendChild(viz_state.dendro.sliders.col); slider_container.appendChild(viz_state.dendro.sliders.row); slider_container.appendChild(col_attr_width_slider); + append_size_controls(slider_container, deck_mat, viz_state); append_screenshot_button(slider_container, deck_mat, 'clustergram'); // add top margin to ctrl_container and slider_container @@ -418,6 +478,7 @@ export const make_sst_ui_container = (deck_sst, layers_sst, viz_state) => { ctrl_container.appendChild(image_container); ctrl_container.appendChild(tile_container); ctrl_container.appendChild(viz_state.genes.gene_search); + append_size_controls(ctrl_container, deck_sst, viz_state); append_screenshot_button(ctrl_container, deck_sst, 'landscape'); return ui_container; @@ -993,6 +1054,7 @@ export const make_ist_ui_container = ( } ctrl_container.appendChild(cell_container); ctrl_container.appendChild(gene_container); + append_size_controls(ctrl_container, deck_ist, viz_state); append_screenshot_button(ctrl_container, deck_ist, 'landscape'); viz_state.genes.gene_search.style.width = '160px'; diff --git a/js/utils/screenshot.js b/js/utils/screenshot.js index 6ca7f37e4..ac4bfa48c 100644 --- a/js/utils/screenshot.js +++ b/js/utils/screenshot.js @@ -18,22 +18,27 @@ export const capture_deck_screenshot = ( return; } - ctx.setTransform(safe_scale, 0, 0, safe_scale, 0, 0); - ctx.drawImage(canvas, 0, 0); + deck.redraw(true); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + ctx.setTransform(safe_scale, 0, 0, safe_scale, 0, 0); + ctx.drawImage(canvas, 0, 0); - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const filename = `${file_prefix}-${timestamp}.png`; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `${file_prefix}-${timestamp}.png`; - export_canvas.toBlob((blob) => { - if (!blob) { - return; - } + export_canvas.toBlob((blob) => { + if (!blob) { + return; + } - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = filename; - link.click(); - setTimeout(() => URL.revokeObjectURL(url), 1000); - }, 'image/png'); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }, 'image/png'); + }); + }); }; From c6bb7efe398f3c6c58ba1ebc68448ba637e4d43c Mon Sep 17 00:00:00 2001 From: Jaspreet Ishar <103258471+jaspreetishar@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:11:47 -0400 Subject: [PATCH 10/11] Add row label width slider for clustergram --- js/ui/ui_containers.js | 50 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/js/ui/ui_containers.js b/js/ui/ui_containers.js index 015dea869..e0d676388 100644 --- a/js/ui/ui_containers.js +++ b/js/ui/ui_containers.js @@ -312,12 +312,19 @@ export const make_matrix_ui_container = (deck_mat, layers_mat, viz_state) => { viz_state.dendro.sliders.row.style.marginTop = '10px'; const col_attr_width_slider = document.createElement('input'); + const row_label_width_slider = document.createElement('input'); const min_col_attr_width = 40; const max_col_attr_width = 220; + const min_row_label_width = 60; + const max_row_label_width = 320; const initial_col_attr_width = Math.max( min_col_attr_width, Math.min(max_col_attr_width, viz_state.viz.col_attr_label_width || 100) ); + const initial_row_label_width = Math.max( + min_row_label_width, + Math.min(max_row_label_width, viz_state.viz.row_label || 75) + ); ini_slider_params( col_attr_width_slider, @@ -338,6 +345,29 @@ export const make_matrix_ui_container = (deck_mat, layers_mat, viz_state) => { ); col_attr_width_slider.style.marginTop = '10px'; + ini_slider_params( + row_label_width_slider, + ((initial_row_label_width - min_row_label_width) * 100) / + (max_row_label_width - min_row_label_width), + (event) => { + const slider_value = Number(event.target.value) || 0; + const width_value = + min_row_label_width + + ((max_row_label_width - min_row_label_width) * slider_value) / 100; + + viz_state.viz.row_label = Math.round(width_value); + viz_state.viz.row_region = + (viz_state.viz.row_cat_width + viz_state.viz.extra_space.row) * + viz_state.attr.num.row + + viz_state.viz.row_label; + ini_views(viz_state); + deck_mat.setProps({ + views: viz_state.views.views_list, + }); + } + ); + row_label_width_slider.style.marginTop = '6px'; + d3.select(slider_container) .append('div') .text('Dendro') @@ -380,9 +410,29 @@ export const make_matrix_ui_container = (deck_mat, layers_mat, viz_state) => { '-apple-system, BlinkMacSystemFont, "San Francisco", "Helvetica Neue", Helvetica, Arial, sans-serif' ); + d3.select(slider_container) + .append('div') + .text('Row W') + .style('width', '40px') + .style('height', '14px') + .style('display', 'inline-flex') + .style('align-items', 'center') + .style('justify-content', 'center') + .style('text-align', 'center') + .style('font-size', '9px') + .style('font-weight', 'bold') + .style('color', '#47515b') + .style('border-color', 'white') + .style('margin-left', '10px') + .style( + 'font-family', + '-apple-system, BlinkMacSystemFont, "San Francisco", "Helvetica Neue", Helvetica, Arial, sans-serif' + ); + slider_container.appendChild(viz_state.dendro.sliders.col); slider_container.appendChild(viz_state.dendro.sliders.row); slider_container.appendChild(col_attr_width_slider); + slider_container.appendChild(row_label_width_slider); append_size_controls(slider_container, deck_mat, viz_state); append_screenshot_button(slider_container, deck_mat, 'clustergram'); From 8cb5539bc97b793cc40fa32b819fb829e84498c6 Mon Sep 17 00:00:00 2001 From: Jaspreet Ishar <103258471+jaspreetishar@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:29:12 -0400 Subject: [PATCH 11/11] Simplify clustergram controls to row width slider only --- js/ui/ui_containers.js | 48 ------------------------------------------ js/viz/matrix_viz.js | 4 ---- 2 files changed, 52 deletions(-) diff --git a/js/ui/ui_containers.js b/js/ui/ui_containers.js index e0d676388..f5f267336 100644 --- a/js/ui/ui_containers.js +++ b/js/ui/ui_containers.js @@ -311,40 +311,14 @@ export const make_matrix_ui_container = (deck_mat, layers_mat, viz_state) => { viz_state.dendro.sliders.col.style.marginTop = '3px'; viz_state.dendro.sliders.row.style.marginTop = '10px'; - const col_attr_width_slider = document.createElement('input'); const row_label_width_slider = document.createElement('input'); - const min_col_attr_width = 40; - const max_col_attr_width = 220; const min_row_label_width = 60; const max_row_label_width = 320; - const initial_col_attr_width = Math.max( - min_col_attr_width, - Math.min(max_col_attr_width, viz_state.viz.col_attr_label_width || 100) - ); const initial_row_label_width = Math.max( min_row_label_width, Math.min(max_row_label_width, viz_state.viz.row_label || 75) ); - ini_slider_params( - col_attr_width_slider, - ((initial_col_attr_width - min_col_attr_width) * 100) / - (max_col_attr_width - min_col_attr_width), - (event) => { - const slider_value = Number(event.target.value) || 0; - const width_value = - min_col_attr_width + - ((max_col_attr_width - min_col_attr_width) * slider_value) / 100; - - viz_state.viz.col_attr_label_width = Math.round(width_value); - ini_views(viz_state); - deck_mat.setProps({ - views: viz_state.views.views_list, - }); - } - ); - col_attr_width_slider.style.marginTop = '10px'; - ini_slider_params( row_label_width_slider, ((initial_row_label_width - min_row_label_width) * 100) / @@ -391,25 +365,6 @@ export const make_matrix_ui_container = (deck_mat, layers_mat, viz_state) => { '-apple-system, BlinkMacSystemFont, "San Francisco", "Helvetica Neue", Helvetica, Arial, sans-serif' ); - d3.select(slider_container) - .append('div') - .text('Attr W') - .style('width', '40px') - .style('height', '14px') - .style('display', 'inline-flex') - .style('align-items', 'center') - .style('justify-content', 'center') - .style('text-align', 'center') - .style('font-size', '9px') - .style('font-weight', 'bold') - .style('color', '#47515b') - .style('border-color', 'white') - .style('margin-left', '10px') - .style( - 'font-family', - '-apple-system, BlinkMacSystemFont, "San Francisco", "Helvetica Neue", Helvetica, Arial, sans-serif' - ); - d3.select(slider_container) .append('div') .text('Row W') @@ -431,10 +386,7 @@ export const make_matrix_ui_container = (deck_mat, layers_mat, viz_state) => { slider_container.appendChild(viz_state.dendro.sliders.col); slider_container.appendChild(viz_state.dendro.sliders.row); - slider_container.appendChild(col_attr_width_slider); slider_container.appendChild(row_label_width_slider); - append_size_controls(slider_container, deck_mat, viz_state); - append_screenshot_button(slider_container, deck_mat, 'clustergram'); // add top margin to ctrl_container and slider_container ctrl_container.style.marginTop = '10px'; diff --git a/js/viz/matrix_viz.js b/js/viz/matrix_viz.js index 4a8ba5cf0..e1efc9217 100644 --- a/js/viz/matrix_viz.js +++ b/js/viz/matrix_viz.js @@ -81,10 +81,6 @@ export const matrix_viz = async ( ) => { const root = document.createElement('div'); root.style.border = '1px solid #d3d3d3'; - root.style.resize = 'both'; - root.style.overflow = 'auto'; - root.style.minWidth = '500px'; - root.style.minHeight = '500px'; const deck_mat = ini_deck(root, width, height); const row_entity = model.get('row_entity');