@jax-data-science/sci-image-visualizer 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/esm2022/index.mjs +7 -1
  2. package/esm2022/lib/contracts/image.contract.mjs +1 -1
  3. package/esm2022/lib/contracts/ports/region-io.port.mjs +1 -1
  4. package/esm2022/lib/contracts/region-editor-api.contract.mjs +1 -1
  5. package/esm2022/lib/implementations/napari-js/napari-spatial-3d-scene.mjs +70 -2
  6. package/esm2022/lib/implementations/napari-js/napari-visualizer.service.mjs +2 -2
  7. package/esm2022/lib/implementations/napari-js/napari-volume-region-meshes.mjs +11 -1
  8. package/esm2022/lib/implementations/osd/openseadragon-viewer-options.mjs +18 -2
  9. package/esm2022/lib/implementations/osd/openseadragon-visualizer.service.mjs +9 -1
  10. package/esm2022/lib/implementations/osd/osd-tile-loading.mjs +71 -0
  11. package/esm2022/lib/implementations/osd/osd-viewer-like.mjs +1 -1
  12. package/esm2022/lib/implementations/osd/slice-cache.mjs +35 -6
  13. package/esm2022/lib/region-editor/region-editor.component.mjs +152 -4
  14. package/esm2022/lib/region-editor/region-persistence.service.mjs +9 -1
  15. package/esm2022/lib/region-import/label-classes.mjs +149 -0
  16. package/esm2022/lib/region-import/label-volume.mjs +225 -0
  17. package/esm2022/lib/region-import/ome-zarr-labels.mjs +253 -0
  18. package/esm2022/lib/region-import/open-annotations.mjs +289 -0
  19. package/esm2022/lib/region-import/region-import.mjs +71 -0
  20. package/esm2022/lib/region-import/zarr-codecs.mjs +167 -0
  21. package/esm2022/lib/region-import/zip.mjs +157 -0
  22. package/esm2022/lib/routing-visualizer.service.mjs +112 -7
  23. package/esm2022/lib/store/region-source-scope.mjs +19 -4
  24. package/esm2022/lib/store/region-store.service.mjs +64 -14
  25. package/esm2022/lib/visualizer/region-load.mjs +16 -1
  26. package/esm2022/lib/visualizer.component.mjs +28 -2
  27. package/fesm2022/{jax-data-science-sci-image-visualizer-jax-data-science-sci-image-visualizer-BKQhpbHv.mjs → jax-data-science-sci-image-visualizer-jax-data-science-sci-image-visualizer-a68Hw5rw.mjs} +2057 -196
  28. package/fesm2022/jax-data-science-sci-image-visualizer-jax-data-science-sci-image-visualizer-a68Hw5rw.mjs.map +1 -0
  29. package/fesm2022/{jax-data-science-sci-image-visualizer-onnx-sam-session-z1IN-F7w.mjs → jax-data-science-sci-image-visualizer-onnx-sam-session-CvadfzjS.mjs} +4 -2
  30. package/fesm2022/{jax-data-science-sci-image-visualizer-onnx-sam-session-z1IN-F7w.mjs.map → jax-data-science-sci-image-visualizer-onnx-sam-session-CvadfzjS.mjs.map} +1 -1
  31. package/fesm2022/jax-data-science-sci-image-visualizer.mjs +3 -1
  32. package/fesm2022/jax-data-science-sci-image-visualizer.mjs.map +1 -1
  33. package/index.d.ts +12 -0
  34. package/lib/contracts/image.contract.d.ts +7 -0
  35. package/lib/contracts/ports/region-io.port.d.ts +24 -0
  36. package/lib/contracts/region-editor-api.contract.d.ts +19 -1
  37. package/lib/implementations/napari-js/napari-spatial-3d-scene.d.ts +40 -1
  38. package/lib/implementations/napari-js/napari-volume-region-meshes.d.ts +5 -0
  39. package/lib/implementations/osd/openseadragon-viewer-options.d.ts +2 -0
  40. package/lib/implementations/osd/openseadragon-visualizer.service.d.ts +2 -0
  41. package/lib/implementations/osd/osd-tile-loading.d.ts +43 -0
  42. package/lib/implementations/osd/osd-viewer-like.d.ts +14 -0
  43. package/lib/implementations/osd/slice-cache.d.ts +13 -3
  44. package/lib/region-editor/region-editor.component.d.ts +27 -0
  45. package/lib/region-editor/region-persistence.service.d.ts +5 -1
  46. package/lib/region-import/label-classes.d.ts +39 -0
  47. package/lib/region-import/label-volume.d.ts +35 -0
  48. package/lib/region-import/ome-zarr-labels.d.ts +36 -0
  49. package/lib/region-import/open-annotations.d.ts +73 -0
  50. package/lib/region-import/region-import.d.ts +46 -0
  51. package/lib/region-import/zarr-codecs.d.ts +14 -0
  52. package/lib/region-import/zip.d.ts +31 -0
  53. package/lib/routing-visualizer.service.d.ts +39 -1
  54. package/lib/store/region-source-scope.d.ts +8 -2
  55. package/lib/store/region-store.service.d.ts +15 -5
  56. package/lib/visualizer.component.d.ts +9 -0
  57. package/package.json +2 -1
  58. package/fesm2022/jax-data-science-sci-image-visualizer-jax-data-science-sci-image-visualizer-BKQhpbHv.mjs.map +0 -1
@@ -6,7 +6,9 @@ import { LruCache, Colormap, buildLut, parseColor as parseColor$1, worldViewport
6
6
  import { BehaviorSubject, firstValueFrom, Subject, Subscription, of, Observable, combineLatest, defer, from, EMPTY, merge } from 'rxjs';
7
7
  import { timeout, debounceTime, takeUntil, switchMap, catchError, startWith, map } from 'rxjs/operators';
8
8
  import * as ort from 'onnxruntime-web';
9
- import { smoothMesh, surfaceNets, pointInRing as pointInRing$2, ringArea as ringArea$1, rasterizePolygon as rasterizePolygon$1, traceContours, floodFill } from 'napari-js/geometry';
9
+ import { smoothMesh, surfaceNets, pointInRing as pointInRing$2, ringArea as ringArea$1, traceContours, rasterizePolygon as rasterizePolygon$1, floodFill } from 'napari-js/geometry';
10
+ import { encode } from 'fast-png';
11
+ import { decompress } from 'fzstd';
10
12
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
11
13
  import * as i1$1 from '@angular/common';
12
14
  import { CommonModule } from '@angular/common';
@@ -5814,6 +5816,1305 @@ function fromWire(f) {
5814
5816
  };
5815
5817
  }
5816
5818
 
