@jax-data-science/sci-image-visualizer 0.7.0 → 0.8.1

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 (34) 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 +49 -6
  9. package/esm2022/lib/implementations/osd/openseadragon-visualizer.service.mjs +8 -1
  10. package/esm2022/lib/implementations/plotly/plotly.service.mjs +7 -1
  11. package/esm2022/lib/implementations/spatial-data-http/spatial-data-http.service.mjs +12 -1
  12. package/esm2022/lib/implementations/spatial-data-http/spatial-wire.mjs +2 -1
  13. package/esm2022/lib/routing-visualizer.service.mjs +5 -1
  14. package/esm2022/lib/spatial/spatial-tiles.mjs +144 -1
  15. package/esm2022/lib/spatial-controls/spatial-controls.component.mjs +198 -9
  16. package/esm2022/lib/toolbar/toolbar.component.mjs +11 -3
  17. package/esm2022/lib/visualizer.component.mjs +58 -4
  18. package/fesm2022/jax-data-science-sci-image-visualizer.mjs +964 -39
  19. package/fesm2022/jax-data-science-sci-image-visualizer.mjs.map +1 -1
  20. package/lib/contracts/display-types.d.ts +4 -2
  21. package/lib/contracts/plot-type-contribution.contract.d.ts +9 -0
  22. package/lib/contracts/ports/spatial-data.port.d.ts +12 -1
  23. package/lib/contracts/spatial-dataset.contract.d.ts +30 -0
  24. package/lib/contracts/visualizer.contract.d.ts +7 -1
  25. package/lib/implementations/napari-js/napari-loading-badge.d.ts +20 -0
  26. package/lib/implementations/napari-js/napari-spatial-tiles.d.ts +76 -1
  27. package/lib/implementations/napari-js/napari-visualizer.service.d.ts +7 -0
  28. package/lib/implementations/spatial-data-http/spatial-data-http.service.d.ts +3 -1
  29. package/lib/implementations/spatial-data-http/spatial-wire.d.ts +2 -1
  30. package/lib/spatial/spatial-tiles.d.ts +38 -1
  31. package/lib/spatial-controls/spatial-controls.component.d.ts +54 -0
  32. package/lib/toolbar/toolbar.component.d.ts +5 -1
  33. package/lib/visualizer.component.d.ts +10 -1
  34. 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(',')}`);
@@ -6633,6 +6645,8 @@ const MODEL_INFO = {
6633
6645
  */
6634
6646
  class ToolbarComponent {
6635
6647
  constructor() {
6648
+ /** A spatial dataset is on offer: its plot modes are a choice even with no image loaded. */
6649
+ this.hasSpatialDataset = false;
6636
6650
  /** Plot types the active backend advertises, then any contributed modes. */
6637
6651
  this.plotTypeOptions = [];
6638
6652
  /** The selector's value: a built-in type or a contributed mode's id. */
@@ -6791,6 +6805,10 @@ class ToolbarComponent {
6791
6805
  */
6792
6806
  this.toolMenuItems = {};
6793
6807
  }
6808
+ /** Whether the plot-type dropdown shows: for an image, or a spatial dataset with none. */
6809
+ get showPlotTypes() {
6810
+ return !!(this.imageInfo || this.hasSpatialDataset) && !!this.tools.specialTools;
6811
+ }
6794
6812
  /** Rebuild the SAM model menu when the model list or active selection
6795
6813
  * changes (keeps the array reference stable across other CD ticks). */
6796
6814
  ngOnChanges(changes) {
@@ -6915,13 +6933,15 @@ class ToolbarComponent {
6915
6933
  this.displayHelpDialog = true;
6916
6934
  }
6917
6935
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.9", ngImport: i0, type: ToolbarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
6918
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.9", type: ToolbarComponent, selector: "plotting-toolbar", inputs: { imageInfo: "imageInfo", plotTypeOptions: "plotTypeOptions", selectedPlotType: "selectedPlotType", basePlotType: "basePlotType", isoRange: "isoRange", maxIndex: "maxIndex", zIndex: "zIndex", isHeatmap: "isHeatmap", is3dRegions: "is3dRegions", activeDragMode: "activeDragMode", activeSurface3dMode: "activeSurface3dMode", axesVisible: "axesVisible", wireframeActive: "wireframeActive", resolutionScale: "resolutionScale", wandSensitivity: "wandSensitivity", brushSize: "brushSize", vertexEraserRadius: "vertexEraserRadius", canUndo: "canUndo", canRedo: "canRedo", stackOptions: "stackOptions", tools: "tools", imageSmoothingEnabled: "imageSmoothingEnabled", samModels: "samModels", samModelId: "samModelId", contributedTools: "contributedTools", dialogTools: "dialogTools", openDialogToolId: "openDialogToolId", toolModelIds: "toolModelIds" }, outputs: { selectPlotType: "selectPlotType", addProfileLine: "addProfileLine", toggleImageSmoothing: "toggleImageSmoothing", isoRangeChange: "isoRangeChange", openChannelHistogram: "openChannelHistogram", openSpatialControls: "openSpatialControls", openRegionEditor: "openRegionEditor", selectStackOption: "selectStackOption", zScrub: "zScrub", zSlide: "zSlide", zIndexInput: "zIndexInput", reloadAndPlot: "reloadAndPlot", downloadImage: "downloadImage", autoscaleImage: "autoscaleImage", toggleDragMode: "toggleDragMode", zoomIn: "zoomIn", zoomOut: "zoomOut", toggleSurface3dMode: "toggleSurface3dMode", resetSurfaceCamera: "resetSurfaceCamera", toggleAxes: "toggleAxes", toggleWireframe: "toggleWireframe", selectResolution: "selectResolution", deleteRegion: "deleteRegion", undoRegion: "undoRegion", redoRegion: "redoRegion", segmentRegions: "segmentRegions", segmentCellpose: "segmentCellpose", runTool: "runTool", toggleDialogTool: "toggleDialogTool", toolModelChange: "toolModelChange", openToolParams: "openToolParams", samModelChange: "samModelChange", wandSensitivityChange: "wandSensitivityChange", brushSizeChange: "brushSizeChange", vertexEraserRadiusChange: "vertexEraserRadiusChange" }, usesOnChanges: true, ngImport: i0, template: "<p-toolbar>\n <div class=\"p-toolbar-group-start flex-wrap align-items-center gap-1\">\n\n <!-- Host-provided custom toolbar buttons, shown first (e.g. app-specific\n actions the visualization view itself doesn't own). -->\n <ng-content></ng-content>\n\n <!-- Contributed dialog tools (TOOLBAR_TOOLS, kind: 'dialog'): app-level actions\n like the host's own buttons above, so they sit right beside them. Image view\n only, the view their dialog draws over. Highlighted while the dialog is open. -->\n <ng-container *ngIf=\"isImageView\">\n <p-button *ngFor=\"let tool of dialogTools; trackBy: trackToolById\"\n size=\"small\" [text]=\"openDialogToolId !== tool.id\"\n [pTooltip]=\"tool.tooltip\" tooltipPosition=\"bottom\"\n [ariaLabel]=\"tool.label\"\n [attr.aria-pressed]=\"openDialogToolId === tool.id\"\n (onClick)=\"toggleDialogTool.emit(tool.id)\">\n <img *ngIf=\"tool.icon.src\" [src]=\"tool.icon.src\" style=\"width: 1rem\" [alt]=\"tool.label\"\n [class]=\"openDialogToolId === tool.id ? 'toolbar-icon-active' : 'toolbar-icon-dark'\" />\n <i *ngIf=\"tool.icon.pi\" class=\"pi {{ tool.icon.pi }}\"></i>\n </p-button>\n </ng-container>\n\n <!-- Plot type: available for any image (grayscale or RGB). -->\n <ng-container *ngIf=\"imageInfo && tools.specialTools\">\n <span class=\"toolbar-separator\"></span>\n <p-dropdown [options]=\"plotTypeOptions\" optionLabel=\"label\" optionValue=\"type\"\n [(ngModel)]=\"selectedPlotType\" appendTo=\"body\"\n pTooltip=\"Select plot type\" tooltipPosition=\"bottom\"\n [style]=\"{'min-width': '11rem'}\"\n styleClass=\"plotmode-dropdown\" panelStyleClass=\"plotmode-panel\"\n (onChange)=\"selectPlotType.emit($event.value)\">\n <ng-template let-opt pTemplate=\"selectedItem\">\n <span class=\"plotmode-item\" *ngIf=\"opt\">\n <i *ngIf=\"isPiIcon(opt.icon)\" [class]=\"opt.icon\"></i>\n <img *ngIf=\"opt.icon && !isPiIcon(opt.icon)\" class=\"plotmode-icon-img\" [src]=\"opt.icon\" alt=\"\" />\n <span>{{ opt.label }}</span>\n </span>\n </ng-template>\n <ng-template let-opt pTemplate=\"item\">\n <span class=\"plotmode-item\">\n <i *ngIf=\"isPiIcon(opt.icon)\" [class]=\"opt.icon\"></i>\n <img *ngIf=\"opt.icon && !isPiIcon(opt.icon)\" class=\"plotmode-icon-img\" [src]=\"opt.icon\" alt=\"\" />\n <span>{{ opt.label }}</span>\n </span>\n </ng-template>\n </p-dropdown>\n <ng-container *ngIf=\"isIsosurfaceMode\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Iso</span>\n <p-slider [(ngModel)]=\"isoRange\" [range]=\"true\"\n [min]=\"isoValueMin\" [max]=\"isoValueMax\" [step]=\"isoValueStep\"\n (onChange)=\"isoRangeChange.emit($event.values)\"\n pTooltip=\"Isosurface band (0\u2013255), mapped onto the volume's\n actual intensity range. Surfaces are drawn for voxels whose\n intensity falls in the selected band \u2014 narrow it to isolate\n features, widen it to see the whole structure.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n styleClass=\"wand-sensitivity-slider\"></p-slider>\n <span class=\"flex align-items-center mx-1 toolbar-text wand-sensitivity-value\">\n {{ isoRange[0] }}\u2013{{ isoRange[1] }}</span>\n </ng-container>\n <!-- Intensity tools (Heatmap / Image mode): add another colored line ROI.\n Each added line gets the next bright palette colour and its own inset\n trace; select a line on the image and use the trash tool to remove it\n (and its trace). The lines are Region-based but excluded from the\n Region Editor and exports. -->\n <ng-container *ngIf=\"isIntensityCapable\">\n <span class=\"toolbar-separator\"></span>\n <p-button size=\"small\" [text]=\"true\"\n pTooltip=\"Add an intensity line (new colour). Click a line to select it,\n then use the trash tool to remove it.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"addProfileLine.emit()\">\n <img src=\"assets/plotting/spline-chart.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n </ng-container>\n </ng-container>\n <!-- Channels & Histogram: brightness/contrast, gamma, per-channel display,\n colormap/reverse/invert \u2014 opens the floating dialog. -->\n <p-button *ngIf=\"tools.specialTools\" pRipple size=\"small\" [text]=\"true\"\n pTooltip=\"Channels &amp; Histogram (brightness/contrast, colormap)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"openChannelHistogram.emit()\">\n <img src=\"assets/plotting/invert.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n\n <!-- Spatial-omics controls. Only meaningful in the spatial mode, which is\n itself only offered while a dataset is loaded \u2014 so this button appears\n exactly when there is something for it to control. -->\n <p-button *ngIf=\"tools.specialTools && isSpatialMode\" pRipple size=\"small\" [text]=\"true\"\n pTooltip=\"Spatial omics (colour by column or gene, legend, selection, distributions)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"openSpatialControls.emit()\">\n <img src=\"assets/plotting/spatial-omics.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n\n <!-- Smoothen toggle: bilinear smoothing vs nearest-neighbour raw pixels (Image\n view only \u2014 it's an OpenSeadragon drawer setting). Highlighted when smoothing on. -->\n <p-button *ngIf=\"tools.specialTools && isImageView\" size=\"small\"\n [text]=\"!imageSmoothingEnabled\"\n pTooltip=\"Smoothen (turn on image smoothing)\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleImageSmoothing.emit()\">\n <img src=\"assets/plotting/smooth.svg\" style=\"width: 1rem\"\n [class]=\"imageSmoothingEnabled ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"/>\n </p-button>\n\n <!-- Stack controls -->\n <ng-container *ngIf=\"imageInfo?.isStack\">\n <span class=\"toolbar-separator\"></span>\n <!-- Image view: scrub z-slices live (the renderer swaps slices in place). -->\n <ng-container *ngIf=\"showsLiveSliceScrubber\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Slice</span>\n <p-slider [(ngModel)]=\"zIndex\" [min]=\"0\" [max]=\"maxIndex\"\n (onChange)=\"zScrub.emit($event.value)\" (onSlideEnd)=\"zSlide.emit($event.value)\"\n pTooltip=\"Slice through the stack (\u2190 / \u2192 keys)\" tooltipPosition=\"bottom\"\n [style]=\"{'width':'16rem'}\" styleClass=\"wand-sensitivity-slider\"></p-slider>\n <span class=\"flex align-items-center mx-1 toolbar-text\">{{zIndex}} of {{maxIndex}}</span>\n </ng-container>\n <!-- Heatmap view: single-image (high-def zoom) vs stack (frame slider). Plotly-only \u2014\n hidden for napari, which has no single/stack toggle or per-frame fetch, and for the\n spatial-omics modes: a 3D omics dataset publishes its reference volume as a z-stack\n image, and these modes draw observations, not one of its slices. -->\n <ng-container *ngIf=\"!showsLiveSliceScrubber && !isNapariMode && !isSpatialMode\">\n <p-button icon=\"pi pi-image\" size=\"small\"\n [text]=\"imageInfo?.showStack\"\n pTooltip=\"Single image mode: enables high-def zoom\" tooltipPosition=\"bottom\"\n (onClick)=\"selectStackOption.emit(stackOptions[0])\">\n </p-button>\n <p-button icon=\"pi pi-images\" size=\"small\"\n [text]=\"!imageInfo?.showStack\"\n pTooltip=\"Stack mode, enables slicing through image frames\" tooltipPosition=\"bottom\"\n (onClick)=\"selectStackOption.emit(stackOptions[1])\">\n </p-button>\n <ng-container *ngIf=\"!imageInfo?.showStack\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Slice</span>\n <div class=\"p-inputgroup\" style=\"width: auto\">\n <p-inputNumber styleClass=\"w-3rem toolbar-input\"\n\t\t\t\t\tpTooltip=\"View image in stack (&#x23CE;)\"\n\t\t\t\t\t[(ngModel)]=\"zIndex\" [min]=\"0\" [max]=\"maxIndex\"\n (onInput)=\"zIndexInput.emit(zIndex)\" (keydown.enter)=\"reloadAndPlot.emit()\"/>\n <p-button icon=\"pi pi-refresh\" size=\"small\" [text]=\"true\" (onClick)=\"reloadAndPlot.emit()\"\n pTooltip=\"Plot this slice\" tooltipPosition=\"bottom\">\n </p-button>\n </div>\n <span class=\"flex align-items-center mx-1 toolbar-text\">of {{maxIndex}}</span>\n </ng-container>\n </ng-container>\n </ng-container>\n\n <!-- Image actions (special tools) -->\n <ng-container *ngIf=\"tools.specialTools\">\n <span class=\"toolbar-separator\"></span>\n <p-button icon=\"pi pi-download\" size=\"small\" [text]=\"true\"\n pTooltip=\"Download image as PNG\" tooltipPosition=\"bottom\"\n (onClick)=\"downloadImage.emit()\">\n </p-button>\n <p-button *ngIf=\"isHeatmap\" icon=\"pi pi-window-maximize\" size=\"small\" [text]=\"true\"\n pTooltip=\"Fit to view\" tooltipPosition=\"bottom\"\n (onClick)=\"autoscaleImage.emit()\">\n </p-button>\n </ng-container>\n\n <!-- Zoom tools. Plotly rubber-band drag-zoom: on OpenSeadragon the box-zoom\n covers this and a bare drag pans natively, so it's hidden in image view. -->\n <ng-container *ngIf=\"isHeatmap && tools.zoomTools\">\n <span class=\"toolbar-separator\"></span>\n <p-button *ngIf=\"!isImageView\" icon=\"pi pi-search\" size=\"small\"\n [text]=\"activeDragMode !== 'zoom'\"\n pTooltip=\"Zoom drag\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('zoom')\">\n </p-button>\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'zoomToBox'\"\n pTooltip=\"Zoom box (<kbd>b</kbd>)\" [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('zoomToBox')\">\n <img src=\"assets/plotting/zoom-box.svg\"\n [class]=\"activeDragMode === 'zoomToBox' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <p-button icon=\"pi pi-arrows-alt\" size=\"small\"\n [text]=\"activeDragMode !== 'pan'\"\n pTooltip=\"Pan the image (<kbd>p</kbd>)\" [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('pan')\">\n </p-button>\n <p-button icon=\"pi pi-search-plus\" size=\"small\" [text]=\"true\"\n pTooltip=\"Zoom in (<kbd>+</kbd>)\" [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"zoomIn.emit()\">\n </p-button>\n <p-button icon=\"pi pi-search-minus\" size=\"small\" [text]=\"true\"\n pTooltip=\"Zoom out (<kbd>-</kbd>)\" [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"zoomOut.emit()\">\n </p-button>\n </ng-container>\n\n <!-- Surface 3D controls -->\n <ng-container *ngIf=\"!isHeatmap\">\n <span class=\"toolbar-separator\"></span>\n <p-button icon=\"pi pi-search\" size=\"small\"\n [text]=\"activeSurface3dMode !== 'zoom'\"\n pTooltip=\"Zoom\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleSurface3dMode.emit('zoom')\">\n </p-button>\n <p-button icon=\"pi pi-arrows-alt\" size=\"small\"\n [text]=\"activeSurface3dMode !== 'pan'\"\n pTooltip=\"Pan\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleSurface3dMode.emit('pan')\">\n </p-button>\n <p-button icon=\"pi pi-globe\" size=\"small\"\n [text]=\"activeSurface3dMode !== 'orbit'\"\n pTooltip=\"Orbital rotation\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleSurface3dMode.emit('orbit')\">\n </p-button>\n <p-button icon=\"pi pi-sync\" size=\"small\"\n [text]=\"activeSurface3dMode !== 'turntable'\"\n pTooltip=\"Turntable rotation\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleSurface3dMode.emit('turntable')\">\n </p-button>\n <p-button icon=\"pi pi-home\" size=\"small\" [text]=\"true\"\n pTooltip=\"Reset camera to default\" tooltipPosition=\"bottom\"\n (onClick)=\"resetSurfaceCamera.emit()\">\n </p-button>\n <!-- 3D coordinate-axes / scale gizmo toggle (napari-js WebGPU volume/isosurface only).\n `p-button-icon-only` gives the same square sizing as the neighbouring pi-icon buttons\n (the projected <img> doesn't trigger PrimeNG's automatic icon-only sizing). -->\n <p-button size=\"small\" *ngIf=\"isNapariMode\" styleClass=\"p-button-icon-only\"\n [text]=\"!axesVisible\"\n pTooltip=\"Toggle 3D axes / scale\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleAxes.emit()\">\n <img src=\"assets/plotting/3d-coordinates.svg\"\n [class]=\"axesVisible ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <!-- Surface wireframe toggle (napari-js WebGPU surface only). -->\n <p-button icon=\"pi pi-share-alt\" size=\"small\" *ngIf=\"isNapariSurfaceMode\"\n [text]=\"!wireframeActive\"\n pTooltip=\"Toggle wireframe\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleWireframe.emit()\">\n </p-button>\n <!-- Resolution (decimate factor) for the napari 3D types \u2014 reloads at coarser/finer sampling. -->\n <ng-container *ngIf=\"isNapari3dMode\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Res</span>\n <p-dropdown [options]=\"decimateOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"resolutionScale\" appendTo=\"body\"\n pTooltip=\"Resolution (decimate factor)\" tooltipPosition=\"bottom\"\n [style]=\"{'min-width': '6rem'}\"\n (onChange)=\"selectResolution.emit($event.value)\">\n </p-dropdown>\n </ng-container>\n </ng-container>\n\n <ng-container *ngIf=\"(isHeatmap || is3dRegions) && tools.regionTools\">\n <span class=\"toolbar-separator\"></span>\n <!-- Region Editor: opens the editor as a floating dialog (formerly the\n right-panel Regions tab). Sits with the region drawing tools. -->\n <p-button size=\"small\" [text]=\"true\"\n pTooltip=\"Open the Region Editor\" tooltipPosition=\"bottom\"\n (onClick)=\"openRegionEditor.emit()\">\n <img src=\"assets/plotting/region-editor.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n <!-- Region drawing tools -->\n <p-button icon=\"pi pi-arrow-up-right\" size=\"small\"\n [text]=\"activeDragMode !== 'select'\"\n pTooltip=\"Selection mode (<kbd>s</kbd>)\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('select')\">\n </p-button>\n <p-button icon=\"pi pi-stop\" size=\"small\"\n [text]=\"activeDragMode !== 'drawrect'\"\n pTooltip=\"Draw a rectangular region (<kbd>r</kbd>)\" [escape]=\"false\"\n tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('drawrect')\">\n </p-button>\n <p-button *ngIf=\"!is3dRegions\" size=\"small\"\n [text]=\"activeDragMode !== 'drawopenpath'\"\n pTooltip=\"Draw a polyline (<kbd>l</kbd>)\" [escape]=\"false\"\n tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('drawopenpath')\">\n <img src=\"assets/plotting/polyline.svg\"\n [class]=\"activeDragMode === 'drawopenpath' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <p-button icon=\"pi pi-pencil\" size=\"small\"\n [text]=\"activeDragMode !== 'drawclosedpath'\"\n pTooltip=\"Draw a freeform region (<kbd>f</kbd>)\" [escape]=\"false\"\n tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('drawclosedpath')\">\n </p-button>\n <!-- Brush: paint a region by dragging a disc of the given size; the Size\n slider appears while it's active. Hold Shift to erase (subtract). -->\n <!-- Brush and polyline are absent from a 3D cloud: the brush paints in IMAGE\n pixels and there is no raster to sample, and an open polyline is a\n profile tool, not a closed region a selection can test against. -->\n <p-button *ngIf=\"!is3dRegions\" size=\"small\"\n [text]=\"activeDragMode !== 'brush'\"\n pTooltip=\"Brush: click and drag to paint a region.\n <br>Hold <kbd>Shift</kbd> while painting to erase from a region.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('brush')\">\n <img src=\"assets/plotting/paintbrush.svg\"\n [class]=\"activeDragMode === 'brush' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <ng-container *ngIf=\"activeDragMode === 'brush'\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Size</span>\n <p-slider [(ngModel)]=\"brushSize\"\n [min]=\"brushSizeMin\" [max]=\"brushSizeMax\" [step]=\"brushSizeStep\"\n (onChange)=\"brushSizeChange.emit($event.value)\"\n pTooltip=\"Brush diameter in image pixels.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n styleClass=\"wand-sensitivity-slider\"></p-slider>\n <span class=\"flex align-items-center mx-1 toolbar-text wand-sensitivity-value\">\n {{ brushSize }}</span>\n </ng-container>\n\n <!-- Vertex editing \u2014 backends with a vertex-editing overlay (OSD + napari-js image). -->\n <ng-container *ngIf=\"supportsRegionVertexTools\">\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'drawpolygon'\"\n pTooltip=\"Polygon: click to place each vertex; click the first point to close\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('drawpolygon')\">\n <img src=\"assets/plotting/polygon-vertices.svg\"\n [class]=\"activeDragMode === 'drawpolygon' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'addpoint'\"\n pTooltip=\"Add a vertex: click an edge of the selected polygon\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('addpoint')\">\n <img src=\"assets/plotting/vertex-add.svg\"\n [class]=\"activeDragMode === 'addpoint' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'deletepoint'\"\n pTooltip=\"Delete a vertex: click a vertex of the selected polygon\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('deletepoint')\">\n <img src=\"assets/plotting/vertex-delete.svg\"\n [class]=\"activeDragMode === 'deletepoint' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n </ng-container>\n\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'wand'\"\n pTooltip=\"Wand: click and drag to grow a region by similar pixel values (<kbd>w</kbd>).\n <br>Hold <kbd>Shift</kbd> while clicking or dragging on a region to erase pixels from it.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('wand')\">\n <img src=\"assets/plotting/wand.svg\"\n [class]=\"activeDragMode === 'wand' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <ng-container *ngIf=\"activeDragMode === 'wand'\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Sens.</span>\n <p-slider [(ngModel)]=\"wandSensitivity\"\n [min]=\"wandSensitivityMin\" [max]=\"wandSensitivityMax\" [step]=\"wandSensitivityStep\"\n (onChange)=\"wandSensitivityChange.emit($event.value)\"\n pTooltip=\"Higher = stricter (smaller selection); lower = looser.\n Takes effect from the next click/drag tick.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n styleClass=\"wand-sensitivity-slider\"></p-slider>\n <span class=\"flex align-items-center mx-1 toolbar-text wand-sensitivity-value\">\n {{ wandSensitivity.toFixed(1) }}</span>\n </ng-container>\n <p-button icon=\"pi pi-eraser\" size=\"small\"\n [text]=\"activeDragMode !== 'eraseVertex'\"\n pTooltip=\"Vertex eraser (<kbd>e</kbd>): click or drag to remove polygon vertices inside\n the cursor circle from any region.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('eraseVertex')\">\n </p-button>\n <ng-container *ngIf=\"activeDragMode === 'eraseVertex'\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Radius</span>\n <p-slider [(ngModel)]=\"vertexEraserRadius\"\n [min]=\"vertexEraserRadiusMin\" [max]=\"vertexEraserRadiusMax\" [step]=\"vertexEraserRadiusStep\"\n (onChange)=\"vertexEraserRadiusChange.emit($event.value)\"\n pTooltip=\"Eraser radius in image pixels.\n Vertices within this distance of the cursor are removed.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n styleClass=\"wand-sensitivity-slider\"></p-slider>\n <span class=\"flex align-items-center mx-1 toolbar-text wand-sensitivity-value\">\n {{ vertexEraserRadius }}</span>\n </ng-container>\n <p-button size=\"small\" [text]=\"true\"\n [disabled]=\"!canUndo\"\n pTooltip=\"Undo the last region action (<kbd>Ctrl</kbd>+<kbd>Z</kbd>).\n Up to 10 actions can be undone.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"undoRegion.emit()\">\n <img src=\"assets/plotting/undo.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n <p-button size=\"small\" [text]=\"true\"\n [disabled]=\"!canRedo\"\n pTooltip=\"Redo the last undone region action\n (<kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd> / <kbd>Ctrl</kbd>+<kbd>Y</kbd>).\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"redoRegion.emit()\">\n <img src=\"assets/plotting/redo.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n <p-button icon=\"pi pi-trash\" size=\"small\" [text]=\"true\"\n pTooltip=\"Delete selected region (<kbd>Del</kbd>)\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"deleteRegion.emit()\">\n </p-button>\n <!-- SAM (jit-ui#90): box-prompt segment, interactive point prompts, and a\n model picker. -->\n <span class=\"toolbar-separator\"></span>\n <!-- SAM controls grouped: box-prompt Segment, then the point-prompt tool,\n then the model-picker dropdown (the chevron) which governs BOTH tools.\n Falls back to Segment + point with no model picker when none are set. -->\n <span *ngIf=\"samModels.length; else segmentPlain\" class=\"p-buttonset segment-split\">\n <p-button size=\"small\" [text]=\"true\"\n pTooltip=\"Segment the drawn rectangles with SAM (box prompt)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"segmentRegions.emit()\">\n <img src=\"assets/plotting/sam-box-prompt.svg\"\n class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n <ng-container [ngTemplateOutlet]=\"samPointBtn\"></ng-container>\n <p-button icon=\"pi pi-chevron-down\" size=\"small\" [text]=\"true\"\n pTooltip=\"Pick the SAM model (used by both segment tools)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"samModelMenu.toggle($event)\">\n </p-button>\n </span>\n <ng-template #segmentPlain>\n <span class=\"p-buttonset segment-split\">\n <p-button size=\"small\" [text]=\"true\"\n pTooltip=\"Segment the drawn rectangles with SAM (box prompt)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"segmentRegions.emit()\">\n <img src=\"assets/plotting/sam-box-prompt.svg\"\n class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n <ng-container [ngTemplateOutlet]=\"samPointBtn\"></ng-container>\n </span>\n </ng-template>\n <!-- Point-prompt tool \u2014 shared between the models / no-models layouts so it\n always sits just left of the model dropdown. -->\n <ng-template #samPointBtn>\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'samPoint'\"\n pTooltip=\"SAM point prompts: click a fiber to segment it as a new region;\n Shift/Alt-click to refine (exclude) the current one.<br>Esc undoes it.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('samPoint')\">\n <img src=\"assets/plotting/sam-point-prompt.svg\"\n [class]=\"activeDragMode === 'samPoint' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n </ng-template>\n <p-menu #samModelMenu styleClass=\"model-menu\" [model]=\"samMenuItems\" [popup]=\"true\" appendTo=\"body\">\n <ng-template pTemplate=\"item\" let-item>\n <ng-container [ngTemplateOutlet]=\"modelMenuItem\"\n [ngTemplateOutletContext]=\"{ $implicit: item }\"></ng-container>\n </ng-template>\n </p-menu>\n\n <!-- Shared item body for all three model pickers (SAM / YOLO / retinal):\n the standard check-or-blank icon and label, plus an info icon carrying\n the model's description as a hover tooltip. This replaced a single\n \"About the SAM models\" overlay panel \u2014 per-model text sits next to the\n model it describes, and YOLO/retinal get the same affordance.\n\n p-menu puts its click handler on the wrapping `.p-menuitem-content`,\n outside this template, so `command` still fires and the whole row stays\n clickable \u2014 which is also why the info icon has to stop propagation, or\n reading about a model would switch to it. -->\n <ng-template #modelMenuItem let-item>\n <a class=\"p-menuitem-link model-menu-item\">\n <span class=\"p-menuitem-icon\" [ngClass]=\"item.icon\"></span>\n <span class=\"p-menuitem-text\">{{ item.label }}</span>\n <!-- `bottom` like every other tooltip in this toolbar, and for room:\n these menus open under buttons that can sit near the right edge,\n where a `right` tooltip has a few hundred px and squeezes its text\n into a narrow column instead of using its max-width. -->\n <!-- A focusable <button>, not a bare <i>. As an <i> the description\n was reachable only by hovering a mouse: not tabbable, no\n accessible name, nothing for a screen reader to announce \u2014 so\n keyboard and AT users could pick a checkpoint but never read what\n it was. PrimeNG's tooltip shows on focus as well as hover, so\n making it a button restores it for free.\n\n `aria-describedby` is deliberately not used: the tooltip is\n rendered into `body` on demand and has no stable id to point at.\n The description is carried on `aria-label` instead, which is\n announced whether or not the tooltip is showing. It is stripped of\n markup first, since the copy contains `<b>`/`<br>` for the visual\n tooltip and those would otherwise be read out literally. -->\n <button *ngIf=\"item.tooltip\" type=\"button\"\n class=\"pi pi-info-circle model-info-icon\"\n [attr.aria-label]=\"'About ' + item.label + ': ' + plainText(item.tooltip)\"\n [pTooltip]=\"item.tooltip\" [escape]=\"false\" tooltipPosition=\"bottom\"\n appendTo=\"body\" tooltipStyleClass=\"model-info-tooltip\"\n (click)=\"$event.stopPropagation()\"\n (keydown.enter)=\"$event.stopPropagation()\"\n (keydown.space)=\"$event.stopPropagation()\"></button>\n </a>\n </ng-template>\n <p-button size=\"small\" [text]=\"true\"\n pTooltip=\"Auto-segment cells inside each drawn rectangle (cellpose-SAM)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"segmentCellpose.emit()\">\n <img src=\"assets/plotting/cells.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n\n <!-- Contributed no-prompt tools (TOOLBAR_TOOLS). Unlike the SAM and\n cellpose tools these take no prompt \u2014 they sweep the whole current\n view rather than being pointed at something \u2014 so there is nothing to\n draw first. Each renders the same split button: run, pick checkpoint,\n open parameters.\n\n Nothing here names a specific tool. The library ships none; a host\n registers them, which is what lets this package be published without\n the closed model dependencies. -->\n <ng-container *ngFor=\"let tool of contributedTools; trackBy: trackToolById\">\n <span class=\"p-buttonset segment-split\">\n <p-button size=\"small\" [text]=\"true\"\n [pTooltip]=\"tool.runTooltip\"\n tooltipPosition=\"bottom\"\n (onClick)=\"runTool.emit(tool.id)\">\n <!-- `toolbar-icon-dark` is hardcoded rather than bound, unlike the\n toggle tools: these fire a run and have no active state. -->\n <img *ngIf=\"tool.icon.src\" [src]=\"tool.icon.src\" class=\"toolbar-icon-dark\"\n style=\"width: 1rem\" [alt]=\"tool.label\" />\n <i *ngIf=\"tool.icon.pi\" class=\"pi {{ tool.icon.pi }}\"></i>\n </p-button>\n <p-button icon=\"pi pi-chevron-down\" size=\"small\" [text]=\"true\"\n [pTooltip]=\"tool.modelTooltip || 'Pick the model'\"\n tooltipPosition=\"bottom\"\n (onClick)=\"toolMenu.toggle($event)\">\n </p-button>\n <p-button *ngIf=\"tool.params.length\"\n icon=\"pi pi-sliders-h\" size=\"small\" [text]=\"true\"\n [pTooltip]=\"tool.paramsTooltip || 'Parameters'\"\n tooltipPosition=\"bottom\"\n (onClick)=\"openToolParams.emit(tool.id)\">\n </p-button>\n </span>\n <p-menu #toolMenu styleClass=\"model-menu\" [model]=\"toolMenuItems[tool.id] || []\"\n [popup]=\"true\" appendTo=\"body\">\n <ng-template pTemplate=\"item\" let-item>\n <ng-container [ngTemplateOutlet]=\"modelMenuItem\"\n [ngTemplateOutletContext]=\"{ $implicit: item }\"></ng-container>\n </ng-template>\n </p-menu>\n </ng-container>\n </ng-container>\n\n <ng-container *ngIf=\"tools.help\">\n <span class=\"toolbar-separator\"></span>\n\n <!-- Help -->\n <p-button icon=\"pi pi-question-circle\" size=\"small\" [text]=\"true\"\n pTooltip=\"Help\" tooltipPosition=\"bottom\"\n (onClick)=\"showHelp()\">\n </p-button>\n </ng-container>\n\n </div>\n</p-toolbar>\n<p-dialog header=\"Plotting help\" [(visible)]=\"displayHelpDialog\" [modal]=\"true\" [style]=\"{width: '50vw'}\"\n [maximizable]=\"true\" [draggable]=\"false\" [resizable]=\"false\">\n <p>Click a file in the left-hand tree to display it. By default it opens in the\n <b>Image</b> view \u2014 a natively tiled, deeply zoomable raster (OpenSeadragon) with a\n navigator minimap and a physical-units scale bar. <b>Click the image to zoom</b> toward\n the clicked point; scroll to zoom and drag to pan. When a stack is open, a slice slider\n (Image view) or image-mode toggle (other views) appears. Regions can also be edited in the\n <b>Region Editor</b>, opened with the\n <img src=\"assets/plotting/region-editor.svg\" width=\"14\" height=\"14\" alt=\"Region Editor\"/>\n toolbar button as a floating dialog. Drag the <i class=\"pi pi-bars\"></i> handle to detach\n this toolbar into a floating window.\n </p>\n\n <h3>Plot-type dropdown</h3>\n <p>\n The <b>plot-type dropdown</b> switches how the file is visualized:\n <b>Image</b>, <b>Heatmap</b>, <b>Contour</b>, <b>Surface</b>, <b>Volume</b>,\n <b>Isosurface</b>, and \u2014 when a spatial-omics dataset is open \u2014\n <b>Spatial omics</b> and <b>Spatial omics 3D</b>.\n </p>\n <p>\n <b>Only the modes the data can actually support are offered</b>, so the list changes\n with what is loaded rather than showing entries that would come up blank:\n </p>\n <ul>\n <li><b>Contour</b>, <b>Volume</b> and <b>Isosurface</b> need a grayscale image; the\n two 3D ones also need a z-stack.</li>\n <li><b>Spatial omics</b> needs a published dataset, and <b>Spatial omics 3D</b> needs\n one whose coordinates carry a z.</li>\n <li>The pixel modes (<b>Image</b>, <b>Heatmap</b>, <b>Surface</b>) are withheld for a\n dataset that brings no tissue image \u2014 seqFISH records coordinates and no section, so\n there is nothing for them to draw.</li>\n </ul>\n <ul>\n <li>\"<b>Isosurface band</b>\" slider (Isosurface): sets the intensity band the surfaces are\n drawn for, mapped onto the volume's real intensity range.</li>\n <li>\"<b>3D camera</b>\" (Surface / Scatter 3D / Isosurface): zoom <i class=\"pi pi-search\"></i>,\n pan <i class=\"pi pi-arrows-alt\"></i>, orbit <i class=\"pi pi-globe\"></i>,\n turntable <i class=\"pi pi-sync\"></i>, and reset camera <i class=\"pi pi-home\"></i>;\n scroll to zoom the 3D scene.</li>\n </ul>\n\n <h3>Image manipulation tools</h3>\n <ul>\n <li>\"<b>Region Editor</b>\" <img src=\"assets/plotting/region-editor.svg\" width=\"15\" height=\"15\">:\n opens the Region Editor as a floating, draggable dialog \u2014 a per-image table of every region\n (class, colour, area) with bulk select/label/colour/delete and GeoJSON import/export. It stays\n in sync with the image, so you can keep it open while drawing.</li>\n <li>\"<b>Intensity profile lines</b>\" <img src=\"assets/plotting/spline-chart.svg\" width=\"15\" height=\"15\">\n (<i>work in progress \u2014 not yet stable</i>): adds a coloured line ROI; a floating inset\n chart plots intensity along each line, updates live as you drag it, and re-samples at the\n current zoom resolution. Each added line gets its own colour; select a line and use the\n trash tool <i class=\"pi pi-trash\"></i> to remove it (and its trace).</li>\n <li>\"<b>Channels &amp; Histogram</b>\" <img src=\"assets/plotting/invert.svg\" width=\"15\" height=\"15\">:\n opens a floating dialog for brightness/contrast, gamma, and per-channel display, plus the\n <b>colormap / LUT</b> controls (grayscale only) \u2014 a <b>reverse</b> toggle and a colormap\n dropdown (default: inverted greys).</li>\n <li>\"<b>Smoothen</b>\" <img src=\"assets/plotting/smooth.svg\" width=\"15\" height=\"15\">\n (Image view only): the image opens showing <b>raw pixels</b> (nearest-neighbour), so\n zooming in renders crisp blocks for pixel inspection. Toggle this on for bilinear\n smoothing; the icon turns white while smoothing is active.</li>\n </ul>\n\n <h3>Download &amp; view tools</h3>\n <ul>\n <li>\"<b>Download</b>\" <i class=\"pi pi-download\"></i>: download the current image as a PNG.</li>\n <li>\"<b>Fit to view</b>\" / autoscale <i class=\"pi pi-window-maximize\"></i>: resets the image\n to its original dimensions. In stack mode it also resets to single-image mode.</li>\n </ul>\n\n <h3>Zoom tools</h3>\n <ul>\n <li>\"<b>Zoom box</b>\" <img src=\"assets/plotting/zoom-box.svg\" width=\"15\" height=\"15\">\n (<kbd>b</kbd>): drag a box to select an area to zoom into.</li>\n <li>\"<b>Pan</b>\" <i class=\"pi pi-arrows-alt\"></i> (<kbd>p</kbd>): drag to pan the image.</li>\n <li>\"<b>Zoom in</b>\" <i class=\"pi pi-search-plus\"></i> (<kbd>+</kbd>): zoom in one step.</li>\n <li>\"<b>Zoom out</b>\" <i class=\"pi pi-search-minus\"></i> (<kbd>-</kbd>): zoom out one step,\n stopping at the original image dimensions.</li>\n </ul>\n\n <h3>Region tools</h3>\n <p>By default no tool is selected (all greyed-out); selecting a tool highlights it. The\n vertex-editing tools (polygon, add/delete vertex, move, B\u00E9zier conversions) are available\n in the <b>Image</b> view only. On a <b>donut</b> (a region with a hole), the inner-ring\n vertices are editable too \u2014 drag, add, and delete them just like the outer ring.</p>\n <ul>\n <li>\"<b>Selection</b>\" <i class=\"pi pi-arrow-up-right\"></i> (<kbd>s</kbd>): a neutral mode\n that turns off whatever drawing tool was active (wand, vertex eraser, rectangle, etc.).\n Use it to step out of editing without picking another tool.</li>\n <li>\"<b>Rectangular region</b>\" <i class=\"pi pi-stop\"></i> (<kbd>r</kbd>): click and drag to\n create a rectangular region.</li>\n <li>\"<b>Polyline</b>\" <img src=\"assets/plotting/polyline.svg\" width=\"14\" height=\"14\">\n (<kbd>l</kbd>): draw an open (non-closed) polyline by clicking and dragging. Polylines can\n be edited and exported to GeoJSON as a <code>LineString</code> feature.</li>\n <li>\"<b>Freeform region</b>\" <i class=\"pi pi-pencil\"></i> (<kbd>f</kbd>): click and drag to\n draw a closed freeform polygon.</li>\n <li>\"<b>Brush</b>\" <img src=\"assets/plotting/paintbrush.svg\" width=\"15\" height=\"15\">: click\n and drag to paint a region with a circular brush \u2014 the painted stroke is the region.\n A <b>Size</b> slider (brush diameter, in image pixels) appears in the toolbar while the\n brush is active. Hold <kbd>Shift</kbd> while painting to <b>erase</b> (subtract) from a\n region instead. Painting over an existing region extends it; brushing across two regions\n merges them. Painting a closed ring (or shift-erasing an interior pocket) leaves the\n enclosed area empty \u2014 a <b>donut</b> (Image view).</li>\n <li>\"<b>Polygon</b>\" <img src=\"assets/plotting/polygon-vertices.svg\" width=\"15\" height=\"15\">:\n click to place each vertex; click the first point to close the polygon.</li>\n <li>\"<b>Add vertex</b>\" <img src=\"assets/plotting/vertex-add.svg\" width=\"15\" height=\"15\">:\n click an edge of the selected polygon to insert a new vertex.</li>\n <li>\"<b>Delete vertex</b>\" <img src=\"assets/plotting/vertex-delete.svg\" width=\"15\" height=\"15\">:\n click a vertex of the selected polygon to remove it.</li>\n <li>\"<b>Move region</b>\" <img src=\"assets/plotting/region-move.svg\" width=\"15\" height=\"15\">:\n drag to move the selected region.</li>\n <li>\"<b>To B\u00E9zier</b>\" <img src=\"assets/plotting/to-bezier.svg\" width=\"15\" height=\"15\"> /\n \"<b>To polygon</b>\" <img src=\"assets/plotting/to-polygon.svg\" width=\"15\" height=\"15\">:\n convert the selected region to a smooth B\u00E9zier curve, or back to a straight-edged polygon.</li>\n <li>\"<b>Wand</b>\" <img src=\"assets/plotting/wand.svg\" width=\"15\" height=\"15\"> (<kbd>w</kbd>):\n click to grow a region from pixels with similar values around the click point. Hold\n <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> for an exact-match flood fill; drag to keep extending the\n selection while moving; hold <kbd>Shift</kbd> while clicking or dragging on an existing\n region to <b>erase</b> pixels from it. Use the <b>Sens.</b> slider that appears to make the\n wand stricter (higher = smaller selection) or looser. Subsequent clicks keep extending the\n same region; clicking inside an existing region adopts it, and growing into another region\n merges them.</li>\n <li>\"<b>Vertex eraser</b>\" <i class=\"pi pi-eraser\"></i> (<kbd>e</kbd>): click or click-and-drag\n to remove polygon vertices that fall inside the cursor circle from any region, including a\n region's inner rings (donut holes). Use the <b>Radius</b> slider to set the eraser size in\n image pixels. Polygons reduced below 3 vertices (or polylines below 2) are deleted entirely;\n a hole reduced below 3 vertices is removed.</li>\n <li>\"<b>Undo</b>\" <img src=\"assets/plotting/undo.svg\" width=\"15\" height=\"15\">\n (<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Z</kbd>): reverts the\n last region action (draw, delete, edit, wand/brush stroke, segmentation, \u2026). Up to the last\n <b>10</b> actions can be undone; the button greys out when there's nothing left to undo.</li>\n <li>\"<b>Redo</b>\" <img src=\"assets/plotting/redo.svg\" width=\"15\" height=\"15\">\n (<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd> or <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Y</kbd>):\n re-applies the most recently undone action. Performing a new region action clears what can be redone.</li>\n <li>\"<b>Delete region</b>\" <i class=\"pi pi-trash\"></i> (<kbd>Del</kbd>): deletes the selected\n region. This can also be done from the Region Editor.</li>\n </ul>\n\n <h3>Region actions (right-click the image)</h3>\n <p>Right-click a region \u2014 or the current multi-selection \u2014 to open the\n \"<b>Selected region(s)</b>\" group at the top of the context menu. These act immediately on\n the selection (they're not drawing modes) and are fully undo/redo-able:</p>\n <ul>\n <li>\"<b>Select all regions</b>\" <i class=\"pi pi-check-square\"></i>: select every region on\n the image (also in the Region Editor toolbar).</li>\n <li>\"<b>Merge / group</b>\": combine 2+ selected regions into one. If the pieces don't touch,\n the result is a single multi-part region (sparse \"bits\").</li>\n <li>\"<b>Ungroup</b>\": split a multi-part region back into one region per piece.</li>\n <li>\"<b>Inverse</b>\": replace the selection with everything <i>outside</i> it \u2014 the image\n rectangle with the selected regions punched out as holes. Works at any image size.</li>\n <li>\"<b>Simplify</b>\": reduce a region's vertex count (Douglas\u2013Peucker) by an altitude\n threshold in pixels \u2014 pick <b>Light</b> (1&nbsp;px), <b>Medium</b> (3&nbsp;px),\n <b>Strong</b> (8&nbsp;px), or <b>Custom\u2026</b> for a slider. Applies to the exterior and any\n holes (and every part of a multi-part region).</li>\n <li>The B\u00E9zier conversions and Delete also live in this group (the right-click equivalents of\n the toolbar / vertex tools above).</li>\n </ul>\n\n <h3>Segmentation &amp; detection tools</h3>\n <p>AI-assisted segmentation and detection that run entirely in the browser. The first run\n of any model downloads it (a progress toast is shown); it's then cached.</p>\n <p>Two shapes of tool: the <b>prompted</b> ones (Segment, Point prompts, Cellpose) act on\n rectangles you draw, and generated regions inherit the color of the rectangle they came\n from<span *ngIf=\"contributedTools.length\">; the <b>no-prompt</b> ones ({{ contributedToolNames }})\n sweep the whole current view, so there is nothing to draw first</span>.</p>\n <p>Each model dropdown <i class=\"pi pi-chevron-down\"></i> lists the available checkpoints with\n a <i class=\"pi pi-info-circle\"></i> beside each one \u2014 hover it for that model's size, speed,\n training domain and accuracy, so you can pick without leaving the toolbar.</p>\n <ul>\n <li>\"<b>Segment</b>\" <img src=\"assets/plotting/sam-box-prompt.svg\" width=\"15\" height=\"15\">\n (box prompt): draw one or more <b>Rectangular regions</b> around the objects of interest,\n then click Segment. Each rectangle is replaced by the SAM mask of the object inside it.</li>\n <li>\"<b>Point prompts</b>\" <img src=\"assets/plotting/sam-point-prompt.svg\" width=\"15\" height=\"15\">: click directly on an object to\n segment it as a <b>new</b> region. Each click starts a fresh object \u2014 clicking another\n object won't grow the previous one. <kbd>Shift</kbd>/<kbd>Alt</kbd>-click adds an\n <i>exclude</i> point that refines the current object; <kbd>Esc</kbd> undoes it,\n <kbd>Enter</kbd> commits it.</li>\n <li>\"<b>Model picker</b>\" <i class=\"pi pi-chevron-down\"></i>: the dropdown attached to the\n Segment button chooses the SAM model \u2014 <b>micro-sam ViT-T</b> (default; small, fast) or\n <b>micro-sam ViT-B</b> (larger, highest accuracy) for light microscopy, or\n <b>patho-sam ViT-B</b> for histopathology (H&amp;E / stained tissue). All are promptable\n Segment Anything models finetuned for biomedical images; they differ in the <i>image\n encoder</i> (the heavy once-per-image step) and the training domain \u2014 box and point\n prompting behave the same. The choice applies to <i>both</i> the box and point tools, and\n each tool loads its own encoder, so the first click after switching re-encodes once.</li>\n <li>\"<b>Cellpose</b>\" <img src=\"assets/plotting/cells.svg\" width=\"15\" height=\"15\">: draw rectangles, then click Cellpose to\n automatically segment every cell inside each rectangle (client-side cellpose-SAM) \u2014 one\n region per detected cell. No clicking on individual cells needed.</li>\n <!-- Contributed tools describe themselves. This library ships none, so an\n open build's help lists only the prompted tools above \u2014 the text no\n longer promises buttons that are not there. -->\n <ng-container *ngFor=\"let tool of contributedTools\">\n <li *ngIf=\"tool.help\" [innerHTML]=\"tool.help.body\"></li>\n </ng-container>\n <ng-container *ngFor=\"let tool of dialogTools\">\n <li *ngIf=\"tool.help\" [innerHTML]=\"tool.help.body\"></li>\n </ng-container>\n </ul>\n\n <h3>Spatial omics</h3>\n <p>\n In <b>Spatial omics</b> mode each observation \u2014 a cell or a Visium spot \u2014 is drawn at\n its own coordinates, over the tissue image where the dataset registers onto one. The\n <img src=\"assets/plotting/spatial-omics.svg\" width=\"14\" height=\"14\" alt=\"Spatial omics\"/>\n panel colours them by a <b>column</b> (a cell type, a cluster) or by a <b>gene</b>, and\n the two are exclusive so what drives the colours is never ambiguous.\n </p>\n <ul>\n <li>\"<b>Select from ROIs</b>\": turns the regions you have drawn into a selection of\n observations. Everything else then follows that selection.</li>\n <li>\"<b>Point size</b>\" / \"<b>Opacity</b>\": how the markers are drawn, not what is\n selected.</li>\n </ul>\n\n <h3>Distribution charts</h3>\n <p>\n The <b>Distribution</b> section of the spatial-omics panel charts whatever the map is\n coloured by, so the two can never disagree. Each tab answers a different question, and\n the <i class=\"pi pi-question-circle\"></i> beside them explains the one on screen \u2014\n including what it must <em>not</em> be read for.\n </p>\n <ul>\n <li>\"<b>Counts</b>\": how many observations per category. A category is a label, not a\n magnitude, so a frequency is the only distribution it has.</li>\n <li>\"<b>Histogram</b>\" / \"<b>Violin</b>\" / \"<b>Box</b>\": how a continuous value is\n distributed \u2014 over everything, or split by a grouping column.</li>\n <li>\"<b>Heatmap</b>\": mean expression of the genes you pick within each group.\n Z-scored per gene by default, so a colour means \"above or below this gene's own\n average\" rather than \"highly expressed\".</li>\n <li>\"<b>Embedding</b>\": a UMAP, PCA or t-SNE over the same cells. <b>Lasso it to\n select on the map</b> \u2014 both views read the same selection. Only PCA labels its axes\n with a percentage, because only its axes explain a measurable share of the variance;\n UMAP and t-SNE coordinates are arbitrary, so distance between distant clusters\n should not be read.</li>\n <li>\"<b>Detach</b>\": moves the charts into their own window so they can sit beside the\n map instead of inside the panel. Switching tabs keeps the window where it is.</li>\n </ul>\n\n <h3>Help</h3>\n <p>\"<b>Help</b>\" <i class=\"pi pi-question-circle\"></i>: opens this dialog.</p>\n\n <h3>Keyboard shortcuts</h3>\n <table class=\"shortcuts-table\">\n <tr><td><kbd>+</kbd> / <kbd>=</kbd></td><td>Zoom in</td></tr>\n <tr><td><kbd>-</kbd></td><td>Zoom out</td></tr>\n <tr><td><kbd>b</kbd></td><td>Toggle zoom box mode</td></tr>\n <tr><td><kbd>p</kbd></td><td>Toggle pan mode</td></tr>\n <tr><td><kbd>f</kbd></td><td>Toggle freeform region drawing</td></tr>\n <tr><td><kbd>l</kbd></td><td>Toggle polyline drawing</td></tr>\n <tr><td><kbd>r</kbd></td><td>Toggle rectangular region drawing</td></tr>\n <tr><td><kbd>w</kbd></td><td>Toggle wand tool</td></tr>\n <tr><td>Wand + <kbd>Shift</kbd></td><td>Erase pixels from the active region</td></tr>\n <tr><td>Wand + <kbd>Ctrl</kbd>/<kbd>Cmd</kbd></td><td>Exact-match flood fill (no smoothing)</td></tr>\n <tr><td><kbd>e</kbd></td><td>Toggle vertex eraser</td></tr>\n <tr><td><kbd>s</kbd></td><td>Selection mode (deactivates drawing tools)</td></tr>\n <tr><td>Point prompt + <kbd>Shift</kbd>/<kbd>Alt</kbd></td><td>Add an exclude point (refine the current object)</td></tr>\n <tr><td><kbd>Enter</kbd></td><td>Commit the current SAM point object</td></tr>\n <tr><td><kbd>Esc</kbd></td><td>Undo the current SAM point object</td></tr>\n <tr><td><kbd>Del</kbd> / <kbd>\u232B</kbd></td><td>Delete selected region</td></tr>\n <tr><td><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Z</kbd></td><td>Undo the last region action (up to 10)</td></tr>\n <tr><td><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd> / <kbd>Ctrl</kbd>+<kbd>Y</kbd></td><td>Redo the last undone region action</td></tr>\n </table>\n\n <ng-template pTemplate=\"footer\">\n <p-button icon=\"pi pi-check\" (click)=\"displayHelpDialog=false\" label=\"Ok\" styleClass=\"p-button-text\">\n </p-button>\n </ng-template>\n</p-dialog>\n", styles: [":host{display:block}:host ::ng-deep .p-toolbar{padding:.25rem .5rem}.toolbar-text{color:var(--primary-color);font-size:.875rem}::ng-deep .toolbar-input .p-inputtext{color:var(--primary-color);border-color:var(--primary-color)}.toolbar-separator{border-left:1px solid var(--surface-border);height:1.5rem;margin:0 .25rem;align-self:center}.toolbar-icon-dark{filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}.toolbar-icon-active{filter:brightness(0) invert(1)}::ng-deep .plotmode-item{display:inline-flex;align-items:center;gap:.5rem;color:#000}::ng-deep .plotmode-item i{font-size:1rem;color:var(--primary-color)}::ng-deep .plotmode-item .plotmode-icon-img{width:1.1rem;height:1.1rem;object-fit:contain;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .p-menu.model-menu{width:auto;min-width:12.5rem}::ng-deep .model-menu-item{display:flex;align-items:center;gap:.5rem}::ng-deep .model-menu-item .p-menuitem-text{flex:1 1 auto;white-space:nowrap}::ng-deep .model-menu-item .model-info-icon{flex:0 0 auto;font-size:.875rem;opacity:.55;cursor:help;appearance:none;background:none;border:0;padding:0;margin:0;color:inherit;line-height:1}::ng-deep .model-menu-item .model-info-icon:hover{opacity:1}::ng-deep .model-menu-item .model-info-icon:focus-visible{opacity:1;outline:2px solid var(--primary-color, #2196f3);outline-offset:2px;border-radius:2px}::ng-deep .model-info-tooltip .p-tooltip-text{min-width:18rem;max-width:24rem;line-height:1.4}::ng-deep .wand-sensitivity-slider{width:8rem;align-self:center;margin:0 .25rem}.wand-sensitivity-value{min-width:2rem;text-align:right}span.colormap{width:50px;height:auto;display:inline-block}img.colormap{width:50px;height:auto}.colormap{background:no-repeat;background-size:100%;vertical-align:middle}kbd{background:#e8e8e8;border:1px solid #bbb;border-radius:3px;padding:1px 5px;font-family:monospace;font-size:.85em}.shortcuts-table{border-collapse:collapse}.shortcuts-table td{padding:.25rem .75rem .25rem 0;vertical-align:middle}.shortcuts-table td:first-child{white-space:nowrap}\n"], dependencies: [{ kind: "directive", type: i1$1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { 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: i1$1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { 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: "component", type: i3.Toolbar, selector: "p-toolbar", inputs: ["style", "styleClass", "ariaLabelledBy"] }, { 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: 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: "directive", type: i12.Ripple, selector: "[pRipple]" }] }); }
6936
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.9", type: ToolbarComponent, selector: "plotting-toolbar", inputs: { imageInfo: "imageInfo", hasSpatialDataset: "hasSpatialDataset", plotTypeOptions: "plotTypeOptions", selectedPlotType: "selectedPlotType", basePlotType: "basePlotType", isoRange: "isoRange", maxIndex: "maxIndex", zIndex: "zIndex", isHeatmap: "isHeatmap", is3dRegions: "is3dRegions", activeDragMode: "activeDragMode", activeSurface3dMode: "activeSurface3dMode", axesVisible: "axesVisible", wireframeActive: "wireframeActive", resolutionScale: "resolutionScale", wandSensitivity: "wandSensitivity", brushSize: "brushSize", vertexEraserRadius: "vertexEraserRadius", canUndo: "canUndo", canRedo: "canRedo", stackOptions: "stackOptions", tools: "tools", imageSmoothingEnabled: "imageSmoothingEnabled", samModels: "samModels", samModelId: "samModelId", contributedTools: "contributedTools", dialogTools: "dialogTools", openDialogToolId: "openDialogToolId", toolModelIds: "toolModelIds" }, outputs: { selectPlotType: "selectPlotType", addProfileLine: "addProfileLine", toggleImageSmoothing: "toggleImageSmoothing", isoRangeChange: "isoRangeChange", openChannelHistogram: "openChannelHistogram", openSpatialControls: "openSpatialControls", openRegionEditor: "openRegionEditor", selectStackOption: "selectStackOption", zScrub: "zScrub", zSlide: "zSlide", zIndexInput: "zIndexInput", reloadAndPlot: "reloadAndPlot", downloadImage: "downloadImage", autoscaleImage: "autoscaleImage", toggleDragMode: "toggleDragMode", zoomIn: "zoomIn", zoomOut: "zoomOut", toggleSurface3dMode: "toggleSurface3dMode", resetSurfaceCamera: "resetSurfaceCamera", toggleAxes: "toggleAxes", toggleWireframe: "toggleWireframe", selectResolution: "selectResolution", deleteRegion: "deleteRegion", undoRegion: "undoRegion", redoRegion: "redoRegion", segmentRegions: "segmentRegions", segmentCellpose: "segmentCellpose", runTool: "runTool", toggleDialogTool: "toggleDialogTool", toolModelChange: "toolModelChange", openToolParams: "openToolParams", samModelChange: "samModelChange", wandSensitivityChange: "wandSensitivityChange", brushSizeChange: "brushSizeChange", vertexEraserRadiusChange: "vertexEraserRadiusChange" }, usesOnChanges: true, ngImport: i0, template: "<p-toolbar>\n <div class=\"p-toolbar-group-start flex-wrap align-items-center gap-1\">\n\n <!-- Host-provided custom toolbar buttons, shown first (e.g. app-specific\n actions the visualization view itself doesn't own). -->\n <ng-content></ng-content>\n\n <!-- Contributed dialog tools (TOOLBAR_TOOLS, kind: 'dialog'): app-level actions\n like the host's own buttons above, so they sit right beside them. Image view\n only, the view their dialog draws over. Highlighted while the dialog is open. -->\n <ng-container *ngIf=\"isImageView\">\n <p-button *ngFor=\"let tool of dialogTools; trackBy: trackToolById\"\n size=\"small\" [text]=\"openDialogToolId !== tool.id\"\n [pTooltip]=\"tool.tooltip\" tooltipPosition=\"bottom\"\n [ariaLabel]=\"tool.label\"\n [attr.aria-pressed]=\"openDialogToolId === tool.id\"\n (onClick)=\"toggleDialogTool.emit(tool.id)\">\n <img *ngIf=\"tool.icon.src\" [src]=\"tool.icon.src\" style=\"width: 1rem\" [alt]=\"tool.label\"\n [class]=\"openDialogToolId === tool.id ? 'toolbar-icon-active' : 'toolbar-icon-dark'\" />\n <i *ngIf=\"tool.icon.pi\" class=\"pi {{ tool.icon.pi }}\"></i>\n </p-button>\n </ng-container>\n\n <!-- Plot type: available for any image (grayscale or RGB), and for a spatial dataset\n that brings none (its spatial modes are still a choice). -->\n <ng-container *ngIf=\"showPlotTypes\">\n <span class=\"toolbar-separator\"></span>\n <p-dropdown [options]=\"plotTypeOptions\" optionLabel=\"label\" optionValue=\"type\"\n [(ngModel)]=\"selectedPlotType\" appendTo=\"body\"\n pTooltip=\"Select plot type\" tooltipPosition=\"bottom\"\n [style]=\"{'min-width': '11rem'}\"\n styleClass=\"plotmode-dropdown\" panelStyleClass=\"plotmode-panel\"\n (onChange)=\"selectPlotType.emit($event.value)\">\n <ng-template let-opt pTemplate=\"selectedItem\">\n <span class=\"plotmode-item\" *ngIf=\"opt\">\n <i *ngIf=\"isPiIcon(opt.icon)\" [class]=\"opt.icon\"></i>\n <img *ngIf=\"opt.icon && !isPiIcon(opt.icon)\" class=\"plotmode-icon-img\" [src]=\"opt.icon\" alt=\"\" />\n <span>{{ opt.label }}</span>\n </span>\n </ng-template>\n <ng-template let-opt pTemplate=\"item\">\n <span class=\"plotmode-item\">\n <i *ngIf=\"isPiIcon(opt.icon)\" [class]=\"opt.icon\"></i>\n <img *ngIf=\"opt.icon && !isPiIcon(opt.icon)\" class=\"plotmode-icon-img\" [src]=\"opt.icon\" alt=\"\" />\n <span>{{ opt.label }}</span>\n </span>\n </ng-template>\n </p-dropdown>\n <ng-container *ngIf=\"isIsosurfaceMode\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Iso</span>\n <p-slider [(ngModel)]=\"isoRange\" [range]=\"true\"\n [min]=\"isoValueMin\" [max]=\"isoValueMax\" [step]=\"isoValueStep\"\n (onChange)=\"isoRangeChange.emit($event.values)\"\n pTooltip=\"Isosurface band (0\u2013255), mapped onto the volume's\n actual intensity range. Surfaces are drawn for voxels whose\n intensity falls in the selected band \u2014 narrow it to isolate\n features, widen it to see the whole structure.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n styleClass=\"wand-sensitivity-slider\"></p-slider>\n <span class=\"flex align-items-center mx-1 toolbar-text wand-sensitivity-value\">\n {{ isoRange[0] }}\u2013{{ isoRange[1] }}</span>\n </ng-container>\n <!-- Intensity tools (Heatmap / Image mode): add another colored line ROI.\n Each added line gets the next bright palette colour and its own inset\n trace; select a line on the image and use the trash tool to remove it\n (and its trace). The lines are Region-based but excluded from the\n Region Editor and exports. -->\n <ng-container *ngIf=\"isIntensityCapable\">\n <span class=\"toolbar-separator\"></span>\n <p-button size=\"small\" [text]=\"true\"\n pTooltip=\"Add an intensity line (new colour). Click a line to select it,\n then use the trash tool to remove it.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"addProfileLine.emit()\">\n <img src=\"assets/plotting/spline-chart.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n </ng-container>\n </ng-container>\n <!-- Channels & Histogram: brightness/contrast, gamma, per-channel display,\n colormap/reverse/invert \u2014 opens the floating dialog. -->\n <p-button *ngIf=\"tools.specialTools\" pRipple size=\"small\" [text]=\"true\"\n pTooltip=\"Channels &amp; Histogram (brightness/contrast, colormap)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"openChannelHistogram.emit()\">\n <img src=\"assets/plotting/invert.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n\n <!-- Spatial-omics controls. Only meaningful in the spatial mode, which is\n itself only offered while a dataset is loaded \u2014 so this button appears\n exactly when there is something for it to control. -->\n <p-button *ngIf=\"tools.specialTools && isSpatialMode\" pRipple size=\"small\" [text]=\"true\"\n pTooltip=\"Spatial omics (colour by column or gene, legend, selection, distributions)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"openSpatialControls.emit()\">\n <img src=\"assets/plotting/spatial-omics.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n\n <!-- Smoothen toggle: bilinear smoothing vs nearest-neighbour raw pixels (Image\n view only \u2014 it's an OpenSeadragon drawer setting). Highlighted when smoothing on. -->\n <p-button *ngIf=\"tools.specialTools && isImageView\" size=\"small\"\n [text]=\"!imageSmoothingEnabled\"\n pTooltip=\"Smoothen (turn on image smoothing)\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleImageSmoothing.emit()\">\n <img src=\"assets/plotting/smooth.svg\" style=\"width: 1rem\"\n [class]=\"imageSmoothingEnabled ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"/>\n </p-button>\n\n <!-- Stack controls -->\n <ng-container *ngIf=\"imageInfo?.isStack\">\n <span class=\"toolbar-separator\"></span>\n <!-- Image view: scrub z-slices live (the renderer swaps slices in place). -->\n <ng-container *ngIf=\"showsLiveSliceScrubber\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Slice</span>\n <p-slider [(ngModel)]=\"zIndex\" [min]=\"0\" [max]=\"maxIndex\"\n (onChange)=\"zScrub.emit($event.value)\" (onSlideEnd)=\"zSlide.emit($event.value)\"\n pTooltip=\"Slice through the stack (\u2190 / \u2192 keys)\" tooltipPosition=\"bottom\"\n [style]=\"{'width':'16rem'}\" styleClass=\"wand-sensitivity-slider\"></p-slider>\n <span class=\"flex align-items-center mx-1 toolbar-text\">{{zIndex}} of {{maxIndex}}</span>\n </ng-container>\n <!-- Heatmap view: single-image (high-def zoom) vs stack (frame slider). Plotly-only \u2014\n hidden for napari, which has no single/stack toggle or per-frame fetch, and for the\n spatial-omics modes: a 3D omics dataset publishes its reference volume as a z-stack\n image, and these modes draw observations, not one of its slices. -->\n <ng-container *ngIf=\"!showsLiveSliceScrubber && !isNapariMode && !isSpatialMode\">\n <p-button icon=\"pi pi-image\" size=\"small\"\n [text]=\"imageInfo?.showStack\"\n pTooltip=\"Single image mode: enables high-def zoom\" tooltipPosition=\"bottom\"\n (onClick)=\"selectStackOption.emit(stackOptions[0])\">\n </p-button>\n <p-button icon=\"pi pi-images\" size=\"small\"\n [text]=\"!imageInfo?.showStack\"\n pTooltip=\"Stack mode, enables slicing through image frames\" tooltipPosition=\"bottom\"\n (onClick)=\"selectStackOption.emit(stackOptions[1])\">\n </p-button>\n <ng-container *ngIf=\"!imageInfo?.showStack\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Slice</span>\n <div class=\"p-inputgroup\" style=\"width: auto\">\n <p-inputNumber styleClass=\"w-3rem toolbar-input\"\n\t\t\t\t\tpTooltip=\"View image in stack (&#x23CE;)\"\n\t\t\t\t\t[(ngModel)]=\"zIndex\" [min]=\"0\" [max]=\"maxIndex\"\n (onInput)=\"zIndexInput.emit(zIndex)\" (keydown.enter)=\"reloadAndPlot.emit()\"/>\n <p-button icon=\"pi pi-refresh\" size=\"small\" [text]=\"true\" (onClick)=\"reloadAndPlot.emit()\"\n pTooltip=\"Plot this slice\" tooltipPosition=\"bottom\">\n </p-button>\n </div>\n <span class=\"flex align-items-center mx-1 toolbar-text\">of {{maxIndex}}</span>\n </ng-container>\n </ng-container>\n </ng-container>\n\n <!-- Image actions (special tools) -->\n <ng-container *ngIf=\"tools.specialTools\">\n <span class=\"toolbar-separator\"></span>\n <p-button icon=\"pi pi-download\" size=\"small\" [text]=\"true\"\n pTooltip=\"Download image as PNG\" tooltipPosition=\"bottom\"\n (onClick)=\"downloadImage.emit()\">\n </p-button>\n <p-button *ngIf=\"isHeatmap\" icon=\"pi pi-window-maximize\" size=\"small\" [text]=\"true\"\n pTooltip=\"Fit to view\" tooltipPosition=\"bottom\"\n (onClick)=\"autoscaleImage.emit()\">\n </p-button>\n </ng-container>\n\n <!-- Zoom tools. Plotly rubber-band drag-zoom: on OpenSeadragon the box-zoom\n covers this and a bare drag pans natively, so it's hidden in image view. -->\n <ng-container *ngIf=\"isHeatmap && tools.zoomTools\">\n <span class=\"toolbar-separator\"></span>\n <p-button *ngIf=\"!isImageView\" icon=\"pi pi-search\" size=\"small\"\n [text]=\"activeDragMode !== 'zoom'\"\n pTooltip=\"Zoom drag\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('zoom')\">\n </p-button>\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'zoomToBox'\"\n pTooltip=\"Zoom box (<kbd>b</kbd>)\" [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('zoomToBox')\">\n <img src=\"assets/plotting/zoom-box.svg\"\n [class]=\"activeDragMode === 'zoomToBox' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <p-button icon=\"pi pi-arrows-alt\" size=\"small\"\n [text]=\"activeDragMode !== 'pan'\"\n pTooltip=\"Pan the image (<kbd>p</kbd>)\" [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('pan')\">\n </p-button>\n <p-button icon=\"pi pi-search-plus\" size=\"small\" [text]=\"true\"\n pTooltip=\"Zoom in (<kbd>+</kbd>)\" [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"zoomIn.emit()\">\n </p-button>\n <p-button icon=\"pi pi-search-minus\" size=\"small\" [text]=\"true\"\n pTooltip=\"Zoom out (<kbd>-</kbd>)\" [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"zoomOut.emit()\">\n </p-button>\n </ng-container>\n\n <!-- Surface 3D controls -->\n <ng-container *ngIf=\"!isHeatmap\">\n <span class=\"toolbar-separator\"></span>\n <p-button icon=\"pi pi-search\" size=\"small\"\n [text]=\"activeSurface3dMode !== 'zoom'\"\n pTooltip=\"Zoom\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleSurface3dMode.emit('zoom')\">\n </p-button>\n <p-button icon=\"pi pi-arrows-alt\" size=\"small\"\n [text]=\"activeSurface3dMode !== 'pan'\"\n pTooltip=\"Pan\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleSurface3dMode.emit('pan')\">\n </p-button>\n <p-button icon=\"pi pi-globe\" size=\"small\"\n [text]=\"activeSurface3dMode !== 'orbit'\"\n pTooltip=\"Orbital rotation\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleSurface3dMode.emit('orbit')\">\n </p-button>\n <p-button icon=\"pi pi-sync\" size=\"small\"\n [text]=\"activeSurface3dMode !== 'turntable'\"\n pTooltip=\"Turntable rotation\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleSurface3dMode.emit('turntable')\">\n </p-button>\n <p-button icon=\"pi pi-home\" size=\"small\" [text]=\"true\"\n pTooltip=\"Reset camera to default\" tooltipPosition=\"bottom\"\n (onClick)=\"resetSurfaceCamera.emit()\">\n </p-button>\n <!-- 3D coordinate-axes / scale gizmo toggle (napari-js WebGPU volume/isosurface only).\n `p-button-icon-only` gives the same square sizing as the neighbouring pi-icon buttons\n (the projected <img> doesn't trigger PrimeNG's automatic icon-only sizing). -->\n <p-button size=\"small\" *ngIf=\"isNapariMode\" styleClass=\"p-button-icon-only\"\n [text]=\"!axesVisible\"\n pTooltip=\"Toggle 3D axes / scale\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleAxes.emit()\">\n <img src=\"assets/plotting/3d-coordinates.svg\"\n [class]=\"axesVisible ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <!-- Surface wireframe toggle (napari-js WebGPU surface only). -->\n <p-button icon=\"pi pi-share-alt\" size=\"small\" *ngIf=\"isNapariSurfaceMode\"\n [text]=\"!wireframeActive\"\n pTooltip=\"Toggle wireframe\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleWireframe.emit()\">\n </p-button>\n <!-- Resolution (decimate factor) for the napari 3D types \u2014 reloads at coarser/finer sampling. -->\n <ng-container *ngIf=\"isNapari3dMode\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Res</span>\n <p-dropdown [options]=\"decimateOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"resolutionScale\" appendTo=\"body\"\n pTooltip=\"Resolution (decimate factor)\" tooltipPosition=\"bottom\"\n [style]=\"{'min-width': '6rem'}\"\n (onChange)=\"selectResolution.emit($event.value)\">\n </p-dropdown>\n </ng-container>\n </ng-container>\n\n <ng-container *ngIf=\"(isHeatmap || is3dRegions) && tools.regionTools\">\n <span class=\"toolbar-separator\"></span>\n <!-- Region Editor: opens the editor as a floating dialog (formerly the\n right-panel Regions tab). Sits with the region drawing tools. -->\n <p-button size=\"small\" [text]=\"true\"\n pTooltip=\"Open the Region Editor\" tooltipPosition=\"bottom\"\n (onClick)=\"openRegionEditor.emit()\">\n <img src=\"assets/plotting/region-editor.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n <!-- Region drawing tools -->\n <p-button icon=\"pi pi-arrow-up-right\" size=\"small\"\n [text]=\"activeDragMode !== 'select'\"\n pTooltip=\"Selection mode (<kbd>s</kbd>)\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('select')\">\n </p-button>\n <p-button icon=\"pi pi-stop\" size=\"small\"\n [text]=\"activeDragMode !== 'drawrect'\"\n pTooltip=\"Draw a rectangular region (<kbd>r</kbd>)\" [escape]=\"false\"\n tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('drawrect')\">\n </p-button>\n <p-button *ngIf=\"!is3dRegions\" size=\"small\"\n [text]=\"activeDragMode !== 'drawopenpath'\"\n pTooltip=\"Draw a polyline (<kbd>l</kbd>)\" [escape]=\"false\"\n tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('drawopenpath')\">\n <img src=\"assets/plotting/polyline.svg\"\n [class]=\"activeDragMode === 'drawopenpath' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <p-button icon=\"pi pi-pencil\" size=\"small\"\n [text]=\"activeDragMode !== 'drawclosedpath'\"\n pTooltip=\"Draw a freeform region (<kbd>f</kbd>)\" [escape]=\"false\"\n tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('drawclosedpath')\">\n </p-button>\n <!-- Brush: paint a region by dragging a disc of the given size; the Size\n slider appears while it's active. Hold Shift to erase (subtract). -->\n <!-- Brush and polyline are absent from a 3D cloud: the brush paints in IMAGE\n pixels and there is no raster to sample, and an open polyline is a\n profile tool, not a closed region a selection can test against. -->\n <p-button *ngIf=\"!is3dRegions\" size=\"small\"\n [text]=\"activeDragMode !== 'brush'\"\n pTooltip=\"Brush: click and drag to paint a region.\n <br>Hold <kbd>Shift</kbd> while painting to erase from a region.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('brush')\">\n <img src=\"assets/plotting/paintbrush.svg\"\n [class]=\"activeDragMode === 'brush' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <ng-container *ngIf=\"activeDragMode === 'brush'\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Size</span>\n <p-slider [(ngModel)]=\"brushSize\"\n [min]=\"brushSizeMin\" [max]=\"brushSizeMax\" [step]=\"brushSizeStep\"\n (onChange)=\"brushSizeChange.emit($event.value)\"\n pTooltip=\"Brush diameter in image pixels.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n styleClass=\"wand-sensitivity-slider\"></p-slider>\n <span class=\"flex align-items-center mx-1 toolbar-text wand-sensitivity-value\">\n {{ brushSize }}</span>\n </ng-container>\n\n <!-- Vertex editing \u2014 backends with a vertex-editing overlay (OSD + napari-js image). -->\n <ng-container *ngIf=\"supportsRegionVertexTools\">\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'drawpolygon'\"\n pTooltip=\"Polygon: click to place each vertex; click the first point to close\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('drawpolygon')\">\n <img src=\"assets/plotting/polygon-vertices.svg\"\n [class]=\"activeDragMode === 'drawpolygon' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'addpoint'\"\n pTooltip=\"Add a vertex: click an edge of the selected polygon\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('addpoint')\">\n <img src=\"assets/plotting/vertex-add.svg\"\n [class]=\"activeDragMode === 'addpoint' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'deletepoint'\"\n pTooltip=\"Delete a vertex: click a vertex of the selected polygon\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('deletepoint')\">\n <img src=\"assets/plotting/vertex-delete.svg\"\n [class]=\"activeDragMode === 'deletepoint' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n </ng-container>\n\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'wand'\"\n pTooltip=\"Wand: click and drag to grow a region by similar pixel values (<kbd>w</kbd>).\n <br>Hold <kbd>Shift</kbd> while clicking or dragging on a region to erase pixels from it.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('wand')\">\n <img src=\"assets/plotting/wand.svg\"\n [class]=\"activeDragMode === 'wand' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <ng-container *ngIf=\"activeDragMode === 'wand'\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Sens.</span>\n <p-slider [(ngModel)]=\"wandSensitivity\"\n [min]=\"wandSensitivityMin\" [max]=\"wandSensitivityMax\" [step]=\"wandSensitivityStep\"\n (onChange)=\"wandSensitivityChange.emit($event.value)\"\n pTooltip=\"Higher = stricter (smaller selection); lower = looser.\n Takes effect from the next click/drag tick.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n styleClass=\"wand-sensitivity-slider\"></p-slider>\n <span class=\"flex align-items-center mx-1 toolbar-text wand-sensitivity-value\">\n {{ wandSensitivity.toFixed(1) }}</span>\n </ng-container>\n <p-button icon=\"pi pi-eraser\" size=\"small\"\n [text]=\"activeDragMode !== 'eraseVertex'\"\n pTooltip=\"Vertex eraser (<kbd>e</kbd>): click or drag to remove polygon vertices inside\n the cursor circle from any region.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('eraseVertex')\">\n </p-button>\n <ng-container *ngIf=\"activeDragMode === 'eraseVertex'\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Radius</span>\n <p-slider [(ngModel)]=\"vertexEraserRadius\"\n [min]=\"vertexEraserRadiusMin\" [max]=\"vertexEraserRadiusMax\" [step]=\"vertexEraserRadiusStep\"\n (onChange)=\"vertexEraserRadiusChange.emit($event.value)\"\n pTooltip=\"Eraser radius in image pixels.\n Vertices within this distance of the cursor are removed.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n styleClass=\"wand-sensitivity-slider\"></p-slider>\n <span class=\"flex align-items-center mx-1 toolbar-text wand-sensitivity-value\">\n {{ vertexEraserRadius }}</span>\n </ng-container>\n <p-button size=\"small\" [text]=\"true\"\n [disabled]=\"!canUndo\"\n pTooltip=\"Undo the last region action (<kbd>Ctrl</kbd>+<kbd>Z</kbd>).\n Up to 10 actions can be undone.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"undoRegion.emit()\">\n <img src=\"assets/plotting/undo.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n <p-button size=\"small\" [text]=\"true\"\n [disabled]=\"!canRedo\"\n pTooltip=\"Redo the last undone region action\n (<kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd> / <kbd>Ctrl</kbd>+<kbd>Y</kbd>).\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"redoRegion.emit()\">\n <img src=\"assets/plotting/redo.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n <p-button icon=\"pi pi-trash\" size=\"small\" [text]=\"true\"\n pTooltip=\"Delete selected region (<kbd>Del</kbd>)\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"deleteRegion.emit()\">\n </p-button>\n <!-- SAM (jit-ui#90): box-prompt segment, interactive point prompts, and a\n model picker. -->\n <span class=\"toolbar-separator\"></span>\n <!-- SAM controls grouped: box-prompt Segment, then the point-prompt tool,\n then the model-picker dropdown (the chevron) which governs BOTH tools.\n Falls back to Segment + point with no model picker when none are set. -->\n <span *ngIf=\"samModels.length; else segmentPlain\" class=\"p-buttonset segment-split\">\n <p-button size=\"small\" [text]=\"true\"\n pTooltip=\"Segment the drawn rectangles with SAM (box prompt)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"segmentRegions.emit()\">\n <img src=\"assets/plotting/sam-box-prompt.svg\"\n class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n <ng-container [ngTemplateOutlet]=\"samPointBtn\"></ng-container>\n <p-button icon=\"pi pi-chevron-down\" size=\"small\" [text]=\"true\"\n pTooltip=\"Pick the SAM model (used by both segment tools)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"samModelMenu.toggle($event)\">\n </p-button>\n </span>\n <ng-template #segmentPlain>\n <span class=\"p-buttonset segment-split\">\n <p-button size=\"small\" [text]=\"true\"\n pTooltip=\"Segment the drawn rectangles with SAM (box prompt)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"segmentRegions.emit()\">\n <img src=\"assets/plotting/sam-box-prompt.svg\"\n class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n <ng-container [ngTemplateOutlet]=\"samPointBtn\"></ng-container>\n </span>\n </ng-template>\n <!-- Point-prompt tool \u2014 shared between the models / no-models layouts so it\n always sits just left of the model dropdown. -->\n <ng-template #samPointBtn>\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'samPoint'\"\n pTooltip=\"SAM point prompts: click a fiber to segment it as a new region;\n Shift/Alt-click to refine (exclude) the current one.<br>Esc undoes it.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('samPoint')\">\n <img src=\"assets/plotting/sam-point-prompt.svg\"\n [class]=\"activeDragMode === 'samPoint' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n </ng-template>\n <p-menu #samModelMenu styleClass=\"model-menu\" [model]=\"samMenuItems\" [popup]=\"true\" appendTo=\"body\">\n <ng-template pTemplate=\"item\" let-item>\n <ng-container [ngTemplateOutlet]=\"modelMenuItem\"\n [ngTemplateOutletContext]=\"{ $implicit: item }\"></ng-container>\n </ng-template>\n </p-menu>\n\n <!-- Shared item body for all three model pickers (SAM / YOLO / retinal):\n the standard check-or-blank icon and label, plus an info icon carrying\n the model's description as a hover tooltip. This replaced a single\n \"About the SAM models\" overlay panel \u2014 per-model text sits next to the\n model it describes, and YOLO/retinal get the same affordance.\n\n p-menu puts its click handler on the wrapping `.p-menuitem-content`,\n outside this template, so `command` still fires and the whole row stays\n clickable \u2014 which is also why the info icon has to stop propagation, or\n reading about a model would switch to it. -->\n <ng-template #modelMenuItem let-item>\n <a class=\"p-menuitem-link model-menu-item\">\n <span class=\"p-menuitem-icon\" [ngClass]=\"item.icon\"></span>\n <span class=\"p-menuitem-text\">{{ item.label }}</span>\n <!-- `bottom` like every other tooltip in this toolbar, and for room:\n these menus open under buttons that can sit near the right edge,\n where a `right` tooltip has a few hundred px and squeezes its text\n into a narrow column instead of using its max-width. -->\n <!-- A focusable <button>, not a bare <i>. As an <i> the description\n was reachable only by hovering a mouse: not tabbable, no\n accessible name, nothing for a screen reader to announce \u2014 so\n keyboard and AT users could pick a checkpoint but never read what\n it was. PrimeNG's tooltip shows on focus as well as hover, so\n making it a button restores it for free.\n\n `aria-describedby` is deliberately not used: the tooltip is\n rendered into `body` on demand and has no stable id to point at.\n The description is carried on `aria-label` instead, which is\n announced whether or not the tooltip is showing. It is stripped of\n markup first, since the copy contains `<b>`/`<br>` for the visual\n tooltip and those would otherwise be read out literally. -->\n <button *ngIf=\"item.tooltip\" type=\"button\"\n class=\"pi pi-info-circle model-info-icon\"\n [attr.aria-label]=\"'About ' + item.label + ': ' + plainText(item.tooltip)\"\n [pTooltip]=\"item.tooltip\" [escape]=\"false\" tooltipPosition=\"bottom\"\n appendTo=\"body\" tooltipStyleClass=\"model-info-tooltip\"\n (click)=\"$event.stopPropagation()\"\n (keydown.enter)=\"$event.stopPropagation()\"\n (keydown.space)=\"$event.stopPropagation()\"></button>\n </a>\n </ng-template>\n <p-button size=\"small\" [text]=\"true\"\n pTooltip=\"Auto-segment cells inside each drawn rectangle (cellpose-SAM)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"segmentCellpose.emit()\">\n <img src=\"assets/plotting/cells.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n\n <!-- Contributed no-prompt tools (TOOLBAR_TOOLS). Unlike the SAM and\n cellpose tools these take no prompt \u2014 they sweep the whole current\n view rather than being pointed at something \u2014 so there is nothing to\n draw first. Each renders the same split button: run, pick checkpoint,\n open parameters.\n\n Nothing here names a specific tool. The library ships none; a host\n registers them, which is what lets this package be published without\n the closed model dependencies. -->\n <ng-container *ngFor=\"let tool of contributedTools; trackBy: trackToolById\">\n <span class=\"p-buttonset segment-split\">\n <p-button size=\"small\" [text]=\"true\"\n [pTooltip]=\"tool.runTooltip\"\n tooltipPosition=\"bottom\"\n (onClick)=\"runTool.emit(tool.id)\">\n <!-- `toolbar-icon-dark` is hardcoded rather than bound, unlike the\n toggle tools: these fire a run and have no active state. -->\n <img *ngIf=\"tool.icon.src\" [src]=\"tool.icon.src\" class=\"toolbar-icon-dark\"\n style=\"width: 1rem\" [alt]=\"tool.label\" />\n <i *ngIf=\"tool.icon.pi\" class=\"pi {{ tool.icon.pi }}\"></i>\n </p-button>\n <p-button icon=\"pi pi-chevron-down\" size=\"small\" [text]=\"true\"\n [pTooltip]=\"tool.modelTooltip || 'Pick the model'\"\n tooltipPosition=\"bottom\"\n (onClick)=\"toolMenu.toggle($event)\">\n </p-button>\n <p-button *ngIf=\"tool.params.length\"\n icon=\"pi pi-sliders-h\" size=\"small\" [text]=\"true\"\n [pTooltip]=\"tool.paramsTooltip || 'Parameters'\"\n tooltipPosition=\"bottom\"\n (onClick)=\"openToolParams.emit(tool.id)\">\n </p-button>\n </span>\n <p-menu #toolMenu styleClass=\"model-menu\" [model]=\"toolMenuItems[tool.id] || []\"\n [popup]=\"true\" appendTo=\"body\">\n <ng-template pTemplate=\"item\" let-item>\n <ng-container [ngTemplateOutlet]=\"modelMenuItem\"\n [ngTemplateOutletContext]=\"{ $implicit: item }\"></ng-container>\n </ng-template>\n </p-menu>\n </ng-container>\n </ng-container>\n\n <ng-container *ngIf=\"tools.help\">\n <span class=\"toolbar-separator\"></span>\n\n <!-- Help -->\n <p-button icon=\"pi pi-question-circle\" size=\"small\" [text]=\"true\"\n pTooltip=\"Help\" tooltipPosition=\"bottom\"\n (onClick)=\"showHelp()\">\n </p-button>\n </ng-container>\n\n </div>\n</p-toolbar>\n<p-dialog header=\"Plotting help\" [(visible)]=\"displayHelpDialog\" [modal]=\"true\" [style]=\"{width: '50vw'}\"\n [maximizable]=\"true\" [draggable]=\"false\" [resizable]=\"false\">\n <p>Click a file in the left-hand tree to display it. By default it opens in the\n <b>Image</b> view \u2014 a natively tiled, deeply zoomable raster (OpenSeadragon) with a\n navigator minimap and a physical-units scale bar. <b>Click the image to zoom</b> toward\n the clicked point; scroll to zoom and drag to pan. When a stack is open, a slice slider\n (Image view) or image-mode toggle (other views) appears. Regions can also be edited in the\n <b>Region Editor</b>, opened with the\n <img src=\"assets/plotting/region-editor.svg\" width=\"14\" height=\"14\" alt=\"Region Editor\"/>\n toolbar button as a floating dialog. Drag the <i class=\"pi pi-bars\"></i> handle to detach\n this toolbar into a floating window.\n </p>\n\n <h3>Plot-type dropdown</h3>\n <p>\n The <b>plot-type dropdown</b> switches how the file is visualized:\n <b>Image</b>, <b>Heatmap</b>, <b>Contour</b>, <b>Surface</b>, <b>Volume</b>,\n <b>Isosurface</b>, and \u2014 when a spatial-omics dataset is open \u2014\n <b>Spatial omics</b> and <b>Spatial omics 3D</b>.\n </p>\n <p>\n <b>Only the modes the data can actually support are offered</b>, so the list changes\n with what is loaded rather than showing entries that would come up blank:\n </p>\n <ul>\n <li><b>Contour</b>, <b>Volume</b> and <b>Isosurface</b> need a grayscale image; the\n two 3D ones also need a z-stack.</li>\n <li><b>Spatial omics</b> needs a published dataset, and <b>Spatial omics 3D</b> needs\n one whose coordinates carry a z.</li>\n <li>The pixel modes (<b>Image</b>, <b>Heatmap</b>, <b>Surface</b>) are withheld for a\n dataset that brings no tissue image \u2014 seqFISH records coordinates and no section, so\n there is nothing for them to draw.</li>\n </ul>\n <ul>\n <li>\"<b>Isosurface band</b>\" slider (Isosurface): sets the intensity band the surfaces are\n drawn for, mapped onto the volume's real intensity range.</li>\n <li>\"<b>3D camera</b>\" (Surface / Scatter 3D / Isosurface): zoom <i class=\"pi pi-search\"></i>,\n pan <i class=\"pi pi-arrows-alt\"></i>, orbit <i class=\"pi pi-globe\"></i>,\n turntable <i class=\"pi pi-sync\"></i>, and reset camera <i class=\"pi pi-home\"></i>;\n scroll to zoom the 3D scene.</li>\n </ul>\n\n <h3>Image manipulation tools</h3>\n <ul>\n <li>\"<b>Region Editor</b>\" <img src=\"assets/plotting/region-editor.svg\" width=\"15\" height=\"15\">:\n opens the Region Editor as a floating, draggable dialog \u2014 a per-image table of every region\n (class, colour, area) with bulk select/label/colour/delete and GeoJSON import/export. It stays\n in sync with the image, so you can keep it open while drawing.</li>\n <li>\"<b>Intensity profile lines</b>\" <img src=\"assets/plotting/spline-chart.svg\" width=\"15\" height=\"15\">\n (<i>work in progress \u2014 not yet stable</i>): adds a coloured line ROI; a floating inset\n chart plots intensity along each line, updates live as you drag it, and re-samples at the\n current zoom resolution. Each added line gets its own colour; select a line and use the\n trash tool <i class=\"pi pi-trash\"></i> to remove it (and its trace).</li>\n <li>\"<b>Channels &amp; Histogram</b>\" <img src=\"assets/plotting/invert.svg\" width=\"15\" height=\"15\">:\n opens a floating dialog for brightness/contrast, gamma, and per-channel display, plus the\n <b>colormap / LUT</b> controls (grayscale only) \u2014 a <b>reverse</b> toggle and a colormap\n dropdown (default: inverted greys).</li>\n <li>\"<b>Smoothen</b>\" <img src=\"assets/plotting/smooth.svg\" width=\"15\" height=\"15\">\n (Image view only): the image opens showing <b>raw pixels</b> (nearest-neighbour), so\n zooming in renders crisp blocks for pixel inspection. Toggle this on for bilinear\n smoothing; the icon turns white while smoothing is active.</li>\n </ul>\n\n <h3>Download &amp; view tools</h3>\n <ul>\n <li>\"<b>Download</b>\" <i class=\"pi pi-download\"></i>: download the current image as a PNG.</li>\n <li>\"<b>Fit to view</b>\" / autoscale <i class=\"pi pi-window-maximize\"></i>: resets the image\n to its original dimensions. In stack mode it also resets to single-image mode.</li>\n </ul>\n\n <h3>Zoom tools</h3>\n <ul>\n <li>\"<b>Zoom box</b>\" <img src=\"assets/plotting/zoom-box.svg\" width=\"15\" height=\"15\">\n (<kbd>b</kbd>): drag a box to select an area to zoom into.</li>\n <li>\"<b>Pan</b>\" <i class=\"pi pi-arrows-alt\"></i> (<kbd>p</kbd>): drag to pan the image.</li>\n <li>\"<b>Zoom in</b>\" <i class=\"pi pi-search-plus\"></i> (<kbd>+</kbd>): zoom in one step.</li>\n <li>\"<b>Zoom out</b>\" <i class=\"pi pi-search-minus\"></i> (<kbd>-</kbd>): zoom out one step,\n stopping at the original image dimensions.</li>\n </ul>\n\n <h3>Region tools</h3>\n <p>By default no tool is selected (all greyed-out); selecting a tool highlights it. The\n vertex-editing tools (polygon, add/delete vertex, move, B\u00E9zier conversions) are available\n in the <b>Image</b> view only. On a <b>donut</b> (a region with a hole), the inner-ring\n vertices are editable too \u2014 drag, add, and delete them just like the outer ring.</p>\n <ul>\n <li>\"<b>Selection</b>\" <i class=\"pi pi-arrow-up-right\"></i> (<kbd>s</kbd>): a neutral mode\n that turns off whatever drawing tool was active (wand, vertex eraser, rectangle, etc.).\n Use it to step out of editing without picking another tool.</li>\n <li>\"<b>Rectangular region</b>\" <i class=\"pi pi-stop\"></i> (<kbd>r</kbd>): click and drag to\n create a rectangular region.</li>\n <li>\"<b>Polyline</b>\" <img src=\"assets/plotting/polyline.svg\" width=\"14\" height=\"14\">\n (<kbd>l</kbd>): draw an open (non-closed) polyline by clicking and dragging. Polylines can\n be edited and exported to GeoJSON as a <code>LineString</code> feature.</li>\n <li>\"<b>Freeform region</b>\" <i class=\"pi pi-pencil\"></i> (<kbd>f</kbd>): click and drag to\n draw a closed freeform polygon.</li>\n <li>\"<b>Brush</b>\" <img src=\"assets/plotting/paintbrush.svg\" width=\"15\" height=\"15\">: click\n and drag to paint a region with a circular brush \u2014 the painted stroke is the region.\n A <b>Size</b> slider (brush diameter, in image pixels) appears in the toolbar while the\n brush is active. Hold <kbd>Shift</kbd> while painting to <b>erase</b> (subtract) from a\n region instead. Painting over an existing region extends it; brushing across two regions\n merges them. Painting a closed ring (or shift-erasing an interior pocket) leaves the\n enclosed area empty \u2014 a <b>donut</b> (Image view).</li>\n <li>\"<b>Polygon</b>\" <img src=\"assets/plotting/polygon-vertices.svg\" width=\"15\" height=\"15\">:\n click to place each vertex; click the first point to close the polygon.</li>\n <li>\"<b>Add vertex</b>\" <img src=\"assets/plotting/vertex-add.svg\" width=\"15\" height=\"15\">:\n click an edge of the selected polygon to insert a new vertex.</li>\n <li>\"<b>Delete vertex</b>\" <img src=\"assets/plotting/vertex-delete.svg\" width=\"15\" height=\"15\">:\n click a vertex of the selected polygon to remove it.</li>\n <li>\"<b>Move region</b>\" <img src=\"assets/plotting/region-move.svg\" width=\"15\" height=\"15\">:\n drag to move the selected region.</li>\n <li>\"<b>To B\u00E9zier</b>\" <img src=\"assets/plotting/to-bezier.svg\" width=\"15\" height=\"15\"> /\n \"<b>To polygon</b>\" <img src=\"assets/plotting/to-polygon.svg\" width=\"15\" height=\"15\">:\n convert the selected region to a smooth B\u00E9zier curve, or back to a straight-edged polygon.</li>\n <li>\"<b>Wand</b>\" <img src=\"assets/plotting/wand.svg\" width=\"15\" height=\"15\"> (<kbd>w</kbd>):\n click to grow a region from pixels with similar values around the click point. Hold\n <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> for an exact-match flood fill; drag to keep extending the\n selection while moving; hold <kbd>Shift</kbd> while clicking or dragging on an existing\n region to <b>erase</b> pixels from it. Use the <b>Sens.</b> slider that appears to make the\n wand stricter (higher = smaller selection) or looser. Subsequent clicks keep extending the\n same region; clicking inside an existing region adopts it, and growing into another region\n merges them.</li>\n <li>\"<b>Vertex eraser</b>\" <i class=\"pi pi-eraser\"></i> (<kbd>e</kbd>): click or click-and-drag\n to remove polygon vertices that fall inside the cursor circle from any region, including a\n region's inner rings (donut holes). Use the <b>Radius</b> slider to set the eraser size in\n image pixels. Polygons reduced below 3 vertices (or polylines below 2) are deleted entirely;\n a hole reduced below 3 vertices is removed.</li>\n <li>\"<b>Undo</b>\" <img src=\"assets/plotting/undo.svg\" width=\"15\" height=\"15\">\n (<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Z</kbd>): reverts the\n last region action (draw, delete, edit, wand/brush stroke, segmentation, \u2026). Up to the last\n <b>10</b> actions can be undone; the button greys out when there's nothing left to undo.</li>\n <li>\"<b>Redo</b>\" <img src=\"assets/plotting/redo.svg\" width=\"15\" height=\"15\">\n (<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd> or <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Y</kbd>):\n re-applies the most recently undone action. Performing a new region action clears what can be redone.</li>\n <li>\"<b>Delete region</b>\" <i class=\"pi pi-trash\"></i> (<kbd>Del</kbd>): deletes the selected\n region. This can also be done from the Region Editor.</li>\n </ul>\n\n <h3>Region actions (right-click the image)</h3>\n <p>Right-click a region \u2014 or the current multi-selection \u2014 to open the\n \"<b>Selected region(s)</b>\" group at the top of the context menu. These act immediately on\n the selection (they're not drawing modes) and are fully undo/redo-able:</p>\n <ul>\n <li>\"<b>Select all regions</b>\" <i class=\"pi pi-check-square\"></i>: select every region on\n the image (also in the Region Editor toolbar).</li>\n <li>\"<b>Merge / group</b>\": combine 2+ selected regions into one. If the pieces don't touch,\n the result is a single multi-part region (sparse \"bits\").</li>\n <li>\"<b>Ungroup</b>\": split a multi-part region back into one region per piece.</li>\n <li>\"<b>Inverse</b>\": replace the selection with everything <i>outside</i> it \u2014 the image\n rectangle with the selected regions punched out as holes. Works at any image size.</li>\n <li>\"<b>Simplify</b>\": reduce a region's vertex count (Douglas\u2013Peucker) by an altitude\n threshold in pixels \u2014 pick <b>Light</b> (1&nbsp;px), <b>Medium</b> (3&nbsp;px),\n <b>Strong</b> (8&nbsp;px), or <b>Custom\u2026</b> for a slider. Applies to the exterior and any\n holes (and every part of a multi-part region).</li>\n <li>The B\u00E9zier conversions and Delete also live in this group (the right-click equivalents of\n the toolbar / vertex tools above).</li>\n </ul>\n\n <h3>Segmentation &amp; detection tools</h3>\n <p>AI-assisted segmentation and detection that run entirely in the browser. The first run\n of any model downloads it (a progress toast is shown); it's then cached.</p>\n <p>Two shapes of tool: the <b>prompted</b> ones (Segment, Point prompts, Cellpose) act on\n rectangles you draw, and generated regions inherit the color of the rectangle they came\n from<span *ngIf=\"contributedTools.length\">; the <b>no-prompt</b> ones ({{ contributedToolNames }})\n sweep the whole current view, so there is nothing to draw first</span>.</p>\n <p>Each model dropdown <i class=\"pi pi-chevron-down\"></i> lists the available checkpoints with\n a <i class=\"pi pi-info-circle\"></i> beside each one \u2014 hover it for that model's size, speed,\n training domain and accuracy, so you can pick without leaving the toolbar.</p>\n <ul>\n <li>\"<b>Segment</b>\" <img src=\"assets/plotting/sam-box-prompt.svg\" width=\"15\" height=\"15\">\n (box prompt): draw one or more <b>Rectangular regions</b> around the objects of interest,\n then click Segment. Each rectangle is replaced by the SAM mask of the object inside it.</li>\n <li>\"<b>Point prompts</b>\" <img src=\"assets/plotting/sam-point-prompt.svg\" width=\"15\" height=\"15\">: click directly on an object to\n segment it as a <b>new</b> region. Each click starts a fresh object \u2014 clicking another\n object won't grow the previous one. <kbd>Shift</kbd>/<kbd>Alt</kbd>-click adds an\n <i>exclude</i> point that refines the current object; <kbd>Esc</kbd> undoes it,\n <kbd>Enter</kbd> commits it.</li>\n <li>\"<b>Model picker</b>\" <i class=\"pi pi-chevron-down\"></i>: the dropdown attached to the\n Segment button chooses the SAM model \u2014 <b>micro-sam ViT-T</b> (default; small, fast) or\n <b>micro-sam ViT-B</b> (larger, highest accuracy) for light microscopy, or\n <b>patho-sam ViT-B</b> for histopathology (H&amp;E / stained tissue). All are promptable\n Segment Anything models finetuned for biomedical images; they differ in the <i>image\n encoder</i> (the heavy once-per-image step) and the training domain \u2014 box and point\n prompting behave the same. The choice applies to <i>both</i> the box and point tools, and\n each tool loads its own encoder, so the first click after switching re-encodes once.</li>\n <li>\"<b>Cellpose</b>\" <img src=\"assets/plotting/cells.svg\" width=\"15\" height=\"15\">: draw rectangles, then click Cellpose to\n automatically segment every cell inside each rectangle (client-side cellpose-SAM) \u2014 one\n region per detected cell. No clicking on individual cells needed.</li>\n <!-- Contributed tools describe themselves. This library ships none, so an\n open build's help lists only the prompted tools above \u2014 the text no\n longer promises buttons that are not there. -->\n <ng-container *ngFor=\"let tool of contributedTools\">\n <li *ngIf=\"tool.help\" [innerHTML]=\"tool.help.body\"></li>\n </ng-container>\n <ng-container *ngFor=\"let tool of dialogTools\">\n <li *ngIf=\"tool.help\" [innerHTML]=\"tool.help.body\"></li>\n </ng-container>\n </ul>\n\n <h3>Spatial omics</h3>\n <p>\n In <b>Spatial omics</b> mode each observation \u2014 a cell or a Visium spot \u2014 is drawn at\n its own coordinates, over the tissue image where the dataset registers onto one. The\n <img src=\"assets/plotting/spatial-omics.svg\" width=\"14\" height=\"14\" alt=\"Spatial omics\"/>\n panel colours them by a <b>column</b> (a cell type, a cluster) or by a <b>gene</b>, and\n the two are exclusive so what drives the colours is never ambiguous.\n </p>\n <ul>\n <li>\"<b>Select from ROIs</b>\": turns the regions you have drawn into a selection of\n observations. Everything else then follows that selection.</li>\n <li>\"<b>Point size</b>\" / \"<b>Opacity</b>\": how the markers are drawn, not what is\n selected.</li>\n </ul>\n\n <h3>Distribution charts</h3>\n <p>\n The <b>Distribution</b> section of the spatial-omics panel charts whatever the map is\n coloured by, so the two can never disagree. Each tab answers a different question, and\n the <i class=\"pi pi-question-circle\"></i> beside them explains the one on screen \u2014\n including what it must <em>not</em> be read for.\n </p>\n <ul>\n <li>\"<b>Counts</b>\": how many observations per category. A category is a label, not a\n magnitude, so a frequency is the only distribution it has.</li>\n <li>\"<b>Histogram</b>\" / \"<b>Violin</b>\" / \"<b>Box</b>\": how a continuous value is\n distributed \u2014 over everything, or split by a grouping column.</li>\n <li>\"<b>Heatmap</b>\": mean expression of the genes you pick within each group.\n Z-scored per gene by default, so a colour means \"above or below this gene's own\n average\" rather than \"highly expressed\".</li>\n <li>\"<b>Embedding</b>\": a UMAP, PCA or t-SNE over the same cells. <b>Lasso it to\n select on the map</b> \u2014 both views read the same selection. Only PCA labels its axes\n with a percentage, because only its axes explain a measurable share of the variance;\n UMAP and t-SNE coordinates are arbitrary, so distance between distant clusters\n should not be read.</li>\n <li>\"<b>Detach</b>\": moves the charts into their own window so they can sit beside the\n map instead of inside the panel. Switching tabs keeps the window where it is.</li>\n </ul>\n\n <h3>Help</h3>\n <p>\"<b>Help</b>\" <i class=\"pi pi-question-circle\"></i>: opens this dialog.</p>\n\n <h3>Keyboard shortcuts</h3>\n <table class=\"shortcuts-table\">\n <tr><td><kbd>+</kbd> / <kbd>=</kbd></td><td>Zoom in</td></tr>\n <tr><td><kbd>-</kbd></td><td>Zoom out</td></tr>\n <tr><td><kbd>b</kbd></td><td>Toggle zoom box mode</td></tr>\n <tr><td><kbd>p</kbd></td><td>Toggle pan mode</td></tr>\n <tr><td><kbd>f</kbd></td><td>Toggle freeform region drawing</td></tr>\n <tr><td><kbd>l</kbd></td><td>Toggle polyline drawing</td></tr>\n <tr><td><kbd>r</kbd></td><td>Toggle rectangular region drawing</td></tr>\n <tr><td><kbd>w</kbd></td><td>Toggle wand tool</td></tr>\n <tr><td>Wand + <kbd>Shift</kbd></td><td>Erase pixels from the active region</td></tr>\n <tr><td>Wand + <kbd>Ctrl</kbd>/<kbd>Cmd</kbd></td><td>Exact-match flood fill (no smoothing)</td></tr>\n <tr><td><kbd>e</kbd></td><td>Toggle vertex eraser</td></tr>\n <tr><td><kbd>s</kbd></td><td>Selection mode (deactivates drawing tools)</td></tr>\n <tr><td>Point prompt + <kbd>Shift</kbd>/<kbd>Alt</kbd></td><td>Add an exclude point (refine the current object)</td></tr>\n <tr><td><kbd>Enter</kbd></td><td>Commit the current SAM point object</td></tr>\n <tr><td><kbd>Esc</kbd></td><td>Undo the current SAM point object</td></tr>\n <tr><td><kbd>Del</kbd> / <kbd>\u232B</kbd></td><td>Delete selected region</td></tr>\n <tr><td><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Z</kbd></td><td>Undo the last region action (up to 10)</td></tr>\n <tr><td><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd> / <kbd>Ctrl</kbd>+<kbd>Y</kbd></td><td>Redo the last undone region action</td></tr>\n </table>\n\n <ng-template pTemplate=\"footer\">\n <p-button icon=\"pi pi-check\" (click)=\"displayHelpDialog=false\" label=\"Ok\" styleClass=\"p-button-text\">\n </p-button>\n </ng-template>\n</p-dialog>\n", styles: [":host{display:block}:host ::ng-deep .p-toolbar{padding:.25rem .5rem}.toolbar-text{color:var(--primary-color);font-size:.875rem}::ng-deep .toolbar-input .p-inputtext{color:var(--primary-color);border-color:var(--primary-color)}.toolbar-separator{border-left:1px solid var(--surface-border);height:1.5rem;margin:0 .25rem;align-self:center}.toolbar-icon-dark{filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}.toolbar-icon-active{filter:brightness(0) invert(1)}::ng-deep .plotmode-item{display:inline-flex;align-items:center;gap:.5rem;color:#000}::ng-deep .plotmode-item i{font-size:1rem;color:var(--primary-color)}::ng-deep .plotmode-item .plotmode-icon-img{width:1.1rem;height:1.1rem;object-fit:contain;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .p-menu.model-menu{width:auto;min-width:12.5rem}::ng-deep .model-menu-item{display:flex;align-items:center;gap:.5rem}::ng-deep .model-menu-item .p-menuitem-text{flex:1 1 auto;white-space:nowrap}::ng-deep .model-menu-item .model-info-icon{flex:0 0 auto;font-size:.875rem;opacity:.55;cursor:help;appearance:none;background:none;border:0;padding:0;margin:0;color:inherit;line-height:1}::ng-deep .model-menu-item .model-info-icon:hover{opacity:1}::ng-deep .model-menu-item .model-info-icon:focus-visible{opacity:1;outline:2px solid var(--primary-color, #2196f3);outline-offset:2px;border-radius:2px}::ng-deep .model-info-tooltip .p-tooltip-text{min-width:18rem;max-width:24rem;line-height:1.4}::ng-deep .wand-sensitivity-slider{width:8rem;align-self:center;margin:0 .25rem}.wand-sensitivity-value{min-width:2rem;text-align:right}span.colormap{width:50px;height:auto;display:inline-block}img.colormap{width:50px;height:auto}.colormap{background:no-repeat;background-size:100%;vertical-align:middle}kbd{background:#e8e8e8;border:1px solid #bbb;border-radius:3px;padding:1px 5px;font-family:monospace;font-size:.85em}.shortcuts-table{border-collapse:collapse}.shortcuts-table td{padding:.25rem .75rem .25rem 0;vertical-align:middle}.shortcuts-table td:first-child{white-space:nowrap}\n"], dependencies: [{ kind: "directive", type: i1$1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { 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: i1$1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { 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: "component", type: i3.Toolbar, selector: "p-toolbar", inputs: ["style", "styleClass", "ariaLabelledBy"] }, { 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: 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: "directive", type: i12.Ripple, selector: "[pRipple]" }] }); }
6919
6937
  }
6920
6938
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.9", ngImport: i0, type: ToolbarComponent, decorators: [{
6921
6939
  type: Component,
6922
- args: [{ selector: 'plotting-toolbar', template: "<p-toolbar>\n <div class=\"p-toolbar-group-start flex-wrap align-items-center gap-1\">\n\n <!-- Host-provided custom toolbar buttons, shown first (e.g. app-specific\n actions the visualization view itself doesn't own). -->\n <ng-content></ng-content>\n\n <!-- Contributed dialog tools (TOOLBAR_TOOLS, kind: 'dialog'): app-level actions\n like the host's own buttons above, so they sit right beside them. Image view\n only, the view their dialog draws over. Highlighted while the dialog is open. -->\n <ng-container *ngIf=\"isImageView\">\n <p-button *ngFor=\"let tool of dialogTools; trackBy: trackToolById\"\n size=\"small\" [text]=\"openDialogToolId !== tool.id\"\n [pTooltip]=\"tool.tooltip\" tooltipPosition=\"bottom\"\n [ariaLabel]=\"tool.label\"\n [attr.aria-pressed]=\"openDialogToolId === tool.id\"\n (onClick)=\"toggleDialogTool.emit(tool.id)\">\n <img *ngIf=\"tool.icon.src\" [src]=\"tool.icon.src\" style=\"width: 1rem\" [alt]=\"tool.label\"\n [class]=\"openDialogToolId === tool.id ? 'toolbar-icon-active' : 'toolbar-icon-dark'\" />\n <i *ngIf=\"tool.icon.pi\" class=\"pi {{ tool.icon.pi }}\"></i>\n </p-button>\n </ng-container>\n\n <!-- Plot type: available for any image (grayscale or RGB). -->\n <ng-container *ngIf=\"imageInfo && tools.specialTools\">\n <span class=\"toolbar-separator\"></span>\n <p-dropdown [options]=\"plotTypeOptions\" optionLabel=\"label\" optionValue=\"type\"\n [(ngModel)]=\"selectedPlotType\" appendTo=\"body\"\n pTooltip=\"Select plot type\" tooltipPosition=\"bottom\"\n [style]=\"{'min-width': '11rem'}\"\n styleClass=\"plotmode-dropdown\" panelStyleClass=\"plotmode-panel\"\n (onChange)=\"selectPlotType.emit($event.value)\">\n <ng-template let-opt pTemplate=\"selectedItem\">\n <span class=\"plotmode-item\" *ngIf=\"opt\">\n <i *ngIf=\"isPiIcon(opt.icon)\" [class]=\"opt.icon\"></i>\n <img *ngIf=\"opt.icon && !isPiIcon(opt.icon)\" class=\"plotmode-icon-img\" [src]=\"opt.icon\" alt=\"\" />\n <span>{{ opt.label }}</span>\n </span>\n </ng-template>\n <ng-template let-opt pTemplate=\"item\">\n <span class=\"plotmode-item\">\n <i *ngIf=\"isPiIcon(opt.icon)\" [class]=\"opt.icon\"></i>\n <img *ngIf=\"opt.icon && !isPiIcon(opt.icon)\" class=\"plotmode-icon-img\" [src]=\"opt.icon\" alt=\"\" />\n <span>{{ opt.label }}</span>\n </span>\n </ng-template>\n </p-dropdown>\n <ng-container *ngIf=\"isIsosurfaceMode\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Iso</span>\n <p-slider [(ngModel)]=\"isoRange\" [range]=\"true\"\n [min]=\"isoValueMin\" [max]=\"isoValueMax\" [step]=\"isoValueStep\"\n (onChange)=\"isoRangeChange.emit($event.values)\"\n pTooltip=\"Isosurface band (0\u2013255), mapped onto the volume's\n actual intensity range. Surfaces are drawn for voxels whose\n intensity falls in the selected band \u2014 narrow it to isolate\n features, widen it to see the whole structure.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n styleClass=\"wand-sensitivity-slider\"></p-slider>\n <span class=\"flex align-items-center mx-1 toolbar-text wand-sensitivity-value\">\n {{ isoRange[0] }}\u2013{{ isoRange[1] }}</span>\n </ng-container>\n <!-- Intensity tools (Heatmap / Image mode): add another colored line ROI.\n Each added line gets the next bright palette colour and its own inset\n trace; select a line on the image and use the trash tool to remove it\n (and its trace). The lines are Region-based but excluded from the\n Region Editor and exports. -->\n <ng-container *ngIf=\"isIntensityCapable\">\n <span class=\"toolbar-separator\"></span>\n <p-button size=\"small\" [text]=\"true\"\n pTooltip=\"Add an intensity line (new colour). Click a line to select it,\n then use the trash tool to remove it.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"addProfileLine.emit()\">\n <img src=\"assets/plotting/spline-chart.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n </ng-container>\n </ng-container>\n <!-- Channels & Histogram: brightness/contrast, gamma, per-channel display,\n colormap/reverse/invert \u2014 opens the floating dialog. -->\n <p-button *ngIf=\"tools.specialTools\" pRipple size=\"small\" [text]=\"true\"\n pTooltip=\"Channels &amp; Histogram (brightness/contrast, colormap)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"openChannelHistogram.emit()\">\n <img src=\"assets/plotting/invert.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n\n <!-- Spatial-omics controls. Only meaningful in the spatial mode, which is\n itself only offered while a dataset is loaded \u2014 so this button appears\n exactly when there is something for it to control. -->\n <p-button *ngIf=\"tools.specialTools && isSpatialMode\" pRipple size=\"small\" [text]=\"true\"\n pTooltip=\"Spatial omics (colour by column or gene, legend, selection, distributions)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"openSpatialControls.emit()\">\n <img src=\"assets/plotting/spatial-omics.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n\n <!-- Smoothen toggle: bilinear smoothing vs nearest-neighbour raw pixels (Image\n view only \u2014 it's an OpenSeadragon drawer setting). Highlighted when smoothing on. -->\n <p-button *ngIf=\"tools.specialTools && isImageView\" size=\"small\"\n [text]=\"!imageSmoothingEnabled\"\n pTooltip=\"Smoothen (turn on image smoothing)\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleImageSmoothing.emit()\">\n <img src=\"assets/plotting/smooth.svg\" style=\"width: 1rem\"\n [class]=\"imageSmoothingEnabled ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"/>\n </p-button>\n\n <!-- Stack controls -->\n <ng-container *ngIf=\"imageInfo?.isStack\">\n <span class=\"toolbar-separator\"></span>\n <!-- Image view: scrub z-slices live (the renderer swaps slices in place). -->\n <ng-container *ngIf=\"showsLiveSliceScrubber\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Slice</span>\n <p-slider [(ngModel)]=\"zIndex\" [min]=\"0\" [max]=\"maxIndex\"\n (onChange)=\"zScrub.emit($event.value)\" (onSlideEnd)=\"zSlide.emit($event.value)\"\n pTooltip=\"Slice through the stack (\u2190 / \u2192 keys)\" tooltipPosition=\"bottom\"\n [style]=\"{'width':'16rem'}\" styleClass=\"wand-sensitivity-slider\"></p-slider>\n <span class=\"flex align-items-center mx-1 toolbar-text\">{{zIndex}} of {{maxIndex}}</span>\n </ng-container>\n <!-- Heatmap view: single-image (high-def zoom) vs stack (frame slider). Plotly-only \u2014\n hidden for napari, which has no single/stack toggle or per-frame fetch, and for the\n spatial-omics modes: a 3D omics dataset publishes its reference volume as a z-stack\n image, and these modes draw observations, not one of its slices. -->\n <ng-container *ngIf=\"!showsLiveSliceScrubber && !isNapariMode && !isSpatialMode\">\n <p-button icon=\"pi pi-image\" size=\"small\"\n [text]=\"imageInfo?.showStack\"\n pTooltip=\"Single image mode: enables high-def zoom\" tooltipPosition=\"bottom\"\n (onClick)=\"selectStackOption.emit(stackOptions[0])\">\n </p-button>\n <p-button icon=\"pi pi-images\" size=\"small\"\n [text]=\"!imageInfo?.showStack\"\n pTooltip=\"Stack mode, enables slicing through image frames\" tooltipPosition=\"bottom\"\n (onClick)=\"selectStackOption.emit(stackOptions[1])\">\n </p-button>\n <ng-container *ngIf=\"!imageInfo?.showStack\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Slice</span>\n <div class=\"p-inputgroup\" style=\"width: auto\">\n <p-inputNumber styleClass=\"w-3rem toolbar-input\"\n\t\t\t\t\tpTooltip=\"View image in stack (&#x23CE;)\"\n\t\t\t\t\t[(ngModel)]=\"zIndex\" [min]=\"0\" [max]=\"maxIndex\"\n (onInput)=\"zIndexInput.emit(zIndex)\" (keydown.enter)=\"reloadAndPlot.emit()\"/>\n <p-button icon=\"pi pi-refresh\" size=\"small\" [text]=\"true\" (onClick)=\"reloadAndPlot.emit()\"\n pTooltip=\"Plot this slice\" tooltipPosition=\"bottom\">\n </p-button>\n </div>\n <span class=\"flex align-items-center mx-1 toolbar-text\">of {{maxIndex}}</span>\n </ng-container>\n </ng-container>\n </ng-container>\n\n <!-- Image actions (special tools) -->\n <ng-container *ngIf=\"tools.specialTools\">\n <span class=\"toolbar-separator\"></span>\n <p-button icon=\"pi pi-download\" size=\"small\" [text]=\"true\"\n pTooltip=\"Download image as PNG\" tooltipPosition=\"bottom\"\n (onClick)=\"downloadImage.emit()\">\n </p-button>\n <p-button *ngIf=\"isHeatmap\" icon=\"pi pi-window-maximize\" size=\"small\" [text]=\"true\"\n pTooltip=\"Fit to view\" tooltipPosition=\"bottom\"\n (onClick)=\"autoscaleImage.emit()\">\n </p-button>\n </ng-container>\n\n <!-- Zoom tools. Plotly rubber-band drag-zoom: on OpenSeadragon the box-zoom\n covers this and a bare drag pans natively, so it's hidden in image view. -->\n <ng-container *ngIf=\"isHeatmap && tools.zoomTools\">\n <span class=\"toolbar-separator\"></span>\n <p-button *ngIf=\"!isImageView\" icon=\"pi pi-search\" size=\"small\"\n [text]=\"activeDragMode !== 'zoom'\"\n pTooltip=\"Zoom drag\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('zoom')\">\n </p-button>\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'zoomToBox'\"\n pTooltip=\"Zoom box (<kbd>b</kbd>)\" [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('zoomToBox')\">\n <img src=\"assets/plotting/zoom-box.svg\"\n [class]=\"activeDragMode === 'zoomToBox' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <p-button icon=\"pi pi-arrows-alt\" size=\"small\"\n [text]=\"activeDragMode !== 'pan'\"\n pTooltip=\"Pan the image (<kbd>p</kbd>)\" [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('pan')\">\n </p-button>\n <p-button icon=\"pi pi-search-plus\" size=\"small\" [text]=\"true\"\n pTooltip=\"Zoom in (<kbd>+</kbd>)\" [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"zoomIn.emit()\">\n </p-button>\n <p-button icon=\"pi pi-search-minus\" size=\"small\" [text]=\"true\"\n pTooltip=\"Zoom out (<kbd>-</kbd>)\" [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"zoomOut.emit()\">\n </p-button>\n </ng-container>\n\n <!-- Surface 3D controls -->\n <ng-container *ngIf=\"!isHeatmap\">\n <span class=\"toolbar-separator\"></span>\n <p-button icon=\"pi pi-search\" size=\"small\"\n [text]=\"activeSurface3dMode !== 'zoom'\"\n pTooltip=\"Zoom\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleSurface3dMode.emit('zoom')\">\n </p-button>\n <p-button icon=\"pi pi-arrows-alt\" size=\"small\"\n [text]=\"activeSurface3dMode !== 'pan'\"\n pTooltip=\"Pan\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleSurface3dMode.emit('pan')\">\n </p-button>\n <p-button icon=\"pi pi-globe\" size=\"small\"\n [text]=\"activeSurface3dMode !== 'orbit'\"\n pTooltip=\"Orbital rotation\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleSurface3dMode.emit('orbit')\">\n </p-button>\n <p-button icon=\"pi pi-sync\" size=\"small\"\n [text]=\"activeSurface3dMode !== 'turntable'\"\n pTooltip=\"Turntable rotation\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleSurface3dMode.emit('turntable')\">\n </p-button>\n <p-button icon=\"pi pi-home\" size=\"small\" [text]=\"true\"\n pTooltip=\"Reset camera to default\" tooltipPosition=\"bottom\"\n (onClick)=\"resetSurfaceCamera.emit()\">\n </p-button>\n <!-- 3D coordinate-axes / scale gizmo toggle (napari-js WebGPU volume/isosurface only).\n `p-button-icon-only` gives the same square sizing as the neighbouring pi-icon buttons\n (the projected <img> doesn't trigger PrimeNG's automatic icon-only sizing). -->\n <p-button size=\"small\" *ngIf=\"isNapariMode\" styleClass=\"p-button-icon-only\"\n [text]=\"!axesVisible\"\n pTooltip=\"Toggle 3D axes / scale\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleAxes.emit()\">\n <img src=\"assets/plotting/3d-coordinates.svg\"\n [class]=\"axesVisible ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <!-- Surface wireframe toggle (napari-js WebGPU surface only). -->\n <p-button icon=\"pi pi-share-alt\" size=\"small\" *ngIf=\"isNapariSurfaceMode\"\n [text]=\"!wireframeActive\"\n pTooltip=\"Toggle wireframe\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleWireframe.emit()\">\n </p-button>\n <!-- Resolution (decimate factor) for the napari 3D types \u2014 reloads at coarser/finer sampling. -->\n <ng-container *ngIf=\"isNapari3dMode\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Res</span>\n <p-dropdown [options]=\"decimateOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"resolutionScale\" appendTo=\"body\"\n pTooltip=\"Resolution (decimate factor)\" tooltipPosition=\"bottom\"\n [style]=\"{'min-width': '6rem'}\"\n (onChange)=\"selectResolution.emit($event.value)\">\n </p-dropdown>\n </ng-container>\n </ng-container>\n\n <ng-container *ngIf=\"(isHeatmap || is3dRegions) && tools.regionTools\">\n <span class=\"toolbar-separator\"></span>\n <!-- Region Editor: opens the editor as a floating dialog (formerly the\n right-panel Regions tab). Sits with the region drawing tools. -->\n <p-button size=\"small\" [text]=\"true\"\n pTooltip=\"Open the Region Editor\" tooltipPosition=\"bottom\"\n (onClick)=\"openRegionEditor.emit()\">\n <img src=\"assets/plotting/region-editor.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n <!-- Region drawing tools -->\n <p-button icon=\"pi pi-arrow-up-right\" size=\"small\"\n [text]=\"activeDragMode !== 'select'\"\n pTooltip=\"Selection mode (<kbd>s</kbd>)\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('select')\">\n </p-button>\n <p-button icon=\"pi pi-stop\" size=\"small\"\n [text]=\"activeDragMode !== 'drawrect'\"\n pTooltip=\"Draw a rectangular region (<kbd>r</kbd>)\" [escape]=\"false\"\n tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('drawrect')\">\n </p-button>\n <p-button *ngIf=\"!is3dRegions\" size=\"small\"\n [text]=\"activeDragMode !== 'drawopenpath'\"\n pTooltip=\"Draw a polyline (<kbd>l</kbd>)\" [escape]=\"false\"\n tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('drawopenpath')\">\n <img src=\"assets/plotting/polyline.svg\"\n [class]=\"activeDragMode === 'drawopenpath' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <p-button icon=\"pi pi-pencil\" size=\"small\"\n [text]=\"activeDragMode !== 'drawclosedpath'\"\n pTooltip=\"Draw a freeform region (<kbd>f</kbd>)\" [escape]=\"false\"\n tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('drawclosedpath')\">\n </p-button>\n <!-- Brush: paint a region by dragging a disc of the given size; the Size\n slider appears while it's active. Hold Shift to erase (subtract). -->\n <!-- Brush and polyline are absent from a 3D cloud: the brush paints in IMAGE\n pixels and there is no raster to sample, and an open polyline is a\n profile tool, not a closed region a selection can test against. -->\n <p-button *ngIf=\"!is3dRegions\" size=\"small\"\n [text]=\"activeDragMode !== 'brush'\"\n pTooltip=\"Brush: click and drag to paint a region.\n <br>Hold <kbd>Shift</kbd> while painting to erase from a region.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('brush')\">\n <img src=\"assets/plotting/paintbrush.svg\"\n [class]=\"activeDragMode === 'brush' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <ng-container *ngIf=\"activeDragMode === 'brush'\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Size</span>\n <p-slider [(ngModel)]=\"brushSize\"\n [min]=\"brushSizeMin\" [max]=\"brushSizeMax\" [step]=\"brushSizeStep\"\n (onChange)=\"brushSizeChange.emit($event.value)\"\n pTooltip=\"Brush diameter in image pixels.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n styleClass=\"wand-sensitivity-slider\"></p-slider>\n <span class=\"flex align-items-center mx-1 toolbar-text wand-sensitivity-value\">\n {{ brushSize }}</span>\n </ng-container>\n\n <!-- Vertex editing \u2014 backends with a vertex-editing overlay (OSD + napari-js image). -->\n <ng-container *ngIf=\"supportsRegionVertexTools\">\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'drawpolygon'\"\n pTooltip=\"Polygon: click to place each vertex; click the first point to close\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('drawpolygon')\">\n <img src=\"assets/plotting/polygon-vertices.svg\"\n [class]=\"activeDragMode === 'drawpolygon' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'addpoint'\"\n pTooltip=\"Add a vertex: click an edge of the selected polygon\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('addpoint')\">\n <img src=\"assets/plotting/vertex-add.svg\"\n [class]=\"activeDragMode === 'addpoint' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'deletepoint'\"\n pTooltip=\"Delete a vertex: click a vertex of the selected polygon\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('deletepoint')\">\n <img src=\"assets/plotting/vertex-delete.svg\"\n [class]=\"activeDragMode === 'deletepoint' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n </ng-container>\n\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'wand'\"\n pTooltip=\"Wand: click and drag to grow a region by similar pixel values (<kbd>w</kbd>).\n <br>Hold <kbd>Shift</kbd> while clicking or dragging on a region to erase pixels from it.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('wand')\">\n <img src=\"assets/plotting/wand.svg\"\n [class]=\"activeDragMode === 'wand' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <ng-container *ngIf=\"activeDragMode === 'wand'\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Sens.</span>\n <p-slider [(ngModel)]=\"wandSensitivity\"\n [min]=\"wandSensitivityMin\" [max]=\"wandSensitivityMax\" [step]=\"wandSensitivityStep\"\n (onChange)=\"wandSensitivityChange.emit($event.value)\"\n pTooltip=\"Higher = stricter (smaller selection); lower = looser.\n Takes effect from the next click/drag tick.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n styleClass=\"wand-sensitivity-slider\"></p-slider>\n <span class=\"flex align-items-center mx-1 toolbar-text wand-sensitivity-value\">\n {{ wandSensitivity.toFixed(1) }}</span>\n </ng-container>\n <p-button icon=\"pi pi-eraser\" size=\"small\"\n [text]=\"activeDragMode !== 'eraseVertex'\"\n pTooltip=\"Vertex eraser (<kbd>e</kbd>): click or drag to remove polygon vertices inside\n the cursor circle from any region.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('eraseVertex')\">\n </p-button>\n <ng-container *ngIf=\"activeDragMode === 'eraseVertex'\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Radius</span>\n <p-slider [(ngModel)]=\"vertexEraserRadius\"\n [min]=\"vertexEraserRadiusMin\" [max]=\"vertexEraserRadiusMax\" [step]=\"vertexEraserRadiusStep\"\n (onChange)=\"vertexEraserRadiusChange.emit($event.value)\"\n pTooltip=\"Eraser radius in image pixels.\n Vertices within this distance of the cursor are removed.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n styleClass=\"wand-sensitivity-slider\"></p-slider>\n <span class=\"flex align-items-center mx-1 toolbar-text wand-sensitivity-value\">\n {{ vertexEraserRadius }}</span>\n </ng-container>\n <p-button size=\"small\" [text]=\"true\"\n [disabled]=\"!canUndo\"\n pTooltip=\"Undo the last region action (<kbd>Ctrl</kbd>+<kbd>Z</kbd>).\n Up to 10 actions can be undone.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"undoRegion.emit()\">\n <img src=\"assets/plotting/undo.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n <p-button size=\"small\" [text]=\"true\"\n [disabled]=\"!canRedo\"\n pTooltip=\"Redo the last undone region action\n (<kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd> / <kbd>Ctrl</kbd>+<kbd>Y</kbd>).\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"redoRegion.emit()\">\n <img src=\"assets/plotting/redo.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n <p-button icon=\"pi pi-trash\" size=\"small\" [text]=\"true\"\n pTooltip=\"Delete selected region (<kbd>Del</kbd>)\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"deleteRegion.emit()\">\n </p-button>\n <!-- SAM (jit-ui#90): box-prompt segment, interactive point prompts, and a\n model picker. -->\n <span class=\"toolbar-separator\"></span>\n <!-- SAM controls grouped: box-prompt Segment, then the point-prompt tool,\n then the model-picker dropdown (the chevron) which governs BOTH tools.\n Falls back to Segment + point with no model picker when none are set. -->\n <span *ngIf=\"samModels.length; else segmentPlain\" class=\"p-buttonset segment-split\">\n <p-button size=\"small\" [text]=\"true\"\n pTooltip=\"Segment the drawn rectangles with SAM (box prompt)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"segmentRegions.emit()\">\n <img src=\"assets/plotting/sam-box-prompt.svg\"\n class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n <ng-container [ngTemplateOutlet]=\"samPointBtn\"></ng-container>\n <p-button icon=\"pi pi-chevron-down\" size=\"small\" [text]=\"true\"\n pTooltip=\"Pick the SAM model (used by both segment tools)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"samModelMenu.toggle($event)\">\n </p-button>\n </span>\n <ng-template #segmentPlain>\n <span class=\"p-buttonset segment-split\">\n <p-button size=\"small\" [text]=\"true\"\n pTooltip=\"Segment the drawn rectangles with SAM (box prompt)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"segmentRegions.emit()\">\n <img src=\"assets/plotting/sam-box-prompt.svg\"\n class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n <ng-container [ngTemplateOutlet]=\"samPointBtn\"></ng-container>\n </span>\n </ng-template>\n <!-- Point-prompt tool \u2014 shared between the models / no-models layouts so it\n always sits just left of the model dropdown. -->\n <ng-template #samPointBtn>\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'samPoint'\"\n pTooltip=\"SAM point prompts: click a fiber to segment it as a new region;\n Shift/Alt-click to refine (exclude) the current one.<br>Esc undoes it.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('samPoint')\">\n <img src=\"assets/plotting/sam-point-prompt.svg\"\n [class]=\"activeDragMode === 'samPoint' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n </ng-template>\n <p-menu #samModelMenu styleClass=\"model-menu\" [model]=\"samMenuItems\" [popup]=\"true\" appendTo=\"body\">\n <ng-template pTemplate=\"item\" let-item>\n <ng-container [ngTemplateOutlet]=\"modelMenuItem\"\n [ngTemplateOutletContext]=\"{ $implicit: item }\"></ng-container>\n </ng-template>\n </p-menu>\n\n <!-- Shared item body for all three model pickers (SAM / YOLO / retinal):\n the standard check-or-blank icon and label, plus an info icon carrying\n the model's description as a hover tooltip. This replaced a single\n \"About the SAM models\" overlay panel \u2014 per-model text sits next to the\n model it describes, and YOLO/retinal get the same affordance.\n\n p-menu puts its click handler on the wrapping `.p-menuitem-content`,\n outside this template, so `command` still fires and the whole row stays\n clickable \u2014 which is also why the info icon has to stop propagation, or\n reading about a model would switch to it. -->\n <ng-template #modelMenuItem let-item>\n <a class=\"p-menuitem-link model-menu-item\">\n <span class=\"p-menuitem-icon\" [ngClass]=\"item.icon\"></span>\n <span class=\"p-menuitem-text\">{{ item.label }}</span>\n <!-- `bottom` like every other tooltip in this toolbar, and for room:\n these menus open under buttons that can sit near the right edge,\n where a `right` tooltip has a few hundred px and squeezes its text\n into a narrow column instead of using its max-width. -->\n <!-- A focusable <button>, not a bare <i>. As an <i> the description\n was reachable only by hovering a mouse: not tabbable, no\n accessible name, nothing for a screen reader to announce \u2014 so\n keyboard and AT users could pick a checkpoint but never read what\n it was. PrimeNG's tooltip shows on focus as well as hover, so\n making it a button restores it for free.\n\n `aria-describedby` is deliberately not used: the tooltip is\n rendered into `body` on demand and has no stable id to point at.\n The description is carried on `aria-label` instead, which is\n announced whether or not the tooltip is showing. It is stripped of\n markup first, since the copy contains `<b>`/`<br>` for the visual\n tooltip and those would otherwise be read out literally. -->\n <button *ngIf=\"item.tooltip\" type=\"button\"\n class=\"pi pi-info-circle model-info-icon\"\n [attr.aria-label]=\"'About ' + item.label + ': ' + plainText(item.tooltip)\"\n [pTooltip]=\"item.tooltip\" [escape]=\"false\" tooltipPosition=\"bottom\"\n appendTo=\"body\" tooltipStyleClass=\"model-info-tooltip\"\n (click)=\"$event.stopPropagation()\"\n (keydown.enter)=\"$event.stopPropagation()\"\n (keydown.space)=\"$event.stopPropagation()\"></button>\n </a>\n </ng-template>\n <p-button size=\"small\" [text]=\"true\"\n pTooltip=\"Auto-segment cells inside each drawn rectangle (cellpose-SAM)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"segmentCellpose.emit()\">\n <img src=\"assets/plotting/cells.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n\n <!-- Contributed no-prompt tools (TOOLBAR_TOOLS). Unlike the SAM and\n cellpose tools these take no prompt \u2014 they sweep the whole current\n view rather than being pointed at something \u2014 so there is nothing to\n draw first. Each renders the same split button: run, pick checkpoint,\n open parameters.\n\n Nothing here names a specific tool. The library ships none; a host\n registers them, which is what lets this package be published without\n the closed model dependencies. -->\n <ng-container *ngFor=\"let tool of contributedTools; trackBy: trackToolById\">\n <span class=\"p-buttonset segment-split\">\n <p-button size=\"small\" [text]=\"true\"\n [pTooltip]=\"tool.runTooltip\"\n tooltipPosition=\"bottom\"\n (onClick)=\"runTool.emit(tool.id)\">\n <!-- `toolbar-icon-dark` is hardcoded rather than bound, unlike the\n toggle tools: these fire a run and have no active state. -->\n <img *ngIf=\"tool.icon.src\" [src]=\"tool.icon.src\" class=\"toolbar-icon-dark\"\n style=\"width: 1rem\" [alt]=\"tool.label\" />\n <i *ngIf=\"tool.icon.pi\" class=\"pi {{ tool.icon.pi }}\"></i>\n </p-button>\n <p-button icon=\"pi pi-chevron-down\" size=\"small\" [text]=\"true\"\n [pTooltip]=\"tool.modelTooltip || 'Pick the model'\"\n tooltipPosition=\"bottom\"\n (onClick)=\"toolMenu.toggle($event)\">\n </p-button>\n <p-button *ngIf=\"tool.params.length\"\n icon=\"pi pi-sliders-h\" size=\"small\" [text]=\"true\"\n [pTooltip]=\"tool.paramsTooltip || 'Parameters'\"\n tooltipPosition=\"bottom\"\n (onClick)=\"openToolParams.emit(tool.id)\">\n </p-button>\n </span>\n <p-menu #toolMenu styleClass=\"model-menu\" [model]=\"toolMenuItems[tool.id] || []\"\n [popup]=\"true\" appendTo=\"body\">\n <ng-template pTemplate=\"item\" let-item>\n <ng-container [ngTemplateOutlet]=\"modelMenuItem\"\n [ngTemplateOutletContext]=\"{ $implicit: item }\"></ng-container>\n </ng-template>\n </p-menu>\n </ng-container>\n </ng-container>\n\n <ng-container *ngIf=\"tools.help\">\n <span class=\"toolbar-separator\"></span>\n\n <!-- Help -->\n <p-button icon=\"pi pi-question-circle\" size=\"small\" [text]=\"true\"\n pTooltip=\"Help\" tooltipPosition=\"bottom\"\n (onClick)=\"showHelp()\">\n </p-button>\n </ng-container>\n\n </div>\n</p-toolbar>\n<p-dialog header=\"Plotting help\" [(visible)]=\"displayHelpDialog\" [modal]=\"true\" [style]=\"{width: '50vw'}\"\n [maximizable]=\"true\" [draggable]=\"false\" [resizable]=\"false\">\n <p>Click a file in the left-hand tree to display it. By default it opens in the\n <b>Image</b> view \u2014 a natively tiled, deeply zoomable raster (OpenSeadragon) with a\n navigator minimap and a physical-units scale bar. <b>Click the image to zoom</b> toward\n the clicked point; scroll to zoom and drag to pan. When a stack is open, a slice slider\n (Image view) or image-mode toggle (other views) appears. Regions can also be edited in the\n <b>Region Editor</b>, opened with the\n <img src=\"assets/plotting/region-editor.svg\" width=\"14\" height=\"14\" alt=\"Region Editor\"/>\n toolbar button as a floating dialog. Drag the <i class=\"pi pi-bars\"></i> handle to detach\n this toolbar into a floating window.\n </p>\n\n <h3>Plot-type dropdown</h3>\n <p>\n The <b>plot-type dropdown</b> switches how the file is visualized:\n <b>Image</b>, <b>Heatmap</b>, <b>Contour</b>, <b>Surface</b>, <b>Volume</b>,\n <b>Isosurface</b>, and \u2014 when a spatial-omics dataset is open \u2014\n <b>Spatial omics</b> and <b>Spatial omics 3D</b>.\n </p>\n <p>\n <b>Only the modes the data can actually support are offered</b>, so the list changes\n with what is loaded rather than showing entries that would come up blank:\n </p>\n <ul>\n <li><b>Contour</b>, <b>Volume</b> and <b>Isosurface</b> need a grayscale image; the\n two 3D ones also need a z-stack.</li>\n <li><b>Spatial omics</b> needs a published dataset, and <b>Spatial omics 3D</b> needs\n one whose coordinates carry a z.</li>\n <li>The pixel modes (<b>Image</b>, <b>Heatmap</b>, <b>Surface</b>) are withheld for a\n dataset that brings no tissue image \u2014 seqFISH records coordinates and no section, so\n there is nothing for them to draw.</li>\n </ul>\n <ul>\n <li>\"<b>Isosurface band</b>\" slider (Isosurface): sets the intensity band the surfaces are\n drawn for, mapped onto the volume's real intensity range.</li>\n <li>\"<b>3D camera</b>\" (Surface / Scatter 3D / Isosurface): zoom <i class=\"pi pi-search\"></i>,\n pan <i class=\"pi pi-arrows-alt\"></i>, orbit <i class=\"pi pi-globe\"></i>,\n turntable <i class=\"pi pi-sync\"></i>, and reset camera <i class=\"pi pi-home\"></i>;\n scroll to zoom the 3D scene.</li>\n </ul>\n\n <h3>Image manipulation tools</h3>\n <ul>\n <li>\"<b>Region Editor</b>\" <img src=\"assets/plotting/region-editor.svg\" width=\"15\" height=\"15\">:\n opens the Region Editor as a floating, draggable dialog \u2014 a per-image table of every region\n (class, colour, area) with bulk select/label/colour/delete and GeoJSON import/export. It stays\n in sync with the image, so you can keep it open while drawing.</li>\n <li>\"<b>Intensity profile lines</b>\" <img src=\"assets/plotting/spline-chart.svg\" width=\"15\" height=\"15\">\n (<i>work in progress \u2014 not yet stable</i>): adds a coloured line ROI; a floating inset\n chart plots intensity along each line, updates live as you drag it, and re-samples at the\n current zoom resolution. Each added line gets its own colour; select a line and use the\n trash tool <i class=\"pi pi-trash\"></i> to remove it (and its trace).</li>\n <li>\"<b>Channels &amp; Histogram</b>\" <img src=\"assets/plotting/invert.svg\" width=\"15\" height=\"15\">:\n opens a floating dialog for brightness/contrast, gamma, and per-channel display, plus the\n <b>colormap / LUT</b> controls (grayscale only) \u2014 a <b>reverse</b> toggle and a colormap\n dropdown (default: inverted greys).</li>\n <li>\"<b>Smoothen</b>\" <img src=\"assets/plotting/smooth.svg\" width=\"15\" height=\"15\">\n (Image view only): the image opens showing <b>raw pixels</b> (nearest-neighbour), so\n zooming in renders crisp blocks for pixel inspection. Toggle this on for bilinear\n smoothing; the icon turns white while smoothing is active.</li>\n </ul>\n\n <h3>Download &amp; view tools</h3>\n <ul>\n <li>\"<b>Download</b>\" <i class=\"pi pi-download\"></i>: download the current image as a PNG.</li>\n <li>\"<b>Fit to view</b>\" / autoscale <i class=\"pi pi-window-maximize\"></i>: resets the image\n to its original dimensions. In stack mode it also resets to single-image mode.</li>\n </ul>\n\n <h3>Zoom tools</h3>\n <ul>\n <li>\"<b>Zoom box</b>\" <img src=\"assets/plotting/zoom-box.svg\" width=\"15\" height=\"15\">\n (<kbd>b</kbd>): drag a box to select an area to zoom into.</li>\n <li>\"<b>Pan</b>\" <i class=\"pi pi-arrows-alt\"></i> (<kbd>p</kbd>): drag to pan the image.</li>\n <li>\"<b>Zoom in</b>\" <i class=\"pi pi-search-plus\"></i> (<kbd>+</kbd>): zoom in one step.</li>\n <li>\"<b>Zoom out</b>\" <i class=\"pi pi-search-minus\"></i> (<kbd>-</kbd>): zoom out one step,\n stopping at the original image dimensions.</li>\n </ul>\n\n <h3>Region tools</h3>\n <p>By default no tool is selected (all greyed-out); selecting a tool highlights it. The\n vertex-editing tools (polygon, add/delete vertex, move, B\u00E9zier conversions) are available\n in the <b>Image</b> view only. On a <b>donut</b> (a region with a hole), the inner-ring\n vertices are editable too \u2014 drag, add, and delete them just like the outer ring.</p>\n <ul>\n <li>\"<b>Selection</b>\" <i class=\"pi pi-arrow-up-right\"></i> (<kbd>s</kbd>): a neutral mode\n that turns off whatever drawing tool was active (wand, vertex eraser, rectangle, etc.).\n Use it to step out of editing without picking another tool.</li>\n <li>\"<b>Rectangular region</b>\" <i class=\"pi pi-stop\"></i> (<kbd>r</kbd>): click and drag to\n create a rectangular region.</li>\n <li>\"<b>Polyline</b>\" <img src=\"assets/plotting/polyline.svg\" width=\"14\" height=\"14\">\n (<kbd>l</kbd>): draw an open (non-closed) polyline by clicking and dragging. Polylines can\n be edited and exported to GeoJSON as a <code>LineString</code> feature.</li>\n <li>\"<b>Freeform region</b>\" <i class=\"pi pi-pencil\"></i> (<kbd>f</kbd>): click and drag to\n draw a closed freeform polygon.</li>\n <li>\"<b>Brush</b>\" <img src=\"assets/plotting/paintbrush.svg\" width=\"15\" height=\"15\">: click\n and drag to paint a region with a circular brush \u2014 the painted stroke is the region.\n A <b>Size</b> slider (brush diameter, in image pixels) appears in the toolbar while the\n brush is active. Hold <kbd>Shift</kbd> while painting to <b>erase</b> (subtract) from a\n region instead. Painting over an existing region extends it; brushing across two regions\n merges them. Painting a closed ring (or shift-erasing an interior pocket) leaves the\n enclosed area empty \u2014 a <b>donut</b> (Image view).</li>\n <li>\"<b>Polygon</b>\" <img src=\"assets/plotting/polygon-vertices.svg\" width=\"15\" height=\"15\">:\n click to place each vertex; click the first point to close the polygon.</li>\n <li>\"<b>Add vertex</b>\" <img src=\"assets/plotting/vertex-add.svg\" width=\"15\" height=\"15\">:\n click an edge of the selected polygon to insert a new vertex.</li>\n <li>\"<b>Delete vertex</b>\" <img src=\"assets/plotting/vertex-delete.svg\" width=\"15\" height=\"15\">:\n click a vertex of the selected polygon to remove it.</li>\n <li>\"<b>Move region</b>\" <img src=\"assets/plotting/region-move.svg\" width=\"15\" height=\"15\">:\n drag to move the selected region.</li>\n <li>\"<b>To B\u00E9zier</b>\" <img src=\"assets/plotting/to-bezier.svg\" width=\"15\" height=\"15\"> /\n \"<b>To polygon</b>\" <img src=\"assets/plotting/to-polygon.svg\" width=\"15\" height=\"15\">:\n convert the selected region to a smooth B\u00E9zier curve, or back to a straight-edged polygon.</li>\n <li>\"<b>Wand</b>\" <img src=\"assets/plotting/wand.svg\" width=\"15\" height=\"15\"> (<kbd>w</kbd>):\n click to grow a region from pixels with similar values around the click point. Hold\n <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> for an exact-match flood fill; drag to keep extending the\n selection while moving; hold <kbd>Shift</kbd> while clicking or dragging on an existing\n region to <b>erase</b> pixels from it. Use the <b>Sens.</b> slider that appears to make the\n wand stricter (higher = smaller selection) or looser. Subsequent clicks keep extending the\n same region; clicking inside an existing region adopts it, and growing into another region\n merges them.</li>\n <li>\"<b>Vertex eraser</b>\" <i class=\"pi pi-eraser\"></i> (<kbd>e</kbd>): click or click-and-drag\n to remove polygon vertices that fall inside the cursor circle from any region, including a\n region's inner rings (donut holes). Use the <b>Radius</b> slider to set the eraser size in\n image pixels. Polygons reduced below 3 vertices (or polylines below 2) are deleted entirely;\n a hole reduced below 3 vertices is removed.</li>\n <li>\"<b>Undo</b>\" <img src=\"assets/plotting/undo.svg\" width=\"15\" height=\"15\">\n (<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Z</kbd>): reverts the\n last region action (draw, delete, edit, wand/brush stroke, segmentation, \u2026). Up to the last\n <b>10</b> actions can be undone; the button greys out when there's nothing left to undo.</li>\n <li>\"<b>Redo</b>\" <img src=\"assets/plotting/redo.svg\" width=\"15\" height=\"15\">\n (<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd> or <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Y</kbd>):\n re-applies the most recently undone action. Performing a new region action clears what can be redone.</li>\n <li>\"<b>Delete region</b>\" <i class=\"pi pi-trash\"></i> (<kbd>Del</kbd>): deletes the selected\n region. This can also be done from the Region Editor.</li>\n </ul>\n\n <h3>Region actions (right-click the image)</h3>\n <p>Right-click a region \u2014 or the current multi-selection \u2014 to open the\n \"<b>Selected region(s)</b>\" group at the top of the context menu. These act immediately on\n the selection (they're not drawing modes) and are fully undo/redo-able:</p>\n <ul>\n <li>\"<b>Select all regions</b>\" <i class=\"pi pi-check-square\"></i>: select every region on\n the image (also in the Region Editor toolbar).</li>\n <li>\"<b>Merge / group</b>\": combine 2+ selected regions into one. If the pieces don't touch,\n the result is a single multi-part region (sparse \"bits\").</li>\n <li>\"<b>Ungroup</b>\": split a multi-part region back into one region per piece.</li>\n <li>\"<b>Inverse</b>\": replace the selection with everything <i>outside</i> it \u2014 the image\n rectangle with the selected regions punched out as holes. Works at any image size.</li>\n <li>\"<b>Simplify</b>\": reduce a region's vertex count (Douglas\u2013Peucker) by an altitude\n threshold in pixels \u2014 pick <b>Light</b> (1&nbsp;px), <b>Medium</b> (3&nbsp;px),\n <b>Strong</b> (8&nbsp;px), or <b>Custom\u2026</b> for a slider. Applies to the exterior and any\n holes (and every part of a multi-part region).</li>\n <li>The B\u00E9zier conversions and Delete also live in this group (the right-click equivalents of\n the toolbar / vertex tools above).</li>\n </ul>\n\n <h3>Segmentation &amp; detection tools</h3>\n <p>AI-assisted segmentation and detection that run entirely in the browser. The first run\n of any model downloads it (a progress toast is shown); it's then cached.</p>\n <p>Two shapes of tool: the <b>prompted</b> ones (Segment, Point prompts, Cellpose) act on\n rectangles you draw, and generated regions inherit the color of the rectangle they came\n from<span *ngIf=\"contributedTools.length\">; the <b>no-prompt</b> ones ({{ contributedToolNames }})\n sweep the whole current view, so there is nothing to draw first</span>.</p>\n <p>Each model dropdown <i class=\"pi pi-chevron-down\"></i> lists the available checkpoints with\n a <i class=\"pi pi-info-circle\"></i> beside each one \u2014 hover it for that model's size, speed,\n training domain and accuracy, so you can pick without leaving the toolbar.</p>\n <ul>\n <li>\"<b>Segment</b>\" <img src=\"assets/plotting/sam-box-prompt.svg\" width=\"15\" height=\"15\">\n (box prompt): draw one or more <b>Rectangular regions</b> around the objects of interest,\n then click Segment. Each rectangle is replaced by the SAM mask of the object inside it.</li>\n <li>\"<b>Point prompts</b>\" <img src=\"assets/plotting/sam-point-prompt.svg\" width=\"15\" height=\"15\">: click directly on an object to\n segment it as a <b>new</b> region. Each click starts a fresh object \u2014 clicking another\n object won't grow the previous one. <kbd>Shift</kbd>/<kbd>Alt</kbd>-click adds an\n <i>exclude</i> point that refines the current object; <kbd>Esc</kbd> undoes it,\n <kbd>Enter</kbd> commits it.</li>\n <li>\"<b>Model picker</b>\" <i class=\"pi pi-chevron-down\"></i>: the dropdown attached to the\n Segment button chooses the SAM model \u2014 <b>micro-sam ViT-T</b> (default; small, fast) or\n <b>micro-sam ViT-B</b> (larger, highest accuracy) for light microscopy, or\n <b>patho-sam ViT-B</b> for histopathology (H&amp;E / stained tissue). All are promptable\n Segment Anything models finetuned for biomedical images; they differ in the <i>image\n encoder</i> (the heavy once-per-image step) and the training domain \u2014 box and point\n prompting behave the same. The choice applies to <i>both</i> the box and point tools, and\n each tool loads its own encoder, so the first click after switching re-encodes once.</li>\n <li>\"<b>Cellpose</b>\" <img src=\"assets/plotting/cells.svg\" width=\"15\" height=\"15\">: draw rectangles, then click Cellpose to\n automatically segment every cell inside each rectangle (client-side cellpose-SAM) \u2014 one\n region per detected cell. No clicking on individual cells needed.</li>\n <!-- Contributed tools describe themselves. This library ships none, so an\n open build's help lists only the prompted tools above \u2014 the text no\n longer promises buttons that are not there. -->\n <ng-container *ngFor=\"let tool of contributedTools\">\n <li *ngIf=\"tool.help\" [innerHTML]=\"tool.help.body\"></li>\n </ng-container>\n <ng-container *ngFor=\"let tool of dialogTools\">\n <li *ngIf=\"tool.help\" [innerHTML]=\"tool.help.body\"></li>\n </ng-container>\n </ul>\n\n <h3>Spatial omics</h3>\n <p>\n In <b>Spatial omics</b> mode each observation \u2014 a cell or a Visium spot \u2014 is drawn at\n its own coordinates, over the tissue image where the dataset registers onto one. The\n <img src=\"assets/plotting/spatial-omics.svg\" width=\"14\" height=\"14\" alt=\"Spatial omics\"/>\n panel colours them by a <b>column</b> (a cell type, a cluster) or by a <b>gene</b>, and\n the two are exclusive so what drives the colours is never ambiguous.\n </p>\n <ul>\n <li>\"<b>Select from ROIs</b>\": turns the regions you have drawn into a selection of\n observations. Everything else then follows that selection.</li>\n <li>\"<b>Point size</b>\" / \"<b>Opacity</b>\": how the markers are drawn, not what is\n selected.</li>\n </ul>\n\n <h3>Distribution charts</h3>\n <p>\n The <b>Distribution</b> section of the spatial-omics panel charts whatever the map is\n coloured by, so the two can never disagree. Each tab answers a different question, and\n the <i class=\"pi pi-question-circle\"></i> beside them explains the one on screen \u2014\n including what it must <em>not</em> be read for.\n </p>\n <ul>\n <li>\"<b>Counts</b>\": how many observations per category. A category is a label, not a\n magnitude, so a frequency is the only distribution it has.</li>\n <li>\"<b>Histogram</b>\" / \"<b>Violin</b>\" / \"<b>Box</b>\": how a continuous value is\n distributed \u2014 over everything, or split by a grouping column.</li>\n <li>\"<b>Heatmap</b>\": mean expression of the genes you pick within each group.\n Z-scored per gene by default, so a colour means \"above or below this gene's own\n average\" rather than \"highly expressed\".</li>\n <li>\"<b>Embedding</b>\": a UMAP, PCA or t-SNE over the same cells. <b>Lasso it to\n select on the map</b> \u2014 both views read the same selection. Only PCA labels its axes\n with a percentage, because only its axes explain a measurable share of the variance;\n UMAP and t-SNE coordinates are arbitrary, so distance between distant clusters\n should not be read.</li>\n <li>\"<b>Detach</b>\": moves the charts into their own window so they can sit beside the\n map instead of inside the panel. Switching tabs keeps the window where it is.</li>\n </ul>\n\n <h3>Help</h3>\n <p>\"<b>Help</b>\" <i class=\"pi pi-question-circle\"></i>: opens this dialog.</p>\n\n <h3>Keyboard shortcuts</h3>\n <table class=\"shortcuts-table\">\n <tr><td><kbd>+</kbd> / <kbd>=</kbd></td><td>Zoom in</td></tr>\n <tr><td><kbd>-</kbd></td><td>Zoom out</td></tr>\n <tr><td><kbd>b</kbd></td><td>Toggle zoom box mode</td></tr>\n <tr><td><kbd>p</kbd></td><td>Toggle pan mode</td></tr>\n <tr><td><kbd>f</kbd></td><td>Toggle freeform region drawing</td></tr>\n <tr><td><kbd>l</kbd></td><td>Toggle polyline drawing</td></tr>\n <tr><td><kbd>r</kbd></td><td>Toggle rectangular region drawing</td></tr>\n <tr><td><kbd>w</kbd></td><td>Toggle wand tool</td></tr>\n <tr><td>Wand + <kbd>Shift</kbd></td><td>Erase pixels from the active region</td></tr>\n <tr><td>Wand + <kbd>Ctrl</kbd>/<kbd>Cmd</kbd></td><td>Exact-match flood fill (no smoothing)</td></tr>\n <tr><td><kbd>e</kbd></td><td>Toggle vertex eraser</td></tr>\n <tr><td><kbd>s</kbd></td><td>Selection mode (deactivates drawing tools)</td></tr>\n <tr><td>Point prompt + <kbd>Shift</kbd>/<kbd>Alt</kbd></td><td>Add an exclude point (refine the current object)</td></tr>\n <tr><td><kbd>Enter</kbd></td><td>Commit the current SAM point object</td></tr>\n <tr><td><kbd>Esc</kbd></td><td>Undo the current SAM point object</td></tr>\n <tr><td><kbd>Del</kbd> / <kbd>\u232B</kbd></td><td>Delete selected region</td></tr>\n <tr><td><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Z</kbd></td><td>Undo the last region action (up to 10)</td></tr>\n <tr><td><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd> / <kbd>Ctrl</kbd>+<kbd>Y</kbd></td><td>Redo the last undone region action</td></tr>\n </table>\n\n <ng-template pTemplate=\"footer\">\n <p-button icon=\"pi pi-check\" (click)=\"displayHelpDialog=false\" label=\"Ok\" styleClass=\"p-button-text\">\n </p-button>\n </ng-template>\n</p-dialog>\n", styles: [":host{display:block}:host ::ng-deep .p-toolbar{padding:.25rem .5rem}.toolbar-text{color:var(--primary-color);font-size:.875rem}::ng-deep .toolbar-input .p-inputtext{color:var(--primary-color);border-color:var(--primary-color)}.toolbar-separator{border-left:1px solid var(--surface-border);height:1.5rem;margin:0 .25rem;align-self:center}.toolbar-icon-dark{filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}.toolbar-icon-active{filter:brightness(0) invert(1)}::ng-deep .plotmode-item{display:inline-flex;align-items:center;gap:.5rem;color:#000}::ng-deep .plotmode-item i{font-size:1rem;color:var(--primary-color)}::ng-deep .plotmode-item .plotmode-icon-img{width:1.1rem;height:1.1rem;object-fit:contain;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .p-menu.model-menu{width:auto;min-width:12.5rem}::ng-deep .model-menu-item{display:flex;align-items:center;gap:.5rem}::ng-deep .model-menu-item .p-menuitem-text{flex:1 1 auto;white-space:nowrap}::ng-deep .model-menu-item .model-info-icon{flex:0 0 auto;font-size:.875rem;opacity:.55;cursor:help;appearance:none;background:none;border:0;padding:0;margin:0;color:inherit;line-height:1}::ng-deep .model-menu-item .model-info-icon:hover{opacity:1}::ng-deep .model-menu-item .model-info-icon:focus-visible{opacity:1;outline:2px solid var(--primary-color, #2196f3);outline-offset:2px;border-radius:2px}::ng-deep .model-info-tooltip .p-tooltip-text{min-width:18rem;max-width:24rem;line-height:1.4}::ng-deep .wand-sensitivity-slider{width:8rem;align-self:center;margin:0 .25rem}.wand-sensitivity-value{min-width:2rem;text-align:right}span.colormap{width:50px;height:auto;display:inline-block}img.colormap{width:50px;height:auto}.colormap{background:no-repeat;background-size:100%;vertical-align:middle}kbd{background:#e8e8e8;border:1px solid #bbb;border-radius:3px;padding:1px 5px;font-family:monospace;font-size:.85em}.shortcuts-table{border-collapse:collapse}.shortcuts-table td{padding:.25rem .75rem .25rem 0;vertical-align:middle}.shortcuts-table td:first-child{white-space:nowrap}\n"] }]
6940
+ args: [{ selector: 'plotting-toolbar', template: "<p-toolbar>\n <div class=\"p-toolbar-group-start flex-wrap align-items-center gap-1\">\n\n <!-- Host-provided custom toolbar buttons, shown first (e.g. app-specific\n actions the visualization view itself doesn't own). -->\n <ng-content></ng-content>\n\n <!-- Contributed dialog tools (TOOLBAR_TOOLS, kind: 'dialog'): app-level actions\n like the host's own buttons above, so they sit right beside them. Image view\n only, the view their dialog draws over. Highlighted while the dialog is open. -->\n <ng-container *ngIf=\"isImageView\">\n <p-button *ngFor=\"let tool of dialogTools; trackBy: trackToolById\"\n size=\"small\" [text]=\"openDialogToolId !== tool.id\"\n [pTooltip]=\"tool.tooltip\" tooltipPosition=\"bottom\"\n [ariaLabel]=\"tool.label\"\n [attr.aria-pressed]=\"openDialogToolId === tool.id\"\n (onClick)=\"toggleDialogTool.emit(tool.id)\">\n <img *ngIf=\"tool.icon.src\" [src]=\"tool.icon.src\" style=\"width: 1rem\" [alt]=\"tool.label\"\n [class]=\"openDialogToolId === tool.id ? 'toolbar-icon-active' : 'toolbar-icon-dark'\" />\n <i *ngIf=\"tool.icon.pi\" class=\"pi {{ tool.icon.pi }}\"></i>\n </p-button>\n </ng-container>\n\n <!-- Plot type: available for any image (grayscale or RGB), and for a spatial dataset\n that brings none (its spatial modes are still a choice). -->\n <ng-container *ngIf=\"showPlotTypes\">\n <span class=\"toolbar-separator\"></span>\n <p-dropdown [options]=\"plotTypeOptions\" optionLabel=\"label\" optionValue=\"type\"\n [(ngModel)]=\"selectedPlotType\" appendTo=\"body\"\n pTooltip=\"Select plot type\" tooltipPosition=\"bottom\"\n [style]=\"{'min-width': '11rem'}\"\n styleClass=\"plotmode-dropdown\" panelStyleClass=\"plotmode-panel\"\n (onChange)=\"selectPlotType.emit($event.value)\">\n <ng-template let-opt pTemplate=\"selectedItem\">\n <span class=\"plotmode-item\" *ngIf=\"opt\">\n <i *ngIf=\"isPiIcon(opt.icon)\" [class]=\"opt.icon\"></i>\n <img *ngIf=\"opt.icon && !isPiIcon(opt.icon)\" class=\"plotmode-icon-img\" [src]=\"opt.icon\" alt=\"\" />\n <span>{{ opt.label }}</span>\n </span>\n </ng-template>\n <ng-template let-opt pTemplate=\"item\">\n <span class=\"plotmode-item\">\n <i *ngIf=\"isPiIcon(opt.icon)\" [class]=\"opt.icon\"></i>\n <img *ngIf=\"opt.icon && !isPiIcon(opt.icon)\" class=\"plotmode-icon-img\" [src]=\"opt.icon\" alt=\"\" />\n <span>{{ opt.label }}</span>\n </span>\n </ng-template>\n </p-dropdown>\n <ng-container *ngIf=\"isIsosurfaceMode\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Iso</span>\n <p-slider [(ngModel)]=\"isoRange\" [range]=\"true\"\n [min]=\"isoValueMin\" [max]=\"isoValueMax\" [step]=\"isoValueStep\"\n (onChange)=\"isoRangeChange.emit($event.values)\"\n pTooltip=\"Isosurface band (0\u2013255), mapped onto the volume's\n actual intensity range. Surfaces are drawn for voxels whose\n intensity falls in the selected band \u2014 narrow it to isolate\n features, widen it to see the whole structure.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n styleClass=\"wand-sensitivity-slider\"></p-slider>\n <span class=\"flex align-items-center mx-1 toolbar-text wand-sensitivity-value\">\n {{ isoRange[0] }}\u2013{{ isoRange[1] }}</span>\n </ng-container>\n <!-- Intensity tools (Heatmap / Image mode): add another colored line ROI.\n Each added line gets the next bright palette colour and its own inset\n trace; select a line on the image and use the trash tool to remove it\n (and its trace). The lines are Region-based but excluded from the\n Region Editor and exports. -->\n <ng-container *ngIf=\"isIntensityCapable\">\n <span class=\"toolbar-separator\"></span>\n <p-button size=\"small\" [text]=\"true\"\n pTooltip=\"Add an intensity line (new colour). Click a line to select it,\n then use the trash tool to remove it.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"addProfileLine.emit()\">\n <img src=\"assets/plotting/spline-chart.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n </ng-container>\n </ng-container>\n <!-- Channels & Histogram: brightness/contrast, gamma, per-channel display,\n colormap/reverse/invert \u2014 opens the floating dialog. -->\n <p-button *ngIf=\"tools.specialTools\" pRipple size=\"small\" [text]=\"true\"\n pTooltip=\"Channels &amp; Histogram (brightness/contrast, colormap)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"openChannelHistogram.emit()\">\n <img src=\"assets/plotting/invert.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n\n <!-- Spatial-omics controls. Only meaningful in the spatial mode, which is\n itself only offered while a dataset is loaded \u2014 so this button appears\n exactly when there is something for it to control. -->\n <p-button *ngIf=\"tools.specialTools && isSpatialMode\" pRipple size=\"small\" [text]=\"true\"\n pTooltip=\"Spatial omics (colour by column or gene, legend, selection, distributions)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"openSpatialControls.emit()\">\n <img src=\"assets/plotting/spatial-omics.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n\n <!-- Smoothen toggle: bilinear smoothing vs nearest-neighbour raw pixels (Image\n view only \u2014 it's an OpenSeadragon drawer setting). Highlighted when smoothing on. -->\n <p-button *ngIf=\"tools.specialTools && isImageView\" size=\"small\"\n [text]=\"!imageSmoothingEnabled\"\n pTooltip=\"Smoothen (turn on image smoothing)\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleImageSmoothing.emit()\">\n <img src=\"assets/plotting/smooth.svg\" style=\"width: 1rem\"\n [class]=\"imageSmoothingEnabled ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"/>\n </p-button>\n\n <!-- Stack controls -->\n <ng-container *ngIf=\"imageInfo?.isStack\">\n <span class=\"toolbar-separator\"></span>\n <!-- Image view: scrub z-slices live (the renderer swaps slices in place). -->\n <ng-container *ngIf=\"showsLiveSliceScrubber\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Slice</span>\n <p-slider [(ngModel)]=\"zIndex\" [min]=\"0\" [max]=\"maxIndex\"\n (onChange)=\"zScrub.emit($event.value)\" (onSlideEnd)=\"zSlide.emit($event.value)\"\n pTooltip=\"Slice through the stack (\u2190 / \u2192 keys)\" tooltipPosition=\"bottom\"\n [style]=\"{'width':'16rem'}\" styleClass=\"wand-sensitivity-slider\"></p-slider>\n <span class=\"flex align-items-center mx-1 toolbar-text\">{{zIndex}} of {{maxIndex}}</span>\n </ng-container>\n <!-- Heatmap view: single-image (high-def zoom) vs stack (frame slider). Plotly-only \u2014\n hidden for napari, which has no single/stack toggle or per-frame fetch, and for the\n spatial-omics modes: a 3D omics dataset publishes its reference volume as a z-stack\n image, and these modes draw observations, not one of its slices. -->\n <ng-container *ngIf=\"!showsLiveSliceScrubber && !isNapariMode && !isSpatialMode\">\n <p-button icon=\"pi pi-image\" size=\"small\"\n [text]=\"imageInfo?.showStack\"\n pTooltip=\"Single image mode: enables high-def zoom\" tooltipPosition=\"bottom\"\n (onClick)=\"selectStackOption.emit(stackOptions[0])\">\n </p-button>\n <p-button icon=\"pi pi-images\" size=\"small\"\n [text]=\"!imageInfo?.showStack\"\n pTooltip=\"Stack mode, enables slicing through image frames\" tooltipPosition=\"bottom\"\n (onClick)=\"selectStackOption.emit(stackOptions[1])\">\n </p-button>\n <ng-container *ngIf=\"!imageInfo?.showStack\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Slice</span>\n <div class=\"p-inputgroup\" style=\"width: auto\">\n <p-inputNumber styleClass=\"w-3rem toolbar-input\"\n\t\t\t\t\tpTooltip=\"View image in stack (&#x23CE;)\"\n\t\t\t\t\t[(ngModel)]=\"zIndex\" [min]=\"0\" [max]=\"maxIndex\"\n (onInput)=\"zIndexInput.emit(zIndex)\" (keydown.enter)=\"reloadAndPlot.emit()\"/>\n <p-button icon=\"pi pi-refresh\" size=\"small\" [text]=\"true\" (onClick)=\"reloadAndPlot.emit()\"\n pTooltip=\"Plot this slice\" tooltipPosition=\"bottom\">\n </p-button>\n </div>\n <span class=\"flex align-items-center mx-1 toolbar-text\">of {{maxIndex}}</span>\n </ng-container>\n </ng-container>\n </ng-container>\n\n <!-- Image actions (special tools) -->\n <ng-container *ngIf=\"tools.specialTools\">\n <span class=\"toolbar-separator\"></span>\n <p-button icon=\"pi pi-download\" size=\"small\" [text]=\"true\"\n pTooltip=\"Download image as PNG\" tooltipPosition=\"bottom\"\n (onClick)=\"downloadImage.emit()\">\n </p-button>\n <p-button *ngIf=\"isHeatmap\" icon=\"pi pi-window-maximize\" size=\"small\" [text]=\"true\"\n pTooltip=\"Fit to view\" tooltipPosition=\"bottom\"\n (onClick)=\"autoscaleImage.emit()\">\n </p-button>\n </ng-container>\n\n <!-- Zoom tools. Plotly rubber-band drag-zoom: on OpenSeadragon the box-zoom\n covers this and a bare drag pans natively, so it's hidden in image view. -->\n <ng-container *ngIf=\"isHeatmap && tools.zoomTools\">\n <span class=\"toolbar-separator\"></span>\n <p-button *ngIf=\"!isImageView\" icon=\"pi pi-search\" size=\"small\"\n [text]=\"activeDragMode !== 'zoom'\"\n pTooltip=\"Zoom drag\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('zoom')\">\n </p-button>\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'zoomToBox'\"\n pTooltip=\"Zoom box (<kbd>b</kbd>)\" [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('zoomToBox')\">\n <img src=\"assets/plotting/zoom-box.svg\"\n [class]=\"activeDragMode === 'zoomToBox' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <p-button icon=\"pi pi-arrows-alt\" size=\"small\"\n [text]=\"activeDragMode !== 'pan'\"\n pTooltip=\"Pan the image (<kbd>p</kbd>)\" [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('pan')\">\n </p-button>\n <p-button icon=\"pi pi-search-plus\" size=\"small\" [text]=\"true\"\n pTooltip=\"Zoom in (<kbd>+</kbd>)\" [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"zoomIn.emit()\">\n </p-button>\n <p-button icon=\"pi pi-search-minus\" size=\"small\" [text]=\"true\"\n pTooltip=\"Zoom out (<kbd>-</kbd>)\" [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"zoomOut.emit()\">\n </p-button>\n </ng-container>\n\n <!-- Surface 3D controls -->\n <ng-container *ngIf=\"!isHeatmap\">\n <span class=\"toolbar-separator\"></span>\n <p-button icon=\"pi pi-search\" size=\"small\"\n [text]=\"activeSurface3dMode !== 'zoom'\"\n pTooltip=\"Zoom\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleSurface3dMode.emit('zoom')\">\n </p-button>\n <p-button icon=\"pi pi-arrows-alt\" size=\"small\"\n [text]=\"activeSurface3dMode !== 'pan'\"\n pTooltip=\"Pan\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleSurface3dMode.emit('pan')\">\n </p-button>\n <p-button icon=\"pi pi-globe\" size=\"small\"\n [text]=\"activeSurface3dMode !== 'orbit'\"\n pTooltip=\"Orbital rotation\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleSurface3dMode.emit('orbit')\">\n </p-button>\n <p-button icon=\"pi pi-sync\" size=\"small\"\n [text]=\"activeSurface3dMode !== 'turntable'\"\n pTooltip=\"Turntable rotation\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleSurface3dMode.emit('turntable')\">\n </p-button>\n <p-button icon=\"pi pi-home\" size=\"small\" [text]=\"true\"\n pTooltip=\"Reset camera to default\" tooltipPosition=\"bottom\"\n (onClick)=\"resetSurfaceCamera.emit()\">\n </p-button>\n <!-- 3D coordinate-axes / scale gizmo toggle (napari-js WebGPU volume/isosurface only).\n `p-button-icon-only` gives the same square sizing as the neighbouring pi-icon buttons\n (the projected <img> doesn't trigger PrimeNG's automatic icon-only sizing). -->\n <p-button size=\"small\" *ngIf=\"isNapariMode\" styleClass=\"p-button-icon-only\"\n [text]=\"!axesVisible\"\n pTooltip=\"Toggle 3D axes / scale\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleAxes.emit()\">\n <img src=\"assets/plotting/3d-coordinates.svg\"\n [class]=\"axesVisible ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <!-- Surface wireframe toggle (napari-js WebGPU surface only). -->\n <p-button icon=\"pi pi-share-alt\" size=\"small\" *ngIf=\"isNapariSurfaceMode\"\n [text]=\"!wireframeActive\"\n pTooltip=\"Toggle wireframe\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleWireframe.emit()\">\n </p-button>\n <!-- Resolution (decimate factor) for the napari 3D types \u2014 reloads at coarser/finer sampling. -->\n <ng-container *ngIf=\"isNapari3dMode\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Res</span>\n <p-dropdown [options]=\"decimateOptions\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"resolutionScale\" appendTo=\"body\"\n pTooltip=\"Resolution (decimate factor)\" tooltipPosition=\"bottom\"\n [style]=\"{'min-width': '6rem'}\"\n (onChange)=\"selectResolution.emit($event.value)\">\n </p-dropdown>\n </ng-container>\n </ng-container>\n\n <ng-container *ngIf=\"(isHeatmap || is3dRegions) && tools.regionTools\">\n <span class=\"toolbar-separator\"></span>\n <!-- Region Editor: opens the editor as a floating dialog (formerly the\n right-panel Regions tab). Sits with the region drawing tools. -->\n <p-button size=\"small\" [text]=\"true\"\n pTooltip=\"Open the Region Editor\" tooltipPosition=\"bottom\"\n (onClick)=\"openRegionEditor.emit()\">\n <img src=\"assets/plotting/region-editor.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n <!-- Region drawing tools -->\n <p-button icon=\"pi pi-arrow-up-right\" size=\"small\"\n [text]=\"activeDragMode !== 'select'\"\n pTooltip=\"Selection mode (<kbd>s</kbd>)\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('select')\">\n </p-button>\n <p-button icon=\"pi pi-stop\" size=\"small\"\n [text]=\"activeDragMode !== 'drawrect'\"\n pTooltip=\"Draw a rectangular region (<kbd>r</kbd>)\" [escape]=\"false\"\n tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('drawrect')\">\n </p-button>\n <p-button *ngIf=\"!is3dRegions\" size=\"small\"\n [text]=\"activeDragMode !== 'drawopenpath'\"\n pTooltip=\"Draw a polyline (<kbd>l</kbd>)\" [escape]=\"false\"\n tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('drawopenpath')\">\n <img src=\"assets/plotting/polyline.svg\"\n [class]=\"activeDragMode === 'drawopenpath' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <p-button icon=\"pi pi-pencil\" size=\"small\"\n [text]=\"activeDragMode !== 'drawclosedpath'\"\n pTooltip=\"Draw a freeform region (<kbd>f</kbd>)\" [escape]=\"false\"\n tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('drawclosedpath')\">\n </p-button>\n <!-- Brush: paint a region by dragging a disc of the given size; the Size\n slider appears while it's active. Hold Shift to erase (subtract). -->\n <!-- Brush and polyline are absent from a 3D cloud: the brush paints in IMAGE\n pixels and there is no raster to sample, and an open polyline is a\n profile tool, not a closed region a selection can test against. -->\n <p-button *ngIf=\"!is3dRegions\" size=\"small\"\n [text]=\"activeDragMode !== 'brush'\"\n pTooltip=\"Brush: click and drag to paint a region.\n <br>Hold <kbd>Shift</kbd> while painting to erase from a region.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('brush')\">\n <img src=\"assets/plotting/paintbrush.svg\"\n [class]=\"activeDragMode === 'brush' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <ng-container *ngIf=\"activeDragMode === 'brush'\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Size</span>\n <p-slider [(ngModel)]=\"brushSize\"\n [min]=\"brushSizeMin\" [max]=\"brushSizeMax\" [step]=\"brushSizeStep\"\n (onChange)=\"brushSizeChange.emit($event.value)\"\n pTooltip=\"Brush diameter in image pixels.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n styleClass=\"wand-sensitivity-slider\"></p-slider>\n <span class=\"flex align-items-center mx-1 toolbar-text wand-sensitivity-value\">\n {{ brushSize }}</span>\n </ng-container>\n\n <!-- Vertex editing \u2014 backends with a vertex-editing overlay (OSD + napari-js image). -->\n <ng-container *ngIf=\"supportsRegionVertexTools\">\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'drawpolygon'\"\n pTooltip=\"Polygon: click to place each vertex; click the first point to close\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('drawpolygon')\">\n <img src=\"assets/plotting/polygon-vertices.svg\"\n [class]=\"activeDragMode === 'drawpolygon' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'addpoint'\"\n pTooltip=\"Add a vertex: click an edge of the selected polygon\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('addpoint')\">\n <img src=\"assets/plotting/vertex-add.svg\"\n [class]=\"activeDragMode === 'addpoint' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'deletepoint'\"\n pTooltip=\"Delete a vertex: click a vertex of the selected polygon\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('deletepoint')\">\n <img src=\"assets/plotting/vertex-delete.svg\"\n [class]=\"activeDragMode === 'deletepoint' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n </ng-container>\n\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'wand'\"\n pTooltip=\"Wand: click and drag to grow a region by similar pixel values (<kbd>w</kbd>).\n <br>Hold <kbd>Shift</kbd> while clicking or dragging on a region to erase pixels from it.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('wand')\">\n <img src=\"assets/plotting/wand.svg\"\n [class]=\"activeDragMode === 'wand' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n <ng-container *ngIf=\"activeDragMode === 'wand'\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Sens.</span>\n <p-slider [(ngModel)]=\"wandSensitivity\"\n [min]=\"wandSensitivityMin\" [max]=\"wandSensitivityMax\" [step]=\"wandSensitivityStep\"\n (onChange)=\"wandSensitivityChange.emit($event.value)\"\n pTooltip=\"Higher = stricter (smaller selection); lower = looser.\n Takes effect from the next click/drag tick.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n styleClass=\"wand-sensitivity-slider\"></p-slider>\n <span class=\"flex align-items-center mx-1 toolbar-text wand-sensitivity-value\">\n {{ wandSensitivity.toFixed(1) }}</span>\n </ng-container>\n <p-button icon=\"pi pi-eraser\" size=\"small\"\n [text]=\"activeDragMode !== 'eraseVertex'\"\n pTooltip=\"Vertex eraser (<kbd>e</kbd>): click or drag to remove polygon vertices inside\n the cursor circle from any region.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('eraseVertex')\">\n </p-button>\n <ng-container *ngIf=\"activeDragMode === 'eraseVertex'\">\n <span class=\"flex align-items-center mx-1 toolbar-text\">Radius</span>\n <p-slider [(ngModel)]=\"vertexEraserRadius\"\n [min]=\"vertexEraserRadiusMin\" [max]=\"vertexEraserRadiusMax\" [step]=\"vertexEraserRadiusStep\"\n (onChange)=\"vertexEraserRadiusChange.emit($event.value)\"\n pTooltip=\"Eraser radius in image pixels.\n Vertices within this distance of the cursor are removed.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n styleClass=\"wand-sensitivity-slider\"></p-slider>\n <span class=\"flex align-items-center mx-1 toolbar-text wand-sensitivity-value\">\n {{ vertexEraserRadius }}</span>\n </ng-container>\n <p-button size=\"small\" [text]=\"true\"\n [disabled]=\"!canUndo\"\n pTooltip=\"Undo the last region action (<kbd>Ctrl</kbd>+<kbd>Z</kbd>).\n Up to 10 actions can be undone.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"undoRegion.emit()\">\n <img src=\"assets/plotting/undo.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n <p-button size=\"small\" [text]=\"true\"\n [disabled]=\"!canRedo\"\n pTooltip=\"Redo the last undone region action\n (<kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd> / <kbd>Ctrl</kbd>+<kbd>Y</kbd>).\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"redoRegion.emit()\">\n <img src=\"assets/plotting/redo.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n <p-button icon=\"pi pi-trash\" size=\"small\" [text]=\"true\"\n pTooltip=\"Delete selected region (<kbd>Del</kbd>)\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"deleteRegion.emit()\">\n </p-button>\n <!-- SAM (jit-ui#90): box-prompt segment, interactive point prompts, and a\n model picker. -->\n <span class=\"toolbar-separator\"></span>\n <!-- SAM controls grouped: box-prompt Segment, then the point-prompt tool,\n then the model-picker dropdown (the chevron) which governs BOTH tools.\n Falls back to Segment + point with no model picker when none are set. -->\n <span *ngIf=\"samModels.length; else segmentPlain\" class=\"p-buttonset segment-split\">\n <p-button size=\"small\" [text]=\"true\"\n pTooltip=\"Segment the drawn rectangles with SAM (box prompt)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"segmentRegions.emit()\">\n <img src=\"assets/plotting/sam-box-prompt.svg\"\n class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n <ng-container [ngTemplateOutlet]=\"samPointBtn\"></ng-container>\n <p-button icon=\"pi pi-chevron-down\" size=\"small\" [text]=\"true\"\n pTooltip=\"Pick the SAM model (used by both segment tools)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"samModelMenu.toggle($event)\">\n </p-button>\n </span>\n <ng-template #segmentPlain>\n <span class=\"p-buttonset segment-split\">\n <p-button size=\"small\" [text]=\"true\"\n pTooltip=\"Segment the drawn rectangles with SAM (box prompt)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"segmentRegions.emit()\">\n <img src=\"assets/plotting/sam-box-prompt.svg\"\n class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n <ng-container [ngTemplateOutlet]=\"samPointBtn\"></ng-container>\n </span>\n </ng-template>\n <!-- Point-prompt tool \u2014 shared between the models / no-models layouts so it\n always sits just left of the model dropdown. -->\n <ng-template #samPointBtn>\n <p-button size=\"small\"\n [text]=\"activeDragMode !== 'samPoint'\"\n pTooltip=\"SAM point prompts: click a fiber to segment it as a new region;\n Shift/Alt-click to refine (exclude) the current one.<br>Esc undoes it.\"\n [escape]=\"false\" tooltipPosition=\"bottom\"\n (onClick)=\"toggleDragMode.emit('samPoint')\">\n <img src=\"assets/plotting/sam-point-prompt.svg\"\n [class]=\"activeDragMode === 'samPoint' ? 'toolbar-icon-active' : 'toolbar-icon-dark'\"\n style=\"width: 1rem\"/>\n </p-button>\n </ng-template>\n <p-menu #samModelMenu styleClass=\"model-menu\" [model]=\"samMenuItems\" [popup]=\"true\" appendTo=\"body\">\n <ng-template pTemplate=\"item\" let-item>\n <ng-container [ngTemplateOutlet]=\"modelMenuItem\"\n [ngTemplateOutletContext]=\"{ $implicit: item }\"></ng-container>\n </ng-template>\n </p-menu>\n\n <!-- Shared item body for all three model pickers (SAM / YOLO / retinal):\n the standard check-or-blank icon and label, plus an info icon carrying\n the model's description as a hover tooltip. This replaced a single\n \"About the SAM models\" overlay panel \u2014 per-model text sits next to the\n model it describes, and YOLO/retinal get the same affordance.\n\n p-menu puts its click handler on the wrapping `.p-menuitem-content`,\n outside this template, so `command` still fires and the whole row stays\n clickable \u2014 which is also why the info icon has to stop propagation, or\n reading about a model would switch to it. -->\n <ng-template #modelMenuItem let-item>\n <a class=\"p-menuitem-link model-menu-item\">\n <span class=\"p-menuitem-icon\" [ngClass]=\"item.icon\"></span>\n <span class=\"p-menuitem-text\">{{ item.label }}</span>\n <!-- `bottom` like every other tooltip in this toolbar, and for room:\n these menus open under buttons that can sit near the right edge,\n where a `right` tooltip has a few hundred px and squeezes its text\n into a narrow column instead of using its max-width. -->\n <!-- A focusable <button>, not a bare <i>. As an <i> the description\n was reachable only by hovering a mouse: not tabbable, no\n accessible name, nothing for a screen reader to announce \u2014 so\n keyboard and AT users could pick a checkpoint but never read what\n it was. PrimeNG's tooltip shows on focus as well as hover, so\n making it a button restores it for free.\n\n `aria-describedby` is deliberately not used: the tooltip is\n rendered into `body` on demand and has no stable id to point at.\n The description is carried on `aria-label` instead, which is\n announced whether or not the tooltip is showing. It is stripped of\n markup first, since the copy contains `<b>`/`<br>` for the visual\n tooltip and those would otherwise be read out literally. -->\n <button *ngIf=\"item.tooltip\" type=\"button\"\n class=\"pi pi-info-circle model-info-icon\"\n [attr.aria-label]=\"'About ' + item.label + ': ' + plainText(item.tooltip)\"\n [pTooltip]=\"item.tooltip\" [escape]=\"false\" tooltipPosition=\"bottom\"\n appendTo=\"body\" tooltipStyleClass=\"model-info-tooltip\"\n (click)=\"$event.stopPropagation()\"\n (keydown.enter)=\"$event.stopPropagation()\"\n (keydown.space)=\"$event.stopPropagation()\"></button>\n </a>\n </ng-template>\n <p-button size=\"small\" [text]=\"true\"\n pTooltip=\"Auto-segment cells inside each drawn rectangle (cellpose-SAM)\"\n tooltipPosition=\"bottom\"\n (onClick)=\"segmentCellpose.emit()\">\n <img src=\"assets/plotting/cells.svg\" class=\"toolbar-icon-dark\" style=\"width: 1rem\"/>\n </p-button>\n\n <!-- Contributed no-prompt tools (TOOLBAR_TOOLS). Unlike the SAM and\n cellpose tools these take no prompt \u2014 they sweep the whole current\n view rather than being pointed at something \u2014 so there is nothing to\n draw first. Each renders the same split button: run, pick checkpoint,\n open parameters.\n\n Nothing here names a specific tool. The library ships none; a host\n registers them, which is what lets this package be published without\n the closed model dependencies. -->\n <ng-container *ngFor=\"let tool of contributedTools; trackBy: trackToolById\">\n <span class=\"p-buttonset segment-split\">\n <p-button size=\"small\" [text]=\"true\"\n [pTooltip]=\"tool.runTooltip\"\n tooltipPosition=\"bottom\"\n (onClick)=\"runTool.emit(tool.id)\">\n <!-- `toolbar-icon-dark` is hardcoded rather than bound, unlike the\n toggle tools: these fire a run and have no active state. -->\n <img *ngIf=\"tool.icon.src\" [src]=\"tool.icon.src\" class=\"toolbar-icon-dark\"\n style=\"width: 1rem\" [alt]=\"tool.label\" />\n <i *ngIf=\"tool.icon.pi\" class=\"pi {{ tool.icon.pi }}\"></i>\n </p-button>\n <p-button icon=\"pi pi-chevron-down\" size=\"small\" [text]=\"true\"\n [pTooltip]=\"tool.modelTooltip || 'Pick the model'\"\n tooltipPosition=\"bottom\"\n (onClick)=\"toolMenu.toggle($event)\">\n </p-button>\n <p-button *ngIf=\"tool.params.length\"\n icon=\"pi pi-sliders-h\" size=\"small\" [text]=\"true\"\n [pTooltip]=\"tool.paramsTooltip || 'Parameters'\"\n tooltipPosition=\"bottom\"\n (onClick)=\"openToolParams.emit(tool.id)\">\n </p-button>\n </span>\n <p-menu #toolMenu styleClass=\"model-menu\" [model]=\"toolMenuItems[tool.id] || []\"\n [popup]=\"true\" appendTo=\"body\">\n <ng-template pTemplate=\"item\" let-item>\n <ng-container [ngTemplateOutlet]=\"modelMenuItem\"\n [ngTemplateOutletContext]=\"{ $implicit: item }\"></ng-container>\n </ng-template>\n </p-menu>\n </ng-container>\n </ng-container>\n\n <ng-container *ngIf=\"tools.help\">\n <span class=\"toolbar-separator\"></span>\n\n <!-- Help -->\n <p-button icon=\"pi pi-question-circle\" size=\"small\" [text]=\"true\"\n pTooltip=\"Help\" tooltipPosition=\"bottom\"\n (onClick)=\"showHelp()\">\n </p-button>\n </ng-container>\n\n </div>\n</p-toolbar>\n<p-dialog header=\"Plotting help\" [(visible)]=\"displayHelpDialog\" [modal]=\"true\" [style]=\"{width: '50vw'}\"\n [maximizable]=\"true\" [draggable]=\"false\" [resizable]=\"false\">\n <p>Click a file in the left-hand tree to display it. By default it opens in the\n <b>Image</b> view \u2014 a natively tiled, deeply zoomable raster (OpenSeadragon) with a\n navigator minimap and a physical-units scale bar. <b>Click the image to zoom</b> toward\n the clicked point; scroll to zoom and drag to pan. When a stack is open, a slice slider\n (Image view) or image-mode toggle (other views) appears. Regions can also be edited in the\n <b>Region Editor</b>, opened with the\n <img src=\"assets/plotting/region-editor.svg\" width=\"14\" height=\"14\" alt=\"Region Editor\"/>\n toolbar button as a floating dialog. Drag the <i class=\"pi pi-bars\"></i> handle to detach\n this toolbar into a floating window.\n </p>\n\n <h3>Plot-type dropdown</h3>\n <p>\n The <b>plot-type dropdown</b> switches how the file is visualized:\n <b>Image</b>, <b>Heatmap</b>, <b>Contour</b>, <b>Surface</b>, <b>Volume</b>,\n <b>Isosurface</b>, and \u2014 when a spatial-omics dataset is open \u2014\n <b>Spatial omics</b> and <b>Spatial omics 3D</b>.\n </p>\n <p>\n <b>Only the modes the data can actually support are offered</b>, so the list changes\n with what is loaded rather than showing entries that would come up blank:\n </p>\n <ul>\n <li><b>Contour</b>, <b>Volume</b> and <b>Isosurface</b> need a grayscale image; the\n two 3D ones also need a z-stack.</li>\n <li><b>Spatial omics</b> needs a published dataset, and <b>Spatial omics 3D</b> needs\n one whose coordinates carry a z.</li>\n <li>The pixel modes (<b>Image</b>, <b>Heatmap</b>, <b>Surface</b>) are withheld for a\n dataset that brings no tissue image \u2014 seqFISH records coordinates and no section, so\n there is nothing for them to draw.</li>\n </ul>\n <ul>\n <li>\"<b>Isosurface band</b>\" slider (Isosurface): sets the intensity band the surfaces are\n drawn for, mapped onto the volume's real intensity range.</li>\n <li>\"<b>3D camera</b>\" (Surface / Scatter 3D / Isosurface): zoom <i class=\"pi pi-search\"></i>,\n pan <i class=\"pi pi-arrows-alt\"></i>, orbit <i class=\"pi pi-globe\"></i>,\n turntable <i class=\"pi pi-sync\"></i>, and reset camera <i class=\"pi pi-home\"></i>;\n scroll to zoom the 3D scene.</li>\n </ul>\n\n <h3>Image manipulation tools</h3>\n <ul>\n <li>\"<b>Region Editor</b>\" <img src=\"assets/plotting/region-editor.svg\" width=\"15\" height=\"15\">:\n opens the Region Editor as a floating, draggable dialog \u2014 a per-image table of every region\n (class, colour, area) with bulk select/label/colour/delete and GeoJSON import/export. It stays\n in sync with the image, so you can keep it open while drawing.</li>\n <li>\"<b>Intensity profile lines</b>\" <img src=\"assets/plotting/spline-chart.svg\" width=\"15\" height=\"15\">\n (<i>work in progress \u2014 not yet stable</i>): adds a coloured line ROI; a floating inset\n chart plots intensity along each line, updates live as you drag it, and re-samples at the\n current zoom resolution. Each added line gets its own colour; select a line and use the\n trash tool <i class=\"pi pi-trash\"></i> to remove it (and its trace).</li>\n <li>\"<b>Channels &amp; Histogram</b>\" <img src=\"assets/plotting/invert.svg\" width=\"15\" height=\"15\">:\n opens a floating dialog for brightness/contrast, gamma, and per-channel display, plus the\n <b>colormap / LUT</b> controls (grayscale only) \u2014 a <b>reverse</b> toggle and a colormap\n dropdown (default: inverted greys).</li>\n <li>\"<b>Smoothen</b>\" <img src=\"assets/plotting/smooth.svg\" width=\"15\" height=\"15\">\n (Image view only): the image opens showing <b>raw pixels</b> (nearest-neighbour), so\n zooming in renders crisp blocks for pixel inspection. Toggle this on for bilinear\n smoothing; the icon turns white while smoothing is active.</li>\n </ul>\n\n <h3>Download &amp; view tools</h3>\n <ul>\n <li>\"<b>Download</b>\" <i class=\"pi pi-download\"></i>: download the current image as a PNG.</li>\n <li>\"<b>Fit to view</b>\" / autoscale <i class=\"pi pi-window-maximize\"></i>: resets the image\n to its original dimensions. In stack mode it also resets to single-image mode.</li>\n </ul>\n\n <h3>Zoom tools</h3>\n <ul>\n <li>\"<b>Zoom box</b>\" <img src=\"assets/plotting/zoom-box.svg\" width=\"15\" height=\"15\">\n (<kbd>b</kbd>): drag a box to select an area to zoom into.</li>\n <li>\"<b>Pan</b>\" <i class=\"pi pi-arrows-alt\"></i> (<kbd>p</kbd>): drag to pan the image.</li>\n <li>\"<b>Zoom in</b>\" <i class=\"pi pi-search-plus\"></i> (<kbd>+</kbd>): zoom in one step.</li>\n <li>\"<b>Zoom out</b>\" <i class=\"pi pi-search-minus\"></i> (<kbd>-</kbd>): zoom out one step,\n stopping at the original image dimensions.</li>\n </ul>\n\n <h3>Region tools</h3>\n <p>By default no tool is selected (all greyed-out); selecting a tool highlights it. The\n vertex-editing tools (polygon, add/delete vertex, move, B\u00E9zier conversions) are available\n in the <b>Image</b> view only. On a <b>donut</b> (a region with a hole), the inner-ring\n vertices are editable too \u2014 drag, add, and delete them just like the outer ring.</p>\n <ul>\n <li>\"<b>Selection</b>\" <i class=\"pi pi-arrow-up-right\"></i> (<kbd>s</kbd>): a neutral mode\n that turns off whatever drawing tool was active (wand, vertex eraser, rectangle, etc.).\n Use it to step out of editing without picking another tool.</li>\n <li>\"<b>Rectangular region</b>\" <i class=\"pi pi-stop\"></i> (<kbd>r</kbd>): click and drag to\n create a rectangular region.</li>\n <li>\"<b>Polyline</b>\" <img src=\"assets/plotting/polyline.svg\" width=\"14\" height=\"14\">\n (<kbd>l</kbd>): draw an open (non-closed) polyline by clicking and dragging. Polylines can\n be edited and exported to GeoJSON as a <code>LineString</code> feature.</li>\n <li>\"<b>Freeform region</b>\" <i class=\"pi pi-pencil\"></i> (<kbd>f</kbd>): click and drag to\n draw a closed freeform polygon.</li>\n <li>\"<b>Brush</b>\" <img src=\"assets/plotting/paintbrush.svg\" width=\"15\" height=\"15\">: click\n and drag to paint a region with a circular brush \u2014 the painted stroke is the region.\n A <b>Size</b> slider (brush diameter, in image pixels) appears in the toolbar while the\n brush is active. Hold <kbd>Shift</kbd> while painting to <b>erase</b> (subtract) from a\n region instead. Painting over an existing region extends it; brushing across two regions\n merges them. Painting a closed ring (or shift-erasing an interior pocket) leaves the\n enclosed area empty \u2014 a <b>donut</b> (Image view).</li>\n <li>\"<b>Polygon</b>\" <img src=\"assets/plotting/polygon-vertices.svg\" width=\"15\" height=\"15\">:\n click to place each vertex; click the first point to close the polygon.</li>\n <li>\"<b>Add vertex</b>\" <img src=\"assets/plotting/vertex-add.svg\" width=\"15\" height=\"15\">:\n click an edge of the selected polygon to insert a new vertex.</li>\n <li>\"<b>Delete vertex</b>\" <img src=\"assets/plotting/vertex-delete.svg\" width=\"15\" height=\"15\">:\n click a vertex of the selected polygon to remove it.</li>\n <li>\"<b>Move region</b>\" <img src=\"assets/plotting/region-move.svg\" width=\"15\" height=\"15\">:\n drag to move the selected region.</li>\n <li>\"<b>To B\u00E9zier</b>\" <img src=\"assets/plotting/to-bezier.svg\" width=\"15\" height=\"15\"> /\n \"<b>To polygon</b>\" <img src=\"assets/plotting/to-polygon.svg\" width=\"15\" height=\"15\">:\n convert the selected region to a smooth B\u00E9zier curve, or back to a straight-edged polygon.</li>\n <li>\"<b>Wand</b>\" <img src=\"assets/plotting/wand.svg\" width=\"15\" height=\"15\"> (<kbd>w</kbd>):\n click to grow a region from pixels with similar values around the click point. Hold\n <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> for an exact-match flood fill; drag to keep extending the\n selection while moving; hold <kbd>Shift</kbd> while clicking or dragging on an existing\n region to <b>erase</b> pixels from it. Use the <b>Sens.</b> slider that appears to make the\n wand stricter (higher = smaller selection) or looser. Subsequent clicks keep extending the\n same region; clicking inside an existing region adopts it, and growing into another region\n merges them.</li>\n <li>\"<b>Vertex eraser</b>\" <i class=\"pi pi-eraser\"></i> (<kbd>e</kbd>): click or click-and-drag\n to remove polygon vertices that fall inside the cursor circle from any region, including a\n region's inner rings (donut holes). Use the <b>Radius</b> slider to set the eraser size in\n image pixels. Polygons reduced below 3 vertices (or polylines below 2) are deleted entirely;\n a hole reduced below 3 vertices is removed.</li>\n <li>\"<b>Undo</b>\" <img src=\"assets/plotting/undo.svg\" width=\"15\" height=\"15\">\n (<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Z</kbd>): reverts the\n last region action (draw, delete, edit, wand/brush stroke, segmentation, \u2026). Up to the last\n <b>10</b> actions can be undone; the button greys out when there's nothing left to undo.</li>\n <li>\"<b>Redo</b>\" <img src=\"assets/plotting/redo.svg\" width=\"15\" height=\"15\">\n (<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd> or <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Y</kbd>):\n re-applies the most recently undone action. Performing a new region action clears what can be redone.</li>\n <li>\"<b>Delete region</b>\" <i class=\"pi pi-trash\"></i> (<kbd>Del</kbd>): deletes the selected\n region. This can also be done from the Region Editor.</li>\n </ul>\n\n <h3>Region actions (right-click the image)</h3>\n <p>Right-click a region \u2014 or the current multi-selection \u2014 to open the\n \"<b>Selected region(s)</b>\" group at the top of the context menu. These act immediately on\n the selection (they're not drawing modes) and are fully undo/redo-able:</p>\n <ul>\n <li>\"<b>Select all regions</b>\" <i class=\"pi pi-check-square\"></i>: select every region on\n the image (also in the Region Editor toolbar).</li>\n <li>\"<b>Merge / group</b>\": combine 2+ selected regions into one. If the pieces don't touch,\n the result is a single multi-part region (sparse \"bits\").</li>\n <li>\"<b>Ungroup</b>\": split a multi-part region back into one region per piece.</li>\n <li>\"<b>Inverse</b>\": replace the selection with everything <i>outside</i> it \u2014 the image\n rectangle with the selected regions punched out as holes. Works at any image size.</li>\n <li>\"<b>Simplify</b>\": reduce a region's vertex count (Douglas\u2013Peucker) by an altitude\n threshold in pixels \u2014 pick <b>Light</b> (1&nbsp;px), <b>Medium</b> (3&nbsp;px),\n <b>Strong</b> (8&nbsp;px), or <b>Custom\u2026</b> for a slider. Applies to the exterior and any\n holes (and every part of a multi-part region).</li>\n <li>The B\u00E9zier conversions and Delete also live in this group (the right-click equivalents of\n the toolbar / vertex tools above).</li>\n </ul>\n\n <h3>Segmentation &amp; detection tools</h3>\n <p>AI-assisted segmentation and detection that run entirely in the browser. The first run\n of any model downloads it (a progress toast is shown); it's then cached.</p>\n <p>Two shapes of tool: the <b>prompted</b> ones (Segment, Point prompts, Cellpose) act on\n rectangles you draw, and generated regions inherit the color of the rectangle they came\n from<span *ngIf=\"contributedTools.length\">; the <b>no-prompt</b> ones ({{ contributedToolNames }})\n sweep the whole current view, so there is nothing to draw first</span>.</p>\n <p>Each model dropdown <i class=\"pi pi-chevron-down\"></i> lists the available checkpoints with\n a <i class=\"pi pi-info-circle\"></i> beside each one \u2014 hover it for that model's size, speed,\n training domain and accuracy, so you can pick without leaving the toolbar.</p>\n <ul>\n <li>\"<b>Segment</b>\" <img src=\"assets/plotting/sam-box-prompt.svg\" width=\"15\" height=\"15\">\n (box prompt): draw one or more <b>Rectangular regions</b> around the objects of interest,\n then click Segment. Each rectangle is replaced by the SAM mask of the object inside it.</li>\n <li>\"<b>Point prompts</b>\" <img src=\"assets/plotting/sam-point-prompt.svg\" width=\"15\" height=\"15\">: click directly on an object to\n segment it as a <b>new</b> region. Each click starts a fresh object \u2014 clicking another\n object won't grow the previous one. <kbd>Shift</kbd>/<kbd>Alt</kbd>-click adds an\n <i>exclude</i> point that refines the current object; <kbd>Esc</kbd> undoes it,\n <kbd>Enter</kbd> commits it.</li>\n <li>\"<b>Model picker</b>\" <i class=\"pi pi-chevron-down\"></i>: the dropdown attached to the\n Segment button chooses the SAM model \u2014 <b>micro-sam ViT-T</b> (default; small, fast) or\n <b>micro-sam ViT-B</b> (larger, highest accuracy) for light microscopy, or\n <b>patho-sam ViT-B</b> for histopathology (H&amp;E / stained tissue). All are promptable\n Segment Anything models finetuned for biomedical images; they differ in the <i>image\n encoder</i> (the heavy once-per-image step) and the training domain \u2014 box and point\n prompting behave the same. The choice applies to <i>both</i> the box and point tools, and\n each tool loads its own encoder, so the first click after switching re-encodes once.</li>\n <li>\"<b>Cellpose</b>\" <img src=\"assets/plotting/cells.svg\" width=\"15\" height=\"15\">: draw rectangles, then click Cellpose to\n automatically segment every cell inside each rectangle (client-side cellpose-SAM) \u2014 one\n region per detected cell. No clicking on individual cells needed.</li>\n <!-- Contributed tools describe themselves. This library ships none, so an\n open build's help lists only the prompted tools above \u2014 the text no\n longer promises buttons that are not there. -->\n <ng-container *ngFor=\"let tool of contributedTools\">\n <li *ngIf=\"tool.help\" [innerHTML]=\"tool.help.body\"></li>\n </ng-container>\n <ng-container *ngFor=\"let tool of dialogTools\">\n <li *ngIf=\"tool.help\" [innerHTML]=\"tool.help.body\"></li>\n </ng-container>\n </ul>\n\n <h3>Spatial omics</h3>\n <p>\n In <b>Spatial omics</b> mode each observation \u2014 a cell or a Visium spot \u2014 is drawn at\n its own coordinates, over the tissue image where the dataset registers onto one. The\n <img src=\"assets/plotting/spatial-omics.svg\" width=\"14\" height=\"14\" alt=\"Spatial omics\"/>\n panel colours them by a <b>column</b> (a cell type, a cluster) or by a <b>gene</b>, and\n the two are exclusive so what drives the colours is never ambiguous.\n </p>\n <ul>\n <li>\"<b>Select from ROIs</b>\": turns the regions you have drawn into a selection of\n observations. Everything else then follows that selection.</li>\n <li>\"<b>Point size</b>\" / \"<b>Opacity</b>\": how the markers are drawn, not what is\n selected.</li>\n </ul>\n\n <h3>Distribution charts</h3>\n <p>\n The <b>Distribution</b> section of the spatial-omics panel charts whatever the map is\n coloured by, so the two can never disagree. Each tab answers a different question, and\n the <i class=\"pi pi-question-circle\"></i> beside them explains the one on screen \u2014\n including what it must <em>not</em> be read for.\n </p>\n <ul>\n <li>\"<b>Counts</b>\": how many observations per category. A category is a label, not a\n magnitude, so a frequency is the only distribution it has.</li>\n <li>\"<b>Histogram</b>\" / \"<b>Violin</b>\" / \"<b>Box</b>\": how a continuous value is\n distributed \u2014 over everything, or split by a grouping column.</li>\n <li>\"<b>Heatmap</b>\": mean expression of the genes you pick within each group.\n Z-scored per gene by default, so a colour means \"above or below this gene's own\n average\" rather than \"highly expressed\".</li>\n <li>\"<b>Embedding</b>\": a UMAP, PCA or t-SNE over the same cells. <b>Lasso it to\n select on the map</b> \u2014 both views read the same selection. Only PCA labels its axes\n with a percentage, because only its axes explain a measurable share of the variance;\n UMAP and t-SNE coordinates are arbitrary, so distance between distant clusters\n should not be read.</li>\n <li>\"<b>Detach</b>\": moves the charts into their own window so they can sit beside the\n map instead of inside the panel. Switching tabs keeps the window where it is.</li>\n </ul>\n\n <h3>Help</h3>\n <p>\"<b>Help</b>\" <i class=\"pi pi-question-circle\"></i>: opens this dialog.</p>\n\n <h3>Keyboard shortcuts</h3>\n <table class=\"shortcuts-table\">\n <tr><td><kbd>+</kbd> / <kbd>=</kbd></td><td>Zoom in</td></tr>\n <tr><td><kbd>-</kbd></td><td>Zoom out</td></tr>\n <tr><td><kbd>b</kbd></td><td>Toggle zoom box mode</td></tr>\n <tr><td><kbd>p</kbd></td><td>Toggle pan mode</td></tr>\n <tr><td><kbd>f</kbd></td><td>Toggle freeform region drawing</td></tr>\n <tr><td><kbd>l</kbd></td><td>Toggle polyline drawing</td></tr>\n <tr><td><kbd>r</kbd></td><td>Toggle rectangular region drawing</td></tr>\n <tr><td><kbd>w</kbd></td><td>Toggle wand tool</td></tr>\n <tr><td>Wand + <kbd>Shift</kbd></td><td>Erase pixels from the active region</td></tr>\n <tr><td>Wand + <kbd>Ctrl</kbd>/<kbd>Cmd</kbd></td><td>Exact-match flood fill (no smoothing)</td></tr>\n <tr><td><kbd>e</kbd></td><td>Toggle vertex eraser</td></tr>\n <tr><td><kbd>s</kbd></td><td>Selection mode (deactivates drawing tools)</td></tr>\n <tr><td>Point prompt + <kbd>Shift</kbd>/<kbd>Alt</kbd></td><td>Add an exclude point (refine the current object)</td></tr>\n <tr><td><kbd>Enter</kbd></td><td>Commit the current SAM point object</td></tr>\n <tr><td><kbd>Esc</kbd></td><td>Undo the current SAM point object</td></tr>\n <tr><td><kbd>Del</kbd> / <kbd>\u232B</kbd></td><td>Delete selected region</td></tr>\n <tr><td><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Z</kbd></td><td>Undo the last region action (up to 10)</td></tr>\n <tr><td><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd> / <kbd>Ctrl</kbd>+<kbd>Y</kbd></td><td>Redo the last undone region action</td></tr>\n </table>\n\n <ng-template pTemplate=\"footer\">\n <p-button icon=\"pi pi-check\" (click)=\"displayHelpDialog=false\" label=\"Ok\" styleClass=\"p-button-text\">\n </p-button>\n </ng-template>\n</p-dialog>\n", styles: [":host{display:block}:host ::ng-deep .p-toolbar{padding:.25rem .5rem}.toolbar-text{color:var(--primary-color);font-size:.875rem}::ng-deep .toolbar-input .p-inputtext{color:var(--primary-color);border-color:var(--primary-color)}.toolbar-separator{border-left:1px solid var(--surface-border);height:1.5rem;margin:0 .25rem;align-self:center}.toolbar-icon-dark{filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}.toolbar-icon-active{filter:brightness(0) invert(1)}::ng-deep .plotmode-item{display:inline-flex;align-items:center;gap:.5rem;color:#000}::ng-deep .plotmode-item i{font-size:1rem;color:var(--primary-color)}::ng-deep .plotmode-item .plotmode-icon-img{width:1.1rem;height:1.1rem;object-fit:contain;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .p-menu.model-menu{width:auto;min-width:12.5rem}::ng-deep .model-menu-item{display:flex;align-items:center;gap:.5rem}::ng-deep .model-menu-item .p-menuitem-text{flex:1 1 auto;white-space:nowrap}::ng-deep .model-menu-item .model-info-icon{flex:0 0 auto;font-size:.875rem;opacity:.55;cursor:help;appearance:none;background:none;border:0;padding:0;margin:0;color:inherit;line-height:1}::ng-deep .model-menu-item .model-info-icon:hover{opacity:1}::ng-deep .model-menu-item .model-info-icon:focus-visible{opacity:1;outline:2px solid var(--primary-color, #2196f3);outline-offset:2px;border-radius:2px}::ng-deep .model-info-tooltip .p-tooltip-text{min-width:18rem;max-width:24rem;line-height:1.4}::ng-deep .wand-sensitivity-slider{width:8rem;align-self:center;margin:0 .25rem}.wand-sensitivity-value{min-width:2rem;text-align:right}span.colormap{width:50px;height:auto;display:inline-block}img.colormap{width:50px;height:auto}.colormap{background:no-repeat;background-size:100%;vertical-align:middle}kbd{background:#e8e8e8;border:1px solid #bbb;border-radius:3px;padding:1px 5px;font-family:monospace;font-size:.85em}.shortcuts-table{border-collapse:collapse}.shortcuts-table td{padding:.25rem .75rem .25rem 0;vertical-align:middle}.shortcuts-table td:first-child{white-space:nowrap}\n"] }]
6923
6941
  }], propDecorators: { imageInfo: [{
6924
6942
  type: Input
6943
+ }], hasSpatialDataset: [{
6944
+ type: Input
6925
6945
  }], plotTypeOptions: [{
6926
6946
  type: Input
6927
6947
  }], selectedPlotType: [{
@@ -11320,6 +11340,149 @@ function transcriptLevelFor(pxPerUnit, levels, targetTilePx = 512) {
11320
11340
  }
11321
11341
  return best;
11322
11342
  }
11343
+ /**
11344
+ * The bin a selection of genes is grouped into at this zoom, in observation units, or null
11345
+ * to draw every transcript. Bins follow the all-gene pyramid's ladder (`baseBin` × 2^m) so a
11346
+ * gene selection and "all genes" group alike: the finest bin whose markers land at least
11347
+ * `spacingPx` apart on screen. Once the finest bin is that wide, transcripts are drawn as
11348
+ * themselves.
11349
+ */
11350
+ function geneBinSize(pxPerUnit, baseBin, levels = 7, spacingPx = GROUP_MIN_SPACING_PX) {
11351
+ if (!(pxPerUnit > 0) || !(baseBin > 0))
11352
+ return null;
11353
+ if (baseBin * pxPerUnit >= spacingPx)
11354
+ return null;
11355
+ for (let m = 1; m < levels; m++) {
11356
+ const bin = baseBin * 2 ** m;
11357
+ if (bin * pxPerUnit >= spacingPx)
11358
+ return bin;
11359
+ }
11360
+ return baseBin * 2 ** (levels - 1);
11361
+ }
11362
+ /** The entries of `t` inside `rect` (observation units). */
11363
+ function clipTranscripts(t, rect) {
11364
+ const keep = [];
11365
+ for (let i = 0; i < t.count; i++) {
11366
+ if (t.x[i] >= rect.x0 && t.x[i] <= rect.x1 && t.y[i] >= rect.y0 && t.y[i] <= rect.y1)
11367
+ keep.push(i);
11368
+ }
11369
+ if (keep.length === t.count)
11370
+ return t;
11371
+ const pick = (a) => {
11372
+ const out = new a.constructor(keep.length);
11373
+ keep.forEach((src, i) => { out[i] = a[src]; });
11374
+ return out;
11375
+ };
11376
+ return {
11377
+ count: keep.length, aggregated: t.aggregated,
11378
+ x: pick(t.x), y: pick(t.y), z: pick(t.z), weight: pick(t.weight), observation: pick(t.observation),
11379
+ gene: pick(t.gene),
11380
+ };
11381
+ }
11382
+ /**
11383
+ * Group transcripts into `bin`-wide squares of a grid anchored at the origin (so a pan does
11384
+ * not move them), one entry per (group, square): `groupOf(gene slot)` says which group a gene
11385
+ * is in — its cluster in the gene tree, or itself. Each entry sits at its transcripts'
11386
+ * centroid, weighted by how many each stands for; it takes the gene and the cell holding most
11387
+ * of them, each summed over all its transcripts in the entry. `group[i]` is entry i's group.
11388
+ */
11389
+ function groupTranscripts(t, bin, groupOf = (g) => g) {
11390
+ const index = new Map();
11391
+ const sx = [];
11392
+ const sy = [];
11393
+ const sz = [];
11394
+ const w = [];
11395
+ const grp = [];
11396
+ // Per entry, how many transcripts of each gene (the dominant one gives the icon and colour)
11397
+ // and in each cell (the dominant one is the entry's cell).
11398
+ const geneW = [];
11399
+ const obsW = [];
11400
+ for (let i = 0; i < t.count; i++) {
11401
+ const gk = groupOf(t.gene[i]);
11402
+ const key = `${gk}|${Math.floor(t.x[i] / bin)}|${Math.floor(t.y[i] / bin)}`;
11403
+ let k = index.get(key);
11404
+ const wi = t.weight[i] || 1;
11405
+ if (k === undefined) {
11406
+ k = w.length;
11407
+ index.set(key, k);
11408
+ sx.push(0);
11409
+ sy.push(0);
11410
+ sz.push(0);
11411
+ w.push(0);
11412
+ grp.push(gk);
11413
+ geneW.push(new Map());
11414
+ obsW.push(new Map());
11415
+ }
11416
+ sx[k] += t.x[i] * wi;
11417
+ sy[k] += t.y[i] * wi;
11418
+ sz[k] += t.z[i] * wi;
11419
+ w[k] += wi;
11420
+ geneW[k].set(t.gene[i], (geneW[k].get(t.gene[i]) ?? 0) + wi);
11421
+ obsW[k].set(t.observation[i], (obsW[k].get(t.observation[i]) ?? 0) + wi);
11422
+ }
11423
+ const n = w.length;
11424
+ const tile = {
11425
+ count: n, aggregated: true,
11426
+ x: new Float32Array(n), y: new Float32Array(n), z: new Float32Array(n),
11427
+ weight: new Uint32Array(n), observation: new Uint32Array(n), gene: new Uint16Array(n),
11428
+ };
11429
+ const group = new Int32Array(n);
11430
+ for (let k = 0; k < n; k++) {
11431
+ tile.x[k] = sx[k] / w[k];
11432
+ tile.y[k] = sy[k] / w[k];
11433
+ tile.z[k] = sz[k] / w[k];
11434
+ tile.weight[k] = w[k];
11435
+ tile.observation[k] = dominant(obsW[k]);
11436
+ tile.gene[k] = dominant(geneW[k]);
11437
+ group[k] = grp[k];
11438
+ }
11439
+ return { tile, group };
11440
+ }
11441
+ /** The key with the largest total; the first seen on a tie. */
11442
+ function dominant(totals) {
11443
+ let best = 0;
11444
+ let top = -1;
11445
+ for (const [key, v] of totals)
11446
+ if (v > top) {
11447
+ top = v;
11448
+ best = key;
11449
+ }
11450
+ return best;
11451
+ }
11452
+ /**
11453
+ * The colour of each gene-tree cluster of a selection, for colouring transcripts by cluster: a
11454
+ * cluster named like a group of the cells' grouping takes that group's colour (its cells and its
11455
+ * transcripts agree); the others take palette colours in tree order. Clusters are the tree's
11456
+ * groups holding a selected gene, then each ungrouped selected gene on its own. Every gene of a
11457
+ * cluster gets the same colour — the tree's swatches and the markers use this one map.
11458
+ */
11459
+ function clusterColorMap(genes, groups, cellColors, palette) {
11460
+ const chosen = new Set(genes);
11461
+ const names = [];
11462
+ const grouped = new Set();
11463
+ for (const g of groups) {
11464
+ const mine = g.genes.filter((x) => chosen.has(x) && !grouped.has(x));
11465
+ if (!mine.length)
11466
+ continue;
11467
+ mine.forEach((x) => grouped.add(x));
11468
+ names.push(g.name);
11469
+ }
11470
+ for (const gene of genes)
11471
+ if (!grouped.has(gene))
11472
+ names.push(gene);
11473
+ const out = new Map();
11474
+ let k = 0;
11475
+ for (const name of names) {
11476
+ if (out.has(name))
11477
+ continue;
11478
+ out.set(name, cellColors.get(name) ?? palette[k++ % palette.length]);
11479
+ }
11480
+ return out;
11481
+ }
11482
+ /** The cluster a selected gene is in: its first gene-tree group holding it, else itself. */
11483
+ function clusterOfGene(gene, groups) {
11484
+ return groups.find((g) => g.genes.includes(gene))?.name ?? gene;
11485
+ }
11323
11486
  /** Smallest and largest transcript marker, in canvas pixels. */
11324
11487
  const TRANSCRIPT_MIN_PX = 4;
11325
11488
  const TRANSCRIPT_MAX_PX = 32;
@@ -11820,7 +11983,7 @@ class SpatialControlsComponent {
11820
11983
  { label: 'Fill', value: 'fill' }, { label: 'Outline', value: 'outline' }, { label: 'Both', value: 'both' },
11821
11984
  ];
11822
11985
  this.transcriptColorOptions = [
11823
- { label: 'Cell type', value: 'cellType' }, { label: 'Gene', value: 'gene' },
11986
+ { label: 'Cluster', value: 'cluster' }, { label: 'Cell type', value: 'cellType' }, { label: 'Gene', value: 'gene' },
11824
11987
  ];
11825
11988
  this.glyphOptions = GLYPH_OPTIONS;
11826
11989
  this.budgetOptions = [25_000, 50_000, 100_000, 200_000, 400_000]
@@ -11872,10 +12035,27 @@ class SpatialControlsComponent {
11872
12035
  this.geneMenu = [];
11873
12036
  this.collapsedGeneGroups = new Set();
11874
12037
  this.geneTreeOpen = true;
12038
+ // ── per-gene icon and colour picker ──────────────────────────────────────
12039
+ /** Swatches in the picker: the categorical palette genes are coloured from by default. */
12040
+ this.colorPresets = DEFAULT_CATEGORICAL_PALETTE.slice(0, 10).map((c) => c.toLowerCase());
12041
+ /** The gene the open picker edits. */
12042
+ this.styleGene = null;
11875
12043
  this.trackByLabel = (_i, row) => row.label;
11876
12044
  this.trackByNode = (_i, node) => node.name ?? '';
11877
12045
  this.trackByLabelString = (_i, s) => s;
11878
12046
  this.geneGroupError = null;
12047
+ // ── per-gene counts in view ─────────────────────────────────────────────
12048
+ /** Transcripts of each selected gene in the current view, from the renderer. */
12049
+ this.geneCounts = null;
12050
+ // ── marker genes of clusters, as gene groups ────────────────────────────
12051
+ this.markersOpen = false;
12052
+ this.markerColumn = null;
12053
+ this.markerPerGroup = 5;
12054
+ this.markerPerGroupOptions = [3, 5, 10, 20].map((n) => ({ label: `${n} genes`, value: n }));
12055
+ this.markerClusters = [];
12056
+ this.markerClusterOptions = [];
12057
+ this.markerLoading = false;
12058
+ this.markerError = null;
11879
12059
  // density
11880
12060
  this.densityBins = [10, 20, 40, 80];
11881
12061
  /**
@@ -11888,6 +12068,9 @@ class SpatialControlsComponent {
11888
12068
  this.densitySliderMax = 1;
11889
12069
  this.densityStep = 0.005;
11890
12070
  this.selectedDensityColormapNode = null;
12071
+ /** Colours of the cells' groups, by name — what a cluster of the same name is drawn in. */
12072
+ this.cellGroupColors = new Map();
12073
+ this.cellGroupColorsFor = null;
11891
12074
  }
11892
12075
  ngOnInit() {
11893
12076
  this.controls = this.viz.getSpatialControls?.() ?? null;
@@ -11928,10 +12111,14 @@ class SpatialControlsComponent {
11928
12111
  this.refreshDensityWindow();
11929
12112
  void this.refreshKey();
11930
12113
  void this.refreshGroups();
12114
+ void this.refreshCellGroupColors();
11931
12115
  }));
11932
12116
  const estimate$ = this.controls.getTranscriptEstimate$?.();
11933
12117
  if (estimate$)
11934
12118
  this.subs.add(estimate$.subscribe((e) => this.zone.run(() => { this.estimate = e; })));
12119
+ const counts$ = this.controls.getGeneCountsInView$?.();
12120
+ if (counts$)
12121
+ this.subs.add(counts$.subscribe((c) => this.zone.run(() => { this.geneCounts = c; })));
11935
12122
  const density$ = this.controls.getDensityStats$?.();
11936
12123
  if (density$) {
11937
12124
  this.subs.add(density$.subscribe((d) => this.zone.run(() => {
@@ -12567,6 +12754,38 @@ class SpatialControlsComponent {
12567
12754
  onGeneColor(gene, hex) {
12568
12755
  this.controls?.setViewState({ transcriptGeneColors: { ...this.view.transcriptGeneColors, [gene]: hex } });
12569
12756
  }
12757
+ openGeneStyle(event, gene, panel) {
12758
+ if (this.styleGene === gene) {
12759
+ panel.toggle(event);
12760
+ return;
12761
+ }
12762
+ this.styleGene = gene;
12763
+ panel.hide();
12764
+ // Re-open anchored on the row that was clicked, once the panel has closed.
12765
+ setTimeout(() => panel.toggle(event));
12766
+ }
12767
+ /** The icon the open picker shows as chosen. */
12768
+ get styleGlyph() {
12769
+ const gene = this.styleGene;
12770
+ return gene ? this.glyphOf(gene, Math.max(0, this.view.transcriptGenes.indexOf(gene))) : null;
12771
+ }
12772
+ glyphPoints(glyph) {
12773
+ return GLYPH_OPTIONS.find((o) => o.value === glyph)?.points ?? '';
12774
+ }
12775
+ /** A typed hex colour, accepted as `#rrggbb` or `rrggbb`; anything else is ignored. */
12776
+ onGeneHex(gene, text) {
12777
+ const m = /^#?([0-9a-f]{6})$/i.exec(text.trim());
12778
+ if (m)
12779
+ this.onGeneColor(gene, `#${m[1].toLowerCase()}`);
12780
+ }
12781
+ /** Back to the gene's default icon and colour (by its position in the list). */
12782
+ resetGeneStyle(gene) {
12783
+ const colors = { ...this.view.transcriptGeneColors };
12784
+ const glyphs = { ...this.view.transcriptGlyphs };
12785
+ delete colors[gene];
12786
+ delete glyphs[gene];
12787
+ this.controls?.setViewState({ transcriptGeneColors: colors, transcriptGlyphs: glyphs });
12788
+ }
12570
12789
  /** '±': every gene, or back to the chosen list. */
12571
12790
  onToggleAllGenes() {
12572
12791
  if (!this.canShowAllGenes)
@@ -12576,6 +12795,10 @@ class SpatialControlsComponent {
12576
12795
  /** '⋮' menu. */
12577
12796
  buildGeneMenu() {
12578
12797
  return [
12798
+ {
12799
+ label: 'Add marker genes of clusters…', icon: 'pi pi-sitemap',
12800
+ disabled: !this.canAddMarkers, command: () => this.openMarkers(),
12801
+ },
12579
12802
  {
12580
12803
  label: 'New group from selected genes', icon: 'pi pi-folder-plus',
12581
12804
  disabled: !this.view.transcriptGenes.length, command: () => this.onNewGeneGroup(),
@@ -12601,6 +12824,104 @@ class SpatialControlsComponent {
12601
12824
  get densityOpacityPercent() {
12602
12825
  return Math.round(this.view.densityOpacity * 100);
12603
12826
  }
12827
+ geneCountOf(gene) {
12828
+ return this.geneCounts ? (this.geneCounts[gene] ?? 0) : null;
12829
+ }
12830
+ get canAddMarkers() {
12831
+ return !!this.controls?.markerGenes && this.markerColumnOptions.length > 0;
12832
+ }
12833
+ /** Every categorical column but the segmentation method, which says nothing about genes. */
12834
+ get markerColumnOptions() {
12835
+ return (this.dataset?.columns ?? [])
12836
+ .filter((c) => c.kind === 'categorical' && c.name !== 'segmentation_method')
12837
+ .map((c) => ({ label: c.description && c.section ? c.description : this.columnLabel(c), value: c.name }));
12838
+ }
12839
+ openMarkers() {
12840
+ const options = this.markerColumnOptions;
12841
+ const active = this.activeCellTypeColumn;
12842
+ if (!this.markerColumn || !options.some((o) => o.value === this.markerColumn)) {
12843
+ this.markerColumn = active && options.some((o) => o.value === active) ? active : options[0]?.value ?? null;
12844
+ }
12845
+ this.markerError = null;
12846
+ this.refreshMarkerClusters();
12847
+ this.markersOpen = true;
12848
+ this.open = { ...this.open, transcripts: true };
12849
+ }
12850
+ onMarkerColumn(column) {
12851
+ this.markerColumn = column;
12852
+ this.refreshMarkerClusters();
12853
+ }
12854
+ /** The clusters of the chosen column, all picked to start with. */
12855
+ refreshMarkerClusters() {
12856
+ const col = this.dataset?.columns.find((c) => c.name === this.markerColumn);
12857
+ const categories = col && col.kind === 'categorical' ? col.categories : [];
12858
+ this.markerClusterOptions = categories.map((c) => ({ label: c, value: c }));
12859
+ this.markerClusters = [...categories];
12860
+ }
12861
+ /**
12862
+ * Add each picked cluster's top marker genes as a gene group named after it, and select
12863
+ * them. A gene that marks several clusters goes to the one it is most specific to, so the
12864
+ * tree lists it once.
12865
+ */
12866
+ async addMarkerGenes() {
12867
+ const column = this.markerColumn;
12868
+ const markerGenes = this.controls?.markerGenes;
12869
+ if (!column || !markerGenes || !this.markerClusters.length)
12870
+ return;
12871
+ // The form stays editable while the scan runs: apply what was picked when it was asked.
12872
+ const picked = new Set(this.markerClusters);
12873
+ this.markerLoading = true;
12874
+ this.markerError = null;
12875
+ try {
12876
+ const result = await markerGenes(column, this.markerPerGroup);
12877
+ this.zone.run(() => {
12878
+ const best = new Map();
12879
+ for (const g of result.groups) {
12880
+ if (!picked.has(g.name))
12881
+ continue;
12882
+ for (const gene of g.genes) {
12883
+ const prev = best.get(gene.name);
12884
+ if (!prev || gene.score > prev.score)
12885
+ best.set(gene.name, { group: g.name, score: gene.score });
12886
+ }
12887
+ }
12888
+ const groups = result.groups
12889
+ .filter((g) => picked.has(g.name))
12890
+ .map((g) => ({
12891
+ name: g.name,
12892
+ genes: g.genes.map((x) => x.name).filter((n) => best.get(n)?.group === g.name),
12893
+ }))
12894
+ .filter((g) => g.genes.length);
12895
+ if (!groups.length) {
12896
+ this.markerError = 'No marker genes passed the filter for the chosen clusters.';
12897
+ return;
12898
+ }
12899
+ const names = new Set(groups.map((g) => g.name));
12900
+ const genes = [...this.view.transcriptGenes];
12901
+ for (const g of groups)
12902
+ for (const n of g.genes)
12903
+ if (!genes.includes(n))
12904
+ genes.push(n);
12905
+ this.controls?.setViewState({
12906
+ transcriptGeneGroups: [...this.view.transcriptGeneGroups.filter((g) => !names.has(g.name)), ...groups],
12907
+ transcriptGenes: genes,
12908
+ // Marker groups are clusters: colour the transcripts by them, as their cells are.
12909
+ transcriptColorBy: 'cluster',
12910
+ ...(this.view.transcriptMode === 'off' ? { transcriptMode: 'circles' } : {}),
12911
+ });
12912
+ this.markersOpen = false;
12913
+ });
12914
+ }
12915
+ catch (err) {
12916
+ this.zone.run(() => {
12917
+ const e = err;
12918
+ this.markerError = e?.error?.error ?? e?.message ?? 'Could not compute marker genes.';
12919
+ });
12920
+ }
12921
+ finally {
12922
+ this.zone.run(() => { this.markerLoading = false; });
12923
+ }
12924
+ }
12604
12925
  onNewGeneGroup() {
12605
12926
  const name = (globalThis.prompt?.('Name for this gene group', 'Gene group') ?? '').trim();
12606
12927
  if (!name)
@@ -12748,6 +13069,35 @@ class SpatialControlsComponent {
12748
13069
  return (gene && this.view.transcriptGeneColors[gene])
12749
13070
  || DEFAULT_CATEGORICAL_PALETTE[slot % DEFAULT_CATEGORICAL_PALETTE.length];
12750
13071
  }
13072
+ async refreshCellGroupColors() {
13073
+ const column = this.activeCellTypeColumn;
13074
+ if (column === this.cellGroupColorsFor)
13075
+ return;
13076
+ this.cellGroupColorsFor = column;
13077
+ const map = new Map();
13078
+ const meta = column ? this.dataset?.columns.find((c) => c.name === column) : null;
13079
+ if (meta && meta.kind === 'categorical' && this.controls) {
13080
+ try {
13081
+ const colors = await this.controls.categoryColors(column);
13082
+ meta.categories.forEach((c, k) => { if (colors[k])
13083
+ map.set(c, colors[k]); });
13084
+ }
13085
+ catch {
13086
+ // No colours: palette colours, as the markers fall back to.
13087
+ }
13088
+ }
13089
+ // The cells' grouping changed while the colours loaded: the newer request applies its own.
13090
+ if (this.cellGroupColorsFor !== column)
13091
+ return;
13092
+ this.zone.run(() => { this.cellGroupColors = map; });
13093
+ }
13094
+ /** In Cluster colouring, a gene's swatch is its cluster's colour, as its markers are. */
13095
+ geneSwatchOf(gene) {
13096
+ if (this.view.transcriptColorBy !== 'cluster')
13097
+ return this.geneColorOf(gene);
13098
+ const colors = clusterColorMap(this.view.transcriptGenes, this.view.transcriptGeneGroups, this.cellGroupColors, DEFAULT_CATEGORICAL_PALETTE);
13099
+ return colors.get(clusterOfGene(gene, this.view.transcriptGeneGroups)) ?? this.geneColorOf(gene);
13100
+ }
12751
13101
  geneColorOf(gene) {
12752
13102
  return this.geneColor(Math.max(0, this.view.transcriptGenes.indexOf(gene)));
12753
13103
  }
@@ -12872,11 +13222,11 @@ class SpatialControlsComponent {
12872
13222
  return `linear-gradient(to right, ${stops.join(', ')})`;
12873
13223
  }
12874
13224
  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" }] }); }
13225
+ 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
13226
  }
12877
13227
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.9", ngImport: i0, type: SpatialControlsComponent, decorators: [{
12878
13228
  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"] }]
13229
+ 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
13230
  }], ctorParameters: () => [{ type: undefined, decorators: [{
12881
13231
  type: Inject,
12882
13232
  args: [VISUALIZER]
@@ -13199,7 +13549,10 @@ class VisualizerComponent {
13199
13549
  this.isoRange = [0, 255];
13200
13550
  /** Whether a spatial-omics dataset is currently published on
13201
13551
  * `SPATIAL_DATA_PORT` — gates the spatial plot types in the selector. */
13552
+ /** Bound by the toolbar, which offers the plot modes for a dataset with no image too. */
13202
13553
  this.hasSpatialDataset = false;
13554
+ /** The spatial dataset on offer, for drawing one that brings no image (see reloadAndPlot). */
13555
+ this.spatialDataset = null;
13203
13556
  /** Whether that dataset's observations carry a z, gating the 3D spatial mode. */
13204
13557
  this.hasSpatial3dDataset = false;
13205
13558
  /** Whether it carries a registered volume. Change detection only: a volume
@@ -13300,6 +13653,7 @@ class VisualizerComponent {
13300
13653
  */
13301
13654
  watchSpatialDataset() {
13302
13655
  this.spatialDatasetSubscription = this.spatialData?.getDataset$().subscribe((dataset) => {
13656
+ this.spatialDataset = dataset ?? null;
13303
13657
  const has = !!dataset;
13304
13658
  // Only a dataset whose observations carry a z can be drawn as a cloud, so
13305
13659
  // the 3D mode is gated on the coordinates, not merely on a dataset being
@@ -13362,6 +13716,10 @@ class VisualizerComponent {
13362
13716
  const target = has3d ? PlotType.SPATIAL_OMICS_3D : PlotType.SPATIAL_OMICS;
13363
13717
  if (this.selectedPlotTypeId !== target)
13364
13718
  this.onSelectPlotType(target);
13719
+ // Same mode, another image-less dataset, nothing loaded: re-plot so its placeholder
13720
+ // image info (and with it the regions' key) is this dataset's, not the last one's.
13721
+ else if (!this.imageInfo)
13722
+ this.reloadAndPlot();
13365
13723
  }
13366
13724
  }
13367
13725
  // Clearing the dataset while a spatial mode is active leaves a type that is
@@ -14367,10 +14725,56 @@ class VisualizerComponent {
14367
14725
  if (this.imageInfo) {
14368
14726
  this.state.setImageInfo(this.imageInfo);
14369
14727
  }
14728
+ else if (this.spatialDataset && (isSpatialOmics(this.plotType) || isSpatialOmics3d(this.plotType))) {
14729
+ // A spatial dataset that brings no image, opened with no image loaded (a host's
14730
+ // first view): there is no image info to re-drive, so draw the spatial mode itself.
14731
+ void this.plotSpatialWithoutImage(this.spatialDataset);
14732
+ }
14370
14733
  else {
14371
14734
  this.plotService.reloadAndPlot();
14372
14735
  }
14373
14736
  }
14737
+ /**
14738
+ * Draw a spatial mode with no image behind it: the observations alone, framed on their own
14739
+ * extent, as the seqFISH example shows them. The image info is a placeholder naming the
14740
+ * dataset, so regions drawn here are kept per dataset like any image's.
14741
+ */
14742
+ async plotSpatialWithoutImage(dataset) {
14743
+ // The same generation as the image pipeline: a newer image or dataset supersedes this
14744
+ // draw, and a superseded draw neither reports nor releases the newer one's loading state.
14745
+ const token = ++this.renderToken;
14746
+ const div = document.getElementById(this.plotDivName);
14747
+ const info = {
14748
+ isGrayscale: false, trueImageSize: [0, 0], urls: [], isStack: false, showStack: false,
14749
+ scaleRatio: true, fileName: `spatial:${dataset.id}`, imageMeta: [],
14750
+ };
14751
+ let failure = null;
14752
+ try {
14753
+ // A backend that cannot draw (no WebGPU, no plot target) resolves false rather than throwing.
14754
+ if (!(await this.plotService.plot(this.plotDivName, null, info, div?.offsetHeight || 500, this.plotType))) {
14755
+ failure = 'the renderer could not start';
14756
+ }
14757
+ }
14758
+ catch (err) {
14759
+ failure = err;
14760
+ }
14761
+ if (token !== this.renderToken)
14762
+ return;
14763
+ if (failure) {
14764
+ console.warn('[visualizer] could not draw the spatial dataset', failure);
14765
+ const e = failure;
14766
+ this.messageService.add({
14767
+ key: this.vizAlertToastKey,
14768
+ severity: 'error',
14769
+ summary: 'Could not draw the dataset',
14770
+ // An image-less dataset has no tissue image to fall back on, so say so rather than
14771
+ // leave an empty canvas that looks finished.
14772
+ detail: `${dataset.name ?? dataset.id}: ${e?.message ?? String(failure)}.`,
14773
+ });
14774
+ }
14775
+ this.state.setImageLoading(false);
14776
+ this.cdr.detectChanges();
14777
+ }
14374
14778
  cancelLoading() {
14375
14779
  // Stop any in-flight frame streaming (napari-js volume/surface preload) so the fetch loops
14376
14780
  // actually abort — clearing the flag alone only routed to Plotly and left napari fetching.
@@ -15307,11 +15711,11 @@ class VisualizerComponent {
15307
15711
  this.plotService.setVertexEraserMode(false);
15308
15712
  }
15309
15713
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.9", ngImport: i0, type: VisualizerComponent, deps: [{ token: IMAGE_STATE_PORT }, { token: VISUALIZER }, { token: i1$2.MessageService }, { token: i0.NgZone }, { token: i0.ChangeDetectorRef }, { token: VisualizerStore }, { token: SamToolService }, { token: CellSegmentToolService }, { token: SamPointToolService }, { token: RegionOpsService }, { token: VIZ_CONFIG, optional: true }, { token: TOOLBAR_TOOLS, optional: true }, { token: SPATIAL_DATA_PORT, optional: true }, { token: PLOT_TYPE_CONTRIBUTIONS, optional: true }, { token: i0.Injector, optional: true }], target: i0.ɵɵFactoryTarget.Component }); }
15310
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.9", type: VisualizerComponent, selector: "visualizer", inputs: { toolbarTools: "toolbarTools", testMode: "testMode" }, outputs: { isStackEvent: "isStackEvent", isGrayscaleEvent: "isGrayscaleEvent" }, host: { listeners: { "window:keydown": "onKeydown($event)" } }, viewQueries: [{ propertyName: "contextMenu", first: true, predicate: ["cm"], descendants: true }, { propertyName: "plotModePanelSlot", first: true, predicate: ["plotModePanelSlot"], descendants: true }, { propertyName: "toolDialogSlot", first: true, predicate: ["toolDialogSlot"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "<!-- SAM toast: live status + encoder-download progress bar (jit-ui#90). Sticky\n while a segment runs; stays open until the bar reaches 100% and the run ends. -->\n<p-toast [key]=\"samToastKey\" position=\"bottom-right\">\n <ng-template let-message pTemplate=\"message\">\n <div style=\"flex: 1; min-width: 16rem\">\n <div class=\"font-bold mb-1\">{{ message.summary }}</div>\n <div>{{ samStatus || message.detail }}</div>\n <p-progressBar *ngIf=\"samDownloading\" [value]=\"samProgress\" styleClass=\"mt-2 sam-progress\"></p-progressBar>\n <p-progressBar *ngIf=\"samBusy && !samDownloading\" mode=\"indeterminate\" styleClass=\"mt-2\"\n [style]=\"{ height: '4px' }\"></p-progressBar>\n </div>\n </ng-template>\n</p-toast>\n\n<!-- Result/error notices from the library itself (segmentation outcome, region\n validation). Rendered here so they do not depend on the host mounting a\n keyless <p-toast> \u2014 without this the messages went nowhere in any host but\n jit-ui, and a failed run looked like a button that did nothing. -->\n<p-toast [key]=\"resultToastKey\" position=\"bottom-right\"></p-toast>\n\n<!-- Shared outlets for notices raised where there is no per-instance key to use:\n the root-provided PlotlyService and the region-editor dialog. Rendered by a\n single visualizer (ownsSharedToasts) so a message from the root service is\n not shown once per live viewer. -->\n<p-toast *ngIf=\"ownsSharedToasts\" [key]=\"vizToastKey\"></p-toast>\n<p-toast *ngIf=\"ownsSharedToasts\" [key]=\"vizAlertToastKey\" position=\"center\"></p-toast>\n\n<!-- Toolbar dock: docked across the top by default; drag the handle to detach\n it into a floating, movable window (frees the top row for the image). -->\n<div class=\"toolbar-dock\" [class.floating]=\"toolbarFloating\"\n [style.left.px]=\"toolbarFloating ? toolbarPos.x : null\"\n [style.top.px]=\"toolbarFloating ? toolbarPos.y : null\">\n <div class=\"toolbar-drag-handle\" (mousedown)=\"onToolbarDragStart($event)\"\n pTooltip=\"Drag to float the toolbar\" tooltipPosition=\"bottom\">\n <i class=\"pi pi-bars\"></i>\n </div>\n<plotting-toolbar\n [imageInfo]=\"imageInfo\"\n [plotTypeOptions]=\"plotTypeMenu\"\n [selectedPlotType]=\"selectedPlotTypeId\"\n [basePlotType]=\"basePlotType\"\n [isoRange]=\"isoRange\"\n [maxIndex]=\"maxIndex\"\n [zIndex]=\"zIndex\"\n [isHeatmap]=\"isHeatmap\"\n [is3dRegions]=\"isSpatial3dMode\"\n [activeDragMode]=\"activeDragMode\"\n [activeSurface3dMode]=\"activeSurface3dMode\"\n [axesVisible]=\"axesVisible\"\n [wireframeActive]=\"wireframeActive\"\n [resolutionScale]=\"resolutionScale\"\n [wandSensitivity]=\"wandSensitivity\"\n [brushSize]=\"brushSize\"\n [samModels]=\"samModels\"\n [samModelId]=\"samModelId\"\n [contributedTools]=\"contributedTools\"\n [dialogTools]=\"dialogTools\"\n [openDialogToolId]=\"openDialogToolId\"\n (toggleDialogTool)=\"toggleDialogTool($event)\"\n [toolModelIds]=\"toolModelIds\"\n [vertexEraserRadius]=\"vertexEraserRadius\"\n [canUndo]=\"canUndoRegion\"\n [canRedo]=\"canRedoRegion\"\n [stackOptions]=\"stackOptions\"\n [tools]=\"toolbarTools\"\n [imageSmoothingEnabled]=\"imageSmoothingEnabled\"\n (selectPlotType)=\"onSelectPlotType($event)\"\n (toggleImageSmoothing)=\"onToggleImageSmoothing()\"\n (addProfileLine)=\"addProfileLine()\"\n (isoRangeChange)=\"onIsoRangeChange($event)\"\n (openChannelHistogram)=\"openChannelHistogram()\"\n (openSpatialControls)=\"openSpatialControls()\"\n (openRegionEditor)=\"openRegionEditor()\"\n (selectStackOption)=\"selectStackOption($event)\"\n (zScrub)=\"onZScrub($event)\"\n (zSlide)=\"onZSlide($event)\"\n (zIndexInput)=\"zIndex = $event; updateZIndex()\"\n (reloadAndPlot)=\"reloadAndPlot()\"\n (downloadImage)=\"downloadImage()\"\n (autoscaleImage)=\"autoscaleImage()\"\n (toggleDragMode)=\"toggleDragMode($event)\"\n (zoomIn)=\"zoomIn()\"\n (zoomOut)=\"zoomOut()\"\n (toggleSurface3dMode)=\"toggleSurface3dMode($event)\"\n (resetSurfaceCamera)=\"resetSurfaceCamera()\"\n (toggleAxes)=\"toggleAxes()\"\n (toggleWireframe)=\"toggleWireframe()\"\n (selectResolution)=\"selectResolution($event)\"\n (deleteRegion)=\"deleteRegion()\"\n (undoRegion)=\"undoRegion()\"\n (redoRegion)=\"redoRegion()\"\n (segmentRegions)=\"segmentRegions()\"\n (segmentCellpose)=\"segmentCellpose()\"\n (samModelChange)=\"onSamModelChange($event)\"\n (runTool)=\"runTool($event)\"\n (toolModelChange)=\"onToolModelChange($event)\"\n (openToolParams)=\"openToolParams($event)\"\n (wandSensitivityChange)=\"onWandSensitivityChange($event)\"\n (brushSizeChange)=\"onBrushSizeChange($event)\"\n (vertexEraserRadiusChange)=\"onVertexEraserRadiusChange($event)\">\n <!-- Host-projected custom toolbar buttons, forwarded into the toolbar. -->\n <ng-content></ng-content>\n</plotting-toolbar>\n <p-button *ngIf=\"toolbarFloating\" class=\"toolbar-dock-btn\" icon=\"pi pi-thumbtack\" size=\"small\" [text]=\"true\"\n pTooltip=\"Dock to top\" tooltipPosition=\"bottom\" (onClick)=\"dockToolbar()\">\n </p-button>\n</div>\n\n<p-contextMenu #cm [model]=\"contextMenuItems\" appendTo=\"body\"></p-contextMenu>\n<!-- No `h-full` here: PrimeFlex's height:100% !important made #plot as tall as\n the whole host, so under the docked toolbar its bottom toolbar-height strip\n (incl. the OSD scale bar at bottom-left) was clipped by the host's\n overflow:hidden. The component stylesheet sizes it (.viz-plot { flex: 1 }). -->\n<div class=\"w-full relative viz-plot\" [id]=\"plotDivName\">\n <div *ngIf=\"imgLoading || stackLoading || isCaching\" id=\"overlay\">\n <!-- Determinate stack/volume progress takes precedence: while it's active show ONLY the\n progress bar + \"Loading frames...\", not the generic spinner/\"Loading image...\" on top of\n it (both were rendering at once for napari volume/isosurface loads). -->\n <ng-container *ngIf=\"stackLoading; else imgLoadingTpl\">\n <p-progressBar class=\"progress\" [value]=\"loadingPercentage\"></p-progressBar>\n <span class=\"text-loading\">Loading frames...</span>\n </ng-container>\n <ng-template #imgLoadingTpl>\n <!-- Cache-copy progress is shown whenever a cache download is active (isCaching), independent\n of `imgLoading`, so it's never dropped/masked mid-download. -->\n <ng-container *ngIf=\"isCaching; else spinnerLoading\">\n <!-- Stage-aware message comes from CacheProgressService updating\n imageLoadingMessage$ (\"Caching image...\" during the GCS download,\n then \"Generating preview...\" during per-slice thumbnail gen). Once the\n download hits 100% the remaining work (server preview/tile generation)\n has no determinate progress, so fall through to the spinner template\n rather than parking a full bar under \"Loading image...\". -->\n <p-progressBar class=\"progress\" [value]=\"cacheProgress\"></p-progressBar>\n <span class=\"text-loading\">{{loadingMessage}}</span>\n </ng-container>\n <ng-template #spinnerLoading>\n <div *ngIf=\"imgLoading\" class=\"spinner\"></div>\n <span *ngIf=\"imgLoading && !zoom\" class=\"text-loading\">{{loadingMessage}}</span>\n <span *ngIf=\"imgLoading && zoom\" class=\"zoom-text-loading\">{{loadingMessage}}</span>\n </ng-template>\n </ng-template>\n <p-button *ngIf=\"stackLoading || imgLoading\" class=\"cancel-loading \" label=\"Cancel\"\n (onClick)=\"cancelLoading()\">\n </p-button>\n </div>\n <!-- Translucent \"sharpening\" overlay shown after the small-tier preview is\n on screen while the large-tier render is still loading in the\n background. Lets the user see the blurry image underneath but makes\n it clear something is still loading so they don't mistake the\n low-res render for the final image. -->\n <div *ngIf=\"sharpening && !imgLoading && !stackLoading && !isCaching\" id=\"sharpening-overlay\">\n <div class=\"spinner\"></div>\n <span class=\"text-loading\">Sharpening preview...</span>\n </div>\n\n</div>\n\n<!-- Floating, draggable intensity-profile inset. Shown in LINE mode; the chart\n updates live as the line ROI on the image is moved. Kept OUTSIDE #plot and\n positioned fixed so it floats over the whole page (not clipped to the\n diagram/canvas area). -->\n<div *ngIf=\"hasProfiles\" class=\"intensity-panel\"\n [style.left.px]=\"profilePanelPos.x\" [style.top.px]=\"profilePanelPos.y\">\n <div class=\"intensity-panel-header\" (mousedown)=\"onProfilePanelDragStart($event)\">\n <span>Intensity profile</span>\n <i class=\"pi pi-arrows-alt\"></i>\n </div>\n <div [id]=\"intensityInsetDiv\" class=\"intensity-panel-plot\"></div>\n</div>\n\n<!-- Channels & Histogram: non-modal floating dialog opened from the toolbar. -->\n<channel-histogram [(visible)]=\"showChannelHistogram\"></channel-histogram>\n\n<!-- Spatial-omics controls. Renders its own empty state when no dataset (or no\n SPATIAL_DATA_PORT) is present, so it is always safe to host. -->\n<spatial-controls [(visible)]=\"showSpatialControls\" [is3d]=\"isSpatial3dMode\" ></spatial-controls>\n\n<!-- Side panel of the active contributed plot mode (PLOT_TYPE_CONTRIBUTIONS),\n in the same right-hand area as the spatial controls and the Region Editor.\n Exists only while the mode's session is live; there is no close button,\n because leaving the mode is what ends it. A component panel gets\n PLOT_MODE_CONTEXT / PLOT_MODE_SESSION from its injector; a `mount` panel's\n host element is attached to the slot below (see plotModePanelSlot). -->\n<p-dialog *ngIf=\"plotModePanel as panel\" [header]=\"panel.title\" [visible]=\"true\"\n [modal]=\"false\" [closable]=\"false\" [resizable]=\"true\" [draggable]=\"true\"\n appendTo=\"body\" position=\"right\" styleClass=\"plot-mode-panel\"\n [style]=\"{ width: '22rem', maxHeight: '85vh' }\"\n [contentStyle]=\"{ overflow: 'auto' }\">\n <ng-container *ngIf=\"panel.component\">\n <ng-container *ngComponentOutlet=\"panel.component; injector: panel.injector ?? undefined\"></ng-container>\n </ng-container>\n <div *ngIf=\"panel.host\" #plotModePanelSlot class=\"plot-mode-panel-slot\"></div>\n</p-dialog>\n\n<!-- An open dialog tool (TOOLBAR_TOOLS, kind: 'dialog'). Non-modal so the user can\n keep working on the image, e.g. painting with the brush the tool armed. The\n tool renders its body into a host element attached to the slot below (see\n toolDialogSlot); closing the dialog ends the tool's session. -->\n<p-dialog *ngIf=\"toolDialog as dlg\" [header]=\"dlg.title\" [visible]=\"true\"\n (visibleChange)=\"$event || closeDialogTool()\"\n [modal]=\"false\" [closable]=\"true\" [resizable]=\"true\" [draggable]=\"true\"\n appendTo=\"body\" position=\"right\" styleClass=\"tool-dialog\"\n [style]=\"{ width: dlg.width, maxHeight: '85vh' }\"\n [contentStyle]=\"{ overflow: 'auto' }\">\n <div #toolDialogSlot class=\"tool-dialog-slot\"></div>\n</p-dialog>\n\n<!-- Region Editor: non-modal, draggable, resizable floating dialog opened from\n the toolbar (formerly the right-panel Regions tab). Modeless so regions can\n still be drawn/selected on the plot while it's open; stays linked to this\n plotting instance via the root RegionStore singleton. Opens over the right\n side of the view. -->\n<p-dialog header=\"Region Editor\" [(visible)]=\"showRegionEditor\"\n [modal]=\"false\" [resizable]=\"true\" [draggable]=\"true\"\n [maximizable]=\"true\" appendTo=\"body\" position=\"right\"\n [style]=\"{ width: regionEditorWidth, height: '85vh' }\"\n [contentStyle]=\"{ overflow: 'auto' }\">\n <region-editor></region-editor>\n</p-dialog>\n\n<!-- Simplify (Douglas\u2013Peucker): custom px-threshold picker for the selected\n region(s), opened from the right-click \"Simplify \u25B8 Custom\u2026\" action. -->\n<p-dialog header=\"Simplify region(s)\" [(visible)]=\"displaySimplifyDialog\" [modal]=\"true\"\n [style]=\"{width: '22rem'}\" [draggable]=\"false\" [resizable]=\"false\">\n <div class=\"flex flex-column gap-2\">\n <span>Altitude threshold: <b>{{ simplifyThreshold }} px</b></span>\n <p-slider [(ngModel)]=\"simplifyThreshold\" [min]=\"1\" [max]=\"20\" [step]=\"1\"></p-slider>\n <small>Vertices within this distance of the line between their neighbours are removed.</small>\n </div>\n <ng-template pTemplate=\"footer\">\n <p-button label=\"Cancel\" [text]=\"true\" (onClick)=\"displaySimplifyDialog = false\"></p-button>\n <p-button label=\"Apply\" icon=\"pi pi-check\" (onClick)=\"simplifyRegions(simplifyThreshold)\"></p-button>\n </ng-template>\n</p-dialog>\n\n<!-- Parameters for whichever contributed tool is open. One dialog for all of\n them: a tool declares its fields (ToolParamSpec) and this renders them, so\n the toolbar can lay every tool out the same way and a plugin needs no\n PrimeNG of its own. Defaults come from the active checkpoint, since they\n encode what it was trained for. -->\n<p-dialog *ngIf=\"openParamsTool as tool\"\n [header]=\"tool.label + ' parameters'\"\n [visible]=\"true\" (visibleChange)=\"openParamsToolId = null\"\n [modal]=\"true\"\n [style]=\"{width: '30rem'}\" [draggable]=\"false\" [resizable]=\"false\" appendTo=\"body\">\n <div class=\"grid formgrid p-fluid\">\n <ng-container *ngFor=\"let p of tool.params\">\n <div class=\"field\" [ngClass]=\"p.type === 'boolean' ? 'col-12' : 'col-6'\">\n <ng-container [ngSwitch]=\"p.type\">\n\n <ng-container *ngSwitchCase=\"'number'\">\n <label class=\"text-sm\" [pTooltip]=\"p.tooltip || ''\">{{ p.label }}</label>\n <p-inputNumber [ngModel]=\"paramsFor(tool.id)[p.id]\"\n (ngModelChange)=\"paramsFor(tool.id)[p.id] = $event\"\n [min]=\"numberParam(p).min\" [max]=\"numberParam(p).max\"\n [step]=\"numberParam(p).step\"\n [minFractionDigits]=\"numberParam(p).fractionDigits\"\n [showButtons]=\"true\"></p-inputNumber>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'select'\">\n <label class=\"text-sm\" [pTooltip]=\"p.tooltip || ''\">{{ p.label }}</label>\n <p-dropdown [options]=\"selectParam(p).options\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"paramsFor(tool.id)[p.id]\"\n (ngModelChange)=\"paramsFor(tool.id)[p.id] = $event\"\n appendTo=\"body\"></p-dropdown>\n </ng-container>\n\n <!-- Checkbox label sits beside the box and may run to a sentence, so\n it does not get the stacked label the other types use. -->\n <div *ngSwitchCase=\"'boolean'\" class=\"flex align-items-center gap-2\">\n <p-checkbox [ngModel]=\"paramsFor(tool.id)[p.id]\"\n (ngModelChange)=\"paramsFor(tool.id)[p.id] = $event\"\n [binary]=\"true\" [inputId]=\"tool.id + '-' + p.id\"></p-checkbox>\n <label [for]=\"tool.id + '-' + p.id\" class=\"text-sm mb-0\"\n [pTooltip]=\"p.tooltip || ''\">{{ p.label }}</label>\n </div>\n\n </ng-container>\n </div>\n </ng-container>\n </div>\n <ng-template pTemplate=\"footer\">\n <p-button label=\"Reset to model defaults\" [text]=\"true\" size=\"small\"\n (onClick)=\"resetToolParams(tool.id)\"></p-button>\n <p-button label=\"Close\" size=\"small\" (onClick)=\"openParamsToolId = null\"></p-button>\n </ng-template>\n</p-dialog>\n", styles: [":host{display:flex;flex-direction:column;height:100%;overflow:hidden}.viz-plot{flex:1;min-height:0;height:auto!important}.toolbar-dock{display:flex;align-items:stretch;width:100%}.toolbar-dock plotting-toolbar{flex:1;min-width:0}.toolbar-dock.floating{position:fixed;z-index:1000;width:auto;max-width:calc(100% - 16px);background:var(--surface-card, #ffffff);border:1px solid var(--surface-border, #cccccc);border-radius:6px;box-shadow:0 4px 16px #0000004d}.toolbar-dock.floating plotting-toolbar{flex:0 1 auto}.toolbar-dock.floating .toolbar-drag-handle{border-radius:6px 0 0 6px}.toolbar-drag-handle{display:flex;align-items:center;justify-content:center;padding:0 .4rem;cursor:move;color:var(--primary-color);background:var(--surface-100, rgba(0, 0, 0, .04));border-right:1px solid var(--surface-border, #cccccc);-webkit-user-select:none;user-select:none}.toolbar-dock-btn{align-self:center}:host ::ng-deep .navigator{margin-bottom:80px!important;margin-right:12px!important}kbd{background:#e8e8e8;border:1px solid #bbb;border-radius:3px;padding:1px 5px;font-family:monospace;font-size:.85em}::ng-deep .context-menu-active>.p-menuitem-content{background-color:var(--primary-color)!important}::ng-deep .context-menu-active>.p-menuitem-content .p-menuitem-icon,::ng-deep .context-menu-active>.p-menuitem-content .p-menuitem-text{color:#fff!important}::ng-deep .context-menu-active>.p-menuitem-content .zoom-box-off-icon,::ng-deep .context-menu-active>.p-menuitem-content .wand-icon,::ng-deep .context-menu-active>.p-menuitem-content .polyline-icon,::ng-deep .context-menu-active>.p-menuitem-content .polygon-vertices-icon,::ng-deep .context-menu-active>.p-menuitem-content .vertex-add-icon,::ng-deep .context-menu-active>.p-menuitem-content .vertex-delete-icon,::ng-deep .context-menu-active>.p-menuitem-content .region-move-icon,::ng-deep .context-menu-active>.p-menuitem-content .to-bezier-icon,::ng-deep .context-menu-active>.p-menuitem-content .to-polygon-icon{filter:brightness(0) invert(1)}.toolbar-separator{border-left:1px solid var(--surface-border);height:1.5rem;margin:0 .25rem;align-self:center}.progress{position:absolute;width:50%;left:25%;top:55%}.text-loading{position:absolute;margin:0 auto;left:45%;top:60%;font-weight:700}.zoom-text-loading{position:absolute;margin:0 auto;left:35%;top:60%;font-weight:700;display:inline-block;width:350px}.cancel-loading{position:absolute;margin:0 auto;left:46%;top:70%}.spinner{position:absolute;left:45%;top:45%;height:100px;width:100px;margin:0 auto;-webkit-animation:rotation .6s infinite linear;-moz-animation:rotation .6s infinite linear;-o-animation:rotation .6s infinite linear;animation:rotation .6s infinite linear;border-left:6px solid rgba(0,174,239,.15);border-right:6px solid rgba(0,174,239,.15);border-bottom:6px solid rgba(0,174,239,.15);border-top:6px solid rgb(82,107,178);border-radius:100%}#overlay{position:absolute;top:0;left:0;width:100%;height:100%;background-color:#ffffff80;z-index:1000}#sharpening-overlay{position:absolute;top:0;left:0;width:100%;height:100%;background-color:#ffffff2e;pointer-events:none;z-index:1000}#sharpening-overlay .spinner{height:60px;width:60px;border-width:4px;left:calc(50% - 30px);top:calc(50% - 60px)}#sharpening-overlay .text-loading{position:absolute;left:0;right:0;width:auto;text-align:center;top:calc(50% + 35px);font-size:.9rem;color:#526bb2}@-webkit-keyframes rotation{0%{-webkit-transform:rotate(0deg)}to{-webkit-transform:rotate(359deg)}}@-moz-keyframes rotation{0%{-moz-transform:rotate(0deg)}to{-moz-transform:rotate(359deg)}}@-o-keyframes rotation{0%{-o-transform:rotate(0deg)}to{-o-transform:rotate(359deg)}}@keyframes rotation{0%{transform:rotate(0)}to{transform:rotate(359deg)}}::ng-deep .zoom-box-off-icon{background:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\" fill=\"black\" fill-rule=\"evenodd\">%0A <!-- Rectangle outline -->%0A <rect x=\"2\" y=\"2\" width=\"56\" height=\"56\"/>%0A <rect x=\"9\" y=\"9\" width=\"42\" height=\"42\"/>%0A <!-- Magnifying glass ring -->%0A <circle cx=\"70\" cy=\"70\" r=\"23\"/>%0A <circle cx=\"70\" cy=\"70\" r=\"14\"/>%0A <!-- Handle -->%0A <polygon points=\"81,81 97,97 92,100 77,85\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .wand-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<svg%0A viewBox=\"0 0 100 100\"%0A fill=\"black\"%0A fill-rule=\"evenodd\"%0A version=\"1.1\"%0A id=\"svg4\"%0A sodipodi:docname=\"wand.svg\"%0A inkscape:version=\"1.4.3 (0d15f75, 2025-12-25)\"%0A xmlns:inkscape=\"http://www.inkscape.org/namespaces/inkscape\"%0A xmlns:sodipodi=\"http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd\"%0A xmlns=\"http://www.w3.org/2000/svg\"%0A xmlns:svg=\"http://www.w3.org/2000/svg\">%0A <defs%0A id=\"defs4\" />%0A <sodipodi:namedview%0A id=\"namedview4\"%0A pagecolor=\"%23ffffff\"%0A bordercolor=\"%23000000\"%0A borderopacity=\"0.25\"%0A inkscape:showpageshadow=\"2\"%0A inkscape:pageopacity=\"0.0\"%0A inkscape:pagecheckerboard=\"0\"%0A inkscape:deskcolor=\"%23d1d1d1\"%0A inkscape:zoom=\"8.74\"%0A inkscape:cx=\"50\"%0A inkscape:cy=\"49.942792\"%0A inkscape:window-width=\"1024\"%0A inkscape:window-height=\"1186\"%0A inkscape:window-x=\"3560\"%0A inkscape:window-y=\"131\"%0A inkscape:window-maximized=\"0\"%0A inkscape:current-layer=\"svg4\" />%0A <!-- Wand handle: thin diagonal stick from lower-left to upper-right -->%0A <polygon%0A points=\"12,92 78,26 74,22 8,88 \"%0A id=\"polygon1\"%0A transform=\"matrix(1.0375192,0.05799445,0.25179496,0.91871864,-17.85351,5.6289793)\" />%0A <!-- 4-pointed star at the wand tip -->%0A <polygon%0A points=\"62,34 76,30 80,6 84,30 98,34 84,38 80,62 76,38 \"%0A id=\"polygon2\"%0A transform=\"matrix(1.3082889,0,0,1.0245178,-38.907958,-1.4056881)\" />%0A <!-- Small sparkle to the upper-left -->%0A <polygon%0A points=\"46,30 36,32 34,42 32,32 22,30 32,28 34,18 36,28 \"%0A id=\"polygon3\"%0A transform=\"translate(-9.610984,-6.7505721)\" />%0A <!-- Small sparkle to the lower-right -->%0A <polygon%0A points=\"62,80 70,82 62,84 60,92 58,84 50,82 58,80 60,72 \"%0A id=\"polygon4\"%0A transform=\"translate(0,-1.6018307)\" />%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .brush-icon{background:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 640 640\"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d=\"M512.5 74.3L291.1 222C262 241.4 243.5 272.9 240.5 307.3C302.8 320.1 351.9 369.2 364.8 431.6C399.3 428.6 430.7 410.1 450.1 381L597.7 159.5C604.4 149.4 608 137.6 608 125.4C608 91.5 580.5 64 546.6 64C534.5 64 522.6 67.6 512.5 74.3zM320 464C320 402.1 269.9 352 208 352C146.1 352 96 402.1 96 464C96 467.9 96.2 471.8 96.6 475.6C98.4 493.1 86.4 512 68.8 512L64 512C46.3 512 32 526.3 32 544C32 561.7 46.3 576 64 576L208 576C269.9 576 320 525.9 320 464z\"/></svg>') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .polyline-icon{background:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\">%0A <polyline points=\"1,14 5,3 10,11 15,2\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .polygon-vertices-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Polygon (click-to-place) tool icon: a traced polyline with vertex dots. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <g transform=\"matrix(1.4740709,0,0,1.4740709,-6.0268793,-6.8086826)\">%0A <polyline points=\"23.877,16.627 14.657,10.767 5.25,21.551 12.757,29.397\"%0A fill=\"none\" stroke=\"%23000000\" stroke-width=\"1.6\"%0A stroke-linejoin=\"round\" stroke-linecap=\"round\"/>%0A <circle cx=\"23.877\" cy=\"16.627\" r=\"1.9\"/>%0A <circle cx=\"14.657\" cy=\"10.767\" r=\"1.9\"/>%0A <circle cx=\"5.25\" cy=\"21.551\" r=\"1.9\"/>%0A <circle cx=\"12.757\" cy=\"29.397\" r=\"1.9\"/>%0A </g>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .vertex-add-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Add-vertex tool icon: a region blob with three corner vertices and a plus glyph. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <g transform=\"matrix(1.5222969,0,0,1.5222969,-8.2143505,-7.6146399)\">%0A <path d=\"m 9.35834,12.33267 c 1.78,-2.53 4.93,-3.56 7.86,-3.98 3.98,-0.72 8.33,0.08 11.52,2.67 2.62,1.98 4.71,5.1 4.53,8.49 -0.29,2.27 -2.5,3.81 -4.66,4.01 -2.75,0.43 -5.54,0.07 -8.28,-0.26 -3.99,-0.68 -8.05,-2.16 -10.93,-5.12 -1.32,-1.63 -1.39,-4.17 -0.04,-5.81 z\"%0A fill=\"none\" stroke=\"%23000000\" stroke-width=\"1.6\" stroke-miterlimit=\"10\"/>%0A <circle cx=\"8.294\" cy=\"15.248\" r=\"2.63\"/>%0A <circle cx=\"19.781\" cy=\"8.287\" r=\"2.63\"/>%0A <circle cx=\"25.044\" cy=\"23.748\" r=\"2.63\"/>%0A </g>%0A <path d=\"M 9.65,29.0 H 5.85 v 5.3 H 0.55 v 3.4 H 5.85 v 5.3 H 9.65 V 37.7 H 14.95 V 34.3 H 9.65 Z\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .vertex-delete-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Delete-vertex tool icon: a region blob with three corner vertices and a minus glyph. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <g transform=\"matrix(1.5222969,0,0,1.5222969,-8.2143505,-7.6146399)\">%0A <path d=\"m 9.35834,12.33267 c 1.78,-2.53 4.93,-3.56 7.86,-3.98 3.98,-0.72 8.33,0.08 11.52,2.67 2.62,1.98 4.71,5.1 4.53,8.49 -0.29,2.27 -2.5,3.81 -4.66,4.01 -2.75,0.43 -5.54,0.07 -8.28,-0.26 -3.99,-0.68 -8.05,-2.16 -10.93,-5.12 -1.32,-1.63 -1.39,-4.17 -0.04,-5.81 z\"%0A fill=\"none\" stroke=\"%23000000\" stroke-width=\"1.6\" stroke-miterlimit=\"10\"/>%0A <circle cx=\"8.294\" cy=\"15.248\" r=\"2.63\"/>%0A <circle cx=\"19.781\" cy=\"8.287\" r=\"2.63\"/>%0A <circle cx=\"25.044\" cy=\"23.748\" r=\"2.63\"/>%0A </g>%0A <path d=\"M 0.55,34.3 v 3.4 H 14.95 V 34.3 Z\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .region-move-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Region-move tool icon: a region blob with a drag arrow. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <path d=\"m 17.115582,8.9648664 c 1.707597,-3.3276806 4.729468,-4.6824281 7.540287,-5.2348498 3.827703,-0.947008 7.991169,0.1052238 11.051414,3.5118228 2.523022,2.604273 4.518416,6.7079726 4.345738,11.1668036 -0.278204,2.985704 -2.398311,5.011251 -4.470451,5.274308 -2.638142,0.565574 -5.314655,0.09206 -7.943203,-0.341976 C 23.811664,22.446579 19.916808,20.499952 17.153955,16.606696 15.89724,14.462775 15.820494,11.12194 17.115585,8.9648664 Z\"%0A fill=\"none\" stroke=\"%23000000\" stroke-width=\"2.1\" stroke-miterlimit=\"10\"/>%0A <path d=\"m 26.741429,13.966963 6.746922,16.786269 3.229398,-4.060758 3.583857,3.583857 2.761195,-2.761196 -3.583856,-3.571852 4.060758,-3.2354 z\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .to-bezier-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Convert-to-bezier tool icon: a smooth curve through two anchor points with control handles. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <!-- smooth cubic curve between the two anchors -->%0A <path d=\"M 7,34 C 7,16 37,28 37,10\" fill=\"none\" stroke=\"%23000000\" stroke-width=\"2.4\"%0A stroke-linecap=\"round\"/>%0A <!-- control handles -->%0A <line x1=\"7\" y1=\"34\" x2=\"7\" y2=\"22\" stroke=\"%23000000\" stroke-width=\"1.4\"/>%0A <line x1=\"37\" y1=\"10\" x2=\"37\" y2=\"22\" stroke=\"%23000000\" stroke-width=\"1.4\"/>%0A <circle cx=\"7\" cy=\"22\" r=\"2.2\"/>%0A <circle cx=\"37\" cy=\"22\" r=\"2.2\"/>%0A <!-- anchor points -->%0A <rect x=\"4\" y=\"31\" width=\"6\" height=\"6\" rx=\"1\"/>%0A <rect x=\"34\" y=\"7\" width=\"6\" height=\"6\" rx=\"1\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .to-polygon-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Convert-to-polygon tool icon: endpoints joined by straight segments through a vertex. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <!-- straight-segment path through the vertices -->%0A <polyline points=\"7,34 22,12 37,10\" fill=\"none\" stroke=\"%23000000\" stroke-width=\"2.4\"%0A stroke-linecap=\"round\" stroke-linejoin=\"round\"/>%0A <!-- vertices -->%0A <rect x=\"4\" y=\"31\" width=\"6\" height=\"6\" rx=\"1\"/>%0A <rect x=\"19\" y=\"9\" width=\"6\" height=\"6\" rx=\"1\"/>%0A <rect x=\"34\" y=\"7\" width=\"6\" height=\"6\" rx=\"1\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}.intensity-panel{position:fixed;z-index:1100;width:280px;height:180px;background:#191919e6;border:1px solid var(--primary-color, #3399ff);border-radius:6px;box-shadow:0 4px 14px #00000073;display:flex;flex-direction:column;overflow:hidden}.intensity-panel-header{display:flex;align-items:center;justify-content:space-between;padding:.25rem .5rem;cursor:move;-webkit-user-select:none;user-select:none;background:#ffffff14;color:#eee;font-size:.75rem;font-weight:600}.intensity-panel-header i{opacity:.6;font-size:.7rem}.intensity-panel-plot{flex:1 1 auto;width:100%}:host ::ng-deep .sam-progress .p-progressbar-value{transition:none}\n"], dependencies: [{ kind: "directive", type: i1$1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1$1.NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletContent", "ngComponentOutletNgModule", "ngComponentOutletNgModuleFactory"] }, { 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: i1$1.NgSwitch, selector: "[ngSwitch]", inputs: ["ngSwitch"] }, { kind: "directive", type: i1$1.NgSwitchCase, selector: "[ngSwitchCase]", inputs: ["ngSwitchCase"] }, { 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: "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: 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: i15$1.ContextMenu, selector: "p-contextMenu", inputs: ["model", "triggerEvent", "target", "global", "style", "styleClass", "appendTo", "autoZIndex", "baseZIndex", "id", "ariaLabel", "ariaLabelledBy", "pressDelay"], outputs: ["onShow", "onHide"] }, { kind: "component", type: i9$1.ProgressBar, selector: "p-progressBar", inputs: ["value", "showValue", "styleClass", "style", "unit", "mode", "color"] }, { kind: "component", type: i17$1.Toast, selector: "p-toast", inputs: ["key", "autoZIndex", "baseZIndex", "life", "style", "styleClass", "position", "preventOpenDuplicates", "preventDuplicates", "showTransformOptions", "hideTransformOptions", "showTransitionOptions", "hideTransitionOptions", "breakpoints"], outputs: ["onClose"] }, { 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: ToolbarComponent, selector: "plotting-toolbar", inputs: ["imageInfo", "plotTypeOptions", "selectedPlotType", "basePlotType", "isoRange", "maxIndex", "zIndex", "isHeatmap", "is3dRegions", "activeDragMode", "activeSurface3dMode", "axesVisible", "wireframeActive", "resolutionScale", "wandSensitivity", "brushSize", "vertexEraserRadius", "canUndo", "canRedo", "stackOptions", "tools", "imageSmoothingEnabled", "samModels", "samModelId", "contributedTools", "dialogTools", "openDialogToolId", "toolModelIds"], outputs: ["selectPlotType", "addProfileLine", "toggleImageSmoothing", "isoRangeChange", "openChannelHistogram", "openSpatialControls", "openRegionEditor", "selectStackOption", "zScrub", "zSlide", "zIndexInput", "reloadAndPlot", "downloadImage", "autoscaleImage", "toggleDragMode", "zoomIn", "zoomOut", "toggleSurface3dMode", "resetSurfaceCamera", "toggleAxes", "toggleWireframe", "selectResolution", "deleteRegion", "undoRegion", "redoRegion", "segmentRegions", "segmentCellpose", "runTool", "toggleDialogTool", "toolModelChange", "openToolParams", "samModelChange", "wandSensitivityChange", "brushSizeChange", "vertexEraserRadiusChange"] }, { kind: "component", type: RegionEditorComponent, selector: "region-editor" }, { kind: "component", type: ChannelHistogramComponent, selector: "channel-histogram", inputs: ["visible"], outputs: ["visibleChange"] }, { kind: "component", type: SpatialControlsComponent, selector: "spatial-controls", inputs: ["visible", "is3d"], outputs: ["visibleChange"] }] }); }
15714
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.9", type: VisualizerComponent, selector: "visualizer", inputs: { toolbarTools: "toolbarTools", testMode: "testMode" }, outputs: { isStackEvent: "isStackEvent", isGrayscaleEvent: "isGrayscaleEvent" }, host: { listeners: { "window:keydown": "onKeydown($event)" } }, viewQueries: [{ propertyName: "contextMenu", first: true, predicate: ["cm"], descendants: true }, { propertyName: "plotModePanelSlot", first: true, predicate: ["plotModePanelSlot"], descendants: true }, { propertyName: "toolDialogSlot", first: true, predicate: ["toolDialogSlot"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "<!-- SAM toast: live status + encoder-download progress bar (jit-ui#90). Sticky\n while a segment runs; stays open until the bar reaches 100% and the run ends. -->\n<p-toast [key]=\"samToastKey\" position=\"bottom-right\">\n <ng-template let-message pTemplate=\"message\">\n <div style=\"flex: 1; min-width: 16rem\">\n <div class=\"font-bold mb-1\">{{ message.summary }}</div>\n <div>{{ samStatus || message.detail }}</div>\n <p-progressBar *ngIf=\"samDownloading\" [value]=\"samProgress\" styleClass=\"mt-2 sam-progress\"></p-progressBar>\n <p-progressBar *ngIf=\"samBusy && !samDownloading\" mode=\"indeterminate\" styleClass=\"mt-2\"\n [style]=\"{ height: '4px' }\"></p-progressBar>\n </div>\n </ng-template>\n</p-toast>\n\n<!-- Result/error notices from the library itself (segmentation outcome, region\n validation). Rendered here so they do not depend on the host mounting a\n keyless <p-toast> \u2014 without this the messages went nowhere in any host but\n jit-ui, and a failed run looked like a button that did nothing. -->\n<p-toast [key]=\"resultToastKey\" position=\"bottom-right\"></p-toast>\n\n<!-- Shared outlets for notices raised where there is no per-instance key to use:\n the root-provided PlotlyService and the region-editor dialog. Rendered by a\n single visualizer (ownsSharedToasts) so a message from the root service is\n not shown once per live viewer. -->\n<p-toast *ngIf=\"ownsSharedToasts\" [key]=\"vizToastKey\"></p-toast>\n<p-toast *ngIf=\"ownsSharedToasts\" [key]=\"vizAlertToastKey\" position=\"center\"></p-toast>\n\n<!-- Toolbar dock: docked across the top by default; drag the handle to detach\n it into a floating, movable window (frees the top row for the image). -->\n<div class=\"toolbar-dock\" [class.floating]=\"toolbarFloating\"\n [style.left.px]=\"toolbarFloating ? toolbarPos.x : null\"\n [style.top.px]=\"toolbarFloating ? toolbarPos.y : null\">\n <div class=\"toolbar-drag-handle\" (mousedown)=\"onToolbarDragStart($event)\"\n pTooltip=\"Drag to float the toolbar\" tooltipPosition=\"bottom\">\n <i class=\"pi pi-bars\"></i>\n </div>\n<plotting-toolbar\n [imageInfo]=\"imageInfo\"\n [hasSpatialDataset]=\"hasSpatialDataset\"\n [plotTypeOptions]=\"plotTypeMenu\"\n [selectedPlotType]=\"selectedPlotTypeId\"\n [basePlotType]=\"basePlotType\"\n [isoRange]=\"isoRange\"\n [maxIndex]=\"maxIndex\"\n [zIndex]=\"zIndex\"\n [isHeatmap]=\"isHeatmap\"\n [is3dRegions]=\"isSpatial3dMode\"\n [activeDragMode]=\"activeDragMode\"\n [activeSurface3dMode]=\"activeSurface3dMode\"\n [axesVisible]=\"axesVisible\"\n [wireframeActive]=\"wireframeActive\"\n [resolutionScale]=\"resolutionScale\"\n [wandSensitivity]=\"wandSensitivity\"\n [brushSize]=\"brushSize\"\n [samModels]=\"samModels\"\n [samModelId]=\"samModelId\"\n [contributedTools]=\"contributedTools\"\n [dialogTools]=\"dialogTools\"\n [openDialogToolId]=\"openDialogToolId\"\n (toggleDialogTool)=\"toggleDialogTool($event)\"\n [toolModelIds]=\"toolModelIds\"\n [vertexEraserRadius]=\"vertexEraserRadius\"\n [canUndo]=\"canUndoRegion\"\n [canRedo]=\"canRedoRegion\"\n [stackOptions]=\"stackOptions\"\n [tools]=\"toolbarTools\"\n [imageSmoothingEnabled]=\"imageSmoothingEnabled\"\n (selectPlotType)=\"onSelectPlotType($event)\"\n (toggleImageSmoothing)=\"onToggleImageSmoothing()\"\n (addProfileLine)=\"addProfileLine()\"\n (isoRangeChange)=\"onIsoRangeChange($event)\"\n (openChannelHistogram)=\"openChannelHistogram()\"\n (openSpatialControls)=\"openSpatialControls()\"\n (openRegionEditor)=\"openRegionEditor()\"\n (selectStackOption)=\"selectStackOption($event)\"\n (zScrub)=\"onZScrub($event)\"\n (zSlide)=\"onZSlide($event)\"\n (zIndexInput)=\"zIndex = $event; updateZIndex()\"\n (reloadAndPlot)=\"reloadAndPlot()\"\n (downloadImage)=\"downloadImage()\"\n (autoscaleImage)=\"autoscaleImage()\"\n (toggleDragMode)=\"toggleDragMode($event)\"\n (zoomIn)=\"zoomIn()\"\n (zoomOut)=\"zoomOut()\"\n (toggleSurface3dMode)=\"toggleSurface3dMode($event)\"\n (resetSurfaceCamera)=\"resetSurfaceCamera()\"\n (toggleAxes)=\"toggleAxes()\"\n (toggleWireframe)=\"toggleWireframe()\"\n (selectResolution)=\"selectResolution($event)\"\n (deleteRegion)=\"deleteRegion()\"\n (undoRegion)=\"undoRegion()\"\n (redoRegion)=\"redoRegion()\"\n (segmentRegions)=\"segmentRegions()\"\n (segmentCellpose)=\"segmentCellpose()\"\n (samModelChange)=\"onSamModelChange($event)\"\n (runTool)=\"runTool($event)\"\n (toolModelChange)=\"onToolModelChange($event)\"\n (openToolParams)=\"openToolParams($event)\"\n (wandSensitivityChange)=\"onWandSensitivityChange($event)\"\n (brushSizeChange)=\"onBrushSizeChange($event)\"\n (vertexEraserRadiusChange)=\"onVertexEraserRadiusChange($event)\">\n <!-- Host-projected custom toolbar buttons, forwarded into the toolbar. -->\n <ng-content></ng-content>\n</plotting-toolbar>\n <p-button *ngIf=\"toolbarFloating\" class=\"toolbar-dock-btn\" icon=\"pi pi-thumbtack\" size=\"small\" [text]=\"true\"\n pTooltip=\"Dock to top\" tooltipPosition=\"bottom\" (onClick)=\"dockToolbar()\">\n </p-button>\n</div>\n\n<p-contextMenu #cm [model]=\"contextMenuItems\" appendTo=\"body\"></p-contextMenu>\n<!-- No `h-full` here: PrimeFlex's height:100% !important made #plot as tall as\n the whole host, so under the docked toolbar its bottom toolbar-height strip\n (incl. the OSD scale bar at bottom-left) was clipped by the host's\n overflow:hidden. The component stylesheet sizes it (.viz-plot { flex: 1 }). -->\n<div class=\"w-full relative viz-plot\" [id]=\"plotDivName\">\n <div *ngIf=\"imgLoading || stackLoading || isCaching\" id=\"overlay\">\n <!-- Determinate stack/volume progress takes precedence: while it's active show ONLY the\n progress bar + \"Loading frames...\", not the generic spinner/\"Loading image...\" on top of\n it (both were rendering at once for napari volume/isosurface loads). -->\n <ng-container *ngIf=\"stackLoading; else imgLoadingTpl\">\n <p-progressBar class=\"progress\" [value]=\"loadingPercentage\"></p-progressBar>\n <span class=\"text-loading\">Loading frames...</span>\n </ng-container>\n <ng-template #imgLoadingTpl>\n <!-- Cache-copy progress is shown whenever a cache download is active (isCaching), independent\n of `imgLoading`, so it's never dropped/masked mid-download. -->\n <ng-container *ngIf=\"isCaching; else spinnerLoading\">\n <!-- Stage-aware message comes from CacheProgressService updating\n imageLoadingMessage$ (\"Caching image...\" during the GCS download,\n then \"Generating preview...\" during per-slice thumbnail gen). Once the\n download hits 100% the remaining work (server preview/tile generation)\n has no determinate progress, so fall through to the spinner template\n rather than parking a full bar under \"Loading image...\". -->\n <p-progressBar class=\"progress\" [value]=\"cacheProgress\"></p-progressBar>\n <span class=\"text-loading\">{{loadingMessage}}</span>\n </ng-container>\n <ng-template #spinnerLoading>\n <div *ngIf=\"imgLoading\" class=\"spinner\"></div>\n <span *ngIf=\"imgLoading && !zoom\" class=\"text-loading\">{{loadingMessage}}</span>\n <span *ngIf=\"imgLoading && zoom\" class=\"zoom-text-loading\">{{loadingMessage}}</span>\n </ng-template>\n </ng-template>\n <p-button *ngIf=\"stackLoading || imgLoading\" class=\"cancel-loading \" label=\"Cancel\"\n (onClick)=\"cancelLoading()\">\n </p-button>\n </div>\n <!-- Translucent \"sharpening\" overlay shown after the small-tier preview is\n on screen while the large-tier render is still loading in the\n background. Lets the user see the blurry image underneath but makes\n it clear something is still loading so they don't mistake the\n low-res render for the final image. -->\n <div *ngIf=\"sharpening && !imgLoading && !stackLoading && !isCaching\" id=\"sharpening-overlay\">\n <div class=\"spinner\"></div>\n <span class=\"text-loading\">Sharpening preview...</span>\n </div>\n\n</div>\n\n<!-- Floating, draggable intensity-profile inset. Shown in LINE mode; the chart\n updates live as the line ROI on the image is moved. Kept OUTSIDE #plot and\n positioned fixed so it floats over the whole page (not clipped to the\n diagram/canvas area). -->\n<div *ngIf=\"hasProfiles\" class=\"intensity-panel\"\n [style.left.px]=\"profilePanelPos.x\" [style.top.px]=\"profilePanelPos.y\">\n <div class=\"intensity-panel-header\" (mousedown)=\"onProfilePanelDragStart($event)\">\n <span>Intensity profile</span>\n <i class=\"pi pi-arrows-alt\"></i>\n </div>\n <div [id]=\"intensityInsetDiv\" class=\"intensity-panel-plot\"></div>\n</div>\n\n<!-- Channels & Histogram: non-modal floating dialog opened from the toolbar. -->\n<channel-histogram [(visible)]=\"showChannelHistogram\"></channel-histogram>\n\n<!-- Spatial-omics controls. Renders its own empty state when no dataset (or no\n SPATIAL_DATA_PORT) is present, so it is always safe to host. -->\n<spatial-controls [(visible)]=\"showSpatialControls\" [is3d]=\"isSpatial3dMode\" ></spatial-controls>\n\n<!-- Side panel of the active contributed plot mode (PLOT_TYPE_CONTRIBUTIONS),\n in the same right-hand area as the spatial controls and the Region Editor.\n Exists only while the mode's session is live; there is no close button,\n because leaving the mode is what ends it. A component panel gets\n PLOT_MODE_CONTEXT / PLOT_MODE_SESSION from its injector; a `mount` panel's\n host element is attached to the slot below (see plotModePanelSlot). -->\n<p-dialog *ngIf=\"plotModePanel as panel\" [header]=\"panel.title\" [visible]=\"true\"\n [modal]=\"false\" [closable]=\"false\" [resizable]=\"true\" [draggable]=\"true\"\n appendTo=\"body\" position=\"right\" styleClass=\"plot-mode-panel\"\n [style]=\"{ width: '22rem', maxHeight: '85vh' }\"\n [contentStyle]=\"{ overflow: 'auto' }\">\n <ng-container *ngIf=\"panel.component\">\n <ng-container *ngComponentOutlet=\"panel.component; injector: panel.injector ?? undefined\"></ng-container>\n </ng-container>\n <div *ngIf=\"panel.host\" #plotModePanelSlot class=\"plot-mode-panel-slot\"></div>\n</p-dialog>\n\n<!-- An open dialog tool (TOOLBAR_TOOLS, kind: 'dialog'). Non-modal so the user can\n keep working on the image, e.g. painting with the brush the tool armed. The\n tool renders its body into a host element attached to the slot below (see\n toolDialogSlot); closing the dialog ends the tool's session. -->\n<p-dialog *ngIf=\"toolDialog as dlg\" [header]=\"dlg.title\" [visible]=\"true\"\n (visibleChange)=\"$event || closeDialogTool()\"\n [modal]=\"false\" [closable]=\"true\" [resizable]=\"true\" [draggable]=\"true\"\n appendTo=\"body\" position=\"right\" styleClass=\"tool-dialog\"\n [style]=\"{ width: dlg.width, maxHeight: '85vh' }\"\n [contentStyle]=\"{ overflow: 'auto' }\">\n <div #toolDialogSlot class=\"tool-dialog-slot\"></div>\n</p-dialog>\n\n<!-- Region Editor: non-modal, draggable, resizable floating dialog opened from\n the toolbar (formerly the right-panel Regions tab). Modeless so regions can\n still be drawn/selected on the plot while it's open; stays linked to this\n plotting instance via the root RegionStore singleton. Opens over the right\n side of the view. -->\n<p-dialog header=\"Region Editor\" [(visible)]=\"showRegionEditor\"\n [modal]=\"false\" [resizable]=\"true\" [draggable]=\"true\"\n [maximizable]=\"true\" appendTo=\"body\" position=\"right\"\n [style]=\"{ width: regionEditorWidth, height: '85vh' }\"\n [contentStyle]=\"{ overflow: 'auto' }\">\n <region-editor></region-editor>\n</p-dialog>\n\n<!-- Simplify (Douglas\u2013Peucker): custom px-threshold picker for the selected\n region(s), opened from the right-click \"Simplify \u25B8 Custom\u2026\" action. -->\n<p-dialog header=\"Simplify region(s)\" [(visible)]=\"displaySimplifyDialog\" [modal]=\"true\"\n [style]=\"{width: '22rem'}\" [draggable]=\"false\" [resizable]=\"false\">\n <div class=\"flex flex-column gap-2\">\n <span>Altitude threshold: <b>{{ simplifyThreshold }} px</b></span>\n <p-slider [(ngModel)]=\"simplifyThreshold\" [min]=\"1\" [max]=\"20\" [step]=\"1\"></p-slider>\n <small>Vertices within this distance of the line between their neighbours are removed.</small>\n </div>\n <ng-template pTemplate=\"footer\">\n <p-button label=\"Cancel\" [text]=\"true\" (onClick)=\"displaySimplifyDialog = false\"></p-button>\n <p-button label=\"Apply\" icon=\"pi pi-check\" (onClick)=\"simplifyRegions(simplifyThreshold)\"></p-button>\n </ng-template>\n</p-dialog>\n\n<!-- Parameters for whichever contributed tool is open. One dialog for all of\n them: a tool declares its fields (ToolParamSpec) and this renders them, so\n the toolbar can lay every tool out the same way and a plugin needs no\n PrimeNG of its own. Defaults come from the active checkpoint, since they\n encode what it was trained for. -->\n<p-dialog *ngIf=\"openParamsTool as tool\"\n [header]=\"tool.label + ' parameters'\"\n [visible]=\"true\" (visibleChange)=\"openParamsToolId = null\"\n [modal]=\"true\"\n [style]=\"{width: '30rem'}\" [draggable]=\"false\" [resizable]=\"false\" appendTo=\"body\">\n <div class=\"grid formgrid p-fluid\">\n <ng-container *ngFor=\"let p of tool.params\">\n <div class=\"field\" [ngClass]=\"p.type === 'boolean' ? 'col-12' : 'col-6'\">\n <ng-container [ngSwitch]=\"p.type\">\n\n <ng-container *ngSwitchCase=\"'number'\">\n <label class=\"text-sm\" [pTooltip]=\"p.tooltip || ''\">{{ p.label }}</label>\n <p-inputNumber [ngModel]=\"paramsFor(tool.id)[p.id]\"\n (ngModelChange)=\"paramsFor(tool.id)[p.id] = $event\"\n [min]=\"numberParam(p).min\" [max]=\"numberParam(p).max\"\n [step]=\"numberParam(p).step\"\n [minFractionDigits]=\"numberParam(p).fractionDigits\"\n [showButtons]=\"true\"></p-inputNumber>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'select'\">\n <label class=\"text-sm\" [pTooltip]=\"p.tooltip || ''\">{{ p.label }}</label>\n <p-dropdown [options]=\"selectParam(p).options\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"paramsFor(tool.id)[p.id]\"\n (ngModelChange)=\"paramsFor(tool.id)[p.id] = $event\"\n appendTo=\"body\"></p-dropdown>\n </ng-container>\n\n <!-- Checkbox label sits beside the box and may run to a sentence, so\n it does not get the stacked label the other types use. -->\n <div *ngSwitchCase=\"'boolean'\" class=\"flex align-items-center gap-2\">\n <p-checkbox [ngModel]=\"paramsFor(tool.id)[p.id]\"\n (ngModelChange)=\"paramsFor(tool.id)[p.id] = $event\"\n [binary]=\"true\" [inputId]=\"tool.id + '-' + p.id\"></p-checkbox>\n <label [for]=\"tool.id + '-' + p.id\" class=\"text-sm mb-0\"\n [pTooltip]=\"p.tooltip || ''\">{{ p.label }}</label>\n </div>\n\n </ng-container>\n </div>\n </ng-container>\n </div>\n <ng-template pTemplate=\"footer\">\n <p-button label=\"Reset to model defaults\" [text]=\"true\" size=\"small\"\n (onClick)=\"resetToolParams(tool.id)\"></p-button>\n <p-button label=\"Close\" size=\"small\" (onClick)=\"openParamsToolId = null\"></p-button>\n </ng-template>\n</p-dialog>\n", styles: [":host{display:flex;flex-direction:column;height:100%;overflow:hidden}.viz-plot{flex:1;min-height:0;height:auto!important}.toolbar-dock{display:flex;align-items:stretch;width:100%}.toolbar-dock plotting-toolbar{flex:1;min-width:0}.toolbar-dock.floating{position:fixed;z-index:1000;width:auto;max-width:calc(100% - 16px);background:var(--surface-card, #ffffff);border:1px solid var(--surface-border, #cccccc);border-radius:6px;box-shadow:0 4px 16px #0000004d}.toolbar-dock.floating plotting-toolbar{flex:0 1 auto}.toolbar-dock.floating .toolbar-drag-handle{border-radius:6px 0 0 6px}.toolbar-drag-handle{display:flex;align-items:center;justify-content:center;padding:0 .4rem;cursor:move;color:var(--primary-color);background:var(--surface-100, rgba(0, 0, 0, .04));border-right:1px solid var(--surface-border, #cccccc);-webkit-user-select:none;user-select:none}.toolbar-dock-btn{align-self:center}:host ::ng-deep .navigator{margin-bottom:80px!important;margin-right:12px!important}kbd{background:#e8e8e8;border:1px solid #bbb;border-radius:3px;padding:1px 5px;font-family:monospace;font-size:.85em}::ng-deep .context-menu-active>.p-menuitem-content{background-color:var(--primary-color)!important}::ng-deep .context-menu-active>.p-menuitem-content .p-menuitem-icon,::ng-deep .context-menu-active>.p-menuitem-content .p-menuitem-text{color:#fff!important}::ng-deep .context-menu-active>.p-menuitem-content .zoom-box-off-icon,::ng-deep .context-menu-active>.p-menuitem-content .wand-icon,::ng-deep .context-menu-active>.p-menuitem-content .polyline-icon,::ng-deep .context-menu-active>.p-menuitem-content .polygon-vertices-icon,::ng-deep .context-menu-active>.p-menuitem-content .vertex-add-icon,::ng-deep .context-menu-active>.p-menuitem-content .vertex-delete-icon,::ng-deep .context-menu-active>.p-menuitem-content .region-move-icon,::ng-deep .context-menu-active>.p-menuitem-content .to-bezier-icon,::ng-deep .context-menu-active>.p-menuitem-content .to-polygon-icon{filter:brightness(0) invert(1)}.toolbar-separator{border-left:1px solid var(--surface-border);height:1.5rem;margin:0 .25rem;align-self:center}.progress{position:absolute;width:50%;left:25%;top:55%}.text-loading{position:absolute;margin:0 auto;left:45%;top:60%;font-weight:700}.zoom-text-loading{position:absolute;margin:0 auto;left:35%;top:60%;font-weight:700;display:inline-block;width:350px}.cancel-loading{position:absolute;margin:0 auto;left:46%;top:70%}.spinner{position:absolute;left:45%;top:45%;height:100px;width:100px;margin:0 auto;-webkit-animation:rotation .6s infinite linear;-moz-animation:rotation .6s infinite linear;-o-animation:rotation .6s infinite linear;animation:rotation .6s infinite linear;border-left:6px solid rgba(0,174,239,.15);border-right:6px solid rgba(0,174,239,.15);border-bottom:6px solid rgba(0,174,239,.15);border-top:6px solid rgb(82,107,178);border-radius:100%}#overlay{position:absolute;top:0;left:0;width:100%;height:100%;background-color:#ffffff80;z-index:1000}#sharpening-overlay{position:absolute;top:0;left:0;width:100%;height:100%;background-color:#ffffff2e;pointer-events:none;z-index:1000}#sharpening-overlay .spinner{height:60px;width:60px;border-width:4px;left:calc(50% - 30px);top:calc(50% - 60px)}#sharpening-overlay .text-loading{position:absolute;left:0;right:0;width:auto;text-align:center;top:calc(50% + 35px);font-size:.9rem;color:#526bb2}@-webkit-keyframes rotation{0%{-webkit-transform:rotate(0deg)}to{-webkit-transform:rotate(359deg)}}@-moz-keyframes rotation{0%{-moz-transform:rotate(0deg)}to{-moz-transform:rotate(359deg)}}@-o-keyframes rotation{0%{-o-transform:rotate(0deg)}to{-o-transform:rotate(359deg)}}@keyframes rotation{0%{transform:rotate(0)}to{transform:rotate(359deg)}}::ng-deep .zoom-box-off-icon{background:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\" fill=\"black\" fill-rule=\"evenodd\">%0A <!-- Rectangle outline -->%0A <rect x=\"2\" y=\"2\" width=\"56\" height=\"56\"/>%0A <rect x=\"9\" y=\"9\" width=\"42\" height=\"42\"/>%0A <!-- Magnifying glass ring -->%0A <circle cx=\"70\" cy=\"70\" r=\"23\"/>%0A <circle cx=\"70\" cy=\"70\" r=\"14\"/>%0A <!-- Handle -->%0A <polygon points=\"81,81 97,97 92,100 77,85\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .wand-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<svg%0A viewBox=\"0 0 100 100\"%0A fill=\"black\"%0A fill-rule=\"evenodd\"%0A version=\"1.1\"%0A id=\"svg4\"%0A sodipodi:docname=\"wand.svg\"%0A inkscape:version=\"1.4.3 (0d15f75, 2025-12-25)\"%0A xmlns:inkscape=\"http://www.inkscape.org/namespaces/inkscape\"%0A xmlns:sodipodi=\"http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd\"%0A xmlns=\"http://www.w3.org/2000/svg\"%0A xmlns:svg=\"http://www.w3.org/2000/svg\">%0A <defs%0A id=\"defs4\" />%0A <sodipodi:namedview%0A id=\"namedview4\"%0A pagecolor=\"%23ffffff\"%0A bordercolor=\"%23000000\"%0A borderopacity=\"0.25\"%0A inkscape:showpageshadow=\"2\"%0A inkscape:pageopacity=\"0.0\"%0A inkscape:pagecheckerboard=\"0\"%0A inkscape:deskcolor=\"%23d1d1d1\"%0A inkscape:zoom=\"8.74\"%0A inkscape:cx=\"50\"%0A inkscape:cy=\"49.942792\"%0A inkscape:window-width=\"1024\"%0A inkscape:window-height=\"1186\"%0A inkscape:window-x=\"3560\"%0A inkscape:window-y=\"131\"%0A inkscape:window-maximized=\"0\"%0A inkscape:current-layer=\"svg4\" />%0A <!-- Wand handle: thin diagonal stick from lower-left to upper-right -->%0A <polygon%0A points=\"12,92 78,26 74,22 8,88 \"%0A id=\"polygon1\"%0A transform=\"matrix(1.0375192,0.05799445,0.25179496,0.91871864,-17.85351,5.6289793)\" />%0A <!-- 4-pointed star at the wand tip -->%0A <polygon%0A points=\"62,34 76,30 80,6 84,30 98,34 84,38 80,62 76,38 \"%0A id=\"polygon2\"%0A transform=\"matrix(1.3082889,0,0,1.0245178,-38.907958,-1.4056881)\" />%0A <!-- Small sparkle to the upper-left -->%0A <polygon%0A points=\"46,30 36,32 34,42 32,32 22,30 32,28 34,18 36,28 \"%0A id=\"polygon3\"%0A transform=\"translate(-9.610984,-6.7505721)\" />%0A <!-- Small sparkle to the lower-right -->%0A <polygon%0A points=\"62,80 70,82 62,84 60,92 58,84 50,82 58,80 60,72 \"%0A id=\"polygon4\"%0A transform=\"translate(0,-1.6018307)\" />%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .brush-icon{background:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 640 640\"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d=\"M512.5 74.3L291.1 222C262 241.4 243.5 272.9 240.5 307.3C302.8 320.1 351.9 369.2 364.8 431.6C399.3 428.6 430.7 410.1 450.1 381L597.7 159.5C604.4 149.4 608 137.6 608 125.4C608 91.5 580.5 64 546.6 64C534.5 64 522.6 67.6 512.5 74.3zM320 464C320 402.1 269.9 352 208 352C146.1 352 96 402.1 96 464C96 467.9 96.2 471.8 96.6 475.6C98.4 493.1 86.4 512 68.8 512L64 512C46.3 512 32 526.3 32 544C32 561.7 46.3 576 64 576L208 576C269.9 576 320 525.9 320 464z\"/></svg>') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .polyline-icon{background:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\">%0A <polyline points=\"1,14 5,3 10,11 15,2\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .polygon-vertices-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Polygon (click-to-place) tool icon: a traced polyline with vertex dots. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <g transform=\"matrix(1.4740709,0,0,1.4740709,-6.0268793,-6.8086826)\">%0A <polyline points=\"23.877,16.627 14.657,10.767 5.25,21.551 12.757,29.397\"%0A fill=\"none\" stroke=\"%23000000\" stroke-width=\"1.6\"%0A stroke-linejoin=\"round\" stroke-linecap=\"round\"/>%0A <circle cx=\"23.877\" cy=\"16.627\" r=\"1.9\"/>%0A <circle cx=\"14.657\" cy=\"10.767\" r=\"1.9\"/>%0A <circle cx=\"5.25\" cy=\"21.551\" r=\"1.9\"/>%0A <circle cx=\"12.757\" cy=\"29.397\" r=\"1.9\"/>%0A </g>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .vertex-add-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Add-vertex tool icon: a region blob with three corner vertices and a plus glyph. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <g transform=\"matrix(1.5222969,0,0,1.5222969,-8.2143505,-7.6146399)\">%0A <path d=\"m 9.35834,12.33267 c 1.78,-2.53 4.93,-3.56 7.86,-3.98 3.98,-0.72 8.33,0.08 11.52,2.67 2.62,1.98 4.71,5.1 4.53,8.49 -0.29,2.27 -2.5,3.81 -4.66,4.01 -2.75,0.43 -5.54,0.07 -8.28,-0.26 -3.99,-0.68 -8.05,-2.16 -10.93,-5.12 -1.32,-1.63 -1.39,-4.17 -0.04,-5.81 z\"%0A fill=\"none\" stroke=\"%23000000\" stroke-width=\"1.6\" stroke-miterlimit=\"10\"/>%0A <circle cx=\"8.294\" cy=\"15.248\" r=\"2.63\"/>%0A <circle cx=\"19.781\" cy=\"8.287\" r=\"2.63\"/>%0A <circle cx=\"25.044\" cy=\"23.748\" r=\"2.63\"/>%0A </g>%0A <path d=\"M 9.65,29.0 H 5.85 v 5.3 H 0.55 v 3.4 H 5.85 v 5.3 H 9.65 V 37.7 H 14.95 V 34.3 H 9.65 Z\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .vertex-delete-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Delete-vertex tool icon: a region blob with three corner vertices and a minus glyph. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <g transform=\"matrix(1.5222969,0,0,1.5222969,-8.2143505,-7.6146399)\">%0A <path d=\"m 9.35834,12.33267 c 1.78,-2.53 4.93,-3.56 7.86,-3.98 3.98,-0.72 8.33,0.08 11.52,2.67 2.62,1.98 4.71,5.1 4.53,8.49 -0.29,2.27 -2.5,3.81 -4.66,4.01 -2.75,0.43 -5.54,0.07 -8.28,-0.26 -3.99,-0.68 -8.05,-2.16 -10.93,-5.12 -1.32,-1.63 -1.39,-4.17 -0.04,-5.81 z\"%0A fill=\"none\" stroke=\"%23000000\" stroke-width=\"1.6\" stroke-miterlimit=\"10\"/>%0A <circle cx=\"8.294\" cy=\"15.248\" r=\"2.63\"/>%0A <circle cx=\"19.781\" cy=\"8.287\" r=\"2.63\"/>%0A <circle cx=\"25.044\" cy=\"23.748\" r=\"2.63\"/>%0A </g>%0A <path d=\"M 0.55,34.3 v 3.4 H 14.95 V 34.3 Z\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .region-move-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Region-move tool icon: a region blob with a drag arrow. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <path d=\"m 17.115582,8.9648664 c 1.707597,-3.3276806 4.729468,-4.6824281 7.540287,-5.2348498 3.827703,-0.947008 7.991169,0.1052238 11.051414,3.5118228 2.523022,2.604273 4.518416,6.7079726 4.345738,11.1668036 -0.278204,2.985704 -2.398311,5.011251 -4.470451,5.274308 -2.638142,0.565574 -5.314655,0.09206 -7.943203,-0.341976 C 23.811664,22.446579 19.916808,20.499952 17.153955,16.606696 15.89724,14.462775 15.820494,11.12194 17.115585,8.9648664 Z\"%0A fill=\"none\" stroke=\"%23000000\" stroke-width=\"2.1\" stroke-miterlimit=\"10\"/>%0A <path d=\"m 26.741429,13.966963 6.746922,16.786269 3.229398,-4.060758 3.583857,3.583857 2.761195,-2.761196 -3.583856,-3.571852 4.060758,-3.2354 z\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .to-bezier-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Convert-to-bezier tool icon: a smooth curve through two anchor points with control handles. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <!-- smooth cubic curve between the two anchors -->%0A <path d=\"M 7,34 C 7,16 37,28 37,10\" fill=\"none\" stroke=\"%23000000\" stroke-width=\"2.4\"%0A stroke-linecap=\"round\"/>%0A <!-- control handles -->%0A <line x1=\"7\" y1=\"34\" x2=\"7\" y2=\"22\" stroke=\"%23000000\" stroke-width=\"1.4\"/>%0A <line x1=\"37\" y1=\"10\" x2=\"37\" y2=\"22\" stroke=\"%23000000\" stroke-width=\"1.4\"/>%0A <circle cx=\"7\" cy=\"22\" r=\"2.2\"/>%0A <circle cx=\"37\" cy=\"22\" r=\"2.2\"/>%0A <!-- anchor points -->%0A <rect x=\"4\" y=\"31\" width=\"6\" height=\"6\" rx=\"1\"/>%0A <rect x=\"34\" y=\"7\" width=\"6\" height=\"6\" rx=\"1\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .to-polygon-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Convert-to-polygon tool icon: endpoints joined by straight segments through a vertex. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <!-- straight-segment path through the vertices -->%0A <polyline points=\"7,34 22,12 37,10\" fill=\"none\" stroke=\"%23000000\" stroke-width=\"2.4\"%0A stroke-linecap=\"round\" stroke-linejoin=\"round\"/>%0A <!-- vertices -->%0A <rect x=\"4\" y=\"31\" width=\"6\" height=\"6\" rx=\"1\"/>%0A <rect x=\"19\" y=\"9\" width=\"6\" height=\"6\" rx=\"1\"/>%0A <rect x=\"34\" y=\"7\" width=\"6\" height=\"6\" rx=\"1\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}.intensity-panel{position:fixed;z-index:1100;width:280px;height:180px;background:#191919e6;border:1px solid var(--primary-color, #3399ff);border-radius:6px;box-shadow:0 4px 14px #00000073;display:flex;flex-direction:column;overflow:hidden}.intensity-panel-header{display:flex;align-items:center;justify-content:space-between;padding:.25rem .5rem;cursor:move;-webkit-user-select:none;user-select:none;background:#ffffff14;color:#eee;font-size:.75rem;font-weight:600}.intensity-panel-header i{opacity:.6;font-size:.7rem}.intensity-panel-plot{flex:1 1 auto;width:100%}:host ::ng-deep .sam-progress .p-progressbar-value{transition:none}\n"], dependencies: [{ kind: "directive", type: i1$1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1$1.NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletContent", "ngComponentOutletNgModule", "ngComponentOutletNgModuleFactory"] }, { 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: i1$1.NgSwitch, selector: "[ngSwitch]", inputs: ["ngSwitch"] }, { kind: "directive", type: i1$1.NgSwitchCase, selector: "[ngSwitchCase]", inputs: ["ngSwitchCase"] }, { 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: "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: 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: i15$1.ContextMenu, selector: "p-contextMenu", inputs: ["model", "triggerEvent", "target", "global", "style", "styleClass", "appendTo", "autoZIndex", "baseZIndex", "id", "ariaLabel", "ariaLabelledBy", "pressDelay"], outputs: ["onShow", "onHide"] }, { kind: "component", type: i9$1.ProgressBar, selector: "p-progressBar", inputs: ["value", "showValue", "styleClass", "style", "unit", "mode", "color"] }, { kind: "component", type: i17$1.Toast, selector: "p-toast", inputs: ["key", "autoZIndex", "baseZIndex", "life", "style", "styleClass", "position", "preventOpenDuplicates", "preventDuplicates", "showTransformOptions", "hideTransformOptions", "showTransitionOptions", "hideTransitionOptions", "breakpoints"], outputs: ["onClose"] }, { 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: ToolbarComponent, selector: "plotting-toolbar", inputs: ["imageInfo", "hasSpatialDataset", "plotTypeOptions", "selectedPlotType", "basePlotType", "isoRange", "maxIndex", "zIndex", "isHeatmap", "is3dRegions", "activeDragMode", "activeSurface3dMode", "axesVisible", "wireframeActive", "resolutionScale", "wandSensitivity", "brushSize", "vertexEraserRadius", "canUndo", "canRedo", "stackOptions", "tools", "imageSmoothingEnabled", "samModels", "samModelId", "contributedTools", "dialogTools", "openDialogToolId", "toolModelIds"], outputs: ["selectPlotType", "addProfileLine", "toggleImageSmoothing", "isoRangeChange", "openChannelHistogram", "openSpatialControls", "openRegionEditor", "selectStackOption", "zScrub", "zSlide", "zIndexInput", "reloadAndPlot", "downloadImage", "autoscaleImage", "toggleDragMode", "zoomIn", "zoomOut", "toggleSurface3dMode", "resetSurfaceCamera", "toggleAxes", "toggleWireframe", "selectResolution", "deleteRegion", "undoRegion", "redoRegion", "segmentRegions", "segmentCellpose", "runTool", "toggleDialogTool", "toolModelChange", "openToolParams", "samModelChange", "wandSensitivityChange", "brushSizeChange", "vertexEraserRadiusChange"] }, { kind: "component", type: RegionEditorComponent, selector: "region-editor" }, { kind: "component", type: ChannelHistogramComponent, selector: "channel-histogram", inputs: ["visible"], outputs: ["visibleChange"] }, { kind: "component", type: SpatialControlsComponent, selector: "spatial-controls", inputs: ["visible", "is3d"], outputs: ["visibleChange"] }] }); }
15311
15715
  }
15312
15716
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.9", ngImport: i0, type: VisualizerComponent, decorators: [{
15313
15717
  type: Component,
15314
- args: [{ selector: 'visualizer', template: "<!-- SAM toast: live status + encoder-download progress bar (jit-ui#90). Sticky\n while a segment runs; stays open until the bar reaches 100% and the run ends. -->\n<p-toast [key]=\"samToastKey\" position=\"bottom-right\">\n <ng-template let-message pTemplate=\"message\">\n <div style=\"flex: 1; min-width: 16rem\">\n <div class=\"font-bold mb-1\">{{ message.summary }}</div>\n <div>{{ samStatus || message.detail }}</div>\n <p-progressBar *ngIf=\"samDownloading\" [value]=\"samProgress\" styleClass=\"mt-2 sam-progress\"></p-progressBar>\n <p-progressBar *ngIf=\"samBusy && !samDownloading\" mode=\"indeterminate\" styleClass=\"mt-2\"\n [style]=\"{ height: '4px' }\"></p-progressBar>\n </div>\n </ng-template>\n</p-toast>\n\n<!-- Result/error notices from the library itself (segmentation outcome, region\n validation). Rendered here so they do not depend on the host mounting a\n keyless <p-toast> \u2014 without this the messages went nowhere in any host but\n jit-ui, and a failed run looked like a button that did nothing. -->\n<p-toast [key]=\"resultToastKey\" position=\"bottom-right\"></p-toast>\n\n<!-- Shared outlets for notices raised where there is no per-instance key to use:\n the root-provided PlotlyService and the region-editor dialog. Rendered by a\n single visualizer (ownsSharedToasts) so a message from the root service is\n not shown once per live viewer. -->\n<p-toast *ngIf=\"ownsSharedToasts\" [key]=\"vizToastKey\"></p-toast>\n<p-toast *ngIf=\"ownsSharedToasts\" [key]=\"vizAlertToastKey\" position=\"center\"></p-toast>\n\n<!-- Toolbar dock: docked across the top by default; drag the handle to detach\n it into a floating, movable window (frees the top row for the image). -->\n<div class=\"toolbar-dock\" [class.floating]=\"toolbarFloating\"\n [style.left.px]=\"toolbarFloating ? toolbarPos.x : null\"\n [style.top.px]=\"toolbarFloating ? toolbarPos.y : null\">\n <div class=\"toolbar-drag-handle\" (mousedown)=\"onToolbarDragStart($event)\"\n pTooltip=\"Drag to float the toolbar\" tooltipPosition=\"bottom\">\n <i class=\"pi pi-bars\"></i>\n </div>\n<plotting-toolbar\n [imageInfo]=\"imageInfo\"\n [plotTypeOptions]=\"plotTypeMenu\"\n [selectedPlotType]=\"selectedPlotTypeId\"\n [basePlotType]=\"basePlotType\"\n [isoRange]=\"isoRange\"\n [maxIndex]=\"maxIndex\"\n [zIndex]=\"zIndex\"\n [isHeatmap]=\"isHeatmap\"\n [is3dRegions]=\"isSpatial3dMode\"\n [activeDragMode]=\"activeDragMode\"\n [activeSurface3dMode]=\"activeSurface3dMode\"\n [axesVisible]=\"axesVisible\"\n [wireframeActive]=\"wireframeActive\"\n [resolutionScale]=\"resolutionScale\"\n [wandSensitivity]=\"wandSensitivity\"\n [brushSize]=\"brushSize\"\n [samModels]=\"samModels\"\n [samModelId]=\"samModelId\"\n [contributedTools]=\"contributedTools\"\n [dialogTools]=\"dialogTools\"\n [openDialogToolId]=\"openDialogToolId\"\n (toggleDialogTool)=\"toggleDialogTool($event)\"\n [toolModelIds]=\"toolModelIds\"\n [vertexEraserRadius]=\"vertexEraserRadius\"\n [canUndo]=\"canUndoRegion\"\n [canRedo]=\"canRedoRegion\"\n [stackOptions]=\"stackOptions\"\n [tools]=\"toolbarTools\"\n [imageSmoothingEnabled]=\"imageSmoothingEnabled\"\n (selectPlotType)=\"onSelectPlotType($event)\"\n (toggleImageSmoothing)=\"onToggleImageSmoothing()\"\n (addProfileLine)=\"addProfileLine()\"\n (isoRangeChange)=\"onIsoRangeChange($event)\"\n (openChannelHistogram)=\"openChannelHistogram()\"\n (openSpatialControls)=\"openSpatialControls()\"\n (openRegionEditor)=\"openRegionEditor()\"\n (selectStackOption)=\"selectStackOption($event)\"\n (zScrub)=\"onZScrub($event)\"\n (zSlide)=\"onZSlide($event)\"\n (zIndexInput)=\"zIndex = $event; updateZIndex()\"\n (reloadAndPlot)=\"reloadAndPlot()\"\n (downloadImage)=\"downloadImage()\"\n (autoscaleImage)=\"autoscaleImage()\"\n (toggleDragMode)=\"toggleDragMode($event)\"\n (zoomIn)=\"zoomIn()\"\n (zoomOut)=\"zoomOut()\"\n (toggleSurface3dMode)=\"toggleSurface3dMode($event)\"\n (resetSurfaceCamera)=\"resetSurfaceCamera()\"\n (toggleAxes)=\"toggleAxes()\"\n (toggleWireframe)=\"toggleWireframe()\"\n (selectResolution)=\"selectResolution($event)\"\n (deleteRegion)=\"deleteRegion()\"\n (undoRegion)=\"undoRegion()\"\n (redoRegion)=\"redoRegion()\"\n (segmentRegions)=\"segmentRegions()\"\n (segmentCellpose)=\"segmentCellpose()\"\n (samModelChange)=\"onSamModelChange($event)\"\n (runTool)=\"runTool($event)\"\n (toolModelChange)=\"onToolModelChange($event)\"\n (openToolParams)=\"openToolParams($event)\"\n (wandSensitivityChange)=\"onWandSensitivityChange($event)\"\n (brushSizeChange)=\"onBrushSizeChange($event)\"\n (vertexEraserRadiusChange)=\"onVertexEraserRadiusChange($event)\">\n <!-- Host-projected custom toolbar buttons, forwarded into the toolbar. -->\n <ng-content></ng-content>\n</plotting-toolbar>\n <p-button *ngIf=\"toolbarFloating\" class=\"toolbar-dock-btn\" icon=\"pi pi-thumbtack\" size=\"small\" [text]=\"true\"\n pTooltip=\"Dock to top\" tooltipPosition=\"bottom\" (onClick)=\"dockToolbar()\">\n </p-button>\n</div>\n\n<p-contextMenu #cm [model]=\"contextMenuItems\" appendTo=\"body\"></p-contextMenu>\n<!-- No `h-full` here: PrimeFlex's height:100% !important made #plot as tall as\n the whole host, so under the docked toolbar its bottom toolbar-height strip\n (incl. the OSD scale bar at bottom-left) was clipped by the host's\n overflow:hidden. The component stylesheet sizes it (.viz-plot { flex: 1 }). -->\n<div class=\"w-full relative viz-plot\" [id]=\"plotDivName\">\n <div *ngIf=\"imgLoading || stackLoading || isCaching\" id=\"overlay\">\n <!-- Determinate stack/volume progress takes precedence: while it's active show ONLY the\n progress bar + \"Loading frames...\", not the generic spinner/\"Loading image...\" on top of\n it (both were rendering at once for napari volume/isosurface loads). -->\n <ng-container *ngIf=\"stackLoading; else imgLoadingTpl\">\n <p-progressBar class=\"progress\" [value]=\"loadingPercentage\"></p-progressBar>\n <span class=\"text-loading\">Loading frames...</span>\n </ng-container>\n <ng-template #imgLoadingTpl>\n <!-- Cache-copy progress is shown whenever a cache download is active (isCaching), independent\n of `imgLoading`, so it's never dropped/masked mid-download. -->\n <ng-container *ngIf=\"isCaching; else spinnerLoading\">\n <!-- Stage-aware message comes from CacheProgressService updating\n imageLoadingMessage$ (\"Caching image...\" during the GCS download,\n then \"Generating preview...\" during per-slice thumbnail gen). Once the\n download hits 100% the remaining work (server preview/tile generation)\n has no determinate progress, so fall through to the spinner template\n rather than parking a full bar under \"Loading image...\". -->\n <p-progressBar class=\"progress\" [value]=\"cacheProgress\"></p-progressBar>\n <span class=\"text-loading\">{{loadingMessage}}</span>\n </ng-container>\n <ng-template #spinnerLoading>\n <div *ngIf=\"imgLoading\" class=\"spinner\"></div>\n <span *ngIf=\"imgLoading && !zoom\" class=\"text-loading\">{{loadingMessage}}</span>\n <span *ngIf=\"imgLoading && zoom\" class=\"zoom-text-loading\">{{loadingMessage}}</span>\n </ng-template>\n </ng-template>\n <p-button *ngIf=\"stackLoading || imgLoading\" class=\"cancel-loading \" label=\"Cancel\"\n (onClick)=\"cancelLoading()\">\n </p-button>\n </div>\n <!-- Translucent \"sharpening\" overlay shown after the small-tier preview is\n on screen while the large-tier render is still loading in the\n background. Lets the user see the blurry image underneath but makes\n it clear something is still loading so they don't mistake the\n low-res render for the final image. -->\n <div *ngIf=\"sharpening && !imgLoading && !stackLoading && !isCaching\" id=\"sharpening-overlay\">\n <div class=\"spinner\"></div>\n <span class=\"text-loading\">Sharpening preview...</span>\n </div>\n\n</div>\n\n<!-- Floating, draggable intensity-profile inset. Shown in LINE mode; the chart\n updates live as the line ROI on the image is moved. Kept OUTSIDE #plot and\n positioned fixed so it floats over the whole page (not clipped to the\n diagram/canvas area). -->\n<div *ngIf=\"hasProfiles\" class=\"intensity-panel\"\n [style.left.px]=\"profilePanelPos.x\" [style.top.px]=\"profilePanelPos.y\">\n <div class=\"intensity-panel-header\" (mousedown)=\"onProfilePanelDragStart($event)\">\n <span>Intensity profile</span>\n <i class=\"pi pi-arrows-alt\"></i>\n </div>\n <div [id]=\"intensityInsetDiv\" class=\"intensity-panel-plot\"></div>\n</div>\n\n<!-- Channels & Histogram: non-modal floating dialog opened from the toolbar. -->\n<channel-histogram [(visible)]=\"showChannelHistogram\"></channel-histogram>\n\n<!-- Spatial-omics controls. Renders its own empty state when no dataset (or no\n SPATIAL_DATA_PORT) is present, so it is always safe to host. -->\n<spatial-controls [(visible)]=\"showSpatialControls\" [is3d]=\"isSpatial3dMode\" ></spatial-controls>\n\n<!-- Side panel of the active contributed plot mode (PLOT_TYPE_CONTRIBUTIONS),\n in the same right-hand area as the spatial controls and the Region Editor.\n Exists only while the mode's session is live; there is no close button,\n because leaving the mode is what ends it. A component panel gets\n PLOT_MODE_CONTEXT / PLOT_MODE_SESSION from its injector; a `mount` panel's\n host element is attached to the slot below (see plotModePanelSlot). -->\n<p-dialog *ngIf=\"plotModePanel as panel\" [header]=\"panel.title\" [visible]=\"true\"\n [modal]=\"false\" [closable]=\"false\" [resizable]=\"true\" [draggable]=\"true\"\n appendTo=\"body\" position=\"right\" styleClass=\"plot-mode-panel\"\n [style]=\"{ width: '22rem', maxHeight: '85vh' }\"\n [contentStyle]=\"{ overflow: 'auto' }\">\n <ng-container *ngIf=\"panel.component\">\n <ng-container *ngComponentOutlet=\"panel.component; injector: panel.injector ?? undefined\"></ng-container>\n </ng-container>\n <div *ngIf=\"panel.host\" #plotModePanelSlot class=\"plot-mode-panel-slot\"></div>\n</p-dialog>\n\n<!-- An open dialog tool (TOOLBAR_TOOLS, kind: 'dialog'). Non-modal so the user can\n keep working on the image, e.g. painting with the brush the tool armed. The\n tool renders its body into a host element attached to the slot below (see\n toolDialogSlot); closing the dialog ends the tool's session. -->\n<p-dialog *ngIf=\"toolDialog as dlg\" [header]=\"dlg.title\" [visible]=\"true\"\n (visibleChange)=\"$event || closeDialogTool()\"\n [modal]=\"false\" [closable]=\"true\" [resizable]=\"true\" [draggable]=\"true\"\n appendTo=\"body\" position=\"right\" styleClass=\"tool-dialog\"\n [style]=\"{ width: dlg.width, maxHeight: '85vh' }\"\n [contentStyle]=\"{ overflow: 'auto' }\">\n <div #toolDialogSlot class=\"tool-dialog-slot\"></div>\n</p-dialog>\n\n<!-- Region Editor: non-modal, draggable, resizable floating dialog opened from\n the toolbar (formerly the right-panel Regions tab). Modeless so regions can\n still be drawn/selected on the plot while it's open; stays linked to this\n plotting instance via the root RegionStore singleton. Opens over the right\n side of the view. -->\n<p-dialog header=\"Region Editor\" [(visible)]=\"showRegionEditor\"\n [modal]=\"false\" [resizable]=\"true\" [draggable]=\"true\"\n [maximizable]=\"true\" appendTo=\"body\" position=\"right\"\n [style]=\"{ width: regionEditorWidth, height: '85vh' }\"\n [contentStyle]=\"{ overflow: 'auto' }\">\n <region-editor></region-editor>\n</p-dialog>\n\n<!-- Simplify (Douglas\u2013Peucker): custom px-threshold picker for the selected\n region(s), opened from the right-click \"Simplify \u25B8 Custom\u2026\" action. -->\n<p-dialog header=\"Simplify region(s)\" [(visible)]=\"displaySimplifyDialog\" [modal]=\"true\"\n [style]=\"{width: '22rem'}\" [draggable]=\"false\" [resizable]=\"false\">\n <div class=\"flex flex-column gap-2\">\n <span>Altitude threshold: <b>{{ simplifyThreshold }} px</b></span>\n <p-slider [(ngModel)]=\"simplifyThreshold\" [min]=\"1\" [max]=\"20\" [step]=\"1\"></p-slider>\n <small>Vertices within this distance of the line between their neighbours are removed.</small>\n </div>\n <ng-template pTemplate=\"footer\">\n <p-button label=\"Cancel\" [text]=\"true\" (onClick)=\"displaySimplifyDialog = false\"></p-button>\n <p-button label=\"Apply\" icon=\"pi pi-check\" (onClick)=\"simplifyRegions(simplifyThreshold)\"></p-button>\n </ng-template>\n</p-dialog>\n\n<!-- Parameters for whichever contributed tool is open. One dialog for all of\n them: a tool declares its fields (ToolParamSpec) and this renders them, so\n the toolbar can lay every tool out the same way and a plugin needs no\n PrimeNG of its own. Defaults come from the active checkpoint, since they\n encode what it was trained for. -->\n<p-dialog *ngIf=\"openParamsTool as tool\"\n [header]=\"tool.label + ' parameters'\"\n [visible]=\"true\" (visibleChange)=\"openParamsToolId = null\"\n [modal]=\"true\"\n [style]=\"{width: '30rem'}\" [draggable]=\"false\" [resizable]=\"false\" appendTo=\"body\">\n <div class=\"grid formgrid p-fluid\">\n <ng-container *ngFor=\"let p of tool.params\">\n <div class=\"field\" [ngClass]=\"p.type === 'boolean' ? 'col-12' : 'col-6'\">\n <ng-container [ngSwitch]=\"p.type\">\n\n <ng-container *ngSwitchCase=\"'number'\">\n <label class=\"text-sm\" [pTooltip]=\"p.tooltip || ''\">{{ p.label }}</label>\n <p-inputNumber [ngModel]=\"paramsFor(tool.id)[p.id]\"\n (ngModelChange)=\"paramsFor(tool.id)[p.id] = $event\"\n [min]=\"numberParam(p).min\" [max]=\"numberParam(p).max\"\n [step]=\"numberParam(p).step\"\n [minFractionDigits]=\"numberParam(p).fractionDigits\"\n [showButtons]=\"true\"></p-inputNumber>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'select'\">\n <label class=\"text-sm\" [pTooltip]=\"p.tooltip || ''\">{{ p.label }}</label>\n <p-dropdown [options]=\"selectParam(p).options\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"paramsFor(tool.id)[p.id]\"\n (ngModelChange)=\"paramsFor(tool.id)[p.id] = $event\"\n appendTo=\"body\"></p-dropdown>\n </ng-container>\n\n <!-- Checkbox label sits beside the box and may run to a sentence, so\n it does not get the stacked label the other types use. -->\n <div *ngSwitchCase=\"'boolean'\" class=\"flex align-items-center gap-2\">\n <p-checkbox [ngModel]=\"paramsFor(tool.id)[p.id]\"\n (ngModelChange)=\"paramsFor(tool.id)[p.id] = $event\"\n [binary]=\"true\" [inputId]=\"tool.id + '-' + p.id\"></p-checkbox>\n <label [for]=\"tool.id + '-' + p.id\" class=\"text-sm mb-0\"\n [pTooltip]=\"p.tooltip || ''\">{{ p.label }}</label>\n </div>\n\n </ng-container>\n </div>\n </ng-container>\n </div>\n <ng-template pTemplate=\"footer\">\n <p-button label=\"Reset to model defaults\" [text]=\"true\" size=\"small\"\n (onClick)=\"resetToolParams(tool.id)\"></p-button>\n <p-button label=\"Close\" size=\"small\" (onClick)=\"openParamsToolId = null\"></p-button>\n </ng-template>\n</p-dialog>\n", styles: [":host{display:flex;flex-direction:column;height:100%;overflow:hidden}.viz-plot{flex:1;min-height:0;height:auto!important}.toolbar-dock{display:flex;align-items:stretch;width:100%}.toolbar-dock plotting-toolbar{flex:1;min-width:0}.toolbar-dock.floating{position:fixed;z-index:1000;width:auto;max-width:calc(100% - 16px);background:var(--surface-card, #ffffff);border:1px solid var(--surface-border, #cccccc);border-radius:6px;box-shadow:0 4px 16px #0000004d}.toolbar-dock.floating plotting-toolbar{flex:0 1 auto}.toolbar-dock.floating .toolbar-drag-handle{border-radius:6px 0 0 6px}.toolbar-drag-handle{display:flex;align-items:center;justify-content:center;padding:0 .4rem;cursor:move;color:var(--primary-color);background:var(--surface-100, rgba(0, 0, 0, .04));border-right:1px solid var(--surface-border, #cccccc);-webkit-user-select:none;user-select:none}.toolbar-dock-btn{align-self:center}:host ::ng-deep .navigator{margin-bottom:80px!important;margin-right:12px!important}kbd{background:#e8e8e8;border:1px solid #bbb;border-radius:3px;padding:1px 5px;font-family:monospace;font-size:.85em}::ng-deep .context-menu-active>.p-menuitem-content{background-color:var(--primary-color)!important}::ng-deep .context-menu-active>.p-menuitem-content .p-menuitem-icon,::ng-deep .context-menu-active>.p-menuitem-content .p-menuitem-text{color:#fff!important}::ng-deep .context-menu-active>.p-menuitem-content .zoom-box-off-icon,::ng-deep .context-menu-active>.p-menuitem-content .wand-icon,::ng-deep .context-menu-active>.p-menuitem-content .polyline-icon,::ng-deep .context-menu-active>.p-menuitem-content .polygon-vertices-icon,::ng-deep .context-menu-active>.p-menuitem-content .vertex-add-icon,::ng-deep .context-menu-active>.p-menuitem-content .vertex-delete-icon,::ng-deep .context-menu-active>.p-menuitem-content .region-move-icon,::ng-deep .context-menu-active>.p-menuitem-content .to-bezier-icon,::ng-deep .context-menu-active>.p-menuitem-content .to-polygon-icon{filter:brightness(0) invert(1)}.toolbar-separator{border-left:1px solid var(--surface-border);height:1.5rem;margin:0 .25rem;align-self:center}.progress{position:absolute;width:50%;left:25%;top:55%}.text-loading{position:absolute;margin:0 auto;left:45%;top:60%;font-weight:700}.zoom-text-loading{position:absolute;margin:0 auto;left:35%;top:60%;font-weight:700;display:inline-block;width:350px}.cancel-loading{position:absolute;margin:0 auto;left:46%;top:70%}.spinner{position:absolute;left:45%;top:45%;height:100px;width:100px;margin:0 auto;-webkit-animation:rotation .6s infinite linear;-moz-animation:rotation .6s infinite linear;-o-animation:rotation .6s infinite linear;animation:rotation .6s infinite linear;border-left:6px solid rgba(0,174,239,.15);border-right:6px solid rgba(0,174,239,.15);border-bottom:6px solid rgba(0,174,239,.15);border-top:6px solid rgb(82,107,178);border-radius:100%}#overlay{position:absolute;top:0;left:0;width:100%;height:100%;background-color:#ffffff80;z-index:1000}#sharpening-overlay{position:absolute;top:0;left:0;width:100%;height:100%;background-color:#ffffff2e;pointer-events:none;z-index:1000}#sharpening-overlay .spinner{height:60px;width:60px;border-width:4px;left:calc(50% - 30px);top:calc(50% - 60px)}#sharpening-overlay .text-loading{position:absolute;left:0;right:0;width:auto;text-align:center;top:calc(50% + 35px);font-size:.9rem;color:#526bb2}@-webkit-keyframes rotation{0%{-webkit-transform:rotate(0deg)}to{-webkit-transform:rotate(359deg)}}@-moz-keyframes rotation{0%{-moz-transform:rotate(0deg)}to{-moz-transform:rotate(359deg)}}@-o-keyframes rotation{0%{-o-transform:rotate(0deg)}to{-o-transform:rotate(359deg)}}@keyframes rotation{0%{transform:rotate(0)}to{transform:rotate(359deg)}}::ng-deep .zoom-box-off-icon{background:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\" fill=\"black\" fill-rule=\"evenodd\">%0A <!-- Rectangle outline -->%0A <rect x=\"2\" y=\"2\" width=\"56\" height=\"56\"/>%0A <rect x=\"9\" y=\"9\" width=\"42\" height=\"42\"/>%0A <!-- Magnifying glass ring -->%0A <circle cx=\"70\" cy=\"70\" r=\"23\"/>%0A <circle cx=\"70\" cy=\"70\" r=\"14\"/>%0A <!-- Handle -->%0A <polygon points=\"81,81 97,97 92,100 77,85\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .wand-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<svg%0A viewBox=\"0 0 100 100\"%0A fill=\"black\"%0A fill-rule=\"evenodd\"%0A version=\"1.1\"%0A id=\"svg4\"%0A sodipodi:docname=\"wand.svg\"%0A inkscape:version=\"1.4.3 (0d15f75, 2025-12-25)\"%0A xmlns:inkscape=\"http://www.inkscape.org/namespaces/inkscape\"%0A xmlns:sodipodi=\"http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd\"%0A xmlns=\"http://www.w3.org/2000/svg\"%0A xmlns:svg=\"http://www.w3.org/2000/svg\">%0A <defs%0A id=\"defs4\" />%0A <sodipodi:namedview%0A id=\"namedview4\"%0A pagecolor=\"%23ffffff\"%0A bordercolor=\"%23000000\"%0A borderopacity=\"0.25\"%0A inkscape:showpageshadow=\"2\"%0A inkscape:pageopacity=\"0.0\"%0A inkscape:pagecheckerboard=\"0\"%0A inkscape:deskcolor=\"%23d1d1d1\"%0A inkscape:zoom=\"8.74\"%0A inkscape:cx=\"50\"%0A inkscape:cy=\"49.942792\"%0A inkscape:window-width=\"1024\"%0A inkscape:window-height=\"1186\"%0A inkscape:window-x=\"3560\"%0A inkscape:window-y=\"131\"%0A inkscape:window-maximized=\"0\"%0A inkscape:current-layer=\"svg4\" />%0A <!-- Wand handle: thin diagonal stick from lower-left to upper-right -->%0A <polygon%0A points=\"12,92 78,26 74,22 8,88 \"%0A id=\"polygon1\"%0A transform=\"matrix(1.0375192,0.05799445,0.25179496,0.91871864,-17.85351,5.6289793)\" />%0A <!-- 4-pointed star at the wand tip -->%0A <polygon%0A points=\"62,34 76,30 80,6 84,30 98,34 84,38 80,62 76,38 \"%0A id=\"polygon2\"%0A transform=\"matrix(1.3082889,0,0,1.0245178,-38.907958,-1.4056881)\" />%0A <!-- Small sparkle to the upper-left -->%0A <polygon%0A points=\"46,30 36,32 34,42 32,32 22,30 32,28 34,18 36,28 \"%0A id=\"polygon3\"%0A transform=\"translate(-9.610984,-6.7505721)\" />%0A <!-- Small sparkle to the lower-right -->%0A <polygon%0A points=\"62,80 70,82 62,84 60,92 58,84 50,82 58,80 60,72 \"%0A id=\"polygon4\"%0A transform=\"translate(0,-1.6018307)\" />%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .brush-icon{background:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 640 640\"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d=\"M512.5 74.3L291.1 222C262 241.4 243.5 272.9 240.5 307.3C302.8 320.1 351.9 369.2 364.8 431.6C399.3 428.6 430.7 410.1 450.1 381L597.7 159.5C604.4 149.4 608 137.6 608 125.4C608 91.5 580.5 64 546.6 64C534.5 64 522.6 67.6 512.5 74.3zM320 464C320 402.1 269.9 352 208 352C146.1 352 96 402.1 96 464C96 467.9 96.2 471.8 96.6 475.6C98.4 493.1 86.4 512 68.8 512L64 512C46.3 512 32 526.3 32 544C32 561.7 46.3 576 64 576L208 576C269.9 576 320 525.9 320 464z\"/></svg>') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .polyline-icon{background:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\">%0A <polyline points=\"1,14 5,3 10,11 15,2\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .polygon-vertices-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Polygon (click-to-place) tool icon: a traced polyline with vertex dots. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <g transform=\"matrix(1.4740709,0,0,1.4740709,-6.0268793,-6.8086826)\">%0A <polyline points=\"23.877,16.627 14.657,10.767 5.25,21.551 12.757,29.397\"%0A fill=\"none\" stroke=\"%23000000\" stroke-width=\"1.6\"%0A stroke-linejoin=\"round\" stroke-linecap=\"round\"/>%0A <circle cx=\"23.877\" cy=\"16.627\" r=\"1.9\"/>%0A <circle cx=\"14.657\" cy=\"10.767\" r=\"1.9\"/>%0A <circle cx=\"5.25\" cy=\"21.551\" r=\"1.9\"/>%0A <circle cx=\"12.757\" cy=\"29.397\" r=\"1.9\"/>%0A </g>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .vertex-add-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Add-vertex tool icon: a region blob with three corner vertices and a plus glyph. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <g transform=\"matrix(1.5222969,0,0,1.5222969,-8.2143505,-7.6146399)\">%0A <path d=\"m 9.35834,12.33267 c 1.78,-2.53 4.93,-3.56 7.86,-3.98 3.98,-0.72 8.33,0.08 11.52,2.67 2.62,1.98 4.71,5.1 4.53,8.49 -0.29,2.27 -2.5,3.81 -4.66,4.01 -2.75,0.43 -5.54,0.07 -8.28,-0.26 -3.99,-0.68 -8.05,-2.16 -10.93,-5.12 -1.32,-1.63 -1.39,-4.17 -0.04,-5.81 z\"%0A fill=\"none\" stroke=\"%23000000\" stroke-width=\"1.6\" stroke-miterlimit=\"10\"/>%0A <circle cx=\"8.294\" cy=\"15.248\" r=\"2.63\"/>%0A <circle cx=\"19.781\" cy=\"8.287\" r=\"2.63\"/>%0A <circle cx=\"25.044\" cy=\"23.748\" r=\"2.63\"/>%0A </g>%0A <path d=\"M 9.65,29.0 H 5.85 v 5.3 H 0.55 v 3.4 H 5.85 v 5.3 H 9.65 V 37.7 H 14.95 V 34.3 H 9.65 Z\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .vertex-delete-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Delete-vertex tool icon: a region blob with three corner vertices and a minus glyph. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <g transform=\"matrix(1.5222969,0,0,1.5222969,-8.2143505,-7.6146399)\">%0A <path d=\"m 9.35834,12.33267 c 1.78,-2.53 4.93,-3.56 7.86,-3.98 3.98,-0.72 8.33,0.08 11.52,2.67 2.62,1.98 4.71,5.1 4.53,8.49 -0.29,2.27 -2.5,3.81 -4.66,4.01 -2.75,0.43 -5.54,0.07 -8.28,-0.26 -3.99,-0.68 -8.05,-2.16 -10.93,-5.12 -1.32,-1.63 -1.39,-4.17 -0.04,-5.81 z\"%0A fill=\"none\" stroke=\"%23000000\" stroke-width=\"1.6\" stroke-miterlimit=\"10\"/>%0A <circle cx=\"8.294\" cy=\"15.248\" r=\"2.63\"/>%0A <circle cx=\"19.781\" cy=\"8.287\" r=\"2.63\"/>%0A <circle cx=\"25.044\" cy=\"23.748\" r=\"2.63\"/>%0A </g>%0A <path d=\"M 0.55,34.3 v 3.4 H 14.95 V 34.3 Z\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .region-move-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Region-move tool icon: a region blob with a drag arrow. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <path d=\"m 17.115582,8.9648664 c 1.707597,-3.3276806 4.729468,-4.6824281 7.540287,-5.2348498 3.827703,-0.947008 7.991169,0.1052238 11.051414,3.5118228 2.523022,2.604273 4.518416,6.7079726 4.345738,11.1668036 -0.278204,2.985704 -2.398311,5.011251 -4.470451,5.274308 -2.638142,0.565574 -5.314655,0.09206 -7.943203,-0.341976 C 23.811664,22.446579 19.916808,20.499952 17.153955,16.606696 15.89724,14.462775 15.820494,11.12194 17.115585,8.9648664 Z\"%0A fill=\"none\" stroke=\"%23000000\" stroke-width=\"2.1\" stroke-miterlimit=\"10\"/>%0A <path d=\"m 26.741429,13.966963 6.746922,16.786269 3.229398,-4.060758 3.583857,3.583857 2.761195,-2.761196 -3.583856,-3.571852 4.060758,-3.2354 z\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .to-bezier-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Convert-to-bezier tool icon: a smooth curve through two anchor points with control handles. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <!-- smooth cubic curve between the two anchors -->%0A <path d=\"M 7,34 C 7,16 37,28 37,10\" fill=\"none\" stroke=\"%23000000\" stroke-width=\"2.4\"%0A stroke-linecap=\"round\"/>%0A <!-- control handles -->%0A <line x1=\"7\" y1=\"34\" x2=\"7\" y2=\"22\" stroke=\"%23000000\" stroke-width=\"1.4\"/>%0A <line x1=\"37\" y1=\"10\" x2=\"37\" y2=\"22\" stroke=\"%23000000\" stroke-width=\"1.4\"/>%0A <circle cx=\"7\" cy=\"22\" r=\"2.2\"/>%0A <circle cx=\"37\" cy=\"22\" r=\"2.2\"/>%0A <!-- anchor points -->%0A <rect x=\"4\" y=\"31\" width=\"6\" height=\"6\" rx=\"1\"/>%0A <rect x=\"34\" y=\"7\" width=\"6\" height=\"6\" rx=\"1\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .to-polygon-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Convert-to-polygon tool icon: endpoints joined by straight segments through a vertex. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <!-- straight-segment path through the vertices -->%0A <polyline points=\"7,34 22,12 37,10\" fill=\"none\" stroke=\"%23000000\" stroke-width=\"2.4\"%0A stroke-linecap=\"round\" stroke-linejoin=\"round\"/>%0A <!-- vertices -->%0A <rect x=\"4\" y=\"31\" width=\"6\" height=\"6\" rx=\"1\"/>%0A <rect x=\"19\" y=\"9\" width=\"6\" height=\"6\" rx=\"1\"/>%0A <rect x=\"34\" y=\"7\" width=\"6\" height=\"6\" rx=\"1\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}.intensity-panel{position:fixed;z-index:1100;width:280px;height:180px;background:#191919e6;border:1px solid var(--primary-color, #3399ff);border-radius:6px;box-shadow:0 4px 14px #00000073;display:flex;flex-direction:column;overflow:hidden}.intensity-panel-header{display:flex;align-items:center;justify-content:space-between;padding:.25rem .5rem;cursor:move;-webkit-user-select:none;user-select:none;background:#ffffff14;color:#eee;font-size:.75rem;font-weight:600}.intensity-panel-header i{opacity:.6;font-size:.7rem}.intensity-panel-plot{flex:1 1 auto;width:100%}:host ::ng-deep .sam-progress .p-progressbar-value{transition:none}\n"] }]
15718
+ args: [{ selector: 'visualizer', template: "<!-- SAM toast: live status + encoder-download progress bar (jit-ui#90). Sticky\n while a segment runs; stays open until the bar reaches 100% and the run ends. -->\n<p-toast [key]=\"samToastKey\" position=\"bottom-right\">\n <ng-template let-message pTemplate=\"message\">\n <div style=\"flex: 1; min-width: 16rem\">\n <div class=\"font-bold mb-1\">{{ message.summary }}</div>\n <div>{{ samStatus || message.detail }}</div>\n <p-progressBar *ngIf=\"samDownloading\" [value]=\"samProgress\" styleClass=\"mt-2 sam-progress\"></p-progressBar>\n <p-progressBar *ngIf=\"samBusy && !samDownloading\" mode=\"indeterminate\" styleClass=\"mt-2\"\n [style]=\"{ height: '4px' }\"></p-progressBar>\n </div>\n </ng-template>\n</p-toast>\n\n<!-- Result/error notices from the library itself (segmentation outcome, region\n validation). Rendered here so they do not depend on the host mounting a\n keyless <p-toast> \u2014 without this the messages went nowhere in any host but\n jit-ui, and a failed run looked like a button that did nothing. -->\n<p-toast [key]=\"resultToastKey\" position=\"bottom-right\"></p-toast>\n\n<!-- Shared outlets for notices raised where there is no per-instance key to use:\n the root-provided PlotlyService and the region-editor dialog. Rendered by a\n single visualizer (ownsSharedToasts) so a message from the root service is\n not shown once per live viewer. -->\n<p-toast *ngIf=\"ownsSharedToasts\" [key]=\"vizToastKey\"></p-toast>\n<p-toast *ngIf=\"ownsSharedToasts\" [key]=\"vizAlertToastKey\" position=\"center\"></p-toast>\n\n<!-- Toolbar dock: docked across the top by default; drag the handle to detach\n it into a floating, movable window (frees the top row for the image). -->\n<div class=\"toolbar-dock\" [class.floating]=\"toolbarFloating\"\n [style.left.px]=\"toolbarFloating ? toolbarPos.x : null\"\n [style.top.px]=\"toolbarFloating ? toolbarPos.y : null\">\n <div class=\"toolbar-drag-handle\" (mousedown)=\"onToolbarDragStart($event)\"\n pTooltip=\"Drag to float the toolbar\" tooltipPosition=\"bottom\">\n <i class=\"pi pi-bars\"></i>\n </div>\n<plotting-toolbar\n [imageInfo]=\"imageInfo\"\n [hasSpatialDataset]=\"hasSpatialDataset\"\n [plotTypeOptions]=\"plotTypeMenu\"\n [selectedPlotType]=\"selectedPlotTypeId\"\n [basePlotType]=\"basePlotType\"\n [isoRange]=\"isoRange\"\n [maxIndex]=\"maxIndex\"\n [zIndex]=\"zIndex\"\n [isHeatmap]=\"isHeatmap\"\n [is3dRegions]=\"isSpatial3dMode\"\n [activeDragMode]=\"activeDragMode\"\n [activeSurface3dMode]=\"activeSurface3dMode\"\n [axesVisible]=\"axesVisible\"\n [wireframeActive]=\"wireframeActive\"\n [resolutionScale]=\"resolutionScale\"\n [wandSensitivity]=\"wandSensitivity\"\n [brushSize]=\"brushSize\"\n [samModels]=\"samModels\"\n [samModelId]=\"samModelId\"\n [contributedTools]=\"contributedTools\"\n [dialogTools]=\"dialogTools\"\n [openDialogToolId]=\"openDialogToolId\"\n (toggleDialogTool)=\"toggleDialogTool($event)\"\n [toolModelIds]=\"toolModelIds\"\n [vertexEraserRadius]=\"vertexEraserRadius\"\n [canUndo]=\"canUndoRegion\"\n [canRedo]=\"canRedoRegion\"\n [stackOptions]=\"stackOptions\"\n [tools]=\"toolbarTools\"\n [imageSmoothingEnabled]=\"imageSmoothingEnabled\"\n (selectPlotType)=\"onSelectPlotType($event)\"\n (toggleImageSmoothing)=\"onToggleImageSmoothing()\"\n (addProfileLine)=\"addProfileLine()\"\n (isoRangeChange)=\"onIsoRangeChange($event)\"\n (openChannelHistogram)=\"openChannelHistogram()\"\n (openSpatialControls)=\"openSpatialControls()\"\n (openRegionEditor)=\"openRegionEditor()\"\n (selectStackOption)=\"selectStackOption($event)\"\n (zScrub)=\"onZScrub($event)\"\n (zSlide)=\"onZSlide($event)\"\n (zIndexInput)=\"zIndex = $event; updateZIndex()\"\n (reloadAndPlot)=\"reloadAndPlot()\"\n (downloadImage)=\"downloadImage()\"\n (autoscaleImage)=\"autoscaleImage()\"\n (toggleDragMode)=\"toggleDragMode($event)\"\n (zoomIn)=\"zoomIn()\"\n (zoomOut)=\"zoomOut()\"\n (toggleSurface3dMode)=\"toggleSurface3dMode($event)\"\n (resetSurfaceCamera)=\"resetSurfaceCamera()\"\n (toggleAxes)=\"toggleAxes()\"\n (toggleWireframe)=\"toggleWireframe()\"\n (selectResolution)=\"selectResolution($event)\"\n (deleteRegion)=\"deleteRegion()\"\n (undoRegion)=\"undoRegion()\"\n (redoRegion)=\"redoRegion()\"\n (segmentRegions)=\"segmentRegions()\"\n (segmentCellpose)=\"segmentCellpose()\"\n (samModelChange)=\"onSamModelChange($event)\"\n (runTool)=\"runTool($event)\"\n (toolModelChange)=\"onToolModelChange($event)\"\n (openToolParams)=\"openToolParams($event)\"\n (wandSensitivityChange)=\"onWandSensitivityChange($event)\"\n (brushSizeChange)=\"onBrushSizeChange($event)\"\n (vertexEraserRadiusChange)=\"onVertexEraserRadiusChange($event)\">\n <!-- Host-projected custom toolbar buttons, forwarded into the toolbar. -->\n <ng-content></ng-content>\n</plotting-toolbar>\n <p-button *ngIf=\"toolbarFloating\" class=\"toolbar-dock-btn\" icon=\"pi pi-thumbtack\" size=\"small\" [text]=\"true\"\n pTooltip=\"Dock to top\" tooltipPosition=\"bottom\" (onClick)=\"dockToolbar()\">\n </p-button>\n</div>\n\n<p-contextMenu #cm [model]=\"contextMenuItems\" appendTo=\"body\"></p-contextMenu>\n<!-- No `h-full` here: PrimeFlex's height:100% !important made #plot as tall as\n the whole host, so under the docked toolbar its bottom toolbar-height strip\n (incl. the OSD scale bar at bottom-left) was clipped by the host's\n overflow:hidden. The component stylesheet sizes it (.viz-plot { flex: 1 }). -->\n<div class=\"w-full relative viz-plot\" [id]=\"plotDivName\">\n <div *ngIf=\"imgLoading || stackLoading || isCaching\" id=\"overlay\">\n <!-- Determinate stack/volume progress takes precedence: while it's active show ONLY the\n progress bar + \"Loading frames...\", not the generic spinner/\"Loading image...\" on top of\n it (both were rendering at once for napari volume/isosurface loads). -->\n <ng-container *ngIf=\"stackLoading; else imgLoadingTpl\">\n <p-progressBar class=\"progress\" [value]=\"loadingPercentage\"></p-progressBar>\n <span class=\"text-loading\">Loading frames...</span>\n </ng-container>\n <ng-template #imgLoadingTpl>\n <!-- Cache-copy progress is shown whenever a cache download is active (isCaching), independent\n of `imgLoading`, so it's never dropped/masked mid-download. -->\n <ng-container *ngIf=\"isCaching; else spinnerLoading\">\n <!-- Stage-aware message comes from CacheProgressService updating\n imageLoadingMessage$ (\"Caching image...\" during the GCS download,\n then \"Generating preview...\" during per-slice thumbnail gen). Once the\n download hits 100% the remaining work (server preview/tile generation)\n has no determinate progress, so fall through to the spinner template\n rather than parking a full bar under \"Loading image...\". -->\n <p-progressBar class=\"progress\" [value]=\"cacheProgress\"></p-progressBar>\n <span class=\"text-loading\">{{loadingMessage}}</span>\n </ng-container>\n <ng-template #spinnerLoading>\n <div *ngIf=\"imgLoading\" class=\"spinner\"></div>\n <span *ngIf=\"imgLoading && !zoom\" class=\"text-loading\">{{loadingMessage}}</span>\n <span *ngIf=\"imgLoading && zoom\" class=\"zoom-text-loading\">{{loadingMessage}}</span>\n </ng-template>\n </ng-template>\n <p-button *ngIf=\"stackLoading || imgLoading\" class=\"cancel-loading \" label=\"Cancel\"\n (onClick)=\"cancelLoading()\">\n </p-button>\n </div>\n <!-- Translucent \"sharpening\" overlay shown after the small-tier preview is\n on screen while the large-tier render is still loading in the\n background. Lets the user see the blurry image underneath but makes\n it clear something is still loading so they don't mistake the\n low-res render for the final image. -->\n <div *ngIf=\"sharpening && !imgLoading && !stackLoading && !isCaching\" id=\"sharpening-overlay\">\n <div class=\"spinner\"></div>\n <span class=\"text-loading\">Sharpening preview...</span>\n </div>\n\n</div>\n\n<!-- Floating, draggable intensity-profile inset. Shown in LINE mode; the chart\n updates live as the line ROI on the image is moved. Kept OUTSIDE #plot and\n positioned fixed so it floats over the whole page (not clipped to the\n diagram/canvas area). -->\n<div *ngIf=\"hasProfiles\" class=\"intensity-panel\"\n [style.left.px]=\"profilePanelPos.x\" [style.top.px]=\"profilePanelPos.y\">\n <div class=\"intensity-panel-header\" (mousedown)=\"onProfilePanelDragStart($event)\">\n <span>Intensity profile</span>\n <i class=\"pi pi-arrows-alt\"></i>\n </div>\n <div [id]=\"intensityInsetDiv\" class=\"intensity-panel-plot\"></div>\n</div>\n\n<!-- Channels & Histogram: non-modal floating dialog opened from the toolbar. -->\n<channel-histogram [(visible)]=\"showChannelHistogram\"></channel-histogram>\n\n<!-- Spatial-omics controls. Renders its own empty state when no dataset (or no\n SPATIAL_DATA_PORT) is present, so it is always safe to host. -->\n<spatial-controls [(visible)]=\"showSpatialControls\" [is3d]=\"isSpatial3dMode\" ></spatial-controls>\n\n<!-- Side panel of the active contributed plot mode (PLOT_TYPE_CONTRIBUTIONS),\n in the same right-hand area as the spatial controls and the Region Editor.\n Exists only while the mode's session is live; there is no close button,\n because leaving the mode is what ends it. A component panel gets\n PLOT_MODE_CONTEXT / PLOT_MODE_SESSION from its injector; a `mount` panel's\n host element is attached to the slot below (see plotModePanelSlot). -->\n<p-dialog *ngIf=\"plotModePanel as panel\" [header]=\"panel.title\" [visible]=\"true\"\n [modal]=\"false\" [closable]=\"false\" [resizable]=\"true\" [draggable]=\"true\"\n appendTo=\"body\" position=\"right\" styleClass=\"plot-mode-panel\"\n [style]=\"{ width: '22rem', maxHeight: '85vh' }\"\n [contentStyle]=\"{ overflow: 'auto' }\">\n <ng-container *ngIf=\"panel.component\">\n <ng-container *ngComponentOutlet=\"panel.component; injector: panel.injector ?? undefined\"></ng-container>\n </ng-container>\n <div *ngIf=\"panel.host\" #plotModePanelSlot class=\"plot-mode-panel-slot\"></div>\n</p-dialog>\n\n<!-- An open dialog tool (TOOLBAR_TOOLS, kind: 'dialog'). Non-modal so the user can\n keep working on the image, e.g. painting with the brush the tool armed. The\n tool renders its body into a host element attached to the slot below (see\n toolDialogSlot); closing the dialog ends the tool's session. -->\n<p-dialog *ngIf=\"toolDialog as dlg\" [header]=\"dlg.title\" [visible]=\"true\"\n (visibleChange)=\"$event || closeDialogTool()\"\n [modal]=\"false\" [closable]=\"true\" [resizable]=\"true\" [draggable]=\"true\"\n appendTo=\"body\" position=\"right\" styleClass=\"tool-dialog\"\n [style]=\"{ width: dlg.width, maxHeight: '85vh' }\"\n [contentStyle]=\"{ overflow: 'auto' }\">\n <div #toolDialogSlot class=\"tool-dialog-slot\"></div>\n</p-dialog>\n\n<!-- Region Editor: non-modal, draggable, resizable floating dialog opened from\n the toolbar (formerly the right-panel Regions tab). Modeless so regions can\n still be drawn/selected on the plot while it's open; stays linked to this\n plotting instance via the root RegionStore singleton. Opens over the right\n side of the view. -->\n<p-dialog header=\"Region Editor\" [(visible)]=\"showRegionEditor\"\n [modal]=\"false\" [resizable]=\"true\" [draggable]=\"true\"\n [maximizable]=\"true\" appendTo=\"body\" position=\"right\"\n [style]=\"{ width: regionEditorWidth, height: '85vh' }\"\n [contentStyle]=\"{ overflow: 'auto' }\">\n <region-editor></region-editor>\n</p-dialog>\n\n<!-- Simplify (Douglas\u2013Peucker): custom px-threshold picker for the selected\n region(s), opened from the right-click \"Simplify \u25B8 Custom\u2026\" action. -->\n<p-dialog header=\"Simplify region(s)\" [(visible)]=\"displaySimplifyDialog\" [modal]=\"true\"\n [style]=\"{width: '22rem'}\" [draggable]=\"false\" [resizable]=\"false\">\n <div class=\"flex flex-column gap-2\">\n <span>Altitude threshold: <b>{{ simplifyThreshold }} px</b></span>\n <p-slider [(ngModel)]=\"simplifyThreshold\" [min]=\"1\" [max]=\"20\" [step]=\"1\"></p-slider>\n <small>Vertices within this distance of the line between their neighbours are removed.</small>\n </div>\n <ng-template pTemplate=\"footer\">\n <p-button label=\"Cancel\" [text]=\"true\" (onClick)=\"displaySimplifyDialog = false\"></p-button>\n <p-button label=\"Apply\" icon=\"pi pi-check\" (onClick)=\"simplifyRegions(simplifyThreshold)\"></p-button>\n </ng-template>\n</p-dialog>\n\n<!-- Parameters for whichever contributed tool is open. One dialog for all of\n them: a tool declares its fields (ToolParamSpec) and this renders them, so\n the toolbar can lay every tool out the same way and a plugin needs no\n PrimeNG of its own. Defaults come from the active checkpoint, since they\n encode what it was trained for. -->\n<p-dialog *ngIf=\"openParamsTool as tool\"\n [header]=\"tool.label + ' parameters'\"\n [visible]=\"true\" (visibleChange)=\"openParamsToolId = null\"\n [modal]=\"true\"\n [style]=\"{width: '30rem'}\" [draggable]=\"false\" [resizable]=\"false\" appendTo=\"body\">\n <div class=\"grid formgrid p-fluid\">\n <ng-container *ngFor=\"let p of tool.params\">\n <div class=\"field\" [ngClass]=\"p.type === 'boolean' ? 'col-12' : 'col-6'\">\n <ng-container [ngSwitch]=\"p.type\">\n\n <ng-container *ngSwitchCase=\"'number'\">\n <label class=\"text-sm\" [pTooltip]=\"p.tooltip || ''\">{{ p.label }}</label>\n <p-inputNumber [ngModel]=\"paramsFor(tool.id)[p.id]\"\n (ngModelChange)=\"paramsFor(tool.id)[p.id] = $event\"\n [min]=\"numberParam(p).min\" [max]=\"numberParam(p).max\"\n [step]=\"numberParam(p).step\"\n [minFractionDigits]=\"numberParam(p).fractionDigits\"\n [showButtons]=\"true\"></p-inputNumber>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'select'\">\n <label class=\"text-sm\" [pTooltip]=\"p.tooltip || ''\">{{ p.label }}</label>\n <p-dropdown [options]=\"selectParam(p).options\" optionLabel=\"label\" optionValue=\"value\"\n [ngModel]=\"paramsFor(tool.id)[p.id]\"\n (ngModelChange)=\"paramsFor(tool.id)[p.id] = $event\"\n appendTo=\"body\"></p-dropdown>\n </ng-container>\n\n <!-- Checkbox label sits beside the box and may run to a sentence, so\n it does not get the stacked label the other types use. -->\n <div *ngSwitchCase=\"'boolean'\" class=\"flex align-items-center gap-2\">\n <p-checkbox [ngModel]=\"paramsFor(tool.id)[p.id]\"\n (ngModelChange)=\"paramsFor(tool.id)[p.id] = $event\"\n [binary]=\"true\" [inputId]=\"tool.id + '-' + p.id\"></p-checkbox>\n <label [for]=\"tool.id + '-' + p.id\" class=\"text-sm mb-0\"\n [pTooltip]=\"p.tooltip || ''\">{{ p.label }}</label>\n </div>\n\n </ng-container>\n </div>\n </ng-container>\n </div>\n <ng-template pTemplate=\"footer\">\n <p-button label=\"Reset to model defaults\" [text]=\"true\" size=\"small\"\n (onClick)=\"resetToolParams(tool.id)\"></p-button>\n <p-button label=\"Close\" size=\"small\" (onClick)=\"openParamsToolId = null\"></p-button>\n </ng-template>\n</p-dialog>\n", styles: [":host{display:flex;flex-direction:column;height:100%;overflow:hidden}.viz-plot{flex:1;min-height:0;height:auto!important}.toolbar-dock{display:flex;align-items:stretch;width:100%}.toolbar-dock plotting-toolbar{flex:1;min-width:0}.toolbar-dock.floating{position:fixed;z-index:1000;width:auto;max-width:calc(100% - 16px);background:var(--surface-card, #ffffff);border:1px solid var(--surface-border, #cccccc);border-radius:6px;box-shadow:0 4px 16px #0000004d}.toolbar-dock.floating plotting-toolbar{flex:0 1 auto}.toolbar-dock.floating .toolbar-drag-handle{border-radius:6px 0 0 6px}.toolbar-drag-handle{display:flex;align-items:center;justify-content:center;padding:0 .4rem;cursor:move;color:var(--primary-color);background:var(--surface-100, rgba(0, 0, 0, .04));border-right:1px solid var(--surface-border, #cccccc);-webkit-user-select:none;user-select:none}.toolbar-dock-btn{align-self:center}:host ::ng-deep .navigator{margin-bottom:80px!important;margin-right:12px!important}kbd{background:#e8e8e8;border:1px solid #bbb;border-radius:3px;padding:1px 5px;font-family:monospace;font-size:.85em}::ng-deep .context-menu-active>.p-menuitem-content{background-color:var(--primary-color)!important}::ng-deep .context-menu-active>.p-menuitem-content .p-menuitem-icon,::ng-deep .context-menu-active>.p-menuitem-content .p-menuitem-text{color:#fff!important}::ng-deep .context-menu-active>.p-menuitem-content .zoom-box-off-icon,::ng-deep .context-menu-active>.p-menuitem-content .wand-icon,::ng-deep .context-menu-active>.p-menuitem-content .polyline-icon,::ng-deep .context-menu-active>.p-menuitem-content .polygon-vertices-icon,::ng-deep .context-menu-active>.p-menuitem-content .vertex-add-icon,::ng-deep .context-menu-active>.p-menuitem-content .vertex-delete-icon,::ng-deep .context-menu-active>.p-menuitem-content .region-move-icon,::ng-deep .context-menu-active>.p-menuitem-content .to-bezier-icon,::ng-deep .context-menu-active>.p-menuitem-content .to-polygon-icon{filter:brightness(0) invert(1)}.toolbar-separator{border-left:1px solid var(--surface-border);height:1.5rem;margin:0 .25rem;align-self:center}.progress{position:absolute;width:50%;left:25%;top:55%}.text-loading{position:absolute;margin:0 auto;left:45%;top:60%;font-weight:700}.zoom-text-loading{position:absolute;margin:0 auto;left:35%;top:60%;font-weight:700;display:inline-block;width:350px}.cancel-loading{position:absolute;margin:0 auto;left:46%;top:70%}.spinner{position:absolute;left:45%;top:45%;height:100px;width:100px;margin:0 auto;-webkit-animation:rotation .6s infinite linear;-moz-animation:rotation .6s infinite linear;-o-animation:rotation .6s infinite linear;animation:rotation .6s infinite linear;border-left:6px solid rgba(0,174,239,.15);border-right:6px solid rgba(0,174,239,.15);border-bottom:6px solid rgba(0,174,239,.15);border-top:6px solid rgb(82,107,178);border-radius:100%}#overlay{position:absolute;top:0;left:0;width:100%;height:100%;background-color:#ffffff80;z-index:1000}#sharpening-overlay{position:absolute;top:0;left:0;width:100%;height:100%;background-color:#ffffff2e;pointer-events:none;z-index:1000}#sharpening-overlay .spinner{height:60px;width:60px;border-width:4px;left:calc(50% - 30px);top:calc(50% - 60px)}#sharpening-overlay .text-loading{position:absolute;left:0;right:0;width:auto;text-align:center;top:calc(50% + 35px);font-size:.9rem;color:#526bb2}@-webkit-keyframes rotation{0%{-webkit-transform:rotate(0deg)}to{-webkit-transform:rotate(359deg)}}@-moz-keyframes rotation{0%{-moz-transform:rotate(0deg)}to{-moz-transform:rotate(359deg)}}@-o-keyframes rotation{0%{-o-transform:rotate(0deg)}to{-o-transform:rotate(359deg)}}@keyframes rotation{0%{transform:rotate(0)}to{transform:rotate(359deg)}}::ng-deep .zoom-box-off-icon{background:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\" fill=\"black\" fill-rule=\"evenodd\">%0A <!-- Rectangle outline -->%0A <rect x=\"2\" y=\"2\" width=\"56\" height=\"56\"/>%0A <rect x=\"9\" y=\"9\" width=\"42\" height=\"42\"/>%0A <!-- Magnifying glass ring -->%0A <circle cx=\"70\" cy=\"70\" r=\"23\"/>%0A <circle cx=\"70\" cy=\"70\" r=\"14\"/>%0A <!-- Handle -->%0A <polygon points=\"81,81 97,97 92,100 77,85\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .wand-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<svg%0A viewBox=\"0 0 100 100\"%0A fill=\"black\"%0A fill-rule=\"evenodd\"%0A version=\"1.1\"%0A id=\"svg4\"%0A sodipodi:docname=\"wand.svg\"%0A inkscape:version=\"1.4.3 (0d15f75, 2025-12-25)\"%0A xmlns:inkscape=\"http://www.inkscape.org/namespaces/inkscape\"%0A xmlns:sodipodi=\"http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd\"%0A xmlns=\"http://www.w3.org/2000/svg\"%0A xmlns:svg=\"http://www.w3.org/2000/svg\">%0A <defs%0A id=\"defs4\" />%0A <sodipodi:namedview%0A id=\"namedview4\"%0A pagecolor=\"%23ffffff\"%0A bordercolor=\"%23000000\"%0A borderopacity=\"0.25\"%0A inkscape:showpageshadow=\"2\"%0A inkscape:pageopacity=\"0.0\"%0A inkscape:pagecheckerboard=\"0\"%0A inkscape:deskcolor=\"%23d1d1d1\"%0A inkscape:zoom=\"8.74\"%0A inkscape:cx=\"50\"%0A inkscape:cy=\"49.942792\"%0A inkscape:window-width=\"1024\"%0A inkscape:window-height=\"1186\"%0A inkscape:window-x=\"3560\"%0A inkscape:window-y=\"131\"%0A inkscape:window-maximized=\"0\"%0A inkscape:current-layer=\"svg4\" />%0A <!-- Wand handle: thin diagonal stick from lower-left to upper-right -->%0A <polygon%0A points=\"12,92 78,26 74,22 8,88 \"%0A id=\"polygon1\"%0A transform=\"matrix(1.0375192,0.05799445,0.25179496,0.91871864,-17.85351,5.6289793)\" />%0A <!-- 4-pointed star at the wand tip -->%0A <polygon%0A points=\"62,34 76,30 80,6 84,30 98,34 84,38 80,62 76,38 \"%0A id=\"polygon2\"%0A transform=\"matrix(1.3082889,0,0,1.0245178,-38.907958,-1.4056881)\" />%0A <!-- Small sparkle to the upper-left -->%0A <polygon%0A points=\"46,30 36,32 34,42 32,32 22,30 32,28 34,18 36,28 \"%0A id=\"polygon3\"%0A transform=\"translate(-9.610984,-6.7505721)\" />%0A <!-- Small sparkle to the lower-right -->%0A <polygon%0A points=\"62,80 70,82 62,84 60,92 58,84 50,82 58,80 60,72 \"%0A id=\"polygon4\"%0A transform=\"translate(0,-1.6018307)\" />%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .brush-icon{background:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 640 640\"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d=\"M512.5 74.3L291.1 222C262 241.4 243.5 272.9 240.5 307.3C302.8 320.1 351.9 369.2 364.8 431.6C399.3 428.6 430.7 410.1 450.1 381L597.7 159.5C604.4 149.4 608 137.6 608 125.4C608 91.5 580.5 64 546.6 64C534.5 64 522.6 67.6 512.5 74.3zM320 464C320 402.1 269.9 352 208 352C146.1 352 96 402.1 96 464C96 467.9 96.2 471.8 96.6 475.6C98.4 493.1 86.4 512 68.8 512L64 512C46.3 512 32 526.3 32 544C32 561.7 46.3 576 64 576L208 576C269.9 576 320 525.9 320 464z\"/></svg>') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .polyline-icon{background:url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\">%0A <polyline points=\"1,14 5,3 10,11 15,2\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .polygon-vertices-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Polygon (click-to-place) tool icon: a traced polyline with vertex dots. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <g transform=\"matrix(1.4740709,0,0,1.4740709,-6.0268793,-6.8086826)\">%0A <polyline points=\"23.877,16.627 14.657,10.767 5.25,21.551 12.757,29.397\"%0A fill=\"none\" stroke=\"%23000000\" stroke-width=\"1.6\"%0A stroke-linejoin=\"round\" stroke-linecap=\"round\"/>%0A <circle cx=\"23.877\" cy=\"16.627\" r=\"1.9\"/>%0A <circle cx=\"14.657\" cy=\"10.767\" r=\"1.9\"/>%0A <circle cx=\"5.25\" cy=\"21.551\" r=\"1.9\"/>%0A <circle cx=\"12.757\" cy=\"29.397\" r=\"1.9\"/>%0A </g>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .vertex-add-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Add-vertex tool icon: a region blob with three corner vertices and a plus glyph. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <g transform=\"matrix(1.5222969,0,0,1.5222969,-8.2143505,-7.6146399)\">%0A <path d=\"m 9.35834,12.33267 c 1.78,-2.53 4.93,-3.56 7.86,-3.98 3.98,-0.72 8.33,0.08 11.52,2.67 2.62,1.98 4.71,5.1 4.53,8.49 -0.29,2.27 -2.5,3.81 -4.66,4.01 -2.75,0.43 -5.54,0.07 -8.28,-0.26 -3.99,-0.68 -8.05,-2.16 -10.93,-5.12 -1.32,-1.63 -1.39,-4.17 -0.04,-5.81 z\"%0A fill=\"none\" stroke=\"%23000000\" stroke-width=\"1.6\" stroke-miterlimit=\"10\"/>%0A <circle cx=\"8.294\" cy=\"15.248\" r=\"2.63\"/>%0A <circle cx=\"19.781\" cy=\"8.287\" r=\"2.63\"/>%0A <circle cx=\"25.044\" cy=\"23.748\" r=\"2.63\"/>%0A </g>%0A <path d=\"M 9.65,29.0 H 5.85 v 5.3 H 0.55 v 3.4 H 5.85 v 5.3 H 9.65 V 37.7 H 14.95 V 34.3 H 9.65 Z\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .vertex-delete-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Delete-vertex tool icon: a region blob with three corner vertices and a minus glyph. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <g transform=\"matrix(1.5222969,0,0,1.5222969,-8.2143505,-7.6146399)\">%0A <path d=\"m 9.35834,12.33267 c 1.78,-2.53 4.93,-3.56 7.86,-3.98 3.98,-0.72 8.33,0.08 11.52,2.67 2.62,1.98 4.71,5.1 4.53,8.49 -0.29,2.27 -2.5,3.81 -4.66,4.01 -2.75,0.43 -5.54,0.07 -8.28,-0.26 -3.99,-0.68 -8.05,-2.16 -10.93,-5.12 -1.32,-1.63 -1.39,-4.17 -0.04,-5.81 z\"%0A fill=\"none\" stroke=\"%23000000\" stroke-width=\"1.6\" stroke-miterlimit=\"10\"/>%0A <circle cx=\"8.294\" cy=\"15.248\" r=\"2.63\"/>%0A <circle cx=\"19.781\" cy=\"8.287\" r=\"2.63\"/>%0A <circle cx=\"25.044\" cy=\"23.748\" r=\"2.63\"/>%0A </g>%0A <path d=\"M 0.55,34.3 v 3.4 H 14.95 V 34.3 Z\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .region-move-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Region-move tool icon: a region blob with a drag arrow. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <path d=\"m 17.115582,8.9648664 c 1.707597,-3.3276806 4.729468,-4.6824281 7.540287,-5.2348498 3.827703,-0.947008 7.991169,0.1052238 11.051414,3.5118228 2.523022,2.604273 4.518416,6.7079726 4.345738,11.1668036 -0.278204,2.985704 -2.398311,5.011251 -4.470451,5.274308 -2.638142,0.565574 -5.314655,0.09206 -7.943203,-0.341976 C 23.811664,22.446579 19.916808,20.499952 17.153955,16.606696 15.89724,14.462775 15.820494,11.12194 17.115585,8.9648664 Z\"%0A fill=\"none\" stroke=\"%23000000\" stroke-width=\"2.1\" stroke-miterlimit=\"10\"/>%0A <path d=\"m 26.741429,13.966963 6.746922,16.786269 3.229398,-4.060758 3.583857,3.583857 2.761195,-2.761196 -3.583856,-3.571852 4.060758,-3.2354 z\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .to-bezier-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Convert-to-bezier tool icon: a smooth curve through two anchor points with control handles. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <!-- smooth cubic curve between the two anchors -->%0A <path d=\"M 7,34 C 7,16 37,28 37,10\" fill=\"none\" stroke=\"%23000000\" stroke-width=\"2.4\"%0A stroke-linecap=\"round\"/>%0A <!-- control handles -->%0A <line x1=\"7\" y1=\"34\" x2=\"7\" y2=\"22\" stroke=\"%23000000\" stroke-width=\"1.4\"/>%0A <line x1=\"37\" y1=\"10\" x2=\"37\" y2=\"22\" stroke=\"%23000000\" stroke-width=\"1.4\"/>%0A <circle cx=\"7\" cy=\"22\" r=\"2.2\"/>%0A <circle cx=\"37\" cy=\"22\" r=\"2.2\"/>%0A <!-- anchor points -->%0A <rect x=\"4\" y=\"31\" width=\"6\" height=\"6\" rx=\"1\"/>%0A <rect x=\"34\" y=\"7\" width=\"6\" height=\"6\" rx=\"1\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}::ng-deep .to-polygon-icon{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>%0A<!-- Convert-to-polygon tool icon: endpoints joined by straight segments through a vertex. -->%0A<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 44 44\" width=\"44\" height=\"44\" fill=\"%23000000\">%0A <!-- straight-segment path through the vertices -->%0A <polyline points=\"7,34 22,12 37,10\" fill=\"none\" stroke=\"%23000000\" stroke-width=\"2.4\"%0A stroke-linecap=\"round\" stroke-linejoin=\"round\"/>%0A <!-- vertices -->%0A <rect x=\"4\" y=\"31\" width=\"6\" height=\"6\" rx=\"1\"/>%0A <rect x=\"19\" y=\"9\" width=\"6\" height=\"6\" rx=\"1\"/>%0A <rect x=\"34\" y=\"7\" width=\"6\" height=\"6\" rx=\"1\"/>%0A</svg>%0A') no-repeat center center;background-size:contain;width:1.25rem;height:1.25rem;display:inline-block;filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}.intensity-panel{position:fixed;z-index:1100;width:280px;height:180px;background:#191919e6;border:1px solid var(--primary-color, #3399ff);border-radius:6px;box-shadow:0 4px 14px #00000073;display:flex;flex-direction:column;overflow:hidden}.intensity-panel-header{display:flex;align-items:center;justify-content:space-between;padding:.25rem .5rem;cursor:move;-webkit-user-select:none;user-select:none;background:#ffffff14;color:#eee;font-size:.75rem;font-weight:600}.intensity-panel-header i{opacity:.6;font-size:.7rem}.intensity-panel-plot{flex:1 1 auto;width:100%}:host ::ng-deep .sam-progress .p-progressbar-value{transition:none}\n"] }]
15315
15719
  }], ctorParameters: () => [{ type: undefined, decorators: [{
15316
15720
  type: Inject,
15317
15721
  args: [IMAGE_STATE_PORT]
@@ -19441,6 +19845,12 @@ class PlotlyService {
19441
19845
  this.plotType = plotType;
19442
19846
  }
19443
19847
  setImageInfo(showStack, scaleratio) {
19848
+ // Nothing plotted yet (a host's first view is an image-less spatial dataset): there is no
19849
+ // image to describe, so nothing to re-plot — stop the spinner rather than throw.
19850
+ if (!this.trueImgSize) {
19851
+ this.state.setImageLoading(false);
19852
+ return;
19853
+ }
19444
19854
  // Build a partial image descriptor and push it to the host via the port.
19445
19855
  const imgInfo = {
19446
19856
  isGrayscale: this.imageInfo?.isGrayscale,
@@ -24172,6 +24582,13 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
24172
24582
  // following `frame$.subscribe(redraw)` would stay blank until the next pan/zoom.
24173
24583
  frame$: this.withCurrentRect(this.frame$, ready),
24174
24584
  settled$: this.withCurrentRect(this.viewportChange$, ready),
24585
+ fitBounds: (rect, options) => {
24586
+ const viewer = this.viewer;
24587
+ if (!viewer || !ready() || !(rect.width > 0) || !(rect.height > 0))
24588
+ return;
24589
+ const vpRect = imageRectToViewport(viewer, rect.x, rect.y, rect.width, rect.height);
24590
+ viewer.viewport.fitBoundsWithConstraints(vpRect, !!options?.immediately);
24591
+ },
24175
24592
  };
24176
24593
  return this.plotModeViewport;
24177
24594
  }
@@ -25434,6 +25851,13 @@ class NapariSpatialTooltip {
25434
25851
  }
25435
25852
  }
25436
25853
 
25854
+ /** The all-gene pyramid's finest bin (250 µm source tiles / 128) and default level count, used
25855
+ * to group a gene selection on the same ladder when a dataset has no pyramid. */
25856
+ const PYRAMID_BASE_BIN_UM = 250 / 128;
25857
+ const PYRAMID_LEVELS = 7;
25858
+ /** Icons get a dark rim only while there are at most this many, at least this big (px). */
25859
+ const GLYPH_OUTLINE_MAX = 20_000;
25860
+ const GLYPH_OUTLINE_MIN_PX = 8;
25437
25861
  const ORDER = [
25438
25862
  'cellFill', 'cellOutline', 'nucleusOutline', 'density', 'transcripts', 'transcriptOutline',
25439
25863
  ];
@@ -25472,6 +25896,12 @@ class NapariSpatialTileLayers {
25472
25896
  /** Selection identity → revision, so a change key can name a selection cheaply. */
25473
25897
  this.lastSelection = null;
25474
25898
  this.selectionRevision = 0;
25899
+ /** µm per observation unit of the drawn dataset, for describing a grouped marker's area. */
25900
+ this.micronsPerUnit = null;
25901
+ /** Loads in flight, per layer label, for the loading badge. */
25902
+ this.loading = new Map();
25903
+ /** The loaded per-gene transcripts the in-view counts are taken from. */
25904
+ this.countSource = null;
25475
25905
  // ── hover ─────────────────────────────────────────────────────────────────────────
25476
25906
  this.drawn = null;
25477
25907
  this.hoverTypes = null;
@@ -25479,6 +25909,7 @@ class NapariSpatialTileLayers {
25479
25909
  this.hoverCache = new Map();
25480
25910
  this.hoverKey = null;
25481
25911
  this.hoverTimer = null;
25912
+ this.densityCache = new Map();
25482
25913
  this.countsCache = null;
25483
25914
  /** The density window in use and the densest bin, for the panel's threshold control. */
25484
25915
  this.densityStats = null;
@@ -25491,6 +25922,81 @@ class NapariSpatialTileLayers {
25491
25922
  this.viewer = viewer;
25492
25923
  this.cameraOff = viewer.camera.changed.connect(() => this.schedule());
25493
25924
  }
25925
+ /**
25926
+ * Group a gene selection for the zoom: one marker per gene-tree cluster (a gene in no
25927
+ * cluster is its own) per bin of the pyramid ladder, starting at the finest bin whose
25928
+ * markers are 14 px apart and stepping up while the markers would exceed the budget.
25929
+ * Null to draw every transcript: zoomed in, with the selection within the budget.
25930
+ */
25931
+ groupSelection(t, view, ladder, pxPerUnit) {
25932
+ const budget = Math.max(1, view.transcriptBudget);
25933
+ const genes = view.transcriptGenes;
25934
+ const groups = view.transcriptGeneGroups;
25935
+ const names = [...groups.map((g) => g.name), ...genes];
25936
+ const clusterOf = genes.map((gene, slot) => {
25937
+ const g = groups.findIndex((x) => x.genes.includes(gene));
25938
+ return g >= 0 ? g : groups.length + slot;
25939
+ });
25940
+ const keyOf = (slot) => clusterOf[slot] ?? groups.length + slot;
25941
+ let bin = ladder.start;
25942
+ if (bin === null) {
25943
+ // Every transcript, if they fit the max and none was cut off at the cap.
25944
+ if (t.count <= budget && t.count < MAX_TRANSCRIPTS)
25945
+ return null;
25946
+ bin = ladder.baseBin;
25947
+ }
25948
+ const top = ladder.baseBin * 2 ** (ladder.levels - 1);
25949
+ let grouped = groupTranscripts(t, bin, keyOf);
25950
+ // Over the max: combine into larger markers, a level at a time.
25951
+ while (grouped.tile.count > budget && bin < top) {
25952
+ bin *= 2;
25953
+ grouped = groupTranscripts(t, bin, keyOf);
25954
+ }
25955
+ const refCount = quantileOf(grouped.tile.weight, 0.95);
25956
+ const binPx = bin * pxPerUnit;
25957
+ const px = new Float32Array(grouped.tile.count);
25958
+ for (let i = 0; i < grouped.tile.count; i++) {
25959
+ px[i] = groupedMarkerPx(grouped.tile.weight[i], refCount, binPx, view.transcriptScale);
25960
+ }
25961
+ return { merged: grouped.tile, px, bin, group: grouped.group, names };
25962
+ }
25963
+ /** Report `label` loading while `work` is in flight. */
25964
+ async track(label, work) {
25965
+ this.loading.set(label, (this.loading.get(label) ?? 0) + 1);
25966
+ this.host.loadingChanged?.([...this.loading.keys()]);
25967
+ try {
25968
+ return await work;
25969
+ }
25970
+ finally {
25971
+ const n = (this.loading.get(label) ?? 1) - 1;
25972
+ if (n > 0)
25973
+ this.loading.set(label, n);
25974
+ else
25975
+ this.loading.delete(label);
25976
+ this.host.loadingChanged?.([...this.loading.keys()]);
25977
+ }
25978
+ }
25979
+ /**
25980
+ * Transcripts of each selected gene inside `rect`: each entry counts for the transcripts it
25981
+ * stands for (an aggregate at a coarse level holds several). Null without per-gene data.
25982
+ */
25983
+ geneCountsIn(rect) {
25984
+ const src = this.countSource;
25985
+ if (!src)
25986
+ return null;
25987
+ const out = {};
25988
+ for (const g of src.genes)
25989
+ out[g] = 0;
25990
+ const { x, y, weight, gene } = src.merged;
25991
+ for (let i = 0; i < src.merged.count; i++) {
25992
+ if (x[i] < rect.x0 || x[i] > rect.x1 || y[i] < rect.y0 || y[i] > rect.y1)
25993
+ continue;
25994
+ const name = src.genes[gene[i]];
25995
+ if (name !== undefined)
25996
+ out[name] += weight[i];
25997
+ }
25998
+ return out;
25999
+ }
25494
26000
  /** Whether `layer` is one of the overlays drawn here (density, cells, transcripts). */
25495
26001
  owns(layer) {
25496
26002
  for (const l of this.layers.values())
@@ -25510,6 +26016,9 @@ class NapariSpatialTileLayers {
25510
26016
  this.timer = null;
25511
26017
  this.token++;
25512
26018
  this.tileRetries = 0;
26019
+ this.countSource = null;
26020
+ this.loading.clear();
26021
+ this.host.loadingChanged?.([]);
25513
26022
  if (this.viewer) {
25514
26023
  for (const layer of this.layers.values()) {
25515
26024
  if (this.viewer.layers.items.includes(layer))
@@ -25581,6 +26090,7 @@ class NapariSpatialTileLayers {
25581
26090
  ]);
25582
26091
  if (stale())
25583
26092
  return;
26093
+ this.host.geneCountsChanged?.(this.geneCountsIn(rect));
25584
26094
  // A tile that failed left a hole the cache keys do not record, so try the same view
25585
26095
  // again, backing off, a bounded number of times.
25586
26096
  if (!this.planIncomplete) {
@@ -25612,7 +26122,9 @@ class NapariSpatialTileLayers {
25612
26122
  const hidden = await this.hiddenCodes(dataset, view);
25613
26123
  if (stale())
25614
26124
  return;
25615
- const geometry = await this.cellGeometry(dataset, view, rect, level, hidden, stale);
26125
+ const label = view.cellSet === 'both' ? 'Cells and nuclei'
26126
+ : view.cellSet === 'nucleus' ? 'Nuclei' : 'Cells';
26127
+ const geometry = await this.track(label, this.cellGeometry(dataset, view, rect, level, hidden, stale));
25616
26128
  if (!geometry)
25617
26129
  return;
25618
26130
  const { rings, nuclei, geometryKey } = geometry;
@@ -25796,7 +26308,8 @@ class NapariSpatialTileLayers {
25796
26308
  // ── transcripts ───────────────────────────────────────────────────────────────────
25797
26309
  async planTranscripts(dataset, view, rect, pxPerUnit, stale) {
25798
26310
  const mode = view.transcriptMode;
25799
- const job = (mode === 'circles' || mode === 'glyphs') && this.port.getTranscriptTile
26311
+ const job = (mode === 'circles' || mode === 'glyphs')
26312
+ && (this.port.getTranscriptTile || (this.port.getTranscriptGeneBins && dataset.transcriptGeneBins))
25800
26313
  ? (view.transcriptAllGenes
25801
26314
  ? this.allGenesJob(dataset, view, rect, pxPerUnit)
25802
26315
  : this.geneJob(dataset, view, rect, pxPerUnit))
@@ -25805,6 +26318,7 @@ class NapariSpatialTileLayers {
25805
26318
  this.drop('transcripts');
25806
26319
  this.drop('transcriptOutline');
25807
26320
  this.drawn = null;
26321
+ this.countSource = null;
25808
26322
  return;
25809
26323
  }
25810
26324
  // Marker sizes follow the zoom, so the zoom is part of the key; a pan that keeps
@@ -25813,25 +26327,49 @@ class NapariSpatialTileLayers {
25813
26327
  dataset.id, mode, job.key, view.transcriptColorBy, this.cellTypeColumnName(dataset, view),
25814
26328
  view.transcriptScale, view.transcriptOpacity, JSON.stringify(view.transcriptGlyphs),
25815
26329
  pxPerUnit.toPrecision(4), view.hiddenGroups.join('\u0001'), view.transcriptHiddenGenes.join(','),
25816
- JSON.stringify(view.transcriptGeneColors),
26330
+ JSON.stringify(view.transcriptGeneColors), JSON.stringify(view.transcriptGeneGroups),
25817
26331
  ].join('|');
25818
26332
  const current = this.layers.get('transcripts');
25819
26333
  if (planKey === this.keys.get('transcripts') && current && this.viewer.layers.items.includes(current)) {
25820
26334
  return;
25821
26335
  }
25822
- const loaded = await job.load();
26336
+ const loaded = await this.track('Transcripts', job.load());
25823
26337
  if (stale())
25824
26338
  return;
26339
+ // Per-gene counts in view come from what was loaded, before hidden genes are dropped:
26340
+ // a hidden gene still has transcripts there. Summed density grids (no per-gene levels,
26341
+ // zoomed out) hold clusters, not genes: no per-gene counts there, rather than a guess.
26342
+ this.countSource = (loaded.kind ?? job.kind) === 'genes' && !loaded.clustered
26343
+ ? { merged: loaded.merged, genes: [...view.transcriptGenes] } : null;
25825
26344
  const hidden = await this.hiddenCodes(dataset, view);
25826
26345
  if (stale())
25827
26346
  return;
25828
- const { merged, px } = filterTranscripts(loaded.merged, loaded.px, hidden, (loaded.kind ?? job.kind) === 'genes' ? hiddenGeneSlots(view) : null);
26347
+ // Density-grid markers carry no cells and were built from the visible genes only.
26348
+ const filtered = loaded.clustered ? { merged: loaded.merged, px: loaded.px }
26349
+ : filterTranscripts(loaded.merged, loaded.px, hidden, (loaded.kind ?? job.kind) === 'genes' ? hiddenGeneSlots(view) : null);
26350
+ let { merged, px } = filtered;
26351
+ let selectionBin = loaded.clustered?.bin ?? null;
26352
+ let entryGroup = loaded.clustered?.group ?? null;
26353
+ let groupNames = loaded.clustered?.names ?? null;
26354
+ if (loaded.ladder) {
26355
+ const g = this.groupSelection(merged, view, loaded.ladder, pxPerUnit);
26356
+ if (g)
26357
+ ({ merged, px, bin: selectionBin, group: entryGroup, names: groupNames } = g);
26358
+ }
25829
26359
  // A bin of the all-gene pyramid mixes genes and carries none, so it cannot be coloured
25830
26360
  // by gene: colour it by its dominant cell's type until individual transcripts show.
25831
26361
  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);
26362
+ const colorView = kind === 'bins' && view.transcriptColorBy !== 'cellType'
26363
+ ? { ...view, transcriptColorBy: 'cellType' }
26364
+ // Density-grid markers have no cell to take a type from: they take their cluster's colour.
26365
+ : loaded.clustered && view.transcriptColorBy === 'cellType'
26366
+ ? { ...view, transcriptColorBy: 'cluster' } : view;
26367
+ // Each entry's cluster: its marker's group, or the gene-tree group its gene is in.
26368
+ const geneCluster = view.transcriptGenes.map((g) => view.transcriptGeneGroups.find((x) => x.genes.includes(g))?.name ?? g);
26369
+ const clusterOf = (i) => (entryGroup && groupNames
26370
+ ? groupNames[entryGroup[i]] ?? null
26371
+ : geneCluster[merged.gene[i]] ?? null);
26372
+ const faces = await this.transcriptColors(dataset, colorView, merged, kind === 'genes' ? clusterOf : undefined);
25835
26373
  if (stale())
25836
26374
  return;
25837
26375
  if (this.planIncomplete)
@@ -25840,11 +26378,16 @@ class NapariSpatialTileLayers {
25840
26378
  this.keys.set('transcripts', planKey);
25841
26379
  const diam = px.map((d) => d / pxPerUnit);
25842
26380
  this.drawn = {
25843
- kind: loaded.kind ?? job.kind, bin: loaded.kind ? loaded.bin : job.bin,
26381
+ kind: loaded.kind ?? job.kind,
26382
+ bin: selectionBin ? { size: selectionBin, origin: loaded.clustered?.origin ?? [0, 0] }
26383
+ : (loaded.kind ? loaded.bin : job.bin),
25844
26384
  merged, radius: diam.map((d) => d / 2),
25845
26385
  genes: [...view.transcriptGenes], ref: dataset.imageRef ?? null, grid: null,
26386
+ ...(entryGroup && groupNames ? { entryGroup, groupNames } : {}),
26387
+ ...(loaded.clustered ? { groupGenes: loaded.clustered.genes } : {}),
25846
26388
  };
25847
26389
  this.hoverCache.clear();
26390
+ this.micronsPerUnit = dataset.micronsPerUnit ?? null;
25848
26391
  const typeColumn = this.cellTypeColumnName(dataset, view);
25849
26392
  this.hoverTypes = typeColumn ? await this.categoricalCodes(typeColumn).catch(() => null) : null;
25850
26393
  if (stale())
@@ -25856,7 +26399,7 @@ class NapariSpatialTileLayers {
25856
26399
  this.drawTranscriptCircles(merged, diam, faces.rgba, view, place);
25857
26400
  }
25858
26401
  else {
25859
- this.drawTranscriptGlyphs(merged, diam, faces, view, place);
26402
+ this.drawTranscriptGlyphs(merged, diam, faces, view, place, pxPerUnit);
25860
26403
  }
25861
26404
  }
25862
26405
  /** One sized circle per entry, the size saying how many transcripts it stands for. */
@@ -25880,7 +26423,7 @@ class NapariSpatialTileLayers {
25880
26423
  });
25881
26424
  this.replace('transcripts', layer);
25882
26425
  }
25883
- drawTranscriptGlyphs(merged, diam, faces, view, { scale, translate }) {
26426
+ drawTranscriptGlyphs(merged, diam, faces, view, { scale, translate }, pxPerUnit) {
25884
26427
  // Glyphs: each entry becomes its gene's icon polygon, filled through a discrete
25885
26428
  // colormap and outlined dark so small icons stay readable over the tissue. With every
25886
26429
  // gene drawn there is one icon for all of them — 18,000 shapes would say nothing.
@@ -25903,6 +26446,13 @@ class NapariSpatialTileLayers {
25903
26446
  translate,
25904
26447
  });
25905
26448
  this.replace('transcripts', fill);
26449
+ // A dark rim keeps a few large icons readable over the tissue; on many small ones the
26450
+ // rims merge into a solid dark sheet that hides every colour, so they are left off.
26451
+ const medianPx = median(diam) * pxPerUnit;
26452
+ if (merged.count > GLYPH_OUTLINE_MAX || medianPx < GLYPH_OUTLINE_MIN_PX) {
26453
+ this.drop('transcriptOutline');
26454
+ return;
26455
+ }
25906
26456
  const edge = this.viewer.addShapes(coords, offsets, {
25907
26457
  name: 'transcript outlines',
25908
26458
  draw: 'outline',
@@ -25954,7 +26504,7 @@ class NapariSpatialTileLayers {
25954
26504
  const obs = d.merged.observation[i];
25955
26505
  const cells = obs === NO_OBSERVATION ? [] : [obs];
25956
26506
  let box;
25957
- if (d.kind === 'bins' && d.bin) {
26507
+ if ((d.kind === 'bins' || d.kind === 'genes') && d.bin) {
25958
26508
  const { size, origin } = d.bin;
25959
26509
  const bx = Math.floor((d.merged.x[i] - origin[0]) / size);
25960
26510
  const by = Math.floor((d.merged.y[i] - origin[1]) / size);
@@ -25966,8 +26516,9 @@ class NapariSpatialTileLayers {
25966
26516
  const e = 0.02;
25967
26517
  box = [d.merged.x[i] - e, d.merged.y[i] - e, d.merged.x[i] + e, d.merged.y[i] + e];
25968
26518
  }
26519
+ const genes = d.groupGenes && d.entryGroup ? d.groupGenes[d.entryGroup[i]] : undefined;
25969
26520
  try {
25970
- const summary = await this.port.getTranscriptSummary({ box, cells });
26521
+ const summary = await this.port.getTranscriptSummary({ box, cells, ...(genes ? { genes } : {}) });
25971
26522
  const lines = this.describe(d, i, summary);
25972
26523
  this.hoverCache.set(key, lines);
25973
26524
  if (this.hoverKey === key)
@@ -26022,6 +26573,19 @@ class NapariSpatialTileLayers {
26022
26573
  ];
26023
26574
  }
26024
26575
  const gene = d.genes[t.gene[i]] ?? 'transcript';
26576
+ if (d.bin) {
26577
+ const um = d.bin.size * (this.micronsPerUnit ?? 1);
26578
+ const group = d.entryGroup && d.groupNames ? d.groupNames[d.entryGroup[i]] : gene;
26579
+ const top = s?.topGenes?.length
26580
+ ? [s.topGenes.slice(0, 6).map((g) => `${g.name} ${fmt(g.count)}`).join(', ')] : [];
26581
+ return [
26582
+ `${group} · ${fmt(n)} transcript${n === 1 ? '' : 's'}`,
26583
+ ...(d.groupGenes ? top : group !== gene ? [`mostly ${gene}`] : []),
26584
+ `${um.toFixed(1)} × ${um.toFixed(1)} µm area · zoom in to split`,
26585
+ ...(obs === NO_OBSERVATION && d.groupGenes ? [] : [cellLine(obs, 'mostly cell')]),
26586
+ ...(!s && d.groupGenes && this.port.getTranscriptSummary ? ['loading details…'] : []),
26587
+ ];
26588
+ }
26025
26589
  return n > 1
26026
26590
  ? [`${gene} · ${fmt(n)} transcripts`, 'grouped: zoom in to split', cellLine(obs, 'near cell')]
26027
26591
  : [`${gene} transcript`, cellLine(obs, 'in cell')];
@@ -26043,24 +26607,182 @@ class NapariSpatialTileLayers {
26043
26607
  const keysAt = (l) => tilesInRect(rect, l, meta.levels, meta.bounds, MAX_TRANSCRIPT_TILES);
26044
26608
  const query = { genes, quality: view.transcriptQuality };
26045
26609
  const pxPerMicron = this.pxPerMicron(dataset, pxPerUnit);
26610
+ const mx = (rect.x1 - rect.x0) * 0.25;
26611
+ const my = (rect.y1 - rect.y0) * 0.25;
26612
+ const around = { x0: rect.x0 - mx, y0: rect.y0 - my, x1: rect.x1 + mx, y1: rect.y1 + my };
26613
+ // Zoomed out, each gene's transcripts are grouped on the all-gene pyramid's bin ladder,
26614
+ // so a gene selection reads like "all genes" does: one marker per gene per area, sized
26615
+ // by how many it holds; zoomed in far enough, every transcript is its own marker.
26616
+ const ladder = dataset.transcriptBins?.levels;
26617
+ const baseBin = ladder?.[0]?.binSize
26618
+ ?? (dataset.micronsPerUnit ? PYRAMID_BASE_BIN_UM / dataset.micronsPerUnit : 0);
26619
+ const bin = geneBinSize(pxPerUnit, baseBin, ladder?.length ?? PYRAMID_LEVELS);
26620
+ // Zoomed out to bins as wide as the density grid, a selection is drawn from the per-gene
26621
+ // density grids — one cached request per cluster — rather than 10x's coarse per-gene
26622
+ // tiles, which take minutes for a few hundred genes.
26623
+ // With the pyramid's per-gene levels, every zoom of a selection reads them: the level the
26624
+ // zoom calls for, coarser while the markers would exceed the max.
26625
+ // Both are built from high-quality calls only: with low-quality calls included, the
26626
+ // quality-aware tiles below serve every zoom (as long as there are tiles to serve).
26627
+ const highOnly = view.transcriptQuality !== 'all' || !this.port.getTranscriptTile;
26628
+ const geneBins = dataset.transcriptGeneBins;
26629
+ const fromGeneBins = highOnly && geneBins && this.port.getTranscriptGeneBins;
26630
+ if (fromGeneBins && (bin !== null || !this.port.getTranscriptTile)) {
26631
+ return this.geneBinsJob(dataset, view, around, geneBins, bin ?? geneBins.levels[0].binSize, mx, my, rect);
26632
+ }
26633
+ const grid = dataset.density?.gridSize[0] ?? 0;
26634
+ if (highOnly && bin !== null && grid > 0 && this.port.getDensity && bin >= grid * 0.75) {
26635
+ return this.clusterDensityJob(dataset, view, around, pxPerUnit, Math.max(bin, grid), mx, my, rect);
26636
+ }
26046
26637
  return {
26047
26638
  kind: 'genes',
26048
- key: `genes|${genes.join(',')}|${view.transcriptQuality}|${view.transcriptBudget}|${keysAt(first).map(tileId)}`,
26639
+ // The clip window, in quarter-view steps: a pan past the margin re-clips.
26640
+ key: `genes|${genes.join(',')}|${view.transcriptQuality}|${view.transcriptBudget}|${bin ?? 'each'}|`
26641
+ + `${Math.floor(rect.x0 / (mx || 1))},${Math.floor(rect.y0 / (my || 1))}|${keysAt(first).map(tileId)}`,
26049
26642
  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
- }
26643
+ // The zoom's own level only: over the budget, the transcripts are combined into
26644
+ // larger markers (groupSelection), never fetched from slower coarse levels.
26645
+ const tiles = await this.fetchAll(keysAt(first), (k) => this.port.getTranscriptTile(k.level, k.gx, k.gy, query));
26646
+ // Only what is on screen (and a margin, so a small pan needs nothing new) counts
26647
+ // against the budget and the cap; a tile reaches far past the view.
26648
+ const merged = mergeTranscriptTiles(tiles.map((t) => clipTranscripts(t, around)), MAX_TRANSCRIPTS);
26059
26649
  const px = new Float32Array(merged.count);
26060
26650
  for (let i = 0; i < merged.count; i++) {
26061
26651
  px[i] = transcriptMarkerPx(merged.weight[i], view.transcriptScale, pxPerMicron);
26062
26652
  }
26063
- return { merged, px };
26653
+ return baseBin > 0
26654
+ ? { merged, px, ladder: { baseBin, levels: ladder?.length ?? PYRAMID_LEVELS, start: bin } }
26655
+ : { merged, px };
26656
+ },
26657
+ };
26658
+ }
26659
+ /**
26660
+ * A gene selection from the pyramid's per-gene levels: starting at the level whose bins are
26661
+ * `startBin`, fetch the view's tiles for the selected genes and step to a coarser level while
26662
+ * one marker per cluster per bin would exceed the max. Hidden genes are fetched too (their
26663
+ * counts in view still show) and dropped before drawing; grouping by cluster happens there.
26664
+ */
26665
+ geneBinsJob(dataset, view, around, meta, startBin, mx, my, rect) {
26666
+ const genes = view.transcriptGenes;
26667
+ const levels = meta.levels;
26668
+ let first = levels.findIndex((l) => l.binSize >= startBin * 0.999);
26669
+ if (first < 0)
26670
+ first = levels.length - 1;
26671
+ const budget = Math.max(1, view.transcriptBudget);
26672
+ const hidden = new Set(view.transcriptHiddenGenes);
26673
+ const clusters = this.selectionClusters(view);
26674
+ const clusterOf = new Map();
26675
+ clusters.forEach((c, ci) => c.genes.forEach((g) => clusterOf.set(genes.indexOf(g), ci)));
26676
+ const keysAt = (m) => tilesInRectFrom(meta.origin, around, m, levels.map((l) => ({ tileSize: l.tileSize })), null, MAX_BIN_TILES);
26677
+ return {
26678
+ kind: 'genes',
26679
+ key: `gene-bins|${genes.join(',')}|${budget}|${first}|${JSON.stringify(view.transcriptGeneGroups)}|`
26680
+ + `${Math.floor(rect.x0 / (mx || 1))},${Math.floor(rect.y0 / (my || 1))}`,
26681
+ load: async () => {
26682
+ let m = first;
26683
+ for (;;) {
26684
+ const tiles = await this.fetchAll(keysAt(m), (k) => this.port.getTranscriptGeneBins(k.level, k.gx, k.gy, genes));
26685
+ const clipped = tiles.map((t) => clipTranscripts(t, around));
26686
+ // Past the cap a merge would drop whole genes (the tail of every tile): go coarser.
26687
+ if (clipped.reduce((n, t) => n + t.count, 0) > MAX_TRANSCRIPTS && m < levels.length - 1) {
26688
+ m++;
26689
+ continue;
26690
+ }
26691
+ const merged = mergeTranscriptTiles(clipped, MAX_TRANSCRIPTS);
26692
+ // Markers this level would draw: one per (visible cluster, bin).
26693
+ const seen = new Set();
26694
+ const bin = levels[m].binSize;
26695
+ for (let i = 0; i < merged.count; i++) {
26696
+ if (hidden.has(genes[merged.gene[i]]))
26697
+ continue;
26698
+ const c = clusterOf.get(merged.gene[i]);
26699
+ if (c === undefined)
26700
+ continue;
26701
+ seen.add(`${c}|${Math.floor(merged.x[i] / bin)}|${Math.floor(merged.y[i] / bin)}`);
26702
+ }
26703
+ if (seen.size <= budget || m >= levels.length - 1) {
26704
+ return { merged, px: new Float32Array(merged.count), ladder: { baseBin: bin, levels: 1, start: bin } };
26705
+ }
26706
+ m++;
26707
+ }
26708
+ },
26709
+ };
26710
+ }
26711
+ /** The selection's clusters: each gene-tree group's visible genes, then each ungrouped gene. */
26712
+ selectionClusters(view) {
26713
+ const hidden = new Set(view.transcriptHiddenGenes);
26714
+ const slots = new Map(view.transcriptGenes.map((g, i) => [g, i]));
26715
+ const taken = new Set();
26716
+ const out = [];
26717
+ for (const g of view.transcriptGeneGroups) {
26718
+ const genes = g.genes.filter((x) => slots.has(x) && !hidden.has(x) && !taken.has(x));
26719
+ genes.forEach((x) => taken.add(x));
26720
+ if (genes.length)
26721
+ out.push({ name: g.name, genes, slot: slots.get(genes[0]) });
26722
+ }
26723
+ for (const gene of view.transcriptGenes) {
26724
+ if (!taken.has(gene) && !hidden.has(gene))
26725
+ out.push({ name: gene, genes: [gene], slot: slots.get(gene) });
26726
+ }
26727
+ return out;
26728
+ }
26729
+ /**
26730
+ * Per-gene density grids summed for `genes`, cached (a cluster is asked for once per bin).
26731
+ * Keyed by dataset too: this manager outlives a dataset, and another may share gene names.
26732
+ */
26733
+ densityFor(dataset, genes, bin) {
26734
+ const key = `${dataset}|${bin}|${genes.join(',')}`;
26735
+ let p = this.densityCache.get(key);
26736
+ if (!p) {
26737
+ p = this.port.getDensity(genes, bin);
26738
+ p.catch(() => this.densityCache.delete(key));
26739
+ this.densityCache.set(key, p);
26740
+ if (this.densityCache.size > 256)
26741
+ this.densityCache.delete(this.densityCache.keys().next().value);
26742
+ }
26743
+ return p;
26744
+ }
26745
+ /**
26746
+ * A gene selection zoomed out: one marker per cluster per bin, from the per-gene density
26747
+ * grids. The bin starts where markers are 14 px apart and doubles until the markers fit the
26748
+ * max; each marker sits at the count-weighted centre of the finer grid inside it, so markers
26749
+ * follow the tissue rather than a lattice.
26750
+ */
26751
+ clusterDensityJob(dataset, view, around, pxPerUnit, startBin, mx, my, rect) {
26752
+ const grid = dataset.density.gridSize[0];
26753
+ const clusters = this.selectionClusters(view);
26754
+ let first = grid;
26755
+ while (first < startBin * 0.999)
26756
+ first *= 2;
26757
+ const budget = Math.max(1, view.transcriptBudget);
26758
+ return {
26759
+ kind: 'genes',
26760
+ key: `clusters|${clusters.map((c) => `${c.name}:${c.genes.join('+')}`).join(';')}|${budget}|${first}|`
26761
+ + `${Math.floor(rect.x0 / (mx || 1))},${Math.floor(rect.y0 / (my || 1))}`,
26762
+ load: async () => {
26763
+ let bin = first;
26764
+ for (;;) {
26765
+ // The finer grid gives each marker its centre; the server bins 1, 2, 4 or 8 cells.
26766
+ const fine = Math.min(bin >= grid * 2 ? bin / 2 : bin, grid * 8);
26767
+ const rasters = await Promise.all(clusters.map((c) => this.densityFor(dataset.id, c.genes, fine)));
26768
+ const out = clusterMarkers(rasters, clusters.map((c) => c.slot), bin, around);
26769
+ if (out.tile.count <= budget || bin >= grid * 2 ** 12) {
26770
+ const refCount = quantileOf(out.tile.weight, 0.95);
26771
+ const binPx = bin * pxPerUnit;
26772
+ const px = new Float32Array(out.tile.count);
26773
+ for (let i = 0; i < out.tile.count; i++) {
26774
+ px[i] = groupedMarkerPx(out.tile.weight[i], refCount, binPx, view.transcriptScale);
26775
+ }
26776
+ return {
26777
+ merged: out.tile, px, kind: 'genes',
26778
+ clustered: {
26779
+ group: out.group, names: clusters.map((c) => c.name), genes: clusters.map((c) => c.genes), bin,
26780
+ origin: rasters[0]?.meta.origin ?? [0, 0],
26781
+ },
26782
+ };
26783
+ }
26784
+ bin *= 2;
26785
+ }
26064
26786
  },
26065
26787
  };
26066
26788
  }
@@ -26145,10 +26867,40 @@ class NapariSpatialTileLayers {
26145
26867
  };
26146
26868
  }
26147
26869
  /** Per-entry colours both as RGBA (points) and as colormap values (glyph shapes). */
26148
- async transcriptColors(dataset, view, t) {
26870
+ async transcriptColors(dataset, view, t, clusterOf) {
26149
26871
  let rgb;
26150
26872
  let codeOf;
26151
- if (view.transcriptColorBy === 'gene') {
26873
+ if (view.transcriptColorBy === 'cluster' && clusterOf) {
26874
+ // A cluster named like a group of the cells' grouping takes that group's colour, so a
26875
+ // cluster's transcripts and its cells agree; any other cluster takes a palette colour.
26876
+ const name = this.cellTypeColumnName(dataset, view);
26877
+ const codes = name ? await this.categoricalCodes(name).catch(() => null) : null;
26878
+ const cellColor = new Map();
26879
+ if (codes?.meta.kind === 'categorical') {
26880
+ const colors = resolveCategoryColors(codes.meta);
26881
+ codes.meta.categories.forEach((c, k) => cellColor.set(c, colors[k]));
26882
+ }
26883
+ const colors = clusterColorMap(view.transcriptGenes, view.transcriptGeneGroups, cellColor, DEFAULT_CATEGORICAL_PALETTE);
26884
+ const index = new Map();
26885
+ const hex = [];
26886
+ const codeFor = (cluster) => {
26887
+ let k = index.get(cluster);
26888
+ if (k === undefined) {
26889
+ k = hex.length;
26890
+ index.set(cluster, k);
26891
+ hex.push(colors.get(cluster) ?? cellColor.get(cluster) ?? DEFAULT_CATEGORICAL_PALETTE[0]);
26892
+ }
26893
+ return k;
26894
+ };
26895
+ const code = new Int32Array(t.count);
26896
+ for (let i = 0; i < t.count; i++) {
26897
+ const c = clusterOf(i);
26898
+ code[i] = c === null ? -1 : codeFor(c);
26899
+ }
26900
+ rgb = hex.map(parseHex);
26901
+ codeOf = (i) => code[i];
26902
+ }
26903
+ else if (view.transcriptColorBy === 'gene' || view.transcriptColorBy === 'cluster') {
26152
26904
  // All genes: codes are the dataset's gene indices, folded onto the palette.
26153
26905
  const n = view.transcriptAllGenes ? DEFAULT_CATEGORICAL_PALETTE.length : view.transcriptGenes.length;
26154
26906
  rgb = Array.from({ length: n }, (_g, i) => parseHex((!view.transcriptAllGenes && view.transcriptGeneColors[view.transcriptGenes[i]])
@@ -26194,7 +26946,7 @@ class NapariSpatialTileLayers {
26194
26946
  JSON.stringify(view.densityRange), JSON.stringify(view.densityColormap)].join('|');
26195
26947
  if (key === this.keys.get('density') && this.layers.has('density'))
26196
26948
  return;
26197
- const raster = await this.port.getDensity(genes, view.densityBin);
26949
+ const raster = await this.track('Transcript density', this.port.getDensity(genes, view.densityBin));
26198
26950
  if (stale())
26199
26951
  return;
26200
26952
  // Bins drawn as squares, as Xenium Explorer does; the window is in transcripts/µm².
@@ -26526,6 +27278,60 @@ function mergeTranscriptTiles(tiles, limit = Infinity) {
26526
27278
  }
26527
27279
  return out;
26528
27280
  }
27281
+ /**
27282
+ * One marker per (cluster, `bin` square) from per-cluster density grids: the counts of the grid
27283
+ * cells inside `area` summed per square, at their count-weighted centre. `slots[c]` is the gene
27284
+ * slot whose icon cluster c's markers take.
27285
+ */
27286
+ function clusterMarkers(rasters, slots, bin, area) {
27287
+ const x = [];
27288
+ const y = [];
27289
+ const w = [];
27290
+ const gene = [];
27291
+ const group = [];
27292
+ rasters.forEach((r, c) => {
27293
+ const [cw, ch] = r.meta.gridSize;
27294
+ const [ox, oy] = r.meta.origin;
27295
+ const { rows, cols } = r.meta;
27296
+ const c0 = Math.max(0, Math.floor((area.x0 - ox) / cw));
27297
+ const c1 = Math.min(cols - 1, Math.floor((area.x1 - ox) / cw));
27298
+ const r0 = Math.max(0, Math.floor((area.y0 - oy) / ch));
27299
+ const r1 = Math.min(rows - 1, Math.floor((area.y1 - oy) / ch));
27300
+ const squares = new Map();
27301
+ for (let row = r0; row <= r1; row++) {
27302
+ for (let col = c0; col <= c1; col++) {
27303
+ const v = r.values[row * cols + col];
27304
+ if (!(v > 0))
27305
+ continue;
27306
+ const cx = ox + (col + 0.5) * cw;
27307
+ const cy = oy + (row + 0.5) * ch;
27308
+ const key = Math.floor((cy - oy) / bin) * 1e6 + Math.floor((cx - ox) / bin);
27309
+ const s = squares.get(key) ?? { n: 0, sx: 0, sy: 0 };
27310
+ s.n += v;
27311
+ s.sx += v * cx;
27312
+ s.sy += v * cy;
27313
+ squares.set(key, s);
27314
+ }
27315
+ }
27316
+ for (const s of squares.values()) {
27317
+ x.push(s.sx / s.n);
27318
+ y.push(s.sy / s.n);
27319
+ w.push(Math.round(s.n));
27320
+ gene.push(slots[c]);
27321
+ group.push(c);
27322
+ }
27323
+ });
27324
+ const n = w.length;
27325
+ return {
27326
+ tile: {
27327
+ count: n, aggregated: true,
27328
+ x: Float32Array.from(x), y: Float32Array.from(y), z: new Float32Array(n),
27329
+ weight: Uint32Array.from(w), observation: new Uint32Array(n).fill(NO_OBSERVATION),
27330
+ gene: Uint16Array.from(gene),
27331
+ },
27332
+ group: Int32Array.from(group),
27333
+ };
27334
+ }
26529
27335
 
26530
27336
  /**
26531
27337
  * Overview navigator (minimap) for the napari-js 2D views, mirroring the OSD backend's
@@ -26722,6 +27528,79 @@ function stop(e) {
26722
27528
  e.preventDefault();
26723
27529
  }
26724
27530
 
27531
+ /**
27532
+ * "Transcripts reloading…" at the bottom of the canvas while a layer's data is in flight: one
27533
+ * line naming every layer still loading, on a semi-opaque backing so it reads over any tissue.
27534
+ *
27535
+ * Shown only once something has been loading for {@link SHOW_AFTER_MS}, so a redraw served from
27536
+ * cache does not flash it.
27537
+ */
27538
+ const SHOW_AFTER_MS = 150;
27539
+ class NapariLoadingBadge {
27540
+ constructor(host) {
27541
+ this.host = host;
27542
+ this.names = [];
27543
+ this.timer = null;
27544
+ if (getComputedStyle(host).position === 'static')
27545
+ host.style.position = 'relative';
27546
+ this.el = document.createElement('div');
27547
+ this.el.className = 'napari-loading-badge';
27548
+ this.el.setAttribute('role', 'status');
27549
+ this.el.setAttribute('aria-live', 'polite');
27550
+ Object.assign(this.el.style, {
27551
+ position: 'absolute',
27552
+ left: '50%',
27553
+ bottom: '12px',
27554
+ transform: 'translateX(-50%)',
27555
+ padding: '4px 12px',
27556
+ borderRadius: '12px',
27557
+ background: 'rgba(0, 0, 0, 0.6)',
27558
+ color: '#fff',
27559
+ font: '12px/1.4 system-ui, sans-serif',
27560
+ whiteSpace: 'nowrap',
27561
+ pointerEvents: 'none',
27562
+ zIndex: '5',
27563
+ display: 'none',
27564
+ });
27565
+ host.appendChild(this.el);
27566
+ }
27567
+ /** The layers loading now; empty hides the badge. */
27568
+ set(names) {
27569
+ this.names = [...new Set(names)];
27570
+ if (!this.names.length) {
27571
+ if (this.timer)
27572
+ clearTimeout(this.timer);
27573
+ this.timer = null;
27574
+ this.el.style.display = 'none';
27575
+ return;
27576
+ }
27577
+ this.el.textContent = `${listOf(this.names)} reloading…`;
27578
+ if (this.el.style.display === 'block' || this.timer)
27579
+ return;
27580
+ this.timer = setTimeout(() => {
27581
+ this.timer = null;
27582
+ if (this.names.length)
27583
+ this.el.style.display = 'block';
27584
+ }, SHOW_AFTER_MS);
27585
+ }
27586
+ /** The text currently set (shown or about to be). */
27587
+ get text() {
27588
+ return this.names.length ? this.el.textContent ?? '' : '';
27589
+ }
27590
+ destroy() {
27591
+ if (this.timer)
27592
+ clearTimeout(this.timer);
27593
+ this.el.remove();
27594
+ }
27595
+ }
27596
+ /** "A", "A and B", "A, B and C" — the first capitalised, the rest as they are. */
27597
+ function listOf(names) {
27598
+ const [first, ...rest] = names;
27599
+ const lower = rest.map((n) => n.charAt(0).toLowerCase() + n.slice(1));
27600
+ const all = [first, ...lower];
27601
+ return all.length === 1 ? all[0] : `${all.slice(0, -1).join(', ')} and ${all[all.length - 1]}`;
27602
+ }
27603
+
26725
27604
  /**
26726
27605
  * Wheel-zoom sensitivity for the napari-js backend, as `exp(-delta * speed)` per normalized,
26727
27606
  * clamped wheel-delta unit.
@@ -28472,6 +29351,8 @@ class NapariVisualizerService extends BaseStoreVisualizer {
28472
29351
  this.spatialTilesMgr = null;
28473
29352
  /** For the panel: the transcripts-in-view estimate and the density window in use. */
28474
29353
  this.transcriptEstimate$ = new BehaviorSubject(null);
29354
+ /** Transcripts of each selected gene in view (see NapariSpatialTileLayers.geneCountsIn). */
29355
+ this.geneCountsInView$ = new BehaviorSubject(null);
28475
29356
  this.densityStats$ = new BehaviorSubject(null);
28476
29357
  /** Latest (dataset, view, selection) the spatial subscription saw, so a slice
28477
29358
  * change can rebuild the markers for the new plane. */
@@ -28483,6 +29364,10 @@ class NapariVisualizerService extends BaseStoreVisualizer {
28483
29364
  * round-trip, so a fast sequence of colour-by changes can resolve out of
28484
29365
  * order. Only the newest rebuild is allowed to touch the layer. */
28485
29366
  this.spatialRebuildToken = 0;
29367
+ /** "x reloading…" at the bottom of the canvas: the tile layers' loads and the observations'. */
29368
+ this.loadingBadge = null;
29369
+ this.tileLoading = [];
29370
+ this.observationsLoading = 0;
28486
29371
  /** Monotonic load generation. Bumped by {@link reset} and {@link cancelLoading}; the frame-loading
28487
29372
  * loops (volume assembly, surface preload) capture it and bail when it changes, so a Cancel (or a
28488
29373
  * new plot) actually stops fetching frames instead of running to completion in the background. */
@@ -28846,7 +29731,9 @@ class NapariVisualizerService extends BaseStoreVisualizer {
28846
29731
  await this.mountSpatialOmics3d(viewer, host);
28847
29732
  }
28848
29733
  else if (isSpatialOmics(plotType)) {
28849
- await this.mountSpatialOmics(viewer, host, z);
29734
+ // No loaded image: an image-less dataset opened before any image (the visualizer's
29735
+ // plotSpatialWithoutImage) — the observations alone.
29736
+ await this.mountSpatialOmics(viewer, host, z, imageLoaded == null);
28850
29737
  }
28851
29738
  else if (isNapariScatter(plotType)) {
28852
29739
  await this.mountScatter(viewer, host, z);
@@ -29279,6 +30166,12 @@ class NapariVisualizerService extends BaseStoreVisualizer {
29279
30166
  * The overview navigator for a 2D view with an image: a coarse thumbnail of the whole
29280
30167
  * image with the viewport on it; click or drag to pan at the current zoom (as OSD).
29281
30168
  */
30169
+ showLoading() {
30170
+ if (!this.host)
30171
+ return;
30172
+ this.loadingBadge ??= new NapariLoadingBadge(this.host);
30173
+ this.loadingBadge.set([...(this.observationsLoading > 0 ? ['Observations'] : []), ...this.tileLoading]);
30174
+ }
29282
30175
  installNavigator(z) {
29283
30176
  this.navigator?.destroy();
29284
30177
  this.navigator = null;
@@ -29751,8 +30644,11 @@ class NapariVisualizerService extends BaseStoreVisualizer {
29751
30644
  * exactly as the plain image view does. That is NOT optional here: this mode's selection is
29752
30645
  * driven by drawn ROIs, so without the overlay there is no way to make a selection at all.
29753
30646
  */
29754
- async mountSpatialOmics(viewer, host, z) {
29755
- await this.renderImage(z);
30647
+ async mountSpatialOmics(viewer, host, z, noImage = false) {
30648
+ // With no image loaded there is nothing to render under the observations; they are
30649
+ // framed on their own extent, as for any dataset that brings no image.
30650
+ if (!noImage)
30651
+ await this.renderImage(z);
29756
30652
  // Only fit to the image when this dataset actually has one. Otherwise there is
29757
30653
  // nothing to fit, `imageW`/`imageH` still hold the LAST image's dimensions, and
29758
30654
  // this fits to those — and because it defers to a frame, it lands AFTER the points
@@ -29761,8 +30657,12 @@ class NapariVisualizerService extends BaseStoreVisualizer {
29761
30657
  if (this.spatialLatest?.[0]?.imageRef)
29762
30658
  this.fitCameraSoon();
29763
30659
  this.subscribeDisplayState();
29764
- this.installScaleBar();
29765
- this.installNavigator(z);
30660
+ // The scale bar and navigator describe an image; with none loaded, whatever they would
30661
+ // read is left over from the last one.
30662
+ if (!noImage) {
30663
+ this.installScaleBar();
30664
+ this.installNavigator(z);
30665
+ }
29766
30666
  this.install2dInteraction(viewer, host);
29767
30667
  this.installSpatialHover(host);
29768
30668
  this.spatialTiles()?.attach(viewer);
@@ -29794,6 +30694,11 @@ class NapariVisualizerService extends BaseStoreVisualizer {
29794
30694
  return spatialContinuousLut(node?.data?.value, this.currentReverse, view.continuousColormap);
29795
30695
  },
29796
30696
  estimateChanged: (e) => this.transcriptEstimate$.next(e),
30697
+ geneCountsChanged: (c) => this.geneCountsInView$.next(c),
30698
+ loadingChanged: (layers) => {
30699
+ this.tileLoading = layers;
30700
+ this.showLoading();
30701
+ },
29797
30702
  densityChanged: (d) => this.densityStats$.next(d),
29798
30703
  polygonsShownChanged: () => {
29799
30704
  if (this.spatialPoints)
@@ -30290,6 +31195,8 @@ class NapariVisualizerService extends BaseStoreVisualizer {
30290
31195
  // Resolve colours BEFORE touching the scene: a gene fetch can fail or be
30291
31196
  // superseded, and dropping the existing layer first would blank the view.
30292
31197
  let faceColor;
31198
+ this.observationsLoading++;
31199
+ this.showLoading();
30293
31200
  try {
30294
31201
  faceColor = dataset
30295
31202
  ? await this.spatialFaceColors(dataset, view, selection)
@@ -30299,6 +31206,10 @@ class NapariVisualizerService extends BaseStoreVisualizer {
30299
31206
  console.warn('[napari-js] spatial colouring failed — falling back to a flat colour', err);
30300
31207
  faceColor = NapariVisualizerService.SPATIAL_NEUTRAL_COLOR;
30301
31208
  }
31209
+ finally {
31210
+ this.observationsLoading--;
31211
+ this.showLoading();
31212
+ }
30302
31213
  // A newer rebuild (or a teardown) started while the vector was in flight.
30303
31214
  if (token !== this.spatialRebuildToken || this.viewer !== viewer)
30304
31215
  return;
@@ -30725,6 +31636,8 @@ class NapariVisualizerService extends BaseStoreVisualizer {
30725
31636
  // layer first would blank the view.
30726
31637
  let enc = null;
30727
31638
  if (dataset) {
31639
+ this.observationsLoading++;
31640
+ this.showLoading();
30728
31641
  try {
30729
31642
  enc = await this.spatialScalar3d(dataset, view);
30730
31643
  }
@@ -30732,6 +31645,10 @@ class NapariVisualizerService extends BaseStoreVisualizer {
30732
31645
  console.warn('[napari-js] spatial 3D colouring failed — falling back to a flat colour', err);
30733
31646
  enc = null;
30734
31647
  }
31648
+ finally {
31649
+ this.observationsLoading--;
31650
+ this.showLoading();
31651
+ }
30735
31652
  }
30736
31653
  if (token !== this.spatialRebuildToken || this.viewer !== viewer)
30737
31654
  return;
@@ -31798,6 +32715,10 @@ class NapariVisualizerService extends BaseStoreVisualizer {
31798
32715
  this.scaleBar = null;
31799
32716
  this.navigator?.destroy();
31800
32717
  this.navigator = null;
32718
+ this.loadingBadge?.destroy();
32719
+ this.loadingBadge = null;
32720
+ this.tileLoading = [];
32721
+ this.observationsLoading = 0;
31801
32722
  this.regionOverlay?.destroy();
31802
32723
  this.regionOverlay = null;
31803
32724
  this.axesLabels?.destroy();
@@ -33122,7 +34043,11 @@ class RoutingVisualizerService {
33122
34043
  ...(port.getTranscriptCounts ? {
33123
34044
  transcriptCounts: (genes) => port.getTranscriptCounts(genes),
33124
34045
  } : {}),
34046
+ ...(port.getMarkerGenes ? {
34047
+ markerGenes: (column, perGroup) => port.getMarkerGenes(column, perGroup),
34048
+ } : {}),
33125
34049
  getTranscriptEstimate$: () => this.napari.transcriptEstimate$.asObservable(),
34050
+ getGeneCountsInView$: () => this.napari.geneCountsInView$.asObservable(),
33126
34051
  getDensityStats$: () => this.napari.densityStats$.asObservable(),
33127
34052
  categoryColors: async (name) => {
33128
34053
  const column = await port.getColumn(name);