Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
14321c5
working on align module
cornhundred Jul 13, 2026
12df8a2
working on module
cornhundred Jul 14, 2026
9a8a0fe
working on docs
cornhundred Jul 15, 2026
42055fd
ruff
cornhundred Jul 15, 2026
4c8d943
ruff format
cornhundred Jul 15, 2026
b282540
working on align module
cornhundred Jul 15, 2026
1aae7fe
improving api
cornhundred Jul 15, 2026
0e83361
improved landmark api
cornhundred Jul 15, 2026
6b52c70
switched to slice_attr
cornhundred Jul 16, 2026
aba1413
added transform exposing api
cornhundred Jul 16, 2026
5dfebc7
moved z out of transform calc and into align
cornhundred Jul 16, 2026
efc1ef3
working on front-end fixes 3d
cornhundred Jul 16, 2026
d1fcedc
merging in main
cornhundred Jul 16, 2026
fae6f9a
point-cloud fixes
cornhundred Jul 16, 2026
eae7e79
front-end optimal pan/zoom ini
cornhundred Jul 16, 2026
e281209
merged in StrataMap placeholder gallery
cornhundred Jul 17, 2026
a337b36
testing 3D alpha slice view (#316)
cornhundred Jul 17, 2026
bacc688
v0.19.0a1 pre-release align module, small fix to enable 3D alpha shap…
cornhundred Jul 17, 2026
e2a4737
0.19.0a2 aws creds bug fix
cornhundred Jul 17, 2026
603b994
first draft of the Landmark widget
cornhundred Jul 20, 2026
0bf3905
working on dataset swapping etc
cornhundred Jul 20, 2026
3c0ed22
fixing bugs
cornhundred Jul 20, 2026
542e911
working on control panel
cornhundred Jul 20, 2026
8db87c9
addressing comments
cornhundred Jul 21, 2026
7df62f2
Merge branch 'DEGA-262-semi-automated-align' into DEGA-555-Landmark
cornhundred Jul 21, 2026
2b56977
working on landmark bugs
cornhundred Jul 21, 2026
b97e512
bug fixing
cornhundred Jul 21, 2026
ac7cc4d
working on UI flow
cornhundred Jul 21, 2026
901e28a
ui fixes
cornhundred Jul 21, 2026
46f0e39
fixing bugs
cornhundred Jul 21, 2026
041b20b
ui bug fixes
cornhundred Jul 21, 2026
26d5427
multi-slide update before save
cornhundred Jul 21, 2026
dd6296b
fix drag bug
cornhundred Jul 21, 2026
d568fea
improving API
cornhundred Jul 21, 2026
0d89eca
cleanup and another modify bug fix
cornhundred Jul 21, 2026
985c4db
scatterplot landmarks
cornhundred Jul 21, 2026
3691a60
fixing bug
cornhundred Jul 21, 2026
ba62c19
working on preventing tps overfit
cornhundred Jul 21, 2026
394d933
working on preventing tps overfit
cornhundred Jul 21, 2026
25ce810
small quality of life improvement to widget
cornhundred Jul 22, 2026
66e11d3
penalty for area change;
cornhundred Jul 22, 2026
2e2c434
working on cbg saving for new DegaFiles
cornhundred Jul 22, 2026
0a83580
Merge branch 'main' into DEGA-555-Landmark
cornhundred Jul 22, 2026
a07d9e9
Bump to 0.19.0a3 (align + Landmark alpha pre-release)
cornhundred Jul 22, 2026
cf8f5d9
Move Landmark widget from align to viz module
cornhundred Jul 23, 2026
245dbe4
Point CHANGELOG a3 entry at celldega.viz.Landmark (post-move)
cornhundred Jul 23, 2026
174cd70
0.19.0a4 move Landmark to viz module
cornhundred Jul 23, 2026
2f26662
front-end bug fixes
cornhundred Jul 24, 2026
4b633f4
qol improvements
cornhundred Jul 24, 2026
5284092
v0.19.0a5: pagination and qol improvements
cornhundred Jul 24, 2026
7bb177b
v0.19.0a6 adata obs index bug fix
cornhundred Jul 24, 2026
ac9bdeb
fixed keying on cell_id from adata.obs
cornhundred Jul 24, 2026
4c9cf65
cleaning up obs_store
cornhundred Jul 26, 2026
b733aae
obs_store state cleaning
cornhundred Jul 26, 2026
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
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,48 @@ All notable changes to Celldega are documented here. This project follows
[Keep a Changelog](https://keepachangelog.com/) conventions and
[semantic versioning](https://semver.org/).

## [0.19.0a3] - 2026-07-22

Alpha pre-release: interactive landmarking and 3D visualization of aligned
data, building on the serial-slice alignment module from `0.19.0a1`. Published
to make the `Landmark` widget and point-cloud alignment views easier to test.

### Added

- **`celldega.viz.Landmark`** — an interactive widget for manually marking
corresponding landmark points across slices. Two side-by-side panels (any
slice swappable into either via dropdowns) with MARK / MODIFY / SAVE / DEL,
per-landmark rename + color, and per-slice rotation. Centroids are colored by
an optional `cluster_key` and streamed over the widget comm channel (no
bucket reads). Emits `.landmarks` in the exact shape `calc_landmarks`
produces, so manual and automatic landmarks concatenate; `landmarks=`
reloads a prior table for review/extension.
- **Anti-overfit TPS controls** — `area_regularization` and
`shape_regularization` (both `[0, 1]`) on `fit_transform_tps` /
`calc_alignment_transform`, applied as a post-fit SVD correction that pulls
the warp's global area and proportions toward rigid while leaving local
deformation intact (`1`/`1` makes the global part rotation-only). Both are
persisted through `save`/`load` and `uns["align_serial_slices"]`.
- **`celldega.align.plot_alignment`** (also `transform.plot()`) — a before/after
2D scatter to sanity-check a fit at a glance.
- **`celldega.align.write_alignment_point_cloud`** — writes aligned 3D cell
centroids into a point-cloud DegaFiles as named alignment variants
(`cell_metadata_<name>.parquet`), registered under a new `"alignments"` key
in `landscape_parameters.json`. Appends to an existing DegaFiles (positions
only, reusing clusters/genes) or creates a fresh one (clusters from
`obs[cluster_key]`, plus gene expression when `adata` carries it).
- **`Landscape(alignment="<name>")`** — a new argument for point-cloud
technology that loads a named alignment's positions
(`cell_metadata_<name>.parquet`) while clusters/genes keep loading from
their normal paths, so alignments can be swapped without a dropdown.

### Fixed

- **Gene panel shown for gene-less datasets** — the `Landscape` gene bar-graph
and gene search are now hidden when a dataset has no gene expression (e.g. a
point-cloud DegaFiles written without `cbg/`), instead of rendering an empty
panel.

## [0.19.0a2] - 2026-07-17

### Fixed
Expand Down
202 changes: 101 additions & 101 deletions docs/assets/js/celldega.js

Large diffs are not rendered by default.

100 changes: 100 additions & 0 deletions js/__tests__/bind_selection.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/* global require */

describe('bind_selection', () => {
let bind_observable_field;
let bind_selection_to_store;

beforeAll(() => {
const fs = require('fs');
const path = require('path');

const source = fs
.readFileSync(
path.join(__dirname, '../obs_store/bind_selection.js'),
'utf8'
)
.replace(/^export const /gm, 'const ');
const code = `${source}\nmodule.exports = { bind_observable_field, bind_selection_to_store };`;
const module = { exports: {} };
new Function('module', 'exports', code)(module, module.exports);
({ bind_observable_field, bind_selection_to_store } = module.exports);
});

// Minimal observable stand-in with the get/set surface bind relies on.
const makeObservable = (initial) => {
let value = initial;
return {
get: () => value,
set: (next) => {
value = next;
},
};
};

test('reads delegate to the observable', () => {
const obs = makeObservable(['a']);
const target = {};
bind_observable_field(target, 'selected', obs);

expect(target.selected).toEqual(['a']);
obs.set(['b']);
expect(target.selected).toEqual(['b']);
});

test('writes delegate to the observable', () => {
const obs = makeObservable([]);
const target = {};
bind_observable_field(target, 'selected', obs);

target.selected = ['x', 'y'];
expect(obs.get()).toEqual(['x', 'y']);
});

test('seeds the observable from a pre-existing field value', () => {
const obs = makeObservable([]);
const target = { selected: ['seed'] };
bind_observable_field(target, 'selected', obs);

expect(obs.get()).toEqual(['seed']);
expect(target.selected).toEqual(['seed']);
});

test('there is a single source of truth after binding', () => {
const obs = makeObservable([]);
const target = {};
bind_observable_field(target, 'selected', obs);

// Writing through either the field or the observable is observed by both.
target.selected = [1];
expect(obs.get()).toEqual([1]);
obs.set([2]);
expect(target.selected).toEqual([2]);
});

test('bind_selection_to_store binds both selection fields', () => {
const viz_state = {
obs_store: {
selected_cats: makeObservable([]),
selected_genes: makeObservable([]),
},
cats: { selected_cats: ['c1'] },
genes: { selected_genes: ['g1'] },
};

bind_selection_to_store(viz_state);

expect(viz_state.obs_store.selected_cats.get()).toEqual(['c1']);
expect(viz_state.obs_store.selected_genes.get()).toEqual(['g1']);

viz_state.cats.selected_cats = ['c2'];
expect(viz_state.obs_store.selected_cats.get()).toEqual(['c2']);
});

test('is a no-op when target or observable is missing', () => {
expect(() =>
bind_observable_field(null, 'x', makeObservable(1))
).not.toThrow();
expect(() => bind_observable_field({}, 'x', null)).not.toThrow();
expect(() => bind_selection_to_store({})).not.toThrow();
});
});
98 changes: 98 additions & 0 deletions js/__tests__/observable.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/* global require */

describe('Observable', () => {
let Observable;
let deepEquals;

beforeAll(() => {
const fs = require('fs');
const path = require('path');

const source = fs
.readFileSync(path.join(__dirname, '../obs_store/observable.js'), 'utf8')
.replace(/^export const /gm, 'const ');
const code = `${source}\nmodule.exports = { Observable, deepEquals };`;
const module = { exports: {} };
new Function('module', 'exports', code)(module, module.exports);
({ Observable, deepEquals } = module.exports);
});

test('get / getDefault reflect initial value', () => {
const obs = Observable(5);
expect(obs.get()).toBe(5);
obs.set(10);
expect(obs.get()).toBe(10);
expect(obs.getDefault()).toBe(5);
});

test('subscribe fires immediately by default and on change', () => {
const obs = Observable('a');
const calls = [];
obs.subscribe((v) => calls.push(v));
obs.set('b');
expect(calls).toEqual(['a', 'b']);
});

test('subscribe with immediate: false skips the initial call', () => {
const obs = Observable('a');
const calls = [];
obs.subscribe((v) => calls.push(v), { immediate: false });
obs.set('b');
expect(calls).toEqual(['b']);
});

test('unsubscribe stops further notifications', () => {
const obs = Observable(0);
const calls = [];
const unsubscribe = obs.subscribe((v) => calls.push(v), {
immediate: false,
});
obs.set(1);
unsubscribe();
obs.set(2);
expect(calls).toEqual([1]);
});

test('default equality does not fire when the value is unchanged', () => {
const obs = Observable(1);
const calls = [];
obs.subscribe(() => calls.push(true), { immediate: false });
obs.set(1);
expect(calls).toHaveLength(0);
});

test('default (reference) equality fires for equal-content arrays', () => {
const obs = Observable([]);
const calls = [];
obs.subscribe(() => calls.push(true), { immediate: false });
obs.set([]); // new reference, same content
expect(calls).toHaveLength(1);
});

test('deepEquals equality suppresses equal-content updates', () => {
const obs = Observable([1, 2], { equals: deepEquals });
const calls = [];
obs.subscribe(() => calls.push(true), { immediate: false });
obs.set([1, 2]); // same content -> no fire
expect(calls).toHaveLength(0);
obs.set([1, 3]); // different content -> fire
expect(calls).toHaveLength(1);
});

test('update derives the next value from the current one', () => {
const obs = Observable([1]);
const calls = [];
obs.subscribe((v) => calls.push(v), { immediate: false });
obs.update((arr) => [...arr, 2]);
expect(obs.get()).toEqual([1, 2]);
expect(calls).toEqual([[1, 2]]);
});

test('deepEquals handles primitives and null', () => {
expect(deepEquals(1, 1)).toBe(true);
expect(deepEquals(null, null)).toBe(true);
expect(deepEquals(null, {})).toBe(false);
expect(deepEquals({ a: 1 }, { a: 1 })).toBe(true);
expect(deepEquals({ a: 1 }, { a: 2 })).toBe(false);
});
});
8 changes: 8 additions & 0 deletions js/celldega.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
handleAsyncError,
handleValidationWarning,
} from './temp_utils/errorHandler';
import { landmark } from './viz/landmark';
import { landscape_h_e } from './viz/landscape_h_e';
import { landscape_ist } from './viz/landscape_ist';
import { matrix_viz } from './viz/matrix_viz';
Expand Down Expand Up @@ -223,6 +224,8 @@ const render_yearbook = async ({ model, el }) => {
);
};

const render_landmark = async ({ model, el }) => landmark(model, el);

const render_matrix_new = async ({ model, el }) => {
// let network = model.get('network');
let network;
Expand Down Expand Up @@ -274,6 +277,9 @@ async function render({ model, el }) {
case 'Enrich':
cleanup = await render_enrich({ model, el });
break;
case 'Landmark':
cleanup = await render_landmark({ model, el });
break;
default:
handleValidationWarning(`Unknown component type: ${componentType}`, {
data: { componentType, model: model?.id || 'unknown' },
Expand Down Expand Up @@ -371,13 +377,15 @@ const matrix_from_dega_files = async (
};

export default {
landmark,
landscape_ist,
landscape_h_e,
matrix_viz,
matrix_from_dega_files,
networkFromDegaFiles,
yearbook,
render,
render_landmark,
render_landscape_ist,
render_landscape_h_e,
render_landscape,
Expand Down
35 changes: 22 additions & 13 deletions js/deck-gl/core/deck_ist.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,30 @@ const getCursor = ({ isDragging }) => {
return 'pointer';
};

export const ini_deck = (root, width, height, technology = '') => {
const controller = { doubleClickZoom: false };
if (is_point_cloud_technology(technology)) {
controller.type = OrbitController;
}
export const ini_deck = (
root,
width,
height,
technology = '',
{ per_view_controllers = false } = {}
) => {
const deck_props = { parent: root, getCursor, width, height };

const deck_ist = new Deck({
parent: root,
controller,
getCursor,
width,
height,
});
// A multi-view deck (e.g. Landmark's two side-by-side panels) manages a
// controller per view via the `views` prop. A top-level `controller` here
// would additionally bind to the default view at the canvas origin — which
// the first/left view (also at 0,0) then inherits instead of its own
// per-view `dragPan:false`, so dragging a marker on the left panel also pans
// the camera. Omit the top-level controller in that case.
if (!per_view_controllers) {
const controller = { doubleClickZoom: false };
if (is_point_cloud_technology(technology)) {
controller.type = OrbitController;
}
deck_props.controller = controller;
}

return deck_ist;
return new Deck(deck_props);
};

export const set_views_prop = (deck_ist, views) => {
Expand Down
Loading
Loading