@jax-data-science/sci-image-visualizer 0.10.0 → 0.11.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 (41) hide show
  1. package/esm2022/index.mjs +4 -1
  2. package/esm2022/lib/contracts/ports/region-io.port.mjs +1 -1
  3. package/esm2022/lib/contracts/region-editor-api.contract.mjs +1 -1
  4. package/esm2022/lib/implementations/osd/openseadragon-viewer-options.mjs +18 -2
  5. package/esm2022/lib/implementations/osd/openseadragon-visualizer.service.mjs +9 -1
  6. package/esm2022/lib/implementations/osd/osd-tile-loading.mjs +71 -0
  7. package/esm2022/lib/implementations/osd/osd-viewer-like.mjs +1 -1
  8. package/esm2022/lib/implementations/osd/slice-cache.mjs +35 -6
  9. package/esm2022/lib/region-editor/region-editor.component.mjs +147 -4
  10. package/esm2022/lib/region-editor/region-persistence.service.mjs +9 -1
  11. package/esm2022/lib/region-import/label-classes.mjs +149 -0
  12. package/esm2022/lib/region-import/label-volume.mjs +194 -0
  13. package/esm2022/lib/region-import/region-import.mjs +71 -0
  14. package/esm2022/lib/routing-visualizer.service.mjs +93 -1
  15. package/esm2022/lib/store/region-source-scope.mjs +19 -4
  16. package/esm2022/lib/store/region-store.service.mjs +64 -14
  17. package/esm2022/lib/visualizer.component.mjs +28 -2
  18. 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-BELcx0jv.mjs} +1049 -188
  19. package/fesm2022/jax-data-science-sci-image-visualizer-jax-data-science-sci-image-visualizer-BELcx0jv.mjs.map +1 -0
  20. package/fesm2022/{jax-data-science-sci-image-visualizer-onnx-sam-session-z1IN-F7w.mjs → jax-data-science-sci-image-visualizer-onnx-sam-session-DEDSm_NL.mjs} +2 -2
  21. package/fesm2022/{jax-data-science-sci-image-visualizer-onnx-sam-session-z1IN-F7w.mjs.map → jax-data-science-sci-image-visualizer-onnx-sam-session-DEDSm_NL.mjs.map} +1 -1
  22. package/fesm2022/jax-data-science-sci-image-visualizer.mjs +1 -1
  23. package/index.d.ts +6 -0
  24. package/lib/contracts/ports/region-io.port.d.ts +24 -0
  25. package/lib/contracts/region-editor-api.contract.d.ts +19 -1
  26. package/lib/implementations/osd/openseadragon-viewer-options.d.ts +2 -0
  27. package/lib/implementations/osd/openseadragon-visualizer.service.d.ts +2 -0
  28. package/lib/implementations/osd/osd-tile-loading.d.ts +43 -0
  29. package/lib/implementations/osd/osd-viewer-like.d.ts +14 -0
  30. package/lib/implementations/osd/slice-cache.d.ts +13 -3
  31. package/lib/region-editor/region-editor.component.d.ts +26 -0
  32. package/lib/region-editor/region-persistence.service.d.ts +5 -1
  33. package/lib/region-import/label-classes.d.ts +39 -0
  34. package/lib/region-import/label-volume.d.ts +31 -0
  35. package/lib/region-import/region-import.d.ts +46 -0
  36. package/lib/routing-visualizer.service.d.ts +30 -0
  37. package/lib/store/region-source-scope.d.ts +8 -2
  38. package/lib/store/region-store.service.d.ts +15 -5
  39. package/lib/visualizer.component.d.ts +9 -0
  40. package/package.json +1 -1
  41. package/fesm2022/jax-data-science-sci-image-visualizer-jax-data-science-sci-image-visualizer-BKQhpbHv.mjs.map +0 -1
@@ -6,7 +6,7 @@ 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
10
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
11
11
  import * as i1$1 from '@angular/common';
12
12
  import { CommonModule } from '@angular/common';
@@ -5814,6 +5814,418 @@ function fromWire(f) {
5814
5814
  };
5815
5815
  }
5816
5816
 
