@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.
Files changed (29) hide show
  1. package/esm2022/lib/contracts/display-types.mjs +1 -1
  2. package/esm2022/lib/contracts/plot-type-contribution.contract.mjs +1 -1
  3. package/esm2022/lib/contracts/ports/spatial-data.port.mjs +1 -1
  4. package/esm2022/lib/contracts/spatial-dataset.contract.mjs +1 -1
  5. package/esm2022/lib/contracts/visualizer.contract.mjs +1 -1
  6. package/esm2022/lib/implementations/napari-js/napari-loading-badge.mjs +73 -0
  7. package/esm2022/lib/implementations/napari-js/napari-spatial-tiles.mjs +418 -29
  8. package/esm2022/lib/implementations/napari-js/napari-visualizer.service.mjs +35 -1
  9. package/esm2022/lib/implementations/osd/openseadragon-visualizer.service.mjs +8 -1
  10. package/esm2022/lib/implementations/spatial-data-http/spatial-data-http.service.mjs +12 -1
  11. package/esm2022/lib/implementations/spatial-data-http/spatial-wire.mjs +2 -1
  12. package/esm2022/lib/routing-visualizer.service.mjs +5 -1
  13. package/esm2022/lib/spatial/spatial-tiles.mjs +144 -1
  14. package/esm2022/lib/spatial-controls/spatial-controls.component.mjs +198 -9
  15. package/fesm2022/jax-data-science-sci-image-visualizer.mjs +878 -30
  16. package/fesm2022/jax-data-science-sci-image-visualizer.mjs.map +1 -1
  17. package/lib/contracts/display-types.d.ts +4 -2
  18. package/lib/contracts/plot-type-contribution.contract.d.ts +9 -0
  19. package/lib/contracts/ports/spatial-data.port.d.ts +12 -1
  20. package/lib/contracts/spatial-dataset.contract.d.ts +30 -0
  21. package/lib/contracts/visualizer.contract.d.ts +7 -1
  22. package/lib/implementations/napari-js/napari-loading-badge.d.ts +20 -0
  23. package/lib/implementations/napari-js/napari-spatial-tiles.d.ts +76 -1
  24. package/lib/implementations/napari-js/napari-visualizer.service.d.ts +7 -0
  25. package/lib/implementations/spatial-data-http/spatial-data-http.service.d.ts +3 -1
  26. package/lib/implementations/spatial-data-http/spatial-wire.d.ts +2 -1
  27. package/lib/spatial/spatial-tiles.d.ts +38 -1
  28. package/lib/spatial-controls/spatial-controls.component.d.ts +54 -0
  29. 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 geometry = await this.cellGeometry(dataset, view, rect, level, hidden, stale);
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') && this.port.getTranscriptTile
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
- const { merged, px } = filterTranscripts(loaded.merged, loaded.px, hidden, (loaded.kind ?? job.kind) === 'genes' ? hiddenGeneSlots(view) : null);
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 === 'gene'
25833
- ? { ...view, transcriptColorBy: 'cellType' } : view;
25834
- const faces = await this.transcriptColors(dataset, colorView, merged);
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, bin: loaded.kind ? loaded.bin : job.bin,
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
- key: `genes|${genes.join(',')}|${view.transcriptQuality}|${view.transcriptBudget}|${keysAt(first).map(tileId)}`,
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
- let level = first;
26051
- let merged;
26052
- for (;;) {
26053
- const tiles = await this.fetchAll(keysAt(level), (k) => this.port.getTranscriptTile(k.level, k.gx, k.gy, query));
26054
- merged = mergeTranscriptTiles(tiles, MAX_TRANSCRIPTS);
26055
- if (merged.count <= view.transcriptBudget || level >= meta.levels.length - 1)
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 { merged, px };
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 === 'gene') {
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);