diff --git a/js/deck-gl/matrix/dendro_layers.js b/js/deck-gl/matrix/dendro_layers.js index cc01e1815..ffec5754e 100644 --- a/js/deck-gl/matrix/dendro_layers.js +++ b/js/deck-gl/matrix/dendro_layers.js @@ -1,25 +1,163 @@ -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 getCurrentFocus = (viz_state) => { - const storeFocus = +const ease_out_cubic = (t) => 1 - Math.pow(1 - t, 3); + +const ensure_click_tracking = (viz_state) => { + if (!viz_state.dendro.clicks) { + viz_state.dendro.clicks = { row: 0, col: 0 }; + } + + 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 apply_view_state_transition = (view_state) => { + const transition_props = () => ({ + transitionDuration: DENDRO_FOCUS_TRANSITION, + transitionEasing: ease_out_cubic, + transitionInterpolator: new LinearInterpolator(['target', 'zoom']), + }); + + return Object.fromEntries( + Object.entries(view_state).map(([key, value]) => [ + key, + { ...value, ...transition_props() }, + ]) + ); +}; + +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(matrix_span / Math.max(effective_span, 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 is_row_axis = axis === 'row'; + const matrix_span = is_row_axis + ? viz_state.viz.mat_height + : viz_state.viz.mat_width; + const min_unit = is_row_axis + ? viz_state.viz.row_offset + : viz_state.viz.col_offset; + + 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 cluster_span = Math.max( + Math.abs((polygonProps.pos_bot ?? 0) - (polygonProps.pos_top ?? 0)), + min_unit + ); + + const target_zoom = compute_cluster_zoom(matrix_span, cluster_span, min_unit); + + const zoom_curated = [ + is_row_axis ? current_zoom_x : target_zoom, + is_row_axis ? target_zoom : current_zoom_y, + ]; + + const cluster_center = + ((polygonProps.pos_top ?? 0) + (polygonProps.pos_bot ?? 0)) / 2; + + const existing_pan = viz_state.zoom.zoom_data.matrix; + + 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(desired_pan.x, zoom_curated[0], viz_state), + curate_pan_y(desired_pan.y, zoom_curated[1], viz_state), + ]; + + const view_id = is_row_axis ? 'dendro_rows' : 'dendro_cols'; + + const global_view_state = redefine_global_view_state( + viz_state, + view_id, + 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: apply_view_state_transition(global_view_state), + layers: get_mat_layers_list(layers_mat), + }); +}; + +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; @@ -30,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; } @@ -45,7 +183,7 @@ const applyDendroFocus = (deck_mat, layers_mat, viz_state, focus) => { ...polygon, properties: { ...polygon.properties, - is_focused: isFocused, + is_focused, }, }; } @@ -62,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') { @@ -131,41 +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) => { +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); @@ -181,6 +334,67 @@ const dendro_layer_onclick = (event, deck_mat, layers_mat, viz_state, axis) => { event.object.properties.all_names ); } + + const double_click_state = { + name: clicked_name, + was_active_before_click, + }; + + if (viz_state.dendro.clicks[axis] === 1) { + 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.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.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, + viz_state, + axis, + event.object.properties + ); + } }; export const set_dendro_layer_onclick = ( @@ -192,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), } ); };