Skip to content
470 changes: 92 additions & 378 deletions docs/assets/js/celldega.js

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion docs/visualizations/clustergram.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ tissue location.
encodes value) or a filled tile, toggled with the `TILE: prop/unit` and
composition `prop/counts` controls.
- **Dendrograms** on both axes, with sliders to change the linkage-distance
cutoff used to cut the tree into clusters.
cutoff used to cut the tree into clusters. While adjusting a slider, a
temporary full-tree overview appears over the matrix on a translucent white
background. A blue water rectangle expands from the leaves to the cutoff,
showing the branches merged below that level. It rises for
columns and moves sideways for rows. Release the slider to fade the overview,
or press Escape to dismiss it immediately. Keyboard slider controls also work.
- **Reorder controls** for both axes (`clust`, `sum`, `var`, `ini`) to
resort rows/columns by clustering order, summed value, variance, or the
original input order.
Expand All @@ -22,6 +27,12 @@ For comparing category composition (e.g. cell-type proportions) across
groups instead of a general heatmap, see [Composition](composition.md), a
`Clustergram` variant purpose-built for that comparison.

The tree preview follows the matrix's zoom and pan: row branches stay aligned
vertically and column branches horizontally, including in the current RANK
view. The blue cut rectangle stays fitted to the visible matrix. The preview
appears in clustering order; an axis crop keeps its dendrogram slice pinned
until the crop is undone.

## Usage

`Clustergram` is built from a clustered `celldega.clust.Matrix`:
Expand Down
107 changes: 107 additions & 0 deletions js/__tests__/dendro_tree.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/* global require */
const fs = require('fs');
const path = require('path');

const source = fs
.readFileSync(path.join(__dirname, '../matrix/dendro_tree.js'), 'utf8')
.replace(/^export const /gm, 'const ');
const { build_dendro_tree, get_dendro_tree_groups, dendro_tree_point } =
new Function(
`${source}; return { build_dendro_tree, get_dendro_tree_groups, dendro_tree_point };`
)();
const slice_source = fs
.readFileSync(path.join(__dirname, '../matrix/dendro.js'), 'utf8')
.replace(/^import[\s\S]*?from\s+['"][^'"]+['"];$/gm, '')
.replace(/^export const /gm, 'const ');
const alt_slice_linkage = new Function(
`${slice_source}; return alt_slice_linkage;`
)();

const linkage = [
[0, 2, 0, 2],
[1, 4, 1, 3],
[5, 3, 2, 4],
];

test.each(['row', 'col'])(
'%s tree preserves duplicate leaves, current cut, and RANK mapping',
(axis) => {
const map = [4, 1, 5, 2];
// Display order: raw nodes 1, 4, 5, 2 (scipy leaves 1, 0, 2, 3).
const order = [0, 4, 1, 0, 3, 2];
const tree = build_dendro_tree(linkage, map, order);
expect(tree.nodes[0].raw_index).toBe(4);
expect(tree.nodes[0].center).toBe(0.375);
expect(tree.nodes[4]).toMatchObject({
start: 0.25,
end: 0.75,
count: 2,
distance: 0,
});
expect(tree.nodes[6]).toMatchObject({
start: 0,
end: 1,
count: 4,
distance: 2,
});
const state = {
[`${axis}_nodes`]: Array.from({ length: 6 }, () => ({})),
linkage: { [axis]: linkage },
rank_view: { leaf_map: { [axis]: map } },
};
[
[0, 4],
[0.5, 3],
[1.5, 2],
[2.01, 1],
].forEach(([cut, expected]) => {
alt_slice_linkage(state, axis, cut);
const groups = get_dendro_tree_groups(tree, state[`${axis}_nodes`]);
expect(groups).toHaveLength(expected);
expect(groups.reduce((sum, group) => sum + group.count, 0)).toBe(4);
// Every cut partitions the leaf edge with no gaps or overlapping vials.
const sorted = groups.sort((a, b) => a.start - b.start);
expect(sorted[0].start).toBe(0);
expect(sorted[sorted.length - 1].end).toBe(1);
sorted
.slice(1)
.forEach((group, i) => expect(group.start).toBe(sorted[i].end));
});
}
);

test('water rises from the leaves on both axes', () => {
expect(dendro_tree_point('col', 0.5, 0, 2, 300, 200)).toEqual([150, 200]);
expect(dendro_tree_point('col', 0.5, 1, 2, 300, 200)).toEqual([150, 100]);
expect(dendro_tree_point('col', 0.5, 2, 2, 300, 200)).toEqual([150, 0]);
expect(dendro_tree_point('row', 0.5, 0, 2, 300, 200)).toEqual([300, 100]);
expect(dendro_tree_point('row', 0.5, 1, 2, 300, 200)).toEqual([150, 100]);
expect(dendro_tree_point('row', 0.5, 2, 2, 300, 200)).toEqual([0, 100]);
});

