From c89ba36199959fdc94c42fc1a2cc48833f2d96aa Mon Sep 17 00:00:00 2001 From: Nicolas Fernandez Date: Wed, 12 Nov 2025 12:17:46 -0500 Subject: [PATCH 1/3] Add animated dendrogram double-click zoom focus --- js/deck-gl/matrix/dendro_layers.js | 141 ++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 1 deletion(-) diff --git a/js/deck-gl/matrix/dendro_layers.js b/js/deck-gl/matrix/dendro_layers.js index cc01e1815..df73ff726 100644 --- a/js/deck-gl/matrix/dendro_layers.js +++ b/js/deck-gl/matrix/dendro_layers.js @@ -1,12 +1,125 @@ -import { PolygonLayer } from 'deck.gl'; +import { LinearInterpolator, PolygonLayer } from 'deck.gl'; import { sync_selected_genes } from '../../global_variables/selected_genes'; +import { curate_pan_x, curate_pan_y } from './curate_pan'; import { get_mat_layers_list } from './matrix_layers'; +import { redefine_global_view_state } from './redefine_global_view_state'; +import { update_zoom_data } from './zoom'; const DENDRO_AXES = ['row', 'col']; const DEFAULT_FILL_COLOR = [0, 0, 0, 90]; const FOCUSED_FILL_COLOR = [0, 0, 0, 180]; +const DOUBLE_CLICK_DELAY = 250; +const DENDRO_FOCUS_TRANSITION = 650; +const MAX_FOCUS_ZOOM = 7; + +const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3); + +const ensureClickTracking = (viz_state) => { + if (!viz_state.dendro.clicks) { + viz_state.dendro.clicks = { row: 0, col: 0 }; + } + + if (!viz_state.dendro.clickTimeouts) { + viz_state.dendro.clickTimeouts = { row: null, col: null }; + } +}; + +const applyViewStateTransition = (viewState) => { + const transitionProps = () => ({ + transitionDuration: DENDRO_FOCUS_TRANSITION, + transitionEasing: easeOutCubic, + transitionInterpolator: new LinearInterpolator(['target', 'zoom']), + }); + + return Object.fromEntries( + Object.entries(viewState).map(([key, value]) => [ + key, + { ...value, ...transitionProps() }, + ]) + ); +}; + +const computeClusterZoom = (matrixSpan, clusterSpan, minUnit) => { + const paddedSpan = Math.min( + matrixSpan, + Math.max(clusterSpan * 1.2, minUnit * 3) + ); + + const zoom = Math.log2(matrixSpan / Math.max(paddedSpan, 1e-6)); + + return Math.min(Math.max(zoom, 0), MAX_FOCUS_ZOOM); +}; + +const animate_focus_to_cluster = ( + deck_mat, + layers_mat, + viz_state, + axis, + polygonProps +) => { + if (!polygonProps) { + return; + } + + const isRowAxis = axis === 'row'; + const matrixSpan = isRowAxis + ? viz_state.viz.mat_height + : viz_state.viz.mat_width; + const minUnit = isRowAxis + ? viz_state.viz.row_offset + : viz_state.viz.col_offset; + + const currentZoomX = viz_state.zoom.zoom_data.matrix.zoom_x; + const currentZoomY = viz_state.zoom.zoom_data.matrix.zoom_y; + + const clusterSpan = Math.max( + Math.abs((polygonProps.pos_bot ?? 0) - (polygonProps.pos_top ?? 0)), + minUnit + ); + + const targetZoom = computeClusterZoom(matrixSpan, clusterSpan, minUnit); + + const zoom_curated = [ + isRowAxis ? currentZoomX : targetZoom, + isRowAxis ? targetZoom : currentZoomY, + ]; + + const clusterCenter = + ((polygonProps.pos_top ?? 0) + (polygonProps.pos_bot ?? 0)) / 2; + + const existingPan = viz_state.zoom.zoom_data.matrix; + + const desiredPan = { + x: isRowAxis ? existingPan.pan_x : clusterCenter, + y: isRowAxis ? clusterCenter : existingPan.pan_y, + }; + + const pan_curated = [ + curate_pan_x(desiredPan.x, zoom_curated[0], viz_state), + curate_pan_y(desiredPan.y, zoom_curated[1], viz_state), + ]; + + const viewId = isRowAxis ? 'dendro_rows' : 'dendro_cols'; + + const globalViewState = redefine_global_view_state( + viz_state, + viewId, + zoom_curated, + pan_curated + ); + + update_zoom_data(viz_state, viewId, zoom_curated, pan_curated); + + viz_state.zoom.zoom_data.total_zoom.x = zoom_curated[0]; + viz_state.zoom.zoom_data.total_zoom.y = zoom_curated[1]; + + deck_mat.setProps({ + viewState: applyViewStateTransition(globalViewState), + layers: get_mat_layers_list(layers_mat), + }); +}; const getCurrentFocus = (viz_state) => { const storeFocus = @@ -151,6 +264,10 @@ const focus_dendro_polygon = ( }; const dendro_layer_onclick = (event, deck_mat, layers_mat, viz_state, axis) => { + ensureClickTracking(viz_state); + + viz_state.dendro.clicks[axis] += 1; + viz_state.click.type = `${axis}_dendro`; viz_state.click.value = { @@ -181,6 +298,28 @@ const dendro_layer_onclick = (event, deck_mat, layers_mat, viz_state, axis) => { event.object.properties.all_names ); } + + if (viz_state.dendro.clicks[axis] === 1) { + viz_state.dendro.clickTimeouts[axis] = setTimeout(() => { + viz_state.dendro.clicks[axis] = 0; + viz_state.dendro.clickTimeouts[axis] = null; + }, DOUBLE_CLICK_DELAY); + } else if (viz_state.dendro.clicks[axis] === 2) { + if (viz_state.dendro.clickTimeouts[axis]) { + clearTimeout(viz_state.dendro.clickTimeouts[axis]); + viz_state.dendro.clickTimeouts[axis] = null; + } + + viz_state.dendro.clicks[axis] = 0; + + animate_focus_to_cluster( + deck_mat, + layers_mat, + viz_state, + axis, + event.object.properties + ); + } }; export const set_dendro_layer_onclick = ( From ea5e8add675af0b6c5dfe2d199babc216e79d54e Mon Sep 17 00:00:00 2001 From: Nicolas Fernandez Date: Thu, 13 Nov 2025 11:48:21 -0500 Subject: [PATCH 2/3] Tighten dendrogram focus zoom --- js/deck-gl/matrix/dendro_layers.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/js/deck-gl/matrix/dendro_layers.js b/js/deck-gl/matrix/dendro_layers.js index df73ff726..ffd1da82d 100644 --- a/js/deck-gl/matrix/dendro_layers.js +++ b/js/deck-gl/matrix/dendro_layers.js @@ -42,12 +42,9 @@ const applyViewStateTransition = (viewState) => { }; const computeClusterZoom = (matrixSpan, clusterSpan, minUnit) => { - const paddedSpan = Math.min( - matrixSpan, - Math.max(clusterSpan * 1.2, minUnit * 3) - ); + const effectiveSpan = Math.min(matrixSpan, Math.max(clusterSpan, minUnit)); - const zoom = Math.log2(matrixSpan / Math.max(paddedSpan, 1e-6)); + const zoom = Math.log2(matrixSpan / Math.max(effectiveSpan, 1e-6)); return Math.min(Math.max(zoom, 0), MAX_FOCUS_ZOOM); }; From fbdfd581c53a82f10078231df038d88be71f105c Mon Sep 17 00:00:00 2001 From: Nicolas Fernandez Date: Sat, 15 Nov 2025 15:00:26 -0500 Subject: [PATCH 3/3] Improve dendrogram double click focus behavior --- js/deck-gl/matrix/dendro_layers.js | 226 +++++++++++++++++++---------- 1 file changed, 152 insertions(+), 74 deletions(-) diff --git a/js/deck-gl/matrix/dendro_layers.js b/js/deck-gl/matrix/dendro_layers.js index ffd1da82d..ffec5754e 100644 --- a/js/deck-gl/matrix/dendro_layers.js +++ b/js/deck-gl/matrix/dendro_layers.js @@ -14,37 +14,41 @@ const DOUBLE_CLICK_DELAY = 250; const DENDRO_FOCUS_TRANSITION = 650; const MAX_FOCUS_ZOOM = 7; -const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3); +const ease_out_cubic = (t) => 1 - Math.pow(1 - t, 3); -const ensureClickTracking = (viz_state) => { +const ensure_click_tracking = (viz_state) => { if (!viz_state.dendro.clicks) { viz_state.dendro.clicks = { row: 0, col: 0 }; } - if (!viz_state.dendro.clickTimeouts) { - viz_state.dendro.clickTimeouts = { row: null, col: null }; + if (!viz_state.dendro.click_timeouts) { + viz_state.dendro.click_timeouts = { row: null, col: null }; + } + + if (!viz_state.dendro.double_click_state) { + viz_state.dendro.double_click_state = { row: null, col: null }; } }; -const applyViewStateTransition = (viewState) => { - const transitionProps = () => ({ +const apply_view_state_transition = (view_state) => { + const transition_props = () => ({ transitionDuration: DENDRO_FOCUS_TRANSITION, - transitionEasing: easeOutCubic, + transitionEasing: ease_out_cubic, transitionInterpolator: new LinearInterpolator(['target', 'zoom']), }); return Object.fromEntries( - Object.entries(viewState).map(([key, value]) => [ + Object.entries(view_state).map(([key, value]) => [ key, - { ...value, ...transitionProps() }, + { ...value, ...transition_props() }, ]) ); }; -const computeClusterZoom = (matrixSpan, clusterSpan, minUnit) => { - const effectiveSpan = Math.min(matrixSpan, Math.max(clusterSpan, minUnit)); +const compute_cluster_zoom = (matrix_span, cluster_span, min_unit) => { + const effective_span = Math.min(matrix_span, Math.max(cluster_span, min_unit)); - const zoom = Math.log2(matrixSpan / Math.max(effectiveSpan, 1e-6)); + const zoom = Math.log2(matrix_span / Math.max(effective_span, 1e-6)); return Math.min(Math.max(zoom, 0), MAX_FOCUS_ZOOM); }; @@ -60,76 +64,100 @@ const animate_focus_to_cluster = ( return; } - const isRowAxis = axis === 'row'; - const matrixSpan = isRowAxis + const is_row_axis = axis === 'row'; + const matrix_span = is_row_axis ? viz_state.viz.mat_height : viz_state.viz.mat_width; - const minUnit = isRowAxis + const min_unit = is_row_axis ? viz_state.viz.row_offset : viz_state.viz.col_offset; - const currentZoomX = viz_state.zoom.zoom_data.matrix.zoom_x; - const currentZoomY = viz_state.zoom.zoom_data.matrix.zoom_y; + const current_zoom_x = viz_state.zoom.zoom_data.matrix.zoom_x; + const current_zoom_y = viz_state.zoom.zoom_data.matrix.zoom_y; - const clusterSpan = Math.max( + const cluster_span = Math.max( Math.abs((polygonProps.pos_bot ?? 0) - (polygonProps.pos_top ?? 0)), - minUnit + min_unit ); - const targetZoom = computeClusterZoom(matrixSpan, clusterSpan, minUnit); + const target_zoom = compute_cluster_zoom(matrix_span, cluster_span, min_unit); const zoom_curated = [ - isRowAxis ? currentZoomX : targetZoom, - isRowAxis ? targetZoom : currentZoomY, + is_row_axis ? current_zoom_x : target_zoom, + is_row_axis ? target_zoom : current_zoom_y, ]; - const clusterCenter = + const cluster_center = ((polygonProps.pos_top ?? 0) + (polygonProps.pos_bot ?? 0)) / 2; - const existingPan = viz_state.zoom.zoom_data.matrix; + const existing_pan = viz_state.zoom.zoom_data.matrix; - const desiredPan = { - x: isRowAxis ? existingPan.pan_x : clusterCenter, - y: isRowAxis ? clusterCenter : existingPan.pan_y, + const desired_pan = { + x: is_row_axis ? existing_pan.pan_x : cluster_center, + y: is_row_axis ? cluster_center : existing_pan.pan_y, }; const pan_curated = [ - curate_pan_x(desiredPan.x, zoom_curated[0], viz_state), - curate_pan_y(desiredPan.y, zoom_curated[1], viz_state), + curate_pan_x(desired_pan.x, zoom_curated[0], viz_state), + curate_pan_y(desired_pan.y, zoom_curated[1], viz_state), ]; - const viewId = isRowAxis ? 'dendro_rows' : 'dendro_cols'; + const view_id = is_row_axis ? 'dendro_rows' : 'dendro_cols'; - const globalViewState = redefine_global_view_state( + const global_view_state = redefine_global_view_state( viz_state, - viewId, + view_id, zoom_curated, pan_curated ); - update_zoom_data(viz_state, viewId, zoom_curated, pan_curated); + update_zoom_data(viz_state, view_id, zoom_curated, pan_curated); viz_state.zoom.zoom_data.total_zoom.x = zoom_curated[0]; viz_state.zoom.zoom_data.total_zoom.y = zoom_curated[1]; deck_mat.setProps({ - viewState: applyViewStateTransition(globalViewState), + viewState: apply_view_state_transition(global_view_state), layers: get_mat_layers_list(layers_mat), }); }; -const getCurrentFocus = (viz_state) => { - const storeFocus = +const animate_focus_reset = (deck_mat, layers_mat, viz_state) => { + const default_zoom = [viz_state.zoom.ini_zoom_x, viz_state.zoom.ini_zoom_y]; + const default_pan = [viz_state.zoom.ini_pan_x, viz_state.zoom.ini_pan_y]; + + const global_view_state = redefine_global_view_state( + viz_state, + 'matrix', + default_zoom, + default_pan + ); + + update_zoom_data(viz_state, 'matrix', default_zoom, default_pan); + + viz_state.zoom.zoom_data.total_zoom.x = default_zoom[0]; + viz_state.zoom.zoom_data.total_zoom.y = default_zoom[1]; + + deck_mat.setProps({ + viewState: apply_view_state_transition(global_view_state), + layers: get_mat_layers_list(layers_mat), + }); +}; + +const get_current_focus = (viz_state) => { + const store_focus = viz_state?.obs_store?.focused_dendro && typeof viz_state.obs_store.focused_dendro.get === 'function' ? viz_state.obs_store.focused_dendro.get() : null; - return storeFocus ?? viz_state.dendro?.active_polygon ?? null; + return store_focus ?? viz_state.dendro?.active_polygon ?? null; }; -const applyDendroFocus = (deck_mat, layers_mat, viz_state, focus) => { - const normalizedFocus = focus ? { axis: focus.axis, name: focus.name } : null; +const apply_dendro_focus = (deck_mat, layers_mat, viz_state, focus) => { + const normalized_focus = focus + ? { axis: focus.axis, name: focus.name } + : null; let didUpdate = false; @@ -140,12 +168,12 @@ const applyDendroFocus = (deck_mat, layers_mat, viz_state, focus) => { const updatedPolygons = viz_state.dendro.polygons[targetAxis].map( (polygon) => { - const isFocused = - !!normalizedFocus && - polygon.properties.axis === normalizedFocus.axis && - polygon.properties.name === normalizedFocus.name; + const is_focused = + !!normalized_focus && + polygon.properties.axis === normalized_focus.axis && + polygon.properties.name === normalized_focus.name; - if (polygon.properties.is_focused === isFocused) { + if (polygon.properties.is_focused === is_focused) { return polygon; } @@ -155,7 +183,7 @@ const applyDendroFocus = (deck_mat, layers_mat, viz_state, focus) => { ...polygon, properties: { ...polygon.properties, - is_focused: isFocused, + is_focused, }, }; } @@ -172,11 +200,11 @@ const applyDendroFocus = (deck_mat, layers_mat, viz_state, focus) => { } }); - viz_state.dendro.active_polygon = normalizedFocus; + viz_state.dendro.active_polygon = normalized_focus; if (viz_state.obs_store?.focused_dendro) { - const focusValue = normalizedFocus ? { ...normalizedFocus } : null; - viz_state.obs_store.focused_dendro.set(focusValue); + const focus_value = normalized_focus ? { ...normalized_focus } : null; + viz_state.obs_store.focused_dendro.set(focus_value); } if (didUpdate && typeof deck_mat?.setProps === 'function') { @@ -241,45 +269,56 @@ const focus_dendro_polygon = ( layers_mat, viz_state, axis, - polygonName + polygon_name, + options = {} ) => { - const previousFocus = getCurrentFocus(viz_state); + const { allow_toggle = true } = options; + + const previous_focus = get_current_focus(viz_state); if ( - previousFocus && - previousFocus.axis === axis && - previousFocus.name === polygonName + allow_toggle && + previous_focus && + previous_focus.axis === axis && + previous_focus.name === polygon_name ) { - applyDendroFocus(deck_mat, layers_mat, viz_state, null); - return; + apply_dendro_focus(deck_mat, layers_mat, viz_state, null); + return false; } - applyDendroFocus(deck_mat, layers_mat, viz_state, { + apply_dendro_focus(deck_mat, layers_mat, viz_state, { axis, - name: polygonName, + name: polygon_name, }); + + return true; }; -const dendro_layer_onclick = (event, deck_mat, layers_mat, viz_state, axis) => { - ensureClickTracking(viz_state); +const dendro_layer_on_click = ( + event, + deck_mat, + layers_mat, + viz_state, + axis +) => { + ensure_click_tracking(viz_state); + + const clicked_name = event.object.properties.name; + const current_focus = get_current_focus(viz_state); + const was_active_before_click = + current_focus && + current_focus.axis === axis && + current_focus.name === clicked_name; viz_state.dendro.clicks[axis] += 1; viz_state.click.type = `${axis}_dendro`; viz_state.click.value = { - name: event.object.properties.name, + name: clicked_name, selected_names: event.object.properties.all_names, }; - focus_dendro_polygon( - deck_mat, - layers_mat, - viz_state, - axis, - event.object.properties.name - ); - if (Object.keys(viz_state.model).length > 0) { viz_state.model.set('click_info', null); viz_state.model.set('click_info', viz_state.click); @@ -296,19 +335,58 @@ const dendro_layer_onclick = (event, deck_mat, layers_mat, viz_state, axis) => { ); } + const double_click_state = { + name: clicked_name, + was_active_before_click, + }; + if (viz_state.dendro.clicks[axis] === 1) { - viz_state.dendro.clickTimeouts[axis] = setTimeout(() => { + viz_state.dendro.double_click_state[axis] = double_click_state; + + focus_dendro_polygon( + deck_mat, + layers_mat, + viz_state, + axis, + clicked_name + ); + + viz_state.dendro.click_timeouts[axis] = setTimeout(() => { viz_state.dendro.clicks[axis] = 0; - viz_state.dendro.clickTimeouts[axis] = null; + viz_state.dendro.click_timeouts[axis] = null; + viz_state.dendro.double_click_state[axis] = null; }, DOUBLE_CLICK_DELAY); } else if (viz_state.dendro.clicks[axis] === 2) { - if (viz_state.dendro.clickTimeouts[axis]) { - clearTimeout(viz_state.dendro.clickTimeouts[axis]); - viz_state.dendro.clickTimeouts[axis] = null; + if (viz_state.dendro.click_timeouts[axis]) { + clearTimeout(viz_state.dendro.click_timeouts[axis]); + viz_state.dendro.click_timeouts[axis] = null; } viz_state.dendro.clicks[axis] = 0; + const prior_state = viz_state.dendro.double_click_state[axis]; + viz_state.dendro.double_click_state[axis] = null; + + const initiated_from_active = + prior_state && + prior_state.name === clicked_name && + prior_state.was_active_before_click; + + if (initiated_from_active) { + apply_dendro_focus(deck_mat, layers_mat, viz_state, null); + animate_focus_reset(deck_mat, layers_mat, viz_state); + return; + } + + focus_dendro_polygon( + deck_mat, + layers_mat, + viz_state, + axis, + clicked_name, + { allow_toggle: false } + ); + animate_focus_to_cluster( deck_mat, layers_mat, @@ -328,7 +406,7 @@ export const set_dendro_layer_onclick = ( layers_mat[`${axis}_dendro_layer`] = layers_mat[`${axis}_dendro_layer`].clone( { onClick: (event) => - dendro_layer_onclick(event, deck_mat, layers_mat, viz_state, axis), + dendro_layer_on_click(event, deck_mat, layers_mat, viz_state, axis), } ); };