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