5819
+ /**
5820
+ * A label volume (a NIfTI-1 `.nii` / `.nii.gz` or a NRRD `.nrrd` of integer structure ids, an
5821
+ * atlas annotation say) read in the browser, sliced as the server opens the matching image:
5822
+ *
5823
+ * - **NRRD**: a 3D volume is cut along its FIRST axis, each section its third axis wide and its
5824
+ * second axis high, as jit-service opens a NRRD volume (`AtlasNrrdReader`: the Allen CCF's 528
5825
+ * coronal sections of 456 × 320, anterior first). A 2D NRRD is one slice.
5826
+ * - **NIfTI**: along its third axis, each slice its first axis wide and its second axis high, as
5827
+ * Bio-Formats reads one (no row flip).
5828
+ *
5829
+ * Only what label volumes use: a single file (NRRD with attached data, NIfTI-1 `n+1`), integer
5830
+ * or integer-valued float samples, either byte order, raw or gzip. Anything else throws rather
5831
+ * than be read approximately. Pure: usable from workers.
5832
+ */
5833
+ /** Whether a file name is a label volume this reads. */
5834
+ function isLabelVolumeName(name) {
5835
+ return /\.(nrrd|nii|nii\.gz)$/i.test(name);
5836
+ }
5837
+ /** Read the label volume in `bytes`, named `name` (`.nrrd`, `.nii`, `.nii.gz`). */
5838
+ async function readLabelVolume(name, bytes, gunzip = platformGunzip) {
5839
+ if (/\.nrrd$/i.test(name))
5840
+ return readNrrd(bytes, gunzip);
5841
+ if (/\.nii(\.gz)?$/i.test(name))
5842
+ return readNifti(isGzip(bytes) ? await gunzip(bytes) : bytes);
5843
+ throw new Error(`${name}: not a NIfTI (.nii, .nii.gz) or NRRD (.nrrd) label volume`);
5844
+ }
5845
+ const SAMPLES = {
5846
+ u8: { bytes: 1, read: (v, at) => v.getUint8(at) },
5847
+ i8: { bytes: 1, read: (v, at) => v.getInt8(at) },
5848
+ u16: { bytes: 2, read: (v, at, le) => v.getUint16(at, le) },
5849
+ i16: { bytes: 2, read: (v, at, le) => v.getInt16(at, le) },
5850
+ u32: { bytes: 4, read: (v, at, le) => v.getUint32(at, le) },
5851
+ i32: { bytes: 4, read: (v, at, le) => v.getInt32(at, le) },
5852
+ f32: { bytes: 4, read: (v, at, le) => Math.round(v.getFloat32(at, le)) },
5853
+ f64: { bytes: 8, read: (v, at, le) => Math.round(v.getFloat64(at, le)) },
5854
+ };
5855
+ /** NRRD type names (every spelling the format allows) → sample type. */
5856
+ const NRRD_TYPES = {
5857
+ 'signed char': 'i8',
5858
+ int8: 'i8',
5859
+ int8_t: 'i8',
5860
+ uchar: 'u8',
5861
+ 'unsigned char': 'u8',
5862
+ uint8: 'u8',
5863
+ uint8_t: 'u8',
5864
+ short: 'i16',
5865
+ 'short int': 'i16',
5866
+ 'signed short': 'i16',
5867
+ 'signed short int': 'i16',
5868
+ int16: 'i16',
5869
+ int16_t: 'i16',
5870
+ ushort: 'u16',
5871
+ 'unsigned short': 'u16',
5872
+ 'unsigned short int': 'u16',
5873
+ uint16: 'u16',
5874
+ uint16_t: 'u16',
5875
+ int: 'i32',
5876
+ 'signed int': 'i32',
5877
+ int32: 'i32',
5878
+ int32_t: 'i32',
5879
+ uint: 'u32',
5880
+ 'unsigned int': 'u32',
5881
+ uint32: 'u32',
5882
+ uint32_t: 'u32',
5883
+ float: 'f32',
5884
+ double: 'f64',
5885
+ };
5886
+ /** NIfTI datatype codes → sample type. */
5887
+ const NIFTI_TYPES = {
5888
+ 2: 'u8',
5889
+ 4: 'i16',
5890
+ 8: 'i32',
5891
+ 16: 'f32',
5892
+ 64: 'f64',
5893
+ 256: 'i8',
5894
+ 512: 'u16',
5895
+ 768: 'u32',
5896
+ };
5897
+ async function readNrrd(bytes, gunzip) {
5898
+ const end = headerEnd(bytes);
5899
+ if (end < 0)
5900
+ throw new Error('NRRD: no blank line ends the header');
5901
+ const lines = new TextDecoder('latin1').decode(bytes.subarray(0, end)).split(/\r?\n/);
5902
+ if (!/^NRRD\d{4}/.test(lines[0] ?? ''))
5903
+ throw new Error('NRRD: not a NRRD file');
5904
+ const field = {};
5905
+ for (const line of lines.slice(1)) {
5906
+ if (line.startsWith('#'))
5907
+ continue;
5908
+ const at = line.indexOf(': ');
5909
+ if (at > 0)
5910
+ field[line.slice(0, at).trim().toLowerCase()] = line.slice(at + 2).trim();
5911
+ }
5912
+ if (field['data file'] || field['datafile'])
5913
+ throw new Error('NRRD: detached data (a .nhdr) is not supported');
5914
+ const type = NRRD_TYPES[(field['type'] ?? '').toLowerCase()];
5915
+ if (!type)
5916
+ throw new Error(`NRRD: unsupported type "${field['type']}"`);
5917
+ const sizes = (field['sizes'] ?? '').split(/\s+/).filter(Boolean).map(Number);
5918
+ if (sizes.length !== 2 && sizes.length !== 3)
5919
+ throw new Error(`NRRD: need 2 or 3 dimensions, got ${sizes.length}`);
5920
+ const encoding = (field['encoding'] ?? 'raw').toLowerCase();
5921
+ let data = bytes.subarray(end);
5922
+ if (encoding === 'gzip' || encoding === 'gz')
5923
+ data = await gunzip(data);
5924
+ else if (encoding !== 'raw')
5925
+ throw new Error(`NRRD: unsupported encoding "${encoding}"`);
5926
+ const little = (field['endian'] ?? 'little').toLowerCase() !== 'big';
5927
+ // A 3D volume: sections along axis 0, each axis 2 wide and axis 1 high (jit-service's cut).
5928
+ const [n0, n1, n2] = sizes.length === 3 ? sizes : [sizes[0], sizes[1], 1];
5929
+ const axes = sizes.length === 3 ? { z: 0, x: 2, y: 1 } : { z: 2, x: 0, y: 1 };
5930
+ return volume('nrrd', [n0, n1, n2], axes, data, SAMPLES[type], little, nrrdSpacing(field, sizes.length));
5931
+ }
5932
+ /** Per file axis, the voxel size in micrometres (see {@link LabelVolume.spacing}), or undefined. */
5933
+ function nrrdSpacing(field, dims) {
5934
+ let sizes;
5935
+ const directions = field['space directions'];
5936
+ if (directions) {
5937
+ const vectors = directions.match(/\(([^)]*)\)|none/g) ?? [];
5938
+ sizes = vectors.filter((v) => v !== 'none').map((v) => Math.hypot(...v.slice(1, -1).split(',').map(Number)));
5939
+ }
5940
+ else if (field['spacings']) {
5941
+ sizes = field['spacings'].split(/\s+/).map(Number);
5942
+ }
5943
+ if (!sizes || sizes.length !== dims || sizes.some((n) => !(n > 0)))
5944
+ return undefined;
5945
+ // One unit per axis (`space units: "mm" "um" "um"`), each axis scaled by its own.
5946
+ const units = (field['space units'] ?? '').match(/"[^"]*"|\S+/g)?.map((u) => u.replace(/"/g, '').toLowerCase()) ?? [];
5947
+ const factor = (unit) => unit === 'mm' || unit === 'millimeter' || unit === 'millimeters'
5948
+ ? 1e3
5949
+ : unit === 'm' || unit === 'meter' || unit === 'meters'
5950
+ ? 1e6
5951
+ : unit === 'nm' || unit === 'nanometer' || unit === 'nanometers'
5952
+ ? 1e-3
5953
+ : 1; // micrometres, or none said
5954
+ return [...sizes.map((n, i) => n * factor(units[i])), ...(dims === 2 ? [1] : [])];
5955
+ }
5956
+ function readNifti(bytes) {
5957
+ if (bytes.length < 352)
5958
+ throw new Error('NIfTI: shorter than a header');
5959
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
5960
+ let little = true;
5961
+ if (view.getInt32(0, true) !== 348) {
5962
+ if (view.getInt32(0, false) !== 348)
5963
+ throw new Error('NIfTI: not a NIfTI-1 file');
5964
+ little = false;
5965
+ }
5966
+ const magic = new TextDecoder('latin1').decode(bytes.subarray(344, 347));
5967
+ if (magic !== 'n+1')
5968
+ throw new Error('NIfTI: a separate .hdr/.img pair is not supported');
5969
+ const ndim = view.getInt16(40, little);
5970
+ const dims = [1, 2, 3, 4].map((i) => view.getInt16(40 + 2 * i, little));
5971
+ if (ndim < 2)
5972
+ throw new Error(`NIfTI: need at least 2 dimensions, got ${ndim}`);
5973
+ if (ndim >= 4 && dims[3] > 1)
5974
+ throw new Error('NIfTI: a 4D file is not a label volume');
5975
+ const type = NIFTI_TYPES[view.getInt16(70, little)];
5976
+ if (!type)
5977
+ throw new Error(`NIfTI: unsupported datatype ${view.getInt16(70, little)}`);
5978
+ const offset = Math.max(352, Math.round(view.getFloat32(108, little)));
5979
+ const [n0, n1, n2] = [dims[0], dims[1], ndim >= 3 ? dims[2] : 1];
5980
+ // Slices along axis 2, each axis 0 wide and axis 1 high (Bio-Formats' planes).
5981
+ const pixdim = [1, 2, 3].map((i) => Math.abs(view.getFloat32(76 + 4 * i, little)));
5982
+ const units = bytes[123] & 7;
5983
+ const factor = units === 1 ? 1e6 : units === 3 ? 1 : 1e3; // metres, micrometres, else millimetres
5984
+ const spacing = pixdim.every((n) => n > 0) ? pixdim.map((n) => n * factor) : undefined;
5985
+ return volume('nifti', [n0, n1, n2], { z: 2, x: 0, y: 1 }, bytes.subarray(offset), SAMPLES[type], little, spacing);
5986
+ }
5987
+ function volume(format, n, axes, data, sample, little, spacing) {
5988
+ const count = n[0] * n[1] * n[2];
5989
+ if (!(count > 0))
5990
+ throw new Error(`${format}: empty volume`);
5991
+ if (data.length < count * sample.bytes) {
5992
+ throw new Error(`${format}: ${data.length} bytes of data, ${count * sample.bytes} needed`);
5993
+ }
5994
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
5995
+ const stride = [1, n[0], n[0] * n[1]];
5996
+ const width = n[axes.x];
5997
+ const height = n[axes.y];
5998
+ return {
5999
+ format,
6000
+ width,
6001
+ height,
6002
+ slices: n[axes.z],
6003
+ ...(spacing
6004
+ ? { spacing: [spacing[axes.x], spacing[axes.y], spacing[axes.z]] }
6005
+ : {}),
6006
+ sliceLabels(z) {
6007
+ const out = new Uint32Array(width * height);
6008
+ const base = z * stride[axes.z];
6009
+ const sx = stride[axes.x];
6010
+ const sy = stride[axes.y];
6011
+ for (let y = 0; y < height; y++) {
6012
+ for (let x = 0; x < width; x++) {
6013
+ const v = sample.read(view, (base + x * sx + y * sy) * sample.bytes, little);
6014
+ out[y * width + x] = v > 0 ? v : 0;
6015
+ }
6016
+ }
6017
+ return out;
6018
+ },
6019
+ };
6020
+ }
6021
+ /** The offset just past the header's blank line (`\n\n` or `\r\n\r\n`); -1 without one. */
6022
+ function headerEnd(bytes) {
6023
+ const limit = Math.min(bytes.length - 1, 1 << 20);
6024
+ for (let i = 0; i < limit; i++) {
6025
+ if (bytes[i] !== 10)
6026
+ continue;
6027
+ if (bytes[i + 1] === 10)
6028
+ return i + 2;
6029
+ if (bytes[i + 1] === 13 && bytes[i + 2] === 10)
6030
+ return i + 3;
6031
+ }
6032
+ return -1;
6033
+ }
6034
+ function isGzip(bytes) {
6035
+ return bytes.length > 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
6036
+ }
6037
+ async function platformGunzip(bytes) {
6038
+ if (typeof DecompressionStream === 'undefined')
6039
+ throw new Error('gzip data needs DecompressionStream');
6040
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'));
6041
+ return new Uint8Array(await new Response(stream).arrayBuffer());
6042
+ }
6043
+
6044
+ /** The look the Allen atlas region sets carry (see the brain-atlas example's GeoJSON). */
6045
+ const ATLAS_STYLE = {
6046
+ fillOpacity: 0.6,
6047
+ stroke: '#3c3c3c',
6048
+ strokeWidth: 1,
6049
+ labels: true,
6050
+ highlight: '#a020f0',
6051
+ };
6052
+ /**
6053
+ * The classes of a label volume from `ontology`, a parsed JSON document:
6054
+ *
6055
+ * - an **Allen structure graph** (`structure_graph_1.json`: `{ msg: [root] }`, or a node or a
6056
+ * list of nodes with `id`, `acronym`, `name`, `color_hex_triplet`, `graph_order` and
6057
+ * `children`): label = structure id, class = acronym, its tree the ontology's, drawn as the
6058
+ * atlas region sets are;
6059
+ * - a **region set description** (a `regionset.json`: `classHierarchy`, optional `style`), whose
6060
+ * classes name their label as `value` (or `id`), or with a `labels` map of value → class name.
6061
+ *
6062
+ * Without one (or with another document), each label is its own class `label <value>`.
6063
+ */
6064
+ function labelClasses(ontology) {
6065
+ const allen = allenRoots(ontology);
6066
+ if (allen)
6067
+ return allenClasses(allen);
6068
+ if (ontology &&
6069
+ typeof ontology === 'object' &&
6070
+ Array.isArray(ontology.classHierarchy)) {
6071
+ return regionSetClasses(ontology);
6072
+ }
6073
+ return { classOf: unnamed };
6074
+ }
6075
+ function unnamed(value) {
6076
+ return { name: `label ${value}`, color: paletteColor(value) };
6077
+ }
6078
+ function allenRoots(doc) {
6079
+ const isNode = (n) => !!n &&
6080
+ typeof n === 'object' &&
6081
+ typeof n.id === 'number' &&
6082
+ typeof n.acronym === 'string';
6083
+ const msg = doc?.msg;
6084
+ const roots = Array.isArray(msg) ? msg : Array.isArray(doc) ? doc : doc ? [doc] : [];
6085
+ return roots.length && roots.every(isNode) ? roots : null;
6086
+ }
6087
+ function allenClasses(roots) {
6088
+ const byId = new Map();
6089
+ const hierarchy = [];
6090
+ const walk = (n, parent) => {
6091
+ const color = `#${(n.color_hex_triplet || '808080').toUpperCase()}`;
6092
+ byId.set(n.id, { name: n.acronym, color, title: n.name });
6093
+ hierarchy.push({
6094
+ name: n.acronym,
6095
+ ...(n.name ? { title: n.name } : {}),
6096
+ ...(parent ? { parent } : {}),
6097
+ color,
6098
+ ...(n.graph_order != null ? { order: n.graph_order } : {}),
6099
+ });
6100
+ for (const c of n.children ?? [])
6101
+ walk(c, n.acronym);
6102
+ };
6103
+ for (const r of roots)
6104
+ walk(r, null);
6105
+ hierarchy.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
6106
+ return { classOf: (v) => byId.get(v) ?? unnamed(v), hierarchy, style: ATLAS_STYLE };
6107
+ }
6108
+ function regionSetClasses(doc) {
6109
+ const nodes = new Map(doc.classHierarchy.map((n) => [n.name, n]));
6110
+ const byValue = new Map();
6111
+ for (const n of doc.classHierarchy) {
6112
+ const v = typeof n.value === 'number' ? n.value : typeof n.id === 'number' ? n.id : undefined;
6113
+ if (v != null)
6114
+ byValue.set(v, n.name);
6115
+ }
6116
+ for (const [v, name] of Object.entries(doc.labels ?? {}))
6117
+ byValue.set(Number(v), name);
6118
+ const hierarchy = doc.classHierarchy.map(({ name, title, parent, color, order }) => ({
6119
+ name,
6120
+ ...(title ? { title } : {}),
6121
+ ...(parent ? { parent } : {}),
6122
+ ...(color ? { color } : {}),
6123
+ ...(order != null ? { order } : {}),
6124
+ }));
6125
+ return {
6126
+ classOf: (v) => {
6127
+ const name = byValue.get(v);
6128
+ const node = name ? nodes.get(name) : undefined;
6129
+ return node ? { name: node.name, color: node.color ?? paletteColor(v), title: node.title } : unnamed(v);
6130
+ },
6131
+ hierarchy,
6132
+ ...(doc.style ? { style: doc.style } : {}),
6133
+ };
6134
+ }
6135
+ /** A colour per label value, spread around the hue circle. */
6136
+ function paletteColor(value) {
6137
+ const hue = (value * 137.508) % 360;
6138
+ const [r, g, b] = hsl(hue, 0.65, 0.55);
6139
+ return `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`.toUpperCase();
6140
+ }
6141
+ function hsl(h, s, l) {
6142
+ const k = (n) => (n + h / 30) % 12;
6143
+ const a = s * Math.min(l, 1 - l);
6144
+ const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
6145
+ return [f(0), f(8), f(4)].map((c) => Math.round(c * 255));
6146
+ }
6147
+ const rgb$1 = (hex) => [1, 3, 5].map((o) => parseInt(hex.slice(o, o + 2), 16));
6148
+ /**
6149
+ * Slice `z` of `volume` as a GeoJSON FeatureCollection string: one feature per label on the
6150
+ * slice, its every piece (with holes) traced along pixel edges, in image pixels with y down;
6151
+ * classified by `classes`, which also give the collection its class tree and style.
6152
+ */
6153
+ function labelSliceGeoJson(volume, z, classes) {
6154
+ const labels = volume.sliceLabels(z);
6155
+ const byLabel = new Map();
6156
+ for (const c of traceContours({ data: labels, width: volume.width, height: volume.height })) {
6157
+ const polygons = byLabel.get(c.value) ?? [];
6158
+ polygons.push([ring(c.outer), ...c.holes.map(ring)]);
6159
+ byLabel.set(c.value, polygons);
6160
+ }
6161
+ const features = [...byLabel].map(([value, polygons]) => {
6162
+ const cls = classes.classOf(value);
6163
+ return {
6164
+ type: 'Feature',
6165
+ id: String(value),
6166
+ geometry: { type: 'MultiPolygon', coordinates: polygons },
6167
+ properties: {
6168
+ objectType: 'annotation',
6169
+ name: cls.title ?? cls.name,
6170
+ classification: { name: cls.name, color: rgb$1(cls.color) },
6171
+ labelValue: value,
6172
+ },
6173
+ };
6174
+ });
6175
+ return JSON.stringify({
6176
+ type: 'FeatureCollection',
6177
+ ...(classes.hierarchy ? { classHierarchy: classes.hierarchy } : {}),
6178
+ ...(classes.style ? { style: classes.style } : {}),
6179
+ features,
6180
+ });
6181
+ }
6182
+ /** A closed GeoJSON ring from a flat `[x0, y0, …]` one. */
6183
+ function ring(flat) {
6184
+ const pts = [];
6185
+ for (let i = 0; i < flat.length; i += 2)
6186
+ pts.push([flat[i], flat[i + 1]]);
6187
+ if (pts.length)
6188
+ pts.push([...pts[0]]);
6189
+ return pts;
6190
+ }
6191
+
6192
+ /** `data`, named `name`, as an import file. A `File`'s own name is used when `name` is omitted. */
6193
+ function importFile(data, name = data.name ?? '') {
6194
+ const encoder = new TextEncoder();
6195
+ return {
6196
+ name,
6197
+ text: async () => typeof data === 'string'
6198
+ ? data
6199
+ : data instanceof Blob
6200
+ ? data.text()
6201
+ : new TextDecoder().decode(data instanceof Uint8Array ? data : new Uint8Array(data)),
6202
+ bytes: async () => typeof data === 'string'
6203
+ ? encoder.encode(data)
6204
+ : data instanceof Blob
6205
+ ? new Uint8Array(await data.arrayBuffer())
6206
+ : data instanceof Uint8Array
6207
+ ? data
6208
+ : new Uint8Array(data),
6209
+ };
6210
+ }
6211
+ const leaf = (name) => name.split(/[\\/]/).pop() ?? name;
6212
+ const isRegionSet = (f) => leaf(f.name).toLowerCase() === 'regionset.json';
6213
+ const isGeoJson = (f) => /\.geojson$/i.test(f.name);
6214
+ const isJson = (f) => /\.json$/i.test(f.name);
6215
+ /**
6216
+ * What `files` import as: a label volume (`.nii`, `.nii.gz`, `.nrrd`) with the ontology JSON chosen
6217
+ * with it (a `regionset.json`, else an Allen `structure_graph*.json`, else any `.json`); several
6218
+ * region files (a folder of per-slice `.geojson`, in natural name order, its `regionset.json`
6219
+ * apart); or one region file (`.geojson` or `.json`). Null when none of them is one.
6220
+ */
6221
+ function planRegionImport(files) {
6222
+ const volume = files.find((f) => isLabelVolumeName(f.name));
6223
+ if (volume) {
6224
+ const jsons = files.filter((f) => f !== volume && isJson(f));
6225
+ const ontology = jsons.find(isRegionSet) ??
6226
+ jsons.find((f) => /structure_graph.*\.json$/i.test(leaf(f.name))) ??
6227
+ jsons[0] ??
6228
+ null;
6229
+ return { kind: 'volume', volume, ontology };
6230
+ }
6231
+ const regionSet = files.find(isRegionSet) ?? null;
6232
+ const geojsons = files.filter((f) => isGeoJson(f));
6233
+ const regionFiles = geojsons.length ? geojsons : files.filter((f) => isJson(f) && !isRegionSet(f));
6234
+ if (regionFiles.length > 1 || (regionFiles.length === 1 && regionSet)) {
6235
+ const slices = [...regionFiles].sort((a, b) => naturalCompare(leaf(a.name), leaf(b.name)));
6236
+ return { kind: 'folder', slices, regionSet };
6237
+ }
6238
+ return regionFiles.length === 1 ? { kind: 'single', file: regionFiles[0] } : null;
6239
+ }
6240
+ /** "slice_2" before "slice_10": runs of digits compared as numbers. */
6241
+ function naturalCompare(a, b) {
6242
+ return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
6243
+ }
6244
+ /**
6245
+ * A folder slice's GeoJSON with the set's class tree and style (its `regionset.json`) as the
6246
+ * collection's own members, unless the file has its own: what a served folder's slice carries.
6247
+ */
6248
+ async function folderSliceGeoJson(file, regionSet) {
6249
+ const text = await file.text();
6250
+ if (!regionSet || (!regionSet.classHierarchy && !regionSet.style))
6251
+ return text;
6252
+ const doc = JSON.parse(text);
6253
+ if (doc['type'] !== 'FeatureCollection')
6254
+ return text;
6255
+ if (regionSet.classHierarchy && !doc['classHierarchy'])
6256
+ doc['classHierarchy'] = regionSet.classHierarchy;
6257
+ if (regionSet.style && !doc['style'])
6258
+ doc['style'] = regionSet.style;
6259
+ return JSON.stringify(doc);
6260
+ }
6261
+
6262
+ /**
6263
+ * The chunk codecs of the zarr stores label images come in (OME-Zarr `labels/`): Blosc framing
6264
+ * with its lz4, zstd or zlib inner codecs (zarr v2's default, as ome-zarr-py writes), zstd (zarr
6265
+ * v3's default), gzip and zlib. Anything else throws rather than be decoded wrongly. Pure: usable
6266
+ * from workers.
6267
+ *
6268
+ * The Blosc framing follows jit-bioformats' `Blosc` (the decoder JIT validated on SpatialData
6269
+ * stores): `u8 version, u8 versionlz, u8 flags, u8 typesize, u32 nbytes, u32 blocksize,
6270
+ * u32 cbytes`, then `u32 bstarts[nblocks]`, then blocks of `nstreams` sub-streams, each
6271
+ * `i32 csize` + bytes (a stream whose csize equals its decoded size stored raw, a negative one a
6272
+ * run of one byte). Flags: 0x1 byte-shuffle, 0x2 memcpyed, 0x4 bit-shuffle, 0x10 no-split, bits
6273
+ * 5-7 the inner codec.
6274
+ */
6275
+ const BLOSC_CODECS = ['blosclz', 'lz4', 'snappy', 'zlib', 'zstd'];
6276
+ /** Decode a Blosc frame. */
6277
+ async function bloscDecompress(frame, inflate = platformInflate) {
6278
+ const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
6279
+ if (frame.length < 16)
6280
+ throw new Error('blosc: frame too short');
6281
+ const flags = frame[2];
6282
+ const typesize = frame[3];
6283
+ const nbytes = view.getUint32(4, true);
6284
+ const blocksize = view.getUint32(8, true);
6285
+ const out = new Uint8Array(nbytes);
6286
+ if (nbytes === 0)
6287
+ return out;
6288
+ if (flags & 0x2) {
6289
+ out.set(frame.subarray(16, 16 + nbytes));
6290
+ return out;
6291
+ }
6292
+ if (flags & 0x4)
6293
+ throw new Error('blosc: bit-shuffle is not supported');
6294
+ const code = flags >>> 5;
6295
+ const codec = BLOSC_CODECS[code] ?? `code ${code}`;
6296
+ if (codec !== 'lz4' && codec !== 'zstd' && codec !== 'zlib') {
6297
+ throw new Error(`blosc: inner codec ${codec} is not supported (lz4, zstd or zlib)`);
6298
+ }
6299
+ const shuffle = (flags & 0x1) !== 0 && typesize > 1;
6300
+ const noSplit = (flags & 0x10) !== 0;
6301
+ const nblocks = Math.ceil(nbytes / blocksize);
6302
+ const scratch = shuffle ? new Uint8Array(blocksize) : null;
6303
+ for (let blk = 0; blk < nblocks; blk++) {
6304
+ const bsize = Math.min(blocksize, nbytes - blk * blocksize);
6305
+ const leftover = bsize !== blocksize;
6306
+ const nstreams = !noSplit && !leftover ? typesize : 1;
6307
+ const ssize = Math.floor(bsize / nstreams);
6308
+ let p = view.getUint32(16 + blk * 4, true);
6309
+ const block = scratch ?? out;
6310
+ const base = scratch ? 0 : blk * blocksize;
6311
+ for (let s = 0; s < nstreams; s++) {
6312
+ const csize = view.getInt32(p, true);
6313
+ p += 4;
6314
+ const dst = base + s * ssize;
6315
+ if (csize < 0) {
6316
+ block.fill(-csize & 0xff, dst, dst + ssize);
6317
+ continue;
6318
+ }
6319
+ const src = frame.subarray(p, p + csize);
6320
+ if (csize === ssize) {
6321
+ block.set(src, dst);
6322
+ }
6323
+ else {
6324
+ const dec = codec === 'lz4'
6325
+ ? lz4BlockDecompress(src, ssize)
6326
+ : codec === 'zstd'
6327
+ ? decompress(src)
6328
+ : await inflate(src, 'deflate');
6329
+ if (dec.length !== ssize)
6330
+ throw new Error(`blosc: ${codec} produced ${dec.length} of ${ssize} bytes`);
6331
+ block.set(dec, dst);
6332
+ }
6333
+ p += csize;
6334
+ }
6335
+ if (scratch)
6336
+ unshuffle(scratch, bsize, typesize, out, blk * blocksize);
6337
+ }
6338
+ return out;
6339
+ }
6340
+ /** Decode one LZ4 block (no frame) of `size` bytes. */
6341
+ function lz4BlockDecompress(src, size) {
6342
+ const out = new Uint8Array(size);
6343
+ let i = 0;
6344
+ let o = 0;
6345
+ while (i < src.length) {
6346
+ const token = src[i++];
6347
+ let literals = token >>> 4;
6348
+ if (literals === 15) {
6349
+ let b;
6350
+ do {
6351
+ b = src[i++];
6352
+ literals += b;
6353
+ } while (b === 255);
6354
+ }
6355
+ if (o + literals > size || i + literals > src.length)
6356
+ throw new Error('lz4: literals overrun');
6357
+ out.set(src.subarray(i, i + literals), o);
6358
+ i += literals;
6359
+ o += literals;
6360
+ if (i >= src.length)
6361
+ break; // the last sequence has no match
6362
+ const offset = src[i] | (src[i + 1] << 8);
6363
+ i += 2;
6364
+ if (offset === 0 || offset > o)
6365
+ throw new Error('lz4: bad match offset');
6366
+ let length = (token & 15) + 4;
6367
+ if ((token & 15) === 15) {
6368
+ let b;
6369
+ do {
6370
+ b = src[i++];
6371
+ length += b;
6372
+ } while (b === 255);
6373
+ }
6374
+ if (o + length > size)
6375
+ throw new Error('lz4: match overrun');
6376
+ // Byte by byte: a match may overlap what it copies (a run).
6377
+ for (let k = 0; k < length; k++, o++)
6378
+ out[o] = out[o - offset];
6379
+ }
6380
+ if (o !== size)
6381
+ throw new Error(`lz4: produced ${o} of ${size} bytes`);
6382
+ return out;
6383
+ }
6384
+ /**
6385
+ * Decode a zarr chunk through `codecs`, outermost last as zarr lists them (v2: the one
6386
+ * `compressor`; v3: the bytes-to-bytes codecs after `bytes`).
6387
+ */
6388
+ async function decodeChunk(bytes, codecs, inflate = platformInflate) {
6389
+ let data = bytes;
6390
+ for (const codec of [...codecs].reverse()) {
6391
+ switch (codec.id) {
6392
+ case 'blosc':
6393
+ data = await bloscDecompress(data, inflate);
6394
+ break;
6395
+ case 'zstd':
6396
+ data = decompress(data);
6397
+ break;
6398
+ case 'gzip':
6399
+ data = await inflate(data, 'gzip');
6400
+ break;
6401
+ case 'zlib':
6402
+ data = await inflate(data, 'deflate');
6403
+ break;
6404
+ default:
6405
+ throw new Error(`zarr: codec ${codec.id} is not supported (blosc, zstd, gzip, zlib)`);
6406
+ }
6407
+ }
6408
+ return data;
6409
+ }
6410
+ function unshuffle(src, len, typesize, dst, off) {
6411
+ const n = Math.floor(len / typesize);
6412
+ for (let j = 0; j < typesize; j++) {
6413
+ const base = j * n;
6414
+ for (let i = 0; i < n; i++)
6415
+ dst[off + i * typesize + j] = src[base + i];
6416
+ }
6417
+ // Bytes past the last whole element are not shuffled.
6418
+ for (let k = n * typesize; k < len; k++)
6419
+ dst[off + k] = src[k];
6420
+ }
6421
+ async function platformInflate(bytes, format) {
6422
+ if (typeof DecompressionStream === 'undefined')
6423
+ throw new Error(`zarr: ${format} chunks need DecompressionStream`);
6424
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream(format));
6425
+ return new Uint8Array(await new Response(stream).arrayBuffer());
6426
+ }
6427
+
6428
+ /**
6429
+ * A label image of an OME-Zarr store (`labels/<name>/`, NGFF 0.4 on zarr v2 or 0.5 on zarr v3),
6430
+ * read in the browser from the store's files (a folder chosen, or a zip's members): its full
6431
+ * resolution level, sliced along z, with the classes its `image-label` metadata names and colours.
6432
+ *
6433
+ * Chunks are decoded when a slice that needs them is read, so a store is never decoded whole.
6434
+ * Only what label images use: C-order chunks of integer (or integer-valued float) samples, through
6435
+ * the codecs {@link decodeChunk} reads, a missing chunk being the fill value. Sharded or transposed
6436
+ * arrays, F-order and filters throw rather than be read wrongly. Pure: usable from workers.
6437
+ */
6438
+ /** Whether `files` hold a zarr store (any zarr metadata among them). */
6439
+ function hasZarrMetadata(files) {
6440
+ return files.some((f) => /(^|\/)(\.zattrs|\.zarray|\.zgroup|zarr\.json)$/.test(norm(f.name)));
6441
+ }
6442
+ /**
6443
+ * The first label image of the zarr store among `files` (the order `labels/`'s listing gives,
6444
+ * else the first found), or null when the store has none.
6445
+ */
6446
+ async function readOmeZarrLabels(files, inflate) {
6447
+ const byPath = new Map(files.map((f) => [norm(f.name), f]));
6448
+ const groups = new Map();
6449
+ for (const [path, file] of byPath) {
6450
+ const m = /^(.*?)\/?(\.zattrs|zarr\.json)$/.exec(path);
6451
+ if (!m)
6452
+ continue;
6453
+ const doc = JSON.parse(await file.text());
6454
+ const attrs = (m[2] === 'zarr.json' ? doc['attributes'] : doc) ?? {};
6455
+ groups.set(m[1], (attrs['ome'] ?? attrs));
6456
+ }
6457
+ const listed = [];
6458
+ for (const [dir, ome] of groups) {
6459
+ for (const name of ome['labels'] ?? [])
6460
+ listed.push(join(dir, name));
6461
+ }
6462
+ const isLabel = (dir) => {
6463
+ const ome = groups.get(dir);
6464
+ return !!ome?.['multiscales'] && (!!ome['image-label'] || /(^|\/)labels\/[^/]+$/.test(dir));
6465
+ };
6466
+ const dir = [...listed, ...groups.keys()].find(isLabel);
6467
+ if (dir === undefined)
6468
+ return null;
6469
+ const ome = groups.get(dir);
6470
+ const ms = ome['multiscales'][0];
6471
+ const dataset = ms.datasets[0];
6472
+ const array = await readArrayMeta(byPath, join(dir, dataset.path));
6473
+ const axes = (ms.axes ?? ['z', 'y', 'x'].slice(3 - array.shape.length)).map((a) => typeof a === 'string' ? { name: a } : a);
6474
+ if (axes.length !== array.shape.length)
6475
+ throw new Error(`OME-Zarr ${dir}: ${axes.length} axes for a ${array.shape.length}-D array`);
6476
+ const at = (n) => axes.findIndex((a) => a.name.toLowerCase() === n);
6477
+ const ix = at('x');
6478
+ const iy = at('y');
6479
+ const iz = at('z');
6480
+ if (ix < 0 || iy < 0)
6481
+ throw new Error(`OME-Zarr ${dir}: no x and y axes`);
6482
+ const scale = dataset.coordinateTransformations?.find((t) => t.type === 'scale')?.scale;
6483
+ const um = (i) => (i < 0 ? 1 : (scale?.[i] ?? 1) * unitToMicrons(axes[i].unit));
6484
+ const stack = labelStack(byPath, join(dir, dataset.path), array, { ix, iy, iz }, inflate);
6485
+ stack.spacing = [um(ix), um(iy), iz < 0 ? 1 : um(iz)];
6486
+ const name = ms.name ?? dir.split('/').pop() ?? 'labels';
6487
+ return { name, stack, classes: imageLabelClasses(ome['image-label']) };
6488
+ }
6489
+ async function readArrayMeta(byPath, path) {
6490
+ const v2 = byPath.get(join(path, '.zarray'));
6491
+ if (v2) {
6492
+ const m = JSON.parse(await v2.text());
6493
+ if (m.order && m.order !== 'C')
6494
+ throw new Error(`zarr ${path}: ${m.order}-order chunks are not supported`);
6495
+ if (m.filters?.length)
6496
+ throw new Error(`zarr ${path}: filters are not supported`);
6497
+ const sep = m.dimension_separator ?? '.';
6498
+ return {
6499
+ shape: m.shape,
6500
+ chunks: m.chunks,
6501
+ dtype: v2Dtype(m.dtype, path),
6502
+ codecs: m.compressor ? [m.compressor] : [],
6503
+ fill: Number(m.fill_value ?? 0) || 0,
6504
+ key: (index) => index.join(sep),
6505
+ };
6506
+ }
6507
+ const v3 = byPath.get(join(path, 'zarr.json'));
6508
+ if (!v3)
6509
+ throw new Error(`zarr ${path}: no array metadata`);
6510
+ const m = JSON.parse(await v3.text());
6511
+ if (m.chunk_grid?.name !== 'regular')
6512
+ throw new Error(`zarr ${path}: chunk grid ${m.chunk_grid?.name} is not supported`);
6513
+ const codecs = (m.codecs ?? []);
6514
+ const bytes = codecs[0];
6515
+ if (bytes?.name !== 'bytes') {
6516
+ throw new Error(`zarr ${path}: codec ${bytes?.name ?? 'none'} before bytes is not supported (sharding, transpose)`);
6517
+ }
6518
+ const little = (bytes.configuration?.['endian'] ?? 'little') === 'little';
6519
+ const enc = m.chunk_key_encoding ?? { name: 'default' };
6520
+ const sep = enc.configuration?.separator ?? (enc.name === 'v2' ? '.' : '/');
6521
+ return {
6522
+ shape: m.shape,
6523
+ chunks: m.chunk_grid.configuration.chunk_shape,
6524
+ dtype: v3Dtype(m.data_type, little, path),
6525
+ codecs: codecs.slice(1).map((c) => ({ id: c.name, ...(c.configuration ?? {}) })),
6526
+ fill: Number(m.fill_value ?? 0) || 0,
6527
+ key: (index) => (enc.name === 'v2' ? index.join(sep) : ['c', ...index].join(sep)),
6528
+ };
6529
+ }
6530
+ /** The slices of the array at `path`, decoding (and keeping) the chunk layers a slice spans. */
6531
+ function labelStack(byPath, path, a, axes, inflate) {
6532
+ const { ix, iy, iz } = axes;
6533
+ const width = a.shape[ix];
6534
+ const height = a.shape[iy];
6535
+ const slices = iz < 0 ? 1 : a.shape[iz];
6536
+ const cx = a.chunks[ix];
6537
+ const cy = a.chunks[iy];
6538
+ // The chunks (their decodes, pending or done) of the last chunk layer along z read: a slice's
6539
+ // neighbours share it. A read keeps the layer it started with, so a read of another layer
6540
+ // replacing this cache while it awaits a chunk cannot change what it reads.
6541
+ let layer = null;
6542
+ const chunk = async (index) => {
6543
+ const file = byPath.get(join(path, a.key(index)));
6544
+ if (!file)
6545
+ return null;
6546
+ return decodeChunk(await file.bytes(), a.codecs, inflate);
6547
+ };
6548
+ return {
6549
+ width,
6550
+ height,
6551
+ slices,
6552
+ async labels(z) {
6553
+ if (z < 0 || z >= slices)
6554
+ throw new Error(`slice ${z} of ${slices}`);
6555
+ const kz = iz < 0 ? 0 : Math.floor(z / a.chunks[iz]);
6556
+ if (layer?.kz !== kz)
6557
+ layer = { kz, chunks: new Map() };
6558
+ const mine = layer;
6559
+ const out = new Uint32Array(width * height);
6560
+ const strides = a.chunks.map((_, d) => a.chunks.slice(d + 1).reduce((p, n) => p * n, 1));
6561
+ for (let gy = 0; gy * cy < height; gy++) {
6562
+ for (let gx = 0; gx * cx < width; gx++) {
6563
+ // Every other axis (time, channel) at its first index.
6564
+ const index = a.shape.map((_, d) => (d === ix ? gx : d === iy ? gy : d === iz ? kz : 0));
6565
+ const key = index.join(',');
6566
+ let pending = mine.chunks.get(key);
6567
+ if (!pending) {
6568
+ pending = chunk(index);
6569
+ mine.chunks.set(key, pending);
6570
+ // A chunk that failed to decode is not kept: the next read tries it again.
6571
+ pending.catch(() => mine.chunks.delete(key));
6572
+ }
6573
+ const data = await pending;
6574
+ const view = data ? new DataView(data.buffer, data.byteOffset, data.byteLength) : null;
6575
+ const inZ = iz < 0 ? 0 : z - kz * a.chunks[iz];
6576
+ for (let y = gy * cy; y < Math.min(height, (gy + 1) * cy); y++) {
6577
+ for (let x = gx * cx; x < Math.min(width, (gx + 1) * cx); x++) {
6578
+ let v = a.fill;
6579
+ if (view) {
6580
+ const local = a.shape.map((_, d) => d === ix ? x - gx * cx : d === iy ? y - gy * cy : d === iz ? inZ : 0);
6581
+ const i = local.reduce((p, n, d) => p + n * strides[d], 0);
6582
+ v = a.dtype.read(view, i * a.dtype.bytes);
6583
+ }
6584
+ if (v > 0xffffffff) {
6585
+ // A label id past 32 bits would be another label once narrowed: refuse it.
6586
+ throw new Error(`zarr ${path}: label ${v} does not fit 32 bits`);
6587
+ }
6588
+ out[y * width + x] = v > 0 ? v : 0;
6589
+ }
6590
+ }
6591
+ }
6592
+ }
6593
+ return out;
6594
+ },
6595
+ };
6596
+ }
6597
+ const SAMPLE = {
6598
+ u1: { bytes: 1, read: (v, at) => v.getUint8(at) },
6599
+ i1: { bytes: 1, read: (v, at) => v.getInt8(at) },
6600
+ u2: { bytes: 2, read: (v, at, le) => v.getUint16(at, le) },
6601
+ i2: { bytes: 2, read: (v, at, le) => v.getInt16(at, le) },
6602
+ u4: { bytes: 4, read: (v, at, le) => v.getUint32(at, le) },
6603
+ i4: { bytes: 4, read: (v, at, le) => v.getInt32(at, le) },
6604
+ u8: { bytes: 8, read: (v, at, le) => Number(v.getBigUint64(at, le)) },
6605
+ i8: { bytes: 8, read: (v, at, le) => Number(v.getBigInt64(at, le)) },
6606
+ f4: { bytes: 4, read: (v, at, le) => Math.round(v.getFloat32(at, le)) },
6607
+ f8: { bytes: 8, read: (v, at, le) => Math.round(v.getFloat64(at, le)) },
6608
+ };
6609
+ function v2Dtype(dtype, path) {
6610
+ const m = /^([<>|])([uif])(\d)$/.exec(dtype);
6611
+ const s = m ? SAMPLE[m[2] + m[3]] : undefined;
6612
+ if (!m || !s)
6613
+ throw new Error(`zarr ${path}: dtype ${dtype} is not a label type`);
6614
+ const le = m[1] !== '>';
6615
+ return { bytes: s.bytes, read: (v, at) => s.read(v, at, le) };
6616
+ }
6617
+ function v3Dtype(type, little, path) {
6618
+ const m = /^(u?int|float)(8|16|32|64)$/.exec(type);
6619
+ const code = m ? (m[1] === 'uint' ? 'u' : m[1] === 'int' ? 'i' : 'f') + Number(m[2]) / 8 : '';
6620
+ const s = SAMPLE[code];
6621
+ if (!s)
6622
+ throw new Error(`zarr ${path}: data type ${type} is not a label type`);
6623
+ return { bytes: s.bytes, read: (v, at) => s.read(v, at, little) };
6624
+ }
6625
+ /** The classes `image-label` names (a property naming each value) and colours; others unnamed. */
6626
+ function imageLabelClasses(meta) {
6627
+ const named = new Map();
6628
+ for (const p of meta?.properties ?? []) {
6629
+ const name = ['name', 'class', 'label', 'acronym', 'structure', 'type']
6630
+ .map((k) => p[k])
6631
+ .find((v) => typeof v === 'string');
6632
+ named.set(p['label-value'], { ...named.get(p['label-value']), name: name });
6633
+ }
6634
+ for (const c of meta?.colors ?? []) {
6635
+ if (!c.rgba)
6636
+ continue;
6637
+ const hex = `#${c.rgba
6638
+ .slice(0, 3)
6639
+ .map((n) => Math.round(n).toString(16).padStart(2, '0'))
6640
+ .join('')}`.toUpperCase();
6641
+ named.set(c['label-value'], { ...named.get(c['label-value']), color: hex });
6642
+ }
6643
+ if (!named.size)
6644
+ return labelClasses(undefined);
6645
+ const classHierarchy = [];
6646
+ const seen = new Set();
6647
+ for (const [value, { name, color }] of named) {
6648
+ const cls = name ?? `label ${value}`;
6649
+ // One class per name: an instance segmentation names many values alike.
6650
+ if (!seen.has(cls))
6651
+ classHierarchy.push({ name: cls, ...(color ? { color } : {}), value });
6652
+ seen.add(cls);
6653
+ }
6654
+ const labels = Object.fromEntries([...named].map(([v, { name }]) => [v, name ?? `label ${v}`]));
6655
+ return labelClasses({ classHierarchy, labels });
6656
+ }
6657
+ function unitToMicrons(unit) {
6658
+ switch (unit?.toLowerCase()) {
6659
+ case 'nanometer':
6660
+ return 1e-3;
6661
+ case 'millimeter':
6662
+ return 1e3;
6663
+ case 'centimeter':
6664
+ return 1e4;
6665
+ case 'meter':
6666
+ return 1e6;
6667
+ default:
6668
+ return 1; // micrometer, or none said
6669
+ }
6670
+ }
6671
+ /** A path relative to the store, '/'-separated, without a leading './'. */
6672
+ function norm(name) {
6673
+ return name.replace(/\\/g, '/').replace(/^\.\//, '');
6674
+ }
6675
+ function join(dir, name) {
6676
+ return dir ? `${dir}/${name}` : name;
6677
+ }
6678
+
6679
+ const EOCD = 0x06054b50;
6680
+ const ZIP64_LOCATOR = 0x07064b50;
6681
+ const ZIP64_EOCD = 0x06064b50;
6682
+ const CENTRAL = 0x02014b50;
6683
+ const LOCAL = 0x04034b50;
6684
+ const U32 = 0xffffffff;
6685
+ /** Whether a file name is a zip archive. */
6686
+ function isZipName(name) {
6687
+ return /\.zip$/i.test(name);
6688
+ }
6689
+ /** The members of the archive in `bytes` (directories and macOS metadata left out). */
6690
+ function readZip(bytes, inflateRaw = platformInflateRaw) {
6691
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
6692
+ const eocd = findEocd(view);
6693
+ let count = view.getUint16(eocd + 10, true);
6694
+ let dirOffset = view.getUint32(eocd + 16, true);
6695
+ if (count === 0xffff || dirOffset === U32) {
6696
+ const locator = eocd - 20;
6697
+ if (locator < 0 || view.getUint32(locator, true) !== ZIP64_LOCATOR)
6698
+ throw new Error('zip: ZIP64 locator missing');
6699
+ const z64 = num(view, locator + 8);
6700
+ if (view.getUint32(z64, true) !== ZIP64_EOCD)
6701
+ throw new Error('zip: ZIP64 directory end missing');
6702
+ count = num(view, z64 + 32);
6703
+ dirOffset = num(view, z64 + 48);
6704
+ }
6705
+ const entries = [];
6706
+ let at = dirOffset;
6707
+ for (let i = 0; i < count; i++) {
6708
+ if (view.getUint32(at, true) !== CENTRAL)
6709
+ throw new Error('zip: bad central directory');
6710
+ const flags = view.getUint16(at + 8, true);
6711
+ const method = view.getUint16(at + 10, true);
6712
+ const crc = view.getUint32(at + 16, true);
6713
+ let compressed = view.getUint32(at + 20, true);
6714
+ let size = view.getUint32(at + 24, true);
6715
+ const nameLen = view.getUint16(at + 28, true);
6716
+ const extraLen = view.getUint16(at + 30, true);
6717
+ const commentLen = view.getUint16(at + 32, true);
6718
+ let local = view.getUint32(at + 42, true);
6719
+ const nameBytes = bytes.subarray(at + 46, at + 46 + nameLen);
6720
+ const name = (flags & 0x800 ? new TextDecoder() : new TextDecoder('latin1'))
6721
+ .decode(nameBytes)
6722
+ .replace(/\\/g, '/');
6723
+ // ZIP64: the 32-bit fields that overflowed are in the extra field, in this order.
6724
+ let x = at + 46 + nameLen;
6725
+ const extraEnd = x + extraLen;
6726
+ while (x + 4 <= extraEnd) {
6727
+ const id = view.getUint16(x, true);
6728
+ const len = view.getUint16(x + 2, true);
6729
+ if (id === 0x0001) {
6730
+ let p = x + 4;
6731
+ if (size === U32)
6732
+ ((size = num(view, p)), (p += 8));
6733
+ if (compressed === U32)
6734
+ ((compressed = num(view, p)), (p += 8));
6735
+ if (local === U32)
6736
+ local = num(view, p);
6737
+ }
6738
+ x += 4 + len;
6739
+ }
6740
+ at = extraEnd + commentLen;
6741
+ if (name.endsWith('/') || /(^|\/)__MACOSX\//.test(name) || /(^|\/)\.DS_Store$/.test(name))
6742
+ continue;
6743
+ const entry = { flags, method, crc, compressed, size, local };
6744
+ entries.push({ name, size, bytes: () => readMember(bytes, view, name, entry, inflateRaw) });
6745
+ }
6746
+ return entries;
6747
+ }
6748
+ /**
6749
+ * `files` with every `.zip` among them replaced by its members, named by their paths in it (a
6750
+ * single top folder the archive wraps everything in is kept, as a folder pick would keep it).
6751
+ */
6752
+ async function expandZips(files, inflateRaw) {
6753
+ const out = [];
6754
+ for (const file of files) {
6755
+ if (!isZipName(file.name)) {
6756
+ out.push(file);
6757
+ continue;
6758
+ }
6759
+ for (const entry of readZip(await file.bytes(), inflateRaw)) {
6760
+ out.push({
6761
+ name: entry.name,
6762
+ bytes: () => entry.bytes(),
6763
+ text: async () => new TextDecoder().decode(await entry.bytes()),
6764
+ });
6765
+ }
6766
+ }
6767
+ return out;
6768
+ }
6769
+ async function readMember(bytes, view, name, m, inflateRaw) {
6770
+ if (m.flags & 0x1)
6771
+ throw new Error(`zip: ${name} is encrypted`);
6772
+ if (view.getUint32(m.local, true) !== LOCAL)
6773
+ throw new Error(`zip: ${name}: bad local header`);
6774
+ const start = m.local + 30 + view.getUint16(m.local + 26, true) + view.getUint16(m.local + 28, true);
6775
+ const raw = bytes.subarray(start, start + m.compressed);
6776
+ if (raw.length !== m.compressed)
6777
+ throw new Error(`zip: ${name} is truncated`);
6778
+ let out;
6779
+ if (m.method === 0)
6780
+ out = raw;
6781
+ else if (m.method === 8)
6782
+ out = await inflateRaw(raw);
6783
+ else
6784
+ throw new Error(`zip: ${name} uses compression method ${m.method}, not stored or deflate`);
6785
+ if (out.length !== m.size)
6786
+ throw new Error(`zip: ${name} is ${out.length} bytes, expected ${m.size}`);
6787
+ if (crc32(out) !== m.crc)
6788
+ throw new Error(`zip: ${name} fails its CRC check`);
6789
+ return out;
6790
+ }
6791
+ /** The end-of-central-directory record: the last one, within the 64 KiB a comment may take. */
6792
+ function findEocd(view) {
6793
+ const stop = Math.max(0, view.byteLength - 22 - 0xffff);
6794
+ for (let at = view.byteLength - 22; at >= stop; at--) {
6795
+ if (view.getUint32(at, true) === EOCD)
6796
+ return at;
6797
+ }
6798
+ throw new Error('zip: not a zip archive (no central directory)');
6799
+ }
6800
+ /** A little-endian u64 that fits a double (archives under 8 PB). */
6801
+ function num(view, at) {
6802
+ return view.getUint32(at, true) + view.getUint32(at + 4, true) * 0x100000000;
6803
+ }
6804
+ let crcTable = null;
6805
+ /** CRC-32 (IEEE), as zip records it. */
6806
+ function crc32(data) {
6807
+ if (!crcTable) {
6808
+ crcTable = new Uint32Array(256);
6809
+ for (let n = 0; n < 256; n++) {
6810
+ let c = n;
6811
+ for (let k = 0; k < 8; k++)
6812
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
6813
+ crcTable[n] = c >>> 0;
6814
+ }
6815
+ }
6816
+ let c = 0xffffffff;
6817
+ for (let i = 0; i < data.length; i++)
6818
+ c = crcTable[(c ^ data[i]) & 0xff] ^ (c >>> 8);
6819
+ return (c ^ 0xffffffff) >>> 0;
6820
+ }
6821
+ async function platformInflateRaw(bytes) {
6822
+ if (typeof DecompressionStream === 'undefined')
6823
+ throw new Error('zip: deflated members need DecompressionStream');
6824
+ let inflater;
6825
+ try {
6826
+ // Compression Streams' raw DEFLATE (Chrome 103, Firefox 113, Safari 16.4 on).
6827
+ inflater = new DecompressionStream('deflate-raw');
6828
+ }
6829
+ catch {
6830
+ throw new Error('zip: this browser cannot inflate zip members (no DecompressionStream "deflate-raw")');
6831
+ }
6832
+ const stream = new Blob([bytes]).stream().pipeThrough(inflater);
6833
+ return new Uint8Array(await new Response(stream).arrayBuffer());
6834
+ }
6835
+
6836
+ /**
6837
+ * Annotations opened by themselves, with no image under them: a label volume (`.nii`, `.nii.gz`,
6838
+ * `.nrrd`, with the ontology JSON chosen with it), an OME-Zarr store's label image, a folder of
6839
+ * per-slice GeoJSON (with its `regionset.json`), one GeoJSON (z-indexed by `geometry.plane.z` or
6840
+ * not), or a `.zip` of any of these.
6841
+ *
6842
+ * {@link openAnnotations} builds the image they are shown on, which every view takes as it takes
6843
+ * any other (OpenSeadragon, napari, Plotly; a stack when there are several slices; the Volume and
6844
+ * Isosurface views and their 3D region surfaces): a label volume's slices in gray levels, its
6845
+ * regions filled over them in their classes' colours, else a blank stack the size of the regions.
6846
+ * The image carries the annotations as its own region sources, one per slice
6847
+ * ({@link IImageInfo.regionSlices}), traced or parsed the first time a slice is shown.
6848
+ * The host publishes it as it publishes any image, and disposes it when another replaces it.
6849
+ *
6850
+ * {@link readAnnotationSlices} is the part the Region Editor's import shares: the sources, by slice.
6851
+ */
6852
+ /** The longest side an image built for annotations may have: OpenSeadragon's non-tiled limit. */
6853
+ const ANNOTATION_IMAGE_MAX_DIM = 8192;
6854
+ /**
6855
+ * The annotations among `files` (zips expanded), as one region source per slice; null when there
6856
+ * are none to read. A label image's slices are traced, and a GeoJSON parsed, when the slice is
6857
+ * first shown.
6858
+ */
6859
+ async function readAnnotationSlices(chosen, decoders = {}) {
6860
+ const files = await expandZips(chosen, decoders.inflateRaw);
6861
+ const openGeoJson = decoders.openGeoJson ?? ((text) => openGeoJsonRegionSource(text));
6862
+ if (hasZarrMetadata(files)) {
6863
+ const zarr = await readOmeZarrLabels(files, decoders.inflate);
6864
+ if (!zarr)
6865
+ throw new Error('The zarr store has no label image (an OME-Zarr labels/ group).');
6866
+ return labelSlices('labels', zarr.name, zarr.stack, styled(zarr.classes, decoders), openGeoJson);
6867
+ }
6868
+ const plan = planRegionImport(files);
6869
+ if (!plan)
6870
+ return null;
6871
+ if (plan.kind === 'volume') {
6872
+ const volume = await readLabelVolume(plan.volume.name, await plan.volume.bytes(), decoders.gunzip);
6873
+ const classes = labelClasses(plan.ontology ? JSON.parse(await plan.ontology.text()) : undefined);
6874
+ return labelSlices('labels', stem(plan.volume.name), stackOf(volume), styled(classes, decoders), openGeoJson);
6875
+ }
6876
+ if (plan.kind === 'folder') {
6877
+ const regionSet = plan.regionSet ? JSON.parse(await plan.regionSet.text()) : null;
6878
+ const openers = new Map();
6879
+ plan.slices.forEach((file, z) => openers.set(z, async () => openGeoJson(await folderSliceGeoJson(file, regionSet))));
6880
+ const folder = plan.slices[0].name.split('/').slice(-2, -1)[0] || 'regions';
6881
+ return {
6882
+ kind: 'folder',
6883
+ name: folder,
6884
+ slices: plan.slices.length,
6885
+ openers,
6886
+ summary: `${plan.slices.length} slice files`,
6887
+ };
6888
+ }
6889
+ const slices = splitByPlane(await plan.file.text());
6890
+ const openers = new Map();
6891
+ slices.forEach((text, z) => openers.set(z, () => openGeoJson(text)));
6892
+ return {
6893
+ kind: 'single',
6894
+ name: stem(plan.file.name),
6895
+ slices: slices.length,
6896
+ openers,
6897
+ summary: slices.length > 1 ? `${slices.length} slices` : 'one plane',
6898
+ };
6899
+ }
6900
+ let opened = 0;
6901
+ /**
6902
+ * The image `files`' annotations are shown on by themselves (see the module comment). Throws when
6903
+ * there are none, or when their regions span more than {@link ANNOTATION_IMAGE_MAX_DIM} pixels
6904
+ * (they are then imported onto the image they were drawn on).
6905
+ */
6906
+ async function openAnnotations(files, decoders = {}) {
6907
+ const set = await readAnnotationSlices(files, { outlineLabels: true, ...decoders });
6908
+ if (!set) {
6909
+ throw new Error('Choose a label volume (.nii, .nii.gz, .nrrd, with its ontology JSON), an OME-Zarr store, ' +
6910
+ 'GeoJSON files or a folder of them, or a .zip of any of these.');
6911
+ }
6912
+ let urls = [];
6913
+ let width;
6914
+ let height;
6915
+ let openers = set.openers;
6916
+ // Releases the sources measuring opened that the store has not taken (none for a label image).
6917
+ let release = () => undefined;
6918
+ try {
6919
+ if (set.labels && set.size) {
6920
+ [width, height] = set.size;
6921
+ checkSize(width, height);
6922
+ urls = await labelSliceUrls(set.labels.stack, set.labels.classes);
6923
+ }
6924
+ else {
6925
+ // Regions alone: a blank stack as large as they reach, measured on the sources (kept open
6926
+ // for their slices, so each is parsed once).
6927
+ const measured = await measure(set.openers);
6928
+ openers = measured.openers;
6929
+ release = measured.release;
6930
+ [width, height] = measured.size;
6931
+ checkSize(width, height);
6932
+ const png = encode({ width, height, data: new Uint8Array(width * height), channels: 1, depth: 8 });
6933
+ const blank = new Blob([png], { type: 'image/png' });
6934
+ urls = Array.from({ length: set.slices }, () => URL.createObjectURL(blank));
6935
+ }
6936
+ }
6937
+ catch (err) {
6938
+ urls.forEach((u) => URL.revokeObjectURL(u));
6939
+ release();
6940
+ throw err;
6941
+ }
6942
+ const [mx, my, mz] = set.spacing ?? [];
6943
+ const isStack = set.slices > 1;
6944
+ const info = {
6945
+ isGrayscale: true,
6946
+ trueImageSize: [width, height],
6947
+ urls,
6948
+ isStack,
6949
+ showStack: false,
6950
+ scaleRatio: true,
6951
+ // Unique: the slice cache keys on the name, and two opened sets must not share slices.
6952
+ fileName: `${set.name} · annotations [${++opened}]`,
6953
+ imageMeta: [
6954
+ {
6955
+ channelCount: 1,
6956
+ rgbChannels: 1,
6957
+ x: width,
6958
+ y: height,
6959
+ z: set.slices,
6960
+ mppX: mx ?? null,
6961
+ mppY: my ?? null,
6962
+ ...(mz != null ? { mppZ: mz } : {}),
6963
+ },
6964
+ ],
6965
+ tiled: false,
6966
+ // Open mid-stack: an atlas's first and last sections are mostly empty.
6967
+ initialZIndex: isStack ? set.slices >> 1 : undefined,
6968
+ regionSlices: openers,
6969
+ };
6970
+ return {
6971
+ info,
6972
+ summary: `${set.summary}, ${width} × ${height}`,
6973
+ dispose: () => {
6974
+ urls.forEach((u) => URL.revokeObjectURL(u));
6975
+ release();
6976
+ },
6977
+ };
6978
+ }
6979
+ function labelSlices(kind, name, stack, classes, openGeoJson) {
6980
+ const openers = new Map();
6981
+ for (let z = 0; z < stack.slices; z++) {
6982
+ openers.set(z, async () => openGeoJson(labelSliceGeoJson(oneSlice(stack, await stack.labels(z)), 0, classes)));
6983
+ }
6984
+ return {
6985
+ kind,
6986
+ name,
6987
+ slices: stack.slices,
6988
+ openers,
6989
+ labels: { stack, classes },
6990
+ size: [stack.width, stack.height],
6991
+ ...(stack.spacing ? { spacing: stack.spacing } : {}),
6992
+ summary: `${stack.slices} slice${stack.slices === 1 ? '' : 's'} of ${stack.width} × ${stack.height}`,
6993
+ };
6994
+ }
6995
+ /** `classes`, with the atlas look when asked for and they name no style of their own. */
6996
+ function styled(classes, decoders) {
6997
+ return decoders.outlineLabels && !classes.style ? { ...classes, style: ATLAS_STYLE } : classes;
6998
+ }
6999
+ /** A label volume as a stack read on demand. */
7000
+ function stackOf(volume) {
7001
+ return {
7002
+ width: volume.width,
7003
+ height: volume.height,
7004
+ slices: volume.slices,
7005
+ ...(volume.spacing ? { spacing: volume.spacing } : {}),
7006
+ labels: async (z) => volume.sliceLabels(z),
7007
+ };
7008
+ }
7009
+ /** One slice's labels as the one-slice volume the tracer reads. */
7010
+ function oneSlice(stack, labels) {
7011
+ return { format: 'nrrd', width: stack.width, height: stack.height, slices: 1, sliceLabels: () => labels };
7012
+ }
7013
+ /**
7014
+ * Every slice as a grayscale PNG blob URL, each label at its class colour's luminance (never 0),
7015
+ * background black. Grayscale, not colour: the Volume and Isosurface views take a grayscale stack,
7016
+ * and the colour is the regions' own, filled over it in their classes' colours (the atlas look).
7017
+ */
7018
+ async function labelSliceUrls(stack, classes) {
7019
+ const levels = new Map();
7020
+ const levelOf = (v) => {
7021
+ let l = levels.get(v);
7022
+ if (l === undefined) {
7023
+ const hex = classes.classOf(v).color;
7024
+ const [r, g, b] = [1, 3, 5].map((o) => parseInt(hex.slice(o, o + 2), 16));
7025
+ l = Math.max(1, Math.round(0.299 * r + 0.587 * g + 0.114 * b));
7026
+ levels.set(v, l);
7027
+ }
7028
+ return l;
7029
+ };
7030
+ const urls = [];
7031
+ const gray = new Uint8Array(stack.width * stack.height);
7032
+ for (let z = 0; z < stack.slices; z++) {
7033
+ const labels = await stack.labels(z);
7034
+ for (let i = 0; i < labels.length; i++)
7035
+ gray[i] = labels[i] ? levelOf(labels[i]) : 0;
7036
+ const png = encode({ width: stack.width, height: stack.height, data: gray, channels: 1, depth: 8 });
7037
+ urls.push(URL.createObjectURL(new Blob([png], { type: 'image/png' })));
7038
+ }
7039
+ return urls;
7040
+ }
7041
+ /**
7042
+ * Open every slice's source to learn how far the regions reach (the image is as large, from the
7043
+ * origin, as region coordinates are image pixels). Each opener then hands its already-open source
7044
+ * over the first time (the region store owns it from then on) and opens anew after. `release`
7045
+ * disposes the sources not handed over yet; they are released here when measuring fails.
7046
+ */
7047
+ async function measure(openers) {
7048
+ // Sources opened here and not yet handed to the store: theirs to release until then.
7049
+ const held = new Map();
7050
+ const release = () => {
7051
+ for (const source of held.values())
7052
+ source.dispose();
7053
+ held.clear();
7054
+ };
7055
+ let x1 = 0;
7056
+ let y1 = 0;
7057
+ try {
7058
+ for (const [z, open] of openers) {
7059
+ const source = await open();
7060
+ held.set(z, source);
7061
+ const bounds = source.info.bounds;
7062
+ if (bounds) {
7063
+ x1 = Math.max(x1, bounds[2]);
7064
+ y1 = Math.max(y1, bounds[3]);
7065
+ }
7066
+ }
7067
+ if (!(x1 > 0 && y1 > 0))
7068
+ throw new Error('The regions have no extent to show.');
7069
+ }
7070
+ catch (err) {
7071
+ release();
7072
+ throw err;
7073
+ }
7074
+ const reused = new Map();
7075
+ for (const [z, open] of openers) {
7076
+ reused.set(z, () => {
7077
+ const first = held.get(z);
7078
+ if (!first)
7079
+ return open();
7080
+ held.delete(z);
7081
+ return Promise.resolve(first);
7082
+ });
7083
+ }
7084
+ return { openers: reused, size: [Math.ceil(x1), Math.ceil(y1)], release };
7085
+ }
7086
+ function checkSize(width, height) {
7087
+ if (Math.max(width, height) > ANNOTATION_IMAGE_MAX_DIM) {
7088
+ throw new Error(`The annotations span ${width} × ${height} pixels, more than ${ANNOTATION_IMAGE_MAX_DIM} on a side: ` +
7089
+ 'open the image they were drawn on, and import them onto it from the Region Editor.');
7090
+ }
7091
+ }
7092
+ /**
7093
+ * One GeoJSON's slices: a collection whose features name planes (`geometry.plane.z`) split into
7094
+ * one collection per plane, each keeping the set's class tree and style, its features moved to
7095
+ * plane 0 (a slice's source shows plane 0); any other GeoJSON is one slice as it is.
7096
+ */
7097
+ function splitByPlane(text) {
7098
+ const doc = JSON.parse(text);
7099
+ const features = doc.type === 'FeatureCollection' ? (doc.features ?? []) : [];
7100
+ const zOf = (f) => Math.max(0, Math.floor(f.geometry?.plane?.z ?? 0));
7101
+ const last = features.reduce((m, f) => Math.max(m, zOf(f)), 0);
7102
+ if (last === 0)
7103
+ return [text];
7104
+ const slices = Array.from({ length: last + 1 }, () => []);
7105
+ for (const f of features) {
7106
+ const z = zOf(f);
7107
+ const geometry = f.geometry ? { ...f.geometry } : f.geometry;
7108
+ if (geometry)
7109
+ delete geometry.plane;
7110
+ slices[z].push({ ...f, geometry });
7111
+ }
7112
+ return slices.map((list) => JSON.stringify({ ...doc, features: list }));
7113
+ }
7114
+ function stem(name) {
7115
+ return (name.split('/').pop() ?? name).replace(/\.(nii\.gz|nii|nrrd|geojson|json|zip)$/i, '');
7116
+ }
7117
+
5817
7118
  /**
5818
7119
  * The largest bbox (in pixels) {@link rasterizePolygon} allocates before it
5819
7120
  * falls back to clipping at the window. Also the cap the wand/brush use to
@@ -9098,7 +10399,7 @@ function frameToRgba(cached, frameIndex) {
9098
10399
  /** Lazy-import so onnxruntime-web is never pulled into unit tests or the
9099
10400
  * initial bundle — only when segmentation actually runs. */
9100
10401
  const onnxSessionFactory = async () => {
9101
- const { OnnxSamSession } = await import('./jax-data-science-sci-image-visualizer-onnx-sam-session-z1IN-F7w.mjs');
10402
+ const { OnnxSamSession } = await import('./jax-data-science-sci-image-visualizer-onnx-sam-session-CvadfzjS.mjs');
9102
10403
  return new OnnxSamSession();
9103
10404
  };
9104
10405
  /** Thrown inside a load that a model switch superseded. */
@@ -10037,6 +11338,21 @@ function applyImageRois(info, target, z, minSourceChars = REGION_SOURCE_MIN_CHAR
10037
11338
  const load = (loads.get(target) ?? 0) + 1;
10038
11339
  loads.set(target, load);
10039
11340
  const asSource = (json) => !!attach && !!json && json.length >= minSourceChars;
11341
+ if (info.regionSlices && attach) {
11342
+ // An image built from annotations: its regions are its own sources, one per slice.
11343
+ if (info.isStack) {
11344
+ target.enterStackMode(new Map(), z, 'per-slice-file');
11345
+ for (const [s, open] of info.regionSlices)
11346
+ attach(open, { slice: s });
11347
+ return;
11348
+ }
11349
+ target.setRegions([]);
11350
+ target.resetUndoHistory();
11351
+ const first = info.regionSlices.get(0) ?? info.regionSlices.values().next().value;
11352
+ if (first)
11353
+ attach(first, { slice: 0 });
11354
+ return;
11355
+ }
10040
11356
  if (info.isStack && info.tiled === false) {
10041
11357
  const perSlice = info.roiJsonStrs;
10042
11358
  const sliceCount = info.urls?.length ?? perSlice?.length ?? 0;
@@ -12838,6 +14154,14 @@ class RegionPersistenceService {
12838
14154
  }
12839
14155
  return out;
12840
14156
  }
14157
+ /** Whether the host can pick regions on its server (see RegionIoPort.pickServerRegions). */
14158
+ canPickServerRegions() {
14159
+ return typeof this.io.pickServerRegions === 'function';
14160
+ }
14161
+ /** Let the user pick regions on the host's server; null when cancelled or unsupported. */
14162
+ pickServerRegions() {
14163
+ return this.io.pickServerRegions?.() ?? of(null);
14164
+ }
12841
14165
  /** Write per-slice geojsons through the host port. */
12842
14166
  saveSlices(slices) {
12843
14167
  return this.io.saveSliceGeoJsons(slices);
@@ -14392,6 +15716,8 @@ class RegionEditorComponent {
14392
15716
  this.persistence = persistence;
14393
15717
  this.maskExport = maskExport;
14394
15718
  this.isActive = true;
15719
+ /** The host's HttpClient: a served region set's requests go through it (its auth). */
15720
+ this.http = inject(HttpClient, { optional: true });
14395
15721
  this.regions = [];
14396
15722
  this.selectedRegions = [];
14397
15723
  this.displayHelpDialog = false;
@@ -14517,6 +15843,8 @@ class RegionEditorComponent {
14517
15843
  this.volumeRequest = 0;
14518
15844
  this._pageKey = {};
14519
15845
  this._page = [];
15846
+ /** An import from the server is in flight. */
15847
+ this.importing = false;
14520
15848
  }
14521
15849
  ngOnInit() {
14522
15850
  this.showShapeLabel = this.regionApi.getShowShapeLabel();
@@ -15451,8 +16779,135 @@ class RegionEditorComponent {
15451
16779
  }
15452
16780
  this.changed();
15453
16781
  }
16782
+ /** The files chosen in the import inputs: one GeoJSON, a label volume with its ontology, or a
16783
+ * folder of per-slice GeoJSON files. */
15454
16784
  importRois(event) {
15455
- this.persistence.readChosenFile(event, (text) => void this.applyImportedRois(text));
16785
+ const files = Array.from(event.target.files ?? []);
16786
+ if (files.length)
16787
+ void this.importRegionFiles(files.map((f) => importFile(f, f.webkitRelativePath || f.name)));
16788
+ }
16789
+ /** Whether the host can pick regions on its server ("Import regions from server"). */
16790
+ get canImportFromServer() {
16791
+ return this.persistence.canPickServerRegions();
16792
+ }
16793
+ /** "Import regions from server": the host picks files or a served region set; they import as chosen files do. */
16794
+ importFromServer() {
16795
+ this.importing = true;
16796
+ this.changed();
16797
+ this.persistence.pickServerRegions().subscribe({
16798
+ next: (pick) => {
16799
+ if (!pick)
16800
+ return;
16801
+ if (pick.kind === 'files') {
16802
+ void this.importRegionFiles(pick.files.map((f) => importFile(f.data, f.name)));
16803
+ return;
16804
+ }
16805
+ void this.importServedRegionSet(pick.url, pick.name);
16806
+ },
16807
+ error: (err) => this.importFailed(err),
16808
+ complete: () => {
16809
+ this.importing = false;
16810
+ this.changed();
16811
+ },
16812
+ });
16813
+ }
16814
+ /**
16815
+ * Import `files`: one GeoJSON file as before (its regions, editable); anything else (see
16816
+ * readAnnotationSlices) as one region source per slice, replacing the regions and sources of
16817
+ * every slice: a label volume or an OME-Zarr label image traced the first time a slice is shown,
16818
+ * its classes named (and treed) by its ontology; a folder as one source per slice file; a
16819
+ * z-indexed GeoJSON one per plane; a zip of any of these.
16820
+ */
16821
+ async importRegionFiles(files) {
16822
+ // Every step, reading the files included, reports its failure here rather than rejecting:
16823
+ // the inputs call this without waiting on it.
16824
+ try {
16825
+ const plan = files.some((f) => isZipName(f.name)) ? null : planRegionImport(files);
16826
+ if (plan?.kind === 'single') {
16827
+ const text = await plan.file.text();
16828
+ if (onePlane(text)) {
16829
+ await this.applyImportedRois(text);
16830
+ return;
16831
+ }
16832
+ }
16833
+ const attach = this.regionApi.importSliceRegionSources?.bind(this.regionApi);
16834
+ if (!attach) {
16835
+ this.importFailed(new Error('This viewer cannot hold region sources.'));
16836
+ return;
16837
+ }
16838
+ // Taken before the files are read: decoding a volume takes a while, and the import must land
16839
+ // on the image it was started for, not on one shown meanwhile.
16840
+ const ticket = this.regionApi.regionImportTicket?.();
16841
+ const set = await readAnnotationSlices(files);
16842
+ if (!set) {
16843
+ this.importFailed(new Error('Choose a GeoJSON file, a folder of GeoJSON files, a .nii, .nii.gz or .nrrd label volume, ' +
16844
+ 'an OME-Zarr store, or a .zip of any of these.'));
16845
+ return;
16846
+ }
16847
+ if (set.size)
16848
+ this.warnIfSizeDiffers(set.size[0], set.size[1]);
16849
+ const attached = attach(set.openers, ticket);
16850
+ if (attached === null) {
16851
+ this.importStale();
16852
+ return;
16853
+ }
16854
+ this.afterSourceImport();
16855
+ this.toast('success', 'Regions imported', `${set.summary}; ${attached} slice${attached === 1 ? '' : 's'} annotated.`);
16856
+ }
16857
+ catch (err) {
16858
+ this.importFailed(err);
16859
+ }
16860
+ }
16861
+ /** A region set the host's server serves (jit-service's /regions/{id}/), read through its HttpClient. */
16862
+ async importServedRegionSet(url, name) {
16863
+ const importSource = this.regionApi.importRegionSourceOpener?.bind(this.regionApi);
16864
+ if (!importSource) {
16865
+ this.importFailed(new Error('This viewer cannot hold region sources.'));
16866
+ return;
16867
+ }
16868
+ const ticket = this.regionApi.regionImportTicket?.();
16869
+ try {
16870
+ const applied = await importSource(() => openHttpRegionSource(url, { http: this.http ?? undefined }), ticket);
16871
+ if (applied === false) {
16872
+ this.importStale();
16873
+ return;
16874
+ }
16875
+ this.afterSourceImport();
16876
+ this.toast('success', 'Regions imported', name ?? url);
16877
+ }
16878
+ catch (err) {
16879
+ this.importFailed(err);
16880
+ }
16881
+ }
16882
+ /** An import that finished after another image was shown (or another import began): dropped. */
16883
+ importStale() {
16884
+ this.toast('info', 'Import discarded', 'The image changed while the regions were being read.');
16885
+ this.changed();
16886
+ }
16887
+ /** The table and selection after the regions were replaced by imported sources. */
16888
+ afterSourceImport() {
16889
+ this.selectedRegions = [];
16890
+ this.paginatorFirst = 0;
16891
+ this.commit([]);
16892
+ this.onSelectionChanged();
16893
+ }
16894
+ /** Warn when an imported volume's slices are not the image's size: its outlines would not fit. */
16895
+ warnIfSizeDiffers(width, height) {
16896
+ const meta = this.regionApi.getMaskImageSize();
16897
+ if (meta && (meta.width !== width || meta.height !== height)) {
16898
+ this.toast('info', 'Different size', `The volume's slices are ${width} × ${height}; the image is ${meta.width} × ${meta.height}.`);
16899
+ }
16900
+ }
16901
+ importFailed(err) {
16902
+ this.importing = false;
16903
+ this.messageService.add({
16904
+ key: VIZ_TOAST_KEY,
16905
+ severity: 'error',
16906
+ summary: 'Error importing regions',
16907
+ detail: `${err?.message ?? err}`,
16908
+ });
16909
+ console.error('Error importing regions:', err);
16910
+ this.changed();
15456
16911
  }
15457
16912
  /**
15458
16913
  * Replace the regions with an imported GeoJSON file's, and the slice's region source with them.
@@ -15699,7 +17154,7 @@ class RegionEditorComponent {
15699
17154
  this.messageService.add({ key: VIZ_TOAST_KEY, severity, summary, detail });
15700
17155
  }
15701
17156
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.9", ngImport: i0, type: RegionEditorComponent, deps: [{ token: REGION_EDITOR_API }, { token: i1$2.MessageService }, { token: i1$2.ConfirmationService }, { token: RegionPersistenceService }, { token: MaskExportService }], target: i0.ɵɵFactoryTarget.Component }); }
15702
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.9", type: RegionEditorComponent, isStandalone: true, selector: "region-editor", inputs: { active: "active" }, providers: [RegionPersistenceService], viewQueries: [{ propertyName: "overlayPanel", first: true, predicate: ["op"], descendants: true }], ngImport: i0, template: "<p-toolbar styleClass=\"region-toolbar\">\n <div class=\"p-toolbar-group-start align-items-center flex-wrap gap-1\">\n <p-button\n icon=\"pi pi-tag\"\n size=\"small\"\n [text]=\"!showShapeLabel\"\n pTooltip=\"Show region labels.\"\n tooltipPosition=\"bottom\"\n (onClick)=\"showShapeLabel = !showShapeLabel; changeShowShapeLabel(showShapeLabel)\"\n >\n </p-button>\n <p-button\n icon=\"pi pi-stop-circle\"\n size=\"small\"\n [text]=\"!regionsFilled\"\n pTooltip=\"Fill regions in their colour (an atlas's look); labels sit inside.\"\n tooltipPosition=\"bottom\"\n ariaLabel=\"Fill regions\"\n (onClick)=\"toggleFill()\"\n >\n </p-button>\n <p-button\n *ngIf=\"volumeView\"\n icon=\"pi pi-eye\"\n size=\"small\"\n [text]=\"!regionsThroughVolume\"\n [attr.aria-pressed]=\"regionsThroughVolume\"\n pTooltip=\"See the regions through the volume. Off, the volume hides the parts of them behind what it shows; on, none (layers traced inside bright structures, such as an OCT's).\"\n tooltipPosition=\"bottom\"\n ariaLabel=\"Regions through volume\"\n (onClick)=\"toggleThroughVolume()\"\n >\n </p-button>\n <span class=\"toolbar-separator\"></span>\n <!-- New regions are now created from the diagram toolbar\n (rectangle, polyline, freeform, wand). -->\n <p-button\n icon=\"pi pi-upload\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Upload and display regions from geojson file.\"\n tooltipPosition=\"bottom\"\n (click)=\"roiFileInput.click()\"\n >\n </p-button>\n <input\n #roiFileInput\n type=\"file\"\n accept=\".geojson,.json\"\n hidden\n (change)=\"importRois($event); roiFileInput.value = ''\"\n />\n <p-button\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Download regions as geojson file.\"\n tooltipPosition=\"bottom\"\n icon=\"pi pi-download\"\n (click)=\"exportRois()\"\n >\n </p-button>\n <p-button\n icon=\"pi pi-save\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Save regions to server.\"\n tooltipPosition=\"bottom\"\n (click)=\"persistRegions()\"\n [disabled]=\"(!regions || !regions.length) && !hasRegionSource\"\n >\n </p-button>\n <p-button\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Download mask of the regions as PNG\"\n tooltipPosition=\"bottom\"\n (click)=\"openSaveMaskDialog()\"\n [disabled]=\"!regions || !regions.length\"\n >\n <!-- References the file so Inkscape edits to mask.svg show up directly\n (the lib's assets/** are served at assets/plotting/). The\n toolbar-icon-dark filter recolours the black SVG to the theme blue,\n same as region-editor.svg and the other SVG toolbar icons. -->\n <img\n src=\"assets/plotting/mask.svg\"\n class=\"toolbar-icon-dark\"\n style=\"width: 1rem; height: 1rem; vertical-align: middle\"\n alt=\"Download mask\"\n />\n </p-button>\n <p-button\n label=\"x512\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Round all rectangle regions\n to width and height that are multiple of 512\"\n tooltipPosition=\"bottom\"\n (click)=\"roundRectangleLengths()\"\n >\n </p-button>\n <p-button\n icon=\"pi pi-check-square\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Select all regions\"\n tooltipPosition=\"bottom\"\n (click)=\"selectAllRegions()\"\n [disabled]=\"!regions || !regions.length\"\n >\n </p-button>\n <p-button\n icon=\"pi pi-tags\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Edit class on selected rows\"\n tooltipPosition=\"bottom\"\n (click)=\"!selectedRegions || !selectedRegions.length ? op.hide() : op.toggle($event)\"\n [disabled]=\"!selectedRegions || !selectedRegions.length\"\n >\n </p-button>\n <p-button\n icon=\"pi pi-palette\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Edit colour of selected regions\"\n tooltipPosition=\"bottom\"\n (click)=\"openColorDialog()\"\n [disabled]=\"!selectedRegions || !selectedRegions.length\"\n >\n </p-button>\n <p-overlayPanel #op (onHide)=\"bulkClass = ''; newBulkClass = ''\">\n <div class=\"flex flex-column gap-2\" style=\"width: 18rem\">\n <span class=\"font-medium text-900\"\n >Set class on {{ selectedRegions?.length || 0 }} selected region(s)</span\n >\n <!-- Selecting a class applies it immediately (choosing an option closes the\n overlay, so there's no separate Apply button). -->\n <p-dropdown\n styleClass=\"w-full\"\n appendTo=\"body\"\n [options]=\"presetSet.classes\"\n optionLabel=\"name\"\n optionValue=\"name\"\n [(ngModel)]=\"bulkClass\"\n placeholder=\"Choose a class\u2026\"\n [filter]=\"presetSet.classes.length > 8\"\n filterBy=\"name\"\n (onChange)=\"applyBulkClass()\"\n >\n <ng-template let-c pTemplate=\"item\">\n <span class=\"dd-value\"\n ><span class=\"chip-swatch\" [style.background]=\"c.color\"></span>{{ c.name }}</span\n >\n </ng-template>\n </p-dropdown>\n <div class=\"p-inputgroup\">\n <input\n type=\"text\"\n pInputText\n placeholder=\"\u2026or a new class\"\n [(ngModel)]=\"newBulkClass\"\n (keydown.enter)=\"addAndApplyBulkClass()\"\n />\n <button\n pButton\n type=\"button\"\n icon=\"pi pi-plus\"\n pTooltip=\"Add class &amp; set it\"\n tooltipPosition=\"bottom\"\n [disabled]=\"!newBulkClass.trim()\"\n (click)=\"addAndApplyBulkClass()\"\n ></button>\n </div>\n </div>\n </p-overlayPanel>\n <p-button\n icon=\"pi pi-trash\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n (click)=\"deleteSelectedRegions()\"\n pTooltip=\"Delete selected regions\"\n tooltipPosition=\"bottom\"\n [disabled]=\"!selectedRegions || !selectedRegions.length\"\n ></p-button>\n <p-button\n icon=\"pi pi-times-circle\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n (click)=\"clearAllRegions()\"\n pTooltip=\"Delete all regions\"\n tooltipPosition=\"bottom\"\n [disabled]=\"(!regions || !regions.length) && !hasRegionSource\"\n ></p-button>\n <p-button\n icon=\"pi pi-question\"\n size=\"small\"\n [text]=\"true\"\n (click)=\"showHelp()\"\n pTooltip=\"Help\"\n tooltipPosition=\"bottom\"\n ></p-button>\n </div>\n</p-toolbar>\n\n<region-classes-panel\n [classes]=\"displayClasses\"\n [counts]=\"classCounts\"\n [matchMode]=\"presetSet.matchMode\"\n [activeClass]=\"activeClass\"\n [picked]=\"pickedClass\"\n [marked]=\"markedClasses\"\n [selectedCount]=\"selectedRegions?.length || 0\"\n [defaultClassName]=\"defaultClassName\"\n (pick)=\"selectActiveClass($event)\"\n (recolor)=\"setClassColor($event.name, $event.color)\"\n (remove)=\"deleteClass($event)\"\n (manage)=\"openManageDialog()\"\n [volume]=\"volumeView\"\n [shown3d]=\"shown3dNames\"\n (toggle3d)=\"toggleClass3d($event)\"\n [hidden]=\"hiddenClasses\"\n (toggleHidden)=\"setClassesHidden([$event], !hiddenClasses.has($event))\"\n [treeView]=\"classHierarchy ? classTreeView : null\"\n (treeViewChange)=\"classTreeView = $event\"\n>\n <!-- A region set with a class hierarchy (an atlas ontology) can show its classes as a tree. -->\n <region-class-tree\n *ngIf=\"classHierarchy as hierarchy\"\n [nodes]=\"hierarchy\"\n [counts]=\"treeCounts\"\n [colorOf]=\"treeColor\"\n [picked]=\"pickedClass\"\n [marked]=\"markedClasses\"\n [volume]=\"volumeView\"\n [shown3d]=\"shown3dNames\"\n (pick)=\"pickClasses($event)\"\n (recolor)=\"setClassColor($event.name, $event.color)\"\n (toggle3d)=\"toggleClass3d($event.name, $event.classes)\"\n [hidden]=\"hiddenClasses\"\n (toggleHidden)=\"setClassesHidden($event.classes, $event.hidden)\"\n >\n </region-class-tree>\n</region-classes-panel>\n\n<region-table\n [page]=\"pagedRegions\"\n [total]=\"tableTotal\"\n [first]=\"paginatorFirst\"\n [rows]=\"paginatorRows\"\n [rowsPerPageOptions]=\"rowsPerPageOptions\"\n [metaKey]=\"metaKey\"\n [selection]=\"selectedRegions\"\n (selectionChange)=\"selectedRegions = $event\"\n (rowSelect)=\"onSelectionChanged()\"\n [presetSet]=\"tablePresetSet\"\n [defaultColor]=\"shapeColor\"\n [mpp]=\"mpp\"\n [volumes]=\"tableVolumes\"\n [labelDrafts]=\"editingLabelRegions\"\n (labelEditStart)=\"startEditLabel($event)\"\n (labelDraftChange)=\"setLabelDraft($event.region, $event.value)\"\n (labelEditStop)=\"stopEditLabel($event.region, $event.commit)\"\n (classPick)=\"applyPresetToRegion($event.region, $event.name)\"\n (deleteRow)=\"deleteRegion($event)\"\n (pageChange)=\"onPageChange($event)\"\n>\n</region-table>\n\n<!-- Live-edit mode: no Save/Cancel \u2014 every edit commits immediately to the\n active image's region cache. -->\n\n<p-confirmDialog\n #cd\n [style]=\"{ width: '24rem', height: '14rem' }\"\n key=\"positionDialog\"\n [baseZIndex]=\"10000\"\n rejectButtonStyleClass=\"p-button-outlined\"\n>\n <ng-template pTemplate=\"footer\">\n <button type=\"button\" pButton icon=\"pi pi-check\" label=\"Yes\" (click)=\"cd.accept()\"></button>\n <button\n type=\"button\"\n class=\"p-button-outlined\"\n pButton\n icon=\"pi pi-times\"\n label=\"No\"\n (click)=\"cd.reject()\"\n ></button>\n </ng-template>\n</p-confirmDialog>\n\n<region-save-regions-dialog\n [(visible)]=\"showSaveAsDialog\"\n [filename]=\"saveAsFilename\"\n (filenameChange)=\"saveAsFilename = $event; checkSaveAsFileExists()\"\n [fileExists]=\"saveAsFileExists\"\n [busy]=\"saveAsBusy\"\n (confirm)=\"confirmSaveAs()\"\n (cancelSave)=\"cancelSaveAs()\"\n>\n</region-save-regions-dialog>\n\n<region-save-regions-dialog\n header=\"Export Regions\"\n confirmLabel=\"Export\"\n confirmIcon=\"pi pi-download\"\n inputId=\"export-filename\"\n [(visible)]=\"showExportDialog\"\n [(filename)]=\"exportFilename\"\n (confirm)=\"confirmExport()\"\n>\n</region-save-regions-dialog>\n\n<region-save-mask-dialog\n [(visible)]=\"showSaveMaskDialog\"\n [(filename)]=\"saveMaskFilename\"\n [(mode)]=\"maskMode\"\n [busy]=\"maskBusy\"\n [progress]=\"maskProgress\"\n [encoding]=\"maskEncoding\"\n (confirm)=\"confirmSaveMask()\"\n (cancelExport)=\"cancelSaveMask()\"\n>\n</region-save-mask-dialog>\n\n<region-color-dialog\n [(visible)]=\"showColorDialog\"\n [edits]=\"classColorEdits\"\n [selectedCount]=\"selectedRegions?.length || 0\"\n (apply)=\"applyColorToSelected($event)\"\n>\n</region-color-dialog>\n\n<!-- Manage annotation classes (jit-ui#70): edits a working copy, committed on Apply. -->\n<region-manage-classes-dialog\n [(visible)]=\"showManageDialog\"\n [(draft)]=\"presetDraft\"\n (apply)=\"applyManageDialog(true)\"\n (resetDefaults)=\"resetPresetsToDefaults()\"\n (importFile)=\"importPresets($event)\"\n (export)=\"exportPresets()\"\n>\n</region-manage-classes-dialog>\n\n<region-editor-help [(visible)]=\"displayHelpDialog\"></region-editor-help>\n", styles: ["@charset \"UTF-8\";.chip-swatch{display:inline-block;width:.75rem;height:.75rem;border-radius:50%;border:1px solid rgba(0,0,0,.2);flex:0 0 auto}.dd-value{display:inline-flex;align-items:center;gap:.4rem;min-width:0}.dd-value .class-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}:host{display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden}::ng-deep region-editor .p-toolbar.region-toolbar{padding:.25rem .5rem;border-radius:0;border-left:none;border-right:none;border-top:none}::ng-deep region-editor .p-toolbar.region-toolbar .p-button.p-button-icon-only{width:auto;padding:.25rem .5rem}::ng-deep region-editor .p-toolbar.region-toolbar .p-button{padding:.25rem .5rem}.toolbar-icon-dark{filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}.toolbar-separator{border-left:1px solid var(--surface-border);height:1.5rem;margin:0 .25rem;align-self:center}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i2.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { 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: "ngmodule", type: SharedModule }, { kind: "directive", type: i1$2.PrimeTemplate, selector: "[pTemplate]", inputs: ["type", "pTemplate"] }, { kind: "ngmodule", type: ButtonModule }, { kind: "directive", type: i3.ButtonDirective, selector: "[pButton]", inputs: ["iconPos", "loadingIcon", "label", "icon", "loading", "severity", "raised", "rounded", "text", "outlined", "size", "plain"] }, { kind: "component", type: i3.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: "ngmodule", type: ConfirmDialogModule }, { kind: "component", type: i7$2.ConfirmDialog, selector: "p-confirmDialog", inputs: ["header", "icon", "message", "style", "styleClass", "maskStyleClass", "acceptIcon", "acceptLabel", "closeAriaLabel", "acceptAriaLabel", "acceptVisible", "rejectIcon", "rejectLabel", "rejectAriaLabel", "rejectVisible", "acceptButtonStyleClass", "rejectButtonStyleClass", "closeOnEscape", "dismissableMask", "blockScroll", "rtl", "closable", "appendTo", "key", "autoZIndex", "baseZIndex", "transitionOptions", "focusTrap", "defaultFocus", "breakpoints", "visible", "position"], outputs: ["onHide"] }, { kind: "ngmodule", type: DropdownModule }, { kind: "component", type: i4.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: "ngmodule", type: InputTextModule }, { kind: "directive", type: i6.InputText, selector: "[pInputText]", inputs: ["variant"] }, { kind: "ngmodule", type: OverlayPanelModule }, { kind: "component", type: i10.OverlayPanel, selector: "p-overlayPanel", inputs: ["ariaLabel", "ariaLabelledBy", "dismissable", "showCloseIcon", "style", "styleClass", "appendTo", "autoZIndex", "ariaCloseLabel", "baseZIndex", "focusOnShow", "showTransitionOptions", "hideTransitionOptions"], outputs: ["onShow", "onHide"] }, { kind: "ngmodule", type: ToolbarModule }, { kind: "component", type: i4$2.Toolbar, selector: "p-toolbar", inputs: ["style", "styleClass", "ariaLabelledBy"] }, { kind: "ngmodule", type: TooltipModule }, { kind: "directive", type: i5$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: ClassesPanelComponent, selector: "region-classes-panel", inputs: ["classes", "counts", "matchMode", "activeClass", "picked", "marked", "selectedCount", "defaultClassName", "volume", "shown3d", "hidden", "treeView"], outputs: ["toggleHidden", "toggle3d", "treeViewChange", "pick", "recolor", "remove", "manage"] }, { kind: "component", type: RegionTableComponent, selector: "region-table", inputs: ["page", "total", "first", "rows", "rowsPerPageOptions", "metaKey", "selection", "presetSet", "defaultColor", "mpp", "volumes", "labelDrafts"], outputs: ["selectionChange", "rowSelect", "labelEditStart", "labelDraftChange", "labelEditStop", "classPick", "deleteRow", "pageChange"] }, { kind: "component", type: ClassTreeComponent, selector: "region-class-tree", inputs: ["nodes", "counts", "colorOf", "picked", "marked", "volume", "shown3d", "hidden"], outputs: ["pick", "recolor", "toggle3d", "toggleHidden"] }, { kind: "component", type: SaveRegionsDialogComponent, selector: "region-save-regions-dialog", inputs: ["header", "confirmLabel", "confirmIcon", "inputId", "visible", "filename", "fileExists", "busy"], outputs: ["visibleChange", "filenameChange", "confirm", "cancelSave"] }, { kind: "component", type: SaveMaskDialogComponent, selector: "region-save-mask-dialog", inputs: ["visible", "filename", "mode", "busy", "progress", "encoding"], outputs: ["visibleChange", "filenameChange", "modeChange", "confirm", "cancelExport"] }, { kind: "component", type: RegionColorDialogComponent, selector: "region-color-dialog", inputs: ["visible", "selectedCount", "edits"], outputs: ["visibleChange", "apply"] }, { kind: "component", type: ManageClassesDialogComponent, selector: "region-manage-classes-dialog", inputs: ["visible", "draft"], outputs: ["visibleChange", "draftChange", "apply", "resetDefaults", "importFile", "export"] }, { kind: "component", type: RegionEditorHelpComponent, selector: "region-editor-help", inputs: ["visible"], outputs: ["visibleChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
17157
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.9", type: RegionEditorComponent, isStandalone: true, selector: "region-editor", inputs: { active: "active" }, providers: [RegionPersistenceService], viewQueries: [{ propertyName: "overlayPanel", first: true, predicate: ["op"], descendants: true }], ngImport: i0, template: "<p-toolbar styleClass=\"region-toolbar\">\n <div class=\"p-toolbar-group-start align-items-center flex-wrap gap-1\">\n <p-button\n icon=\"pi pi-tag\"\n size=\"small\"\n [text]=\"!showShapeLabel\"\n pTooltip=\"Show region labels.\"\n tooltipPosition=\"bottom\"\n (onClick)=\"showShapeLabel = !showShapeLabel; changeShowShapeLabel(showShapeLabel)\"\n >\n </p-button>\n <p-button\n icon=\"pi pi-stop-circle\"\n size=\"small\"\n [text]=\"!regionsFilled\"\n pTooltip=\"Fill regions in their colour (an atlas's look); labels sit inside.\"\n tooltipPosition=\"bottom\"\n ariaLabel=\"Fill regions\"\n (onClick)=\"toggleFill()\"\n >\n </p-button>\n <p-button\n *ngIf=\"volumeView\"\n icon=\"pi pi-eye\"\n size=\"small\"\n [text]=\"!regionsThroughVolume\"\n [attr.aria-pressed]=\"regionsThroughVolume\"\n pTooltip=\"See the regions through the volume. Off, the volume hides the parts of them behind what it shows; on, none (layers traced inside bright structures, such as an OCT's).\"\n tooltipPosition=\"bottom\"\n ariaLabel=\"Regions through volume\"\n (onClick)=\"toggleThroughVolume()\"\n >\n </p-button>\n <span class=\"toolbar-separator\"></span>\n <!-- New regions are now created from the diagram toolbar\n (rectangle, polyline, freeform, wand). -->\n <p-button\n icon=\"pi pi-upload\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Import regions: a GeoJSON file, or a label volume (.nii, .nii.gz, .nrrd) chosen with its ontology JSON for the class tree.\"\n tooltipPosition=\"bottom\"\n (click)=\"roiFileInput.click()\"\n >\n </p-button>\n <input\n #roiFileInput\n type=\"file\"\n accept=\".geojson,.json,.nii,.gz,.nrrd\"\n multiple\n hidden\n (change)=\"importRois($event); roiFileInput.value = ''\"\n />\n <p-button\n icon=\"pi pi-folder-open\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Import a folder of per-slice GeoJSON files (one per slice, in name order, with its regionset.json class tree).\"\n tooltipPosition=\"bottom\"\n ariaLabel=\"Import a folder of regions\"\n (click)=\"roiFolderInput.click()\"\n >\n </p-button>\n <input\n #roiFolderInput\n type=\"file\"\n webkitdirectory\n multiple\n hidden\n (change)=\"importRois($event); roiFolderInput.value = ''\"\n />\n <p-button\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Download regions as geojson file.\"\n tooltipPosition=\"bottom\"\n icon=\"pi pi-download\"\n (click)=\"exportRois()\"\n >\n </p-button>\n <p-button\n icon=\"pi pi-save\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Save regions to server.\"\n tooltipPosition=\"bottom\"\n (click)=\"persistRegions()\"\n [disabled]=\"(!regions || !regions.length) && !hasRegionSource\"\n >\n </p-button>\n <p-button\n *ngIf=\"canImportFromServer\"\n icon=\"pi pi-cloud-download\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Import regions from server: a GeoJSON file, a folder of per-slice GeoJSON files or a label volume (.nii, .nii.gz, .nrrd).\"\n tooltipPosition=\"bottom\"\n ariaLabel=\"Import regions from server\"\n [loading]=\"importing\"\n (click)=\"importFromServer()\"\n >\n </p-button>\n <p-button\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Download mask of the regions as PNG\"\n tooltipPosition=\"bottom\"\n (click)=\"openSaveMaskDialog()\"\n [disabled]=\"!regions || !regions.length\"\n >\n <!-- References the file so Inkscape edits to mask.svg show up directly\n (the lib's assets/** are served at assets/plotting/). The\n toolbar-icon-dark filter recolours the black SVG to the theme blue,\n same as region-editor.svg and the other SVG toolbar icons. -->\n <img\n src=\"assets/plotting/mask.svg\"\n class=\"toolbar-icon-dark\"\n style=\"width: 1rem; height: 1rem; vertical-align: middle\"\n alt=\"Download mask\"\n />\n </p-button>\n <p-button\n label=\"x512\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Round all rectangle regions\n to width and height that are multiple of 512\"\n tooltipPosition=\"bottom\"\n (click)=\"roundRectangleLengths()\"\n >\n </p-button>\n <p-button\n icon=\"pi pi-check-square\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Select all regions\"\n tooltipPosition=\"bottom\"\n (click)=\"selectAllRegions()\"\n [disabled]=\"!regions || !regions.length\"\n >\n </p-button>\n <p-button\n icon=\"pi pi-tags\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Edit class on selected rows\"\n tooltipPosition=\"bottom\"\n (click)=\"!selectedRegions || !selectedRegions.length ? op.hide() : op.toggle($event)\"\n [disabled]=\"!selectedRegions || !selectedRegions.length\"\n >\n </p-button>\n <p-button\n icon=\"pi pi-palette\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Edit colour of selected regions\"\n tooltipPosition=\"bottom\"\n (click)=\"openColorDialog()\"\n [disabled]=\"!selectedRegions || !selectedRegions.length\"\n >\n </p-button>\n <p-overlayPanel #op (onHide)=\"bulkClass = ''; newBulkClass = ''\">\n <div class=\"flex flex-column gap-2\" style=\"width: 18rem\">\n <span class=\"font-medium text-900\"\n >Set class on {{ selectedRegions?.length || 0 }} selected region(s)</span\n >\n <!-- Selecting a class applies it immediately (choosing an option closes the\n overlay, so there's no separate Apply button). -->\n <p-dropdown\n styleClass=\"w-full\"\n appendTo=\"body\"\n [options]=\"presetSet.classes\"\n optionLabel=\"name\"\n optionValue=\"name\"\n [(ngModel)]=\"bulkClass\"\n placeholder=\"Choose a class\u2026\"\n [filter]=\"presetSet.classes.length > 8\"\n filterBy=\"name\"\n (onChange)=\"applyBulkClass()\"\n >\n <ng-template let-c pTemplate=\"item\">\n <span class=\"dd-value\"\n ><span class=\"chip-swatch\" [style.background]=\"c.color\"></span>{{ c.name }}</span\n >\n </ng-template>\n </p-dropdown>\n <div class=\"p-inputgroup\">\n <input\n type=\"text\"\n pInputText\n placeholder=\"\u2026or a new class\"\n [(ngModel)]=\"newBulkClass\"\n (keydown.enter)=\"addAndApplyBulkClass()\"\n />\n <button\n pButton\n type=\"button\"\n icon=\"pi pi-plus\"\n pTooltip=\"Add class &amp; set it\"\n tooltipPosition=\"bottom\"\n [disabled]=\"!newBulkClass.trim()\"\n (click)=\"addAndApplyBulkClass()\"\n ></button>\n </div>\n </div>\n </p-overlayPanel>\n <p-button\n icon=\"pi pi-trash\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n (click)=\"deleteSelectedRegions()\"\n pTooltip=\"Delete selected regions\"\n tooltipPosition=\"bottom\"\n [disabled]=\"!selectedRegions || !selectedRegions.length\"\n ></p-button>\n <p-button\n icon=\"pi pi-times-circle\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n (click)=\"clearAllRegions()\"\n pTooltip=\"Delete all regions\"\n tooltipPosition=\"bottom\"\n [disabled]=\"(!regions || !regions.length) && !hasRegionSource\"\n ></p-button>\n <p-button\n icon=\"pi pi-question\"\n size=\"small\"\n [text]=\"true\"\n (click)=\"showHelp()\"\n pTooltip=\"Help\"\n tooltipPosition=\"bottom\"\n ></p-button>\n </div>\n</p-toolbar>\n\n<region-classes-panel\n [classes]=\"displayClasses\"\n [counts]=\"classCounts\"\n [matchMode]=\"presetSet.matchMode\"\n [activeClass]=\"activeClass\"\n [picked]=\"pickedClass\"\n [marked]=\"markedClasses\"\n [selectedCount]=\"selectedRegions?.length || 0\"\n [defaultClassName]=\"defaultClassName\"\n (pick)=\"selectActiveClass($event)\"\n (recolor)=\"setClassColor($event.name, $event.color)\"\n (remove)=\"deleteClass($event)\"\n (manage)=\"openManageDialog()\"\n [volume]=\"volumeView\"\n [shown3d]=\"shown3dNames\"\n (toggle3d)=\"toggleClass3d($event)\"\n [hidden]=\"hiddenClasses\"\n (toggleHidden)=\"setClassesHidden([$event], !hiddenClasses.has($event))\"\n [treeView]=\"classHierarchy ? classTreeView : null\"\n (treeViewChange)=\"classTreeView = $event\"\n>\n <!-- A region set with a class hierarchy (an atlas ontology) can show its classes as a tree. -->\n <region-class-tree\n *ngIf=\"classHierarchy as hierarchy\"\n [nodes]=\"hierarchy\"\n [counts]=\"treeCounts\"\n [colorOf]=\"treeColor\"\n [picked]=\"pickedClass\"\n [marked]=\"markedClasses\"\n [volume]=\"volumeView\"\n [shown3d]=\"shown3dNames\"\n (pick)=\"pickClasses($event)\"\n (recolor)=\"setClassColor($event.name, $event.color)\"\n (toggle3d)=\"toggleClass3d($event.name, $event.classes)\"\n [hidden]=\"hiddenClasses\"\n (toggleHidden)=\"setClassesHidden($event.classes, $event.hidden)\"\n >\n </region-class-tree>\n</region-classes-panel>\n\n<region-table\n [page]=\"pagedRegions\"\n [total]=\"tableTotal\"\n [first]=\"paginatorFirst\"\n [rows]=\"paginatorRows\"\n [rowsPerPageOptions]=\"rowsPerPageOptions\"\n [metaKey]=\"metaKey\"\n [selection]=\"selectedRegions\"\n (selectionChange)=\"selectedRegions = $event\"\n (rowSelect)=\"onSelectionChanged()\"\n [presetSet]=\"tablePresetSet\"\n [defaultColor]=\"shapeColor\"\n [mpp]=\"mpp\"\n [volumes]=\"tableVolumes\"\n [labelDrafts]=\"editingLabelRegions\"\n (labelEditStart)=\"startEditLabel($event)\"\n (labelDraftChange)=\"setLabelDraft($event.region, $event.value)\"\n (labelEditStop)=\"stopEditLabel($event.region, $event.commit)\"\n (classPick)=\"applyPresetToRegion($event.region, $event.name)\"\n (deleteRow)=\"deleteRegion($event)\"\n (pageChange)=\"onPageChange($event)\"\n>\n</region-table>\n\n<!-- Live-edit mode: no Save/Cancel \u2014 every edit commits immediately to the\n active image's region cache. -->\n\n<p-confirmDialog\n #cd\n [style]=\"{ width: '24rem', height: '14rem' }\"\n key=\"positionDialog\"\n [baseZIndex]=\"10000\"\n rejectButtonStyleClass=\"p-button-outlined\"\n>\n <ng-template pTemplate=\"footer\">\n <button type=\"button\" pButton icon=\"pi pi-check\" label=\"Yes\" (click)=\"cd.accept()\"></button>\n <button\n type=\"button\"\n class=\"p-button-outlined\"\n pButton\n icon=\"pi pi-times\"\n label=\"No\"\n (click)=\"cd.reject()\"\n ></button>\n </ng-template>\n</p-confirmDialog>\n\n<region-save-regions-dialog\n [(visible)]=\"showSaveAsDialog\"\n [filename]=\"saveAsFilename\"\n (filenameChange)=\"saveAsFilename = $event; checkSaveAsFileExists()\"\n [fileExists]=\"saveAsFileExists\"\n [busy]=\"saveAsBusy\"\n (confirm)=\"confirmSaveAs()\"\n (cancelSave)=\"cancelSaveAs()\"\n>\n</region-save-regions-dialog>\n\n<region-save-regions-dialog\n header=\"Export Regions\"\n confirmLabel=\"Export\"\n confirmIcon=\"pi pi-download\"\n inputId=\"export-filename\"\n [(visible)]=\"showExportDialog\"\n [(filename)]=\"exportFilename\"\n (confirm)=\"confirmExport()\"\n>\n</region-save-regions-dialog>\n\n<region-save-mask-dialog\n [(visible)]=\"showSaveMaskDialog\"\n [(filename)]=\"saveMaskFilename\"\n [(mode)]=\"maskMode\"\n [busy]=\"maskBusy\"\n [progress]=\"maskProgress\"\n [encoding]=\"maskEncoding\"\n (confirm)=\"confirmSaveMask()\"\n (cancelExport)=\"cancelSaveMask()\"\n>\n</region-save-mask-dialog>\n\n<region-color-dialog\n [(visible)]=\"showColorDialog\"\n [edits]=\"classColorEdits\"\n [selectedCount]=\"selectedRegions?.length || 0\"\n (apply)=\"applyColorToSelected($event)\"\n>\n</region-color-dialog>\n\n<!-- Manage annotation classes (jit-ui#70): edits a working copy, committed on Apply. -->\n<region-manage-classes-dialog\n [(visible)]=\"showManageDialog\"\n [(draft)]=\"presetDraft\"\n (apply)=\"applyManageDialog(true)\"\n (resetDefaults)=\"resetPresetsToDefaults()\"\n (importFile)=\"importPresets($event)\"\n (export)=\"exportPresets()\"\n>\n</region-manage-classes-dialog>\n\n<region-editor-help [(visible)]=\"displayHelpDialog\"></region-editor-help>\n", styles: ["@charset \"UTF-8\";.chip-swatch{display:inline-block;width:.75rem;height:.75rem;border-radius:50%;border:1px solid rgba(0,0,0,.2);flex:0 0 auto}.dd-value{display:inline-flex;align-items:center;gap:.4rem;min-width:0}.dd-value .class-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}:host{display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden}::ng-deep region-editor .p-toolbar.region-toolbar{padding:.25rem .5rem;border-radius:0;border-left:none;border-right:none;border-top:none}::ng-deep region-editor .p-toolbar.region-toolbar .p-button.p-button-icon-only{width:auto;padding:.25rem .5rem}::ng-deep region-editor .p-toolbar.region-toolbar .p-button{padding:.25rem .5rem}.toolbar-icon-dark{filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}.toolbar-separator{border-left:1px solid var(--surface-border);height:1.5rem;margin:0 .25rem;align-self:center}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i2.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { 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: "ngmodule", type: SharedModule }, { kind: "directive", type: i1$2.PrimeTemplate, selector: "[pTemplate]", inputs: ["type", "pTemplate"] }, { kind: "ngmodule", type: ButtonModule }, { kind: "directive", type: i3.ButtonDirective, selector: "[pButton]", inputs: ["iconPos", "loadingIcon", "label", "icon", "loading", "severity", "raised", "rounded", "text", "outlined", "size", "plain"] }, { kind: "component", type: i3.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: "ngmodule", type: ConfirmDialogModule }, { kind: "component", type: i7$2.ConfirmDialog, selector: "p-confirmDialog", inputs: ["header", "icon", "message", "style", "styleClass", "maskStyleClass", "acceptIcon", "acceptLabel", "closeAriaLabel", "acceptAriaLabel", "acceptVisible", "rejectIcon", "rejectLabel", "rejectAriaLabel", "rejectVisible", "acceptButtonStyleClass", "rejectButtonStyleClass", "closeOnEscape", "dismissableMask", "blockScroll", "rtl", "closable", "appendTo", "key", "autoZIndex", "baseZIndex", "transitionOptions", "focusTrap", "defaultFocus", "breakpoints", "visible", "position"], outputs: ["onHide"] }, { kind: "ngmodule", type: DropdownModule }, { kind: "component", type: i4.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: "ngmodule", type: InputTextModule }, { kind: "directive", type: i6.InputText, selector: "[pInputText]", inputs: ["variant"] }, { kind: "ngmodule", type: OverlayPanelModule }, { kind: "component", type: i10.OverlayPanel, selector: "p-overlayPanel", inputs: ["ariaLabel", "ariaLabelledBy", "dismissable", "showCloseIcon", "style", "styleClass", "appendTo", "autoZIndex", "ariaCloseLabel", "baseZIndex", "focusOnShow", "showTransitionOptions", "hideTransitionOptions"], outputs: ["onShow", "onHide"] }, { kind: "ngmodule", type: ToolbarModule }, { kind: "component", type: i4$2.Toolbar, selector: "p-toolbar", inputs: ["style", "styleClass", "ariaLabelledBy"] }, { kind: "ngmodule", type: TooltipModule }, { kind: "directive", type: i5$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: ClassesPanelComponent, selector: "region-classes-panel", inputs: ["classes", "counts", "matchMode", "activeClass", "picked", "marked", "selectedCount", "defaultClassName", "volume", "shown3d", "hidden", "treeView"], outputs: ["toggleHidden", "toggle3d", "treeViewChange", "pick", "recolor", "remove", "manage"] }, { kind: "component", type: RegionTableComponent, selector: "region-table", inputs: ["page", "total", "first", "rows", "rowsPerPageOptions", "metaKey", "selection", "presetSet", "defaultColor", "mpp", "volumes", "labelDrafts"], outputs: ["selectionChange", "rowSelect", "labelEditStart", "labelDraftChange", "labelEditStop", "classPick", "deleteRow", "pageChange"] }, { kind: "component", type: ClassTreeComponent, selector: "region-class-tree", inputs: ["nodes", "counts", "colorOf", "picked", "marked", "volume", "shown3d", "hidden"], outputs: ["pick", "recolor", "toggle3d", "toggleHidden"] }, { kind: "component", type: SaveRegionsDialogComponent, selector: "region-save-regions-dialog", inputs: ["header", "confirmLabel", "confirmIcon", "inputId", "visible", "filename", "fileExists", "busy"], outputs: ["visibleChange", "filenameChange", "confirm", "cancelSave"] }, { kind: "component", type: SaveMaskDialogComponent, selector: "region-save-mask-dialog", inputs: ["visible", "filename", "mode", "busy", "progress", "encoding"], outputs: ["visibleChange", "filenameChange", "modeChange", "confirm", "cancelExport"] }, { kind: "component", type: RegionColorDialogComponent, selector: "region-color-dialog", inputs: ["visible", "selectedCount", "edits"], outputs: ["visibleChange", "apply"] }, { kind: "component", type: ManageClassesDialogComponent, selector: "region-manage-classes-dialog", inputs: ["visible", "draft"], outputs: ["visibleChange", "draftChange", "apply", "resetDefaults", "importFile", "export"] }, { kind: "component", type: RegionEditorHelpComponent, selector: "region-editor-help", inputs: ["visible"], outputs: ["visibleChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
15703
17158
  }
15704
17159
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.9", ngImport: i0, type: RegionEditorComponent, decorators: [{
15705
17160
  type: Component,
@@ -15722,7 +17177,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.9", ngImpor
15722
17177
  RegionColorDialogComponent,
15723
17178
  ManageClassesDialogComponent,
15724
17179
  RegionEditorHelpComponent,
15725
- ], changeDetection: ChangeDetectionStrategy.OnPush, providers: [RegionPersistenceService], template: "<p-toolbar styleClass=\"region-toolbar\">\n <div class=\"p-toolbar-group-start align-items-center flex-wrap gap-1\">\n <p-button\n icon=\"pi pi-tag\"\n size=\"small\"\n [text]=\"!showShapeLabel\"\n pTooltip=\"Show region labels.\"\n tooltipPosition=\"bottom\"\n (onClick)=\"showShapeLabel = !showShapeLabel; changeShowShapeLabel(showShapeLabel)\"\n >\n </p-button>\n <p-button\n icon=\"pi pi-stop-circle\"\n size=\"small\"\n [text]=\"!regionsFilled\"\n pTooltip=\"Fill regions in their colour (an atlas's look); labels sit inside.\"\n tooltipPosition=\"bottom\"\n ariaLabel=\"Fill regions\"\n (onClick)=\"toggleFill()\"\n >\n </p-button>\n <p-button\n *ngIf=\"volumeView\"\n icon=\"pi pi-eye\"\n size=\"small\"\n [text]=\"!regionsThroughVolume\"\n [attr.aria-pressed]=\"regionsThroughVolume\"\n pTooltip=\"See the regions through the volume. Off, the volume hides the parts of them behind what it shows; on, none (layers traced inside bright structures, such as an OCT's).\"\n tooltipPosition=\"bottom\"\n ariaLabel=\"Regions through volume\"\n (onClick)=\"toggleThroughVolume()\"\n >\n </p-button>\n <span class=\"toolbar-separator\"></span>\n <!-- New regions are now created from the diagram toolbar\n (rectangle, polyline, freeform, wand). -->\n <p-button\n icon=\"pi pi-upload\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Upload and display regions from geojson file.\"\n tooltipPosition=\"bottom\"\n (click)=\"roiFileInput.click()\"\n >\n </p-button>\n <input\n #roiFileInput\n type=\"file\"\n accept=\".geojson,.json\"\n hidden\n (change)=\"importRois($event); roiFileInput.value = ''\"\n />\n <p-button\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Download regions as geojson file.\"\n tooltipPosition=\"bottom\"\n icon=\"pi pi-download\"\n (click)=\"exportRois()\"\n >\n </p-button>\n <p-button\n icon=\"pi pi-save\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Save regions to server.\"\n tooltipPosition=\"bottom\"\n (click)=\"persistRegions()\"\n [disabled]=\"(!regions || !regions.length) && !hasRegionSource\"\n >\n </p-button>\n <p-button\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Download mask of the regions as PNG\"\n tooltipPosition=\"bottom\"\n (click)=\"openSaveMaskDialog()\"\n [disabled]=\"!regions || !regions.length\"\n >\n <!-- References the file so Inkscape edits to mask.svg show up directly\n (the lib's assets/** are served at assets/plotting/). The\n toolbar-icon-dark filter recolours the black SVG to the theme blue,\n same as region-editor.svg and the other SVG toolbar icons. -->\n <img\n src=\"assets/plotting/mask.svg\"\n class=\"toolbar-icon-dark\"\n style=\"width: 1rem; height: 1rem; vertical-align: middle\"\n alt=\"Download mask\"\n />\n </p-button>\n <p-button\n label=\"x512\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Round all rectangle regions\n to width and height that are multiple of 512\"\n tooltipPosition=\"bottom\"\n (click)=\"roundRectangleLengths()\"\n >\n </p-button>\n <p-button\n icon=\"pi pi-check-square\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Select all regions\"\n tooltipPosition=\"bottom\"\n (click)=\"selectAllRegions()\"\n [disabled]=\"!regions || !regions.length\"\n >\n </p-button>\n <p-button\n icon=\"pi pi-tags\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Edit class on selected rows\"\n tooltipPosition=\"bottom\"\n (click)=\"!selectedRegions || !selectedRegions.length ? op.hide() : op.toggle($event)\"\n [disabled]=\"!selectedRegions || !selectedRegions.length\"\n >\n </p-button>\n <p-button\n icon=\"pi pi-palette\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Edit colour of selected regions\"\n tooltipPosition=\"bottom\"\n (click)=\"openColorDialog()\"\n [disabled]=\"!selectedRegions || !selectedRegions.length\"\n >\n </p-button>\n <p-overlayPanel #op (onHide)=\"bulkClass = ''; newBulkClass = ''\">\n <div class=\"flex flex-column gap-2\" style=\"width: 18rem\">\n <span class=\"font-medium text-900\"\n >Set class on {{ selectedRegions?.length || 0 }} selected region(s)</span\n >\n <!-- Selecting a class applies it immediately (choosing an option closes the\n overlay, so there's no separate Apply button). -->\n <p-dropdown\n styleClass=\"w-full\"\n appendTo=\"body\"\n [options]=\"presetSet.classes\"\n optionLabel=\"name\"\n optionValue=\"name\"\n [(ngModel)]=\"bulkClass\"\n placeholder=\"Choose a class\u2026\"\n [filter]=\"presetSet.classes.length > 8\"\n filterBy=\"name\"\n (onChange)=\"applyBulkClass()\"\n >\n <ng-template let-c pTemplate=\"item\">\n <span class=\"dd-value\"\n ><span class=\"chip-swatch\" [style.background]=\"c.color\"></span>{{ c.name }}</span\n >\n </ng-template>\n </p-dropdown>\n <div class=\"p-inputgroup\">\n <input\n type=\"text\"\n pInputText\n placeholder=\"\u2026or a new class\"\n [(ngModel)]=\"newBulkClass\"\n (keydown.enter)=\"addAndApplyBulkClass()\"\n />\n <button\n pButton\n type=\"button\"\n icon=\"pi pi-plus\"\n pTooltip=\"Add class &amp; set it\"\n tooltipPosition=\"bottom\"\n [disabled]=\"!newBulkClass.trim()\"\n (click)=\"addAndApplyBulkClass()\"\n ></button>\n </div>\n </div>\n </p-overlayPanel>\n <p-button\n icon=\"pi pi-trash\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n (click)=\"deleteSelectedRegions()\"\n pTooltip=\"Delete selected regions\"\n tooltipPosition=\"bottom\"\n [disabled]=\"!selectedRegions || !selectedRegions.length\"\n ></p-button>\n <p-button\n icon=\"pi pi-times-circle\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n (click)=\"clearAllRegions()\"\n pTooltip=\"Delete all regions\"\n tooltipPosition=\"bottom\"\n [disabled]=\"(!regions || !regions.length) && !hasRegionSource\"\n ></p-button>\n <p-button\n icon=\"pi pi-question\"\n size=\"small\"\n [text]=\"true\"\n (click)=\"showHelp()\"\n pTooltip=\"Help\"\n tooltipPosition=\"bottom\"\n ></p-button>\n </div>\n</p-toolbar>\n\n<region-classes-panel\n [classes]=\"displayClasses\"\n [counts]=\"classCounts\"\n [matchMode]=\"presetSet.matchMode\"\n [activeClass]=\"activeClass\"\n [picked]=\"pickedClass\"\n [marked]=\"markedClasses\"\n [selectedCount]=\"selectedRegions?.length || 0\"\n [defaultClassName]=\"defaultClassName\"\n (pick)=\"selectActiveClass($event)\"\n (recolor)=\"setClassColor($event.name, $event.color)\"\n (remove)=\"deleteClass($event)\"\n (manage)=\"openManageDialog()\"\n [volume]=\"volumeView\"\n [shown3d]=\"shown3dNames\"\n (toggle3d)=\"toggleClass3d($event)\"\n [hidden]=\"hiddenClasses\"\n (toggleHidden)=\"setClassesHidden([$event], !hiddenClasses.has($event))\"\n [treeView]=\"classHierarchy ? classTreeView : null\"\n (treeViewChange)=\"classTreeView = $event\"\n>\n <!-- A region set with a class hierarchy (an atlas ontology) can show its classes as a tree. -->\n <region-class-tree\n *ngIf=\"classHierarchy as hierarchy\"\n [nodes]=\"hierarchy\"\n [counts]=\"treeCounts\"\n [colorOf]=\"treeColor\"\n [picked]=\"pickedClass\"\n [marked]=\"markedClasses\"\n [volume]=\"volumeView\"\n [shown3d]=\"shown3dNames\"\n (pick)=\"pickClasses($event)\"\n (recolor)=\"setClassColor($event.name, $event.color)\"\n (toggle3d)=\"toggleClass3d($event.name, $event.classes)\"\n [hidden]=\"hiddenClasses\"\n (toggleHidden)=\"setClassesHidden($event.classes, $event.hidden)\"\n >\n </region-class-tree>\n</region-classes-panel>\n\n<region-table\n [page]=\"pagedRegions\"\n [total]=\"tableTotal\"\n [first]=\"paginatorFirst\"\n [rows]=\"paginatorRows\"\n [rowsPerPageOptions]=\"rowsPerPageOptions\"\n [metaKey]=\"metaKey\"\n [selection]=\"selectedRegions\"\n (selectionChange)=\"selectedRegions = $event\"\n (rowSelect)=\"onSelectionChanged()\"\n [presetSet]=\"tablePresetSet\"\n [defaultColor]=\"shapeColor\"\n [mpp]=\"mpp\"\n [volumes]=\"tableVolumes\"\n [labelDrafts]=\"editingLabelRegions\"\n (labelEditStart)=\"startEditLabel($event)\"\n (labelDraftChange)=\"setLabelDraft($event.region, $event.value)\"\n (labelEditStop)=\"stopEditLabel($event.region, $event.commit)\"\n (classPick)=\"applyPresetToRegion($event.region, $event.name)\"\n (deleteRow)=\"deleteRegion($event)\"\n (pageChange)=\"onPageChange($event)\"\n>\n</region-table>\n\n<!-- Live-edit mode: no Save/Cancel \u2014 every edit commits immediately to the\n active image's region cache. -->\n\n<p-confirmDialog\n #cd\n [style]=\"{ width: '24rem', height: '14rem' }\"\n key=\"positionDialog\"\n [baseZIndex]=\"10000\"\n rejectButtonStyleClass=\"p-button-outlined\"\n>\n <ng-template pTemplate=\"footer\">\n <button type=\"button\" pButton icon=\"pi pi-check\" label=\"Yes\" (click)=\"cd.accept()\"></button>\n <button\n type=\"button\"\n class=\"p-button-outlined\"\n pButton\n icon=\"pi pi-times\"\n label=\"No\"\n (click)=\"cd.reject()\"\n ></button>\n </ng-template>\n</p-confirmDialog>\n\n<region-save-regions-dialog\n [(visible)]=\"showSaveAsDialog\"\n [filename]=\"saveAsFilename\"\n (filenameChange)=\"saveAsFilename = $event; checkSaveAsFileExists()\"\n [fileExists]=\"saveAsFileExists\"\n [busy]=\"saveAsBusy\"\n (confirm)=\"confirmSaveAs()\"\n (cancelSave)=\"cancelSaveAs()\"\n>\n</region-save-regions-dialog>\n\n<region-save-regions-dialog\n header=\"Export Regions\"\n confirmLabel=\"Export\"\n confirmIcon=\"pi pi-download\"\n inputId=\"export-filename\"\n [(visible)]=\"showExportDialog\"\n [(filename)]=\"exportFilename\"\n (confirm)=\"confirmExport()\"\n>\n</region-save-regions-dialog>\n\n<region-save-mask-dialog\n [(visible)]=\"showSaveMaskDialog\"\n [(filename)]=\"saveMaskFilename\"\n [(mode)]=\"maskMode\"\n [busy]=\"maskBusy\"\n [progress]=\"maskProgress\"\n [encoding]=\"maskEncoding\"\n (confirm)=\"confirmSaveMask()\"\n (cancelExport)=\"cancelSaveMask()\"\n>\n</region-save-mask-dialog>\n\n<region-color-dialog\n [(visible)]=\"showColorDialog\"\n [edits]=\"classColorEdits\"\n [selectedCount]=\"selectedRegions?.length || 0\"\n (apply)=\"applyColorToSelected($event)\"\n>\n</region-color-dialog>\n\n<!-- Manage annotation classes (jit-ui#70): edits a working copy, committed on Apply. -->\n<region-manage-classes-dialog\n [(visible)]=\"showManageDialog\"\n [(draft)]=\"presetDraft\"\n (apply)=\"applyManageDialog(true)\"\n (resetDefaults)=\"resetPresetsToDefaults()\"\n (importFile)=\"importPresets($event)\"\n (export)=\"exportPresets()\"\n>\n</region-manage-classes-dialog>\n\n<region-editor-help [(visible)]=\"displayHelpDialog\"></region-editor-help>\n", styles: ["@charset \"UTF-8\";.chip-swatch{display:inline-block;width:.75rem;height:.75rem;border-radius:50%;border:1px solid rgba(0,0,0,.2);flex:0 0 auto}.dd-value{display:inline-flex;align-items:center;gap:.4rem;min-width:0}.dd-value .class-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}:host{display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden}::ng-deep region-editor .p-toolbar.region-toolbar{padding:.25rem .5rem;border-radius:0;border-left:none;border-right:none;border-top:none}::ng-deep region-editor .p-toolbar.region-toolbar .p-button.p-button-icon-only{width:auto;padding:.25rem .5rem}::ng-deep region-editor .p-toolbar.region-toolbar .p-button{padding:.25rem .5rem}.toolbar-icon-dark{filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}.toolbar-separator{border-left:1px solid var(--surface-border);height:1.5rem;margin:0 .25rem;align-self:center}\n"] }]
17180
+ ], changeDetection: ChangeDetectionStrategy.OnPush, providers: [RegionPersistenceService], template: "<p-toolbar styleClass=\"region-toolbar\">\n <div class=\"p-toolbar-group-start align-items-center flex-wrap gap-1\">\n <p-button\n icon=\"pi pi-tag\"\n size=\"small\"\n [text]=\"!showShapeLabel\"\n pTooltip=\"Show region labels.\"\n tooltipPosition=\"bottom\"\n (onClick)=\"showShapeLabel = !showShapeLabel; changeShowShapeLabel(showShapeLabel)\"\n >\n </p-button>\n <p-button\n icon=\"pi pi-stop-circle\"\n size=\"small\"\n [text]=\"!regionsFilled\"\n pTooltip=\"Fill regions in their colour (an atlas's look); labels sit inside.\"\n tooltipPosition=\"bottom\"\n ariaLabel=\"Fill regions\"\n (onClick)=\"toggleFill()\"\n >\n </p-button>\n <p-button\n *ngIf=\"volumeView\"\n icon=\"pi pi-eye\"\n size=\"small\"\n [text]=\"!regionsThroughVolume\"\n [attr.aria-pressed]=\"regionsThroughVolume\"\n pTooltip=\"See the regions through the volume. Off, the volume hides the parts of them behind what it shows; on, none (layers traced inside bright structures, such as an OCT's).\"\n tooltipPosition=\"bottom\"\n ariaLabel=\"Regions through volume\"\n (onClick)=\"toggleThroughVolume()\"\n >\n </p-button>\n <span class=\"toolbar-separator\"></span>\n <!-- New regions are now created from the diagram toolbar\n (rectangle, polyline, freeform, wand). -->\n <p-button\n icon=\"pi pi-upload\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Import regions: a GeoJSON file, or a label volume (.nii, .nii.gz, .nrrd) chosen with its ontology JSON for the class tree.\"\n tooltipPosition=\"bottom\"\n (click)=\"roiFileInput.click()\"\n >\n </p-button>\n <input\n #roiFileInput\n type=\"file\"\n accept=\".geojson,.json,.nii,.gz,.nrrd\"\n multiple\n hidden\n (change)=\"importRois($event); roiFileInput.value = ''\"\n />\n <p-button\n icon=\"pi pi-folder-open\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Import a folder of per-slice GeoJSON files (one per slice, in name order, with its regionset.json class tree).\"\n tooltipPosition=\"bottom\"\n ariaLabel=\"Import a folder of regions\"\n (click)=\"roiFolderInput.click()\"\n >\n </p-button>\n <input\n #roiFolderInput\n type=\"file\"\n webkitdirectory\n multiple\n hidden\n (change)=\"importRois($event); roiFolderInput.value = ''\"\n />\n <p-button\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Download regions as geojson file.\"\n tooltipPosition=\"bottom\"\n icon=\"pi pi-download\"\n (click)=\"exportRois()\"\n >\n </p-button>\n <p-button\n icon=\"pi pi-save\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Save regions to server.\"\n tooltipPosition=\"bottom\"\n (click)=\"persistRegions()\"\n [disabled]=\"(!regions || !regions.length) && !hasRegionSource\"\n >\n </p-button>\n <p-button\n *ngIf=\"canImportFromServer\"\n icon=\"pi pi-cloud-download\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Import regions from server: a GeoJSON file, a folder of per-slice GeoJSON files or a label volume (.nii, .nii.gz, .nrrd).\"\n tooltipPosition=\"bottom\"\n ariaLabel=\"Import regions from server\"\n [loading]=\"importing\"\n (click)=\"importFromServer()\"\n >\n </p-button>\n <p-button\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Download mask of the regions as PNG\"\n tooltipPosition=\"bottom\"\n (click)=\"openSaveMaskDialog()\"\n [disabled]=\"!regions || !regions.length\"\n >\n <!-- References the file so Inkscape edits to mask.svg show up directly\n (the lib's assets/** are served at assets/plotting/). The\n toolbar-icon-dark filter recolours the black SVG to the theme blue,\n same as region-editor.svg and the other SVG toolbar icons. -->\n <img\n src=\"assets/plotting/mask.svg\"\n class=\"toolbar-icon-dark\"\n style=\"width: 1rem; height: 1rem; vertical-align: middle\"\n alt=\"Download mask\"\n />\n </p-button>\n <p-button\n label=\"x512\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Round all rectangle regions\n to width and height that are multiple of 512\"\n tooltipPosition=\"bottom\"\n (click)=\"roundRectangleLengths()\"\n >\n </p-button>\n <p-button\n icon=\"pi pi-check-square\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Select all regions\"\n tooltipPosition=\"bottom\"\n (click)=\"selectAllRegions()\"\n [disabled]=\"!regions || !regions.length\"\n >\n </p-button>\n <p-button\n icon=\"pi pi-tags\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Edit class on selected rows\"\n tooltipPosition=\"bottom\"\n (click)=\"!selectedRegions || !selectedRegions.length ? op.hide() : op.toggle($event)\"\n [disabled]=\"!selectedRegions || !selectedRegions.length\"\n >\n </p-button>\n <p-button\n icon=\"pi pi-palette\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n pTooltip=\"Edit colour of selected regions\"\n tooltipPosition=\"bottom\"\n (click)=\"openColorDialog()\"\n [disabled]=\"!selectedRegions || !selectedRegions.length\"\n >\n </p-button>\n <p-overlayPanel #op (onHide)=\"bulkClass = ''; newBulkClass = ''\">\n <div class=\"flex flex-column gap-2\" style=\"width: 18rem\">\n <span class=\"font-medium text-900\"\n >Set class on {{ selectedRegions?.length || 0 }} selected region(s)</span\n >\n <!-- Selecting a class applies it immediately (choosing an option closes the\n overlay, so there's no separate Apply button). -->\n <p-dropdown\n styleClass=\"w-full\"\n appendTo=\"body\"\n [options]=\"presetSet.classes\"\n optionLabel=\"name\"\n optionValue=\"name\"\n [(ngModel)]=\"bulkClass\"\n placeholder=\"Choose a class\u2026\"\n [filter]=\"presetSet.classes.length > 8\"\n filterBy=\"name\"\n (onChange)=\"applyBulkClass()\"\n >\n <ng-template let-c pTemplate=\"item\">\n <span class=\"dd-value\"\n ><span class=\"chip-swatch\" [style.background]=\"c.color\"></span>{{ c.name }}</span\n >\n </ng-template>\n </p-dropdown>\n <div class=\"p-inputgroup\">\n <input\n type=\"text\"\n pInputText\n placeholder=\"\u2026or a new class\"\n [(ngModel)]=\"newBulkClass\"\n (keydown.enter)=\"addAndApplyBulkClass()\"\n />\n <button\n pButton\n type=\"button\"\n icon=\"pi pi-plus\"\n pTooltip=\"Add class &amp; set it\"\n tooltipPosition=\"bottom\"\n [disabled]=\"!newBulkClass.trim()\"\n (click)=\"addAndApplyBulkClass()\"\n ></button>\n </div>\n </div>\n </p-overlayPanel>\n <p-button\n icon=\"pi pi-trash\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n (click)=\"deleteSelectedRegions()\"\n pTooltip=\"Delete selected regions\"\n tooltipPosition=\"bottom\"\n [disabled]=\"!selectedRegions || !selectedRegions.length\"\n ></p-button>\n <p-button\n icon=\"pi pi-times-circle\"\n size=\"small\"\n [text]=\"true\"\n styleClass=\"mr-2\"\n (click)=\"clearAllRegions()\"\n pTooltip=\"Delete all regions\"\n tooltipPosition=\"bottom\"\n [disabled]=\"(!regions || !regions.length) && !hasRegionSource\"\n ></p-button>\n <p-button\n icon=\"pi pi-question\"\n size=\"small\"\n [text]=\"true\"\n (click)=\"showHelp()\"\n pTooltip=\"Help\"\n tooltipPosition=\"bottom\"\n ></p-button>\n </div>\n</p-toolbar>\n\n<region-classes-panel\n [classes]=\"displayClasses\"\n [counts]=\"classCounts\"\n [matchMode]=\"presetSet.matchMode\"\n [activeClass]=\"activeClass\"\n [picked]=\"pickedClass\"\n [marked]=\"markedClasses\"\n [selectedCount]=\"selectedRegions?.length || 0\"\n [defaultClassName]=\"defaultClassName\"\n (pick)=\"selectActiveClass($event)\"\n (recolor)=\"setClassColor($event.name, $event.color)\"\n (remove)=\"deleteClass($event)\"\n (manage)=\"openManageDialog()\"\n [volume]=\"volumeView\"\n [shown3d]=\"shown3dNames\"\n (toggle3d)=\"toggleClass3d($event)\"\n [hidden]=\"hiddenClasses\"\n (toggleHidden)=\"setClassesHidden([$event], !hiddenClasses.has($event))\"\n [treeView]=\"classHierarchy ? classTreeView : null\"\n (treeViewChange)=\"classTreeView = $event\"\n>\n <!-- A region set with a class hierarchy (an atlas ontology) can show its classes as a tree. -->\n <region-class-tree\n *ngIf=\"classHierarchy as hierarchy\"\n [nodes]=\"hierarchy\"\n [counts]=\"treeCounts\"\n [colorOf]=\"treeColor\"\n [picked]=\"pickedClass\"\n [marked]=\"markedClasses\"\n [volume]=\"volumeView\"\n [shown3d]=\"shown3dNames\"\n (pick)=\"pickClasses($event)\"\n (recolor)=\"setClassColor($event.name, $event.color)\"\n (toggle3d)=\"toggleClass3d($event.name, $event.classes)\"\n [hidden]=\"hiddenClasses\"\n (toggleHidden)=\"setClassesHidden($event.classes, $event.hidden)\"\n >\n </region-class-tree>\n</region-classes-panel>\n\n<region-table\n [page]=\"pagedRegions\"\n [total]=\"tableTotal\"\n [first]=\"paginatorFirst\"\n [rows]=\"paginatorRows\"\n [rowsPerPageOptions]=\"rowsPerPageOptions\"\n [metaKey]=\"metaKey\"\n [selection]=\"selectedRegions\"\n (selectionChange)=\"selectedRegions = $event\"\n (rowSelect)=\"onSelectionChanged()\"\n [presetSet]=\"tablePresetSet\"\n [defaultColor]=\"shapeColor\"\n [mpp]=\"mpp\"\n [volumes]=\"tableVolumes\"\n [labelDrafts]=\"editingLabelRegions\"\n (labelEditStart)=\"startEditLabel($event)\"\n (labelDraftChange)=\"setLabelDraft($event.region, $event.value)\"\n (labelEditStop)=\"stopEditLabel($event.region, $event.commit)\"\n (classPick)=\"applyPresetToRegion($event.region, $event.name)\"\n (deleteRow)=\"deleteRegion($event)\"\n (pageChange)=\"onPageChange($event)\"\n>\n</region-table>\n\n<!-- Live-edit mode: no Save/Cancel \u2014 every edit commits immediately to the\n active image's region cache. -->\n\n<p-confirmDialog\n #cd\n [style]=\"{ width: '24rem', height: '14rem' }\"\n key=\"positionDialog\"\n [baseZIndex]=\"10000\"\n rejectButtonStyleClass=\"p-button-outlined\"\n>\n <ng-template pTemplate=\"footer\">\n <button type=\"button\" pButton icon=\"pi pi-check\" label=\"Yes\" (click)=\"cd.accept()\"></button>\n <button\n type=\"button\"\n class=\"p-button-outlined\"\n pButton\n icon=\"pi pi-times\"\n label=\"No\"\n (click)=\"cd.reject()\"\n ></button>\n </ng-template>\n</p-confirmDialog>\n\n<region-save-regions-dialog\n [(visible)]=\"showSaveAsDialog\"\n [filename]=\"saveAsFilename\"\n (filenameChange)=\"saveAsFilename = $event; checkSaveAsFileExists()\"\n [fileExists]=\"saveAsFileExists\"\n [busy]=\"saveAsBusy\"\n (confirm)=\"confirmSaveAs()\"\n (cancelSave)=\"cancelSaveAs()\"\n>\n</region-save-regions-dialog>\n\n<region-save-regions-dialog\n header=\"Export Regions\"\n confirmLabel=\"Export\"\n confirmIcon=\"pi pi-download\"\n inputId=\"export-filename\"\n [(visible)]=\"showExportDialog\"\n [(filename)]=\"exportFilename\"\n (confirm)=\"confirmExport()\"\n>\n</region-save-regions-dialog>\n\n<region-save-mask-dialog\n [(visible)]=\"showSaveMaskDialog\"\n [(filename)]=\"saveMaskFilename\"\n [(mode)]=\"maskMode\"\n [busy]=\"maskBusy\"\n [progress]=\"maskProgress\"\n [encoding]=\"maskEncoding\"\n (confirm)=\"confirmSaveMask()\"\n (cancelExport)=\"cancelSaveMask()\"\n>\n</region-save-mask-dialog>\n\n<region-color-dialog\n [(visible)]=\"showColorDialog\"\n [edits]=\"classColorEdits\"\n [selectedCount]=\"selectedRegions?.length || 0\"\n (apply)=\"applyColorToSelected($event)\"\n>\n</region-color-dialog>\n\n<!-- Manage annotation classes (jit-ui#70): edits a working copy, committed on Apply. -->\n<region-manage-classes-dialog\n [(visible)]=\"showManageDialog\"\n [(draft)]=\"presetDraft\"\n (apply)=\"applyManageDialog(true)\"\n (resetDefaults)=\"resetPresetsToDefaults()\"\n (importFile)=\"importPresets($event)\"\n (export)=\"exportPresets()\"\n>\n</region-manage-classes-dialog>\n\n<region-editor-help [(visible)]=\"displayHelpDialog\"></region-editor-help>\n", styles: ["@charset \"UTF-8\";.chip-swatch{display:inline-block;width:.75rem;height:.75rem;border-radius:50%;border:1px solid rgba(0,0,0,.2);flex:0 0 auto}.dd-value{display:inline-flex;align-items:center;gap:.4rem;min-width:0}.dd-value .class-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}:host{display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden}::ng-deep region-editor .p-toolbar.region-toolbar{padding:.25rem .5rem;border-radius:0;border-left:none;border-right:none;border-top:none}::ng-deep region-editor .p-toolbar.region-toolbar .p-button.p-button-icon-only{width:auto;padding:.25rem .5rem}::ng-deep region-editor .p-toolbar.region-toolbar .p-button{padding:.25rem .5rem}.toolbar-icon-dark{filter:brightness(0) invert(47%) sepia(98%) saturate(567%) hue-rotate(181deg) brightness(103%) contrast(98%)}.toolbar-separator{border-left:1px solid var(--surface-border);height:1.5rem;margin:0 .25rem;align-self:center}\n"] }]
15726
17181
  }], ctorParameters: () => [{ type: undefined, decorators: [{
15727
17182
  type: Inject,
15728
17183
  args: [REGION_EDITOR_API]
@@ -15732,6 +17187,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.9", ngImpor
15732
17187
  }], active: [{
15733
17188
  type: Input
15734
17189
  }] } });
17190
+ /**
17191
+ * Whether one GeoJSON file holds a single plane (its regions imported as they always were). One
17192
+ * that does not parse is taken as one too, so the regions import reports it as it always has.
17193
+ */
17194
+ function onePlane(text) {
17195
+ try {
17196
+ return splitByPlane(text).length === 1;
17197
+ }
17198
+ catch {
17199
+ return true;
17200
+ }
17201
+ }
15735
17202
 
15736
17203
  /**
15737
17204
  * Shared intensity helpers (refactoring plan, Step 5). The two backends use
@@ -22971,6 +24438,8 @@ class VisualizerComponent {
22971
24438
  /** Isosurface band as a 0–255 slider position, mapped onto the volume's real
22972
24439
  * intensity range by the renderer. Defaults to the full range. */
22973
24440
  this.isoRange = [0, 255];
24441
+ /** Waits for a viewer created outside the document to be attached (see {@link whenInDocument}). */
24442
+ this.attachObserver = null;
22974
24443
  /** Debounced z-slice scrubbing (see SliceScrubber — refactoring plan Step 7). */
22975
24444
  this.scrubber = new SliceScrubber((z) => {
22976
24445
  this.plotService.setZIndex(z);
@@ -23250,12 +24719,35 @@ class VisualizerComponent {
23250
24719
  // re-enters the zone (ngZone.run) only when it actually changes view state.
23251
24720
  this.shortcuts = new ViewerShortcuts(this.shortcutHost(), this.ngZone);
23252
24721
  this.shortcuts.attach();
23253
- this.onViewReady();
24722
+ this.whenInDocument(() => this.onViewReady());
23254
24723
  }
23255
24724
  /** The plot div exists now: run an image-less draw that arrived before it did. */
23256
24725
  onViewReady() {
23257
24726
  this.render.viewIsReady(this.spatial.dataset, !!this.imageInfo);
23258
24727
  }
24728
+ /**
24729
+ * Run `ready` once this viewer is in the document. A host may create it detached — a closed
24730
+ * dialog's projected content, such as jit-ui's pipeline preview — and the backends find the
24731
+ * plot div by id, so a draw then found no target and alerted "the renderer could not start"
24732
+ * over another viewer. Until then its view is not ready, and an image-less draw waits.
24733
+ */
24734
+ whenInDocument(ready) {
24735
+ const el = this.hostRef?.nativeElement;
24736
+ if (!el || el.isConnected || typeof ResizeObserver === 'undefined') {
24737
+ ready();
24738
+ return;
24739
+ }
24740
+ // Attached and laid out, its size changes from nothing: that is when it can be drawn into.
24741
+ const observer = new ResizeObserver(() => {
24742
+ if (!el.isConnected)
24743
+ return;
24744
+ observer.disconnect();
24745
+ this.attachObserver = null;
24746
+ this.ngZone.run(ready);
24747
+ });
24748
+ this.attachObserver = observer;
24749
+ observer.observe(el);
24750
+ }
23259
24751
  /** What the keyboard, wheel and context-menu shortcuts act on. Their listeners live
23260
24752
  * outside this view, so every action that changes what it shows marks it for check. */
23261
24753
  shortcutHost() {
@@ -23304,6 +24796,7 @@ class VisualizerComponent {
23304
24796
  this.toggleDragMode('select');
23305
24797
  }
23306
24798
  ngOnDestroy() {
24799
+ this.attachObserver?.disconnect();
23307
24800
  // A contributed mode's session ends first, while the viewer it drew over
23308
24801
  // still exists. The panel goes with this view, so no change detection.
23309
24802
  this.contributions.destroy();
@@ -27699,8 +29192,12 @@ class RegionSourceScope {
27699
29192
  /** The entry of the slice on screen: kept open. */
27700
29193
  this.shown = null;
27701
29194
  }
27702
- /** Attach `open` to `image` for `slices`, replacing (and closing) what was there. */
27703
- attach(image, open, slices) {
29195
+ /**
29196
+ * Attach `open` to `image` for `slices`, replacing (and closing) what was there. `imported` names
29197
+ * the slices it brings that a save must write (see {@link importedSlices}): for a source of one
29198
+ * slice, that slice when non-empty; for a `'planes'` source, the slices its planes cover.
29199
+ */
29200
+ attach(image, open, slices, imported = []) {
27704
29201
  const sources = this.imageSources(image);
27705
29202
  const entry = { open, source: null, ready: null };
27706
29203
  if (slices === 'planes') {
@@ -27710,6 +29207,7 @@ class RegionSourceScope {
27710
29207
  sources.perSlice.forEach((e) => this.close(e));
27711
29208
  sources.perSlice.clear();
27712
29209
  sources.taken.clear();
29210
+ sources.imported = new Set(imported);
27713
29211
  }
27714
29212
  else {
27715
29213
  const old = sources.perSlice.get(slices.slice);
@@ -27718,6 +29216,10 @@ class RegionSourceScope {
27718
29216
  entry.plane = slices.plane ?? 0;
27719
29217
  sources.perSlice.set(slices.slice, entry);
27720
29218
  sources.taken.delete(slices.slice);
29219
+ if (imported.length)
29220
+ sources.imported.add(slices.slice);
29221
+ else
29222
+ sources.imported.delete(slices.slice);
27721
29223
  }
27722
29224
  }
27723
29225
  /** Drop every source of `image` (closing the open ones) and what was taken from them. */
@@ -27739,6 +29241,7 @@ class RegionSourceScope {
27739
29241
  this.close(entry);
27740
29242
  sources.perSlice.delete(slice);
27741
29243
  sources.taken.delete(slice);
29244
+ sources.imported.delete(slice);
27742
29245
  }
27743
29246
  /** The slice on screen (null: none): its source stays open while others are read. */
27744
29247
  show(image, slice) {
@@ -27782,6 +29285,11 @@ class RegionSourceScope {
27782
29285
  const taken = image ? this.images.get(image)?.taken : undefined;
27783
29286
  return taken ? Array.from(taken.values()).flatMap((ids) => Array.from(ids)) : [];
27784
29287
  }
29288
+ /** The slices of `image` whose source was imported (see {@link attach}), in order. */
29289
+ importedSlices(image) {
29290
+ const imported = image ? this.images.get(image)?.imported : undefined;
29291
+ return imported ? Array.from(imported).sort((a, b) => a - b) : [];
29292
+ }
27785
29293
  /** The slices of `image` with anything taken from their source. */
27786
29294
  slicesWithTaken(image) {
27787
29295
  const taken = image ? this.images.get(image)?.taken : undefined;
@@ -27806,7 +29314,7 @@ class RegionSourceScope {
27806
29314
  imageSources(image) {
27807
29315
  let sources = this.images.get(image);
27808
29316
  if (!sources) {
27809
- sources = { planes: null, perSlice: new Map(), taken: new Map() };
29317
+ sources = { planes: null, perSlice: new Map(), taken: new Map(), imported: new Set() };
27810
29318
  this.images.set(image, sources);
27811
29319
  }
27812
29320
  return sources;
@@ -28824,8 +30332,12 @@ class RegionStore {
28824
30332
  */
28825
30333
  getStackSaveSlices() {
28826
30334
  const slices = this.scope.stackSaveSlices(this.regions);
28827
- // A slice whose region source lost features (taken, deleted) is re-written too.
28828
- const changed = this.sources.slicesWithTaken(this.scope.activeImage).filter((z) => !slices.has(z));
30335
+ // A slice whose region source lost features (taken, deleted) is re-written too, and so is one
30336
+ // whose source was imported: it is on no file yet.
30337
+ const image = this.scope.activeImage;
30338
+ const changed = [
30339
+ ...new Set([...this.sources.slicesWithTaken(image), ...this.sources.importedSlices(image)]),
30340
+ ].filter((z) => !slices.has(z));
28829
30341
  if (!changed.length)
28830
30342
  return slices;
28831
30343
  for (const z of changed)
@@ -29218,13 +30730,19 @@ class RegionStore {
29218
30730
  * source was closed to save memory). `slices` says which slice of the current image it
29219
30731
  * serves: one (default: the slice shown; plane 0 of the source), or `'planes'`, every slice z
29220
30732
  * from plane z of a z-indexed source. Replaces the source those slices had.
30733
+ *
30734
+ * `imported` marks a source brought in over the image (an imported volume, folder or served set)
30735
+ * rather than the image's own regions: it is on no file yet, so a save writes its slices though
30736
+ * nothing was taken from it — `true` for a source of one slice, the slices its planes cover for a
30737
+ * `'planes'` source. The image's own sources are not marked: loading them changes nothing to save.
29221
30738
  */
29222
- attachRegionSource(source, slices = { slice: this.sourceSlice }) {
30739
+ attachRegionSource(source, slices = { slice: this.sourceSlice }, imported = false) {
29223
30740
  const image = this.scope.activeImage;
29224
30741
  if (!image)
29225
30742
  return;
29226
30743
  const open = typeof source === 'function' ? source : () => Promise.resolve(source);
29227
- this.sources.attach(image, open, slices);
30744
+ const importedSlices = imported === false ? [] : imported === true ? (slices === 'planes' ? [] : [slices.slice]) : imported;
30745
+ this.sources.attach(image, open, slices, importedSlices);
29228
30746
  this.sourcesVersion++;
29229
30747
  this.refreshSourceView();
29230
30748
  }
@@ -29602,22 +31120,50 @@ class RegionStore {
29602
31120
  return view.source.getIndex(view.plane, { ...query, exclude: Array.from(view.taken) });
29603
31121
  }
29604
31122
  /**
29605
- * `regions` as a GeoJSON FeatureCollection string merged with the region source behind them:
29606
- * the source's features that were not taken (or deleted), then `regions`. Without a source,
29607
- * {@link getGeoJsonString}. `slice` names a folder stack's slice (default the one shown);
29608
- * on a z-indexed stack's source (`'planes'`) the export spans every slice.
31123
+ * `regions` as a GeoJSON FeatureCollection string merged with the region sources behind them:
31124
+ * the sources' features that were not taken (or deleted), then `regions`. Without a source,
31125
+ * {@link getGeoJsonString}.
31126
+ *
31127
+ * `slice` names one slice-file of a folder stack (a per-slice-file save): its source's features,
31128
+ * on the file's own plane — of a z-indexed source over every slice, only that slice's plane.
31129
+ * Without it, the export is the image's: on a stack saved as one file, every slice's source
31130
+ * (each feature on its slice's plane, `geometry.plane.z`) and a z-indexed source's every plane;
31131
+ * otherwise the source of the slice shown.
29609
31132
  */
29610
- async getGeoJsonWithSource(regions, slice = this.sourceSlice) {
31133
+ async getGeoJsonWithSource(regions, slice) {
29611
31134
  const image = this.scope.activeImage;
29612
31135
  const planes = this.scope.stackMode && this.sources.hasPlanesSource(image);
29613
- const binding = await this.sources.binding(image, slice);
29614
- if (!binding)
29615
- return regionsToGeoJson(regions);
29616
31136
  const features = regions
29617
31137
  .filter((r) => !r.isProfile?.())
29618
31138
  .map((r) => featureFromRegion(r))
29619
31139
  .filter((f) => !!f);
29620
- const exclude = planes ? this.sources.allTaken(image) : Array.from(this.sources.taken(image, slice));
31140
+ if (slice === undefined && this.scope.stackMode && this.scope.saveLayout === 'combined' && !planes) {
31141
+ const sliced = this.sources.slicesWithSource(image);
31142
+ if (sliced.length) {
31143
+ const sourceFeatures = [];
31144
+ for (const z of sliced) {
31145
+ const binding = await this.sources.binding(image, z);
31146
+ if (!binding)
31147
+ continue;
31148
+ const exported = await binding.source.exportGeoJson(Array.from(this.sources.taken(image, z)), []);
31149
+ sourceFeatures.push(...onPlane(planeFeatures(exported, binding.plane), z));
31150
+ }
31151
+ return JSON.stringify({ type: 'FeatureCollection', features: [...sourceFeatures, ...features] });
31152
+ }
31153
+ }
31154
+ const at = slice ?? this.sourceSlice;
31155
+ const binding = await this.sources.binding(image, at);
31156
+ if (!binding)
31157
+ return regionsToGeoJson(regions);
31158
+ if (planes && slice !== undefined) {
31159
+ // One slice-file of a z-indexed source: that slice's plane, written on the file's plane 0.
31160
+ const exported = await binding.source.exportGeoJson(this.sources.allTaken(image), []);
31161
+ return JSON.stringify({
31162
+ type: 'FeatureCollection',
31163
+ features: [...onPlane(planeFeatures(exported, binding.plane), 0), ...features],
31164
+ });
31165
+ }
31166
+ const exclude = planes ? this.sources.allTaken(image) : Array.from(this.sources.taken(image, at));
29621
31167
  return binding.source.exportGeoJson(exclude, features);
29622
31168
  }
29623
31169
  // ── Region look ────────────────────────────────────────────────────────
@@ -29858,6 +31404,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.9", ngImpor
29858
31404
  type: Injectable,
29859
31405
  args: [{ providedIn: 'root' }]
29860
31406
  }], ctorParameters: () => [{ type: VisualizerStore }] });
31407
+ /** The features of an exported FeatureCollection on `plane` (a missing plane is plane 0). */
31408
+ function planeFeatures(geoJson, plane) {
31409
+ const features = (JSON.parse(geoJson)?.features ?? []);
31410
+ return features.filter((f) => (f?.geometry?.plane?.z ?? 0) === plane);
31411
+ }
31412
+ /** `features` moved onto plane `z`, as QuPath writes it: `geometry.plane`, left out for plane 0. */
31413
+ function onPlane(features, z) {
31414
+ return features.map((f) => {
31415
+ const { plane: _plane, ...geometry } = f.geometry ?? {};
31416
+ return { ...f, geometry: z === 0 ? geometry : { ...geometry, plane: { c: -1, z, t: 0 } } };
31417
+ });
31418
+ }
29861
31419
 
29862
31420
  /**
29863
31421
  * Pure intensity-profile math: sampling a line through a pixel frame, placing a
@@ -31396,6 +32954,12 @@ function silenceOsdMultiImageAdvisory() {
31396
32954
  });
31397
32955
  osd.__multiImageFilterInstalled = true;
31398
32956
  }
32957
+ /** Tile cache entries a single image keeps per tiled image (one per channel when drawn per channel). */
32958
+ const TILES_PER_IMAGE = 150;
32959
+ /** The most tiles a single per-channel image keeps (each a decoded 512² tile, ~1 MB). */
32960
+ const MAX_CHANNEL_TILES = 1200;
32961
+ /** Tile requests in flight at once for an image drawn per channel. */
32962
+ const CHANNEL_REQUEST_LIMIT = 12;
31399
32963
  /**
31400
32964
  * The options object handed to the OpenSeadragon factory. Pure: every value
31401
32965
  * that depends on the service comes in through `input`.
@@ -31481,7 +33045,17 @@ function buildViewerOptions(input) {
31481
33045
  // so they get a much larger tile budget — otherwise switching slices evicts
31482
33046
  // each other's tiles and scrubbing back re-fetches. Scaled to the resident
31483
33047
  // slice count; single images keep the lean default.
31484
- maxImageCacheCount: input.sliceCount > 1 ? Math.min(2400, Math.max(600, input.maxSlices * 40)) : 150,
33048
+ // A single image drawn per channel needs room for every visible channel's tiles in view: at
33049
+ // 150 a 22-channel plate evicted tiles while they were still being converted.
33050
+ maxImageCacheCount: input.sliceCount > 1
33051
+ ? Math.min(2400, Math.max(600, input.maxSlices * 40))
33052
+ : Math.min(MAX_CHANNEL_TILES, TILES_PER_IMAGE * Math.max(1, Math.ceil((input.channelImages ?? 1) / 2))),
33053
+ // OSD sends every tile request at once by default (imageLoaderLimit 0) and times each out
33054
+ // after 30 s from when it is sent. A view of a 22-channel image asks for hundreds of tiles;
33055
+ // the browser holds all but a few connections' worth back, so those timed out while still
33056
+ // waiting their turn. Limited, they wait in OSD's queue (not timed), where a channel being
33057
+ // hidden drops its own (SliceCache.revealChannelSlice). Single images keep OSD's default.
33058
+ ...((input.channelImages ?? 1) > 1 ? { imageLoaderLimit: CHANNEL_REQUEST_LIMIT } : {}),
31485
33059
  };
31486
33060
  }
31487
33061
 
@@ -32935,6 +34509,234 @@ class OsdRegionOverlay {
32935
34509
  }
32936
34510
  }
32937
34511
 
34512
+ /**
34513
+ * "Transcripts reloading…" at the bottom of the canvas while a layer's data is in flight: one
34514
+ * line naming every layer still loading, on a semi-opaque backing so it reads over any tissue.
34515
+ * Longer work names itself ("Building HPF in 3D…") and joins the line.
34516
+ *
34517
+ * Shown only once something has been loading for {@link SHOW_AFTER_MS}, so a redraw served from
34518
+ * cache does not flash it.
34519
+ */
34520
+ const SHOW_AFTER_MS = 150;
34521
+ class NapariLoadingBadge {
34522
+ constructor(host) {
34523
+ this.names = [];
34524
+ this.tasks = [];
34525
+ this.timer = null;
34526
+ if (getComputedStyle(host).position === 'static')
34527
+ host.style.position = 'relative';
34528
+ this.el = document.createElement('div');
34529
+ this.el.className = 'napari-loading-badge';
34530
+ this.el.setAttribute('role', 'status');
34531
+ this.el.setAttribute('aria-live', 'polite');
34532
+ Object.assign(this.el.style, {
34533
+ position: 'absolute',
34534
+ left: '50%',
34535
+ bottom: '12px',
34536
+ transform: 'translateX(-50%)',
34537
+ padding: '4px 12px',
34538
+ borderRadius: '12px',
34539
+ background: 'rgba(0, 0, 0, 0.6)',
34540
+ color: '#fff',
34541
+ font: '12px/1.4 system-ui, sans-serif',
34542
+ whiteSpace: 'nowrap',
34543
+ pointerEvents: 'none',
34544
+ zIndex: '5',
34545
+ display: 'none',
34546
+ });
34547
+ host.appendChild(this.el);
34548
+ }
34549
+ /** The layers loading now, and the tasks running (their messages); both empty hides the badge. */
34550
+ set(names, tasks = []) {
34551
+ this.names = [...new Set(names)];
34552
+ this.tasks = [...new Set(tasks)];
34553
+ if (!this.names.length && !this.tasks.length) {
34554
+ if (this.timer)
34555
+ clearTimeout(this.timer);
34556
+ this.timer = null;
34557
+ this.el.style.display = 'none';
34558
+ return;
34559
+ }
34560
+ const parts = this.names.length ? [`${listOf(this.names)} reloading…`, ...this.tasks] : this.tasks;
34561
+ this.el.textContent = parts.join(' · ');
34562
+ if (this.el.style.display === 'block' || this.timer)
34563
+ return;
34564
+ this.timer = setTimeout(() => {
34565
+ this.timer = null;
34566
+ if (this.names.length || this.tasks.length)
34567
+ this.el.style.display = 'block';
34568
+ }, SHOW_AFTER_MS);
34569
+ }
34570
+ /** The text currently set (shown or about to be). */
34571
+ get text() {
34572
+ return this.names.length || this.tasks.length ? (this.el.textContent ?? '') : '';
34573
+ }
34574
+ destroy() {
34575
+ if (this.timer)
34576
+ clearTimeout(this.timer);
34577
+ this.el.remove();
34578
+ }
34579
+ }
34580
+ /** "A", "A and b", "A, b and c": the first as given, the rest with a lower-cased initial. */
34581
+ function listOf(names) {
34582
+ const [first, ...rest] = names;
34583
+ const lower = rest.map((n) => n.charAt(0).toLowerCase() + n.slice(1));
34584
+ const all = [first, ...lower];
34585
+ return all.length === 1 ? all[0] : `${all.slice(0, -1).join(', ')} and ${all[all.length - 1]}`;
34586
+ }
34587
+
34588
+ /**
34589
+ * "x reloading…" at the bottom of the canvas, aggregated over everything that loads into one
34590
+ * scene: image tiles (the tile client), observation colourings (the spatial scenes) and the
34591
+ * spatial tile layers' own loads (which report a list of layer names).
34592
+ *
34593
+ * Each load is a {@link begin} that hands back its own `end`. {@link reset} starts a new scene:
34594
+ * the counts are dropped, and an `end` from a load that began before it is a no-op, so a load
34595
+ * that settles after a re-plot can neither un-count the new scene's work nor drive a count below
34596
+ * zero (which hid the next scene's "Observations reloading…" until a second load overlapped it).
34597
+ *
34598
+ * A {@link beginTask} is work that outlives a scene (a region's 3D reconstruction, built for
34599
+ * whichever volume is on screen when it is done): its message stays across a reset.
34600
+ */
34601
+ class LoadingBadgeState {
34602
+ constructor() {
34603
+ this.host = null;
34604
+ this.badge = null;
34605
+ this.counts = new Map();
34606
+ this.tileLayers = [];
34607
+ this.generation = 0;
34608
+ this.tasks = new Map();
34609
+ }
34610
+ /** The element the badge is drawn into (the plot host); set per plot. */
34611
+ attach(host) {
34612
+ this.host = host;
34613
+ if (this.tasks.size)
34614
+ this.show();
34615
+ }
34616
+ /** One load of `source` started; call the returned function once it settles. */
34617
+ begin(source) {
34618
+ const generation = this.generation;
34619
+ this.counts.set(source, (this.counts.get(source) ?? 0) + 1);
34620
+ this.show();
34621
+ let ended = false;
34622
+ return () => {
34623
+ if (ended || generation !== this.generation)
34624
+ return;
34625
+ ended = true;
34626
+ this.counts.set(source, (this.counts.get(source) ?? 0) - 1);
34627
+ this.show();
34628
+ };
34629
+ }
34630
+ /** A task started, shown by its `message` ("Building HPF in 3D…"); call the returned function
34631
+ * once it settles. */
34632
+ beginTask(message) {
34633
+ const id = Symbol(message);
34634
+ this.tasks.set(id, message);
34635
+ this.show();
34636
+ return () => {
34637
+ if (this.tasks.delete(id))
34638
+ this.show();
34639
+ };
34640
+ }
34641
+ /** The spatial tile layers still loading (their own names, in their order). */
34642
+ setTileLayers(layers) {
34643
+ this.tileLayers = layers;
34644
+ this.show();
34645
+ }
34646
+ /** The text the badge has set (shown, or about to be once the show delay passes). */
34647
+ get text() {
34648
+ return this.badge?.text ?? '';
34649
+ }
34650
+ /** A new scene: forget every count and remove the badge (tasks show again on the next attach). */
34651
+ reset() {
34652
+ this.generation++;
34653
+ this.counts.clear();
34654
+ this.tileLayers = [];
34655
+ this.badge?.destroy();
34656
+ this.badge = null;
34657
+ }
34658
+ show() {
34659
+ if (!this.host)
34660
+ return;
34661
+ this.badge ??= new NapariLoadingBadge(this.host);
34662
+ this.badge.set([
34663
+ ...((this.counts.get('Image') ?? 0) > 0 ? ['Image'] : []),
34664
+ ...((this.counts.get('Observations') ?? 0) > 0 ? ['Observations'] : []),
34665
+ ...this.tileLayers,
34666
+ ], [...this.tasks.values()]);
34667
+ }
34668
+ }
34669
+
34670
+ /**
34671
+ * "Image reloading…" over the OpenSeadragon view while it fetches the tiles of what it shows, as
34672
+ * napari's views show it for their image, cells and transcripts (the same badge). Driven by each
34673
+ * world item's own `fully-loaded-change` (an image, a stack slice, a channel), so it covers every
34674
+ * tile source alike, and shown only after a short delay, so tiles served from cache never flash it.
34675
+ *
34676
+ * Only items on screen count (opacity above 0): a hidden channel loads nothing by design (it is
34677
+ * not preloaded), so it never becomes fully loaded, and a slice cached or loading in the
34678
+ * background is not what the view shows. An item is weighed again when its opacity changes.
34679
+ */
34680
+ class OsdTileLoading {
34681
+ constructor(viewer) {
34682
+ this.viewer = viewer;
34683
+ this.state = new LoadingBadgeState();
34684
+ /** Each item still loading, with the function that un-counts it. */
34685
+ this.loading = new Map();
34686
+ this.watched = new Map();
34687
+ this.onAdd = (e) => this.watch(e.item);
34688
+ this.onRemove = (e) => this.unwatch(e.item);
34689
+ this.state.attach(viewer.element);
34690
+ viewer.world.addHandler('add-item', this.onAdd);
34691
+ viewer.world.addHandler('remove-item', this.onRemove);
34692
+ for (let i = 0; i < viewer.world.getItemCount(); i++)
34693
+ this.watch(viewer.world.getItemAt(i));
34694
+ }
34695
+ /** The badge's text (shown, or about to be). */
34696
+ get text() {
34697
+ return this.state.text;
34698
+ }
34699
+ destroy() {
34700
+ this.viewer.world.removeHandler('add-item', this.onAdd);
34701
+ this.viewer.world.removeHandler('remove-item', this.onRemove);
34702
+ for (const item of [...this.watched.keys()])
34703
+ this.unwatch(item);
34704
+ this.state.reset();
34705
+ }
34706
+ watch(item) {
34707
+ if (!item || this.watched.has(item))
34708
+ return;
34709
+ const handler = () => this.set(item, this.isLoading(item));
34710
+ this.watched.set(item, handler);
34711
+ item.addHandler('fully-loaded-change', handler);
34712
+ item.addHandler('opacity-change', handler);
34713
+ handler();
34714
+ }
34715
+ unwatch(item) {
34716
+ const handler = this.watched.get(item);
34717
+ if (handler) {
34718
+ item.removeHandler('fully-loaded-change', handler);
34719
+ item.removeHandler('opacity-change', handler);
34720
+ }
34721
+ this.watched.delete(item);
34722
+ this.set(item, false);
34723
+ }
34724
+ /** Whether the item is on screen and still fetching its tiles. */
34725
+ isLoading(item) {
34726
+ const opacity = typeof item.getOpacity === 'function' ? item.getOpacity() : 1;
34727
+ return opacity > 0 && !item.getFullyLoaded();
34728
+ }
34729
+ set(item, loading) {
34730
+ const end = this.loading.get(item);
34731
+ if (loading && !end)
34732
+ this.loading.set(item, this.state.begin('Image'));
34733
+ else if (!loading && end) {
34734
+ end();
34735
+ this.loading.delete(item);
34736
+ }
34737
+ }
34738
+ }
34739
+
32938
34740
  /**
32939
34741
  * A physical scale bar drawn over an OpenSeadragon viewer. The length maths (the nice 1/2/5 × 10ⁿ
32940
34742
  * length nearest the target, and its unit) are napari-js's `scaleBarFor` / `formatLength`, which
@@ -33790,7 +35592,10 @@ class SliceCache {
33790
35592
  /**
33791
35593
  * Add slice z's channel group: one additively-composited ('lighter' = Fiji
33792
35594
  * "Composite") TiledImage per channel, hidden (opacity 0) until revealed.
33793
- * Each is preloaded so the background loader can pre-fill every slice.
35595
+ * The visible channels' are preloaded, so the background loader can pre-fill
35596
+ * every slice; a hidden channel's loads nothing until it is shown (a 22-channel
35597
+ * plate fetched and decoded every channel's tiles, overflowing the tile cache:
35598
+ * tiles evicted mid-conversion failed to decode).
33794
35599
  * Cached so a revisit is an instant opacity toggle — no re-fetch, no
33795
35600
  * white-flicker. Mirrors addSlice for the composite path.
33796
35601
  */
@@ -33831,7 +35636,7 @@ class SliceCache {
33831
35636
  width: 1,
33832
35637
  opacity: 0, // revealed by revealChannelSlice once the group is in
33833
35638
  compositeOperation: 'lighter',
33834
- preload: true,
35639
+ preload: this.host.channelVisible(c),
33835
35640
  success: (e) => {
33836
35641
  const item = e?.item;
33837
35642
  if (token !== this.sliceLoadToken) {
@@ -33854,17 +35659,37 @@ class SliceCache {
33854
35659
  }
33855
35660
  }
33856
35661
  /** Reveal slice z's channel group (each visible channel → opacity 1) and
33857
- * hide every other cached slice's group. Instant; no fetch/decode. */
35662
+ * hide every other cached slice's group. Instant; no fetch/decode. Only the
35663
+ * visible channels keep loading (preload): a hidden channel's tiles are not
35664
+ * fetched or decoded until it is shown. */
33858
35665
  revealChannelSlice(z) {
35666
+ const hidden = new Set();
33859
35667
  for (const [zz, group] of this.channelSliceItems) {
33860
35668
  const active = zz === z;
33861
35669
  group.forEach((it, c) => {
33862
35670
  if (!it)
33863
35671
  return;
33864
35672
  const visible = this.host.channelVisible(c);
35673
+ if (!visible)
35674
+ hidden.add(it);
35675
+ quiet(() => it.setPreload?.(visible));
33865
35676
  quiet(() => it.setOpacity(active && visible ? 1 : 0));
33866
35677
  });
33867
35678
  }
35679
+ this.dropQueuedTiles(hidden);
35680
+ }
35681
+ /** Drop the tile requests still waiting in OSD's queue for `items` (channels just hidden), so
35682
+ * the visible channels' tiles are not stuck behind them. A request already sent completes. */
35683
+ dropQueuedTiles(items) {
35684
+ const loader = this.host.viewer()?.imageLoader;
35685
+ if (!items.size || !Array.isArray(loader?.jobQueue))
35686
+ return;
35687
+ loader.jobQueue = loader.jobQueue.filter((job) => {
35688
+ if (!items.has(job?.tile?.tiledImage))
35689
+ return true;
35690
+ quiet(() => job.abort?.());
35691
+ return false;
35692
+ });
33868
35693
  }
33869
35694
  /** Show the given composite slice (opacity 1) and hide all other cached slices. */
33870
35695
  showOnlySlice(z) {
@@ -34028,12 +35853,18 @@ class SliceCache {
34028
35853
  const it = this.sliceItems.get(z);
34029
35854
  return it ? [it] : [];
34030
35855
  }
34031
- /** Whether slice z's tiles are still streaming (any cached image not fully loaded). */
35856
+ /** Whether slice z's tiles are still streaming (any cached image not fully loaded). A hidden
35857
+ * channel loads nothing, so it never becomes fully loaded: only the visible channels count, or
35858
+ * the background loader would wait on it forever. */
34032
35859
  sliceTilesLoading(z) {
34033
35860
  const g = this.sliceGroup(z);
34034
35861
  if (!g.length)
34035
35862
  return false;
34036
- return g.some((it) => it && typeof it.getFullyLoaded === 'function' && !it.getFullyLoaded());
35863
+ const multi = this.host.isMultiChannel();
35864
+ return g.some((it, c) => it &&
35865
+ (!multi || this.host.channelVisible(c)) &&
35866
+ typeof it.getFullyLoaded === 'function' &&
35867
+ !it.getFullyLoaded());
34037
35868
  }
34038
35869
  /** Remove slice z's cached image(s) from the world and the active cache. */
34039
35870
  removeCachedSlice(z) {
@@ -35526,6 +37357,8 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
35526
37357
  this.viewer = null;
35527
37358
  this.overlay = null;
35528
37359
  this.scaleBar = null;
37360
+ /** "Image reloading…" while the view fetches its tiles. */
37361
+ this.tileLoading = null;
35529
37362
  this.descriptor = null;
35530
37363
  /** Base64 RawFileInfo + current z-slice, kept so setZIndex can rebuild the
35531
37364
  * tile source and swap slices live (stack navigation). */
@@ -35812,6 +37645,7 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
35812
37645
  authHeaders: this.authHeaders,
35813
37646
  sliceCount: d.z ?? 1,
35814
37647
  maxSlices: this.cache.maxSlices(),
37648
+ channelImages: this.isMultiChannel ? (d.channels ?? 1) : 1,
35815
37649
  });
35816
37650
  this.viewer = createViewer(options);
35817
37651
  return openViewer(this.viewer, tileSource, (v) => this.onViewerOpened(v, d, simple, plotDiv));
@@ -35827,6 +37661,8 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
35827
37661
  this.overlay = new OsdRegionOverlay(viewer, this.regionStore);
35828
37662
  this.coordTransform = new OsdCoordinateTransform(viewer);
35829
37663
  this.scaleBar = new OsdScaleBar(viewer, d.mppX ?? 0);
37664
+ this.tileLoading?.destroy();
37665
+ this.tileLoading = new OsdTileLoading(viewer);
35830
37666
  if (simple) {
35831
37667
  // A single, self-contained image — no slice cache to seed. Its z-scrub and
35832
37668
  // recomposite re-open the viewer; one persistent handler reports a failed
@@ -35886,6 +37722,8 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
35886
37722
  this.scaleBar.destroy();
35887
37723
  this.scaleBar = null;
35888
37724
  }
37725
+ this.tileLoading?.destroy();
37726
+ this.tileLoading = null;
35889
37727
  if (this.viewer) {
35890
37728
  this.viewer.destroy();
35891
37729
  this.viewer = null;
@@ -37387,164 +39225,6 @@ class NapariDisplayState {
37387
39225
  }
37388
39226
  }
37389
39227
 
37390
- /**
37391
- * "Transcripts reloading…" at the bottom of the canvas while a layer's data is in flight: one
37392
- * line naming every layer still loading, on a semi-opaque backing so it reads over any tissue.
37393
- * Longer work names itself ("Building HPF in 3D…") and joins the line.
37394
- *
37395
- * Shown only once something has been loading for {@link SHOW_AFTER_MS}, so a redraw served from
37396
- * cache does not flash it.
37397
- */
37398
- const SHOW_AFTER_MS = 150;
37399
- class NapariLoadingBadge {
37400
- constructor(host) {
37401
- this.names = [];
37402
- this.tasks = [];
37403
- this.timer = null;
37404
- if (getComputedStyle(host).position === 'static')
37405
- host.style.position = 'relative';
37406
- this.el = document.createElement('div');
37407
- this.el.className = 'napari-loading-badge';
37408
- this.el.setAttribute('role', 'status');
37409
- this.el.setAttribute('aria-live', 'polite');
37410
- Object.assign(this.el.style, {
37411
- position: 'absolute',
37412
- left: '50%',
37413
- bottom: '12px',
37414
- transform: 'translateX(-50%)',
37415
- padding: '4px 12px',
37416
- borderRadius: '12px',
37417
- background: 'rgba(0, 0, 0, 0.6)',
37418
- color: '#fff',
37419
- font: '12px/1.4 system-ui, sans-serif',
37420
- whiteSpace: 'nowrap',
37421
- pointerEvents: 'none',
37422
- zIndex: '5',
37423
- display: 'none',
37424
- });
37425
- host.appendChild(this.el);
37426
- }
37427
- /** The layers loading now, and the tasks running (their messages); both empty hides the badge. */
37428
- set(names, tasks = []) {
37429
- this.names = [...new Set(names)];
37430
- this.tasks = [...new Set(tasks)];
37431
- if (!this.names.length && !this.tasks.length) {
37432
- if (this.timer)
37433
- clearTimeout(this.timer);
37434
- this.timer = null;
37435
- this.el.style.display = 'none';
37436
- return;
37437
- }
37438
- const parts = this.names.length ? [`${listOf(this.names)} reloading…`, ...this.tasks] : this.tasks;
37439
- this.el.textContent = parts.join(' · ');
37440
- if (this.el.style.display === 'block' || this.timer)
37441
- return;
37442
- this.timer = setTimeout(() => {
37443
- this.timer = null;
37444
- if (this.names.length || this.tasks.length)
37445
- this.el.style.display = 'block';
37446
- }, SHOW_AFTER_MS);
37447
- }
37448
- /** The text currently set (shown or about to be). */
37449
- get text() {
37450
- return this.names.length || this.tasks.length ? (this.el.textContent ?? '') : '';
37451
- }
37452
- destroy() {
37453
- if (this.timer)
37454
- clearTimeout(this.timer);
37455
- this.el.remove();
37456
- }
37457
- }
37458
- /** "A", "A and b", "A, b and c": the first as given, the rest with a lower-cased initial. */
37459
- function listOf(names) {
37460
- const [first, ...rest] = names;
37461
- const lower = rest.map((n) => n.charAt(0).toLowerCase() + n.slice(1));
37462
- const all = [first, ...lower];
37463
- return all.length === 1 ? all[0] : `${all.slice(0, -1).join(', ')} and ${all[all.length - 1]}`;
37464
- }
37465
-
37466
- /**
37467
- * "x reloading…" at the bottom of the canvas, aggregated over everything that loads into one
37468
- * scene: image tiles (the tile client), observation colourings (the spatial scenes) and the
37469
- * spatial tile layers' own loads (which report a list of layer names).
37470
- *
37471
- * Each load is a {@link begin} that hands back its own `end`. {@link reset} starts a new scene:
37472
- * the counts are dropped, and an `end` from a load that began before it is a no-op, so a load
37473
- * that settles after a re-plot can neither un-count the new scene's work nor drive a count below
37474
- * zero (which hid the next scene's "Observations reloading…" until a second load overlapped it).
37475
- *
37476
- * A {@link beginTask} is work that outlives a scene (a region's 3D reconstruction, built for
37477
- * whichever volume is on screen when it is done): its message stays across a reset.
37478
- */
37479
- class LoadingBadgeState {
37480
- constructor() {
37481
- this.host = null;
37482
- this.badge = null;
37483
- this.counts = new Map();
37484
- this.tileLayers = [];
37485
- this.generation = 0;
37486
- this.tasks = new Map();
37487
- }
37488
- /** The element the badge is drawn into (the plot host); set per plot. */
37489
- attach(host) {
37490
- this.host = host;
37491
- if (this.tasks.size)
37492
- this.show();
37493
- }
37494
- /** One load of `source` started; call the returned function once it settles. */
37495
- begin(source) {
37496
- const generation = this.generation;
37497
- this.counts.set(source, (this.counts.get(source) ?? 0) + 1);
37498
- this.show();
37499
- let ended = false;
37500
- return () => {
37501
- if (ended || generation !== this.generation)
37502
- return;
37503
- ended = true;
37504
- this.counts.set(source, (this.counts.get(source) ?? 0) - 1);
37505
- this.show();
37506
- };
37507
- }
37508
- /** A task started, shown by its `message` ("Building HPF in 3D…"); call the returned function
37509
- * once it settles. */
37510
- beginTask(message) {
37511
- const id = Symbol(message);
37512
- this.tasks.set(id, message);
37513
- this.show();
37514
- return () => {
37515
- if (this.tasks.delete(id))
37516
- this.show();
37517
- };
37518
- }
37519
- /** The spatial tile layers still loading (their own names, in their order). */
37520
- setTileLayers(layers) {
37521
- this.tileLayers = layers;
37522
- this.show();
37523
- }
37524
- /** The text the badge has set (shown, or about to be once the show delay passes). */
37525
- get text() {
37526
- return this.badge?.text ?? '';
37527
- }
37528
- /** A new scene: forget every count and remove the badge (tasks show again on the next attach). */
37529
- reset() {
37530
- this.generation++;
37531
- this.counts.clear();
37532
- this.tileLayers = [];
37533
- this.badge?.destroy();
37534
- this.badge = null;
37535
- }
37536
- show() {
37537
- if (!this.host)
37538
- return;
37539
- this.badge ??= new NapariLoadingBadge(this.host);
37540
- this.badge.set([
37541
- ...((this.counts.get('Image') ?? 0) > 0 ? ['Image'] : []),
37542
- ...((this.counts.get('Observations') ?? 0) > 0 ? ['Observations'] : []),
37543
- ...this.tileLayers,
37544
- ], [...this.tasks.values()]);
37545
- }
37546
- }
37547
-
37548
39228
  const SVG_NS$1 = 'http://www.w3.org/2000/svg';
37549
39229
  /** Rendered handle size (screen px). */
37550
39230
  const HANDLE_SIZE = 7;
@@ -39637,6 +41317,16 @@ class VolumeRegionMeshes {
39637
41317
  this.remove(key, e.layer);
39638
41318
  this.wanted = [];
39639
41319
  }
41320
+ /**
41321
+ * The volume the surfaces sat in is gone (or no longer theirs): take every surface down, keeping
41322
+ * the meshes wanted, so they come back when a frame is set again.
41323
+ */
41324
+ clearFrame() {
41325
+ this.frame = null;
41326
+ for (const [key, e] of [...this.layers])
41327
+ this.remove(key, e.layer);
41328
+ this.viewer.requestRender();
41329
+ }
39640
41330
  sync() {
39641
41331
  const keep = new Map(this.wanted.map((m) => [m.key, m]));
39642
41332
  for (const [key, e] of [...this.layers]) {
@@ -43955,8 +45645,13 @@ function slabAt(dataset, slice) {
43955
45645
  * shape while keeping the selection. The first 3D layer added frames the camera (`fit3d: 'once'`).
43956
45646
  */
43957
45647
  class Spatial3dScene extends SpatialSceneBase {
43958
- constructor(ctx, session) {
45648
+ /**
45649
+ * @param info the image plotted with the cloud: the reference volume's stack (the 2D view's
45650
+ * image, see showVolumeAsImage) when the dataset has a volume, which the regions are on
45651
+ */
45652
+ constructor(ctx, session, info) {
43959
45653
  super(ctx, session, true);
45654
+ this.info = info;
43960
45655
  /** The 3D point cloud. One layer: selection is a per-point alpha on it, not a second. */
43961
45656
  this.points = null;
43962
45657
  this.layerKey = null;
@@ -43998,9 +45693,64 @@ class Spatial3dScene extends SpatialSceneBase {
43998
45693
  * one; a rebuild that leaves the key alone lets it finish. */
43999
45694
  this.densityLoads = new Supersede();
44000
45695
  this.geneMapLoads = new Supersede();
45696
+ /** Whether the reference volume lets every region surface through (see setRegionsThroughVolume). */
45697
+ this.regionsThrough = false;
44001
45698
  /** The store's invert setting, which the density volumes follow (no other subscription of
44002
45699
  * this scene carries it). */
44003
45700
  this.invertSub = null;
45701
+ this.regionMeshes = new VolumeRegionMeshes(ctx.viewer);
45702
+ }
45703
+ /** Show these region reconstructions with the cloud (replacing the ones shown). */
45704
+ setRegionMeshes(meshes) {
45705
+ this.regionMeshes.set(meshes);
45706
+ }
45707
+ setRegionHighlight(keys) {
45708
+ this.regionMeshes.setHighlight(keys);
45709
+ }
45710
+ setRegionHidden(keys) {
45711
+ this.regionMeshes.setHidden(keys);
45712
+ }
45713
+ /** As the Volume view: the reference volume hides the surfaces behind what it shows, or none. */
45714
+ setRegionsThroughVolume(through) {
45715
+ this.regionsThrough = through;
45716
+ if (this.volume)
45717
+ this.volume.blending = through ? 'translucent_no_depth' : 'translucent';
45718
+ this.ctx.viewer.requestRender();
45719
+ }
45720
+ /**
45721
+ * Place the region surfaces in the reference volume's box, as the Volume view places them in its
45722
+ * own: image pixel (x, y) is voxel (x, y), slice k is voxel plane k, and the box is the volume's
45723
+ * voxels times its voxel size, centred on the origin as napari centres the volume (and the cloud
45724
+ * is offset to sit in it). Only when the plotted image IS that volume's stack: regions drawn on
45725
+ * another image (a tissue image) have no place in it, and are not shown - nor are surfaces
45726
+ * placed in a volume this one replaced.
45727
+ */
45728
+ placeRegionMeshes(dataset) {
45729
+ const meta = dataset.volume;
45730
+ if (!meta || !this.isVolumeStack(meta)) {
45731
+ this.regionMeshes.clearFrame();
45732
+ return;
45733
+ }
45734
+ const [vx, vy, vz] = meta.voxelSize;
45735
+ this.regionMeshes.setFrame({
45736
+ size: [meta.width, meta.height, meta.depth],
45737
+ world: [meta.width * vx, meta.height * vy, meta.depth * vz],
45738
+ });
45739
+ }
45740
+ /**
45741
+ * Image px per voxel of the volume the surfaces sit in: 1, the reference volume being the stack's
45742
+ * own voxels, as the Volume view's is at full resolution, so both reconstruct a class alike (and
45743
+ * share its cached surface). Null while there is no volume stack to place them in.
45744
+ */
45745
+ voxelStep() {
45746
+ const meta = this.session.latest?.[0]?.volume;
45747
+ return this.volume && meta && this.isVolumeStack(meta) ? 1 : null;
45748
+ }
45749
+ /** Whether the plotted image is the reference volume's stack: its size and slice count. */
45750
+ isVolumeStack(meta) {
45751
+ const info = this.info;
45752
+ const [w, h] = info?.trueImageSize ?? [0, 0];
45753
+ return !!info?.isStack && w === meta.width && h === meta.height && (info.urls?.length ?? 0) === meta.depth;
44004
45754
  }
44005
45755
  async mount() {
44006
45756
  // Before the spatial subscription, so the first build already reads the current value.
@@ -44051,6 +45801,7 @@ class Spatial3dScene extends SpatialSceneBase {
44051
45801
  return out;
44052
45802
  }
44053
45803
  dispose() {
45804
+ this.regionMeshes.clear();
44054
45805
  this.disposeSpatial();
44055
45806
  this.invertSub?.unsubscribe();
44056
45807
  this.invertSub = null;
@@ -44586,6 +46337,8 @@ class Spatial3dScene extends SpatialSceneBase {
44586
46337
  this.volume = null;
44587
46338
  this.volumeKey = null;
44588
46339
  }
46340
+ // The surfaces sat in the volume just removed (or there was none): down until one is placed.
46341
+ this.regionMeshes.clearFrame();
44589
46342
  this.origin = [0, 0, 0];
44590
46343
  if (!meta || !port?.getVolume)
44591
46344
  return;
@@ -44612,14 +46365,18 @@ class Spatial3dScene extends SpatialSceneBase {
44612
46365
  opacity: view.volumeOpacity,
44613
46366
  voxelSize: [vx, vy, vz],
44614
46367
  });
46368
+ if (this.regionsThrough)
46369
+ this.volume.blending = 'translucent_no_depth';
44615
46370
  // Half the box, negated: the observations' origin is the box's near corner,
44616
46371
  // and the box is centred on the world origin.
44617
46372
  this.origin = [-(meta.width * vx) / 2, -(meta.height * vy) / 2, -(meta.depth * vz) / 2];
46373
+ this.placeRegionMeshes(dataset);
44618
46374
  // Force a geometry rebuild: the offset changed, so cached positions are stale.
44619
46375
  this.layerKey = null;
44620
46376
  }
44621
46377
  /** Drop every layer of the cloud (the dataset went away, or has no z). */
44622
46378
  removeLayers(viewer) {
46379
+ this.regionMeshes.clearFrame();
44623
46380
  for (const layer of [this.points, this.volume, this.geneMapLayer, ...this.densityLayers]) {
44624
46381
  if (layer)
44625
46382
  viewer.layers.remove(layer);
@@ -44963,7 +46720,7 @@ class NapariVisualizerService extends BaseStoreVisualizer {
44963
46720
  /** The scene a plot type mounts. */
44964
46721
  createScene(plotType, ctx, info, noImage) {
44965
46722
  if (isSpatialOmics3d(plotType))
44966
- return new Spatial3dScene(ctx, this.spatial);
46723
+ return new Spatial3dScene(ctx, this.spatial, info);
44967
46724
  if (isSpatialOmics(plotType))
44968
46725
  return new Spatial2dScene(ctx, this.spatial, noImage);
44969
46726
  if (isNapariScatter(plotType))
@@ -45884,8 +47641,14 @@ class RoutingVisualizerService {
45884
47641
  this.napariFellBack = false;
45885
47642
  /** The div the backends render into (every backend shares it); '' before the first plot. */
45886
47643
  this.plotDiv = '';
45887
- /** Whether a napari volume view is the current plot type. */
47644
+ /**
47645
+ * Whether regions can be shown in 3D on screen: a napari volume (or isosurface) view, or the 3D
47646
+ * spatial cloud over a stack image (its reference volume's, see showVolumeAsImage). Both draw the
47647
+ * region reconstructions the same way (VolumeRegionMeshes), in the volume's box.
47648
+ */
45888
47649
  this.volumeView$ = new BehaviorSubject(false);
47650
+ /** Whether a napari volume (or isosurface) view is the current plot type: the slice pane's. */
47651
+ this.imageVolumeView = false;
45889
47652
  /** Whether the volume view's slice pane is wanted (see {@link setSliceEditor}). */
45890
47653
  this.sliceEditor$ = new BehaviorSubject(false);
45891
47654
  /** Whether the volume view draws the region reconstructions through the volume. */
@@ -45918,6 +47681,8 @@ class RoutingVisualizerService {
45918
47681
  this.sectionSubs = new Subscription();
45919
47682
  /** The image info and screen height of the last plot (what the slice pane mounts). */
45920
47683
  this.plottedInfo = null;
47684
+ /** The current region import (see regionImportTicket): its id and the image it was taken for. */
47685
+ this.importTicket = { id: 0, image: undefined };
45921
47686
  this.plottedHeight = 0;
45922
47687
  this.viewOptions = {
45923
47688
  setNavigatorVisible: (visible) => {
@@ -46109,13 +47874,13 @@ class RoutingVisualizerService {
46109
47874
  }
46110
47875
  plot(plotDiv, imageLoaded, imageInfo, screenHeight, plotType, inPlace) {
46111
47876
  this.currentPlotType = plotType;
46112
- this.volumeView$.next(isNapariVolume(plotType) || isNapariIsosurface(plotType));
46113
47877
  this.plottedFileName = imageInfo?.fileName;
46114
47878
  this.plottedInfo = imageInfo ?? null;
46115
47879
  this.plottedHeight = screenHeight;
47880
+ this.updateVolumeView(plotType);
46116
47881
  // The slice pane belongs to the volume view: another plot type takes it down (OSD may be
46117
47882
  // about to draw the main view, which must not find the pane's viewer still mounted).
46118
- if (!this.volumeView$.value)
47883
+ if (!this.imageVolumeView)
46119
47884
  this.unmountSlice();
46120
47885
  // Region surfaces belong to the image they were built from: another image drops them.
46121
47886
  if (this.classes3d$.value.length && this.plottedFileName !== this.classes3dImage)
@@ -46138,7 +47903,7 @@ class RoutingVisualizerService {
46138
47903
  }
46139
47904
  this.lastRendered = next;
46140
47905
  const plotted = next.plot(plotDiv, imageLoaded, imageInfo, screenHeight, plotType, inPlace);
46141
- if (!this.sliceDiv || !this.volumeView$.value)
47906
+ if (!this.sliceDiv || !this.imageVolumeView)
46142
47907
  return plotted;
46143
47908
  // The split volume view: the slice pane follows the image (a new one, or its sharper pass).
46144
47909
  return plotted.then((ok) => {
@@ -46170,7 +47935,7 @@ class RoutingVisualizerService {
46170
47935
  async mountSlice(inPlace) {
46171
47936
  const div = this.sliceDiv;
46172
47937
  const info = this.plottedInfo;
46173
- if (!div || !info?.urls?.length || !this.volumeView$.value)
47938
+ if (!div || !info?.urls?.length || !this.imageVolumeView)
46174
47939
  return false;
46175
47940
  const request = ++this.sliceRequest;
46176
47941
  try {
@@ -46292,7 +48057,7 @@ class RoutingVisualizerService {
46292
48057
  }
46293
48058
  setPlotType(plotType) {
46294
48059
  this.currentPlotType = plotType;
46295
- this.volumeView$.next(isNapariVolume(plotType) || isNapariIsosurface(plotType));
48060
+ this.updateVolumeView(plotType);
46296
48061
  this.plotly.setPlotType(plotType);
46297
48062
  }
46298
48063
  /** @deprecated Use `getSurface3dControls()`. */
@@ -46488,6 +48253,94 @@ class RoutingVisualizerService {
46488
48253
  dropRegionSource() {
46489
48254
  return this.regionStore.dropSliceRegionSource();
46490
48255
  }
48256
+ /**
48257
+ * A ticket for a region import that is about to read or decode its files: the import methods
48258
+ * given it change nothing once it is stale — another import took a ticket since, another image
48259
+ * was plotted, or the service was destroyed — so an import that finishes late never lands on an
48260
+ * image it was not started for.
48261
+ */
48262
+ regionImportTicket() {
48263
+ this.importTicket = { id: this.importTicket.id + 1, image: this.importImageKey() };
48264
+ return this.importTicket.id;
48265
+ }
48266
+ /** Whether `ticket` is still the current import's, on the image it was taken for. */
48267
+ importTicketValid(ticket) {
48268
+ return ticket === this.importTicket.id && this.importTicket.image === this.importImageKey();
48269
+ }
48270
+ importImageKey() {
48271
+ return RegionScopeCache.imageKey(this.plottedInfo ?? undefined);
48272
+ }
48273
+ /**
48274
+ * Replace the image's regions and region sources with one source per slice, as an imported label
48275
+ * volume or folder of per-slice region files gives them: `openers` by slice index, each opened
48276
+ * the first time its slice is shown. A single image takes slice 0's. Returns how many slices
48277
+ * have a source, or null (changing nothing) when `ticket` is stale (see
48278
+ * {@link regionImportTicket}). The sources are marked imported, so a save writes every slice.
48279
+ */
48280
+ importSliceRegionSources(openers, ticket) {
48281
+ if (ticket !== undefined && !this.importTicketValid(ticket))
48282
+ return null;
48283
+ this.regionImportTicket(); // supersedes any import still in flight
48284
+ const info = this.plottedInfo;
48285
+ this.regionStore.detachRegionSources();
48286
+ if (!info?.isStack) {
48287
+ const first = openers.get(0) ?? openers.values().next().value;
48288
+ this.regionStore.setRegions([]);
48289
+ this.regionStore.resetUndoHistory();
48290
+ if (!first)
48291
+ return 0;
48292
+ this.regionStore.attachRegionSource(first, { slice: 0 }, true);
48293
+ return 1;
48294
+ }
48295
+ this.enterImportedStack(info);
48296
+ for (const [z, open] of openers)
48297
+ this.regionStore.attachRegionSource(open, { slice: z }, true);
48298
+ return openers.size;
48299
+ }
48300
+ /**
48301
+ * Replace the image's regions and region sources with one imported source (a region set the
48302
+ * host's server serves): over every slice of a stack when it is z-indexed, else on the slice
48303
+ * shown, as {@link applyImageRois} lays out an image's own. Resolves false, changing nothing
48304
+ * (and disposing the source), when the import went stale while it opened: another import, or
48305
+ * another image (see {@link regionImportTicket}; one is taken here when `ticket` is not given).
48306
+ */
48307
+ async importRegionSourceOpener(open, ticket) {
48308
+ const mine = ticket ?? this.regionImportTicket();
48309
+ if (!this.importTicketValid(mine))
48310
+ return false;
48311
+ let first = await open();
48312
+ if (!this.importTicketValid(mine)) {
48313
+ first.dispose();
48314
+ return false;
48315
+ }
48316
+ const reuse = () => {
48317
+ const source = first;
48318
+ first = null;
48319
+ return source ? Promise.resolve(source) : open();
48320
+ };
48321
+ const info = this.plottedInfo;
48322
+ const planes = first.info.planes;
48323
+ const zIndexed = !!info?.isStack && planes.some((p) => p !== 0);
48324
+ this.regionStore.detachRegionSources();
48325
+ if (zIndexed) {
48326
+ this.enterImportedStack(info);
48327
+ this.regionStore.attachRegionSource(reuse, 'planes', [...planes]);
48328
+ return true;
48329
+ }
48330
+ this.regionStore.setRegions([]);
48331
+ this.regionStore.resetUndoHistory();
48332
+ this.regionStore.attachRegionSource(reuse, { slice: this.regionStore.getDisplaySlice() }, true);
48333
+ return true;
48334
+ }
48335
+ /** Empty every slice of a stack (its regions and undo history) for regions imported over it. */
48336
+ enterImportedStack(info) {
48337
+ const layout = this.regionStore.isStackMode()
48338
+ ? this.regionStore.getStackSaveLayout()
48339
+ : info.tiled === false
48340
+ ? 'per-slice-file'
48341
+ : 'combined';
48342
+ this.regionStore.enterStackMode(new Map(), this.regionStore.getDisplaySlice(), layout);
48343
+ }
46491
48344
  getRegionSourceView$() {
46492
48345
  return this.regionStore.getRegionSourceView$();
46493
48346
  }
@@ -46507,6 +48360,13 @@ class RoutingVisualizerService {
46507
48360
  this.regionStore.setRegionStyle(patch);
46508
48361
  }
46509
48362
  // ── Regions in 3D: their reconstructions shown in the volume view ─────
48363
+ /** Recompute which 3D view is on screen for `plotType` (see {@link volumeView$}). */
48364
+ updateVolumeView(plotType) {
48365
+ this.imageVolumeView = isNapariVolume(plotType) || isNapariIsosurface(plotType);
48366
+ const regions3d = this.imageVolumeView || (isSpatialOmics3d(plotType) && !!this.plottedInfo?.isStack);
48367
+ // Every plot, as before: the Region Editor drops its per-class volumes on each (a new image).
48368
+ this.volumeView$.next(regions3d);
48369
+ }
46510
48370
  /** Whether a napari volume (or isosurface) view is on screen: regions can be shown in 3D. */
46511
48371
  getVolumeView$() {
46512
48372
  return this.volumeView$.asObservable();
@@ -46938,6 +48798,7 @@ class RoutingVisualizerService {
46938
48798
  * component for a `provideVisualization()` chain. The dataset subscription would
46939
48799
  * otherwise keep the whole isolated chain reachable from the root data port. */
46940
48800
  ngOnDestroy() {
48801
+ this.importTicket = { id: this.importTicket.id + 1, image: undefined }; // a late import changes nothing
46941
48802
  this.spatialFacade?.dispose();
46942
48803
  this.spatialFacade = null;
46943
48804
  this.regionUpdates.unsubscribe();
@@ -47165,5 +49026,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.9", ngImpor
47165
49026
  * Generated bundle index. Do not edit.
47166
49027
  */
47167
49028
 
47168
- export { TOOLBAR_TOOLS as $, ALL_GENES as A, REGION_IO_PORT as B, CANVAS_TOOL_IDS as C, DEFAULT_CATEGORICAL_PALETTE as D, REGION_SOURCE_MIN_CHARS as E, Rectangle as F, Region as G, HexColorPickerComponent as H, IMAGE_STATE_PORT as I, RegionEditorComponent as J, RoutingVisualizerService as K, LUT_COLORS as L, MISSING_COLOR as M, NAPARI_DECIMATE_OPTIONS as N, SEMANTIC_SEGMENTER as O, PLOT_MODE_CONTEXT as P, SPATIAL_DATA_PORT as Q, REGION_EDITOR_API as R, SAM_MODELS as S, SPATIAL_WIRE_VERSION as T, ShapeSelection as U, SpatialChartsComponent as V, SpatialControlsComponent as W, SpatialDataHttpService as X, SpatialSelectionStore as Y, SupersededError as Z, TILE_ACCESS_PORT as _, runDecoder as a, setDefaultSamModel as a$, VISUALIZER as a0, VIZ_ALERT_TOAST_KEY as a1, VIZ_CONFIG as a2, VIZ_TOAST_KEY as a3, ViewerFeature as a4, VisualizationModule as a5, VisualizerComponent as a6, assertManifestVersion as a7, benefitsFromGrouping as a8, buildOmicsTraces as a9, isDialogToolContribution as aA, isEmptyPresetSet as aB, isLittleEndian as aC, isNapari3d as aD, isNapariIsosurface as aE, isNapariScatter as aF, isNapariScatter3d as aG, isNapariSurface as aH, isNapariVolume as aI, isSamModelReady as aJ, isSpatialOmics as aK, isSpatialOmics3d as aL, isThreeDimensional as aM, lutFor as aN, markerDiameters as aO, maskToIndices as aP, mutedFromSelection as aQ, omicsLayout as aR, openGeoJsonRegionSource as aS, openHttpRegionSource as aT, parsePresetSet as aU, pointInRing as aV, provideVisualization as aW, rendererOwnsWheel as aX, resolveCategoryColors as aY, selectByCategory as aZ, selectInRegions as a_, capabilitiesOf as aa, clearSamModelCache as ab, contrastWindow as ac, contributedPlotTypeOption as ad, countMask as ae, cropImage as af, datasetFromManifest as ag, decodeColumn as ah, decodeCoords as ai, decodeFeatureVector as aj, decodePolygons as ak, decodeRadius as al, defaultPresetSet as am, dialogToolContributions as an, emptySelection as ao, encodeCategorical as ap, encodeContinuous as aq, findColumnMeta as ar, getDefaultSamModelId as as, getPlotTypeDescriptor as at, getSamModel as au, hydrateBounds as av, isBuiltinPlotType as aw, isCanvasToolId as ax, isCategoricalColumn as ay, isContinuousColumn as az, ALL_TOOLBAR_TOOLS as b, setOrtWasmBase as b0, setSamModelUrls as b1, sortToolContributions as b2, visibleToolContributions as b3, CELL_SEGMENTER as c, CHANNEL_HISTOGRAM_API as d, CellposeSegmenterService as e, fetchModel as f, getOrtWasmBase as g, ChannelHistogramComponent as h, DEFAULT_FALLBACK_PALETTE as i, DEFAULT_MUTED_OPACITY as j, DEFAULT_SAM_MODEL_ID as k, DEFAULT_SPATIAL_VIEW as l, INSTANCE_SEGMENTER as m, ImageConverterService as n, MultiPolygon as o, NAPARI_DEFAULT_DECIMATE as p, NO_CATEGORY as q, runEncoder as r, NO_OBSERVATION as s, PLOT_MODE_SESSION as t, PLOT_TYPE_CONTRIBUTIONS as u, PLOT_TYPE_DESCRIPTORS as v, PREFERENCES_PORT as w, PlotType as x, Polygon as y, ProcessingImage as z };
47169
- //# sourceMappingURL=jax-data-science-sci-image-visualizer-jax-data-science-sci-image-visualizer-BKQhpbHv.mjs.map
49029
+ export { TILE_ACCESS_PORT as $, ALL_GENES as A, ProcessingImage as B, CANVAS_TOOL_IDS as C, DEFAULT_CATEGORICAL_PALETTE as D, REGION_IO_PORT as E, REGION_SOURCE_MIN_CHARS as F, Rectangle as G, HexColorPickerComponent as H, IMAGE_STATE_PORT as I, Region as J, RegionEditorComponent as K, LUT_COLORS as L, MISSING_COLOR as M, NAPARI_DECIMATE_OPTIONS as N, RoutingVisualizerService as O, PLOT_MODE_CONTEXT as P, SEMANTIC_SEGMENTER as Q, REGION_EDITOR_API as R, SAM_MODELS as S, SPATIAL_DATA_PORT as T, SPATIAL_WIRE_VERSION as U, ShapeSelection as V, SpatialChartsComponent as W, SpatialControlsComponent as X, SpatialDataHttpService as Y, SpatialSelectionStore as Z, SupersededError as _, runDecoder as a, openGeoJsonRegionSource as a$, TOOLBAR_TOOLS as a0, VISUALIZER as a1, VIZ_ALERT_TOAST_KEY as a2, VIZ_CONFIG as a3, VIZ_TOAST_KEY as a4, ViewerFeature as a5, VisualizationModule as a6, VisualizerComponent as a7, assertManifestVersion as a8, benefitsFromGrouping as a9, isBuiltinPlotType as aA, isCanvasToolId as aB, isCategoricalColumn as aC, isContinuousColumn as aD, isDialogToolContribution as aE, isEmptyPresetSet as aF, isLabelVolumeName as aG, isLittleEndian as aH, isNapari3d as aI, isNapariIsosurface as aJ, isNapariScatter as aK, isNapariScatter3d as aL, isNapariSurface as aM, isNapariVolume as aN, isSamModelReady as aO, isSpatialOmics as aP, isSpatialOmics3d as aQ, isThreeDimensional as aR, isZipName as aS, labelClasses as aT, labelSliceGeoJson as aU, lutFor as aV, markerDiameters as aW, maskToIndices as aX, mutedFromSelection as aY, omicsLayout as aZ, openAnnotations as a_, buildOmicsTraces as aa, capabilitiesOf as ab, clearSamModelCache as ac, contrastWindow as ad, contributedPlotTypeOption as ae, countMask as af, cropImage as ag, datasetFromManifest as ah, decodeColumn as ai, decodeCoords as aj, decodeFeatureVector as ak, decodePolygons as al, decodeRadius as am, defaultPresetSet as an, dialogToolContributions as ao, emptySelection as ap, encodeCategorical as aq, encodeContinuous as ar, expandZips as as, findColumnMeta as at, getDefaultSamModelId as au, getPlotTypeDescriptor as av, getSamModel as aw, hasZarrMetadata as ax, hydrateBounds as ay, importFile as az, ALL_TOOLBAR_TOOLS as b, openHttpRegionSource as b0, parsePresetSet as b1, planRegionImport as b2, pointInRing as b3, provideVisualization as b4, readAnnotationSlices as b5, readLabelVolume as b6, readOmeZarrLabels as b7, readZip as b8, rendererOwnsWheel as b9, resolveCategoryColors as ba, selectByCategory as bb, selectInRegions as bc, setDefaultSamModel as bd, setOrtWasmBase as be, setSamModelUrls as bf, sortToolContributions as bg, visibleToolContributions as bh, ANNOTATION_IMAGE_MAX_DIM as c, CELL_SEGMENTER as d, CHANNEL_HISTOGRAM_API as e, fetchModel as f, getOrtWasmBase as g, CellposeSegmenterService as h, ChannelHistogramComponent as i, DEFAULT_FALLBACK_PALETTE as j, DEFAULT_MUTED_OPACITY as k, DEFAULT_SAM_MODEL_ID as l, DEFAULT_SPATIAL_VIEW as m, INSTANCE_SEGMENTER as n, ImageConverterService as o, MultiPolygon as p, NAPARI_DEFAULT_DECIMATE as q, runEncoder as r, NO_CATEGORY as s, NO_OBSERVATION as t, PLOT_MODE_SESSION as u, PLOT_TYPE_CONTRIBUTIONS as v, PLOT_TYPE_DESCRIPTORS as w, PREFERENCES_PORT as x, PlotType as y, Polygon as z };
49030
+ //# sourceMappingURL=jax-data-science-sci-image-visualizer-jax-data-science-sci-image-visualizer-a68Hw5rw.mjs.map