diff --git a/docs/advanced/datasets.md b/docs/advanced/datasets.md deleted file mode 100644 index 5850b315165a29e21ecb7c237fe1c5e5999f3914..0000000000000000000000000000000000000000 --- a/docs/advanced/datasets.md +++ /dev/null @@ -1,10 +0,0 @@ -# Fetching datasets from Knowledge Graph - -Human Brain Project Knowledge Graph is a metadata database consisting of datasets contributed by collaborators of the Human Brain Project and curated by human curoators in order to ensure the highest standards. - -!!! note - v2.3.0 changed the way datasets are fetched from the knowledge graph. - -Datasets are now fetched from the knowledge graph on a region of interest basis. The fetched datasets are then filtered only on the reference space basis. - -Currently, only parcellation regions are supported. diff --git a/docs/advanced/keyboard.md b/docs/advanced/keyboard.md index d331cb87b092483f4d9fb98e1d8ffa4bd1ebfe4b..734072c77cf20cf08ca995301e255d824c424f07 100644 --- a/docs/advanced/keyboard.md +++ b/docs/advanced/keyboard.md @@ -1,10 +1,18 @@ # Keyboard shortcuts -Please note that the keyboard shortcuts may alter the behaviour irreversibly. +| Action | Desktop | Touch devices | +| --- | --- | --- | +| Translate / Pan | `[drag]` any of the slice views | `[drag]` any of the slice views | +| Oblique rotation | `<shift>` + `[drag]` any of the slice views | `[rotate]` any of the slice views | +| Zoom | `[mousewheel]` | `[pinch zoom]` | +| Zoom | `[hover]` on any slice views > `[click]` magnifier | `[tap]` on magnifier | +| Next slice | `<ctrl>` + `[mousewheel]` / `[p]`revious / `[n]`ext | - | +| Next 10 slice | `<shift>` + `[mousewheel]` | - | +| Toggle delineation | `[q]` | - | +| Toggle cross hair | `[a]` | - | +| Multiple region select | `<ctrl>` + `[click]` on region | - | +| Context menu | `[right click]` | - | -|Key|Description| -|---|---| -|[0-9]|Toggle layer visibility| -|[h] [?]|Show help| -|[o]|Toggle orthographic/perspective _3d view_ | -|[a]|Toggle axis visibility | +--- + +You can enable touch interface by `[Portrait]` > `Settings` > `Enable Mobile UI` diff --git a/docs/advanced/otherVolumes.md b/docs/advanced/otherVolumes.md deleted file mode 100644 index b38f05425259ccdfd47e1f2e43086ec9055aa8a4..0000000000000000000000000000000000000000 --- a/docs/advanced/otherVolumes.md +++ /dev/null @@ -1,127 +0,0 @@ -# Displaying non-atlas volumes - -!!! warning - This section is still been developed, and the content/API may change in future versions. - -Interactive atlas can allow for arbitary volumes to be viewed, either in the context of a reference template or without. - -## Viewing standalone volumes - -`standaloneVolumes` query param is parsed, and parsed as JSON. They are passed directly to be rendered in nehuba. - -If both `standaloneVolumes` and `templateSelected` are present, the latter is ignored. - -### Query param - -standaloneVolumes - -### Example -``` -/?standaloneVolumes=%5B%22nifti%3A%2F%2Fhttp%3A%2F%2Flocalhost%3A1234%2Fnii.gz%22%2C%22precomputed%3A%2F%2Fhttp%3A%2F%2Flocalhost%3A4321%2Fvolume%22%5D -``` - -decoding and parsing as JSON: - -```json -[ - "nifti://http://localhost:1234/nii.gz", - "precomputed://http://localhost:4321/volume" -] -``` - -## Viewing registered volumes - -`previewingDatasetFiles` query param is parsed, and parsed as JSON. Then, relevant volume information is retrieved, and displayed with `templateSelected` and `parcellationSelected` - -### Query param - -previewingDatasetFiles - -### Example - -``` -/?templateSelected=Big+Brain+%28Histology%29&parcellationSelected=Grey%2FWhite+matter&previewingDatasetFiles=%5B%7B%22datasetId%22%3A%22minds%2Fcore%2Fdataset%2Fv1.0.0%2Fb08a7dbc-7c75-4ce7-905b-690b2b1e8957%22%2C%22filename%22%3A%22Overlay%20of%20data%20modalities%22%7D%5D -``` - -decoding and parsing as JSON: - -```json -[ - { - "datasetId":"minds/core/dataset/v1.0.0/b08a7dbc-7c75-4ce7-905b-690b2b1e8957", - "filename":"Overlay of data modalities" - } -] -``` - -The metadata fetched from these ID [^1] is as follows. - -[^1]: Currently, `kg-dataset-previewer` is used to resolve the preview URL. This is very likely going to change in the future. - -```json -{ - "name": "Overlay of data modalities", - "filename": "Overlay of data modalities", - "mimetype": "application/json", - "data": { - "iav-registered-volumes": { - "volumes": [ - { - "name": "PLI Fiber Orientation Red Channel", - "source": "precomputed://https://zam10143.zam.kfa-juelich.de/chumni/nifti/8b970e20de0e31b1b78ec9dba13d20319111189711983cb03ddbb7cc/BI-FOM-HSV_R", - "shader": "void main(){ float x = toNormalized(getDataValue()); if (x < 0.1) { emitTransparent(); } else { emitRGB(vec3(1.0 * x, x * 0., 0. * x )); } }", - "transform": [[0.7400000095367432, 0, 0, 11020745], [0, 0.2653011679649353, -0.6908077001571655, 2533286.5], [0, 0.6908077001571655, 0.2653011679649353, -32682974], [0, 0, 0, 1]], - "opacity": 1.0 - }, - { - "name": "PLI Fiber Orientation Green Channel", - "source": "precomputed://https://zam10143.zam.kfa-juelich.de/chumni/nifti/8b970e20de0e31b1b78ec9dba13d20319111189711983cb03ddbb7cc/BI-FOM-HSV_G", - "shader": "void main(){ float x = toNormalized(getDataValue()); if (x < 0.1) { emitTransparent(); } else { emitRGB(vec3(0. * x, x * 1., 0. * x )); } }", - "transform": [[0.7400000095367432, 0, 0, 11020745], [0, 0.2653011679649353, -0.6908077001571655, 2533286.5], [0, 0.6908077001571655, 0.2653011679649353, -32682974], [0, 0, 0, 1]], - "opacity": 0.5 - }, - { - "name": "PLI Fiber Orientation Blue Channel", - "source": "precomputed://https://zam10143.zam.kfa-juelich.de/chumni/nifti/8b970e20de0e31b1b78ec9dba13d20319111189711983cb03ddbb7cc/BI-FOM-HSV_B", - "shader": "void main(){ float x = toNormalized(getDataValue()); if (x < 0.1) { emitTransparent(); } else { emitRGB(vec3(0. * x, x * 0., 1.0 * x )); } }", - "transform": [[0.7400000095367432, 0, 0, 11020745], [0, 0.2653011679649353, -0.6908077001571655, 2533286.5], [0, 0.6908077001571655, 0.2653011679649353, -32682974], [0, 0, 0, 1]], - "opacity": 0.25 - }, - { - "name": "Blockface Image", - "source": "precomputed://https://zam10143.zam.kfa-juelich.de/chumni/nifti/cb905d54437734b39807e252ef8aa68bc6ac889047fbebbafd885490/BI", - "shader": "void main(){ float x = toNormalized(getDataValue()); if (x < 0.1) { emitTransparent(); } else { emitRGB(vec3(0.8 * x, x * 1., 0.8 * x )); } }", - "transform": [[0.7400000095367432, 0, 0, 11020745], [0, 0.2653011679649353, -0.6908077001571655, 2533286.5], [0, 0.6908077001571655, 0.2653011679649353, -32682974], [0, 0, 0, 1]], - "opacity": 1.0 - }, - { - "name": "PLI Transmittance", - "source": "precomputed://https://zam10143.zam.kfa-juelich.de/chumni/nifti/cb905d54437734b39807e252ef8aa68bc6ac889047fbebbafd885490/BI-TIM", - "shader": "void main(){ float x = toNormalized(getDataValue()); if (x > 0.9) { emitTransparent(); } else { emitRGB(vec3(x * 1., x * 0.8, x * 0.8 )); } }", - "transform": [[0.7400000095367432, 0, 0, 11020745], [0, 0.2653011679649353, -0.6908077001571655, 2533286.5], [0, 0.6908077001571655, 0.2653011679649353, -32682974], [0, 0, 0, 1]], - "opacity": 1.0 - }, - { - "name": "T2w MRI", - "source": "precomputed://https://zam10143.zam.kfa-juelich.de/chumni/nifti/cb905d54437734b39807e252ef8aa68bc6ac889047fbebbafd885490/BI-MRI", - "shader": "void main(){ float x = toNormalized(getDataValue()); if (x < 0.1) { emitTransparent(); } else { emitRGB(vec3(0.8 * x, 0.8 * x, x * 1. )); } }", - "transform": [[0.7400000095367432, 0, 0, 11020745], [0, 0.2653011679649353, -0.6908077001571655, 2533286.5], [0, 0.6908077001571655, 0.2653011679649353, -32682974], [0, 0, 0, 1]], - "opacity": 1.0 - }, - { - "name": "MRI Labels", - "source": "precomputed://https://zam10143.zam.kfa-juelich.de/chumni/nifti/cb905d54437734b39807e252ef8aa68bc6ac889047fbebbafd885490/BI-MRS", - "transform": [[0.7400000095367432, 0, 0, 11020745], [0, 0.2653011679649353, -0.6908077001571655, 2533286.5], [0, 0.6908077001571655, 0.2653011679649353, -32682974], [0, 0, 0, 1]], - "opacity": 1.0 - } - ] - } - }, - "referenceSpaces": [ - { - "name": "Big Brain (Histology)", - "fullId": "minds/core/referencespace/v1.0.0/a1655b99-82f1-420f-a3c2-fe80fd4c8588" - } - ] -} -``` diff --git a/docs/advanced/url.md b/docs/advanced/url.md deleted file mode 100644 index d5353f6d8ccf22af54cfeb5a67e38e80aed4a3ff..0000000000000000000000000000000000000000 --- a/docs/advanced/url.md +++ /dev/null @@ -1,225 +0,0 @@ -# URL parsing - -!!! note - Since [version 2.0.0](../releases/v2.0.0.md), navigation state and region(s) selected has been significantly redesigned. - - While the the URL parsing engine should still be backwards compatible, users should update their bookmarks/links. - -The interactive atlas viewer uses query parameters to store some of the viewer state. As a result, users can share or bookmark the URL, easily collaborating with other users in an interactive environment. - - -``` -https://interactive-viewer.apps.hbp.eu/?templateSelected=Big+Brain+%28Histology%29&parcellationSelected=Cytoarchitectonic+Maps&cRegionsSelected=%7B%22interpolated%22%3A%224.5.6.7.O.P%22%7D&cNavigation=0.0.0.-W000..-J0_A.2_4alZ._DTi1.2-3oKv..7LIx..jFlG~.Efml~.M7am..10c2 -``` - -However, expert users may want to generate custom state URLs. - -This document explains how the URL parsing in the Interactive Atlas Viewer work. - -## Query Parameters - -| Query param | -| --- | -| [`templateSelected`](#templateselected) | -| [`parcellationSelected`](#parcellationselected) | -| [`cNavigation`](#cnavigation) | -| [`cRegionsSelected`](#cregionsselected) | - -### `templateSelected` - -Describes the selected template. URI encoded value of the name of the selected template. - -If unset, loads homepage. - -__Example__ - -``` -templateSelected=Big+Brain+%28Histology%29 -``` - - -### `parcellationSelected` - -Describes the parcellation selected. Depends on `templateSelected`. URI encoded value of the name of the selected parcellation. - -If unset, or not a subset of parcellations supported by the selected template, the first parcellation of the selected template will be loaded instead - -__Example__ - -``` -parcellationSelected=Cytoarchitectonic+Maps -``` - -### `cNavigation` - -Describes the navigation state of the viewer. - -Uses `..` as a delimiter for key value, `.` as a delimiter for value and [hash function](#hash-function) to encode signed float to base64 string. - -If unset, loads the default orientation. - -__Example__ - -``` -cNavigation=0.0.0.-W000..-J0_A.2_4alZ._DTi1.2-3oKv..7LIx..jFlG~.Efml~.M7am..10c2 -``` - -```javascript -// cNavigation=0.0.0.-W000..-J0_A.2_4alZ._DTi1.2-3oKv..7LIx..jFlG~.Efml~.M7am..10c2 - -const cNavigation = `0.0.0.-W000..-J0_A.2_4alZ._DTi1.2-3oKv..7LIx..jFlG~.Efml~.M7am..10c2` - -// First, separate with key value delimiter -const [ - orientationStr, - perspectiveOrientationStr, - perspectiveZoomStr, - positionStr, - zoomStr -] = cNavigation.split('..') - -// For entries that are Array: - -const orientationArr = orientationStr.split('.') -const perspectiveOrientationArr = perspectiveOrientationStr.split('.') -const positionArr = positionStr.split('.') - - -// check hash function for decodeToNumber -// To get values back: -const orientation = orientationArr.map(v => decodeToNumber(v, { float: true })) -// [ 0, 0, 0, 1 ] - - -const perspectiveOrientation = perspectiveOrientationArr.map(v => decodeToNumber(v, { float: true })) -// [ 0.7971121072769165, -0.14286760985851288, 0.17759324610233307, -0.5591617226600647 ] - -const zoom = decodeToNumber(zoomStr, { float: false }) -// 264578 - -const perspectiveZoom = decodeToNumber(perspectiveZoomStr, { float: false }) -// 1922235 - -const position = positionArr.map(v => decodeToNumber(v, { float: false })) -// [ -11860944, -3841071, 5798192 ] - -``` - - -### `cRegionsSelected` - -Describe the regions selected. - -Query value is an URI encoded JSON object. Upon decoding, keys represent the name of the segmentation layer (which is often different to _selected parcellation_). Value is a `.` delimited array of integer [hashed](#hash-function) to base64 string. - -If unset, or if unable to decode, does not select region. - -__Example__ - -``` -cRegionsSelected=%7B%22interpolated%22%3A%224.5.6.7.O.P%22%7D -``` - -```javascript -const cRegionSelected = `%7B%22interpolated%22%3A%224.5.6.7.O.P%22%7D` -const decoded = decodeURIComponent(cRegionSelected) - -const parsed = JSON.parse(decoded) -const returnObj = {} -for (const key in parsed){ - const seg = parsed[key].split('.').map(v => decodeToNumber(v, { float: false })) - returnObj[key] = seg -} - -// { interpolated: [ 4, 5, 6, 7, 24, 25 ] } - -``` - -## hash function - -```javascript - -/** - * First attempt at encoding int (e.g. selected region, navigation location) from number (loc info density) to b64 (higher info density) - * The constraint is that the cipher needs to be commpatible with URI encoding - * and a URI compatible separator is required. - * - * While a faster solution exist in the same post, this operation is expected to be done: - * - once per 1 sec frequency - * - on < 1000 numbers - * - * So performance is not really that important (Also, need to learn bitwise operation) - */ - -const cipher = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-' -export const separator = "." -const negString = '~' - -const encodeInt = number => { - if (number % 1 !== 0) { throw new Error('cannot encodeInt on a float. Ensure float flag is set') } - if (isNaN(Number(number)) || number === null || number === Number.POSITIVE_INFINITY) { throw new Error('The input is not valid') } - - let residual - let result = '' - - if (number < 0) { - result += negString - residual = Math.floor(number * -1) - } else { - residual = Math.floor(number) - } - - while (true) { - result = cipher.charAt(residual % 64) + result - residual = Math.floor(residual / 64) - - if (residual === 0) { - break - } - } - return result -} - -const defaultB64EncodingOption = { - float: false -} - -export const encodeNumber = (number, option = defaultB64EncodingOption) => { - const { float } = option - if (!float) return encodeInt(number) - else { - const floatArray = new Float32Array(1) - floatArray[0] = number - const intArray = new Uint32Array(floatArray.buffer) - const castedInt = intArray[0] - return encodeInt(castedInt) - } -} - -const decodetoInt = encodedString => { - let _encodedString, negFlag = false - if (encodedString.slice(-1) === negString) { - negFlag = true - _encodedString = encodedString.slice(0, -1) - } else { - _encodedString = encodedString - } - return (negFlag ? -1 : 1) * [..._encodedString].reduce((acc,curr) => { - const index = cipher.indexOf(curr) - if (index < 0) throw new Error(`Poisoned b64 encoding ${encodedString}`) - return acc * 64 + index - }, 0) -} - -export const decodeToNumber = (encodedString, {float = false} = defaultB64EncodingOption) => { - if (!float) return decodetoInt(encodedString) - else { - const _int = decodetoInt(encodedString) - const intArray = new Uint32Array(1) - intArray[0] = _int - const castedFloat = new Float32Array(intArray.buffer) - return castedFloat[0] - } -} - -``` diff --git a/mkdocs.yml b/mkdocs.yml index 683054e07f3097a9c1b3cc699afc0485aff729fb..22a04cd407599d49da3fc3401cec38cf4450fc80 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -29,9 +29,6 @@ nav: - Sharing: 'usage/sharing.md' - Advanced usage: - Keyboard shortcuts: 'advanced/keyboard.md' - - URL parsing: 'advanced/url.md' - - Fetching datasets: 'advanced/datasets.md' - - Display non-atlas volumes: 'advanced/otherVolumes.md' - Release notes: - v2.14.0: 'releases/v2.14.0.md' - v2.13.5: 'releases/v2.13.5.md' diff --git a/package-lock.json b/package-lock.json index 988893d143b1661bf6bca7685e244e46e87c2898..b91a2cc43f2dedefcb87b50ad762cd74162b5b57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "siibra-explorer", - "version": "2.12.1", + "version": "2.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "siibra-explorer", - "version": "2.12.0", + "version": "2.14.0", "license": "apache-2.0", "dependencies": { "@angular/animations": "^14.2.12", @@ -23,7 +23,7 @@ "@ngrx/effects": "^14.3.2", "@ngrx/store": "^14.3.2", "acorn": "^8.4.1", - "export-nehuba": "^0.1.0", + "export-nehuba": "^0.1.2", "file-loader": "^6.2.0", "jszip": "^3.6.0", "postcss": "^8.3.6", @@ -26966,9 +26966,9 @@ "dev": true }, "node_modules/export-nehuba": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/export-nehuba/-/export-nehuba-0.1.0.tgz", - "integrity": "sha512-49mp9MiR6n+1zzeoVOfYTmr1g9CWBXrCtXK6PxwnRj+VBFrmjbp5PzBjVsGr5HsODrhwBWCLInK7zXmXaDnE/Q==", + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/export-nehuba/-/export-nehuba-0.1.2.tgz", + "integrity": "sha512-rzydWAaa9QUKZqbYQcAuwnGsMGBlEQFD5URkEi5IGTG8LS4eH/xqc97ol0ZpUExa6jyn6nLtAjFJQmKL1rdV0w==", "dependencies": { "pako": "^1.0.6" } diff --git a/package.json b/package.json index 101d49c59e5b92380e94010ac86ee15d81cea2fe..2aa0ea7ab4af53dcf0a1bfb695cc6c31f38f0748 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@ngrx/effects": "^14.3.2", "@ngrx/store": "^14.3.2", "acorn": "^8.4.1", - "export-nehuba": "^0.1.0", + "export-nehuba": "^0.1.2", "file-loader": "^6.2.0", "jszip": "^3.6.0", "postcss": "^8.3.6", diff --git a/src/viewerModule/nehuba/userLayers/service.ts b/src/viewerModule/nehuba/userLayers/service.ts index 5f9c1e130ce2ea589b26293292ccefe2be0ae915..5937a106ed53e1fa3682f2900446bba171ffcdb5 100644 --- a/src/viewerModule/nehuba/userLayers/service.ts +++ b/src/viewerModule/nehuba/userLayers/service.ts @@ -1,8 +1,8 @@ import { Injectable, OnDestroy } from "@angular/core" import { MatDialog } from "@angular/material/dialog" import { select, Store } from "@ngrx/store" -import { concat, forkJoin, from, of, Subscription } from "rxjs" -import { map, pairwise, switchMap } from "rxjs/operators" +import { forkJoin, from, Subscription } from "rxjs" +import { distinctUntilChanged, filter } from "rxjs/operators" import { linearTransform, TVALID_LINEAR_XFORM_DST, @@ -19,130 +19,241 @@ import { translateV3Entities } from "src/atlasComponents/sapi/translateV3" import { MetaV1Schema } from "src/atlasComponents/sapi/typeV3" type OmitKeys = "clType" | "id" | "source" +type LayerOption = Omit<atlasAppearance.const.NgLayerCustomLayer, OmitKeys> type Meta = { - min?: number - max?: number message?: string filename: string } const OVERLAY_LAYER_KEY = "x-overlay-layer" +const OVERLAY_LAYER_PROTOCOL = `${OVERLAY_LAYER_KEY}://` +const SUPPORTED_PREFIX = ["nifti://", "precomputed://", "swc://", "deepzoom://"] as const + +type ValidProtocol = typeof SUPPORTED_PREFIX[number] +type ValidInputTypes = File|string + +type ProcessorOutput = {option: LayerOption, url: string, protocol: ValidProtocol, meta: Meta, cleanup: () => void} +type ProcessResource = { + matcher: (input: ValidInputTypes) => boolean + processor: (input: ValidInputTypes) => Promise<ProcessorOutput> +} + +const SOURCE_PROCESSOR: ProcessResource[] = [] +function RegisterSource(matcher: ProcessResource['matcher']) { + return (target: Record<string, any>, propertyKey: string, _descriptor: PropertyDescriptor) => { + SOURCE_PROCESSOR.push({ + matcher, + processor: target[propertyKey], + }) + } +} + @Injectable() export class UserLayerService implements OnDestroy { - private userLayerUrlToIdMap = new Map<string, string>() - private createdUrlRes = new Set<string>() - - private supportedPrefix = ["nifti://", "precomputed://", "swc://"] + #idToCleanup = new Map<string, () => void>() - private verifyUrl(url: string) { - for (const prefix of this.supportedPrefix) { - if (url.includes(prefix)) return + static VerifyUrl(source: string) { + for (const prefix of SUPPORTED_PREFIX) { + if (source.includes(prefix)) return } throw new Error( - `url: ${url} does not start with supported prefixes ${this.supportedPrefix}` + `source: ${source} does not start with supported prefixes ${SUPPORTED_PREFIX}` ) } - async getCvtFileToUrl(file: File): Promise<{ - url: string - meta: Meta - options?: Omit<atlasAppearance.const.NgLayerCustomLayer, OmitKeys> - }> { - /** - * if extension is .swc, process as if swc - */ - if (/\.swc$/i.test(file.name)) { - let message = `The swc rendering is experimental. Please contact us on any feedbacks. ` - const swcText = await file.text() - let src: TVALID_LINEAR_XFORM_SRC - const dst: TVALID_LINEAR_XFORM_DST = "NEHUBA" - if (/ccf/i.test(swcText)) { - src = "CCF" - message += `CCF detected, applying known transformation.` - } - if (!src) { - message += `no known space detected. Applying default transformation.` - } + @RegisterSource( + input => input instanceof File && input.name.endsWith(".swc") + ) + async processSwc(file: File): ReturnType<ProcessResource['processor']> { + let message = `The swc rendering is experimental. Please contact us on any feedbacks. ` + const swcText = await file.text() + let src: TVALID_LINEAR_XFORM_SRC + const dst: TVALID_LINEAR_XFORM_DST = "NEHUBA" + if (/ccf/i.test(swcText)) { + src = "CCF" + message += `CCF detected, applying known transformation.` + } + if (!src) { + message += `no known space detected. Applying default transformation.` + } - const xform = await linearTransform(src, dst) + const xform = await linearTransform(src, dst) + const url = URL.createObjectURL(file) - const url = URL.createObjectURL(file) - this.createdUrlRes.add(url) + return { + option: { + type: 'segmentation', + transform: xform, + segments: ["1"] + }, + url, + protocol: "swc://", + meta: { + filename: file.name + }, + cleanup: () => URL.revokeObjectURL(url) + } + } - return { - url: `swc://${url}`, - meta: { - filename: file.name, - message, - }, - options: { - segments: ["1"], - transform: xform, - type: "segmentation" - }, - } + async #processUnpackedNiiBuf(buf: ArrayBuffer): ReturnType<ProcessResource['processor']> { + const { result } = await this.worker.sendMessage({ + method: "PROCESS_NIFTI", + param: { + nifti: buf, + }, + transfers: [buf], + }) + const { buffer, meta } = result + const url = URL.createObjectURL(new Blob([buffer])) + return { + protocol: 'nifti://', + url, + option: { + type: 'image', + shader: getShader({ + colormap: EnumColorMapName.MAGMA, + lowThreshold: meta.min || 0, + highThreshold: meta.max || 1, + }) + }, + meta, + cleanup: () => URL.revokeObjectURL(url) } + } - /** - * process as if nifti - */ + @RegisterSource( + input => input instanceof File && input.name.endsWith(".nii") + ) + async processNifti(file: File){ + const buf = await file.arrayBuffer() + return await this.#processUnpackedNiiBuf(buf) + } - // Get file, try to inflate, if files, use original array buffer + @RegisterSource( + input => input instanceof File && input.name.endsWith(".nii.gz") + ) + async processNiiGz(file: File) { const buf = await file.arrayBuffer() - let outbuf try { const { pako } = await getExportNehuba() - outbuf = pako.inflate(buf).buffer + const outbuf = pako.inflate(buf).buffer + return await this.#processUnpackedNiiBuf(outbuf) } catch (e) { console.log("unpack error", e) - outbuf = buf + throw e } + } - const { result } = await this.worker.sendMessage({ - method: "PROCESS_NIFTI", - param: { - nifti: outbuf, - }, - transfers: [outbuf], - }) + @RegisterSource( + input => typeof input === "string" && input.startsWith(OVERLAY_LAYER_PROTOCOL) + ) + async processOverlayPath(source: string) { + const strippedSrc = source.replace(OVERLAY_LAYER_PROTOCOL, "") + const { cleanup, ...rest } = await this.#processInput(strippedSrc) + return { + ...rest, + cleanup: () => { + this.routerSvc.setCustomRoute(OVERLAY_LAYER_KEY, null) + cleanup() + } + } + } - const { meta, buffer } = result + @RegisterSource( + input => typeof input === "string" && input.startsWith("precomputed://") + ) + async processPrecomputed(source: string): Promise<ProcessorOutput>{ + const url = source.replace("precomputed://", "") + const { transform, meta } = await forkJoin({ + transform: fetch(`${url}/transform.json`) + .then(res => res.json() as Promise<MetaV1Schema["transform"]>) + .catch(_e => null as MetaV1Schema["transform"]), + meta: from( + translateV3Entities.fetchMeta(url) + .catch(_e => null as MetaV1Schema) + ) + }).toPromise() + + return { + cleanup: () => {}, + meta: { + filename: url + }, + option: { + transform: meta?.transform || transform, + shader: getShaderFromMeta(meta), + }, + protocol: "precomputed://", + url + } + } - const url = URL.createObjectURL(new Blob([buffer])) + @RegisterSource( + input => typeof input === "string" && input.startsWith("deepzoom://") + ) + async processDzi(source: string): Promise<ProcessorOutput> { + const url = source.replace("deepzoom://", "") + const scaleFactor = 1e2 return { - url: `nifti://${url}`, + cleanup: () => {}, meta: { - filename: file.name, - min: meta.min || 0, - max: meta.max || 1, - message: meta.message, + filename: `deepzoom://${url}` }, - options: { - shader: getShader({ - colormap: EnumColorMapName.MAGMA, - lowThreshold: meta.min || 0, - highThreshold: meta.max || 1, - }), - type: 'image' + option: { + transform: [ + [ scaleFactor, 0, 0, 0 ], + [ 0, scaleFactor, 0, 0 ], + [ 0, 0, 1, 0 ], + [ 0, 0, 0, 1 ], + ], + shader: `void main(){emitRGB(vec3(toNormalized(getDataValue(0)),toNormalized(getDataValue(1)),toNormalized(getDataValue(2))));}`, }, + protocol: "deepzoom://", + url } } + async #processInput(input: ValidInputTypes): Promise<ProcessorOutput> { + for (const { matcher, processor } of SOURCE_PROCESSOR) { + if (matcher(input)) { + return await processor.apply(this, [input]) + } + } + debugger + const inputStr = input instanceof File + ? input.name + : input + throw new Error(`Could not find a processor for ${inputStr}`) + } + + async handleUserInput(input: ValidInputTypes){ + const id = getUuid() + const { option, protocol, url, meta, cleanup } = await this.#processInput(input) + if (this.#idToCleanup.has(id)) { + throw new Error(`${url} was already registered`) + } + this.#idToCleanup.set(id, cleanup) + this.addUserLayer( + id, + `${protocol}${url}`, + meta, + option, + ) + return + } + addUserLayer( - url: string, + id: string, + source: string, meta: Meta, - options: Omit<atlasAppearance.const.NgLayerCustomLayer, OmitKeys> = {} + options: LayerOption = {} ) { - this.verifyUrl(url) - if (this.userLayerUrlToIdMap.has(url)) { - throw new Error(`url ${url} already added`) - } - const id = getUuid() - const layer: atlasAppearance.const.NgLayerCustomLayer = { + UserLayerService.VerifyUrl(source) + const layer = { id, - clType: "customlayer/nglayer", - source: url, + clType: "customlayer/nglayer" as const, + source, ...options, } this.store$.dispatch( @@ -151,48 +262,30 @@ export class UserLayerService implements OnDestroy { }) ) - this.userLayerUrlToIdMap.set(url, id) - - this.dialog - .open(UserLayerInfoCmp, { - data: { - layerName: id, - filename: meta.filename, - min: meta.min || 0, - max: meta.max || 1, - warning: [meta.message] || [], - }, - hasBackdrop: false, - disableClose: true, - position: { - top: "0em", - }, - autoFocus: false, - panelClass: ["no-padding-dialog", "w-100"], - }) - .afterClosed() - .subscribe(() => { - this.routerSvc.setCustomRoute(OVERLAY_LAYER_KEY, null) - }) - } - - removeUserLayer(url: string) { - if (!this.userLayerUrlToIdMap.has(url)) { - throw new Error(`${url} has not yet been added.`) - } - - /** - * if the url to be removed is a url resource, revoke the resource - */ - const matched = /http.*$/.exec(url) - if (matched && this.createdUrlRes.has(matched[0])) { - URL.revokeObjectURL(matched[0]) - this.createdUrlRes.delete(matched[0]) - } - - const id = this.userLayerUrlToIdMap.get(url) - this.store$.dispatch(atlasAppearance.actions.removeCustomLayer({ id })) - this.userLayerUrlToIdMap.delete(url) + this.dialog.open(UserLayerInfoCmp, { + data: { + layerName: id, + filename: meta.filename, + warning: [meta.message] || [], + }, + hasBackdrop: false, + disableClose: true, + position: { + top: "0em", + }, + autoFocus: false, + panelClass: ["no-padding-dialog", "w-100"], + }) + .afterClosed() + .subscribe(() => { + this.store$.dispatch(atlasAppearance.actions.removeCustomLayer({ id })) + const cleanup = this.#idToCleanup.get(id) + if (!cleanup) { + console.warn(`idToCleanup ${id} could not be found! ${meta.filename}`) + return + } + cleanup() + }) } #subscription: Subscription[] = [] @@ -203,66 +296,12 @@ export class UserLayerService implements OnDestroy { private routerSvc: RouterService ) { this.#subscription.push( - concat( - of(null as string), - this.routerSvc.customRoute$.pipe( - select(v => v[OVERLAY_LAYER_KEY]) - ) + this.routerSvc.customRoute$.pipe( + select(v => v[OVERLAY_LAYER_KEY]) ).pipe( - pairwise(), - switchMap(([prev, curr]) => { - /** - * for precomputed sources, check if transform.json exists. - * if so, try to fetch it, and set it as transform - */ - if (!curr) { - return of({ prev, curr, meta: null as MetaV1Schema }) - } - if (!curr.startsWith("precomputed://")) { - return of({ prev, curr, meta: null as MetaV1Schema }) - } - const url = curr.replace("precomputed://", "") - - - return forkJoin({ - transform: fetch(`${url}/transform.json`) - .then(res => res.json() as Promise<MetaV1Schema["transform"]>) - .catch(_e => null as MetaV1Schema["transform"]), - meta: from( - translateV3Entities.fetchMeta(url) - .catch(_e => null as MetaV1Schema) - ) - }).pipe( - map(({ transform, meta }) => { - return { - prev, - curr, - meta: { - ...meta, - transform: meta?.transform || transform - } - } - }), - ) - }) - ).subscribe(({ prev, curr, meta }) => { - if (prev) { - this.removeUserLayer(prev) - } - if (curr) { - this.addUserLayer( - curr, - { - filename: curr, - message: `Overlay layer populated in URL`, - }, - { - shader: getShaderFromMeta(meta), - transform: meta.transform - } - ) - } - }) + distinctUntilChanged(), + filter(url => !!url) + ).subscribe(url => this.handleUserInput(url)) ) } diff --git a/src/viewerModule/nehuba/userLayers/userlayerDragdrop.directive.spec.ts b/src/viewerModule/nehuba/userLayers/userlayerDragdrop.directive.spec.ts index be2c332759205abf28aa9d844244a325a7a5a106..d3724c1e2a6134973661e30578df71d3a9deafa2 100644 --- a/src/viewerModule/nehuba/userLayers/userlayerDragdrop.directive.spec.ts +++ b/src/viewerModule/nehuba/userLayers/userlayerDragdrop.directive.spec.ts @@ -17,10 +17,7 @@ class TestCmp { describe("dragdrop.directive.spec.ts", () => { let fixture: ComponentFixture<TestCmp> - - let addUserLayerSpy: jasmine.Spy - let removeUserLayerSpy: jasmine.Spy - let getCvtFileToUrlSpy: jasmine.Spy + let handleUserInputSpy: jasmine.Spy let dummyFile1: File let dummyFile2: File @@ -42,18 +39,14 @@ describe("dragdrop.directive.spec.ts", () => { useValue: { addUserLayer: () => {}, removeUserLayer: () => {}, - getCvtFileToUrl: () => Promise.resolve(), + handleUserInput: () => Promise.resolve(), }, }, ], }) const svc = TestBed.inject(UserLayerService) - - addUserLayerSpy = spyOn(svc, "addUserLayer") - removeUserLayerSpy = spyOn(svc, "removeUserLayer") - getCvtFileToUrlSpy = spyOn(svc, "getCvtFileToUrl") - - getCvtFileToUrlSpy.and.resolveTo({ meta, url, options }) + handleUserInputSpy = spyOn(svc, "handleUserInput") + handleUserInputSpy.and.resolveTo(null) fixture = TestBed.createComponent(TestCmp) fixture.detectChanges() @@ -73,9 +66,7 @@ describe("dragdrop.directive.spec.ts", () => { })() }) afterEach(() => { - addUserLayerSpy.calls.reset() - removeUserLayerSpy.calls.reset() - getCvtFileToUrlSpy.calls.reset() + handleUserInputSpy.calls.reset() }) describe("> malformed input", () => { @@ -99,7 +90,7 @@ describe("dragdrop.directive.spec.ts", () => { }) it("> should not call addnglayer", () => { - expect(getCvtFileToUrlSpy).not.toHaveBeenCalled() + expect(handleUserInputSpy).not.toHaveBeenCalled() }) // TODO having a difficult time getting snackbar harness @@ -128,17 +119,11 @@ describe("dragdrop.directive.spec.ts", () => { }) it("> should call addNgLayer", () => { - expect(getCvtFileToUrlSpy).toHaveBeenCalledTimes(1) - const arg = getCvtFileToUrlSpy.calls.argsFor(0) + expect(handleUserInputSpy).toHaveBeenCalledTimes(1) + const arg = handleUserInputSpy.calls.argsFor(0) expect(arg.length).toEqual(1) expect(arg[0]).toEqual(dummyFile1) - expect(addUserLayerSpy).toHaveBeenCalledTimes(1) - const args1 = addUserLayerSpy.calls.argsFor(0) - - expect(args1[0]).toBe(url) - expect(args1[1]).toBe(meta) - expect(args1[2]).toBe(options) }) }) }) diff --git a/src/viewerModule/nehuba/userLayers/userlayerDragdrop.directive.ts b/src/viewerModule/nehuba/userLayers/userlayerDragdrop.directive.ts index a56d677fd8b77ecce210dc7c2940ea7b1836472b..2b049c7f70b4f011f45d99b7f09bbb808e088d5a 100644 --- a/src/viewerModule/nehuba/userLayers/userlayerDragdrop.directive.ts +++ b/src/viewerModule/nehuba/userLayers/userlayerDragdrop.directive.ts @@ -53,9 +53,6 @@ export class UserLayerDragDropDirective return } const file = files[0] - - const { meta, url, options } = await this.svc.getCvtFileToUrl(file) - - this.svc.addUserLayer(url, meta, options) + await this.svc.handleUserInput(file) } } diff --git a/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.component.ts b/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.component.ts index 204da49bf5170ca9998ed52f26fc31ad1df05471..eb4f6fac154934c88b3c83e595430046b2e2b770 100644 --- a/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.component.ts +++ b/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.component.ts @@ -8,8 +8,6 @@ import { MediaQueryDirective } from "src/util/directives/mediaQuery.directive"; export type UserLayerInfoData = { layerName: string filename: string - min: number - max: number warning: string[] } diff --git a/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.template.html b/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.template.html index c999b1c1befd8d650096bf38c6b23a8aadf63192..84b908d46b466d47be99763d6bd3147a1423260e 100644 --- a/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.template.html +++ b/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.template.html @@ -55,8 +55,6 @@ <ng-layer-tune [hideCtrl]="onlyOpacity ? HIDE_NG_TUNE_CTRL.ONLY_SHOW_OPACITY : ''" advanced-control="true" - [ngLayerName]="data.layerName" - [thresholdMin]="data.min" - [thresholdMax]="data.max"> + [ngLayerName]="data.layerName"> </ng-layer-tune> </ng-template>