5817
+ /**
5818
+ * A label volume (a NIfTI-1 `.nii` / `.nii.gz` or a NRRD `.nrrd` of integer structure ids, an
5819
+ * atlas annotation say) read in the browser, sliced as the server opens the matching image:
5820
+ *
5821
+ * - **NRRD**: a 3D volume is cut along its FIRST axis, each section its third axis wide and its
5822
+ * second axis high, as jit-service opens a NRRD volume (`AtlasNrrdReader`: the Allen CCF's 528
5823
+ * coronal sections of 456 × 320, anterior first). A 2D NRRD is one slice.
5824
+ * - **NIfTI**: along its third axis, each slice its first axis wide and its second axis high, as
5825
+ * Bio-Formats reads one (no row flip).
5826
+ *
5827
+ * Only what label volumes use: a single file (NRRD with attached data, NIfTI-1 `n+1`), integer
5828
+ * or integer-valued float samples, either byte order, raw or gzip. Anything else throws rather
5829
+ * than be read approximately. Pure: usable from workers.
5830
+ */
5831
+ /** Whether a file name is a label volume this reads. */
5832
+ function isLabelVolumeName(name) {
5833
+ return /\.(nrrd|nii|nii\.gz)$/i.test(name);
5834
+ }
5835
+ /** Read the label volume in `bytes`, named `name` (`.nrrd`, `.nii`, `.nii.gz`). */
5836
+ async function readLabelVolume(name, bytes, gunzip = platformGunzip) {
5837
+ if (/\.nrrd$/i.test(name))
5838
+ return readNrrd(bytes, gunzip);
5839
+ if (/\.nii(\.gz)?$/i.test(name))
5840
+ return readNifti(isGzip(bytes) ? await gunzip(bytes) : bytes);
5841
+ throw new Error(`${name}: not a NIfTI (.nii, .nii.gz) or NRRD (.nrrd) label volume`);
5842
+ }
5843
+ const SAMPLES = {
5844
+ u8: { bytes: 1, read: (v, at) => v.getUint8(at) },
5845
+ i8: { bytes: 1, read: (v, at) => v.getInt8(at) },
5846
+ u16: { bytes: 2, read: (v, at, le) => v.getUint16(at, le) },
5847
+ i16: { bytes: 2, read: (v, at, le) => v.getInt16(at, le) },
5848
+ u32: { bytes: 4, read: (v, at, le) => v.getUint32(at, le) },
5849
+ i32: { bytes: 4, read: (v, at, le) => v.getInt32(at, le) },
5850
+ f32: { bytes: 4, read: (v, at, le) => Math.round(v.getFloat32(at, le)) },
5851
+ f64: { bytes: 8, read: (v, at, le) => Math.round(v.getFloat64(at, le)) },
5852
+ };
5853
+ /** NRRD type names (every spelling the format allows) → sample type. */
5854
+ const NRRD_TYPES = {
5855
+ 'signed char': 'i8',
5856
+ int8: 'i8',
5857
+ int8_t: 'i8',
5858
+ uchar: 'u8',
5859
+ 'unsigned char': 'u8',
5860
+ uint8: 'u8',
5861
+ uint8_t: 'u8',
5862
+ short: 'i16',
5863
+ 'short int': 'i16',
5864
+ 'signed short': 'i16',
5865
+ 'signed short int': 'i16',
5866
+ int16: 'i16',
5867
+ int16_t: 'i16',
5868
+ ushort: 'u16',
5869
+ 'unsigned short': 'u16',
5870
+ 'unsigned short int': 'u16',
5871
+ uint16: 'u16',
5872
+ uint16_t: 'u16',
5873
+ int: 'i32',
5874
+ 'signed int': 'i32',
5875
+ int32: 'i32',
5876
+ int32_t: 'i32',
5877
+ uint: 'u32',
5878
+ 'unsigned int': 'u32',
5879
+ uint32: 'u32',
5880
+ uint32_t: 'u32',
5881
+ float: 'f32',
5882
+ double: 'f64',
5883
+ };
5884
+ /** NIfTI datatype codes → sample type. */
5885
+ const NIFTI_TYPES = {
5886
+ 2: 'u8',
5887
+ 4: 'i16',
5888
+ 8: 'i32',
5889
+ 16: 'f32',
5890
+ 64: 'f64',
5891
+ 256: 'i8',
5892
+ 512: 'u16',
5893
+ 768: 'u32',
5894
+ };
5895
+ async function readNrrd(bytes, gunzip) {
5896
+ const end = headerEnd(bytes);
5897
+ if (end < 0)
5898
+ throw new Error('NRRD: no blank line ends the header');
5899
+ const lines = new TextDecoder('latin1').decode(bytes.subarray(0, end)).split(/\r?\n/);
5900
+ if (!/^NRRD\d{4}/.test(lines[0] ?? ''))
5901
+ throw new Error('NRRD: not a NRRD file');
5902
+ const field = {};
5903
+ for (const line of lines.slice(1)) {
5904
+ if (line.startsWith('#'))
5905
+ continue;
5906
+ const at = line.indexOf(': ');
5907
+ if (at > 0)
5908
+ field[line.slice(0, at).trim().toLowerCase()] = line.slice(at + 2).trim();
5909
+ }
5910
+ if (field['data file'] || field['datafile'])
5911
+ throw new Error('NRRD: detached data (a .nhdr) is not supported');
5912
+ const type = NRRD_TYPES[(field['type'] ?? '').toLowerCase()];
5913
+ if (!type)
5914
+ throw new Error(`NRRD: unsupported type "${field['type']}"`);
5915
+ const sizes = (field['sizes'] ?? '').split(/\s+/).filter(Boolean).map(Number);
5916
+ if (sizes.length !== 2 && sizes.length !== 3)
5917
+ throw new Error(`NRRD: need 2 or 3 dimensions, got ${sizes.length}`);
5918
+ const encoding = (field['encoding'] ?? 'raw').toLowerCase();
5919
+ let data = bytes.subarray(end);
5920
+ if (encoding === 'gzip' || encoding === 'gz')
5921
+ data = await gunzip(data);
5922
+ else if (encoding !== 'raw')
5923
+ throw new Error(`NRRD: unsupported encoding "${encoding}"`);
5924
+ const little = (field['endian'] ?? 'little').toLowerCase() !== 'big';
5925
+ // A 3D volume: sections along axis 0, each axis 2 wide and axis 1 high (jit-service's cut).
5926
+ const [n0, n1, n2] = sizes.length === 3 ? sizes : [sizes[0], sizes[1], 1];
5927
+ const axes = sizes.length === 3 ? { z: 0, x: 2, y: 1 } : { z: 2, x: 0, y: 1 };
5928
+ return volume('nrrd', [n0, n1, n2], axes, data, SAMPLES[type], little);
5929
+ }
5930
+ function readNifti(bytes) {
5931
+ if (bytes.length < 352)
5932
+ throw new Error('NIfTI: shorter than a header');
5933
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
5934
+ let little = true;
5935
+ if (view.getInt32(0, true) !== 348) {
5936
+ if (view.getInt32(0, false) !== 348)
5937
+ throw new Error('NIfTI: not a NIfTI-1 file');
5938
+ little = false;
5939
+ }
5940
+ const magic = new TextDecoder('latin1').decode(bytes.subarray(344, 347));
5941
+ if (magic !== 'n+1')
5942
+ throw new Error('NIfTI: a separate .hdr/.img pair is not supported');
5943
+ const ndim = view.getInt16(40, little);
5944
+ const dims = [1, 2, 3, 4].map((i) => view.getInt16(40 + 2 * i, little));
5945
+ if (ndim < 2)
5946
+ throw new Error(`NIfTI: need at least 2 dimensions, got ${ndim}`);
5947
+ if (ndim >= 4 && dims[3] > 1)
5948
+ throw new Error('NIfTI: a 4D file is not a label volume');
5949
+ const type = NIFTI_TYPES[view.getInt16(70, little)];
5950
+ if (!type)
5951
+ throw new Error(`NIfTI: unsupported datatype ${view.getInt16(70, little)}`);
5952
+ const offset = Math.max(352, Math.round(view.getFloat32(108, little)));
5953
+ const [n0, n1, n2] = [dims[0], dims[1], ndim >= 3 ? dims[2] : 1];
5954
+ // Slices along axis 2, each axis 0 wide and axis 1 high (Bio-Formats' planes).
5955
+ return volume('nifti', [n0, n1, n2], { z: 2, x: 0, y: 1 }, bytes.subarray(offset), SAMPLES[type], little);
5956
+ }
5957
+ function volume(format, n, axes, data, sample, little) {
5958
+ const count = n[0] * n[1] * n[2];
5959
+ if (!(count > 0))
5960
+ throw new Error(`${format}: empty volume`);
5961
+ if (data.length < count * sample.bytes) {
5962
+ throw new Error(`${format}: ${data.length} bytes of data, ${count * sample.bytes} needed`);
5963
+ }
5964
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
5965
+ const stride = [1, n[0], n[0] * n[1]];
5966
+ const width = n[axes.x];
5967
+ const height = n[axes.y];
5968
+ return {
5969
+ format,
5970
+ width,
5971
+ height,
5972
+ slices: n[axes.z],
5973
+ sliceLabels(z) {
5974
+ const out = new Uint32Array(width * height);
5975
+ const base = z * stride[axes.z];
5976
+ const sx = stride[axes.x];
5977
+ const sy = stride[axes.y];
5978
+ for (let y = 0; y < height; y++) {
5979
+ for (let x = 0; x < width; x++) {
5980
+ const v = sample.read(view, (base + x * sx + y * sy) * sample.bytes, little);
5981
+ out[y * width + x] = v > 0 ? v : 0;
5982
+ }
5983
+ }
5984
+ return out;
5985
+ },
5986
+ };
5987
+ }
5988
+ /** The offset just past the header's blank line (`\n\n` or `\r\n\r\n`); -1 without one. */
5989
+ function headerEnd(bytes) {
5990
+ const limit = Math.min(bytes.length - 1, 1 << 20);
5991
+ for (let i = 0; i < limit; i++) {
5992
+ if (bytes[i] !== 10)
5993
+ continue;
5994
+ if (bytes[i + 1] === 10)
5995
+ return i + 2;
5996
+ if (bytes[i + 1] === 13 && bytes[i + 2] === 10)
5997
+ return i + 3;
5998
+ }
5999
+ return -1;
6000
+ }
6001
+ function isGzip(bytes) {
6002
+ return bytes.length > 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
6003
+ }
6004
+ async function platformGunzip(bytes) {
6005
+ if (typeof DecompressionStream === 'undefined')
6006
+ throw new Error('gzip data needs DecompressionStream');
6007
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'));
6008
+ return new Uint8Array(await new Response(stream).arrayBuffer());
6009
+ }
6010
+
6011
+ /** The look the Allen atlas region sets carry (see the brain-atlas example's GeoJSON). */
6012
+ const ATLAS_STYLE = {
6013
+ fillOpacity: 0.6,
6014
+ stroke: '#3c3c3c',
6015
+ strokeWidth: 1,
6016
+ labels: true,
6017
+ highlight: '#a020f0',
6018
+ };
6019
+ /**
6020
+ * The classes of a label volume from `ontology`, a parsed JSON document:
6021
+ *
6022
+ * - an **Allen structure graph** (`structure_graph_1.json`: `{ msg: [root] }`, or a node or a
6023
+ * list of nodes with `id`, `acronym`, `name`, `color_hex_triplet`, `graph_order` and
6024
+ * `children`): label = structure id, class = acronym, its tree the ontology's, drawn as the
6025
+ * atlas region sets are;
6026
+ * - a **region set description** (a `regionset.json`: `classHierarchy`, optional `style`), whose
6027
+ * classes name their label as `value` (or `id`), or with a `labels` map of value → class name.
6028
+ *
6029
+ * Without one (or with another document), each label is its own class `label <value>`.
6030
+ */
6031
+ function labelClasses(ontology) {
6032
+ const allen = allenRoots(ontology);
6033
+ if (allen)
6034
+ return allenClasses(allen);
6035
+ if (ontology &&
6036
+ typeof ontology === 'object' &&
6037
+ Array.isArray(ontology.classHierarchy)) {
6038
+ return regionSetClasses(ontology);
6039
+ }
6040
+ return { classOf: unnamed };
6041
+ }
6042
+ function unnamed(value) {
6043
+ return { name: `label ${value}`, color: paletteColor(value) };
6044
+ }
6045
+ function allenRoots(doc) {
6046
+ const isNode = (n) => !!n &&
6047
+ typeof n === 'object' &&
6048
+ typeof n.id === 'number' &&
6049
+ typeof n.acronym === 'string';
6050
+ const msg = doc?.msg;
6051
+ const roots = Array.isArray(msg) ? msg : Array.isArray(doc) ? doc : doc ? [doc] : [];
6052
+ return roots.length && roots.every(isNode) ? roots : null;
6053
+ }
6054
+ function allenClasses(roots) {
6055
+ const byId = new Map();
6056
+ const hierarchy = [];
6057
+ const walk = (n, parent) => {
6058
+ const color = `#${(n.color_hex_triplet || '808080').toUpperCase()}`;
6059
+ byId.set(n.id, { name: n.acronym, color, title: n.name });
6060
+ hierarchy.push({
6061
+ name: n.acronym,
6062
+ ...(n.name ? { title: n.name } : {}),
6063
+ ...(parent ? { parent } : {}),
6064
+ color,
6065
+ ...(n.graph_order != null ? { order: n.graph_order } : {}),
6066
+ });
6067
+ for (const c of n.children ?? [])
6068
+ walk(c, n.acronym);
6069
+ };
6070
+ for (const r of roots)
6071
+ walk(r, null);
6072
+ hierarchy.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
6073
+ return { classOf: (v) => byId.get(v) ?? unnamed(v), hierarchy, style: ATLAS_STYLE };
6074
+ }
6075
+ function regionSetClasses(doc) {
6076
+ const nodes = new Map(doc.classHierarchy.map((n) => [n.name, n]));
6077
+ const byValue = new Map();
6078
+ for (const n of doc.classHierarchy) {
6079
+ const v = typeof n.value === 'number' ? n.value : typeof n.id === 'number' ? n.id : undefined;
6080
+ if (v != null)
6081
+ byValue.set(v, n.name);
6082
+ }
6083
+ for (const [v, name] of Object.entries(doc.labels ?? {}))
6084
+ byValue.set(Number(v), name);
6085
+ const hierarchy = doc.classHierarchy.map(({ name, title, parent, color, order }) => ({
6086
+ name,
6087
+ ...(title ? { title } : {}),
6088
+ ...(parent ? { parent } : {}),
6089
+ ...(color ? { color } : {}),
6090
+ ...(order != null ? { order } : {}),
6091
+ }));
6092
+ return {
6093
+ classOf: (v) => {
6094
+ const name = byValue.get(v);
6095
+ const node = name ? nodes.get(name) : undefined;
6096
+ return node ? { name: node.name, color: node.color ?? paletteColor(v), title: node.title } : unnamed(v);
6097
+ },
6098
+ hierarchy,
6099
+ ...(doc.style ? { style: doc.style } : {}),
6100
+ };
6101
+ }
6102
+ /** A colour per label value, spread around the hue circle. */
6103
+ function paletteColor(value) {
6104
+ const hue = (value * 137.508) % 360;
6105
+ const [r, g, b] = hsl(hue, 0.65, 0.55);
6106
+ return `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`.toUpperCase();
6107
+ }
6108
+ function hsl(h, s, l) {
6109
+ const k = (n) => (n + h / 30) % 12;
6110
+ const a = s * Math.min(l, 1 - l);
6111
+ const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
6112
+ return [f(0), f(8), f(4)].map((c) => Math.round(c * 255));
6113
+ }
6114
+ const rgb$1 = (hex) => [1, 3, 5].map((o) => parseInt(hex.slice(o, o + 2), 16));
6115
+ /**
6116
+ * Slice `z` of `volume` as a GeoJSON FeatureCollection string: one feature per label on the
6117
+ * slice, its every piece (with holes) traced along pixel edges, in image pixels with y down;
6118
+ * classified by `classes`, which also give the collection its class tree and style.
6119
+ */
6120
+ function labelSliceGeoJson(volume, z, classes) {
6121
+ const labels = volume.sliceLabels(z);
6122
+ const byLabel = new Map();
6123
+ for (const c of traceContours({ data: labels, width: volume.width, height: volume.height })) {
6124
+ const polygons = byLabel.get(c.value) ?? [];
6125
+ polygons.push([ring(c.outer), ...c.holes.map(ring)]);
6126
+ byLabel.set(c.value, polygons);
6127
+ }
6128
+ const features = [...byLabel].map(([value, polygons]) => {
6129
+ const cls = classes.classOf(value);
6130
+ return {
6131
+ type: 'Feature',
6132
+ id: String(value),
6133
+ geometry: { type: 'MultiPolygon', coordinates: polygons },
6134
+ properties: {
6135
+ objectType: 'annotation',
6136
+ name: cls.title ?? cls.name,
6137
+ classification: { name: cls.name, color: rgb$1(cls.color) },
6138
+ labelValue: value,
6139
+ },
6140
+ };
6141
+ });
6142
+ return JSON.stringify({
6143
+ type: 'FeatureCollection',
6144
+ ...(classes.hierarchy ? { classHierarchy: classes.hierarchy } : {}),
6145
+ ...(classes.style ? { style: classes.style } : {}),
6146
+ features,
6147
+ });
6148
+ }
6149
+ /** A closed GeoJSON ring from a flat `[x0, y0, …]` one. */
6150
+ function ring(flat) {
6151
+ const pts = [];
6152
+ for (let i = 0; i < flat.length; i += 2)
6153
+ pts.push([flat[i], flat[i + 1]]);
6154
+ if (pts.length)
6155
+ pts.push([...pts[0]]);
6156
+ return pts;
6157
+ }
6158
+
6159
+ /** `data`, named `name`, as an import file. A `File`'s own name is used when `name` is omitted. */
6160
+ function importFile(data, name = data.name ?? '') {
6161
+ const encoder = new TextEncoder();
6162
+ return {
6163
+ name,
6164
+ text: async () => typeof data === 'string'
6165
+ ? data
6166
+ : data instanceof Blob
6167
+ ? data.text()
6168
+ : new TextDecoder().decode(data instanceof Uint8Array ? data : new Uint8Array(data)),
6169
+ bytes: async () => typeof data === 'string'
6170
+ ? encoder.encode(data)
6171
+ : data instanceof Blob
6172
+ ? new Uint8Array(await data.arrayBuffer())
6173
+ : data instanceof Uint8Array
6174
+ ? data
6175
+ : new Uint8Array(data),
6176
+ };
6177
+ }
6178
+ const leaf = (name) => name.split(/[\\/]/).pop() ?? name;
6179
+ const isRegionSet = (f) => leaf(f.name).toLowerCase() === 'regionset.json';
6180
+ const isGeoJson = (f) => /\.geojson$/i.test(f.name);
6181
+ const isJson = (f) => /\.json$/i.test(f.name);
6182
+ /**
6183
+ * What `files` import as: a label volume (`.nii`, `.nii.gz`, `.nrrd`) with the ontology JSON chosen
6184
+ * with it (a `regionset.json`, else an Allen `structure_graph*.json`, else any `.json`); several
6185
+ * region files (a folder of per-slice `.geojson`, in natural name order, its `regionset.json`
6186
+ * apart); or one region file (`.geojson` or `.json`). Null when none of them is one.
6187
+ */
6188
+ function planRegionImport(files) {
6189
+ const volume = files.find((f) => isLabelVolumeName(f.name));
6190
+ if (volume) {
6191
+ const jsons = files.filter((f) => f !== volume && isJson(f));
6192
+ const ontology = jsons.find(isRegionSet) ??
6193
+ jsons.find((f) => /structure_graph.*\.json$/i.test(leaf(f.name))) ??
6194
+ jsons[0] ??
6195
+ null;
6196
+ return { kind: 'volume', volume, ontology };
6197
+ }
6198
+ const regionSet = files.find(isRegionSet) ?? null;
6199
+ const geojsons = files.filter((f) => isGeoJson(f));
6200
+ const regionFiles = geojsons.length ? geojsons : files.filter((f) => isJson(f) && !isRegionSet(f));
6201
+ if (regionFiles.length > 1 || (regionFiles.length === 1 && regionSet)) {
6202
+ const slices = [...regionFiles].sort((a, b) => naturalCompare(leaf(a.name), leaf(b.name)));
6203
+ return { kind: 'folder', slices, regionSet };
6204
+ }
6205
+ return regionFiles.length === 1 ? { kind: 'single', file: regionFiles[0] } : null;
6206
+ }
6207
+ /** "slice_2" before "slice_10": runs of digits compared as numbers. */
6208
+ function naturalCompare(a, b) {
6209
+ return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
6210
+ }
6211
+ /**
6212
+ * A folder slice's GeoJSON with the set's class tree and style (its `regionset.json`) as the
6213
+ * collection's own members, unless the file has its own: what a served folder's slice carries.
6214
+ */
6215
+ async function folderSliceGeoJson(file, regionSet) {
6216
+ const text = await file.text();
6217
+ if (!regionSet || (!regionSet.classHierarchy && !regionSet.style))
6218
+ return text;
6219
+ const doc = JSON.parse(text);
6220
+ if (doc['type'] !== 'FeatureCollection')
6221
+ return text;
6222
+ if (regionSet.classHierarchy && !doc['classHierarchy'])
6223
+ doc['classHierarchy'] = regionSet.classHierarchy;
6224
+ if (regionSet.style && !doc['style'])
6225
+ doc['style'] = regionSet.style;
6226
+ return JSON.stringify(doc);
6227
+ }
6228
+
5817
6229
  /**
5818
6230
  * The largest bbox (in pixels) {@link rasterizePolygon} allocates before it
5819
6231
  * falls back to clipping at the window. Also the cap the wand/brush use to
@@ -9098,7 +9510,7 @@ function frameToRgba(cached, frameIndex) {
9098
9510
  /** Lazy-import so onnxruntime-web is never pulled into unit tests or the
9099
9511
  * initial bundle — only when segmentation actually runs. */
