diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3c07d114df83ae2c2dc663da720cc5451914f31..0ee38faacdd75f02e4d4c8567b72065c7d9ef719 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Use Node.js 14.x for lint + - name: Use Node.js 16.x for lint uses: actions/setup-node@v1 with: - node-version: '14.x' + node-version: '16.x' - run: npm i - run: npm run lint @@ -26,38 +26,37 @@ jobs: if: always() runs-on: ubuntu-latest - strategy: - matrix: - node-version: [12.x, 14.x, 16.x] - env: NODE_ENV: test steps: - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js 16.x uses: actions/setup-node@v1 with: - node-version: ${{ matrix.node-version }} + node-version: 16.x - run: npm i - - run: npm run test-ci + - run: | + if [[ "$GITHUB_REF" = *hotfix* ]] || [[ "$GITHUB_REF" = refs/heads/staging ]] + then + export SIIBRA_API_ENDPOINTS=https://siibra-api-rc.apps.hbp.eu/v2_0,https://siibra-api-rc.apps.jsc.hbp.eu/v2_0 + node src/environments/parseEnv.js ./environment.ts + fi + npm run test-ci backend: if: always() runs-on: ubuntu-latest - strategy: - matrix: - node-version: [12.x, 14.x, 16.x] env: NODE_ENV: test steps: - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js 16.x uses: actions/setup-node@v1 with: - node-version: ${{ matrix.node-version }} + node-version: 16.x - run: | cd deploy npm i diff --git a/.github/workflows/docker_img.yml b/.github/workflows/docker_img.yml index 47734fc27ba6540ee111646142553af53a365798..a25d30c92fe0636dc593c859e224eead06025d52 100644 --- a/.github/workflows/docker_img.yml +++ b/.github/workflows/docker_img.yml @@ -18,7 +18,7 @@ jobs: PRODUCTION: 'true' DOCKER_REGISTRY: 'docker-registry.ebrains.eu/siibra/' - SIIBRA_API_STABLE: 'https://siibra-api-stable.apps.hbp.eu/v2_0' + SIIBRA_API_STABLE: 'https://siibra-api-stable.apps.hbp.eu/v2_0,https://siibra-api-stable-ns.apps.hbp.eu/v2_0,https://siibra-api-stable.apps.jsc.hbp.eu/v2_0' SIIBRA_API_RC: 'https://siibra-api-rc.apps.hbp.eu/v2_0' SIIBRA_API_LATEST: 'https://siibra-api-latest.apps-dev.hbp.eu/v2_0' @@ -37,19 +37,19 @@ jobs: if [[ "$GITHUB_REF" == 'refs/heads/master' ]] then echo "Either master, using prod env..." - echo "BS_REST_URL=${{ env.SIIBRA_API_STABLE }}" >> $GITHUB_ENV + echo "SIIBRA_API_ENDPOINTS=${{ env.SIIBRA_API_STABLE }}" >> $GITHUB_ENV elif [[ "$GITHUB_REF" == 'refs/heads/staging' ]] then echo "Either staging, using staging env..." - echo "BS_REST_URL=${{ env.SIIBRA_API_RC }}" >> $GITHUB_ENV + echo "SIIBRA_API_ENDPOINTS=${{ env.SIIBRA_API_RC }}" >> $GITHUB_ENV else if [[ "$GITHUB_REF" == *hotfix* ]] then echo "Hotfix branch, using prod env..." - echo "BS_REST_URL=${{ env.SIIBRA_API_RC }}" >> $GITHUB_ENV + echo "SIIBRA_API_ENDPOINTS=${{ env.SIIBRA_API_RC }}" >> $GITHUB_ENV else echo "Using dev env..." - echo "BS_REST_URL=${{ env.SIIBRA_API_LATEST }}" >> $GITHUB_ENV + echo "SIIBRA_API_ENDPOINTS=${{ env.SIIBRA_API_LATEST }}" >> $GITHUB_ENV fi fi @@ -78,7 +78,7 @@ jobs: --build-arg VERSION=$VERSION \ --build-arg MATOMO_URL=$MATOMO_URL \ --build-arg MATOMO_ID=$MATOMO_ID \ - --build-arg BS_REST_URL=$BS_REST_URL \ + --build-arg SIIBRA_API_ENDPOINTS=$SIIBRA_API_ENDPOINTS \ --build-arg EXPERIMENTAL_FEATURE_FLAG=$EXPERIMENTAL_FEATURE_FLAG \ -t $DOCKER_BUILT_TAG \ . diff --git a/Dockerfile b/Dockerfile index 17e735232f80890da6b5b2b65db1b33c152c9765..8ad7624403cf18aa021e133e2926d1fc1f616b04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,8 +3,8 @@ FROM node:14 as builder ARG BACKEND_URL ENV BACKEND_URL=${BACKEND_URL} -ARG BS_REST_URL -ENV BS_REST_URL=${BS_REST_URL:-https://siibra-api-stable.apps.hbp.eu/v1_0} +ARG SIIBRA_API_ENDPOINTS +ENV SIIBRA_API_ENDPOINTS=${SIIBRA_API_ENDPOINTS:-https://siibra-api-stable.apps.hbp.eu/v2_0,https://siibra-api-stable-ns.apps.hbp.eu/v2_0,https://siibra-api-stable.apps.jsc.hbp.eu/v2_0} ARG STRICT_LOCAL ENV STRICT_LOCAL=${STRICT_LOCAL:-false} @@ -39,6 +39,7 @@ RUN rm -rf ./node_modules RUN npm i RUN npm run build +RUN node third_party/matomo/processMatomo.js RUN npm run build-storybook # gzipping container diff --git a/build_env.md b/build_env.md index 1c29ea98614c3a40dbe91d7c620f9e6a5356b4e8..fa8ed9e3951cdabd2cfa2f26bd46c5904e74af14 100644 --- a/build_env.md +++ b/build_env.md @@ -7,7 +7,8 @@ As interactive atlas viewer uses [webpack define plugin](https://webpack.js.org/ | `VERSION` | printed in console on viewer startup | `GIT_HASH` \|\| unspecificed hash | v2.2.2 | | `PRODUCTION` | if the build is for production, toggles optimisations such as minification | `undefined` | true | | `BACKEND_URL` | backend that the viewer calls to fetch available template spaces, parcellations, plugins, datasets | `null` | https://interactive-viewer.apps.hbp.eu/ | -| `BS_REST_URL` | [siibra-api](https://github.com/FZJ-INM1-BDA/siibra-api) used to fetch different resources | https://siibra-api-stable.apps.hbp.eu/v1_0 | +| ~~`BS_REST_URL`~~ _deprecated. use `SIIBRA_API_ENDPOINTS` instead_ | [siibra-api](https://github.com/FZJ-INM1-BDA/siibra-api) used to fetch different resources | `https://siibra-api-stable.apps.hbp.eu/v1_0` | +| `SIIBRA_API_ENDPOINTS` | Comma separated endpoints of [siibra-api](https://github.com/FZJ-INM1-BDA/siibra-api) used to fetch different resources | `https://siibra-api-stable.apps.hbp.eu/v2_0,https://siibra-api-stable-ns.apps.hbp.eu/v2_0,https://siibra-api-stable.apps.jsc.hbp.eu/v2_0` | | `MATOMO_URL` | base url for matomo analytics | `null` | https://example.com/matomo/ | | `MATOMO_ID` | application id for matomo analytics | `null` | 6 | | `STRICT_LOCAL` | hides **Explore** and **Download** buttons. Useful for offline demo's | `false` | `true` | diff --git a/common/constants.js b/common/constants.js index cf56106555a3b26559711671e22fcc0e06494e86..94a42b81abf59142a3d50f5a175d64208e02fbbd 100644 --- a/common/constants.js +++ b/common/constants.js @@ -108,6 +108,7 @@ If you do not accept the Terms & Conditions you are not permitted to access or u CANNOT_DECIPHER_HEMISPHERE: 'Cannot decipher region hemisphere.', DOES_NOT_SUPPORT_MULTI_REGION_SELECTION: `Please only select a single region.`, MULTI_REGION_SELECTION: `Multi region selection`, + DESCRIPTION: 'Description', REGIONAL_FEATURES: 'Regional features', CONNECTIVITY: 'Connectivity', NO_ADDIONTAL_INFO_AVAIL: `Currently, no additional information is linked to this region.`, diff --git a/deploy/app.js b/deploy/app.js index 7b48f151dcd9e9e9fc8d9595b37eab27d00e02b4..f4b33a1c39d3904ace5651ca22ed2a909e8d269b 100644 --- a/deploy/app.js +++ b/deploy/app.js @@ -7,7 +7,7 @@ const crypto = require('crypto') const cookieParser = require('cookie-parser') const bkwdMdl = require('./bkwdCompat')() -const LOCAL_CDN_FLAG = !!process.env.PRECOMPUTED_SERVER +const LOCAL_CDN_FLAG = !!process.env.LOCAL_CDN if (process.env.NODE_ENV !== 'production') { app.use(require('cors')()) @@ -126,7 +126,8 @@ if (LOCAL_CDN_FLAG) { const LOCAL_CDN = process.env.LOCAL_CDN const CDN_ARRAY = [ 'https://stackpath.bootstrapcdn.com', - 'https://use.fontawesome.com' + 'https://use.fontawesome.com', + 'https://unpkg.com' ] let indexFile @@ -214,8 +215,7 @@ app.get('/ready', async (req, res) => { * only use compression for production * this allows locally built aot to be served without errors */ -const { compressionMiddleware, setAlwaysOff } = require('nomiseco') -if (LOCAL_CDN_FLAG) setAlwaysOff(true) +const { compressionMiddleware } = require('nomiseco') app.use(compressionMiddleware, express.static(PUBLIC_PATH)) diff --git a/deploy/bkwdCompat/urlState.js b/deploy/bkwdCompat/urlState.js index 66f92c3dc4f6b375fcfe0c70f16a685b43c30e0d..64093f9e035e19fc95c9ee8eea8b2caa2303912b 100644 --- a/deploy/bkwdCompat/urlState.js +++ b/deploy/bkwdCompat/urlState.js @@ -114,8 +114,12 @@ const WARNING_STRINGS = { REGION_SELECT_ERROR: 'Region selected cannot be processed properly.', TEMPLATE_ERROR: 'Template not found.', } - +const pliPreviewUrl = `/a:juelich:iav:atlas:v1.0.0:1/t:minds:core:referencespace:v1.0.0:a1655b99-82f1-420f-a3c2-fe80fd4c8588/p:juelich:iav:atlas:v1.0.0:4/@:0.0.0.-W000.._eCwg.2-FUe3._-s_W.2_evlu..7LIy..1qI1a.D31U~.i-Os~..HRE/f:siibra:features:voi:19c437087299dd62e7c507200f69aea6` module.exports = (query, _warningCb) => { + + const HOST_PATHNAME = process.env.HOST_PATHNAME || '' + let redirectUrl = `${HOST_PATHNAME}/#` + const { standaloneVolumes, niftiLayers, // deprecating - check if anyone calls this url @@ -163,7 +167,17 @@ module.exports = (query, _warningCb) => { if (Array.isArray(parsedDsp)) { if (parsedDsp.length === 1) { const { datasetId, filename } = parsedDsp[0] - dsp = `/dsp:${encodeId(datasetId)}::${encodeURI(filename)}` + if (datasetId === "minds/core/dataset/v1.0.0/b08a7dbc-7c75-4ce7-905b-690b2b1e8957") { + /** + * if preview pli link, return hardcoded link + */ + return `${HOST_PATHNAME}/#${pliPreviewUrl}` + } else { + /** + * TODO deprecate dsp + */ + dsp = `/dsp:${encodeId(datasetId)}::${encodeURI(filename)}` + } } else { searchParam.set(`previewingDatasetFiles`, previewingDatasetFiles) } @@ -206,8 +220,6 @@ module.exports = (query, _warningCb) => { // ignore region selected and move on } } - const HOST_PATHNAME = process.env.HOST_PATHNAME || '' - let redirectUrl = `${HOST_PATHNAME}/#` if (standaloneVolumes) { searchParam.set('standaloneVolumes', standaloneVolumes) if (nav) redirectUrl += nav diff --git a/deploy/csp/index.js b/deploy/csp/index.js index 51a3cf1598f93b61e0efdc38f4a2e80d40aba68c..898228ce845b95c747d027704c08ab96f8196c30 100644 --- a/deploy/csp/index.js +++ b/deploy/csp/index.js @@ -54,7 +54,9 @@ const connectSrc = [ 'object.cscs.ch', // required for dataset previews - 'hbp-kg-dataset-previewer.apps.hbp.eu/v2/', + + // spatial transform + "hbp-spatial-backend.apps.hbp.eu", // injected by env var ...CSP_CONNECT_SRC @@ -102,7 +104,6 @@ module.exports = { ], imgSrc: [ "'self'", - "hbp-kg-dataset-previewer.apps.hbp.eu/v2/" ], scriptSrc:[ "'self'", @@ -111,12 +112,16 @@ module.exports = { 'cdnjs.cloudflare.com/ajax/libs/d3/', // required for preview component 'cdnjs.cloudflare.com/ajax/libs/mathjax/', // math jax 'https://unpkg.com/three-surfer@0.0.11/dist/bundle.js', // for threeSurfer (freesurfer support in browser) - 'https://unpkg.com/ng-layer-tune@0.0.5/dist/ng-layer-tune/', // needed for ng layer control + 'https://unpkg.com/ng-layer-tune@0.0.6/dist/ng-layer-tune/', // needed for ng layer control + 'https://unpkg.com/hbp-connectivity-component@0.6.2/', // needed for connectivity component (req, res) => res.locals.nonce ? `'nonce-${res.locals.nonce}'` : null, ...SCRIPT_SRC, ...WHITE_LIST_SRC, ...defaultAllowedSites ], + frameSrc: [ + "*" + ], reportUri: CSP_REPORT_URI || '/report-violation' }, reportOnly diff --git a/deploy/package-lock.json b/deploy/package-lock.json index 1dac062d78f8ca3d5a23d9456457a913ceef919c..85a12542dc7b71eca0510d03a3d5ce1178f09567 100644 --- a/deploy/package-lock.json +++ b/deploy/package-lock.json @@ -10,9 +10,9 @@ "integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==" }, "@sindresorhus/is": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-1.2.0.tgz", - "integrity": "sha512-mwhXGkRV5dlvQc4EgPDxDxO6WuMBVymGFd1CA+2Y+z5dG9MNspoQ+AWjl/Ld1MnpCL8AKbosZlDVohqcIwuWsw==" + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==" }, "@sinonjs/commons": { "version": "1.7.0", @@ -924,25 +924,60 @@ } }, "got": { - "version": "10.5.5", - "resolved": "https://registry.npmjs.org/got/-/got-10.5.5.tgz", - "integrity": "sha512-B13HHkCkTA7KxyxTrFoZfrurBX1fZxjMTKpmIfoVzh0Xfs9aZV7xEfI6EKuERQOIPbomh5LE4xDkfK6o2VXksw==", + "version": "11.8.5", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.5.tgz", + "integrity": "sha512-o0Je4NvQObAuZPHLFoRSkdG2lTgtcynqymzg2Vupdx6PorhaT5MCbIyXG6d4D94kk8ZG57QeosgdiqfJWhEhlQ==", "requires": { - "@sindresorhus/is": "^1.0.0", - "@szmarczak/http-timer": "^4.0.0", + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", - "cacheable-lookup": "^2.0.0", - "cacheable-request": "^7.0.1", - "decompress-response": "^5.0.0", - "duplexer3": "^0.1.4", - "get-stream": "^5.0.0", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", - "mimic-response": "^2.0.0", "p-cancelable": "^2.0.0", - "p-event": "^4.0.0", - "responselike": "^2.0.0", - "to-readable-stream": "^2.0.0", - "type-fest": "^0.9.0" + "responselike": "^2.0.0" + }, + "dependencies": { + "cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==" + }, + "cacheable-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.2.tgz", + "integrity": "sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==", + "requires": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + } + }, + "decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "requires": { + "mimic-response": "^3.1.0" + } + }, + "mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==" + }, + "normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==" + } } }, "growl": { @@ -1571,9 +1606,9 @@ }, "dependencies": { "@sindresorhus/is": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.0.0.tgz", - "integrity": "sha512-FyD2meJpDPjyNQejSjvnhpgI/azsQkA4lGbuu5BQZfjvJ9cbRZXzeWL2HceCekW4lixO9JPesIIQkSoLjeJHNQ==" + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==" }, "cacheable-lookup": { "version": "5.0.4", @@ -1589,21 +1624,46 @@ } }, "got": { - "version": "11.8.2", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.2.tgz", - "integrity": "sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ==", + "version": "11.8.5", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.5.tgz", + "integrity": "sha512-o0Je4NvQObAuZPHLFoRSkdG2lTgtcynqymzg2Vupdx6PorhaT5MCbIyXG6d4D94kk8ZG57QeosgdiqfJWhEhlQ==", "requires": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.1", + "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", "p-cancelable": "^2.0.0", "responselike": "^2.0.0" + }, + "dependencies": { + "cacheable-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.2.tgz", + "integrity": "sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==", + "requires": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + } + }, + "http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "requires": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + } + } } }, "lru-cache": { @@ -1619,6 +1679,11 @@ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==" }, + "normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==" + }, "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -1917,9 +1982,9 @@ "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" }, "resolve-alpn": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.0.0.tgz", - "integrity": "sha512-rTuiIEqFmGxne4IovivKSDzld2lWW9QCjqv80SYjPgf+gS35eaCAjaP54CCwGAwBtnCsvNLYtqxe1Nw+i6JEmA==" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" }, "responselike": { "version": "2.0.0", @@ -2229,11 +2294,6 @@ "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true }, - "type-fest": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.9.0.tgz", - "integrity": "sha512-j55pzONIdg7rdtJTRZPKIbV0FosUqYdhHK1aAYJIrUvejv1VVyBokrILE8KQDT4emW/1Ev9tx+yZG+AxuSBMmA==" - }, "type-is": { "version": "1.6.16", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.16.tgz", diff --git a/deploy/package.json b/deploy/package.json index 333568cdfc0b2804ed97319a6b00dd1577c59d50..9470dcf3c16f707ecd0c023fcff17e3e843d2bc8 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -18,7 +18,7 @@ "express": "^4.16.4", "express-rate-limit": "^5.5.1", "express-session": "^1.15.6", - "got": "^10.5.5", + "got": "^11.8.5", "hbp-seafile": "^0.2.0", "helmet-csp": "^3.4.0", "lru-cache": "^5.1.1", diff --git a/deploy/plugins/index.js b/deploy/plugins/index.js index 6089e2fe9eb3c54dcb8f4c081f947d4cb719377c..028a0158d9a94b4a353242099c9cfed7eb8f867f 100644 --- a/deploy/plugins/index.js +++ b/deploy/plugins/index.js @@ -35,40 +35,45 @@ const getKey = url => `plugin:manifest-cache:${url}` router.get('/manifests', async (_req, res) => { const allManifests = await Promise.all( - [...V2_7_PLUGIN_URLS, ...V2_7_STAGING_PLUGIN_URLS].map(async url => - race( - async () => { - const key = getKey(url) - - await lruStore._initPr - const { store } = lruStore - - try { - const storedManifest = await store.get(key) - if (storedManifest) return JSON.parse(storedManifest) - else throw `not found` - } catch (e) { - const resp = await got(url) - const json = JSON.parse(resp.body) - - const { iframeUrl, 'siibra-explorer': flag } = json - if (!flag) return null - if (!iframeUrl) return null - const u = new URL(url) + [...V2_7_PLUGIN_URLS, ...V2_7_STAGING_PLUGIN_URLS].map(async url => { + try { + return await race( + async () => { + const key = getKey(url) - let replaceObj = {} - if (!/^https?:\/\//.test(iframeUrl)) { - u.pathname = path.resolve(path.dirname(u.pathname), iframeUrl) - replaceObj['iframeUrl'] = u.toString() + await lruStore._initPr + const { store } = lruStore + + try { + const storedManifest = await store.get(key) + if (storedManifest) return JSON.parse(storedManifest) + else throw `not found` + } catch (e) { + const resp = await got(url) + const json = JSON.parse(resp.body) + + const { iframeUrl, 'siibra-explorer': flag } = json + if (!flag) return null + if (!iframeUrl) return null + const u = new URL(url) + + let replaceObj = {} + if (!/^https?:\/\//.test(iframeUrl)) { + u.pathname = path.resolve(path.dirname(u.pathname), iframeUrl) + replaceObj['iframeUrl'] = u.toString() + } + const returnObj = {...json, ...replaceObj} + await store.set(key, JSON.stringify(returnObj), { maxAge: 1000 * 60 * 60 }) + return returnObj } - const returnObj = {...json, ...replaceObj} - await store.set(key, JSON.stringify(returnObj), { maxAge: 1000 * 60 * 60 }) - return returnObj - } - }, - { timeout: 1000 } - ) - ) + }, + { timeout: 1000 } + ) + } catch (e) { + console.error(`fetching manifest error: ${e}`) + return null + } + }) ) res.status(200).json( diff --git a/deploy/util/reconfigPrecomputedServer.js b/deploy/util/reconfigPrecomputedServer.js deleted file mode 100644 index 98a1932ba74dbdc4361efae7056f876af7141b15..0000000000000000000000000000000000000000 --- a/deploy/util/reconfigPrecomputedServer.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * n.b. trailing slash is required - * e.g. http://localhost:10080/ - */ -const PRECOMPUTED_SERVER = process.env.PRECOMPUTED_SERVER - -const reconfigureFlag = !!PRECOMPUTED_SERVER - -exports.reconfigureFlag = reconfigureFlag - -exports.reconfigureUrl = (str) => { - if (!reconfigureFlag) return str - return str.replace(/https?:\/\/.*?\//g, PRECOMPUTED_SERVER) -} \ No newline at end of file diff --git a/deploy_env.md b/deploy_env.md index bb17c783cbd86d4fa1932a36f44f7a4118012452..c470a1ef47b2415b37b94cc774490463301bb718 100644 --- a/deploy_env.md +++ b/deploy_env.md @@ -8,7 +8,7 @@ | `HOST_PATHNAME` | pathname to listen on, restrictions: leading slash, no trailing slash | `''` | `/viewer` | | `SESSIONSECRET` | session secret for cookie session | | `NODE_ENV` | determines where the built viewer will be served from | | `production` | -| `PRECOMPUTED_SERVER` | redirect data uri to another server. Useful for offline demos | | `http://localhost:8080/precomputed/` | +| ~~`PRECOMPUTED_SERVER`~~ _deprecated_, use `LOCAL_CDN` instead. | redirect data uri to another server. Useful for offline demos | | `http://localhost:8080/precomputed/` | | `LOCAL_CDN` | rewrite cdns to local server. useful for offlnie demo | | `http://localhost:7080/` | | `PLUGIN_URLS` | semi colon separated urls to be returned when user queries plugins | `''` | `STAGING_PLUGIN_URLS` | semi colon separated urls to be returned when user queries plugins | `''` diff --git a/docs/releases/v2.6.10.md b/docs/releases/v2.6.10.md new file mode 100644 index 0000000000000000000000000000000000000000..afc4ab3ece8cdef9414ff7ef42ad7a84b4489007 --- /dev/null +++ b/docs/releases/v2.6.10.md @@ -0,0 +1,5 @@ +# 2.6.9 + +## Bugfix + +- Remove empty quick tour. diff --git a/docs/releases/v2.6.9.md b/docs/releases/v2.6.9.md new file mode 100644 index 0000000000000000000000000000000000000000..4937a041b7efdfc236164034d37f7ca452dd97e7 --- /dev/null +++ b/docs/releases/v2.6.9.md @@ -0,0 +1,5 @@ +# 2.6.9 + +## Bugfix + +- Bumped version of ng-layer-tune dependency diff --git a/docs/releases/v2.7.1.md b/docs/releases/v2.7.1.md new file mode 100644 index 0000000000000000000000000000000000000000..a2d967a9c06c5baa042653168ddded4bbf99c52f --- /dev/null +++ b/docs/releases/v2.7.1.md @@ -0,0 +1,5 @@ +# v2.7.1 + +## Bugfix + +- fixed region detail fetching using duplicated id as endpoint diff --git a/docs/releases/v2.7.2.md b/docs/releases/v2.7.2.md new file mode 100644 index 0000000000000000000000000000000000000000..2a5ada611284053bd629c4f6dc0ed4c765889b58 --- /dev/null +++ b/docs/releases/v2.7.2.md @@ -0,0 +1,12 @@ +# v2.7.2 + +## Feature + +- (re)introduced the parcellation info button + +## Bugfix + +- fix the position of quick tour panel of slice view panels +- fix the atlas selection logic. This should reduce 4xx/5xx calls significantly +- minor update to parcellation chip appearance +- clicking on feature badge now properly selects the feature diff --git a/docs/releases/v2.7.3.md b/docs/releases/v2.7.3.md new file mode 100644 index 0000000000000000000000000000000000000000..ec24dc460b6f4bc81b0faab9a78fa5eaf29ff948 --- /dev/null +++ b/docs/releases/v2.7.3.md @@ -0,0 +1,13 @@ +# v2.7.3 + +## Bugfix + +- fixed matomo visitor counting (broke since 2.7.0 release) +- fixed sane url generation +- fixed reset navigation buttons in navigation card + +## Under the hood + +- minor refactor of unused code +- added mirrors to siibra-api +- experimental support for drag and drop swc diff --git a/docs/releases/v2.7.4.md b/docs/releases/v2.7.4.md new file mode 100644 index 0000000000000000000000000000000000000000..d214b2f110f3848759ec885f36821072f051b987 --- /dev/null +++ b/docs/releases/v2.7.4.md @@ -0,0 +1,6 @@ +# v2.7.4 + +## Bugfix + +- Properly use fallback when detecting fault +- Minor wording/cosmetic change diff --git a/e2e/checklist.md b/e2e/checklist.md index b51cd1cdc38ce354cf1bc0ca97a6d06e9b3896b4..d03d59b1f8424872c3c88efefa15b01a3b600ecd 100644 --- a/e2e/checklist.md +++ b/e2e/checklist.md @@ -14,6 +14,11 @@ - [ ] Human multilevel atlas - [ ] on click from home page, MNI152, Julich v2.9 loads without issue - [ ] on hover, show correct region name(s) + - [ ] Parcellation smart chip + - [ ] show/hide parcellation toggle exists and works + - [ ] `q` is a shortcut to show/hide parcellation toggle + - [ ] info button exists and works + - [ ] info button shows desc, and link to KG - [ ] regional is fine :: select hOC1 right - [ ] probabilistic map loads fine - [ ] segmentation layer hides @@ -60,6 +65,9 @@ - [ ] on hover, show correct region name(s) - [ ] whole mesh loads ## saneURL +- [ ] saneurl generation functions properly + - [ ] try existing key (human), and get unavailable error + - [ ] try non existing key, and get available - [ ] [saneUrl](https://atlases.ebrains.eu/viewer-staging/saneUrl/bigbrainGreyWhite) redirects to big brain - [ ] [saneUrl](https://atlases.ebrains.eu/viewer-staging/saneUrl/julichbrain) redirects to julich brain (colin 27) - [ ] [saneUrl](https://atlases.ebrains.eu/viewer-staging/saneUrl/whs4) redirects to waxholm v4 @@ -72,3 +80,4 @@ - [ ] [vipUrl](https://atlases.ebrains.eu/viewer-staging/mouse) redirects allen mouse 2017 ## plugins - [ ] jugex plugin works +- [ ] 1um section works diff --git a/mkdocs.yml b/mkdocs.yml index a2a76f72dc5420575cd9ef362adf589458e1d27d..7fce47cfb860fd0fa5cd67382445bdd098b2bde4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,4 +1,4 @@ -site_name: Interactive Atlas Viewer User Documentation +site_name: Siibra Explorer User Documentation theme: name: 'material' @@ -33,7 +33,13 @@ nav: - Fetching datasets: 'advanced/datasets.md' - Display non-atlas volumes: 'advanced/otherVolumes.md' - Release notes: + - v2.7.4: 'releases/v2.7.4.md' + - v2.7.3: 'releases/v2.7.3.md' + - v2.7.2: 'releases/v2.7.2.md' + - v2.7.1: 'releases/v2.7.1.md' - v2.7.0: 'releases/v2.7.0.md' + - v2.6.10: 'releases/v2.6.10.md' + - v2.6.9: 'releases/v2.6.9.md' - v2.6.8: 'releases/v2.6.8.md' - v2.6.7: 'releases/v2.6.7.md' - v2.6.6: 'releases/v2.6.6.md' diff --git a/package.json b/package.json index 4720c10cf9e51b8a900f221ce25ed048a1ce6a84..0890653e938f23546b5a2173c2ee93c3e8ab75d6 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "interactive-viewer", + "version": "2.7.4", "description": "siibra-explorer - explore brain atlases. Based on humanbrainproject/nehuba & google/neuroglancer. Built with angular", "scripts": { "lint": "eslint src --ext .ts", diff --git a/src/atlasComponents/annotations/annotation.service.ts b/src/atlasComponents/annotations/annotation.service.ts index ec352e5fbd3707b63b4da42ca92e9157c36bfb0c..fd67601ee087cf32ddd230d7e42b3ca9fdaea421 100644 --- a/src/atlasComponents/annotations/annotation.service.ts +++ b/src/atlasComponents/annotations/annotation.service.ts @@ -146,7 +146,8 @@ export class AnnotationLayer { } } updateAnnotation(spec: AnnotationSpec) { - const localAnnotations = this.nglayer.layer.localAnnotations + const localAnnotations = this.nglayer?.layer?.localAnnotations + if (!localAnnotations) return const ref = localAnnotations.references.get(spec.id) const _spec = this.parseNgSpecType(spec) if (ref) { diff --git a/src/atlasComponents/sapi/constants.ts b/src/atlasComponents/sapi/constants.ts index e9822b4a39c4157b893cef83f6d11082a8475674..b31cfc1b14703de11f2c5e2efaee1cacb369347b 100644 --- a/src/atlasComponents/sapi/constants.ts +++ b/src/atlasComponents/sapi/constants.ts @@ -1,13 +1,16 @@ export const IDS = { ATLAES: { - HUMAN: "juelich/iav/atlas/v1.0.0/1" + HUMAN: "juelich/iav/atlas/v1.0.0/1", + RAT: "minds/core/parcellationatlas/v1.0.0/522b368e-49a3-49fa-88d3-0870a307974a", }, TEMPLATES: { BIG_BRAIN: "minds/core/referencespace/v1.0.0/a1655b99-82f1-420f-a3c2-fe80fd4c8588", MNI152: "minds/core/referencespace/v1.0.0/dafcffc5-4826-4bf1-8ff6-46b8a31ff8e2", - COLIN27: "minds/core/referencespace/v1.0.0/7f39f7be-445b-47c0-9791-e971c0b6d992" + COLIN27: "minds/core/referencespace/v1.0.0/7f39f7be-445b-47c0-9791-e971c0b6d992", + WAXHOLM: "minds/core/referencespace/v1.0.0/d5717c4a-0fa1-46e6-918c-b8003069ade8", }, PARCELLATION: { - JBA29: "minds/core/parcellationatlas/v1.0.0/94c1125b-b87e-45e4-901c-00daee7f2579-290" + JBA29: "minds/core/parcellationatlas/v1.0.0/94c1125b-b87e-45e4-901c-00daee7f2579-290", + WAXHOLMV4: "minds/core/parcellationatlas/v1.0.0/ebb923ba-b4d5-4b82-8088-fa9215c2e1fe-v4", } } diff --git a/src/atlasComponents/sapi/core/sapiParcellation.ts b/src/atlasComponents/sapi/core/sapiParcellation.ts index 4767cc2897ab0ed6c6567f3ee727ad2f73fece04..085c5a82b8136901136593aa0b2933d992efb22e 100644 --- a/src/atlasComponents/sapi/core/sapiParcellation.ts +++ b/src/atlasComponents/sapi/core/sapiParcellation.ts @@ -1,4 +1,5 @@ import { Observable } from "rxjs" +import { switchMap } from "rxjs/operators" import { SapiVolumeModel } from ".." import { SAPI } from "../sapi.service" import {SapiParcellationFeatureModel, SapiParcellationModel, SapiQueryPriorityArg, SapiRegionModel} from "../type" @@ -20,43 +21,53 @@ export class SAPIParcellation{ } getDetail(queryParam?: SapiQueryPriorityArg): Observable<SapiParcellationModel>{ - return this.sapi.httpGet<SapiParcellationModel>( - `${this.sapi.bsEndpoint}/atlases/${encodeURIComponent(this.atlasId)}/parcellations/${encodeURIComponent(this.id)}`, - null, - queryParam + return SAPI.BsEndpoint$.pipe( + switchMap(endpt => this.sapi.httpGet<SapiParcellationModel>( + `${endpt}/atlases/${encodeURIComponent(this.atlasId)}/parcellations/${encodeURIComponent(this.id)}`, + null, + queryParam + )) ) } getRegions(spaceId: string, queryParam?: SapiQueryPriorityArg): Observable<SapiRegionModel[]> { - return this.sapi.httpGet<SapiRegionModel[]>( - `${this.sapi.bsEndpoint}/atlases/${encodeURIComponent(this.atlasId)}/parcellations/${encodeURIComponent(this.id)}/regions`, - { - space_id: spaceId - }, - queryParam + return SAPI.BsEndpoint$.pipe( + switchMap(endpt => this.sapi.httpGet<SapiRegionModel[]>( + `${endpt}/atlases/${encodeURIComponent(this.atlasId)}/parcellations/${encodeURIComponent(this.id)}/regions`, + { + space_id: spaceId + }, + queryParam + )) ) } getVolumes(): Observable<SapiVolumeModel[]>{ - return this.sapi.httpGet<SapiVolumeModel[]>( - `${this.sapi.bsEndpoint}/atlases/${encodeURIComponent(this.atlasId)}/parcellations/${encodeURIComponent(this.id)}/volumes` - ) + return SAPI.BsEndpoint$.pipe( + switchMap(endpt => this.sapi.httpGet<SapiVolumeModel[]>( + `${endpt}/atlases/${encodeURIComponent(this.atlasId)}/parcellations/${encodeURIComponent(this.id)}/volumes` + )) + ) } getFeatures(parcPagination?: ParcellationPaginationQuery, queryParam?: SapiQueryPriorityArg): Observable<SapiParcellationFeatureModel[]> { - return this.sapi.httpGet<SapiParcellationFeatureModel[]>( - `${this.sapi.bsEndpoint}/atlases/${encodeURIComponent(this.atlasId)}/parcellations/${encodeURIComponent(this.id)}/features`, - { - type: parcPagination?.type, - size: parcPagination?.size?.toString() || '5', - page: parcPagination?.page.toString() || '0', - }, - queryParam + return SAPI.BsEndpoint$.pipe( + switchMap(endpt => this.sapi.httpGet<SapiParcellationFeatureModel[]>( + `${endpt}/atlases/${encodeURIComponent(this.atlasId)}/parcellations/${encodeURIComponent(this.id)}/features`, + { + type: parcPagination?.type, + size: parcPagination?.size?.toString() || '5', + page: parcPagination?.page.toString() || '0', + }, + queryParam + )) ) } getFeatureInstance(instanceId: string): Observable<SapiParcellationFeatureModel> { - return this.sapi.http.get<SapiParcellationFeatureModel>( - `${this.sapi.bsEndpoint}/atlases/${encodeURIComponent(this.atlasId)}/parcellations/${encodeURIComponent(this.id)}/features/${encodeURIComponent(instanceId)}`, + return SAPI.BsEndpoint$.pipe( + switchMap(endpt => this.sapi.http.get<SapiParcellationFeatureModel>( + `${endpt}/atlases/${encodeURIComponent(this.atlasId)}/parcellations/${encodeURIComponent(this.id)}/features/${encodeURIComponent(instanceId)}`, + )) ) } } diff --git a/src/atlasComponents/sapi/core/sapiRegion.ts b/src/atlasComponents/sapi/core/sapiRegion.ts index 8248992c16ef86a1bbd6e28c395b8dd08db9d66f..d9263cea03f0d7045e3a1f5043f8a4edc2209929 100644 --- a/src/atlasComponents/sapi/core/sapiRegion.ts +++ b/src/atlasComponents/sapi/core/sapiRegion.ts @@ -2,7 +2,7 @@ import { SAPI } from ".."; import { SapiRegionalFeatureModel, SapiRegionMapInfoModel, SapiRegionModel, cleanIeegSessionDatasets, SapiIeegSessionModel, CleanedIeegDataset, SapiVolumeModel, PaginatedResponse } from "../type"; import { strToRgb, hexToRgb } from 'common/util' import { merge, Observable, of } from "rxjs"; -import { catchError, map, scan } from "rxjs/operators"; +import { catchError, map, scan, switchMap } from "rxjs/operators"; export class SAPIRegion{ @@ -16,7 +16,7 @@ export class SAPIRegion{ return strToRgb(JSON.stringify(region)) } - private prefix: string + private prefix$: Observable<string> constructor( private sapi: SAPI, @@ -24,20 +24,26 @@ export class SAPIRegion{ public parcId: string, public id: string, ){ - this.prefix = `${this.sapi.bsEndpoint}/atlases/${encodeURIComponent(this.atlasId)}/parcellations/${encodeURIComponent(this.parcId)}/regions/${encodeURIComponent(this.id)}` + this.prefix$ = SAPI.BsEndpoint$.pipe( + map(endpt => `${endpt}/atlases/${encodeURIComponent(this.atlasId)}/parcellations/${encodeURIComponent(this.parcId)}/regions/${encodeURIComponent(this.id)}`) + ) } getFeatures(spaceId: string): Observable<(SapiRegionalFeatureModel | CleanedIeegDataset)[]> { return merge( - this.sapi.httpGet<SapiRegionalFeatureModel[]>( - `${this.prefix}/features`, - { - space_id: spaceId - } - ).pipe( - catchError((err, obs) => { - return of([]) - }) + this.prefix$.pipe( + switchMap(prefix => + this.sapi.httpGet<SapiRegionalFeatureModel[]>( + `${prefix}/features`, + { + space_id: spaceId + } + ).pipe( + catchError((err, obs) => { + return of([]) + }) + ) + ) ), spaceId ? this.sapi.getSpace(this.atlasId, spaceId).getFeatures({ parcellationId: this.parcId, region: this.id }).pipe( @@ -56,50 +62,59 @@ export class SAPIRegion{ } getFeatureInstance(instanceId: string, spaceId: string = null): Observable<SapiRegionalFeatureModel> { - return this.sapi.httpGet<SapiRegionalFeatureModel>( - `${this.prefix}/features/${encodeURIComponent(instanceId)}`, - { - space_id: spaceId - } + return this.prefix$.pipe( + switchMap(prefix => this.sapi.httpGet<SapiRegionalFeatureModel>( + `${prefix}/features/${encodeURIComponent(instanceId)}`, + { + space_id: spaceId + } + )) ) } getMapInfo(spaceId: string): Observable<SapiRegionMapInfoModel> { - return this.sapi.http.get<SapiRegionMapInfoModel>( - `${this.prefix}/regional_map/info`, - { - params: { - space_id: spaceId + return this.prefix$.pipe( + switchMap(prefix => this.sapi.http.get<SapiRegionMapInfoModel>( + `${prefix}/regional_map/info`, + { + params: { + space_id: spaceId + } } - } + )) ) } - getMapUrl(spaceId: string): string { - return `${this.prefix}/regional_map/map?space_id=${encodeURI(spaceId)}` + getMapUrl(spaceId: string): Observable<string> { + return this.prefix$.pipe( + map(prefix => `${prefix}/regional_map/map?space_id=${encodeURI(spaceId)}`) + ) } getVolumes(): Observable<PaginatedResponse<SapiVolumeModel>>{ - const url = `${this.prefix}/volumes` - return this.sapi.httpGet<PaginatedResponse<SapiVolumeModel>>( - url + return this.prefix$.pipe( + switchMap(prefix => this.sapi.httpGet<PaginatedResponse<SapiVolumeModel>>( + `${prefix}/volumes` + )) ) } getVolumeInstance(volumeId: string): Observable<SapiVolumeModel> { - const url = `${this.prefix}/volumes/${encodeURIComponent(volumeId)}` - return this.sapi.httpGet<SapiVolumeModel>( - url + return this.prefix$.pipe( + switchMap(prefix => this.sapi.httpGet<SapiVolumeModel>( + `${prefix}/volumes/${encodeURIComponent(volumeId)}` + )) ) } getDetail(spaceId: string): Observable<SapiRegionModel> { - const url = `${this.prefix}/${encodeURIComponent(this.id)}` - return this.sapi.httpGet<SapiRegionModel>( - url, - { - space_id: spaceId - } + return this.prefix$.pipe( + switchMap(prefix => this.sapi.httpGet<SapiRegionModel>( + prefix, + { + space_id: spaceId + } + )) ) } } diff --git a/src/atlasComponents/sapi/core/sapiSpace.ts b/src/atlasComponents/sapi/core/sapiSpace.ts index 3effd6f16345aa82eebd3cf94b9f83118fb9546e..5f61ae6f68240437d2a9510b4744d64d16e54977 100644 --- a/src/atlasComponents/sapi/core/sapiSpace.ts +++ b/src/atlasComponents/sapi/core/sapiSpace.ts @@ -2,6 +2,7 @@ import { Observable } from "rxjs" import { SAPI } from '../sapi.service' import { camelToSnake } from 'common/util' import {SapiQueryPriorityArg, SapiSpaceModel, SapiSpatialFeatureModel, SapiVolumeModel} from "../type" +import { map, switchMap } from "rxjs/operators" type FeatureResponse = { features: { @@ -22,13 +23,21 @@ type SpatialFeatureOpts = RegionalSpatialFeatureOpts | BBoxSpatialFEatureOpts export class SAPISpace{ - constructor(private sapi: SAPI, public atlasId: string, public id: string){} + constructor(private sapi: SAPI, public atlasId: string, public id: string){ + this.prefix$ = SAPI.BsEndpoint$.pipe( + map(endpt => `${endpt}/atlases/${encodeURIComponent(this.atlasId)}/spaces/${encodeURIComponent(this.id)}`) + ) + } + + private prefix$: Observable<string> getModalities(param?: SapiQueryPriorityArg): Observable<FeatureResponse> { - return this.sapi.httpGet<FeatureResponse>( - `${this.sapi.bsEndpoint}/atlases/${encodeURIComponent(this.atlasId)}/spaces/${encodeURIComponent(this.id)}/features`, - null, - param + return this.prefix$.pipe( + switchMap(prefix => this.sapi.httpGet<FeatureResponse>( + `${prefix}/features`, + null, + param + )) ) } @@ -37,9 +46,11 @@ export class SAPISpace{ for (const [key, value] of Object.entries(opts)) { query[camelToSnake(key)] = value } - return this.sapi.httpGet<SapiSpatialFeatureModel[]>( - `${this.sapi.bsEndpoint}/atlases/${encodeURIComponent(this.atlasId)}/spaces/${encodeURIComponent(this.id)}/features`, - query + return this.prefix$.pipe( + switchMap(prefix => this.sapi.httpGet<SapiSpatialFeatureModel[]>( + `${prefix}/features`, + query + )) ) } @@ -48,23 +59,29 @@ export class SAPISpace{ for (const [key, value] of Object.entries(opts)) { query[camelToSnake(key)] = value } - return this.sapi.httpGet<SapiSpatialFeatureModel>( - `${this.sapi.bsEndpoint}/atlases/${encodeURIComponent(this.atlasId)}/spaces/${encodeURIComponent(this.id)}/features/${encodeURIComponent(instanceId)}`, - query + return this.prefix$.pipe( + switchMap(prefix => this.sapi.httpGet<SapiSpatialFeatureModel>( + `${prefix}/features/${encodeURIComponent(instanceId)}`, + query + )) ) } getDetail(param?: SapiQueryPriorityArg): Observable<SapiSpaceModel>{ - return this.sapi.httpGet<SapiSpaceModel>( - `${this.sapi.bsEndpoint}/atlases/${encodeURIComponent(this.atlasId)}/spaces/${encodeURIComponent(this.id)}`, - null, - param + return this.prefix$.pipe( + switchMap(prefix => this.sapi.httpGet<SapiSpaceModel>( + `${prefix}`, + null, + param + )) ) } getVolumes(): Observable<SapiVolumeModel[]>{ - return this.sapi.httpGet<SapiVolumeModel[]>( - `${this.sapi.bsEndpoint}/atlases/${encodeURIComponent(this.atlasId)}/spaces/${encodeURIComponent(this.id)}/volumes`, + return this.prefix$.pipe( + switchMap(prefix => this.sapi.httpGet<SapiVolumeModel[]>( + `${prefix}/volumes`, + )) ) } } diff --git a/src/atlasComponents/sapi/features/sapiFeature.ts b/src/atlasComponents/sapi/features/sapiFeature.ts index 5abaa0040f6cb71046c70bf57e7a77a57f2794a8..f2f341acce3aca72ba481f3eda6c68083320cfe1 100644 --- a/src/atlasComponents/sapi/features/sapiFeature.ts +++ b/src/atlasComponents/sapi/features/sapiFeature.ts @@ -1,3 +1,4 @@ +import { switchMap } from "rxjs/operators"; import { SAPI } from "../sapi.service"; import { SapiFeatureModel } from "../type"; @@ -6,8 +7,10 @@ export class SAPIFeature { } - public detail$ = this.sapi.httpGet<SapiFeatureModel>( - `${SAPI.bsEndpoint}/features/${this.id}`, - this.opts + public detail$ = SAPI.BsEndpoint$.pipe( + switchMap(endpt => this.sapi.httpGet<SapiFeatureModel>( + `${endpt}/features/${this.id}`, + this.opts + )) ) } diff --git a/src/atlasComponents/sapi/module.ts b/src/atlasComponents/sapi/module.ts index a64cc8bc817f05c801cde40d58a95d93c1d198a1..9b9efaf0ee92240c0b27b57b5067acf02dcbd972 100644 --- a/src/atlasComponents/sapi/module.ts +++ b/src/atlasComponents/sapi/module.ts @@ -1,5 +1,4 @@ import { NgModule } from "@angular/core"; -import { SAPI } from "./sapi.service"; import { CommonModule } from "@angular/common"; import { HttpClientModule, HTTP_INTERCEPTORS } from "@angular/common/http"; import { PriorityHttpInterceptor } from "src/util/priority"; @@ -16,7 +15,6 @@ import { MatSnackBarModule } from "@angular/material/snack-bar"; exports: [ ], providers: [ - SAPI, { provide: HTTP_INTERCEPTORS, useClass: PriorityHttpInterceptor, diff --git a/src/atlasComponents/sapi/sapi.service.spec.ts b/src/atlasComponents/sapi/sapi.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..20233eca63ce2485db0136ee3702cace46659e96 --- /dev/null +++ b/src/atlasComponents/sapi/sapi.service.spec.ts @@ -0,0 +1,107 @@ +import { finalize } from "rxjs/operators" +import * as env from "src/environments/environment" +import { SAPI } from "./sapi.service" + +describe("> sapi.service.ts", () => { + describe("> SAPI", () => { + describe("#BsEndpoint$", () => { + let fetchSpy: jasmine.Spy + let environmentSpy: jasmine.Spy + + const endpt1 = 'http://foo-bar' + const endpt2 = 'http://buzz-bizz' + + const atlas1 = 'foo' + const atlas2 = 'bar' + + let subscribedVal: string + + beforeEach(() => { + SAPI.ClearBsEndPoint() + fetchSpy = spyOn(window, 'fetch') + fetchSpy.and.callThrough() + + environmentSpy = spyOnProperty(env, 'environment') + environmentSpy.and.returnValue({ + SIIBRA_API_ENDPOINTS: `${endpt1},${endpt2}` + }) + }) + + + afterEach(() => { + SAPI.ClearBsEndPoint() + fetchSpy.calls.reset() + environmentSpy.calls.reset() + subscribedVal = null + }) + + describe("> first passes", () => { + beforeEach(done => { + const resp = new Response(JSON.stringify([atlas1]), { headers: { 'content-type': 'application/json' }, status: 200 }) + fetchSpy.and.callFake(async url => { + if (url === `${endpt1}/atlases`) { + return resp + } + throw new Error("controlled throw") + }) + SAPI.BsEndpoint$.pipe( + finalize(() => done()) + ).subscribe(val => { + subscribedVal = val + }) + }) + it("> should call fetch twice", async () => { + expect(fetchSpy).toHaveBeenCalledTimes(2) + + const allArgs = fetchSpy.calls.allArgs() + expect(allArgs.length).toEqual(2) + expect(allArgs[0]).toEqual([`${endpt1}/atlases`]) + expect(allArgs[1]).toEqual([`${endpt2}/atlases`]) + }) + + it("> endpoint should be set", async () => { + expect(subscribedVal).toBe(endpt1) + }) + + it("> additional calls should return cached observable", () => { + + expect(fetchSpy).toHaveBeenCalledTimes(2) + SAPI.BsEndpoint$.subscribe() + SAPI.BsEndpoint$.subscribe() + + expect(fetchSpy).toHaveBeenCalledTimes(2) + }) + }) + + describe("> first fails", () => { + beforeEach(done => { + fetchSpy.and.callFake(async url => { + if (url === `${endpt1}/atlases`) { + throw new Error(`bla`) + } + const resp = new Response(JSON.stringify([atlas1]), { headers: { 'content-type': 'application/json' }, status: 200 }) + return resp + }) + + SAPI.BsEndpoint$.pipe( + finalize(() => done()) + ).subscribe(val => { + subscribedVal = val + }) + }) + + it("> should call twice", async () => { + expect(fetchSpy).toHaveBeenCalledTimes(2) + expect(fetchSpy.calls.allArgs()).toEqual([ + [`${endpt1}/atlases`], + [`${endpt2}/atlases`], + ]) + }) + + it('> should set endpt2', async () => { + expect(subscribedVal).toBe(endpt2) + }) + }) + }) + }) +}) diff --git a/src/atlasComponents/sapi/sapi.service.ts b/src/atlasComponents/sapi/sapi.service.ts index ae43869a35d2197fc387f234bda476087e8d941c..ef3dc46e1e4dd37e1b50206f5b070bc562a270e5 100644 --- a/src/atlasComponents/sapi/sapi.service.ts +++ b/src/atlasComponents/sapi/sapi.service.ts @@ -1,9 +1,10 @@ import { Injectable } from "@angular/core"; import { HttpClient } from '@angular/common/http'; -import { map, shareReplay } from "rxjs/operators"; +import { catchError, filter, map, shareReplay, switchMap, take, tap } from "rxjs/operators"; import { SAPIAtlas, SAPISpace } from './core' import { - SapiAtlasModel, SapiModalityModel, + SapiAtlasModel, + SapiModalityModel, SapiParcellationModel, SapiQueryPriorityArg, SapiRegionalFeatureModel, @@ -19,20 +20,65 @@ import { MatSnackBar } from "@angular/material/snack-bar"; import { AtlasWorkerService } from "src/atlasViewer/atlasViewer.workerService.service"; import { EnumColorMapName } from "src/util/colorMaps"; import { PRIORITY_HEADER } from "src/util/priority"; -import { Observable } from "rxjs"; +import { concat, EMPTY, from, merge, Observable, of } from "rxjs"; import { SAPIFeature } from "./features"; import { environment } from "src/environments/environment" export const SIIBRA_API_VERSION_HEADER_KEY='x-siibra-api-version' -export const SIIBRA_API_VERSION = '0.2.0' +export const SIIBRA_API_VERSION = '0.2.2' type RegistryType = SAPIAtlas | SAPISpace | SAPIParcellation -@Injectable() +let BS_ENDPOINT_CACHED_VALUE: Observable<string> = null + +@Injectable({ + providedIn: 'root' +}) export class SAPI{ - static bsEndpoint = `https://siibra-api-latest.apps-dev.hbp.eu/v2_0` || environment.BS_REST_URL - public bsEndpoint = SAPI.bsEndpoint + /** + * Used to clear BsEndPoint, so the next static get BsEndpoints$ will + * fetch again. Only used for unit test of BsEndpoint$ + */ + static ClearBsEndPoint(){ + BS_ENDPOINT_CACHED_VALUE = null + } + + /** + * BsEndpoint$ is designed as a static getter mainly for unit testing purposes. + * see usage of BsEndpoint$ and ClearBsEndPoint in sapi.service.spec.ts + */ + static get BsEndpoint$(): Observable<string> { + if (!!BS_ENDPOINT_CACHED_VALUE) return BS_ENDPOINT_CACHED_VALUE + BS_ENDPOINT_CACHED_VALUE = concat( + merge( + ...environment.SIIBRA_API_ENDPOINTS.split(',').map(url => { + return from((async () => { + const resp = await fetch(`${url}/atlases`) + const atlases = await resp.json() + if (atlases.length == 0) { + throw new Error(`atlas length == 0`) + } + return url + })()).pipe( + catchError(() => EMPTY) + ) + }) + ), + of(null).pipe( + tap(() => { + SAPI.ErrorMessage = `It appears all of our mirrors are not working. The viewer may not be working properly...` + }), + filter(() => false) + ) + ).pipe( + take(1), + shareReplay(1), + ) + return BS_ENDPOINT_CACHED_VALUE + } + + static ErrorMessage = null registry = { _map: {} as Record<string, { @@ -92,7 +138,9 @@ export class SAPI{ } getModalities(): Observable<SapiModalityModel[]> { - return this.http.get<SapiModalityModel[]>(`${SAPI.bsEndpoint}/modalities`) + return SAPI.BsEndpoint$.pipe( + switchMap(endpt => this.http.get<SapiModalityModel[]>(`${endpt}/modalities`)) + ) } httpGet<T>(url: string, params?: Record<string, string>, sapiParam?: SapiQueryPriorityArg){ @@ -109,12 +157,13 @@ export class SAPI{ ) } - public atlases$ = this.http.get<SapiAtlasModel[]>( - `${this.bsEndpoint}/atlases`, - { - observe: "response" - } - ).pipe( + public atlases$ = SAPI.BsEndpoint$.pipe( + switchMap(endpt => this.http.get<SapiAtlasModel[]>( + `${endpt}/atlases`, + { + observe: "response" + } + )), map(resp => { const respVersion = resp.headers.get(SIIBRA_API_VERSION_HEADER_KEY) if (respVersion !== SIIBRA_API_VERSION) { @@ -133,6 +182,9 @@ export class SAPI{ private snackbar: MatSnackBar, private workerSvc: AtlasWorkerService, ){ + if (SAPI.ErrorMessage) { + this.snackbar.open(SAPI.ErrorMessage, 'Dismiss', { duration: 5000 }) + } this.atlases$.subscribe(atlases => { for (const atlas of atlases) { for (const space of atlas.spaces) { diff --git a/src/atlasComponents/sapi/stories.base.ts b/src/atlasComponents/sapi/stories.base.ts index 51fde4f0cd5695f6055398e8bd9ec39ed469c541..57e28f20da83acf96a247117f70fb921998c3bf0 100644 --- a/src/atlasComponents/sapi/stories.base.ts +++ b/src/atlasComponents/sapi/stories.base.ts @@ -67,22 +67,27 @@ export const parcId = { } export async function getAtlases(): Promise<SapiAtlasModel[]> { - return await (await fetch(`${SAPI.bsEndpoint}/atlases`)).json() as SapiAtlasModel[] + const endPt = await SAPI.BsEndpoint$.toPromise() + return await (await fetch(`${endPt}/atlases`)).json() as SapiAtlasModel[] } export async function getAtlas(id: string): Promise<SapiAtlasModel>{ - return await (await fetch(`${SAPI.bsEndpoint}/atlases/${id}`)).json() + const endPt = await SAPI.BsEndpoint$.toPromise() + return await (await fetch(`${endPt}/atlases/${id}`)).json() } export async function getParc(atlasId: string, id: string): Promise<SapiParcellationModel>{ - return await (await fetch(`${SAPI.bsEndpoint}/atlases/${atlasId}/parcellations/${id}`)).json() + const endPt = await SAPI.BsEndpoint$.toPromise() + return await (await fetch(`${endPt}/atlases/${atlasId}/parcellations/${id}`)).json() } export async function getParcRegions(atlasId: string, id: string, spaceId: string): Promise<SapiRegionModel[]>{ - return await (await fetch(`${SAPI.bsEndpoint}/atlases/${atlasId}/parcellations/${id}/regions?space_id=${encodeURIComponent(spaceId)}`)).json() + const endPt = await SAPI.BsEndpoint$.toPromise() + return await (await fetch(`${endPt}/atlases/${atlasId}/parcellations/${id}/regions?space_id=${encodeURIComponent(spaceId)}`)).json() } export async function getSpace(atlasId: string, id: string): Promise<SapiSpaceModel> { - return await (await fetch(`${SAPI.bsEndpoint}/atlases/${atlasId}/spaces/${id}`)).json() + const endPt = await SAPI.BsEndpoint$.toPromise() + return await (await fetch(`${endPt}/atlases/${atlasId}/spaces/${id}`)).json() } export async function getHumanAtlas(): Promise<SapiAtlasModel> { @@ -90,7 +95,8 @@ export async function getHumanAtlas(): Promise<SapiAtlasModel> { } export async function getMni152(): Promise<SapiSpaceModel> { - return await (await fetch(`${SAPI.bsEndpoint}/atlases/${atlasId.human}/spaces/${spaceId.human.mni152}`)).json() + const endPt = await SAPI.BsEndpoint$.toPromise() + return await (await fetch(`${endPt}/atlases/${atlasId.human}/spaces/${spaceId.human.mni152}`)).json() } export async function getJba29(): Promise<SapiParcellationModel> { @@ -103,33 +109,41 @@ export async function getJba29Regions(): Promise<SapiRegionModel[]> { export async function getHoc1Right(spaceId=null): Promise<SapiRegionModel> { if (!spaceId) { - return await (await fetch(`${SAPI.bsEndpoint}/atlases/${atlasId.human}/parcellations/${parcId.human.jba29}/regions/hoc1%20right`)).json() + const endPt = await SAPI.BsEndpoint$.toPromise() + return await (await fetch(`${endPt}/atlases/${atlasId.human}/parcellations/${parcId.human.jba29}/regions/hoc1%20right`)).json() } - return await (await fetch(`${SAPI.bsEndpoint}/atlases/${atlasId.human}/parcellations/${parcId.human.jba29}/regions/hoc1%20right?space_id=${encodeURIComponent(spaceId)}`)).json() + const endPt = await SAPI.BsEndpoint$.toPromise() + return await (await fetch(`${endPt}/atlases/${atlasId.human}/parcellations/${parcId.human.jba29}/regions/hoc1%20right?space_id=${encodeURIComponent(spaceId)}`)).json() } export async function get44Left(spaceId=null): Promise<SapiRegionModel> { if (!spaceId) { - return await (await fetch(`${SAPI.bsEndpoint}/atlases/${atlasId.human}/parcellations/${parcId.human.jba29}/regions/area%2044%20left`)).json() + const endPt = await SAPI.BsEndpoint$.toPromise() + return await (await fetch(`${endPt}/atlases/${atlasId.human}/parcellations/${parcId.human.jba29}/regions/area%2044%20left`)).json() } - return await (await fetch(`${SAPI.bsEndpoint}/atlases/${atlasId.human}/parcellations/${parcId.human.jba29}/regions/area%2044%20left?space_id=${encodeURIComponent(spaceId)}`)).json() + const endPt = await SAPI.BsEndpoint$.toPromise() + return await (await fetch(`${endPt}/atlases/${atlasId.human}/parcellations/${parcId.human.jba29}/regions/area%2044%20left?space_id=${encodeURIComponent(spaceId)}`)).json() } export async function getHoc1RightSpatialFeatures(): Promise<SxplrCleanedFeatureModel[]> { - const json: SapiSpatialFeatureModel[] = await (await fetch(`${SAPI.bsEndpoint}/atlases/${atlasId.human}/spaces/${spaceId.human.mni152}/features?parcellation_id=2.9®ion=hoc1%20right`)).json() + const endPt = await SAPI.BsEndpoint$.toPromise() + const json: SapiSpatialFeatureModel[] = await (await fetch(`${endPt}/atlases/${atlasId.human}/spaces/${spaceId.human.mni152}/features?parcellation_id=2.9®ion=hoc1%20right`)).json() return cleanIeegSessionDatasets(json.filter(it => it['@type'] === "siibra/features/ieegSession")) } export async function getHoc1RightFeatures(): Promise<SapiRegionalFeatureModel[]> { - return await (await fetch(`${SAPI.bsEndpoint}/atlases/${atlasId.human}/parcellations/${parcId.human.jba29}/regions/hoc1%20right/features`)).json() + const endPt = await SAPI.BsEndpoint$.toPromise() + return await (await fetch(`${endPt}/atlases/${atlasId.human}/parcellations/${parcId.human.jba29}/regions/hoc1%20right/features`)).json() } export async function getHoc1RightFeatureDetail(featId: string): Promise<SapiRegionalFeatureModel>{ - return await (await fetch(`${SAPI.bsEndpoint}/atlases/${atlasId.human}/parcellations/${parcId.human.jba29}/regions/hoc1%20right/features/${encodeURIComponent(featId)}`)).json() + const endPt = await SAPI.BsEndpoint$.toPromise() + return await (await fetch(`${endPt}/atlases/${atlasId.human}/parcellations/${parcId.human.jba29}/regions/hoc1%20right/features/${encodeURIComponent(featId)}`)).json() } export async function getJba29Features(): Promise<SapiParcellationFeatureModel[]> { - return await (await fetch(`${SAPI.bsEndpoint}/atlases/${atlasId.human}/parcellations/${parcId.human.jba29}/features`)).json() + const endPt = await SAPI.BsEndpoint$.toPromise() + return await (await fetch(`${endPt}/atlases/${atlasId.human}/parcellations/${parcId.human.jba29}/features`)).json() } export async function getBigbrainSpatialFeatures(): Promise<SapiSpatialFeatureModel[]>{ @@ -137,14 +151,16 @@ export async function getBigbrainSpatialFeatures(): Promise<SapiSpatialFeatureMo [-1000, -1000, -1000], [1000, 1000, 1000] ] - const url = new URL(`${SAPI.bsEndpoint}/atlases/${atlasId.human}/spaces/${spaceId.human.bigbrain}/features`) + const endPt = await SAPI.BsEndpoint$.toPromise() + const url = new URL(`${endPt}/atlases/${atlasId.human}/spaces/${spaceId.human.bigbrain}/features`) url.searchParams.set(`bbox`, JSON.stringify(bbox)) return await (await fetch(url.toString())).json() } export async function getMni152SpatialFeatureHoc1Right(): Promise<SapiSpatialFeatureModel[]>{ - const url = new URL(`${SAPI.bsEndpoint}/atlases/${atlasId.human}/spaces/${spaceId.human.mni152}/features`) + const endPt = await SAPI.BsEndpoint$.toPromise() + const url = new URL(`${endPt}/atlases/${atlasId.human}/spaces/${spaceId.human.mni152}/features`) url.searchParams.set(`parcellation_id`, parcId.human.jba29) url.searchParams.set("region", 'hoc1 right') return await (await fetch(url.toString())).json() diff --git a/src/atlasComponents/sapiViews/core/atlas/tmplParcSelector/tmplParcSelector.component.ts b/src/atlasComponents/sapiViews/core/atlas/tmplParcSelector/tmplParcSelector.component.ts index 5636ccdd3e36c4930bab5a923f1b20d4ec25507a..58a2151732937cd390d516ace5a2bfab2de87f7b 100644 --- a/src/atlasComponents/sapiViews/core/atlas/tmplParcSelector/tmplParcSelector.component.ts +++ b/src/atlasComponents/sapiViews/core/atlas/tmplParcSelector/tmplParcSelector.component.ts @@ -104,13 +104,20 @@ export class SapiViewsCoreAtlasAtlasTmplParcSelector { ) ) - private showOverlayIntent$ = new Subject() + private showOverlayIntentByTemplate$ = new Subject() + private showOverlayIntentByParcellation$ = new Subject() public showLoadingOverlay$ = merge( - this.showOverlayIntent$.pipe( + this.showOverlayIntentByTemplate$.pipe( mapTo(true) ), this.selectedTemplate$.pipe( mapTo(false) + ), + this.showOverlayIntentByParcellation$.pipe( + mapTo(true) + ), + this.selectedParcellation$.pipe( + mapTo(false) ) ).pipe( distinctUntilChanged(), @@ -180,7 +187,7 @@ export class SapiViewsCoreAtlasAtlasTmplParcSelector { } selectTemplate(tmpl: SapiSpaceModel) { - this.showOverlayIntent$.next(true) + this.showOverlayIntentByTemplate$.next(true) this.store$.dispatch( atlasSelection.actions.selectTemplate({ @@ -190,6 +197,7 @@ export class SapiViewsCoreAtlasAtlasTmplParcSelector { } selectParcellation(parc: SapiParcellationModel) { + this.showOverlayIntentByParcellation$.next(true) this.store$.dispatch( atlasSelection.actions.selectParcellation({ diff --git a/src/atlasComponents/sapiViews/core/datasets/dataset/dataset.component.ts b/src/atlasComponents/sapiViews/core/datasets/dataset/dataset.component.ts index 08bdb9d55bd85a616914cae076797142f3ff1026..22813095a37d1c1b8b6a12b44003995d8b7e8db5 100644 --- a/src/atlasComponents/sapiViews/core/datasets/dataset/dataset.component.ts +++ b/src/atlasComponents/sapiViews/core/datasets/dataset/dataset.component.ts @@ -1,15 +1,29 @@ -import { Component, Input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from "@angular/core"; import { SapiDatasetModel } from "src/atlasComponents/sapi"; +import { CONST } from "common/constants" + +const RESTRICTED_ACCESS_ID = "https://nexus.humanbrainproject.org/v0/data/minds/core/embargostatus/v1.0.0/3054f80d-96a8-4dce-9b92-55c68a8b5efd" @Component({ selector: `sxplr-sapiviews-core-datasets-dataset`, templateUrl: './dataset.template.html', styleUrls: [ `./dataset.style.css` - ] + ], + changeDetection: ChangeDetectionStrategy.OnPush }) -export class DatasetView { +export class DatasetView implements OnChanges{ @Input('sxplr-sapiviews-core-datasets-dataset-input') dataset: SapiDatasetModel + + public isRestricted = false + public CONST = CONST + + ngOnChanges(changes: SimpleChanges): void { + const { dataset } = changes + if (dataset) { + this.isRestricted = (dataset.currentValue as SapiDatasetModel)?.metadata?.accessibility?.["@id"] === RESTRICTED_ACCESS_ID + } + } } diff --git a/src/atlasComponents/sapiViews/core/datasets/dataset/dataset.template.html b/src/atlasComponents/sapiViews/core/datasets/dataset/dataset.template.html index 68ce4f9a8540b0c1154408328260ed2e00a07b5a..a04996afbb8d961cba682ed0cd1cc9d5b2940d31 100644 --- a/src/atlasComponents/sapiViews/core/datasets/dataset/dataset.template.html +++ b/src/atlasComponents/sapiViews/core/datasets/dataset/dataset.template.html @@ -24,9 +24,16 @@ <span class="sxplr-m-a"> EBRAINS dataset </span> + + <button *ngIf="isRestricted" + [matTooltip]="CONST.GDPR_TEXT" + mat-icon-button color="warn"> + <i class="fas fa-exclamation-triangle"></i> + </button> + <mat-divider class="sxplr-pl-1" [vertical]="true"></mat-divider> - <a mat-icon-button *ngFor="let url of dataset.urls" [href]="url.doi | parseDoi" target="_blank"> + <a mat-icon-button sxplr-hide-when-local *ngFor="let url of dataset.urls" [href]="url.doi | parseDoi" target="_blank"> <i class="fas fa-external-link-alt"></i> </a> </mat-card-subtitle> diff --git a/src/atlasComponents/sapiViews/core/datasets/module.ts b/src/atlasComponents/sapiViews/core/datasets/module.ts index b295da6bc25f1a9fdc98031a42810ba907013320..d7b43955b58f954fa02bbbf9000982e5ac382b41 100644 --- a/src/atlasComponents/sapiViews/core/datasets/module.ts +++ b/src/atlasComponents/sapiViews/core/datasets/module.ts @@ -2,6 +2,7 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; import { MarkdownModule } from "src/components/markdown"; import { AngularMaterialModule } from "src/sharedModules"; +import { StrictLocalModule } from "src/strictLocal"; import { SapiViewsUtilModule } from "../../util/module"; import { DatasetView } from "./dataset/dataset.component"; @@ -10,7 +11,8 @@ import { DatasetView } from "./dataset/dataset.component"; CommonModule, AngularMaterialModule, MarkdownModule, - SapiViewsUtilModule + SapiViewsUtilModule, + StrictLocalModule, ], declarations: [ DatasetView, diff --git a/src/atlasComponents/sapiViews/core/index.ts b/src/atlasComponents/sapiViews/core/index.ts index 6db4628176e571f0a50adca091dc02c5d51801de..cb3d0ffce18ea93d534e44866392f0fc819b0ae8 100644 --- a/src/atlasComponents/sapiViews/core/index.ts +++ b/src/atlasComponents/sapiViews/core/index.ts @@ -1,3 +1,7 @@ export { SapiViewsCoreModule -} from "./module" \ No newline at end of file +} from "./module" + +export { + SapiViewsCoreSpaceBoundingBox +} from "./space" \ No newline at end of file diff --git a/src/atlasComponents/sapiViews/core/parcellation/module.ts b/src/atlasComponents/sapiViews/core/parcellation/module.ts index fca2367997a445d3ccb20593a6ce44cf3f1f9283..1fe2e70c09d9a1bda3e3fd3a991d76eac75be09b 100644 --- a/src/atlasComponents/sapiViews/core/parcellation/module.ts +++ b/src/atlasComponents/sapiViews/core/parcellation/module.ts @@ -4,10 +4,14 @@ import { Store } from "@ngrx/store"; import { ComponentsModule } from "src/components"; import { AngularMaterialModule } from "src/sharedModules"; import { atlasAppearance } from "src/state"; +import { StrictLocalModule } from "src/strictLocal"; +import { DialogModule } from "src/ui/dialogInfo/module"; import { UtilModule } from "src/util"; +import { SapiViewsUtilModule } from "../../util"; import { SapiViewsCoreParcellationParcellationChip } from "./chip/parcellation.chip.component"; import { FilterGroupedParcellationPipe } from "./filterGroupedParcellations.pipe"; import { FilterUnsupportedParcPipe } from "./filterUnsupportedParc.pipe"; +import { ParcellationDoiPipe } from "./parcellationDoi.pipe"; import { ParcellationIsBaseLayer } from "./parcellationIsBaseLayer.pipe"; import { ParcellationVisibilityService } from "./parcellationVis.service"; import { PreviewParcellationUrlPipe } from "./previewParcellationUrl.pipe"; @@ -20,6 +24,9 @@ import { SapiViewsCoreParcellationParcellationTile } from "./tile/parcellation.t ComponentsModule, AngularMaterialModule, UtilModule, + SapiViewsUtilModule, + DialogModule, + StrictLocalModule ], declarations: [ SapiViewsCoreParcellationParcellationTile, @@ -29,6 +36,7 @@ import { SapiViewsCoreParcellationParcellationTile } from "./tile/parcellation.t FilterGroupedParcellationPipe, FilterUnsupportedParcPipe, ParcellationIsBaseLayer, + ParcellationDoiPipe, ], exports: [ SapiViewsCoreParcellationParcellationTile, diff --git a/src/atlasComponents/sapiViews/core/parcellation/parcellationDoi.pipe.ts b/src/atlasComponents/sapiViews/core/parcellation/parcellationDoi.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..8652365e1bb40d6565ccfd9d88e30eb94492c6d4 --- /dev/null +++ b/src/atlasComponents/sapiViews/core/parcellation/parcellationDoi.pipe.ts @@ -0,0 +1,18 @@ +import { Pipe, PipeTransform } from "@angular/core"; +import { SapiParcellationModel } from "src/atlasComponents/sapi/type"; + +@Pipe({ + name: 'parcellationDoiPipe', + pure: true +}) + +export class ParcellationDoiPipe implements PipeTransform { + public transform(parc: SapiParcellationModel): string[] { + const urls = (parc?.brainAtlasVersions || []).filter( + v => v.digitalIdentifier && v.digitalIdentifier['@type'] === 'https://openminds.ebrains.eu/core/DOI' + ).map( + v => v.digitalIdentifier['@id'] as string + ) + return Array.from(new Set(urls)) + } +} diff --git a/src/atlasComponents/sapiViews/core/parcellation/parcellationIsBaseLayer.pipe.ts b/src/atlasComponents/sapiViews/core/parcellation/parcellationIsBaseLayer.pipe.ts index 7bd6f31f13409a290cd1adf7e9062eb04cd4e591..2c3c09a9d9b7307a7a1f8b18c193565137e9bd2f 100644 --- a/src/atlasComponents/sapiViews/core/parcellation/parcellationIsBaseLayer.pipe.ts +++ b/src/atlasComponents/sapiViews/core/parcellation/parcellationIsBaseLayer.pipe.ts @@ -2,9 +2,31 @@ import { Pipe, PipeTransform } from "@angular/core"; import { SapiParcellationModel } from "src/atlasComponents/sapi/type"; const baseLayerIds = [ + /** + * julich brain + */ "minds/core/parcellationatlas/v1.0.0/94c1125b-b87e-45e4-901c-00daee7f2579-290", "minds/core/parcellationatlas/v1.0.0/94c1125b-b87e-45e4-901c-00daee7f2579-25", "minds/core/parcellationatlas/v1.0.0/94c1125b-b87e-45e4-901c-00daee7f2579", + + /** + * allen mouse + */ + "minds/core/parcellationatlas/v1.0.0/05655b58-3b6f-49db-b285-64b5a0276f83", + "minds/core/parcellationatlas/v1.0.0/39a1384b-8413-4d27-af8d-22432225401f", + + /** + * waxholm + */ + "minds/core/parcellationatlas/v1.0.0/11017b35-7056-4593-baad-3934d211daba", + "minds/core/parcellationatlas/v1.0.0/2449a7f0-6dd0-4b5a-8f1e-aec0db03679d", + "minds/core/parcellationatlas/v1.0.0/ebb923ba-b4d5-4b82-8088-fa9215c2e1fe", + "minds/core/parcellationatlas/v1.0.0/ebb923ba-b4d5-4b82-8088-fa9215c2e1fe-v4", + + /** + * monkey + */ + "minds/core/parcellationatlas/v1.0.0/mebrains-tmp-id", ] @Pipe({ diff --git a/src/atlasComponents/sapiViews/core/parcellation/parcellationVersion.pipe.spec.ts b/src/atlasComponents/sapiViews/core/parcellation/parcellationVersion.pipe.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed2edbf53711f0c84c1b024c24b46b2e30934f63 --- /dev/null +++ b/src/atlasComponents/sapiViews/core/parcellation/parcellationVersion.pipe.spec.ts @@ -0,0 +1,53 @@ +import { IDS } from "src/atlasComponents/sapi/constants" +import { SAPI } from "src/atlasComponents/sapi/sapi.service" +import { SapiParcellationModel } from "src/atlasComponents/sapi/type" +import { getTraverseFunctions } from "./parcellationVersion.pipe" + +describe(`parcellationVersion.pipe.ts`, () => { + describe("getTraverseFunctions", () => { + let julichBrainParcellations: SapiParcellationModel[] = [] + beforeAll(async () => { + const bsEndPoint = await SAPI.BsEndpoint$.toPromise() + const res = await fetch(`${bsEndPoint}/atlases/${encodeURIComponent(IDS.ATLAES.HUMAN)}/parcellations`) + const arr: SapiParcellationModel[] = await res.json() + julichBrainParcellations = arr.filter(it => /Julich-Brain Cytoarchitectonic Maps/.test(it.name)) + }) + it("> should be at least 3 parcellations", () => { + expect(julichBrainParcellations.length).toBeGreaterThanOrEqual(3) + }) + + const scenarios = [{ + name: "default", + inputFlag: undefined, + expect25: false + },{ + name: "skipDeprecated set to true", + inputFlag: true, + expect25: false + },{ + name: "skipDeprecated set to false", + inputFlag: false, + expect25: true + }] + + for (const { name, inputFlag, expect25} of scenarios) { + describe(name, () => { + it(`expect to find 25: ${expect25}`, () => { + const { findNewer, findOldest } = typeof inputFlag === "undefined" + ? getTraverseFunctions(julichBrainParcellations) + : getTraverseFunctions(julichBrainParcellations, inputFlag) + let cursor: SapiParcellationModel = findOldest() + let foundFlag: boolean = false + while (cursor) { + if (cursor.name === "Julich-Brain Cytoarchitectonic Maps 2.5") { + if (expect25) foundFlag = true + break + } + cursor = findNewer(cursor) + } + expect(foundFlag).toEqual(expect25) + }) + }) + } + }) +}) \ No newline at end of file diff --git a/src/atlasComponents/sapiViews/core/parcellation/parcellationVersion.pipe.ts b/src/atlasComponents/sapiViews/core/parcellation/parcellationVersion.pipe.ts index 506acc493479285eb4271aee38f5cd1798174fda..a2e5e1f3ff73e2b3ec837188c52c5670e4348e46 100644 --- a/src/atlasComponents/sapiViews/core/parcellation/parcellationVersion.pipe.ts +++ b/src/atlasComponents/sapiViews/core/parcellation/parcellationVersion.pipe.ts @@ -1,20 +1,28 @@ import { Pipe, PipeTransform } from "@angular/core"; import { SapiParcellationModel } from "src/atlasComponents/sapi/type"; -export function getTraverseFunctions(parcellations: SapiParcellationModel[]) { +export function getTraverseFunctions(parcellations: SapiParcellationModel[], skipDeprecated: boolean = true) { - const getTraverse = (key: 'prev' | 'next') => (parc: SapiParcellationModel) => { - if (!parc.version) { - throw new Error(`parcellation ${parc.name} does not have version defined!`) - } - if (!parc.version[key]) { - return null - } - const found = parcellations.find(p => p["@id"] === parc.version[key]["@id"]) - if (!found) { - throw new Error(`parcellation ${parc.name} references ${parc.version[key]['@id']} as ${key} version, but it cannot be found.`) + const getTraverse = (key: 'prev' | 'next') => { + + const returnFunction = (parc: SapiParcellationModel) => { + if (!parc.version) { + throw new Error(`parcellation ${parc.name} does not have version defined!`) + } + if (!parc.version[key]) { + return null + } + const found = parcellations.find(p => p["@id"] === parc.version[key]["@id"]) + if (!found) { + throw new Error(`parcellation ${parc.name} references ${parc.version[key]['@id']} as ${key} version, but it cannot be found.`) + } + if (skipDeprecated && found.version.deprecated) { + return returnFunction(found) + } + return found } - return found + + return returnFunction } const findNewer = getTraverse('next') diff --git a/src/atlasComponents/sapiViews/core/parcellation/smartChip/parcellation.smartChip.component.ts b/src/atlasComponents/sapiViews/core/parcellation/smartChip/parcellation.smartChip.component.ts index f56ae3fdb4de939dad8b5ac6285f10a81e7cda8b..27d4a53cc941a95164407b86f61825c01df1527f 100644 --- a/src/atlasComponents/sapiViews/core/parcellation/smartChip/parcellation.smartChip.component.ts +++ b/src/atlasComponents/sapiViews/core/parcellation/smartChip/parcellation.smartChip.component.ts @@ -1,9 +1,10 @@ -import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from "@angular/core"; -import { Observable } from "rxjs"; +import { Component, EventEmitter, Input, OnChanges, Output, SimpleChange, SimpleChanges } from "@angular/core"; +import { BehaviorSubject, concat, Observable, of, timer } from "rxjs"; import { SapiParcellationModel } from "src/atlasComponents/sapi/type"; import { ParcellationVisibilityService } from "../parcellationVis.service"; import { ARIA_LABELS } from "common/constants" import { getTraverseFunctions } from "../parcellationVersion.pipe"; +import { mapTo, shareReplay, switchMap } from "rxjs/operators"; @Component({ selector: `sxplr-sapiviews-core-parcellation-smartchip`, @@ -37,7 +38,11 @@ export class SapiViewsCoreParcellationParcellationSmartChip implements OnChanges otherVersions: SapiParcellationModel[] - ngOnChanges() { + ngOnChanges(changes: SimpleChanges) { + const { parcellation } = changes + if (parcellation) { + this.onDismissClicked$.next(false) + } this.otherVersions = [] if (!this.parcellation) { return @@ -64,6 +69,16 @@ export class SapiViewsCoreParcellationParcellationSmartChip implements OnChanges } } + loadingParc$: Observable<SapiParcellationModel> = this.onSelectParcellation.pipe( + switchMap(parc => concat( + of(parc), + timer(5000).pipe( + mapTo(null) + ), + )), + shareReplay(1), + ) + parcellationVisibility$: Observable<boolean> = this.svc.visibility$ toggleParcellationVisibility(){ @@ -71,11 +86,19 @@ export class SapiViewsCoreParcellationParcellationSmartChip implements OnChanges } dismiss(){ + if (this.onDismissClicked$.value) return + this.onDismissClicked$.next(true) this.onDismiss.emit(this.parcellation) } selectParcellation(parc: SapiParcellationModel){ - if (parc === this.parcellation) return + if (this.trackByFn(parc) === this.trackByFn(this.parcellation)) return this.onSelectParcellation.emit(parc) } + + trackByFn(parc: SapiParcellationModel){ + return parc["@id"] + } + + onDismissClicked$ = new BehaviorSubject<boolean>(false) } diff --git a/src/atlasComponents/sapiViews/core/parcellation/smartChip/parcellation.smartChip.style.css b/src/atlasComponents/sapiViews/core/parcellation/smartChip/parcellation.smartChip.style.css index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..f83fb1f49984f554b1a3fbcea0852d7f8005949b 100644 --- a/src/atlasComponents/sapiViews/core/parcellation/smartChip/parcellation.smartChip.style.css +++ b/src/atlasComponents/sapiViews/core/parcellation/smartChip/parcellation.smartChip.style.css @@ -0,0 +1,34 @@ +.otherversion-wrapper +{ + position: relative; + overflow: hidden; + margin: 0.5rem; +} + +.otherversion-wrapper.loading > .spinner-container +{ + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + + display: flex; + align-items: center; +} + +.otherversion-wrapper.loading > .spinner-container > spinner-cmp +{ + margin: 0.5rem; +} + +.icons-container +{ + transform: scale(0.7); + margin-right: -1.5rem; +} + +.icons-container > * +{ + margin: auto 0.2rem; +} diff --git a/src/atlasComponents/sapiViews/core/parcellation/smartChip/parcellation.smartChip.template.html b/src/atlasComponents/sapiViews/core/parcellation/smartChip/parcellation.smartChip.template.html index f792f5ce186c694e5b4d56f3335611c2ecc302b5..4dac117899e6a081d5d4309053c67d51ee9e714f 100644 --- a/src/atlasComponents/sapiViews/core/parcellation/smartChip/parcellation.smartChip.template.html +++ b/src/atlasComponents/sapiViews/core/parcellation/smartChip/parcellation.smartChip.template.html @@ -1,21 +1,54 @@ <mat-menu #otherParcMenu="matMenu" [hasBackdrop]="false" - class="sxplr-bg-none sxplr-of-x-hidden sxplr-box-shadow-none sxplr-mxw-80vw"> + class="parc-smart-chip-menu-panel sxplr-bg-none sxplr-of-x-hidden sxplr-box-shadow-none sxplr-mxw-80vw"> <div (iav-outsideClick)="menuTrigger.closeMenu()"> - <sxplr-sapiviews-core-parcellation-chip *ngFor="let parc of otherVersions" - [sxplr-sapiviews-core-parcellation-chip-parcellation]="parc" - [sxplr-sapiviews-core-parcellation-chip-color]="parcellation === parc ? 'primary' : 'default'" - (sxplr-sapiviews-core-parcellation-chip-onclick)="selectParcellation(parc)"> + <div *ngFor="let parc of otherVersions" + class="otherversion-wrapper" + [ngClass]="{ + 'loading': (loadingParc$ | async) === parc + }"> - </sxplr-sapiviews-core-parcellation-chip> + + <sxplr-sapiviews-core-parcellation-chip + [ngClass]="{ + 'sxplr-blink': (loadingParc$ | async) === parc + }" + [sxplr-sapiviews-core-parcellation-chip-parcellation]="parc" + [sxplr-sapiviews-core-parcellation-chip-color]="(parcellation | equality : parc : trackByFn) ? 'primary' : 'default'" + (sxplr-sapiviews-core-parcellation-chip-onclick)="selectParcellation(parc)"> + + <div class="sxplr-scale-70" + suffix + iav-stop="mousedown click"> + + <ng-template #otherParcDesc> + <ng-template [ngTemplateOutlet]="parcDescTmpl" + [ngTemplateOutletContext]="{ parcellation: parc }"> + </ng-template> + </ng-template> + + <button mat-mini-fab color="default" + [sxplr-dialog]="otherParcDesc" + [sxplr-dialog-size]="null"> + <i class="fas fa-info"></i> + </button> + </div> + </sxplr-sapiviews-core-parcellation-chip> + + <div class="spinner-container" *ngIf="(loadingParc$ | async) === parc"> + <spinner-cmp> + </spinner-cmp> + </div> + </div> </div> </mat-menu> <sxplr-sapiviews-core-parcellation-chip [ngClass]="{ - 'sxplr-muted': !(parcellationVisibility$ | async) + 'sxplr-muted': !(parcellationVisibility$ | async), + 'sxplr-blink': onDismissClicked$ | async }" class="sxplr-d-inline-block" [sxplr-sapiviews-core-parcellation-chip-parcellation]="parcellation" @@ -25,9 +58,25 @@ #menuTrigger="matMenuTrigger" > - <div prefix class="sxplr-scale-70"> - <button mat-mini-fab - [color]="(parcellationVisibility$ | async) ? 'primary' : 'default'" + <div class="icons-container" + suffix + iav-stop="mousedown click"> + + <ng-template #mainParcDesc> + <ng-template [ngTemplateOutlet]="parcDescTmpl" + [ngTemplateOutletContext]="{ parcellation: parcellation }"> + </ng-template> + </ng-template> + + <button mat-icon-button + color="default" + [sxplr-dialog]="mainParcDesc" + [sxplr-dialog-size]="null"> + <i class="fas fa-info"></i> + </button> + + <button mat-icon-button + color="default" [matTooltip]="ARIA_LABELS.TOGGLE_DELINEATION" iav-stop="mousedown click" [iav-key-listener]="[{'type': 'keydown', 'key': 'q', 'capture': true, 'target': 'document' }]" @@ -41,16 +90,47 @@ {{ ARIA_LABELS.TOGGLE_DELINEATION }} </span> </button> - </div> - <div *ngIf="!(parcellation | parcellationIsBaseLayer)" - class="sxplr-scale-70" - suffix> <button mat-mini-fab - color="primary" - iav-stop="mousedown click" + *ngIf="!(parcellation | parcellationIsBaseLayer)" + color="default" (click)="dismiss()"> - <i class="fas fa-times"></i> + + <spinner-cmp class="sxplr-w-100 sxplr-h-100" *ngIf="onDismissClicked$ | async; else defaultDismissIcon"></spinner-cmp> + <ng-template #defaultDismissIcon> + <i class="fas fa-times"></i> + </ng-template> + </button> </div> -</sxplr-sapiviews-core-parcellation-chip> \ No newline at end of file +</sxplr-sapiviews-core-parcellation-chip> + +<!-- parcellation description template --> + +<ng-template #parcDescTmpl let-parc="parcellation"> + <h1 mat-dialog-title> + {{ parc.name }} + </h1> + <div mat-dialog-content> + <markdown-dom + *ngIf="parc.brainAtlasVersions.length > 0 && parc.brainAtlasVersions[0].versionInnovation" + [markdown]="parc.brainAtlasVersions[0].versionInnovation"> + </markdown-dom> + </div> + + <mat-dialog-actions align="start"> + <a *ngFor="let url of parc | parcellationDoiPipe" + [href]="url" + sxplr-hide-when-local + target="_blank" + mat-raised-button + color="primary"> + <div class="fas fa-external-link-alt"></div> + <span> + Dataset Detail + </span> + </a> + + <button mat-button mat-dialog-close>Close</button> + </mat-dialog-actions> +</ng-template> diff --git a/src/atlasComponents/sapiViews/core/region/module.ts b/src/atlasComponents/sapiViews/core/region/module.ts index f0e19a9bc9a82c83dc8e3501ce3f2b99ff51b7bd..60fd2425cc9b9f67008b8caa8c386dcf32614bd8 100644 --- a/src/atlasComponents/sapiViews/core/region/module.ts +++ b/src/atlasComponents/sapiViews/core/region/module.ts @@ -1,7 +1,9 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; +import { MarkdownModule } from "src/components/markdown"; import { SpinnerModule } from "src/components/spinner"; import { AngularMaterialModule } from "src/sharedModules"; +import { StrictLocalModule } from "src/strictLocal"; import { SapiViewsFeaturesModule } from "../../features"; import { SapiViewsUtilModule } from "../../util/module"; import { SapiViewsCoreRegionRegionChip } from "./region/chip/region.chip.component"; @@ -17,6 +19,8 @@ import { SapiViewsCoreRegionRegionRich } from "./region/rich/region.rich.compone SapiViewsUtilModule, SapiViewsFeaturesModule, SpinnerModule, + MarkdownModule, + StrictLocalModule, ], declarations: [ SapiViewsCoreRegionRegionListItem, diff --git a/src/atlasComponents/sapiViews/core/region/region/region.base.directive.ts b/src/atlasComponents/sapiViews/core/region/region/region.base.directive.ts index 39c1ccc4aa4429380aeae48ded57df5e68dfd3ac..96f374b267ced1fb700333654b7e62a39a9e311a 100644 --- a/src/atlasComponents/sapiViews/core/region/region/region.base.directive.ts +++ b/src/atlasComponents/sapiViews/core/region/region/region.base.directive.ts @@ -2,7 +2,7 @@ import { Directive, EventEmitter, Input, OnDestroy, Output } from "@angular/core import { SapiAtlasModel, SapiParcellationModel, SapiRegionModel, SapiSpaceModel } from "src/atlasComponents/sapi"; import { rgbToHsl } from 'common/util' import { SAPI } from "src/atlasComponents/sapi/sapi.service"; -import { Subject } from "rxjs"; +import { BehaviorSubject, Subject } from "rxjs"; import { SAPIRegion } from "src/atlasComponents/sapi/core"; @Directive({ @@ -13,7 +13,8 @@ export class SapiViewsCoreRegionRegionBase { @Input('sxplr-sapiviews-core-region-detail-flag') shouldFetchDetail = false - public fetchInProgress = false + + public fetchInProgress$ = new BehaviorSubject<boolean>(false) @Input('sxplr-sapiviews-core-region-atlas') atlas: SapiAtlasModel @@ -37,7 +38,7 @@ export class SapiViewsCoreRegionRegionBase { this.setupRegionDarkmode() return } - this.fetchInProgress = true + this.fetchInProgress$.next(true) this._region = null this.fetchDetail(val) @@ -49,7 +50,7 @@ export class SapiViewsCoreRegionRegionBase { this._region = val }) .finally(() => { - this.fetchInProgress = false + this.fetchInProgress$.next(false) this.setupRegionDarkmode() }) } diff --git a/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.template.html b/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.template.html index 22ebc5640a5a7c9787f51d44e4cdfd9068558018..2d03c20427270d82cdd3d7e66895168b5e995951 100644 --- a/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.template.html +++ b/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.template.html @@ -4,128 +4,139 @@ <ng-template [ngIf]="region"> -<mat-card class="mat-elevation-z4"> - <div - [style.backgroundColor]="regionRgbString" - class="vanishing-border" - [ngClass]="{ - 'darktheme': regionDarkmode === true, - 'lighttheme': regionDarkmode === false - }"> + <mat-card class="mat-elevation-z4"> + <div + [style.backgroundColor]="regionRgbString" + class="vanishing-border" + [ngClass]="{ + 'darktheme': regionDarkmode === true, + 'lighttheme': regionDarkmode === false + }"> - <ng-template [ngTemplateOutlet]="headerTmpl"></ng-template> - - <mat-card-title class="sxplr-custom-cmp text"> - {{ region.name }} - </mat-card-title> - - - <!-- subtitle on what it is --> - <mat-card-subtitle class="d-inline-flex align-items-center flex-wrap"> - <mat-icon fontSet="fas" fontIcon="fa-brain"></mat-icon> - <span> - Brain region - </span> - - <!-- origin datas format --> - - <mat-divider vertical="true" class="sxplr-pl-2 h-2rem"></mat-divider> - - <!-- position --> - <button mat-icon-button *ngIf="regionPosition" - (click)="navigateTo(regionPosition)" - [matTooltip]="ARIA_LABELS.GO_TO_REGION_CENTROID + ': ' + (regionPosition | numbers | addUnitAndJoin : 'mm')"> - <mat-icon fontSet="fas" fontIcon="fa-map-marked-alt"> - </mat-icon> - </button> - - <!-- explore doi --> - <a *ngFor="let doi of dois" - [href]="doi | parseDoi" - [matTooltip]="ARIA_LABELS.EXPLORE_DATASET_IN_KG" - target="_blank" - mat-icon-button> - <i class="fas fa-external-link-alt"></i> - </a> - - </mat-card-subtitle> - - </div> -</mat-card> - - -<!-- kg regional features list --> -<ng-template #kgRegionalFeatureList> - <div sxplr-sapiviews-core-region-regional-feature - [sxplr-sapiviews-core-region-atlas]="atlas" - [sxplr-sapiviews-core-region-template]="template" - [sxplr-sapiviews-core-region-parcellation]="parcellation" - [sxplr-sapiviews-core-region-region]="region" - #rfDir="sapiViewsRegionalFeature" - class="feature-list-container" - > - - <spinner-cmp *ngIf="rfDir.busy$ | async"></spinner-cmp> - - <sxplr-sapiviews-features-entry-list-item - *ngFor="let feat of rfDir.listOfFeatures$ | async" - [sxplr-sapiviews-features-entry-list-item-feature]="feat" - (click)="handleRegionalFeatureClicked(feat)"> - </sxplr-sapiviews-features-entry-list-item> - </div> - -</ng-template> + <ng-template [ngTemplateOutlet]="headerTmpl"></ng-template> + <mat-card-title class="sxplr-custom-cmp text"> + {{ region.name }} + </mat-card-title> + <!-- subtitle on what it is --> + <mat-card-subtitle class="d-inline-flex align-items-center flex-wrap"> + <mat-icon fontSet="fas" fontIcon="fa-brain"></mat-icon> + <span> + Brain region + </span> -<mat-accordion class="d-block mt-2"> + <!-- origin datas format --> + + <mat-divider vertical="true" class="sxplr-pl-2 h-2rem"></mat-divider> + + <!-- position --> + <button mat-icon-button *ngIf="regionPosition" + (click)="navigateTo(regionPosition)" + [matTooltip]="ARIA_LABELS.GO_TO_REGION_CENTROID + ': ' + (regionPosition | numbers | addUnitAndJoin : 'mm')"> + <mat-icon fontSet="fas" fontIcon="fa-map-marked-alt"> + </mat-icon> + </button> + + <!-- explore doi --> + <a *ngFor="let doi of dois" + [href]="doi | parseDoi" + sxplr-hide-when-local + [matTooltip]="ARIA_LABELS.EXPLORE_DATASET_IN_KG" + target="_blank" + mat-icon-button> + <i class="fas fa-external-link-alt"></i> + </a> + + </mat-card-subtitle> + + </div> + </mat-card> + + + <!-- kg regional features list --> + <ng-template #kgRegionalFeatureList> + <div sxplr-sapiviews-core-region-regional-feature + [sxplr-sapiviews-core-region-atlas]="atlas" + [sxplr-sapiviews-core-region-template]="template" + [sxplr-sapiviews-core-region-parcellation]="parcellation" + [sxplr-sapiviews-core-region-region]="region" + #rfDir="sapiViewsRegionalFeature" + class="feature-list-container" + > + + <spinner-cmp *ngIf="rfDir.busy$ | async"></spinner-cmp> + + <sxplr-sapiviews-features-entry-list-item + *ngFor="let feat of rfDir.listOfFeatures$ | async | orderFilterFeatures" + [sxplr-sapiviews-features-entry-list-item-feature]="feat" + (click)="handleRegionalFeatureClicked(feat)"> + </sxplr-sapiviews-features-entry-list-item> + </div> + + </ng-template> - <ng-container *ngTemplateOutlet="ngMatAccordionTmpl; context: { - title: CONST.REGIONAL_FEATURES, - iconClass: 'fas fa-database', - content: kgRegionalFeatureList, - desc: '', - iconTooltip: 'Regional Features', - iavNgIf: true - }"> - </ng-container> + <ng-template #regionDesc> + <markdown-dom class="sxplr-muted" [markdown]="region?.versionInnovation || 'No description provided.'"> + </markdown-dom> + </ng-template> -</mat-accordion> + <mat-accordion class="d-block mt-2"> -<mat-accordion class="d-block mt-2"> + <ng-container *ngTemplateOutlet="ngMatAccordionTmpl; context: { + title: CONST.DESCRIPTION, + iconClass: 'fas fa-info', + content: regionDesc, + desc: '', + iconTooltip: 'Description', + iavNgIf: !!region?.versionInnovation + }"> - <!-- connectivity --> - <ng-template #sxplrSapiviewsFeaturesConnectivityBrowser> - <sxplr-sapiviews-features-connectivity-browser class="pe-all flex-shrink-1" - [region]="region" - [types]="hasConnectivityDirective.availableModalities" - [defaultProfile]="hasConnectivityDirective.defaultProfile" - [sxplr-sapiviews-features-connectivity-browser-atlas]="atlas" - [sxplr-sapiviews-features-connectivity-browser-parcellation]="parcellation" - [accordionExpanded]="expandedPanel === CONST.CONNECTIVITY" - > - </sxplr-sapiviews-features-connectivity-browser> - </ng-template> + </ng-container> + + <ng-container *ngTemplateOutlet="ngMatAccordionTmpl; context: { + title: CONST.REGIONAL_FEATURES, + iconClass: 'fas fa-database', + content: kgRegionalFeatureList, + desc: '', + iconTooltip: 'Regional Features', + iavNgIf: true + }"> + </ng-container> + + <!-- connectivity --> + <ng-template #sxplrSapiviewsFeaturesConnectivityBrowser> + <sxplr-sapiviews-features-connectivity-browser + class="pe-all flex-shrink-1" + [region]="region" + [types]="hasConnectivityDirective.availableModalities" + [defaultProfile]="hasConnectivityDirective.defaultProfile" + [sxplr-sapiviews-features-connectivity-browser-atlas]="atlas" + [sxplr-sapiviews-features-connectivity-browser-parcellation]="parcellation" + [accordionExpanded]="expandedPanel === CONST.CONNECTIVITY" + > + </sxplr-sapiviews-features-connectivity-browser> + </ng-template> + + <ng-container *ngTemplateOutlet="ngMatAccordionTmpl; context: { + title: CONST.CONNECTIVITY, + iconClass: 'fab fa-connectdevelop', + content: sxplrSapiviewsFeaturesConnectivityBrowser, + desc: hasConnectivityDirective.connectivityNumber, + iconTooltip: hasConnectivityDirective.connectivityNumber + 'Connections', + iavNgIf: hasConnectivityDirective.hasConnectivity + }"> + </ng-container> + + <div sxplr-sapiviews-features-connectivity-check + [sxplr-sapiviews-features-connectivity-check-atlas]="atlas" + [sxplr-sapiviews-features-connectivity-check-parcellation]="parcellation" + [region]="region" + #hasConnectivityDirective="hasConnectivityDirective"> + </div> - <ng-container *ngTemplateOutlet="ngMatAccordionTmpl; context: { - title: CONST.CONNECTIVITY, - iconClass: 'fab fa-connectdevelop', - content: sxplrSapiviewsFeaturesConnectivityBrowser, - desc: hasConnectivityDirective.connectivityNumber, - iconTooltip: hasConnectivityDirective.connectivityNumber + 'Connections', - iavNgIf: hasConnectivityDirective.hasConnectivity - }"> - </ng-container> - - <div sxplr-sapiviews-features-connectivity-check - [sxplr-sapiviews-features-connectivity-check-atlas]="atlas" - [sxplr-sapiviews-features-connectivity-check-parcellation]="parcellation" - [region]="region" - #hasConnectivityDirective="hasConnectivityDirective"> - </div> - -</mat-accordion> + </mat-accordion> </ng-template> diff --git a/src/atlasComponents/sapiViews/core/space/index.ts b/src/atlasComponents/sapiViews/core/space/index.ts index 46f783b69e03bdae2ef01144ba731e0b264c1c12..26c7eed07b1e454d4eac3bcbdf0c77bb9fe4f162 100644 --- a/src/atlasComponents/sapiViews/core/space/index.ts +++ b/src/atlasComponents/sapiViews/core/space/index.ts @@ -1 +1,4 @@ -export { SapiViewsCoreSpaceModule } from "./module" \ No newline at end of file +export { SapiViewsCoreSpaceModule } from "./module" +export { + SapiViewsCoreSpaceBoundingBox +} from "./boundingBox.directive" \ No newline at end of file diff --git a/src/atlasComponents/sapiViews/features/connectivity/connectivityBrowser/connectivityBrowser.component.spec.ts b/src/atlasComponents/sapiViews/features/connectivity/connectivityBrowser/connectivityBrowser.component.spec.ts index 3889c4c3f20621280a66f92f3e37f2a4521414d0..b1efcd694501bd33796a961449fbb3fec244b28c 100644 --- a/src/atlasComponents/sapiViews/features/connectivity/connectivityBrowser/connectivityBrowser.component.spec.ts +++ b/src/atlasComponents/sapiViews/features/connectivity/connectivityBrowser/connectivityBrowser.component.spec.ts @@ -6,7 +6,6 @@ import {CUSTOM_ELEMENTS_SCHEMA, Directive, Input} from "@angular/core"; import {provideMockActions} from "@ngrx/effects/testing"; import {MockStore, provideMockStore} from "@ngrx/store/testing"; import {Observable, of} from "rxjs"; -import {BS_ENDPOINT} from "src/util/constants"; import {SAPI} from "src/atlasComponents/sapi"; import {AngularMaterialModule} from "src/sharedModules"; @@ -66,10 +65,6 @@ describe('ConnectivityComponent', () => { providers: [ provideMockActions(() => actions$), provideMockStore(), - { - provide: BS_ENDPOINT, - useValue: MOCK_BS_ENDPOINT - }, { provide: SAPI, useValue: { diff --git a/src/atlasComponents/sapiViews/features/entryListItem/entryListItem.component.ts b/src/atlasComponents/sapiViews/features/entryListItem/entryListItem.component.ts index 3b915ff248a21b0cf19d3dc5fbac8131b2d21643..8008affe34a6b783fd1b93ed14ee527279e36d04 100644 --- a/src/atlasComponents/sapiViews/features/entryListItem/entryListItem.component.ts +++ b/src/atlasComponents/sapiViews/features/entryListItem/entryListItem.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, Input } from "@angular/core"; import { SapiFeatureModel } from "src/atlasComponents/sapi"; import { CleanedIeegDataset, CLEANED_IEEG_DATASET_TYPE, SapiDatasetModel, SapiParcellationFeatureMatrixModel, SapiRegionalFeatureReceptorModel, SapiSerializationErrorModel, SapiVOIDataResponse, SxplrCleanedFeatureModel } from "src/atlasComponents/sapi/type"; @@ -7,7 +7,8 @@ import { CleanedIeegDataset, CLEANED_IEEG_DATASET_TYPE, SapiDatasetModel, SapiPa templateUrl: `./entryListItem.template.html`, styleUrls: [ `./entryListItem.style.css` - ] + ], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SapiViewsFeaturesEntryListItem{ diff --git a/src/atlasComponents/sapiViews/features/entryListItem/entryListItem.template.html b/src/atlasComponents/sapiViews/features/entryListItem/entryListItem.template.html index 76f584328b3e46b5a240defb44e98e7cc2abe4a2..fc1ac7920b3599f52aafb071417eb6bb41c0d6e1 100644 --- a/src/atlasComponents/sapiViews/features/entryListItem/entryListItem.template.html +++ b/src/atlasComponents/sapiViews/features/entryListItem/entryListItem.template.html @@ -3,7 +3,7 @@ <mat-chip-list *ngIf="feature | featureBadgeFlag" - class="sxplr-scale-80 transform-origin-left-center"> + class="sxplr-scale-80 transform-origin-left-center sxplr-pe-none"> <mat-chip [color]="feature | featureBadgeColour" selected> diff --git a/src/atlasComponents/sapiViews/features/index.ts b/src/atlasComponents/sapiViews/features/index.ts index 19e30dcb1873aa37868be5e8944eaad3a9b0ce05..89e1fafbad8108576c708d401326b2d5f4137f6d 100644 --- a/src/atlasComponents/sapiViews/features/index.ts +++ b/src/atlasComponents/sapiViews/features/index.ts @@ -1,3 +1,7 @@ export { SapiViewsFeaturesModule -} from "./module" \ No newline at end of file +} from "./module" + +export { + SapiViewsFeaturesVoiQuery +} from "./voi" diff --git a/src/atlasComponents/sapiViews/features/module.ts b/src/atlasComponents/sapiViews/features/module.ts index cbf7aeacaeccaf721bb6ca55108b01366f3fa685..3df348c72f79be4b53c3050e30627c896e8b93f2 100644 --- a/src/atlasComponents/sapiViews/features/module.ts +++ b/src/atlasComponents/sapiViews/features/module.ts @@ -11,6 +11,7 @@ import * as ieeg from "./ieeg" import * as receptor from "./receptors" import {SapiViewsFeatureConnectivityModule} from "src/atlasComponents/sapiViews/features/connectivity"; import * as voi from "./voi" +import { OrderFilterFeaturesPipe } from "./orderFilterFeatureList.pipe" const { SxplrSapiViewsFeaturesIeegModule @@ -35,6 +36,7 @@ const { SapiViewsFeaturesVoiModule } = voi FeatureBadgeColourPipe, FeatureBadgeFlagPipe, SapiViewsFeaturesEntryListItem, + OrderFilterFeaturesPipe, ], providers: [ { @@ -48,6 +50,7 @@ const { SapiViewsFeaturesVoiModule } = voi SapiViewsFeaturesEntryListItem, SapiViewsFeaturesVoiModule, SapiViewsFeatureConnectivityModule, + OrderFilterFeaturesPipe, ] }) export class SapiViewsFeaturesModule{} diff --git a/src/atlasComponents/sapiViews/features/orderFilterFeatureList.pipe.ts b/src/atlasComponents/sapiViews/features/orderFilterFeatureList.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..382862b0300f4bdaffadce20aeb4e28274a8bef6 --- /dev/null +++ b/src/atlasComponents/sapiViews/features/orderFilterFeatureList.pipe.ts @@ -0,0 +1,37 @@ +import { Pipe, PipeTransform } from "@angular/core"; +import { CLEANED_IEEG_DATASET_TYPE, SapiFeatureModel, SxplrCleanedFeatureModel } from "src/atlasComponents/sapi/type"; +import { environment } from "src/environments/environment" + +type PipableFeatureType = SapiFeatureModel | SxplrCleanedFeatureModel + +type ArrayOperation<T extends boolean | number> = (input: PipableFeatureType) => T + +const FILTER_FN: ArrayOperation<boolean> = feature => { + return feature["@type"] !== "siibra/features/cells" +} + +const ORDER_LIST: ArrayOperation<number> = feature => { + if (feature["@type"] === "siibra/features/receptor") return -4 + if (feature["@type"] === CLEANED_IEEG_DATASET_TYPE) return -3 + if (feature['@type'] === "https://openminds.ebrains.eu/core/DatasetVersion") return 2 + return 0 +} + +@Pipe({ + name: 'orderFilterFeatures', + pure: true +}) + +export class OrderFilterFeaturesPipe implements PipeTransform{ + public transform(inputFeatures: PipableFeatureType[]): PipableFeatureType[] { + return inputFeatures + .filter(f => { + /** + * if experimental flag is set, do not filter out anything + */ + if (environment.EXPERIMENTAL_FEATURE_FLAG) return true + return FILTER_FN(f) + }) + .sort((a, b) => ORDER_LIST(a) - ORDER_LIST(b)) + } +} diff --git a/src/atlasComponents/sapiViews/features/receptors/module.ts b/src/atlasComponents/sapiViews/features/receptors/module.ts index d5bd04dc6b11f71c1646b4c8b2892c6ddf372f6b..34f29d7f07be5b5df7ddfaad65d9f2c6423fbb04 100644 --- a/src/atlasComponents/sapiViews/features/receptors/module.ts +++ b/src/atlasComponents/sapiViews/features/receptors/module.ts @@ -32,21 +32,6 @@ import { Profile } from "./profile/profile.component" Profile, Entry, ], - providers: [{ - provide: APP_INITIALIZER, - multi: true, - useFactory: (appendScriptFn: (url: string) => Promise<any>) => { - - const libraries = [ - 'https://cdnjs.cloudflare.com/ajax/libs/d3/6.2.0/d3.min.js', - 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.1.2/es5/tex-svg.js' - ] - return () => Promise.all(libraries.map(appendScriptFn)) - }, - deps: [ - APPEND_SCRIPT_TOKEN - ] - }], schemas: [ CUSTOM_ELEMENTS_SCHEMA, ] diff --git a/src/atlasComponents/sapiViews/util/equality.pipe.ts b/src/atlasComponents/sapiViews/util/equality.pipe.ts new file mode 100644 index 0000000000000000000000000000000000000000..8251d641fed51ac99384c6470af5623e0c37451c --- /dev/null +++ b/src/atlasComponents/sapiViews/util/equality.pipe.ts @@ -0,0 +1,16 @@ +import { Pipe } from "@angular/core"; + +type TTrackBy<T, O> = (input: T) => O + +const defaultTrackBy: TTrackBy<unknown, unknown> = i => i + +@Pipe({ + name: 'equality', + pure: true +}) + +export class EqualityPipe<T>{ + public transform(c1: T, c2: T, trackBy: TTrackBy<T, unknown> = defaultTrackBy): boolean { + return trackBy(c1) === trackBy(c2) + } +} diff --git a/src/atlasComponents/sapiViews/util/module.ts b/src/atlasComponents/sapiViews/util/module.ts index 4a9eddaf8ef6cb42a5b0b5b1bec270fac766f82b..53a3a88c549821dfe9a7ee654c93dac7747cd368 100644 --- a/src/atlasComponents/sapiViews/util/module.ts +++ b/src/atlasComponents/sapiViews/util/module.ts @@ -1,5 +1,6 @@ import { NgModule } from "@angular/core"; import { AddUnitAndJoin } from "./addUnitAndJoin.pipe"; +import { EqualityPipe } from "./equality.pipe"; import { IncludesPipe } from "./includes.pipe"; import { NumbersPipe } from "./numbers.pipe"; import { ParcellationSupportedInCurrentSpace } from "./parcellationSupportedInCurrentSpace.pipe"; @@ -9,6 +10,7 @@ import { SpaceSupportedInCurrentParcellationPipe } from "./spaceSupportedInCurre @NgModule({ declarations: [ + EqualityPipe, ParseDoiPipe, NumbersPipe, AddUnitAndJoin, @@ -18,6 +20,7 @@ import { SpaceSupportedInCurrentParcellationPipe } from "./spaceSupportedInCurre SpaceSupportedInCurrentParcellationPipe, ], exports: [ + EqualityPipe, ParseDoiPipe, NumbersPipe, AddUnitAndJoin, diff --git a/src/atlasComponents/sapiViews/util/parcellationSupportedInSpace.pipe.ts b/src/atlasComponents/sapiViews/util/parcellationSupportedInSpace.pipe.ts index f16e6a0a82d991ca8955e84f9ed4a15fe14e2ab4..42eec193e93fcd29dcf576c45135bf0b0f892eef 100644 --- a/src/atlasComponents/sapiViews/util/parcellationSupportedInSpace.pipe.ts +++ b/src/atlasComponents/sapiViews/util/parcellationSupportedInSpace.pipe.ts @@ -1,5 +1,5 @@ import { Pipe, PipeTransform } from "@angular/core"; -import { Observable, of } from "rxjs"; +import { NEVER, Observable, of } from "rxjs"; import { map } from "rxjs/operators"; import { SAPIParcellation } from "src/atlasComponents/sapi/core"; import { SAPI } from "src/atlasComponents/sapi/sapi.service"; @@ -29,6 +29,7 @@ export class ParcellationSupportedInSpacePipe implements PipeTransform{ constructor(private sapi: SAPI){} public transform(parc: SapiParcellationModel|string, tmpl: SapiSpaceModel|string): Observable<boolean> { + if (!parc) return NEVER const parcId = typeof parc === "string" ? parc : parc["@id"] diff --git a/src/atlasViewer/atlasViewer.workerService.service.ts b/src/atlasViewer/atlasViewer.workerService.service.ts index 9a17115534cae61a899a29c7d32bc4602599fd19..67a422714822d25e47fdb55b345116cda9db8eba 100644 --- a/src/atlasViewer/atlasViewer.workerService.service.ts +++ b/src/atlasViewer/atlasViewer.workerService.service.ts @@ -3,9 +3,6 @@ import { fromEvent } from "rxjs"; import { filter, take } from "rxjs/operators"; import { getUuid } from "src/util/fn"; -// worker is now exported in angular.json file -export const worker = new Worker('worker.js') - interface IWorkerMessage { method: string param: any @@ -17,7 +14,11 @@ interface IWorkerMessage { }) export class AtlasWorkerService { - public worker = worker + private worker: Worker + + constructor(){ + this.worker = new Worker('worker.js') + } async sendMessage(_data: IWorkerMessage){ diff --git a/src/environments/environment.common.ts b/src/environments/environment.common.ts index f4d5c96275db63b6e84e76a7223c597139fba508..4f410904c371a0239abecd0e602153b1680fdba3 100644 --- a/src/environments/environment.common.ts +++ b/src/environments/environment.common.ts @@ -4,7 +4,7 @@ export const environment = { VERSION: 'unknown version', PRODUCTION: true, BACKEND_URL: null, - BS_REST_URL: 'https://siibra-api-latest.apps-dev.hbp.eu/v2_0', + SIIBRA_API_ENDPOINTS: 'https://siibra-api-stable.apps.hbp.eu/v2_0,https://siibra-api-stable.apps.jsc.hbp.eu/v2_0,https://siibra-api-stable-ns.apps.hbp.eu/v2_0', SPATIAL_TRANSFORM_BACKEND: 'https://hbp-spatial-backend.apps.hbp.eu', MATOMO_URL: null, MATOMO_ID: null, diff --git a/src/environments/parseEnv.js b/src/environments/parseEnv.js index b05909d596c85cc1875650602cafb8a5fa2fba3a..9533d341158797f5da8a604929d9327e8980545f 100644 --- a/src/environments/parseEnv.js +++ b/src/environments/parseEnv.js @@ -2,15 +2,17 @@ const fs = require('fs') const path = require('path') const { promisify } = require('util') const asyncWrite = promisify(fs.writeFile) +const process = require("process") const main = async () => { - const pathToEnvFile = path.join(__dirname, './environment.prod.ts') + const target = process.argv[2] || './environment.prod.ts' + const pathToEnvFile = path.join(__dirname, target) const { BACKEND_URL, STRICT_LOCAL, MATOMO_URL, MATOMO_ID, - BS_REST_URL, + SIIBRA_API_ENDPOINTS, VERSION, GIT_HASH = 'unknown hash', EXPERIMENTAL_FEATURE_FLAG @@ -21,7 +23,7 @@ const main = async () => { STRICT_LOCAL, MATOMO_URL, MATOMO_ID, - BS_REST_URL, + SIIBRA_API_ENDPOINTS, VERSION, GIT_HASH, EXPERIMENTAL_FEATURE_FLAG, @@ -39,7 +41,7 @@ export const environment = { ...commonEnv, GIT_HASH: ${gitHash}, VERSION: ${version}, - BS_REST_URL: ${JSON.stringify(BS_REST_URL)}, + SIIBRA_API_ENDPOINTS: ${JSON.stringify(SIIBRA_API_ENDPOINTS)}, BACKEND_URL: ${JSON.stringify(BACKEND_URL)}, STRICT_LOCAL: ${JSON.stringify(STRICT_LOCAL)}, MATOMO_URL: ${JSON.stringify(MATOMO_URL)}, diff --git a/src/extra_styles.css b/src/extra_styles.css index a85cfbe825e0d260d99818e059a0927de8ce922a..643ab1e507c31fc6e833ca777748a16714e83982 100644 --- a/src/extra_styles.css +++ b/src/extra_styles.css @@ -390,11 +390,6 @@ markdown-dom p height: 0px; } -.pe-none -{ - pointer-events: none!important; -} - .h-2rem { height: 2rem!important; @@ -876,3 +871,8 @@ how-to-cite img { width: 100%; } + +.mat-menu-panel.parc-smart-chip-menu-panel +{ + max-width: 100vw; +} diff --git a/src/index.html b/src/index.html index bc2785b8c855b286c5e53b6a385243899b504e41..3a856105768123945f81f4837560f75d0bdf8f46 100644 --- a/src/index.html +++ b/src/index.html @@ -14,10 +14,11 @@ <script src="extra_js.js"></script> <script src="https://unpkg.com/kg-dataset-previewer@1.2.0/dist/kg-dataset-previewer/kg-dataset-previewer.js" defer></script> <script src="https://unpkg.com/three-surfer@0.0.11/dist/bundle.js" defer></script> - <script type="module" src="https://unpkg.com/ng-layer-tune@0.0.5/dist/ng-layer-tune/ng-layer-tune.esm.js"></script> + <script type="module" src="https://unpkg.com/ng-layer-tune@0.0.6/dist/ng-layer-tune/ng-layer-tune.esm.js"></script> <script type="module" src="https://unpkg.com/hbp-connectivity-component@0.6.2/dist/connectivity-component/connectivity-component.js" ></script> - - <title>Interactive Atlas Viewer</title> + <script defer src="https://unpkg.com/mathjax@3.1.2/es5/tex-svg.js"></script> + <script defer src="https://unpkg.com/d3@6.2.0/dist/d3.min.js"></script> + <title>Siibra Explorer</title> </head> <body> <atlas-viewer> diff --git a/src/main.module.ts b/src/main.module.ts index 7d26a8ee52400fa2acd8e9cbd0a670ddd06c5670..da570799012cb86e814b3760f110174c943f8f36 100644 --- a/src/main.module.ts +++ b/src/main.module.ts @@ -33,7 +33,6 @@ import { CookieModule } from './ui/cookieAgreement/module'; import { KgTosModule } from './ui/kgtos/module'; import { AtlasViewerRouterModule } from './routerModule'; import { MessagingGlue } from './messagingGlue'; -import { BS_ENDPOINT } from './util/constants'; import { QuickTourModule } from './ui/quickTour'; import { of } from 'rxjs'; import { CANCELLABLE_DIALOG, CANCELLABLE_DIALOG_OPTS } from './util/interfaces'; @@ -163,10 +162,6 @@ import { CONST } from "common/constants" provide: WINDOW_MESSAGING_HANDLER_TOKEN, useClass: MessagingGlue }, - { - provide: BS_ENDPOINT, - useValue: (environment.BS_REST_URL || `https://siibra-api-stable.apps.hbp.eu/v1_0`).replace(/\/$/, '') - }, { provide: DARKTHEME, useFactory: (store: Store) => store.pipe( diff --git a/src/overwrite.scss b/src/overwrite.scss index a6b3e9f55255d3080d58a94e7be6aedce47fc890..86f98096cead1a68e768e98ebd77aa084859fdf2 100644 --- a/src/overwrite.scss +++ b/src/overwrite.scss @@ -264,4 +264,28 @@ $flex-directions: row,column; .#{$nsp}-flex-static { flex: 0 0 auto; -} \ No newline at end of file +} + +.#{$nsp}-blink +{ + animation: blink 500ms ease-in-out infinite alternate; +} + +@keyframes blink { + 0% { + opacity: 0.8; + } + 100% { + opacity: 0.5; + } +} + +a[mat-raised-button] +{ + text-decoration: none; +} + +.#{$nsp}-pe-none +{ + pointer-events: none!important; +} diff --git a/src/plugin/const.ts b/src/plugin/const.ts index 68104badfa030957d049a7acdfb58286fd254680..474bc36bf2cc119f845074735cc3073368db6a13 100644 --- a/src/plugin/const.ts +++ b/src/plugin/const.ts @@ -1,3 +1,5 @@ +import { InjectionToken } from "@angular/core" + const PLUGIN_SRC_KEY = "x-plugin-portal-src" export function setPluginSrc(src: string, record: Record<string, unknown> = {}){ @@ -10,3 +12,5 @@ export function setPluginSrc(src: string, record: Record<string, unknown> = {}){ export function getPluginSrc(record: Record<string, string> = {}){ return record[PLUGIN_SRC_KEY] } + +export const SET_PLUGIN_NAME = new InjectionToken('SET_PLUGIN_NAME') diff --git a/src/plugin/pluginPortal/pluginPortal.component.ts b/src/plugin/pluginPortal/pluginPortal.component.ts index 7d5b4d4214babf9c9af7ceda85e6e2e205530851..58bd58993f3c2423ed278fee3c64b330b78b4af2 100644 --- a/src/plugin/pluginPortal/pluginPortal.component.ts +++ b/src/plugin/pluginPortal/pluginPortal.component.ts @@ -5,8 +5,7 @@ import { BoothVisitor, JRPCRequest, JRPCSuccessResp, ListenerChannel } from "src import { ApiBoothEvents, ApiService, BroadCastingApiEvents, HeartbeatEvents, namespace } from "src/api/service"; import { getUuid } from "src/util/fn"; import { WIDGET_PORTAL_TOKEN } from "src/widget/constants"; -import { getPluginSrc } from "../const"; -import { PluginService } from "../service"; +import { getPluginSrc, SET_PLUGIN_NAME } from "../const"; @Component({ selector: 'sxplr-plugin-portal', @@ -44,8 +43,8 @@ export class PluginPortal implements AfterViewInit, OnDestroy, ListenerChannel{ constructor( private apiService: ApiService, - private pluginSvc: PluginService, public vcr: ViewContainerRef, + @Optional() @Inject(SET_PLUGIN_NAME) private setPluginName: (inst: unknown, pluginName: string) => void, @Optional() @Inject(WIDGET_PORTAL_TOKEN) portalData: Record<string, string> ){ if (portalData){ @@ -91,7 +90,7 @@ export class PluginPortal implements AfterViewInit, OnDestroy, ListenerChannel{ const data = event.data as JRPCSuccessResp<HeartbeatEvents['init']['response']> this.srcName = data.result.name || 'Untitled Pluging' - this.pluginSvc.setPluginName(this, this.srcName) + this.setPluginName(this, this.srcName) while (this.handshakeSub.length > 0) this.handshakeSub.pop().unsubscribe() diff --git a/src/plugin/service.ts b/src/plugin/service.ts index 4f5a5e74f5a6bfae42a6fd445b68fcd3c115e1f2..19f183189fac54e16b9b5a33be58de632276327a 100644 --- a/src/plugin/service.ts +++ b/src/plugin/service.ts @@ -3,7 +3,7 @@ import { Injectable, Injector, NgZone } from "@angular/core"; import { WIDGET_PORTAL_TOKEN } from "src/widget/constants"; import { WidgetService } from "src/widget/service"; import { WidgetPortal } from "src/widget/widgetPortal/widgetPortal.component"; -import { setPluginSrc } from "./const"; +import { setPluginSrc, SET_PLUGIN_NAME } from "./const"; import { PluginPortal } from "./pluginPortal/pluginPortal.component"; import { environment } from "src/environments/environment" @@ -33,6 +33,9 @@ export class PluginService { providers: [{ provide: WIDGET_PORTAL_TOKEN, useValue: setPluginSrc(htmlSrc, {}) + }, { + provide: SET_PLUGIN_NAME, + useValue: (inst: PluginPortal, pluginName: string) => this.setPluginName(inst, pluginName) }], parent: this.injector }) diff --git a/src/plugin_examples/README.md b/src/plugin_examples/README.md deleted file mode 100644 index 7f967539c0997992f1dae6e391a72e084d5a33a5..0000000000000000000000000000000000000000 --- a/src/plugin_examples/README.md +++ /dev/null @@ -1,118 +0,0 @@ -# Plugin README - -A plugin needs to contain three files. -- Manifest JSON -- template HTML -- script JS - - -These files need to be served by GET requests over HTTP with appropriate CORS header. - ---- - -## Manifest JSON - -The manifest JSON file describes the metadata associated with the plugin. - -```json -{ - "name":"fzj.xg.helloWorld", - "displayName": "Hello World - my first plugin", - "templateURL":"http://LINK-TO-YOUR-PLUGIN-TEMPLATE/template.html", - "scriptURL":"http://LINK-TO-YOUR-PLUGIN-SCRIPT/script.js", - "initState":{ - "key1": "value1", - "key2" : { - "nestedKey1" : "nestedValue1" - } - }, - "initStateUrl": "http://LINK-TO-PLUGIN-STATE", - "persistency": false, - - "description": "Human readable description of the plugin.", - "desc": "Same as description. If both present, description takes more priority.", - "homepage": "https://HOMEPAGE-URL-TO-YOUR-PLUGIN/doc.html", - "authors": "Author <author@example.com>, Author2 <author2@example.org>" -} -``` -*NB* -- Plugin name must be unique globally. To prevent plugin name clashing, please adhere to the convention of naming your package **AFFILIATION.AUTHORNAME.PACKAGENAME\[.VERSION\]**. -- the `initState` object and `initStateUrl` will be available prior to the evaluation of `script.js`, and will populate the objects `interactiveViewer.pluginControl[MANIFEST.name].initState` and `interactiveViewer.pluginControl[MANIFEST.name].initStateUrl` respectively. - ---- - -## Template HTML - -The template HTML file describes the HTML view that will be rendered in the widget. - - -```html -<form> - <div class = "input-group"> - <span class = "input-group-addon">Area 1</span> - <input type = "text" id = "fzj.xg.helloWorld.area1" name = "fzj.xg.helloWorld.area1" class = "form-control" placeholder="Select a region" value = ""> - </div> - - <div class = "input-group"> - <span class = "input-group-addon">Area 2</span> - <input type = "text" id = "fzj.xg.helloWorld.area2" name = "fzj.xg.helloWorld.area2" class = "form-control" placeholder="Select a region" value = ""> - </div> - - <hr class = "col-md-10"> - - <div class = "col-md-12"> - Select genes of interest: - </div> - <div class = "input-group"> - <input type = "text" id = "fzj.xg.helloWorld.genes" name = "fzj.xg.helloWorld.genes" class = "form-control" placeholder = "Genes of interest ..."> - <span class = "input-group-btn"> - <button id = "fzj.xg.helloWorld.addgenes" name = "fzj.xg.helloWorld.addgenes" class = "btn btn-default" type = "button">Add</button> - </span> - </div> - - <hr class = "col-md-10"> - - <button id = "fzj.xg.helloWorld.submit" name = "fzj.xg.helloWorld.submit" type = "button" class = "btn btn-default btn-block">Submit</button> - - <hr class = "col-md-10"> - - <div class = "col-md-12" id = "fzj.xg.helloWorld.result"> - - </div> -</form> -``` - -*NB* -- *bootstrap 3.3.6* css is already included for templating. -- keep in mind of the widget width restriction (400px) when crafting the template -- whilst there are no vertical limits on the widget, contents can be rendered outside the viewport. Consider setting the *max-height* attribute. -- your template and script will interact with each other likely via *element id*. As a result, it is highly recommended that unique id's are used. Please adhere to the convention: **AFFILIATION.AUTHOR.PACKAGENAME.ELEMENTID** - ---- - -## Script JS - -The script will always be appended **after** the rendering of the template. - -```javascript -(()=>{ - /* your code here */ - - if(interactiveViewer.pluginControl['fzj.xg.helloWorld'].initState){ - /* init plugin with initState */ - } - - const submitButton = document.getElemenById('fzj.xg.helloWorld.submit') - submitButton.addEventListener('click',(ev)=>{ - console.log('submit button was clicked') - }) -})() -``` -*NB* -- JS is loaded and executed **before** the attachment of DOM (template). This is to allow webcomponents have a chance to be loaded. If your script needs the DOM to be attached, use a `setTimeout` callback to delay script execution. -- ensure the script is scoped locally, instead of poisoning the global scope -- for every observable subscription, call *unsubscribe()* in the *onShutdown* callback -- some frameworks such as *jquery2*, *jquery3*, *react/reactdom* and *webcomponents* can be loaded via *interactiveViewer.pluinControl.loadExternalLibraries([LIBRARY_NAME_1, LIBRARY_NAME_2])*. if the libraries are loaded, remember to hook *interactiveViewer.pluginControl.unloadExternalLibraries([LIBRARY_NAME_1,LIBRARY_NAME_2])* in the *onShutdown* callback -- when/if using webcomponents, please be aware that the `connectedCallback()` and `disconnectedCallback()` will be called everytime user toggle between *floating* and *docked* modes. -- when user navigate to a new template all existing widgets will be destroyed, unless the `persistency` is set to `true` in `manifest.json`. -- for a list of APIs, see [plugin_api.md](plugin_api.md) diff --git a/src/plugin_examples/migrationGuide.md b/src/plugin_examples/migrationGuide.md deleted file mode 100644 index fcd5e040b9333b262dbbac60b6d3237859fe7f1c..0000000000000000000000000000000000000000 --- a/src/plugin_examples/migrationGuide.md +++ /dev/null @@ -1,51 +0,0 @@ -Plugin Migration Guide (v0.1.0 => v0.2.0) -====== -Plugin APIs have changed drastically from v0.1.0 to v0.2.0. Here is a list of plugin API from v0.1.0, and how it has changed moving to v0.2.0. - -**n.b.** `webcomponents-lite.js` is no longer included by default. You will need to request it explicitly with `window.interactiveViewer.pluginControl.loadExternalLibraries()` and unload it once you are done. - ---- - -- ~~*window.nehubaUI*~~ removed - - ~~*metadata*~~ => **window.interactiveViewer.metadata** - - ~~*selectedTemplate* : nullable Object~~ removed. use **window.interactiveViewer.metadata.selectedTemplateBSubject** instead - - ~~*availableTemplates* : Array of TemplateDescriptors (empty array if no templates are available)~~ => **window.interactiveViewer.metadata.loadedTemplates** - - ~~*selectedParcellation* : nullable Object~~ removed. use **window.interactiveViewer.metadata.selectedParcellationBSubject** instead - - ~~*selectedRegions* : Array of Object (empty array if no regions are selected)~~ removed. use **window.interactiveViewer.metadata.selectedRegionsBSubject** instead - -- ~~window.pluginControl['YOURPLUGINNAME'] *nb: may be undefined if yourpluginname is incorrect*~~ => **window.interactiveViewer.pluginControl[YOURPLUGINNAME]** - - blink(sec?:number) : Function that causes the floating widget to blink, attempt to grab user attention - - ~~pushMessage(message:string) : Function that pushes a message that are displayed as a popover if the widget is minimised. No effect if the widget is not miniminised.~~ removed - - shutdown() : Function that causes the widget to shutdown dynamically. (triggers onShutdown callback) - - onShutdown(callback) : Attaches a callback function, which is called when the plugin is shutdown. - -- ~~*window.viewerHandle*~~ => **window.interactiveViewer.viewerHandle** - - ~~*loadTemplate(TemplateDescriptor)* : Function that loads a new template~~ removed. use **window.interactiveViewer.metadata.selectedTemplateBSubject** instead - - ~~*onViewerInit(callback)* : Functional that allows a callback function to be called just before a nehuba viewer is initialised~~ removed - - ~~*afterViewerInit(callback)* : Function that allows a callback function to be called just after a nehuba viewer is initialised~~ removed - - ~~*onViewerDestroy(callback)* : Function that allows a callback function be called just before a nehuba viewer is destroyed~~ removed - - ~~*onParcellationLoading(callback)* : Function that allows a callback function to be called just before a parcellation is selected~~ removed - - ~~*afterParcellationLoading(callback)* : Function that allows a callback function to be called just after a parcellation is selected~~ removed - - *setNavigationLoc(loc,realSpace?)* : Function that teleports to loc : number[3]. Optional argument to determine if the loc is in realspace (default) or voxelspace. - - ~~*setNavigationOrientation(ori)* : Function that teleports to ori : number[4]. (Does not work currently)~~ => **setNavigationOri(ori)** (still non-functional) - - *moveToNavigationLoc(loc,realSpace?)* : same as *setNavigationLoc(loc,realSpace?)*, except moves to target location over 500ms. - - *showSegment(id)* : Function that selectes a segment in the viewer and UI. - - *hideSegment(id)* : Function that deselects a segment in the viewer and UI. - - *showAllSegments()* : Function that selects all segments. - - *hideAllSegments()* : Function that deselects all segments. - - *loadLayer(layerObject)* : Function that loads a custom neuroglancer compatible layer into the viewer (e.g. precomputed, NIFTI, etc). Does not influence UI. - - *mouseEvent* RxJs Observable. Read more at [rxjs doc](http://reactivex.io/rxjs/) - - *mouseEvent.filter(filterFn:({eventName : String, event: Event})=>boolean)* returns an Observable. Filters the event stream according to the filter function. - - *mouseEvent.map(mapFn:({eventName : String, event: Event})=>any)* returns an Observable. Map the event stream according to the map function. - - *mouseEvent.subscribe(callback:({eventName : String , event : Event})=>void)* returns an Subscriber instance. Call *Subscriber.unsubscribe()* when done to avoid memory leak. - - *mouseOverNehuba* RxJs Observable. Read more at [rxjs doc](http://reactivex.io/rxjs) - - *mouseOverNehuba.filter* && *mouseOvernehuba.map* see above - - *mouseOverNehuba.subscribe(callback:({nehubaOutput : any, foundRegion : any})=>void)* - -- ~~*window.uiHandle*~~ => **window.interactiveViewer.uiHandle** - - ~~*onTemplateSelection(callback)* : Function that allows a callback function to be called just after user clicks to navigate to a new template, before *selectedTemplate* is updated~~ removed. use **window.interactiveViewer.metadata.selectedTemplateBSubject** instead - - ~~*afterTemplateSelection(callback)* : Function that allows a callback function to be called after the template selection process is complete, and *selectedTemplate* is updated~~ removed - - ~~*onParcellationSelection(callback)* : Function that attach a callback function to user selecting a different parcellation~~ removed. use **window.interactiveViewer.metadata.selectedParcellationBSubject** instead. - - ~~*afterParcellationSelection(callback)* : Function that attach a callback function to be called after the parcellation selection process is complete and *selectedParcellation* is updated.~~ removed - - *modalControl* - - ~~*getModalHandler()* : Function returning a handler to change/show/hide/listen to a Modal.~~ removed \ No newline at end of file diff --git a/src/plugin_examples/plugin1/manifest.json b/src/plugin_examples/plugin1/manifest.json deleted file mode 100644 index 0813f787c22dc71f22c3d14bbd20de2716d27068..0000000000000000000000000000000000000000 --- a/src/plugin_examples/plugin1/manifest.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name":"fzj.xg.exmaple.0_0_1", - "displayName": "Example Plugin (v0.0.1)", - "templateURL": "http://HOSTNAME/test.html", - "scriptURL": "http://HOSTNAME/script.js", - "initState": { - "key1": "val1", - "key2": { - "key21": "val21" - } - }, - "initStateUrl": "http://HOSTNAME/state?id=007", - "persistency": false, - "description": "description of example plugin", - "desc": "desc of example plugin", - "homepage": "http://HOSTNAME/home.html", - "authors": "Xiaoyun Gui <x.gui@fz-juelich.de>" -} \ No newline at end of file diff --git a/src/plugin_examples/plugin_api.md b/src/plugin_examples/plugin_api.md deleted file mode 100644 index 6954f15b52d7900e14a493376423c61697dacb82..0000000000000000000000000000000000000000 --- a/src/plugin_examples/plugin_api.md +++ /dev/null @@ -1,416 +0,0 @@ -# Plugin APIs - -## window.interactiveViewer - -### metadata - -#### selectedTemplateBSubject - -BehaviourSubject that emits a TemplateDescriptor object whenever a template is selected. Emits null onInit. - -#### selectedParcellationBSubject - -BehaviourSubject that emits a ParcellationDescriptor object whenever a parcellation is selected. n.b. selecting a new template automatically select the first available parcellation. Emits null onInit. - -#### selectedRegionsBSubject - -BehaviourSubject that emits an Array of RegionDescriptor objects whenever the list of selected regions changes. Emits empty array onInit. - -#### loadedTemplates - -Array of TemplateDescriptor objects. Loaded asynchronously onInit. - -#### layersRegionLabelIndexMap - -Map of layer name to Map of labelIndex (used by neuroglancer and nehuba) to the corresponding RegionDescriptor object. - -### viewerHandle - -> **nb** `viewerHandle` may be undefined at any time (user be yet to select an atlas, user could have unloaded an atlas. ...etc) - -#### setNavigationLoc(coordinates, realspace?:boolean) - -Function that teleports the navigation state to coordinates : [x:number,y:number,z:number]. Optional arg determine if the set of coordinates is in realspace (default) or voxelspace. - -#### moveToNavigationLoc(coordinates,realspace?:boolean) - -same as *setNavigationLoc(coordinates,realspace?)*, except the action is carried out over 500ms. - -#### setNavigationOri(ori) - -(NYI) Function that sets the orientation state of the viewer. - -#### moveToNavigationOri(ori) - -(NYI) same as *setNavigationOri*, except the action is carried out over 500ms. - -#### showSegment(labelIndex) - -Function that shows a specific segment. Will trigger *selectedRegionsBSubject*. - -#### hideSegment(labelIndex) - -Function that hides a specific segment. Will trigger *selectRegionsBSubject* - -#### showAllSegments() - -Function that shows all segments. Will trigger *selectRegionsBSubject* - -#### hideAllSegments() -Function that hides all segments. Will trigger *selectRegionBSubject* - -#### getLayersSegmentColourMap() - -Call to get Map of layer name to Map of label index to colour map - -#### applyLayersColourMap - -Function that applies a custom colour map. - -#### loadLayer(layerObject) - -Function that loads *ManagedLayersWithSpecification* directly to neuroglancer. Returns the values of the object successfully added. **n.b.** advanced feature, will likely break other functionalities. **n.b.** if the layer name is already taken, the layer will not be added. - -```javascript -const obj = { - 'advanced layer' : { - type : 'image', - source : 'nifti://http://example.com/data/nifti.nii', - }, - 'advanced layer 2' : { - type : 'mesh', - source : 'vtk://http://example.com/data/vtk.vtk' - } -} -const returnValue = window.interactiveViewer.viewerHandle.loadLayer(obj) -/* loads two layers, an image nifti layer and a mesh vtk layer */ - -console.log(returnValue) -/* prints - -[{ - type : 'image', - source : 'nifti...' -}, -{ - type : 'mesh', - source : 'vtk...' -}] -*/ -``` - -#### removeLayer(layerObject) - -Function that removes *ManagedLayersWithSpecification*, returns an array of the names of the layers removed. - -**n.b.** advanced feature. may break other functionalities. - -```js -const obj = { - 'name' : /^PMap/ -} -const returnValue = window.interactiveViewer.viewerHandle.removeLayer(obj) - -console.log(returnValue) -/* prints -['PMap 001','PMap 002'] -*/ -``` - -#### add3DLandmarks(landmarks) - -adds landmarks to both the perspective view and slice view. - -_input_ - -| input | type | desc | -| --- | --- | --- | -| landmarks | array | an array of `landmarks` to be rendered by the viewer | - -A landmark object consist of the following keys: - -| key | type | desc | required | -| --- | --- | --- | --- | -| id | string | id of the landmark | yes | -| name | string | name of the landmark | | -| position | [number, number, number] | position (in mm) | yes | -| color | [number, number, number] | rgb of the landmark | | - - -```js -const landmarks = [{ - id : `fzj-xg-jugex-1`, - position : [0,0,0] -},{ - id : `fzj-xg-jugex-2`, - position : [22,27,-1], - color: [255, 0, 255] -}] -window.interactiveViewer.viewerHandle.add3DLandmarks(landmarks) - -/* adds landmarks in perspective view and slice view */ -``` - -#### remove3DLandmarks(IDs) - -removes the landmarks by their IDs - -```js -window.interactiveViewer.viewerHandle - .remove3DLandmarks(['fzj-xg-jugex-1', 'fzj-xg-jugex-2']) -/* removes the landmarks added above */ -``` - -#### setLayerVisibility(layerObject, visible) - -Function that sets the visibility of a layer. Returns the names of all the layers that are affected as an Array of string. - -```js -const obj = { - 'type' : 'segmentation' -} - -window.interactiveViewer.viewerHandle.setLayerVisibility(obj,false) - -/* turns off all the segmentation layers */ -``` - -#### mouseEvent - -Subject that emits an object shaped `{ eventName : string, event: event }` when a user triggers a mouse event on the viewer. - - -#### mouseOverNehuba - -**deprecating** use mouseOverNehubaUI instead - -BehaviourSubject that emits an object shaped `{ nehubaOutput : number | null, foundRegion : RegionDescriptor | null }` - -#### mouseOverNehubaUI - -`Observable<{ landmark, segments, customLandmark }>`. - -**nb** it is a known issue that if customLandmarks are destroyed/created while user mouse over the custom landmark this observable will emit `{ customLandmark: null }` - -### uiHandle - -#### getModalHandler() - -returns a modalHandler object, which has the following methods/properties: - -##### hide() - -Dynamically hides the modal - -##### show() - -Shows the modal - -##### title - -title of the modal (String) - -##### body - -body of the modal shown (String) - -##### footer - -footer of the modal (String) - -##### dismissable - -whether the modal is dismissable on click backdrop/esc key (Boolean) - -*n.b. if true, users will not be able to interact with the viewer unless you specifically call `handler.hide()`* - -#### launchNewWidget(manifest) - -returns a Promise. expects a JSON object, with the same key value as a plugin manifest. the *name* key must be unique, or the promise will be rejected. - -#### getUserInput(config) - -returns a Promise, resolves when user confirms, rejects when user cancels. expects config object object with the following structure: - -```javascript -const config = { - "title": "Title of the modal", // default: "Message" - "message":"Message to be seen by the user.", // default: "" - "placeholder": "Start typing here", // default: "Type your response here" - "defaultValue": "42" // default: "" - "iconClass":"fas fa-save" // default fas fa-save, set to falsy value to disable -} -``` - -#### getUserConfirmation(config) - -returns a Promise, resolves when user confirms, rejects when user cancels. expects config object object with the following structure: - -```javascript -const config = { - "title": "Title of the modal", // default: "Message" - "message":"Message to be seen by the user." // default: "" -} -``` - -#### getUserToSelectARegion(message) - -**To be deprecated** - -_input_ - -| input | type | desc | -| --- | --- | --- | -| message | `string` | human readable message displayed to the user | -| spec.type | `'POINT'` `'PARCELLATION_REGION'` **default** | type of region to be returned. | - -_returns_ - -`Promise`, resolves to return array of region clicked, rejects with error object `{ userInitiated: boolean }` - -Requests user to select a region of interest. Resolving to the region selected by the user. Rejects if either user cancels by pressing `Esc` or `Cancel`, or by developer calling `cancelPromise` - -#### getUserToSelectRoi(message, spec) - -_input_ - -| input | type | desc | -| --- | --- | --- | -| message | `string` | human readable message displayed to the user | -| spec.type | `POINT` `PARCELLATION_REGION` | type of ROI to be returned. | - -_returns_ - -`Promise` - -**resolves**: return `{ type, payload }`. `type` is the same as `spec.type`, and `payload` differs depend on the type requested: - -| type | payload | example | -| --- | --- | --- | -| `POINT` | array of number in mm | `[12.2, 10.1, -0.3]` | -| `PARCELLATION_REGOIN` | non empty array of region selected | `[{ "layer": { "name" : " viewer specific layer name " }, "segment": {} }]` | - -**rejects**: with error object `{ userInitiated: boolean }` - -Requests user to select a region of interest. If the `spec.type` input is missing, it is assumed to be `'PARCELLATION_REGION'`. Resolving to the region selected by the user. Rejects if either user cancels by pressing `Esc` or `Cancel`, or by developer calling `cancelPromise` - -#### cancelPromise(promise) - -returns `void` - -_input_ - -| input | type | desc | -| --- | --- | --- | -| promise | `Promise` | Reference to the __exact__ promise returned by `uiHnandle` methods | - -Cancel the request to select a parcellation region. - -_usage example_ - -```javascript - -(() => { - const pr = interactive.uiHandle.getUserToSelectARegion(`webJuGEx would like you to select a region`) - - pr.then(region => { }) - .catch(console.warn) - - /* - * do NOT do - * - * const pr = interactive.uiHandle.getUserToSelectARegion(`webJuGEx would like you to select a region`) - * .then(region => { }) - * -catch(console.warn) - * - * the promise passed to `cancelPromise` must be the exact promise returned. - * by chaining then/catch, a new reference is returned - */ - - setTimeout(() => { - try { - interactive.uiHandle.cancelPromise(pr) - } catch (e) { - // if the promise has been fulfilled (by resolving or user cancel), cancelPromise will throw - } - }, 5000) -})() -``` - -### pluginControl - -#### loadExternalLibraries([LIBRARY_NAME_1,LIBRARY_NAME_2]) - -Function that loads external libraries. Pass the name of the libraries as an Array of string, and returns a Promise. When promise resolves, the libraries are loaded. - -**n.b.** while unlikely, there is a possibility that multiple requests to load external libraries in quick succession can cause the promise to resolve before the library is actually loaded. - -```js -const currentlySupportedLibraries = ['jquery@2','jquery@3','webcomponentsLite@1.1.0','react@16','reactdom@16','vue@2.5.16'] - -window.interactivewViewer.loadExternalLibraries(currentlySupportedLibraries) - .then(() => { - /* loaded */ - }) - .catch(e=>console.warn(e)) - -``` - -#### unloadExternalLibraries([LIBRARY_NAME_1,LIBRARY_NAME_2]) - -unloading the libraries (should be called on shutdown). - -#### *[PLUGINNAME]* - -returns a plugin handler. This would be how to interface with the plugins. - -##### blink() - -Function that causes the floating widget to blink, attempt to grab user attention (silently fails if called on startup). - -##### setProgressIndicator(val:number|null) - -Set the progress of the plugin. Useful for giving user feedbacks on the progress of a long running process. Call the function with null to unset the progress. - -##### shutdown() - -Function that causes the widget to shutdown dynamically. (triggers onShutdown callback, silently fails if called on startup) - -##### onShutdown(callback) - -Attaches a callback function, which is called when the plugin is shutdown. - -##### initState - -passed from `manifest.json`. Useful for setting initial state of the plugin. Can be any JSON valid value (array, object, string). - -##### initStateUrl - -passed from `manifest.json`. Useful for setting initial state of the plugin. Can be any JSON valid value (array, object, string). - -##### setInitManifestUrl(url|null) - -set/unset the url for a manifest json that will be fetched on atlas viewer startup. the argument should be a valid URL, has necessary CORS header, and returns a valid manifest json file. null will unset the search param. Useful for passing/preserving state. If called multiple times, the last one will take effect. - -```js -const pluginHandler = window.interactiveViewer.pluginControl[PLUGINNAME] - -const subscription = window.interactiveViewer.metadata.selectedTemplateBSubject.subscribe(template=>console.log(template)) - -fetch(`http://YOUR_BACKEND.com/API_ENDPOINT`) - .then(data=>pluginHandler.blink(20)) - -pluginHandler.onShutdown(()=>{ - subscription.unsubscribe() -}) -``` - ------- - -## window.nehubaViewer - -nehuba object, exposed if developer would like to use it - -## window.viewer - -neuroglancer object, exposed if developer would like to use it \ No newline at end of file diff --git a/src/routerModule/routeStateTransform.service.ts b/src/routerModule/routeStateTransform.service.ts index 10f003638a6f8bed8c281e40777134572fe42c5c..dc7da45d8a9eed4d3b8c4edbe31c409864d09741 100644 --- a/src/routerModule/routeStateTransform.service.ts +++ b/src/routerModule/routeStateTransform.service.ts @@ -146,8 +146,11 @@ export class RouteStateTransformSvc { const pluginStates = fullPath.queryParams['pl'] if (pluginStates) { try { - const arrPluginStates = JSON.parse(pluginStates) - returnState["[state.plugins]"].initManifests = arrPluginStates.map(url => [plugins.INIT_MANIFEST_SRC, url] as [string, string]) + const arrPluginStates: string[] = JSON.parse(pluginStates) + if (arrPluginStates.length > 1) throw new Error(`can only initialise one plugin at a time`) + returnState["[state.plugins]"].initManifests = { + [plugins.INIT_MANIFEST_SRC]: arrPluginStates + } } catch (e) { /** * parsing plugin error diff --git a/src/routerModule/router.service.ts b/src/routerModule/router.service.ts index 3500cc3dcba1b6d4aa412d38026fd28cdf31ca3a..3359055f7b70b395772c5d27842dffee46b1b374 100644 --- a/src/routerModule/router.service.ts +++ b/src/routerModule/router.service.ts @@ -10,9 +10,8 @@ import { scan } from 'rxjs/operators' import { RouteStateTransformSvc } from "./routeStateTransform.service"; import { SAPI } from "src/atlasComponents/sapi"; import { generalActions } from "src/state"; -/** - * http://localhost:8080/#/a:juelich:iav:atlas:v1.0.0:1/t:minds:core:referencespace:v1.0.0:dafcffc5-4826-4bf1-8ff6-46b8a31ff8e2/p:minds:core:parcellationatlas:v1.0.0:94c1125b-b87e-45e4-901c-00daee7f2579-290/@:0.0.0.-W000.._eCwg.2-FUe3._-s_W.2_evlu..7LIy..0.14gY0~.14gY0..1LSm - */ + + @Injectable({ providedIn: 'root' }) diff --git a/src/share/saneUrl/saneUrl.component.spec.ts b/src/share/saneUrl/saneUrl.component.spec.ts index 5ab173950c92148144b7b3025f63cddf95205a30..982ecb76fb47ca954a5da6c9f5c792c69f3118ba 100644 --- a/src/share/saneUrl/saneUrl.component.spec.ts +++ b/src/share/saneUrl/saneUrl.component.spec.ts @@ -1,14 +1,13 @@ -import { TestBed, fakeAsync, tick, flush } from '@angular/core/testing' -import { ShareModule } from '../share.module' +import { TestBed, fakeAsync, tick, flush, ComponentFixture } from '@angular/core/testing' import { SaneUrl } from './saneUrl.component' -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing' import { By } from '@angular/platform-browser' import { BACKENDURL } from 'src/util/constants' import { NoopAnimationsModule } from '@angular/platform-browser/animations' import { SaneUrlSvc } from './saneUrl.service' import { AngularMaterialModule } from 'src/sharedModules' import { CUSTOM_ELEMENTS_SCHEMA, Directive } from '@angular/core' -import { of } from 'rxjs' +import { of, throwError } from 'rxjs' +import { NotFoundError } from '../type' const inputCss = `input[aria-label="Custom link"]` const submitCss = `button[aria-label="Create custom link"]` @@ -25,15 +24,22 @@ class AuthStateDummy { describe('> saneUrl.component.ts', () => { describe('> SaneUrl', () => { + const mockSaneUrlSvc = { + saneUrlroot: 'saneUrlroot', + getKeyVal: jasmine.createSpy('getKeyVal'), + setKeyVal: jasmine.createSpy('setKeyVal'), + } beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ - HttpClientTestingModule, NoopAnimationsModule, AngularMaterialModule, ], providers: [ - SaneUrlSvc, + { + provide: SaneUrlSvc, + useValue: mockSaneUrlSvc + } ], declarations: [ SaneUrl, @@ -43,11 +49,18 @@ describe('> saneUrl.component.ts', () => { CUSTOM_ELEMENTS_SCHEMA ] }).compileComponents() + + mockSaneUrlSvc.getKeyVal.and.returnValue( + of('foo-bar') + ) + mockSaneUrlSvc.setKeyVal.and.returnValue( + of('OK') + ) }) afterEach(() => { - const ctrl = TestBed.inject(HttpTestingController) - ctrl.verify() + mockSaneUrlSvc.getKeyVal.calls.reset() + mockSaneUrlSvc.setKeyVal.calls.reset() }) it('> can be created', () => { @@ -112,215 +125,188 @@ describe('> saneUrl.component.ts', () => { const input = fixture.debugElement.query( By.css( inputCss ) ) }) - it('> on entering string in input, makes debounced GET request', fakeAsync(() => { - - const value = 'test_1' - - const httpTestingController = TestBed.inject(HttpTestingController) - - // Necessary to detectChanges, or formControl will not initialise properly - // See https://stackoverflow.com/a/56600762/6059235 - const fixture = TestBed.createComponent(SaneUrl) - fixture.detectChanges() - - // Set value - fixture.componentInstance.customUrl.setValue(value) - - tick(500) - - const req = httpTestingController.expectOne(`${BACKENDURL}saneUrl/${value}`) - req.flush(200) - })) - - it('> on 200 response, show error', fakeAsync(() => { - - const value = 'test_1' - - const httpTestingController = TestBed.inject(HttpTestingController) - - // Necessary to detectChanges, or formControl will not initialise properly - // See https://stackoverflow.com/a/56600762/6059235 - const fixture = TestBed.createComponent(SaneUrl) - fixture.detectChanges() - - // Set value - fixture.componentInstance.customUrl.setValue(value) - - tick(500) - - const req = httpTestingController.expectOne(`${BACKENDURL}saneUrl/${value}`) - req.flush('OK') - - // Expect validator to fail catch it - expect(fixture.componentInstance.customUrl.invalid).toEqual(true) - - // on change detection, UI should catch it - fixture.detectChanges() - - const input = fixture.debugElement.query( By.css( inputCss ) ) - - const submit = fixture.debugElement.query( By.css( submitCss ) ) - const disabled = !!submit.attributes['disabled'] - expect(disabled.toString()).toEqual('true') - })) - - it('> on 404 response, show available', fakeAsync(() => { - - const value = 'test_1' - - const httpTestingController = TestBed.inject(HttpTestingController) - - // Necessary to detectChanges, or formControl will not initialise properly - // See https://stackoverflow.com/a/56600762/6059235 - const fixture = TestBed.createComponent(SaneUrl) - fixture.detectChanges() - - // Set value - fixture.componentInstance.customUrl.setValue(value) - - tick(500) - - const req = httpTestingController.expectOne(`${BACKENDURL}saneUrl/${value}`) - req.flush('some reason', { status: 404, statusText: 'Not Found.' }) - - // Expect validator to fail catch it - expect(fixture.componentInstance.customUrl.invalid).toEqual(false) - - // on change detection, UI should catch it - fixture.detectChanges() - - const input = fixture.debugElement.query( By.css( inputCss ) ) - - const submit = fixture.debugElement.query( By.css( submitCss ) ) - const disabled = !!submit.attributes['disabled'] - expect(disabled.toString()).toEqual('false') - })) - - it('> on other error codes, show invalid', fakeAsync(() => { - - const value = 'test_1' - - const httpTestingController = TestBed.inject(HttpTestingController) - - // Necessary to detectChanges, or formControl will not initialise properly - // See https://stackoverflow.com/a/56600762/6059235 - const fixture = TestBed.createComponent(SaneUrl) - fixture.detectChanges() - - // Set value - fixture.componentInstance.customUrl.setValue(value) - - tick(500) - - const req = httpTestingController.expectOne(`${BACKENDURL}saneUrl/${value}`) - req.flush('some reason', { status: 401, statusText: 'Unauthorised.' }) - - // Expect validator to fail catch it - expect(fixture.componentInstance.customUrl.invalid).toEqual(true) - - // on change detection, UI should catch it - fixture.detectChanges() - - const input = fixture.debugElement.query( By.css( inputCss ) ) - - const submit = fixture.debugElement.query( By.css( submitCss ) ) - const disabled = !!submit.attributes['disabled'] - expect(disabled.toString()).toEqual('true') - })) - - it('> on click create link btn calls correct API', fakeAsync(() => { - - const value = 'test_1' - - const httpTestingController = TestBed.inject(HttpTestingController) - - // Necessary to detectChanges, or formControl will not initialise properly - // See https://stackoverflow.com/a/56600762/6059235 - const fixture = TestBed.createComponent(SaneUrl) - fixture.detectChanges() - - // Set value - fixture.componentInstance.customUrl.setValue(value) - - tick(500) - - const req = httpTestingController.expectOne(`${BACKENDURL}saneUrl/${value}`) - req.flush('some reason', { status: 404, statusText: 'Not Found.' }) - - fixture.detectChanges() - flush() - - const submit = fixture.debugElement.query( By.css( submitCss ) ) - const disabled = !!submit.attributes['disabled'] - expect(disabled.toString()).toEqual('false') - - submit.triggerEventHandler('click', {}) - - fixture.detectChanges() - - const disabledInProgress = !!submit.attributes['disabled'] - expect(disabledInProgress.toString()).toEqual('true') - - const req2 = httpTestingController.expectOne({ - method: 'POST', - url: `${BACKENDURL}saneUrl/${value}` + describe("> on valid input", () => { + let saneUrlCmp: SaneUrl + let fixture: ComponentFixture<SaneUrl> + const stateTobeSaved = 'foo-bar' + beforeEach(() => { + // Necessary to detectChanges, or formControl will not initialise properly + // See https://stackoverflow.com/a/56600762/6059235 + fixture = TestBed.createComponent(SaneUrl) + saneUrlCmp = fixture.componentInstance + saneUrlCmp.stateTobeSaved = stateTobeSaved + fixture.detectChanges() }) - - req2.flush({}) - - fixture.detectChanges() - - const disabledAfterComplete = !!submit.attributes['disabled'] - expect(disabledAfterComplete.toString()).toEqual('true') - - const cpyBtn = fixture.debugElement.query( By.css( copyBtnCss ) ) - expect(cpyBtn).toBeTruthy() - })) - - it('> on click create link btn fails show result', fakeAsync(() => { - - const value = 'test_1' - - const httpTestingController = TestBed.inject(HttpTestingController) - - // Necessary to detectChanges, or formControl will not initialise properly - // See https://stackoverflow.com/a/56600762/6059235 - const fixture = TestBed.createComponent(SaneUrl) - fixture.detectChanges() - - // Set value - fixture.componentInstance.customUrl.setValue(value) - - tick(500) - - const req = httpTestingController.expectOne(`${BACKENDURL}saneUrl/${value}`) - req.flush('some reason', { status: 404, statusText: 'Not Found.' }) - - fixture.detectChanges() - flush() - - const submit = fixture.debugElement.query( By.css( submitCss ) ) - const disabled = !!submit.attributes['disabled'] - expect(disabled.toString()).toEqual('false') - - submit.triggerEventHandler('click', {}) - - fixture.detectChanges() - - const disabledInProgress = !!submit.attributes['disabled'] - expect(disabledInProgress.toString()).toEqual('true') - - const req2 = httpTestingController.expectOne({ - method: 'POST', - url: `${BACKENDURL}saneUrl/${value}` + it('> on entering string in input, makes debounced GET request', fakeAsync(() => { + + const value = 'test_1' + + // Set value + fixture.componentInstance.customUrl.setValue(value) + + tick(500) + + expect(mockSaneUrlSvc.getKeyVal).toHaveBeenCalledOnceWith(value) + })) + + describe("> on 200", () => { + it("> show error", fakeAsync(() => { + + const value = 'test_1' + + // Set value + fixture.componentInstance.customUrl.setValue(value) + + tick(500) + + // Expect validator to fail catch it + expect(fixture.componentInstance.customUrl.invalid).toEqual(true) + + // on change detection, UI should catch it + fixture.detectChanges() + + const input = fixture.debugElement.query( By.css( inputCss ) ) + + const submit = fixture.debugElement.query( By.css( submitCss ) ) + const disabled = !!submit.attributes['disabled'] + expect(disabled.toString()).toEqual('true') + })) + }) + + describe('> on 404', () => { + beforeEach(() => { + mockSaneUrlSvc.getKeyVal.and.returnValue( + throwError(new NotFoundError('not found')) + ) + }) + it("> should available", fakeAsync(() => { + + const value = 'test_1' + + // Set value + fixture.componentInstance.customUrl.setValue(value) + + tick(500) + + // Expect validator to fail catch it + expect(fixture.componentInstance.customUrl.invalid).toEqual(false) + + // on change detection, UI should catch it + fixture.detectChanges() + + const input = fixture.debugElement.query( By.css( inputCss ) ) + + const submit = fixture.debugElement.query( By.css( submitCss ) ) + const disabled = !!submit.attributes['disabled'] + expect(disabled.toString()).toEqual('false') + })) }) + + describe("> on other error", () => { + beforeEach(() => { + + mockSaneUrlSvc.getKeyVal.and.returnValue( + throwError(new Error('other errors')) + ) + }) + it("> show invalid", fakeAsync(() => { + const value = 'test_1' + + // Set value + fixture.componentInstance.customUrl.setValue(value) + + tick(500) + + // Expect validator to fail catch it + expect(fixture.componentInstance.customUrl.invalid).toEqual(true) + + // on change detection, UI should catch it + fixture.detectChanges() + + const input = fixture.debugElement.query( By.css( inputCss ) ) + + const submit = fixture.debugElement.query( By.css( submitCss ) ) + const disabled = !!submit.attributes['disabled'] + expect(disabled.toString()).toEqual('true') + })) + }) + + describe("> on click create link", () => { + beforeEach(() => { + mockSaneUrlSvc.getKeyVal.and.returnValue( + throwError(new NotFoundError('not found')) + ) + }) + it("> calls correct service function", fakeAsync(() => { + + const value = 'test_1' + + // Set value + fixture.componentInstance.customUrl.setValue(value) + + tick(500) + + fixture.detectChanges() + flush() + + const submit = fixture.debugElement.query( By.css( submitCss ) ) + const disabled = !!submit.attributes['disabled'] + expect(disabled.toString()).toEqual('false') + + submit.triggerEventHandler('click', {}) + + fixture.detectChanges() + + const disabledInProgress = !!submit.attributes['disabled'] + expect(disabledInProgress.toString()).toEqual('true') + + fixture.detectChanges() + + const disabledAfterComplete = !!submit.attributes['disabled'] + expect(disabledAfterComplete.toString()).toEqual('true') + + const cpyBtn = fixture.debugElement.query( By.css( copyBtnCss ) ) + expect(cpyBtn).toBeTruthy() + })) + + describe("> on fail", () => { + beforeEach(() => { + mockSaneUrlSvc.setKeyVal.and.returnValue( + throwError(new Error(`some error`)) + ) + }) + it("> show result", fakeAsync(() => { + + const value = 'test_1' - req2.flush('Something went wrong', { statusText: 'Wrong status text', status: 500 }) - - fixture.detectChanges() - - const input = fixture.debugElement.query( By.css( inputCss ) ) - - })) + // Set value + fixture.componentInstance.customUrl.setValue(value) + + tick(500) + + fixture.detectChanges() + + const submit = fixture.debugElement.query( By.css( submitCss ) ) + const disabled = !!submit.attributes['disabled'] + expect(disabled.toString()).toEqual('false') + + submit.triggerEventHandler('click', {}) + + fixture.detectChanges() + + const disabledInProgress = !!submit.attributes['disabled'] + expect(disabledInProgress.toString()).toEqual('true') + + expect(mockSaneUrlSvc.setKeyVal).toHaveBeenCalledOnceWith(value, stateTobeSaved) + + fixture.detectChanges() + + const input = fixture.debugElement.query( By.css( inputCss ) ) + + })) + }) + }) + + }) }) }) diff --git a/src/share/saneUrl/saneUrl.service.ts b/src/share/saneUrl/saneUrl.service.ts index 8f3af1f3d55fcb9baec57d44ddbbda76ac24c2df..c5d9849f760ed90b7197ef8206b53db55ab1025d 100644 --- a/src/share/saneUrl/saneUrl.service.ts +++ b/src/share/saneUrl/saneUrl.service.ts @@ -4,23 +4,27 @@ import { throwError } from "rxjs"; import { catchError, mapTo } from "rxjs/operators"; import { BACKENDURL } from 'src/util/constants' import { IKeyValStore, NotFoundError } from '../type' +import { DISABLE_PRIORITY_HEADER } from "src/util/priority" @Injectable({ providedIn: 'root' }) export class SaneUrlSvc implements IKeyValStore{ - public saneUrlRoot = `${BACKENDURL}saneUrl/` + public saneUrlRoot = `${BACKENDURL}go/` constructor( private http: HttpClient ){ - + if (!BACKENDURL) { + const loc = window.location + this.saneUrlRoot = `${loc.protocol}//${loc.hostname}${!!loc.port ? (':' + loc.port) : ''}${loc.pathname}go/` + } } getKeyVal(key: string) { return this.http.get<Record<string, any>>( `${this.saneUrlRoot}${key}`, - { responseType: 'json' } + { responseType: 'json', headers: { [DISABLE_PRIORITY_HEADER]: '1' } } ).pipe( catchError((err, obs) => { const { status } = err @@ -35,7 +39,8 @@ export class SaneUrlSvc implements IKeyValStore{ setKeyVal(key: string, value: any) { return this.http.post( `${this.saneUrlRoot}${key}`, - value + value, + { headers: { [DISABLE_PRIORITY_HEADER]: '1' } } ).pipe( mapTo(`${this.saneUrlRoot}${key}`) ) diff --git a/src/state/annotations/selectors.ts b/src/state/annotations/selectors.ts index d44a9e21167b54a7fc1a6b695bfa46c3c300955a..5504a6bb1fb7ab03a4053a365c24d55290a50036 100644 --- a/src/state/annotations/selectors.ts +++ b/src/state/annotations/selectors.ts @@ -2,7 +2,6 @@ import { createSelector } from "@ngrx/store" import { nameSpace } from "./const" import { Annotation, AnnotationState } from "./store" import { selectors as atlasSelectionSelectors } from "../atlasSelection" -import { annotation } from ".." const selectStore = state => state[nameSpace] as AnnotationState diff --git a/src/state/atlasSelection/effects.spec.ts b/src/state/atlasSelection/effects.spec.ts index 61110e26b2d8b746e3c65c04dc89b07ddabd5b73..556e002c56f518ba5957629a178c5249ad37d234 100644 --- a/src/state/atlasSelection/effects.spec.ts +++ b/src/state/atlasSelection/effects.spec.ts @@ -1,16 +1,15 @@ -import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing" import { TestBed } from "@angular/core/testing" import { provideMockActions } from "@ngrx/effects/testing" import { Action } from "@ngrx/store" import { MockStore, provideMockStore } from "@ngrx/store/testing" import { hot } from "jasmine-marbles" import { Observable, of, throwError } from "rxjs" -import { SAPI, SAPIModule, SapiRegionModel, SAPIParcellation, SapiAtlasModel, SapiSpaceModel, SapiParcellationModel } from "src/atlasComponents/sapi" +import { SAPI, SAPIModule, SapiRegionModel, SapiAtlasModel, SapiSpaceModel, SapiParcellationModel } from "src/atlasComponents/sapi" import { IDS } from "src/atlasComponents/sapi/constants" import { actions, selectors } from "." import { Effect } from "./effects" import * as mainActions from "../actions" -import { take } from "rxjs/operators" +import { atlasSelection } from ".." describe("> effects.ts", () => { describe("> Effect", () => { @@ -40,7 +39,6 @@ describe("> effects.ts", () => { const sapisvc = TestBed.inject(SAPI) const regions = await sapisvc.getParcRegions(IDS.ATLAES.HUMAN, IDS.PARCELLATION.JBA29, IDS.TEMPLATES.MNI152).toPromise() - hoc1left = regions.find(r => /hoc1/i.test(r.name) && /left/i.test(r.name)) if (!hoc1left) throw new Error(`cannot find hoc1 left`) hoc1leftCentroid = JSON.parse(JSON.stringify(hoc1left)) @@ -94,6 +92,98 @@ describe("> effects.ts", () => { }) }) + + describe("> onTemplateParcSelectionPostHook", () => { + describe("> 0", () => { + }) + describe("> 1", () => { + const currNavigation = { + orientation: [0, 0, 0, 1], + perspectiveOrientation: [0, 0, 0, 1], + perspectiveZoom: 1, + position: [1, 2, 3], + zoom: 1 + } + beforeEach(() => { + const store = TestBed.inject(MockStore) + store.overrideSelector(atlasSelection.selectors.navigation, currNavigation) + }) + describe("> when atlas is different", () => { + describe("> if no atlas prior", () => { + + it("> navigation should be reset", () => { + const effects = TestBed.inject(Effect) + const hook = effects.onTemplateParcSelectionPostHook[1] + const obs = hook({ + current: { + atlas: null, + parcellation: null, + template: null + }, + previous: { + atlas: { + "@id": IDS.ATLAES.RAT + } as any, + parcellation: { + "@id": IDS.PARCELLATION.WAXHOLMV4 + } as any, + template: { + "@id": IDS.TEMPLATES.WAXHOLM + } as any, + } + }) + + expect(obs).toBeObservable( + hot('(a|)', { + a: { + navigation: null + } + }) + ) + }) + }) + describe("> if different atlas prior", () => { + + it("> navigation should be reset", () => { + const effects = TestBed.inject(Effect) + const hook = effects.onTemplateParcSelectionPostHook[1] + const obs = hook({ + current: { + atlas: { + "@id": IDS.ATLAES.HUMAN + } as any, + parcellation: { + "@id": IDS.PARCELLATION.JBA29 + } as any, + template: { + "@id": IDS.TEMPLATES.MNI152 + } as any, + }, + previous: { + atlas: { + "@id": IDS.ATLAES.RAT + } as any, + parcellation: { + "@id": IDS.PARCELLATION.WAXHOLMV4 + } as any, + template: { + "@id": IDS.TEMPLATES.WAXHOLM + } as any, + } + }) + + expect(obs).toBeObservable( + hot('(a|)', { + a: { + navigation: null + } + }) + ) + }) + }) + }) + }) + }) describe('> if selected atlas has no matching tmpl space', () => { diff --git a/src/state/atlasSelection/effects.ts b/src/state/atlasSelection/effects.ts index 5461f7dd87b365d9e87028b87a10c69b9ad14342..2c95b75602df2ec483983d910f61ceccdcc7525d 100644 --- a/src/state/atlasSelection/effects.ts +++ b/src/state/atlasSelection/effects.ts @@ -52,6 +52,16 @@ export class Effect { ({ current, previous }) => { const prevSpcName = InterSpaceCoordXformSvc.TmplIdToValidSpaceName(previous?.template?.["@id"]) const currSpcName = InterSpaceCoordXformSvc.TmplIdToValidSpaceName(current?.template?.["@id"]) + + /** + * if trans-species, return default state for navigation + */ + if (previous?.atlas?.["@id"] !== current?.atlas?.["@id"]) { + return of({ + navigation: null + }) + } + /** * if either space name is undefined, return default state for navigation */ @@ -435,6 +445,5 @@ export class Effect { private store: Store, private interSpaceCoordXformSvc: InterSpaceCoordXformSvc, ){ - } } \ No newline at end of file diff --git a/src/state/atlasSelection/store.ts b/src/state/atlasSelection/store.ts index 08848c1efa9b81e649bfc8d7c29d3cc6581f77ed..ebea8a78dd3015d9b0b050c3902e40fb8ab2a0c8 100644 --- a/src/state/atlasSelection/store.ts +++ b/src/state/atlasSelection/store.ts @@ -117,9 +117,14 @@ const reducer = createReducer( on( actions.selectAtlas, (state, { atlas }) => { + if (atlas?.["@id"] === state?.selectedAtlas?.["@id"]) { + return state + } return { ...state, - selectedAtlas: atlas + selectedAtlas: atlas, + selectedTemplate: null, + selectedParcellation: null, } } ), diff --git a/src/state/plugins/actions.ts b/src/state/plugins/actions.ts index 5fe4fcd15b162f4bf26f5947d124e925dca43552..759c7523082503c38eaed07b9ff92f34b1b3e775 100644 --- a/src/state/plugins/actions.ts +++ b/src/state/plugins/actions.ts @@ -7,12 +7,3 @@ export const clearInitManifests = createAction( nameSpace: string }>() ) - -export const setInitMan = createAction( - `${nameSpace} setInitMan`, - props<{ - nameSpace: string - url: string - internal?: boolean - }>() -) diff --git a/src/state/plugins/effects.ts b/src/state/plugins/effects.ts index c27a74669d3dc0b96dd3e0506a41a14c5455220a..5f147b9b17f29011441b3c92fb7c92a992cca1a2 100644 --- a/src/state/plugins/effects.ts +++ b/src/state/plugins/effects.ts @@ -6,9 +6,8 @@ import * as constants from "./const" import * as selectors from "./selectors" import * as actions from "./actions" import { DialogService } from "src/services/dialogService.service"; -import { of } from "rxjs"; -import { HttpClient } from "@angular/common/http"; -import { getHttpHeader } from "src/util/constants" +import { NEVER, of } from "rxjs"; +import { PluginService } from "src/plugin/service"; @Injectable() export class Effects{ @@ -16,27 +15,33 @@ export class Effects{ initMan = this.store.pipe( select(selectors.initManfests), map(initMan => initMan[constants.INIT_MANIFEST_SRC]), - filter(val => !!val), + filter(val => val && val.length > 0), ) + private pendingList = new Set<string>() + private launchedList = new Set<string>() + private banList = new Set<string>() + initManLaunch = createEffect(() => this.initMan.pipe( - switchMap(val => - this.dialogSvc - .getUserConfirm({ - message: `This URL is trying to open a plugin from ${val}. Proceed?` - }) - .then(() => - this.http.get(val, { - headers: getHttpHeader(), - responseType: 'json' - }).toPromise() - ) - .then(json => { - /** - * TODO fix init plugin launch - * at that time, also restore effects.spec.ts test - */ - }) + switchMap(val => of(...val)), + switchMap( + url => { + if (this.pendingList.has(url)) return NEVER + if (this.launchedList.has(url)) return NEVER + if (this.banList.has(url)) return NEVER + this.pendingList.add(url) + return this.dialogSvc + .getUserConfirm({ + message: `This URL is trying to open a plugin from ${url}. Proceed?` + }) + .then(() => { + this.launchedList.add(url) + return this.svc.launchPlugin(url) + }) + .finally(() => { + this.pendingList.delete(url) + }) + } ), catchError(() => of(null)) ), { dispatch: false }) @@ -52,8 +57,8 @@ export class Effects{ constructor( private store: Store, private dialogSvc: DialogService, - private http: HttpClient, + private svc: PluginService, ){ } -} \ No newline at end of file +} diff --git a/src/state/plugins/store.ts b/src/state/plugins/store.ts index 83bb211ec912966efade6b361faced1dd43ede0c..7283a77a4f74431800af70c0360b0c8dfd2204f1 100644 --- a/src/state/plugins/store.ts +++ b/src/state/plugins/store.ts @@ -1,9 +1,8 @@ import { createReducer, on } from "@ngrx/store"; import * as actions from "./actions" -import { INIT_MANIFEST_SRC } from "./const" export type PluginStore = { - initManifests: Record<string, string> + initManifests: Record<string, string[]> } export const defaultState: PluginStore = { @@ -15,8 +14,8 @@ export const reducer = createReducer( on( actions.clearInitManifests, (state, { nameSpace }) => { - if (!state[nameSpace]) return state - const newMan: Record<string, string> = {} + if (!state.initManifests[nameSpace]) return state + const newMan: Record<string, string[]> = {} const { initManifests } = state for (const key in initManifests) { if (key === nameSpace) continue @@ -28,20 +27,4 @@ export const reducer = createReducer( } } ), - on( - actions.setInitMan, - (state, { nameSpace, url, internal }) => { - if (!internal) { - if (nameSpace === INIT_MANIFEST_SRC) return state - } - const { initManifests } = state - return { - ...state, - initManifests: { - ...initManifests, - [nameSpace]: url - } - } - } - ) ) diff --git a/src/strictLocal/index.ts b/src/strictLocal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..df38eaa22fe65187cca51189b4442684509278ed --- /dev/null +++ b/src/strictLocal/index.ts @@ -0,0 +1,2 @@ +export { StrictLocalModule } from "./module" +export { HideWhenLocal } from "./strictLocal.directive" \ No newline at end of file diff --git a/src/strictLocal/module.ts b/src/strictLocal/module.ts new file mode 100644 index 0000000000000000000000000000000000000000..62db4e8d888eb11107503bcb5b1ce8145b8a3d61 --- /dev/null +++ b/src/strictLocal/module.ts @@ -0,0 +1,23 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { MatButtonModule } from "@angular/material/button"; +import { MatTooltipModule } from "@angular/material/tooltip"; +import { HideWhenLocal } from "./strictLocal.directive"; +import { StrictLocalInfo } from "./strictLocalCmp/strictLocalCmp.component"; + +@NgModule({ + declarations: [ + HideWhenLocal, + StrictLocalInfo, + ], + imports: [ + CommonModule, + MatTooltipModule, + MatButtonModule, + ], + exports: [ + HideWhenLocal, + ] +}) + +export class StrictLocalModule{} diff --git a/src/strictLocal/strictLocal.directive.ts b/src/strictLocal/strictLocal.directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..b5f0c83ad82310ec40b64906c3f6d5c266fb7a2f --- /dev/null +++ b/src/strictLocal/strictLocal.directive.ts @@ -0,0 +1,22 @@ +import { ComponentFactoryResolver, Directive, HostBinding, ViewContainerRef } from "@angular/core"; +import { environment } from "src/environments/environment" +import { StrictLocalInfo } from "./strictLocalCmp/strictLocalCmp.component"; + +@Directive({ + selector: '[sxplr-hide-when-local]', + exportAs: 'hideWhenLocal' +}) + +export class HideWhenLocal { + @HostBinding('style.display') + hideWhenLocal = environment.STRICT_LOCAL ? 'none!important' : null + constructor( + private vc: ViewContainerRef, + private cfr: ComponentFactoryResolver, + ){ + if (environment.STRICT_LOCAL) { + const cf = this.cfr.resolveComponentFactory(StrictLocalInfo) + this.vc.createComponent(cf) + } + } +} diff --git a/src/strictLocal/strictLocalCmp/strictLocalCmp.component.ts b/src/strictLocal/strictLocalCmp/strictLocalCmp.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..e75b84c2b782a501996b66ca5a2be98687accda7 --- /dev/null +++ b/src/strictLocal/strictLocalCmp/strictLocalCmp.component.ts @@ -0,0 +1,13 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: `strict-local-info`, + template: ` + <button mat-icon-button [matTooltip]="tooltip" tabindex="-1"> + <i class="fas fa-unlink"></i> + </button>`, +}) + +export class StrictLocalInfo{ + tooltip = "External links are hidden in strict local mode." +} diff --git a/src/ui/dialogInfo/dialog.directive.ts b/src/ui/dialogInfo/dialog.directive.ts index 1b30c4e59ede96787318fe0f8582a2ee85317072..63d886a6ef5fa172ea1a8cf17abb5587005a5751 100644 --- a/src/ui/dialogInfo/dialog.directive.ts +++ b/src/ui/dialogInfo/dialog.directive.ts @@ -52,7 +52,7 @@ export class DialogDirective{ } this.matDialog.open(this.templateRef, { data: this.data, - ...sizeDict[this.size] + ...(sizeDict[this.size] || {}) }) } } \ No newline at end of file diff --git a/src/ui/help/about/about.template.html b/src/ui/help/about/about.template.html index 9a9169afd9eb25c1897e14b7dd950a5b55e4d212..380dbf4d2d83fbb0693857a00ac2929e13598905 100644 --- a/src/ui/help/about/about.template.html +++ b/src/ui/help/about/about.template.html @@ -1,7 +1,7 @@ <div class="container-fluid"> <div class="row mt-4 mb-4"> - <a [href]="userDoc" target="_blank"> + <a sxplr-hide-when-local [href]="userDoc" target="_blank"> <button mat-raised-button color="primary"> <i class="fas fa-book-open"></i> <span> @@ -10,7 +10,7 @@ </button> </a> - <a [href]="repoUrl" target="_blank"> + <a sxplr-hide-when-local [href]="repoUrl" target="_blank"> <button mat-flat-button> <i class="fab fa-github"></i> <span> diff --git a/src/ui/help/module.ts b/src/ui/help/module.ts index b99786401f97aad174c2e5db0398223d377be5c7..5dded09484db45d2ff96e81ac3502f11ae69f737 100644 --- a/src/ui/help/module.ts +++ b/src/ui/help/module.ts @@ -7,6 +7,7 @@ import { AboutCmp } from './about/about.component' import { HelpOnePager } from "./helpOnePager/helpOnePager.component"; import {QuickTourModule} from "src/ui/quickTour/module"; import { HowToCite } from "./howToCite/howToCite.component"; +import { StrictLocalModule } from "src/strictLocal"; @NgModule({ imports: [ @@ -14,7 +15,8 @@ import { HowToCite } from "./howToCite/howToCite.component"; AngularMaterialModule, ComponentsModule, UtilModule, - QuickTourModule + QuickTourModule, + StrictLocalModule, ], declarations: [ AboutCmp, diff --git a/src/ui/quickTour/quickTour.service.ts b/src/ui/quickTour/quickTour.service.ts index 3853a19497de7f3494d77c587b1a1e044cf00f5c..3aed96edf8d1cde0ff32642954be57f735a634a5 100644 --- a/src/ui/quickTour/quickTour.service.ts +++ b/src/ui/quickTour/quickTour.service.ts @@ -90,7 +90,7 @@ export class QuickTourService { height: '0px', width: '0px', hasBackdrop: true, - backdropClass: ['pe-none', 'cdk-overlay-dark-backdrop'], + backdropClass: ['sxplr-pe-none', 'cdk-overlay-dark-backdrop'], positionStrategy: this.overlay.position().global(), }) } diff --git a/src/util/constants.ts b/src/util/constants.ts index 5c4e7185743a1dca35e0523f076385b797fc21b5..9b1700442a02984d4b5a15c73b122d5bcc0cf8ed 100644 --- a/src/util/constants.ts +++ b/src/util/constants.ts @@ -115,7 +115,6 @@ export const compareLandmarksChanged: (prevLandmarks: any[], newLandmarks: any[] } export const CYCLE_PANEL_MESSAGE = `[spacebar] to cycle through views` -export const BS_ENDPOINT = new InjectionToken<string>('BS_ENDPOINT') export const UNSUPPORTED_PREVIEW = [{ text: 'Preview of Colin 27 and JuBrain Cytoarchitectonic', diff --git a/src/util/priority.ts b/src/util/priority.ts index ab703f76b14e1a299fa2ae017d81038a43c1c858..79ef1b043e4d5bb13a99e157995315e05e94b8d5 100644 --- a/src/util/priority.ts +++ b/src/util/priority.ts @@ -24,6 +24,8 @@ type Queue = { next: HttpHandler } +export const DISABLE_PRIORITY_HEADER = 'x-sxplr-disable-priority' + @Injectable({ providedIn: 'root' }) @@ -137,8 +139,11 @@ export class PriorityHttpInterceptor implements HttpInterceptor{ * Since the way in which serialization occurs is via path and query param... * body is not used. */ - if (this.disablePriority || req.method !== 'GET') { - return next.handle(req) + if (this.disablePriority || req.method !== 'GET' || !!req.headers.get(DISABLE_PRIORITY_HEADER)) { + const newReq = req.clone({ + headers: req.headers.delete(DISABLE_PRIORITY_HEADER) + }) + return next.handle(newReq) } const { urlWithParams } = req diff --git a/src/viewerModule/nehuba/annotation/effects.spec.ts b/src/viewerModule/nehuba/annotation/effects.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d18fc4adfdfab0a6e1b45b939be6f95748fe134 --- /dev/null +++ b/src/viewerModule/nehuba/annotation/effects.spec.ts @@ -0,0 +1,60 @@ +import { TestBed } from "@angular/core/testing" +import { provideMockActions } from "@ngrx/effects/testing" +import { Action } from "@ngrx/store" +import { MockStore, provideMockStore } from "@ngrx/store/testing" +import { hot } from "jasmine-marbles" +import { Observable } from "rxjs" +import { annotation, atlasAppearance } from "src/state" +import { NgAnnotationEffects } from "./effects" + +describe("effects.ts", () => { + describe("NgAnnotationEffects", () => { + let actions$: Observable<Action> + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideMockStore(), + provideMockActions(() => actions$), + NgAnnotationEffects, + ] + }) + }) + describe("onAnnotationHideQuadrant", () => { + describe("> when space filtered annotation does not exist", () => { + it("> should setOctantRemoval true", () => { + const mockStore = TestBed.inject(MockStore) + mockStore.overrideSelector(annotation.selectors.spaceFilteredAnnotations, []) + const effect = TestBed.inject(NgAnnotationEffects) + expect(effect.onAnnotationHideQuadrant).toBeObservable( + hot('a', { + a: atlasAppearance.actions.setOctantRemoval({ + flag: true + }) + }) + ) + }) + }) + describe("> when space filtered annotation exist", () => { + it("> should setOctantRemoval false", () => { + const mockStore = TestBed.inject(MockStore) + mockStore.overrideSelector(annotation.selectors.spaceFilteredAnnotations, [{} as any]) + const effect = TestBed.inject(NgAnnotationEffects) + expect(effect.onAnnotationHideQuadrant).toBeObservable( + hot('a', { + a: atlasAppearance.actions.setOctantRemoval({ + flag: false + }) + }) + ) + }) + }) + + describe("> on switch of space filtered annotations length", () => { + it("> should emit accordingly") + }) + describe("> on repeated emit of space filtered annotations length", () => { + it("> should only emit once") + }) + }) + }) +}) \ No newline at end of file diff --git a/src/viewerModule/nehuba/annotation/effects.ts b/src/viewerModule/nehuba/annotation/effects.ts index b4a6de67c1e0acdaaf3f4572c05a6cfa9ac51aff..10c510db4a0133b41962613926b72ea7159884f4 100644 --- a/src/viewerModule/nehuba/annotation/effects.ts +++ b/src/viewerModule/nehuba/annotation/effects.ts @@ -1,7 +1,7 @@ import { Injectable } from "@angular/core"; import { createEffect } from "@ngrx/effects"; import { select, Store } from "@ngrx/store"; -import { map } from "rxjs/operators"; +import { distinctUntilChanged, map } from "rxjs/operators"; import { annotation, atlasAppearance } from "src/state" @Injectable() @@ -10,8 +10,9 @@ export class NgAnnotationEffects{ onAnnotationHideQuadrant = createEffect(() => this.store.pipe( select(annotation.selectors.spaceFilteredAnnotations), - map(arr => { - const spaceFilteredAnnotationExists = arr.length > 0 + map(arr => arr.length > 0), + distinctUntilChanged(), + map(spaceFilteredAnnotationExists => { return atlasAppearance.actions.setOctantRemoval({ flag: !spaceFilteredAnnotationExists }) diff --git a/src/viewerModule/nehuba/constants.ts b/src/viewerModule/nehuba/constants.ts index 6537a7aef4ea762d6d38c85ac335011551b5767e..ca5ae4be810af998f93318a6348f751555fabff3 100644 --- a/src/viewerModule/nehuba/constants.ts +++ b/src/viewerModule/nehuba/constants.ts @@ -64,3 +64,5 @@ export interface IMeshesToLoad { } export const SET_MESHES_TO_LOAD = new InjectionToken<Observable<IMeshesToLoad>>('SET_MESHES_TO_LOAD') + +export const PMAP_LAYER_NAME = 'regional-pmap' diff --git a/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.effects.ts b/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.effects.ts index 6f6d708876773bd84a484d73226fab01066f731a..6886634b6e6c02a6556dcaa8a18c3f4729b7b71a 100644 --- a/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.effects.ts +++ b/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.effects.ts @@ -11,7 +11,7 @@ import { EnumColorMapName } from "src/util/colorMaps"; import { getShader } from "src/util/constants"; import { getNgLayersFromVolumesATP, getRegionLabelIndex } from "../config.service"; import { ParcVolumeSpec } from "../store/util"; -import { NehubaLayerControlService } from "./layerCtrl.service"; +import { PMAP_LAYER_NAME } from "../constants"; @Injectable() export class LayerCtrlEffects { @@ -22,7 +22,7 @@ export class LayerCtrlEffects { ), mapTo( atlasAppearance.actions.removeCustomLayer({ - id: NehubaLayerControlService.PMAP_LAYER_NAME + id: PMAP_LAYER_NAME }) ) )) @@ -37,17 +37,20 @@ export class LayerCtrlEffects { ), switchMap(([ regions, { atlas, parcellation, template } ]) => { const sapiRegion = this.sapi.getRegion(atlas["@id"], parcellation["@id"], regions[0].name) - return sapiRegion.getMapInfo(template["@id"]).pipe( - map(val => + return forkJoin([ + sapiRegion.getMapInfo(template["@id"]), + sapiRegion.getMapUrl(template["@id"]) + ]).pipe( + map(([mapInfo, mapUrl]) => atlasAppearance.actions.addCustomLayer({ customLayer: { clType: "customlayer/nglayer", - id: NehubaLayerControlService.PMAP_LAYER_NAME, - source: `nifti://${sapiRegion.getMapUrl(template["@id"])}`, + id: PMAP_LAYER_NAME, + source: `nifti://${mapUrl}`, shader: getShader({ colormap: EnumColorMapName.VIRIDIS, - highThreshold: val.max, - lowThreshold: val.min, + highThreshold: mapInfo.max, + lowThreshold: mapInfo.min, removeBg: true, }) } @@ -55,7 +58,7 @@ export class LayerCtrlEffects { ), catchError(() => of( atlasAppearance.actions.removeCustomLayer({ - id: NehubaLayerControlService.PMAP_LAYER_NAME + id: PMAP_LAYER_NAME }) )) ) diff --git a/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.service.ts b/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.service.ts index ba8b1d0adbe40c1c00fb794cac66a0b1890c25ae..136c0d0a314394628ddccdcd91010e85420f7524 100644 --- a/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.service.ts +++ b/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.service.ts @@ -13,6 +13,9 @@ import { arrayEqual } from "src/util/array"; import { ColorMapCustomLayer } from "src/state/atlasAppearance"; import { SapiRegionModel } from "src/atlasComponents/sapi"; import { AnnotationLayer } from "src/atlasComponents/annotations"; +import { PMAP_LAYER_NAME } from "../constants" +import { EnumColorMapName, mapKeyColorMap } from "src/util/colorMaps"; +import { getShader } from "src/util/constants"; export const BACKUP_COLOR = { red: 255, @@ -25,8 +28,6 @@ export const BACKUP_COLOR = { }) export class NehubaLayerControlService implements OnDestroy{ - static PMAP_LAYER_NAME = 'regional-pmap' - private selectedRegion$ = this.store$.pipe( select(atlasSelection.selectors.selectedRegions), shareReplay(1), @@ -276,15 +277,20 @@ export class NehubaLayerControlService implements OnDestroy{ private ngLayersRegister: atlasAppearance.NgLayerCustomLayer[] = [] - private updateCustomLayerTransparency$ = this.store$.pipe( - select(atlasAppearance.selectors.customLayers), - map(customLayers => customLayers.filter(l => l.clType === "customlayer/nglayer") as atlasAppearance.NgLayerCustomLayer[]), - pairwise(), - map(([ oldCustomLayers, newCustomLayers ]) => { - return newCustomLayers.filter(({ id, opacity }) => oldCustomLayers.some(({ id: oldId, opacity: oldOpacity }) => oldId === id && oldOpacity !== opacity)) - }), - filter(arr => arr.length > 0) - ) + private getUpdatedCustomLayer(isSameLayer: (o: atlasAppearance.NgLayerCustomLayer, n: atlasAppearance.NgLayerCustomLayer) => boolean){ + return this.store$.pipe( + select(atlasAppearance.selectors.customLayers), + map(customLayers => customLayers.filter(l => l.clType === "customlayer/nglayer") as atlasAppearance.NgLayerCustomLayer[]), + pairwise(), + map(([ oldCustomLayers, newCustomLayers ]) => { + return newCustomLayers.filter(n => oldCustomLayers.some(o => o.id === n.id && !isSameLayer(o, n))) + }), + filter(arr => arr.length > 0), + ) + } + + private updateCustomLayerTransparency$ = this.getUpdatedCustomLayer((o, n) => o.opacity === n.opacity) + private updateCustomLayerColorMap$ = this.getUpdatedCustomLayer((o, n) => o.shader === n.shader) private ngLayers$ = this.customLayers$.pipe( map(customLayers => customLayers.filter(l => l.clType === "customlayer/nglayer") as atlasAppearance.NgLayerCustomLayer[]), @@ -347,6 +353,19 @@ export class NehubaLayerControlService implements OnDestroy{ } as TNgLayerCtrl<'setLayerTransparency'> }) ), + this.updateCustomLayerColorMap$.pipe( + map(layers => { + const payload: Record<string, string> = {} + for (const layer of layers) { + const shader = layer.shader ?? getShader() + payload[layer.id] = shader + } + return { + type: 'updateShader', + payload + } as TNgLayerCtrl<'updateShader'> + }) + ), this.manualNgLayersControl$, ).pipe( ) @@ -367,7 +386,7 @@ export class NehubaLayerControlService implements OnDestroy{ */ return customLayers .map(l => l.id) - .filter(name => name !== NehubaLayerControlService.PMAP_LAYER_NAME) + .filter(name => name !== PMAP_LAYER_NAME) }) ), this.customLayers$.pipe( @@ -378,7 +397,7 @@ export class NehubaLayerControlService implements OnDestroy{ }), distinctUntilChanged(), map(flag => flag - ? [ NehubaLayerControlService.PMAP_LAYER_NAME ] + ? [ PMAP_LAYER_NAME ] : [] ) ) diff --git a/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.util.ts b/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.util.ts index d5a743d67ee5dfe7ab13521b7ae0383d2e9a7524..1fa9f52ff34373fdb76dd62ddeb7acffc71ff091 100644 --- a/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.util.ts +++ b/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.util.ts @@ -51,6 +51,9 @@ export interface INgLayerCtrl { setLayerTransparency: { [key: string]: number } + updateShader: { + [key: string]: string + } } export type TNgLayerCtrl<T extends keyof INgLayerCtrl> = { diff --git a/src/viewerModule/nehuba/layoutOverlay/nehuba.layoutOverlay/nehuba.layoutOverlay.component.ts b/src/viewerModule/nehuba/layoutOverlay/nehuba.layoutOverlay/nehuba.layoutOverlay.component.ts index a5f13432b611c3fee0b082338df7c3e3b086cf7d..2fcf3b824328773660a44e725872f1aafd36dfd5 100644 --- a/src/viewerModule/nehuba/layoutOverlay/nehuba.layoutOverlay/nehuba.layoutOverlay.component.ts +++ b/src/viewerModule/nehuba/layoutOverlay/nehuba.layoutOverlay/nehuba.layoutOverlay.component.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, ChangeDetectorRef, Component, Inject, OnDestroy } from "@angular/core"; +import { ChangeDetectorRef, Component, Inject, OnDestroy } from "@angular/core"; import { select, Store } from "@ngrx/store"; import { combineLatest, fromEvent, interval, merge, Observable, of, Subject, Subscription } from "rxjs"; import { userInterface } from "src/state"; @@ -6,7 +6,7 @@ import { NehubaViewerUnit } from "../../nehubaViewer/nehubaViewer.component"; import { NEHUBA_INSTANCE_INJTKN, takeOnePipe, getFourPanel, getHorizontalOneThree, getSinglePanel, getVerticalOneThree } from "../../util"; import { QUICKTOUR_DESC, ARIA_LABELS, IDS } from 'common/constants' import { IQuickTourData } from "src/ui/quickTour/constrants"; -import { debounce, debounceTime, filter, mapTo, switchMap, take } from "rxjs/operators"; +import { debounce, debounceTime, distinctUntilChanged, filter, map, mapTo, switchMap, take } from "rxjs/operators"; @Component({ selector: `nehuba-layout-overlay`, @@ -16,7 +16,7 @@ import { debounce, debounceTime, filter, mapTo, switchMap, take } from "rxjs/ope ] }) -export class NehubaLayoutOverlay implements OnDestroy, AfterViewInit{ +export class NehubaLayoutOverlay implements OnDestroy{ public ARIA_LABELS = ARIA_LABELS public IDS = IDS @@ -44,10 +44,6 @@ export class NehubaLayoutOverlay implements OnDestroy, AfterViewInit{ while(this.nehubaUnitSubs.length > 0) this.nehubaUnitSubs.pop().unsubscribe() } - ngAfterViewInit(): void { - this.setQuickTourPos() - } - handleCycleViewEvent(): void { if (this.currentPanelMode !== "SINGLE_PANEL") return this.store$.dispatch( @@ -124,6 +120,7 @@ export class NehubaLayoutOverlay implements OnDestroy, AfterViewInit{ nehuba$.subscribe(nehuba => { this.nehubaUnit = nehuba this.onNewNehubaUnit(nehuba) + this.setQuickTourPos() }) ) } @@ -154,11 +151,17 @@ export class NehubaLayoutOverlay implements OnDestroy, AfterViewInit{ fromEvent<CustomEvent>( nehubaUnit.elementRef.nativeElement, 'sliceRenderEvent' - ).subscribe(ev => { - const { missingImageChunks, missingChunks } = ev.detail + ).pipe( + map(ev => { + const { missingImageChunks, missingChunks } = ev.detail + return { missingImageChunks, missingChunks } + }), + distinctUntilChanged((o, n) => o.missingChunks === n.missingChunks && o.missingImageChunks === n.missingImageChunks) + ).subscribe(({ missingImageChunks, missingChunks }) => { this.volumeChunkLoading$.next( - missingImageChunks.length === 0 && missingChunks.length === 0 + missingImageChunks > 0 || missingChunks > 0 ) + this.detectChanges() }), /** diff --git a/src/viewerModule/nehuba/layoutOverlay/nehuba.layoutOverlay/nehuba.layoutOverlay.template.html b/src/viewerModule/nehuba/layoutOverlay/nehuba.layoutOverlay/nehuba.layoutOverlay.template.html index e716c29fac236a4c7394b76a68a5027ffaf94646..245fb9f07ed2da09550b1bc63a6f1d9497a924cb 100644 --- a/src/viewerModule/nehuba/layoutOverlay/nehuba.layoutOverlay/nehuba.layoutOverlay.template.html +++ b/src/viewerModule/nehuba/layoutOverlay/nehuba.layoutOverlay/nehuba.layoutOverlay.template.html @@ -68,7 +68,7 @@ [attr.data-viewer-controller-visible]="visible" [attr.data-viewer-controller-index]="panelIndex"> - <div class="position-absolute w-100 h-100 pe-none" + <div class="position-absolute w-100 h-100 sxplr-pe-none" *ngIf="panelIndex === 1" quick-tour [quick-tour-description]="quickTourIconsSlide.description" diff --git a/src/viewerModule/nehuba/mesh.service/mesh.service.spec.ts b/src/viewerModule/nehuba/mesh.service/mesh.service.spec.ts index 62a4ce09eb3d14682498812aee8e9e90dd01b253..ef3ac53320fbfa6ee86ec80ac140c105a2534af8 100644 --- a/src/viewerModule/nehuba/mesh.service/mesh.service.spec.ts +++ b/src/viewerModule/nehuba/mesh.service/mesh.service.spec.ts @@ -7,7 +7,7 @@ import { SapiRegionModel } from "src/atlasComponents/sapi" import * as configSvc from "../config.service" import { LayerCtrlEffects } from "../layerCtrl.service/layerCtrl.effects" import { NEVER, of, pipe } from "rxjs" -import { mapTo } from "rxjs/operators" +import { mapTo, take } from "rxjs/operators" import { selectorAuxMeshes } from "../store" @@ -51,6 +51,12 @@ describe('> mesh.service.ts', () => { ) ) }) + + afterEach(() => { + getParcNgIdSpy.calls.reset() + getRegionLabelIndexSpy.calls.reset() + getATPSpy.calls.reset() + }) describe('> NehubaMeshService', () => { beforeEach(() => { TestBed.configureTestingModule({ @@ -72,37 +78,106 @@ describe('> mesh.service.ts', () => { expect(service).toBeTruthy() }) - it('> mixes in auxillaryMeshIndices', () => { - const mockStore = TestBed.inject(MockStore) - mockStore.overrideSelector(atlasSelection.selectors.selectedRegions, [ fits1 ]) - mockStore.overrideSelector(atlasSelection.selectors.selectedParcAllRegions, []) - mockStore.overrideSelector(selectorAuxMeshes, [auxMesh]) + describe("> loadMeshes$", () => { - const ngId = 'blabla' - const labelIndex = 12 - getParcNgIdSpy.and.returnValue(ngId) - getRegionLabelIndexSpy.and.returnValue(labelIndex) + describe("> auxMesh defined", () => { + + const ngId = 'blabla' + const labelIndex = 12 + + beforeEach(() => { + + const mockStore = TestBed.inject(MockStore) + mockStore.overrideSelector(atlasSelection.selectors.selectedRegions, [ fits1 ]) + mockStore.overrideSelector(atlasSelection.selectors.selectedParcAllRegions, []) + mockStore.overrideSelector(selectorAuxMeshes, [auxMesh]) + + getParcNgIdSpy.and.returnValue(ngId) + getRegionLabelIndexSpy.and.returnValue(labelIndex) - const service = TestBed.inject(NehubaMeshService) - - expect( - service.loadMeshes$ - ).toBeObservable( - hot('(ab)', { - a: { - layer: { - name: ngId - }, - labelIndicies: [ labelIndex ] - }, - b: { - layer: { - name: auxMesh.ngId, - }, - labelIndicies: auxMesh.labelIndicies - } }) - ) + + it("> auxMesh ngId labelIndex emitted", () => { + + const service = TestBed.inject(NehubaMeshService) + expect( + service.loadMeshes$ + ).toBeObservable( + hot('(ab)', { + a: { + layer: { + name: ngId + }, + labelIndicies: [ labelIndex ] + }, + b: { + layer: { + name: auxMesh.ngId, + }, + labelIndicies: auxMesh.labelIndicies + } + }) + ) + }) + }) + + describe("> if multiple ngid and labelindicies are present", () => { + + const ngId1 = 'blabla' + const labelIndex1 = 12 + + const ngId2 = 'foobar' + const labelIndex2 = 13 + + beforeEach(() => { + + const mockStore = TestBed.inject(MockStore) + mockStore.overrideSelector(atlasSelection.selectors.selectedRegions, [ fits1 ]) + mockStore.overrideSelector(atlasSelection.selectors.selectedParcAllRegions, [fits1, fits1]) + mockStore.overrideSelector(selectorAuxMeshes, []) + + getParcNgIdSpy.and.returnValues(ngId1, ngId2, ngId2) + getRegionLabelIndexSpy.and.returnValues(labelIndex1, labelIndex2, labelIndex2) + }) + + it('> should call getParcNgIdSpy and getRegionLabelIndexSpy thrice', () => { + const service = TestBed.inject(NehubaMeshService) + service.loadMeshes$.pipe( + take(1) + ).subscribe(() => { + + expect(getParcNgIdSpy).toHaveBeenCalledTimes(3) + expect(getRegionLabelIndexSpy).toHaveBeenCalledTimes(3) + }) + }) + + /** + * in the case of julich brain 2.9 in colin 27, we expect selecting a region will hide meshes from all relevant ngIds (both left and right) + */ + it('> expect the emitted value to be incl all ngIds', () => { + const service = TestBed.inject(NehubaMeshService) + expect( + service.loadMeshes$ + ).toBeObservable( + hot('(ab)', { + a: { + layer: { + name: ngId1 + }, + labelIndicies: [] + }, + b: { + layer: { + name: ngId2 + }, + labelIndicies: [ labelIndex2 ] + } + }) + ) + + }) + }) + }) }) }) diff --git a/src/viewerModule/nehuba/mesh.service/mesh.service.ts b/src/viewerModule/nehuba/mesh.service/mesh.service.ts index 2585f00224e8729a4434afa568e127a486e66fcb..d372ce460746d01c5cf560518e1532f38616b882 100644 --- a/src/viewerModule/nehuba/mesh.service/mesh.service.ts +++ b/src/viewerModule/nehuba/mesh.service/mesh.service.ts @@ -47,7 +47,40 @@ export class NehubaMeshService implements OnDestroy { ]).pipe( switchMap(([{ atlas, template, parcellation }, regions, selectedRegions]) => { const ngIdRecord: Record<string, number[]> = {} + + const tree = new Tree( + regions, + (c, p) => (c.hasParent || []).some(_p => _p["@id"] === p["@id"]) + ) + + for (const r of regions) { + const regionLabelIndex = getRegionLabelIndex( atlas, template, parcellation, r ) + if (!regionLabelIndex) { + continue + } + if ( + tree.someAncestor(r, anc => !!getRegionLabelIndex(atlas, template, parcellation, anc)) + ) { + continue + } + const ngId = getParcNgId(atlas, template, parcellation, r) + if (!ngIdRecord[ngId]) { + ngIdRecord[ngId] = [] + } + ngIdRecord[ngId].push(regionLabelIndex) + } + if (selectedRegions.length > 0) { + /** + * If regions are selected, reset the meshes + */ + for (const key in ngIdRecord) { + ngIdRecord[key] = [] + } + + /** + * only show selected region + */ for (const r of selectedRegions) { const ngId = getParcNgId(atlas, template, parcellation, r) const regionLabelIndex = getRegionLabelIndex( atlas, template, parcellation, r ) @@ -56,28 +89,6 @@ export class NehubaMeshService implements OnDestroy { } ngIdRecord[ngId].push(regionLabelIndex) } - } else { - const tree = new Tree( - regions, - (c, p) => (c.hasParent || []).some(_p => _p["@id"] === p["@id"]) - ) - - for (const r of regions) { - const regionLabelIndex = getRegionLabelIndex( atlas, template, parcellation, r ) - if (!regionLabelIndex) { - continue - } - if ( - tree.someAncestor(r, (anc) => !!getRegionLabelIndex(atlas, template, parcellation, anc)) - ) { - continue - } - const ngId = getParcNgId(atlas, template, parcellation, r) - if (!ngIdRecord[ngId]) { - ngIdRecord[ngId] = [] - } - ngIdRecord[ngId].push(regionLabelIndex) - } } const arr: IMeshesToLoad[] = [] diff --git a/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.spec.ts b/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.spec.ts index c6437fc7c93844af930c70ce2c5ae019227f76cf..6c5bb5aeb0c02634389135fe0056c5b88644d89b 100644 --- a/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.spec.ts +++ b/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.spec.ts @@ -1,7 +1,6 @@ import { TestBed, fakeAsync, tick, ComponentFixture } from "@angular/core/testing" import { CommonModule } from "@angular/common" import { NehubaViewerUnit, IMPORT_NEHUBA_INJECT_TOKEN, scanFn } from "./nehubaViewer.component" -import { AtlasWorkerService } from "src/atlasViewer/atlasViewer.workerService.service" import { LoggingModule, LoggingService } from "src/logging" import { IMeshesToLoad, SET_MESHES_TO_LOAD } from "../constants" import { Subject } from "rxjs" @@ -106,7 +105,6 @@ describe('> nehubaViewer.component.ts', () => { provide: SET_COLORMAP_OBS, useValue: setcolorMap$ }, - AtlasWorkerService, LoggingService, ] }).compileComponents() diff --git a/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.ts b/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.ts index cfd902f1a76b4dc530b4d30050c96d70e1edff05..4d38f0487b48b24a678e8a34b454d521d102e94e 100644 --- a/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.ts +++ b/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.ts @@ -1,7 +1,6 @@ import { Component, ElementRef, EventEmitter, OnDestroy, Output, Inject, Optional } from "@angular/core"; -import { fromEvent, Subscription, BehaviorSubject, Observable, Subject, of, interval } from 'rxjs' -import { debounceTime, filter, map, scan, switchMap, take, distinctUntilChanged, debounce } from "rxjs/operators"; -import { AtlasWorkerService } from "src/atlasViewer/atlasViewer.workerService.service"; +import { Subscription, BehaviorSubject, Observable, Subject, of, interval } from 'rxjs' +import { debounceTime, filter, scan, switchMap, take, distinctUntilChanged, debounce } from "rxjs/operators"; import { LoggingService } from "src/logging"; import { bufferUntil, getExportNehuba, getViewer, setNehubaViewer, switchMapWaitFor } from "src/util/fn"; import { deserializeSegment, NEHUBA_INSTANCE_INJTKN } from "../util"; @@ -135,7 +134,6 @@ export class NehubaViewerUnit implements OnDestroy { constructor( public elementRef: ElementRef, - private workerService: AtlasWorkerService, private log: LoggingService, @Inject(IMPORT_NEHUBA_INJECT_TOKEN) getImportNehubaPr: () => Promise<any>, @Optional() @Inject(NEHUBA_INSTANCE_INJTKN) private nehubaViewer$: Subject<NehubaViewerUnit>, @@ -179,67 +177,6 @@ export class NehubaViewerUnit implements OnDestroy { }) .catch(e => this.errorEmitter.emit(e)) - - /** - * TODO move to layerCtrl.service - */ - this.ondestroySubscriptions.push( - fromEvent(this.workerService.worker, 'message').pipe( - filter((message: any) => { - - if (!message) { - // this.log.error('worker response message is undefined', message) - return false - } - if (!message.data) { - // this.log.error('worker response message.data is undefined', message.data) - return false - } - if (message.data.type !== 'ASSEMBLED_USERLANDMARKS_VTK') { - /* worker responded with not assembled landmark, no need to act */ - return false - } - /** - * nb url may be undefined - * if undefined, user have removed all user landmarks, and all that needs to be done - * is remove the user landmark layer - * - * message.data.url - */ - - return true - }), - debounceTime(100), - map(e => e.data.url), - ).subscribe(url => { - this.landmarksLoaded = !!url - this.removeuserLandmarks() - - /** - * url may be null if user removes all landmarks - */ - if (!url) { - /** - * remove transparency from meshes in current layer(s) - */ - this.setMeshTransparency(false) - return - } - const _ = {} - _[NG_USER_LANDMARK_LAYER_NAME] = { - type: 'mesh', - source: `vtk://${url}`, - shader: this.userLandmarkShader, - } - this.loadLayer(_) - - /** - * adding transparency to meshes in current layer(s) - */ - this.setMeshTransparency(true) - }), - ) - if (this.setColormap$) { this.ondestroySubscriptions.push( this.setColormap$.pipe( @@ -349,6 +286,12 @@ export class NehubaViewerUnit implements OnDestroy { this.setLayerTransparency(key, p.payload[key]) } } + if (message.type === "updateShader") { + const p = message as TNgLayerCtrl<'updateShader'> + for (const key in p.payload) { + this.setLayerShader(key, p.payload[key]) + } + } } }) ) @@ -540,37 +483,6 @@ export class NehubaViewerUnit implements OnDestroy { } private userLandmarkShader: string = FRAGMENT_MAIN_WHITE - - // TODO single landmark for user landmark - public updateUserLandmarks(landmarks: any[]) { - if (!this.nehubaViewer) { - return - } - - this.workerService.worker.postMessage({ - type : 'GET_USERLANDMARKS_VTK', - scale: Math.min(...this.dim.map(v => v * NG_LANDMARK_CONSTANT)), - landmarks : landmarks.map(lm => lm.position.map(coord => coord * 1e6)), - }) - - const parseLmColor = lm => { - if (!lm) return null - const { color } = lm - if (!color) return null - if (!Array.isArray(color)) return null - if (color.length !== 3) return null - const parseNum = num => (num >= 0 && num <= 255 ? num / 255 : 1).toFixed(3) - return `emitRGB(vec3(${color.map(parseNum).join(',')}));` - } - - const appendConditional = (frag, idx) => frag && `if (label > ${idx - 0.01} && label < ${idx + 0.01}) { ${frag} }` - - if (landmarks.some(parseLmColor)) { - this.userLandmarkShader = `void main(){ ${landmarks.map(parseLmColor).map(appendConditional).filter(v => !!v).join('else ')} else {${FRAGMENT_EMIT_WHITE}} }` - } else { - this.userLandmarkShader = FRAGMENT_MAIN_WHITE - } - } public removeSpatialSearch3DLandmarks() { this.removeLayer({ @@ -796,6 +708,11 @@ export class NehubaViewerUnit implements OnDestroy { if (layer.layer.opacity) layer.layer.opacity.restoreState(alpha) } + private setLayerShader(layerName: string, shader: string) { + const layer = this.nehubaViewer.ngviewer.layerManager.getLayerByName(layerName) + if (layer?.layer?.fragmentMain) layer.layer.fragmentMain.restoreState(shader) + } + public setMeshTransparency(flag: boolean){ /** diff --git a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts index 0c7b084eb8dab41764d88d913578e8eeb843478b..fea1911266af6a410bdd339c805c4aeb3129f20c 100644 --- a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts +++ b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts @@ -284,8 +284,33 @@ export class NehubaGlueCmp implements IViewer<'nehuba'>, OnDestroy, AfterViewIni /** * TODO check extension? */ - this.dismissAllAddedLayers() + + if (/\.swc$/i.test(file.name)) { + const url = URL.createObjectURL(file) + this.droppedLayerNames.push({ + layerName: randomUuid, + resourceUrl: url + }) + this.store$.dispatch( + atlasAppearance.actions.addCustomLayer({ + customLayer: { + id: randomUuid, + source: `swc://${url}`, + segments: ["1"], + transform: [ + [1e3, 0, 0, 0], + [0, 1e3, 0, 0], + [0, 0, 1e3, 0], + [0, 0, 0, 1], + ], + clType: 'customlayer/nglayer' as const + } + }) + ) + return + } + // Get file, try to inflate, if files, use original array buffer const buf = await file.arrayBuffer() diff --git a/src/viewerModule/nehuba/nehubaViewerInterface/nehubaViewerTouch.directive.ts b/src/viewerModule/nehuba/nehubaViewerInterface/nehubaViewerTouch.directive.ts index 824db2f2e8adc80b51c1747eb3064a08bfe6f65b..0fbd2562a1d4f50bc39e6157aefe322747afe475 100644 --- a/src/viewerModule/nehuba/nehubaViewerInterface/nehubaViewerTouch.directive.ts +++ b/src/viewerModule/nehuba/nehubaViewerInterface/nehubaViewerTouch.directive.ts @@ -27,9 +27,20 @@ export class NehubaViewerTouchDirective implements OnDestroy{ public translate$: Observable<any> private nehubaUnit: NehubaViewerUnit + private htmlElementIndexMap = new WeakMap<HTMLElement, number>() private findPanelIndex(panel: HTMLElement){ if (!this.nehubaUnit) return null - return Array.from(this.nehubaUnit?.nehubaViewer?.ngviewer?.display?.panels || []).indexOf(panel) + if (!this.htmlElementIndexMap.has(panel)) { + Array.from(this.nehubaUnit?.nehubaViewer?.ngviewer?.display?.panels || []).forEach((el, idx) => { + if (el['element']) { + this.htmlElementIndexMap.set( + el['element'] as HTMLElement, + idx + ) + } + }) + } + return this.htmlElementIndexMap.get(panel) } private _exportNehuba: any diff --git a/src/viewerModule/nehuba/ngLayerCtl/ngLayerCtrl.component.ts b/src/viewerModule/nehuba/ngLayerCtl/ngLayerCtrl.component.ts index fd216151b9bbbf14d3b498eae8ffc98b2501ba05..f2e75e77f501517ac2f166f44a1b851042458489 100644 --- a/src/viewerModule/nehuba/ngLayerCtl/ngLayerCtrl.component.ts +++ b/src/viewerModule/nehuba/ngLayerCtl/ngLayerCtrl.component.ts @@ -54,6 +54,10 @@ export class NgLayerCtrlCmp implements OnChanges, OnDestroy{ private onDestroyCb: (() => void)[] = [] private removeLayer: () => void + public showOpacityCtrl = false + public hideNgTuneCtrl = 'lower_threshold,higher_threshold,brightness,contrast,colormap,hide-threshold-checkbox' + public defaultOpacity = 1 + @Input('ng-layer-ctl-name') name: string diff --git a/src/viewerModule/nehuba/ngLayerCtl/ngLayerCtrl.template.html b/src/viewerModule/nehuba/ngLayerCtl/ngLayerCtrl.template.html index 2188b17d9f57f496928a0b40f2ba78e3508be9d5..e3442c7b4323dac373e0b9110549b8c97a206a3e 100644 --- a/src/viewerModule/nehuba/ngLayerCtl/ngLayerCtrl.template.html +++ b/src/viewerModule/nehuba/ngLayerCtl/ngLayerCtrl.template.html @@ -7,4 +7,16 @@ <span> {{ name }} </span> + + <button mat-icon-button (click)="showOpacityCtrl = !showOpacityCtrl"> + <i class="fas fa-cog"></i> + </button> + + <ng-template [ngIf]="showOpacityCtrl"> + <ng-layer-tune + [ngLayerName]="name" + [hideCtrl]="hideNgTuneCtrl" + [opacity]="defaultOpacity"> + </ng-layer-tune> + </ng-template> </div> diff --git a/src/viewerModule/nehuba/statusCard/statusCard.component.spec.ts b/src/viewerModule/nehuba/statusCard/statusCard.component.spec.ts index ce0afa8fa8632b433b5998eed1107654fdc4dfa2..ab6ac511153553a1bc50365bd2b85c4a239c94f4 100644 --- a/src/viewerModule/nehuba/statusCard/statusCard.component.spec.ts +++ b/src/viewerModule/nehuba/statusCard/statusCard.component.spec.ts @@ -146,8 +146,8 @@ describe('> statusCard.component.ts', () => { initialNgState: { navigation: { pose: { - orientation: [0,0,0,1], - position: [10, 20, 30] + orientation: [0, 0, 0, 1], + position: [0, 0, 0] }, zoomFactor: 1e6 } diff --git a/src/viewerModule/nehuba/statusCard/statusCard.component.ts b/src/viewerModule/nehuba/statusCard/statusCard.component.ts index 26d6786e8b7f7ea3723eb47d7a32bc299c46b720..58bbdf911ed843cd5e68123f65869fe5843b3ace 100644 --- a/src/viewerModule/nehuba/statusCard/statusCard.component.ts +++ b/src/viewerModule/nehuba/statusCard/statusCard.component.ts @@ -177,10 +177,7 @@ export class StatusCardComponent implements OnInit, OnChanges{ */ public resetNavigation({rotation: rotationFlag = false, position: positionFlag = false, zoom : zoomFlag = false}: {rotation?: boolean, position?: boolean, zoom?: boolean}): void { const config = getNehubaConfig(this.selectedTemplate) - const { - orientation, - position - } = config.dataset.initialNgState.navigation.pose + const { zoomFactor: zoom } = config.dataset.initialNgState.navigation @@ -189,8 +186,8 @@ export class StatusCardComponent implements OnInit, OnChanges{ actions.navigateTo({ navigation: { ...this.currentNavigation, - ...(rotationFlag ? { orientation: orientation } : {}), - ...(positionFlag ? { position: position } : {}), + ...(rotationFlag ? { orientation: [0, 0, 0, 1] } : {}), + ...(positionFlag ? { position: [0, 0, 0] } : {}), ...(zoomFlag ? { zoom: zoom } : {}), }, physical: true, diff --git a/src/viewerModule/nehuba/viewerCtrl/viewerCtrlCmp/viewerCtrlCmp.component.spec.ts b/src/viewerModule/nehuba/viewerCtrl/viewerCtrlCmp/viewerCtrlCmp.component.spec.ts index c5042ecfc22e3134ab6af2c967bdad86bf5d4933..0ec1d5125ba83f89dc91ea8e20e33954673030a7 100644 --- a/src/viewerModule/nehuba/viewerCtrl/viewerCtrlCmp/viewerCtrlCmp.component.spec.ts +++ b/src/viewerModule/nehuba/viewerCtrl/viewerCtrlCmp/viewerCtrlCmp.component.spec.ts @@ -22,7 +22,6 @@ describe('> viewerCtrlCmp.component.ts', () => { let mockStore: MockStore let mockNehubaViewer = { - updateUserLandmarks: jasmine.createSpy(), nehubaViewer: { ngviewer: { layerManager: { @@ -42,7 +41,6 @@ describe('> viewerCtrlCmp.component.ts', () => { } afterEach(() => { - mockNehubaViewer.updateUserLandmarks.calls.reset() mockNehubaViewer.nehubaViewer.ngviewer.layerManager.getLayerByName.calls.reset() mockNehubaViewer.nehubaViewer.ngviewer.display.scheduleRedraw.calls.reset() }) diff --git a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts index a194176aa6d83027be793d3ebe1d098aa75f8c8d..f04ad6cc1b0a5b85875c6bc247150b737eb5605c 100644 --- a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts +++ b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts @@ -328,7 +328,8 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit * subscribe to main store and negotiate with relay to set camera */ const navSub = this.store$.pipe( - select(atlasSelection.selectors.navigation) + select(atlasSelection.selectors.navigation), + filter(v => !!v), ).subscribe(nav => { const { perspectiveOrientation, perspectiveZoom } = nav this.mainStoreCameraNav = { diff --git a/src/viewerModule/viewerCmp/viewerCmp.component.ts b/src/viewerModule/viewerCmp/viewerCmp.component.ts index d28f45292763e4b62385e8829198849d62b8af58..f0b32efea3c8233a648e0277bfd38ddeaebe2074 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.component.ts +++ b/src/viewerModule/viewerCmp/viewerCmp.component.ts @@ -12,6 +12,9 @@ import { SAPI, SapiRegionModel } from "src/atlasComponents/sapi"; import { atlasSelection, userInteraction, } from "src/state"; import { SapiSpatialFeatureModel, SapiFeatureModel, SapiParcellationModel, SapiSpaceModel } from "src/atlasComponents/sapi/type"; import { getUuid } from "src/util/fn"; +import { environment } from "src/environments/environment" +import { SapiViewsFeaturesVoiQuery } from "src/atlasComponents/sapiViews/features"; +import { SapiViewsCoreSpaceBoundingBox } from "src/atlasComponents/sapiViews/core"; @Component({ selector: 'iav-cmp-viewer-container', @@ -62,10 +65,17 @@ export class ViewerCmp implements OnDestroy { public CONST = CONST public ARIA_LABELS = ARIA_LABELS + public VOI_QUERY_FLAG = environment.EXPERIMENTAL_FEATURE_FLAG @ViewChild('genericInfoVCR', { read: ViewContainerRef }) genericInfoVCR: ViewContainerRef + @ViewChild('voiFeatures', { read: SapiViewsFeaturesVoiQuery }) + voiQueryDirective: SapiViewsFeaturesVoiQuery + + @ViewChild('bbox', { read: SapiViewsCoreSpaceBoundingBox }) + boundingBoxDirective: SapiViewsCoreSpaceBoundingBox + public quickTourRegionSearch: IQuickTourData = { order: 7, description: QUICKTOUR_DESC.REGION_SEARCH, @@ -321,6 +331,7 @@ export class ViewerCmp implements OnDestroy { switch(event.type) { case EnumViewerEvt.VIEWERLOADED: this.viewerLoaded = event.data + this.cdr.detectChanges() break case EnumViewerEvt.VIEWER_CTX: this.ctxMenuSvc.context$.next(event.data) diff --git a/src/viewerModule/viewerCmp/viewerCmp.style.css b/src/viewerModule/viewerCmp/viewerCmp.style.css index 671fef08c4faf6d72e1cfd3c9664208fb60261b7..66c272acfcbc1f6fca1419582bde6d98efa53d88 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.style.css +++ b/src/viewerModule/viewerCmp/viewerCmp.style.css @@ -118,3 +118,14 @@ mat-list[dense].contextual-block { background-color : rgba(30,30,30,0.8); } + +.region-populated +{ + overflow: hidden auto; +} + +.region-chip-suffix +{ + transform: scale(0.7); + margin-right: -0.25rem; +} diff --git a/src/viewerModule/viewerCmp/viewerCmp.template.html b/src/viewerModule/viewerCmp/viewerCmp.template.html index 891cdb9f42e5cecfe7732c6004bdaae24a2dab6a..f1a6b4faf088a2d733b6c91ff1f9d6c6d53964d5 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.template.html +++ b/src/viewerModule/viewerCmp/viewerCmp.template.html @@ -4,12 +4,12 @@ <div class="floating-ui"> - <div *ngIf="(media.mediaBreakPoint$ | async) < 3" - class="fixed-bottom pe-none mb-2 d-flex justify-content-center"> + <div *ngIf="(media.mediaBreakPoint$ | async) < 2" + class="fixed-bottom sxplr-pe-none mb-2 d-flex justify-content-center"> <logo-container></logo-container> </div> - <div *ngIf="(media.mediaBreakPoint$ | async) < 3" floatingMouseContextualContainerDirective> + <div *ngIf="(media.mediaBreakPoint$ | async) < 2" floatingMouseContextualContainerDirective> <div class="h-0" iav-mouse-hover @@ -29,7 +29,7 @@ </mat-list-item> - <ng-template [ngIf]="voiFeatures.onhover | async" let-feat> + <ng-template [ngIf]="voiQueryDirective && (voiQueryDirective.onhover | async)" let-feat> <mat-list-item> <mat-icon fontSet="fas" @@ -99,7 +99,7 @@ </mat-drawer> <!-- master content --> - <mat-drawer-content class="visible pe-none position-relative"> + <mat-drawer-content class="visible sxplr-pe-none position-relative"> <iav-layout-fourcorners> <!-- top left --> @@ -148,7 +148,7 @@ <!-- bottom left --> - <div iavLayoutFourCornersBottomLeft class="ws-no-wrap d-inline-flex w-100vw pe-none align-items-center mb-4"> + <div iavLayoutFourCornersBottomLeft class="ws-no-wrap d-inline-flex w-100vw sxplr-pe-none align-items-center mb-4"> <!-- special bottom left --> <ng-template [ngIf]="viewerMode$ | async" let-mode [ngIfElse]="localBottomLeftTmpl"></ng-template> @@ -223,9 +223,11 @@ <ng-container *ngTemplateOutlet="autocompleteTmpl; context: { showTour: true }"> </ng-container> - <div *ngIf="!((selectedRegions$ | async)[0])" class="sxplr-p-2 w-100"> - <ng-container *ngTemplateOutlet="spatialFeatureListViewTmpl"></ng-container> - </div> + <ng-template [ngIf]="VOI_QUERY_FLAG"> + <div *ngIf="!((selectedRegions$ | async)[0])" class="sxplr-p-2 w-100"> + <ng-container *ngTemplateOutlet="spatialFeatureListViewTmpl"></ng-container> + </div> + </ng-template> </div> <!-- such a gross implementation --> @@ -264,7 +266,7 @@ isOpen: minTrayVisSwitch.switchState$ | async, regionSelected: selectedRegions$ | async, click: minTrayVisSwitch.toggle.bind(minTrayVisSwitch), - badge: (voiFeatures.features$ | async).length || null + badge: voiQueryDirective && (voiQueryDirective.features$ | async).length || null }"> </ng-container> </div> @@ -434,7 +436,7 @@ <div prefix> </div> - <div suffix class="sxplr-scale-70"> + <div suffix class="region-chip-suffix"> <button mat-mini-fab color="primary" iav-stop="mousedown click" @@ -543,7 +545,7 @@ <i class="fas fa-sitemap"></i> </button> - <div class="w-100 h-100 position-absolute pe-none" *ngIf="showTour"> + <div class="w-100 h-100 position-absolute sxplr-pe-none" *ngIf="showTour"> </div> </div> @@ -683,7 +685,7 @@ <!-- see https://github.com/HumanBrainProject/interactive-viewer/issues/698 --> - <ng-template [ngIf]="regionDirective.fetchInProgress"> + <ng-template [ngIf]="regionDirective.fetchInProgress$ | async"> <spinner-cmp class="sxplr-mt-10 fs-200"></spinner-cmp> </ng-template> <sxplr-sapiviews-core-region-region-rich @@ -983,18 +985,18 @@ </ng-template> <ng-template #spatialFeatureListViewTmpl> - <div *ngIf="voiFeatures.busy$ | async; else notBusyTmpl" class="fs-200"> + <div *ngIf="voiQueryDirective && (voiQueryDirective.busy$ | async); else notBusyTmpl" class="fs-200"> <spinner-cmp></spinner-cmp> </div> <ng-template #notBusyTmpl> - <mat-card *ngIf="(voiFeatures.features$ | async).length > 0" class="pe-all mat-elevation-z4"> + <mat-card *ngIf="voiQueryDirective && (voiQueryDirective.features$ | async).length > 0" class="pe-all mat-elevation-z4"> <mat-card-title> Volumes of interest </mat-card-title> <mat-card-subtitle class="overflow-hidden"> <!-- TODO in future, perhaps encapsulate this as a component? seems like a nature fit in sapiView/space/boundingbox --> - <ng-template let-bbox [ngIf]="bbox.bbox$ | async | getProperty : 'bbox'" [ngIfElse]="bboxFallbackTmpl"> + <ng-template let-bbox [ngIf]="boundingBoxDirective && (boundingBoxDirective.bbox$ | async | getProperty : 'bbox')" [ngIfElse]="bboxFallbackTmpl"> Bounding box: {{ bbox[0] | numbers | json }} - {{ bbox[1] | numbers | json }} mm </ng-template> <ng-template #bboxFallbackTmpl> @@ -1005,17 +1007,21 @@ <mat-divider></mat-divider> - <div *ngFor="let feature of voiFeatures.features$ | async" - mat-ripple - (click)="showDataset(feature)" - class="sxplr-custom-cmp hoverable w-100 overflow-hidden text-overflow-ellipses"> - {{ feature.metadata.fullName }} - </div> + <ng-template [ngIf]="voiQueryDirective"> + + <div *ngFor="let feature of voiQueryDirective.features$ | async" + mat-ripple + (click)="showDataset(feature)" + class="sxplr-custom-cmp hoverable w-100 overflow-hidden text-overflow-ellipses"> + {{ feature.metadata.fullName }} + </div> + </ng-template> </mat-card> </ng-template> </ng-template> <div class="d-none" + *ngIf="VOI_QUERY_FLAG" sxplr-sapiviews-core-space-boundingbox [sxplr-sapiviews-core-space-boundingbox-atlas]="selectedAtlas$ | async" [sxplr-sapiviews-core-space-boundingbox-space]="templateSelected$ | async" diff --git a/src/widget/constants.ts b/src/widget/constants.ts index 412bccd405d746982f24081f8d405cd7011aeb53..a779b079ad01afafeff6bf9e52f0675e4fdad6e4 100644 --- a/src/widget/constants.ts +++ b/src/widget/constants.ts @@ -21,3 +21,5 @@ interface TypeActionWidgetReturnVal<T>{ export type TypeActionToWidget<T> = (type: EnumActionToWidget, obj: T, option: IActionWidgetOption) => TypeActionWidgetReturnVal<T> export const WIDGET_PORTAL_TOKEN = new InjectionToken<Record<string, unknown>>("WIDGET_PORTAL_TOKEN") + +export const RM_WIDGET = new InjectionToken('RM_WIDGET') \ No newline at end of file diff --git a/src/widget/service.ts b/src/widget/service.ts index 75be508867a61aa48951701ff91a43dbab8045cd..9430269418731ceb3b57810b54b26ef155ef7ef0 100644 --- a/src/widget/service.ts +++ b/src/widget/service.ts @@ -1,5 +1,6 @@ import { ComponentPortal } from "@angular/cdk/portal"; import { ComponentFactory, ComponentFactoryResolver, ComponentRef, Injectable, Injector, ViewContainerRef } from "@angular/core"; +import { RM_WIDGET } from "./constants"; import { WidgetPortal } from "./widgetPortal/widgetPortal.component"; @Injectable({ @@ -18,8 +19,15 @@ export class WidgetService { } public addNewWidget<T>(Component: new (...arg: any) => T, injector: Injector): WidgetPortal<T> { - const widgetPortal = this.vcr.createComponent(this.cf, 0, injector) as ComponentRef<WidgetPortal<T>> - const cmpPortal = new ComponentPortal<T>(Component, this.vcr, injector) + const inj = Injector.create({ + providers: [{ + provide: RM_WIDGET, + useValue: (cmp: WidgetPortal<T>) => this.rmWidget(cmp) + }], + parent: injector + }) + const widgetPortal = this.vcr.createComponent(this.cf, 0, inj) as ComponentRef<WidgetPortal<T>> + const cmpPortal = new ComponentPortal<T>(Component, this.vcr, inj) this.viewRefMap.set(widgetPortal.instance, widgetPortal) diff --git a/src/widget/widgetPortal/widgetPortal.component.ts b/src/widget/widgetPortal/widgetPortal.component.ts index a1351e15e370318c5e9bf363c4c36f2f0b8d37e9..5e5cc05fa5fa6f15a139fb415097c3fff4078c48 100644 --- a/src/widget/widgetPortal/widgetPortal.component.ts +++ b/src/widget/widgetPortal/widgetPortal.component.ts @@ -1,6 +1,6 @@ import { ComponentPortal } from "@angular/cdk/portal"; -import { ChangeDetectionStrategy, ChangeDetectorRef, Component } from "@angular/core"; -import { WidgetService } from "../service"; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Optional } from "@angular/core"; +import { RM_WIDGET } from "../constants"; @Component({ selector: 'sxplr-widget-portal', @@ -30,12 +30,12 @@ export class WidgetPortal<T>{ } constructor( - private wSvc: WidgetService, private cdr: ChangeDetectorRef, + @Optional() @Inject(RM_WIDGET) private rmWidget: (inst: unknown) => void ){ } exit(){ - this.wSvc.rmWidget(this) + if (this.rmWidget) this.rmWidget(this) } }