@jax-data-science/sci-image-visualizer 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/esm2022/lib/contracts/display-types.mjs +1 -1
- package/esm2022/lib/contracts/plot-type-contribution.contract.mjs +1 -1
- package/esm2022/lib/contracts/ports/spatial-data.port.mjs +1 -1
- package/esm2022/lib/contracts/spatial-dataset.contract.mjs +1 -1
- package/esm2022/lib/contracts/visualizer.contract.mjs +1 -1
- package/esm2022/lib/implementations/napari-js/napari-loading-badge.mjs +73 -0
- package/esm2022/lib/implementations/napari-js/napari-spatial-tiles.mjs +418 -29
- package/esm2022/lib/implementations/napari-js/napari-visualizer.service.mjs +35 -1
- package/esm2022/lib/implementations/osd/openseadragon-visualizer.service.mjs +8 -1
- package/esm2022/lib/implementations/spatial-data-http/spatial-data-http.service.mjs +12 -1
- package/esm2022/lib/implementations/spatial-data-http/spatial-wire.mjs +2 -1
- package/esm2022/lib/routing-visualizer.service.mjs +5 -1
- package/esm2022/lib/spatial/spatial-tiles.mjs +144 -1
- package/esm2022/lib/spatial-controls/spatial-controls.component.mjs +198 -9
- package/fesm2022/jax-data-science-sci-image-visualizer.mjs +878 -30
- package/fesm2022/jax-data-science-sci-image-visualizer.mjs.map +1 -1
- package/lib/contracts/display-types.d.ts +4 -2
- package/lib/contracts/plot-type-contribution.contract.d.ts +9 -0
- package/lib/contracts/ports/spatial-data.port.d.ts +12 -1
- package/lib/contracts/spatial-dataset.contract.d.ts +30 -0
- package/lib/contracts/visualizer.contract.d.ts +7 -1
- package/lib/implementations/napari-js/napari-loading-badge.d.ts +20 -0
- package/lib/implementations/napari-js/napari-spatial-tiles.d.ts +76 -1
- package/lib/implementations/napari-js/napari-visualizer.service.d.ts +7 -0
- package/lib/implementations/spatial-data-http/spatial-data-http.service.d.ts +3 -1
- package/lib/implementations/spatial-data-http/spatial-wire.d.ts +2 -1
- package/lib/spatial/spatial-tiles.d.ts +38 -1
- package/lib/spatial-controls/spatial-controls.component.d.ts +54 -0
- package/package.json +1 -1
|
@@ -954,6 +954,7 @@ function datasetFromManifest(manifest, coords, extras = {}) {
|
|
|
954
954
|
...(manifest.transcriptTiles ? { transcriptTiles: manifest.transcriptTiles } : {}),
|
|
955
955
|
...(manifest.density ? { density: manifest.density } : {}),
|
|
956
956
|
...(manifest.transcriptBins ? { transcriptBins: manifest.transcriptBins } : {}),
|
|
957
|
+
...(manifest.transcriptGeneBins ? { transcriptGeneBins: manifest.transcriptGeneBins } : {}),
|
|
957
958
|
...(manifest.transcriptBinsStatus ? { transcriptBinsStatus: manifest.transcriptBinsStatus } : {}),
|
|
958
959
|
};
|
|
959
960
|
}
|
|
@@ -1183,6 +1184,12 @@ class SpatialDataHttpService {
|
|
|
1183
1184
|
+ `?genes=${genes}&quality=${query.quality ?? 'high'}${box}`;
|
|
1184
1185
|
return this.cachedTile(path, () => this.getBinary(path).then(decodeTranscriptTile));
|
|
1185
1186
|
}
|
|
1187
|
+
getTranscriptGeneBins(level, tx, ty, genes) {
|
|
1188
|
+
const manifest = this.requireManifest();
|
|
1189
|
+
const path = `spatial/${encodeURIComponent(manifest.id)}/gene-bins/${level}/${tx}/${ty}`
|
|
1190
|
+
+ `?genes=${genes.map(encodeURIComponent).join(',')}`;
|
|
1191
|
+
return this.cachedTile(path, () => this.getBinary(path).then(decodeTranscriptTile));
|
|
1192
|
+
}
|
|
1186
1193
|
getTranscriptBins(level, tx, ty) {
|
|
1187
1194
|
const manifest = this.requireManifest();
|
|
1188
1195
|
if (!manifest.transcriptBins) {
|
|
@@ -1245,6 +1252,11 @@ class SpatialDataHttpService {
|
|
|
1245
1252
|
}
|
|
1246
1253
|
return { column, matched: body.matched };
|
|
1247
1254
|
}
|
|
1255
|
+
getMarkerGenes(column, perGroup = 5) {
|
|
1256
|
+
const manifest = this.requireManifest();
|
|
1257
|
+
// No per-request timeout: the first computation is a pass over the whole matrix.
|
|
1258
|
+
return firstValueFrom(this.http.get(`${this.baseUrl}spatial/${encodeURIComponent(manifest.id)}/markers/${encodeURIComponent(column)}?n=${perGroup}`));
|
|
1259
|
+
}
|
|
1248
1260
|
getTranscriptCounts(genes) {
|
|
1249
1261
|
const manifest = this.requireManifest();
|
|
1250
1262
|
return this.getJson(`spatial/${encodeURIComponent(manifest.id)}/transcript-counts?genes=${genes.map(encodeURIComponent).join(',')}`);
|
|
@@ -11320,6 +11332,149 @@ function transcriptLevelFor(pxPerUnit, levels, targetTilePx = 512) {
|
|
|
11320
11332
|
}
|
|
11321
11333
|
return best;
|
|
11322
11334
|
}
|
|
11335
|
+
/**
|
|
11336
|
+
* The bin a selection of genes is grouped into at this zoom, in observation units, or null
|
|
11337
|
+
* to draw every transcript. Bins follow the all-gene pyramid's ladder (`baseBin` × 2^m) so a
|
|
11338
|
+
* gene selection and "all genes" group alike: the finest bin whose markers land at least
|
|
11339
|
+
* `spacingPx` apart on screen. Once the finest bin is that wide, transcripts are drawn as
|
|
11340
|
+
* themselves.
|
|
11341
|
+
*/
|
|
11342
|
+
function geneBinSize(pxPerUnit, baseBin, levels = 7, spacingPx = GROUP_MIN_SPACING_PX) {
|
|
11343
|
+
if (!(pxPerUnit > 0) || !(baseBin > 0))
|
|
11344
|
+
return null;
|
|
11345
|
+
if (baseBin * pxPerUnit >= spacingPx)
|
|
11346
|
+
return null;
|
|
11347
|
+
for (let m = 1; m < levels; m++) {
|
|
11348
|
+
const bin = baseBin * 2 ** m;
|
|
11349
|
+
if (bin * pxPerUnit >= spacingPx)
|
|
11350
|
+
return bin;
|
|
11351
|
+
}
|
|
11352
|
+
return baseBin * 2 ** (levels - 1);
|
|
11353
|
+
}
|
|
11354
|
+
/** The entries of `t` inside `rect` (observation units). */
|
|
11355
|
+
function clipTranscripts(t, rect) {
|
|
11356
|
+
const keep = [];
|
|
11357
|
+
for (let i = 0; i < t.count; i++) {
|
|
11358
|
+
if (t.x[i] >= rect.x0 && t.x[i] <= rect.x1 && t.y[i] >= rect.y0 && t.y[i] <= rect.y1)
|
|
11359
|
+
keep.push(i);
|
|
11360
|
+
}
|
|
11361
|
+
if (keep.length === t.count)
|
|
11362
|
+
return t;
|
|
11363
|
+
const pick = (a) => {
|
|
11364
|
+
const out = new a.constructor(keep.length);
|
|
11365
|
+
keep.forEach((src, i) => { out[i] = a[src]; });
|
|
11366
|
+
return out;
|
|
11367
|
+
};
|
|
11368
|
+
return {
|
|
11369
|
+
count: keep.length, aggregated: t.aggregated,
|
|
11370
|
+
x: pick(t.x), y: pick(t.y), z: pick(t.z), weight: pick(t.weight), observation: pick(t.observation),
|
|
11371
|
+
gene: pick(t.gene),
|
|
11372
|
+
};
|
|
11373
|
+
}
|
|
11374
|
+
/**
|
|
11375
|
+
* Group transcripts into `bin`-wide squares of a grid anchored at the origin (so a pan does
|
|
11376
|
+
* not move them), one entry per (group, square): `groupOf(gene slot)` says which group a gene
|
|
11377
|
+
* is in — its cluster in the gene tree, or itself. Each entry sits at its transcripts'
|
|
11378
|
+
* centroid, weighted by how many each stands for; it takes the gene and the cell holding most
|
|
11379
|
+
* of them, each summed over all its transcripts in the entry. `group[i]` is entry i's group.
|
|
11380
|
+
*/
|
|
11381
|
+
function groupTranscripts(t, bin, groupOf = (g) => g) {
|
|
11382
|
+
const index = new Map();
|
|
11383
|
+
const sx = [];
|
|
11384
|
+
const sy = [];
|
|
11385
|
+
const sz = [];
|
|
11386
|
+
const w = [];
|
|
11387
|
+
const grp = [];
|
|
11388
|
+
// Per entry, how many transcripts of each gene (the dominant one gives the icon and colour)
|
|
11389
|
+
// and in each cell (the dominant one is the entry's cell).
|
|
11390
|
+
const geneW = [];
|
|
11391
|
+
const obsW = [];
|
|
11392
|
+
for (let i = 0; i < t.count; i++) {
|
|
11393
|
+
const gk = groupOf(t.gene[i]);
|
|
11394
|
+
const key = `${gk}|${Math.floor(t.x[i] / bin)}|${Math.floor(t.y[i] / bin)}`;
|
|
11395
|
+
let k = index.get(key);
|
|
11396
|
+
const wi = t.weight[i] || 1;
|
|
11397
|
+
if (k === undefined) {
|
|
11398
|
+
k = w.length;
|
|
11399
|
+
index.set(key, k);
|
|
11400
|
+
sx.push(0);
|
|
11401
|
+
sy.push(0);
|
|
11402
|
+
sz.push(0);
|
|
11403
|
+
w.push(0);
|
|
11404
|
+
grp.push(gk);
|
|
11405
|
+
geneW.push(new Map());
|
|
11406
|
+
obsW.push(new Map());
|
|
11407
|
+
}
|
|
11408
|
+
sx[k] += t.x[i] * wi;
|
|
11409
|
+
sy[k] += t.y[i] * wi;
|
|
11410
|
+
sz[k] += t.z[i] * wi;
|
|
11411
|
+
w[k] += wi;
|
|
11412
|
+
geneW[k].set(t.gene[i], (geneW[k].get(t.gene[i]) ?? 0) + wi);
|
|
11413
|
+
obsW[k].set(t.observation[i], (obsW[k].get(t.observation[i]) ?? 0) + wi);
|
|
11414
|
+
}
|
|
11415
|
+
const n = w.length;
|
|
11416
|
+
const tile = {
|
|
11417
|
+
count: n, aggregated: true,
|
|
11418
|
+
x: new Float32Array(n), y: new Float32Array(n), z: new Float32Array(n),
|
|
11419
|
+
weight: new Uint32Array(n), observation: new Uint32Array(n), gene: new Uint16Array(n),
|
|
11420
|
+
};
|
|
11421
|
+
const group = new Int32Array(n);
|
|
11422
|
+
for (let k = 0; k < n; k++) {
|
|
11423
|
+
tile.x[k] = sx[k] / w[k];
|
|
11424
|
+
tile.y[k] = sy[k] / w[k];
|
|
11425
|
+
tile.z[k] = sz[k] / w[k];
|
|
11426
|
+
tile.weight[k] = w[k];
|
|
11427
|
+
tile.observation[k] = dominant(obsW[k]);
|
|
11428
|
+
tile.gene[k] = dominant(geneW[k]);
|
|
11429
|
+
group[k] = grp[k];
|
|
11430
|
+
}
|
|
11431
|
+
return { tile, group };
|
|
11432
|
+
}
|
|
11433
|
+
/** The key with the largest total; the first seen on a tie. */
|
|
11434
|
+
function dominant(totals) {
|
|
11435
|
+
let best = 0;
|
|
11436
|
+
let top = -1;
|
|
11437
|
+
for (const [key, v] of totals)
|
|
11438
|
+
if (v > top) {
|
|
11439
|
+
top = v;
|
|
11440
|
+
best = key;
|
|
11441
|
+
}
|
|
11442
|
+
return best;
|
|
11443
|
+
}
|
|
11444
|
+
/**
|
|
11445
|
+
* The colour of each gene-tree cluster of a selection, for colouring transcripts by cluster: a
|
|
11446
|
+
* cluster named like a group of the cells' grouping takes that group's colour (its cells and its
|
|
11447
|
+
* transcripts agree); the others take palette colours in tree order. Clusters are the tree's
|
|
11448
|
+
* groups holding a selected gene, then each ungrouped selected gene on its own. Every gene of a
|
|
11449
|
+
* cluster gets the same colour — the tree's swatches and the markers use this one map.
|
|
11450
|
+
*/
|
|
11451
|
+
function clusterColorMap(genes, groups, cellColors, palette) {
|
|
11452
|
+
const chosen = new Set(genes);
|
|
11453
|
+
const names = [];
|
|
11454
|
+
const grouped = new Set();
|
|
11455
|
+
for (const g of groups) {
|
|
11456
|
+
const mine = g.genes.filter((x) => chosen.has(x) && !grouped.has(x));
|
|
11457
|
+
if (!mine.length)
|
|
11458
|
+
continue;
|
|
11459
|
+
mine.forEach((x) => grouped.add(x));
|
|
11460
|
+
names.push(g.name);
|
|
11461
|
+
}
|
|
11462
|
+
for (const gene of genes)
|
|
11463
|
+
if (!grouped.has(gene))
|
|
11464
|
+
names.push(gene);
|
|
11465
|
+
const out = new Map();
|
|
11466
|
+
let k = 0;
|
|
11467
|
+
for (const name of names) {
|
|
11468
|
+
if (out.has(name))
|
|
11469
|
+
continue;
|
|
11470
|
+
out.set(name, cellColors.get(name) ?? palette[k++ % palette.length]);
|
|
11471
|
+
}
|
|
11472
|
+
return out;
|
|
11473
|
+
}
|
|
11474
|
+
/** The cluster a selected gene is in: its first gene-tree group holding it, else itself. */
|
|
11475
|
+
function clusterOfGene(gene, groups) {
|
|
11476
|
+
return groups.find((g) => g.genes.includes(gene))?.name ?? gene;
|
|
11477
|
+
}
|
|
11323
11478
|
/** Smallest and largest transcript marker, in canvas pixels. */
|
|
11324
11479
|
const TRANSCRIPT_MIN_PX = 4;
|
|
11325
11480
|
const TRANSCRIPT_MAX_PX = 32;
|
|
@@ -11820,7 +11975,7 @@ class SpatialControlsComponent {
|
|
|
11820
11975
|
{ label: 'Fill', value: 'fill' }, { label: 'Outline', value: 'outline' }, { label: 'Both', value: 'both' },
|
|
11821
11976
|
];
|
|
11822
11977
|
this.transcriptColorOptions = [
|
|
11823
|
-
{ label: 'Cell type', value: 'cellType' }, { label: 'Gene', value: 'gene' },
|
|
11978
|
+
{ label: 'Cluster', value: 'cluster' }, { label: 'Cell type', value: 'cellType' }, { label: 'Gene', value: 'gene' },
|
|
11824
11979
|
];
|
|
11825
11980
|
this.glyphOptions = GLYPH_OPTIONS;
|
|
11826
11981
|
this.budgetOptions = [25_000, 50_000, 100_000, 200_000, 400_000]
|
|
@@ -11872,10 +12027,27 @@ class SpatialControlsComponent {
|
|
|
11872
12027
|
this.geneMenu = [];
|
|
11873
12028
|
this.collapsedGeneGroups = new Set();
|
|
11874
12029
|
this.geneTreeOpen = true;
|
|
12030
|
+
// ── per-gene icon and colour picker ──────────────────────────────────────
|
|
12031
|
+
/** Swatches in the picker: the categorical palette genes are coloured from by default. */
|
|
12032
|
+
this.colorPresets = DEFAULT_CATEGORICAL_PALETTE.slice(0, 10).map((c) => c.toLowerCase());
|
|
12033
|
+
/** The gene the open picker edits. */
|
|
12034
|
+
this.styleGene = null;
|
|
11875
12035
|
this.trackByLabel = (_i, row) => row.label;
|
|
11876
12036
|
this.trackByNode = (_i, node) => node.name ?? '';
|
|
11877
12037
|
this.trackByLabelString = (_i, s) => s;
|
|
11878
12038
|
this.geneGroupError = null;
|
|
12039
|
+
// ── per-gene counts in view ─────────────────────────────────────────────
|
|
12040
|
+
/** Transcripts of each selected gene in the current view, from the renderer. */
|
|
12041
|
+
this.geneCounts = null;
|
|
12042
|
+
// ── marker genes of clusters, as gene groups ────────────────────────────
|
|
12043
|
+
this.markersOpen = false;
|
|
12044
|
+
this.markerColumn = null;
|
|
12045
|
+
this.markerPerGroup = 5;
|
|
12046
|
+
this.markerPerGroupOptions = [3, 5, 10, 20].map((n) => ({ label: `${n} genes`, value: n }));
|
|
12047
|
+
this.markerClusters = [];
|
|
12048
|
+
this.markerClusterOptions = [];
|
|
12049
|
+
this.markerLoading = false;
|
|
12050
|
+
this.markerError = null;
|
|
11879
12051
|
// density
|
|
11880
12052
|
this.densityBins = [10, 20, 40, 80];
|
|
11881
12053
|
/**
|
|
@@ -11888,6 +12060,9 @@ class SpatialControlsComponent {
|
|
|
11888
12060
|
this.densitySliderMax = 1;
|
|
11889
12061
|
this.densityStep = 0.005;
|
|
11890
12062
|
this.selectedDensityColormapNode = null;
|
|
12063
|
+
/** Colours of the cells' groups, by name — what a cluster of the same name is drawn in. */
|
|
12064
|
+
this.cellGroupColors = new Map();
|
|
12065
|
+
this.cellGroupColorsFor = null;
|
|
11891
12066
|
}
|
|
11892
12067
|
ngOnInit() {
|
|
11893
12068
|
this.controls = this.viz.getSpatialControls?.() ?? null;
|
|
@@ -11928,10 +12103,14 @@ class SpatialControlsComponent {
|
|
|
11928
12103
|
this.refreshDensityWindow();
|
|
11929
12104
|
void this.refreshKey();
|
|
11930
12105
|
void this.refreshGroups();
|
|
12106
|
+
void this.refreshCellGroupColors();
|
|
11931
12107
|
}));
|
|
11932
12108
|
const estimate$ = this.controls.getTranscriptEstimate$?.();
|
|
11933
12109
|
if (estimate$)
|
|
11934
12110
|
this.subs.add(estimate$.subscribe((e) => this.zone.run(() => { this.estimate = e; })));
|
|
12111
|
+
const counts$ = this.controls.getGeneCountsInView$?.();
|
|
12112
|
+
if (counts$)
|
|
12113
|
+
this.subs.add(counts$.subscribe((c) => this.zone.run(() => { this.geneCounts = c; })));
|
|
11935
12114
|
const density$ = this.controls.getDensityStats$?.();
|
|
11936
12115
|
if (density$) {
|
|
11937
12116
|
this.subs.add(density$.subscribe((d) => this.zone.run(() => {
|
|
@@ -12567,6 +12746,38 @@ class SpatialControlsComponent {
|
|
|
12567
12746
|
onGeneColor(gene, hex) {
|
|
12568
12747
|
this.controls?.setViewState({ transcriptGeneColors: { ...this.view.transcriptGeneColors, [gene]: hex } });
|
|
12569
12748
|
}
|
|
12749
|
+
openGeneStyle(event, gene, panel) {
|
|
12750
|
+
if (this.styleGene === gene) {
|
|
12751
|
+
panel.toggle(event);
|
|
12752
|
+
return;
|
|
12753
|
+
}
|
|
12754
|
+
this.styleGene = gene;
|
|
12755
|
+
panel.hide();
|
|
12756
|
+
// Re-open anchored on the row that was clicked, once the panel has closed.
|
|
12757
|
+
setTimeout(() => panel.toggle(event));
|
|
12758
|
+
}
|
|
12759
|
+
/** The icon the open picker shows as chosen. */
|
|
12760
|
+
get styleGlyph() {
|
|
12761
|
+
const gene = this.styleGene;
|
|
12762
|
+
return gene ? this.glyphOf(gene, Math.max(0, this.view.transcriptGenes.indexOf(gene))) : null;
|
|
12763
|
+
}
|
|
12764
|
+
glyphPoints(glyph) {
|
|
12765
|
+
return GLYPH_OPTIONS.find((o) => o.value === glyph)?.points ?? '';
|
|
12766
|
+
}
|
|
12767
|
+
/** A typed hex colour, accepted as `#rrggbb` or `rrggbb`; anything else is ignored. */
|
|
12768
|
+
onGeneHex(gene, text) {
|
|
12769
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(text.trim());
|
|
12770
|
+
if (m)
|
|
12771
|
+
this.onGeneColor(gene, `#${m[1].toLowerCase()}`);
|
|
12772
|
+
}
|
|
12773
|
+
/** Back to the gene's default icon and colour (by its position in the list). */
|
|
12774
|
+
resetGeneStyle(gene) {
|
|
12775
|
+
const colors = { ...this.view.transcriptGeneColors };
|
|
12776
|
+
const glyphs = { ...this.view.transcriptGlyphs };
|
|
12777
|
+
delete colors[gene];
|
|
12778
|
+
delete glyphs[gene];
|
|
12779
|
+
this.controls?.setViewState({ transcriptGeneColors: colors, transcriptGlyphs: glyphs });
|
|
12780
|
+
}
|
|
12570
12781
|
/** '±': every gene, or back to the chosen list. */
|
|
12571
12782
|
onToggleAllGenes() {
|
|
12572
12783
|
if (!this.canShowAllGenes)
|
|
@@ -12576,6 +12787,10 @@ class SpatialControlsComponent {
|
|
|
12576
12787
|
/** '⋮' menu. */
|
|
12577
12788
|
buildGeneMenu() {
|
|
12578
12789
|
return [
|
|
12790
|
+
{
|
|
12791
|
+
label: 'Add marker genes of clusters…', icon: 'pi pi-sitemap',
|
|
12792
|
+
disabled: !this.canAddMarkers, command: () => this.openMarkers(),
|
|
12793
|
+
},
|
|
12579
12794
|
{
|
|
12580
12795
|
label: 'New group from selected genes', icon: 'pi pi-folder-plus',
|
|
12581
12796
|
disabled: !this.view.transcriptGenes.length, command: () => this.onNewGeneGroup(),
|
|
@@ -12601,6 +12816,104 @@ class SpatialControlsComponent {
|
|
|
12601
12816
|
get densityOpacityPercent() {
|
|
12602
12817
|
return Math.round(this.view.densityOpacity * 100);
|
|
12603
12818
|
}
|
|
12819
|
+
geneCountOf(gene) {
|
|
12820
|
+
return this.geneCounts ? (this.geneCounts[gene] ?? 0) : null;
|
|
12821
|
+
}
|
|
12822
|
+
get canAddMarkers() {
|
|
12823
|
+
return !!this.controls?.markerGenes && this.markerColumnOptions.length > 0;
|
|
12824
|
+
}
|
|
12825
|
+
/** Every categorical column but the segmentation method, which says nothing about genes. */
|
|
12826
|
+
get markerColumnOptions() {
|
|
12827
|
+
return (this.dataset?.columns ?? [])
|
|
12828
|
+
.filter((c) => c.kind === 'categorical' && c.name !== 'segmentation_method')
|
|
12829
|
+
.map((c) => ({ label: c.description && c.section ? c.description : this.columnLabel(c), value: c.name }));
|
|
12830
|
+
}
|
|
12831
|
+
openMarkers() {
|
|
12832
|
+
const options = this.markerColumnOptions;
|
|
12833
|
+
const active = this.activeCellTypeColumn;
|
|
12834
|
+
if (!this.markerColumn || !options.some((o) => o.value === this.markerColumn)) {
|
|
12835
|
+
this.markerColumn = active && options.some((o) => o.value === active) ? active : options[0]?.value ?? null;
|
|
12836
|
+
}
|
|
12837
|
+
this.markerError = null;
|
|
12838
|
+
this.refreshMarkerClusters();
|
|
12839
|
+
this.markersOpen = true;
|
|
12840
|
+
this.open = { ...this.open, transcripts: true };
|
|
12841
|
+
}
|
|
12842
|
+
onMarkerColumn(column) {
|
|
12843
|
+
this.markerColumn = column;
|
|
12844
|
+
this.refreshMarkerClusters();
|
|
12845
|
+
}
|
|
12846
|
+
/** The clusters of the chosen column, all picked to start with. */
|
|
12847
|
+
refreshMarkerClusters() {
|
|
12848
|
+
const col = this.dataset?.columns.find((c) => c.name === this.markerColumn);
|
|
12849
|
+
const categories = col && col.kind === 'categorical' ? col.categories : [];
|
|
12850
|
+
this.markerClusterOptions = categories.map((c) => ({ label: c, value: c }));
|
|
12851
|
+
this.markerClusters = [...categories];
|
|
12852
|
+
}
|
|
12853
|
+
/**
|
|
12854
|
+
* Add each picked cluster's top marker genes as a gene group named after it, and select
|
|
12855
|
+
* them. A gene that marks several clusters goes to the one it is most specific to, so the
|
|
12856
|
+
* tree lists it once.
|
|
12857
|
+
*/
|
|
12858
|
+
async addMarkerGenes() {
|
|
12859
|
+
const column = this.markerColumn;
|
|
12860
|
+
const markerGenes = this.controls?.markerGenes;
|
|
12861
|
+
if (!column || !markerGenes || !this.markerClusters.length)
|
|
12862
|
+
return;
|
|
12863
|
+
// The form stays editable while the scan runs: apply what was picked when it was asked.
|
|
12864
|
+
const picked = new Set(this.markerClusters);
|
|
12865
|
+
this.markerLoading = true;
|
|
12866
|
+
this.markerError = null;
|
|
12867
|
+
try {
|
|
12868
|
+
const result = await markerGenes(column, this.markerPerGroup);
|
|
12869
|
+
this.zone.run(() => {
|
|
12870
|
+
const best = new Map();
|
|
12871
|
+
for (const g of result.groups) {
|
|
12872
|
+
if (!picked.has(g.name))
|
|
12873
|
+
continue;
|
|
12874
|
+
for (const gene of g.genes) {
|
|
12875
|
+
const prev = best.get(gene.name);
|
|
12876
|
+
if (!prev || gene.score > prev.score)
|
|
12877
|
+
best.set(gene.name, { group: g.name, score: gene.score });
|
|
12878
|
+
}
|
|
12879
|
+
}
|
|
12880
|
+
const groups = result.groups
|
|
12881
|
+
.filter((g) => picked.has(g.name))
|
|
12882
|
+
.map((g) => ({
|
|
12883
|
+
name: g.name,
|
|
12884
|
+
genes: g.genes.map((x) => x.name).filter((n) => best.get(n)?.group === g.name),
|
|
12885
|
+
}))
|
|
12886
|
+
.filter((g) => g.genes.length);
|
|
12887
|
+
if (!groups.length) {
|
|
12888
|
+
this.markerError = 'No marker genes passed the filter for the chosen clusters.';
|
|
12889
|
+
return;
|
|
12890
|
+
}
|
|
12891
|
+
const names = new Set(groups.map((g) => g.name));
|
|
12892
|
+
const genes = [...this.view.transcriptGenes];
|
|
12893
|
+
for (const g of groups)
|
|
12894
|
+
for (const n of g.genes)
|
|
12895
|
+
if (!genes.includes(n))
|
|
12896
|
+
genes.push(n);
|
|
12897
|
+
this.controls?.setViewState({
|
|
12898
|
+
transcriptGeneGroups: [...this.view.transcriptGeneGroups.filter((g) => !names.has(g.name)), ...groups],
|
|
12899
|
+
transcriptGenes: genes,
|
|
12900
|
+
// Marker groups are clusters: colour the transcripts by them, as their cells are.
|
|
12901
|
+
transcriptColorBy: 'cluster',
|
|
12902
|
+
...(this.view.transcriptMode === 'off' ? { transcriptMode: 'circles' } : {}),
|
|
12903
|
+
});
|
|
12904
|
+
this.markersOpen = false;
|
|
12905
|
+
});
|
|
12906
|
+
}
|
|
12907
|
+
catch (err) {
|
|
12908
|
+
this.zone.run(() => {
|
|
12909
|
+
const e = err;
|
|
12910
|
+
this.markerError = e?.error?.error ?? e?.message ?? 'Could not compute marker genes.';
|
|
12911
|
+
});
|
|
12912
|
+
}
|
|
12913
|
+
finally {
|
|
12914
|
+
this.zone.run(() => { this.markerLoading = false; });
|
|
12915
|
+
}
|
|
12916
|
+
}
|
|
12604
12917
|
onNewGeneGroup() {
|
|
12605
12918
|
const name = (globalThis.prompt?.('Name for this gene group', 'Gene group') ?? '').trim();
|
|
12606
12919
|
if (!name)
|
|
@@ -12748,6 +13061,35 @@ class SpatialControlsComponent {
|
|
|
12748
13061
|
return (gene && this.view.transcriptGeneColors[gene])
|
|
12749
13062
|
|| DEFAULT_CATEGORICAL_PALETTE[slot % DEFAULT_CATEGORICAL_PALETTE.length];
|
|
12750
13063
|
}
|
|
13064
|
+
async refreshCellGroupColors() {
|
|
13065
|
+
const column = this.activeCellTypeColumn;
|
|
13066
|
+
if (column === this.cellGroupColorsFor)
|
|
13067
|
+
return;
|
|
13068
|
+
this.cellGroupColorsFor = column;
|
|
13069
|
+
const map = new Map();
|
|
13070
|
+
const meta = column ? this.dataset?.columns.find((c) => c.name === column) : null;
|
|
13071
|
+
if (meta && meta.kind === 'categorical' && this.controls) {
|
|
13072
|
+
try {
|
|
13073
|
+
const colors = await this.controls.categoryColors(column);
|
|
13074
|
+
meta.categories.forEach((c, k) => { if (colors[k])
|
|
13075
|
+
map.set(c, colors[k]); });
|
|
13076
|
+
}
|
|
13077
|
+
catch {
|
|
13078
|
+
// No colours: palette colours, as the markers fall back to.
|
|
13079
|
+
}
|
|
13080
|
+
}
|
|
13081
|
+
// The cells' grouping changed while the colours loaded: the newer request applies its own.
|
|
13082
|
+
if (this.cellGroupColorsFor !== column)
|
|
13083
|
+
return;
|
|
13084
|
+
this.zone.run(() => { this.cellGroupColors = map; });
|
|
13085
|
+
}
|
|
13086
|
+
/** In Cluster colouring, a gene's swatch is its cluster's colour, as its markers are. */
|
|
13087
|
+
geneSwatchOf(gene) {
|
|
13088
|
+
if (this.view.transcriptColorBy !== 'cluster')
|
|
13089
|
+
return this.geneColorOf(gene);
|
|
13090
|
+
const colors = clusterColorMap(this.view.transcriptGenes, this.view.transcriptGeneGroups, this.cellGroupColors, DEFAULT_CATEGORICAL_PALETTE);
|
|
13091
|
+
return colors.get(clusterOfGene(gene, this.view.transcriptGeneGroups)) ?? this.geneColorOf(gene);
|
|
13092
|
+
}
|
|
12751
13093
|
geneColorOf(gene) {
|
|
12752
13094
|
return this.geneColor(Math.max(0, this.view.transcriptGenes.indexOf(gene)));
|
|
12753
13095
|
}
|
|
@@ -12872,11 +13214,11 @@ class SpatialControlsComponent {
|
|
|
12872
13214
|
return `linear-gradient(to right, ${stops.join(', ')})`;
|
|
12873
13215
|
}
|
|
12874
13216
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.9", ngImport: i0, type: SpatialControlsComponent, deps: [{ token: VISUALIZER }, { token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
12875
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.9", type: SpatialControlsComponent, selector: "spatial-controls", inputs: { visible: "visible", is3d: "is3d" }, outputs: { visibleChange: "visibleChange" }, viewQueries: [{ propertyName: "charts", first: true, predicate: SpatialChartsComponent, descendants: true }, { propertyName: "geneGroupFileRef", first: true, predicate: ["geneGroupFile"], descendants: true }], ngImport: i0, template: "<p-dialog header=\"Spatial omics\"\n [visible]=\"visible\" (visibleChange)=\"onVisibleChange($event)\"\n [modal]=\"false\" [resizable]=\"true\" [draggable]=\"true\"\n position=\"left\"\n (onResizeEnd)=\"onResizeEnd()\"\n [style]=\"{ width: '30rem' }\"\n [breakpoints]=\"{ '960px': '90vw' }\"\n styleClass=\"spatial-controls-dialog\">\n\n <!-- No port bound: say why rather than showing controls that do nothing. -->\n <div class=\"sc-empty\" *ngIf=\"!controls\">\n No spatial-omics data source is configured. A host provides one by binding\n <code>SPATIAL_DATA_PORT</code>.\n </div>\n\n <div class=\"sc-empty\" *ngIf=\"controls && !dataset\">\n No dataset is loaded.\n </div>\n\n <div class=\"sc-body\" *ngIf=\"controls && dataset as ds\">\n\n <div class=\"sc-dataset\">\n <span class=\"sc-dataset-name\">{{ ds.name }}</span>\n <span class=\"sc-dataset-count\">{{ ds.observations.count | number }} observations</span>\n </div>\n\n <!-- Organised as Xenium Explorer's side panel: one collapsible section per layer,\n each with its own on/off switch in the header. -->\n\n <!-- Images -->\n <div class=\"sc-panel\" *ngIf=\"ds.imageRef && !is3d\" [class.open]=\"open.images\">\n <div class=\"sc-panel-head\" (click)=\"toggleSection('images')\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.showImage\" (click)=\"$event.stopPropagation()\"\n (onChange)=\"onShowImage($event.checked)\"></p-checkbox>\n <span class=\"sc-panel-title\">Images</span>\n <i class=\"pi\" [class.pi-chevron-down]=\"open.images\" [class.pi-chevron-right]=\"!open.images\"></i>\n </div>\n <div class=\"sc-panel-body\" *ngIf=\"open.images\">\n <small class=\"sc-hint\">\n Channels, colours and contrast are in the Channels & Histogram dialog.\n </small>\n </div>\n </div>\n\n <!-- Cells -->\n <div class=\"sc-panel\" *ngIf=\"hasCells && !is3d\" [class.open]=\"open.cells\">\n <div class=\"sc-panel-head\" (click)=\"toggleSection('cells')\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"cellsOn\" (click)=\"$event.stopPropagation()\"\n (onChange)=\"onShowCells($event.checked)\"></p-checkbox>\n <span class=\"sc-panel-title\">Cells</span>\n <i class=\"pi\" [class.pi-chevron-down]=\"open.cells\" [class.pi-chevron-right]=\"!open.cells\"></i>\n </div>\n <div class=\"sc-panel-body\" *ngIf=\"open.cells\">\n <ng-container *ngIf=\"cellSetOptions.length > 1\">\n <div class=\"sc-subhead\">Boundaries</div>\n <p-selectButton [options]=\"cellSetOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"activeCellSet\" (ngModelChange)=\"onCellSet($event)\"\n [allowEmpty]=\"false\" styleClass=\"sc-buttons\"></p-selectButton>\n </ng-container>\n\n <div class=\"sc-subhead\">Coloring</div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Cell Color</span>\n <p-dropdown [options]=\"cellColorOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.cellColorMode\" (ngModelChange)=\"onCellColorMode($event)\"\n appendTo=\"body\"></p-dropdown>\n </div>\n <small class=\"sc-hint\" *ngIf=\"view.cellColorMode === 'group'\">\n Adjust cell color in the groups section below.\n </small>\n <div class=\"sc-row\" *ngIf=\"view.cellColorMode === 'gene' && ds.features\">\n <span class=\"sc-lbl\">Gene</span>\n <p-dropdown [options]=\"geneOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.cellColorGene\" (ngModelChange)=\"onCellColorGene($event)\"\n [filter]=\"true\" filterBy=\"label\" [resetFilterOnHide]=\"true\"\n (onFilter)=\"onGeneFilter($event.filter)\" (onShow)=\"ensureGeneList()\"\n [virtualScroll]=\"geneOptions.length > geneVirtualScrollFrom\"\n [virtualScrollItemSize]=\"30\" appendTo=\"body\"\n placeholder=\"Search genes\u2026\"></p-dropdown>\n </div>\n <div class=\"sc-row\" *ngIf=\"view.cellColorMode === 'single'\">\n <span class=\"sc-lbl\">Color</span>\n <input type=\"color\" class=\"sc-color-input\" [value]=\"view.cellSingleColor\"\n (change)=\"onCellSingleColor($any($event.target).value)\" />\n </div>\n <div *ngIf=\"view.cellColorMode === 'gene' || view.cellColorMode === 'transcriptDensity'\">\n <div class=\"sc-colorbar\" [style.background]=\"colorBarCss || densityColorBarCss\"></div>\n </div>\n\n <ng-container *ngIf=\"groupOptions.length\">\n <div class=\"sc-subhead\">Groups</div>\n <div class=\"sc-row sc-row-picker\">\n <p-dropdown [options]=\"groupOptions\" [group]=\"true\" optionLabel=\"label\" optionValue=\"value\"\n optionGroupLabel=\"label\" optionGroupChildren=\"items\"\n [ngModel]=\"activeGroupEntry\" (ngModelChange)=\"onGroupEntry($event)\"\n appendTo=\"body\" styleClass=\"sc-full\">\n <ng-template let-group pTemplate=\"group\">\n <span class=\"sc-group-heading\">{{ group.label }}</span>\n </ng-template>\n </p-dropdown>\n <p-dropdown *ngIf=\"groupVariantOptions.length\" [options]=\"groupVariantOptions\"\n optionLabel=\"label\" optionValue=\"value\" styleClass=\"sc-variant\"\n [ngModel]=\"activeCellTypeColumn\" (ngModelChange)=\"onGroupVariant($event)\"\n appendTo=\"body\"></p-dropdown>\n <button *ngIf=\"canImportGroups\" type=\"button\" class=\"sc-icon-btn\" [disabled]=\"groupImporting\"\n (click)=\"groupFile.click()\" title=\"Import cell groups (CSV of cell_id, group)\">\n <i class=\"pi\" [class.pi-plus-circle]=\"!groupImporting\" [class.pi-spin]=\"groupImporting\"\n [class.pi-spinner]=\"groupImporting\"></i>\n </button>\n <input #groupFile type=\"file\" accept=\".csv,.tsv,.txt\" hidden\n (change)=\"onImportGroupsFile(groupFile)\" />\n </div>\n <small class=\"sc-hint sc-error\" *ngIf=\"groupImportError\">{{ groupImportError }}</small>\n\n <div class=\"sc-group-list\" *ngIf=\"groupRows.length\">\n <div class=\"sc-group-row sc-group-all\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"allGroupsShown\"\n (onChange)=\"onAllGroupsShown($event.checked)\"></p-checkbox>\n <span class=\"sc-group-label\">All groups</span>\n <span class=\"sc-count\">({{ groupTotal | number }})</span>\n <button type=\"button\" class=\"sc-icon-btn\" (click)=\"groupsExpanded = !groupsExpanded\">\n <i class=\"pi\" [class.pi-chevron-down]=\"groupsExpanded\" [class.pi-chevron-right]=\"!groupsExpanded\"></i>\n </button>\n </div>\n <ng-container *ngIf=\"groupsExpanded\">\n <div class=\"sc-group-row\" *ngFor=\"let row of groupRows; trackBy: trackByLabel\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"isGroupShown(row.label)\"\n (onChange)=\"onGroupShown(row.label, $event.checked)\"></p-checkbox>\n <span class=\"sc-dot\" [style.background]=\"row.color\"></span>\n <span class=\"sc-group-label\" [title]=\"row.label\">{{ row.label }}</span>\n <span class=\"sc-count\">({{ row.count | number }})</span>\n </div>\n </ng-container>\n </div>\n </ng-container>\n\n <div class=\"sc-subhead\">Visibility</div>\n <span class=\"sc-lbl\">View cells as</span>\n <p-selectButton [options]=\"cellDrawOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.cellDraw\" (ngModelChange)=\"onCellDraw($event)\"\n [allowEmpty]=\"false\" styleClass=\"sc-buttons\"></p-selectButton>\n <ng-container *ngIf=\"view.cellDraw !== 'outline'\">\n <span class=\"sc-lbl\">Cell fill opacity</span>\n <div class=\"sc-row sc-row-number\">\n <p-slider [ngModel]=\"view.cellOpacity\" [min]=\"0.05\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onCellOpacity($event.value)\"></p-slider>\n <p-inputNumber [ngModel]=\"cellOpacityPercent\" (ngModelChange)=\"onCellOpacityPercent($event)\"\n [min]=\"5\" [max]=\"100\" [showButtons]=\"false\" inputStyleClass=\"sc-num\"></p-inputNumber>\n </div>\n </ng-container>\n </div>\n </div>\n\n <!-- Transcripts -->\n <div class=\"sc-panel\" *ngIf=\"hasTranscripts && !is3d\" [class.open]=\"open.transcripts\">\n <div class=\"sc-panel-head\" (click)=\"toggleSection('transcripts')\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.transcriptMode !== 'off'\" (click)=\"$event.stopPropagation()\"\n (onChange)=\"onTranscriptsOn($event.checked)\"></p-checkbox>\n <span class=\"sc-panel-title\">Transcripts</span>\n <i class=\"pi\" [class.pi-chevron-down]=\"open.transcripts\" [class.pi-chevron-right]=\"!open.transcripts\"></i>\n </div>\n <div class=\"sc-panel-body\" *ngIf=\"open.transcripts\">\n <div class=\"sc-row sc-row-picker\">\n <p-multiSelect inputId=\"sc-tx-genes\" [options]=\"geneOptions\"\n optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.transcriptGenes\" (onChange)=\"onTranscriptGenes($event.value)\"\n [filter]=\"true\" filterBy=\"label\" [resetFilterOnHide]=\"true\"\n (onFilter)=\"onGeneFilter($event.filter)\" (onPanelShow)=\"ensureGeneList()\"\n [virtualScroll]=\"geneOptions.length > geneVirtualScrollFrom\"\n [virtualScrollItemSize]=\"30\" [showToggleAll]=\"false\"\n [emptyFilterMessage]=\"genesAreRemote ? 'No match \u2014 keep typing' : 'No match'\"\n [emptyMessage]=\"genesAreRemote ? 'Type to search genes' : 'No genes'\"\n appendTo=\"body\" placeholder=\"Search for genes to visualize\u2026\"\n [selectedItemsLabel]=\"'{0} genes selected'\" [maxSelectedLabels]=\"0\"\n styleClass=\"sc-full\"></p-multiSelect>\n <button type=\"button\" class=\"sc-icon-btn\" *ngIf=\"canShowAllGenes\" (click)=\"onToggleAllGenes()\"\n [class.active]=\"view.transcriptAllGenes\"\n [title]=\"view.transcriptAllGenes ? 'Back to the selected genes' : 'Select all genes'\">\u00B1</button>\n <button type=\"button\" class=\"sc-icon-btn\" (click)=\"geneMenuRef.toggle($event)\" title=\"More\">\n <i class=\"pi pi-ellipsis-v\"></i>\n </button>\n <p-menu #geneMenuRef [model]=\"geneMenu\" [popup]=\"true\" appendTo=\"body\"></p-menu>\n <input #geneGroupFile type=\"file\" accept=\".csv,.tsv,.txt\" hidden\n (change)=\"onImportGeneGroups(geneGroupFile)\" />\n </div>\n <small class=\"sc-hint sc-error\" *ngIf=\"geneGroupError\">{{ geneGroupError }}</small>\n <small class=\"sc-hint\" *ngIf=\"allGenesPreparing\">{{ allGenesPreparing }}</small>\n\n <div class=\"sc-subhead\">Selected genes</div>\n <div class=\"sc-gene-tree\">\n <div class=\"sc-gene-row sc-gene-root\">\n <button type=\"button\" class=\"sc-eye\"\n (click)=\"view.transcriptAllGenes ? onToggleAllGenes() : onGenesShown(view.transcriptGenes, !areGenesShown(view.transcriptGenes))\">\n <i class=\"pi\" [class.pi-eye]=\"view.transcriptAllGenes || areGenesShown(view.transcriptGenes)\"\n [class.pi-eye-slash]=\"!view.transcriptAllGenes && !areGenesShown(view.transcriptGenes)\"></i>\n </button>\n <span class=\"sc-group-label\">Genes</span>\n <span class=\"sc-count\">({{ (view.transcriptAllGenes ? geneTotal : view.transcriptGenes.length) | number }}/{{ geneTotal | number }})</span>\n <button type=\"button\" class=\"sc-icon-btn\" (click)=\"geneTreeOpen = !geneTreeOpen\"\n *ngIf=\"!view.transcriptAllGenes && view.transcriptGenes.length\">\n <i class=\"pi\" [class.pi-chevron-down]=\"geneTreeOpen\" [class.pi-chevron-right]=\"!geneTreeOpen\"></i>\n </button>\n </div>\n <ng-container *ngIf=\"geneTreeOpen && !view.transcriptAllGenes\">\n <ng-container *ngFor=\"let node of geneTree; trackBy: trackByNode\">\n <div class=\"sc-gene-row sc-gene-group\" *ngIf=\"node.name\">\n <button type=\"button\" class=\"sc-eye\" (click)=\"onGenesShown(node.genes, !areGenesShown(node.genes))\">\n <i class=\"pi\" [class.pi-eye]=\"areGenesShown(node.genes)\" [class.pi-eye-slash]=\"!areGenesShown(node.genes)\"></i>\n </button>\n <span class=\"sc-group-label\" [title]=\"node.name\">{{ node.name }}</span>\n <span class=\"sc-count\">({{ node.genes.length }})</span>\n <button type=\"button\" class=\"sc-icon-btn\" (click)=\"toggleGeneGroup(node.name)\">\n <i class=\"pi\" [class.pi-chevron-down]=\"!collapsedGeneGroups.has(node.name)\"\n [class.pi-chevron-right]=\"collapsedGeneGroups.has(node.name)\"></i>\n </button>\n </div>\n <ng-container *ngIf=\"!node.name || !collapsedGeneGroups.has(node.name)\">\n <div class=\"sc-gene-row sc-gene-leaf\" [class.nested]=\"!!node.name\"\n *ngFor=\"let gene of node.genes; trackBy: trackByLabelString\">\n <button type=\"button\" class=\"sc-eye\" (click)=\"onGenesShown([gene], !isGeneShown(gene))\">\n <i class=\"pi\" [class.pi-eye]=\"isGeneShown(gene)\" [class.pi-eye-slash]=\"!isGeneShown(gene)\"></i>\n </button>\n <label class=\"sc-dot sc-dot-pick\" [style.background]=\"geneColorOf(gene)\" title=\"Colour\">\n <input type=\"color\" [value]=\"geneColorOf(gene)\" (change)=\"onGeneColor(gene, $any($event.target).value)\" />\n </label>\n <span class=\"sc-group-label\">{{ gene }}</span>\n <p-dropdown *ngIf=\"view.transcriptMode === 'glyphs'\"\n [options]=\"glyphOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"glyphOf(gene, view.transcriptGenes.indexOf(gene))\"\n (ngModelChange)=\"onGlyph(gene, $event)\"\n appendTo=\"body\" styleClass=\"sc-glyph-pick\">\n <ng-template let-opt pTemplate=\"item\">\n <span class=\"sc-glyph-option\">\n <svg viewBox=\"-1.2 -1.2 2.4 2.4\" class=\"sc-glyph-svg\"><polygon [attr.points]=\"opt.points\"></polygon></svg>\n {{ opt.label }}\n </span>\n </ng-template>\n <ng-template let-opt pTemplate=\"selectedItem\">\n <span class=\"sc-glyph-option\">\n <svg viewBox=\"-1.2 -1.2 2.4 2.4\" class=\"sc-glyph-svg\"><polygon [attr.points]=\"opt?.points\"></polygon></svg>\n </span>\n </ng-template>\n </p-dropdown>\n </div>\n </ng-container>\n </ng-container>\n </ng-container>\n <div class=\"sc-gene-footer\">\n {{ (view.transcriptAllGenes ? geneTotal : view.transcriptGenes.length) | number }}/{{ geneTotal | number }} genes selected\n </div>\n </div>\n\n <div class=\"sc-subhead\">Transcript controls</div>\n <span class=\"sc-lbl\">View transcripts as</span>\n <p-selectButton [options]=\"transcriptModeOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.transcriptMode === 'off' ? null : view.transcriptMode\"\n (ngModelChange)=\"onTranscriptMode($event)\"\n [allowEmpty]=\"false\" styleClass=\"sc-buttons\"></p-selectButton>\n\n <ng-container *ngIf=\"estimate\">\n <div class=\"sc-row\">\n <span class=\"sc-lbl\" pTooltip=\"Transcripts of the visible genes estimated to be in view, against the most markers drawn at once\"\n tooltipPosition=\"top\">Estimated Transcript Points</span>\n <button type=\"button\" class=\"sc-small-btn\" (click)=\"editingMax = !editingMax\">Edit Max</button>\n </div>\n <div class=\"sc-meter\"><div class=\"sc-meter-fill\" [class.over]=\"estimateOverMax\" [style.width.%]=\"estimatePercent\"></div></div>\n <div class=\"sc-meter-ends\">\n <span>{{ estimate.points | number }} points</span>\n <span>{{ estimate.max | number }} max{{ estimateOverMax ? ' reached' : '' }}</span>\n </div>\n <div class=\"sc-row\" *ngIf=\"editingMax\">\n <span class=\"sc-lbl\">Max markers</span>\n <p-dropdown [options]=\"budgetOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.transcriptBudget\" (ngModelChange)=\"onTranscriptBudget($event)\"\n appendTo=\"body\"></p-dropdown>\n </div>\n <small class=\"sc-hint\" *ngIf=\"estimateOverMax && view.transcriptMode !== 'density'\">\n Max points reached: transcripts are grouped by area, circles sized by how many they hold.\n Zoom in or select fewer genes to see individual points.\n </small>\n </ng-container>\n\n <ng-container *ngIf=\"view.transcriptMode === 'circles' || view.transcriptMode === 'glyphs'\">\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Colour by</span>\n <p-selectButton [options]=\"transcriptColorOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.transcriptColorBy\" (ngModelChange)=\"onTranscriptColorBy($event)\"\n [allowEmpty]=\"false\" styleClass=\"sc-buttons\"></p-selectButton>\n </div>\n <small class=\"sc-hint\" *ngIf=\"view.transcriptAllGenes && view.transcriptColorBy === 'gene' && estimateOverMax\">\n Grouped markers mix genes, so they are coloured by cell type until you zoom in to individual transcripts.\n </small>\n <div class=\"sc-row\" *ngIf=\"view.transcriptAllGenes && view.transcriptMode === 'glyphs'\">\n <span class=\"sc-lbl\">Icon</span>\n <p-dropdown [options]=\"glyphOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"glyphOf('*', 0)\" (ngModelChange)=\"onGlyph('*', $event)\"\n appendTo=\"body\" styleClass=\"sc-glyph-pick\">\n <ng-template let-opt pTemplate=\"item\">\n <span class=\"sc-glyph-option\">\n <svg viewBox=\"-1.2 -1.2 2.4 2.4\" class=\"sc-glyph-svg\"><polygon [attr.points]=\"opt.points\"></polygon></svg>\n {{ opt.label }}\n </span>\n </ng-template>\n <ng-template let-opt pTemplate=\"selectedItem\">\n <span class=\"sc-glyph-option\">\n <svg viewBox=\"-1.2 -1.2 2.4 2.4\" class=\"sc-glyph-svg\"><polygon [attr.points]=\"opt?.points\"></polygon></svg>\n </span>\n </ng-template>\n </p-dropdown>\n </div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Size</span>\n <p-slider [ngModel]=\"view.transcriptScale\" [min]=\"0.5\" [max]=\"3\" [step]=\"0.25\"\n (onChange)=\"onTranscriptScale($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.transcriptScale }}\u00D7</span>\n </div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Opacity</span>\n <p-slider [ngModel]=\"view.transcriptOpacity\" [min]=\"0.1\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onTranscriptOpacity($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.transcriptOpacity }}</span>\n </div>\n <div class=\"sc-row\" *ngIf=\"!showingAllGenes\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.transcriptQuality === 'all'\" inputId=\"sc-tx-lowq\"\n (onChange)=\"onTranscriptQuality($event.checked)\"></p-checkbox>\n <label class=\"sc-lbl\" for=\"sc-tx-lowq\">Include low-quality calls</label>\n </div>\n </ng-container>\n\n <ng-container *ngIf=\"view.transcriptMode === 'density'\">\n <span class=\"sc-lbl\">Density map opacity</span>\n <div class=\"sc-row sc-row-number\">\n <p-slider [ngModel]=\"view.densityOpacity\" [min]=\"0.05\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onDensityOpacity($event.value)\"></p-slider>\n <p-inputNumber [ngModel]=\"densityOpacityPercent\" (ngModelChange)=\"onDensityOpacityPercent($event)\"\n [min]=\"5\" [max]=\"100\" inputStyleClass=\"sc-num\"></p-inputNumber>\n </div>\n <span class=\"sc-lbl\" pTooltip=\"Edge of each density bin\" tooltipPosition=\"top\">Density map bin size</span>\n <p-slider [ngModel]=\"densityBinIndex\" [min]=\"0\" [max]=\"3\" [step]=\"1\"\n (onChange)=\"onDensityBinIndex($event.value)\" styleClass=\"sc-bin-slider\"></p-slider>\n <div class=\"sc-ticks\">\n <span *ngFor=\"let b of densityBins\">{{ b }}\u00D7{{ b }}\u00B5m</span>\n </div>\n <span class=\"sc-lbl\" pTooltip=\"Transcripts per \u00B5m\u00B2 at which the colour scale starts and saturates\"\n tooltipPosition=\"top\">Density map scale threshold</span>\n <div class=\"sc-row sc-row-range\">\n <p-inputNumber [ngModel]=\"densityWindow[0]\" (ngModelChange)=\"onDensityRangeEnd(0, $event)\"\n mode=\"decimal\" [minFractionDigits]=\"3\" [maxFractionDigits]=\"3\"\n inputStyleClass=\"sc-num\"></p-inputNumber>\n <p-slider [ngModel]=\"densityWindow\" [range]=\"true\" [min]=\"0\" [max]=\"densitySliderMax\"\n [step]=\"densityStep\" (onChange)=\"onDensityRange($event.values)\"></p-slider>\n <p-inputNumber [ngModel]=\"densityWindow[1]\" (ngModelChange)=\"onDensityRangeEnd(1, $event)\"\n mode=\"decimal\" [minFractionDigits]=\"3\" [maxFractionDigits]=\"3\"\n inputStyleClass=\"sc-num\"></p-inputNumber>\n </div>\n <div class=\"sc-row\">\n <small class=\"sc-hint\">{{ view.densityRange ? 'Set by hand.' : 'Derived from the data.' }}</small>\n <button type=\"button\" class=\"sc-small-btn\" *ngIf=\"view.densityRange\" (click)=\"onDensityAuto()\">Auto</button>\n </div>\n <span class=\"sc-lbl\">Density map bin coloring</span>\n <div class=\"sc-row sc-row-picker\">\n <div class=\"sc-colorbar sc-full\" [style.background]=\"densityColorBarCss\"></div>\n <p-treeSelect [options]=\"colormapOptions\" [ngModel]=\"selectedDensityColormapNode\"\n selectionMode=\"single\" [showClear]=\"true\" placeholder=\"Inferno\"\n [scrollHeight]=\"'260px'\" appendTo=\"body\" styleClass=\"sc-palette\"\n (onNodeSelect)=\"onDensityColormap($event.node)\" (onClear)=\"onDensityColormap(null)\">\n <ng-template let-node pTemplate=\"default\">\n <span class=\"sc-cmap-option\">\n <img *ngIf=\"!node.children && node.data?.src\" [src]=\"node.data.src\" class=\"sc-cmap-swatch\" alt=\"\" />\n <span>{{ node.label }}</span>\n </span>\n </ng-template>\n </p-treeSelect>\n </div>\n <div class=\"sc-colorbar-ends\"><span>{{ densityWindow[0] | number: '1.0-3' }}</span><span>{{ densityWindow[1] | number: '1.0-3' }}</span></div>\n </ng-container>\n </div>\n </div>\n\n <!-- Annotations: the drawn regions, and selecting what they enclose. -->\n <div class=\"sc-panel\" [class.open]=\"open.annotations\">\n <div class=\"sc-panel-head\" (click)=\"toggleSection('annotations')\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.showAnnotations !== false\" (click)=\"$event.stopPropagation()\"\n (onChange)=\"onShowAnnotations($event.checked)\" pTooltip=\"Show the drawn regions\"\n tooltipPosition=\"top\"></p-checkbox>\n <span class=\"sc-panel-title\">Annotations</span>\n <i class=\"pi\" [class.pi-chevron-down]=\"open.annotations\" [class.pi-chevron-right]=\"!open.annotations\"></i>\n </div>\n <div class=\"sc-panel-body\" *ngIf=\"open.annotations\">\n <div class=\"sc-row\">\n <p-button label=\"Select from ROIs\" icon=\"pi pi-object-group\" size=\"small\"\n [outlined]=\"true\" (onClick)=\"selectFromRegions()\"\n pTooltip=\"Select every observation inside the drawn regions\"\n tooltipPosition=\"top\"></p-button>\n <p-button *ngIf=\"hasSelection\" label=\"Clear\" size=\"small\" [text]=\"true\"\n (onClick)=\"clearSelection()\"></p-button>\n </div>\n <small class=\"sc-hint\" *ngIf=\"hasSelection\">\n {{ selection.count | number }} of {{ ds.observations.count | number }} selected \u2014\n the rest are muted.\n </small>\n <small class=\"sc-hint\" *ngIf=\"selectionMissed\">\n No observations fell inside the drawn regions.\n </small>\n <small class=\"sc-hint\" *ngIf=\"is3d\">\n In 3D a region cuts through the cloud's full depth. Orbit and draw again\n to cut from another angle.\n </small>\n </div>\n </div>\n\n <!-- Observations: one marker per observation (cell centroid, Visium spot), and what\n colours them. The lead section for datasets without cell outlines. -->\n <div class=\"sc-panel\" [class.open]=\"open.observations\">\n <div class=\"sc-panel-head\" (click)=\"toggleSection('observations')\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.showPoints\" (click)=\"$event.stopPropagation()\"\n (onChange)=\"onShowPoints($event.checked)\"></p-checkbox>\n <span class=\"sc-panel-title\">Observations</span>\n <i class=\"pi\" [class.pi-chevron-down]=\"open.observations\" [class.pi-chevron-right]=\"!open.observations\"></i>\n </div>\n <div class=\"sc-panel-body\" *ngIf=\"open.observations\">\n <small class=\"sc-hint\" *ngIf=\"hasCells && !is3d\">\n One circle per cell. With cell outlines on, circles are only drawn where cells\n are too small on screen to outline.\n </small>\n <!-- Colour source. Column and gene are mutually exclusive: picking one\n clears the other, so what drives the colours is never ambiguous. -->\n <div class=\"sc-section\">\n <label class=\"sc-lbl\" for=\"sc-column\">Colour by column</label>\n <p-dropdown inputId=\"sc-column\" [options]=\"columnOptions\"\n optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"selectedColumn\" (ngModelChange)=\"onColumn($event)\"\n placeholder=\"None (flat colour)\" appendTo=\"body\"\n styleClass=\"sc-full\"></p-dropdown>\n </div>\n\n <div class=\"sc-section\" *ngIf=\"ds.features\">\n <label class=\"sc-lbl\" for=\"sc-gene\">Colour by gene</label>\n <!-- A filterable dropdown, not a free-text box: the genes are visible before\n anything is typed, and each keystroke narrows a list the user can see.\n Virtual-scrolled because a targeted panel runs to a few thousand names. -->\n <p-dropdown inputId=\"sc-gene\" [options]=\"geneOptions\"\n optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"selectedGene\" (ngModelChange)=\"onGene($event)\"\n [filter]=\"true\" filterBy=\"label\" [resetFilterOnHide]=\"true\"\n (onFilter)=\"onGeneFilter($event.filter)\" (onShow)=\"ensureGeneList()\"\n [showClear]=\"true\"\n [virtualScroll]=\"geneOptions.length > geneVirtualScrollFrom\"\n [virtualScrollItemSize]=\"30\"\n [emptyFilterMessage]=\"genesAreRemote ? 'No match \u2014 keep typing' : 'No match'\"\n [emptyMessage]=\"genesAreRemote ? 'Type to search genes' : 'No genes'\"\n appendTo=\"body\"\n placeholder=\"Search {{ ds.features.count | number }} genes\u2026\"\n styleClass=\"sc-full\"></p-dropdown>\n <small class=\"sc-hint\" *ngIf=\"geneSearchFailed\">Gene lookup failed.</small>\n </div>\n\n <!-- Key: categorical legend or a continuous colour bar, built from the same\n functions the renderer uses so it cannot disagree with the canvas. -->\n <div class=\"sc-section sc-key\" *ngIf=\"view.colorBy\">\n <span class=\"sc-lbl\">{{ colorByLabel }}</span>\n <!-- Derived columns say so, so they are not mistaken for measured data. -->\n <small class=\"sc-hint\" *ngIf=\"activeDescription\">{{ activeDescription }}</small>\n\n <!-- Legend rows are buttons: clicking one selects that category, clicking\n it again clears \u2014 so a click is always reversible. -->\n <ul class=\"sc-legend\" *ngIf=\"isCategorical\">\n <li *ngFor=\"let entry of legend; index as i\">\n <button type=\"button\" class=\"sc-legend-btn\"\n [class.selected]=\"selectedCategory === i\"\n (click)=\"selectCategory(i)\"\n [title]=\"'Select ' + entry.label\">\n <span class=\"sc-swatch\" [style.background]=\"entry.color\"></span>\n <span class=\"sc-legend-label\">{{ entry.label }}</span>\n </button>\n </li>\n </ul>\n\n <div *ngIf=\"isContinuous\">\n <div class=\"sc-colorbar\" [style.background]=\"colorBarCss\"></div>\n <div class=\"sc-colorbar-ends\">\n <span>low</span>\n <span class=\"sc-hint\">window from percentiles</span>\n <span>high</span>\n </div>\n <!-- The gradient this bar and the map are drawn with. Sits under the bar\n it controls, with the same swatch previews the image's colormap picker\n uses: which gradient reads best is a visual judgement, so a list of\n names would be the wrong control. Cleared = follow the image's. -->\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Colormap</span>\n <p-treeSelect [options]=\"colormapOptions\" [ngModel]=\"selectedColormapNode\"\n selectionMode=\"single\" [showClear]=\"true\"\n placeholder=\"Match the image\" [scrollHeight]=\"'260px'\"\n appendTo=\"body\"\n pTooltip=\"Colour scale for gene and numeric values \u2014 the markers, the gene map and this bar. Clear it to follow the image's colormap\"\n tooltipPosition=\"top\"\n (onNodeSelect)=\"onContinuousColormap($event.node)\"\n (onClear)=\"onContinuousColormap(null)\">\n <ng-template let-node pTemplate=\"default\">\n <span class=\"sc-cmap-option\">\n <img *ngIf=\"!node.children && node.data?.src\" [src]=\"node.data.src\"\n class=\"sc-cmap-swatch\" alt=\"\" />\n <span>{{ node.label }}</span>\n </span>\n </ng-template>\n </p-treeSelect>\n <span class=\"sc-val\"></span>\n </div>\n </div>\n </div>\n\n <!-- Display -->\n <div class=\"sc-section\">\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Point size</span>\n <p-slider [ngModel]=\"view.pointScale\" [min]=\"0.25\" [max]=\"5\" [step]=\"0.25\"\n (onChange)=\"onPointScale($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.pointScale }}\u00D7</span>\n </div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Opacity</span>\n <p-slider [ngModel]=\"view.opacity\" [min]=\"0.1\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onOpacity($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.opacity }}</span>\n </div>\n </div>\n\n </div>\n </div>\n\n <!-- Only for a gene: the map draws that gene's expression as a field. In 2D\n it goes under the cells of the displayed plane; in 3D it is one sheet per\n imaged section, optionally smoothed along z into a volume. -->\n <div class=\"sc-section\" *ngIf=\"canMapGene\">\n <div class=\"sc-row\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.geneMap\"\n (onChange)=\"onGeneMap($event.checked)\"\n label=\"Gene map (expression field)\"\n [pTooltip]=\"is3d\n ? 'Draw this gene\\'s expression as one field per imaged section \u2014 an estimate between the cells, empty where nothing was measured'\n : 'Draw this gene\\'s expression as a smooth field UNDER the cells \u2014 an estimate between them, transparent where nothing was measured'\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <!-- 3D only: interpolating along z turns the measured sheets into an\n estimate, so it is its own decision and its own label. -->\n <div class=\"sc-row\" *ngIf=\"view.geneMap && is3d\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.geneMapVolume\"\n (onChange)=\"onGeneMapVolume($event.checked)\"\n label=\"Volume rendering (interpolate along z)\"\n pTooltip=\"Smooth the per-section fields into a continuous volume \u2014 an ESTIMATE between the imaged sections, not measured tissue\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <!-- Meaningless while interpolating: a volume built from one section would\n smear that slide through the specimen's whole depth. -->\n <div class=\"sc-row\" *ngIf=\"view.geneMap && is3d && isSectioned && !view.geneMapVolume\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"geneMapOneSection\"\n (onChange)=\"onGeneMapOneSection($event.checked)\"\n label=\"One map section at a time\"\n pTooltip=\"Draw a single imaged section's field instead of the whole stack of sheets\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <div class=\"sc-row\"\n *ngIf=\"view.geneMap && is3d && isSectioned && !view.geneMapVolume && geneMapOneSection\">\n <span class=\"sc-lbl\">Map section</span>\n <p-slider [ngModel]=\"view.geneMapSection\" [min]=\"0\" [max]=\"lastSection\" [step]=\"1\"\n (onChange)=\"onGeneMapSection($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ geneMapSectionLabel }}</span>\n </div>\n <div class=\"sc-row\" *ngIf=\"view.geneMap\">\n <span class=\"sc-lbl\">Smoothing</span>\n <p-slider [ngModel]=\"view.geneMapSmoothing\" [min]=\"0.5\" [max]=\"4\" [step]=\"0.5\"\n (onChange)=\"onGeneMapSmoothing($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.geneMapSmoothing }}\u00D7</span>\n </div>\n <div class=\"sc-row\" *ngIf=\"view.geneMap\">\n <span class=\"sc-lbl\">Map opacity</span>\n <p-slider [ngModel]=\"view.geneMapOpacity\" [min]=\"0.1\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onGeneMapOpacity($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.geneMapOpacity }}</span>\n </div>\n <div class=\"sc-hint\" *ngIf=\"view.geneMap && !is3d\">\n Kernel-weighted mean per cell, not a sum \u2014 so a crowded region does not glow\n for being crowded. Transparent where no cell was measured. Turn the cells'\n own Opacity down to read the field under them.\n </div>\n <div class=\"sc-hint\" *ngIf=\"view.geneMap && is3d\">\n {{ geneMapVolumeNote }}\n </div>\n </div>\n\n <!-- 3D only: the volume, the cloud and the density volumes occupy the same\n space, so each hides the others. Independent toggles, because the useful\n views are the combinations \u2014 the field alone, one section's cells over it,\n the anatomy alone. -->\n <div class=\"sc-section\" *ngIf=\"is3d\">\n <span class=\"sc-lbl\">Show</span>\n <div class=\"sc-row\" *ngIf=\"ds.volume\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.showVolume\"\n (onChange)=\"onShowVolume($event.checked)\"\n label=\"Reference volume\"\n pTooltip=\"The registered anatomy the observations sit in\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <!-- The backdrop's own opacity: reading the cloud or a density field\n through the anatomy means turning the anatomy down, not the data. -->\n <div class=\"sc-row\" *ngIf=\"ds.volume && view.showVolume\">\n <span class=\"sc-lbl\">Volume opacity</span>\n <p-slider [ngModel]=\"view.volumeOpacity\" [min]=\"0.05\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onVolumeOpacity($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.volumeOpacity }}</span>\n </div>\n <div class=\"sc-row\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.showPoints\"\n (onChange)=\"onShowPoints($event.checked)\"\n label=\"Observations\"\n pTooltip=\"The measured cells. Hide them to read the density volumes underneath\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <!-- Only for data that really is sectioned; a continuous z has no sections\n to step through. -->\n <div class=\"sc-row\" *ngIf=\"view.showPoints && isSectioned\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"oneSection\"\n (onChange)=\"onOneSection($event.checked)\"\n label=\"One section at a time\"\n pTooltip=\"Draw a single imaged section's cells, so the density volumes between the sections stay visible\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <div class=\"sc-row\" *ngIf=\"view.showPoints && isSectioned && oneSection\">\n <span class=\"sc-lbl\">Section</span>\n <p-slider [ngModel]=\"view.pointSection\" [min]=\"0\" [max]=\"lastSection\" [step]=\"1\"\n (onChange)=\"onPointSection($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ sectionLabel }}</span>\n </div>\n </div>\n\n <div class=\"sc-section\" *ngIf=\"is3d\">\n <div class=\"sc-hint\" *ngIf=\"exceedsCloudPalette\">\n {{ legend?.length }} categories is past the {{ cloudPaletteLimit }} the 3D cloud's\n colour LUT can keep apart, so the points draw flat for this column. The density\n volumes below render it, and so does the 2D Spatial omics view.\n </div>\n <div class=\"sc-row\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.densityVolume\"\n (onChange)=\"onDensityVolume($event.checked)\"\n label=\"Cluster density volumes\"\n pTooltip=\"Raymarch each cluster as a smooth density field \u2014 an ESTIMATE between the imaged sections, not measured cells\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <div class=\"sc-row\" *ngIf=\"view.densityVolume\">\n <span class=\"sc-lbl\">Smoothing</span>\n <p-slider [ngModel]=\"view.densitySmoothing\" [min]=\"0.5\" [max]=\"4\" [step]=\"0.5\"\n (onChange)=\"onDensitySmoothing($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.densitySmoothing }}\u00D7</span>\n </div>\n <div class=\"sc-hint\" *ngIf=\"view.densityVolume\">{{ densityNote }}</div>\n </div>\n\n <!-- Continuous-only knobs: meaningless for a categorical palette. -->\n <div class=\"sc-section\" *ngIf=\"isContinuous\">\n <div class=\"sc-row\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.logScale\"\n (onChange)=\"onLogScale($event.checked)\"\n label=\"Log scale\"\n pTooltip=\"Counts collapse against their outliers on a linear ramp\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Clip outliers</span>\n <p-dropdown [options]=\"clipOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.percentileClip\" (ngModelChange)=\"onClip($event)\"\n appendTo=\"body\"\n pTooltip=\"Percentile the contrast window is taken at\"\n tooltipPosition=\"top\"></p-dropdown>\n <!-- Reserves the readout column this row has no readout for, so the\n control's right edge lines up with every slider's. -->\n <span class=\"sc-val\"></span>\n </div>\n </div>\n\n <!-- Distributions of whatever the map is coloured by. In the SAME dialog as\n the colour controls on purpose: changing the gene and watching the\n distribution move is one action, and two floating windows made that\n harder to see rather than easier. -->\n <div class=\"sc-section sc-charts\">\n <button type=\"button\" class=\"sc-disclosure\" (click)=\"toggleCharts()\"\n [attr.aria-expanded]=\"chartsOpen\" [attr.aria-controls]=\"chartsBodyId\">\n <i class=\"pi\" [class.pi-chevron-right]=\"!chartsOpen\"\n [class.pi-chevron-down]=\"chartsOpen\"></i>\n <span>Distribution</span>\n <small *ngIf=\"!chartsOpen && view.colorBy\">of {{ colorByLabel }}</small>\n </button>\n <!-- Kept MOUNTED when collapsed, only hidden: destroying it would throw\n away the chart kind and grouping the user picked. `active` is what\n stops it drawing while it has no size. -->\n <div [id]=\"chartsBodyId\" [hidden]=\"!chartsOpen\">\n <spatial-charts [active]=\"visible && chartsOpen\"></spatial-charts>\n </div>\n </div>\n\n <div class=\"sc-btns\">\n <p-button label=\"Reset\" size=\"small\" [text]=\"true\" (onClick)=\"reset()\"></p-button>\n </div>\n\n </div>\n</p-dialog>\n", styles: ["@charset \"UTF-8\";:host ::ng-deep .spatial-controls-dialog .p-dialog-content{padding:.75rem 1rem 1rem}.sc-empty{padding:.5rem 0;font-size:.85rem;color:var(--text-color-secondary, #6c757d);line-height:1.4}.sc-empty code{font-size:.8rem}.sc-body{display:flex;flex-direction:column;gap:.85rem}.sc-dataset{display:flex;flex-direction:column;gap:.1rem;padding-bottom:.5rem;border-bottom:1px solid var(--surface-border, #dee2e6)}.sc-dataset-name{font-weight:600;font-size:.9rem}.sc-dataset-count,.sc-hint{font-size:.75rem;color:var(--text-color-secondary, #6c757d)}.sc-section{display:flex;flex-direction:column;gap:.35rem}.sc-lbl{font-size:.78rem;color:var(--text-color-secondary, #6c757d);white-space:nowrap}.sc-row{display:flex;align-items:center;gap:.6rem}.sc-row p-slider,.sc-row p-dropdown,.sc-row p-treeSelect{flex:1 1 0;min-width:0}:host ::ng-deep .sc-row p-slider .p-slider{width:100%}:host ::ng-deep .sc-row p-dropdown .p-dropdown,:host ::ng-deep .sc-row p-treeSelect .p-treeselect{width:100%}.sc-row .sc-lbl{flex:0 0 calc(50% - .6rem);min-width:0;overflow:hidden;text-overflow:ellipsis}.sc-cmap-option{display:inline-flex;align-items:center;gap:.4rem}.sc-cmap-swatch{width:3.5rem;height:.7rem;border-radius:2px;display:block}.sc-val{flex:0 0 4rem;text-align:right;font-size:.78rem;font-variant-numeric:tabular-nums;white-space:nowrap}:host ::ng-deep .sc-full,:host ::ng-deep .sc-full .p-dropdown,:host ::ng-deep .sc-full .p-autocomplete,:host ::ng-deep .sc-full input{width:100%}.sc-key{padding:.5rem 0;border-top:1px solid var(--surface-border, #dee2e6);border-bottom:1px solid var(--surface-border, #dee2e6)}.sc-legend{list-style:none;margin:0;padding:0;max-height:11rem;overflow-y:auto;display:flex;flex-direction:column;gap:.2rem}.sc-legend li{display:flex}.sc-legend-btn{display:flex;align-items:center;gap:.45rem;width:100%;padding:.15rem .3rem;font:inherit;font-size:.8rem;text-align:left;background:none;border:1px solid transparent;border-radius:3px;cursor:pointer;color:inherit}.sc-legend-btn:hover{background:var(--surface-hover, rgba(0, 0, 0, .04))}.sc-legend-btn.selected{border-color:var(--primary-color, #3b82f6);background:var(--surface-hover, rgba(59, 130, 246, .08));font-weight:600}.sc-selection .sc-row{gap:.4rem}.sc-swatch{width:.85rem;height:.85rem;flex:0 0 auto;border-radius:2px;border:1px solid rgba(0,0,0,.2)}.sc-legend-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sc-colorbar{height:.75rem;border-radius:2px;border:1px solid rgba(0,0,0,.2)}.sc-colorbar-ends{display:flex;justify-content:space-between;font-size:.7rem;color:var(--text-color-secondary, #6c757d);margin-top:.2rem}.sc-btns{display:flex;justify-content:flex-end}.sc-charts{padding-top:.5rem;border-top:1px solid var(--surface-border, #dee2e6);gap:0}.sc-disclosure{display:flex;align-items:center;gap:.4rem;width:100%;padding:.2rem 0;font:inherit;font-size:.82rem;font-weight:600;text-align:left;background:none;border:0;cursor:pointer;color:inherit}.sc-disclosure i{font-size:.7rem;color:var(--text-color-secondary, #6c757d)}.sc-disclosure small{font-weight:400;color:var(--text-color-secondary, #6c757d);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sc-disclosure:hover{color:var(--primary-color, #3b82f6)}.sc-legend-static{cursor:default}.sc-tx-gene{display:flex;align-items:center;gap:.5rem}.sc-tx-gene .sc-legend-label{flex:1}:host ::ng-deep .sc-glyph-pick .p-dropdown-label{padding:.2rem .4rem}.sc-glyph-option{display:inline-flex;align-items:center;gap:.4rem}.sc-glyph-svg{width:1rem;height:1rem;fill:currentColor}:host ::ng-deep .sc-row-buttons .p-selectbutton .p-button{padding:.35rem .6rem;font-size:.85rem}:host ::ng-deep .sc-row-buttons .p-selectbutton{display:flex;flex-wrap:nowrap}.sc-row-wide .sc-lbl{flex:0 0 auto}.sc-panel{border:1px solid var(--surface-border, #dee2e6);border-radius:6px;margin-bottom:.5rem;overflow:hidden}.sc-panel-head{display:flex;align-items:center;gap:.6rem;padding:.5rem .7rem;cursor:pointer;-webkit-user-select:none;user-select:none;background:var(--surface-100, #f5f6f7)}.sc-panel-head .sc-panel-title{flex:1;font-weight:600}.sc-panel.open>.sc-panel-head{background:var(--primary-color, #2196f3);color:var(--primary-color-text, #fff)}.sc-panel-body{display:flex;flex-direction:column;gap:.45rem;padding:.6rem .75rem .75rem}.sc-subhead{display:flex;align-items:center;gap:.5rem;margin-top:.4rem;font-size:.72rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--text-color-secondary, #6c757d)}.sc-subhead:after{content:\"\";flex:1;border-top:1px solid var(--surface-border, #dee2e6)}:host ::ng-deep .sc-buttons .p-button{padding:.35rem .75rem;font-size:.85rem}.sc-row-picker p-dropdown,.sc-row-picker p-multiSelect,.sc-row-picker p-treeSelect{flex:1 1 0;min-width:0}:host ::ng-deep .sc-row-picker .sc-variant{width:6.5rem}:host ::ng-deep .sc-row-picker p-multiselect .p-multiselect{width:100%}:host ::ng-deep .sc-row-picker p-dropdown:has(.sc-variant){flex:0 0 6.5rem}:host ::ng-deep .sc-row-picker p-treeselect:has(.sc-palette){flex:0 0 3rem}:host ::ng-deep .sc-palette .p-treeselect-label{width:0;padding:0}.sc-icon-btn{flex:0 0 auto;border:0;background:transparent;cursor:pointer;padding:.25rem .35rem;font:inherit;font-size:1.05rem;color:var(--text-color, #333);border-radius:4px}.sc-icon-btn:hover{background:var(--surface-200, #e9ecef)}.sc-icon-btn.active{color:var(--primary-color, #2196f3);font-weight:700}.sc-icon-btn:disabled{opacity:.5;cursor:default}.sc-small-btn{margin-left:auto;border:1px solid var(--surface-border, #ced4da);background:var(--surface-0, #fff);border-radius:4px;padding:.2rem .6rem;font:inherit;font-size:.8rem;cursor:pointer}:host ::ng-deep .sc-group-heading{font-size:.7rem;font-weight:700;letter-spacing:.05em;text-transform:uppercase;opacity:.7}.sc-group-list,.sc-gene-tree{max-height:16rem;overflow-y:auto;border:1px solid var(--surface-border, #dee2e6);border-radius:4px;padding:.25rem .4rem}.sc-group-row,.sc-gene-row{display:flex;align-items:center;gap:.45rem;padding:.15rem 0;font-size:.88rem}.sc-group-row .sc-group-label,.sc-gene-row .sc-group-label{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sc-group-all,.sc-gene-root,.sc-gene-group{font-weight:600}.sc-group-all .sc-icon-btn,.sc-gene-root .sc-icon-btn,.sc-gene-group .sc-icon-btn{margin-left:auto;font-size:.85rem}.sc-gene-group,.sc-gene-leaf{padding-left:1rem}.sc-gene-leaf.nested{padding-left:2rem}.sc-count{flex:0 0 auto;font-size:.78rem;color:var(--text-color-secondary, #6c757d)}.sc-dot{flex:0 0 auto;width:.7rem;height:.7rem;border-radius:50%;border:1px solid rgba(0,0,0,.2)}.sc-dot-pick{position:relative;cursor:pointer}.sc-dot-pick input{position:absolute;inset:0;opacity:0;width:100%;height:100%;cursor:pointer}.sc-eye{flex:0 0 auto;border:0;background:transparent;cursor:pointer;padding:0;color:var(--text-color-secondary, #6c757d)}.sc-eye .pi-eye{color:var(--text-color, #333)}.sc-gene-footer{text-align:right;font-size:.78rem;padding:.2rem 0;color:var(--text-color-secondary, #6c757d)}.sc-color-input{width:2.5rem;height:1.8rem;border:1px solid var(--surface-border, #ced4da);border-radius:4px;padding:0;background:none}.sc-meter{height:.45rem;border-radius:3px;background:var(--surface-200, #e9ecef);overflow:hidden}.sc-meter-fill{height:100%;background:var(--primary-color, #2196f3)}.sc-meter-fill.over{background:var(--orange-500, #f59e0b)}.sc-meter-ends,.sc-ticks{display:flex;justify-content:space-between;font-size:.72rem;font-weight:600;color:var(--text-color-secondary, #6c757d)}.sc-row-number p-slider,.sc-row-range p-slider{flex:1 1 0;min-width:0}:host ::ng-deep .sc-num{width:4.5rem;padding:.3rem .4rem;text-align:center}:host ::ng-deep .sc-row-number p-inputnumber,:host ::ng-deep .sc-row-range p-inputnumber{flex:0 0 auto}.sc-error{color:var(--red-600, #c62828)}\n"], dependencies: [{ kind: "directive", type: i1$1.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$2.PrimeTemplate, selector: "[pTemplate]", inputs: ["type", "pTemplate"] }, { kind: "component", type: i5.Button, selector: "p-button", inputs: ["type", "iconPos", "icon", "badge", "label", "disabled", "loading", "loadingIcon", "raised", "rounded", "text", "plain", "severity", "outlined", "link", "tabindex", "size", "style", "styleClass", "badgeClass", "ariaLabel", "autofocus"], outputs: ["onClick", "onFocus", "onBlur"] }, { kind: "component", type: i6.Menu, selector: "p-menu", inputs: ["model", "popup", "style", "styleClass", "appendTo", "autoZIndex", "baseZIndex", "showTransitionOptions", "hideTransitionOptions", "ariaLabel", "ariaLabelledBy", "id", "tabindex"], outputs: ["onShow", "onHide", "onBlur", "onFocus"] }, { kind: "directive", type: i6$1.Tooltip, selector: "[pTooltip]", inputs: ["tooltipPosition", "tooltipEvent", "appendTo", "positionStyle", "tooltipStyleClass", "tooltipZIndex", "escape", "showDelay", "hideDelay", "life", "positionTop", "positionLeft", "autoHide", "fitContent", "hideOnEscape", "pTooltip", "tooltipDisabled", "tooltipOptions"] }, { kind: "component", type: i7.Dropdown, selector: "p-dropdown", inputs: ["id", "scrollHeight", "filter", "name", "style", "panelStyle", "styleClass", "panelStyleClass", "readonly", "required", "editable", "appendTo", "tabindex", "placeholder", "loadingIcon", "filterPlaceholder", "filterLocale", "variant", "inputId", "dataKey", "filterBy", "filterFields", "autofocus", "resetFilterOnHide", "checkmark", "dropdownIcon", "loading", "optionLabel", "optionValue", "optionDisabled", "optionGroupLabel", "optionGroupChildren", "autoDisplayFirst", "group", "showClear", "emptyFilterMessage", "emptyMessage", "lazy", "virtualScroll", "virtualScrollItemSize", "virtualScrollOptions", "overlayOptions", "ariaFilterLabel", "ariaLabel", "ariaLabelledBy", "filterMatchMode", "maxlength", "tooltip", "tooltipPosition", "tooltipPositionStyle", "tooltipStyleClass", "focusOnHover", "selectOnFocus", "autoOptionFocus", "autofocusFilter", "autoShowPanelOnPrintableCharacterKeyDown", "disabled", "itemSize", "autoZIndex", "baseZIndex", "showTransitionOptions", "hideTransitionOptions", "filterValue", "options"], outputs: ["onChange", "onFilter", "onFocus", "onBlur", "onClick", "onShow", "onHide", "onClear", "onLazyLoad"] }, { kind: "component", type: i9.Slider, selector: "p-slider", inputs: ["animate", "disabled", "min", "max", "orientation", "step", "range", "style", "styleClass", "ariaLabel", "ariaLabelledBy", "tabindex", "autofocus"], outputs: ["onChange", "onSlideEnd"] }, { kind: "component", type: i7$1.TreeSelect, selector: "p-treeSelect", inputs: ["inputId", "scrollHeight", "disabled", "metaKeySelection", "variant", "display", "selectionMode", "tabindex", "ariaLabel", "ariaLabelledBy", "placeholder", "panelClass", "panelStyle", "panelStyleClass", "containerStyle", "containerStyleClass", "labelStyle", "labelStyleClass", "overlayOptions", "emptyMessage", "appendTo", "filter", "filterBy", "filterMode", "filterPlaceholder", "filterLocale", "filterInputAutoFocus", "propagateSelectionDown", "propagateSelectionUp", "showClear", "resetFilterOnHide", "virtualScroll", "virtualScrollItemSize", "virtualScrollOptions", "autofocus", "options", "showTransitionOptions", "hideTransitionOptions", "loading"], outputs: ["onNodeExpand", "onNodeCollapse", "onShow", "onHide", "onClear", "onFilter", "onFocus", "onBlur", "onNodeUnselect", "onNodeSelect"] }, { kind: "component", type: i10.InputNumber, selector: "p-inputNumber", inputs: ["showButtons", "format", "buttonLayout", "inputId", "styleClass", "style", "placeholder", "size", "maxlength", "tabindex", "title", "ariaLabelledBy", "ariaLabel", "ariaRequired", "name", "required", "autocomplete", "min", "max", "incrementButtonClass", "decrementButtonClass", "incrementButtonIcon", "decrementButtonIcon", "readonly", "step", "allowEmpty", "locale", "localeMatcher", "mode", "currency", "currencyDisplay", "useGrouping", "variant", "minFractionDigits", "maxFractionDigits", "prefix", "suffix", "inputStyle", "inputStyleClass", "showClear", "autofocus", "disabled"], outputs: ["onInput", "onFocus", "onBlur", "onKeyDown", "onClear"] }, { kind: "component", type: i11.Dialog, selector: "p-dialog", inputs: ["header", "draggable", "resizable", "positionLeft", "positionTop", "contentStyle", "contentStyleClass", "modal", "closeOnEscape", "dismissableMask", "rtl", "closable", "responsive", "appendTo", "breakpoints", "styleClass", "maskStyleClass", "maskStyle", "showHeader", "breakpoint", "blockScroll", "autoZIndex", "baseZIndex", "minX", "minY", "focusOnShow", "maximizable", "keepInViewport", "focusTrap", "transitionOptions", "closeIcon", "closeAriaLabel", "closeTabindex", "minimizeIcon", "maximizeIcon", "visible", "style", "position"], outputs: ["onShow", "onHide", "visibleChange", "onResizeInit", "onResizeEnd", "onDragEnd", "onMaximize"] }, { kind: "component", type: i16.Checkbox, selector: "p-checkbox", inputs: ["value", "name", "disabled", "binary", "label", "ariaLabelledBy", "ariaLabel", "tabindex", "inputId", "style", "styleClass", "labelStyleClass", "formControl", "checkboxIcon", "readonly", "required", "autofocus", "trueValue", "falseValue", "variant"], outputs: ["onChange", "onFocus", "onBlur"] }, { kind: "component", type: i9$2.MultiSelect, selector: "p-multiSelect", inputs: ["id", "ariaLabel", "style", "styleClass", "panelStyle", "panelStyleClass", "inputId", "disabled", "readonly", "group", "filter", "filterPlaceHolder", "filterLocale", "overlayVisible", "tabindex", "variant", "appendTo", "dataKey", "name", "ariaLabelledBy", "displaySelectedLabel", "maxSelectedLabels", "selectionLimit", "selectedItemsLabel", "showToggleAll", "emptyFilterMessage", "emptyMessage", "resetFilterOnHide", "dropdownIcon", "optionLabel", "optionValue", "optionDisabled", "optionGroupLabel", "optionGroupChildren", "showHeader", "filterBy", "scrollHeight", "lazy", "virtualScroll", "loading", "virtualScrollItemSize", "loadingIcon", "virtualScrollOptions", "overlayOptions", "ariaFilterLabel", "filterMatchMode", "tooltip", "tooltipPosition", "tooltipPositionStyle", "tooltipStyleClass", "autofocusFilter", "display", "autocomplete", "showClear", "autofocus", "autoZIndex", "baseZIndex", "showTransitionOptions", "hideTransitionOptions", "defaultLabel", "placeholder", "options", "filterValue", "itemSize", "selectAll", "focusOnHover", "filterFields", "selectOnFocus", "autoOptionFocus"], outputs: ["onChange", "onFilter", "onFocus", "onBlur", "onClick", "onClear", "onPanelShow", "onPanelHide", "onLazyLoad", "onRemove", "onSelectAllChange"] }, { kind: "component", type: i18.SelectButton, selector: "p-selectButton", inputs: ["options", "optionLabel", "optionValue", "optionDisabled", "unselectable", "tabindex", "multiple", "allowEmpty", "style", "styleClass", "ariaLabelledBy", "disabled", "dataKey", "autofocus"], outputs: ["onOptionClick", "onChange"] }, { kind: "component", type: SpatialChartsComponent, selector: "spatial-charts", inputs: ["active"] }, { kind: "pipe", type: i1$1.DecimalPipe, name: "number" }] }); }
|
|
13217
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.9", type: SpatialControlsComponent, selector: "spatial-controls", inputs: { visible: "visible", is3d: "is3d" }, outputs: { visibleChange: "visibleChange" }, viewQueries: [{ propertyName: "charts", first: true, predicate: SpatialChartsComponent, descendants: true }, { propertyName: "geneGroupFileRef", first: true, predicate: ["geneGroupFile"], descendants: true }], ngImport: i0, template: "<p-dialog header=\"Spatial omics\"\n [visible]=\"visible\" (visibleChange)=\"onVisibleChange($event)\"\n [modal]=\"false\" [resizable]=\"true\" [draggable]=\"true\"\n position=\"left\"\n (onResizeEnd)=\"onResizeEnd()\"\n [style]=\"{ width: '30rem' }\"\n [breakpoints]=\"{ '960px': '90vw' }\"\n styleClass=\"spatial-controls-dialog\">\n\n <!-- No port bound: say why rather than showing controls that do nothing. -->\n <div class=\"sc-empty\" *ngIf=\"!controls\">\n No spatial-omics data source is configured. A host provides one by binding\n <code>SPATIAL_DATA_PORT</code>.\n </div>\n\n <div class=\"sc-empty\" *ngIf=\"controls && !dataset\">\n No dataset is loaded.\n </div>\n\n <div class=\"sc-body\" *ngIf=\"controls && dataset as ds\">\n\n <div class=\"sc-dataset\">\n <span class=\"sc-dataset-name\">{{ ds.name }}</span>\n <span class=\"sc-dataset-count\">{{ ds.observations.count | number }} observations</span>\n </div>\n\n <!-- Organised as Xenium Explorer's side panel: one collapsible section per layer,\n each with its own on/off switch in the header. -->\n\n <!-- Images -->\n <div class=\"sc-panel\" *ngIf=\"ds.imageRef && !is3d\" [class.open]=\"open.images\">\n <div class=\"sc-panel-head\" (click)=\"toggleSection('images')\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.showImage\" (click)=\"$event.stopPropagation()\"\n (onChange)=\"onShowImage($event.checked)\"></p-checkbox>\n <span class=\"sc-panel-title\">Images</span>\n <i class=\"pi\" [class.pi-chevron-down]=\"open.images\" [class.pi-chevron-right]=\"!open.images\"></i>\n </div>\n <div class=\"sc-panel-body\" *ngIf=\"open.images\">\n <small class=\"sc-hint\">\n Channels, colours and contrast are in the Channels & Histogram dialog.\n </small>\n </div>\n </div>\n\n <!-- Cells -->\n <div class=\"sc-panel\" *ngIf=\"hasCells && !is3d\" [class.open]=\"open.cells\">\n <div class=\"sc-panel-head\" (click)=\"toggleSection('cells')\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"cellsOn\" (click)=\"$event.stopPropagation()\"\n (onChange)=\"onShowCells($event.checked)\"></p-checkbox>\n <span class=\"sc-panel-title\">Cells</span>\n <i class=\"pi\" [class.pi-chevron-down]=\"open.cells\" [class.pi-chevron-right]=\"!open.cells\"></i>\n </div>\n <div class=\"sc-panel-body\" *ngIf=\"open.cells\">\n <ng-container *ngIf=\"cellSetOptions.length > 1\">\n <div class=\"sc-subhead\">Boundaries</div>\n <p-selectButton [options]=\"cellSetOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"activeCellSet\" (ngModelChange)=\"onCellSet($event)\"\n [allowEmpty]=\"false\" styleClass=\"sc-buttons\"></p-selectButton>\n </ng-container>\n\n <div class=\"sc-subhead\">Coloring</div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Cell Color</span>\n <p-dropdown [options]=\"cellColorOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.cellColorMode\" (ngModelChange)=\"onCellColorMode($event)\"\n appendTo=\"body\"></p-dropdown>\n </div>\n <small class=\"sc-hint\" *ngIf=\"view.cellColorMode === 'group'\">\n Adjust cell color in the groups section below.\n </small>\n <div class=\"sc-row\" *ngIf=\"view.cellColorMode === 'gene' && ds.features\">\n <span class=\"sc-lbl\">Gene</span>\n <p-dropdown [options]=\"geneOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.cellColorGene\" (ngModelChange)=\"onCellColorGene($event)\"\n [filter]=\"true\" filterBy=\"label\" [resetFilterOnHide]=\"true\"\n (onFilter)=\"onGeneFilter($event.filter)\" (onShow)=\"ensureGeneList()\"\n [virtualScroll]=\"geneOptions.length > geneVirtualScrollFrom\"\n [virtualScrollItemSize]=\"30\" appendTo=\"body\"\n placeholder=\"Search genes\u2026\"></p-dropdown>\n </div>\n <div class=\"sc-row\" *ngIf=\"view.cellColorMode === 'single'\">\n <span class=\"sc-lbl\">Color</span>\n <input type=\"color\" class=\"sc-color-input\" [value]=\"view.cellSingleColor\"\n (change)=\"onCellSingleColor($any($event.target).value)\" />\n </div>\n <div *ngIf=\"view.cellColorMode === 'gene' || view.cellColorMode === 'transcriptDensity'\">\n <div class=\"sc-colorbar\" [style.background]=\"colorBarCss || densityColorBarCss\"></div>\n </div>\n\n <ng-container *ngIf=\"groupOptions.length\">\n <div class=\"sc-subhead\">Groups</div>\n <div class=\"sc-row sc-row-picker\">\n <p-dropdown [options]=\"groupOptions\" [group]=\"true\" optionLabel=\"label\" optionValue=\"value\"\n optionGroupLabel=\"label\" optionGroupChildren=\"items\"\n [ngModel]=\"activeGroupEntry\" (ngModelChange)=\"onGroupEntry($event)\"\n appendTo=\"body\" styleClass=\"sc-full\">\n <ng-template let-group pTemplate=\"group\">\n <span class=\"sc-group-heading\">{{ group.label }}</span>\n </ng-template>\n </p-dropdown>\n <p-dropdown *ngIf=\"groupVariantOptions.length\" [options]=\"groupVariantOptions\"\n optionLabel=\"label\" optionValue=\"value\" styleClass=\"sc-variant\"\n [ngModel]=\"activeCellTypeColumn\" (ngModelChange)=\"onGroupVariant($event)\"\n appendTo=\"body\"></p-dropdown>\n <button *ngIf=\"canImportGroups\" type=\"button\" class=\"sc-icon-btn\" [disabled]=\"groupImporting\"\n (click)=\"groupFile.click()\" title=\"Import cell groups (CSV of cell_id, group)\">\n <i class=\"pi\" [class.pi-plus-circle]=\"!groupImporting\" [class.pi-spin]=\"groupImporting\"\n [class.pi-spinner]=\"groupImporting\"></i>\n </button>\n <input #groupFile type=\"file\" accept=\".csv,.tsv,.txt\" hidden\n (change)=\"onImportGroupsFile(groupFile)\" />\n </div>\n <small class=\"sc-hint sc-error\" *ngIf=\"groupImportError\">{{ groupImportError }}</small>\n\n <div class=\"sc-group-list\" *ngIf=\"groupRows.length\">\n <div class=\"sc-group-row sc-group-all\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"allGroupsShown\"\n (onChange)=\"onAllGroupsShown($event.checked)\"></p-checkbox>\n <span class=\"sc-group-label\">All groups</span>\n <span class=\"sc-count\">({{ groupTotal | number }})</span>\n <button type=\"button\" class=\"sc-icon-btn\" (click)=\"groupsExpanded = !groupsExpanded\">\n <i class=\"pi\" [class.pi-chevron-down]=\"groupsExpanded\" [class.pi-chevron-right]=\"!groupsExpanded\"></i>\n </button>\n </div>\n <ng-container *ngIf=\"groupsExpanded\">\n <div class=\"sc-group-row\" *ngFor=\"let row of groupRows; trackBy: trackByLabel\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"isGroupShown(row.label)\"\n (onChange)=\"onGroupShown(row.label, $event.checked)\"></p-checkbox>\n <span class=\"sc-dot\" [style.background]=\"row.color\"></span>\n <span class=\"sc-group-label\" [title]=\"row.label\">{{ row.label }}</span>\n <span class=\"sc-count\">({{ row.count | number }})</span>\n </div>\n </ng-container>\n </div>\n </ng-container>\n\n <div class=\"sc-subhead\">Visibility</div>\n <span class=\"sc-lbl\">View cells as</span>\n <p-selectButton [options]=\"cellDrawOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.cellDraw\" (ngModelChange)=\"onCellDraw($event)\"\n [allowEmpty]=\"false\" styleClass=\"sc-buttons\"></p-selectButton>\n <ng-container *ngIf=\"view.cellDraw !== 'outline'\">\n <span class=\"sc-lbl\">Cell fill opacity</span>\n <div class=\"sc-row sc-row-number\">\n <p-slider [ngModel]=\"view.cellOpacity\" [min]=\"0.05\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onCellOpacity($event.value)\"></p-slider>\n <p-inputNumber [ngModel]=\"cellOpacityPercent\" (ngModelChange)=\"onCellOpacityPercent($event)\"\n [min]=\"5\" [max]=\"100\" [showButtons]=\"false\" inputStyleClass=\"sc-num\"></p-inputNumber>\n </div>\n </ng-container>\n </div>\n </div>\n\n <!-- Transcripts -->\n <div class=\"sc-panel\" *ngIf=\"hasTranscripts && !is3d\" [class.open]=\"open.transcripts\">\n <div class=\"sc-panel-head\" (click)=\"toggleSection('transcripts')\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.transcriptMode !== 'off'\" (click)=\"$event.stopPropagation()\"\n (onChange)=\"onTranscriptsOn($event.checked)\"></p-checkbox>\n <span class=\"sc-panel-title\">Transcripts</span>\n <i class=\"pi\" [class.pi-chevron-down]=\"open.transcripts\" [class.pi-chevron-right]=\"!open.transcripts\"></i>\n </div>\n <div class=\"sc-panel-body\" *ngIf=\"open.transcripts\">\n <div class=\"sc-row sc-row-picker\">\n <p-multiSelect inputId=\"sc-tx-genes\" [options]=\"geneOptions\"\n optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.transcriptGenes\" (onChange)=\"onTranscriptGenes($event.value)\"\n [filter]=\"true\" filterBy=\"label\" [resetFilterOnHide]=\"true\"\n (onFilter)=\"onGeneFilter($event.filter)\" (onPanelShow)=\"ensureGeneList()\"\n [virtualScroll]=\"geneOptions.length > geneVirtualScrollFrom\"\n [virtualScrollItemSize]=\"30\" [showToggleAll]=\"false\"\n [emptyFilterMessage]=\"genesAreRemote ? 'No match \u2014 keep typing' : 'No match'\"\n [emptyMessage]=\"genesAreRemote ? 'Type to search genes' : 'No genes'\"\n appendTo=\"body\" placeholder=\"Search for genes to visualize\u2026\"\n [selectedItemsLabel]=\"'{0} genes selected'\" [maxSelectedLabels]=\"0\"\n styleClass=\"sc-full\"></p-multiSelect>\n <button type=\"button\" class=\"sc-icon-btn\" *ngIf=\"canShowAllGenes\" (click)=\"onToggleAllGenes()\"\n [class.active]=\"view.transcriptAllGenes\"\n [title]=\"view.transcriptAllGenes ? 'Back to the selected genes' : 'Select all genes'\">\u00B1</button>\n <button type=\"button\" class=\"sc-icon-btn\" (click)=\"geneMenuRef.toggle($event)\" title=\"More\">\n <i class=\"pi pi-ellipsis-v\"></i>\n </button>\n <p-menu #geneMenuRef [model]=\"geneMenu\" [popup]=\"true\" appendTo=\"body\"></p-menu>\n <input #geneGroupFile type=\"file\" accept=\".csv,.tsv,.txt\" hidden\n (change)=\"onImportGeneGroups(geneGroupFile)\" />\n </div>\n <small class=\"sc-hint sc-error\" *ngIf=\"geneGroupError\">{{ geneGroupError }}</small>\n <small class=\"sc-hint\" *ngIf=\"allGenesPreparing\">{{ allGenesPreparing }}</small>\n\n <!-- Marker genes of each cluster, added as gene groups named after the clusters. -->\n <div class=\"sc-markers\" *ngIf=\"markersOpen\">\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Clusters from</span>\n <p-dropdown [options]=\"markerColumnOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"markerColumn\" (ngModelChange)=\"onMarkerColumn($event)\"\n appendTo=\"body\" [filter]=\"markerColumnOptions.length > 8\"></p-dropdown>\n </div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Per cluster</span>\n <p-dropdown [options]=\"markerPerGroupOptions\" optionLabel=\"label\" optionValue=\"value\"\n [(ngModel)]=\"markerPerGroup\" appendTo=\"body\"></p-dropdown>\n </div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Clusters</span>\n <p-multiSelect [options]=\"markerClusterOptions\" optionLabel=\"label\" optionValue=\"value\"\n [(ngModel)]=\"markerClusters\" appendTo=\"body\" [filter]=\"true\"\n selectedItemsLabel=\"{0} clusters\" [maxSelectedLabels]=\"2\"\n placeholder=\"Pick clusters\"></p-multiSelect>\n </div>\n <small class=\"sc-hint\" *ngIf=\"markerLoading\">\n Finding marker genes\u2026 the first time for a grouping reads the whole expression matrix,\n which can take a minute.\n </small>\n <small class=\"sc-hint sc-error\" *ngIf=\"markerError\">{{ markerError }}</small>\n <div class=\"sc-row sc-markers-actions\">\n <p-button label=\"Add marker genes\" icon=\"pi pi-sitemap\" size=\"small\"\n [loading]=\"markerLoading\" [disabled]=\"!markerClusters.length\"\n (onClick)=\"addMarkerGenes()\"></p-button>\n <p-button label=\"Cancel\" size=\"small\" [text]=\"true\" [disabled]=\"markerLoading\"\n (onClick)=\"markersOpen = false\"></p-button>\n </div>\n </div>\n\n <div class=\"sc-subhead\">Selected genes</div>\n <div class=\"sc-gene-tree\">\n <div class=\"sc-gene-row sc-gene-root\">\n <button type=\"button\" class=\"sc-eye\"\n (click)=\"view.transcriptAllGenes ? onToggleAllGenes() : onGenesShown(view.transcriptGenes, !areGenesShown(view.transcriptGenes))\">\n <i class=\"pi\" [class.pi-eye]=\"view.transcriptAllGenes || areGenesShown(view.transcriptGenes)\"\n [class.pi-eye-slash]=\"!view.transcriptAllGenes && !areGenesShown(view.transcriptGenes)\"></i>\n </button>\n <span class=\"sc-group-label\">Genes</span>\n <span class=\"sc-count\">({{ (view.transcriptAllGenes ? geneTotal : view.transcriptGenes.length) | number }}/{{ geneTotal | number }})</span>\n <button type=\"button\" class=\"sc-icon-btn\" (click)=\"geneTreeOpen = !geneTreeOpen\"\n *ngIf=\"!view.transcriptAllGenes && view.transcriptGenes.length\">\n <i class=\"pi\" [class.pi-chevron-down]=\"geneTreeOpen\" [class.pi-chevron-right]=\"!geneTreeOpen\"></i>\n </button>\n </div>\n <ng-container *ngIf=\"geneTreeOpen && !view.transcriptAllGenes\">\n <ng-container *ngFor=\"let node of geneTree; trackBy: trackByNode\">\n <div class=\"sc-gene-row sc-gene-group\" *ngIf=\"node.name\">\n <button type=\"button\" class=\"sc-eye\" (click)=\"onGenesShown(node.genes, !areGenesShown(node.genes))\">\n <i class=\"pi\" [class.pi-eye]=\"areGenesShown(node.genes)\" [class.pi-eye-slash]=\"!areGenesShown(node.genes)\"></i>\n </button>\n <span class=\"sc-group-label\" [title]=\"node.name\">{{ node.name }}</span>\n <span class=\"sc-count\">({{ node.genes.length }})</span>\n <button type=\"button\" class=\"sc-icon-btn\" (click)=\"toggleGeneGroup(node.name)\">\n <i class=\"pi\" [class.pi-chevron-down]=\"!collapsedGeneGroups.has(node.name)\"\n [class.pi-chevron-right]=\"collapsedGeneGroups.has(node.name)\"></i>\n </button>\n </div>\n <ng-container *ngIf=\"!node.name || !collapsedGeneGroups.has(node.name)\">\n <div class=\"sc-gene-row sc-gene-leaf\" [class.nested]=\"!!node.name\"\n *ngFor=\"let gene of node.genes; trackBy: trackByLabelString\">\n <button type=\"button\" class=\"sc-eye\" (click)=\"onGenesShown([gene], !isGeneShown(gene))\">\n <i class=\"pi\" [class.pi-eye]=\"isGeneShown(gene)\" [class.pi-eye-slash]=\"!isGeneShown(gene)\"></i>\n </button>\n <!-- The gene's marker, in its colour: its icon in Icons mode, a dot for circles.\n Clicking it opens the icon and colour picker (as Xenium Explorer does). -->\n <button type=\"button\" class=\"sc-gene-icon\" [style.color]=\"geneSwatchOf(gene)\"\n [attr.aria-label]=\"'Icon and colour for ' + gene\" pTooltip=\"Icon and colour\"\n tooltipPosition=\"top\" (click)=\"openGeneStyle($event, gene, genePop)\">\n <svg viewBox=\"-1.2 -1.2 2.4 2.4\" class=\"sc-glyph-svg\">\n <polygon *ngIf=\"view.transcriptMode === 'glyphs'; else dotIcon\"\n [attr.points]=\"glyphPoints(glyphOf(gene, view.transcriptGenes.indexOf(gene)))\"></polygon>\n <ng-template #dotIcon><circle r=\"0.75\"></circle></ng-template>\n </svg>\n </button>\n <span class=\"sc-group-label\">{{ gene }}</span>\n <span class=\"sc-gene-count\" *ngIf=\"geneCountOf(gene) as n; else zeroCount\"\n title=\"Transcripts in view\">{{ n | number }}</span>\n <ng-template #zeroCount>\n <span class=\"sc-gene-count\" *ngIf=\"geneCounts\" title=\"Transcripts in view\">0</span>\n </ng-template>\n </div>\n </ng-container>\n </ng-container>\n </ng-container>\n <!-- One picker for every gene row, filled for the gene that opened it. -->\n <p-overlayPanel #genePop styleClass=\"sc-gene-style\" appendTo=\"body\">\n <ng-template pTemplate=\"content\">\n <div class=\"sc-style-pop\" *ngIf=\"styleGene as gene\">\n <div class=\"sc-style-title\">Transcript Icon</div>\n <div class=\"sc-style-icons\" role=\"radiogroup\" [attr.aria-label]=\"'Icon for ' + gene\">\n <button type=\"button\" *ngFor=\"let opt of glyphOptions\" class=\"sc-style-icon\"\n role=\"radio\" [attr.aria-checked]=\"opt.value === styleGlyph\"\n [class.active]=\"opt.value === styleGlyph\" [title]=\"opt.label\"\n (click)=\"onGlyph(gene, opt.value)\">\n <svg viewBox=\"-1.2 -1.2 2.4 2.4\" class=\"sc-glyph-svg\"><polygon [attr.points]=\"opt.points\"></polygon></svg>\n </button>\n </div>\n <small class=\"sc-hint\" *ngIf=\"view.transcriptMode !== 'glyphs'\">\n Icons show when the transcript point style is Icons.\n </small>\n <div class=\"sc-style-title\">Transcript Color</div>\n <div class=\"sc-style-color\">\n <label class=\"sc-style-swatch\" [style.background]=\"geneColorOf(gene)\" title=\"Pick a colour\">\n <input type=\"color\" [value]=\"geneColorOf(gene)\" (change)=\"onGeneColor(gene, $any($event.target).value)\" />\n </label>\n <input pInputText class=\"sc-style-hex\" [value]=\"geneColorOf(gene)\" maxlength=\"7\"\n [attr.aria-label]=\"'Hex colour for ' + gene\"\n (change)=\"onGeneHex(gene, $any($event.target).value)\"\n (keydown.enter)=\"onGeneHex(gene, $any($event.target).value)\" />\n </div>\n <div class=\"sc-style-title sc-style-sub\">Presets</div>\n <div class=\"sc-style-presets\">\n <button type=\"button\" *ngFor=\"let c of colorPresets\" class=\"sc-style-preset\"\n [style.background]=\"c\" [class.active]=\"c === geneColorOf(gene).toLowerCase()\"\n [title]=\"c\" [attr.aria-label]=\"'Colour ' + c\" (click)=\"onGeneColor(gene, c)\"></button>\n </div>\n <p-button label=\"Reset to default\" icon=\"pi pi-refresh\" size=\"small\" [text]=\"true\"\n styleClass=\"sc-style-reset\" (onClick)=\"resetGeneStyle(gene)\"></p-button>\n </div>\n </ng-template>\n </p-overlayPanel>\n <div class=\"sc-gene-footer\">\n {{ (view.transcriptAllGenes ? geneTotal : view.transcriptGenes.length) | number }}/{{ geneTotal | number }} genes selected\n </div>\n </div>\n\n <div class=\"sc-subhead\">Transcript controls</div>\n <span class=\"sc-lbl\">View transcripts as</span>\n <p-selectButton [options]=\"transcriptModeOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.transcriptMode === 'off' ? null : view.transcriptMode\"\n (ngModelChange)=\"onTranscriptMode($event)\"\n [allowEmpty]=\"false\" styleClass=\"sc-buttons\"></p-selectButton>\n\n <ng-container *ngIf=\"estimate\">\n <div class=\"sc-row\">\n <span class=\"sc-lbl\" pTooltip=\"Transcripts of the visible genes estimated to be in view, against the most markers drawn at once\"\n tooltipPosition=\"top\">Estimated Transcript Points</span>\n <button type=\"button\" class=\"sc-small-btn\" (click)=\"editingMax = !editingMax\">Edit Max</button>\n </div>\n <div class=\"sc-meter\"><div class=\"sc-meter-fill\" [class.over]=\"estimateOverMax\" [style.width.%]=\"estimatePercent\"></div></div>\n <div class=\"sc-meter-ends\">\n <span>{{ estimate.points | number }} points</span>\n <span>{{ estimate.max | number }} max{{ estimateOverMax ? ' reached' : '' }}</span>\n </div>\n <div class=\"sc-row\" *ngIf=\"editingMax\">\n <span class=\"sc-lbl\">Max markers</span>\n <p-dropdown [options]=\"budgetOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.transcriptBudget\" (ngModelChange)=\"onTranscriptBudget($event)\"\n appendTo=\"body\"></p-dropdown>\n </div>\n <small class=\"sc-hint\" *ngIf=\"estimateOverMax && view.transcriptMode !== 'density'\">\n Max points reached: transcripts are grouped by area, circles sized by how many they hold.\n Zoom in or select fewer genes to see individual points.\n </small>\n </ng-container>\n\n <ng-container *ngIf=\"view.transcriptMode === 'circles' || view.transcriptMode === 'glyphs'\">\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Colour by</span>\n <p-selectButton [options]=\"transcriptColorOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.transcriptColorBy\" (ngModelChange)=\"onTranscriptColorBy($event)\"\n [allowEmpty]=\"false\" styleClass=\"sc-buttons\"></p-selectButton>\n </div>\n <small class=\"sc-hint\" *ngIf=\"view.transcriptAllGenes && view.transcriptColorBy === 'gene' && estimateOverMax\">\n Grouped markers mix genes, so they are coloured by cell type until you zoom in to individual transcripts.\n </small>\n <div class=\"sc-row\" *ngIf=\"view.transcriptAllGenes && view.transcriptMode === 'glyphs'\">\n <span class=\"sc-lbl\">Icon</span>\n <p-dropdown [options]=\"glyphOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"glyphOf('*', 0)\" (ngModelChange)=\"onGlyph('*', $event)\"\n appendTo=\"body\" styleClass=\"sc-glyph-pick\">\n <ng-template let-opt pTemplate=\"item\">\n <span class=\"sc-glyph-option\">\n <svg viewBox=\"-1.2 -1.2 2.4 2.4\" class=\"sc-glyph-svg\"><polygon [attr.points]=\"opt.points\"></polygon></svg>\n {{ opt.label }}\n </span>\n </ng-template>\n <ng-template let-opt pTemplate=\"selectedItem\">\n <span class=\"sc-glyph-option\">\n <svg viewBox=\"-1.2 -1.2 2.4 2.4\" class=\"sc-glyph-svg\"><polygon [attr.points]=\"opt?.points\"></polygon></svg>\n </span>\n </ng-template>\n </p-dropdown>\n </div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Size</span>\n <p-slider [ngModel]=\"view.transcriptScale\" [min]=\"0.5\" [max]=\"3\" [step]=\"0.25\"\n (onChange)=\"onTranscriptScale($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.transcriptScale }}\u00D7</span>\n </div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Opacity</span>\n <p-slider [ngModel]=\"view.transcriptOpacity\" [min]=\"0.1\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onTranscriptOpacity($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.transcriptOpacity }}</span>\n </div>\n <div class=\"sc-row\" *ngIf=\"!showingAllGenes\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.transcriptQuality === 'all'\" inputId=\"sc-tx-lowq\"\n (onChange)=\"onTranscriptQuality($event.checked)\"></p-checkbox>\n <label class=\"sc-lbl\" for=\"sc-tx-lowq\">Include low-quality calls</label>\n </div>\n </ng-container>\n\n <ng-container *ngIf=\"view.transcriptMode === 'density'\">\n <span class=\"sc-lbl\">Density map opacity</span>\n <div class=\"sc-row sc-row-number\">\n <p-slider [ngModel]=\"view.densityOpacity\" [min]=\"0.05\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onDensityOpacity($event.value)\"></p-slider>\n <p-inputNumber [ngModel]=\"densityOpacityPercent\" (ngModelChange)=\"onDensityOpacityPercent($event)\"\n [min]=\"5\" [max]=\"100\" inputStyleClass=\"sc-num\"></p-inputNumber>\n </div>\n <span class=\"sc-lbl\" pTooltip=\"Edge of each density bin\" tooltipPosition=\"top\">Density map bin size</span>\n <p-slider [ngModel]=\"densityBinIndex\" [min]=\"0\" [max]=\"3\" [step]=\"1\"\n (onChange)=\"onDensityBinIndex($event.value)\" styleClass=\"sc-bin-slider\"></p-slider>\n <div class=\"sc-ticks\">\n <span *ngFor=\"let b of densityBins\">{{ b }}\u00D7{{ b }}\u00B5m</span>\n </div>\n <span class=\"sc-lbl\" pTooltip=\"Transcripts per \u00B5m\u00B2 at which the colour scale starts and saturates\"\n tooltipPosition=\"top\">Density map scale threshold</span>\n <div class=\"sc-row sc-row-range\">\n <p-inputNumber [ngModel]=\"densityWindow[0]\" (ngModelChange)=\"onDensityRangeEnd(0, $event)\"\n mode=\"decimal\" [minFractionDigits]=\"3\" [maxFractionDigits]=\"3\"\n inputStyleClass=\"sc-num\"></p-inputNumber>\n <p-slider [ngModel]=\"densityWindow\" [range]=\"true\" [min]=\"0\" [max]=\"densitySliderMax\"\n [step]=\"densityStep\" (onChange)=\"onDensityRange($event.values)\"></p-slider>\n <p-inputNumber [ngModel]=\"densityWindow[1]\" (ngModelChange)=\"onDensityRangeEnd(1, $event)\"\n mode=\"decimal\" [minFractionDigits]=\"3\" [maxFractionDigits]=\"3\"\n inputStyleClass=\"sc-num\"></p-inputNumber>\n </div>\n <div class=\"sc-row\">\n <small class=\"sc-hint\">{{ view.densityRange ? 'Set by hand.' : 'Derived from the data.' }}</small>\n <button type=\"button\" class=\"sc-small-btn\" *ngIf=\"view.densityRange\" (click)=\"onDensityAuto()\">Auto</button>\n </div>\n <span class=\"sc-lbl\">Density map bin coloring</span>\n <div class=\"sc-row sc-row-picker\">\n <div class=\"sc-colorbar sc-full\" [style.background]=\"densityColorBarCss\"></div>\n <p-treeSelect [options]=\"colormapOptions\" [ngModel]=\"selectedDensityColormapNode\"\n selectionMode=\"single\" [showClear]=\"true\" placeholder=\"Inferno\"\n [scrollHeight]=\"'260px'\" appendTo=\"body\" styleClass=\"sc-palette\"\n (onNodeSelect)=\"onDensityColormap($event.node)\" (onClear)=\"onDensityColormap(null)\">\n <ng-template let-node pTemplate=\"default\">\n <span class=\"sc-cmap-option\">\n <img *ngIf=\"!node.children && node.data?.src\" [src]=\"node.data.src\" class=\"sc-cmap-swatch\" alt=\"\" />\n <span>{{ node.label }}</span>\n </span>\n </ng-template>\n </p-treeSelect>\n </div>\n <div class=\"sc-colorbar-ends\"><span>{{ densityWindow[0] | number: '1.0-3' }}</span><span>{{ densityWindow[1] | number: '1.0-3' }}</span></div>\n </ng-container>\n </div>\n </div>\n\n <!-- Annotations: the drawn regions, and selecting what they enclose. -->\n <div class=\"sc-panel\" [class.open]=\"open.annotations\">\n <div class=\"sc-panel-head\" (click)=\"toggleSection('annotations')\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.showAnnotations !== false\" (click)=\"$event.stopPropagation()\"\n (onChange)=\"onShowAnnotations($event.checked)\" pTooltip=\"Show the drawn regions\"\n tooltipPosition=\"top\"></p-checkbox>\n <span class=\"sc-panel-title\">Annotations</span>\n <i class=\"pi\" [class.pi-chevron-down]=\"open.annotations\" [class.pi-chevron-right]=\"!open.annotations\"></i>\n </div>\n <div class=\"sc-panel-body\" *ngIf=\"open.annotations\">\n <div class=\"sc-row\">\n <p-button label=\"Select from ROIs\" icon=\"pi pi-object-group\" size=\"small\"\n [outlined]=\"true\" (onClick)=\"selectFromRegions()\"\n pTooltip=\"Select every observation inside the drawn regions\"\n tooltipPosition=\"top\"></p-button>\n <p-button *ngIf=\"hasSelection\" label=\"Clear\" size=\"small\" [text]=\"true\"\n (onClick)=\"clearSelection()\"></p-button>\n </div>\n <small class=\"sc-hint\" *ngIf=\"hasSelection\">\n {{ selection.count | number }} of {{ ds.observations.count | number }} selected \u2014\n the rest are muted.\n </small>\n <small class=\"sc-hint\" *ngIf=\"selectionMissed\">\n No observations fell inside the drawn regions.\n </small>\n <small class=\"sc-hint\" *ngIf=\"is3d\">\n In 3D a region cuts through the cloud's full depth. Orbit and draw again\n to cut from another angle.\n </small>\n </div>\n </div>\n\n <!-- Observations: one marker per observation (cell centroid, Visium spot), and what\n colours them. The lead section for datasets without cell outlines. -->\n <div class=\"sc-panel\" [class.open]=\"open.observations\">\n <div class=\"sc-panel-head\" (click)=\"toggleSection('observations')\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.showPoints\" (click)=\"$event.stopPropagation()\"\n (onChange)=\"onShowPoints($event.checked)\"></p-checkbox>\n <span class=\"sc-panel-title\">Observations</span>\n <i class=\"pi\" [class.pi-chevron-down]=\"open.observations\" [class.pi-chevron-right]=\"!open.observations\"></i>\n </div>\n <div class=\"sc-panel-body\" *ngIf=\"open.observations\">\n <small class=\"sc-hint\" *ngIf=\"hasCells && !is3d\">\n One circle per cell. With cell outlines on, circles are only drawn where cells\n are too small on screen to outline.\n </small>\n <!-- Colour source. Column and gene are mutually exclusive: picking one\n clears the other, so what drives the colours is never ambiguous. -->\n <div class=\"sc-section\">\n <label class=\"sc-lbl\" for=\"sc-column\">Colour by column</label>\n <p-dropdown inputId=\"sc-column\" [options]=\"columnOptions\"\n optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"selectedColumn\" (ngModelChange)=\"onColumn($event)\"\n placeholder=\"None (flat colour)\" appendTo=\"body\"\n styleClass=\"sc-full\"></p-dropdown>\n </div>\n\n <div class=\"sc-section\" *ngIf=\"ds.features\">\n <label class=\"sc-lbl\" for=\"sc-gene\">Colour by gene</label>\n <!-- A filterable dropdown, not a free-text box: the genes are visible before\n anything is typed, and each keystroke narrows a list the user can see.\n Virtual-scrolled because a targeted panel runs to a few thousand names. -->\n <p-dropdown inputId=\"sc-gene\" [options]=\"geneOptions\"\n optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"selectedGene\" (ngModelChange)=\"onGene($event)\"\n [filter]=\"true\" filterBy=\"label\" [resetFilterOnHide]=\"true\"\n (onFilter)=\"onGeneFilter($event.filter)\" (onShow)=\"ensureGeneList()\"\n [showClear]=\"true\"\n [virtualScroll]=\"geneOptions.length > geneVirtualScrollFrom\"\n [virtualScrollItemSize]=\"30\"\n [emptyFilterMessage]=\"genesAreRemote ? 'No match \u2014 keep typing' : 'No match'\"\n [emptyMessage]=\"genesAreRemote ? 'Type to search genes' : 'No genes'\"\n appendTo=\"body\"\n placeholder=\"Search {{ ds.features.count | number }} genes\u2026\"\n styleClass=\"sc-full\"></p-dropdown>\n <small class=\"sc-hint\" *ngIf=\"geneSearchFailed\">Gene lookup failed.</small>\n </div>\n\n <!-- Key: categorical legend or a continuous colour bar, built from the same\n functions the renderer uses so it cannot disagree with the canvas. -->\n <div class=\"sc-section sc-key\" *ngIf=\"view.colorBy\">\n <span class=\"sc-lbl\">{{ colorByLabel }}</span>\n <!-- Derived columns say so, so they are not mistaken for measured data. -->\n <small class=\"sc-hint\" *ngIf=\"activeDescription\">{{ activeDescription }}</small>\n\n <!-- Legend rows are buttons: clicking one selects that category, clicking\n it again clears \u2014 so a click is always reversible. -->\n <ul class=\"sc-legend\" *ngIf=\"isCategorical\">\n <li *ngFor=\"let entry of legend; index as i\">\n <button type=\"button\" class=\"sc-legend-btn\"\n [class.selected]=\"selectedCategory === i\"\n (click)=\"selectCategory(i)\"\n [title]=\"'Select ' + entry.label\">\n <span class=\"sc-swatch\" [style.background]=\"entry.color\"></span>\n <span class=\"sc-legend-label\">{{ entry.label }}</span>\n </button>\n </li>\n </ul>\n\n <div *ngIf=\"isContinuous\">\n <div class=\"sc-colorbar\" [style.background]=\"colorBarCss\"></div>\n <div class=\"sc-colorbar-ends\">\n <span>low</span>\n <span class=\"sc-hint\">window from percentiles</span>\n <span>high</span>\n </div>\n <!-- The gradient this bar and the map are drawn with. Sits under the bar\n it controls, with the same swatch previews the image's colormap picker\n uses: which gradient reads best is a visual judgement, so a list of\n names would be the wrong control. Cleared = follow the image's. -->\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Colormap</span>\n <p-treeSelect [options]=\"colormapOptions\" [ngModel]=\"selectedColormapNode\"\n selectionMode=\"single\" [showClear]=\"true\"\n placeholder=\"Match the image\" [scrollHeight]=\"'260px'\"\n appendTo=\"body\"\n pTooltip=\"Colour scale for gene and numeric values \u2014 the markers, the gene map and this bar. Clear it to follow the image's colormap\"\n tooltipPosition=\"top\"\n (onNodeSelect)=\"onContinuousColormap($event.node)\"\n (onClear)=\"onContinuousColormap(null)\">\n <ng-template let-node pTemplate=\"default\">\n <span class=\"sc-cmap-option\">\n <img *ngIf=\"!node.children && node.data?.src\" [src]=\"node.data.src\"\n class=\"sc-cmap-swatch\" alt=\"\" />\n <span>{{ node.label }}</span>\n </span>\n </ng-template>\n </p-treeSelect>\n <span class=\"sc-val\"></span>\n </div>\n </div>\n </div>\n\n <!-- Display -->\n <div class=\"sc-section\">\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Point size</span>\n <p-slider [ngModel]=\"view.pointScale\" [min]=\"0.25\" [max]=\"5\" [step]=\"0.25\"\n (onChange)=\"onPointScale($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.pointScale }}\u00D7</span>\n </div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Opacity</span>\n <p-slider [ngModel]=\"view.opacity\" [min]=\"0.1\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onOpacity($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.opacity }}</span>\n </div>\n </div>\n\n </div>\n </div>\n\n <!-- Only for a gene: the map draws that gene's expression as a field. In 2D\n it goes under the cells of the displayed plane; in 3D it is one sheet per\n imaged section, optionally smoothed along z into a volume. -->\n <div class=\"sc-section\" *ngIf=\"canMapGene\">\n <div class=\"sc-row\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.geneMap\"\n (onChange)=\"onGeneMap($event.checked)\"\n label=\"Gene map (expression field)\"\n [pTooltip]=\"is3d\n ? 'Draw this gene\\'s expression as one field per imaged section \u2014 an estimate between the cells, empty where nothing was measured'\n : 'Draw this gene\\'s expression as a smooth field UNDER the cells \u2014 an estimate between them, transparent where nothing was measured'\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <!-- 3D only: interpolating along z turns the measured sheets into an\n estimate, so it is its own decision and its own label. -->\n <div class=\"sc-row\" *ngIf=\"view.geneMap && is3d\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.geneMapVolume\"\n (onChange)=\"onGeneMapVolume($event.checked)\"\n label=\"Volume rendering (interpolate along z)\"\n pTooltip=\"Smooth the per-section fields into a continuous volume \u2014 an ESTIMATE between the imaged sections, not measured tissue\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <!-- Meaningless while interpolating: a volume built from one section would\n smear that slide through the specimen's whole depth. -->\n <div class=\"sc-row\" *ngIf=\"view.geneMap && is3d && isSectioned && !view.geneMapVolume\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"geneMapOneSection\"\n (onChange)=\"onGeneMapOneSection($event.checked)\"\n label=\"One map section at a time\"\n pTooltip=\"Draw a single imaged section's field instead of the whole stack of sheets\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <div class=\"sc-row\"\n *ngIf=\"view.geneMap && is3d && isSectioned && !view.geneMapVolume && geneMapOneSection\">\n <span class=\"sc-lbl\">Map section</span>\n <p-slider [ngModel]=\"view.geneMapSection\" [min]=\"0\" [max]=\"lastSection\" [step]=\"1\"\n (onChange)=\"onGeneMapSection($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ geneMapSectionLabel }}</span>\n </div>\n <div class=\"sc-row\" *ngIf=\"view.geneMap\">\n <span class=\"sc-lbl\">Smoothing</span>\n <p-slider [ngModel]=\"view.geneMapSmoothing\" [min]=\"0.5\" [max]=\"4\" [step]=\"0.5\"\n (onChange)=\"onGeneMapSmoothing($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.geneMapSmoothing }}\u00D7</span>\n </div>\n <div class=\"sc-row\" *ngIf=\"view.geneMap\">\n <span class=\"sc-lbl\">Map opacity</span>\n <p-slider [ngModel]=\"view.geneMapOpacity\" [min]=\"0.1\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onGeneMapOpacity($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.geneMapOpacity }}</span>\n </div>\n <div class=\"sc-hint\" *ngIf=\"view.geneMap && !is3d\">\n Kernel-weighted mean per cell, not a sum \u2014 so a crowded region does not glow\n for being crowded. Transparent where no cell was measured. Turn the cells'\n own Opacity down to read the field under them.\n </div>\n <div class=\"sc-hint\" *ngIf=\"view.geneMap && is3d\">\n {{ geneMapVolumeNote }}\n </div>\n </div>\n\n <!-- 3D only: the volume, the cloud and the density volumes occupy the same\n space, so each hides the others. Independent toggles, because the useful\n views are the combinations \u2014 the field alone, one section's cells over it,\n the anatomy alone. -->\n <div class=\"sc-section\" *ngIf=\"is3d\">\n <span class=\"sc-lbl\">Show</span>\n <div class=\"sc-row\" *ngIf=\"ds.volume\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.showVolume\"\n (onChange)=\"onShowVolume($event.checked)\"\n label=\"Reference volume\"\n pTooltip=\"The registered anatomy the observations sit in\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <!-- The backdrop's own opacity: reading the cloud or a density field\n through the anatomy means turning the anatomy down, not the data. -->\n <div class=\"sc-row\" *ngIf=\"ds.volume && view.showVolume\">\n <span class=\"sc-lbl\">Volume opacity</span>\n <p-slider [ngModel]=\"view.volumeOpacity\" [min]=\"0.05\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onVolumeOpacity($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.volumeOpacity }}</span>\n </div>\n <div class=\"sc-row\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.showPoints\"\n (onChange)=\"onShowPoints($event.checked)\"\n label=\"Observations\"\n pTooltip=\"The measured cells. Hide them to read the density volumes underneath\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <!-- Only for data that really is sectioned; a continuous z has no sections\n to step through. -->\n <div class=\"sc-row\" *ngIf=\"view.showPoints && isSectioned\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"oneSection\"\n (onChange)=\"onOneSection($event.checked)\"\n label=\"One section at a time\"\n pTooltip=\"Draw a single imaged section's cells, so the density volumes between the sections stay visible\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <div class=\"sc-row\" *ngIf=\"view.showPoints && isSectioned && oneSection\">\n <span class=\"sc-lbl\">Section</span>\n <p-slider [ngModel]=\"view.pointSection\" [min]=\"0\" [max]=\"lastSection\" [step]=\"1\"\n (onChange)=\"onPointSection($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ sectionLabel }}</span>\n </div>\n </div>\n\n <div class=\"sc-section\" *ngIf=\"is3d\">\n <div class=\"sc-hint\" *ngIf=\"exceedsCloudPalette\">\n {{ legend?.length }} categories is past the {{ cloudPaletteLimit }} the 3D cloud's\n colour LUT can keep apart, so the points draw flat for this column. The density\n volumes below render it, and so does the 2D Spatial omics view.\n </div>\n <div class=\"sc-row\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.densityVolume\"\n (onChange)=\"onDensityVolume($event.checked)\"\n label=\"Cluster density volumes\"\n pTooltip=\"Raymarch each cluster as a smooth density field \u2014 an ESTIMATE between the imaged sections, not measured cells\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <div class=\"sc-row\" *ngIf=\"view.densityVolume\">\n <span class=\"sc-lbl\">Smoothing</span>\n <p-slider [ngModel]=\"view.densitySmoothing\" [min]=\"0.5\" [max]=\"4\" [step]=\"0.5\"\n (onChange)=\"onDensitySmoothing($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.densitySmoothing }}\u00D7</span>\n </div>\n <div class=\"sc-hint\" *ngIf=\"view.densityVolume\">{{ densityNote }}</div>\n </div>\n\n <!-- Continuous-only knobs: meaningless for a categorical palette. -->\n <div class=\"sc-section\" *ngIf=\"isContinuous\">\n <div class=\"sc-row\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.logScale\"\n (onChange)=\"onLogScale($event.checked)\"\n label=\"Log scale\"\n pTooltip=\"Counts collapse against their outliers on a linear ramp\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Clip outliers</span>\n <p-dropdown [options]=\"clipOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.percentileClip\" (ngModelChange)=\"onClip($event)\"\n appendTo=\"body\"\n pTooltip=\"Percentile the contrast window is taken at\"\n tooltipPosition=\"top\"></p-dropdown>\n <!-- Reserves the readout column this row has no readout for, so the\n control's right edge lines up with every slider's. -->\n <span class=\"sc-val\"></span>\n </div>\n </div>\n\n <!-- Distributions of whatever the map is coloured by. In the SAME dialog as\n the colour controls on purpose: changing the gene and watching the\n distribution move is one action, and two floating windows made that\n harder to see rather than easier. -->\n <div class=\"sc-section sc-charts\">\n <button type=\"button\" class=\"sc-disclosure\" (click)=\"toggleCharts()\"\n [attr.aria-expanded]=\"chartsOpen\" [attr.aria-controls]=\"chartsBodyId\">\n <i class=\"pi\" [class.pi-chevron-right]=\"!chartsOpen\"\n [class.pi-chevron-down]=\"chartsOpen\"></i>\n <span>Distribution</span>\n <small *ngIf=\"!chartsOpen && view.colorBy\">of {{ colorByLabel }}</small>\n </button>\n <!-- Kept MOUNTED when collapsed, only hidden: destroying it would throw\n away the chart kind and grouping the user picked. `active` is what\n stops it drawing while it has no size. -->\n <div [id]=\"chartsBodyId\" [hidden]=\"!chartsOpen\">\n <spatial-charts [active]=\"visible && chartsOpen\"></spatial-charts>\n </div>\n </div>\n\n <div class=\"sc-btns\">\n <p-button label=\"Reset\" size=\"small\" [text]=\"true\" (onClick)=\"reset()\"></p-button>\n </div>\n\n </div>\n</p-dialog>\n", styles: ["@charset \"UTF-8\";:host ::ng-deep .spatial-controls-dialog .p-dialog-content{padding:.75rem 1rem 1rem}.sc-empty{padding:.5rem 0;font-size:.85rem;color:var(--text-color-secondary, #6c757d);line-height:1.4}.sc-empty code{font-size:.8rem}.sc-body{display:flex;flex-direction:column;gap:.85rem}.sc-dataset{display:flex;flex-direction:column;gap:.1rem;padding-bottom:.5rem;border-bottom:1px solid var(--surface-border, #dee2e6)}.sc-dataset-name{font-weight:600;font-size:.9rem}.sc-dataset-count,.sc-hint{font-size:.75rem;color:var(--text-color-secondary, #6c757d)}.sc-section{display:flex;flex-direction:column;gap:.35rem}.sc-lbl{font-size:.78rem;color:var(--text-color-secondary, #6c757d);white-space:nowrap}.sc-row{display:flex;align-items:center;gap:.6rem}.sc-row p-slider,.sc-row p-dropdown,.sc-row p-treeSelect{flex:1 1 0;min-width:0}:host ::ng-deep .sc-row p-slider .p-slider{width:100%}:host ::ng-deep .sc-row p-dropdown .p-dropdown,:host ::ng-deep .sc-row p-treeSelect .p-treeselect{width:100%}.sc-row .sc-lbl{flex:0 0 calc(50% - .6rem);min-width:0;overflow:hidden;text-overflow:ellipsis}.sc-cmap-option{display:inline-flex;align-items:center;gap:.4rem}.sc-cmap-swatch{width:3.5rem;height:.7rem;border-radius:2px;display:block}.sc-val{flex:0 0 4rem;text-align:right;font-size:.78rem;font-variant-numeric:tabular-nums;white-space:nowrap}:host ::ng-deep .sc-full,:host ::ng-deep .sc-full .p-dropdown,:host ::ng-deep .sc-full .p-autocomplete,:host ::ng-deep .sc-full input{width:100%}.sc-key{padding:.5rem 0;border-top:1px solid var(--surface-border, #dee2e6);border-bottom:1px solid var(--surface-border, #dee2e6)}.sc-legend{list-style:none;margin:0;padding:0;max-height:11rem;overflow-y:auto;display:flex;flex-direction:column;gap:.2rem}.sc-legend li{display:flex}.sc-legend-btn{display:flex;align-items:center;gap:.45rem;width:100%;padding:.15rem .3rem;font:inherit;font-size:.8rem;text-align:left;background:none;border:1px solid transparent;border-radius:3px;cursor:pointer;color:inherit}.sc-legend-btn:hover{background:var(--surface-hover, rgba(0, 0, 0, .04))}.sc-legend-btn.selected{border-color:var(--primary-color, #3b82f6);background:var(--surface-hover, rgba(59, 130, 246, .08));font-weight:600}.sc-selection .sc-row{gap:.4rem}.sc-swatch{width:.85rem;height:.85rem;flex:0 0 auto;border-radius:2px;border:1px solid rgba(0,0,0,.2)}.sc-legend-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sc-colorbar{height:.75rem;border-radius:2px;border:1px solid rgba(0,0,0,.2)}.sc-colorbar-ends{display:flex;justify-content:space-between;font-size:.7rem;color:var(--text-color-secondary, #6c757d);margin-top:.2rem}.sc-btns{display:flex;justify-content:flex-end}.sc-charts{padding-top:.5rem;border-top:1px solid var(--surface-border, #dee2e6);gap:0}.sc-disclosure{display:flex;align-items:center;gap:.4rem;width:100%;padding:.2rem 0;font:inherit;font-size:.82rem;font-weight:600;text-align:left;background:none;border:0;cursor:pointer;color:inherit}.sc-disclosure i{font-size:.7rem;color:var(--text-color-secondary, #6c757d)}.sc-disclosure small{font-weight:400;color:var(--text-color-secondary, #6c757d);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sc-disclosure:hover{color:var(--primary-color, #3b82f6)}.sc-legend-static{cursor:default}.sc-tx-gene{display:flex;align-items:center;gap:.5rem}.sc-tx-gene .sc-legend-label{flex:1}:host ::ng-deep .sc-glyph-pick .p-dropdown-label{padding:.2rem .4rem}.sc-glyph-option{display:inline-flex;align-items:center;gap:.4rem}.sc-glyph-svg{width:1rem;height:1rem;fill:currentColor}:host ::ng-deep .sc-row-buttons .p-selectbutton .p-button{padding:.35rem .6rem;font-size:.85rem}:host ::ng-deep .sc-row-buttons .p-selectbutton{display:flex;flex-wrap:nowrap}.sc-row-wide .sc-lbl{flex:0 0 auto}.sc-panel{border:1px solid var(--surface-border, #dee2e6);border-radius:6px;margin-bottom:.5rem;overflow:hidden}.sc-panel-head{display:flex;align-items:center;gap:.6rem;padding:.5rem .7rem;cursor:pointer;-webkit-user-select:none;user-select:none;background:var(--surface-100, #f5f6f7)}.sc-panel-head .sc-panel-title{flex:1;font-weight:600}.sc-panel.open>.sc-panel-head{background:var(--primary-color, #2196f3);color:var(--primary-color-text, #fff)}.sc-panel-body{display:flex;flex-direction:column;gap:.45rem;padding:.6rem .75rem .75rem}.sc-subhead{display:flex;align-items:center;gap:.5rem;margin-top:.4rem;font-size:.72rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--text-color-secondary, #6c757d)}.sc-subhead:after{content:\"\";flex:1;border-top:1px solid var(--surface-border, #dee2e6)}:host ::ng-deep .sc-buttons .p-button{padding:.35rem .75rem;font-size:.85rem}.sc-row-picker p-dropdown,.sc-row-picker p-multiSelect,.sc-row-picker p-treeSelect{flex:1 1 0;min-width:0}:host ::ng-deep .sc-row-picker .sc-variant{width:6.5rem}:host ::ng-deep .sc-row-picker p-multiselect .p-multiselect{width:100%}:host ::ng-deep .sc-row-picker p-dropdown:has(.sc-variant){flex:0 0 6.5rem}:host ::ng-deep .sc-row-picker p-treeselect:has(.sc-palette){flex:0 0 3rem}:host ::ng-deep .sc-palette .p-treeselect-label{width:0;padding:0}.sc-icon-btn{flex:0 0 auto;border:0;background:transparent;cursor:pointer;padding:.25rem .35rem;font:inherit;font-size:1.05rem;color:var(--text-color, #333);border-radius:4px}.sc-icon-btn:hover{background:var(--surface-200, #e9ecef)}.sc-icon-btn.active{color:var(--primary-color, #2196f3);font-weight:700}.sc-icon-btn:disabled{opacity:.5;cursor:default}.sc-small-btn{margin-left:auto;border:1px solid var(--surface-border, #ced4da);background:var(--surface-0, #fff);border-radius:4px;padding:.2rem .6rem;font:inherit;font-size:.8rem;cursor:pointer}:host ::ng-deep .sc-group-heading{font-size:.7rem;font-weight:700;letter-spacing:.05em;text-transform:uppercase;opacity:.7}.sc-group-list,.sc-gene-tree{max-height:16rem;overflow-y:auto;border:1px solid var(--surface-border, #dee2e6);border-radius:4px;padding:.25rem .4rem}.sc-group-row,.sc-gene-row{display:flex;align-items:center;gap:.45rem;padding:.15rem 0;font-size:.88rem}.sc-group-row .sc-group-label,.sc-gene-row .sc-group-label{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sc-group-all,.sc-gene-root,.sc-gene-group{font-weight:600}.sc-group-all .sc-icon-btn,.sc-gene-root .sc-icon-btn,.sc-gene-group .sc-icon-btn{margin-left:auto;font-size:.85rem}.sc-gene-group,.sc-gene-leaf{padding-left:1rem}.sc-gene-leaf.nested{padding-left:2rem}.sc-count{flex:0 0 auto;font-size:.78rem;color:var(--text-color-secondary, #6c757d)}.sc-dot{flex:0 0 auto;width:.7rem;height:.7rem;border-radius:50%;border:1px solid rgba(0,0,0,.2)}.sc-dot-pick{position:relative;cursor:pointer}.sc-dot-pick input{position:absolute;inset:0;opacity:0;width:100%;height:100%;cursor:pointer}.sc-eye{flex:0 0 auto;border:0;background:transparent;cursor:pointer;padding:0;color:var(--text-color-secondary, #6c757d)}.sc-eye .pi-eye{color:var(--text-color, #333)}.sc-gene-footer{text-align:right;font-size:.78rem;padding:.2rem 0;color:var(--text-color-secondary, #6c757d)}.sc-color-input{width:2.5rem;height:1.8rem;border:1px solid var(--surface-border, #ced4da);border-radius:4px;padding:0;background:none}.sc-meter{height:.45rem;border-radius:3px;background:var(--surface-200, #e9ecef);overflow:hidden}.sc-meter-fill{height:100%;background:var(--primary-color, #2196f3)}.sc-meter-fill.over{background:var(--orange-500, #f59e0b)}.sc-meter-ends,.sc-ticks{display:flex;justify-content:space-between;font-size:.72rem;font-weight:600;color:var(--text-color-secondary, #6c757d)}.sc-row-number p-slider,.sc-row-range p-slider{flex:1 1 0;min-width:0}:host ::ng-deep .sc-num{width:4.5rem;padding:.3rem .4rem;text-align:center}:host ::ng-deep .sc-row-number p-inputnumber,:host ::ng-deep .sc-row-range p-inputnumber{flex:0 0 auto}.sc-error{color:var(--red-600, #c62828)}.sc-gene-icon{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:1.4rem;height:1.4rem;padding:0;border:1px solid transparent;border-radius:4px;background:transparent;cursor:pointer}.sc-gene-icon:hover,.sc-gene-icon:focus-visible{border-color:var(--surface-border, rgba(0, 0, 0, .2))}::ng-deep .sc-gene-style .p-overlaypanel-content{padding:.75rem}.sc-style-pop{display:flex;flex-direction:column;gap:.5rem;width:15rem}.sc-style-title{font-weight:600;font-size:.9rem}.sc-style-sub{font-size:.75rem;text-transform:uppercase;opacity:.7}.sc-style-icons,.sc-style-presets{display:grid;grid-template-columns:repeat(5,1fr);gap:.35rem}.sc-style-icon{display:inline-flex;align-items:center;justify-content:center;height:1.9rem;border:1px solid var(--surface-border, rgba(0, 0, 0, .15));border-radius:4px;background:transparent;color:var(--text-color, inherit);cursor:pointer}.sc-style-icon .sc-glyph-svg{width:1.1rem;height:1.1rem}.sc-style-icon.active{border-color:var(--primary-color, #3b82f6);background:var(--highlight-bg, rgba(59, 130, 246, .12))}.sc-style-color{display:flex;gap:.5rem;align-items:center}.sc-style-swatch{position:relative;flex:0 0 3rem;height:2rem;border-radius:4px;border:1px solid rgba(0,0,0,.2);cursor:pointer}.sc-style-swatch input{position:absolute;inset:0;opacity:0;width:100%;height:100%;cursor:pointer}.sc-style-hex{flex:1 1 auto;min-width:0;font-family:monospace}.sc-style-preset{width:1.4rem;height:1.4rem;justify-self:center;border-radius:50%;border:2px solid transparent;cursor:pointer}.sc-style-preset.active{border-color:var(--text-color, #000)}.sc-gene-count{margin-left:auto;font-variant-numeric:tabular-nums;opacity:.8;padding-left:.5rem}.sc-markers{display:flex;flex-direction:column;gap:.4rem;padding:.5rem;margin:.25rem 0 .5rem;border:1px solid var(--surface-border, rgba(0, 0, 0, .15));border-radius:6px}.sc-markers-actions{gap:.5rem}\n"], dependencies: [{ kind: "directive", type: i1$1.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$2.PrimeTemplate, selector: "[pTemplate]", inputs: ["type", "pTemplate"] }, { kind: "component", type: i5.Button, selector: "p-button", inputs: ["type", "iconPos", "icon", "badge", "label", "disabled", "loading", "loadingIcon", "raised", "rounded", "text", "plain", "severity", "outlined", "link", "tabindex", "size", "style", "styleClass", "badgeClass", "ariaLabel", "autofocus"], outputs: ["onClick", "onFocus", "onBlur"] }, { kind: "component", type: i6.Menu, selector: "p-menu", inputs: ["model", "popup", "style", "styleClass", "appendTo", "autoZIndex", "baseZIndex", "showTransitionOptions", "hideTransitionOptions", "ariaLabel", "ariaLabelledBy", "id", "tabindex"], outputs: ["onShow", "onHide", "onBlur", "onFocus"] }, { kind: "directive", type: i6$1.Tooltip, selector: "[pTooltip]", inputs: ["tooltipPosition", "tooltipEvent", "appendTo", "positionStyle", "tooltipStyleClass", "tooltipZIndex", "escape", "showDelay", "hideDelay", "life", "positionTop", "positionLeft", "autoHide", "fitContent", "hideOnEscape", "pTooltip", "tooltipDisabled", "tooltipOptions"] }, { kind: "component", type: i7.Dropdown, selector: "p-dropdown", inputs: ["id", "scrollHeight", "filter", "name", "style", "panelStyle", "styleClass", "panelStyleClass", "readonly", "required", "editable", "appendTo", "tabindex", "placeholder", "loadingIcon", "filterPlaceholder", "filterLocale", "variant", "inputId", "dataKey", "filterBy", "filterFields", "autofocus", "resetFilterOnHide", "checkmark", "dropdownIcon", "loading", "optionLabel", "optionValue", "optionDisabled", "optionGroupLabel", "optionGroupChildren", "autoDisplayFirst", "group", "showClear", "emptyFilterMessage", "emptyMessage", "lazy", "virtualScroll", "virtualScrollItemSize", "virtualScrollOptions", "overlayOptions", "ariaFilterLabel", "ariaLabel", "ariaLabelledBy", "filterMatchMode", "maxlength", "tooltip", "tooltipPosition", "tooltipPositionStyle", "tooltipStyleClass", "focusOnHover", "selectOnFocus", "autoOptionFocus", "autofocusFilter", "autoShowPanelOnPrintableCharacterKeyDown", "disabled", "itemSize", "autoZIndex", "baseZIndex", "showTransitionOptions", "hideTransitionOptions", "filterValue", "options"], outputs: ["onChange", "onFilter", "onFocus", "onBlur", "onClick", "onShow", "onHide", "onClear", "onLazyLoad"] }, { kind: "component", type: i9.Slider, selector: "p-slider", inputs: ["animate", "disabled", "min", "max", "orientation", "step", "range", "style", "styleClass", "ariaLabel", "ariaLabelledBy", "tabindex", "autofocus"], outputs: ["onChange", "onSlideEnd"] }, { kind: "component", type: i7$1.TreeSelect, selector: "p-treeSelect", inputs: ["inputId", "scrollHeight", "disabled", "metaKeySelection", "variant", "display", "selectionMode", "tabindex", "ariaLabel", "ariaLabelledBy", "placeholder", "panelClass", "panelStyle", "panelStyleClass", "containerStyle", "containerStyleClass", "labelStyle", "labelStyleClass", "overlayOptions", "emptyMessage", "appendTo", "filter", "filterBy", "filterMode", "filterPlaceholder", "filterLocale", "filterInputAutoFocus", "propagateSelectionDown", "propagateSelectionUp", "showClear", "resetFilterOnHide", "virtualScroll", "virtualScrollItemSize", "virtualScrollOptions", "autofocus", "options", "showTransitionOptions", "hideTransitionOptions", "loading"], outputs: ["onNodeExpand", "onNodeCollapse", "onShow", "onHide", "onClear", "onFilter", "onFocus", "onBlur", "onNodeUnselect", "onNodeSelect"] }, { kind: "component", type: i10.InputNumber, selector: "p-inputNumber", inputs: ["showButtons", "format", "buttonLayout", "inputId", "styleClass", "style", "placeholder", "size", "maxlength", "tabindex", "title", "ariaLabelledBy", "ariaLabel", "ariaRequired", "name", "required", "autocomplete", "min", "max", "incrementButtonClass", "decrementButtonClass", "incrementButtonIcon", "decrementButtonIcon", "readonly", "step", "allowEmpty", "locale", "localeMatcher", "mode", "currency", "currencyDisplay", "useGrouping", "variant", "minFractionDigits", "maxFractionDigits", "prefix", "suffix", "inputStyle", "inputStyleClass", "showClear", "autofocus", "disabled"], outputs: ["onInput", "onFocus", "onBlur", "onKeyDown", "onClear"] }, { kind: "component", type: i11.Dialog, selector: "p-dialog", inputs: ["header", "draggable", "resizable", "positionLeft", "positionTop", "contentStyle", "contentStyleClass", "modal", "closeOnEscape", "dismissableMask", "rtl", "closable", "responsive", "appendTo", "breakpoints", "styleClass", "maskStyleClass", "maskStyle", "showHeader", "breakpoint", "blockScroll", "autoZIndex", "baseZIndex", "minX", "minY", "focusOnShow", "maximizable", "keepInViewport", "focusTrap", "transitionOptions", "closeIcon", "closeAriaLabel", "closeTabindex", "minimizeIcon", "maximizeIcon", "visible", "style", "position"], outputs: ["onShow", "onHide", "visibleChange", "onResizeInit", "onResizeEnd", "onDragEnd", "onMaximize"] }, { kind: "component", type: i13.OverlayPanel, selector: "p-overlayPanel", inputs: ["ariaLabel", "ariaLabelledBy", "dismissable", "showCloseIcon", "style", "styleClass", "appendTo", "autoZIndex", "ariaCloseLabel", "baseZIndex", "focusOnShow", "showTransitionOptions", "hideTransitionOptions"], outputs: ["onShow", "onHide"] }, { kind: "directive", type: i15.InputText, selector: "[pInputText]", inputs: ["variant"] }, { kind: "component", type: i16.Checkbox, selector: "p-checkbox", inputs: ["value", "name", "disabled", "binary", "label", "ariaLabelledBy", "ariaLabel", "tabindex", "inputId", "style", "styleClass", "labelStyleClass", "formControl", "checkboxIcon", "readonly", "required", "autofocus", "trueValue", "falseValue", "variant"], outputs: ["onChange", "onFocus", "onBlur"] }, { kind: "component", type: i9$2.MultiSelect, selector: "p-multiSelect", inputs: ["id", "ariaLabel", "style", "styleClass", "panelStyle", "panelStyleClass", "inputId", "disabled", "readonly", "group", "filter", "filterPlaceHolder", "filterLocale", "overlayVisible", "tabindex", "variant", "appendTo", "dataKey", "name", "ariaLabelledBy", "displaySelectedLabel", "maxSelectedLabels", "selectionLimit", "selectedItemsLabel", "showToggleAll", "emptyFilterMessage", "emptyMessage", "resetFilterOnHide", "dropdownIcon", "optionLabel", "optionValue", "optionDisabled", "optionGroupLabel", "optionGroupChildren", "showHeader", "filterBy", "scrollHeight", "lazy", "virtualScroll", "loading", "virtualScrollItemSize", "loadingIcon", "virtualScrollOptions", "overlayOptions", "ariaFilterLabel", "filterMatchMode", "tooltip", "tooltipPosition", "tooltipPositionStyle", "tooltipStyleClass", "autofocusFilter", "display", "autocomplete", "showClear", "autofocus", "autoZIndex", "baseZIndex", "showTransitionOptions", "hideTransitionOptions", "defaultLabel", "placeholder", "options", "filterValue", "itemSize", "selectAll", "focusOnHover", "filterFields", "selectOnFocus", "autoOptionFocus"], outputs: ["onChange", "onFilter", "onFocus", "onBlur", "onClick", "onClear", "onPanelShow", "onPanelHide", "onLazyLoad", "onRemove", "onSelectAllChange"] }, { kind: "component", type: i18.SelectButton, selector: "p-selectButton", inputs: ["options", "optionLabel", "optionValue", "optionDisabled", "unselectable", "tabindex", "multiple", "allowEmpty", "style", "styleClass", "ariaLabelledBy", "disabled", "dataKey", "autofocus"], outputs: ["onOptionClick", "onChange"] }, { kind: "component", type: SpatialChartsComponent, selector: "spatial-charts", inputs: ["active"] }, { kind: "pipe", type: i1$1.DecimalPipe, name: "number" }] }); }
|
|
12876
13218
|
}
|
|
12877
13219
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.9", ngImport: i0, type: SpatialControlsComponent, decorators: [{
|
|
12878
13220
|
type: Component,
|
|
12879
|
-
args: [{ selector: 'spatial-controls', template: "<p-dialog header=\"Spatial omics\"\n [visible]=\"visible\" (visibleChange)=\"onVisibleChange($event)\"\n [modal]=\"false\" [resizable]=\"true\" [draggable]=\"true\"\n position=\"left\"\n (onResizeEnd)=\"onResizeEnd()\"\n [style]=\"{ width: '30rem' }\"\n [breakpoints]=\"{ '960px': '90vw' }\"\n styleClass=\"spatial-controls-dialog\">\n\n <!-- No port bound: say why rather than showing controls that do nothing. -->\n <div class=\"sc-empty\" *ngIf=\"!controls\">\n No spatial-omics data source is configured. A host provides one by binding\n <code>SPATIAL_DATA_PORT</code>.\n </div>\n\n <div class=\"sc-empty\" *ngIf=\"controls && !dataset\">\n No dataset is loaded.\n </div>\n\n <div class=\"sc-body\" *ngIf=\"controls && dataset as ds\">\n\n <div class=\"sc-dataset\">\n <span class=\"sc-dataset-name\">{{ ds.name }}</span>\n <span class=\"sc-dataset-count\">{{ ds.observations.count | number }} observations</span>\n </div>\n\n <!-- Organised as Xenium Explorer's side panel: one collapsible section per layer,\n each with its own on/off switch in the header. -->\n\n <!-- Images -->\n <div class=\"sc-panel\" *ngIf=\"ds.imageRef && !is3d\" [class.open]=\"open.images\">\n <div class=\"sc-panel-head\" (click)=\"toggleSection('images')\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.showImage\" (click)=\"$event.stopPropagation()\"\n (onChange)=\"onShowImage($event.checked)\"></p-checkbox>\n <span class=\"sc-panel-title\">Images</span>\n <i class=\"pi\" [class.pi-chevron-down]=\"open.images\" [class.pi-chevron-right]=\"!open.images\"></i>\n </div>\n <div class=\"sc-panel-body\" *ngIf=\"open.images\">\n <small class=\"sc-hint\">\n Channels, colours and contrast are in the Channels & Histogram dialog.\n </small>\n </div>\n </div>\n\n <!-- Cells -->\n <div class=\"sc-panel\" *ngIf=\"hasCells && !is3d\" [class.open]=\"open.cells\">\n <div class=\"sc-panel-head\" (click)=\"toggleSection('cells')\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"cellsOn\" (click)=\"$event.stopPropagation()\"\n (onChange)=\"onShowCells($event.checked)\"></p-checkbox>\n <span class=\"sc-panel-title\">Cells</span>\n <i class=\"pi\" [class.pi-chevron-down]=\"open.cells\" [class.pi-chevron-right]=\"!open.cells\"></i>\n </div>\n <div class=\"sc-panel-body\" *ngIf=\"open.cells\">\n <ng-container *ngIf=\"cellSetOptions.length > 1\">\n <div class=\"sc-subhead\">Boundaries</div>\n <p-selectButton [options]=\"cellSetOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"activeCellSet\" (ngModelChange)=\"onCellSet($event)\"\n [allowEmpty]=\"false\" styleClass=\"sc-buttons\"></p-selectButton>\n </ng-container>\n\n <div class=\"sc-subhead\">Coloring</div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Cell Color</span>\n <p-dropdown [options]=\"cellColorOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.cellColorMode\" (ngModelChange)=\"onCellColorMode($event)\"\n appendTo=\"body\"></p-dropdown>\n </div>\n <small class=\"sc-hint\" *ngIf=\"view.cellColorMode === 'group'\">\n Adjust cell color in the groups section below.\n </small>\n <div class=\"sc-row\" *ngIf=\"view.cellColorMode === 'gene' && ds.features\">\n <span class=\"sc-lbl\">Gene</span>\n <p-dropdown [options]=\"geneOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.cellColorGene\" (ngModelChange)=\"onCellColorGene($event)\"\n [filter]=\"true\" filterBy=\"label\" [resetFilterOnHide]=\"true\"\n (onFilter)=\"onGeneFilter($event.filter)\" (onShow)=\"ensureGeneList()\"\n [virtualScroll]=\"geneOptions.length > geneVirtualScrollFrom\"\n [virtualScrollItemSize]=\"30\" appendTo=\"body\"\n placeholder=\"Search genes\u2026\"></p-dropdown>\n </div>\n <div class=\"sc-row\" *ngIf=\"view.cellColorMode === 'single'\">\n <span class=\"sc-lbl\">Color</span>\n <input type=\"color\" class=\"sc-color-input\" [value]=\"view.cellSingleColor\"\n (change)=\"onCellSingleColor($any($event.target).value)\" />\n </div>\n <div *ngIf=\"view.cellColorMode === 'gene' || view.cellColorMode === 'transcriptDensity'\">\n <div class=\"sc-colorbar\" [style.background]=\"colorBarCss || densityColorBarCss\"></div>\n </div>\n\n <ng-container *ngIf=\"groupOptions.length\">\n <div class=\"sc-subhead\">Groups</div>\n <div class=\"sc-row sc-row-picker\">\n <p-dropdown [options]=\"groupOptions\" [group]=\"true\" optionLabel=\"label\" optionValue=\"value\"\n optionGroupLabel=\"label\" optionGroupChildren=\"items\"\n [ngModel]=\"activeGroupEntry\" (ngModelChange)=\"onGroupEntry($event)\"\n appendTo=\"body\" styleClass=\"sc-full\">\n <ng-template let-group pTemplate=\"group\">\n <span class=\"sc-group-heading\">{{ group.label }}</span>\n </ng-template>\n </p-dropdown>\n <p-dropdown *ngIf=\"groupVariantOptions.length\" [options]=\"groupVariantOptions\"\n optionLabel=\"label\" optionValue=\"value\" styleClass=\"sc-variant\"\n [ngModel]=\"activeCellTypeColumn\" (ngModelChange)=\"onGroupVariant($event)\"\n appendTo=\"body\"></p-dropdown>\n <button *ngIf=\"canImportGroups\" type=\"button\" class=\"sc-icon-btn\" [disabled]=\"groupImporting\"\n (click)=\"groupFile.click()\" title=\"Import cell groups (CSV of cell_id, group)\">\n <i class=\"pi\" [class.pi-plus-circle]=\"!groupImporting\" [class.pi-spin]=\"groupImporting\"\n [class.pi-spinner]=\"groupImporting\"></i>\n </button>\n <input #groupFile type=\"file\" accept=\".csv,.tsv,.txt\" hidden\n (change)=\"onImportGroupsFile(groupFile)\" />\n </div>\n <small class=\"sc-hint sc-error\" *ngIf=\"groupImportError\">{{ groupImportError }}</small>\n\n <div class=\"sc-group-list\" *ngIf=\"groupRows.length\">\n <div class=\"sc-group-row sc-group-all\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"allGroupsShown\"\n (onChange)=\"onAllGroupsShown($event.checked)\"></p-checkbox>\n <span class=\"sc-group-label\">All groups</span>\n <span class=\"sc-count\">({{ groupTotal | number }})</span>\n <button type=\"button\" class=\"sc-icon-btn\" (click)=\"groupsExpanded = !groupsExpanded\">\n <i class=\"pi\" [class.pi-chevron-down]=\"groupsExpanded\" [class.pi-chevron-right]=\"!groupsExpanded\"></i>\n </button>\n </div>\n <ng-container *ngIf=\"groupsExpanded\">\n <div class=\"sc-group-row\" *ngFor=\"let row of groupRows; trackBy: trackByLabel\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"isGroupShown(row.label)\"\n (onChange)=\"onGroupShown(row.label, $event.checked)\"></p-checkbox>\n <span class=\"sc-dot\" [style.background]=\"row.color\"></span>\n <span class=\"sc-group-label\" [title]=\"row.label\">{{ row.label }}</span>\n <span class=\"sc-count\">({{ row.count | number }})</span>\n </div>\n </ng-container>\n </div>\n </ng-container>\n\n <div class=\"sc-subhead\">Visibility</div>\n <span class=\"sc-lbl\">View cells as</span>\n <p-selectButton [options]=\"cellDrawOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.cellDraw\" (ngModelChange)=\"onCellDraw($event)\"\n [allowEmpty]=\"false\" styleClass=\"sc-buttons\"></p-selectButton>\n <ng-container *ngIf=\"view.cellDraw !== 'outline'\">\n <span class=\"sc-lbl\">Cell fill opacity</span>\n <div class=\"sc-row sc-row-number\">\n <p-slider [ngModel]=\"view.cellOpacity\" [min]=\"0.05\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onCellOpacity($event.value)\"></p-slider>\n <p-inputNumber [ngModel]=\"cellOpacityPercent\" (ngModelChange)=\"onCellOpacityPercent($event)\"\n [min]=\"5\" [max]=\"100\" [showButtons]=\"false\" inputStyleClass=\"sc-num\"></p-inputNumber>\n </div>\n </ng-container>\n </div>\n </div>\n\n <!-- Transcripts -->\n <div class=\"sc-panel\" *ngIf=\"hasTranscripts && !is3d\" [class.open]=\"open.transcripts\">\n <div class=\"sc-panel-head\" (click)=\"toggleSection('transcripts')\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.transcriptMode !== 'off'\" (click)=\"$event.stopPropagation()\"\n (onChange)=\"onTranscriptsOn($event.checked)\"></p-checkbox>\n <span class=\"sc-panel-title\">Transcripts</span>\n <i class=\"pi\" [class.pi-chevron-down]=\"open.transcripts\" [class.pi-chevron-right]=\"!open.transcripts\"></i>\n </div>\n <div class=\"sc-panel-body\" *ngIf=\"open.transcripts\">\n <div class=\"sc-row sc-row-picker\">\n <p-multiSelect inputId=\"sc-tx-genes\" [options]=\"geneOptions\"\n optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.transcriptGenes\" (onChange)=\"onTranscriptGenes($event.value)\"\n [filter]=\"true\" filterBy=\"label\" [resetFilterOnHide]=\"true\"\n (onFilter)=\"onGeneFilter($event.filter)\" (onPanelShow)=\"ensureGeneList()\"\n [virtualScroll]=\"geneOptions.length > geneVirtualScrollFrom\"\n [virtualScrollItemSize]=\"30\" [showToggleAll]=\"false\"\n [emptyFilterMessage]=\"genesAreRemote ? 'No match \u2014 keep typing' : 'No match'\"\n [emptyMessage]=\"genesAreRemote ? 'Type to search genes' : 'No genes'\"\n appendTo=\"body\" placeholder=\"Search for genes to visualize\u2026\"\n [selectedItemsLabel]=\"'{0} genes selected'\" [maxSelectedLabels]=\"0\"\n styleClass=\"sc-full\"></p-multiSelect>\n <button type=\"button\" class=\"sc-icon-btn\" *ngIf=\"canShowAllGenes\" (click)=\"onToggleAllGenes()\"\n [class.active]=\"view.transcriptAllGenes\"\n [title]=\"view.transcriptAllGenes ? 'Back to the selected genes' : 'Select all genes'\">\u00B1</button>\n <button type=\"button\" class=\"sc-icon-btn\" (click)=\"geneMenuRef.toggle($event)\" title=\"More\">\n <i class=\"pi pi-ellipsis-v\"></i>\n </button>\n <p-menu #geneMenuRef [model]=\"geneMenu\" [popup]=\"true\" appendTo=\"body\"></p-menu>\n <input #geneGroupFile type=\"file\" accept=\".csv,.tsv,.txt\" hidden\n (change)=\"onImportGeneGroups(geneGroupFile)\" />\n </div>\n <small class=\"sc-hint sc-error\" *ngIf=\"geneGroupError\">{{ geneGroupError }}</small>\n <small class=\"sc-hint\" *ngIf=\"allGenesPreparing\">{{ allGenesPreparing }}</small>\n\n <div class=\"sc-subhead\">Selected genes</div>\n <div class=\"sc-gene-tree\">\n <div class=\"sc-gene-row sc-gene-root\">\n <button type=\"button\" class=\"sc-eye\"\n (click)=\"view.transcriptAllGenes ? onToggleAllGenes() : onGenesShown(view.transcriptGenes, !areGenesShown(view.transcriptGenes))\">\n <i class=\"pi\" [class.pi-eye]=\"view.transcriptAllGenes || areGenesShown(view.transcriptGenes)\"\n [class.pi-eye-slash]=\"!view.transcriptAllGenes && !areGenesShown(view.transcriptGenes)\"></i>\n </button>\n <span class=\"sc-group-label\">Genes</span>\n <span class=\"sc-count\">({{ (view.transcriptAllGenes ? geneTotal : view.transcriptGenes.length) | number }}/{{ geneTotal | number }})</span>\n <button type=\"button\" class=\"sc-icon-btn\" (click)=\"geneTreeOpen = !geneTreeOpen\"\n *ngIf=\"!view.transcriptAllGenes && view.transcriptGenes.length\">\n <i class=\"pi\" [class.pi-chevron-down]=\"geneTreeOpen\" [class.pi-chevron-right]=\"!geneTreeOpen\"></i>\n </button>\n </div>\n <ng-container *ngIf=\"geneTreeOpen && !view.transcriptAllGenes\">\n <ng-container *ngFor=\"let node of geneTree; trackBy: trackByNode\">\n <div class=\"sc-gene-row sc-gene-group\" *ngIf=\"node.name\">\n <button type=\"button\" class=\"sc-eye\" (click)=\"onGenesShown(node.genes, !areGenesShown(node.genes))\">\n <i class=\"pi\" [class.pi-eye]=\"areGenesShown(node.genes)\" [class.pi-eye-slash]=\"!areGenesShown(node.genes)\"></i>\n </button>\n <span class=\"sc-group-label\" [title]=\"node.name\">{{ node.name }}</span>\n <span class=\"sc-count\">({{ node.genes.length }})</span>\n <button type=\"button\" class=\"sc-icon-btn\" (click)=\"toggleGeneGroup(node.name)\">\n <i class=\"pi\" [class.pi-chevron-down]=\"!collapsedGeneGroups.has(node.name)\"\n [class.pi-chevron-right]=\"collapsedGeneGroups.has(node.name)\"></i>\n </button>\n </div>\n <ng-container *ngIf=\"!node.name || !collapsedGeneGroups.has(node.name)\">\n <div class=\"sc-gene-row sc-gene-leaf\" [class.nested]=\"!!node.name\"\n *ngFor=\"let gene of node.genes; trackBy: trackByLabelString\">\n <button type=\"button\" class=\"sc-eye\" (click)=\"onGenesShown([gene], !isGeneShown(gene))\">\n <i class=\"pi\" [class.pi-eye]=\"isGeneShown(gene)\" [class.pi-eye-slash]=\"!isGeneShown(gene)\"></i>\n </button>\n <label class=\"sc-dot sc-dot-pick\" [style.background]=\"geneColorOf(gene)\" title=\"Colour\">\n <input type=\"color\" [value]=\"geneColorOf(gene)\" (change)=\"onGeneColor(gene, $any($event.target).value)\" />\n </label>\n <span class=\"sc-group-label\">{{ gene }}</span>\n <p-dropdown *ngIf=\"view.transcriptMode === 'glyphs'\"\n [options]=\"glyphOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"glyphOf(gene, view.transcriptGenes.indexOf(gene))\"\n (ngModelChange)=\"onGlyph(gene, $event)\"\n appendTo=\"body\" styleClass=\"sc-glyph-pick\">\n <ng-template let-opt pTemplate=\"item\">\n <span class=\"sc-glyph-option\">\n <svg viewBox=\"-1.2 -1.2 2.4 2.4\" class=\"sc-glyph-svg\"><polygon [attr.points]=\"opt.points\"></polygon></svg>\n {{ opt.label }}\n </span>\n </ng-template>\n <ng-template let-opt pTemplate=\"selectedItem\">\n <span class=\"sc-glyph-option\">\n <svg viewBox=\"-1.2 -1.2 2.4 2.4\" class=\"sc-glyph-svg\"><polygon [attr.points]=\"opt?.points\"></polygon></svg>\n </span>\n </ng-template>\n </p-dropdown>\n </div>\n </ng-container>\n </ng-container>\n </ng-container>\n <div class=\"sc-gene-footer\">\n {{ (view.transcriptAllGenes ? geneTotal : view.transcriptGenes.length) | number }}/{{ geneTotal | number }} genes selected\n </div>\n </div>\n\n <div class=\"sc-subhead\">Transcript controls</div>\n <span class=\"sc-lbl\">View transcripts as</span>\n <p-selectButton [options]=\"transcriptModeOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.transcriptMode === 'off' ? null : view.transcriptMode\"\n (ngModelChange)=\"onTranscriptMode($event)\"\n [allowEmpty]=\"false\" styleClass=\"sc-buttons\"></p-selectButton>\n\n <ng-container *ngIf=\"estimate\">\n <div class=\"sc-row\">\n <span class=\"sc-lbl\" pTooltip=\"Transcripts of the visible genes estimated to be in view, against the most markers drawn at once\"\n tooltipPosition=\"top\">Estimated Transcript Points</span>\n <button type=\"button\" class=\"sc-small-btn\" (click)=\"editingMax = !editingMax\">Edit Max</button>\n </div>\n <div class=\"sc-meter\"><div class=\"sc-meter-fill\" [class.over]=\"estimateOverMax\" [style.width.%]=\"estimatePercent\"></div></div>\n <div class=\"sc-meter-ends\">\n <span>{{ estimate.points | number }} points</span>\n <span>{{ estimate.max | number }} max{{ estimateOverMax ? ' reached' : '' }}</span>\n </div>\n <div class=\"sc-row\" *ngIf=\"editingMax\">\n <span class=\"sc-lbl\">Max markers</span>\n <p-dropdown [options]=\"budgetOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.transcriptBudget\" (ngModelChange)=\"onTranscriptBudget($event)\"\n appendTo=\"body\"></p-dropdown>\n </div>\n <small class=\"sc-hint\" *ngIf=\"estimateOverMax && view.transcriptMode !== 'density'\">\n Max points reached: transcripts are grouped by area, circles sized by how many they hold.\n Zoom in or select fewer genes to see individual points.\n </small>\n </ng-container>\n\n <ng-container *ngIf=\"view.transcriptMode === 'circles' || view.transcriptMode === 'glyphs'\">\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Colour by</span>\n <p-selectButton [options]=\"transcriptColorOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.transcriptColorBy\" (ngModelChange)=\"onTranscriptColorBy($event)\"\n [allowEmpty]=\"false\" styleClass=\"sc-buttons\"></p-selectButton>\n </div>\n <small class=\"sc-hint\" *ngIf=\"view.transcriptAllGenes && view.transcriptColorBy === 'gene' && estimateOverMax\">\n Grouped markers mix genes, so they are coloured by cell type until you zoom in to individual transcripts.\n </small>\n <div class=\"sc-row\" *ngIf=\"view.transcriptAllGenes && view.transcriptMode === 'glyphs'\">\n <span class=\"sc-lbl\">Icon</span>\n <p-dropdown [options]=\"glyphOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"glyphOf('*', 0)\" (ngModelChange)=\"onGlyph('*', $event)\"\n appendTo=\"body\" styleClass=\"sc-glyph-pick\">\n <ng-template let-opt pTemplate=\"item\">\n <span class=\"sc-glyph-option\">\n <svg viewBox=\"-1.2 -1.2 2.4 2.4\" class=\"sc-glyph-svg\"><polygon [attr.points]=\"opt.points\"></polygon></svg>\n {{ opt.label }}\n </span>\n </ng-template>\n <ng-template let-opt pTemplate=\"selectedItem\">\n <span class=\"sc-glyph-option\">\n <svg viewBox=\"-1.2 -1.2 2.4 2.4\" class=\"sc-glyph-svg\"><polygon [attr.points]=\"opt?.points\"></polygon></svg>\n </span>\n </ng-template>\n </p-dropdown>\n </div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Size</span>\n <p-slider [ngModel]=\"view.transcriptScale\" [min]=\"0.5\" [max]=\"3\" [step]=\"0.25\"\n (onChange)=\"onTranscriptScale($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.transcriptScale }}\u00D7</span>\n </div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Opacity</span>\n <p-slider [ngModel]=\"view.transcriptOpacity\" [min]=\"0.1\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onTranscriptOpacity($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.transcriptOpacity }}</span>\n </div>\n <div class=\"sc-row\" *ngIf=\"!showingAllGenes\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.transcriptQuality === 'all'\" inputId=\"sc-tx-lowq\"\n (onChange)=\"onTranscriptQuality($event.checked)\"></p-checkbox>\n <label class=\"sc-lbl\" for=\"sc-tx-lowq\">Include low-quality calls</label>\n </div>\n </ng-container>\n\n <ng-container *ngIf=\"view.transcriptMode === 'density'\">\n <span class=\"sc-lbl\">Density map opacity</span>\n <div class=\"sc-row sc-row-number\">\n <p-slider [ngModel]=\"view.densityOpacity\" [min]=\"0.05\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onDensityOpacity($event.value)\"></p-slider>\n <p-inputNumber [ngModel]=\"densityOpacityPercent\" (ngModelChange)=\"onDensityOpacityPercent($event)\"\n [min]=\"5\" [max]=\"100\" inputStyleClass=\"sc-num\"></p-inputNumber>\n </div>\n <span class=\"sc-lbl\" pTooltip=\"Edge of each density bin\" tooltipPosition=\"top\">Density map bin size</span>\n <p-slider [ngModel]=\"densityBinIndex\" [min]=\"0\" [max]=\"3\" [step]=\"1\"\n (onChange)=\"onDensityBinIndex($event.value)\" styleClass=\"sc-bin-slider\"></p-slider>\n <div class=\"sc-ticks\">\n <span *ngFor=\"let b of densityBins\">{{ b }}\u00D7{{ b }}\u00B5m</span>\n </div>\n <span class=\"sc-lbl\" pTooltip=\"Transcripts per \u00B5m\u00B2 at which the colour scale starts and saturates\"\n tooltipPosition=\"top\">Density map scale threshold</span>\n <div class=\"sc-row sc-row-range\">\n <p-inputNumber [ngModel]=\"densityWindow[0]\" (ngModelChange)=\"onDensityRangeEnd(0, $event)\"\n mode=\"decimal\" [minFractionDigits]=\"3\" [maxFractionDigits]=\"3\"\n inputStyleClass=\"sc-num\"></p-inputNumber>\n <p-slider [ngModel]=\"densityWindow\" [range]=\"true\" [min]=\"0\" [max]=\"densitySliderMax\"\n [step]=\"densityStep\" (onChange)=\"onDensityRange($event.values)\"></p-slider>\n <p-inputNumber [ngModel]=\"densityWindow[1]\" (ngModelChange)=\"onDensityRangeEnd(1, $event)\"\n mode=\"decimal\" [minFractionDigits]=\"3\" [maxFractionDigits]=\"3\"\n inputStyleClass=\"sc-num\"></p-inputNumber>\n </div>\n <div class=\"sc-row\">\n <small class=\"sc-hint\">{{ view.densityRange ? 'Set by hand.' : 'Derived from the data.' }}</small>\n <button type=\"button\" class=\"sc-small-btn\" *ngIf=\"view.densityRange\" (click)=\"onDensityAuto()\">Auto</button>\n </div>\n <span class=\"sc-lbl\">Density map bin coloring</span>\n <div class=\"sc-row sc-row-picker\">\n <div class=\"sc-colorbar sc-full\" [style.background]=\"densityColorBarCss\"></div>\n <p-treeSelect [options]=\"colormapOptions\" [ngModel]=\"selectedDensityColormapNode\"\n selectionMode=\"single\" [showClear]=\"true\" placeholder=\"Inferno\"\n [scrollHeight]=\"'260px'\" appendTo=\"body\" styleClass=\"sc-palette\"\n (onNodeSelect)=\"onDensityColormap($event.node)\" (onClear)=\"onDensityColormap(null)\">\n <ng-template let-node pTemplate=\"default\">\n <span class=\"sc-cmap-option\">\n <img *ngIf=\"!node.children && node.data?.src\" [src]=\"node.data.src\" class=\"sc-cmap-swatch\" alt=\"\" />\n <span>{{ node.label }}</span>\n </span>\n </ng-template>\n </p-treeSelect>\n </div>\n <div class=\"sc-colorbar-ends\"><span>{{ densityWindow[0] | number: '1.0-3' }}</span><span>{{ densityWindow[1] | number: '1.0-3' }}</span></div>\n </ng-container>\n </div>\n </div>\n\n <!-- Annotations: the drawn regions, and selecting what they enclose. -->\n <div class=\"sc-panel\" [class.open]=\"open.annotations\">\n <div class=\"sc-panel-head\" (click)=\"toggleSection('annotations')\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.showAnnotations !== false\" (click)=\"$event.stopPropagation()\"\n (onChange)=\"onShowAnnotations($event.checked)\" pTooltip=\"Show the drawn regions\"\n tooltipPosition=\"top\"></p-checkbox>\n <span class=\"sc-panel-title\">Annotations</span>\n <i class=\"pi\" [class.pi-chevron-down]=\"open.annotations\" [class.pi-chevron-right]=\"!open.annotations\"></i>\n </div>\n <div class=\"sc-panel-body\" *ngIf=\"open.annotations\">\n <div class=\"sc-row\">\n <p-button label=\"Select from ROIs\" icon=\"pi pi-object-group\" size=\"small\"\n [outlined]=\"true\" (onClick)=\"selectFromRegions()\"\n pTooltip=\"Select every observation inside the drawn regions\"\n tooltipPosition=\"top\"></p-button>\n <p-button *ngIf=\"hasSelection\" label=\"Clear\" size=\"small\" [text]=\"true\"\n (onClick)=\"clearSelection()\"></p-button>\n </div>\n <small class=\"sc-hint\" *ngIf=\"hasSelection\">\n {{ selection.count | number }} of {{ ds.observations.count | number }} selected \u2014\n the rest are muted.\n </small>\n <small class=\"sc-hint\" *ngIf=\"selectionMissed\">\n No observations fell inside the drawn regions.\n </small>\n <small class=\"sc-hint\" *ngIf=\"is3d\">\n In 3D a region cuts through the cloud's full depth. Orbit and draw again\n to cut from another angle.\n </small>\n </div>\n </div>\n\n <!-- Observations: one marker per observation (cell centroid, Visium spot), and what\n colours them. The lead section for datasets without cell outlines. -->\n <div class=\"sc-panel\" [class.open]=\"open.observations\">\n <div class=\"sc-panel-head\" (click)=\"toggleSection('observations')\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.showPoints\" (click)=\"$event.stopPropagation()\"\n (onChange)=\"onShowPoints($event.checked)\"></p-checkbox>\n <span class=\"sc-panel-title\">Observations</span>\n <i class=\"pi\" [class.pi-chevron-down]=\"open.observations\" [class.pi-chevron-right]=\"!open.observations\"></i>\n </div>\n <div class=\"sc-panel-body\" *ngIf=\"open.observations\">\n <small class=\"sc-hint\" *ngIf=\"hasCells && !is3d\">\n One circle per cell. With cell outlines on, circles are only drawn where cells\n are too small on screen to outline.\n </small>\n <!-- Colour source. Column and gene are mutually exclusive: picking one\n clears the other, so what drives the colours is never ambiguous. -->\n <div class=\"sc-section\">\n <label class=\"sc-lbl\" for=\"sc-column\">Colour by column</label>\n <p-dropdown inputId=\"sc-column\" [options]=\"columnOptions\"\n optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"selectedColumn\" (ngModelChange)=\"onColumn($event)\"\n placeholder=\"None (flat colour)\" appendTo=\"body\"\n styleClass=\"sc-full\"></p-dropdown>\n </div>\n\n <div class=\"sc-section\" *ngIf=\"ds.features\">\n <label class=\"sc-lbl\" for=\"sc-gene\">Colour by gene</label>\n <!-- A filterable dropdown, not a free-text box: the genes are visible before\n anything is typed, and each keystroke narrows a list the user can see.\n Virtual-scrolled because a targeted panel runs to a few thousand names. -->\n <p-dropdown inputId=\"sc-gene\" [options]=\"geneOptions\"\n optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"selectedGene\" (ngModelChange)=\"onGene($event)\"\n [filter]=\"true\" filterBy=\"label\" [resetFilterOnHide]=\"true\"\n (onFilter)=\"onGeneFilter($event.filter)\" (onShow)=\"ensureGeneList()\"\n [showClear]=\"true\"\n [virtualScroll]=\"geneOptions.length > geneVirtualScrollFrom\"\n [virtualScrollItemSize]=\"30\"\n [emptyFilterMessage]=\"genesAreRemote ? 'No match \u2014 keep typing' : 'No match'\"\n [emptyMessage]=\"genesAreRemote ? 'Type to search genes' : 'No genes'\"\n appendTo=\"body\"\n placeholder=\"Search {{ ds.features.count | number }} genes\u2026\"\n styleClass=\"sc-full\"></p-dropdown>\n <small class=\"sc-hint\" *ngIf=\"geneSearchFailed\">Gene lookup failed.</small>\n </div>\n\n <!-- Key: categorical legend or a continuous colour bar, built from the same\n functions the renderer uses so it cannot disagree with the canvas. -->\n <div class=\"sc-section sc-key\" *ngIf=\"view.colorBy\">\n <span class=\"sc-lbl\">{{ colorByLabel }}</span>\n <!-- Derived columns say so, so they are not mistaken for measured data. -->\n <small class=\"sc-hint\" *ngIf=\"activeDescription\">{{ activeDescription }}</small>\n\n <!-- Legend rows are buttons: clicking one selects that category, clicking\n it again clears \u2014 so a click is always reversible. -->\n <ul class=\"sc-legend\" *ngIf=\"isCategorical\">\n <li *ngFor=\"let entry of legend; index as i\">\n <button type=\"button\" class=\"sc-legend-btn\"\n [class.selected]=\"selectedCategory === i\"\n (click)=\"selectCategory(i)\"\n [title]=\"'Select ' + entry.label\">\n <span class=\"sc-swatch\" [style.background]=\"entry.color\"></span>\n <span class=\"sc-legend-label\">{{ entry.label }}</span>\n </button>\n </li>\n </ul>\n\n <div *ngIf=\"isContinuous\">\n <div class=\"sc-colorbar\" [style.background]=\"colorBarCss\"></div>\n <div class=\"sc-colorbar-ends\">\n <span>low</span>\n <span class=\"sc-hint\">window from percentiles</span>\n <span>high</span>\n </div>\n <!-- The gradient this bar and the map are drawn with. Sits under the bar\n it controls, with the same swatch previews the image's colormap picker\n uses: which gradient reads best is a visual judgement, so a list of\n names would be the wrong control. Cleared = follow the image's. -->\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Colormap</span>\n <p-treeSelect [options]=\"colormapOptions\" [ngModel]=\"selectedColormapNode\"\n selectionMode=\"single\" [showClear]=\"true\"\n placeholder=\"Match the image\" [scrollHeight]=\"'260px'\"\n appendTo=\"body\"\n pTooltip=\"Colour scale for gene and numeric values \u2014 the markers, the gene map and this bar. Clear it to follow the image's colormap\"\n tooltipPosition=\"top\"\n (onNodeSelect)=\"onContinuousColormap($event.node)\"\n (onClear)=\"onContinuousColormap(null)\">\n <ng-template let-node pTemplate=\"default\">\n <span class=\"sc-cmap-option\">\n <img *ngIf=\"!node.children && node.data?.src\" [src]=\"node.data.src\"\n class=\"sc-cmap-swatch\" alt=\"\" />\n <span>{{ node.label }}</span>\n </span>\n </ng-template>\n </p-treeSelect>\n <span class=\"sc-val\"></span>\n </div>\n </div>\n </div>\n\n <!-- Display -->\n <div class=\"sc-section\">\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Point size</span>\n <p-slider [ngModel]=\"view.pointScale\" [min]=\"0.25\" [max]=\"5\" [step]=\"0.25\"\n (onChange)=\"onPointScale($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.pointScale }}\u00D7</span>\n </div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Opacity</span>\n <p-slider [ngModel]=\"view.opacity\" [min]=\"0.1\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onOpacity($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.opacity }}</span>\n </div>\n </div>\n\n </div>\n </div>\n\n <!-- Only for a gene: the map draws that gene's expression as a field. In 2D\n it goes under the cells of the displayed plane; in 3D it is one sheet per\n imaged section, optionally smoothed along z into a volume. -->\n <div class=\"sc-section\" *ngIf=\"canMapGene\">\n <div class=\"sc-row\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.geneMap\"\n (onChange)=\"onGeneMap($event.checked)\"\n label=\"Gene map (expression field)\"\n [pTooltip]=\"is3d\n ? 'Draw this gene\\'s expression as one field per imaged section \u2014 an estimate between the cells, empty where nothing was measured'\n : 'Draw this gene\\'s expression as a smooth field UNDER the cells \u2014 an estimate between them, transparent where nothing was measured'\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <!-- 3D only: interpolating along z turns the measured sheets into an\n estimate, so it is its own decision and its own label. -->\n <div class=\"sc-row\" *ngIf=\"view.geneMap && is3d\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.geneMapVolume\"\n (onChange)=\"onGeneMapVolume($event.checked)\"\n label=\"Volume rendering (interpolate along z)\"\n pTooltip=\"Smooth the per-section fields into a continuous volume \u2014 an ESTIMATE between the imaged sections, not measured tissue\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <!-- Meaningless while interpolating: a volume built from one section would\n smear that slide through the specimen's whole depth. -->\n <div class=\"sc-row\" *ngIf=\"view.geneMap && is3d && isSectioned && !view.geneMapVolume\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"geneMapOneSection\"\n (onChange)=\"onGeneMapOneSection($event.checked)\"\n label=\"One map section at a time\"\n pTooltip=\"Draw a single imaged section's field instead of the whole stack of sheets\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <div class=\"sc-row\"\n *ngIf=\"view.geneMap && is3d && isSectioned && !view.geneMapVolume && geneMapOneSection\">\n <span class=\"sc-lbl\">Map section</span>\n <p-slider [ngModel]=\"view.geneMapSection\" [min]=\"0\" [max]=\"lastSection\" [step]=\"1\"\n (onChange)=\"onGeneMapSection($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ geneMapSectionLabel }}</span>\n </div>\n <div class=\"sc-row\" *ngIf=\"view.geneMap\">\n <span class=\"sc-lbl\">Smoothing</span>\n <p-slider [ngModel]=\"view.geneMapSmoothing\" [min]=\"0.5\" [max]=\"4\" [step]=\"0.5\"\n (onChange)=\"onGeneMapSmoothing($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.geneMapSmoothing }}\u00D7</span>\n </div>\n <div class=\"sc-row\" *ngIf=\"view.geneMap\">\n <span class=\"sc-lbl\">Map opacity</span>\n <p-slider [ngModel]=\"view.geneMapOpacity\" [min]=\"0.1\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onGeneMapOpacity($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.geneMapOpacity }}</span>\n </div>\n <div class=\"sc-hint\" *ngIf=\"view.geneMap && !is3d\">\n Kernel-weighted mean per cell, not a sum \u2014 so a crowded region does not glow\n for being crowded. Transparent where no cell was measured. Turn the cells'\n own Opacity down to read the field under them.\n </div>\n <div class=\"sc-hint\" *ngIf=\"view.geneMap && is3d\">\n {{ geneMapVolumeNote }}\n </div>\n </div>\n\n <!-- 3D only: the volume, the cloud and the density volumes occupy the same\n space, so each hides the others. Independent toggles, because the useful\n views are the combinations \u2014 the field alone, one section's cells over it,\n the anatomy alone. -->\n <div class=\"sc-section\" *ngIf=\"is3d\">\n <span class=\"sc-lbl\">Show</span>\n <div class=\"sc-row\" *ngIf=\"ds.volume\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.showVolume\"\n (onChange)=\"onShowVolume($event.checked)\"\n label=\"Reference volume\"\n pTooltip=\"The registered anatomy the observations sit in\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <!-- The backdrop's own opacity: reading the cloud or a density field\n through the anatomy means turning the anatomy down, not the data. -->\n <div class=\"sc-row\" *ngIf=\"ds.volume && view.showVolume\">\n <span class=\"sc-lbl\">Volume opacity</span>\n <p-slider [ngModel]=\"view.volumeOpacity\" [min]=\"0.05\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onVolumeOpacity($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.volumeOpacity }}</span>\n </div>\n <div class=\"sc-row\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.showPoints\"\n (onChange)=\"onShowPoints($event.checked)\"\n label=\"Observations\"\n pTooltip=\"The measured cells. Hide them to read the density volumes underneath\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <!-- Only for data that really is sectioned; a continuous z has no sections\n to step through. -->\n <div class=\"sc-row\" *ngIf=\"view.showPoints && isSectioned\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"oneSection\"\n (onChange)=\"onOneSection($event.checked)\"\n label=\"One section at a time\"\n pTooltip=\"Draw a single imaged section's cells, so the density volumes between the sections stay visible\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <div class=\"sc-row\" *ngIf=\"view.showPoints && isSectioned && oneSection\">\n <span class=\"sc-lbl\">Section</span>\n <p-slider [ngModel]=\"view.pointSection\" [min]=\"0\" [max]=\"lastSection\" [step]=\"1\"\n (onChange)=\"onPointSection($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ sectionLabel }}</span>\n </div>\n </div>\n\n <div class=\"sc-section\" *ngIf=\"is3d\">\n <div class=\"sc-hint\" *ngIf=\"exceedsCloudPalette\">\n {{ legend?.length }} categories is past the {{ cloudPaletteLimit }} the 3D cloud's\n colour LUT can keep apart, so the points draw flat for this column. The density\n volumes below render it, and so does the 2D Spatial omics view.\n </div>\n <div class=\"sc-row\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.densityVolume\"\n (onChange)=\"onDensityVolume($event.checked)\"\n label=\"Cluster density volumes\"\n pTooltip=\"Raymarch each cluster as a smooth density field \u2014 an ESTIMATE between the imaged sections, not measured cells\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <div class=\"sc-row\" *ngIf=\"view.densityVolume\">\n <span class=\"sc-lbl\">Smoothing</span>\n <p-slider [ngModel]=\"view.densitySmoothing\" [min]=\"0.5\" [max]=\"4\" [step]=\"0.5\"\n (onChange)=\"onDensitySmoothing($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.densitySmoothing }}\u00D7</span>\n </div>\n <div class=\"sc-hint\" *ngIf=\"view.densityVolume\">{{ densityNote }}</div>\n </div>\n\n <!-- Continuous-only knobs: meaningless for a categorical palette. -->\n <div class=\"sc-section\" *ngIf=\"isContinuous\">\n <div class=\"sc-row\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.logScale\"\n (onChange)=\"onLogScale($event.checked)\"\n label=\"Log scale\"\n pTooltip=\"Counts collapse against their outliers on a linear ramp\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Clip outliers</span>\n <p-dropdown [options]=\"clipOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.percentileClip\" (ngModelChange)=\"onClip($event)\"\n appendTo=\"body\"\n pTooltip=\"Percentile the contrast window is taken at\"\n tooltipPosition=\"top\"></p-dropdown>\n <!-- Reserves the readout column this row has no readout for, so the\n control's right edge lines up with every slider's. -->\n <span class=\"sc-val\"></span>\n </div>\n </div>\n\n <!-- Distributions of whatever the map is coloured by. In the SAME dialog as\n the colour controls on purpose: changing the gene and watching the\n distribution move is one action, and two floating windows made that\n harder to see rather than easier. -->\n <div class=\"sc-section sc-charts\">\n <button type=\"button\" class=\"sc-disclosure\" (click)=\"toggleCharts()\"\n [attr.aria-expanded]=\"chartsOpen\" [attr.aria-controls]=\"chartsBodyId\">\n <i class=\"pi\" [class.pi-chevron-right]=\"!chartsOpen\"\n [class.pi-chevron-down]=\"chartsOpen\"></i>\n <span>Distribution</span>\n <small *ngIf=\"!chartsOpen && view.colorBy\">of {{ colorByLabel }}</small>\n </button>\n <!-- Kept MOUNTED when collapsed, only hidden: destroying it would throw\n away the chart kind and grouping the user picked. `active` is what\n stops it drawing while it has no size. -->\n <div [id]=\"chartsBodyId\" [hidden]=\"!chartsOpen\">\n <spatial-charts [active]=\"visible && chartsOpen\"></spatial-charts>\n </div>\n </div>\n\n <div class=\"sc-btns\">\n <p-button label=\"Reset\" size=\"small\" [text]=\"true\" (onClick)=\"reset()\"></p-button>\n </div>\n\n </div>\n</p-dialog>\n", styles: ["@charset \"UTF-8\";:host ::ng-deep .spatial-controls-dialog .p-dialog-content{padding:.75rem 1rem 1rem}.sc-empty{padding:.5rem 0;font-size:.85rem;color:var(--text-color-secondary, #6c757d);line-height:1.4}.sc-empty code{font-size:.8rem}.sc-body{display:flex;flex-direction:column;gap:.85rem}.sc-dataset{display:flex;flex-direction:column;gap:.1rem;padding-bottom:.5rem;border-bottom:1px solid var(--surface-border, #dee2e6)}.sc-dataset-name{font-weight:600;font-size:.9rem}.sc-dataset-count,.sc-hint{font-size:.75rem;color:var(--text-color-secondary, #6c757d)}.sc-section{display:flex;flex-direction:column;gap:.35rem}.sc-lbl{font-size:.78rem;color:var(--text-color-secondary, #6c757d);white-space:nowrap}.sc-row{display:flex;align-items:center;gap:.6rem}.sc-row p-slider,.sc-row p-dropdown,.sc-row p-treeSelect{flex:1 1 0;min-width:0}:host ::ng-deep .sc-row p-slider .p-slider{width:100%}:host ::ng-deep .sc-row p-dropdown .p-dropdown,:host ::ng-deep .sc-row p-treeSelect .p-treeselect{width:100%}.sc-row .sc-lbl{flex:0 0 calc(50% - .6rem);min-width:0;overflow:hidden;text-overflow:ellipsis}.sc-cmap-option{display:inline-flex;align-items:center;gap:.4rem}.sc-cmap-swatch{width:3.5rem;height:.7rem;border-radius:2px;display:block}.sc-val{flex:0 0 4rem;text-align:right;font-size:.78rem;font-variant-numeric:tabular-nums;white-space:nowrap}:host ::ng-deep .sc-full,:host ::ng-deep .sc-full .p-dropdown,:host ::ng-deep .sc-full .p-autocomplete,:host ::ng-deep .sc-full input{width:100%}.sc-key{padding:.5rem 0;border-top:1px solid var(--surface-border, #dee2e6);border-bottom:1px solid var(--surface-border, #dee2e6)}.sc-legend{list-style:none;margin:0;padding:0;max-height:11rem;overflow-y:auto;display:flex;flex-direction:column;gap:.2rem}.sc-legend li{display:flex}.sc-legend-btn{display:flex;align-items:center;gap:.45rem;width:100%;padding:.15rem .3rem;font:inherit;font-size:.8rem;text-align:left;background:none;border:1px solid transparent;border-radius:3px;cursor:pointer;color:inherit}.sc-legend-btn:hover{background:var(--surface-hover, rgba(0, 0, 0, .04))}.sc-legend-btn.selected{border-color:var(--primary-color, #3b82f6);background:var(--surface-hover, rgba(59, 130, 246, .08));font-weight:600}.sc-selection .sc-row{gap:.4rem}.sc-swatch{width:.85rem;height:.85rem;flex:0 0 auto;border-radius:2px;border:1px solid rgba(0,0,0,.2)}.sc-legend-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sc-colorbar{height:.75rem;border-radius:2px;border:1px solid rgba(0,0,0,.2)}.sc-colorbar-ends{display:flex;justify-content:space-between;font-size:.7rem;color:var(--text-color-secondary, #6c757d);margin-top:.2rem}.sc-btns{display:flex;justify-content:flex-end}.sc-charts{padding-top:.5rem;border-top:1px solid var(--surface-border, #dee2e6);gap:0}.sc-disclosure{display:flex;align-items:center;gap:.4rem;width:100%;padding:.2rem 0;font:inherit;font-size:.82rem;font-weight:600;text-align:left;background:none;border:0;cursor:pointer;color:inherit}.sc-disclosure i{font-size:.7rem;color:var(--text-color-secondary, #6c757d)}.sc-disclosure small{font-weight:400;color:var(--text-color-secondary, #6c757d);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sc-disclosure:hover{color:var(--primary-color, #3b82f6)}.sc-legend-static{cursor:default}.sc-tx-gene{display:flex;align-items:center;gap:.5rem}.sc-tx-gene .sc-legend-label{flex:1}:host ::ng-deep .sc-glyph-pick .p-dropdown-label{padding:.2rem .4rem}.sc-glyph-option{display:inline-flex;align-items:center;gap:.4rem}.sc-glyph-svg{width:1rem;height:1rem;fill:currentColor}:host ::ng-deep .sc-row-buttons .p-selectbutton .p-button{padding:.35rem .6rem;font-size:.85rem}:host ::ng-deep .sc-row-buttons .p-selectbutton{display:flex;flex-wrap:nowrap}.sc-row-wide .sc-lbl{flex:0 0 auto}.sc-panel{border:1px solid var(--surface-border, #dee2e6);border-radius:6px;margin-bottom:.5rem;overflow:hidden}.sc-panel-head{display:flex;align-items:center;gap:.6rem;padding:.5rem .7rem;cursor:pointer;-webkit-user-select:none;user-select:none;background:var(--surface-100, #f5f6f7)}.sc-panel-head .sc-panel-title{flex:1;font-weight:600}.sc-panel.open>.sc-panel-head{background:var(--primary-color, #2196f3);color:var(--primary-color-text, #fff)}.sc-panel-body{display:flex;flex-direction:column;gap:.45rem;padding:.6rem .75rem .75rem}.sc-subhead{display:flex;align-items:center;gap:.5rem;margin-top:.4rem;font-size:.72rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--text-color-secondary, #6c757d)}.sc-subhead:after{content:\"\";flex:1;border-top:1px solid var(--surface-border, #dee2e6)}:host ::ng-deep .sc-buttons .p-button{padding:.35rem .75rem;font-size:.85rem}.sc-row-picker p-dropdown,.sc-row-picker p-multiSelect,.sc-row-picker p-treeSelect{flex:1 1 0;min-width:0}:host ::ng-deep .sc-row-picker .sc-variant{width:6.5rem}:host ::ng-deep .sc-row-picker p-multiselect .p-multiselect{width:100%}:host ::ng-deep .sc-row-picker p-dropdown:has(.sc-variant){flex:0 0 6.5rem}:host ::ng-deep .sc-row-picker p-treeselect:has(.sc-palette){flex:0 0 3rem}:host ::ng-deep .sc-palette .p-treeselect-label{width:0;padding:0}.sc-icon-btn{flex:0 0 auto;border:0;background:transparent;cursor:pointer;padding:.25rem .35rem;font:inherit;font-size:1.05rem;color:var(--text-color, #333);border-radius:4px}.sc-icon-btn:hover{background:var(--surface-200, #e9ecef)}.sc-icon-btn.active{color:var(--primary-color, #2196f3);font-weight:700}.sc-icon-btn:disabled{opacity:.5;cursor:default}.sc-small-btn{margin-left:auto;border:1px solid var(--surface-border, #ced4da);background:var(--surface-0, #fff);border-radius:4px;padding:.2rem .6rem;font:inherit;font-size:.8rem;cursor:pointer}:host ::ng-deep .sc-group-heading{font-size:.7rem;font-weight:700;letter-spacing:.05em;text-transform:uppercase;opacity:.7}.sc-group-list,.sc-gene-tree{max-height:16rem;overflow-y:auto;border:1px solid var(--surface-border, #dee2e6);border-radius:4px;padding:.25rem .4rem}.sc-group-row,.sc-gene-row{display:flex;align-items:center;gap:.45rem;padding:.15rem 0;font-size:.88rem}.sc-group-row .sc-group-label,.sc-gene-row .sc-group-label{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sc-group-all,.sc-gene-root,.sc-gene-group{font-weight:600}.sc-group-all .sc-icon-btn,.sc-gene-root .sc-icon-btn,.sc-gene-group .sc-icon-btn{margin-left:auto;font-size:.85rem}.sc-gene-group,.sc-gene-leaf{padding-left:1rem}.sc-gene-leaf.nested{padding-left:2rem}.sc-count{flex:0 0 auto;font-size:.78rem;color:var(--text-color-secondary, #6c757d)}.sc-dot{flex:0 0 auto;width:.7rem;height:.7rem;border-radius:50%;border:1px solid rgba(0,0,0,.2)}.sc-dot-pick{position:relative;cursor:pointer}.sc-dot-pick input{position:absolute;inset:0;opacity:0;width:100%;height:100%;cursor:pointer}.sc-eye{flex:0 0 auto;border:0;background:transparent;cursor:pointer;padding:0;color:var(--text-color-secondary, #6c757d)}.sc-eye .pi-eye{color:var(--text-color, #333)}.sc-gene-footer{text-align:right;font-size:.78rem;padding:.2rem 0;color:var(--text-color-secondary, #6c757d)}.sc-color-input{width:2.5rem;height:1.8rem;border:1px solid var(--surface-border, #ced4da);border-radius:4px;padding:0;background:none}.sc-meter{height:.45rem;border-radius:3px;background:var(--surface-200, #e9ecef);overflow:hidden}.sc-meter-fill{height:100%;background:var(--primary-color, #2196f3)}.sc-meter-fill.over{background:var(--orange-500, #f59e0b)}.sc-meter-ends,.sc-ticks{display:flex;justify-content:space-between;font-size:.72rem;font-weight:600;color:var(--text-color-secondary, #6c757d)}.sc-row-number p-slider,.sc-row-range p-slider{flex:1 1 0;min-width:0}:host ::ng-deep .sc-num{width:4.5rem;padding:.3rem .4rem;text-align:center}:host ::ng-deep .sc-row-number p-inputnumber,:host ::ng-deep .sc-row-range p-inputnumber{flex:0 0 auto}.sc-error{color:var(--red-600, #c62828)}\n"] }]
|
|
13221
|
+
args: [{ selector: 'spatial-controls', template: "<p-dialog header=\"Spatial omics\"\n [visible]=\"visible\" (visibleChange)=\"onVisibleChange($event)\"\n [modal]=\"false\" [resizable]=\"true\" [draggable]=\"true\"\n position=\"left\"\n (onResizeEnd)=\"onResizeEnd()\"\n [style]=\"{ width: '30rem' }\"\n [breakpoints]=\"{ '960px': '90vw' }\"\n styleClass=\"spatial-controls-dialog\">\n\n <!-- No port bound: say why rather than showing controls that do nothing. -->\n <div class=\"sc-empty\" *ngIf=\"!controls\">\n No spatial-omics data source is configured. A host provides one by binding\n <code>SPATIAL_DATA_PORT</code>.\n </div>\n\n <div class=\"sc-empty\" *ngIf=\"controls && !dataset\">\n No dataset is loaded.\n </div>\n\n <div class=\"sc-body\" *ngIf=\"controls && dataset as ds\">\n\n <div class=\"sc-dataset\">\n <span class=\"sc-dataset-name\">{{ ds.name }}</span>\n <span class=\"sc-dataset-count\">{{ ds.observations.count | number }} observations</span>\n </div>\n\n <!-- Organised as Xenium Explorer's side panel: one collapsible section per layer,\n each with its own on/off switch in the header. -->\n\n <!-- Images -->\n <div class=\"sc-panel\" *ngIf=\"ds.imageRef && !is3d\" [class.open]=\"open.images\">\n <div class=\"sc-panel-head\" (click)=\"toggleSection('images')\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.showImage\" (click)=\"$event.stopPropagation()\"\n (onChange)=\"onShowImage($event.checked)\"></p-checkbox>\n <span class=\"sc-panel-title\">Images</span>\n <i class=\"pi\" [class.pi-chevron-down]=\"open.images\" [class.pi-chevron-right]=\"!open.images\"></i>\n </div>\n <div class=\"sc-panel-body\" *ngIf=\"open.images\">\n <small class=\"sc-hint\">\n Channels, colours and contrast are in the Channels & Histogram dialog.\n </small>\n </div>\n </div>\n\n <!-- Cells -->\n <div class=\"sc-panel\" *ngIf=\"hasCells && !is3d\" [class.open]=\"open.cells\">\n <div class=\"sc-panel-head\" (click)=\"toggleSection('cells')\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"cellsOn\" (click)=\"$event.stopPropagation()\"\n (onChange)=\"onShowCells($event.checked)\"></p-checkbox>\n <span class=\"sc-panel-title\">Cells</span>\n <i class=\"pi\" [class.pi-chevron-down]=\"open.cells\" [class.pi-chevron-right]=\"!open.cells\"></i>\n </div>\n <div class=\"sc-panel-body\" *ngIf=\"open.cells\">\n <ng-container *ngIf=\"cellSetOptions.length > 1\">\n <div class=\"sc-subhead\">Boundaries</div>\n <p-selectButton [options]=\"cellSetOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"activeCellSet\" (ngModelChange)=\"onCellSet($event)\"\n [allowEmpty]=\"false\" styleClass=\"sc-buttons\"></p-selectButton>\n </ng-container>\n\n <div class=\"sc-subhead\">Coloring</div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Cell Color</span>\n <p-dropdown [options]=\"cellColorOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.cellColorMode\" (ngModelChange)=\"onCellColorMode($event)\"\n appendTo=\"body\"></p-dropdown>\n </div>\n <small class=\"sc-hint\" *ngIf=\"view.cellColorMode === 'group'\">\n Adjust cell color in the groups section below.\n </small>\n <div class=\"sc-row\" *ngIf=\"view.cellColorMode === 'gene' && ds.features\">\n <span class=\"sc-lbl\">Gene</span>\n <p-dropdown [options]=\"geneOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.cellColorGene\" (ngModelChange)=\"onCellColorGene($event)\"\n [filter]=\"true\" filterBy=\"label\" [resetFilterOnHide]=\"true\"\n (onFilter)=\"onGeneFilter($event.filter)\" (onShow)=\"ensureGeneList()\"\n [virtualScroll]=\"geneOptions.length > geneVirtualScrollFrom\"\n [virtualScrollItemSize]=\"30\" appendTo=\"body\"\n placeholder=\"Search genes\u2026\"></p-dropdown>\n </div>\n <div class=\"sc-row\" *ngIf=\"view.cellColorMode === 'single'\">\n <span class=\"sc-lbl\">Color</span>\n <input type=\"color\" class=\"sc-color-input\" [value]=\"view.cellSingleColor\"\n (change)=\"onCellSingleColor($any($event.target).value)\" />\n </div>\n <div *ngIf=\"view.cellColorMode === 'gene' || view.cellColorMode === 'transcriptDensity'\">\n <div class=\"sc-colorbar\" [style.background]=\"colorBarCss || densityColorBarCss\"></div>\n </div>\n\n <ng-container *ngIf=\"groupOptions.length\">\n <div class=\"sc-subhead\">Groups</div>\n <div class=\"sc-row sc-row-picker\">\n <p-dropdown [options]=\"groupOptions\" [group]=\"true\" optionLabel=\"label\" optionValue=\"value\"\n optionGroupLabel=\"label\" optionGroupChildren=\"items\"\n [ngModel]=\"activeGroupEntry\" (ngModelChange)=\"onGroupEntry($event)\"\n appendTo=\"body\" styleClass=\"sc-full\">\n <ng-template let-group pTemplate=\"group\">\n <span class=\"sc-group-heading\">{{ group.label }}</span>\n </ng-template>\n </p-dropdown>\n <p-dropdown *ngIf=\"groupVariantOptions.length\" [options]=\"groupVariantOptions\"\n optionLabel=\"label\" optionValue=\"value\" styleClass=\"sc-variant\"\n [ngModel]=\"activeCellTypeColumn\" (ngModelChange)=\"onGroupVariant($event)\"\n appendTo=\"body\"></p-dropdown>\n <button *ngIf=\"canImportGroups\" type=\"button\" class=\"sc-icon-btn\" [disabled]=\"groupImporting\"\n (click)=\"groupFile.click()\" title=\"Import cell groups (CSV of cell_id, group)\">\n <i class=\"pi\" [class.pi-plus-circle]=\"!groupImporting\" [class.pi-spin]=\"groupImporting\"\n [class.pi-spinner]=\"groupImporting\"></i>\n </button>\n <input #groupFile type=\"file\" accept=\".csv,.tsv,.txt\" hidden\n (change)=\"onImportGroupsFile(groupFile)\" />\n </div>\n <small class=\"sc-hint sc-error\" *ngIf=\"groupImportError\">{{ groupImportError }}</small>\n\n <div class=\"sc-group-list\" *ngIf=\"groupRows.length\">\n <div class=\"sc-group-row sc-group-all\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"allGroupsShown\"\n (onChange)=\"onAllGroupsShown($event.checked)\"></p-checkbox>\n <span class=\"sc-group-label\">All groups</span>\n <span class=\"sc-count\">({{ groupTotal | number }})</span>\n <button type=\"button\" class=\"sc-icon-btn\" (click)=\"groupsExpanded = !groupsExpanded\">\n <i class=\"pi\" [class.pi-chevron-down]=\"groupsExpanded\" [class.pi-chevron-right]=\"!groupsExpanded\"></i>\n </button>\n </div>\n <ng-container *ngIf=\"groupsExpanded\">\n <div class=\"sc-group-row\" *ngFor=\"let row of groupRows; trackBy: trackByLabel\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"isGroupShown(row.label)\"\n (onChange)=\"onGroupShown(row.label, $event.checked)\"></p-checkbox>\n <span class=\"sc-dot\" [style.background]=\"row.color\"></span>\n <span class=\"sc-group-label\" [title]=\"row.label\">{{ row.label }}</span>\n <span class=\"sc-count\">({{ row.count | number }})</span>\n </div>\n </ng-container>\n </div>\n </ng-container>\n\n <div class=\"sc-subhead\">Visibility</div>\n <span class=\"sc-lbl\">View cells as</span>\n <p-selectButton [options]=\"cellDrawOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.cellDraw\" (ngModelChange)=\"onCellDraw($event)\"\n [allowEmpty]=\"false\" styleClass=\"sc-buttons\"></p-selectButton>\n <ng-container *ngIf=\"view.cellDraw !== 'outline'\">\n <span class=\"sc-lbl\">Cell fill opacity</span>\n <div class=\"sc-row sc-row-number\">\n <p-slider [ngModel]=\"view.cellOpacity\" [min]=\"0.05\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onCellOpacity($event.value)\"></p-slider>\n <p-inputNumber [ngModel]=\"cellOpacityPercent\" (ngModelChange)=\"onCellOpacityPercent($event)\"\n [min]=\"5\" [max]=\"100\" [showButtons]=\"false\" inputStyleClass=\"sc-num\"></p-inputNumber>\n </div>\n </ng-container>\n </div>\n </div>\n\n <!-- Transcripts -->\n <div class=\"sc-panel\" *ngIf=\"hasTranscripts && !is3d\" [class.open]=\"open.transcripts\">\n <div class=\"sc-panel-head\" (click)=\"toggleSection('transcripts')\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.transcriptMode !== 'off'\" (click)=\"$event.stopPropagation()\"\n (onChange)=\"onTranscriptsOn($event.checked)\"></p-checkbox>\n <span class=\"sc-panel-title\">Transcripts</span>\n <i class=\"pi\" [class.pi-chevron-down]=\"open.transcripts\" [class.pi-chevron-right]=\"!open.transcripts\"></i>\n </div>\n <div class=\"sc-panel-body\" *ngIf=\"open.transcripts\">\n <div class=\"sc-row sc-row-picker\">\n <p-multiSelect inputId=\"sc-tx-genes\" [options]=\"geneOptions\"\n optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.transcriptGenes\" (onChange)=\"onTranscriptGenes($event.value)\"\n [filter]=\"true\" filterBy=\"label\" [resetFilterOnHide]=\"true\"\n (onFilter)=\"onGeneFilter($event.filter)\" (onPanelShow)=\"ensureGeneList()\"\n [virtualScroll]=\"geneOptions.length > geneVirtualScrollFrom\"\n [virtualScrollItemSize]=\"30\" [showToggleAll]=\"false\"\n [emptyFilterMessage]=\"genesAreRemote ? 'No match \u2014 keep typing' : 'No match'\"\n [emptyMessage]=\"genesAreRemote ? 'Type to search genes' : 'No genes'\"\n appendTo=\"body\" placeholder=\"Search for genes to visualize\u2026\"\n [selectedItemsLabel]=\"'{0} genes selected'\" [maxSelectedLabels]=\"0\"\n styleClass=\"sc-full\"></p-multiSelect>\n <button type=\"button\" class=\"sc-icon-btn\" *ngIf=\"canShowAllGenes\" (click)=\"onToggleAllGenes()\"\n [class.active]=\"view.transcriptAllGenes\"\n [title]=\"view.transcriptAllGenes ? 'Back to the selected genes' : 'Select all genes'\">\u00B1</button>\n <button type=\"button\" class=\"sc-icon-btn\" (click)=\"geneMenuRef.toggle($event)\" title=\"More\">\n <i class=\"pi pi-ellipsis-v\"></i>\n </button>\n <p-menu #geneMenuRef [model]=\"geneMenu\" [popup]=\"true\" appendTo=\"body\"></p-menu>\n <input #geneGroupFile type=\"file\" accept=\".csv,.tsv,.txt\" hidden\n (change)=\"onImportGeneGroups(geneGroupFile)\" />\n </div>\n <small class=\"sc-hint sc-error\" *ngIf=\"geneGroupError\">{{ geneGroupError }}</small>\n <small class=\"sc-hint\" *ngIf=\"allGenesPreparing\">{{ allGenesPreparing }}</small>\n\n <!-- Marker genes of each cluster, added as gene groups named after the clusters. -->\n <div class=\"sc-markers\" *ngIf=\"markersOpen\">\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Clusters from</span>\n <p-dropdown [options]=\"markerColumnOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"markerColumn\" (ngModelChange)=\"onMarkerColumn($event)\"\n appendTo=\"body\" [filter]=\"markerColumnOptions.length > 8\"></p-dropdown>\n </div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Per cluster</span>\n <p-dropdown [options]=\"markerPerGroupOptions\" optionLabel=\"label\" optionValue=\"value\"\n [(ngModel)]=\"markerPerGroup\" appendTo=\"body\"></p-dropdown>\n </div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Clusters</span>\n <p-multiSelect [options]=\"markerClusterOptions\" optionLabel=\"label\" optionValue=\"value\"\n [(ngModel)]=\"markerClusters\" appendTo=\"body\" [filter]=\"true\"\n selectedItemsLabel=\"{0} clusters\" [maxSelectedLabels]=\"2\"\n placeholder=\"Pick clusters\"></p-multiSelect>\n </div>\n <small class=\"sc-hint\" *ngIf=\"markerLoading\">\n Finding marker genes\u2026 the first time for a grouping reads the whole expression matrix,\n which can take a minute.\n </small>\n <small class=\"sc-hint sc-error\" *ngIf=\"markerError\">{{ markerError }}</small>\n <div class=\"sc-row sc-markers-actions\">\n <p-button label=\"Add marker genes\" icon=\"pi pi-sitemap\" size=\"small\"\n [loading]=\"markerLoading\" [disabled]=\"!markerClusters.length\"\n (onClick)=\"addMarkerGenes()\"></p-button>\n <p-button label=\"Cancel\" size=\"small\" [text]=\"true\" [disabled]=\"markerLoading\"\n (onClick)=\"markersOpen = false\"></p-button>\n </div>\n </div>\n\n <div class=\"sc-subhead\">Selected genes</div>\n <div class=\"sc-gene-tree\">\n <div class=\"sc-gene-row sc-gene-root\">\n <button type=\"button\" class=\"sc-eye\"\n (click)=\"view.transcriptAllGenes ? onToggleAllGenes() : onGenesShown(view.transcriptGenes, !areGenesShown(view.transcriptGenes))\">\n <i class=\"pi\" [class.pi-eye]=\"view.transcriptAllGenes || areGenesShown(view.transcriptGenes)\"\n [class.pi-eye-slash]=\"!view.transcriptAllGenes && !areGenesShown(view.transcriptGenes)\"></i>\n </button>\n <span class=\"sc-group-label\">Genes</span>\n <span class=\"sc-count\">({{ (view.transcriptAllGenes ? geneTotal : view.transcriptGenes.length) | number }}/{{ geneTotal | number }})</span>\n <button type=\"button\" class=\"sc-icon-btn\" (click)=\"geneTreeOpen = !geneTreeOpen\"\n *ngIf=\"!view.transcriptAllGenes && view.transcriptGenes.length\">\n <i class=\"pi\" [class.pi-chevron-down]=\"geneTreeOpen\" [class.pi-chevron-right]=\"!geneTreeOpen\"></i>\n </button>\n </div>\n <ng-container *ngIf=\"geneTreeOpen && !view.transcriptAllGenes\">\n <ng-container *ngFor=\"let node of geneTree; trackBy: trackByNode\">\n <div class=\"sc-gene-row sc-gene-group\" *ngIf=\"node.name\">\n <button type=\"button\" class=\"sc-eye\" (click)=\"onGenesShown(node.genes, !areGenesShown(node.genes))\">\n <i class=\"pi\" [class.pi-eye]=\"areGenesShown(node.genes)\" [class.pi-eye-slash]=\"!areGenesShown(node.genes)\"></i>\n </button>\n <span class=\"sc-group-label\" [title]=\"node.name\">{{ node.name }}</span>\n <span class=\"sc-count\">({{ node.genes.length }})</span>\n <button type=\"button\" class=\"sc-icon-btn\" (click)=\"toggleGeneGroup(node.name)\">\n <i class=\"pi\" [class.pi-chevron-down]=\"!collapsedGeneGroups.has(node.name)\"\n [class.pi-chevron-right]=\"collapsedGeneGroups.has(node.name)\"></i>\n </button>\n </div>\n <ng-container *ngIf=\"!node.name || !collapsedGeneGroups.has(node.name)\">\n <div class=\"sc-gene-row sc-gene-leaf\" [class.nested]=\"!!node.name\"\n *ngFor=\"let gene of node.genes; trackBy: trackByLabelString\">\n <button type=\"button\" class=\"sc-eye\" (click)=\"onGenesShown([gene], !isGeneShown(gene))\">\n <i class=\"pi\" [class.pi-eye]=\"isGeneShown(gene)\" [class.pi-eye-slash]=\"!isGeneShown(gene)\"></i>\n </button>\n <!-- The gene's marker, in its colour: its icon in Icons mode, a dot for circles.\n Clicking it opens the icon and colour picker (as Xenium Explorer does). -->\n <button type=\"button\" class=\"sc-gene-icon\" [style.color]=\"geneSwatchOf(gene)\"\n [attr.aria-label]=\"'Icon and colour for ' + gene\" pTooltip=\"Icon and colour\"\n tooltipPosition=\"top\" (click)=\"openGeneStyle($event, gene, genePop)\">\n <svg viewBox=\"-1.2 -1.2 2.4 2.4\" class=\"sc-glyph-svg\">\n <polygon *ngIf=\"view.transcriptMode === 'glyphs'; else dotIcon\"\n [attr.points]=\"glyphPoints(glyphOf(gene, view.transcriptGenes.indexOf(gene)))\"></polygon>\n <ng-template #dotIcon><circle r=\"0.75\"></circle></ng-template>\n </svg>\n </button>\n <span class=\"sc-group-label\">{{ gene }}</span>\n <span class=\"sc-gene-count\" *ngIf=\"geneCountOf(gene) as n; else zeroCount\"\n title=\"Transcripts in view\">{{ n | number }}</span>\n <ng-template #zeroCount>\n <span class=\"sc-gene-count\" *ngIf=\"geneCounts\" title=\"Transcripts in view\">0</span>\n </ng-template>\n </div>\n </ng-container>\n </ng-container>\n </ng-container>\n <!-- One picker for every gene row, filled for the gene that opened it. -->\n <p-overlayPanel #genePop styleClass=\"sc-gene-style\" appendTo=\"body\">\n <ng-template pTemplate=\"content\">\n <div class=\"sc-style-pop\" *ngIf=\"styleGene as gene\">\n <div class=\"sc-style-title\">Transcript Icon</div>\n <div class=\"sc-style-icons\" role=\"radiogroup\" [attr.aria-label]=\"'Icon for ' + gene\">\n <button type=\"button\" *ngFor=\"let opt of glyphOptions\" class=\"sc-style-icon\"\n role=\"radio\" [attr.aria-checked]=\"opt.value === styleGlyph\"\n [class.active]=\"opt.value === styleGlyph\" [title]=\"opt.label\"\n (click)=\"onGlyph(gene, opt.value)\">\n <svg viewBox=\"-1.2 -1.2 2.4 2.4\" class=\"sc-glyph-svg\"><polygon [attr.points]=\"opt.points\"></polygon></svg>\n </button>\n </div>\n <small class=\"sc-hint\" *ngIf=\"view.transcriptMode !== 'glyphs'\">\n Icons show when the transcript point style is Icons.\n </small>\n <div class=\"sc-style-title\">Transcript Color</div>\n <div class=\"sc-style-color\">\n <label class=\"sc-style-swatch\" [style.background]=\"geneColorOf(gene)\" title=\"Pick a colour\">\n <input type=\"color\" [value]=\"geneColorOf(gene)\" (change)=\"onGeneColor(gene, $any($event.target).value)\" />\n </label>\n <input pInputText class=\"sc-style-hex\" [value]=\"geneColorOf(gene)\" maxlength=\"7\"\n [attr.aria-label]=\"'Hex colour for ' + gene\"\n (change)=\"onGeneHex(gene, $any($event.target).value)\"\n (keydown.enter)=\"onGeneHex(gene, $any($event.target).value)\" />\n </div>\n <div class=\"sc-style-title sc-style-sub\">Presets</div>\n <div class=\"sc-style-presets\">\n <button type=\"button\" *ngFor=\"let c of colorPresets\" class=\"sc-style-preset\"\n [style.background]=\"c\" [class.active]=\"c === geneColorOf(gene).toLowerCase()\"\n [title]=\"c\" [attr.aria-label]=\"'Colour ' + c\" (click)=\"onGeneColor(gene, c)\"></button>\n </div>\n <p-button label=\"Reset to default\" icon=\"pi pi-refresh\" size=\"small\" [text]=\"true\"\n styleClass=\"sc-style-reset\" (onClick)=\"resetGeneStyle(gene)\"></p-button>\n </div>\n </ng-template>\n </p-overlayPanel>\n <div class=\"sc-gene-footer\">\n {{ (view.transcriptAllGenes ? geneTotal : view.transcriptGenes.length) | number }}/{{ geneTotal | number }} genes selected\n </div>\n </div>\n\n <div class=\"sc-subhead\">Transcript controls</div>\n <span class=\"sc-lbl\">View transcripts as</span>\n <p-selectButton [options]=\"transcriptModeOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.transcriptMode === 'off' ? null : view.transcriptMode\"\n (ngModelChange)=\"onTranscriptMode($event)\"\n [allowEmpty]=\"false\" styleClass=\"sc-buttons\"></p-selectButton>\n\n <ng-container *ngIf=\"estimate\">\n <div class=\"sc-row\">\n <span class=\"sc-lbl\" pTooltip=\"Transcripts of the visible genes estimated to be in view, against the most markers drawn at once\"\n tooltipPosition=\"top\">Estimated Transcript Points</span>\n <button type=\"button\" class=\"sc-small-btn\" (click)=\"editingMax = !editingMax\">Edit Max</button>\n </div>\n <div class=\"sc-meter\"><div class=\"sc-meter-fill\" [class.over]=\"estimateOverMax\" [style.width.%]=\"estimatePercent\"></div></div>\n <div class=\"sc-meter-ends\">\n <span>{{ estimate.points | number }} points</span>\n <span>{{ estimate.max | number }} max{{ estimateOverMax ? ' reached' : '' }}</span>\n </div>\n <div class=\"sc-row\" *ngIf=\"editingMax\">\n <span class=\"sc-lbl\">Max markers</span>\n <p-dropdown [options]=\"budgetOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.transcriptBudget\" (ngModelChange)=\"onTranscriptBudget($event)\"\n appendTo=\"body\"></p-dropdown>\n </div>\n <small class=\"sc-hint\" *ngIf=\"estimateOverMax && view.transcriptMode !== 'density'\">\n Max points reached: transcripts are grouped by area, circles sized by how many they hold.\n Zoom in or select fewer genes to see individual points.\n </small>\n </ng-container>\n\n <ng-container *ngIf=\"view.transcriptMode === 'circles' || view.transcriptMode === 'glyphs'\">\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Colour by</span>\n <p-selectButton [options]=\"transcriptColorOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.transcriptColorBy\" (ngModelChange)=\"onTranscriptColorBy($event)\"\n [allowEmpty]=\"false\" styleClass=\"sc-buttons\"></p-selectButton>\n </div>\n <small class=\"sc-hint\" *ngIf=\"view.transcriptAllGenes && view.transcriptColorBy === 'gene' && estimateOverMax\">\n Grouped markers mix genes, so they are coloured by cell type until you zoom in to individual transcripts.\n </small>\n <div class=\"sc-row\" *ngIf=\"view.transcriptAllGenes && view.transcriptMode === 'glyphs'\">\n <span class=\"sc-lbl\">Icon</span>\n <p-dropdown [options]=\"glyphOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"glyphOf('*', 0)\" (ngModelChange)=\"onGlyph('*', $event)\"\n appendTo=\"body\" styleClass=\"sc-glyph-pick\">\n <ng-template let-opt pTemplate=\"item\">\n <span class=\"sc-glyph-option\">\n <svg viewBox=\"-1.2 -1.2 2.4 2.4\" class=\"sc-glyph-svg\"><polygon [attr.points]=\"opt.points\"></polygon></svg>\n {{ opt.label }}\n </span>\n </ng-template>\n <ng-template let-opt pTemplate=\"selectedItem\">\n <span class=\"sc-glyph-option\">\n <svg viewBox=\"-1.2 -1.2 2.4 2.4\" class=\"sc-glyph-svg\"><polygon [attr.points]=\"opt?.points\"></polygon></svg>\n </span>\n </ng-template>\n </p-dropdown>\n </div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Size</span>\n <p-slider [ngModel]=\"view.transcriptScale\" [min]=\"0.5\" [max]=\"3\" [step]=\"0.25\"\n (onChange)=\"onTranscriptScale($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.transcriptScale }}\u00D7</span>\n </div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Opacity</span>\n <p-slider [ngModel]=\"view.transcriptOpacity\" [min]=\"0.1\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onTranscriptOpacity($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.transcriptOpacity }}</span>\n </div>\n <div class=\"sc-row\" *ngIf=\"!showingAllGenes\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.transcriptQuality === 'all'\" inputId=\"sc-tx-lowq\"\n (onChange)=\"onTranscriptQuality($event.checked)\"></p-checkbox>\n <label class=\"sc-lbl\" for=\"sc-tx-lowq\">Include low-quality calls</label>\n </div>\n </ng-container>\n\n <ng-container *ngIf=\"view.transcriptMode === 'density'\">\n <span class=\"sc-lbl\">Density map opacity</span>\n <div class=\"sc-row sc-row-number\">\n <p-slider [ngModel]=\"view.densityOpacity\" [min]=\"0.05\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onDensityOpacity($event.value)\"></p-slider>\n <p-inputNumber [ngModel]=\"densityOpacityPercent\" (ngModelChange)=\"onDensityOpacityPercent($event)\"\n [min]=\"5\" [max]=\"100\" inputStyleClass=\"sc-num\"></p-inputNumber>\n </div>\n <span class=\"sc-lbl\" pTooltip=\"Edge of each density bin\" tooltipPosition=\"top\">Density map bin size</span>\n <p-slider [ngModel]=\"densityBinIndex\" [min]=\"0\" [max]=\"3\" [step]=\"1\"\n (onChange)=\"onDensityBinIndex($event.value)\" styleClass=\"sc-bin-slider\"></p-slider>\n <div class=\"sc-ticks\">\n <span *ngFor=\"let b of densityBins\">{{ b }}\u00D7{{ b }}\u00B5m</span>\n </div>\n <span class=\"sc-lbl\" pTooltip=\"Transcripts per \u00B5m\u00B2 at which the colour scale starts and saturates\"\n tooltipPosition=\"top\">Density map scale threshold</span>\n <div class=\"sc-row sc-row-range\">\n <p-inputNumber [ngModel]=\"densityWindow[0]\" (ngModelChange)=\"onDensityRangeEnd(0, $event)\"\n mode=\"decimal\" [minFractionDigits]=\"3\" [maxFractionDigits]=\"3\"\n inputStyleClass=\"sc-num\"></p-inputNumber>\n <p-slider [ngModel]=\"densityWindow\" [range]=\"true\" [min]=\"0\" [max]=\"densitySliderMax\"\n [step]=\"densityStep\" (onChange)=\"onDensityRange($event.values)\"></p-slider>\n <p-inputNumber [ngModel]=\"densityWindow[1]\" (ngModelChange)=\"onDensityRangeEnd(1, $event)\"\n mode=\"decimal\" [minFractionDigits]=\"3\" [maxFractionDigits]=\"3\"\n inputStyleClass=\"sc-num\"></p-inputNumber>\n </div>\n <div class=\"sc-row\">\n <small class=\"sc-hint\">{{ view.densityRange ? 'Set by hand.' : 'Derived from the data.' }}</small>\n <button type=\"button\" class=\"sc-small-btn\" *ngIf=\"view.densityRange\" (click)=\"onDensityAuto()\">Auto</button>\n </div>\n <span class=\"sc-lbl\">Density map bin coloring</span>\n <div class=\"sc-row sc-row-picker\">\n <div class=\"sc-colorbar sc-full\" [style.background]=\"densityColorBarCss\"></div>\n <p-treeSelect [options]=\"colormapOptions\" [ngModel]=\"selectedDensityColormapNode\"\n selectionMode=\"single\" [showClear]=\"true\" placeholder=\"Inferno\"\n [scrollHeight]=\"'260px'\" appendTo=\"body\" styleClass=\"sc-palette\"\n (onNodeSelect)=\"onDensityColormap($event.node)\" (onClear)=\"onDensityColormap(null)\">\n <ng-template let-node pTemplate=\"default\">\n <span class=\"sc-cmap-option\">\n <img *ngIf=\"!node.children && node.data?.src\" [src]=\"node.data.src\" class=\"sc-cmap-swatch\" alt=\"\" />\n <span>{{ node.label }}</span>\n </span>\n </ng-template>\n </p-treeSelect>\n </div>\n <div class=\"sc-colorbar-ends\"><span>{{ densityWindow[0] | number: '1.0-3' }}</span><span>{{ densityWindow[1] | number: '1.0-3' }}</span></div>\n </ng-container>\n </div>\n </div>\n\n <!-- Annotations: the drawn regions, and selecting what they enclose. -->\n <div class=\"sc-panel\" [class.open]=\"open.annotations\">\n <div class=\"sc-panel-head\" (click)=\"toggleSection('annotations')\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.showAnnotations !== false\" (click)=\"$event.stopPropagation()\"\n (onChange)=\"onShowAnnotations($event.checked)\" pTooltip=\"Show the drawn regions\"\n tooltipPosition=\"top\"></p-checkbox>\n <span class=\"sc-panel-title\">Annotations</span>\n <i class=\"pi\" [class.pi-chevron-down]=\"open.annotations\" [class.pi-chevron-right]=\"!open.annotations\"></i>\n </div>\n <div class=\"sc-panel-body\" *ngIf=\"open.annotations\">\n <div class=\"sc-row\">\n <p-button label=\"Select from ROIs\" icon=\"pi pi-object-group\" size=\"small\"\n [outlined]=\"true\" (onClick)=\"selectFromRegions()\"\n pTooltip=\"Select every observation inside the drawn regions\"\n tooltipPosition=\"top\"></p-button>\n <p-button *ngIf=\"hasSelection\" label=\"Clear\" size=\"small\" [text]=\"true\"\n (onClick)=\"clearSelection()\"></p-button>\n </div>\n <small class=\"sc-hint\" *ngIf=\"hasSelection\">\n {{ selection.count | number }} of {{ ds.observations.count | number }} selected \u2014\n the rest are muted.\n </small>\n <small class=\"sc-hint\" *ngIf=\"selectionMissed\">\n No observations fell inside the drawn regions.\n </small>\n <small class=\"sc-hint\" *ngIf=\"is3d\">\n In 3D a region cuts through the cloud's full depth. Orbit and draw again\n to cut from another angle.\n </small>\n </div>\n </div>\n\n <!-- Observations: one marker per observation (cell centroid, Visium spot), and what\n colours them. The lead section for datasets without cell outlines. -->\n <div class=\"sc-panel\" [class.open]=\"open.observations\">\n <div class=\"sc-panel-head\" (click)=\"toggleSection('observations')\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.showPoints\" (click)=\"$event.stopPropagation()\"\n (onChange)=\"onShowPoints($event.checked)\"></p-checkbox>\n <span class=\"sc-panel-title\">Observations</span>\n <i class=\"pi\" [class.pi-chevron-down]=\"open.observations\" [class.pi-chevron-right]=\"!open.observations\"></i>\n </div>\n <div class=\"sc-panel-body\" *ngIf=\"open.observations\">\n <small class=\"sc-hint\" *ngIf=\"hasCells && !is3d\">\n One circle per cell. With cell outlines on, circles are only drawn where cells\n are too small on screen to outline.\n </small>\n <!-- Colour source. Column and gene are mutually exclusive: picking one\n clears the other, so what drives the colours is never ambiguous. -->\n <div class=\"sc-section\">\n <label class=\"sc-lbl\" for=\"sc-column\">Colour by column</label>\n <p-dropdown inputId=\"sc-column\" [options]=\"columnOptions\"\n optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"selectedColumn\" (ngModelChange)=\"onColumn($event)\"\n placeholder=\"None (flat colour)\" appendTo=\"body\"\n styleClass=\"sc-full\"></p-dropdown>\n </div>\n\n <div class=\"sc-section\" *ngIf=\"ds.features\">\n <label class=\"sc-lbl\" for=\"sc-gene\">Colour by gene</label>\n <!-- A filterable dropdown, not a free-text box: the genes are visible before\n anything is typed, and each keystroke narrows a list the user can see.\n Virtual-scrolled because a targeted panel runs to a few thousand names. -->\n <p-dropdown inputId=\"sc-gene\" [options]=\"geneOptions\"\n optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"selectedGene\" (ngModelChange)=\"onGene($event)\"\n [filter]=\"true\" filterBy=\"label\" [resetFilterOnHide]=\"true\"\n (onFilter)=\"onGeneFilter($event.filter)\" (onShow)=\"ensureGeneList()\"\n [showClear]=\"true\"\n [virtualScroll]=\"geneOptions.length > geneVirtualScrollFrom\"\n [virtualScrollItemSize]=\"30\"\n [emptyFilterMessage]=\"genesAreRemote ? 'No match \u2014 keep typing' : 'No match'\"\n [emptyMessage]=\"genesAreRemote ? 'Type to search genes' : 'No genes'\"\n appendTo=\"body\"\n placeholder=\"Search {{ ds.features.count | number }} genes\u2026\"\n styleClass=\"sc-full\"></p-dropdown>\n <small class=\"sc-hint\" *ngIf=\"geneSearchFailed\">Gene lookup failed.</small>\n </div>\n\n <!-- Key: categorical legend or a continuous colour bar, built from the same\n functions the renderer uses so it cannot disagree with the canvas. -->\n <div class=\"sc-section sc-key\" *ngIf=\"view.colorBy\">\n <span class=\"sc-lbl\">{{ colorByLabel }}</span>\n <!-- Derived columns say so, so they are not mistaken for measured data. -->\n <small class=\"sc-hint\" *ngIf=\"activeDescription\">{{ activeDescription }}</small>\n\n <!-- Legend rows are buttons: clicking one selects that category, clicking\n it again clears \u2014 so a click is always reversible. -->\n <ul class=\"sc-legend\" *ngIf=\"isCategorical\">\n <li *ngFor=\"let entry of legend; index as i\">\n <button type=\"button\" class=\"sc-legend-btn\"\n [class.selected]=\"selectedCategory === i\"\n (click)=\"selectCategory(i)\"\n [title]=\"'Select ' + entry.label\">\n <span class=\"sc-swatch\" [style.background]=\"entry.color\"></span>\n <span class=\"sc-legend-label\">{{ entry.label }}</span>\n </button>\n </li>\n </ul>\n\n <div *ngIf=\"isContinuous\">\n <div class=\"sc-colorbar\" [style.background]=\"colorBarCss\"></div>\n <div class=\"sc-colorbar-ends\">\n <span>low</span>\n <span class=\"sc-hint\">window from percentiles</span>\n <span>high</span>\n </div>\n <!-- The gradient this bar and the map are drawn with. Sits under the bar\n it controls, with the same swatch previews the image's colormap picker\n uses: which gradient reads best is a visual judgement, so a list of\n names would be the wrong control. Cleared = follow the image's. -->\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Colormap</span>\n <p-treeSelect [options]=\"colormapOptions\" [ngModel]=\"selectedColormapNode\"\n selectionMode=\"single\" [showClear]=\"true\"\n placeholder=\"Match the image\" [scrollHeight]=\"'260px'\"\n appendTo=\"body\"\n pTooltip=\"Colour scale for gene and numeric values \u2014 the markers, the gene map and this bar. Clear it to follow the image's colormap\"\n tooltipPosition=\"top\"\n (onNodeSelect)=\"onContinuousColormap($event.node)\"\n (onClear)=\"onContinuousColormap(null)\">\n <ng-template let-node pTemplate=\"default\">\n <span class=\"sc-cmap-option\">\n <img *ngIf=\"!node.children && node.data?.src\" [src]=\"node.data.src\"\n class=\"sc-cmap-swatch\" alt=\"\" />\n <span>{{ node.label }}</span>\n </span>\n </ng-template>\n </p-treeSelect>\n <span class=\"sc-val\"></span>\n </div>\n </div>\n </div>\n\n <!-- Display -->\n <div class=\"sc-section\">\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Point size</span>\n <p-slider [ngModel]=\"view.pointScale\" [min]=\"0.25\" [max]=\"5\" [step]=\"0.25\"\n (onChange)=\"onPointScale($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.pointScale }}\u00D7</span>\n </div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Opacity</span>\n <p-slider [ngModel]=\"view.opacity\" [min]=\"0.1\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onOpacity($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.opacity }}</span>\n </div>\n </div>\n\n </div>\n </div>\n\n <!-- Only for a gene: the map draws that gene's expression as a field. In 2D\n it goes under the cells of the displayed plane; in 3D it is one sheet per\n imaged section, optionally smoothed along z into a volume. -->\n <div class=\"sc-section\" *ngIf=\"canMapGene\">\n <div class=\"sc-row\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.geneMap\"\n (onChange)=\"onGeneMap($event.checked)\"\n label=\"Gene map (expression field)\"\n [pTooltip]=\"is3d\n ? 'Draw this gene\\'s expression as one field per imaged section \u2014 an estimate between the cells, empty where nothing was measured'\n : 'Draw this gene\\'s expression as a smooth field UNDER the cells \u2014 an estimate between them, transparent where nothing was measured'\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <!-- 3D only: interpolating along z turns the measured sheets into an\n estimate, so it is its own decision and its own label. -->\n <div class=\"sc-row\" *ngIf=\"view.geneMap && is3d\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.geneMapVolume\"\n (onChange)=\"onGeneMapVolume($event.checked)\"\n label=\"Volume rendering (interpolate along z)\"\n pTooltip=\"Smooth the per-section fields into a continuous volume \u2014 an ESTIMATE between the imaged sections, not measured tissue\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <!-- Meaningless while interpolating: a volume built from one section would\n smear that slide through the specimen's whole depth. -->\n <div class=\"sc-row\" *ngIf=\"view.geneMap && is3d && isSectioned && !view.geneMapVolume\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"geneMapOneSection\"\n (onChange)=\"onGeneMapOneSection($event.checked)\"\n label=\"One map section at a time\"\n pTooltip=\"Draw a single imaged section's field instead of the whole stack of sheets\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <div class=\"sc-row\"\n *ngIf=\"view.geneMap && is3d && isSectioned && !view.geneMapVolume && geneMapOneSection\">\n <span class=\"sc-lbl\">Map section</span>\n <p-slider [ngModel]=\"view.geneMapSection\" [min]=\"0\" [max]=\"lastSection\" [step]=\"1\"\n (onChange)=\"onGeneMapSection($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ geneMapSectionLabel }}</span>\n </div>\n <div class=\"sc-row\" *ngIf=\"view.geneMap\">\n <span class=\"sc-lbl\">Smoothing</span>\n <p-slider [ngModel]=\"view.geneMapSmoothing\" [min]=\"0.5\" [max]=\"4\" [step]=\"0.5\"\n (onChange)=\"onGeneMapSmoothing($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.geneMapSmoothing }}\u00D7</span>\n </div>\n <div class=\"sc-row\" *ngIf=\"view.geneMap\">\n <span class=\"sc-lbl\">Map opacity</span>\n <p-slider [ngModel]=\"view.geneMapOpacity\" [min]=\"0.1\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onGeneMapOpacity($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.geneMapOpacity }}</span>\n </div>\n <div class=\"sc-hint\" *ngIf=\"view.geneMap && !is3d\">\n Kernel-weighted mean per cell, not a sum \u2014 so a crowded region does not glow\n for being crowded. Transparent where no cell was measured. Turn the cells'\n own Opacity down to read the field under them.\n </div>\n <div class=\"sc-hint\" *ngIf=\"view.geneMap && is3d\">\n {{ geneMapVolumeNote }}\n </div>\n </div>\n\n <!-- 3D only: the volume, the cloud and the density volumes occupy the same\n space, so each hides the others. Independent toggles, because the useful\n views are the combinations \u2014 the field alone, one section's cells over it,\n the anatomy alone. -->\n <div class=\"sc-section\" *ngIf=\"is3d\">\n <span class=\"sc-lbl\">Show</span>\n <div class=\"sc-row\" *ngIf=\"ds.volume\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.showVolume\"\n (onChange)=\"onShowVolume($event.checked)\"\n label=\"Reference volume\"\n pTooltip=\"The registered anatomy the observations sit in\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <!-- The backdrop's own opacity: reading the cloud or a density field\n through the anatomy means turning the anatomy down, not the data. -->\n <div class=\"sc-row\" *ngIf=\"ds.volume && view.showVolume\">\n <span class=\"sc-lbl\">Volume opacity</span>\n <p-slider [ngModel]=\"view.volumeOpacity\" [min]=\"0.05\" [max]=\"1\" [step]=\"0.05\"\n (onChange)=\"onVolumeOpacity($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.volumeOpacity }}</span>\n </div>\n <div class=\"sc-row\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.showPoints\"\n (onChange)=\"onShowPoints($event.checked)\"\n label=\"Observations\"\n pTooltip=\"The measured cells. Hide them to read the density volumes underneath\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <!-- Only for data that really is sectioned; a continuous z has no sections\n to step through. -->\n <div class=\"sc-row\" *ngIf=\"view.showPoints && isSectioned\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"oneSection\"\n (onChange)=\"onOneSection($event.checked)\"\n label=\"One section at a time\"\n pTooltip=\"Draw a single imaged section's cells, so the density volumes between the sections stay visible\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <div class=\"sc-row\" *ngIf=\"view.showPoints && isSectioned && oneSection\">\n <span class=\"sc-lbl\">Section</span>\n <p-slider [ngModel]=\"view.pointSection\" [min]=\"0\" [max]=\"lastSection\" [step]=\"1\"\n (onChange)=\"onPointSection($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ sectionLabel }}</span>\n </div>\n </div>\n\n <div class=\"sc-section\" *ngIf=\"is3d\">\n <div class=\"sc-hint\" *ngIf=\"exceedsCloudPalette\">\n {{ legend?.length }} categories is past the {{ cloudPaletteLimit }} the 3D cloud's\n colour LUT can keep apart, so the points draw flat for this column. The density\n volumes below render it, and so does the 2D Spatial omics view.\n </div>\n <div class=\"sc-row\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.densityVolume\"\n (onChange)=\"onDensityVolume($event.checked)\"\n label=\"Cluster density volumes\"\n pTooltip=\"Raymarch each cluster as a smooth density field \u2014 an ESTIMATE between the imaged sections, not measured cells\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <div class=\"sc-row\" *ngIf=\"view.densityVolume\">\n <span class=\"sc-lbl\">Smoothing</span>\n <p-slider [ngModel]=\"view.densitySmoothing\" [min]=\"0.5\" [max]=\"4\" [step]=\"0.5\"\n (onChange)=\"onDensitySmoothing($event.value)\"></p-slider>\n <span class=\"sc-val\">{{ view.densitySmoothing }}\u00D7</span>\n </div>\n <div class=\"sc-hint\" *ngIf=\"view.densityVolume\">{{ densityNote }}</div>\n </div>\n\n <!-- Continuous-only knobs: meaningless for a categorical palette. -->\n <div class=\"sc-section\" *ngIf=\"isContinuous\">\n <div class=\"sc-row\">\n <p-checkbox [binary]=\"true\" [ngModel]=\"view.logScale\"\n (onChange)=\"onLogScale($event.checked)\"\n label=\"Log scale\"\n pTooltip=\"Counts collapse against their outliers on a linear ramp\"\n tooltipPosition=\"top\"></p-checkbox>\n </div>\n <div class=\"sc-row\">\n <span class=\"sc-lbl\">Clip outliers</span>\n <p-dropdown [options]=\"clipOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"view.percentileClip\" (ngModelChange)=\"onClip($event)\"\n appendTo=\"body\"\n pTooltip=\"Percentile the contrast window is taken at\"\n tooltipPosition=\"top\"></p-dropdown>\n <!-- Reserves the readout column this row has no readout for, so the\n control's right edge lines up with every slider's. -->\n <span class=\"sc-val\"></span>\n </div>\n </div>\n\n <!-- Distributions of whatever the map is coloured by. In the SAME dialog as\n the colour controls on purpose: changing the gene and watching the\n distribution move is one action, and two floating windows made that\n harder to see rather than easier. -->\n <div class=\"sc-section sc-charts\">\n <button type=\"button\" class=\"sc-disclosure\" (click)=\"toggleCharts()\"\n [attr.aria-expanded]=\"chartsOpen\" [attr.aria-controls]=\"chartsBodyId\">\n <i class=\"pi\" [class.pi-chevron-right]=\"!chartsOpen\"\n [class.pi-chevron-down]=\"chartsOpen\"></i>\n <span>Distribution</span>\n <small *ngIf=\"!chartsOpen && view.colorBy\">of {{ colorByLabel }}</small>\n </button>\n <!-- Kept MOUNTED when collapsed, only hidden: destroying it would throw\n away the chart kind and grouping the user picked. `active` is what\n stops it drawing while it has no size. -->\n <div [id]=\"chartsBodyId\" [hidden]=\"!chartsOpen\">\n <spatial-charts [active]=\"visible && chartsOpen\"></spatial-charts>\n </div>\n </div>\n\n <div class=\"sc-btns\">\n <p-button label=\"Reset\" size=\"small\" [text]=\"true\" (onClick)=\"reset()\"></p-button>\n </div>\n\n </div>\n</p-dialog>\n", styles: ["@charset \"UTF-8\";:host ::ng-deep .spatial-controls-dialog .p-dialog-content{padding:.75rem 1rem 1rem}.sc-empty{padding:.5rem 0;font-size:.85rem;color:var(--text-color-secondary, #6c757d);line-height:1.4}.sc-empty code{font-size:.8rem}.sc-body{display:flex;flex-direction:column;gap:.85rem}.sc-dataset{display:flex;flex-direction:column;gap:.1rem;padding-bottom:.5rem;border-bottom:1px solid var(--surface-border, #dee2e6)}.sc-dataset-name{font-weight:600;font-size:.9rem}.sc-dataset-count,.sc-hint{font-size:.75rem;color:var(--text-color-secondary, #6c757d)}.sc-section{display:flex;flex-direction:column;gap:.35rem}.sc-lbl{font-size:.78rem;color:var(--text-color-secondary, #6c757d);white-space:nowrap}.sc-row{display:flex;align-items:center;gap:.6rem}.sc-row p-slider,.sc-row p-dropdown,.sc-row p-treeSelect{flex:1 1 0;min-width:0}:host ::ng-deep .sc-row p-slider .p-slider{width:100%}:host ::ng-deep .sc-row p-dropdown .p-dropdown,:host ::ng-deep .sc-row p-treeSelect .p-treeselect{width:100%}.sc-row .sc-lbl{flex:0 0 calc(50% - .6rem);min-width:0;overflow:hidden;text-overflow:ellipsis}.sc-cmap-option{display:inline-flex;align-items:center;gap:.4rem}.sc-cmap-swatch{width:3.5rem;height:.7rem;border-radius:2px;display:block}.sc-val{flex:0 0 4rem;text-align:right;font-size:.78rem;font-variant-numeric:tabular-nums;white-space:nowrap}:host ::ng-deep .sc-full,:host ::ng-deep .sc-full .p-dropdown,:host ::ng-deep .sc-full .p-autocomplete,:host ::ng-deep .sc-full input{width:100%}.sc-key{padding:.5rem 0;border-top:1px solid var(--surface-border, #dee2e6);border-bottom:1px solid var(--surface-border, #dee2e6)}.sc-legend{list-style:none;margin:0;padding:0;max-height:11rem;overflow-y:auto;display:flex;flex-direction:column;gap:.2rem}.sc-legend li{display:flex}.sc-legend-btn{display:flex;align-items:center;gap:.45rem;width:100%;padding:.15rem .3rem;font:inherit;font-size:.8rem;text-align:left;background:none;border:1px solid transparent;border-radius:3px;cursor:pointer;color:inherit}.sc-legend-btn:hover{background:var(--surface-hover, rgba(0, 0, 0, .04))}.sc-legend-btn.selected{border-color:var(--primary-color, #3b82f6);background:var(--surface-hover, rgba(59, 130, 246, .08));font-weight:600}.sc-selection .sc-row{gap:.4rem}.sc-swatch{width:.85rem;height:.85rem;flex:0 0 auto;border-radius:2px;border:1px solid rgba(0,0,0,.2)}.sc-legend-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sc-colorbar{height:.75rem;border-radius:2px;border:1px solid rgba(0,0,0,.2)}.sc-colorbar-ends{display:flex;justify-content:space-between;font-size:.7rem;color:var(--text-color-secondary, #6c757d);margin-top:.2rem}.sc-btns{display:flex;justify-content:flex-end}.sc-charts{padding-top:.5rem;border-top:1px solid var(--surface-border, #dee2e6);gap:0}.sc-disclosure{display:flex;align-items:center;gap:.4rem;width:100%;padding:.2rem 0;font:inherit;font-size:.82rem;font-weight:600;text-align:left;background:none;border:0;cursor:pointer;color:inherit}.sc-disclosure i{font-size:.7rem;color:var(--text-color-secondary, #6c757d)}.sc-disclosure small{font-weight:400;color:var(--text-color-secondary, #6c757d);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sc-disclosure:hover{color:var(--primary-color, #3b82f6)}.sc-legend-static{cursor:default}.sc-tx-gene{display:flex;align-items:center;gap:.5rem}.sc-tx-gene .sc-legend-label{flex:1}:host ::ng-deep .sc-glyph-pick .p-dropdown-label{padding:.2rem .4rem}.sc-glyph-option{display:inline-flex;align-items:center;gap:.4rem}.sc-glyph-svg{width:1rem;height:1rem;fill:currentColor}:host ::ng-deep .sc-row-buttons .p-selectbutton .p-button{padding:.35rem .6rem;font-size:.85rem}:host ::ng-deep .sc-row-buttons .p-selectbutton{display:flex;flex-wrap:nowrap}.sc-row-wide .sc-lbl{flex:0 0 auto}.sc-panel{border:1px solid var(--surface-border, #dee2e6);border-radius:6px;margin-bottom:.5rem;overflow:hidden}.sc-panel-head{display:flex;align-items:center;gap:.6rem;padding:.5rem .7rem;cursor:pointer;-webkit-user-select:none;user-select:none;background:var(--surface-100, #f5f6f7)}.sc-panel-head .sc-panel-title{flex:1;font-weight:600}.sc-panel.open>.sc-panel-head{background:var(--primary-color, #2196f3);color:var(--primary-color-text, #fff)}.sc-panel-body{display:flex;flex-direction:column;gap:.45rem;padding:.6rem .75rem .75rem}.sc-subhead{display:flex;align-items:center;gap:.5rem;margin-top:.4rem;font-size:.72rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--text-color-secondary, #6c757d)}.sc-subhead:after{content:\"\";flex:1;border-top:1px solid var(--surface-border, #dee2e6)}:host ::ng-deep .sc-buttons .p-button{padding:.35rem .75rem;font-size:.85rem}.sc-row-picker p-dropdown,.sc-row-picker p-multiSelect,.sc-row-picker p-treeSelect{flex:1 1 0;min-width:0}:host ::ng-deep .sc-row-picker .sc-variant{width:6.5rem}:host ::ng-deep .sc-row-picker p-multiselect .p-multiselect{width:100%}:host ::ng-deep .sc-row-picker p-dropdown:has(.sc-variant){flex:0 0 6.5rem}:host ::ng-deep .sc-row-picker p-treeselect:has(.sc-palette){flex:0 0 3rem}:host ::ng-deep .sc-palette .p-treeselect-label{width:0;padding:0}.sc-icon-btn{flex:0 0 auto;border:0;background:transparent;cursor:pointer;padding:.25rem .35rem;font:inherit;font-size:1.05rem;color:var(--text-color, #333);border-radius:4px}.sc-icon-btn:hover{background:var(--surface-200, #e9ecef)}.sc-icon-btn.active{color:var(--primary-color, #2196f3);font-weight:700}.sc-icon-btn:disabled{opacity:.5;cursor:default}.sc-small-btn{margin-left:auto;border:1px solid var(--surface-border, #ced4da);background:var(--surface-0, #fff);border-radius:4px;padding:.2rem .6rem;font:inherit;font-size:.8rem;cursor:pointer}:host ::ng-deep .sc-group-heading{font-size:.7rem;font-weight:700;letter-spacing:.05em;text-transform:uppercase;opacity:.7}.sc-group-list,.sc-gene-tree{max-height:16rem;overflow-y:auto;border:1px solid var(--surface-border, #dee2e6);border-radius:4px;padding:.25rem .4rem}.sc-group-row,.sc-gene-row{display:flex;align-items:center;gap:.45rem;padding:.15rem 0;font-size:.88rem}.sc-group-row .sc-group-label,.sc-gene-row .sc-group-label{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sc-group-all,.sc-gene-root,.sc-gene-group{font-weight:600}.sc-group-all .sc-icon-btn,.sc-gene-root .sc-icon-btn,.sc-gene-group .sc-icon-btn{margin-left:auto;font-size:.85rem}.sc-gene-group,.sc-gene-leaf{padding-left:1rem}.sc-gene-leaf.nested{padding-left:2rem}.sc-count{flex:0 0 auto;font-size:.78rem;color:var(--text-color-secondary, #6c757d)}.sc-dot{flex:0 0 auto;width:.7rem;height:.7rem;border-radius:50%;border:1px solid rgba(0,0,0,.2)}.sc-dot-pick{position:relative;cursor:pointer}.sc-dot-pick input{position:absolute;inset:0;opacity:0;width:100%;height:100%;cursor:pointer}.sc-eye{flex:0 0 auto;border:0;background:transparent;cursor:pointer;padding:0;color:var(--text-color-secondary, #6c757d)}.sc-eye .pi-eye{color:var(--text-color, #333)}.sc-gene-footer{text-align:right;font-size:.78rem;padding:.2rem 0;color:var(--text-color-secondary, #6c757d)}.sc-color-input{width:2.5rem;height:1.8rem;border:1px solid var(--surface-border, #ced4da);border-radius:4px;padding:0;background:none}.sc-meter{height:.45rem;border-radius:3px;background:var(--surface-200, #e9ecef);overflow:hidden}.sc-meter-fill{height:100%;background:var(--primary-color, #2196f3)}.sc-meter-fill.over{background:var(--orange-500, #f59e0b)}.sc-meter-ends,.sc-ticks{display:flex;justify-content:space-between;font-size:.72rem;font-weight:600;color:var(--text-color-secondary, #6c757d)}.sc-row-number p-slider,.sc-row-range p-slider{flex:1 1 0;min-width:0}:host ::ng-deep .sc-num{width:4.5rem;padding:.3rem .4rem;text-align:center}:host ::ng-deep .sc-row-number p-inputnumber,:host ::ng-deep .sc-row-range p-inputnumber{flex:0 0 auto}.sc-error{color:var(--red-600, #c62828)}.sc-gene-icon{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:1.4rem;height:1.4rem;padding:0;border:1px solid transparent;border-radius:4px;background:transparent;cursor:pointer}.sc-gene-icon:hover,.sc-gene-icon:focus-visible{border-color:var(--surface-border, rgba(0, 0, 0, .2))}::ng-deep .sc-gene-style .p-overlaypanel-content{padding:.75rem}.sc-style-pop{display:flex;flex-direction:column;gap:.5rem;width:15rem}.sc-style-title{font-weight:600;font-size:.9rem}.sc-style-sub{font-size:.75rem;text-transform:uppercase;opacity:.7}.sc-style-icons,.sc-style-presets{display:grid;grid-template-columns:repeat(5,1fr);gap:.35rem}.sc-style-icon{display:inline-flex;align-items:center;justify-content:center;height:1.9rem;border:1px solid var(--surface-border, rgba(0, 0, 0, .15));border-radius:4px;background:transparent;color:var(--text-color, inherit);cursor:pointer}.sc-style-icon .sc-glyph-svg{width:1.1rem;height:1.1rem}.sc-style-icon.active{border-color:var(--primary-color, #3b82f6);background:var(--highlight-bg, rgba(59, 130, 246, .12))}.sc-style-color{display:flex;gap:.5rem;align-items:center}.sc-style-swatch{position:relative;flex:0 0 3rem;height:2rem;border-radius:4px;border:1px solid rgba(0,0,0,.2);cursor:pointer}.sc-style-swatch input{position:absolute;inset:0;opacity:0;width:100%;height:100%;cursor:pointer}.sc-style-hex{flex:1 1 auto;min-width:0;font-family:monospace}.sc-style-preset{width:1.4rem;height:1.4rem;justify-self:center;border-radius:50%;border:2px solid transparent;cursor:pointer}.sc-style-preset.active{border-color:var(--text-color, #000)}.sc-gene-count{margin-left:auto;font-variant-numeric:tabular-nums;opacity:.8;padding-left:.5rem}.sc-markers{display:flex;flex-direction:column;gap:.4rem;padding:.5rem;margin:.25rem 0 .5rem;border:1px solid var(--surface-border, rgba(0, 0, 0, .15));border-radius:6px}.sc-markers-actions{gap:.5rem}\n"] }]
|
|
12880
13222
|
}], ctorParameters: () => [{ type: undefined, decorators: [{
|
|
12881
13223
|
type: Inject,
|
|
12882
13224
|
args: [VISUALIZER]
|
|
@@ -24172,6 +24514,13 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
|
|
|
24172
24514
|
// following `frame$.subscribe(redraw)` would stay blank until the next pan/zoom.
|
|
24173
24515
|
frame$: this.withCurrentRect(this.frame$, ready),
|
|
24174
24516
|
settled$: this.withCurrentRect(this.viewportChange$, ready),
|
|
24517
|
+
fitBounds: (rect, options) => {
|
|
24518
|
+
const viewer = this.viewer;
|
|
24519
|
+
if (!viewer || !ready() || !(rect.width > 0) || !(rect.height > 0))
|
|
24520
|
+
return;
|
|
24521
|
+
const vpRect = imageRectToViewport(viewer, rect.x, rect.y, rect.width, rect.height);
|
|
24522
|
+
viewer.viewport.fitBoundsWithConstraints(vpRect, !!options?.immediately);
|
|
24523
|
+
},
|
|
24175
24524
|
};
|
|
24176
24525
|
return this.plotModeViewport;
|
|
24177
24526
|
}
|
|
@@ -25434,6 +25783,13 @@ class NapariSpatialTooltip {
|
|
|
25434
25783
|
}
|
|
25435
25784
|
}
|
|
25436
25785
|
|
|
25786
|
+
/** The all-gene pyramid's finest bin (250 µm source tiles / 128) and default level count, used
|
|
25787
|
+
* to group a gene selection on the same ladder when a dataset has no pyramid. */
|
|
25788
|
+
const PYRAMID_BASE_BIN_UM = 250 / 128;
|
|
25789
|
+
const PYRAMID_LEVELS = 7;
|
|
25790
|
+
/** Icons get a dark rim only while there are at most this many, at least this big (px). */
|
|
25791
|
+
const GLYPH_OUTLINE_MAX = 20_000;
|
|
25792
|
+
const GLYPH_OUTLINE_MIN_PX = 8;
|
|
25437
25793
|
const ORDER = [
|
|
25438
25794
|
'cellFill', 'cellOutline', 'nucleusOutline', 'density', 'transcripts', 'transcriptOutline',
|
|
25439
25795
|
];
|
|
@@ -25472,6 +25828,12 @@ class NapariSpatialTileLayers {
|
|
|
25472
25828
|
/** Selection identity → revision, so a change key can name a selection cheaply. */
|
|
25473
25829
|
this.lastSelection = null;
|
|
25474
25830
|
this.selectionRevision = 0;
|
|
25831
|
+
/** µm per observation unit of the drawn dataset, for describing a grouped marker's area. */
|
|
25832
|
+
this.micronsPerUnit = null;
|
|
25833
|
+
/** Loads in flight, per layer label, for the loading badge. */
|
|
25834
|
+
this.loading = new Map();
|
|
25835
|
+
/** The loaded per-gene transcripts the in-view counts are taken from. */
|
|
25836
|
+
this.countSource = null;
|
|
25475
25837
|
// ── hover ─────────────────────────────────────────────────────────────────────────
|
|
25476
25838
|
this.drawn = null;
|
|
25477
25839
|
this.hoverTypes = null;
|
|
@@ -25479,6 +25841,7 @@ class NapariSpatialTileLayers {
|
|
|
25479
25841
|
this.hoverCache = new Map();
|
|
25480
25842
|
this.hoverKey = null;
|
|
25481
25843
|
this.hoverTimer = null;
|
|
25844
|
+
this.densityCache = new Map();
|
|
25482
25845
|
this.countsCache = null;
|
|
25483
25846
|
/** The density window in use and the densest bin, for the panel's threshold control. */
|
|
25484
25847
|
this.densityStats = null;
|
|
@@ -25491,6 +25854,81 @@ class NapariSpatialTileLayers {
|
|
|
25491
25854
|
this.viewer = viewer;
|
|
25492
25855
|
this.cameraOff = viewer.camera.changed.connect(() => this.schedule());
|
|
25493
25856
|
}
|
|
25857
|
+
/**
|
|
25858
|
+
* Group a gene selection for the zoom: one marker per gene-tree cluster (a gene in no
|
|
25859
|
+
* cluster is its own) per bin of the pyramid ladder, starting at the finest bin whose
|
|
25860
|
+
* markers are 14 px apart and stepping up while the markers would exceed the budget.
|
|
25861
|
+
* Null to draw every transcript: zoomed in, with the selection within the budget.
|
|
25862
|
+
*/
|
|
25863
|
+
groupSelection(t, view, ladder, pxPerUnit) {
|
|
25864
|
+
const budget = Math.max(1, view.transcriptBudget);
|
|
25865
|
+
const genes = view.transcriptGenes;
|
|
25866
|
+
const groups = view.transcriptGeneGroups;
|
|
25867
|
+
const names = [...groups.map((g) => g.name), ...genes];
|
|
25868
|
+
const clusterOf = genes.map((gene, slot) => {
|
|
25869
|
+
const g = groups.findIndex((x) => x.genes.includes(gene));
|
|
25870
|
+
return g >= 0 ? g : groups.length + slot;
|
|
25871
|
+
});
|
|
25872
|
+
const keyOf = (slot) => clusterOf[slot] ?? groups.length + slot;
|
|
25873
|
+
let bin = ladder.start;
|
|
25874
|
+
if (bin === null) {
|
|
25875
|
+
// Every transcript, if they fit the max and none was cut off at the cap.
|
|
25876
|
+
if (t.count <= budget && t.count < MAX_TRANSCRIPTS)
|
|
25877
|
+
return null;
|
|
25878
|
+
bin = ladder.baseBin;
|
|
25879
|
+
}
|
|
25880
|
+
const top = ladder.baseBin * 2 ** (ladder.levels - 1);
|
|
25881
|
+
let grouped = groupTranscripts(t, bin, keyOf);
|
|
25882
|
+
// Over the max: combine into larger markers, a level at a time.
|
|
25883
|
+
while (grouped.tile.count > budget && bin < top) {
|
|
25884
|
+
bin *= 2;
|
|
25885
|
+
grouped = groupTranscripts(t, bin, keyOf);
|
|
25886
|
+
}
|
|
25887
|
+
const refCount = quantileOf(grouped.tile.weight, 0.95);
|
|
25888
|
+
const binPx = bin * pxPerUnit;
|
|
25889
|
+
const px = new Float32Array(grouped.tile.count);
|
|
25890
|
+
for (let i = 0; i < grouped.tile.count; i++) {
|
|
25891
|
+
px[i] = groupedMarkerPx(grouped.tile.weight[i], refCount, binPx, view.transcriptScale);
|
|
25892
|
+
}
|
|
25893
|
+
return { merged: grouped.tile, px, bin, group: grouped.group, names };
|
|
25894
|
+
}
|
|
25895
|
+
/** Report `label` loading while `work` is in flight. */
|
|
25896
|
+
async track(label, work) {
|
|
25897
|
+
this.loading.set(label, (this.loading.get(label) ?? 0) + 1);
|
|
25898
|
+
this.host.loadingChanged?.([...this.loading.keys()]);
|
|
25899
|
+
try {
|
|
25900
|
+
return await work;
|
|
25901
|
+
}
|
|
25902
|
+
finally {
|
|
25903
|
+
const n = (this.loading.get(label) ?? 1) - 1;
|
|
25904
|
+
if (n > 0)
|
|
25905
|
+
this.loading.set(label, n);
|
|
25906
|
+
else
|
|
25907
|
+
this.loading.delete(label);
|
|
25908
|
+
this.host.loadingChanged?.([...this.loading.keys()]);
|
|
25909
|
+
}
|
|
25910
|
+
}
|
|
25911
|
+
/**
|
|
25912
|
+
* Transcripts of each selected gene inside `rect`: each entry counts for the transcripts it
|
|
25913
|
+
* stands for (an aggregate at a coarse level holds several). Null without per-gene data.
|
|
25914
|
+
*/
|
|
25915
|
+
geneCountsIn(rect) {
|
|
25916
|
+
const src = this.countSource;
|
|
25917
|
+
if (!src)
|
|
25918
|
+
return null;
|
|
25919
|
+
const out = {};
|
|
25920
|
+
for (const g of src.genes)
|
|
25921
|
+
out[g] = 0;
|
|
25922
|
+
const { x, y, weight, gene } = src.merged;
|
|
25923
|
+
for (let i = 0; i < src.merged.count; i++) {
|
|
25924
|
+
if (x[i] < rect.x0 || x[i] > rect.x1 || y[i] < rect.y0 || y[i] > rect.y1)
|
|
25925
|
+
continue;
|
|
25926
|
+
const name = src.genes[gene[i]];
|
|
25927
|
+
if (name !== undefined)
|
|
25928
|
+
out[name] += weight[i];
|
|
25929
|
+
}
|
|
25930
|
+
return out;
|
|
25931
|
+
}
|
|
25494
25932
|
/** Whether `layer` is one of the overlays drawn here (density, cells, transcripts). */
|
|
25495
25933
|
owns(layer) {
|
|
25496
25934
|
for (const l of this.layers.values())
|
|
@@ -25510,6 +25948,9 @@ class NapariSpatialTileLayers {
|
|
|
25510
25948
|
this.timer = null;
|
|
25511
25949
|
this.token++;
|
|
25512
25950
|
this.tileRetries = 0;
|
|
25951
|
+
this.countSource = null;
|
|
25952
|
+
this.loading.clear();
|
|
25953
|
+
this.host.loadingChanged?.([]);
|
|
25513
25954
|
if (this.viewer) {
|
|
25514
25955
|
for (const layer of this.layers.values()) {
|
|
25515
25956
|
if (this.viewer.layers.items.includes(layer))
|
|
@@ -25581,6 +26022,7 @@ class NapariSpatialTileLayers {
|
|
|
25581
26022
|
]);
|
|
25582
26023
|
if (stale())
|
|
25583
26024
|
return;
|
|
26025
|
+
this.host.geneCountsChanged?.(this.geneCountsIn(rect));
|
|
25584
26026
|
// A tile that failed left a hole the cache keys do not record, so try the same view
|
|
25585
26027
|
// again, backing off, a bounded number of times.
|
|
25586
26028
|
if (!this.planIncomplete) {
|
|
@@ -25612,7 +26054,9 @@ class NapariSpatialTileLayers {
|
|
|
25612
26054
|
const hidden = await this.hiddenCodes(dataset, view);
|
|
25613
26055
|
if (stale())
|
|
25614
26056
|
return;
|
|
25615
|
-
const
|
|
26057
|
+
const label = view.cellSet === 'both' ? 'Cells and nuclei'
|
|
26058
|
+
: view.cellSet === 'nucleus' ? 'Nuclei' : 'Cells';
|
|
26059
|
+
const geometry = await this.track(label, this.cellGeometry(dataset, view, rect, level, hidden, stale));
|
|
25616
26060
|
if (!geometry)
|
|
25617
26061
|
return;
|
|
25618
26062
|
const { rings, nuclei, geometryKey } = geometry;
|
|
@@ -25796,7 +26240,8 @@ class NapariSpatialTileLayers {
|
|
|
25796
26240
|
// ── transcripts ───────────────────────────────────────────────────────────────────
|
|
25797
26241
|
async planTranscripts(dataset, view, rect, pxPerUnit, stale) {
|
|
25798
26242
|
const mode = view.transcriptMode;
|
|
25799
|
-
const job = (mode === 'circles' || mode === 'glyphs')
|
|
26243
|
+
const job = (mode === 'circles' || mode === 'glyphs')
|
|
26244
|
+
&& (this.port.getTranscriptTile || (this.port.getTranscriptGeneBins && dataset.transcriptGeneBins))
|
|
25800
26245
|
? (view.transcriptAllGenes
|
|
25801
26246
|
? this.allGenesJob(dataset, view, rect, pxPerUnit)
|
|
25802
26247
|
: this.geneJob(dataset, view, rect, pxPerUnit))
|
|
@@ -25805,6 +26250,7 @@ class NapariSpatialTileLayers {
|
|
|
25805
26250
|
this.drop('transcripts');
|
|
25806
26251
|
this.drop('transcriptOutline');
|
|
25807
26252
|
this.drawn = null;
|
|
26253
|
+
this.countSource = null;
|
|
25808
26254
|
return;
|
|
25809
26255
|
}
|
|
25810
26256
|
// Marker sizes follow the zoom, so the zoom is part of the key; a pan that keeps
|
|
@@ -25813,25 +26259,49 @@ class NapariSpatialTileLayers {
|
|
|
25813
26259
|
dataset.id, mode, job.key, view.transcriptColorBy, this.cellTypeColumnName(dataset, view),
|
|
25814
26260
|
view.transcriptScale, view.transcriptOpacity, JSON.stringify(view.transcriptGlyphs),
|
|
25815
26261
|
pxPerUnit.toPrecision(4), view.hiddenGroups.join('\u0001'), view.transcriptHiddenGenes.join(','),
|
|
25816
|
-
JSON.stringify(view.transcriptGeneColors),
|
|
26262
|
+
JSON.stringify(view.transcriptGeneColors), JSON.stringify(view.transcriptGeneGroups),
|
|
25817
26263
|
].join('|');
|
|
25818
26264
|
const current = this.layers.get('transcripts');
|
|
25819
26265
|
if (planKey === this.keys.get('transcripts') && current && this.viewer.layers.items.includes(current)) {
|
|
25820
26266
|
return;
|
|
25821
26267
|
}
|
|
25822
|
-
const loaded = await job.load();
|
|
26268
|
+
const loaded = await this.track('Transcripts', job.load());
|
|
25823
26269
|
if (stale())
|
|
25824
26270
|
return;
|
|
26271
|
+
// Per-gene counts in view come from what was loaded, before hidden genes are dropped:
|
|
26272
|
+
// a hidden gene still has transcripts there. Summed density grids (no per-gene levels,
|
|
26273
|
+
// zoomed out) hold clusters, not genes: no per-gene counts there, rather than a guess.
|
|
26274
|
+
this.countSource = (loaded.kind ?? job.kind) === 'genes' && !loaded.clustered
|
|
26275
|
+
? { merged: loaded.merged, genes: [...view.transcriptGenes] } : null;
|
|
25825
26276
|
const hidden = await this.hiddenCodes(dataset, view);
|
|
25826
26277
|
if (stale())
|
|
25827
26278
|
return;
|
|
25828
|
-
|
|
26279
|
+
// Density-grid markers carry no cells and were built from the visible genes only.
|
|
26280
|
+
const filtered = loaded.clustered ? { merged: loaded.merged, px: loaded.px }
|
|
26281
|
+
: filterTranscripts(loaded.merged, loaded.px, hidden, (loaded.kind ?? job.kind) === 'genes' ? hiddenGeneSlots(view) : null);
|
|
26282
|
+
let { merged, px } = filtered;
|
|
26283
|
+
let selectionBin = loaded.clustered?.bin ?? null;
|
|
26284
|
+
let entryGroup = loaded.clustered?.group ?? null;
|
|
26285
|
+
let groupNames = loaded.clustered?.names ?? null;
|
|
26286
|
+
if (loaded.ladder) {
|
|
26287
|
+
const g = this.groupSelection(merged, view, loaded.ladder, pxPerUnit);
|
|
26288
|
+
if (g)
|
|
26289
|
+
({ merged, px, bin: selectionBin, group: entryGroup, names: groupNames } = g);
|
|
26290
|
+
}
|
|
25829
26291
|
// A bin of the all-gene pyramid mixes genes and carries none, so it cannot be coloured
|
|
25830
26292
|
// by gene: colour it by its dominant cell's type until individual transcripts show.
|
|
25831
26293
|
const kind = loaded.kind ?? job.kind;
|
|
25832
|
-
const colorView = kind === 'bins' && view.transcriptColorBy
|
|
25833
|
-
? { ...view, transcriptColorBy: 'cellType' }
|
|
25834
|
-
|
|
26294
|
+
const colorView = kind === 'bins' && view.transcriptColorBy !== 'cellType'
|
|
26295
|
+
? { ...view, transcriptColorBy: 'cellType' }
|
|
26296
|
+
// Density-grid markers have no cell to take a type from: they take their cluster's colour.
|
|
26297
|
+
: loaded.clustered && view.transcriptColorBy === 'cellType'
|
|
26298
|
+
? { ...view, transcriptColorBy: 'cluster' } : view;
|
|
26299
|
+
// Each entry's cluster: its marker's group, or the gene-tree group its gene is in.
|
|
26300
|
+
const geneCluster = view.transcriptGenes.map((g) => view.transcriptGeneGroups.find((x) => x.genes.includes(g))?.name ?? g);
|
|
26301
|
+
const clusterOf = (i) => (entryGroup && groupNames
|
|
26302
|
+
? groupNames[entryGroup[i]] ?? null
|
|
26303
|
+
: geneCluster[merged.gene[i]] ?? null);
|
|
26304
|
+
const faces = await this.transcriptColors(dataset, colorView, merged, kind === 'genes' ? clusterOf : undefined);
|
|
25835
26305
|
if (stale())
|
|
25836
26306
|
return;
|
|
25837
26307
|
if (this.planIncomplete)
|
|
@@ -25840,11 +26310,16 @@ class NapariSpatialTileLayers {
|
|
|
25840
26310
|
this.keys.set('transcripts', planKey);
|
|
25841
26311
|
const diam = px.map((d) => d / pxPerUnit);
|
|
25842
26312
|
this.drawn = {
|
|
25843
|
-
kind: loaded.kind ?? job.kind,
|
|
26313
|
+
kind: loaded.kind ?? job.kind,
|
|
26314
|
+
bin: selectionBin ? { size: selectionBin, origin: loaded.clustered?.origin ?? [0, 0] }
|
|
26315
|
+
: (loaded.kind ? loaded.bin : job.bin),
|
|
25844
26316
|
merged, radius: diam.map((d) => d / 2),
|
|
25845
26317
|
genes: [...view.transcriptGenes], ref: dataset.imageRef ?? null, grid: null,
|
|
26318
|
+
...(entryGroup && groupNames ? { entryGroup, groupNames } : {}),
|
|
26319
|
+
...(loaded.clustered ? { groupGenes: loaded.clustered.genes } : {}),
|
|
25846
26320
|
};
|
|
25847
26321
|
this.hoverCache.clear();
|
|
26322
|
+
this.micronsPerUnit = dataset.micronsPerUnit ?? null;
|
|
25848
26323
|
const typeColumn = this.cellTypeColumnName(dataset, view);
|
|
25849
26324
|
this.hoverTypes = typeColumn ? await this.categoricalCodes(typeColumn).catch(() => null) : null;
|
|
25850
26325
|
if (stale())
|
|
@@ -25856,7 +26331,7 @@ class NapariSpatialTileLayers {
|
|
|
25856
26331
|
this.drawTranscriptCircles(merged, diam, faces.rgba, view, place);
|
|
25857
26332
|
}
|
|
25858
26333
|
else {
|
|
25859
|
-
this.drawTranscriptGlyphs(merged, diam, faces, view, place);
|
|
26334
|
+
this.drawTranscriptGlyphs(merged, diam, faces, view, place, pxPerUnit);
|
|
25860
26335
|
}
|
|
25861
26336
|
}
|
|
25862
26337
|
/** One sized circle per entry, the size saying how many transcripts it stands for. */
|
|
@@ -25880,7 +26355,7 @@ class NapariSpatialTileLayers {
|
|
|
25880
26355
|
});
|
|
25881
26356
|
this.replace('transcripts', layer);
|
|
25882
26357
|
}
|
|
25883
|
-
drawTranscriptGlyphs(merged, diam, faces, view, { scale, translate }) {
|
|
26358
|
+
drawTranscriptGlyphs(merged, diam, faces, view, { scale, translate }, pxPerUnit) {
|
|
25884
26359
|
// Glyphs: each entry becomes its gene's icon polygon, filled through a discrete
|
|
25885
26360
|
// colormap and outlined dark so small icons stay readable over the tissue. With every
|
|
25886
26361
|
// gene drawn there is one icon for all of them — 18,000 shapes would say nothing.
|
|
@@ -25903,6 +26378,13 @@ class NapariSpatialTileLayers {
|
|
|
25903
26378
|
translate,
|
|
25904
26379
|
});
|
|
25905
26380
|
this.replace('transcripts', fill);
|
|
26381
|
+
// A dark rim keeps a few large icons readable over the tissue; on many small ones the
|
|
26382
|
+
// rims merge into a solid dark sheet that hides every colour, so they are left off.
|
|
26383
|
+
const medianPx = median(diam) * pxPerUnit;
|
|
26384
|
+
if (merged.count > GLYPH_OUTLINE_MAX || medianPx < GLYPH_OUTLINE_MIN_PX) {
|
|
26385
|
+
this.drop('transcriptOutline');
|
|
26386
|
+
return;
|
|
26387
|
+
}
|
|
25906
26388
|
const edge = this.viewer.addShapes(coords, offsets, {
|
|
25907
26389
|
name: 'transcript outlines',
|
|
25908
26390
|
draw: 'outline',
|
|
@@ -25954,7 +26436,7 @@ class NapariSpatialTileLayers {
|
|
|
25954
26436
|
const obs = d.merged.observation[i];
|
|
25955
26437
|
const cells = obs === NO_OBSERVATION ? [] : [obs];
|
|
25956
26438
|
let box;
|
|
25957
|
-
if (d.kind === 'bins' && d.bin) {
|
|
26439
|
+
if ((d.kind === 'bins' || d.kind === 'genes') && d.bin) {
|
|
25958
26440
|
const { size, origin } = d.bin;
|
|
25959
26441
|
const bx = Math.floor((d.merged.x[i] - origin[0]) / size);
|
|
25960
26442
|
const by = Math.floor((d.merged.y[i] - origin[1]) / size);
|
|
@@ -25966,8 +26448,9 @@ class NapariSpatialTileLayers {
|
|
|
25966
26448
|
const e = 0.02;
|
|
25967
26449
|
box = [d.merged.x[i] - e, d.merged.y[i] - e, d.merged.x[i] + e, d.merged.y[i] + e];
|
|
25968
26450
|
}
|
|
26451
|
+
const genes = d.groupGenes && d.entryGroup ? d.groupGenes[d.entryGroup[i]] : undefined;
|
|
25969
26452
|
try {
|
|
25970
|
-
const summary = await this.port.getTranscriptSummary({ box, cells });
|
|
26453
|
+
const summary = await this.port.getTranscriptSummary({ box, cells, ...(genes ? { genes } : {}) });
|
|
25971
26454
|
const lines = this.describe(d, i, summary);
|
|
25972
26455
|
this.hoverCache.set(key, lines);
|
|
25973
26456
|
if (this.hoverKey === key)
|
|
@@ -26022,6 +26505,19 @@ class NapariSpatialTileLayers {
|
|
|
26022
26505
|
];
|
|
26023
26506
|
}
|
|
26024
26507
|
const gene = d.genes[t.gene[i]] ?? 'transcript';
|
|
26508
|
+
if (d.bin) {
|
|
26509
|
+
const um = d.bin.size * (this.micronsPerUnit ?? 1);
|
|
26510
|
+
const group = d.entryGroup && d.groupNames ? d.groupNames[d.entryGroup[i]] : gene;
|
|
26511
|
+
const top = s?.topGenes?.length
|
|
26512
|
+
? [s.topGenes.slice(0, 6).map((g) => `${g.name} ${fmt(g.count)}`).join(', ')] : [];
|
|
26513
|
+
return [
|
|
26514
|
+
`${group} · ${fmt(n)} transcript${n === 1 ? '' : 's'}`,
|
|
26515
|
+
...(d.groupGenes ? top : group !== gene ? [`mostly ${gene}`] : []),
|
|
26516
|
+
`${um.toFixed(1)} × ${um.toFixed(1)} µm area · zoom in to split`,
|
|
26517
|
+
...(obs === NO_OBSERVATION && d.groupGenes ? [] : [cellLine(obs, 'mostly cell')]),
|
|
26518
|
+
...(!s && d.groupGenes && this.port.getTranscriptSummary ? ['loading details…'] : []),
|
|
26519
|
+
];
|
|
26520
|
+
}
|
|
26025
26521
|
return n > 1
|
|
26026
26522
|
? [`${gene} · ${fmt(n)} transcripts`, 'grouped: zoom in to split', cellLine(obs, 'near cell')]
|
|
26027
26523
|
: [`${gene} transcript`, cellLine(obs, 'in cell')];
|
|
@@ -26043,24 +26539,182 @@ class NapariSpatialTileLayers {
|
|
|
26043
26539
|
const keysAt = (l) => tilesInRect(rect, l, meta.levels, meta.bounds, MAX_TRANSCRIPT_TILES);
|
|
26044
26540
|
const query = { genes, quality: view.transcriptQuality };
|
|
26045
26541
|
const pxPerMicron = this.pxPerMicron(dataset, pxPerUnit);
|
|
26542
|
+
const mx = (rect.x1 - rect.x0) * 0.25;
|
|
26543
|
+
const my = (rect.y1 - rect.y0) * 0.25;
|
|
26544
|
+
const around = { x0: rect.x0 - mx, y0: rect.y0 - my, x1: rect.x1 + mx, y1: rect.y1 + my };
|
|
26545
|
+
// Zoomed out, each gene's transcripts are grouped on the all-gene pyramid's bin ladder,
|
|
26546
|
+
// so a gene selection reads like "all genes" does: one marker per gene per area, sized
|
|
26547
|
+
// by how many it holds; zoomed in far enough, every transcript is its own marker.
|
|
26548
|
+
const ladder = dataset.transcriptBins?.levels;
|
|
26549
|
+
const baseBin = ladder?.[0]?.binSize
|
|
26550
|
+
?? (dataset.micronsPerUnit ? PYRAMID_BASE_BIN_UM / dataset.micronsPerUnit : 0);
|
|
26551
|
+
const bin = geneBinSize(pxPerUnit, baseBin, ladder?.length ?? PYRAMID_LEVELS);
|
|
26552
|
+
// Zoomed out to bins as wide as the density grid, a selection is drawn from the per-gene
|
|
26553
|
+
// density grids — one cached request per cluster — rather than 10x's coarse per-gene
|
|
26554
|
+
// tiles, which take minutes for a few hundred genes.
|
|
26555
|
+
// With the pyramid's per-gene levels, every zoom of a selection reads them: the level the
|
|
26556
|
+
// zoom calls for, coarser while the markers would exceed the max.
|
|
26557
|
+
// Both are built from high-quality calls only: with low-quality calls included, the
|
|
26558
|
+
// quality-aware tiles below serve every zoom (as long as there are tiles to serve).
|
|
26559
|
+
const highOnly = view.transcriptQuality !== 'all' || !this.port.getTranscriptTile;
|
|
26560
|
+
const geneBins = dataset.transcriptGeneBins;
|
|
26561
|
+
const fromGeneBins = highOnly && geneBins && this.port.getTranscriptGeneBins;
|
|
26562
|
+
if (fromGeneBins && (bin !== null || !this.port.getTranscriptTile)) {
|
|
26563
|
+
return this.geneBinsJob(dataset, view, around, geneBins, bin ?? geneBins.levels[0].binSize, mx, my, rect);
|
|
26564
|
+
}
|
|
26565
|
+
const grid = dataset.density?.gridSize[0] ?? 0;
|
|
26566
|
+
if (highOnly && bin !== null && grid > 0 && this.port.getDensity && bin >= grid * 0.75) {
|
|
26567
|
+
return this.clusterDensityJob(dataset, view, around, pxPerUnit, Math.max(bin, grid), mx, my, rect);
|
|
26568
|
+
}
|
|
26046
26569
|
return {
|
|
26047
26570
|
kind: 'genes',
|
|
26048
|
-
|
|
26571
|
+
// The clip window, in quarter-view steps: a pan past the margin re-clips.
|
|
26572
|
+
key: `genes|${genes.join(',')}|${view.transcriptQuality}|${view.transcriptBudget}|${bin ?? 'each'}|`
|
|
26573
|
+
+ `${Math.floor(rect.x0 / (mx || 1))},${Math.floor(rect.y0 / (my || 1))}|${keysAt(first).map(tileId)}`,
|
|
26049
26574
|
load: async () => {
|
|
26050
|
-
|
|
26051
|
-
|
|
26052
|
-
|
|
26053
|
-
|
|
26054
|
-
|
|
26055
|
-
|
|
26056
|
-
break;
|
|
26057
|
-
level++;
|
|
26058
|
-
}
|
|
26575
|
+
// The zoom's own level only: over the budget, the transcripts are combined into
|
|
26576
|
+
// larger markers (groupSelection), never fetched from slower coarse levels.
|
|
26577
|
+
const tiles = await this.fetchAll(keysAt(first), (k) => this.port.getTranscriptTile(k.level, k.gx, k.gy, query));
|
|
26578
|
+
// Only what is on screen (and a margin, so a small pan needs nothing new) counts
|
|
26579
|
+
// against the budget and the cap; a tile reaches far past the view.
|
|
26580
|
+
const merged = mergeTranscriptTiles(tiles.map((t) => clipTranscripts(t, around)), MAX_TRANSCRIPTS);
|
|
26059
26581
|
const px = new Float32Array(merged.count);
|
|
26060
26582
|
for (let i = 0; i < merged.count; i++) {
|
|
26061
26583
|
px[i] = transcriptMarkerPx(merged.weight[i], view.transcriptScale, pxPerMicron);
|
|
26062
26584
|
}
|
|
26063
|
-
return
|
|
26585
|
+
return baseBin > 0
|
|
26586
|
+
? { merged, px, ladder: { baseBin, levels: ladder?.length ?? PYRAMID_LEVELS, start: bin } }
|
|
26587
|
+
: { merged, px };
|
|
26588
|
+
},
|
|
26589
|
+
};
|
|
26590
|
+
}
|
|
26591
|
+
/**
|
|
26592
|
+
* A gene selection from the pyramid's per-gene levels: starting at the level whose bins are
|
|
26593
|
+
* `startBin`, fetch the view's tiles for the selected genes and step to a coarser level while
|
|
26594
|
+
* one marker per cluster per bin would exceed the max. Hidden genes are fetched too (their
|
|
26595
|
+
* counts in view still show) and dropped before drawing; grouping by cluster happens there.
|
|
26596
|
+
*/
|
|
26597
|
+
geneBinsJob(dataset, view, around, meta, startBin, mx, my, rect) {
|
|
26598
|
+
const genes = view.transcriptGenes;
|
|
26599
|
+
const levels = meta.levels;
|
|
26600
|
+
let first = levels.findIndex((l) => l.binSize >= startBin * 0.999);
|
|
26601
|
+
if (first < 0)
|
|
26602
|
+
first = levels.length - 1;
|
|
26603
|
+
const budget = Math.max(1, view.transcriptBudget);
|
|
26604
|
+
const hidden = new Set(view.transcriptHiddenGenes);
|
|
26605
|
+
const clusters = this.selectionClusters(view);
|
|
26606
|
+
const clusterOf = new Map();
|
|
26607
|
+
clusters.forEach((c, ci) => c.genes.forEach((g) => clusterOf.set(genes.indexOf(g), ci)));
|
|
26608
|
+
const keysAt = (m) => tilesInRectFrom(meta.origin, around, m, levels.map((l) => ({ tileSize: l.tileSize })), null, MAX_BIN_TILES);
|
|
26609
|
+
return {
|
|
26610
|
+
kind: 'genes',
|
|
26611
|
+
key: `gene-bins|${genes.join(',')}|${budget}|${first}|${JSON.stringify(view.transcriptGeneGroups)}|`
|
|
26612
|
+
+ `${Math.floor(rect.x0 / (mx || 1))},${Math.floor(rect.y0 / (my || 1))}`,
|
|
26613
|
+
load: async () => {
|
|
26614
|
+
let m = first;
|
|
26615
|
+
for (;;) {
|
|
26616
|
+
const tiles = await this.fetchAll(keysAt(m), (k) => this.port.getTranscriptGeneBins(k.level, k.gx, k.gy, genes));
|
|
26617
|
+
const clipped = tiles.map((t) => clipTranscripts(t, around));
|
|
26618
|
+
// Past the cap a merge would drop whole genes (the tail of every tile): go coarser.
|
|
26619
|
+
if (clipped.reduce((n, t) => n + t.count, 0) > MAX_TRANSCRIPTS && m < levels.length - 1) {
|
|
26620
|
+
m++;
|
|
26621
|
+
continue;
|
|
26622
|
+
}
|
|
26623
|
+
const merged = mergeTranscriptTiles(clipped, MAX_TRANSCRIPTS);
|
|
26624
|
+
// Markers this level would draw: one per (visible cluster, bin).
|
|
26625
|
+
const seen = new Set();
|
|
26626
|
+
const bin = levels[m].binSize;
|
|
26627
|
+
for (let i = 0; i < merged.count; i++) {
|
|
26628
|
+
if (hidden.has(genes[merged.gene[i]]))
|
|
26629
|
+
continue;
|
|
26630
|
+
const c = clusterOf.get(merged.gene[i]);
|
|
26631
|
+
if (c === undefined)
|
|
26632
|
+
continue;
|
|
26633
|
+
seen.add(`${c}|${Math.floor(merged.x[i] / bin)}|${Math.floor(merged.y[i] / bin)}`);
|
|
26634
|
+
}
|
|
26635
|
+
if (seen.size <= budget || m >= levels.length - 1) {
|
|
26636
|
+
return { merged, px: new Float32Array(merged.count), ladder: { baseBin: bin, levels: 1, start: bin } };
|
|
26637
|
+
}
|
|
26638
|
+
m++;
|
|
26639
|
+
}
|
|
26640
|
+
},
|
|
26641
|
+
};
|
|
26642
|
+
}
|
|
26643
|
+
/** The selection's clusters: each gene-tree group's visible genes, then each ungrouped gene. */
|
|
26644
|
+
selectionClusters(view) {
|
|
26645
|
+
const hidden = new Set(view.transcriptHiddenGenes);
|
|
26646
|
+
const slots = new Map(view.transcriptGenes.map((g, i) => [g, i]));
|
|
26647
|
+
const taken = new Set();
|
|
26648
|
+
const out = [];
|
|
26649
|
+
for (const g of view.transcriptGeneGroups) {
|
|
26650
|
+
const genes = g.genes.filter((x) => slots.has(x) && !hidden.has(x) && !taken.has(x));
|
|
26651
|
+
genes.forEach((x) => taken.add(x));
|
|
26652
|
+
if (genes.length)
|
|
26653
|
+
out.push({ name: g.name, genes, slot: slots.get(genes[0]) });
|
|
26654
|
+
}
|
|
26655
|
+
for (const gene of view.transcriptGenes) {
|
|
26656
|
+
if (!taken.has(gene) && !hidden.has(gene))
|
|
26657
|
+
out.push({ name: gene, genes: [gene], slot: slots.get(gene) });
|
|
26658
|
+
}
|
|
26659
|
+
return out;
|
|
26660
|
+
}
|
|
26661
|
+
/**
|
|
26662
|
+
* Per-gene density grids summed for `genes`, cached (a cluster is asked for once per bin).
|
|
26663
|
+
* Keyed by dataset too: this manager outlives a dataset, and another may share gene names.
|
|
26664
|
+
*/
|
|
26665
|
+
densityFor(dataset, genes, bin) {
|
|
26666
|
+
const key = `${dataset}|${bin}|${genes.join(',')}`;
|
|
26667
|
+
let p = this.densityCache.get(key);
|
|
26668
|
+
if (!p) {
|
|
26669
|
+
p = this.port.getDensity(genes, bin);
|
|
26670
|
+
p.catch(() => this.densityCache.delete(key));
|
|
26671
|
+
this.densityCache.set(key, p);
|
|
26672
|
+
if (this.densityCache.size > 256)
|
|
26673
|
+
this.densityCache.delete(this.densityCache.keys().next().value);
|
|
26674
|
+
}
|
|
26675
|
+
return p;
|
|
26676
|
+
}
|
|
26677
|
+
/**
|
|
26678
|
+
* A gene selection zoomed out: one marker per cluster per bin, from the per-gene density
|
|
26679
|
+
* grids. The bin starts where markers are 14 px apart and doubles until the markers fit the
|
|
26680
|
+
* max; each marker sits at the count-weighted centre of the finer grid inside it, so markers
|
|
26681
|
+
* follow the tissue rather than a lattice.
|
|
26682
|
+
*/
|
|
26683
|
+
clusterDensityJob(dataset, view, around, pxPerUnit, startBin, mx, my, rect) {
|
|
26684
|
+
const grid = dataset.density.gridSize[0];
|
|
26685
|
+
const clusters = this.selectionClusters(view);
|
|
26686
|
+
let first = grid;
|
|
26687
|
+
while (first < startBin * 0.999)
|
|
26688
|
+
first *= 2;
|
|
26689
|
+
const budget = Math.max(1, view.transcriptBudget);
|
|
26690
|
+
return {
|
|
26691
|
+
kind: 'genes',
|
|
26692
|
+
key: `clusters|${clusters.map((c) => `${c.name}:${c.genes.join('+')}`).join(';')}|${budget}|${first}|`
|
|
26693
|
+
+ `${Math.floor(rect.x0 / (mx || 1))},${Math.floor(rect.y0 / (my || 1))}`,
|
|
26694
|
+
load: async () => {
|
|
26695
|
+
let bin = first;
|
|
26696
|
+
for (;;) {
|
|
26697
|
+
// The finer grid gives each marker its centre; the server bins 1, 2, 4 or 8 cells.
|
|
26698
|
+
const fine = Math.min(bin >= grid * 2 ? bin / 2 : bin, grid * 8);
|
|
26699
|
+
const rasters = await Promise.all(clusters.map((c) => this.densityFor(dataset.id, c.genes, fine)));
|
|
26700
|
+
const out = clusterMarkers(rasters, clusters.map((c) => c.slot), bin, around);
|
|
26701
|
+
if (out.tile.count <= budget || bin >= grid * 2 ** 12) {
|
|
26702
|
+
const refCount = quantileOf(out.tile.weight, 0.95);
|
|
26703
|
+
const binPx = bin * pxPerUnit;
|
|
26704
|
+
const px = new Float32Array(out.tile.count);
|
|
26705
|
+
for (let i = 0; i < out.tile.count; i++) {
|
|
26706
|
+
px[i] = groupedMarkerPx(out.tile.weight[i], refCount, binPx, view.transcriptScale);
|
|
26707
|
+
}
|
|
26708
|
+
return {
|
|
26709
|
+
merged: out.tile, px, kind: 'genes',
|
|
26710
|
+
clustered: {
|
|
26711
|
+
group: out.group, names: clusters.map((c) => c.name), genes: clusters.map((c) => c.genes), bin,
|
|
26712
|
+
origin: rasters[0]?.meta.origin ?? [0, 0],
|
|
26713
|
+
},
|
|
26714
|
+
};
|
|
26715
|
+
}
|
|
26716
|
+
bin *= 2;
|
|
26717
|
+
}
|
|
26064
26718
|
},
|
|
26065
26719
|
};
|
|
26066
26720
|
}
|
|
@@ -26145,10 +26799,40 @@ class NapariSpatialTileLayers {
|
|
|
26145
26799
|
};
|
|
26146
26800
|
}
|
|
26147
26801
|
/** Per-entry colours both as RGBA (points) and as colormap values (glyph shapes). */
|
|
26148
|
-
async transcriptColors(dataset, view, t) {
|
|
26802
|
+
async transcriptColors(dataset, view, t, clusterOf) {
|
|
26149
26803
|
let rgb;
|
|
26150
26804
|
let codeOf;
|
|
26151
|
-
if (view.transcriptColorBy === '
|
|
26805
|
+
if (view.transcriptColorBy === 'cluster' && clusterOf) {
|
|
26806
|
+
// A cluster named like a group of the cells' grouping takes that group's colour, so a
|
|
26807
|
+
// cluster's transcripts and its cells agree; any other cluster takes a palette colour.
|
|
26808
|
+
const name = this.cellTypeColumnName(dataset, view);
|
|
26809
|
+
const codes = name ? await this.categoricalCodes(name).catch(() => null) : null;
|
|
26810
|
+
const cellColor = new Map();
|
|
26811
|
+
if (codes?.meta.kind === 'categorical') {
|
|
26812
|
+
const colors = resolveCategoryColors(codes.meta);
|
|
26813
|
+
codes.meta.categories.forEach((c, k) => cellColor.set(c, colors[k]));
|
|
26814
|
+
}
|
|
26815
|
+
const colors = clusterColorMap(view.transcriptGenes, view.transcriptGeneGroups, cellColor, DEFAULT_CATEGORICAL_PALETTE);
|
|
26816
|
+
const index = new Map();
|
|
26817
|
+
const hex = [];
|
|
26818
|
+
const codeFor = (cluster) => {
|
|
26819
|
+
let k = index.get(cluster);
|
|
26820
|
+
if (k === undefined) {
|
|
26821
|
+
k = hex.length;
|
|
26822
|
+
index.set(cluster, k);
|
|
26823
|
+
hex.push(colors.get(cluster) ?? cellColor.get(cluster) ?? DEFAULT_CATEGORICAL_PALETTE[0]);
|
|
26824
|
+
}
|
|
26825
|
+
return k;
|
|
26826
|
+
};
|
|
26827
|
+
const code = new Int32Array(t.count);
|
|
26828
|
+
for (let i = 0; i < t.count; i++) {
|
|
26829
|
+
const c = clusterOf(i);
|
|
26830
|
+
code[i] = c === null ? -1 : codeFor(c);
|
|
26831
|
+
}
|
|
26832
|
+
rgb = hex.map(parseHex);
|
|
26833
|
+
codeOf = (i) => code[i];
|
|
26834
|
+
}
|
|
26835
|
+
else if (view.transcriptColorBy === 'gene' || view.transcriptColorBy === 'cluster') {
|
|
26152
26836
|
// All genes: codes are the dataset's gene indices, folded onto the palette.
|
|
26153
26837
|
const n = view.transcriptAllGenes ? DEFAULT_CATEGORICAL_PALETTE.length : view.transcriptGenes.length;
|
|
26154
26838
|
rgb = Array.from({ length: n }, (_g, i) => parseHex((!view.transcriptAllGenes && view.transcriptGeneColors[view.transcriptGenes[i]])
|
|
@@ -26194,7 +26878,7 @@ class NapariSpatialTileLayers {
|
|
|
26194
26878
|
JSON.stringify(view.densityRange), JSON.stringify(view.densityColormap)].join('|');
|
|
26195
26879
|
if (key === this.keys.get('density') && this.layers.has('density'))
|
|
26196
26880
|
return;
|
|
26197
|
-
const raster = await this.port.getDensity(genes, view.densityBin);
|
|
26881
|
+
const raster = await this.track('Transcript density', this.port.getDensity(genes, view.densityBin));
|
|
26198
26882
|
if (stale())
|
|
26199
26883
|
return;
|
|
26200
26884
|
// Bins drawn as squares, as Xenium Explorer does; the window is in transcripts/µm².
|
|
@@ -26526,6 +27210,60 @@ function mergeTranscriptTiles(tiles, limit = Infinity) {
|
|
|
26526
27210
|
}
|
|
26527
27211
|
return out;
|
|
26528
27212
|
}
|
|
27213
|
+
/**
|
|
27214
|
+
* One marker per (cluster, `bin` square) from per-cluster density grids: the counts of the grid
|
|
27215
|
+
* cells inside `area` summed per square, at their count-weighted centre. `slots[c]` is the gene
|
|
27216
|
+
* slot whose icon cluster c's markers take.
|
|
27217
|
+
*/
|
|
27218
|
+
function clusterMarkers(rasters, slots, bin, area) {
|
|
27219
|
+
const x = [];
|
|
27220
|
+
const y = [];
|
|
27221
|
+
const w = [];
|
|
27222
|
+
const gene = [];
|
|
27223
|
+
const group = [];
|
|
27224
|
+
rasters.forEach((r, c) => {
|
|
27225
|
+
const [cw, ch] = r.meta.gridSize;
|
|
27226
|
+
const [ox, oy] = r.meta.origin;
|
|
27227
|
+
const { rows, cols } = r.meta;
|
|
27228
|
+
const c0 = Math.max(0, Math.floor((area.x0 - ox) / cw));
|
|
27229
|
+
const c1 = Math.min(cols - 1, Math.floor((area.x1 - ox) / cw));
|
|
27230
|
+
const r0 = Math.max(0, Math.floor((area.y0 - oy) / ch));
|
|
27231
|
+
const r1 = Math.min(rows - 1, Math.floor((area.y1 - oy) / ch));
|
|
27232
|
+
const squares = new Map();
|
|
27233
|
+
for (let row = r0; row <= r1; row++) {
|
|
27234
|
+
for (let col = c0; col <= c1; col++) {
|
|
27235
|
+
const v = r.values[row * cols + col];
|
|
27236
|
+
if (!(v > 0))
|
|
27237
|
+
continue;
|
|
27238
|
+
const cx = ox + (col + 0.5) * cw;
|
|
27239
|
+
const cy = oy + (row + 0.5) * ch;
|
|
27240
|
+
const key = Math.floor((cy - oy) / bin) * 1e6 + Math.floor((cx - ox) / bin);
|
|
27241
|
+
const s = squares.get(key) ?? { n: 0, sx: 0, sy: 0 };
|
|
27242
|
+
s.n += v;
|
|
27243
|
+
s.sx += v * cx;
|
|
27244
|
+
s.sy += v * cy;
|
|
27245
|
+
squares.set(key, s);
|
|
27246
|
+
}
|
|
27247
|
+
}
|
|
27248
|
+
for (const s of squares.values()) {
|
|
27249
|
+
x.push(s.sx / s.n);
|
|
27250
|
+
y.push(s.sy / s.n);
|
|
27251
|
+
w.push(Math.round(s.n));
|
|
27252
|
+
gene.push(slots[c]);
|
|
27253
|
+
group.push(c);
|
|
27254
|
+
}
|
|
27255
|
+
});
|
|
27256
|
+
const n = w.length;
|
|
27257
|
+
return {
|
|
27258
|
+
tile: {
|
|
27259
|
+
count: n, aggregated: true,
|
|
27260
|
+
x: Float32Array.from(x), y: Float32Array.from(y), z: new Float32Array(n),
|
|
27261
|
+
weight: Uint32Array.from(w), observation: new Uint32Array(n).fill(NO_OBSERVATION),
|
|
27262
|
+
gene: Uint16Array.from(gene),
|
|
27263
|
+
},
|
|
27264
|
+
group: Int32Array.from(group),
|
|
27265
|
+
};
|
|
27266
|
+
}
|
|
26529
27267
|
|
|
26530
27268
|
/**
|
|
26531
27269
|
* Overview navigator (minimap) for the napari-js 2D views, mirroring the OSD backend's
|
|
@@ -26722,6 +27460,79 @@ function stop(e) {
|
|
|
26722
27460
|
e.preventDefault();
|
|
26723
27461
|
}
|
|
26724
27462
|
|
|
27463
|
+
/**
|
|
27464
|
+
* "Transcripts reloading…" at the bottom of the canvas while a layer's data is in flight: one
|
|
27465
|
+
* line naming every layer still loading, on a semi-opaque backing so it reads over any tissue.
|
|
27466
|
+
*
|
|
27467
|
+
* Shown only once something has been loading for {@link SHOW_AFTER_MS}, so a redraw served from
|
|
27468
|
+
* cache does not flash it.
|
|
27469
|
+
*/
|
|
27470
|
+
const SHOW_AFTER_MS = 150;
|
|
27471
|
+
class NapariLoadingBadge {
|
|
27472
|
+
constructor(host) {
|
|
27473
|
+
this.host = host;
|
|
27474
|
+
this.names = [];
|
|
27475
|
+
this.timer = null;
|
|
27476
|
+
if (getComputedStyle(host).position === 'static')
|
|
27477
|
+
host.style.position = 'relative';
|
|
27478
|
+
this.el = document.createElement('div');
|
|
27479
|
+
this.el.className = 'napari-loading-badge';
|
|
27480
|
+
this.el.setAttribute('role', 'status');
|
|
27481
|
+
this.el.setAttribute('aria-live', 'polite');
|
|
27482
|
+
Object.assign(this.el.style, {
|
|
27483
|
+
position: 'absolute',
|
|
27484
|
+
left: '50%',
|
|
27485
|
+
bottom: '12px',
|
|
27486
|
+
transform: 'translateX(-50%)',
|
|
27487
|
+
padding: '4px 12px',
|
|
27488
|
+
borderRadius: '12px',
|
|
27489
|
+
background: 'rgba(0, 0, 0, 0.6)',
|
|
27490
|
+
color: '#fff',
|
|
27491
|
+
font: '12px/1.4 system-ui, sans-serif',
|
|
27492
|
+
whiteSpace: 'nowrap',
|
|
27493
|
+
pointerEvents: 'none',
|
|
27494
|
+
zIndex: '5',
|
|
27495
|
+
display: 'none',
|
|
27496
|
+
});
|
|
27497
|
+
host.appendChild(this.el);
|
|
27498
|
+
}
|
|
27499
|
+
/** The layers loading now; empty hides the badge. */
|
|
27500
|
+
set(names) {
|
|
27501
|
+
this.names = [...new Set(names)];
|
|
27502
|
+
if (!this.names.length) {
|
|
27503
|
+
if (this.timer)
|
|
27504
|
+
clearTimeout(this.timer);
|
|
27505
|
+
this.timer = null;
|
|
27506
|
+
this.el.style.display = 'none';
|
|
27507
|
+
return;
|
|
27508
|
+
}
|
|
27509
|
+
this.el.textContent = `${listOf(this.names)} reloading…`;
|
|
27510
|
+
if (this.el.style.display === 'block' || this.timer)
|
|
27511
|
+
return;
|
|
27512
|
+
this.timer = setTimeout(() => {
|
|
27513
|
+
this.timer = null;
|
|
27514
|
+
if (this.names.length)
|
|
27515
|
+
this.el.style.display = 'block';
|
|
27516
|
+
}, SHOW_AFTER_MS);
|
|
27517
|
+
}
|
|
27518
|
+
/** The text currently set (shown or about to be). */
|
|
27519
|
+
get text() {
|
|
27520
|
+
return this.names.length ? this.el.textContent ?? '' : '';
|
|
27521
|
+
}
|
|
27522
|
+
destroy() {
|
|
27523
|
+
if (this.timer)
|
|
27524
|
+
clearTimeout(this.timer);
|
|
27525
|
+
this.el.remove();
|
|
27526
|
+
}
|
|
27527
|
+
}
|
|
27528
|
+
/** "A", "A and B", "A, B and C" — the first capitalised, the rest as they are. */
|
|
27529
|
+
function listOf(names) {
|
|
27530
|
+
const [first, ...rest] = names;
|
|
27531
|
+
const lower = rest.map((n) => n.charAt(0).toLowerCase() + n.slice(1));
|
|
27532
|
+
const all = [first, ...lower];
|
|
27533
|
+
return all.length === 1 ? all[0] : `${all.slice(0, -1).join(', ')} and ${all[all.length - 1]}`;
|
|
27534
|
+
}
|
|
27535
|
+
|
|
26725
27536
|
/**
|
|
26726
27537
|
* Wheel-zoom sensitivity for the napari-js backend, as `exp(-delta * speed)` per normalized,
|
|
26727
27538
|
* clamped wheel-delta unit.
|
|
@@ -28472,6 +29283,8 @@ class NapariVisualizerService extends BaseStoreVisualizer {
|
|
|
28472
29283
|
this.spatialTilesMgr = null;
|
|
28473
29284
|
/** For the panel: the transcripts-in-view estimate and the density window in use. */
|
|
28474
29285
|
this.transcriptEstimate$ = new BehaviorSubject(null);
|
|
29286
|
+
/** Transcripts of each selected gene in view (see NapariSpatialTileLayers.geneCountsIn). */
|
|
29287
|
+
this.geneCountsInView$ = new BehaviorSubject(null);
|
|
28475
29288
|
this.densityStats$ = new BehaviorSubject(null);
|
|
28476
29289
|
/** Latest (dataset, view, selection) the spatial subscription saw, so a slice
|
|
28477
29290
|
* change can rebuild the markers for the new plane. */
|
|
@@ -28483,6 +29296,10 @@ class NapariVisualizerService extends BaseStoreVisualizer {
|
|
|
28483
29296
|
* round-trip, so a fast sequence of colour-by changes can resolve out of
|
|
28484
29297
|
* order. Only the newest rebuild is allowed to touch the layer. */
|
|
28485
29298
|
this.spatialRebuildToken = 0;
|
|
29299
|
+
/** "x reloading…" at the bottom of the canvas: the tile layers' loads and the observations'. */
|
|
29300
|
+
this.loadingBadge = null;
|
|
29301
|
+
this.tileLoading = [];
|
|
29302
|
+
this.observationsLoading = 0;
|
|
28486
29303
|
/** Monotonic load generation. Bumped by {@link reset} and {@link cancelLoading}; the frame-loading
|
|
28487
29304
|
* loops (volume assembly, surface preload) capture it and bail when it changes, so a Cancel (or a
|
|
28488
29305
|
* new plot) actually stops fetching frames instead of running to completion in the background. */
|
|
@@ -29279,6 +30096,12 @@ class NapariVisualizerService extends BaseStoreVisualizer {
|
|
|
29279
30096
|
* The overview navigator for a 2D view with an image: a coarse thumbnail of the whole
|
|
29280
30097
|
* image with the viewport on it; click or drag to pan at the current zoom (as OSD).
|
|
29281
30098
|
*/
|
|
30099
|
+
showLoading() {
|
|
30100
|
+
if (!this.host)
|
|
30101
|
+
return;
|
|
30102
|
+
this.loadingBadge ??= new NapariLoadingBadge(this.host);
|
|
30103
|
+
this.loadingBadge.set([...(this.observationsLoading > 0 ? ['Observations'] : []), ...this.tileLoading]);
|
|
30104
|
+
}
|
|
29282
30105
|
installNavigator(z) {
|
|
29283
30106
|
this.navigator?.destroy();
|
|
29284
30107
|
this.navigator = null;
|
|
@@ -29794,6 +30617,11 @@ class NapariVisualizerService extends BaseStoreVisualizer {
|
|
|
29794
30617
|
return spatialContinuousLut(node?.data?.value, this.currentReverse, view.continuousColormap);
|
|
29795
30618
|
},
|
|
29796
30619
|
estimateChanged: (e) => this.transcriptEstimate$.next(e),
|
|
30620
|
+
geneCountsChanged: (c) => this.geneCountsInView$.next(c),
|
|
30621
|
+
loadingChanged: (layers) => {
|
|
30622
|
+
this.tileLoading = layers;
|
|
30623
|
+
this.showLoading();
|
|
30624
|
+
},
|
|
29797
30625
|
densityChanged: (d) => this.densityStats$.next(d),
|
|
29798
30626
|
polygonsShownChanged: () => {
|
|
29799
30627
|
if (this.spatialPoints)
|
|
@@ -30290,6 +31118,8 @@ class NapariVisualizerService extends BaseStoreVisualizer {
|
|
|
30290
31118
|
// Resolve colours BEFORE touching the scene: a gene fetch can fail or be
|
|
30291
31119
|
// superseded, and dropping the existing layer first would blank the view.
|
|
30292
31120
|
let faceColor;
|
|
31121
|
+
this.observationsLoading++;
|
|
31122
|
+
this.showLoading();
|
|
30293
31123
|
try {
|
|
30294
31124
|
faceColor = dataset
|
|
30295
31125
|
? await this.spatialFaceColors(dataset, view, selection)
|
|
@@ -30299,6 +31129,10 @@ class NapariVisualizerService extends BaseStoreVisualizer {
|
|
|
30299
31129
|
console.warn('[napari-js] spatial colouring failed — falling back to a flat colour', err);
|
|
30300
31130
|
faceColor = NapariVisualizerService.SPATIAL_NEUTRAL_COLOR;
|
|
30301
31131
|
}
|
|
31132
|
+
finally {
|
|
31133
|
+
this.observationsLoading--;
|
|
31134
|
+
this.showLoading();
|
|
31135
|
+
}
|
|
30302
31136
|
// A newer rebuild (or a teardown) started while the vector was in flight.
|
|
30303
31137
|
if (token !== this.spatialRebuildToken || this.viewer !== viewer)
|
|
30304
31138
|
return;
|
|
@@ -30725,6 +31559,8 @@ class NapariVisualizerService extends BaseStoreVisualizer {
|
|
|
30725
31559
|
// layer first would blank the view.
|
|
30726
31560
|
let enc = null;
|
|
30727
31561
|
if (dataset) {
|
|
31562
|
+
this.observationsLoading++;
|
|
31563
|
+
this.showLoading();
|
|
30728
31564
|
try {
|
|
30729
31565
|
enc = await this.spatialScalar3d(dataset, view);
|
|
30730
31566
|
}
|
|
@@ -30732,6 +31568,10 @@ class NapariVisualizerService extends BaseStoreVisualizer {
|
|
|
30732
31568
|
console.warn('[napari-js] spatial 3D colouring failed — falling back to a flat colour', err);
|
|
30733
31569
|
enc = null;
|
|
30734
31570
|
}
|
|
31571
|
+
finally {
|
|
31572
|
+
this.observationsLoading--;
|
|
31573
|
+
this.showLoading();
|
|
31574
|
+
}
|
|
30735
31575
|
}
|
|
30736
31576
|
if (token !== this.spatialRebuildToken || this.viewer !== viewer)
|
|
30737
31577
|
return;
|
|
@@ -31798,6 +32638,10 @@ class NapariVisualizerService extends BaseStoreVisualizer {
|
|
|
31798
32638
|
this.scaleBar = null;
|
|
31799
32639
|
this.navigator?.destroy();
|
|
31800
32640
|
this.navigator = null;
|
|
32641
|
+
this.loadingBadge?.destroy();
|
|
32642
|
+
this.loadingBadge = null;
|
|
32643
|
+
this.tileLoading = [];
|
|
32644
|
+
this.observationsLoading = 0;
|
|
31801
32645
|
this.regionOverlay?.destroy();
|
|
31802
32646
|
this.regionOverlay = null;
|
|
31803
32647
|
this.axesLabels?.destroy();
|
|
@@ -33122,7 +33966,11 @@ class RoutingVisualizerService {
|
|
|
33122
33966
|
...(port.getTranscriptCounts ? {
|
|
33123
33967
|
transcriptCounts: (genes) => port.getTranscriptCounts(genes),
|
|
33124
33968
|
} : {}),
|
|
33969
|
+
...(port.getMarkerGenes ? {
|
|
33970
|
+
markerGenes: (column, perGroup) => port.getMarkerGenes(column, perGroup),
|
|
33971
|
+
} : {}),
|
|
33125
33972
|
getTranscriptEstimate$: () => this.napari.transcriptEstimate$.asObservable(),
|
|
33973
|
+
getGeneCountsInView$: () => this.napari.geneCountsInView$.asObservable(),
|
|
33126
33974
|
getDensityStats$: () => this.napari.densityStats$.asObservable(),
|
|
33127
33975
|
categoryColors: async (name) => {
|
|
33128
33976
|
const column = await port.getColumn(name);
|