9100
9512
  const onnxSessionFactory = async () => {
9101
- const { OnnxSamSession } = await import('./jax-data-science-sci-image-visualizer-onnx-sam-session-z1IN-F7w.mjs');
9513
+ const { OnnxSamSession } = await import('./jax-data-science-sci-image-visualizer-onnx-sam-session-DEDSm_NL.mjs');
9102
9514
  return new OnnxSamSession();
9103
9515
  };
9104
9516
  /** Thrown inside a load that a model switch superseded. */
@@ -12838,6 +13250,14 @@ class RegionPersistenceService {
12838
13250
  }
12839
13251
  return out;
12840
13252
  }
13253
+ /** Whether the host can pick regions on its server (see RegionIoPort.pickServerRegions). */
13254
+ canPickServerRegions() {
13255
+ return typeof this.io.pickServerRegions === 'function';
13256
+ }
13257
+ /** Let the user pick regions on the host's server; null when cancelled or unsupported. */
13258
+ pickServerRegions() {
13259
+ return this.io.pickServerRegions?.() ?? of(null);
13260
+ }
12841
13261
  /** Write per-slice geojsons through the host port. */
12842
13262
  saveSlices(slices) {
12843
13263
  return this.io.saveSliceGeoJsons(slices);
@@ -14392,6 +14812,8 @@ class RegionEditorComponent {
14392
14812
  this.persistence = persistence;
14393
14813
  this.maskExport = maskExport;
14394
14814
  this.isActive = true;
14815
+ /** The host's HttpClient: a served region set's requests go through it (its auth). */
14816
+ this.http = inject(HttpClient, { optional: true });
14395
14817
  this.regions = [];
14396
14818
  this.selectedRegions = [];
14397
14819
  this.displayHelpDialog = false;
@@ -14517,6 +14939,8 @@ class RegionEditorComponent {
14517
14939
  this.volumeRequest = 0;
14518
14940
  this._pageKey = {};
14519
14941
  this._page = [];
14942
+ /** An import from the server is in flight. */
14943
+ this.importing = false;
14520
14944
  }
14521
14945
  ngOnInit() {
14522
14946
  this.showShapeLabel = this.regionApi.getShowShapeLabel();
@@ -15451,8 +15875,141 @@ class RegionEditorComponent {
15451
15875
  }
15452
15876
  this.changed();
15453
15877
  }
15878
+ /** The files chosen in the import inputs: one GeoJSON, a label volume with its ontology, or a
15879
+ * folder of per-slice GeoJSON files. */
15454
15880
  importRois(event) {
15455
- this.persistence.readChosenFile(event, (text) => void this.applyImportedRois(text));
15881
+ const files = Array.from(event.target.files ?? []);
15882
+ if (files.length)
15883
+ void this.importRegionFiles(files.map((f) => importFile(f, f.webkitRelativePath || f.name)));
15884
+ }
15885
+ /** Whether the host can pick regions on its server ("Import regions from server"). */
15886
+ get canImportFromServer() {
15887
+ return this.persistence.canPickServerRegions();
15888
+ }
15889
+ /** "Import regions from server": the host picks files or a served region set; they import as chosen files do. */
15890
+ importFromServer() {
15891
+ this.importing = true;
15892
+ this.changed();
15893
+ this.persistence.pickServerRegions().subscribe({
15894
+ next: (pick) => {
15895
+ if (!pick)
15896
+ return;
15897
+ if (pick.kind === 'files') {
15898
+ void this.importRegionFiles(pick.files.map((f) => importFile(f.data, f.name)));
15899
+ return;
15900
+ }
15901
+ void this.importServedRegionSet(pick.url, pick.name);
15902
+ },
15903
+ error: (err) => this.importFailed(err),
15904
+ complete: () => {
15905
+ this.importing = false;
15906
+ this.changed();
15907
+ },
15908
+ });
15909
+ }
15910
+ /**
15911
+ * Import `files` (see planRegionImport): one GeoJSON file as before; a label volume as one
15912
+ * region source per slice, traced from it the first time the slice is shown, its classes named
15913
+ * (and treed) by the ontology chosen with it; a folder as one source per slice file. Either
15914
+ * replaces the regions and sources of every slice.
15915
+ */
15916
+ async importRegionFiles(files) {
15917
+ const plan = planRegionImport(files);
15918
+ if (!plan) {
15919
+ this.importFailed(new Error('Choose a GeoJSON file, a folder of GeoJSON files, or a .nii, .nii.gz or .nrrd label volume.'));
15920
+ return;
15921
+ }
15922
+ if (plan.kind === 'single') {
15923
+ await this.applyImportedRois(await plan.file.text());
15924
+ return;
15925
+ }
15926
+ const attach = this.regionApi.importSliceRegionSources?.bind(this.regionApi);
15927
+ if (!attach) {
15928
+ this.importFailed(new Error('This viewer cannot hold region sources.'));
15929
+ return;
15930
+ }
15931
+ // Taken before the files are read: decoding a volume takes a while, and the import must land
15932
+ // on the image it was started for, not on one shown meanwhile.
15933
+ const ticket = this.regionApi.regionImportTicket?.();
15934
+ try {
15935
+ const openers = new Map();
15936
+ let summary;
15937
+ if (plan.kind === 'volume') {
15938
+ const volume = await readLabelVolume(plan.volume.name, await plan.volume.bytes());
15939
+ const classes = labelClasses(plan.ontology ? JSON.parse(await plan.ontology.text()) : undefined);
15940
+ for (let z = 0; z < volume.slices; z++) {
15941
+ openers.set(z, () => openGeoJsonRegionSource(labelSliceGeoJson(volume, z, classes)));
15942
+ }
15943
+ summary = `${volume.slices} slices of ${volume.width} × ${volume.height}`;
15944
+ this.warnIfSizeDiffers(volume.width, volume.height);
15945
+ }
15946
+ else {
15947
+ const regionSet = plan.regionSet ? JSON.parse(await plan.regionSet.text()) : null;
15948
+ plan.slices.forEach((file, z) => openers.set(z, async () => openGeoJsonRegionSource(await folderSliceGeoJson(file, regionSet))));
15949
+ summary = `${plan.slices.length} slice files`;
15950
+ }
15951
+ const attached = attach(openers, ticket);
15952
+ if (attached === null) {
15953
+ this.importStale();
15954
+ return;
15955
+ }
15956
+ this.afterSourceImport();
15957
+ this.toast('success', 'Regions imported', `${summary}; ${attached} slice${attached === 1 ? '' : 's'} annotated.`);
15958
+ }
15959
+ catch (err) {
15960
+ this.importFailed(err);
15961
+ }
15962
+ }
15963
+ /** A region set the host's server serves (jit-service's /regions/{id}/), read through its HttpClient. */
15964
+ async importServedRegionSet(url, name) {
15965
+ const importSource = this.regionApi.importRegionSourceOpener?.bind(this.regionApi);
15966
+ if (!importSource) {
15967
+ this.importFailed(new Error('This viewer cannot hold region sources.'));
15968
+ return;
15969
+ }
15970
+ const ticket = this.regionApi.regionImportTicket?.();
15971
+ try {
15972
+ const applied = await importSource(() => openHttpRegionSource(url, { http: this.http ?? undefined }), ticket);
15973
+ if (applied === false) {
15974
+ this.importStale();
15975
+ return;
15976
+ }
15977
+ this.afterSourceImport();
15978
+ this.toast('success', 'Regions imported', name ?? url);
15979
+ }
15980
+ catch (err) {
15981
+ this.importFailed(err);
15982
+ }
15983
+ }
15984
+ /** An import that finished after another image was shown (or another import began): dropped. */
15985
+ importStale() {
15986
+ this.toast('info', 'Import discarded', 'The image changed while the regions were being read.');
15987
+ this.changed();
15988
+ }
15989
+ /** The table and selection after the regions were replaced by imported sources. */
15990
+ afterSourceImport() {
15991
+ this.selectedRegions = [];
15992
+ this.paginatorFirst = 0;
15993
+ this.commit([]);
15994
+ this.onSelectionChanged();
15995
+ }
15996
+ /** Warn when an imported volume's slices are not the image's size: its outlines would not fit. */
15997
+ warnIfSizeDiffers(width, height) {
15998
+ const meta = this.regionApi.getMaskImageSize();
15999
+ if (meta && (meta.width !== width || meta.height !== height)) {
16000
+ this.toast('info', 'Different size', `The volume's slices are ${width} × ${height}; the image is ${meta.width} × ${meta.height}.`);
16001
+ }
16002
+ }
16003
+ importFailed(err) {
16004
+ this.importing = false;
16005
+ this.messageService.add({
16006
+ key: VIZ_TOAST_KEY,
16007
+ severity: 'error',
16008
+ summary: 'Error importing regions',
16009
+ detail: `${err?.message ?? err}`,
16010
+ });
16011
+ console.error('Error importing regions:', err);
16012
+ this.changed();
15456
16013
  }
15457
16014
  /**
15458
16015
  * Replace the regions with an imported GeoJSON file's, and the slice's region source with them.
@@ -15699,7 +16256,7 @@ class RegionEditorComponent {
15699
16256
  this.messageService.add({ key: VIZ_TOAST_KEY, severity, summary, detail });
15700
16257
  }
15701
16258
  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 }); }
16259
+ 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
16260
  }
15704
16261
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.9", ngImport: i0, type: RegionEditorComponent, decorators: [{
15705
16262
  type: Component,
@@ -15722,7 +16279,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.9", ngImpor
15722
16279
  RegionColorDialogComponent,
15723
16280
  ManageClassesDialogComponent,
15724
16281
  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"] }]
16282
+ ], 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
16283
  }], ctorParameters: () => [{ type: undefined, decorators: [{
15727
16284
  type: Inject,
15728
16285
  args: [REGION_EDITOR_API]
@@ -22971,6 +23528,8 @@ class VisualizerComponent {
22971
23528
  /** Isosurface band as a 0–255 slider position, mapped onto the volume's real
22972
23529
  * intensity range by the renderer. Defaults to the full range. */
22973
23530
  this.isoRange = [0, 255];
23531
+ /** Waits for a viewer created outside the document to be attached (see {@link whenInDocument}). */
23532
+ this.attachObserver = null;
22974
23533
  /** Debounced z-slice scrubbing (see SliceScrubber — refactoring plan Step 7). */
22975
23534
  this.scrubber = new SliceScrubber((z) => {
22976
23535
  this.plotService.setZIndex(z);
@@ -23250,12 +23809,35 @@ class VisualizerComponent {
23250
23809
  // re-enters the zone (ngZone.run) only when it actually changes view state.
23251
23810
  this.shortcuts = new ViewerShortcuts(this.shortcutHost(), this.ngZone);
23252
23811
  this.shortcuts.attach();
23253
- this.onViewReady();
23812
+ this.whenInDocument(() => this.onViewReady());
23254
23813
  }
23255
23814
  /** The plot div exists now: run an image-less draw that arrived before it did. */
23256
23815
  onViewReady() {
23257
23816
  this.render.viewIsReady(this.spatial.dataset, !!this.imageInfo);
23258
23817
  }
23818
+ /**
23819
+ * Run `ready` once this viewer is in the document. A host may create it detached — a closed
23820
+ * dialog's projected content, such as jit-ui's pipeline preview — and the backends find the
23821
+ * plot div by id, so a draw then found no target and alerted "the renderer could not start"
23822
+ * over another viewer. Until then its view is not ready, and an image-less draw waits.
23823
+ */
23824
+ whenInDocument(ready) {
23825
+ const el = this.hostRef?.nativeElement;
23826
+ if (!el || el.isConnected || typeof ResizeObserver === 'undefined') {
23827
+ ready();
23828
+ return;
23829
+ }
23830
+ // Attached and laid out, its size changes from nothing: that is when it can be drawn into.
23831
+ const observer = new ResizeObserver(() => {
23832
+ if (!el.isConnected)
23833
+ return;
23834
+ observer.disconnect();
23835
+ this.attachObserver = null;
23836
+ this.ngZone.run(ready);
23837
+ });
23838
+ this.attachObserver = observer;
23839
+ observer.observe(el);
23840
+ }
23259
23841
  /** What the keyboard, wheel and context-menu shortcuts act on. Their listeners live
23260
23842
  * outside this view, so every action that changes what it shows marks it for check. */
23261
23843
  shortcutHost() {
@@ -23304,6 +23886,7 @@ class VisualizerComponent {
23304
23886
  this.toggleDragMode('select');
23305
23887
  }
23306
23888
  ngOnDestroy() {
23889
+ this.attachObserver?.disconnect();
23307
23890
  // A contributed mode's session ends first, while the viewer it drew over
23308
23891
  // still exists. The panel goes with this view, so no change detection.
23309
23892
  this.contributions.destroy();
@@ -27699,8 +28282,12 @@ class RegionSourceScope {
27699
28282
  /** The entry of the slice on screen: kept open. */
27700
28283
  this.shown = null;
27701
28284
  }
27702
- /** Attach `open` to `image` for `slices`, replacing (and closing) what was there. */
27703
- attach(image, open, slices) {
28285
+ /**
28286
+ * Attach `open` to `image` for `slices`, replacing (and closing) what was there. `imported` names
28287
+ * the slices it brings that a save must write (see {@link importedSlices}): for a source of one
28288
+ * slice, that slice when non-empty; for a `'planes'` source, the slices its planes cover.
28289
+ */
28290
+ attach(image, open, slices, imported = []) {
27704
28291
  const sources = this.imageSources(image);
27705
28292
  const entry = { open, source: null, ready: null };
27706
28293
  if (slices === 'planes') {
@@ -27710,6 +28297,7 @@ class RegionSourceScope {
27710
28297
  sources.perSlice.forEach((e) => this.close(e));
27711
28298
  sources.perSlice.clear();
27712
28299
  sources.taken.clear();
28300
+ sources.imported = new Set(imported);
27713
28301
  }
27714
28302
  else {
27715
28303
  const old = sources.perSlice.get(slices.slice);
@@ -27718,6 +28306,10 @@ class RegionSourceScope {
27718
28306
  entry.plane = slices.plane ?? 0;
27719
28307
  sources.perSlice.set(slices.slice, entry);
27720
28308
  sources.taken.delete(slices.slice);
28309
+ if (imported.length)
28310
+ sources.imported.add(slices.slice);
28311
+ else
28312
+ sources.imported.delete(slices.slice);
27721
28313
  }
27722
28314
  }
27723
28315
  /** Drop every source of `image` (closing the open ones) and what was taken from them. */
@@ -27739,6 +28331,7 @@ class RegionSourceScope {
27739
28331
  this.close(entry);
27740
28332
  sources.perSlice.delete(slice);
27741
28333
  sources.taken.delete(slice);
28334
+ sources.imported.delete(slice);
27742
28335
  }
27743
28336
  /** The slice on screen (null: none): its source stays open while others are read. */
27744
28337
  show(image, slice) {
@@ -27782,6 +28375,11 @@ class RegionSourceScope {
27782
28375
  const taken = image ? this.images.get(image)?.taken : undefined;
27783
28376
  return taken ? Array.from(taken.values()).flatMap((ids) => Array.from(ids)) : [];
27784
28377
  }
28378
+ /** The slices of `image` whose source was imported (see {@link attach}), in order. */
28379
+ importedSlices(image) {
28380
+ const imported = image ? this.images.get(image)?.imported : undefined;
28381
+ return imported ? Array.from(imported).sort((a, b) => a - b) : [];
28382
+ }
27785
28383
  /** The slices of `image` with anything taken from their source. */
27786
28384
  slicesWithTaken(image) {
27787
28385
  const taken = image ? this.images.get(image)?.taken : undefined;
@@ -27806,7 +28404,7 @@ class RegionSourceScope {
27806
28404
  imageSources(image) {
27807
28405
  let sources = this.images.get(image);
27808
28406
  if (!sources) {
27809
- sources = { planes: null, perSlice: new Map(), taken: new Map() };
28407
+ sources = { planes: null, perSlice: new Map(), taken: new Map(), imported: new Set() };
27810
28408
  this.images.set(image, sources);
27811
28409
  }
27812
28410
  return sources;
@@ -28824,8 +29422,12 @@ class RegionStore {
28824
29422
  */
28825
29423
  getStackSaveSlices() {
28826
29424
  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));
29425
+ // A slice whose region source lost features (taken, deleted) is re-written too, and so is one
29426
+ // whose source was imported: it is on no file yet.
29427
+ const image = this.scope.activeImage;
29428
+ const changed = [
29429
+ ...new Set([...this.sources.slicesWithTaken(image), ...this.sources.importedSlices(image)]),
29430
+ ].filter((z) => !slices.has(z));
28829
29431
  if (!changed.length)
28830
29432
  return slices;
28831
29433
  for (const z of changed)
@@ -29218,13 +29820,19 @@ class RegionStore {
29218
29820
  * source was closed to save memory). `slices` says which slice of the current image it
29219
29821
  * serves: one (default: the slice shown; plane 0 of the source), or `'planes'`, every slice z
29220
29822
  * from plane z of a z-indexed source. Replaces the source those slices had.
29823
+ *
29824
+ * `imported` marks a source brought in over the image (an imported volume, folder or served set)
29825
+ * rather than the image's own regions: it is on no file yet, so a save writes its slices though
29826
+ * nothing was taken from it — `true` for a source of one slice, the slices its planes cover for a
29827
+ * `'planes'` source. The image's own sources are not marked: loading them changes nothing to save.
29221
29828
  */
29222
- attachRegionSource(source, slices = { slice: this.sourceSlice }) {
29829
+ attachRegionSource(source, slices = { slice: this.sourceSlice }, imported = false) {
29223
29830
  const image = this.scope.activeImage;
29224
29831
  if (!image)
29225
29832
  return;
29226
29833
  const open = typeof source === 'function' ? source : () => Promise.resolve(source);
29227
- this.sources.attach(image, open, slices);
29834
+ const importedSlices = imported === false ? [] : imported === true ? (slices === 'planes' ? [] : [slices.slice]) : imported;
29835
+ this.sources.attach(image, open, slices, importedSlices);
29228
29836
  this.sourcesVersion++;
29229
29837
  this.refreshSourceView();
29230
29838
  }
@@ -29602,22 +30210,50 @@ class RegionStore {
29602
30210
  return view.source.getIndex(view.plane, { ...query, exclude: Array.from(view.taken) });
29603
30211
  }
29604
30212
  /**
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.
30213
+ * `regions` as a GeoJSON FeatureCollection string merged with the region sources behind them:
30214
+ * the sources' features that were not taken (or deleted), then `regions`. Without a source,
30215
+ * {@link getGeoJsonString}.
30216
+ *
30217
+ * `slice` names one slice-file of a folder stack (a per-slice-file save): its source's features,
30218
+ * on the file's own plane — of a z-indexed source over every slice, only that slice's plane.
30219
+ * Without it, the export is the image's: on a stack saved as one file, every slice's source
30220
+ * (each feature on its slice's plane, `geometry.plane.z`) and a z-indexed source's every plane;
30221
+ * otherwise the source of the slice shown.
29609
30222
  */
29610
- async getGeoJsonWithSource(regions, slice = this.sourceSlice) {
30223
+ async getGeoJsonWithSource(regions, slice) {
29611
30224
  const image = this.scope.activeImage;
29612
30225
  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
30226
  const features = regions
29617
30227
  .filter((r) => !r.isProfile?.())
29618
30228
  .map((r) => featureFromRegion(r))
29619
30229
  .filter((f) => !!f);
29620
- const exclude = planes ? this.sources.allTaken(image) : Array.from(this.sources.taken(image, slice));
30230
+ if (slice === undefined && this.scope.stackMode && this.scope.saveLayout === 'combined' && !planes) {
30231
+ const sliced = this.sources.slicesWithSource(image);
30232
+ if (sliced.length) {
30233
+ const sourceFeatures = [];
30234
+ for (const z of sliced) {
30235
+ const binding = await this.sources.binding(image, z);
30236
+ if (!binding)
30237
+ continue;
30238
+ const exported = await binding.source.exportGeoJson(Array.from(this.sources.taken(image, z)), []);
30239
+ sourceFeatures.push(...onPlane(planeFeatures(exported, binding.plane), z));
30240
+ }
30241
+ return JSON.stringify({ type: 'FeatureCollection', features: [...sourceFeatures, ...features] });
30242
+ }
30243
+ }
30244
+ const at = slice ?? this.sourceSlice;
30245
+ const binding = await this.sources.binding(image, at);
30246
+ if (!binding)
30247
+ return regionsToGeoJson(regions);
30248
+ if (planes && slice !== undefined) {
30249
+ // One slice-file of a z-indexed source: that slice's plane, written on the file's plane 0.
30250
+ const exported = await binding.source.exportGeoJson(this.sources.allTaken(image), []);
30251
+ return JSON.stringify({
30252
+ type: 'FeatureCollection',
30253
+ features: [...onPlane(planeFeatures(exported, binding.plane), 0), ...features],
30254
+ });
30255
+ }
30256
+ const exclude = planes ? this.sources.allTaken(image) : Array.from(this.sources.taken(image, at));
29621
30257
  return binding.source.exportGeoJson(exclude, features);
29622
30258
  }
29623
30259
  // ── Region look ────────────────────────────────────────────────────────
@@ -29858,6 +30494,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.9", ngImpor
29858
30494
  type: Injectable,
29859
30495
  args: [{ providedIn: 'root' }]
29860
30496
  }], ctorParameters: () => [{ type: VisualizerStore }] });
30497
+ /** The features of an exported FeatureCollection on `plane` (a missing plane is plane 0). */
30498
+ function planeFeatures(geoJson, plane) {
30499
+ const features = (JSON.parse(geoJson)?.features ?? []);
30500
+ return features.filter((f) => (f?.geometry?.plane?.z ?? 0) === plane);
30501
+ }
30502
+ /** `features` moved onto plane `z`, as QuPath writes it: `geometry.plane`, left out for plane 0. */
30503
+ function onPlane(features, z) {
30504
+ return features.map((f) => {
30505
+ const { plane: _plane, ...geometry } = f.geometry ?? {};
30506
+ return { ...f, geometry: z === 0 ? geometry : { ...geometry, plane: { c: -1, z, t: 0 } } };
30507
+ });
30508
+ }
29861
30509
 
29862
30510
  /**
29863
30511
  * Pure intensity-profile math: sampling a line through a pixel frame, placing a
@@ -31396,6 +32044,12 @@ function silenceOsdMultiImageAdvisory() {
31396
32044
  });
31397
32045
  osd.__multiImageFilterInstalled = true;
31398
32046
  }
32047
+ /** Tile cache entries a single image keeps per tiled image (one per channel when drawn per channel). */
32048
+ const TILES_PER_IMAGE = 150;
32049
+ /** The most tiles a single per-channel image keeps (each a decoded 512² tile, ~1 MB). */
32050
+ const MAX_CHANNEL_TILES = 1200;
32051
+ /** Tile requests in flight at once for an image drawn per channel. */
32052
+ const CHANNEL_REQUEST_LIMIT = 12;
31399
32053
  /**
31400
32054
  * The options object handed to the OpenSeadragon factory. Pure: every value
31401
32055
  * that depends on the service comes in through `input`.
@@ -31481,7 +32135,17 @@ function buildViewerOptions(input) {
31481
32135
  // so they get a much larger tile budget — otherwise switching slices evicts
31482
32136
  // each other's tiles and scrubbing back re-fetches. Scaled to the resident
31483
32137
  // slice count; single images keep the lean default.
31484
- maxImageCacheCount: input.sliceCount > 1 ? Math.min(2400, Math.max(600, input.maxSlices * 40)) : 150,
32138
+ // A single image drawn per channel needs room for every visible channel's tiles in view: at
32139
+ // 150 a 22-channel plate evicted tiles while they were still being converted.
32140
+ maxImageCacheCount: input.sliceCount > 1
32141
+ ? Math.min(2400, Math.max(600, input.maxSlices * 40))
32142
+ : Math.min(MAX_CHANNEL_TILES, TILES_PER_IMAGE * Math.max(1, Math.ceil((input.channelImages ?? 1) / 2))),
32143
+ // OSD sends every tile request at once by default (imageLoaderLimit 0) and times each out
32144
+ // after 30 s from when it is sent. A view of a 22-channel image asks for hundreds of tiles;
32145
+ // the browser holds all but a few connections' worth back, so those timed out while still
32146
+ // waiting their turn. Limited, they wait in OSD's queue (not timed), where a channel being
32147
+ // hidden drops its own (SliceCache.revealChannelSlice). Single images keep OSD's default.
32148
+ ...((input.channelImages ?? 1) > 1 ? { imageLoaderLimit: CHANNEL_REQUEST_LIMIT } : {}),
31485
32149
  };
31486
32150
  }
31487
32151
 
@@ -32935,6 +33599,234 @@ class OsdRegionOverlay {
32935
33599
  }
32936
33600
  }
32937
33601
 
33602
+ /**
33603
+ * "Transcripts reloading…" at the bottom of the canvas while a layer's data is in flight: one
33604
+ * line naming every layer still loading, on a semi-opaque backing so it reads over any tissue.
33605
+ * Longer work names itself ("Building HPF in 3D…") and joins the line.
33606
+ *
33607
+ * Shown only once something has been loading for {@link SHOW_AFTER_MS}, so a redraw served from
33608
+ * cache does not flash it.
33609
+ */
33610
+ const SHOW_AFTER_MS = 150;
33611
+ class NapariLoadingBadge {
33612
+ constructor(host) {
33613
+ this.names = [];
33614
+ this.tasks = [];
33615
+ this.timer = null;
33616
+ if (getComputedStyle(host).position === 'static')
33617
+ host.style.position = 'relative';
33618
+ this.el = document.createElement('div');
33619
+ this.el.className = 'napari-loading-badge';
33620
+ this.el.setAttribute('role', 'status');
33621
+ this.el.setAttribute('aria-live', 'polite');
33622
+ Object.assign(this.el.style, {
33623
+ position: 'absolute',
33624
+ left: '50%',
33625
+ bottom: '12px',
33626
+ transform: 'translateX(-50%)',
33627
+ padding: '4px 12px',
33628
+ borderRadius: '12px',
33629
+ background: 'rgba(0, 0, 0, 0.6)',
33630
+ color: '#fff',
33631
+ font: '12px/1.4 system-ui, sans-serif',
33632
+ whiteSpace: 'nowrap',
33633
+ pointerEvents: 'none',
33634
+ zIndex: '5',
33635
+ display: 'none',
33636
+ });
33637
+ host.appendChild(this.el);
33638
+ }
33639
+ /** The layers loading now, and the tasks running (their messages); both empty hides the badge. */
33640
+ set(names, tasks = []) {
33641
+ this.names = [...new Set(names)];
33642
+ this.tasks = [...new Set(tasks)];
33643
+ if (!this.names.length && !this.tasks.length) {
33644
+ if (this.timer)
33645
+ clearTimeout(this.timer);
33646
+ this.timer = null;
33647
+ this.el.style.display = 'none';
33648
+ return;
33649
+ }
33650
+ const parts = this.names.length ? [`${listOf(this.names)} reloading…`, ...this.tasks] : this.tasks;
33651
+ this.el.textContent = parts.join(' · ');
33652
+ if (this.el.style.display === 'block' || this.timer)
33653
+ return;
33654
+ this.timer = setTimeout(() => {
33655
+ this.timer = null;
33656
+ if (this.names.length || this.tasks.length)
33657
+ this.el.style.display = 'block';
33658
+ }, SHOW_AFTER_MS);
33659
+ }
33660
+ /** The text currently set (shown or about to be). */
33661
+ get text() {
33662
+ return this.names.length || this.tasks.length ? (this.el.textContent ?? '') : '';
33663
+ }
33664
+ destroy() {
33665
+ if (this.timer)
33666
+ clearTimeout(this.timer);
33667
+ this.el.remove();
33668
+ }
33669
+ }
33670
+ /** "A", "A and b", "A, b and c": the first as given, the rest with a lower-cased initial. */
33671
+ function listOf(names) {
33672
+ const [first, ...rest] = names;
33673
+ const lower = rest.map((n) => n.charAt(0).toLowerCase() + n.slice(1));
33674
+ const all = [first, ...lower];
33675
+ return all.length === 1 ? all[0] : `${all.slice(0, -1).join(', ')} and ${all[all.length - 1]}`;
33676
+ }
33677
+
33678
+ /**
33679
+ * "x reloading…" at the bottom of the canvas, aggregated over everything that loads into one
33680
+ * scene: image tiles (the tile client), observation colourings (the spatial scenes) and the
33681
+ * spatial tile layers' own loads (which report a list of layer names).
33682
+ *
33683
+ * Each load is a {@link begin} that hands back its own `end`. {@link reset} starts a new scene:
33684
+ * the counts are dropped, and an `end` from a load that began before it is a no-op, so a load
33685
+ * that settles after a re-plot can neither un-count the new scene's work nor drive a count below
33686
+ * zero (which hid the next scene's "Observations reloading…" until a second load overlapped it).
33687
+ *
33688
+ * A {@link beginTask} is work that outlives a scene (a region's 3D reconstruction, built for
33689
+ * whichever volume is on screen when it is done): its message stays across a reset.
33690
+ */
33691
+ class LoadingBadgeState {
33692
+ constructor() {
33693
+ this.host = null;
33694
+ this.badge = null;
33695
+ this.counts = new Map();
33696
+ this.tileLayers = [];
33697
+ this.generation = 0;
33698
+ this.tasks = new Map();
33699
+ }
33700
+ /** The element the badge is drawn into (the plot host); set per plot. */
33701
+ attach(host) {
33702
+ this.host = host;
33703
+ if (this.tasks.size)
33704
+ this.show();
33705
+ }
33706
+ /** One load of `source` started; call the returned function once it settles. */
33707
+ begin(source) {
33708
+ const generation = this.generation;
33709
+ this.counts.set(source, (this.counts.get(source) ?? 0) + 1);
33710
+ this.show();
33711
+ let ended = false;
33712
+ return () => {
33713
+ if (ended || generation !== this.generation)
33714
+ return;
33715
+ ended = true;
33716
+ this.counts.set(source, (this.counts.get(source) ?? 0) - 1);
33717
+ this.show();
33718
+ };
33719
+ }
33720
+ /** A task started, shown by its `message` ("Building HPF in 3D…"); call the returned function
33721
+ * once it settles. */
33722
+ beginTask(message) {
33723
+ const id = Symbol(message);
33724
+ this.tasks.set(id, message);
33725
+ this.show();
33726
+ return () => {
33727
+ if (this.tasks.delete(id))
33728
+ this.show();
33729
+ };
33730
+ }
33731
+ /** The spatial tile layers still loading (their own names, in their order). */
33732
+ setTileLayers(layers) {
33733
+ this.tileLayers = layers;
33734
+ this.show();
33735
+ }
33736
+ /** The text the badge has set (shown, or about to be once the show delay passes). */
33737
+ get text() {
33738
+ return this.badge?.text ?? '';
33739
+ }
33740
+ /** A new scene: forget every count and remove the badge (tasks show again on the next attach). */
33741
+ reset() {
33742
+ this.generation++;
33743
+ this.counts.clear();
33744
+ this.tileLayers = [];
33745
+ this.badge?.destroy();
33746
+ this.badge = null;
33747
+ }
33748
+ show() {
33749
+ if (!this.host)
33750
+ return;
33751
+ this.badge ??= new NapariLoadingBadge(this.host);
33752
+ this.badge.set([
33753
+ ...((this.counts.get('Image') ?? 0) > 0 ? ['Image'] : []),
33754
+ ...((this.counts.get('Observations') ?? 0) > 0 ? ['Observations'] : []),
33755
+ ...this.tileLayers,
33756
+ ], [...this.tasks.values()]);
33757
+ }
33758
+ }
33759
+
33760
+ /**
33761
+ * "Image reloading…" over the OpenSeadragon view while it fetches the tiles of what it shows, as
33762
+ * napari's views show it for their image, cells and transcripts (the same badge). Driven by each
33763
+ * world item's own `fully-loaded-change` (an image, a stack slice, a channel), so it covers every
33764
+ * tile source alike, and shown only after a short delay, so tiles served from cache never flash it.
33765
+ *
33766
+ * Only items on screen count (opacity above 0): a hidden channel loads nothing by design (it is
33767
+ * not preloaded), so it never becomes fully loaded, and a slice cached or loading in the
33768
+ * background is not what the view shows. An item is weighed again when its opacity changes.
33769
+ */
33770
+ class OsdTileLoading {
33771
+ constructor(viewer) {
33772
+ this.viewer = viewer;
33773
+ this.state = new LoadingBadgeState();
33774
+ /** Each item still loading, with the function that un-counts it. */
33775
+ this.loading = new Map();
33776
+ this.watched = new Map();
33777
+ this.onAdd = (e) => this.watch(e.item);
33778
+ this.onRemove = (e) => this.unwatch(e.item);
33779
+ this.state.attach(viewer.element);
33780
+ viewer.world.addHandler('add-item', this.onAdd);
33781
+ viewer.world.addHandler('remove-item', this.onRemove);
33782
+ for (let i = 0; i < viewer.world.getItemCount(); i++)
33783
+ this.watch(viewer.world.getItemAt(i));
33784
+ }
33785
+ /** The badge's text (shown, or about to be). */
33786
+ get text() {
33787
+ return this.state.text;
33788
+ }
33789
+ destroy() {
33790
+ this.viewer.world.removeHandler('add-item', this.onAdd);
33791
+ this.viewer.world.removeHandler('remove-item', this.onRemove);
33792
+ for (const item of [...this.watched.keys()])
33793
+ this.unwatch(item);
33794
+ this.state.reset();
33795
+ }
33796
+ watch(item) {
33797
+ if (!item || this.watched.has(item))
33798
+ return;
33799
+ const handler = () => this.set(item, this.isLoading(item));
33800
+ this.watched.set(item, handler);
33801
+ item.addHandler('fully-loaded-change', handler);
33802
+ item.addHandler('opacity-change', handler);
33803
+ handler();
33804
+ }
33805
+ unwatch(item) {
33806
+ const handler = this.watched.get(item);
33807
+ if (handler) {
33808
+ item.removeHandler('fully-loaded-change', handler);
33809
+ item.removeHandler('opacity-change', handler);
33810
+ }
33811
+ this.watched.delete(item);
33812
+ this.set(item, false);
33813
+ }
33814
+ /** Whether the item is on screen and still fetching its tiles. */
33815
+ isLoading(item) {
33816
+ const opacity = typeof item.getOpacity === 'function' ? item.getOpacity() : 1;
33817
+ return opacity > 0 && !item.getFullyLoaded();
33818
+ }
33819
+ set(item, loading) {
33820
+ const end = this.loading.get(item);
33821
+ if (loading && !end)
33822
+ this.loading.set(item, this.state.begin('Image'));
33823
+ else if (!loading && end) {
33824
+ end();
33825
+ this.loading.delete(item);
33826
+ }
33827
+ }
33828
+ }
33829
+
32938
33830
  /**
32939
33831
  * A physical scale bar drawn over an OpenSeadragon viewer. The length maths (the nice 1/2/5 × 10ⁿ
32940
33832
  * length nearest the target, and its unit) are napari-js's `scaleBarFor` / `formatLength`, which
@@ -33790,7 +34682,10 @@ class SliceCache {
33790
34682
  /**
33791
34683
  * Add slice z's channel group: one additively-composited ('lighter' = Fiji
33792
34684
  * "Composite") TiledImage per channel, hidden (opacity 0) until revealed.
33793
- * Each is preloaded so the background loader can pre-fill every slice.
34685
+ * The visible channels' are preloaded, so the background loader can pre-fill
34686
+ * every slice; a hidden channel's loads nothing until it is shown (a 22-channel
34687
+ * plate fetched and decoded every channel's tiles, overflowing the tile cache:
34688
+ * tiles evicted mid-conversion failed to decode).
33794
34689
  * Cached so a revisit is an instant opacity toggle — no re-fetch, no
33795
34690
  * white-flicker. Mirrors addSlice for the composite path.
33796
34691
  */
@@ -33831,7 +34726,7 @@ class SliceCache {
33831
34726
  width: 1,
33832
34727
  opacity: 0, // revealed by revealChannelSlice once the group is in
33833
34728
  compositeOperation: 'lighter',
33834
- preload: true,
34729
+ preload: this.host.channelVisible(c),
33835
34730
  success: (e) => {
33836
34731
  const item = e?.item;
33837
34732
  if (token !== this.sliceLoadToken) {
@@ -33854,17 +34749,37 @@ class SliceCache {
33854
34749
  }
33855
34750
  }
33856
34751
  /** Reveal slice z's channel group (each visible channel → opacity 1) and
33857
- * hide every other cached slice's group. Instant; no fetch/decode. */
34752
+ * hide every other cached slice's group. Instant; no fetch/decode. Only the
34753
+ * visible channels keep loading (preload): a hidden channel's tiles are not
34754
+ * fetched or decoded until it is shown. */
33858
34755
  revealChannelSlice(z) {
34756
+ const hidden = new Set();
33859
34757
  for (const [zz, group] of this.channelSliceItems) {
33860
34758
  const active = zz === z;
33861
34759
  group.forEach((it, c) => {
33862
34760
  if (!it)
33863
34761
  return;
33864
34762
  const visible = this.host.channelVisible(c);
34763
+ if (!visible)
34764
+ hidden.add(it);
34765
+ quiet(() => it.setPreload?.(visible));
33865
34766
  quiet(() => it.setOpacity(active && visible ? 1 : 0));
33866
34767
  });
33867
34768
  }
34769
+ this.dropQueuedTiles(hidden);
34770
+ }
34771
+ /** Drop the tile requests still waiting in OSD's queue for `items` (channels just hidden), so
34772
+ * the visible channels' tiles are not stuck behind them. A request already sent completes. */
34773
+ dropQueuedTiles(items) {
34774
+ const loader = this.host.viewer()?.imageLoader;
34775
+ if (!items.size || !Array.isArray(loader?.jobQueue))
34776
+ return;
34777
+ loader.jobQueue = loader.jobQueue.filter((job) => {
34778
+ if (!items.has(job?.tile?.tiledImage))
34779
+ return true;
34780
+ quiet(() => job.abort?.());
34781
+ return false;
34782
+ });
33868
34783
  }
33869
34784
  /** Show the given composite slice (opacity 1) and hide all other cached slices. */
33870
34785
  showOnlySlice(z) {
@@ -34028,12 +34943,18 @@ class SliceCache {
34028
34943
  const it = this.sliceItems.get(z);
34029
34944
  return it ? [it] : [];
34030
34945
  }
34031
- /** Whether slice z's tiles are still streaming (any cached image not fully loaded). */
34946
+ /** Whether slice z's tiles are still streaming (any cached image not fully loaded). A hidden
34947
+ * channel loads nothing, so it never becomes fully loaded: only the visible channels count, or
34948
+ * the background loader would wait on it forever. */
34032
34949
  sliceTilesLoading(z) {
34033
34950
  const g = this.sliceGroup(z);
34034
34951
  if (!g.length)
34035
34952
  return false;
34036
- return g.some((it) => it && typeof it.getFullyLoaded === 'function' && !it.getFullyLoaded());
34953
+ const multi = this.host.isMultiChannel();
34954
+ return g.some((it, c) => it &&
34955
+ (!multi || this.host.channelVisible(c)) &&
34956
+ typeof it.getFullyLoaded === 'function' &&
34957
+ !it.getFullyLoaded());
34037
34958
  }
34038
34959
  /** Remove slice z's cached image(s) from the world and the active cache. */
34039
34960
  removeCachedSlice(z) {
@@ -35526,6 +36447,8 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
35526
36447
  this.viewer = null;
35527
36448
  this.overlay = null;
35528
36449
  this.scaleBar = null;
36450
+ /** "Image reloading…" while the view fetches its tiles. */
36451
+ this.tileLoading = null;
35529
36452
  this.descriptor = null;
35530
36453
  /** Base64 RawFileInfo + current z-slice, kept so setZIndex can rebuild the
35531
36454
  * tile source and swap slices live (stack navigation). */
@@ -35812,6 +36735,7 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
35812
36735
  authHeaders: this.authHeaders,
35813
36736
  sliceCount: d.z ?? 1,
35814
36737
  maxSlices: this.cache.maxSlices(),
36738
+ channelImages: this.isMultiChannel ? (d.channels ?? 1) : 1,
35815
36739
  });
35816
36740
  this.viewer = createViewer(options);
35817
36741
  return openViewer(this.viewer, tileSource, (v) => this.onViewerOpened(v, d, simple, plotDiv));
@@ -35827,6 +36751,8 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
35827
36751
  this.overlay = new OsdRegionOverlay(viewer, this.regionStore);
35828
36752
  this.coordTransform = new OsdCoordinateTransform(viewer);
35829
36753
  this.scaleBar = new OsdScaleBar(viewer, d.mppX ?? 0);
36754
+ this.tileLoading?.destroy();
36755
+ this.tileLoading = new OsdTileLoading(viewer);
35830
36756
  if (simple) {
35831
36757
  // A single, self-contained image — no slice cache to seed. Its z-scrub and
35832
36758
  // recomposite re-open the viewer; one persistent handler reports a failed
@@ -35886,6 +36812,8 @@ class OpenSeadragonVisualizerService extends BaseStoreVisualizer {
35886
36812
  this.scaleBar.destroy();
35887
36813
  this.scaleBar = null;
35888
36814
  }
36815
+ this.tileLoading?.destroy();
36816
+ this.tileLoading = null;
35889
36817
  if (this.viewer) {
35890
36818
  this.viewer.destroy();
35891
36819
  this.viewer = null;
@@ -37387,164 +38315,6 @@ class NapariDisplayState {
37387
38315
  }
37388
38316
  }
37389
38317
 
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
38318
  const SVG_NS$1 = 'http://www.w3.org/2000/svg';
37549
38319
  /** Rendered handle size (screen px). */
37550
38320
  const HANDLE_SIZE = 7;
@@ -45918,6 +46688,8 @@ class RoutingVisualizerService {
45918
46688
  this.sectionSubs = new Subscription();
45919
46689
  /** The image info and screen height of the last plot (what the slice pane mounts). */
45920
46690
  this.plottedInfo = null;
46691
+ /** The current region import (see regionImportTicket): its id and the image it was taken for. */
46692
+ this.importTicket = { id: 0, image: undefined };
45921
46693
  this.plottedHeight = 0;
45922
46694
  this.viewOptions = {
45923
46695
  setNavigatorVisible: (visible) => {
@@ -46488,6 +47260,94 @@ class RoutingVisualizerService {
46488
47260
  dropRegionSource() {
46489
47261
  return this.regionStore.dropSliceRegionSource();
46490
47262
  }
47263
+ /**
47264
+ * A ticket for a region import that is about to read or decode its files: the import methods
47265
+ * given it change nothing once it is stale — another import took a ticket since, another image
47266
+ * was plotted, or the service was destroyed — so an import that finishes late never lands on an
47267
+ * image it was not started for.
47268
+ */
47269
+ regionImportTicket() {
47270
+ this.importTicket = { id: this.importTicket.id + 1, image: this.importImageKey() };
47271
+ return this.importTicket.id;
47272
+ }
47273
+ /** Whether `ticket` is still the current import's, on the image it was taken for. */
47274
+ importTicketValid(ticket) {
47275
+ return ticket === this.importTicket.id && this.importTicket.image === this.importImageKey();
47276
+ }
47277
+ importImageKey() {
47278
+ return RegionScopeCache.imageKey(this.plottedInfo ?? undefined);
47279
+ }
47280
+ /**
47281
+ * Replace the image's regions and region sources with one source per slice, as an imported label
47282
+ * volume or folder of per-slice region files gives them: `openers` by slice index, each opened
47283
+ * the first time its slice is shown. A single image takes slice 0's. Returns how many slices
47284
+ * have a source, or null (changing nothing) when `ticket` is stale (see
47285
+ * {@link regionImportTicket}). The sources are marked imported, so a save writes every slice.
47286
+ */
47287
+ importSliceRegionSources(openers, ticket) {
47288
+ if (ticket !== undefined && !this.importTicketValid(ticket))
47289
+ return null;
47290
+ this.regionImportTicket(); // supersedes any import still in flight
47291
+ const info = this.plottedInfo;
47292
+ this.regionStore.detachRegionSources();
47293
+ if (!info?.isStack) {
47294
+ const first = openers.get(0) ?? openers.values().next().value;
47295
+ this.regionStore.setRegions([]);
47296
+ this.regionStore.resetUndoHistory();
47297
+ if (!first)
47298
+ return 0;
47299
+ this.regionStore.attachRegionSource(first, { slice: 0 }, true);
47300
+ return 1;
47301
+ }
47302
+ this.enterImportedStack(info);
47303
+ for (const [z, open] of openers)
47304
+ this.regionStore.attachRegionSource(open, { slice: z }, true);
47305
+ return openers.size;
47306
+ }
47307
+ /**
47308
+ * Replace the image's regions and region sources with one imported source (a region set the
47309
+ * host's server serves): over every slice of a stack when it is z-indexed, else on the slice
47310
+ * shown, as {@link applyImageRois} lays out an image's own. Resolves false, changing nothing
47311
+ * (and disposing the source), when the import went stale while it opened: another import, or
47312
+ * another image (see {@link regionImportTicket}; one is taken here when `ticket` is not given).
47313
+ */
47314
+ async importRegionSourceOpener(open, ticket) {
47315
+ const mine = ticket ?? this.regionImportTicket();
47316
+ if (!this.importTicketValid(mine))
47317
+ return false;
47318
+ let first = await open();
47319
+ if (!this.importTicketValid(mine)) {
47320
+ first.dispose();
47321
+ return false;
47322
+ }
47323
+ const reuse = () => {
47324
+ const source = first;
47325
+ first = null;
47326
+ return source ? Promise.resolve(source) : open();
47327
+ };
47328
+ const info = this.plottedInfo;
47329
+ const planes = first.info.planes;
47330
+ const zIndexed = !!info?.isStack && planes.some((p) => p !== 0);
47331
+ this.regionStore.detachRegionSources();
47332
+ if (zIndexed) {
47333
+ this.enterImportedStack(info);
47334
+ this.regionStore.attachRegionSource(reuse, 'planes', [...planes]);
47335
+ return true;
47336
+ }
47337
+ this.regionStore.setRegions([]);
47338
+ this.regionStore.resetUndoHistory();
47339
+ this.regionStore.attachRegionSource(reuse, { slice: this.regionStore.getDisplaySlice() }, true);
47340
+ return true;
47341
+ }
47342
+ /** Empty every slice of a stack (its regions and undo history) for regions imported over it. */
47343
+ enterImportedStack(info) {
47344
+ const layout = this.regionStore.isStackMode()
47345
+ ? this.regionStore.getStackSaveLayout()
47346
+ : info.tiled === false
47347
+ ? 'per-slice-file'
47348
+ : 'combined';
47349
+ this.regionStore.enterStackMode(new Map(), this.regionStore.getDisplaySlice(), layout);
47350
+ }
46491
47351
  getRegionSourceView$() {
46492
47352
  return this.regionStore.getRegionSourceView$();
46493
47353
  }
@@ -46938,6 +47798,7 @@ class RoutingVisualizerService {
46938
47798
  * component for a `provideVisualization()` chain. The dataset subscription would
46939
47799
  * otherwise keep the whole isolated chain reachable from the root data port. */
46940
47800
  ngOnDestroy() {
47801
+ this.importTicket = { id: this.importTicket.id + 1, image: undefined }; // a late import changes nothing
46941
47802
  this.spatialFacade?.dispose();
46942
47803
  this.spatialFacade = null;
46943
47804
  this.regionUpdates.unsubscribe();
@@ -47165,5 +48026,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.9", ngImpor
47165
48026
  * Generated bundle index. Do not edit.
47166
48027
  */
47167
48028
 
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
48029
+ 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, provideVisualization 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, isContinuousColumn as aA, isDialogToolContribution as aB, isEmptyPresetSet as aC, isLabelVolumeName as aD, isLittleEndian as aE, isNapari3d as aF, isNapariIsosurface as aG, isNapariScatter as aH, isNapariScatter3d as aI, isNapariSurface as aJ, isNapariVolume as aK, isSamModelReady as aL, isSpatialOmics as aM, isSpatialOmics3d as aN, isThreeDimensional as aO, labelClasses as aP, labelSliceGeoJson as aQ, lutFor as aR, markerDiameters as aS, maskToIndices as aT, mutedFromSelection as aU, omicsLayout as aV, openGeoJsonRegionSource as aW, openHttpRegionSource as aX, parsePresetSet as aY, planRegionImport as aZ, pointInRing 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, importFile as aw, isBuiltinPlotType as ax, isCanvasToolId as ay, isCategoricalColumn as az, ALL_TOOLBAR_TOOLS as b, readLabelVolume as b0, rendererOwnsWheel as b1, resolveCategoryColors as b2, selectByCategory as b3, selectInRegions as b4, setDefaultSamModel as b5, setOrtWasmBase as b6, setSamModelUrls as b7, sortToolContributions as b8, visibleToolContributions as b9, 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 };
48030
+ //# sourceMappingURL=jax-data-science-sci-image-visualizer-jax-data-science-sci-image-visualizer-BELcx0jv.mjs.map