diff --git a/.eslintrc.js b/.eslintrc.js index 54af39fd8e639934ed7c4c2658f668a4facc8536..0e79e8f287d1ef96ada9140ca7aeb51938fdbec3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -7,7 +7,6 @@ module.exports = { "@typescript-eslint/no-empty-interface": "off", "@typescript-eslint/no-inferrable-types": "off", "@typescript-eslint/interface-name-prefix": [0], - // "no-unused-vars": "off", "semi": "off", "indent": "off", "@typescript-eslint/member-delimiter-style": [2, { diff --git a/.github/workflows/deploy-on-okd.yml b/.github/workflows/deploy-on-okd.yml new file mode 100644 index 0000000000000000000000000000000000000000..d587449ec01cc971180d8b87cfefbe12b568205b --- /dev/null +++ b/.github/workflows/deploy-on-okd.yml @@ -0,0 +1,85 @@ +name: Trigger deploy on OKD +on: + workflow_call: + + inputs: + FULL_DEPLOY_ID: + required: true + type: string + OKD_ENDPOINT: + required: true + type: string + OKD_PROJECT: + required: true + type: string + + + DEPLOY_ID: + required: false + type: string + BRANCH_NAME: + required: false + type: string + ROUTE_HOST: + required: false + type: string + ROUTE_PATH: + required: false + type: string + BUILD_TEXT: + required: false + type: string + + secrets: + OKD_TOKEN: + required: true +env: + OC_TEMPLATE_NAME: 'siibra-explorer-branch-deploy-2' +jobs: + trigger-deploy: + runs-on: ubuntu-latest + steps: + - name: 'Login' + run: | + oc login ${{ inputs.OKD_ENDPOINT }} --token=${{ secrets.OKD_TOKEN }} + oc project ${{ inputs.OKD_PROJECT }} + - name: 'Login and import image' + run: | + if oc get dc ${{ inputs.FULL_DEPLOY_ID }}; then + # trigger redeploy if deployconfig exists already + echo "dc ${{ inputs.FULL_DEPLOY_ID }} already exist, redeploy..." + oc rollout latest dc/${{ inputs.FULL_DEPLOY_ID }} + else + # create new app if deployconfig does not yet exist + echo "dc ${{ inputs.FULL_DEPLOY_ID }} does not yet exist, create new app..." + + if [[ -z "${{ inputs.ROUTE_HOST }}" ]] + then + echo "ROUTE_HOST not defined!" + exit 1 + fi + + if [[ -z "${{ inputs.ROUTE_PATH }}" ]] + then + echo "ROUTE_PATH not defined!" + exit 1 + fi + + if [[ -z "${{ inputs.BUILD_TEXT }}" ]] + then + echo "BUILD_TEXT not defined!" + exit 1 + fi + if [[ -z "${{ inputs.BRANCH_NAME }}" ]] + then + echo "BRANCH_NAME not defined!" + exit 1 + fi + + oc new-app --template ${{ env.OC_TEMPLATE_NAME }} \ + -p BRANCH_NAME=${{ inputs.BRANCH_NAME }} \ + -p DEPLOY_ID=${{ inputs.DEPLOY_ID }} \ + -p ROUTE_HOST=${{ inputs.ROUTE_HOST }} \ + -p ROUTE_PATH=${{ inputs.ROUTE_PATH }} \ + -p BUILD_TEXT=${{ inputs.BUILD_TEXT }} + fi diff --git a/.github/workflows/docker_img.yml b/.github/workflows/docker_img.yml index eb5368f03107d568f2157cbee31359bae1f03f6b..539d138b3ccd567998a5305e55169ce60e6f71a4 100644 --- a/.github/workflows/docker_img.yml +++ b/.github/workflows/docker_img.yml @@ -111,118 +111,128 @@ jobs: echo "Pushing $DOCKER_BUILT_TAG" docker push $DOCKER_BUILT_TAG - trigger-deploy: + setting-vars: if: success() runs-on: ubuntu-latest - env: - GITHUB_API_ROOT: https://api.github.com/repos/fzj-inm1-bda/siibra-explorer - OC_TEMPLATE_NAME: 'siibra-explorer-branch-deploy-2' - - needs: build-docker-img + outputs: + BRANCH_NAME: ${{ steps.set-vars.outputs.BRANCH_NAME }} + BUILD_TEXT: ${{ steps.set-vars.outputs.BUILD_TEXT }} + DEPLOY_ID: ${{ steps.set-vars.outputs.DEPLOY_ID }} steps: - uses: actions/checkout@v3 - - name: Set env var + - id: set-vars + name: Set vars run: | echo "Using github.ref: $GITHUB_REF" + BRANCH_NAME=${GITHUB_REF#refs/heads/} - echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV - + echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_OUTPUT + echo "Branch is $BRANCH_NAME ." if [[ "$BRANCH_NAME" == 'master' ]] then - echo "BUILD_TEXT=" >> $GITHUB_ENV + echo "BUILD_TEXT=" >> $GITHUB_OUTPUT else - echo "BUILD_TEXT=$BRANCH_NAME" >> $GITHUB_ENV + echo "BUILD_TEXT=$BRANCH_NAME" >> $GITHUB_OUTPUT fi # DEPLOY_ID == remove _ / and lowercase everything from branch DEPLOY_ID=$(echo ${BRANCH_NAME//[_\/]/} | awk '{ print tolower($0) }') - echo "DEPLOY_ID=$DEPLOY_ID" >> $GITHUB_ENV - - if [[ "$BRANCH_NAME" == 'master' ]] || [[ "$BRANCH_NAME" == 'staging' ]] - then - echo "OKD_URL=https://okd.hbp.eu:443" >> $GITHUB_ENV - echo "OKD_SECRET=${{ secrets.OKD_PROD_SECRET }}" >> $GITHUB_ENV - echo "OKD_PROJECT=interactive-viewer" >> $GITHUB_ENV - echo "ROUTE_HOST=siibra-explorer.apps.hbp.eu" >> $GITHUB_ENV - - if [[ "$BRANCH_NAME" == 'master' ]] - then - FULL_DEPLOY_ID=siibra-explorer-branch-deploy-2-prodpathviewer - FULL_DEPLOY_ID2=siibra-explorer-branch-deploy-2-iav-legacy - echo "FULL_DEPLOY_ID=$FULL_DEPLOY_ID" >> $GITHUB_ENV - echo "FULL_DEPLOY_ID2=$FULL_DEPLOY_ID2" >> $GITHUB_ENV - else - FULL_DEPLOY_ID=siibra-explorer-branch-deploy-2-stagingpathed - echo "FULL_DEPLOY_ID=$FULL_DEPLOY_ID" >> $GITHUB_ENV - fi - - echo "Deploy on **prod** cluster..." - echo "Deploy id: **${FULL_DEPLOY_ID}** ..." - if [ ! -z "$FULL_DEPLOY_ID2" ] - then - echo "Secondary deploy id: **$FULL_DEPLOY_ID2** ..." - fi - else - echo "OKD_URL=https://okd-dev.hbp.eu:443" >> $GITHUB_ENV - echo "OKD_SECRET=${{ secrets.OKD_DEV_SECRET }}" >> $GITHUB_ENV - echo "OKD_PROJECT=interactive-atlas-viewer" >> $GITHUB_ENV - echo "ROUTE_HOST=siibra-explorer.apps-dev.hbp.eu" >> $GITHUB_ENV - echo "BUILD_TEXT=$BRANCH_NAME" >> $GITHUB_ENV - FULL_DEPLOY_ID=${{ env.OC_TEMPLATE_NAME }}-$DEPLOY_ID - echo "FULL_DEPLOY_ID=$FULL_DEPLOY_ID" >> $GITHUB_ENV - echo "Deploy on **dev** cluster ..." - echo "Deploy id: **${FULL_DEPLOY_ID}** ..." - fi - - name: 'Login via oc cli & deploy' - run: | - oc login $OKD_URL --token=$OKD_SECRET - oc project $OKD_PROJECT - - ROUTE_PATH=/$DEPLOY_ID - echo "ROUTE_PATH=$ROUTE_PATH" >> $GITHUB_ENV - - echo "Working branch name: $BRANCH_NAME, deploy_id: $DEPLOY_ID" - echo "full deploy id: $FULL_DEPLOY_ID, secondary deploy id: $FULL_DEPLOY_ID2" - - # check if the deploy already exist - - if oc get dc $FULL_DEPLOY_ID; then - # trigger redeploy if deployconfig exists already - echo "dc $FULL_DEPLOY_ID already exist, redeploy..." - oc rollout latest dc/$FULL_DEPLOY_ID - else - # create new app if deployconfig does not yet exist - echo "dc $FULL_DEPLOY_ID does not yet exist, create new app..." - oc new-app --template ${{ env.OC_TEMPLATE_NAME }} \ - -p BRANCH_NAME=$BRANCH_NAME \ - -p DEPLOY_ID=$DEPLOY_ID \ - -p ROUTE_HOST=$ROUTE_HOST \ - -p ROUTE_PATH=$ROUTE_PATH \ - -p BUILD_TEXT=$BUILD_TEXT - fi - - if [ ! -z "$FULL_DEPLOY_ID2" ] - then - echo "FULL_DEPLOY_ID2 is defined, trying to redeploy $FULL_DEPLOY_ID2 ..." - oc rollout latest dc/$FULL_DEPLOY_ID2 - fi - - - name: 'Update status badge' - if: success() - run: | - - DEPLOY_URL=https://$ROUTE_HOST$ROUTE_PATH - curl -v \ - -X POST \ - -H "authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ - -H 'accept: application/vnd.github.v3+json' \ - ${GITHUB_API_ROOT}/statuses/${GITHUB_SHA} \ - -d '{ - "target_url":"'$DEPLOY_URL'", - "name": "Deployed at OKD", - "description": "Deployed at OKD", - "context": "[ebrains-okd-deploy] Deployed at OKD", - "state": "success" - }' + echo "DEPLOY_ID=$DEPLOY_ID" >> $GITHUB_OUTPUT + + trigger-deploy-master-prod: + if: ${{ needs.setting-vars.outputs.BRANCH_NAME == 'master' && success() }} + needs: + - build-docker-img + - setting-vars + uses: ./.github/workflows/deploy-on-okd.yml + with: + FULL_DEPLOY_ID: siibra-explorer-branch-deploy-2-prodpathviewer + OKD_ENDPOINT: https://okd.hbp.eu:443 + OKD_PROJECT: interactive-viewer + secrets: + okd_token: ${{ secrets.OKD_PROD_SECRET }} + + trigger-deploy-master-legacy: + if: ${{ needs.setting-vars.outputs.BRANCH_NAME == 'master' && success() }} + needs: + - build-docker-img + - setting-vars + uses: ./.github/workflows/deploy-on-okd.yml + with: + FULL_DEPLOY_ID: siibra-explorer-branch-deploy-2-iav-legacy + OKD_ENDPOINT: https://okd.hbp.eu:443 + OKD_PROJECT: interactive-viewer + secrets: + okd_token: ${{ secrets.OKD_PROD_SECRET }} + + trigger-deploy-staging-viewer-validation: + if: ${{ needs.setting-vars.outputs.BRANCH_NAME == 'staging' && success() }} + needs: + - build-docker-img + - setting-vars + uses: ./.github/workflows/deploy-on-okd.yml + with: + FULL_DEPLOY_ID: siibra-explorer-branch-deploy-2-stagingpathed + OKD_ENDPOINT: https://okd.hbp.eu:443 + OKD_PROJECT: interactive-viewer + secrets: + okd_token: ${{ secrets.OKD_PROD_SECRET }} + + trigger-deploy-staging-data-validation: + if: ${{ needs.setting-vars.outputs.BRANCH_NAME == 'staging' && success() }} + needs: + - build-docker-img + - setting-vars + uses: ./.github/workflows/deploy-on-okd.yml + with: + FULL_DEPLOY_ID: siibra-explorer-rc + OKD_ENDPOINT: https://okd.jsc.hbp.eu:443 + OKD_PROJECT: siibra-explorer + secrets: + okd_token: ${{ secrets.OKD_JSC_TOKEN }} + + trigger-deploy-other-viewer: + # n.b. "env" context not available in "if" block + if: ${{ needs.setting-vars.outputs.BRANCH_NAME != 'staging' && needs.setting-vars.outputs.BRANCH_NAME != 'master' && success() }} + uses: ./.github/workflows/deploy-on-okd.yml + needs: + - build-docker-img + - setting-vars + with: + FULL_DEPLOY_ID: siibra-explorer-branch-deploy-2-${{ needs.setting-vars.outputs.DEPLOY_ID }} + OKD_ENDPOINT: https://okd-dev.hbp.eu:443 + OKD_PROJECT: interactive-atlas-viewer + BRANCH_NAME: ${{ needs.setting-vars.outputs.BRANCH_NAME }} + DEPLOY_ID: ${{ needs.setting-vars.outputs.DEPLOY_ID }} + ROUTE_HOST: siibra-explorer.apps-dev.hbp.eu + ROUTE_PATH: /${{ needs.setting-vars.outputs.DEPLOY_ID }} + BUILD_TEXT: ${{ needs.setting-vars.outputs.BRANCH_NAME }} + secrets: + okd_token: ${{ secrets.OKD_DEV_SECRET }} + + trigger-deploy-other-badge: + if: ${{ needs.setting-vars.outputs.BRANCH_NAME != 'staging' && needs.setting-vars.outputs.BRANCH_NAME != 'master' && success() }} + runs-on: ubuntu-latest + env: + DEPLOY_URL: https://siibra-explorer.apps-dev.hbp.eu/${{ needs.setting-vars.outputs.DEPLOY_ID }} + GITHUB_API_ROOT: https://api.github.com/repos/fzj-inm1-bda/siibra-explorer + needs: + - trigger-deploy-other-viewer + - setting-vars + steps: + - name: "Update Badge" + run: | + curl -v \ + -X POST \ + -H "authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + -H 'accept: application/vnd.github.v3+json' \ + ${GITHUB_API_ROOT}/statuses/${GITHUB_SHA} \ + -d '{ + "target_url":"${{ env.DEPLOY_URL }}", + "name": "Deployed at OKD", + "description": "Deployed at OKD", + "context": "[ebrains-okd-deploy] Deployed at OKD", + "state": "success" + }' diff --git a/angular.json b/angular.json index dcbadb4911f1203a6204541cc20e00b2ec00947b..46d2ba12824d187f38aab94428da75566e9fccee 100644 --- a/angular.json +++ b/angular.json @@ -34,7 +34,13 @@ "styles": [ "src/theme.scss", "src/overwrite.scss", - "src/extra_styles.css" + "src/extra_styles.css", + + { + "input": "export-nehuba/dist/min/main.css", + "inject": false, + "bundleName": "vanillaMain" + } ], "scripts": [{ "input": "worker/worker.js", diff --git a/common/constants.js b/common/constants.js index d3fe2b2b308520de3e5e9142d58b681c9dbc2a5a..d3f6f24546090062459459f82f8652db30691244 100644 --- a/common/constants.js +++ b/common/constants.js @@ -148,6 +148,9 @@ If you do not accept the Terms & Conditions you are not permitted to access or u REMOVE_FRONTAL_OCTANT_HELPER_TEXT: `Hide the octant facing the user, and overlaying the slice views.`, AUXMESH_DESC: `Some templates contain auxiliary meshes, which compliment the appearance of the template in the perspective view.`, + + OVERWRITE_SAPI_ENDPOINT_ATTR: `x-sapi-base-url`, + DATA_ERROR_ATTR: `data-error` } exports.QUICKTOUR_DESC ={ diff --git a/common/helpOnePager.md b/common/helpOnePager.md index 8d621680ddc456700f1bf8ad9bb57fd08c4d8e2e..2f66d4b38b167b2f9ac0d93a93f0ca347da43c3d 100644 --- a/common/helpOnePager.md +++ b/common/helpOnePager.md @@ -5,7 +5,7 @@ | Zoom | `[mousewheel]` | `[pinch zoom]` | | Zoom | `[hover]` on any slice views > `[click]` magnifier | `[tap]` on magnifier | | Next slice | `<ctrl>` + `[mousewheel]` | - | -| Next 10 slice | `<ctrl>` + `<shift>` + `[mousewheel]` | - | +| Next 10 slice | `<shift>` + `[mousewheel]` | - | | Toggle delineation | `[q]` | - | | Toggle cross hair | `[a]` | - | | Multiple region select | `<ctrl>` + `[click]` on region | - | diff --git a/deploy/app.js b/deploy/app.js index f4b3dcd07ead1a7cad8f3c987db8619c1d16e219..bc3444df3848e83ef16d9e9a8e9e2d3391cbbb58 100644 --- a/deploy/app.js +++ b/deploy/app.js @@ -1,4 +1,3 @@ -const fs = require('fs') const path = require('path') const express = require('express') const app = express.Router() @@ -6,8 +5,7 @@ const session = require('express-session') const crypto = require('crypto') const cookieParser = require('cookie-parser') const bkwdMdl = require('./bkwdCompat')() - -const LOCAL_CDN_FLAG = !!process.env.LOCAL_CDN +const { CONST } = require("../common/constants") if (process.env.NODE_ENV !== 'production') { app.use(require('cors')()) @@ -123,36 +121,6 @@ const PUBLIC_PATH = process.env.NODE_ENV === 'production' */ app.use('/.well-known', express.static(path.join(__dirname, 'well-known'))) -if (LOCAL_CDN_FLAG) { - /* - * TODO setup local cdn for supported libraries map - */ - const LOCAL_CDN = process.env.LOCAL_CDN - const CDN_ARRAY = [ - 'https://stackpath.bootstrapcdn.com', - 'https://use.fontawesome.com', - 'https://unpkg.com' - ] - - let indexFile - fs.readFile(path.join(PUBLIC_PATH, 'index.html'), 'utf-8', (err, data) => { - if (err) throw err - if (!LOCAL_CDN) { - indexFile = data - return - } - const regexString = CDN_ARRAY.join('|').replace(/\/|\./g, s => `\\${s}`) - const regex = new RegExp(regexString, 'gm') - indexFile = data.replace(regex, LOCAL_CDN) - }) - - app.get('/', bkwdMdl, (_req, res) => { - if (!indexFile) return res.status(404).end() - res.setHeader('Content-Type', 'text/html; charset=utf-8') - return res.status(200).send(indexFile) - }) -} - app.use((_req, res, next) => { res.setHeader('Referrer-Policy', 'origin-when-cross-origin') next() @@ -182,23 +150,42 @@ app.get('/', (req, res, next) => { middelware(req, res, next) } -}, bkwdMdl, cookieParser(), (req, res) => { +}, bkwdMdl, cookieParser(), async (req, res) => { + res.setHeader('Content-Type', 'text/html') + + let returnIndex = indexTemplate + + if (!!process.env.LOCAL_CDN) { + const CDN_ARRAY = [ + 'https://stackpath.bootstrapcdn.com', + 'https://use.fontawesome.com', + 'https://unpkg.com' + ] + + const regexString = CDN_ARRAY.join('|').replace(/\/|\./g, s => `\\${s}`) + const regex = new RegExp(regexString, 'gm') + returnIndex = returnIndex.replace(regex, process.env.LOCAL_CDN) + } const iavError = req.cookies && req.cookies['iav-error'] - res.setHeader('Content-Type', 'text/html') + const attributeToAppend = {} if (iavError) { res.clearCookie('iav-error', { httpOnly: true, sameSite: 'strict' }) + attributeToAppend[CONST.DATA_ERROR_ATTR] = iavError + } - const returnTemplate = indexTemplate - .replace(/\$\$NONCE\$\$/g, res.locals.nonce) - .replace('<atlas-viewer>', `<atlas-viewer data-error="${iavError.replace(/"/g, '"')}">`) - res.status(200).send(returnTemplate) - } else { - const returnTemplate = indexTemplate - .replace(/\$\$NONCE\$\$/g, res.locals.nonce) - res.status(200).send(returnTemplate) + if (!!process.env.OVERWRITE_API_ENDPOING) { + attributeToAppend[CONST.OVERWRITE_SAPI_ENDPOINT_ATTR] = process.env.OVERWRITE_API_ENDPOING } + + const attr = Object.entries(attributeToAppend).map(([key, value]) => `${key}="${value.replace(/"/g, '"')}"`).join(" ") + + const returnTemplate = returnIndex + .replace(/\$\$NONCE\$\$/g, res.locals.nonce) + .replace('<atlas-viewer>', `<atlas-viewer ${attr}>`) + + res.status(200).send(returnTemplate) }) app.get('/ready', async (req, res) => { diff --git a/deploy/csp/index.js b/deploy/csp/index.js index d73ba0b2a53fe4b250bb77fdde08838b8f01f4da..a875bb535275a3c294a8ee377d28eb7761624181 100644 --- a/deploy/csp/index.js +++ b/deploy/csp/index.js @@ -116,7 +116,7 @@ module.exports = { "https://cdn.plot.ly/", // required for plotly 'https://unpkg.com/mathjax@3.1.2/', // math jax 'https://unpkg.com/three-surfer@0.0.13/dist/bundle.js', // for threeSurfer (freesurfer support in browser) - 'https://unpkg.com/ng-layer-tune@0.0.14/dist/ng-layer-tune/', // needed for ng layer control + 'https://unpkg.com/ng-layer-tune@0.0.21/dist/ng-layer-tune/', // needed for ng layer control 'https://unpkg.com/hbp-connectivity-component@0.6.6/', // needed for connectivity component (req, res) => res.locals.nonce ? `'nonce-${res.locals.nonce}'` : null, ...SCRIPT_SRC, diff --git a/docs/releases/v2.12.0.md b/docs/releases/v2.12.0.md index ba23e2fa1c0a5e1dc3cdb00fdfef918654e02bf0..99ad15e06c399f56481ee50d135ecb889ae3e1ec 100644 --- a/docs/releases/v2.12.0.md +++ b/docs/releases/v2.12.0.md @@ -13,4 +13,5 @@ ## Behind the scene - update spotlight mechanics from in-house to angular CDK -- Updated neuroglancer/nehuba dependency. This allows volumes with non-rigid affine to be displayed properly. +- updated neuroglancer/nehuba dependency. This allows volumes with non-rigid affine to be displayed properly. +- allow siibra-api endpoint to be configured at runtime diff --git a/docs/releases/v2.12.1.md b/docs/releases/v2.12.1.md new file mode 100644 index 0000000000000000000000000000000000000000..0d9308e1ec98b17cd2e785314cb3b25259b01323 --- /dev/null +++ b/docs/releases/v2.12.1.md @@ -0,0 +1,10 @@ +# v2.12.1 + +# Feature + +- added the option to use red/green/blue colormaps (#1366) + +## Bugfix + +- fixed touch devices unable to navigate viewer +- fixed some volume controls not working diff --git a/docs/releases/v2.12.2.md b/docs/releases/v2.12.2.md new file mode 100644 index 0000000000000000000000000000000000000000..e232505af83b9698261e761af4e89fdccc42f7cc --- /dev/null +++ b/docs/releases/v2.12.2.md @@ -0,0 +1,8 @@ +# v2.12.2 + +## Bugfixes + +- fixes screenshot in fsaverage +- on hover region label in fsaverage now display properly +- fixes annotation mode (export annotations, annotations fail to render in viewer on startup (via shared link, local storage etc)) +- fixes an issue where region definition in full assignment table can also be clicked to select the region diff --git a/docs/releases/v2.12.3.md b/docs/releases/v2.12.3.md new file mode 100644 index 0000000000000000000000000000000000000000..75da7002c5e96e121272ae3c172cbe3b20acb089 --- /dev/null +++ b/docs/releases/v2.12.3.md @@ -0,0 +1,8 @@ +# v2.12.3 + +## Bugfix + +- Visually distinguish regions that are mapped in a space to those that are not +- Remove experimental flag to VOI +- siibra-explorer now displays description and doi for more regions correctly (Julich Brain 3.0 in all MRI spaces and Julich Brain detailed maps in Big Brain spaces) +- Do not display connectivity in big brain space diff --git a/docs/releases/v2.12.4.md b/docs/releases/v2.12.4.md new file mode 100644 index 0000000000000000000000000000000000000000..e821048bc11f2f00a19d065786ec7b15c4c8ed46 --- /dev/null +++ b/docs/releases/v2.12.4.md @@ -0,0 +1,7 @@ +# v2.12.4 + +## Bugfix + +- minor fix of ng-layer-tune incorrectly applying color map (#1390) +- prepare for MEBRAINS update +- fixed traverse in z direction (`<ctrl>` + `[wheel]`/`<shift>` + `[wheel]`) (#1334) diff --git a/docs/releases/v2.12.5.md b/docs/releases/v2.12.5.md new file mode 100644 index 0000000000000000000000000000000000000000..99593eb2a15a269a0dee8d845eceb735659b7c8b --- /dev/null +++ b/docs/releases/v2.12.5.md @@ -0,0 +1,6 @@ +# v2.12.5 + +## Feature + +- enable connectivity for Julich Brain v3 +- added version inspector in UI diff --git a/mkdocs.yml b/mkdocs.yml index c15fb63424945490e6039aa192a7a3a00489d36a..3ff6048f486849482bca9b326b24a851d34e7921 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -33,6 +33,12 @@ nav: - Fetching datasets: 'advanced/datasets.md' - Display non-atlas volumes: 'advanced/otherVolumes.md' - Release notes: + - v2.12.5: 'releases/v2.12.5.md' + - v2.12.4: 'releases/v2.12.4.md' + - v2.12.3: 'releases/v2.12.3.md' + - v2.12.2: 'releases/v2.12.2.md' + - v2.12.1: 'releases/v2.12.1.md' + - v2.12.0: 'releases/v2.12.0.md' - v2.11.4: 'releases/v2.11.4.md' - v2.11.3: 'releases/v2.11.3.md' - v2.11.2: 'releases/v2.11.2.md' diff --git a/package-lock.json b/package-lock.json index c6e536b6eb6532373f96bd0d1c06c34e690ec417..988893d143b1661bf6bca7685e244e46e87c2898 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "siibra-explorer", - "version": "2.12.0", + "version": "2.12.1", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/src/atlasComponents/annotations/annotation.service.ts b/src/atlasComponents/annotations/annotation.service.ts index f0a63f7aa298c0cad46efcd96a1dfbe452f1098b..d133671c3682ccf16c5af36271a12b90fbb4cf73 100644 --- a/src/atlasComponents/annotations/annotation.service.ts +++ b/src/atlasComponents/annotations/annotation.service.ts @@ -1,6 +1,7 @@ import { BehaviorSubject, Observable } from "rxjs"; import { distinctUntilChanged } from "rxjs/operators"; -import { getUuid } from "src/util/fn"; +import { getUuid, waitFor } from "src/util/fn"; +import { PeriodicSvc } from "src/util/periodic.service"; export type TNgAnnotationEv = { pickedAnnotationId: string @@ -38,6 +39,7 @@ type _AnnotationSpec = Omit<AnnotationSpec, 'type'> & { type: number } type AnnotationRef = Record<string, unknown> interface NgAnnotationLayer { + isReady: () => boolean layer: { localAnnotations: { references: { @@ -124,19 +126,26 @@ export class AnnotationLayer { } } - addAnnotation(spec: AnnotationSpec){ + async addAnnotation(spec: AnnotationSpec){ if (!this.nglayer) { throw new Error(`layer has already been disposed`) } - const localAnnotations = this.nglayer.layer.localAnnotations - this.idset.add(spec.id) - const annSpec = this.parseNgSpecType(spec) - localAnnotations.add( - annSpec - ) + + PeriodicSvc.AddToQueue(() => { + if (this.nglayer.isReady()) { + const localAnnotations = this.nglayer.layer.localAnnotations + this.idset.add(spec.id) + const annSpec = this.parseNgSpecType(spec) + localAnnotations.add( + annSpec + ) + return true + } + return false + }) } - removeAnnotation(spec: { id: string }) { - if (!this.nglayer) return + async removeAnnotation(spec: { id: string }) { + await waitFor(() => !!this.nglayer?.layer?.localAnnotations) const { localAnnotations } = this.nglayer.layer this.idset.delete(spec.id) const ref = localAnnotations.references.get(spec.id) @@ -145,9 +154,9 @@ export class AnnotationLayer { localAnnotations.references.delete(spec.id) } } - updateAnnotation(spec: AnnotationSpec) { - const localAnnotations = this.nglayer?.layer?.localAnnotations - if (!localAnnotations) return + async updateAnnotation(spec: AnnotationSpec) { + await waitFor(() => !!this.nglayer?.layer?.localAnnotations) + const { localAnnotations } = this.nglayer.layer 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 da2294c69e9db1351e9c7f2d8606e9879abb7e99..d529b9a77f1f9c1e4ab11e293df6ab0926b88a09 100644 --- a/src/atlasComponents/sapi/constants.ts +++ b/src/atlasComponents/sapi/constants.ts @@ -16,5 +16,6 @@ export const IDS = { JBA30: "minds/core/parcellationatlas/v1.0.0/94c1125b-b87e-45e4-901c-00daee7f2579-300", WAXHOLMV4: "minds/core/parcellationatlas/v1.0.0/ebb923ba-b4d5-4b82-8088-fa9215c2e1fe-v4", CORTICAL_LAYERS: "juelich/iav/atlas/v1.0.0/3", + MEBRAINS: "minds/core/parcellationatlas/v1.0.0/e3235c039c6f54c3ba151568c829f117", } } diff --git a/src/atlasComponents/sapi/sapi.service.ts b/src/atlasComponents/sapi/sapi.service.ts index 6fa6d40f7f8b2c1793bf9b006ef68b1434da6ce4..c372ac1a825a6db13e4e2ec69fec1f1b29d7dfdd 100644 --- a/src/atlasComponents/sapi/sapi.service.ts +++ b/src/atlasComponents/sapi/sapi.service.ts @@ -13,6 +13,7 @@ import { import { FeatureType, PathReturn, RouteParam, SapiRoute } from "./typeV3"; import { BoundingBox, SxplrAtlas, SxplrParcellation, SxplrRegion, SxplrTemplate, VoiFeature, Feature } from "./sxplrTypes"; import { parcBanList, speciesOrder } from "src/util/constants"; +import { CONST } from "common/constants" export const useViewer = { THREESURFER: "THREESURFER", @@ -21,7 +22,7 @@ export const useViewer = { } as const export const SIIBRA_API_VERSION_HEADER_KEY='x-siibra-api-version' -export const EXPECTED_SIIBRA_API_VERSION = '0.3.8' +export const EXPECTED_SIIBRA_API_VERSION = '0.3.12' let BS_ENDPOINT_CACHED_VALUE: Observable<string> = null @@ -94,7 +95,12 @@ export class SAPI{ */ static get BsEndpoint$(): Observable<string> { if (!!BS_ENDPOINT_CACHED_VALUE) return BS_ENDPOINT_CACHED_VALUE - const endpoints = environment.SIIBRA_API_ENDPOINTS.split(',') + const rootEl = document.querySelector('atlas-viewer') + const overwriteSapiUrl = rootEl?.getAttribute(CONST.OVERWRITE_SAPI_ENDPOINT_ATTR) + + const endpoints = overwriteSapiUrl + ? [ overwriteSapiUrl ] + : environment.SIIBRA_API_ENDPOINTS.split(',') if (endpoints.length === 0) { SAPI.ErrorMessage = `No siibra-api endpoint defined!` return NEVER @@ -464,7 +470,7 @@ export class SAPI{ ) } - private async getLabelledMap(parcellation: SxplrParcellation, template: SxplrTemplate) { + async getLabelledMap(parcellation: SxplrParcellation, template: SxplrTemplate) { // No need to retrieve sapi object, since we know @id maps to id return await this.v3Get("/map", { query: { diff --git a/src/atlasComponents/sapi/translateV3.ts b/src/atlasComponents/sapi/translateV3.ts index 93a04c90a90aa18c049acaa177e32b0d83102d91..098aa6b4e26c07591a64f36d03e047f15d7b1712 100644 --- a/src/atlasComponents/sapi/translateV3.ts +++ b/src/atlasComponents/sapi/translateV3.ts @@ -157,7 +157,8 @@ class TranslateV3 { const { resolution, size } = _info.scales[0] const info = { voxel: size as [number, number, number], - real: [0, 1, 2].map(idx => resolution[idx] * size[idx]) as [number, number, number] + real: [0, 1, 2].map(idx => resolution[idx] * size[idx]) as [number, number, number], + resolution: resolution as [number, number, number] } returnObj.push({ source: `precomputed://${url}`, @@ -443,7 +444,7 @@ class TranslateV3 { this.#translatePoint(feat.boundingbox.center), this.#translatePoint(feat.boundingbox.maxpoint), this.#translatePoint(feat.boundingbox.minpoint), - await this.#extractNgPrecompUnfrag(feat.volume.providedVolumes), + this.#extractNgPrecompUnfrag(feat.volume.providedVolumes), ]) const { ['@id']: spaceId } = feat.boundingbox.space const getSpace = (id: string) => this.#sxplrTmplMap.get(id) diff --git a/src/atlasComponents/sapiViews/core/region/module.ts b/src/atlasComponents/sapiViews/core/region/module.ts index ff07e4b2565b1a0270d8e1b635db8f3b9995d155..908761ba5a664ab5a27f1ebeac38c53cf549b829 100644 --- a/src/atlasComponents/sapiViews/core/region/module.ts +++ b/src/atlasComponents/sapiViews/core/region/module.ts @@ -15,6 +15,7 @@ import { ExperimentalModule } from "src/experimental/experimental.module"; import { MatListModule } from "@angular/material/list"; import { DialogModule } from "src/ui/dialogInfo"; import { SapiViewsCoreParcellationModule } from "../parcellation"; +import { MatTooltipModule } from "@angular/material/tooltip"; @NgModule({ imports: [ @@ -31,6 +32,7 @@ import { SapiViewsCoreParcellationModule } from "../parcellation"; MatListModule, DialogModule, SapiViewsCoreParcellationModule, + MatTooltipModule, ], declarations: [ SapiViewsCoreRegionRegionListItem, diff --git a/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.component.ts b/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.component.ts index c61d33b9d2c25cd406383ed85cc437f6364748ca..d774b1e05f61c2ebe3a4f676d6464417626b4cdd 100644 --- a/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.component.ts +++ b/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.component.ts @@ -6,7 +6,7 @@ import { ARIA_LABELS, CONST } from 'common/constants' import { Feature } from "src/atlasComponents/sapi/sxplrTypes"; import { SAPI } from "src/atlasComponents/sapi/sapi.service"; import { environment } from "src/environments/environment"; -import { map, shareReplay, switchMap } from "rxjs/operators"; +import { catchError, map, shareReplay, switchMap } from "rxjs/operators"; import { PathReturn } from "src/atlasComponents/sapi/typeV3"; @Component({ @@ -41,7 +41,7 @@ export class SapiViewsCoreRegionRegionRich extends SapiViewsCoreRegionRegionBase activePanelTitles$: Observable<string[]> = new Subject() - private regionalStatisticalMaps$ = this.ATPR$.pipe( + private regionalMaps$ = this.ATPR$.pipe( switchMap(({ parcellation, template, region }) => concat( of([] as PathReturn<"/map">["volumes"]), @@ -49,14 +49,26 @@ export class SapiViewsCoreRegionRegionRich extends SapiViewsCoreRegionRegionBase map(v => { const mapIndices = v.indices[region.name] return mapIndices.map(mapIdx => v.volumes[mapIdx.volume]) + }), + catchError((_err, _obs) => { + /** + * if statistical map somehow fails to fetch (e.g. does not exist for this combination + * of parc tmpl), fallback to labelled map + */ + return this.sapi.getMap(parcellation.id, template.id, "LABELLED").pipe( + map(v => { + const mapIndices = v.indices[region.name] + return mapIndices.map(mapIdx => v.volumes[mapIdx.volume]) + }) + ) }) - ) + ), ) ), shareReplay(1) ) - public dois$ = this.regionalStatisticalMaps$.pipe( + public dois$ = this.regionalMaps$.pipe( map(sms => { const returnUrls: string[] = [] for (const sm of sms) { @@ -71,7 +83,7 @@ export class SapiViewsCoreRegionRegionRich extends SapiViewsCoreRegionRegionBase }) ) - public desc$ = this.regionalStatisticalMaps$.pipe( + public desc$ = this.regionalMaps$.pipe( map(sm => { for (const ds of (sm?.[0]?.datasets) || []) { if (ds.description) { diff --git a/src/atlasComponents/sapiViews/core/rich/regionsHierarchy/regionsHierarchy.component.ts b/src/atlasComponents/sapiViews/core/rich/regionsHierarchy/regionsHierarchy.component.ts index ac272bc82f976687831e70f358104f302bb52c8a..0c85164a964950a7dec142e1fb047ac1e1aa9106 100644 --- a/src/atlasComponents/sapiViews/core/rich/regionsHierarchy/regionsHierarchy.component.ts +++ b/src/atlasComponents/sapiViews/core/rich/regionsHierarchy/regionsHierarchy.component.ts @@ -20,6 +20,7 @@ const filterByRegexPipe = new FilterByRegexPipe() }) export class SapiViewsCoreRichRegionsHierarchy { + TXT_CANNOT_BE_SELECTED = "Not mapped in this template space." static IsParent(region: SxplrRegion, parentRegion: SxplrRegion): boolean { return region.parentIds.some(id => parentRegion.id === id) @@ -36,6 +37,9 @@ export class SapiViewsCoreRichRegionsHierarchy { ) } + @Input('sxplr-sapiviews-core-rich-regionshierarchy-label-mapped-region-names') + labelMappedRegionNames: string[] = [] + @Input('sxplr-sapiviews-core-rich-regionshierarchy-accent-regions') accentedRegions: SxplrRegion[] = [] @@ -100,10 +104,9 @@ export class SapiViewsCoreRichRegionsHierarchy { onNodeClick({node: roi, event }: {node: SxplrRegion, event: MouseEvent}){ /** - * only allow leave nodes to be selectable for now + * Only allow the regions that are labelled mapped to be selected. */ - const children = this._regions.filter(r => this.isParent(r, roi)) - if (children.length > 0) { + if (!this.labelMappedRegionNames.includes(roi.name)) { return } if (event.ctrlKey) { diff --git a/src/atlasComponents/sapiViews/core/rich/regionsHierarchy/regionsHierarchy.template.html b/src/atlasComponents/sapiViews/core/rich/regionsHierarchy/regionsHierarchy.template.html index 762ae45d856a8a5e53defdfbeea120c2437df898..fe58b7efcf598036e2fbae302f54720a9d0369d0 100644 --- a/src/atlasComponents/sapiViews/core/rich/regionsHierarchy/regionsHierarchy.template.html +++ b/src/atlasComponents/sapiViews/core/rich/regionsHierarchy/regionsHierarchy.template.html @@ -14,10 +14,12 @@ </mat-form-field> -<ng-template #tmplRef let-region> +<ng-template #tmplRef let-region let-last="last"> <div class="mat-body sxplr-d-flex sxplr-align-items-center sxplr-h-100 region-tmpl" + [matTooltip]="last && !labelMappedRegionNames.includes(region.name) ? TXT_CANNOT_BE_SELECTED : null" [ngClass]="{ - 'sxplr-custom-cmp accent': accentedRegions | includes : region + 'sxplr-custom-cmp accent': accentedRegions | includes : region, + 'muted-7': !labelMappedRegionNames.includes(region.name) }" [innerHTML]="region.name | hightlightPipe : searchTerm"> </div> diff --git a/src/atlasComponents/sapiViews/core/rich/regionsListSearch/regionListSearch.component.ts b/src/atlasComponents/sapiViews/core/rich/regionsListSearch/regionListSearch.component.ts index e55fb7e12f574713a7a9c8aab1de3c6cf62e7367..389486f779a8c750600664bfcdb27e8f4dcb31e2 100644 --- a/src/atlasComponents/sapiViews/core/rich/regionsListSearch/regionListSearch.component.ts +++ b/src/atlasComponents/sapiViews/core/rich/regionsListSearch/regionListSearch.component.ts @@ -5,6 +5,7 @@ import { UntypedFormControl } from "@angular/forms"; import { debounceTime, distinctUntilChanged, map, startWith } from "rxjs/operators"; import { MatAutocompleteSelectedEvent } from "@angular/material/autocomplete"; import { SapiViewsCoreRichRegionListTemplateDirective } from "./regionListSearchTmpl.directive"; +import { BehaviorSubject, combineLatest } from "rxjs"; const filterRegionViaSearch = (searchTerm: string) => (region:SxplrRegion) => { return region.name.toLocaleLowerCase().includes(searchTerm.toLocaleLowerCase()) @@ -25,13 +26,16 @@ export class SapiViewsCoreRichRegionListSearch { showNOptions = 4 - private _regions: SxplrRegion[] = [] - get regions(){ - return this._regions - } + #regions = new BehaviorSubject<SxplrRegion[]>([]) @Input('sxplr-sapiviews-core-rich-regionlistsearch-regions') set regions(reg: SxplrRegion[]) { - this._regions = reg.filter(r => !reg.some(c => c.parentIds.includes(r.id))) + this.#regions.next(reg) + } + + #mappedRegionNames = new BehaviorSubject<string[]>([]) + @Input('sxplr-sapiviews-core-rich-regionlistsearch-mapped-region-names') + set mappedRegions(regNames: string[]) { + this.#mappedRegionNames.next(regNames) } @ContentChild(SapiViewsCoreRichRegionListTemplateDirective) @@ -51,13 +55,18 @@ export class SapiViewsCoreRichRegionListSearch { public searchFormControl = new UntypedFormControl() - public searchedList$ = this.searchFormControl.valueChanges.pipe( - startWith(''), - distinctUntilChanged(), - debounceTime(160), - map((searchTerm: string | SxplrRegion) => { + public searchedList$ = combineLatest([ + this.searchFormControl.valueChanges.pipe( + startWith(''), + distinctUntilChanged(), + debounceTime(160), + ), + this.#regions, + this.#mappedRegionNames + ]).pipe( + map(([searchTerm, regions, mappedRegionNames]) => { if (typeof searchTerm === "string") { - return this.regions.filter(filterRegionViaSearch(searchTerm)) + return regions.filter(r => mappedRegionNames.includes(r.name)).filter(filterRegionViaSearch(searchTerm)) } return [] }) diff --git a/src/atlasComponents/sapiViews/volumes/point-assignment/point-assignment.component.html b/src/atlasComponents/sapiViews/volumes/point-assignment/point-assignment.component.html index 59694f74a9f84f067a93a56eb0f0d9988895d8e1..f031edbf5a99879f0041c02a545f234d0b4cd71a 100644 --- a/src/atlasComponents/sapiViews/volumes/point-assignment/point-assignment.component.html +++ b/src/atlasComponents/sapiViews/volumes/point-assignment/point-assignment.component.html @@ -58,15 +58,31 @@ #comphTableSort="matSort" matSortActive="map value" matSortDirection="desc"> - <ng-container *ngFor="let column of columns$ | async" - [matColumnDef]="column"> + + <ng-container matColumnDef="region"> <th mat-header-cell *matHeaderCellDef mat-sort-header> - {{ column }} + region </th> <td mat-cell *matCellDef="let element"> - {{ element[column] | prettyPresent }} + <button mat-button (click)="selectRegion(element['region'], $event)"> + {{ element['region'].name }} + </button> </td> </ng-container> + + <ng-template ngFor [ngForOf]="columns$ | async" let-column> + <ng-template [ngIf]="column !== 'region'"> + <ng-container [matColumnDef]="column"> + <th mat-header-cell *matHeaderCellDef mat-sort-header> + {{ column }} + </th> + <td mat-cell *matCellDef="let element"> + {{ element[column] | prettyPresent }} + </td> + </ng-container> + </ng-template> + </ng-template> + <tr mat-header-row *matHeaderRowDef="columns$ | async"></tr> <tr mat-row *matRowDef="let row; columns: columns$ | async;"></tr> diff --git a/src/atlasComponents/sapiViews/volumes/point-assignment/point-assignment.component.ts b/src/atlasComponents/sapiViews/volumes/point-assignment/point-assignment.component.ts index 64b0ebdb1a71bdbf9baf0d0d4c687df6afa71cae..00ee69f552743fe8cd9a771dbfb44e6cd00ced89 100644 --- a/src/atlasComponents/sapiViews/volumes/point-assignment/point-assignment.component.ts +++ b/src/atlasComponents/sapiViews/volumes/point-assignment/point-assignment.component.ts @@ -1,5 +1,5 @@ import { Component, Input, OnDestroy, Output, TemplateRef, EventEmitter } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { BehaviorSubject, EMPTY, Observable, Subscription, combineLatest, concat, of } from 'rxjs'; import { catchError, map, shareReplay, switchMap, tap } from 'rxjs/operators'; import { SAPI, EXPECTED_SIIBRA_API_VERSION } from 'src/atlasComponents/sapi/sapi.service'; @@ -114,8 +114,12 @@ export class PointAssignmentComponent implements OnDestroy { constructor(private sapi: SAPI, private dialog: MatDialog) {} + #dialogRef: MatDialogRef<unknown> openDialog(tmpl: TemplateRef<unknown>){ - this.dialog.open(tmpl) + this.#dialogRef = this.dialog.open(tmpl) + this.#dialogRef.afterClosed().subscribe(() => { + this.#dialogRef = null + }) } #sub: Subscription[] = [] @@ -125,6 +129,9 @@ export class PointAssignmentComponent implements OnDestroy { async selectRegion(region: PathReturn<"/regions/{region_id}">, event: MouseEvent){ const sxplrReg = await translateV3Entities.translateRegion(region) this.clickOnRegion.emit({ target: sxplrReg, event }) + if (this.#dialogRef) { + this.#dialogRef.close() + } } zipfileConfig$: Observable<TZipFileConfig[]> = combineLatest([ diff --git a/src/atlasComponents/userAnnotations/annotationList/annotationList.component.spec.ts b/src/atlasComponents/userAnnotations/annotationList/annotationList.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..23662fcfdf9d305ec6daa00710944996af46b7fd --- /dev/null +++ b/src/atlasComponents/userAnnotations/annotationList/annotationList.component.spec.ts @@ -0,0 +1,117 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing" +import { AnnotationList } from "./annotationList.component" +import { FileInputModule } from "src/getFileInput/module" +import { CommonModule } from "@angular/common" +import { ModularUserAnnotationToolService } from "../tools/service" +import { NoopAnimationsModule } from "@angular/platform-browser/animations" +import { MatDialogModule } from "@angular/material/dialog" +import { ComponentStore } from "@ngrx/component-store" +import { NEVER, of } from "rxjs" +import { MatSnackBarModule } from "@angular/material/snack-bar" +import { StateModule } from "src/state" +import { hot } from "jasmine-marbles" +import { IAnnotationGeometry } from "../tools/type" +import { MatTooltipModule } from "@angular/material/tooltip" +import { MatButtonModule } from "@angular/material/button" +import { MatCardModule } from "@angular/material/card" +import { ZipFilesOutputModule } from "src/zipFilesOutput/module" +import { AnnotationVisiblePipe } from "../annotationVisible.pipe" +import { SingleAnnotationClsIconPipe, SingleAnnotationNamePipe } from "../singleAnnotationUnit/singleAnnotationUnit.component" +import { MatExpansionModule } from "@angular/material/expansion" + +class MockModularUserAnnotationToolService { + hiddenAnnotations$ = of([]) + toggleAnnotationVisibilityById = jasmine.createSpy() + parseAnnotationObject = jasmine.createSpy() + importAnnotation = jasmine.createSpy() + spaceFilteredManagedAnnotations$ = NEVER + rSpaceManagedAnnotations$ = NEVER + otherSpaceManagedAnnotations$ = NEVER +} + +const readmeContent = `{id}.sands.json file contains the data of annotations. {id}.desc.json contains the metadata of annotations.` + +describe("annotationList.component.ts", () => { + let component: AnnotationList; + let fixture: ComponentFixture<AnnotationList>; + + describe("AnnotationList", () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + CommonModule, + FileInputModule, + NoopAnimationsModule, + MatDialogModule, + MatSnackBarModule, + StateModule, // needed for iavStateAggregator directive + MatTooltipModule, + MatButtonModule, + MatCardModule, + MatExpansionModule, + ZipFilesOutputModule, + ], + providers: [ + ComponentStore, + { + provide: ModularUserAnnotationToolService, + useClass: MockModularUserAnnotationToolService + } + ], + declarations: [ + AnnotationList, + AnnotationVisiblePipe, + SingleAnnotationNamePipe, + SingleAnnotationClsIconPipe, + ] + }).compileComponents() + }) + it("> can be init", () => { + + fixture = TestBed.createComponent(AnnotationList) + component = fixture.componentInstance + fixture.detectChanges() + expect(component).toBeTruthy() + }) + + describe("> filesExport$", () => { + beforeEach(() => { + const svc = TestBed.inject(ModularUserAnnotationToolService) + + const dummyGeom: Partial<IAnnotationGeometry> = { + id: 'foo', + toSands() { + return {} as any + }, + toMetadata() { + return {} as any + }, + toJSON() { + return {} + } + } + svc.spaceFilteredManagedAnnotations$ = of([dummyGeom] as IAnnotationGeometry[]) + }) + it("> do not emit duplicated values", () => { + + fixture = TestBed.createComponent(AnnotationList) + component = fixture.componentInstance + + expect(component.filesExport$).toBeObservable( + hot('(a|)', { + a: [{ + filename: 'README.md', + filecontent: readmeContent + }, { + filename: `foo.sands.json`, + filecontent: '{}' + }, { + filename: `foo.desc.json`, + filecontent: '{}' + }], + }) + ) + }) + }) + }) +}) diff --git a/src/atlasComponents/userAnnotations/annotationList/annotationList.component.ts b/src/atlasComponents/userAnnotations/annotationList/annotationList.component.ts index bad568f3822e640174da67c0162142b3a2188894..7463f9a105d179f96eb273d1880e2facda9a9155 100644 --- a/src/atlasComponents/userAnnotations/annotationList/annotationList.component.ts +++ b/src/atlasComponents/userAnnotations/annotationList/annotationList.component.ts @@ -3,8 +3,8 @@ import { ARIA_LABELS, CONST } from "common/constants"; import { ModularUserAnnotationToolService } from "../tools/service"; import { IAnnotationGeometry, TExportFormats } from "../tools/type"; import { ComponentStore } from "src/viewerModule/componentStore"; -import { map, shareReplay, startWith } from "rxjs/operators"; -import { combineLatest, Observable, Subscription } from "rxjs"; +import { debounceTime, map, shareReplay, startWith } from "rxjs/operators"; +import { combineLatest, concat, Observable, of, Subscription } from "rxjs"; import { TZipFileConfig } from "src/zipFilesOutput/type"; import { TFileInputEvent } from "src/getFileInput/type"; import { FileInputDirective } from "src/getFileInput/getFileInput.directive"; @@ -44,9 +44,11 @@ export class AnnotationList { startWith(false) ) - public filesExport$: Observable<TZipFileConfig[]> = this.managedAnnotations$.pipe( - startWith([] as IAnnotationGeometry[]), - shareReplay(1), + public filesExport$: Observable<TZipFileConfig[]> = concat( + of([] as IAnnotationGeometry[]), + this.managedAnnotations$ + ).pipe( + debounceTime(0), map(manAnns => { const readme = { filename: 'README.md', @@ -61,11 +63,12 @@ export class AnnotationList { const annotationDesc = manAnns.map(ann => { return { filename: `${ann.id}.desc.json`, - filecontent: JSON.stringify(this.annotSvc.exportAnnotationMetadata(ann), null, 2) + filecontent: JSON.stringify(ann.toMetadata(), null, 2) } }) return [ readme, ...annotationSands, ...annotationDesc ] - }) + }), + shareReplay(1), ) constructor( private annotSvc: ModularUserAnnotationToolService, @@ -82,10 +85,10 @@ export class AnnotationList { this.managedAnnotations$.subscribe(anns => this.managedAnnotations = anns), combineLatest([ this.managedAnnotations$.pipe( - startWith([]) + startWith([] as IAnnotationGeometry[]) ), this.annotationInOtherSpaces$.pipe( - startWith([]) + startWith([] as IAnnotationGeometry[]) ) ]).subscribe(([ann, annOther]) => { this.userAnnRoute = { diff --git a/src/atlasComponents/userAnnotations/tools/line.ts b/src/atlasComponents/userAnnotations/tools/line.ts index c156a9e09544fb3e82b50820f49d50668a4ea094..9223b1d989aacccab83e470f9145aca1b81674a9 100644 --- a/src/atlasComponents/userAnnotations/tools/line.ts +++ b/src/atlasComponents/userAnnotations/tools/line.ts @@ -78,11 +78,12 @@ export class Line extends IAnnotationGeometry{ x: x1, y: y1, z: z1 } = this.points[1] + const { id } = this.space return { '@id': this.id, '@type': "tmp/line", coordinateSpace: { - '@id': this.space["@id"] + '@id': id }, coordinatesFrom: [getCoord(x0/1e6), getCoord(y0/1e6), getCoord(z0/1e6)], coordinatesTo: [getCoord(x1/1e6), getCoord(y1/1e6), getCoord(z1/1e6)], diff --git a/src/atlasComponents/userAnnotations/tools/point.ts b/src/atlasComponents/userAnnotations/tools/point.ts index 58d8cdde8acd05863e682814e37f8d667124e36e..5086a30459665423439da1b8ee6cbb2fe51d35df 100644 --- a/src/atlasComponents/userAnnotations/tools/point.ts +++ b/src/atlasComponents/userAnnotations/tools/point.ts @@ -82,12 +82,13 @@ export class Point extends IAnnotationGeometry { } toSands(): TSandsPoint{ + const { id } = this.space const {x, y, z} = this return { '@id': this.id, '@type': 'https://openminds.ebrains.eu/sands/CoordinatePoint', coordinateSpace: { - '@id': this.space["@id"] + '@id': id }, coordinates:[ getCoord(x/1e6), getCoord(y/1e6), getCoord(z/1e6) ] } diff --git a/src/atlasComponents/userAnnotations/tools/poly.ts b/src/atlasComponents/userAnnotations/tools/poly.ts index 244c71a6a2d11163ba70af11727adcefd1ac399f..4bc86182aa6849161b384923cabd24d76534f502 100644 --- a/src/atlasComponents/userAnnotations/tools/poly.ts +++ b/src/atlasComponents/userAnnotations/tools/poly.ts @@ -94,11 +94,12 @@ export class Polygon extends IAnnotationGeometry{ } toSands(): TSandsPolyLine{ + const { id } = this.space return { "@id": this.id, "@type": 'tmp/poly', coordinateSpace: { - '@id': this.space["@id"], + '@id': id, }, coordinates: this.points.map(p => { const { x, y, z } = p diff --git a/src/atlasComponents/userAnnotations/tools/service.spec.ts b/src/atlasComponents/userAnnotations/tools/service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..fdeacfde828aff2d09b31c81fd9b7e2de5baae63 --- /dev/null +++ b/src/atlasComponents/userAnnotations/tools/service.spec.ts @@ -0,0 +1,43 @@ +import { TestBed } from "@angular/core/testing" +import { ModularUserAnnotationToolService } from "./service" +import { MockStore, provideMockStore } from "@ngrx/store/testing" +import { NoopAnimationsModule } from "@angular/platform-browser/animations" +import { MatSnackBarModule } from "@angular/material/snack-bar" +import { ANNOTATION_EVENT_INJ_TOKEN, INJ_ANNOT_TARGET } from "./type" +import { NEVER, Subject } from "rxjs" +import { atlasSelection } from "src/state" + +describe("userAnnotations/service.ts", () => { + + describe("ModularUserAnnotationToolService", () => { + let service: ModularUserAnnotationToolService + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + MatSnackBarModule, + ], + providers: [ + provideMockStore(), + { + provide: INJ_ANNOT_TARGET, + useValue: NEVER + }, + { + provide: ANNOTATION_EVENT_INJ_TOKEN, + useValue: new Subject() + }, + ModularUserAnnotationToolService + ] + }) + + const mStore = TestBed.inject(MockStore) + mStore.overrideSelector(atlasSelection.selectors.selectedTemplate, null) + mStore.overrideSelector(atlasSelection.selectors.viewerMode, null) + }) + it("> can be init", () => { + const svc = TestBed.inject(ModularUserAnnotationToolService) + expect(svc).toBeDefined() + }) + }) +}) diff --git a/src/atlasComponents/userAnnotations/tools/service.ts b/src/atlasComponents/userAnnotations/tools/service.ts index 03f0f060e620f05897e380044ef381bea50c5c71..9ef8d363ac2a1201e28854b1b940d44967f5def6 100644 --- a/src/atlasComponents/userAnnotations/tools/service.ts +++ b/src/atlasComponents/userAnnotations/tools/service.ts @@ -6,7 +6,7 @@ import { BehaviorSubject, combineLatest, fromEvent, merge, Observable, of, Subje import {map, switchMap, filter, shareReplay, pairwise, withLatestFrom } from "rxjs/operators"; import { NehubaViewerUnit } from "src/viewerModule/nehuba"; import { NEHUBA_INSTANCE_INJTKN } from "src/viewerModule/nehuba/util"; -import { AbsToolClass, ANNOTATION_EVENT_INJ_TOKEN, IAnnotationEvents, IAnnotationGeometry, INgAnnotationTypes, INJ_ANNOT_TARGET, TAnnotationEvent, ClassInterface, TCallbackFunction, TSands, TGeometryJson, TCallback } from "./type"; +import { AbsToolClass, ANNOTATION_EVENT_INJ_TOKEN, IAnnotationEvents, IAnnotationGeometry, INgAnnotationTypes, INJ_ANNOT_TARGET, TAnnotationEvent, ClassInterface, TCallbackFunction, TSands, TGeometryJson, TCallback, DESC_TYPE } from "./type"; import { getExportNehuba, switchMapWaitFor } from "src/util/fn"; import { Polygon } from "./poly"; import { Line } from "./line"; @@ -27,7 +27,6 @@ type TAnnotationMetadata = { desc: string } -const descType = 'siibra-ex/meta/desc' as const type TTypedAnnMetadata = { '@type': 'siibra-ex/meta/desc' } & TAnnotationMetadata @@ -325,21 +324,22 @@ export class ModularUserAnnotationToolService implements OnDestroy{ /** * on new nehubaViewer, unset annotationLayer */ - this.subscription.push( - nehubaViewer$.subscribe(() => { - this.annotationLayer = null - }) - ) - - /** - * get mouse real position - */ - this.subscription.push( - nehubaViewer$.pipe( - switchMap(v => v?.mousePosInReal$ || of(null)) - ).subscribe(v => this.mousePosReal = v) - ) - + if (!!nehubaViewer$) { + this.subscription.push( + nehubaViewer$.subscribe(() => { + this.annotationLayer = null + }) + ) + + /** + * get mouse real position + */ + this.subscription.push( + nehubaViewer$.pipe( + switchMap(v => v?.mousePosInReal$ || of(null)) + ).subscribe(v => this.mousePosReal = v) + ) + } /** * on mouse move, render preview annotation */ @@ -410,13 +410,9 @@ export class ModularUserAnnotationToolService implements OnDestroy{ })), ) ]).pipe( - map(([_, annts]) => { - const out = [] - for (const ann of annts) { - out.push(...ann.toNgAnnotation()) - } - return out - }), + map(([_, annts]) => + annts.map(ann => ann.toNgAnnotation()).flatMap(v => v) + ), shareReplay(1), ) this.subscription.push( @@ -552,15 +548,6 @@ export class ModularUserAnnotationToolService implements OnDestroy{ } } - public exportAnnotationMetadata(ann: IAnnotationGeometry): TAnnotationMetadata & { '@type': 'siibra-ex/meta/desc' } { - return { - '@type': descType, - id: ann.id, - name: ann.name, - desc: ann.desc, - } - } - /** * stop gap measure when exporting/import annotations in sands format * metadata (name/desc) will be saved in a separate metadata file @@ -650,7 +637,7 @@ export class ModularUserAnnotationToolService implements OnDestroy{ if (json['@type'] === 'siibra-ex/annotation/polyline') { returnObj = Polygon.fromJSON(json) } - if (json['@type'] === descType) { + if (json['@type'] === DESC_TYPE) { const existingAnn = this.managedAnnotations.find(ann => json.id === ann.id) if (existingAnn) { diff --git a/src/atlasComponents/userAnnotations/tools/type.ts b/src/atlasComponents/userAnnotations/tools/type.ts index e2abb6f9cf4b745180c5d971ccd8a36178637388..d54b231afd55ca503430125855acdc6701c3fb72 100644 --- a/src/atlasComponents/userAnnotations/tools/type.ts +++ b/src/atlasComponents/userAnnotations/tools/type.ts @@ -9,6 +9,8 @@ import { TSandsCoord, TSandsPoint } from "src/util/types" export { getCoord, TSandsPoint } from "src/util/types" +export const DESC_TYPE = 'siibra-ex/meta/desc' as const + type TRecord = Record<string, unknown> /** @@ -311,6 +313,15 @@ export abstract class IAnnotationGeometry extends Highlightable { abstract toString(): string abstract toSands(): ISandsAnnotation[keyof ISandsAnnotation] + toMetadata(){ + return { + '@type': DESC_TYPE, + id: this.id, + name: this.name, + desc: this.desc, + } + } + public remove() { throw new Error(`The remove method needs to be overwritten by the tool manager`) } diff --git a/src/atlasViewer/atlasViewer.component.ts b/src/atlasViewer/atlasViewer.component.ts index 6810e24748d2111236107a85ecc6c5fdf12e1009..c127379a8ee112b36de6e104eaa8da3c62eb04b2 100644 --- a/src/atlasViewer/atlasViewer.component.ts +++ b/src/atlasViewer/atlasViewer.component.ts @@ -74,11 +74,11 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { @Inject(DARKTHEME) private darktheme$: Observable<boolean> ) { - const error = this.el.nativeElement.getAttribute('data-error') + const error = this.el.nativeElement.getAttribute(CONST.DATA_ERROR_ATTR) if (error) { this.snackbar.open(error, 'Dismiss', { duration: 5000 }) - this.el.nativeElement.removeAttribute('data-error') + this.el.nativeElement.removeAttribute(CONST.DATA_ERROR_ATTR) } } diff --git a/src/components/flatHierarchy/treeView/treeView.template.html b/src/components/flatHierarchy/treeView/treeView.template.html index 730cd04b0122a538195b971e9f8f90f39eed7e46..7ecb9798a4b6300275d18e0a8ee753062d8c67df 100644 --- a/src/components/flatHierarchy/treeView/treeView.template.html +++ b/src/components/flatHierarchy/treeView/treeView.template.html @@ -23,7 +23,8 @@ <ng-template [ngTemplateOutlet]="phTmpl" [ngTemplateOutletContext]="{ - $implicit: node + $implicit: node, + last: !node.expandable }"> </ng-template> @@ -51,7 +52,8 @@ <ng-template [ngTemplateOutlet]="renderNodeTmplRef" [ngTemplateOutletContext]="{ - $implicit: node.node + $implicit: node.node, + last: !node.expandable }"> </ng-template> </div> diff --git a/src/environments/environment.common.ts b/src/environments/environment.common.ts index 0253c2aeae7f71fcc509227607b4870a8d131dff..98a9c48141457ae40be7fed945a3704d10086be7 100644 --- a/src/environments/environment.common.ts +++ b/src/environments/environment.common.ts @@ -4,7 +4,12 @@ export const environment = { VERSION: 'unknown version', PRODUCTION: false, BACKEND_URL: null, - SIIBRA_API_ENDPOINTS: 'http://localhost:8000/v3_0', // 'https://siibra-api-latest.apps-dev.hbp.eu/v3_0', //'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', + // N.B. do not update the SIIBRA_API_ENDPOITNS directly + // some libraries rely on the exact string formatting to work properly + SIIBRA_API_ENDPOINTS: + // 'http://localhost:10081/v3_0', // endpoint-local-10081 + 'https://siibra-api-latest.apps-dev.hbp.eu/v3_0', //endpoint-latest + // 'https://siibra-api-stable.apps.hbp.eu/v3_0', // endpoint-stable SPATIAL_TRANSFORM_BACKEND: 'https://hbp-spatial-backend.apps.hbp.eu', MATOMO_URL: null, MATOMO_ID: null, diff --git a/src/features/entry/entry.component.ts b/src/features/entry/entry.component.ts index 4a4dfe791073e0301704086385d742f367cf1a0d..7d97756ddbead7f809aa2e094f916261b47a8ed3 100644 --- a/src/features/entry/entry.component.ts +++ b/src/features/entry/entry.component.ts @@ -11,7 +11,6 @@ import { combineLatest, concat, forkJoin, merge, of, Subject, Subscription } fro import { DsExhausted, IsAlreadyPulling, PulledDataSource } from 'src/util/pullable'; import { TranslatedFeature } from '../list/list.directive'; import { SPECIES_ENUM } from 'src/util/constants'; -import { MatDialog } from '@angular/material/dialog'; const categoryAcc = <T extends Record<string, unknown>>(categories: T[]) => { const returnVal: Record<string, T[]> = {} @@ -27,6 +26,33 @@ const categoryAcc = <T extends Record<string, unknown>>(categories: T[]) => { return returnVal } +type ConnectiivtyFilter = { + SPECIES: string[] + PARCELLATION: string[] + SPACE: string[] +} + +const WHITELIST_CONNECTIVITY: ConnectiivtyFilter = { + SPECIES: [ + SPECIES_ENUM.RATTUS_NORVEGICUS, + SPECIES_ENUM.HOMO_SAPIENS + ], + PARCELLATION: [ + IDS.PARCELLATION.JBA29, + IDS.PARCELLATION.JBA30, + IDS.PARCELLATION.WAXHOLMV4 + ], + SPACE: [], +} + +const BANLIST_CONNECTIVITY: ConnectiivtyFilter = { + SPECIES: [], + PARCELLATION: [], + SPACE: [ + IDS.TEMPLATES.BIG_BRAIN + ] +} + @Component({ selector: 'sxplr-feature-entry', templateUrl: './entry.flattened.component.html', @@ -38,7 +64,7 @@ export class EntryComponent extends FeatureBase implements AfterViewInit, OnDest @ViewChildren(CategoryAccDirective) catAccDirs: QueryList<CategoryAccDirective> - constructor(private sapi: SAPI, private store: Store, private dialog: MatDialog) { + constructor(private sapi: SAPI, private store: Store) { super() } @@ -152,10 +178,14 @@ export class EntryComponent extends FeatureBase implements AfterViewInit, OnDest public showConnectivity$ = combineLatest([ this.selectedAtlas$.pipe( - map(atlas => atlas?.species === SPECIES_ENUM.HOMO_SAPIENS || atlas?.species === SPECIES_ENUM.RATTUS_NORVEGICUS) + map(atlas => WHITELIST_CONNECTIVITY.SPECIES.includes(atlas?.species) && !BANLIST_CONNECTIVITY.SPECIES.includes(atlas?.species)) ), this.TPRBbox$.pipe( - map(({ parcellation }) => parcellation?.id === IDS.PARCELLATION.JBA29 || parcellation?.id === IDS.PARCELLATION.WAXHOLMV4) + map(({ parcellation, template }) => ( + WHITELIST_CONNECTIVITY.SPACE.includes(template?.id) && !BANLIST_CONNECTIVITY.SPACE.includes(template?.id) + ) || ( + WHITELIST_CONNECTIVITY.PARCELLATION.includes(parcellation?.id) && !BANLIST_CONNECTIVITY.PARCELLATION.includes(parcellation?.id) + )) ) ]).pipe( map(flags => flags.every(f => f)) @@ -217,10 +247,4 @@ export class EntryComponent extends FeatureBase implements AfterViewInit, OnDest pullAll(){ this.#pullAll.next(null) } - - openDialog(tmpl: TemplateRef<unknown>, data?: unknown) { - this.dialog.open(tmpl, { - data - }) - } } diff --git a/src/index.html b/src/index.html index 2c80ba7b77d4e34a17eb758f82a940d311cb1f4a..02770ccacd9833b4e1d57e498e1e109593e25efe 100644 --- a/src/index.html +++ b/src/index.html @@ -14,7 +14,7 @@ <link rel="icon" type="image/png" href="assets/favicons/favicon-128-light.png"/> <script src="extra_js.js"></script> <script src="https://unpkg.com/three-surfer@0.0.13/dist/bundle.js" defer></script> - <script type="module" src="https://unpkg.com/ng-layer-tune@0.0.14/dist/ng-layer-tune/ng-layer-tune.esm.js"></script> + <script type="module" src="https://unpkg.com/ng-layer-tune@0.0.21/dist/ng-layer-tune/ng-layer-tune.esm.js"></script> <script type="module" src="https://unpkg.com/hbp-connectivity-component@0.6.6/dist/connectivity-component/connectivity-component.js" ></script> <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> diff --git a/src/state/userPreference/actions.ts b/src/state/userPreference/actions.ts index 05d96e5caabe5e4067ce8903358cf78c2ac5a836..55dffce1d6f4f2e3f85e1220f65d75883fcf0cbc 100644 --- a/src/state/userPreference/actions.ts +++ b/src/state/userPreference/actions.ts @@ -44,3 +44,10 @@ export const setShowExperimental = createAction( flag: boolean }>() ) + +export const setZMultiplier = createAction( + `${nameSpace} setZMultiplier`, + props<{ + value: number + }>() +) diff --git a/src/state/userPreference/selectors.ts b/src/state/userPreference/selectors.ts index 00856d0160f0af6fd391bf1c1e3b68288a981dbb..859ab0f178eea24b153ccb91e4a7c6878187d562 100644 --- a/src/state/userPreference/selectors.ts +++ b/src/state/userPreference/selectors.ts @@ -4,6 +4,11 @@ import { UserPreference } from "./store" const storeSelector = store => store[nameSpace] as UserPreference +export const overrideZTraversalMultiplier = createSelector( + storeSelector, + state => state.overrideZTraversalMultiplier +) + export const useAnimation = createSelector( storeSelector, state => state.useAnimation diff --git a/src/state/userPreference/store.ts b/src/state/userPreference/store.ts index 2a4fa314a1999dbacca0513bea5eaf3efe877c8e..222832b7a41339f72fb97aa5177d01480385af92 100644 --- a/src/state/userPreference/store.ts +++ b/src/state/userPreference/store.ts @@ -7,6 +7,8 @@ import { maxGpuLimit, CSP } from "./const" export const defaultGpuLimit = maxGpuLimit export type UserPreference = { + overrideZTraversalMultiplier: number + useMobileUi: boolean gpuLimit: number useAnimation: boolean @@ -19,6 +21,8 @@ export type UserPreference = { } export const defaultState: UserPreference = { + overrideZTraversalMultiplier: null, + useMobileUi: JSON.parse(localStorage.getItem(LOCAL_STORAGE_CONST.MOBILE_UI)), gpuLimit: Number(localStorage.getItem(LOCAL_STORAGE_CONST.GPU_LIMIT)) || defaultGpuLimit, useAnimation: !localStorage.getItem(LOCAL_STORAGE_CONST.ANIMATION), @@ -100,5 +104,12 @@ export const reducer = createReducer( ...state, showExperimental: flag }) + ), + on( + actions.setZMultiplier, + (state, { value }) => ({ + ...state, + overrideZTraversalMultiplier: value + }) ) ) diff --git a/src/ui/config/configCmp/config.component.ts b/src/ui/config/configCmp/config.component.ts index b3d556c87fa882ef0029e362bcab45cd552f564c..386293b19153fbe8c53957cfde01fe3a307f4e37 100644 --- a/src/ui/config/configCmp/config.component.ts +++ b/src/ui/config/configCmp/config.component.ts @@ -1,13 +1,17 @@ -import { Component, OnDestroy, OnInit } from '@angular/core' +import { Component, Inject, OnDestroy, OnInit, Optional } from '@angular/core' import { select, Store } from '@ngrx/store'; -import { combineLatest, Observable, Subscription } from 'rxjs'; +import { combineLatest, Observable, of, Subscription } from 'rxjs'; import { debounceTime, distinctUntilChanged, map, startWith } from 'rxjs/operators'; import { isIdentityQuat } from 'src/viewerModule/nehuba/util'; import { MatSlideToggleChange } from "@angular/material/slide-toggle"; import { MatSliderChange } from "@angular/material/slider"; import { atlasSelection, userPreference, userInterface } from 'src/state'; import { environment } from "src/environments/environment" +import { Z_TRAVERSAL_MULTIPLIER } from 'src/viewerModule/nehuba/layerCtrl.service/layerCtrl.util'; +import { FormControl, FormGroup } from '@angular/forms'; + +const Z_TRAVERSAL_TOOLTIP = `Value to use when traversing z level. If toggled off, will use the voxel dimension of the template.` const GPU_TOOLTIP = `Higher GPU usage can cause crashes on lower end machines` const ANIMATION_TOOLTIP = `Animation can cause slowdowns in lower end machines` const MOBILE_UI_TOOLTIP = `Mobile UI enables touch controls` @@ -24,6 +28,15 @@ const OBLIQUE_ROOT_TEXT_ORDER: [string, string, string, string] = ['Slice View 1 export class ConfigComponent implements OnInit, OnDestroy { + customZFormGroup = new FormGroup({ + customZValue: new FormControl<number>({ + value: 1, + disabled: true + }), + customZFlag: new FormControl<boolean>(false) + }) + + public Z_TRAVERSAL_TOOLTIP = Z_TRAVERSAL_TOOLTIP public GPU_TOOLTIP = GPU_TOOLTIP public ANIMATION_TOOLTIP = ANIMATION_TOOLTIP public MOBILE_UI_TOOLTIP = MOBILE_UI_TOOLTIP @@ -70,6 +83,7 @@ export class ConfigComponent implements OnInit, OnDestroy { constructor( private store: Store<any>, + @Optional() @Inject(Z_TRAVERSAL_MULTIPLIER) private zTraversalMult$: Observable<number> = of(1) ) { this.gpuLimit$ = this.store.pipe( @@ -112,6 +126,52 @@ export class ConfigComponent implements OnInit, OnDestroy { public ngOnInit() { this.subscriptions.push( this.panelOrder$.subscribe(panelOrder => this.panelOrder = panelOrder), + combineLatest([ + this.zTraversalMult$, + this.store.pipe( + select(userPreference.selectors.overrideZTraversalMultiplier), + map(val => !!val) + ), + ]).subscribe(([ zVal, customZFlag ]) => { + this.customZFormGroup.setValue({ + customZFlag: customZFlag, + customZValue: zVal + }) + }), + this.customZFormGroup.valueChanges.pipe( + map(v => v.customZFlag), + distinctUntilChanged(), + ).subscribe(customZFlag => { + if (customZFlag !== this.customZFormGroup.controls.customZValue.enabled) { + if (customZFlag) { + this.customZFormGroup.controls.customZValue.enable() + } else { + this.customZFormGroup.controls.customZValue.disable() + } + } + }), + this.customZFormGroup.valueChanges.pipe( + debounceTime(160) + ).subscribe(() => { + const { customZFlag, customZValue } = this.customZFormGroup.value + // if customzflag is unset, unset zmultiplier + if (!customZFlag) { + this.store.dispatch( + userPreference.actions.setZMultiplier({ + value: null + }) + ) + return + } + // if the entered value cannot be parsed to number, skip for now. + if (customZValue) { + this.store.dispatch( + userPreference.actions.setZMultiplier({ + value: customZValue + }) + ) + } + }) ) } diff --git a/src/ui/config/configCmp/config.template.html b/src/ui/config/configCmp/config.template.html index 4982158f5bb83596ed47eeaf5425f17981666cc8..3d1f0f2153b66af9fdf37a8c7b111aabc6de2110 100644 --- a/src/ui/config/configCmp/config.template.html +++ b/src/ui/config/configCmp/config.template.html @@ -48,6 +48,26 @@ {{ gpuLimit$ | async }} MB </span> </div> + + <!-- set z increment multiplier --> + <div class="flex flex-row align-items-center justify-content start"> + + <form [formGroup]="customZFormGroup"> + <mat-slide-toggle formControlName="customZFlag"></mat-slide-toggle> + <small iav-stop="click mousedown mouseup" [matTooltip]="Z_TRAVERSAL_TOOLTIP" class="ml-2 fas fa-question"></small> + <mat-form-field class="ml-2" + [ngClass]="{ + 'muted-3': !customZFormGroup.value.customZFlag + }"> + <mat-label> + Custom Z Multiplier + </mat-label> + <input type="number" + matInput + formControlName="customZValue"> + </mat-form-field> + </form> + </div> </div> </mat-tab> diff --git a/src/ui/config/module.ts b/src/ui/config/module.ts index 751aa6f16aca850c8dc9fd3e951d5e1484ba87bb..78ca035e1876f9f342b9e2188b89518390808b47 100644 --- a/src/ui/config/module.ts +++ b/src/ui/config/module.ts @@ -3,12 +3,14 @@ import { NgModule } from "@angular/core"; import { LayoutModule } from "src/layouts/layout.module"; import { AngularMaterialModule } from "src/sharedModules"; import { ConfigComponent } from "./configCmp/config.component"; +import { ReactiveFormsModule } from "@angular/forms"; @NgModule({ imports: [ CommonModule, AngularMaterialModule, LayoutModule, + ReactiveFormsModule, ], declarations: [ ConfigComponent, @@ -17,4 +19,4 @@ import { ConfigComponent } from "./configCmp/config.component"; ConfigComponent, ] }) -export class ConfigModule{} \ No newline at end of file +export class ConfigModule{} diff --git a/src/ui/help/about/about.component.ts b/src/ui/help/about/about.component.ts index 3f64429e7bba3d69716b2a8bfbe54046374e6c2b..31fd286633195fd03a9365b665b2ee935c33c537 100644 --- a/src/ui/help/about/about.component.ts +++ b/src/ui/help/about/about.component.ts @@ -1,9 +1,11 @@ -import { Component } from '@angular/core' +import { ChangeDetectionStrategy, Component } from '@angular/core' import { NewestRelease } from '../newestRelease.directive' import { HttpClient } from '@angular/common/http' import { map } from 'rxjs/operators' import { MatDialog } from '@angular/material/dialog' import { HowToCite } from '../howToCite/howToCite.component' +import { SAPI, EXPECTED_SIIBRA_API_VERSION } from "src/atlasComponents/sapi/sapi.service" +import { environment } from "src/environments/environment" @Component({ selector: 'iav-about', @@ -11,9 +13,12 @@ import { HowToCite } from '../howToCite/howToCite.component' styleUrls: [ './about.style.css', ], + changeDetection: ChangeDetectionStrategy.OnPush }) export class AboutCmp extends NewestRelease{ + public versionString: string + public supportEmailAddress: string = `support@ebrains.eu` public contactEmailHref: string = `mailto:${this.supportEmailAddress}?Subject=[siibra-explorer]%20Queries` @@ -30,6 +35,7 @@ export class AboutCmp extends NewestRelease{ constructor(http: HttpClient, private dialog: MatDialog){ super(http) + this.versionString = `${environment.VERSION}-${environment.GIT_HASH}:${EXPECTED_SIIBRA_API_VERSION}:${SAPI.API_VERSION}` } showHowToCite(){ diff --git a/src/ui/help/about/about.style.css b/src/ui/help/about/about.style.css index 6fac494026f707e9d2d554805c3bc3fe4ebfa5f0..c1e6adabeab3d2bdc690bbfa8f0603d90fdcb599 100644 --- a/src/ui/help/about/about.style.css +++ b/src/ui/help/about/about.style.css @@ -4,3 +4,8 @@ flex-wrap: wrap; justify-content: center; } + +.newline +{ + flex: 1 1 100%; +} diff --git a/src/ui/help/about/about.template.html b/src/ui/help/about/about.template.html index ab91ff0dc4d02bcb1068842b72aa9318ac471a1d..75f5b972c3423d71e0aed13cf541840a1f53e6ff 100644 --- a/src/ui/help/about/about.template.html +++ b/src/ui/help/about/about.template.html @@ -43,3 +43,17 @@ How to cite </span> </button> + +<ng-template [ngIf]="versionString" let-versionString> + <div class="newline"></div> + <mat-form-field class="d-block"> + <mat-label> + Version + </mat-label> + <input type="text" matInput [value]="versionString" disabled> + <button mat-icon-button matSuffix + [iav-clipboard-copy]="versionString"> + <i class="fas fa-copy"></i> + </button> + </mat-form-field> +</ng-template> diff --git a/src/ui/help/module.ts b/src/ui/help/module.ts index 4434b2889a244b51cd4a39c1aecc9d42fbc4d53c..5d08c11796a07ccbe8be9d46367b79428a9cabce 100644 --- a/src/ui/help/module.ts +++ b/src/ui/help/module.ts @@ -9,6 +9,9 @@ import { QuickTourModule } from "src/ui/quickTour/module"; import { HowToCite } from "./howToCite/howToCite.component"; import { StrictLocalModule } from "src/strictLocal"; import { HttpClientModule } from "@angular/common/http"; +import { MatInputModule } from "@angular/material/input"; +import { MatDialogModule } from "@angular/material/dialog"; +import { ShareModule } from "src/share"; @NgModule({ imports: [ @@ -19,6 +22,10 @@ import { HttpClientModule } from "@angular/common/http"; QuickTourModule, StrictLocalModule, HttpClientModule, + + ShareModule, + MatInputModule, + MatDialogModule, ], declarations: [ AboutCmp, diff --git a/src/ui/ui.module.ts b/src/ui/ui.module.ts index 2e083c87bbc17a268a43c2aba3cf1dd90acc5018..98efd3854b801b7cffe5d1720ffc32fab5ea32d7 100644 --- a/src/ui/ui.module.ts +++ b/src/ui/ui.module.ts @@ -40,11 +40,21 @@ import { HANDLE_SCREENSHOT_PROMISE, TypeHandleScrnShotPromise } from "../screens { provide: HANDLE_SCREENSHOT_PROMISE, useValue: ((param) => { - const canvas: HTMLCanvasElement = document.querySelector('#neuroglancer-container canvas') + const ngCanvas: HTMLCanvasElement = document.querySelector('#neuroglancer-container canvas') + const threeSurferCanvas: HTMLCanvasElement = document.querySelector('three-surfer-glue-cmp canvas') + + if (threeSurferCanvas) { + const tsViewer = window['tsViewer'] + tsViewer.renderer.render(tsViewer.scene, tsViewer.camera) + } + if (ngCanvas) { + window['viewer'].display.draw() + } + const canvas = ngCanvas || threeSurferCanvas if (!canvas) { - return Promise.reject(`element '#neuroglancer-container canvas' not found`) + return Promise.reject(`element '#neuroglancer-container canvas' or 'three-surfer-glue-cmp canvas' not found`) } - (window as any).viewer.display.draw() + if (!param) { return new Promise(rs => { canvas.toBlob(blob => { diff --git a/src/util/fn.ts b/src/util/fn.ts index a859bad0898788e5c06c1e8292ddf2c66cafb452..c7edd341b1bad2170efa8a0f0cb51712885180d5 100644 --- a/src/util/fn.ts +++ b/src/util/fn.ts @@ -419,3 +419,15 @@ export function wait(ms: number){ rs(null) }, ms)) } + +/** + * @description Wait until predicate returns true. Tries once every 16 ms. + * @param predicate + */ +export async function waitFor(predicate: () => boolean) { + // eslint-disable-next-line no-constant-condition + while (true) { + if (predicate()) break + await wait(16) + } +} diff --git a/src/util/periodic.service.ts b/src/util/periodic.service.ts index d32637ced9f44e3db18dd5a4de9d80767cac5b64..f73f640769fdd91f89ec6086d167061eb7ba59a1 100644 --- a/src/util/periodic.service.ts +++ b/src/util/periodic.service.ts @@ -5,11 +5,16 @@ import { wait } from "./fn"; providedIn: 'root' }) export class PeriodicSvc{ + + async addToQueue(callback: () => boolean) { + return await PeriodicSvc.AddToQueue(callback) + } + /** * @description retry a callback until it succeeds * @param callback */ - async addToQueue(callback: () => boolean) { + static async AddToQueue(callback: () => boolean) { // eslint-disable-next-line no-constant-condition while (true) { if (callback()) { diff --git a/src/viewerModule/module.ts b/src/viewerModule/module.ts index 79816962edcae195ca1ea2362d3148c4f9ac749d..be0209e2d473412914d16a4c3bf25a77167207d5 100644 --- a/src/viewerModule/module.ts +++ b/src/viewerModule/module.ts @@ -1,6 +1,6 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; -import { Observable } from "rxjs"; +import { Observable, combineLatest, of } from "rxjs"; import { ComponentsModule } from "src/components"; import { ContextMenuModule, ContextMenuService, TContextMenuReg } from "src/contextMenuModule"; import { LayoutModule } from "src/layouts/layout.module"; @@ -14,11 +14,11 @@ import { UserAnnotationsModule } from "src/atlasComponents/userAnnotations"; import { QuickTourModule } from "src/ui/quickTour/module"; import { INJ_ANNOT_TARGET } from "src/atlasComponents/userAnnotations/tools/type"; import { NEHUBA_INSTANCE_INJTKN } from "./nehuba/util"; -import { map } from "rxjs/operators"; +import { map, switchMap } from "rxjs/operators"; import { TContextArg } from "./viewer.interface"; import { KeyFrameModule } from "src/keyframesModule/module"; import { ViewerInternalStateSvc } from "./viewerInternalState.service"; -import { SAPIModule } from 'src/atlasComponents/sapi'; +import { SAPI, SAPIModule } from 'src/atlasComponents/sapi'; import { NehubaVCtxToBbox } from "./pipes/nehubaVCtxToBbox.pipe"; import { SapiViewsModule, SapiViewsUtilModule } from "src/atlasComponents/sapiViews"; import { DialogModule } from "src/ui/dialogInfo/module"; @@ -36,6 +36,9 @@ import { SmartChipModule } from "src/components/smartChip"; import { ReactiveFormsModule } from "@angular/forms"; import { ExperimentalModule } from "src/experimental/experimental.module"; import { BottomMenuModule } from "src/ui/bottomMenu"; +import { CURRENT_TEMPLATE_DIM_INFO, TemplateInfo, Z_TRAVERSAL_MULTIPLIER } from "./nehuba/layerCtrl.service/layerCtrl.util"; +import { Store } from "@ngrx/store"; +import { atlasSelection, userPreference } from "src/state"; @NgModule({ imports: [ @@ -95,6 +98,42 @@ import { BottomMenuModule } from "src/ui/bottomMenu"; deps: [ ContextMenuService ] }, ViewerInternalStateSvc, + + { + provide: Z_TRAVERSAL_MULTIPLIER, + useFactory: (store: Store, templateInfo: Observable<TemplateInfo>) => { + return combineLatest([ + store.select(userPreference.selectors.overrideZTraversalMultiplier), + templateInfo + ]).pipe( + map(([ override, tmplInfo ]) => override || tmplInfo.resolution?.[0] || 1e3) + ) + }, + deps: [ Store, CURRENT_TEMPLATE_DIM_INFO ] + }, + { + provide: CURRENT_TEMPLATE_DIM_INFO, + useFactory: (store: Store, sapi: SAPI) => store.pipe( + atlasSelection.fromRootStore.distinctATP(), + switchMap(({ template }) => + template + ? sapi.getVoxelTemplateImage(template).pipe( + switchMap(defaultImage => { + if (defaultImage.length === 0) { + return of(null) + } + const img = defaultImage[0] + return of({ + ...img.info || {}, + transform: img.transform + }) + }) + ) + : of(null) + ) + ), + deps: [ Store, SAPI ] + }, ], exports: [ ViewerCmp, diff --git a/src/viewerModule/nehuba/config.service/util.ts b/src/viewerModule/nehuba/config.service/util.ts index 2ef5a1cc94d1602c6106ac34e17b983a766e33d1..563c098e5dc6e17d3f70e41c610f3d1876ef2b3f 100644 --- a/src/viewerModule/nehuba/config.service/util.ts +++ b/src/viewerModule/nehuba/config.service/util.ts @@ -156,11 +156,19 @@ const BACKCOMAP_KEY_DICT = { } } +const parcIdIgnoreLateral = [ + IDS.PARCELLATION.MEBRAINS +] export function getParcNgId(atlas: SxplrAtlas, tmpl: SxplrTemplate, parc: SxplrParcellation, region: SxplrRegion): string { if (!region) { return null } + + if (parcIdIgnoreLateral.includes(parc.id)) { + return `_${MultiDimMap.GetKey(atlas.id, tmpl.id, parc.id, "whole brain")}` + } + let laterality: string = "whole brain" if (region.name.indexOf("left") >= 0) laterality = "left hemisphere" if (region.name.indexOf("right") >= 0) laterality = "right hemisphere" diff --git a/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.util.ts b/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.util.ts index 6c7c7cd24d389a426f6b17ef134a495dedf11061..1e18596e98fe06959628aa9186d2e2dd08981f98 100644 --- a/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.util.ts +++ b/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.util.ts @@ -65,3 +65,12 @@ export const SET_COLORMAP_OBS = new InjectionToken<Observable<IColorMap>>('SET_C export const SET_LAYER_VISIBILITY = new InjectionToken<Observable<string[]>>('SET_LAYER_VISIBILITY') export const SET_SEGMENT_VISIBILITY = new InjectionToken<Observable<string[]>>('SET_SEGMENT_VISIBILITY') export const NG_LAYER_CONTROL = new InjectionToken<TNgLayerCtrl<keyof INgLayerCtrl>>('NG_LAYER_CONTROL') +export const Z_TRAVERSAL_MULTIPLIER = new InjectionToken<Observable<number>>('Z_TRAVERSAL_MULTIPLIER') +export const CURRENT_TEMPLATE_DIM_INFO = new InjectionToken<Observable<TemplateInfo>>('CURRENT_TEMPLATE_DIM_INFO') + +export type TemplateInfo = { + transform: number[][] + voxel?: [number, number, number] + real?: [number, number, number] + resolution?: [number, number, number] +} diff --git a/src/viewerModule/nehuba/mesh.service/mesh.service.spec.ts b/src/viewerModule/nehuba/mesh.service/mesh.service.spec.ts index 7db5392a46b74554df8c9e2db2ab8d5e221ab44b..f5bea95d183a58649bb52caedbd27430cfc2b47c 100644 --- a/src/viewerModule/nehuba/mesh.service/mesh.service.spec.ts +++ b/src/viewerModule/nehuba/mesh.service/mesh.service.spec.ts @@ -107,6 +107,10 @@ describe('> mesh.service.ts', () => { [labelIndex2]: fits2 } }) + const mockStore = TestBed.inject(MockStore) + + mockStore.overrideSelector(atlasSelection.selectors.selectedTemplate, {} as any) + mockStore.overrideSelector(atlasSelection.selectors.selectedParcellation, {} as any) }) describe("> auxMesh defined", () => { diff --git a/src/viewerModule/nehuba/mesh.service/mesh.service.ts b/src/viewerModule/nehuba/mesh.service/mesh.service.ts index 2af102701593d4918f8b4c45e3eef92f2382952b..2654a1b3057073eaa55da378bc2a4e97255c204d 100644 --- a/src/viewerModule/nehuba/mesh.service/mesh.service.ts +++ b/src/viewerModule/nehuba/mesh.service/mesh.service.ts @@ -139,7 +139,7 @@ export class NehubaMeshService implements OnDestroy { * TODO monkey patching jba29 in colin to show all meshes * */ - if (selectedParcellation.id === IDS.PARCELLATION.JBA29 && selectedTemplate.id === IDS.TEMPLATES.COLIN27) { + if ((selectedParcellation.id === IDS.PARCELLATION.JBA29 || IDS.PARCELLATION.JBA30 === selectedParcellation.id) && selectedTemplate.id === IDS.TEMPLATES.COLIN27) { return of(...allSegMesh) } const hasSegSelected = selectedSegMesh.some(v => v.labelIndicies.length !== 0) diff --git a/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.spec.ts b/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.spec.ts index ed281a389241e4a8fbaec5c004557601f2af1aa2..89b3a42f99d93c8222d314784bfa9b6057de5aa1 100644 --- a/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.spec.ts +++ b/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.spec.ts @@ -307,8 +307,10 @@ describe('> nehubaViewer.component.ts', () => { describe('> # setColorMap', () => { let nehubaViewerSpy: any let ngViewerStatechildrenGetSpy = jasmine.createSpy('get') - let toJsonSpy = jasmine.createSpy('toJsonSpy') - let restoreStateSpy = jasmine.createSpy('restoreStateSpy') + let layersMngerToJsonSpy = jasmine.createSpy('layersMngerToJsonSpy') + let posToJsonSpy = jasmine.createSpy('posToJsonSpy') + let layerMgerRestoreStateSpy = jasmine.createSpy('layerMgerRestoreStateSpy') + let posRestoreStateSpy = jasmine.createSpy("posRestoreStateSpy") const ngId1 = 'foo-bar' const ngId2 = 'hello-world' @@ -326,11 +328,23 @@ describe('> nehubaViewer.component.ts', () => { } } - ngViewerStatechildrenGetSpy.and.returnValue({ - toJSON: toJsonSpy, - restoreState: restoreStateSpy, + ngViewerStatechildrenGetSpy.and.callFake(prop => { + if (prop === "position") { + return { + toJSON: posToJsonSpy, + restoreState: posRestoreStateSpy + } + } + if (prop === "layers") { + return { + toJSON: layersMngerToJsonSpy, + restoreState: layerMgerRestoreStateSpy, + } + } + throw new Error(`prop ${prop} is not anticipated`) }) - toJsonSpy.and.returnValue([{ + posToJsonSpy.and.returnValue([1.1, 2.2, 3.3]) + layersMngerToJsonSpy.and.returnValue([{ name: ngId1 }, { name: ngId2 @@ -338,8 +352,8 @@ describe('> nehubaViewer.component.ts', () => { }) afterEach(() => { ngViewerStatechildrenGetSpy.calls.reset() - toJsonSpy.calls.reset() - restoreStateSpy.calls.reset() + layersMngerToJsonSpy.calls.reset() + layerMgerRestoreStateSpy.calls.reset() }) it('> calls nehubaViewer.restoreState', () => { const fixture = TestBed.createComponent(NehubaViewerUnit) @@ -359,7 +373,7 @@ describe('> nehubaViewer.component.ts', () => { fixture.componentInstance['setColorMap'](mainMap) - expect(restoreStateSpy).toHaveBeenCalledOnceWith([{ + expect(layerMgerRestoreStateSpy).toHaveBeenCalledOnceWith([{ name: ngId1, segmentColors: { 1: rgbToHex([100, 100, 100]), @@ -372,6 +386,10 @@ describe('> nehubaViewer.component.ts', () => { 2: rgbToHex([20, 20, 20]), } }]) + + expect(posRestoreStateSpy).toHaveBeenCalledOnceWith( + [ 1.1, 2.2, 3.3 ] + ) }) }) diff --git a/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.ts b/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.ts index 12009026cedae24d1a35cd2e5d1998a4b00749a2..dac942b8e0c95f4ab43ff91940546e333f0dbb54 100644 --- a/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.ts +++ b/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.ts @@ -11,7 +11,7 @@ import { IColorMap, SET_COLORMAP_OBS, SET_LAYER_VISIBILITY } from "../layerCtrl. /** * import of nehuba js files moved to angular.json */ -import { INgLayerCtrl, NG_LAYER_CONTROL, SET_SEGMENT_VISIBILITY, TNgLayerCtrl } from "../layerCtrl.service/layerCtrl.util"; +import { INgLayerCtrl, NG_LAYER_CONTROL, SET_SEGMENT_VISIBILITY, TNgLayerCtrl, Z_TRAVERSAL_MULTIPLIER } from "../layerCtrl.service/layerCtrl.util"; import { NgCoordinateSpace, Unit } from "../types"; import { PeriodicSvc } from "src/util/periodic.service"; @@ -116,6 +116,8 @@ export class NehubaViewerUnit implements OnDestroy { #triggerMeshLoad$ = new BehaviorSubject(null) + multplier = new Float32Array(1) + constructor( public elementRef: ElementRef, private log: LoggingService, @@ -127,7 +129,15 @@ export class NehubaViewerUnit implements OnDestroy { @Optional() @Inject(SET_LAYER_VISIBILITY) private layerVis$: Observable<string[]>, @Optional() @Inject(SET_SEGMENT_VISIBILITY) private segVis$: Observable<string[]>, @Optional() @Inject(NG_LAYER_CONTROL) private layerCtrl$: Observable<TNgLayerCtrl<keyof INgLayerCtrl>>, + @Optional() @Inject(Z_TRAVERSAL_MULTIPLIER) multiplier$: Observable<number>, ) { + if (multiplier$) { + this.ondestroySubscriptions.push( + multiplier$.subscribe(val => this.multplier[0] = val) + ) + } else { + this.multplier[0] = 1 + } if (this.nehubaViewer$) { this.nehubaViewer$.next(this) @@ -356,9 +366,24 @@ export class NehubaViewerUnit implements OnDestroy { */ /* creation of the layout is done on next frame, hence the settimeout */ - setTimeout(() => { - window['viewer'].display.panels.forEach(patchSliceViewPanel) - }) + const patchSliceview = async () => { + + const viewer = window['viewer'] + viewer.inputEventBindings.sliceView.set("at:wheel", "proxy-wheel") + viewer.inputEventBindings.sliceView.set("at:control+shift+wheel", "proxy-wheel-alt") + await (async () => { + let lenPanels = 0 + + while (lenPanels === 0) { + lenPanels = viewer.display.panels.size + await new Promise(rs => setTimeout(rs, 150)) + } + })() + viewer.inputEventBindings.sliceView.set("at:wheel", "proxy-wheel-1") + viewer.inputEventBindings.sliceView.set("at:control+shift+wheel", "proxy-wheel-10") + viewer.display.panels.forEach(sliceView => patchSliceViewPanel(sliceView, this.exportNehuba, this.multplier)) + } + patchSliceview() this.newViewerInit() window['nehubaViewer'] = this.nehubaViewer @@ -819,7 +844,14 @@ export class NehubaViewerUnit implements OnDestroy { */ } + /** + * n.b. 2 + * updating layer colormap seems to also mess up the position () + */ + const layersManager = this.nehubaViewer.ngviewer.state.children.get("layers") + const position = this.nehubaViewer.ngviewer.state.children.get("position") + const prevPos = position.toJSON() const layerJson = layersManager.toJSON() for (const layer of layerJson) { if (layer.name in mainDict) { @@ -827,11 +859,14 @@ export class NehubaViewerUnit implements OnDestroy { } } layersManager.restoreState(layerJson) + position.restoreState(prevPos) this.#triggerMeshLoad$.next(null) } } -const patchSliceViewPanel = (sliceViewPanel: any) => { +const patchSliceViewPanel = (sliceViewPanel: any, exportNehuba: any, mulitplier: Float32Array) => { + + // patch draw calls to dispatch viewerportToData const originalDraw = sliceViewPanel.draw sliceViewPanel.draw = function(this) { @@ -839,7 +874,7 @@ const patchSliceViewPanel = (sliceViewPanel: any) => { const viewportToDataEv = new CustomEvent('viewportToData', { bubbles: true, detail: { - viewportToData : this.sliceView.viewportToData, + viewportToData : this.sliceView.invViewMatrix, }, }) this.element.dispatchEvent(viewportToDataEv) @@ -847,6 +882,24 @@ const patchSliceViewPanel = (sliceViewPanel: any) => { originalDraw.call(this) } + + // patch ctrl+wheel & shift+wheel + const { navigationState } = sliceViewPanel + const { registerActionListener, vec3 } = exportNehuba + const tempVec3 = vec3.create() + + for (const val of [1, 10]) { + registerActionListener(sliceViewPanel.element, `proxy-wheel-${val}`, event => { + const e = event.detail + + const offset = tempVec3 + const delta = e.deltaY !== 0 ? e.deltaY : e.deltaX + offset[0] = 0 + offset[1] = 0 + offset[2] = (delta > 0 ? -1 : 1) * mulitplier[0] * val + navigationState.pose.translateVoxelsRelative(offset) + }) + } } export interface ViewerState { diff --git a/src/viewerModule/nehuba/nehubaViewerInterface/nehubaViewerTouch.directive.ts b/src/viewerModule/nehuba/nehubaViewerInterface/nehubaViewerTouch.directive.ts index 6223e187fe07f0bd19f2f5774c793594ff17851f..371cc99e778aaa79ac3ba7d7ec645a27bb2c4f60 100644 --- a/src/viewerModule/nehuba/nehubaViewerInterface/nehubaViewerTouch.directive.ts +++ b/src/viewerModule/nehuba/nehubaViewerInterface/nehubaViewerTouch.directive.ts @@ -66,12 +66,12 @@ export class NehubaViewerTouchDirective implements OnDestroy{ * Touchend also needs to be listened to, as user could start * with multitouch, and end up as single touch */ - const touchStart$ = fromEvent(this.el.nativeElement, 'touchstart').pipe( + const touchStart$ = fromEvent(this.el.nativeElement, 'touchstart', { capture: true }).pipe( shareReplay(1), ) this.singleTouchStart$ = merge( touchStart$, - fromEvent(this.el.nativeElement, 'touchend') + fromEvent(this.el.nativeElement, 'touchend', { capture: true }) ).pipe( filter((ev: TouchEvent) => ev.touches.length === 1), shareReplay(1), @@ -81,18 +81,18 @@ export class NehubaViewerTouchDirective implements OnDestroy{ filter((ev: TouchEvent) => ev.touches.length > 1), ) - this.touchEnd$ = fromEvent(this.el.nativeElement, 'touchend').pipe( + this.touchEnd$ = fromEvent(this.el.nativeElement, 'touchend', { capture: true }).pipe( map(ev => ev as TouchEvent), ) - this.touchMove$ = fromEvent(this.el.nativeElement, 'touchmove') + this.touchMove$ = fromEvent(this.el.nativeElement, 'touchmove', { capture: true }) const multiTouch$ = this.multiTouchStart$.pipe( // only tracks first 2 touches map((ev: TouchEvent) => [ this.findPanelIndex(ev.touches[0].target as HTMLElement), this.findPanelIndex(ev.touches[0].target as HTMLElement) ]), filter(indicies => indicies[0] >= 0 && indicies[0] === indicies[1]), map(indicies => indicies[0]), - switchMap(panelIndex => fromEvent(this.el.nativeElement, 'touchmove').pipe( + switchMap(panelIndex => fromEvent(this.el.nativeElement, 'touchmove', { capture: true }).pipe( filter((ev: TouchEvent) => ev.touches.length > 1), pairwise(), map(([ev0, ev1]) => { @@ -256,7 +256,7 @@ export class NehubaViewerTouchDirective implements OnDestroy{ ).subscribe(({ panelIndex, deltaX, deltaY }) => { if (isNaN(deltaX) || isNaN(deltaX)) return const { position } = this.ngViewer.navigationState - const pos = position.spatialCoordinates + const pos = position.value const { vec3 } = this.exportNehuba vec3.set(pos, deltaX, deltaY, 0) vec3.transformMat4(pos, pos, this.viewportToData[panelIndex]) diff --git a/src/viewerModule/nehuba/viewerCtrl/perspectiveViewSlider/perspectiveViewSlider.component.ts b/src/viewerModule/nehuba/viewerCtrl/perspectiveViewSlider/perspectiveViewSlider.component.ts index 6342411ebf03fabb2d1c816eac98cf15695a8451..a6930c7b46c509d52f7a6d433caf320a46c15ce4 100644 --- a/src/viewerModule/nehuba/viewerCtrl/perspectiveViewSlider/perspectiveViewSlider.component.ts +++ b/src/viewerModule/nehuba/viewerCtrl/perspectiveViewSlider/perspectiveViewSlider.component.ts @@ -2,10 +2,9 @@ import { Component, OnDestroy, Inject, ViewChild, ChangeDetectionStrategy } from import { FormControl } from "@angular/forms"; import { select, Store } from "@ngrx/store"; import { combineLatest, concat, NEVER, Observable, of, Subject, Subscription } from "rxjs"; -import { switchMap, distinctUntilChanged, map, debounceTime, shareReplay, take, withLatestFrom } from "rxjs/operators"; +import { switchMap, distinctUntilChanged, map, debounceTime, shareReplay, take, withLatestFrom, filter } from "rxjs/operators"; import { SAPI } from "src/atlasComponents/sapi"; import { SxplrTemplate } from "src/atlasComponents/sapi/sxplrTypes" -import { fromRootStore } from "src/state/atlasSelection"; import { selectedTemplate } from "src/state/atlasSelection/selectors"; import { panelMode, panelOrder } from "src/state/userInterface/selectors"; import { ResizeObserverDirective } from "src/util/windowResize"; @@ -15,6 +14,7 @@ import { NEHUBA_INSTANCE_INJTKN } from "../../util"; import { EnumClassicalView } from "src/atlasComponents/constants" import { atlasSelection } from "src/state"; import { floatEquality } from "common/util" +import { CURRENT_TEMPLATE_DIM_INFO, TemplateInfo } from "../../layerCtrl.service/layerCtrl.util"; const MAX_DIM = 200 @@ -146,24 +146,8 @@ export class PerspectiveViewSlider implements OnDestroy { map(ctrl => ctrl?.rangeOrientation === "vertical") ) - private currentTemplateSize$ = this.store$.pipe( - fromRootStore.distinctATP(), - switchMap(({ template }) => - template - ? this.sapi.getVoxelTemplateImage(template).pipe( - switchMap(defaultImage => { - if (defaultImage.length == 0) { - // template hs no ng volume, which is the case for threesurfer - return NEVER - } - const img = defaultImage[0] - return of({ - ...img.info || {}, - transform: img.transform - }) - }) - ) - : NEVER), + private currentTemplateSize$ = this.tmplInfo$.pipe( + filter(val => !!val) ) private useMinimap$: Observable<EnumClassicalView> = this.maximisedPanelIndex$.pipe( @@ -347,6 +331,7 @@ export class PerspectiveViewSlider implements OnDestroy { private store$: Store, private sapi: SAPI, @Inject(NEHUBA_INSTANCE_INJTKN) private nehubaViewer$: Observable<NehubaViewerUnit>, + @Inject(CURRENT_TEMPLATE_DIM_INFO) private tmplInfo$: Observable<TemplateInfo>, ) { this.subscriptions.push( diff --git a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts index 33eae8f5833c50ce89e70b106d864610da5eac52..9689793140503916e47fc7677659ecdb91228839 100644 --- a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts +++ b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts @@ -827,11 +827,13 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit () => this.domEl.removeEventListener((window as any).ThreeSurfer.CUSTOM_EVENTNAME_UPDATED, customEvHandler) ) this.tsRef = new (window as any).ThreeSurfer(this.domEl, {highlightHovered: true}) + window['tsViewer'] = this.tsRef this.onDestroyCb.push( () => { this.tsRef.dispose() this.tsRef = null + window['tsViewer'] = null } ) this.tsRef.control.enablePan = false @@ -907,13 +909,9 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit } }) this.mouseoverText = '' - if (mouseover.length > 0) { - this.mouseoverText += mouseover.map(el => el.name).join(' / ') - } if (error) { this.mouseoverText += `::error: ${error}` } - if (this.mouseoverText === '') this.mouseoverText = null } public toggleMeshVis(label: string) { diff --git a/src/viewerModule/viewerCmp/viewerCmp.component.ts b/src/viewerModule/viewerCmp/viewerCmp.component.ts index 7d6a1a038870af42491ef86dc0bf22d96df1e44e..3fb85a4d6d87370362aa69220ca29d7e7bbec09a 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.component.ts +++ b/src/viewerModule/viewerCmp/viewerCmp.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, TemplateRef, ViewChild } from "@angular/core"; import { select, Store } from "@ngrx/store"; -import { combineLatest, Observable, Subscription } from "rxjs"; -import { debounceTime, map, shareReplay } from "rxjs/operators"; +import { combineLatest, Observable, of, Subscription } from "rxjs"; +import { debounceTime, map, shareReplay, switchMap } from "rxjs/operators"; import { CONST, ARIA_LABELS, QUICKTOUR_DESC } from 'common/constants' import { animate, state, style, transition, trigger } from "@angular/animations"; import { IQuickTourData } from "src/ui/quickTour"; @@ -139,15 +139,41 @@ export class ViewerCmp implements OnDestroy { select(atlasSelection.selectors.relevantSelectedPoint) ) - public view$ = combineLatest([ + #currentMap$ = combineLatest([ + this.#templateSelected$, + this.#parcellationSelected$ + ]).pipe( + switchMap(([tmpl, parc]) => tmpl && parc ? this.sapi.getLabelledMap(parc, tmpl) : of(null)) + ) + + + #view0$ = combineLatest([ this.#selectedRegions$, this.#viewerMode$, this.#selectedFeature$, this.#selectedPoint$, this.#templateSelected$, - this.#parcellationSelected$ + this.#parcellationSelected$, + ]).pipe( + map(([ selectedRegions, viewerMode, selectedFeature, selectedPoint, selectedTemplate, selectedParcellation ]) => ({ + selectedRegions, viewerMode, selectedFeature, selectedPoint, selectedTemplate, selectedParcellation + })) + ) + + #view1$ = combineLatest([ + this.#currentMap$, ]).pipe( - map(([ selectedRegions, viewerMode, selectedFeature, selectedPoint, selectedTemplate, selectedParcellation ]) => { + map(( [ currentMap ] ) => ({ + currentMap + })) + ) + + public view$ = combineLatest([ + this.#view0$, + this.#view1$, + ]).pipe( + map(([v0, v1]) => ({ ...v0, ...v1 })), + map(({ selectedRegions, viewerMode, selectedFeature, selectedPoint, selectedTemplate, selectedParcellation, currentMap }) => { let spatialObjectTitle: string let spatialObjectSubtitle: string if (selectedPoint) { @@ -162,6 +188,8 @@ export class ViewerCmp implements OnDestroy { if (!!selectedTemplate) { spatialObjectSubtitle = selectedTemplate.name } + + const labelMappedRegionNames = currentMap && Object.keys(currentMap.indices) || [] return { viewerMode, selectedRegions, @@ -169,6 +197,7 @@ export class ViewerCmp implements OnDestroy { selectedPoint, selectedTemplate, selectedParcellation, + labelMappedRegionNames, /** * Selected Spatial Object @@ -432,6 +461,14 @@ export class ViewerCmp implements OnDestroy { ) } } + if (event.data.viewerType === "threeSurfer") { + const { regions=[] } = (event.data as TContextArg<"threeSurfer">).payload + this.store$.dispatch( + userInteraction.actions.mouseoverRegions({ + regions: regions as SxplrRegion[] + }) + ) + } break default: } diff --git a/src/viewerModule/viewerCmp/viewerCmp.template.html b/src/viewerModule/viewerCmp/viewerCmp.template.html index d213273186d4ad7fd5594dd228188efd770e7c07..4810207ef5225ec2098255c7e832b448779bd161 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.template.html +++ b/src/viewerModule/viewerCmp/viewerCmp.template.html @@ -526,6 +526,7 @@ <sxplr-sapiviews-core-rich-regionshierarchy class="sxplr-w-100 sxplr-flex-var" [sxplr-sapiviews-core-rich-regionshierarchy-regions]="allAvailableRegions$ | async" + [sxplr-sapiviews-core-rich-regionshierarchy-label-mapped-region-names]="view.labelMappedRegionNames" [sxplr-sapiviews-core-rich-regionshierarchy-accent-regions]="view.selectedRegions" (sxplr-sapiviews-core-rich-regionshierarchy-region-select)="selectRoi($event)" (sxplr-sapiviews-core-rich-regionshierarchy-region-toggle)="toggleRoi($event)" @@ -547,6 +548,7 @@ <sxplr-sapiviews-core-rich-regionlistsearch [sxplr-sapiviews-core-rich-regionlistsearch-regions]="allAvailableRegions$ | async" + [sxplr-sapiviews-core-rich-regionlistsearch-mapped-region-names]="view.labelMappedRegionNames" [sxplr-sapiviews-core-rich-regionlistsearch-current-search]="view.selectedRegions.length === 1 ? view.selectedRegions[0].name : null" (sxplr-sapiviews-core-rich-regionlistsearch-region-select)="selectRoi($event)" (sxplr-sapiviews-core-rich-regionlistsearch-region-toggle)="toggleRoi($event)"> diff --git a/third_party/vanilla.html b/third_party/vanilla.html index be223e1e38ab861e649e4c4666e894d0a48c6f8c..5513962d41c1a6cb07e54a164e06a6bae15784f5 100644 --- a/third_party/vanilla.html +++ b/third_party/vanilla.html @@ -9,6 +9,7 @@ <script src="main.bundle.js"></script> <link rel="stylesheet" href="vanilla_styles.css"> <link rel="stylesheet" href="main.css"> + <link rel="stylesheet" href="vanillaMain.css"> </head> <body> <div id="neuroglancer-container"></div>