Skip to content
Draft

Bikes #296

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
180 changes: 90 additions & 90 deletions docs/assets/js/celldega.js

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions js/celldega.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { is_point_cloud_technology } from './global_variables/image_info';
import {
buildCellSlice,
buildColAxisSlice,
buildRowAxisSlice,
emitMatrixSliceRequest,
} from './matrix/matrix_axis_slice';
import { networkFromDegaFiles } from './read_parquet/network_from_dega_files';
import { networkFromParquet } from './read_parquet/network_from_parquet';
import { objects_from_parquet } from './read_parquet/objects_from_parquet';
Expand Down Expand Up @@ -355,4 +361,8 @@ export default {
render_yearbook,
render_matrix_new,
render_enrich,
buildRowAxisSlice,
buildColAxisSlice,
buildCellSlice,
emitMatrixSliceRequest,
};
20 changes: 13 additions & 7 deletions js/deck-gl/matrix/label_layers.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
sync_selected_rows,
sync_selected_cols,
} from '../../global_variables/selected_genes';
import { emitMatrixSliceRequest } from '../../matrix/matrix_axis_slice';

import { toggle_dendro_layer_visibility } from './dendro_layers';
import { get_mat_layers_list } from './matrix_layers';
Expand Down Expand Up @@ -215,14 +216,13 @@ const row_label_layer_onclick = (event, deck_mat, layers_mat, viz_state) => {

if (viz_state.labels.clicks.row === 1) {
viz_state.click.type = 'row_label';
const { name } = event.object;
const { name, index: rowMatrixIndex } = event.object;
// Include full entity info (entity type + attribute)
viz_state.click.value = {
name,
// New structured entity info
index: rowMatrixIndex,
entity: viz_state.row_entity.entity,
attr: viz_state.row_entity.attr,
// Legacy field for backwards compatibility
row_entity: viz_state.row_entity.entity,
};

Expand All @@ -234,6 +234,10 @@ const row_label_layer_onclick = (event, deck_mat, layers_mat, viz_state) => {
viz_state.model.set('click_info', null);
viz_state.model.set('click_info', viz_state.click);
viz_state.model.save_changes();
emitMatrixSliceRequest(viz_state.model, 'row', {
index: rowMatrixIndex,
max_entries: -1,
});
}

// Sync selected row to Python model
Expand Down Expand Up @@ -263,14 +267,12 @@ const col_label_layer_onclick = (event, deck_mat, layers_mat, viz_state) => {

if (viz_state.labels.clicks.col === 1) {
viz_state.click.type = 'col_label';
const { name } = event.object;
// Include full entity info (entity type + attribute)
const { name, index: colMatrixIndex } = event.object;
viz_state.click.value = {
name,
// New structured entity info
index: colMatrixIndex,
entity: viz_state.col_entity.entity,
attr: viz_state.col_entity.attr,
// Legacy field for backwards compatibility
col_entity: viz_state.col_entity.entity,
};

Expand All @@ -282,6 +284,10 @@ const col_label_layer_onclick = (event, deck_mat, layers_mat, viz_state) => {
viz_state.model.set('click_info', null);
viz_state.model.set('click_info', viz_state.click);
viz_state.model.save_changes();
emitMatrixSliceRequest(viz_state.model, 'col', {
index: colMatrixIndex,
max_entries: -1,
});
}

// Sync selected column to Python model
Expand Down
12 changes: 6 additions & 6 deletions js/deck-gl/matrix/mat_layer.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as d3 from 'd3';

import { emitMatrixSliceRequest } from '../../matrix/matrix_axis_slice';

import { CustomMatrixLayer } from './custom_matrix_layer';

const mat_layer_get_position = (d, viz_state) => {
Expand Down Expand Up @@ -56,24 +58,18 @@ const mat_layer_onclick = (event, deck_mat, layers_mat, viz_state) => {
row: {
name: row_name,
index: row_index,
// New structured entity info
entity: viz_state.row_entity.entity,
attr: viz_state.row_entity.attr,
// Legacy field for backwards compatibility
row_entity: viz_state.row_entity.entity,
},
col: {
name: col_name,
index: col_index,
// New structured entity info
entity: viz_state.col_entity.entity,
attr: viz_state.col_entity.attr,
// Legacy field for backwards compatibility
col_entity: viz_state.col_entity.entity,
},
// The actual matrix cell value
value: mat_value,
// Full entity info for both axes
row_entity_full: viz_state.row_entity,
col_entity_full: viz_state.col_entity,
};
Expand All @@ -93,6 +89,10 @@ const mat_layer_onclick = (event, deck_mat, layers_mat, viz_state) => {
viz_state.model.set('click_info', null);
viz_state.model.set('click_info', viz_state.click);
viz_state.model.save_changes();
emitMatrixSliceRequest(viz_state.model, 'cell', {
row: row_index,
col: col_index,
});
}

// Close the attribute editor on matrix click
Expand Down
183 changes: 183 additions & 0 deletions js/matrix/matrix_axis_slice.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* Generic matrix axis slices from `viz_state.mat.net_mat`.
*
* Convention: `net_mat[row_index][col_index]` is the scalar at that row/column entity
* pair (interpretation of row vs col entities is up to the application).
*/

const MAX_ENTRIES = 2000;
/** When `max_entries < 0` (request all), still cap per axis to avoid freezing huge matrices. */
const UNBOUNDED_AXIS_CAP = 500_000;

export const MATRIX_NET_CONVENTION =
'net_mat[row][col] is the matrix entry at row index (row entity) and column index (col entity)';

/**
* Ask the widget model to run the front-end slice handler (second step after a click).
* @param {any} model ipywidgets Backbone model with get/set/save_changes
* @param {'row'|'col'|'cell'|'row_col'} op
* @param {Record<string, unknown>} fields Optional `max_entries` on `row`/`col`/`row_col`
* (`< 0` = all non-zero entries, up to an internal cap; omit = {@link MAX_ENTRIES}).
* @returns {string|undefined} req_id
*/
export function emitMatrixSliceRequest(model, op, fields) {
if (!model?.set) return undefined;
const req_id =
typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `r${Date.now()}-${Math.random().toString(16).slice(2)}`;
model.set('matrix_slice_request', {});
model.set('matrix_slice_request', { req_id, op, ...fields });
model.save_changes();
return req_id;
}

/**
* @param {object} viz_state
* @param {number} rowIndex
* @param {number} [maxEntries] Default {@link MAX_ENTRIES}. Use `< 0` for all entries (capped by {@link UNBOUNDED_AXIS_CAP}).
* @returns {object|null}
*/
export function buildRowAxisSlice(viz_state, rowIndex, maxEntries) {
const net = viz_state?.mat?.net_mat;
const rowNodes = viz_state?.row_nodes;
const colNodes = viz_state?.col_nodes;
if (
!Array.isArray(net) ||
!Array.isArray(rowNodes) ||
!Array.isArray(colNodes) ||
rowIndex == null ||
Number.isNaN(Number(rowIndex))
) {
return null;
}
const r = Number(rowIndex);
if (r < 0 || r >= net.length) return null;
const row = net[r];
if (!Array.isArray(row)) return null;

const entries = [];
for (let c = 0; c < row.length; c++) {
const val = Number(row[c]);
if (!Number.isFinite(val) || val === 0) continue;
const cn = colNodes[c];
if (!cn) continue;
entries.push({
row: r,
col: c,
counterpart_name: cn.name,
value: val,
});
}
entries.sort((a, b) => b.value - a.value);
const cap =
maxEntries == null
? MAX_ENTRIES
: maxEntries < 0
? Math.min(entries.length, UNBOUNDED_AXIS_CAP)
: Math.min(entries.length, maxEntries, UNBOUNDED_AXIS_CAP);
const primaryNode = rowNodes[r];
return {
slice_kind: 'row_axis',
matrix_convention: MATRIX_NET_CONVENTION,
primary_index: r,
primary_name: primaryNode ? primaryNode.name : null,
entries: entries.slice(0, cap),
};
}

/**
* @param {object} viz_state
* @param {number} colIndex
* @param {number} [maxEntries] Default {@link MAX_ENTRIES}. Use `< 0` for all entries (capped by {@link UNBOUNDED_AXIS_CAP}).
* @returns {object|null}
*/
export function buildColAxisSlice(viz_state, colIndex, maxEntries) {
const net = viz_state?.mat?.net_mat;
const rowNodes = viz_state?.row_nodes;
const colNodes = viz_state?.col_nodes;
if (
!Array.isArray(net) ||
!Array.isArray(rowNodes) ||
!Array.isArray(colNodes) ||
colIndex == null ||
Number.isNaN(Number(colIndex))
) {
return null;
}
const c = Number(colIndex);
if (c < 0 || c >= colNodes.length) return null;

const entries = [];
for (let r = 0; r < net.length; r++) {
const row = net[r];
if (!Array.isArray(row) || c >= row.length) continue;
const val = Number(row[c]);
if (!Number.isFinite(val) || val === 0) continue;
const rn = rowNodes[r];
if (!rn) continue;
entries.push({
row: r,
col: c,
counterpart_name: rn.name,
value: val,
});
}
entries.sort((a, b) => b.value - a.value);
const cap =
maxEntries == null
? MAX_ENTRIES
: maxEntries < 0
? Math.min(entries.length, UNBOUNDED_AXIS_CAP)
: Math.min(entries.length, maxEntries, UNBOUNDED_AXIS_CAP);
const primaryNode = colNodes[c];
return {
slice_kind: 'col_axis',
matrix_convention: MATRIX_NET_CONVENTION,
primary_index: c,
primary_name: primaryNode ? primaryNode.name : null,
entries: entries.slice(0, cap),
};
}

/**
* @param {number} rowIndex
* @param {number} colIndex
* @param {unknown} value
*/
export function buildCellSlice(rowIndex, colIndex, value) {
return {
slice_kind: 'cell',
matrix_convention: MATRIX_NET_CONVENTION,
row_index: rowIndex,
col_index: colIndex,
value,
};
}

/**
* One response containing a normal row-axis slice and a normal col-axis slice
* (e.g. same entity on both axes in a square flow matrix).
*
* @param {object} viz_state
* @param {number} rowIndex
* @param {number} colIndex
* @param {number} [maxEntries] Passed through to both axis builders.
* @returns {object|null}
*/
export function buildRowColPairSlice(
viz_state,
rowIndex,
colIndex,
maxEntries
) {
const rowSlice = buildRowAxisSlice(viz_state, rowIndex, maxEntries);
const colSlice = buildColAxisSlice(viz_state, colIndex, maxEntries);
if (!rowSlice && !colSlice) return null;
return {
slice_kind: 'row_col',
matrix_convention: MATRIX_NET_CONVENTION,
row_axis: rowSlice,
col_axis: colSlice,
};
}
58 changes: 58 additions & 0 deletions js/viz/matrix_viz.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ import {
update_label_display_names,
} from '../matrix/label_data';
import { set_mat_data } from '../matrix/mat_data';
import {
buildCellSlice,
buildColAxisSlice,
buildRowAxisSlice,
buildRowColPairSlice,
} from '../matrix/matrix_axis_slice';
import { set_mat_constants } from '../matrix/set_constants';
import { initialize_attribute_editor } from '../ui/attribute_editor';
import { initialize_attribute_labels } from '../ui/attribute_labels';
Expand Down Expand Up @@ -324,6 +330,58 @@ export const matrix_viz = async (
viz_state.model.on('change:top_n_genes', () => {
viz_state.top_n_genes = viz_state.model.get('top_n_genes') || 50;
});

const flushMatrixSliceRequest = () => {
const req = viz_state.model.get('matrix_slice_request');
if (!req || typeof req !== 'object') return;
const reqId = req.req_id;
if (!reqId || !req.op) return;

const result = { req_id: reqId };
let maxEntries;
if (req.max_entries === undefined || req.max_entries === null) {
maxEntries = undefined;
} else {
const n = Number(req.max_entries);
maxEntries = Number.isFinite(n) ? n : undefined;
}
try {
if (req.op === 'row') {
const slice = buildRowAxisSlice(viz_state, req.index, maxEntries);
if (slice) Object.assign(result, slice);
else result.error = 'no_data';
} else if (req.op === 'col') {
const slice = buildColAxisSlice(viz_state, req.index, maxEntries);
if (slice) Object.assign(result, slice);
else result.error = 'no_data';
} else if (req.op === 'cell') {
const r = req.row;
const c = req.col;
const net = viz_state.mat?.net_mat;
let val = null;
if (net?.[r] && c >= 0 && c < net[r].length) {
val = net[r][c];
}
Object.assign(result, buildCellSlice(r, c, val));
} else if (req.op === 'row_col') {
const r = req.row_index;
const c = req.col_index;
const slice = buildRowColPairSlice(viz_state, r, c, maxEntries);
if (slice) Object.assign(result, slice);
else result.error = 'no_data';
} else {
result.error = 'unknown_op';
}
} catch (e) {
result.error = String(e?.message || e);
}

viz_state.model.set('matrix_slice_result', {});
viz_state.model.set('matrix_slice_result', result);
viz_state.model.save_changes();
};

viz_state.model.on('change:matrix_slice_request', flushMatrixSliceRequest);
}

const matrix = {
Expand Down
7 changes: 6 additions & 1 deletion src/celldega/clust/matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,7 +671,12 @@ def make_viz(self) -> None:
if self.data is None:
raise ValueError(ERRORS["no_data"])

# Use cached dat structure (triggers lazy loading)
# Rebuild node_info from current row_attr/col_attr and metadata. Without this,
# cached dat can stay stale after mutating attr lists (e.g. appending a column
# name after add_category), so exported row_nodes/col_nodes miss cat-* keys.
self._dat_cache = None
self._dirty_flags[CacheLevel.DATA.value] = True

_ = self.dat

# Update rankings
Expand Down
Loading
Loading