test('all-zero linkage remains finite and merges into one vial', () => {
const zero_links = linkage.map(([a, b, _d, n]) => [a, b, 0, n]);
const tree = build_dendro_tree(zero_links, [0, 1, 2, 3], [3, 4, 2, 1]);
const state = { row_nodes: [{}, {}, {}, {}], linkage: { row: zero_links } };
alt_slice_linkage(state, 'row', 0.005);
expect(get_dendro_tree_groups(tree, state.row_nodes)).toHaveLength(1);
expect(dendro_tree_point('col', 0.5, 0.005, 0.01, 100, 100)).toEqual([
50, 50,
]);
});

test('a deep 20,000-leaf tree builds without recursion or descendant lists', () => {
const count = 20000;
const links = [[0, 1, 0, 2]];
for (let i = 1; i < count - 1; i++)
links.push([count + i - 1, i + 1, i, i + 2]);
const map = Array.from({ length: count }, (_, i) => i);
const tree = build_dendro_tree(links, map, map);
expect(tree.nodes).toHaveLength(2 * count - 1);
expect(tree.nodes.at(-1)).toMatchObject({ start: 0, end: 1, count });
});

test('missing or malformed trees do not produce geometry', () => {
expect(build_dendro_tree([], [0], [1])).toBeNull();
expect(build_dendro_tree([[0, 7, 1, 2]], [0, 1], [2, 1])).toBeNull();
});
85 changes: 85 additions & 0 deletions js/__tests__/dendro_tree_layers.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/* global require */
const fs = require('fs');
const path = require('path');

class TestLayer {
constructor(props) {
this.id = props.id;
this.props = props;
}
clone(next) {
return new this.constructor({ ...this.props, ...next });
}
}
class PathLayer extends TestLayer {}
class PolygonLayer extends TestLayer {}
class TextLayer extends TestLayer {}

const source = fs
.readFileSync(
path.join(__dirname, '../deck-gl/matrix/dendro_tree_layers.js'),
'utf8'
)
.replace(/^import[\s\S]*?from\s+['"][^'"]+['"];$/gm, '')
.replace(/^export const /gm, 'const ');
const api = new Function(
'PathLayer',
'PolygonLayer',
'TextLayer',
`${source}; return { DENDRO_TREE_LAYER_KEYS, ini_dendro_tree_layers,
update_dendro_tree_layers, empty_dendro_tree_data,
remove_dendro_tree_layers };`
)(PathLayer, PolygonLayer, TextLayer);

describe('temporary dendrogram deck layers', () => {
test('uses PathLayer, PolygonLayer, and TextLayer in layers_mat', () => {
const layers = {};
api.ini_dendro_tree_layers(layers);
expect(layers.dendro_tree_background_layer).toBeInstanceOf(PolygonLayer);
expect(layers.row_dendro_tree_branches_layer).toBeInstanceOf(PathLayer);
expect(layers.col_dendro_tree_branches_layer).toBeInstanceOf(PathLayer);
expect(layers.dendro_tree_water_layer).toBeInstanceOf(PolygonLayer);
expect(layers.dendro_tree_caption_layer).toBeInstanceOf(TextLayer);
expect(layers.dendro_tree_caption_layer.props).toMatchObject({
background: true,
sizeUnits: 'pixels',
pickable: false,
});
});

test('fades via deck layer opacity and removes layers on teardown', () => {
const layers = {};
api.ini_dendro_tree_layers(layers);
const data = api.empty_dendro_tree_data();
data.background = [{ polygon: [], color: [255, 255, 255, 200] }];
data.caption = [
{
position: [17, 12],
text: 'Row tree',
color: [24, 78, 137, 255],
background_color: [255, 255, 255, 230],
},
];
api.update_dendro_tree_layers(layers, data, {
opacity: 0,
revision: 2,
duration: 180,
});
expect(layers.dendro_tree_background_layer.props.opacity).toBe(0);
expect(layers.dendro_tree_caption_layer.props.opacity).toBe(0);
expect(
layers.dendro_tree_background_layer.props.getFillColor(data.background[0])
).toEqual([255, 255, 255, 200]);
expect(
layers.dendro_tree_caption_layer.props.getBackgroundColor(data.caption[0])
).toEqual([255, 255, 255, 230]);
expect(
layers.dendro_tree_background_layer.props.transitions.opacity.duration
).toBe(180);
expect(
layers.dendro_tree_background_layer.props.transitions.getFillColor
).toBeUndefined();
api.remove_dendro_tree_layers(layers);
expect(Object.keys(layers)).toEqual([]);
});
});
Loading
Loading