diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08c5e8edb..66fc6a422 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: - run: | if [[ "$GITHUB_REF" = *hotfix* ]] || [[ "$GITHUB_REF" = refs/heads/staging ]] then - export SIIBRA_API_ENDPOINTS=https://siibra-api-rc.apps.hbp.eu/v2_0,https://siibra-api-rc.apps.jsc.hbp.eu/v2_0 + export SIIBRA_API_ENDPOINTS=https://siibra-api-rc.apps.hbp.eu/v3_0 node src/environments/parseEnv.js ./environment.ts fi npm run test-ci diff --git a/.github/workflows/deploy-helm.yml b/.github/workflows/deploy-helm.yml index cce906fa4..59796a18d 100644 --- a/.github/workflows/deploy-helm.yml +++ b/.github/workflows/deploy-helm.yml @@ -5,40 +5,77 @@ on: inputs: DEPLOYMENT_NAME: required: true - type: string + type: string # prod, rc, expmt IMAGE_TAG: required: true type: string + IMAGE_DIGEST: + required: false + type: string + default: 'default-digest' secrets: KUBECONFIG: required: true jobs: - trigger-deploy: + + trigger-deploy-prod: + runs-on: ubuntu-latest + if: ${{ inputs.DEPLOYMENT_NAME == 'prod' }} + steps: + - uses: actions/checkout@v4 + - name: 'Deploy' + run: | + kubecfg_path=${{ runner.temp }}/.kube_config + echo "${{ secrets.KUBECONFIG }}" > $kubecfg_path + + helm --kubeconfig=$kubecfg_path \ + upgrade \ + --history-max 3 \ + --reuse-values \ + --set image.tag=${{ inputs.IMAGE_TAG }} \ + --set podLabels.image-digest=${{ inputs.IMAGE_DIGEST }} \ + ${{ inputs.DEPLOYMENT_NAME }} .helm/siibra-explorer/ + + rm $kubecfg_path + + trigger-deploy-rc: + runs-on: ubuntu-latest + if: ${{ inputs.DEPLOYMENT_NAME == 'rc' }} + steps: + - uses: actions/checkout@v4 + - name: 'Deploy' + run: | + kubecfg_path=${{ runner.temp }}/.kube_config + echo "${{ secrets.KUBECONFIG }}" > $kubecfg_path + + helm --kubeconfig=$kubecfg_path \ + upgrade \ + --history-max 3 \ + --reuse-values \ + --set image.tag=${{ inputs.IMAGE_TAG }} \ + --set podLabels.image-digest=${{ inputs.IMAGE_DIGEST }} \ + ${{ inputs.DEPLOYMENT_NAME }} .helm/siibra-explorer/ + + rm $kubecfg_path + + trigger-deploy-expmt: runs-on: ubuntu-latest + if: ${{ inputs.DEPLOYMENT_NAME == 'expmt' }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: 'Deploy' run: | kubecfg_path=${{ runner.temp }}/.kube_config echo "${{ secrets.KUBECONFIG }}" > $kubecfg_path - helm --kubeconfig=$kubecfg_path status ${{ inputs.DEPLOYMENT_NAME }} - helm_status=$(echo $?) - - if [[ $helm_status = "0" ]] - then - echo "tag ${{ inputs.DEPLOYMENT_NAME }} found. Update" - helm --kubeconfig=$kubecfg_path \ - upgrade \ - --set image.tag=${{ inputs.IMAGE_TAG }} \ - ${{ inputs.DEPLOYMENT_NAME }} .helm/siibra-explorer/ - else - echo "tag ${{ inputs.DEPLOYMENT_NAME }} not found. Install" - helm --kubeconfig=$kubecfg_path \ - install \ - --set image.tag=${{ inputs.IMAGE_TAG }} \ - ${{ inputs.DEPLOYMENT_NAME }} .helm/siibra-explorer/ - fi + helm --kubeconfig=$kubecfg_path \ + upgrade \ + --history-max 3 \ + --reuse-values \ + --set image.tag=${{ inputs.IMAGE_TAG }} \ + --set podLabels.image-digest=${{ inputs.IMAGE_DIGEST }} \ + ${{ inputs.DEPLOYMENT_NAME }} .helm/siibra-explorer/ + rm $kubecfg_path diff --git a/.github/workflows/deploy-on-okd.yml b/.github/workflows/deploy-on-okd.yml deleted file mode 100644 index d587449ec..000000000 --- a/.github/workflows/deploy-on-okd.yml +++ /dev/null @@ -1,85 +0,0 @@ -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 a9826c04e..77e1c8f70 100644 --- a/.github/workflows/docker_img.yml +++ b/.github/workflows/docker_img.yml @@ -21,10 +21,13 @@ jobs: PRODUCTION: 'true' DOCKER_REGISTRY: 'docker-registry.ebrains.eu/siibra/' - SIIBRA_API_STABLE: 'https://siibra-api-stable.apps.hbp.eu/v3_0,https://siibra-api-stable.apps.jsc.hbp.eu/v3_0' + SIIBRA_API_STABLE: 'https://siibra-api-stable.apps.hbp.eu/v3_0,https://siibra-api-prod.apps.tc.humanbrainproject.eu/v3_0' SIIBRA_API_RC: 'https://siibra-api-rc.apps.hbp.eu/v3_0' SIIBRA_API_LATEST: 'https://siibra-api-latest.apps-dev.hbp.eu/v3_0' + outputs: + GIT_DIGEST: ${{ steps.build-docker-image.outputs.GIT_DIGEST }} + steps: - uses: actions/checkout@v4 with: @@ -59,7 +62,8 @@ jobs: else echo "dev bulid, enable experimental features" fi - - name: 'Build docker image' + - id: 'build-docker-image' + name: 'Build docker image' run: | DOCKER_BUILT_TAG=${{ env.DOCKER_REGISTRY }}siibra-explorer:$BRANCH_NAME echo "Building $DOCKER_BUILT_TAG" @@ -73,6 +77,18 @@ jobs: echo "Successfully built $DOCKER_BUILT_TAG" echo "DOCKER_BUILT_TAG=$DOCKER_BUILT_TAG" >> $GITHUB_ENV + inspect_str=$(docker image inspect --format='json' $DOCKER_BUILT_TAG) + echo "Inspected tag: $inspect_str" + + GIT_DIGEST=${{ github.sha }} + echo "Git digest: $GIT_DIGEST" + + # 62 char limit in label + GIT_DIGEST=$(echo $GIT_DIGEST | grep -oP '^.{6}') + echo "Using first 6 chars of hash: $GIT_DIGEST" + + echo "GIT_DIGEST=$GIT_DIGEST" >> $GITHUB_OUTPUT + - name: 'Push to docker registry' run: | echo "Login to docker registry" @@ -97,8 +113,9 @@ jobs: BRANCH_NAME: ${{ steps.set-vars.outputs.BRANCH_NAME }} BUILD_TEXT: ${{ steps.set-vars.outputs.BUILD_TEXT }} DEPLOY_ID: ${{ steps.set-vars.outputs.DEPLOY_ID }} + SXPLR_VERSION: ${{ steps.set-vars.outputs.SXPLR_VERSION }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - id: set-vars name: Set vars run: | @@ -124,54 +141,42 @@ jobs: echo "SXPLR_VERSION=$SXPLR_VERSION" echo "SXPLR_VERSION=$SXPLR_VERSION" >> $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-rancher: - if: ${{ needs.setting-vars.outputs.BRANCH_NAME == 'master' && success() }} + trigger-deploy-rc-rancher: + if: ${{ needs.setting-vars.outputs.BRANCH_NAME == 'staging' && success() }} needs: - build-docker-img - setting-vars uses: ./.github/workflows/deploy-helm.yml with: - DEPLOYMENT_NAME: master - IMAGE_TAG: ${{ needs.setting-vars.outputs.SXPLR_VERSION }} + DEPLOYMENT_NAME: rc + IMAGE_TAG: staging + IMAGE_DIGEST: ${{ needs.build-docker-img.outputs.GIT_DIGEST }} secrets: KUBECONFIG: ${{ secrets.KUBECONFIG }} - trigger-deploy-staging-viewer-validation: + trigger-deploy-expmt-rancher: if: ${{ needs.setting-vars.outputs.BRANCH_NAME == 'staging' && success() }} needs: - build-docker-img - setting-vars - uses: ./.github/workflows/deploy-on-okd.yml + uses: ./.github/workflows/deploy-helm.yml with: - FULL_DEPLOY_ID: siibra-explorer-branch-deploy-2-stagingpathed - OKD_ENDPOINT: https://okd.hbp.eu:443 - OKD_PROJECT: interactive-viewer + DEPLOYMENT_NAME: expmt + IMAGE_TAG: staging + IMAGE_DIGEST: ${{ needs.build-docker-img.outputs.GIT_DIGEST }} secrets: - okd_token: ${{ secrets.OKD_PROD_SECRET }} - - trigger-deploy-staging-data-validation: - if: ${{ needs.setting-vars.outputs.BRANCH_NAME == 'staging' && success() }} + KUBECONFIG: ${{ secrets.KUBECONFIG }} + + trigger-deploy-master-rancher: + if: ${{ needs.setting-vars.outputs.BRANCH_NAME == 'master' && success() }} needs: - build-docker-img - setting-vars - uses: ./.github/workflows/deploy-on-okd.yml + uses: ./.github/workflows/deploy-helm.yml with: - FULL_DEPLOY_ID: siibra-explorer-rc - OKD_ENDPOINT: https://okd.jsc.hbp.eu:443 - OKD_PROJECT: siibra-explorer + DEPLOYMENT_NAME: master + IMAGE_TAG: ${{ needs.setting-vars.outputs.SXPLR_VERSION }} + IMAGE_DIGEST: ${{ needs.build-docker-img.outputs.GIT_DIGEST }} secrets: - okd_token: ${{ secrets.OKD_JSC_TOKEN }} - \ No newline at end of file + KUBECONFIG: ${{ secrets.KUBECONFIG }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ec46c942a..5ddc69dde 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -59,7 +59,7 @@ jobs: failure-state: ${{ steps.failure-state-step.outputs.failure-state }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: ref: ${{ github.event.ref }} diff --git a/.github/workflows/manual_e2e.yml b/.github/workflows/manual_e2e.yml index a55bd1e16..945946d7a 100644 --- a/.github/workflows/manual_e2e.yml +++ b/.github/workflows/manual_e2e.yml @@ -9,7 +9,7 @@ jobs: hide_previous_if_exists: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: ref: 'master' - uses: actions/github-script@v5 @@ -23,7 +23,14 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: 'Install gherkin-official' + run: 'pip install gherkin-official' + - name: 'Generate checklist' + run: 'python features/_convert.py' - name: 'Add checklist comment' uses: actions/github-script@v5 with: diff --git a/.github/workflows/release-ci.yml b/.github/workflows/release-ci.yml index a4ab67194..792eacb98 100644 --- a/.github/workflows/release-ci.yml +++ b/.github/workflows/release-ci.yml @@ -9,7 +9,7 @@ jobs: if: always() runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - run: | MASTER_VERSION=$(git show origin/master:package.json | jq '.version') THIS_VERSION=$(jq '.version' < package.json) @@ -19,7 +19,7 @@ jobs: if: always() runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - run: | VERSION_NUM=$(jq '.version' < package.json) VERSION_NUM=${VERSION_NUM#\"} @@ -30,7 +30,7 @@ jobs: if: always() runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - run: | VERSION_NUM=$(jq '.version' < package.json) VERSION_NUM=${VERSION_NUM#\"} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1bd7a7b10..0262f2b79 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set version id: set-version run: | @@ -24,7 +24,7 @@ jobs: if: success() runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Create Release id: create_release uses: actions/create-release@v1 diff --git a/.github/workflows/repo_sync_ebrains.yml b/.github/workflows/repo_sync_ebrains.yml index e29122fa0..c98e0f8de 100644 --- a/.github/workflows/repo_sync_ebrains.yml +++ b/.github/workflows/repo_sync_ebrains.yml @@ -9,8 +9,7 @@ jobs: sync: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: wei/git-sync@v3 + - uses: valtech-sd/git-sync@v9 with: source_repo: ${GITHUB_REPOSITORY} source_branch: ${GITHUB_REF_NAME} diff --git a/.helm/adhoc/certificate-atlases-ebrains.yaml b/.helm/adhoc/certificate-atlases-ebrains.yaml new file mode 100644 index 000000000..c4b1cfcf7 --- /dev/null +++ b/.helm/adhoc/certificate-atlases-ebrains.yaml @@ -0,0 +1,21 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: atlases-ebrains-certificate +spec: + secretName: atlases-ebrains-secret + renewBefore: 120h + commonName: atlases.ebrains.eu + isCA: false + privateKey: + algorithm: RSA + encoding: PKCS1 + size: 2048 + usages: + - server auth + dnsNames: + # (CHANGE ME! same as `commonName`) + - atlases.ebrains.eu + issuerRef: + name: letsencrypt-production-issuer-1 + kind: ClusterIssuer \ No newline at end of file diff --git a/.helm/adhoc/certificate-redirect.yml b/.helm/adhoc/certificate-redirect.yml new file mode 100644 index 000000000..36580ca82 --- /dev/null +++ b/.helm/adhoc/certificate-redirect.yml @@ -0,0 +1,65 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: redirect-1-certificate +spec: + secretName: siibra-explorer-redirect-1-secret + renewBefore: 120h + commonName: interactive-viewer.apps.hbp.eu + isCA: false + privateKey: + algorithm: RSA + encoding: PKCS1 + size: 2048 + usages: + - server auth + dnsNames: + # (CHANGE ME! same as `commonName`) + - interactive-viewer.apps.hbp.eu + issuerRef: + name: letsencrypt-production-issuer-1 + kind: ClusterIssuer +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: redirect-2-certificate +spec: + secretName: siibra-explorer-redirect-2-secret + renewBefore: 120h + commonName: interactive-viewer-ms-5-3-2.apps.hbp.eu + isCA: false + privateKey: + algorithm: RSA + encoding: PKCS1 + size: 2048 + usages: + - server auth + dnsNames: + # (CHANGE ME! same as `commonName`) + - interactive-viewer-ms-5-3-2.apps.hbp.eu + issuerRef: + name: letsencrypt-production-issuer-1 + kind: ClusterIssuer +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: redirect-3-certificate +spec: + secretName: siibra-explorer-redirect-3-secret + renewBefore: 120h + commonName: interactive-viewer-expmt.apps.hbp.eu + isCA: false + privateKey: + algorithm: RSA + encoding: PKCS1 + size: 2048 + usages: + - server auth + dnsNames: + # (CHANGE ME! same as `commonName`) + - interactive-viewer-expmt.apps.hbp.eu + issuerRef: + name: letsencrypt-production-issuer-1 + kind: ClusterIssuer \ No newline at end of file diff --git a/.helm/adhoc/certificate-sxplr-ebrains.yml b/.helm/adhoc/certificate-sxplr-ebrains.yml new file mode 100644 index 000000000..6e73e4c5e --- /dev/null +++ b/.helm/adhoc/certificate-sxplr-ebrains.yml @@ -0,0 +1,21 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: siibra-explorer-ebrains-certificate +spec: + secretName: sxplr-ebrains-secret + renewBefore: 120h + commonName: siibra-explorer.apps.ebrains.eu + isCA: false + privateKey: + algorithm: RSA + encoding: PKCS1 + size: 2048 + usages: + - server auth + dnsNames: + # (CHANGE ME! same as `commonName`) + - siibra-explorer.apps.ebrains.eu + issuerRef: + name: letsencrypt-production-issuer-1 + kind: ClusterIssuer diff --git a/.helm/adhoc/configmap-siibra-explorer.yml b/.helm/adhoc/configmap-siibra-explorer.yml index 9dd25153d..a732f6bfc 100644 --- a/.helm/adhoc/configmap-siibra-explorer.yml +++ b/.helm/adhoc/configmap-siibra-explorer.yml @@ -1,12 +1,12 @@ apiVersion: v1 data: - HOST_PATHNAME: "/viewer" - HOSTNAME: "https://siibra-explorer.apps.tc.humanbrainproject.eu" SIIBRA_CACHEDIR: /siibra-api-volume HBP_DISCOVERY_URL: "https://iam.ebrains.eu/auth/realms/hbp" REDIS_ADDR: "cache-redis-service" - V2_7_PLUGIN_URLS: "https://siibra-toolbox-jugex.apps.hbp.eu/viewer_plugin/manifest.json;https://ngpy.apps.hbp.eu/viewer_plugin/manifest.json" + V2_7_PLUGIN_URLS: "https://siibra-jugex.apps.tc.humanbrainproject.eu/viewer_plugin/manifest.json;https://ngpy.apps.hbp.eu/viewer_plugin/manifest.json" LOGGER_DIR: "/sxplr-log" + OVERWRITE_API_ENDPOINT: https://siibra-api-prod.apps.tc.humanbrainproject.eu/v3_0 + OVERWRITE_SPATIAL_ENDPOINT: 'https://siibra-spatial-backend.apps.tc.humanbrainproject.eu' kind: ConfigMap metadata: diff --git a/.helm/adhoc/example-secret-siibra-explorer.yml b/.helm/adhoc/example-secret-siibra-explorer.yml index e4118f801..c1c93e8a5 100644 --- a/.helm/adhoc/example-secret-siibra-explorer.yml +++ b/.helm/adhoc/example-secret-siibra-explorer.yml @@ -7,7 +7,6 @@ data: # n.b. echo -n "foobar" | base64 # or else the new line will also be encoded, and you will # wonder why your application does not work - OVERWRITE_API_ENDPOINT: Zm9vYmFy HBP_CLIENTID_V2: Zm9vYmFy HBP_CLIENTSECRET_V2: Zm9vYmFy SXPLR_EBRAINS_IAM_SA_CLIENT_ID: Zm9vYmFy diff --git a/.helm/adhoc/ingress-main.yml b/.helm/adhoc/ingress-main.yml new file mode 100644 index 000000000..36b3f92dd --- /dev/null +++ b/.helm/adhoc/ingress-main.yml @@ -0,0 +1,92 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: siibra-explorer-main-ingress + labels: + name: siibra-explorer-main-ingress + annotations: + nginx.ingress.kubernetes.io/app-root: "/viewer" +spec: + rules: + - host: siibra-explorer.apps.tc.humanbrainproject.eu + http: + paths: + - pathType: Prefix + path: "/viewer" + backend: + service: + name: prod-siibra-explorer + port: + number: 8080 + - pathType: Prefix + path: "/viewer-staging" + backend: + service: + name: rc-siibra-explorer + port: + number: 8080 + - pathType: Prefix + path: "/viewer-expmt" + backend: + service: + name: expmt-siibra-explorer + port: + number: 8080 + - host: siibra-explorer.apps.ebrains.eu + http: + paths: + - pathType: Prefix + path: "/viewer" + backend: + service: + name: prod-siibra-explorer + port: + number: 8080 + - pathType: Prefix + path: "/viewer-staging" + backend: + service: + name: rc-siibra-explorer + port: + number: 8080 + - pathType: Prefix + path: "/viewer-expmt" + backend: + service: + name: expmt-siibra-explorer + port: + number: 8080 + - host: atlases.ebrains.eu + http: + paths: + - pathType: Prefix + path: "/viewer" + backend: + service: + name: prod-siibra-explorer + port: + number: 8080 + - pathType: Prefix + path: "/viewer-staging" + backend: + service: + name: rc-siibra-explorer + port: + number: 8080 + - pathType: Prefix + path: "/viewer-expmt" + backend: + service: + name: expmt-siibra-explorer + port: + number: 8080 + tls: + - secretName: siibra-explorer-prod-secret + hosts: + - siibra-explorer.apps.tc.humanbrainproject.eu + - secretName: sxplr-ebrains-secret + hosts: + - siibra-explorer.apps.ebrains.eu + - secretName: atlases-ebrains-secret + hosts: + - atlases.ebrains.eu diff --git a/.helm/adhoc/ingress-redirect.yml b/.helm/adhoc/ingress-redirect.yml new file mode 100644 index 000000000..de2d519f7 --- /dev/null +++ b/.helm/adhoc/ingress-redirect.yml @@ -0,0 +1,49 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: siibra-explorer-redirect-ingress + labels: + name: siibra-explorer-redirect-ingress +spec: + rules: + - host: interactive-viewer.apps.hbp.eu + http: + paths: + - pathType: Prefix + path: "/" + backend: + service: + name: trafficcop + port: + number: 8080 + - host: interactive-viewer-ms-5-3-2.apps.hbp.eu + http: + paths: + - pathType: Prefix + path: "/" + backend: + service: + name: trafficcop + port: + number: 8080 + - host: interactive-viewer-expmt.apps.hbp.eu + http: + paths: + - pathType: Prefix + path: "/" + backend: + service: + name: trafficcop + port: + number: 8080 + + tls: + - secretName: siibra-explorer-redirect-1-secret + hosts: + - interactive-viewer.apps.hbp.eu + - secretName: siibra-explorer-redirect-2-secret + hosts: + - interactive-viewer-ms-5-3-2.apps.hbp.eu + - secretName: siibra-explorer-redirect-3-secret + hosts: + - interactive-viewer-expmt.apps.hbp.eu diff --git a/.helm/siibra-explorer/Chart.yaml b/.helm/siibra-explorer/Chart.yaml index cfd3a2c27..99dfe3ddc 100644 --- a/.helm/siibra-explorer/Chart.yaml +++ b/.helm/siibra-explorer/Chart.yaml @@ -15,10 +15,10 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.1.0 +version: 0.1.5 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "1.16.0" +appVersion: "2.14.5" diff --git a/.helm/siibra-explorer/templates/deployment.yaml b/.helm/siibra-explorer/templates/deployment.yaml index 2624dcf1f..ce0cfb66e 100644 --- a/.helm/siibra-explorer/templates/deployment.yaml +++ b/.helm/siibra-explorer/templates/deployment.yaml @@ -53,6 +53,12 @@ spec: # httpGet: # path: / # port: http + env: + {{- range $key, $val := .Values.envObj }} + - name: {{ $key }} + value: {{ $val }} + {{- end }} + resources: {{- toYaml .Values.resources | nindent 12 }} {{- with .Values.volumeMounts }} diff --git a/.helm/siibra-explorer/values.yaml b/.helm/siibra-explorer/values.yaml index af3125286..602d70a8b 100644 --- a/.helm/siibra-explorer/values.yaml +++ b/.helm/siibra-explorer/values.yaml @@ -44,7 +44,7 @@ service: port: 8080 ingress: - enabled: true + enabled: false className: "" annotations: {} # kubernetes.io/ingress.class: nginx @@ -94,3 +94,9 @@ nodeSelector: {} tolerations: [] affinity: {} + +envObj: + HOSTNAME: https://siibra-explorer.apps.tc.humanbrainproject.eu + OVERWRITE_SPATIAL_ENDPOINT: https://siibra-spatial-backend.apps.tc.humanbrainproject.eu + HOST_PATHNAME: /viewer + OVERWRITE_API_ENDPOINT: https://siibra-api-prod.apps.tc.humanbrainproject.eu/v3_0 diff --git a/.helm/trafficcop/.helmignore b/.helm/trafficcop/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/.helm/trafficcop/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/.helm/trafficcop/Chart.yaml b/.helm/trafficcop/Chart.yaml new file mode 100644 index 000000000..4f23dc910 --- /dev/null +++ b/.helm/trafficcop/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: trafficcop +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/.helm/trafficcop/templates/NOTES.txt b/.helm/trafficcop/templates/NOTES.txt new file mode 100644 index 000000000..926f0c642 --- /dev/null +++ b/.helm/trafficcop/templates/NOTES.txt @@ -0,0 +1,22 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "trafficcop.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "trafficcop.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "trafficcop.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "trafficcop.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} diff --git a/.helm/trafficcop/templates/_helpers.tpl b/.helm/trafficcop/templates/_helpers.tpl new file mode 100644 index 000000000..931aa4781 --- /dev/null +++ b/.helm/trafficcop/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "trafficcop.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "trafficcop.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "trafficcop.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "trafficcop.labels" -}} +helm.sh/chart: {{ include "trafficcop.chart" . }} +{{ include "trafficcop.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "trafficcop.selectorLabels" -}} +app.kubernetes.io/name: {{ include "trafficcop.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "trafficcop.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "trafficcop.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/.helm/trafficcop/templates/deployment.yaml b/.helm/trafficcop/templates/deployment.yaml new file mode 100644 index 000000000..025e341dc --- /dev/null +++ b/.helm/trafficcop/templates/deployment.yaml @@ -0,0 +1,75 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "trafficcop.fullname" . }} + labels: + {{- include "trafficcop.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "trafficcop.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "trafficcop.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "trafficcop.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + env: + - name: STATUS_CODE + value: "301" + {{- range $key, $val := .Values.envObj }} + - name: {{ $key }} + value: {{ $val }} + {{- end }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/.helm/trafficcop/templates/hpa.yaml b/.helm/trafficcop/templates/hpa.yaml new file mode 100644 index 000000000..196a6aa3a --- /dev/null +++ b/.helm/trafficcop/templates/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "trafficcop.fullname" . }} + labels: + {{- include "trafficcop.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "trafficcop.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/.helm/trafficcop/templates/ingress.yaml b/.helm/trafficcop/templates/ingress.yaml new file mode 100644 index 000000000..1d1b349a7 --- /dev/null +++ b/.helm/trafficcop/templates/ingress.yaml @@ -0,0 +1,61 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "trafficcop.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "trafficcop.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} + pathType: {{ .pathType }} + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} + {{- else }} + serviceName: {{ $fullName }} + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/.helm/trafficcop/templates/service.yaml b/.helm/trafficcop/templates/service.yaml new file mode 100644 index 000000000..5a340f435 --- /dev/null +++ b/.helm/trafficcop/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "trafficcop.fullname" . }} + labels: + {{- include "trafficcop.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "trafficcop.selectorLabels" . | nindent 4 }} diff --git a/.helm/trafficcop/templates/serviceaccount.yaml b/.helm/trafficcop/templates/serviceaccount.yaml new file mode 100644 index 000000000..c5667fe34 --- /dev/null +++ b/.helm/trafficcop/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "trafficcop.serviceAccountName" . }} + labels: + {{- include "trafficcop.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/.helm/trafficcop/templates/tests/test-connection.yaml b/.helm/trafficcop/templates/tests/test-connection.yaml new file mode 100644 index 000000000..e37b51c5f --- /dev/null +++ b/.helm/trafficcop/templates/tests/test-connection.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "trafficcop.fullname" . }}-test-connection" + labels: + {{- include "trafficcop.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "trafficcop.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never diff --git a/.helm/trafficcop/values.yaml b/.helm/trafficcop/values.yaml new file mode 100644 index 000000000..663818d17 --- /dev/null +++ b/.helm/trafficcop/values.yaml @@ -0,0 +1,108 @@ +# Default values for trafficcop. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: docker-registry.ebrains.eu/siibra/trafficcop + pullPolicy: Always + # Overrides the image tag whose default is the chart appVersion. + tag: "latest" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +podAnnotations: {} +podLabels: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 8080 + +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +# livenessProbe: +# httpGet: +# path: / +# port: http +# readinessProbe: +# httpGet: +# path: / +# port: http + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +# Additional volumes on the output Deployment definition. +volumes: + - name: log-volume + persistentVolumeClaim: + claimName: log-volume-claim + +# Additional volumeMounts on the output Deployment definition. +volumeMounts: + - mountPath: /sxplr-log + name: log-volume + +nodeSelector: {} + +tolerations: [] + +affinity: {} + +envObj: + REDIRECT_URL: 'https://atlases.ebrains.eu/viewer' diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html index e10e90c52..f77b809f6 100644 --- a/.storybook/preview-head.html +++ b/.storybook/preview-head.html @@ -14,5 +14,4 @@ } - diff --git a/Dockerfile b/Dockerfile index 0f859d1dd..2bae7e743 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ARG BACKEND_URL ENV BACKEND_URL=${BACKEND_URL} ARG SIIBRA_API_ENDPOINTS -ENV SIIBRA_API_ENDPOINTS=${SIIBRA_API_ENDPOINTS:-https://siibra-api-stable.apps.hbp.eu/v3_0,https://siibra-api-stable.apps.jsc.hbp.eu/v3_0} +ENV SIIBRA_API_ENDPOINTS=${SIIBRA_API_ENDPOINTS:-https://siibra-api-stable.apps.hbp.eu/v3_0,https://siibra-api-prod.apps.tc.humanbrainproject.eu/v3_0} ARG STRICT_LOCAL ENV STRICT_LOCAL=${STRICT_LOCAL:-false} diff --git a/README.md b/README.md index f07b241af..614683c39 100644 --- a/README.md +++ b/README.md @@ -10,27 +10,36 @@ Copyright 2020-2021, Forschungszentrum Jülich GmbH -`siibra-explorer` is an frontend module wrapping around [nehuba](https://github.com/HumanBrainProject/nehuba) for visualizing volumetric brain volumes at possible high resolutions, and connecting to `siibra-api` for offering access to brain atlases of different species, including to navigate their brain region hierarchies, maps in different coordinate spaces, and linked regional data features. It provides metadata integration with the [EBRAINS knowledge graph](https://kg.ebrains.eu), different forms of data visualisation, and a structured plugin system for implementing custom extensions. +`siibra-explorer` is a browser based 3D viewer for exploring brain atlases that cover different spatial resolutions and modalities. It is built around an interactive 3D view of the brain displaying a unique selection of detailed templates and parcellation maps for the human, macaque, rat or mouse brain, including BigBrain as a microscopic resolution human brain model at its full resolution of 20 micrometers. + +![](https://data-proxy.ebrains.eu/api/v1/buckets/reference-atlas-data/static/siibra-explorer-teaser.png) + +`siibra-explorer` builds on top [nehuba](https://github.com/HumanBrainProject/nehuba) for the visualization volumetric brain volumes at possible high resolutions, and [three-surfer](https://github.com/xgui3783/three-surfer) for the visualization of surface based atlases. By connecting to [siibra-api](https://github.com/fzj-inm1-bda/siibra-api), `siibra-explorer` gains access to brain atlases of different species, including to navigate their brain region hierarchies, maps in different coordinate spaces, and linked regional data features. It provides metadata integration with the [EBRAINS knowledge graph](https://kg.ebrains.eu), different forms of data visualisation, and a structured plugin system for implementing custom extensions. ## Getting Started -A live version of the siibra explorer is available at [https://atlases.ebrains.eu/viewer/](https://atlases.ebrains.eu/viewer/). This section is useful for developers who would like to develop this project. +A live version of the siibra explorer is available at [https://atlases.ebrains.eu/viewer/](https://atlases.ebrains.eu/viewer/). User documentation can be found at . This README.md is aimed at developers who would like to develop and run `siibra-explorer` locally. ### General information Siibra explorer is built with [Angular (v14.0)](https://angular.io/), [Bootstrap (v4)](http://getbootstrap.com/), and [fontawesome icons](https://fontawesome.com/). Some other notable packages used are [ngrx/store](https://github.com/ngrx/platform) for state management. -Releases newer than [v0.2.9](https://github.com/HumanBrainProject/interactive-viewer/tree/v0.2.9) also uses a nodejs backend, which uses [passportjs](http://www.passportjs.org/) for user authentication, [express](https://expressjs.com/) as a http framework. +Releases newer than [v0.2.9](https://github.com/fzj-inm1-bda/siibra-explorer/releases/tag/v0.2.9) also uses a nodejs backend, which uses [passportjs](http://www.passportjs.org/) for user authentication, [express](https://expressjs.com/) as the http framework. + +Releases newer than [v2.13.0](https://github.com/fzj-inm1-bda/siibra-explorer/releases/tagv2.13.0) uses a python backend, which uses [authlib](https://pypi.org/project/Authlib/) for user authentication, [fastapi](https://pypi.org/project/fastapi/) as the http framework. + ### Develop #### Prerequisites -- node 12.20.0 or later +- node 16 or later #### Environments -It is recommended to manage your environments with `.env` file. +Development environments are stored under `src/environments/environment.common.ts`. At build time, `src/environments/environment.prod.ts` will be used to overwrite the environment. + +Whilst this approach adds some complexity to the development/build process, it enhances developer experience by allowing the static typing of environment variables. ##### Buildtime environments @@ -44,28 +53,35 @@ Please see [deploy_env.md](deploy_env.md) Please see [e2e_env.md](e2e_env.md) -#### Start dev server +#### Development -To run a dev server, run: +To run a frontend dev server, run: ```bash $ git clone https://github.com/FZJ-INM1-BDA/siibra-explorer $ cd siibra-explorer $ npm i -$ npm run dev-server +$ npm start +``` + +To run backend dev server: + +```bash +$ cd backend +$ pip install -r requirements.txt +$ uvicorn app.app:app --host 0.0.0.0 --port 8080 ``` -Start backend (in a separate terminal): +### Test ```bash -$ cd deploy -$ node server.js +$ npm run test ``` #### Build ```bash -$ npm run build-aot +$ npm run build ``` ### Develop plugins diff --git a/backend/app/auth.py b/backend/app/auth.py index fc3fed6a8..4cba29b2c 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -7,7 +7,7 @@ import json from .const import EBRAINS_IAM_DISCOVERY_URL, SCOPES, PROFILE_KEY -from .config import HBP_CLIENTID_V2, HBP_CLIENTSECRET_V2, HOST_PATHNAME, HOSTNAME +from .config import HBP_CLIENTID_V2, HBP_CLIENTSECRET_V2, HOST_PATHNAME from ._store import RedisEphStore _store = RedisEphStore.Ephemeral() @@ -38,13 +38,13 @@ def process_ebrains_user(resp): router = APIRouter() -redirect_uri = HOSTNAME.rstrip("/") + HOST_PATHNAME + "/hbp-oidc-v2/cb" - @router.get("/hbp-oidc-v2/auth") async def login_via_ebrains(request: Request, state: str = None): kwargs = {} if state: kwargs["state"] = state + base_url = str(request.base_url).replace("http://", "https://", 1) + redirect_uri = base_url.rstrip("/") + HOST_PATHNAME + "/hbp-oidc-v2/cb" return await oauth.ebrains.authorize_redirect(request, redirect_uri=redirect_uri, **kwargs) @router.get("/hbp-oidc-v2/cb") diff --git a/backend/app/config.py b/backend/app/config.py index bc824fd52..d51631de3 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -4,10 +4,12 @@ HOST_PATHNAME = os.getenv("HOST_PATHNAME", "") -HOSTNAME = os.getenv("HOSTNAME", "http://localhost:3000") - OVERWRITE_API_ENDPOINT = os.getenv("OVERWRITE_API_ENDPOINT") +OVERWRITE_SPATIAL_ENDPOINT = os.getenv("OVERWRITE_SPATIAL_ENDPOINT") + +EXPERIMENTAL_FLAG = os.getenv("EXPERIMENTAL_FLAG") + LOCAL_CDN = os.getenv("LOCAL_CDN") HBP_CLIENTID_V2 = os.getenv("HBP_CLIENTID_V2", "no hbp id") diff --git a/backend/app/const.py b/backend/app/const.py index 9948b8e47..071545dee 100644 --- a/backend/app/const.py +++ b/backend/app/const.py @@ -19,3 +19,7 @@ DATA_ERROR_ATTR = "data-error" OVERWRITE_SAPI_ENDPOINT_ATTR = "x-sapi-base-url" + +OVERWRITE_SPATIAL_BACKEND_ATTR = "x-spatial-backend-url" + +OVERWRITE_EXPERIMENTAL_FLAG_ATTR = "x-experimental-flag" diff --git a/backend/app/index_html.py b/backend/app/index_html.py index 903ec1547..c054d76ca 100644 --- a/backend/app/index_html.py +++ b/backend/app/index_html.py @@ -2,8 +2,8 @@ from pathlib import Path from fastapi.responses import Response from typing import Dict -from .const import ERROR_KEY, DATA_ERROR_ATTR, OVERWRITE_SAPI_ENDPOINT_ATTR, COOKIE_KWARGS -from .config import PATH_TO_PUBLIC, OVERWRITE_API_ENDPOINT +from .const import ERROR_KEY, DATA_ERROR_ATTR, OVERWRITE_SAPI_ENDPOINT_ATTR, COOKIE_KWARGS, OVERWRITE_SPATIAL_BACKEND_ATTR, OVERWRITE_EXPERIMENTAL_FLAG_ATTR +from .config import PATH_TO_PUBLIC, OVERWRITE_API_ENDPOINT, OVERWRITE_SPATIAL_ENDPOINT, EXPERIMENTAL_FLAG path_to_index = Path(PATH_TO_PUBLIC) / "index.html" index_html: str = None @@ -26,14 +26,19 @@ async def get_index_html(request: Request): error = None attributes_to_append: Dict[str, str] = {} if ERROR_KEY in request.session: - error = request.session[ERROR_KEY] + error = request.session.pop(ERROR_KEY) attributes_to_append[DATA_ERROR_ATTR] = error if OVERWRITE_API_ENDPOINT: attributes_to_append[OVERWRITE_SAPI_ENDPOINT_ATTR] = OVERWRITE_API_ENDPOINT + if OVERWRITE_SPATIAL_ENDPOINT: + attributes_to_append[OVERWRITE_SPATIAL_BACKEND_ATTR] = OVERWRITE_SPATIAL_ENDPOINT - attr_string = " ".join([f"{key}={_monkey_sanitize(value)}" for key, value in attributes_to_append.items()]) + if EXPERIMENTAL_FLAG: + attributes_to_append[OVERWRITE_EXPERIMENTAL_FLAG_ATTR] = EXPERIMENTAL_FLAG + + attr_string = " ".join([f'{key}="{_monkey_sanitize(value)}"' for key, value in attributes_to_append.items()]) resp_string = index_html.replace("", f"") diff --git a/backend/app/sane_url.py b/backend/app/sane_url.py index e65c1982c..8a68cf884 100644 --- a/backend/app/sane_url.py +++ b/backend/app/sane_url.py @@ -10,7 +10,7 @@ from pydantic import BaseModel from .config import SXPLR_EBRAINS_IAM_SA_CLIENT_ID, SXPLR_EBRAINS_IAM_SA_CLIENT_SECRET, SXPLR_BUCKET_NAME, HOST_PATHNAME -from .const import EBRAINS_IAM_DISCOVERY_URL +from .const import EBRAINS_IAM_DISCOVERY_URL, ERROR_KEY from ._store import DataproxyStore from .user import get_user_from_request @@ -135,10 +135,11 @@ def delete(self, key: str): @router.get("/{short_id:str}") async def get_short(short_id:str, request: Request): + accept = request.headers.get("Accept", "") + is_browser = "text/html" in accept try: existing_json: Dict[str, Any] = data_proxy_store.get(short_id) - accept = request.headers.get("Accept", "") - if "text/html" in accept: + if is_browser: hashed_path = existing_json.get("hashPath") extra_routes = [] for key in existing_json: @@ -151,8 +152,14 @@ async def get_short(short_id:str, request: Request): return RedirectResponse(f"{HOST_PATHNAME}/#{hashed_path}{extra_routes_str}") return JSONResponse(existing_json) except DataproxyStore.NotFound as e: + if is_browser: + request.session[ERROR_KEY] = f"Short ID {short_id} not found." + return RedirectResponse(HOST_PATHNAME or "/") raise HTTPException(404, str(e)) except DataproxyStore.GenericException as e: + if is_browser: + request.session[ERROR_KEY] = f"Error: {str(e)}" + return RedirectResponse(HOST_PATHNAME or "/") raise HTTPException(500, str(e)) diff --git a/build_env.md b/build_env.md index caaf0ecff..f0eb84c5c 100644 --- a/build_env.md +++ b/build_env.md @@ -4,10 +4,11 @@ As siibra-explorer uses [webpack define plugin](https://webpack.js.org/plugins/d | name | description | default | example | | --- | --- | --- | --- | +| `GIT_HASH` | Used to finely identify siibra-explorer version | | | +| `VERSION` | Used to coarsely identify siibra-explorer version | | | | `PRODUCTION` | if the build is for production, toggles optimisations such as minification | `undefined` | true | | `BACKEND_URL` | backend that the viewer calls to fetch available template spaces, parcellations, plugins, datasets | `null` | https://interactive-viewer.apps.hbp.eu/ | -| ~~`BS_REST_URL`~~ _deprecated. use `SIIBRA_API_ENDPOINTS` instead_ | [siibra-api](https://github.com/FZJ-INM1-BDA/siibra-api) used to fetch different resources | `https://siibra-api-stable.apps.hbp.eu/v1_0` | -| `SIIBRA_API_ENDPOINTS` | Comma separated endpoints of [siibra-api](https://github.com/FZJ-INM1-BDA/siibra-api) used to fetch different resources | `https://siibra-api-stable.apps.hbp.eu/v2_0,https://siibra-api-stable-ns.apps.hbp.eu/v2_0,https://siibra-api-stable.apps.jsc.hbp.eu/v2_0` | +| `SIIBRA_API_ENDPOINTS` | Comma separated endpoints of [siibra-api](https://github.com/FZJ-INM1-BDA/siibra-api) used to fetch different resources | `https://siibra-api-stable.apps.hbp.eu/v3_0,https://siibra-api-prod.apps.tc.humanbrainproject.eu/v3_0` | | `MATOMO_URL` | base url for matomo analytics | `null` | https://example.com/matomo/ | | `MATOMO_ID` | application id for matomo analytics | `null` | 6 | | `STRICT_LOCAL` | hides **Explore** and **Download** buttons. Useful for offline demo's | `false` | `true` | diff --git a/common/constants.js b/common/constants.js index fb5a018be..0e9f6b312 100644 --- a/common/constants.js +++ b/common/constants.js @@ -150,7 +150,9 @@ If you do not accept the Terms & Conditions you are not permitted to access or u 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` + OVERWRITE_SPATIAL_BACKEND_ATTR: `x-spatial-backend-url`, + OVERWRITE_EXPERIMENTAL_FLAG_ATTR: `x-experimental-flag`, + DATA_ERROR_ATTR: `data-error`, } exports.QUICKTOUR_DESC ={ diff --git a/common/util.spec.js b/common/util.spec.js index 552240c56..54609f3bf 100644 --- a/common/util.spec.js +++ b/common/util.spec.js @@ -171,8 +171,7 @@ describe('common/util.js', () => { } finally { const end = performance.now() - expect(end - start).toBeGreaterThan(defaultTimeout) - expect(end - start).toBeLessThan(defaultTimeout + 20) + expect(end - start).toBeGreaterThanOrEqual(defaultTimeout) } }) }) @@ -196,8 +195,7 @@ describe('common/util.js', () => { } finally { const end = performance.now() - expect(end - start).toBeGreaterThan(timeout) - expect(end - start).toBeLessThan(timeout + 20) + expect(end - start).toBeGreaterThanOrEqual(timeout) } }) }) diff --git a/deploy_env.md b/deploy_env.md index 8c2b1a3d8..1ead2b474 100644 --- a/deploy_env.md +++ b/deploy_env.md @@ -9,6 +9,7 @@ | `V2_7_STAGING_PLUGIN_URLS` | semi colon separated urls to be returned when user queries plugins | `''` | `BUILD_TEXT` | overlay text at bottom right of the viewer. set to `''` to hide. | | | `OVERWRITE_API_ENDPOINT` | overwrite build time siibra-api endpoint | +| `OVERWRITE_SPATIAL_ENDPOINT` | overwrite build time spatial transform endpoint | | | `PATH_TO_PUBLIC` | path to built frontend | `../dist/aot` | @@ -16,7 +17,6 @@ | name | description | default | example | | --- | --- | --- | --- | -| `HOSTNAME` | | `HOST_PATHNAME` | pathname to listen on, restrictions: leading slash, no trailing slash | `''` | `/viewer` | | `HBP_CLIENTID_V2` | | `HBP_CLIENTSECRET_V2` | diff --git a/docs/advanced/url_encoding.md b/docs/advanced/url_encoding.md new file mode 100644 index 000000000..330475ee8 --- /dev/null +++ b/docs/advanced/url_encoding.md @@ -0,0 +1,54 @@ +# URL encoding + +!!! warning + It is generally advised that users leverage the [explorer module of siibra-python](https://siibra-python.readthedocs.io/en/latest/autoapi/siibra/explorer/index.html#module-siibra.explorer) to encode and decode URL. + +siibra-explorer achieves [state persistence](../basics/storing_and_sharing_3d_views.md) via URL encoding. + +## Basics + +State that are persisted is first serialized to string. These string are then prefixed by the label representing the string. The list is joined with `/` as a delimiter. + +The resultant string is then prepended by `#/`, where `` is usually `https://atlases.ebrains.eu/viewer/`. + +!!! example + User navigated to the following viewer configuration + + | state | prefix | value | serialized | prefix + serialized | + | --- | --- | --- | --- | --- | + | atlas | `a:` | Multilevel Human Atlas | `juelich:iav:atlas:v1.0.0:1` | `a:juelich:iav:atlas:v1.0.0:1` | + | space | `t:` | ICBM 2009c nonlinear asymmetrical | `minds:core:referencespace:v1.0.0:dafcffc5-4826-4bf1-8ff6-46b8a31ff8e2` | `t:minds:core:referencespace:v1.0.0:dafcffc5-4826-4bf1-8ff6-46b8a31ff8e2` | + | parcellation | `p:` | Julich Brain v3.0.3 | `minds:core:parcellationatlas:v1.0.0:94c1125b-b87e-45e4-901c-00daee7f2579-300` | `p:minds:core:parcellationatlas:v1.0.0:94c1125b-b87e-45e4-901c-00daee7f2579-300` | + + The URL would be + + ``` + https://atlases.ebrains.eu/viewer/#/a:juelich:iav:atlas:v1.0.0:1/t:minds:core:referencespace:v1.0.0:dafcffc5-4826-4bf1-8ff6-46b8a31ff8e2/p:minds:core:parcellationatlas:v1.0.0:94c1125b-b87e-45e4-901c-00daee7f2579-300 + ``` + +## Escaping Characters + +As `/` character is used to separate state, it is escaped to `:`. + +## References + +Here is a comprehensive list of the state encoded in the URL: + +| selected state | prefix | value | example | +| --- | --- | --- | --- | +| atlas | `a:` | id property | | +| parcellation | `p:` | id property | | +| space | `t:` | id property | | +| region | `rn:` | Quick hash of region name[^1] | | +| navigation | `@:` | navigation state hash[^2] | | +| feature | `f:` | id property + additional escaping[^3] | | +| misc viewer state | `vs:` | misc viewer state serialization[^4] | | +| auto launch plugin | `pl` (query param) | stringified JSON representing `string[]` | `?pl=%5B%22http%3A%2F%2Flocalhost%3A1234%2Fmanifest.json%22%5D` . Modern browsers also accept `?pl=["http://localhost:1234/manifest.json"]` | + +[^1]: Quick hash. [[source]](https://github.com/FZJ-INM1-BDA/siibra-explorer/blob/v2.14.4/src/util/fn.ts#L146-L154) Quick one way hash. It will likely be deprecated in favor of [crypto.digest](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest) in the near future. + +[^2]: Encoding navigation state. [[source]](https://github.com/FZJ-INM1-BDA/siibra-explorer/blob/v2.14.4/src/routerModule/routeStateTransform.service.ts#L366-L372) Each of the following state are encoded: `orientation`, `perspectiveOrientation`, `perspectiveZoom`, `position`, `zoom`. They are cast into `[f32, f32, f32, f32]`, `[f32, f32, f32, f32]`, `int`, `[int, int, int]` and `int` respective. Each of the number is base64 encoded [[source]](https://github.com/FZJ-INM1-BDA/siibra-explorer/blob/v2.14.4/common/util.js#L242-L313) with the following cipher: `0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-`. Negation is denoted using `~` as the beginning of encoded value. If the state consists of a tuple of values, they are joined by a single separator (i.e. `:`). The encoded state is then joined with two separators (i.e. `::`) + +[^3]: additional feature id escaping: since feature id can be a lot more varied, they are further encoded by: first instance of `://` is replaced with `~ptc~`; all instances of `:` is replaced with `~`; *any* occurances `[()]` are URL encoded. + +[^4]: miscellaneous viewer state serialization. [[source]](https://github.com/FZJ-INM1-BDA/siibra-explorer/blob/v2.14.4/src/routerModule/routeStateTransform.service.ts#L272-L293) Various viewer configuration related state is encoded. This encoded state is versioned, in order to preserve backwards compatibility. The current version is `v1`. In the current version, three `uint8` values are base64 encoded. First encodes for panel mode ( four-panel, `FOUR_PANEL`, encoded as `1`; `PIP_PANEL`, encoded as `2`). Second encodes for panel order. Third encodes for the bit masked boolean flags for octant removal and show delination, with the remaining 6 bits ignored. diff --git a/docs/basics/exploring_3d_parcellation_maps.md b/docs/basics/exploring_3d_parcellation_maps.md index 86a52f6ed..71eef60cb 100644 --- a/docs/basics/exploring_3d_parcellation_maps.md +++ b/docs/basics/exploring_3d_parcellation_maps.md @@ -11,7 +11,7 @@ Note: - Vice versa, when selecting a parcellation which is not available as a map in the currently selected reference space, you will be asked to select a different space. !!! tip "Downloading the current parcellation map" - You can download the currently selected reference template and parcellation map for offline use by clicking the download button on the top right. + You can download the currently selected reference template and parcellation map for offline use by clicking the download button :material-download: on the top right. In the case of a volumetric template, siibra-explorer combines a rotatable 3D surface view of a brain volume with three planar views of orthogonal image planes (coronal, sagittal, horizontal). It can visualize very large brain volumes in the Terabyte range (here: BigBrain model [^1]). @@ -19,7 +19,7 @@ In the case of a volumetric template, siibra-explorer combines a rotatable 3D su Each planar view allows zooming (`[mouse-wheel]`) and panning (`[mouse-drag]`). You can change the default planes to arbitrary oblique cutting planes using ` + [mouse-drag]`. This is especially useful for inspecting cortical layers and brain regions in their optimal 3D orientation when browsing a microscopic volume. -In addition, each planar view can be maximized to full screen (`[mouse-over]` then `` on `[ ]` icon) to behave like a 2D image viewer. +In addition, each planar view can be maximized to full screen (`[mouse-over]` then `` on :fontawesome-solid-expand: icon) to behave like a 2D image viewer. After maximizing a view, `[space]` cycles between the four available views. ![](https://data-proxy.ebrains.eu/api/v1/buckets/reference-atlas-data/static/julichbrain_bigbrain_coronal.png){: style="width:600px" } diff --git a/docs/basics/selecting_brain_regions.md b/docs/basics/selecting_brain_regions.md index 09845ff8c..ee57fd07a 100644 --- a/docs/basics/selecting_brain_regions.md +++ b/docs/basics/selecting_brain_regions.md @@ -45,9 +45,9 @@ For convenience, the region tree allows to you directly switch species, space an Regions are typically organized as a hierarchy of parent and child regions. For example, in the Julich-Brain parcellation, the top parent nodes are *Telencephalon*, *Metencephalon*, and *Diencephalon*, further subdivided into macroscopic structures such as lobes which then contain subhierarchies of cortical and subcortical regions. -## The region sidepanel +## The Region Side Panel -After finding and selecting a brain region, `siibra-explorer` opens the [region sidepanel](#the-region-sidepanel). +After finding and selecting a brain region, `siibra-explorer` opens the region side panel. ![](https://data-proxy.ebrains.eu/api/v1/buckets/reference-atlas-data/static/siibra-explorer-regionpanel-detail.png){ style="width:300px"} diff --git a/docs/basics/storing_and_sharing_3d_views.md b/docs/basics/storing_and_sharing_3d_views.md index 4540f926a..acae37537 100644 --- a/docs/basics/storing_and_sharing_3d_views.md +++ b/docs/basics/storing_and_sharing_3d_views.md @@ -7,11 +7,11 @@ Any views in `siibra-explorer` can be shared via their URLs. You can use any nat !!! info Automatic markdown renderer such as rocketchat can interfere with the rendering of the URL. To circumvent the issue, it is recommended that you generate a sane URL, or use a `code` block to escape markup -## Creating short URLs ("saneURL") +## Creating short URLs (saneURL) Whilst `siibra-explorer` encodes the current view directly in the URL, such full URLs are quite long and not easily recitable or memorable. You can create short URLs for the current view directly in `siibra-explorer`. To do so, -1. click the *view navigation panel* on the top left (see [main UI elements](../ui/main_elements.md#-and-plugins), +1. click the *view navigation panel* on the top left (see [main UI elements](../ui/main_elements.md#-and-plugins)), 2. click the "share icon", then 3. select `Create custom URL`. @@ -24,7 +24,7 @@ Whilst `siibra-explorer` encodes the current view directly in the URL, such full While you can use any screenshot tool provided by your browser or operating system, `siibra-explorer` offers a dedicated screenshot function which generates a clean image of the current 3D view which hides other user interface elements. To access the screenshot tool, -1. open the tool menu (`᎒᎒᎒`) from the top right (see [main UI elements](../ui/main_elements.md#tools-and-plugins)), then +1. open the tool menu (:material-apps:) from the top right (see [main UI elements](../ui/main_elements.md#tools-and-plugins)), then 2. select `Screenshot`. ![Creating a screenshot in siibra-explorer](https://data-proxy.ebrains.eu/api/v1/buckets/reference-atlas-data/static/siibra-explorer-screenshots.png){ style="width:700px"} diff --git a/docs/getstarted/atlas_elements.md b/docs/getstarted/atlas_elements.md index 13421e4ac..2f4a2790c 100644 --- a/docs/getstarted/atlas_elements.md +++ b/docs/getstarted/atlas_elements.md @@ -36,4 +36,4 @@ siibra provides access to data features anchored to locations in the brain. Loca [^4]: Amunts K, Mohlberg H, Bludau S, Zilles K. Julich-Brain: A 3D probabilistic atlas of the human brain’s cytoarchitecture. Science. 2020;369(6506):988-992. doi:[10.1126/science.abb4588](https://doi.org/10.1126/science.abb4588) -[^5]: Lebenberg J, Labit M, Auzias G, Mohlberg H, Fischer C, Rivière D, Duchesnay E, Kabdebon C, Leroy F, Labra N, Poupon F, Dickscheid T, Hertz-Pannier L, Poupon C, Dehaene-Lambertz G, Hüppi P, Amunts K, Dubois J, Mangin JF. A framework based on sulcal constraints to align preterm, infant and adult human brain images acquired in vivo and post mortem. Brain Struct Funct. 2018;223(9):4153-4168. doi:[10.1007/s00429-018-1735-9](10.1007/s00429-018-1735-9) +[^5]: Lebenberg J, Labit M, Auzias G, Mohlberg H, Fischer C, Rivière D, Duchesnay E, Kabdebon C, Leroy F, Labra N, Poupon F, Dickscheid T, Hertz-Pannier L, Poupon C, Dehaene-Lambertz G, Hüppi P, Amunts K, Dubois J, Mangin JF. A framework based on sulcal constraints to align preterm, infant and adult human brain images acquired in vivo and post mortem. Brain Struct Funct. 2018;223(9):4153-4168. doi:[10.1007/s00429-018-1735-9](https://doi.org/10.1007/s00429-018-1735-9) diff --git a/docs/getstarted/ui.md b/docs/getstarted/ui.md index 28951dc9e..06309b395 100644 --- a/docs/getstarted/ui.md +++ b/docs/getstarted/ui.md @@ -17,7 +17,7 @@ For more information, read about [Exploring 3D parcellation maps](../basics/expl ## View navigation panel & coordinate lookups -At the top left of the user interface, `siibra-explorer` displays the 3D coordinate of the currently selected center of view, together with buttons for expanding the panel (⌄) and entering custom coordinates (✎). +At the top left of the user interface, `siibra-explorer` displays the 3D coordinate of the currently selected center of view, together with buttons for expanding the panel (:material-chevron-down:) and entering custom coordinates (:fontawesome-solid-pen:). Expanding the panel allows to allows to modify the center point and create a shareable link to the current view (see ["Storing and sharing 3D views"](../basics/storing_and_sharing_3d_views.md)). !!! tip "Use coordinate lookups do probabilistic assignment" @@ -28,14 +28,14 @@ Expanding the panel allows to allows to modify the center point and create a sha ## Atlas selection panel At the bottom of the window, you find buttons to switch between different species, reference templates and parcellation maps. -Working with parcellation maps is described in ["Exploring parcellation maps"](../basics/exploring_3d_parcellation_maps.md)). +Working with parcellation maps is described in [Exploring parcellation maps](../basics/exploring_3d_parcellation_maps.md). Note that some of the buttons may be hidden in case that only one option is available. ![](https://data-proxy.ebrains.eu/api/v1/buckets/reference-atlas-data/static/siibra-explorer-chips.png){ style="width:500px"} ## Region search panel -The magnifying glass icon (🔍) in the top left reveals the region search panel. Here you can type keywords to find matching brain regions in the currently selected parcellation, and open the extended regionstree search. +The magnifying glass icon (:octicons-search-16:) in the top left reveals the region search panel. Here you can type keywords to find matching brain regions in the currently selected parcellation, and open the extended regionstree search. To learn more, read about [selecting brain regions](../basics/selecting_brain_regions.md). ![](https://data-proxy.ebrains.eu/api/v1/buckets/reference-atlas-data/static/siibra-explorer-regionsearch.png){ style="width:500px"} @@ -47,17 +47,17 @@ At the top right of the viewer, there are several icons guiding you to additiona ![](https://data-proxy.ebrains.eu/api/v1/buckets/reference-atlas-data/static/siibra-explorer-tools.png){ style="width:500px"} -#### (?) Help panel -The help button (?) opens information about keyboard shortcuts and terms of use. Here you can also launch the interactive quick tour, which is started automatically when you use `siibra-explorer` for the first time. +#### :octicons-question-16: Help panel +The help button :octicons-question-16: opens information about keyboard shortcuts and terms of use. Here you can also launch the interactive quick tour, which is started automatically when you use `siibra-explorer` for the first time. -#### Download current view -The download button () will retrieve the reference template and parcellation map currently displayed in a zip file package +#### :material-download: Download current view +The download button (:material-download:) will retrieve the reference template and parcellation map currently displayed in a zip file package -#### ᎒᎒᎒ Plugins -The plugin button (᎒᎒᎒) reveals a menu of interactive plugins, including advanced tools for [annotation](../advanced/annotating_structures.md) and [differential gene expression analysis](../advanced/differential_gene_expression_analysis.md) +#### :material-apps: Plugins +The plugin button (:material-apps:) reveals a menu of interactive plugins, including advanced tools for [annotation](../advanced/annotating_structures.md) and [differential gene expression analysis](../advanced/differential_gene_expression_analysis.md) -#### 👤 Sign in with EBRAINS -The login button (👤) allows you to sign in with an EBRAINS account to access some custom functionalities for sharing. +#### :fontawesome-solid-user: Sign in with EBRAINS +The login button (:fontawesome-solid-user:) allows you to sign in with an EBRAINS account to access some custom functionalities for sharing. !!! tip "Get an EBRAINS account!" You can sign up for a free EBRAINS account at diff --git a/docs/releases/v2.14.5.md b/docs/releases/v2.14.5.md new file mode 100644 index 000000000..233bc7522 --- /dev/null +++ b/docs/releases/v2.14.5.md @@ -0,0 +1,31 @@ +# v2.14.5 + +## Feature + +- Add support for compound feature +- Added documentation for URL encoding +- Improved documentation for plugin API +- Reworded point assignment UI +- Allow multi selected region names to be copied +- Added legend to region hierarchy +- Allow latest queried concept in feature view +- Allow experimental flag to be set to be on at runtime (this also shows the button, allow toggling of experimental features) +- Feature: added supported for .annot fsaverage label +- (experimental) Added code snippet to limited panels +- (experimental) allow addition of custom linear coordinate space +- (experimental) show BigBrain slice number +- (experimental) allow big brain template to be downloaded + +## Bugfix + +- Copy of free text (Ctrl + C) now works properly +- Fixes issue where annotation mode was not displaying correctly, after selecting volume of interest +- When saneURL is not found, siibra-explorer will not correctly redirects to default app, and show the error message + +## Behind the Scenes + +- Removed dependency on connectivity-component +- Removed reference to JSC OKD instance, as the instance is no longer available +- Updated google-site-verification +- Allow inter-space transform to be configured at runtime +- Fetch `/meta.json` for additional metadata related to a volume diff --git a/e2e/checklist.md b/e2e/checklist.md deleted file mode 100644 index d6f3f1dce..000000000 --- a/e2e/checklist.md +++ /dev/null @@ -1,89 +0,0 @@ -# Staging Checklist - -**use incognito browser** - -[home page](https://atlases.ebrains.eu/viewer-staging/) - -## General - -- [ ] Can access front page -- [ ] Can login to oidc v2 via top-right - -## Verify testing correct siibra-explorer and siibra-api versions - -- [ ] Git hash from `[?]` -> `About` matches with the git hash of `HEAD` of staging -- [ ] Message `Expecting {VERSION}, but got {VERSION}, some functionalities may not work as expected` does **not** show - -## Atlas data specific - -- [ ] Human multilevel atlas - - [ ] on click from home page, MNI152, Julich v2.9 loads without issue - - [ ] on hover, show correct region name(s) - - [ ] Parcellation smart chip - - [ ] show/hide parcellation toggle exists and works - - [ ] `q` is a shortcut to show/hide parcellation toggle - - [ ] info button exists and works - - [ ] info button shows desc, and link to KG - - [ ] regional is fine :: select hOC1 right - - [ ] probabilistic map loads fine - - [ ] segmentation layer hides - - [ ] `navigate to` button exists, and works - - [ ] `Open in KG` button exists and works - - [ ] `Description` tabs exists and works - - [ ] `Regional features` tab exists and works - - [ ] `Receptor density` dataset exists and works - - [ ] `Open in KG` button exists and works - - [ ] `Preview` tab exists and works - - [ ] fingerprint is shown, interactable - - [ ] profiles can be loaded, interactable - - [ ] `Connectivity` tab exists and works - - [ ] on opening tab, PMap disappear, colour mapped segmentation appears - - [ ] on closing tab, PMap reappear, segmentation hides - - [ ] switching template/parc - - [ ] mni152 julich brain 29 (big brain) -> big brain julich brain 29 - - [ ] big brain julich brain 29 (long bundle) -> (asks to change template) - - [ ] in big brain v2.9 (or latest) - - [ ] high res hoc1, hoc2, hoc3, lam1-6 are visible - - [ ] pli dataset [link](https://atlases.ebrains.eu/viewer-staging/?templateSelected=Big+Brain+%28Histology%29&parcellationSelected=Grey%2FWhite+matter&cNavigation=0.0.0.-W000.._eCwg.2-FUe3._-s_W.2_evlu..7LIx..1uaTK.Bq5o~.lKmo~..NBW&previewingDatasetFiles=%5B%7B%22datasetId%22%3A%22minds%2Fcore%2Fdataset%2Fv1.0.0%2Fb08a7dbc-7c75-4ce7-905b-690b2b1e8957%22%2C%22filename%22%3A%22Overlay+of+data+modalities%22%7D%5D) - - [ ] redirects fine - - [ ] shows fine - - [ ] fsaverage - - [ ] can be loaded & visible -- [ ] Waxholm - - [ ] v4 are visible - - [ ] on hover, show correct region name(s) - - [ ] whole mesh loads - -## Pt Assignments - -- [ ] human MNI152 julich brain should work (statistical) -- [ ] rat waxholm v4 should work (labelled) -- [ ] csv can be downloaded -- [ ] big brain & fsaverage *shouldn't* work - -## Download atlas - -- [ ] human MNI152 julich brain can be downloaded -- [ ] human MNI152 julich brain hoc1 left can be downloaded -- [ ] rat waxholm v4 can be downloaded - -## saneURL -- [ ] saneurl generation functions properly - - [ ] try existing key (human), and get unavailable error - - [ ] try non existing key, and get available - - [ ] create use key `x_tmp_foo` and new url works -- [ ] [saneUrl](https://atlases.ebrains.eu/viewer-staging/saneUrl/bigbrainGreyWhite) redirects to big brain -- [ ] [saneUrl](https://atlases.ebrains.eu/viewer-staging/saneUrl/julichbrain) redirects to julich brain (colin 27) -- [ ] [saneUrl](https://atlases.ebrains.eu/viewer-staging/saneUrl/whs4) redirects to waxholm v4 -- [ ] [saneUrl](https://atlases.ebrains.eu/viewer-staging/saneUrl/allen2017) redirects to allen 2017 -- [ ] [saneUrl](https://atlases.ebrains.eu/viewer-staging/saneUrl/mebrains) redirects to monkey -- [ ] [saneUrl](https://atlases.ebrains.eu/viewer-staging/saneUrl/stnr) redirects to URL that contains annotations - -## VIP URL -- [ ] [vipUrl](https://atlases.ebrains.eu/viewer-staging/human) redirects to human mni152 -- [ ] [vipUrl](https://atlases.ebrains.eu/viewer-staging/monkey) redirects mebrains -- [ ] [vipUrl](https://atlases.ebrains.eu/viewer-staging/rat) redirects to waxholm v4 -- [ ] [vipUrl](https://atlases.ebrains.eu/viewer-staging/mouse) redirects allen mouse 2017 - -## plugins -- [ ] jugex plugin works diff --git a/features/_convert.py b/features/_convert.py new file mode 100644 index 000000000..ecd36cac4 --- /dev/null +++ b/features/_convert.py @@ -0,0 +1,125 @@ +from pathlib import Path +from typing import TypedDict, Literal +import sys + + +from gherkin.parser import Parser + +class Location(TypedDict): + line: int + column: int + + +class Base(TypedDict): + tags: list[str] + keyword: str + name: str + location: Location + description: str + + +class Step(TypedDict): + id: str + location: Location + keyword: list[str] + keywordType: Literal['Context', 'Action', 'Outcome'] + text: str + + +class Scenario(Base): + id: str + examples: list[str] + steps: list[Step] + + +class ScenarioDict(TypedDict): + scenario: Scenario + + +class Feature(Base): + language: str + children: list[ScenarioDict] + + +class Comment(TypedDict): + location: Location + text: str + + +class ParsedAST(TypedDict): + feature: Feature + comments: list[Comment] + + + +def gherkin_to_markdown(gherkin_text): + parser = Parser() + feature: ParsedAST = parser.parse(gherkin_text) + + ret_text: list[str] = [] + + f = feature['feature'] + + feature_name = f['name'] + + ret_text.append(f['description'].strip()) + + for scenario in f['children']: + s = scenario['scenario'] + ret_text.append( + f"### {s['name']}" + ) + for step in s['steps']: + verb = step['keywordType'] + if verb == "Context": + verb = "Given" + if verb == "Action": + verb = "When" + if verb == "Outcome": + verb = "Then" + + ret_text.append( + f"- **{verb}** {step['text']}" + ) + + ret_text.append( + f"- [ ] Works" + ) + + return ( + """
""" + + f"""{feature_name}""" + + "\n\n" + + '\n\n'.join(ret_text) + + "\n\n" + + """
""" + + "\n\n" + + "- [ ] All Checked" + + "\n\n" + + "---" + + "\n\n" + ) + + +def main(output: str="./e2e/checklist.md"): + + path_to_feature = Path("features") + markdown_txt = """# Staging Checklist + +**use incognito browser** + +[homepage](https://atlases.ebrains.eu/viewer-staging/) + +""" + for f in path_to_feature.iterdir(): + if f.suffix != ".feature": + continue + text = f.read_text() + markdown_txt += gherkin_to_markdown(text) + + with open(output, "w") as fp: + fp.write(markdown_txt) + + +if __name__ == "__main__": + main(*sys.argv[1:]) diff --git a/features/atlas-availability.feature b/features/atlas-availability.feature new file mode 100644 index 000000000..6fa438681 --- /dev/null +++ b/features/atlas-availability.feature @@ -0,0 +1,34 @@ +Feature: Atlas data availability + + Users should expect all facets of atlas to be available + + Scenario: User checks out high resolution Julich Brain regions + Given User launched the atlas viewer + When User selects Julich Brain v2.9 in Big Brain space + Then User should find high resolution hOc1, hOc2, hOc3, lam1-6 + + Scenario: User checks out PLI dataset from KG + When User clicks the link curated in KG [link](https://atlases.ebrains.eu/viewer-staging/?templateSelected=Big+Brain+%28Histology%29&parcellationSelected=Grey%2FWhite+matter&cNavigation=0.0.0.-W000.._eCwg.2-FUe3._-s_W.2_evlu..7LIx..1uaTK.Bq5o~.lKmo~..NBW&previewingDatasetFiles=%5B%7B%22datasetId%22%3A%22minds%2Fcore%2Fdataset%2Fv1.0.0%2Fb08a7dbc-7c75-4ce7-905b-690b2b1e8957%22%2C%22filename%22%3A%22Overlay+of+data+modalities%22%7D%5D) + Then User is redirected, and everything shows fine + + Scenario: User checks out Julich Brain in fsaverage + Given User launched the atlas viewer + When User selects Julich Brain in fsaverage space + Then The atlas loads and shows fine + + Scenario: User checks out Waxholm atlas + Given User launched the atlas viewer + When User selects Waxholm atlas + Then User is taken to the latest version (v4) + + Scenario: User finds Waxholm atlas showing fine + Given User checked out waxholm atlas + When User hovers volumetric atlas + Then the label representing the voxel shows + + Scenario: User finds Waxholm atlas mesh loads fine + Given User checked out waxholm atlas + Then whole mesh loads + + + \ No newline at end of file diff --git a/features/atlas-download.feature b/features/atlas-download.feature new file mode 100644 index 000000000..3940af6dd --- /dev/null +++ b/features/atlas-download.feature @@ -0,0 +1,24 @@ +Feature: Atlas Download + + Users should be able to download atlas + + Scenario: User downloads Julich Brain v3.0.3 in MNI152 + Given User launched the atlas viewer + Given User selects Julich Brain v3.0.3 in MNI152 + When User click `[download]` button (top right of UI) + Then After a few seconds of preparation, the download should start automatically. A snack bar message should appear when it does. + + Scenario: The downloaded archive should contain the expected files + Given User downloaded Julich Brain v3.0.3 in MNI152 + Then The downloaded archive should contain: README.md, LICENSE.md, template (nii.gz + md), parcellation (nii.gz + md) + + Scenario: User downloads hOc1 left hemisphere in MNI152 + Given User launched the atlas viewer + Given User selects Julich Brain v3.0.3 in MNI152 + Given user selects hOc1 left hemisphere + When User click `[download]` button (top right of UI) + Then After a few seconds of preparation, the download should start automatically. A snack bar message should appear when it does. + + Scenario: The downloaded archive should contain the expected files (#2) + Given User downloaded hOc1 left hemisphere in MNI152 + Then the downloaded archive should contain: README.md, LICENSE.md, template (nii.gz + md), regional map (nii.gz + md) diff --git a/features/basic-ui.feature b/features/basic-ui.feature new file mode 100644 index 000000000..9f37cb905 --- /dev/null +++ b/features/basic-ui.feature @@ -0,0 +1,16 @@ +Feature: Basic UI + + User should expect basic UI to work. + + Scenario: User launches the atlas viewer + When User launches the atlas viewer + Then the user should be able to login via ebrains OIDC + + Scenario: siibra-explorer and siibra-api version is compatible + When User launches the atlas viewer + Then the user should *not* see the message `Expecting , but got , some functionalities may not work as expected` + + Scenario: User trying to find debug information + Given User launches the atlas viewer + When User navigates to `?` -> `About` + Then The git hash matches to that of [HEAD of staing](https://github.com/FZJ-INM1-BDA/siibra-explorer/commits/staging/) diff --git a/features/doc-launch-quicktour.feature b/features/doc-launch-quicktour.feature new file mode 100644 index 000000000..3cdd86029 --- /dev/null +++ b/features/doc-launch-quicktour.feature @@ -0,0 +1,12 @@ +Feature: Doc Launch and Quicktour + + From doc - Launch and Quicktour + + Scenario: Accessing quicktour on startup + Given User first launched siibra-explorer + Then User should be asked if they would like a quick tour + + Scenario: Accessing quicktour on return + Given User launched siibra-explorer second time + When User hit `?` key or clicks `(?)` button (top right) + Then User should be able to find `Quick Tour` button at the modal dialog \ No newline at end of file diff --git a/features/doc-user-interface-structure.feature b/features/doc-user-interface-structure.feature new file mode 100644 index 000000000..c9ad3afcc --- /dev/null +++ b/features/doc-user-interface-structure.feature @@ -0,0 +1,47 @@ +Feature: Doc User Interface Structure + + From doc - User Interface Structure + + Scenario: User can expand coordinate view + Given User launched the atlas viewer + Given User selects an atlas, parcellation, parcellation + When User clicks `[chevron-down]` button + Then The coordinate view expands + + Scenario: User can enter custom coordinates + Given User launched the atlas viewer + Given User selects an atlas, parcellation, parcellation + When User clicks `[pen]` button + Then User can directly enter/copy/paste/select MNI coordinates + + Scenario: User can select via atlas selection panel + Given user launched the atlas viewer + Then User should have access to atlas selection panel at the bottom of the UI + + Scenario: User can search for regions + Given User launched the atlas viewer + Given User selects an atlas, parcellation, parcellation + Given User minimized side panel + When User clicks `[magnifiying glass]` and then focus on the input box + Then User can type keywords to search for regions + + Scenario: User accesses help panel + Given User launched the atlas viewer + When User clicks the `[circled-question-mark]` button + Then A modal dialog about keyboard shortcuts, terms of use and so on should be visible + + Scenario: User downloads current view + Given User launched the atlas viewer + When User clicks the `[download]` button + Then A zip file containing current view is downloaded + + Scenario: User accesses tools + Given User launched the atlas viewer + When User clicks the `[apps]` button + Then User should see tools available to them, including screenshot, annotation, jugex + + Scenario: User signs in using ebrains credentials + Given User launched the atlas viewer + When User clicks the `[account]` button + Then User should be able to sign in with ebrains credential + \ No newline at end of file diff --git a/features/docs-exploring-parcellation-maps.feature b/features/docs-exploring-parcellation-maps.feature new file mode 100644 index 000000000..5e7f13c40 --- /dev/null +++ b/features/docs-exploring-parcellation-maps.feature @@ -0,0 +1,41 @@ +Feature: Doc Exploring 3D Parcellation Maps + + Scenario: Accessing to atlas selection panel + Given User launched siibra-explorer + Then User should have access to atlas selection panel + + Scenario: Accessing volumetric atlases + Given User launched siibra-explorer + When User selects multilevel human atlas, big brain template space + Then User should see four panel volumetric atlas viewer + + Scenario: Zooming the volumetric atlas + Given User is accessing a volumetric atlas + When User scroll the wheel + Then the view should zoom + + Scenario: Panning the volumetric atlas + Given User is accessing a volumetric atlas + When User drags with mouse + Then the view should pan + + Scenario: Oblique slicing the volumetric atlas + Given User is accessing a volumetric atlas + When User drags with mouse whilst holding shift + Then the view should rotate + + Scenario: Accessing the atlas viewer on 2D viewer + Given User is accessing a volumetric atlas + When User hovers, and clicks `[maximize]` button + Then The specific panel will maximize, with 3D view as a picture-in-picture view + + Scenario: Cycling 2D view + Given User is accessing the atlas viewer as a 2D viewer + When User hits `[space bar]` + Then The view rotates to the next orthogonal view + + Scenario: Restoring from 2D view + Given User is accessing the atlas viewer as a 2D viewer + When User clicks `[compress] button` + Then the view restores to the original four panel view + diff --git a/features/docs-multimodal-data-features.feature b/features/docs-multimodal-data-features.feature new file mode 100644 index 000000000..6d51bed7e --- /dev/null +++ b/features/docs-multimodal-data-features.feature @@ -0,0 +1,13 @@ +Feature: Doc Multimodal Data Features + + # Other modal features were tested already + + Scenario: Finding data features linked to 3D view + Given User launched siibra-explorer + Given User selected Big Brain space + Then User should find >0 spatial features + + Scenario: Inspect spatial data feature + Given User found data features linked to 3D view + When User clicks on one of the features + Then User should be shown the VOI interest, teleported to the best viewing point diff --git a/features/docs-storing-sharing-3dview.feature b/features/docs-storing-sharing-3dview.feature new file mode 100644 index 000000000..2b55f5e51 --- /dev/null +++ b/features/docs-storing-sharing-3dview.feature @@ -0,0 +1,6 @@ +Feature: Doc Storing and Sharing 3D view + + Scenario: Taking Screenshots + Given User launched siibra-explorer + When User takes a screenshot with the built in screenshot plugin + Then the screenshot should work as intended diff --git a/features/exploring-features.feature b/features/exploring-features.feature new file mode 100644 index 000000000..e31a93c0d --- /dev/null +++ b/features/exploring-features.feature @@ -0,0 +1,44 @@ +Feature: Exploring features + + User should be able to explore feature panel + + Scenario: User exploring the features related to a region of interest + Given User launched the atlas viewer + Given User selected Julich Brain v3.0.3 in ICBM 2007c nonlinear space + Given User selects a region of interest (hOc1 left hemisphere) + When User clicks the `Feature` tab + Then Feature panel of region of interest is visible + + Scenario: User checking out a feature + Given User is exploring features related to region of interest + When User expands a category, and clicks a feature + Then Feature side panel will be opened + + Scenario: User finding out more about selected feature + Given User checked out a feature + Then User can see description related to the selected feature + + Scenario: User checking out the DOI of the selected feature + Given User checked out a feature (category=molecular, feature=receptor density fingerprint) + When User clicks `DOI` button + Then A new window opens for the said DOI + + Scenario: User downloads the feature + Given User checked out a feature + When User clicks `Download` button + Then A zip file containing metadata and data is downloaded + + Scenario: User checking out feature visualization + Given User checked out a feature (category=molecular, feature="receptor density profile, 5-HT1A") + When User click the `Visualization` tab + Then The visualization of the feature (cortical profile) becomes visible + + Scenario: User checking out connectivity strength + Given User checked out a feature (category=connectivity) + When User click the `Visualization` tab + Then The probabilistic map hides, whilst connection strength is also visualized on the atlas + + Scenario: User quitting checking out connectivity strength + Given User checked out connectivity strength + When User unselects the feature + Then The connection strength color mapped atlas disapepars, whilst the probabilistic map reappears. diff --git a/features/exploring-selected-region.feature b/features/exploring-selected-region.feature new file mode 100644 index 000000000..61673ca84 --- /dev/null +++ b/features/exploring-selected-region.feature @@ -0,0 +1,28 @@ +Feature: Exploring the selected region + + User should be able to explore the selected region + + Scenario: User selecting a region of interest + Given User launched the atlas viewer + Given User selected Julich Brain v3.0.3 in ICBM 2007c nonlinear space + When User clicks a parcel + Then The clicked parcel is selected + + Scenario: User navigates to the selected region + Given User selected a region of interest + When User clicks `Centroid` button + Then The viewer navigates to the said centroid of the region + + Scenario: User finding out more about the selected region + Given User selected a region of interest + Then The user can find description about the region + + Scenario: User accesses the doi of the selected region + Given User selected a region of interest + When User clicks `DOI` button + Then A new window opens for the said DOI + + Scenario: User searches for related regions + Given User selected a region of interest (4p left, Julich Brain v3.0.3, MNI152) + When User clicks `Related Region` button + Then Related region modal shows previous/next versions, as well as homologeous regions \ No newline at end of file diff --git a/features/navigation.feature b/features/navigation.feature new file mode 100644 index 000000000..0cf56d4e4 --- /dev/null +++ b/features/navigation.feature @@ -0,0 +1,38 @@ +Feature: Navigating the viewer + + Users should be able to easily navigate the atlas + + Scenario: User launches the atlas viewer + When User launches the atlas viewer + Then The user should be directed to Human Multilevel Atlas, in MNI152 space, with Julich Brain 3.0 loaded + + Scenario: On hover shows region name(s) + Given User launched atlas viewer + Given User navigated to Human Multilevel Atals, in MNI152 space, with Julich brain 3.0 loaded + Given User has not enabled mobile view + Given The browser's width is at least 900px wide + + When User hovers over non empty voxel + Then Label representing the voxel should show as tooltip + + Scenario: User wishes to hide parcellation + Given User launched atlas viewer + When User clicks the `[eye]` icon + Then Active parcellation should hide, showing the template + + Scenario: User wishes to hide parcellation via keyboard shortcut + Given User launched atlas viewer + When User uses `q` shortcut + Then Active parcellation should hide, showing the template + + Scenario: User wishes to learn more about the active parcellation + Given User launched atlas viewer + Given User selected Julich Brain v3.0.3 + When User clicks `[info]` icon associated with the parcellation + Then User should see more information, including name, desc, doi. + + Scenario: User wishes to learn more about the active space + Given User launched atlas viewer + Given User selected ICBM 2007c nonlinear asym + When User clicks `[info]` icon associated with the space + Then User should see more information, including name, desc, doi. diff --git a/features/plugin-jugex.feature b/features/plugin-jugex.feature new file mode 100644 index 000000000..fee12f529 --- /dev/null +++ b/features/plugin-jugex.feature @@ -0,0 +1,44 @@ +Feature: Plugin jugex + + Plugin jugex should work fine. + + Scenario: User can launch jugex plugin + Given User launched the atlas viewer + Given User selects an human atlas, Julich Brain v3.0.3 parcellation, MNI152 template + When User expands the `[App]` icon at top right, and clicks siibra-jugex + Then siibra-jugex should launch as a plugin + + Scenario: User can select ROI via typing + Given User launched jugex plugin + When User focus on `Select ROI 1` and type `fp1` + Then A list of suggestions should be populated and shown to the user. They can be selected either via left click, or `Enter` key (selecting the first in the list). + + Scenario: User can select ROI via scanning atlas + Given User selected ROI1 via typing + When User clicks `[radar]` button next to `Select ROI 2` text box + Then User should be prompted to `Select a region`. Single click on any region will select the region. + + Scenario: User can select genes of interest by typing + Given User selected ROI2 via scanning atlas + When User focuses selected genes, and starts typing (e.g. `GABA`) + Then A list of suggestions should be populated and shown to the user. They can be selected either via left click, or `Enter` key (selecting the first in the list). + + Scenario: User can export notebook + Given User selected gene(s) of interest + When User clicks `notebook` button + Then A new window should be opened, allowing the user to download the notebook, running it on ebrains labs or my binder + + Scenario: User can run the analysis + Given User selected gene(s) of interest + When User clicks `run` button + Then Jugex analysis should be run based on the user's specification + + Scenario: Analysis should be downloadable + Given User ran the analysis + When User clicks the `Save` Button + Then A result.json file should be downloaded + + Scenario: Result can be visualized on the atlas viewer + Given User ran the analysis + When User toggles `Annotate` + Then the sample sites should be visible in the atlas viewer diff --git a/features/point-assignment.feature b/features/point-assignment.feature new file mode 100644 index 000000000..e37e72419 --- /dev/null +++ b/features/point-assignment.feature @@ -0,0 +1,28 @@ +Feature: Point assignment + + Users should expect point assignment to work as expected + + Scenario: User performs point assignment on Julich Brain v3.0.3 in MNI152 space + Given User launched the atlas viewer + Given User selects Julich Brain v3.0.3 in MNI152 + When User right clicks on any voxel on the viewer, and clicks `x, y, z (mm) Point` + Then User should see statistical assignment of the point, sorted by `map value` + + Scenario: User inspects the full table of the point assignment + Given User performed point assignment on Julich Brain v3.0.3 in MNI152 space + When User clicks `Show full assignment` button + Then User should see a full assignment table in a modal dialog + + Scenario: User wishes to download the assignment + Given User is inspecting the full table of point assignment + When User clicks `Download CSV` button + Then A CSV containing the data should be downloaded + + Scenario: user performs point assignment on Waxholm v4 + Given User launched the atlas viewer + Given User selects Waxholm atlas, v4 parcellation + When User right clicks on any voxel on the viewer, and clicks `x, y, z (mm) Point` + Then User should see labelled assignment of the point + + Scenario: User performs point assignment on fsaverage + Scenario: User performs point assignment on Julich Brain in Big Brain diff --git a/features/region-hierarchy.feature b/features/region-hierarchy.feature new file mode 100644 index 000000000..7a121cb07 --- /dev/null +++ b/features/region-hierarchy.feature @@ -0,0 +1,19 @@ +Feature: Explore region hierarchy + + User should be able to explore region hierarchy + + Scenario: User exploring region hierarchy + Given User launched the atlas viewer + Given User selected Julich Brain v3.0.3 in ICBM 2007c nonlinear space + When User clicks `[site-map]` icon + Then The full hierarchy modal view should be shown + + Scenario: User are given search context + Given User are exploring region hierarchy + Then User should see the context of region hierachy, including atlas, parcellation and template + + Scenario: User searches for branch + Given User are exploring region hierarchy + When User searches for `frontal lobe` + Then User should see the parent (cerebral cortex), the branch itself (frontal lobe) and its children (e.g. inferior frontal sulcus). + \ No newline at end of file diff --git a/features/saneurl.feature b/features/saneurl.feature new file mode 100644 index 000000000..b7082b530 --- /dev/null +++ b/features/saneurl.feature @@ -0,0 +1,80 @@ +Feature: SaneURL a.k.a. URL shortener + + SaneURL should continue to function. + + Scenario: User navigates to SaneURL UI + Given User launched the atlas viewer + Given User selects an atlas, parcellation, parcellation + When User expands navigation submenu, clicks `[share]` button, clicks `Create custom URL` button + Then User should see the SaneURL UI + + Scenario: SaneURL UI should be informative + Given User navigated to SaneURL UI + Then User should see that links expire if they are not loggedin + Then User should see the links they potentially generate (e.g. `https://atlases.ebrains.eu/viewer-staging/go/`) + + Scenario: User attempts to generate existing saneurl + Given User navigated to SaneURL UI + When User enters `human` + Then User should be informed that the shortlink is not available + + Scenario: User attempts to use illegal characters + Given User navigated to SaneURL UI + When User enters `foo:bar` + Then User should be informed that the shortlink is not legal + + Scenario: User attempts to generate valid saneurl + Given User navigated to SaneURL UI + When User enters `x_tmp_foo` + Then User should be informed that the shortlink is available + + Scenario: User generates a valid saneurl + Given User navigated to SaneURL UI + When User enters `x_tmp_foo` and clicks `Create` + Then The short link will be created. User will be informed, and given the option to copy the generated shortlink + + Scenario: Generated shortlink works + Given User generated a valid saneurl + When User enters the said saneURL to a browser + Then They should be taken to where the shortlink is generated + + Scenario: Permalink works (Big Brain) + Given + Then Works + + Scenario: Permalink works (Juclih Brain in Colin 27) + Given + Then Works + + Scenario: Permalink works (Waxholm v4) + Given + Then Works + + Scenario: Permalink works (Allen CCFv3) + Given + Then Works + + Scenario: Permalink works (MEBRAINS) + Given + Then Works + + Scenario: Permalink works (contains annotations) + Given + Then Works + + + Scenario: VIP link works (human) + Given + Then Works + + Scenario: VIP link works (monkey) + Given + Then Works + + Scenario: VIP link works (rat) + Given + Then Works + + Scenario: VIP link works (mouse) + Given + Then Works diff --git a/features/selecting-region.feature b/features/selecting-region.feature new file mode 100644 index 000000000..5955d86ca --- /dev/null +++ b/features/selecting-region.feature @@ -0,0 +1,28 @@ +Feature: Selecting a region + + User should be able to select a region + + Scenario: User selecting a region of interest + Given User launched the atlas viewer + Given User selected Julich Brain v3.0.3 in ICBM 2007c nonlinear space + When User clicks a parcel + Then The clicked parcel is selected + + Scenario: User searching for a region of interest via express search + Given User launched the atlas viewer + Given User selected Julich Brain v3.0.3 in ICBM 2007c nonlinear space + When User focuses on `Search for regions` and types `hoc1` + Then Three options (hoc1 parent, hoc1 left and hoc1 right) should be shown, in this order + + Scenario: User selecting branch via express search + Given User searched for `hoc1` via express search + Given Three options (hoc1 parent, hoc1 left and hoc1 right) are shown, in this order + When User hits `Enter` key + Then Full hierarchy modal view should be shown, with the term `Area hOc1 (V1, 17, CalcS)` populated in the search field + + Scenario: User selecting node via express search + Given User searched for `hoc1` via express search + Given Three options (hoc1 parent, hoc1 left and hoc1 right) are shown, in this order + When User clicks `hoc1 left` + Then The region `hoc1 left` should be selected + diff --git a/features/switching-selection.feature b/features/switching-selection.feature new file mode 100644 index 000000000..a08c6f6c3 --- /dev/null +++ b/features/switching-selection.feature @@ -0,0 +1,17 @@ +Feature: Switching Atlas, Parcellation, Template + + User should be able to freely switch atlas, parcellation and templates + + Scenario: User switches template + Given User launched the atlas viewer + Given User selected Julich Brain v3.0.3 in ICBM 2007c nonlinear space + When User selects `Big Brain` template + Then User should be taken to Julich Brain v2.9 in Big Brain space + + Scenario: User switches parcellation + Given User launched the atlas viewer + Given User selected Julich Brain v2.9 in Big Brain space + When User selects `Deep fibre bundle` parcellation + Then User should be taken to `Deep fibre bundle` in MNI152 space + + \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 322efe7b2..6d2a03c78 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,6 +3,8 @@ site_name: Siibra Explorer User Documentation theme: name: 'material' +repo_url: https://github.com/fzj-inm1-bda/siibra-explorer + extra_css: - extra.css @@ -15,6 +17,10 @@ markdown_extensions: - mdx_truly_sane_lists - attr_list - toc + - attr_list + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg plugins: - search @@ -33,11 +39,13 @@ nav: - Coordinate lookups: 'basics/looking_up_coordinates.md' - Multimodal data features: 'basics/finding_multimodal_data.md' - Advanced functionalities: + - State encoding in URL: "advanced/url_encoding.md" - Superimposing local files: "advanced/superimposing_local_files.md" - Annotating structures in the brain: "advanced/annotating_structures.md" - Differential gene expression analysis: "advanced/differential_gene_expression_analysis.md" - Release notes: + - v2.14.5: 'releases/v2.14.5.md' - v2.14.4: 'releases/v2.14.4.md' - v2.14.3: 'releases/v2.14.3.md' - v2.14.2: 'releases/v2.14.2.md' diff --git a/package-lock.json b/package-lock.json index 4dacf3d98..2fd6dcfa8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "siibra-explorer", - "version": "2.14.0", + "version": "2.14.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "siibra-explorer", - "version": "2.14.0", + "version": "2.14.5", "license": "apache-2.0", "dependencies": { "@angular/animations": "^15.2.10", @@ -23,7 +23,7 @@ "@ngrx/effects": "^15.4.0", "@ngrx/store": "^15.4.0", "acorn": "^8.4.1", - "export-nehuba": "^0.1.2", + "export-nehuba": "^0.1.5", "file-loader": "^6.2.0", "jszip": "^3.6.0", "postcss": "^8.3.6", @@ -7646,9 +7646,9 @@ "dev": true }, "node_modules/export-nehuba": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/export-nehuba/-/export-nehuba-0.1.2.tgz", - "integrity": "sha512-rzydWAaa9QUKZqbYQcAuwnGsMGBlEQFD5URkEi5IGTG8LS4eH/xqc97ol0ZpUExa6jyn6nLtAjFJQmKL1rdV0w==", + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/export-nehuba/-/export-nehuba-0.1.7.tgz", + "integrity": "sha512-LakXeWGkEtHwWrV69snlM2GGmeVP+jGnTaevOpWQJePkdkPq6DvCkCSH0mLBriR8yOPyO0e+VHuE3V4AnV4fPA==", "dependencies": { "pako": "^1.0.6" } diff --git a/package.json b/package.json index 86ceb19f2..4f08a89b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "siibra-explorer", - "version": "2.14.4", + "version": "2.14.5", "description": "siibra-explorer - explore brain atlases. Based on humanbrainproject/nehuba & google/neuroglancer. Built with angular", "scripts": { "lint": "eslint src --ext .ts", @@ -55,7 +55,7 @@ "@ngrx/effects": "^15.4.0", "@ngrx/store": "^15.4.0", "acorn": "^8.4.1", - "export-nehuba": "^0.1.3", + "export-nehuba": "^0.1.5", "file-loader": "^6.2.0", "jszip": "^3.6.0", "postcss": "^8.3.6", diff --git a/src/api/broadcast/README.md b/src/api/broadcast/README.md index ebf43a4f8..fd7719946 100644 --- a/src/api/broadcast/README.md +++ b/src/api/broadcast/README.md @@ -6,4 +6,17 @@ Broadcasting messages are sent under two circumstances: - immediately after the plugin client acknowledged `handshake.init` to the specific client. This is so that the client can get the current state of the viewer. -Broadcasting messages never expects a response (and thus will never contain and `id` attribute) +Broadcasting messages never expects a response (and thus will never contain an `id` attribute) + +## API + + + +| event name | initiator | request | response | +| --- | --- | --- | --- | +| sxplr.on.allRegions | viewer | [jsonschema](sxplr.on.allRegions__fromSxplr__request.json) | | +| sxplr.on.atlasSelected | viewer | [jsonschema](sxplr.on.atlasSelected__fromSxplr__request.json) | | +| sxplr.on.navigation | viewer | [jsonschema](sxplr.on.navigation__fromSxplr__request.json) | | +| sxplr.on.parcellationSelected | viewer | [jsonschema](sxplr.on.parcellationSelected__fromSxplr__request.json) | | +| sxplr.on.regionsSelected | viewer | [jsonschema](sxplr.on.regionsSelected__fromSxplr__request.json) | | +| sxplr.on.templateSelected | viewer | [jsonschema](sxplr.on.templateSelected__fromSxplr__request.json) | | diff --git a/src/api/generateSchema.mjs b/src/api/generateSchema.mjs index 6f9eb2544..4a4b3c1b5 100644 --- a/src/api/generateSchema.mjs +++ b/src/api/generateSchema.mjs @@ -2,7 +2,7 @@ import ts from 'typescript' import path, { dirname } from 'path' import { fileURLToPath } from "url" import { readFile, writeFile } from "node:fs/promises" -import { clearDirectory, resolveAllDefs } from "./tsUtil.mjs" +import { clearDirectory, resolveAllDefs, populateReadme } from "./tsUtil.mjs" import { processNode } from "./tsUtil/index.mjs" /** @@ -57,6 +57,7 @@ async function populateBroadCast(broadcastNode, node){ newSchema = await resolveAllDefs(newSchema, node) await writeFile(path.join(dirnames.broadcast, filename), JSON.stringify(newSchema, null, 2), 'utf-8') } + await populateReadme(dirnames.broadcast) } /** @@ -136,7 +137,7 @@ async function populateHeartbeatEvents(convoNode, node){ /** * request */ - const respFilename = `${NAMESPACE}.${eventName}__fromSxplr__request.json` + const respFilename = `${NAMESPACE}.${eventName}__fromSxplr__response.json` /** * @type {JSchema} */ @@ -157,6 +158,7 @@ async function populateHeartbeatEvents(convoNode, node){ await writeFile(path.join(dirnames.handshake, respFilename), JSON.stringify(respSchema, null, 2), "utf-8") } } + await populateReadme(dirnames.handshake) } /** @@ -224,6 +226,8 @@ async function populateBoothEvents(convoNode, node){ await writeFile(path.join(dirnames.request, respFilename), JSON.stringify(respSchema, null, 2), "utf-8") } } + + await populateReadme(dirnames.request) } const main = async () => { @@ -246,7 +250,6 @@ const main = async () => { } }) - } - - main() - \ No newline at end of file +} + +main() diff --git a/src/api/handshake/README.md b/src/api/handshake/README.md index 02e05c5f8..30fe9a253 100644 --- a/src/api/handshake/README.md +++ b/src/api/handshake/README.md @@ -1,3 +1,11 @@ # Handshake API Handshake messages are meant for siibra-explorer to probe if the plugin is alive and well (and also a way for the plugin to check if siibra-explorer is responsive) + +## API + + + +| event name | initiator | request | response | +| --- | --- | --- | --- | +| sxplr.init | viewer | [jsonschema](sxplr.init__fromSxplr__request.json) | [jsonschema](sxplr.init__fromSxplr__response.json) | diff --git a/src/api/handshake/sxplr.init__fromSxplr__request.json b/src/api/handshake/sxplr.init__fromSxplr__request.json index d97b80726..ce4cbdf88 100644 --- a/src/api/handshake/sxplr.init__fromSxplr__request.json +++ b/src/api/handshake/sxplr.init__fromSxplr__request.json @@ -2,19 +2,14 @@ "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { - "jsonrpc": { - "const": "2.0" - }, "id": { "type": "string" }, - "result": { - "properties": { - "name": { - "type": "string" - } - }, - "type": "object" + "jsonrpc": { + "const": "2.0" + }, + "method": { + "const": "sxplr.init" } } } \ No newline at end of file diff --git a/src/api/handshake/sxplr.init__fromSxplr__response.json b/src/api/handshake/sxplr.init__fromSxplr__response.json new file mode 100644 index 000000000..d97b80726 --- /dev/null +++ b/src/api/handshake/sxplr.init__fromSxplr__response.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "jsonrpc": { + "const": "2.0" + }, + "id": { + "type": "string" + }, + "result": { + "properties": { + "name": { + "type": "string" + } + }, + "type": "object" + } + } +} \ No newline at end of file diff --git a/src/api/request/README.md b/src/api/request/README.md index 6ca2f74e3..7ee087007 100644 --- a/src/api/request/README.md +++ b/src/api/request/README.md @@ -33,3 +33,25 @@ window.addEventListener('pagehide', () => { }) }) ``` + +## API + + + +| event name | initiator | request | response | +| --- | --- | --- | --- | +| sxplr.addAnnotations | client | [jsonschema](sxplr.addAnnotations__toSxplr__request.json) | [jsonschema](sxplr.addAnnotations__toSxplr__response.json) | +| sxplr.cancelRequest | client | [jsonschema](sxplr.cancelRequest__toSxplr__request.json) | [jsonschema](sxplr.cancelRequest__toSxplr__response.json) | +| sxplr.exit | client | [jsonschema](sxplr.exit__toSxplr__request.json) | [jsonschema](sxplr.exit__toSxplr__response.json) | +| sxplr.getAllAtlases | client | [jsonschema](sxplr.getAllAtlases__toSxplr__request.json) | [jsonschema](sxplr.getAllAtlases__toSxplr__response.json) | +| sxplr.getSupportedParcellations | client | [jsonschema](sxplr.getSupportedParcellations__toSxplr__request.json) | [jsonschema](sxplr.getSupportedParcellations__toSxplr__response.json) | +| sxplr.getSupportedTemplates | client | [jsonschema](sxplr.getSupportedTemplates__toSxplr__request.json) | [jsonschema](sxplr.getSupportedTemplates__toSxplr__response.json) | +| sxplr.getUserToSelectARoi | client | [jsonschema](sxplr.getUserToSelectARoi__toSxplr__request.json) | [jsonschema](sxplr.getUserToSelectARoi__toSxplr__response.json) | +| sxplr.loadLayers | client | [jsonschema](sxplr.loadLayers__toSxplr__request.json) | [jsonschema](sxplr.loadLayers__toSxplr__response.json) | +| sxplr.navigateTo | client | [jsonschema](sxplr.navigateTo__toSxplr__request.json) | [jsonschema](sxplr.navigateTo__toSxplr__response.json) | +| sxplr.removeLayers | client | [jsonschema](sxplr.removeLayers__toSxplr__request.json) | [jsonschema](sxplr.removeLayers__toSxplr__response.json) | +| sxplr.rmAnnotations | client | [jsonschema](sxplr.rmAnnotations__toSxplr__request.json) | [jsonschema](sxplr.rmAnnotations__toSxplr__response.json) | +| sxplr.selectAtlas | client | [jsonschema](sxplr.selectAtlas__toSxplr__request.json) | [jsonschema](sxplr.selectAtlas__toSxplr__response.json) | +| sxplr.selectParcellation | client | [jsonschema](sxplr.selectParcellation__toSxplr__request.json) | [jsonschema](sxplr.selectParcellation__toSxplr__response.json) | +| sxplr.selectTemplate | client | [jsonschema](sxplr.selectTemplate__toSxplr__request.json) | [jsonschema](sxplr.selectTemplate__toSxplr__response.json) | +| sxplr.updateLayers | client | [jsonschema](sxplr.updateLayers__toSxplr__request.json) | [jsonschema](sxplr.updateLayers__toSxplr__response.json) | diff --git a/src/api/tsUtil.mjs b/src/api/tsUtil.mjs index ea2dda6d3..61f59565d 100644 --- a/src/api/tsUtil.mjs +++ b/src/api/tsUtil.mjs @@ -1,9 +1,11 @@ import ts from "typescript" import { readdir, mkdir, unlink } from "node:fs/promises" import path, { dirname } from 'path' -import { readFile } from "node:fs/promises" +import { readFile, writeFile } from "node:fs/promises" import { fileURLToPath } from "url" +const WARNINGTXT = `` + const __dirname = dirname(fileURLToPath(import.meta.url)) /** @@ -48,6 +50,112 @@ export async function clearDirectory(pathToDir){ } } + +/** + * + * @param {string} pathToDir + */ +export async function populateReadme(pathToDir){ + + /** + * @type {string} + */ + const text = await readFile(`${pathToDir}/README.md`, 'utf-8') + + /** + * @type {Array.} + */ + const newText = [] + + const lines = text.split("\n") + for (const line of lines) { + newText.push(line) + if (line.startsWith(WARNINGTXT)) { + break + } + } + + newText.push("") + + const files = await readdir(pathToDir) + + /** + * @typedef {Object} EventObj + * @property {'viewer'|'client'} initiator + * @property {string} requestFile + * @property {string} responseFile + */ + + /** + * @type {Object.} + */ + const events = {} + + for (const f of files) { + /** + * only remove json files + */ + if (f.endsWith(".json")) { + const [ evName, fromTo, reqResp ] = f.replace(/\.json$/, "").split("__") + if (['fromSxplr', 'toSxplr'].indexOf(fromTo) < 0) { + throw Error(`Expected ${fromTo} to be either 'fromSxplr' or 'toSxplr', but was neither`) + } + let initiator + if (fromTo === "fromSxplr") { + initiator = "viewer" + } + if (fromTo === "toSxplr") { + initiator = "client" + } + if (['request', 'response'].indexOf(reqResp) < 0) { + throw new Error(`Expected ${reqResp} to be either 'request' or 'response', but was neither`) + } + + /** + * @type {Object} + * @property {string} requestFile + * @property {string} responseFile + */ + const reqRespObj = {} + if (reqResp === "request") { + reqRespObj.requestFile = f + } + if (reqResp === "response") { + reqRespObj.responseFile = f + } + if (!events[evName]) { + events[evName] = { + initiator, + } + } + events[evName] = { + ...events[evName], + ...reqRespObj, + } + + } + } + + + function linkMd(file){ + if (!file) { + return `` + } + return `[jsonschema](${file})` + } + + newText.push( + `| event name | initiator | request | response |`, + `| --- | --- | --- | --- |`, + ...Object.entries(events).map( + ([ evName, { initiator, requestFile, responseFile }]) => `| ${evName} | ${initiator} | ${linkMd(requestFile)} | ${linkMd(responseFile)} |` + ), + `` + ) + + await writeFile(`${pathToDir}/README.md`, newText.join("\n"), "utf8") +} + /** * * @param {Object|Array|string} input diff --git a/src/atlas-download/atlas-download.directive.ts b/src/atlas-download/atlas-download.directive.ts index 637e9ded3..f6c123f56 100644 --- a/src/atlas-download/atlas-download.directive.ts +++ b/src/atlas-download/atlas-download.directive.ts @@ -25,6 +25,11 @@ export class AtlasDownloadDirective { take(1) ).toPromise() + const bbox = await this.store.pipe( + select(selectors.currentViewport), + take(1), + ).toPromise() + const selectedRegions = await this.store.pipe( select(selectors.selectedRegions), take(1) @@ -44,6 +49,11 @@ export class AtlasDownloadDirective { parcellation_id: parcellation.id, space_id: template.id, } + + if (bbox) { + query['bbox'] = JSON.stringify([bbox.minpoint, bbox.maxpoint]) + } + if (selectedRegions.length === 1) { query['region_id'] = selectedRegions[0].name } diff --git a/src/atlasComponents/annotations/annotation.service.ts b/src/atlasComponents/annotations/annotation.service.ts index be1b3fe8e..a7590df02 100644 --- a/src/atlasComponents/annotations/annotation.service.ts +++ b/src/atlasComponents/annotations/annotation.service.ts @@ -82,7 +82,10 @@ export class AnnotationLayer { distinctUntilChanged((o, n) => o?.id === n?.id) ) private onDestroyCb: (() => void)[] = [] - private nglayer: NgAnnotationLayer + + get nglayer(): NgAnnotationLayer{ + return this.viewer.layerManager.getLayerByName(this.name) + } private idset = new Set() constructor( private name: string = getUuid(), @@ -99,7 +102,7 @@ export class AnnotationLayer { transform: affine, } ) - this.nglayer = this.viewer.layerManager.addManagedLayer(layerSpec) + this.viewer.layerManager.addManagedLayer(layerSpec) const mouseState = this.viewer.mouseState const res: () => void = mouseState.changed.add(() => { const payload = mouseState.active @@ -129,11 +132,11 @@ export class AnnotationLayer { this._onHover.complete() while(this.onDestroyCb.length > 0) this.onDestroyCb.pop()() try { - this.viewer.layerManager.removeManagedLayer(this.nglayer) - this.nglayer = null + const l = this.viewer.layerManager.getLayerByName(this.name) + this.viewer.layerManager.removeManagedLayer(l) // eslint-disable-next-line no-empty } catch (e) { - + console.error("removing layer failed", e) } } diff --git a/src/atlasComponents/sapi/codeSnippets/codeSnippet.dialog.ts b/src/atlasComponents/sapi/codeSnippets/codeSnippet.dialog.ts new file mode 100644 index 000000000..63c4e5c2f --- /dev/null +++ b/src/atlasComponents/sapi/codeSnippets/codeSnippet.dialog.ts @@ -0,0 +1,31 @@ +import { CommonModule } from "@angular/common"; +import { Component, Inject } from "@angular/core"; +import { TextareaCopyExportCmp } from "src/components/textareaCopyExport/textareaCopyExport.component"; +import { AngularMaterialModule, Clipboard, MAT_DIALOG_DATA } from "src/sharedModules"; + +@Component({ + templateUrl: './codeSnippet.template.html', + standalone: true, + styleUrls: [ + './codeSnippet.style.scss' + ], + imports: [ + TextareaCopyExportCmp, + AngularMaterialModule, + CommonModule, + ] +}) + +export class CodeSnippetCmp { + constructor( + @Inject(MAT_DIALOG_DATA) + public data: any, + public clipboard: Clipboard, + ){ + + } + + copy(){ + this.clipboard.copy(this.data.code) + } +} diff --git a/src/atlasComponents/sapi/codeSnippets/codeSnippet.directive.ts b/src/atlasComponents/sapi/codeSnippets/codeSnippet.directive.ts new file mode 100644 index 000000000..37cfc0bc0 --- /dev/null +++ b/src/atlasComponents/sapi/codeSnippets/codeSnippet.directive.ts @@ -0,0 +1,88 @@ +import { Directive, HostListener, Input } from "@angular/core"; +import { RouteParam, SapiRoute } from "../typeV3"; +import { SAPI } from "../sapi.service"; +import { BehaviorSubject, from, of } from "rxjs"; +import { switchMap, take } from "rxjs/operators"; +import { MatDialog } from "src/sharedModules" +import { CodeSnippetCmp } from "./codeSnippet.dialog"; + +type V = {route: T, param: RouteParam} + +@Directive({ + selector: '[code-snippet]', + standalone: true, + exportAs: "codeSnippet" +}) + +export class CodeSnippet{ + + code$ = this.sapi.sapiEndpoint$.pipe( + switchMap(endpt => this.#path.pipe( + switchMap(path => { + if (!path) { + return of(null) + } + return from(this.#getCode(`${endpt}${path}`)) + }) + )), + ) + + #busy$ = new BehaviorSubject(false) + busy$ = this.#busy$.asObservable() + + @HostListener("click") + async handleClick(){ + this.#busy$.next(true) + const code = await this.code$.pipe( + take(1) + ).toPromise() + this.#busy$.next(false) + this.matDialog.open(CodeSnippetCmp, { + data: { code } + }) + } + + @Input() + set routeParam(value: V|null|undefined){ + if (!value) { + return + } + const { param, route } = value + const { params, path } = this.sapi.v3GetRoute(route, param) + + const url = encodeURI(path) + const queryParam = new URLSearchParams() + for (const key in params) { + queryParam.set(key, params[key].toString()) + } + const result = `${url}?${queryParam.toString()}` + this.#path.next(result) + } + + @Input() + set path(value: string) { + this.#path.next(value) + } + #path = new BehaviorSubject(null) + + constructor(private sapi: SAPI, private matDialog: MatDialog){} + + async #getCode(url: string): Promise { + try { + const resp = await fetch(url, { + headers: { + Accept: `text/x-sapi-python` + } + }) + if (!resp.ok){ + console.warn(`${url} returned not ok`) + return null + } + const result = await resp.text() + return result + } catch (e) { + console.warn(`Error: ${e}`) + return null + } + } +} diff --git a/src/atlasComponents/sapi/codeSnippets/codeSnippet.style.scss b/src/atlasComponents/sapi/codeSnippets/codeSnippet.style.scss new file mode 100644 index 000000000..2a159cdfc --- /dev/null +++ b/src/atlasComponents/sapi/codeSnippets/codeSnippet.style.scss @@ -0,0 +1,20 @@ +.textarea +{ + width: 75vw; + +} + +textarea-copy-export +{ + display: block; + width: 75vw; + + ::ng-deep mat-form-field { + width: 100%; + + textarea + { + resize: none; + } + } +} diff --git a/src/atlasComponents/sapi/codeSnippets/codeSnippet.template.html b/src/atlasComponents/sapi/codeSnippets/codeSnippet.template.html new file mode 100644 index 000000000..0dbf045b3 --- /dev/null +++ b/src/atlasComponents/sapi/codeSnippets/codeSnippet.template.html @@ -0,0 +1,31 @@ + + + + Code snippet + + + + + + + + + + + + + diff --git a/src/atlasComponents/sapi/core/space/interSpaceCoordXform.service.spec.ts b/src/atlasComponents/sapi/core/space/interSpaceCoordXform.service.spec.ts index 30542d6fc..c9baedadf 100644 --- a/src/atlasComponents/sapi/core/space/interSpaceCoordXform.service.spec.ts +++ b/src/atlasComponents/sapi/core/space/interSpaceCoordXform.service.spec.ts @@ -1,16 +1,25 @@ import { InterSpaceCoordXformSvc, VALID_TEMPLATE_SPACE_NAMES } from './interSpaceCoordXform.service' import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing' import { TestBed, fakeAsync, tick } from '@angular/core/testing' +import { GET_ATTR_TOKEN } from 'src/util/constants' describe('InterSpaceCoordXformSvc.service.spec.ts', () => { describe('InterSpaceCoordXformSvc', () => { + let attr: string = null + const defaultUrl = 'https://hbp-spatial-backend.apps.hbp.eu/v1/transform-points' beforeEach(() => { TestBed.configureTestingModule({ imports: [ HttpClientTestingModule ], providers: [ - InterSpaceCoordXformSvc + InterSpaceCoordXformSvc, + { + provide: GET_ATTR_TOKEN, + useFactory: () => { + return () => attr + } + } ] }) }) @@ -39,7 +48,7 @@ describe('InterSpaceCoordXformSvc.service.spec.ts', () => { ).subscribe((_ev) => { }) - const req = httpTestingController.expectOne(service['url']) + const req = httpTestingController.expectOne(defaultUrl) expect(req.request.method).toEqual('POST') expect( JSON.parse(req.request.body) @@ -67,7 +76,7 @@ describe('InterSpaceCoordXformSvc.service.spec.ts', () => { expect(status).toEqual('completed') expect(result).toEqual([1e6, 2e6, 3e6]) }) - const req = httpTestingController.expectOne(service['url']) + const req = httpTestingController.expectOne(defaultUrl) req.flush({ 'target_points':[ [1, 2, 3] @@ -87,7 +96,7 @@ describe('InterSpaceCoordXformSvc.service.spec.ts', () => { expect(status).toEqual('error') done() }) - const req = httpTestingController.expectOne(service['url']) + const req = httpTestingController.expectOne(defaultUrl) req.flush('intercepted', { status: 500, statusText: 'internal server error' }) }) @@ -105,10 +114,36 @@ describe('InterSpaceCoordXformSvc.service.spec.ts', () => { expect(status).toEqual('error') expect(statusText).toEqual(`Timeout after 3s`) }) - const req = httpTestingController.expectOne(service['url']) + const req = httpTestingController.expectOne(defaultUrl) tick(4000) expect(req.cancelled).toBe(true) })) + + describe("if injected override endpoint", () => { + beforeEach(() => { + attr = "http://foo-bar/" + }) + afterEach(() => { + attr = null + }) + it("trasnforms argument properly", () => { + + const service = TestBed.inject(InterSpaceCoordXformSvc) + const httpTestingController = TestBed.inject(HttpTestingController) + + // subscriptions are necessary for http fetch to occur + service.transform( + VALID_TEMPLATE_SPACE_NAMES.MNI152, + VALID_TEMPLATE_SPACE_NAMES.BIG_BRAIN, + [1,2,3] + ).subscribe((_ev) => { + + }) + const req = httpTestingController.expectOne("http://foo-bar/v1/transform-points") + expect(req.request.method).toEqual('POST') + req.flush({}) + }) + }) }) }) }) diff --git a/src/atlasComponents/sapi/core/space/interSpaceCoordXform.service.ts b/src/atlasComponents/sapi/core/space/interSpaceCoordXform.service.ts index 8dff3f67d..18518da17 100644 --- a/src/atlasComponents/sapi/core/space/interSpaceCoordXform.service.ts +++ b/src/atlasComponents/sapi/core/space/interSpaceCoordXform.service.ts @@ -1,9 +1,11 @@ -import { Injectable } from "@angular/core"; +import { Inject, Injectable } from "@angular/core"; import { HttpClient, HttpHeaders, HttpErrorResponse } from "@angular/common/http"; import { catchError, timeout, map } from "rxjs/operators"; import { of, Observable } from "rxjs"; import { environment } from 'src/environments/environment' import { IDS } from "src/atlasComponents/sapi/constants" +import { GET_ATTR_TOKEN, GetAttr } from "src/util/constants"; +import { CONST } from "common/constants" type ITemplateCoordXformResp = { status: 'pending' | 'error' | 'completed' | 'cached' @@ -49,9 +51,11 @@ export class InterSpaceCoordXformSvc { } } - constructor(private httpClient: HttpClient) {} + constructor(private httpClient: HttpClient, @Inject(GET_ATTR_TOKEN) getAttr: GetAttr) { + this.url = (getAttr(CONST.OVERWRITE_SPATIAL_BACKEND_ATTR) || environment.SPATIAL_TRANSFORM_BACKEND).replace(/\/$/, '') + '/v1/transform-points' + } - private url = `${environment.SPATIAL_TRANSFORM_BACKEND.replace(/\/$/, '')}/v1/transform-points` + private url: string // jasmine marble cannot test promise properly // see https://github.com/ngrx/platform/issues/498#issuecomment-337465179 diff --git a/src/atlasComponents/sapi/openapi.json b/src/atlasComponents/sapi/openapi.json index 808fe0f2b..6b4869a29 100644 --- a/src/atlasComponents/sapi/openapi.json +++ b/src/atlasComponents/sapi/openapi.json @@ -1081,6 +1081,14 @@ "name": "parcellation_id", "in": "query" }, + { + "required": false, + "schema": { + "title": "Bbox" + }, + "name": "bbox", + "in": "query" + }, { "required": false, "schema": { diff --git a/src/atlasComponents/sapi/sapi.service.ts b/src/atlasComponents/sapi/sapi.service.ts index 03167dda1..0d35f90f5 100644 --- a/src/atlasComponents/sapi/sapi.service.ts +++ b/src/atlasComponents/sapi/sapi.service.ts @@ -24,7 +24,7 @@ export const EXPECTED_SIIBRA_API_VERSION = '0.3.16' type PaginatedResponse = { items: T[] - total: number + total?: number page?: number size?: number pages?: number @@ -183,7 +183,7 @@ export class SAPI{ }) } - getFeaturePlot(id: string, params: RouteParam<"/feature/{feature_id}/plotly">["query"] = {}) { + getFeaturePlot(id: string, params: RouteParam<"/feature/{feature_id}/plotly">["query"] & Record = {}) { return this.v3Get("/feature/{feature_id}/plotly", { path: { feature_id: id @@ -191,6 +191,15 @@ export class SAPI{ query: params }) } + + // getFeatureIntents(id: string, params: Record = {}) { + // return this.v3Get("/feature/{feature_id}/intents", { + // path: { + // feature_id: id + // }, + // query: params + // }) + // } @CachedFunction({ serialization: (id, params) => `featDetail:${id}:${Object.entries(params || {}).map(([key, val]) => `${key},${val}`).join('.')}` @@ -277,9 +286,11 @@ export class SAPI{ tap(() => { const respVersion = SAPI.API_VERSION if (respVersion !== EXPECTED_SIIBRA_API_VERSION) { - this.snackbar.open(`Expecting ${EXPECTED_SIIBRA_API_VERSION}, got ${respVersion}. Some functionalities may not work as expected.`, 'Dismiss', { - duration: 5000 - }) + // TODO temporarily disable snackbar. Enable once siibra-api version stabilises + console.log(`Expecting ${EXPECTED_SIIBRA_API_VERSION}, got ${respVersion}. Some functionalities may not work as expected.`) + // this.snackbar.open(`Expecting ${EXPECTED_SIIBRA_API_VERSION}, got ${respVersion}. Some functionalities may not work as expected.`, 'Dismiss', { + // duration: 5000 + // }) } }), shareReplay(1), @@ -429,7 +440,7 @@ export class SAPI{ */ return this.v3Get("/feature/Image", { query: { - space_id: bbox.space.id, + space_id: bbox.space?.id || bbox.spaceId, bbox: JSON.stringify([bbox.minpoint, bbox.maxpoint]), } }).pipe( diff --git a/src/atlasComponents/sapi/schemaV3.ts b/src/atlasComponents/sapi/schemaV3.ts index 2e26c5065..f3589a2a4 100644 --- a/src/atlasComponents/sapi/schemaV3.ts +++ b/src/atlasComponents/sapi/schemaV3.ts @@ -146,6 +146,20 @@ export interface paths { */ get: operations["get_download_bundle_atlas_download_get"] } + "/atlas_download/{task_id}": { + /** + * Get Download Progress + * @description Get download task progress with task_id + */ + get: operations["get_download_progress_atlas_download__task_id__get"] + } + "/atlas_download/{task_id}/download": { + /** + * Get Download Result + * @description Download the bundle + */ + get: operations["get_download_result_atlas_download__task_id__download_get"] + } "/feature/_types": { /** * Get All Feature Types @@ -854,7 +868,7 @@ export interface components { /** Items */ items: (components["schemas"]["CommonCoordinateSpaceModel"])[] /** Total */ - total: number + total?: number /** Page */ page?: number /** Size */ @@ -867,7 +881,7 @@ export interface components { /** Items */ items: (components["schemas"]["FeatureMetaModel"])[] /** Total */ - total: number + total?: number /** Page */ page?: number /** Size */ @@ -880,7 +894,7 @@ export interface components { /** Items */ items: (components["schemas"]["ParcellationEntityVersionModel"])[] /** Total */ - total: number + total?: number /** Page */ page?: number /** Size */ @@ -893,7 +907,7 @@ export interface components { /** Items */ items: (components["schemas"]["RegionRelationAsmtModel"])[] /** Total */ - total: number + total?: number /** Page */ page?: number /** Size */ @@ -906,7 +920,7 @@ export interface components { /** Items */ items: (components["schemas"]["SiibraAtlasModel"])[] /** Total */ - total: number + total?: number /** Page */ page?: number /** Size */ @@ -919,7 +933,7 @@ export interface components { /** Items */ items: (components["schemas"]["SiibraCorticalProfileModel"])[] /** Total */ - total: number + total?: number /** Page */ page?: number /** Size */ @@ -932,7 +946,7 @@ export interface components { /** Items */ items: (components["schemas"]["SiibraEbrainsDataFeatureModel"])[] /** Total */ - total: number + total?: number /** Page */ page?: number /** Size */ @@ -945,7 +959,7 @@ export interface components { /** Items */ items: (components["schemas"]["SiibraParcellationModel"])[] /** Total */ - total: number + total?: number /** Page */ page?: number /** Size */ @@ -958,7 +972,7 @@ export interface components { /** Items */ items: (components["schemas"]["SiibraRegionalConnectivityModel"])[] /** Total */ - total: number + total?: number /** Page */ page?: number /** Size */ @@ -971,7 +985,7 @@ export interface components { /** Items */ items: (components["schemas"]["SiibraTabularModel"])[] /** Total */ - total: number + total?: number /** Page */ page?: number /** Size */ @@ -984,7 +998,7 @@ export interface components { /** Items */ items: (components["schemas"]["SiibraVoiModel"])[] /** Total */ - total: number + total?: number /** Page */ page?: number /** Size */ @@ -997,7 +1011,7 @@ export interface components { /** Items */ items: (components["schemas"]["SiibraCorticalProfileModel"] | components["schemas"]["SiibraReceptorDensityFp"] | components["schemas"]["SiibraTabularModel"])[] /** Total */ - total: number + total?: number /** Page */ page?: number /** Size */ @@ -1010,7 +1024,7 @@ export interface components { /** Items */ items: (components["schemas"]["SiibraVoiModel"] | components["schemas"]["SiibraCorticalProfileModel"] | components["schemas"]["SiibraRegionalConnectivityModel"] | components["schemas"]["SiibraReceptorDensityFp"] | components["schemas"]["SiibraTabularModel"] | components["schemas"]["SiibraEbrainsDataFeatureModel"])[] /** Total */ - total: number + total?: number /** Page */ page?: number /** Size */ @@ -2077,6 +2091,7 @@ export interface operations { query: { space_id: string parcellation_id: string + bbox?: Record region_id?: string feature_id?: string } @@ -2096,6 +2111,56 @@ export interface operations { } } } + get_download_progress_atlas_download__task_id__get: { + /** + * Get Download Progress + * @description Get download task progress with task_id + */ + parameters: { + path: { + task_id: string + } + } + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": Record + } + } + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"] + } + } + } + } + get_download_result_atlas_download__task_id__download_get: { + /** + * Get Download Result + * @description Download the bundle + */ + parameters: { + path: { + task_id: string + } + } + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": Record + } + } + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"] + } + } + } + } get_all_feature_types_feature__types_get: { /** * Get All Feature Types diff --git a/src/atlasComponents/sapi/sxplrTypes.ts b/src/atlasComponents/sapi/sxplrTypes.ts index e8d44c8db..331767f01 100644 --- a/src/atlasComponents/sapi/sxplrTypes.ts +++ b/src/atlasComponents/sapi/sxplrTypes.ts @@ -51,7 +51,8 @@ export type AdditionalInfo = { } type Location = { - readonly space: SxplrTemplate + readonly space?: SxplrTemplate + readonly spaceId: string } type LocTuple = [number, number, number] @@ -97,6 +98,18 @@ export type StatisticalMap = { * Features */ +export type SimpleCompoundFeature = { + id: string + name: string + category?: string + indices: { + category?: string + id: string + index: T + name: string + }[] +} & AdditionalInfo + export type Feature = { id: string name: string diff --git a/src/atlasComponents/sapi/translateV3.ts b/src/atlasComponents/sapi/translateV3.ts index b0e0ad5a4..ff48d7125 100644 --- a/src/atlasComponents/sapi/translateV3.ts +++ b/src/atlasComponents/sapi/translateV3.ts @@ -1,7 +1,7 @@ import { - SxplrAtlas, SxplrParcellation, SxplrTemplate, SxplrRegion, NgLayerSpec, NgPrecompMeshSpec, NgSegLayerSpec, VoiFeature, Point, TThreeMesh, LabelledMap, CorticalFeature, Feature, GenericInfo, BoundingBox + SxplrAtlas, SxplrParcellation, SxplrTemplate, SxplrRegion, NgLayerSpec, NgPrecompMeshSpec, NgSegLayerSpec, VoiFeature, Point, TThreeMesh, LabelledMap, CorticalFeature, Feature, GenericInfo, BoundingBox, SimpleCompoundFeature } from "./sxplrTypes" -import { PathReturn, MetaV1Schema } from "./typeV3" +import { PathReturn, MetaV1Schema, /* CompoundFeature */ } from "./typeV3" import { hexToRgb } from 'common/util' import { components } from "./schemaV3" import { defaultdict } from "src/util/fn" @@ -265,22 +265,20 @@ class TranslateV3 { const { ['@id']: regionId } = region this.#regionMap.set(regionId, region) this.#regionMap.set(region.name, region) + + const bestViewPoint = region.hasAnnotation?.bestViewPoint + return { id: region["@id"], name: region.name, color: hexToRgb(region.hasAnnotation?.displayColor) as [number, number, number], parentIds: region.hasParent.map( v => v["@id"] ), type: "SxplrRegion", - centroid: region.hasAnnotation?.bestViewPoint - ? await (async () => { - const bestViewPoint = region.hasAnnotation?.bestViewPoint - const fullSpace = this.#templateMap.get(bestViewPoint.coordinateSpace['@id']) - const space = await this.translateTemplate(fullSpace) - return { - loc: bestViewPoint.coordinates.map(v => v.value) as [number, number, number], - space - } - })() + centroid: bestViewPoint + ? { + loc: bestViewPoint.coordinates.map(v => v.value) as [number, number, number], + spaceId: bestViewPoint.coordinateSpace['@id'] + } : null } } @@ -400,13 +398,19 @@ class TranslateV3 { } async translateLabelledMapToThreeLabel(map:PathReturn<"/map">) { - const threeLabelMap: Record = {} - const registerLayer = (url: string, laterality: 'left' | 'right', region: string, label: number) => { + const threeLabelMap: Record = {} + const registerLayer = (url: string, clType: 'baselayer/threesurfer-label/gii-label' | 'baselayer/threesurfer-label/annot', laterality: 'left' | 'right', region: string, label: number) => { if (!threeLabelMap[url]) { threeLabelMap[url] = { laterality, region: [], url, + clType } } @@ -418,18 +422,26 @@ class TranslateV3 { for (const regionname in map.indices) { for (const { volume: volIdx, fragment, label } of map.indices[regionname]) { const volume = map.volumes[volIdx || 0] - if (!volume.formats.includes("gii-label")) { - // Does not support gii-label... skipping! - continue + let clType: 'baselayer/threesurfer-label/gii-label' | 'baselayer/threesurfer-label/annot' | null = null + let providedVolume: typeof volume['providedVolumes'][string] | null = null + if (volume.formats.includes("gii-label")) { + clType = 'baselayer/threesurfer-label/gii-label' + providedVolume = volume.providedVolumes["gii-label"] + } + if (volume.formats.includes("freesurfer-annot")) { + clType = 'baselayer/threesurfer-label/annot' + providedVolume = volume.providedVolumes["freesurfer-annot"] } - const { ["gii-label"]: giiLabel } = volume.providedVolumes - + if (!providedVolume || !clType) { + // does not support baselayer threesurfer label, skipping + continue + } if (!fragment || !["left hemisphere", "right hemisphere"].includes(fragment)) { console.warn(`either fragment not defined, or fragment is not '{left|right} hemisphere'. Skipping!`) continue } - if (!giiLabel[fragment]) { + if (!providedVolume[fragment]) { // Does not support gii-label... skipping! continue } @@ -440,7 +452,7 @@ class TranslateV3 { console.warn(`cannot determine the laterality! skipping`) continue } - registerLayer(giiLabel[fragment], laterality, regionname, label) + registerLayer(providedVolume[fragment], clType, laterality, regionname, label) } } return threeLabelMap @@ -543,6 +555,13 @@ class TranslateV3 { } async fetchMeta(url: string): Promise { + // TODO move to neuroglancer-data-vm + // difumo + if (url.startsWith("https://object.cscs.ch/v1/AUTH_08c08f9f119744cbbf77e216988da3eb/")) { + return { + version: 1 + } + } if (url in TMP_META_REGISTRY) { return TMP_META_REGISTRY[url] } @@ -568,14 +587,15 @@ class TranslateV3 { /** * TODO ensure all /meta endpoints are populated */ - // try{ - // const resp = await this.cFetch(`${url}/meta`) - // if (resp.status === 200) { - // return resp.json() - // } - // } catch (e) { + try{ + const resp = await this.cFetch(`${url}/meta.json`) + if (resp.status === 200) { + return resp.json() + } + // eslint-disable-next-line no-empty + } catch (e) { - // } + } return null } @@ -626,27 +646,51 @@ class TranslateV3 { } async #translatePoint(point: components["schemas"]["CoordinatePointModel"]): Promise { - const getTmpl = (id: string) => { - return this.#sxplrTmplMap.get(id) - } return { loc: point.coordinates.map(v => v.value) as [number, number, number], - get space() { - return getTmpl(point.coordinateSpace['@id']) - } + spaceId: point.coordinateSpace['@id'], } } - async translateFeature(feat: PathReturn<"/feature/{feature_id}">): Promise { + async translateFeature(feat: PathReturn<"/feature/{feature_id}">): Promise { if (this.#isVoi(feat)) { return await this.translateVoiFeature(feat) } + // if (this.#isCompound(feat)) { + // const link = feat.datasets.flatMap(ds => ds.urls).map(v => ({ + // href: v.url, + // text: v.url + // })) + // const v: SimpleCompoundFeature = { + // id: feat.id, + // name: feat.name, + // category: feat.category, + // indices: await Promise.all( + // feat.indices.map( + // async ({ id, index, name }) => ({ + // id, + // index: await this.#transformIndex(index), + // name, + // category: feat.category + // }) + // ) + // ), + // desc: feat.description, + // link + // } + // return v + // } return await this.translateBaseFeature(feat) } async translateBaseFeature(feat: PathReturn<"/feature/{feature_id}">): Promise{ const { id, name, category, description, datasets } = feat + if (!datasets) { + return { + id, name, category + } + } const dsDescs = datasets.map(ds => ds.description) const urls = datasets.flatMap(ds => ds.urls).map(v => ({ href: v.url, @@ -665,6 +709,18 @@ class TranslateV3 { return feat['@type'].includes("feature/volume_of_interest") } + // #isCompound(feat: unknown): feat is CompoundFeature { + // return feat['@type'].includes("feature/compoundfeature") + // } + + // async #transformIndex(index: CompoundFeature['indices'][number]['index']): Promise { + // if (typeof index === "string") { + // return index + // } + // return await this.#translatePoint(index) + + // } + async translateVoiFeature(feat: PathReturn<"/feature/Image/{feature_id}">): Promise { const [superObj, { loc: center }, { loc: maxpoint }, { loc: minpoint }, { "neuroglancer/precomputed": precomputedVol }] = await Promise.all([ this.translateBaseFeature(feat), @@ -674,14 +730,11 @@ class TranslateV3 { this.#extractNgPrecompUnfrag(feat.volume.providedVolumes), ]) const { ['@id']: spaceId } = feat.boundingbox.space - const getSpace = (id: string) => this.#sxplrTmplMap.get(id) const bbox: BoundingBox = { center, maxpoint, minpoint, - get space() { - return getSpace(spaceId) - } + spaceId } return { ...superObj, @@ -711,6 +764,10 @@ class TranslateV3 { ] } } + + getSpaceFromId(id: string): SxplrTemplate { + return this.#sxplrTmplMap.get(id) + } } export const translateV3Entities = new TranslateV3() diff --git a/src/atlasComponents/sapi/typeV3.ts b/src/atlasComponents/sapi/typeV3.ts index 0ae097aa9..0c499ac36 100644 --- a/src/atlasComponents/sapi/typeV3.ts +++ b/src/atlasComponents/sapi/typeV3.ts @@ -17,7 +17,7 @@ export type SapiFeatureModel = SapiSpatialFeatureModel | PathReturn<"/feature/Ta export type SapiRoute = keyof paths -type SapiRouteExcludePlotlyDownload = Exclude +type SapiRouteExcludePlotlyDownload = Exclude type _FeatureType = FeatureRoute extends `/feature/${infer FT}` ? FT extends "_types" @@ -134,3 +134,5 @@ export function isEnclosed(v: BestViewPoints[number]): v is EnclosedROI { } export type Qualification = components["schemas"]["Qualification"] + +// export type CompoundFeature = components['schemas']['CompoundFeatureModel'] diff --git a/src/atlasComponents/sapiViews/core/atlas/splashScreen/splashScreen.style.css b/src/atlasComponents/sapiViews/core/atlas/splashScreen/splashScreen.style.css index 0a7e4fcd6..4657360c4 100644 --- a/src/atlasComponents/sapiViews/core/atlas/splashScreen/splashScreen.style.css +++ b/src/atlasComponents/sapiViews/core/atlas/splashScreen/splashScreen.style.css @@ -6,7 +6,12 @@ width: 100%; } +:host > h4 +{ + margin: 2rem; +} + mat-card { - margin: 5rem; + margin: 0.5rem; } \ No newline at end of file diff --git a/src/atlasComponents/sapiViews/core/atlas/splashScreen/splashScreen.template.html b/src/atlasComponents/sapiViews/core/atlas/splashScreen/splashScreen.template.html index a68d5bdc6..983678081 100644 --- a/src/atlasComponents/sapiViews/core/atlas/splashScreen/splashScreen.template.html +++ b/src/atlasComponents/sapiViews/core/atlas/splashScreen/splashScreen.template.html @@ -1,17 +1,13 @@ - +

+ siibra-explorer +

+ + -
- siibra-explorer -
+ {{ atlas.name }}
- - -
diff --git a/src/atlasComponents/sapiViews/core/region/module.ts b/src/atlasComponents/sapiViews/core/region/module.ts index e72f1b154..8bb8f8bf7 100644 --- a/src/atlasComponents/sapiViews/core/region/module.ts +++ b/src/atlasComponents/sapiViews/core/region/module.ts @@ -15,6 +15,7 @@ import { SapiViewsCoreParcellationModule } from "../parcellation"; import { TranslateQualificationPipe } from "./translateQualification.pipe"; import { DedupRelatedRegionPipe } from "./dedupRelatedRegion.pipe"; import { ExperimentalFlagDirective } from "src/experimental/experimental-flag.directive"; +import { CodeSnippet } from "src/atlasComponents/sapi/codeSnippets/codeSnippet.directive"; @NgModule({ imports: [ @@ -30,6 +31,7 @@ import { ExperimentalFlagDirective } from "src/experimental/experimental-flag.di SapiViewsCoreParcellationModule, ExperimentalFlagDirective, + CodeSnippet, ], declarations: [ SapiViewsCoreRegionRegionListItem, diff --git a/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.template.html b/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.template.html index 25a7643b6..defb5198f 100644 --- a/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.template.html +++ b/src/atlasComponents/sapiViews/core/region/region/rich/region.rich.template.html @@ -37,6 +37,34 @@ + + + + + diff --git a/src/atlasComponents/sapiViews/core/rich/regionsListSearch/regionListSearch.template.html b/src/atlasComponents/sapiViews/core/rich/regionsListSearch/regionListSearch.template.html index 92e413e06..2de2fc4b5 100644 --- a/src/atlasComponents/sapiViews/core/rich/regionsListSearch/regionListSearch.template.html +++ b/src/atlasComponents/sapiViews/core/rich/regionsListSearch/regionListSearch.template.html @@ -6,8 +6,7 @@ - - + + + + + + + - + + + + +
@@ -24,12 +57,6 @@ - - df.columns as string[]) ) - constructor(private sapi: SAPI, private dialog: MatDialog, private store: Store) {} + constructor(private sapi: SAPI, private dialog: MatDialog, + private store: Store, + private clipboard: Clipboard, + private snackbar: MatSnackBar) {} #dialogRef: MatDialogRef openDialog(tmpl: TemplateRef){ @@ -163,6 +166,14 @@ export class PointAssignmentComponent implements OnDestroy { }) ) } + + copyCoord(coord: number[]){ + const strToCopy = coord.map(v => `${v.toFixed(2)}mm`).join(', ') + this.clipboard.copy(strToCopy) + this.snackbar.open(`Copied to clipboard`, 'Dismiss', { + duration: 4000 + }) + } } function generateReadMe(pt: TSandsPoint, parc: SxplrParcellation, tmpl: SxplrTemplate){ diff --git a/src/atlasComponents/userAnnotations/annotationMode/annotationMode.component.ts b/src/atlasComponents/userAnnotations/annotationMode/annotationMode.component.ts index 322d59bd2..f11a93778 100644 --- a/src/atlasComponents/userAnnotations/annotationMode/annotationMode.component.ts +++ b/src/atlasComponents/userAnnotations/annotationMode/annotationMode.component.ts @@ -2,7 +2,7 @@ import { Component, Inject, OnDestroy, Optional } from "@angular/core"; import { ModularUserAnnotationToolService } from "../tools/service"; import { ARIA_LABELS } from 'common/constants' import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR, CONTEXT_MENU_ITEM_INJECTOR, TContextMenu } from "src/util"; -import { TContextArg } from "src/viewerModule/viewer.interface"; +import { TViewerEvtCtxData } from "src/viewerModule/viewer.interface"; import { TContextMenuReg } from "src/contextMenuModule"; import { MatSnackBar } from 'src/sharedModules/angularMaterial.exports' @@ -26,7 +26,7 @@ export class AnnotationMode implements OnDestroy{ private modularToolSvc: ModularUserAnnotationToolService, snackbar: MatSnackBar, @Optional() @Inject(CLICK_INTERCEPTOR_INJECTOR) clickInterceptor: ClickInterceptor, - @Optional() @Inject(CONTEXT_MENU_ITEM_INJECTOR) ctxMenuInterceptor: TContextMenu>> + @Optional() @Inject(CONTEXT_MENU_ITEM_INJECTOR) ctxMenuInterceptor: TContextMenu>> ) { /** diff --git a/src/atlasComponents/userAnnotations/tools/module.ts b/src/atlasComponents/userAnnotations/tools/module.ts index 31c675db2..8085478cc 100644 --- a/src/atlasComponents/userAnnotations/tools/module.ts +++ b/src/atlasComponents/userAnnotations/tools/module.ts @@ -15,7 +15,7 @@ import { ToolSelect } from "./select"; import { ToolDelete } from "./delete"; import { Polygon, ToolPolygon } from "./poly"; import { ZipFilesOutputModule } from "src/zipFilesOutput/module"; -import { TextareaCopyExportCmp } from "./textareaCopyExport/textareaCopyExport.component"; +import { TextareaCopyExportCmp } from "src/components/textareaCopyExport/textareaCopyExport.component"; @NgModule({ imports: [ @@ -23,13 +23,14 @@ import { TextareaCopyExportCmp } from "./textareaCopyExport/textareaCopyExport.c AngularMaterialModule, UtilModule, ZipFilesOutputModule, + + TextareaCopyExportCmp, ], declarations: [ LineUpdateCmp, PolyUpdateCmp, PointUpdateCmp, ToFormattedStringPipe, - TextareaCopyExportCmp, ], exports: [ LineUpdateCmp, diff --git a/src/atlasComponents/userAnnotations/tools/service.ts b/src/atlasComponents/userAnnotations/tools/service.ts index 1d36acf63..002fa3189 100644 --- a/src/atlasComponents/userAnnotations/tools/service.ts +++ b/src/atlasComponents/userAnnotations/tools/service.ts @@ -18,6 +18,7 @@ import { atlasSelection } from "src/state"; import { SxplrTemplate } from "src/atlasComponents/sapi/sxplrTypes"; import { AnnotationLayer } from "src/atlasComponents/annotations"; import { translateV3Entities } from "src/atlasComponents/sapi/translateV3"; +import { HOVER_INTERCEPTOR_INJECTOR, HoverInterceptor, THoverConfig } from "src/util/injectionTokens"; const LOCAL_STORAGE_KEY = 'userAnnotationKey' const ANNOTATION_LAYER_NAME = "modular_tool_layer_name" @@ -276,12 +277,52 @@ export class ModularUserAnnotationToolService implements OnDestroy{ }) ) + #hoverMsgs: THoverConfig[] = [] + + #dismimssHoverMsgs(){ + if (!this.hoverInterceptor) { + return + } + const { remove } = this.hoverInterceptor + for (const msg of this.#hoverMsgs){ + remove(msg) + } + } + #appendHoverMsgs(geometries: IAnnotationGeometry[]){ + if (!this.hoverInterceptor) { + return + } + const { append } = this.hoverInterceptor + this.#hoverMsgs = geometries.map(geom => { + let fontIcon = 'fa-file' + if (geom.annotationType === 'Point') { + fontIcon = 'fa-circle' + } + if (geom.annotationType === 'Line') { + fontIcon = 'fa-slash' + } + if (geom.annotationType === 'Polygon') { + fontIcon = 'fa-draw-polygon' + } + return { + message: geom.name || `Unnamed ${geom.annotationType}`, + fontSet: 'fas', + fontIcon + } + }) + for (const msg of this.#hoverMsgs){ + append(msg) + } + } + constructor( private store: Store, private snackbar: MatSnackBar, @Inject(INJ_ANNOT_TARGET) annotTarget$: Observable, @Inject(ANNOTATION_EVENT_INJ_TOKEN) private annotnEvSubj: Subject>, @Optional() @Inject(NEHUBA_INSTANCE_INJTKN) nehubaViewer$: Observable, + @Optional() @Inject(HOVER_INTERCEPTOR_INJECTOR) + private hoverInterceptor: HoverInterceptor, ){ /** @@ -323,7 +364,13 @@ export class ModularUserAnnotationToolService implements OnDestroy{ } } as TAnnotationEvent<'mousedown' | 'mouseup' | 'mousemove'> this.annotnEvSubj.next(payload) - }) + }), + this.hoveringAnnotations$.subscribe(ev => { + this.#dismimssHoverMsgs() + if (ev) { + this.#appendHoverMsgs([ev]) + } + }), ) /** diff --git a/src/atlasViewer/atlasViewer.component.ts b/src/atlasViewer/atlasViewer.component.ts index 5c1f800ec..e326167d1 100644 --- a/src/atlasViewer/atlasViewer.component.ts +++ b/src/atlasViewer/atlasViewer.component.ts @@ -15,7 +15,6 @@ import { Observable, Subscription, merge, timer, fromEvent } from "rxjs"; import { filter, delay, switchMapTo, take, startWith } from "rxjs/operators"; import { colorAnimation } from "./atlasViewer.animation" -import { MouseHoverDirective } from "src/mouseoverModule"; import { MatSnackBar } from 'src/sharedModules/angularMaterial.exports' import { MatDialog, MatDialogRef } from "src/sharedModules/angularMaterial.exports"; import { CONST } from 'common/constants' @@ -49,8 +48,6 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { @ViewChild('cookieAgreementComponent', {read: TemplateRef}) public cookieAgreementComponent: TemplateRef - @ViewChild(MouseHoverDirective) private mouseOverNehuba: MouseHoverDirective - @ViewChild('idleOverlay', {read: TemplateRef}) idelTmpl: TemplateRef @HostBinding('attr.ismobile') @@ -184,12 +181,14 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { const gl = canvas.getContext('webgl2') as WebGLRenderingContext if (!gl) { + console.error(`Get GLContext failed!`) return false } const colorBufferFloat = gl.getExtension('EXT_color_buffer_float') if (!colorBufferFloat) { + console.error(`Get Extension failed!`) return false } diff --git a/src/atlasViewer/onhoverSegment.pipe.ts b/src/atlasViewer/onhoverSegment.pipe.ts deleted file mode 100644 index 5199a582a..000000000 --- a/src/atlasViewer/onhoverSegment.pipe.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Pipe, PipeTransform, SecurityContext } from "@angular/core"; -import { DomSanitizer, SafeHtml } from "@angular/platform-browser"; - -@Pipe({ - name: 'transformOnhoverSegment', -}) - -export class TransformOnhoverSegmentPipe implements PipeTransform { - constructor(private sanitizer: DomSanitizer) { - - } - - private sanitizeHtml(inc: string): SafeHtml { - return this.sanitizer.sanitize(SecurityContext.HTML, inc) - } - - private getStatus(text: string) { - return ` (${this.sanitizeHtml(text)})` - } - - public transform(segment: any | number): SafeHtml { - return this.sanitizer.bypassSecurityTrustHtml(( - ( this.sanitizeHtml(segment.name) || segment) + - (segment.status - ? this.getStatus(segment.status) - : '') - )) - } -} diff --git a/src/components/coordTextBox/coordTextBox.component.spec.ts b/src/components/coordTextBox/coordTextBox.component.spec.ts new file mode 100644 index 000000000..2596b8dce --- /dev/null +++ b/src/components/coordTextBox/coordTextBox.component.spec.ts @@ -0,0 +1,240 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing" +import { CoordTextBox, Render, isAffine, isTVec4, isTriplet } from "./coordTextBox.component" +import { Component } from "@angular/core" +import { NoopAnimationsModule } from "@angular/platform-browser/animations" + +describe("isTriplet", () => { + describe("> correctly returns true", () => { + const triplets = [ + [1, 2, 3], + [0, 0, 0], + [1e10, -1e10, 0] + ] + for (const triplet of triplets){ + it(`> for ${triplet}`, () => { + expect( + isTriplet(triplet) + ).toBeTrue() + }) + } + }) + + describe("> correctly returns false", () => { + const notTriplets = [ + [1, 2], + [1, 2, 3, 4], + ['foo', 1, 2], + [NaN, 1, 2], + [[], 1, 2] + ] + for (const notTriplet of notTriplets) { + it(`> for ${notTriplet}`, () => { + expect( + isTriplet(notTriplet) + ).toBeFalse() + }) + } + }) +}) + +describe("isTVec4", () => { + describe("> correctly returns true", () => { + const triplets = [ + [1, 2, 3, 4], + [0, 0, 0, 0], + [1e10, -1e10, 0, 0] + ] + for (const triplet of triplets){ + it(`> for ${triplet}`, () => { + expect( + isTVec4(triplet) + ).toBeTrue() + }) + } + }) + + describe("> correctly returns false", () => { + const notTriplets = [ + [1, 2, 3], + [1, 2, 3, 4, 5], + ['foo', 1, 2, 3], + [NaN, 1, 2, 3], + [[], 1, 2, 3] + ] + for (const notTriplet of notTriplets) { + it(`> for ${notTriplet}`, () => { + expect( + isTVec4(notTriplet) + ).toBeFalse() + }) + } + }) +}) + +describe("isAffine", () => { + describe("> correctly returns true", () => { + const triplets = [ + [[1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4]], + [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], + [[1e10, -1e10, 0, 0], [1e10, -1e10, 0, 0], [1e10, -1e10, 0, 0], [1e10, -1e10, 0, 0]] + ] + for (const triplet of triplets){ + it(`> for ${triplet}`, () => { + expect( + isAffine(triplet) + ).toBeTrue() + }) + } + }) + + describe("> correctly returns false", () => { + const notTriplets = [ + [[1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4]], + [[1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4]], + [[1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3]], + [[1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4, 5]], + ] + for (const notTriplet of notTriplets) { + it(`> for ${notTriplet}`, () => { + expect( + isAffine(notTriplet) + ).toBeFalse() + }) + } + }) +}) + + +describe("CoordTextBox", () => { + + @Component({ + template: `` + }) + class Dummy { + coord = [1, 2, 3] + iden = [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], + ] + translate = [ + [1, 0, 0, 2], + [0, 1, 0, 4], + [0, 0, 1, 6], + [0, 0, 0, 1], + ] + scale = [ + [2, 0, 0, 0], + [0, 4, 0, 0], + [0, 0, 8, 0], + [0, 0, 0, 1], + ] + + render: Render = v => v.map(v => `${v}f`).join(":") + } + + let fixture: ComponentFixture + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + CoordTextBox, + NoopAnimationsModule, + ], + declarations: [Dummy] + }) + // not yet compiled + }) + + describe("> correct affine inputs", () => { + describe("> iden", () => { + beforeEach(async () => { + await TestBed.overrideComponent(Dummy, { + set: { + template: ` + + + ` + } + }).compileComponents() + }) + + it("> renders correctly", () => { + fixture = TestBed.createComponent(Dummy) + fixture.detectChanges() + const input = fixture.nativeElement.querySelector('input') + expect(input.value).toEqual(`1, 2, 3`) + }) + }) + describe("> translate", () => { + beforeEach(async () => { + await TestBed.overrideComponent(Dummy, { + set: { + template: ` + + + ` + } + }).compileComponents() + }) + + it("> renders correctly", () => { + fixture = TestBed.createComponent(Dummy) + fixture.detectChanges() + const input = fixture.nativeElement.querySelector('input') + expect(input.value).toEqual(`3, 6, 9`) + }) + }) + describe("> scale", () => { + beforeEach(async () => { + await TestBed.overrideComponent(Dummy, { + set: { + template: ` + + + ` + } + }).compileComponents() + }) + + it("> renders correctly", () => { + fixture = TestBed.createComponent(Dummy) + fixture.detectChanges() + const input = fixture.nativeElement.querySelector('input') + expect(input.value).toEqual(`2, 8, 24`) + }) + }) + }) + + describe("> correct render inputs", () => { + describe("> render", () => { + beforeEach(async () => { + await TestBed.overrideComponent(Dummy, { + set: { + template: ` + + + ` + } + }).compileComponents() + }) + + it("> renders correctly", () => { + fixture = TestBed.createComponent(Dummy) + fixture.detectChanges() + const input = fixture.nativeElement.querySelector('input') + expect(input.value).toEqual(`1f:2f:3f`) + }) + }) + }) +}) \ No newline at end of file diff --git a/src/components/coordTextBox/coordTextBox.component.ts b/src/components/coordTextBox/coordTextBox.component.ts new file mode 100644 index 000000000..6f3d33fd7 --- /dev/null +++ b/src/components/coordTextBox/coordTextBox.component.ts @@ -0,0 +1,139 @@ +import { CommonModule } from "@angular/common"; +import { Component, EventEmitter, HostListener, Input, Output, ViewChild, inject } from "@angular/core"; +import { BehaviorSubject, combineLatest } from "rxjs"; +import { map, shareReplay } from "rxjs/operators"; +import { AngularMaterialModule, MatInput } from "src/sharedModules"; +import { DestroyDirective } from "src/util/directives/destroy.directive"; + +type TTriplet = [number, number, number] +type TVec4 = [number, number, number, number] +export type TAffine = [TVec4, TVec4, TVec4, TVec4] +export type Render = (v: TTriplet) => string + +export function isTVec4(val: unknown): val is TAffine { + if (!Array.isArray(val)) { + return false + } + if (val.length !== 4) { + return false + } + return val.every(v => typeof v === "number" && !isNaN(v)) +} + +export function isAffine(val: unknown): val is TAffine { + if (!Array.isArray(val)) { + return false + } + if (val.length !== 4) { + return false + } + return val.every(v => isTVec4(v)) +} + +export function isTriplet(val: unknown): val is TTriplet{ + if (!Array.isArray(val)) { + return false + } + if (val.length !== 3) { + return false + } + return val.every(v => typeof v === "number" && !isNaN(v)) +} + +@Component({ + selector: 'coordinate-text-input', + templateUrl: './coordTextBox.template.html', + styleUrls: [ + './coordTextBox.style.css' + ], + standalone: true, + imports: [ + CommonModule, + AngularMaterialModule + ], + hostDirectives: [ + DestroyDirective + ] +}) + +export class CoordTextBox { + + #destroyed$ = inject(DestroyDirective).destroyed$ + + @ViewChild(MatInput) + input: MatInput + + @Output('enter') + enter = new EventEmitter() + + @HostListener('keydown.enter') + @HostListener('keydown.tab') + enterHandler() { + this.enter.emit() + } + + #coordinates = new BehaviorSubject([0, 0, 0]) + + @Input() + set coordinates(val: unknown) { + if (!isTriplet(val)) { + console.error(`${val} is not TTriplet`) + return + } + this.#coordinates.next(val) + } + + + #affine = new BehaviorSubject([ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], + ]) + + @Input() + set affine(val: unknown) { + if (!isAffine(val)) { + console.error(`${val} is not TAffine!`) + return + } + this.#affine.next(val) + } + + #render = new BehaviorSubject(v => v.join(`, `)) + + @Input() + set render(val: Render) { + this.#render.next(val) + } + + inputValue$ = combineLatest([ + this.#coordinates, + this.#affine, + this.#render, + ]).pipe( + map(([ coord, flattenedAffine, render ]) => { + const [ + [m00, m10, m20, m30], + [m01, m11, m21, m31], + [m02, m12, m22, m32], + // [m03, m13, m23, m33], + ] = flattenedAffine + + const newCoord: TTriplet = [ + coord[0] * m00 + coord[1] * m10 + coord[2] * m20 + 1 * m30, + coord[0] * m01 + coord[1] * m11 + coord[2] * m21 + 1 * m31, + coord[0] * m02 + coord[1] * m12 + coord[2] * m22 + 1 * m32 + ] + return render(newCoord) + }), + shareReplay(1), + ) + + @Input() + label: string = "Coordinates" + + get inputValue(){ + return this.input?.value + } +} diff --git a/src/components/coordTextBox/coordTextBox.style.css b/src/components/coordTextBox/coordTextBox.style.css new file mode 100644 index 000000000..e0db9f988 --- /dev/null +++ b/src/components/coordTextBox/coordTextBox.style.css @@ -0,0 +1,15 @@ +:host +{ + display: flex; + width: 100%; +} + +mat-form-field +{ + flex: 1 1 auto; +} + +.suffix +{ + flex: 0 0 auto; +} diff --git a/src/components/coordTextBox/coordTextBox.template.html b/src/components/coordTextBox/coordTextBox.template.html new file mode 100644 index 000000000..ddf0c59a1 --- /dev/null +++ b/src/components/coordTextBox/coordTextBox.template.html @@ -0,0 +1,13 @@ + + + {{ label }} + + + + + + +
+ +
diff --git a/src/components/coordTextBox/index.ts b/src/components/coordTextBox/index.ts new file mode 100644 index 000000000..a157bc32e --- /dev/null +++ b/src/components/coordTextBox/index.ts @@ -0,0 +1,10 @@ +import { TAffine } from "./coordTextBox.component" + +export * from "./coordTextBox.component" + +export const ID_AFFINE = [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], +] as TAffine diff --git a/src/atlasComponents/userAnnotations/tools/textareaCopyExport/textareaCopyExport.component.ts b/src/components/textareaCopyExport/textareaCopyExport.component.ts similarity index 71% rename from src/atlasComponents/userAnnotations/tools/textareaCopyExport/textareaCopyExport.component.ts rename to src/components/textareaCopyExport/textareaCopyExport.component.ts index f749e39b4..b9482ce1f 100644 --- a/src/atlasComponents/userAnnotations/tools/textareaCopyExport/textareaCopyExport.component.ts +++ b/src/components/textareaCopyExport/textareaCopyExport.component.ts @@ -2,13 +2,23 @@ import { Component, Input } from "@angular/core"; import { MatSnackBar } from 'src/sharedModules/angularMaterial.exports' import { ARIA_LABELS } from 'common/constants' import { Clipboard } from "@angular/cdk/clipboard"; +import { AngularMaterialModule } from "src/sharedModules"; +import { CommonModule } from "@angular/common"; +import { ZipFilesOutputModule } from "src/zipFilesOutput/module"; @Component({ selector: 'textarea-copy-export', templateUrl: './textareaCopyExport.template.html', styleUrls: [ './textareaCopyExport.style.css' - ] + ], + standalone: true, + imports: [ + AngularMaterialModule, + CommonModule, + ZipFilesOutputModule, + ], + exportAs: "textAreaCopyExport" }) export class TextareaCopyExportCmp { @@ -31,6 +41,9 @@ export class TextareaCopyExportCmp { @Input('textarea-copy-export-disable') disableFlag: boolean = false + + @Input('textarea-copy-show-suffixes') + showSuffix: boolean = true public ARIA_LABELS = ARIA_LABELS @@ -42,7 +55,7 @@ export class TextareaCopyExportCmp { } copyToClipboard(value: string){ - const success = this.clipboard.copy(`${value}`) + const success = this.clipboard.copy(value) this.snackbar.open( success ? `Copied to clipboard!` : `Failed to copy URL to clipboard!`, null, diff --git a/src/atlasComponents/userAnnotations/tools/textareaCopyExport/textareaCopyExport.style.css b/src/components/textareaCopyExport/textareaCopyExport.style.css similarity index 100% rename from src/atlasComponents/userAnnotations/tools/textareaCopyExport/textareaCopyExport.style.css rename to src/components/textareaCopyExport/textareaCopyExport.style.css diff --git a/src/atlasComponents/userAnnotations/tools/textareaCopyExport/textareaCopyExport.template.html b/src/components/textareaCopyExport/textareaCopyExport.template.html similarity index 94% rename from src/atlasComponents/userAnnotations/tools/textareaCopyExport/textareaCopyExport.template.html rename to src/components/textareaCopyExport/textareaCopyExport.template.html index 65fe0d112..7e023d9af 100644 --- a/src/atlasComponents/userAnnotations/tools/textareaCopyExport/textareaCopyExport.template.html +++ b/src/components/textareaCopyExport/textareaCopyExport.template.html @@ -10,6 +10,7 @@ #exportTarget>{{ input }}
+ + + + + + + +
name + + {{ element.index | indexToStr }} + + + + + +
+ + +
+ + + diff --git a/src/features/compoundFeatureIndices/idxToIcon.pipe.ts b/src/features/compoundFeatureIndices/idxToIcon.pipe.ts new file mode 100644 index 000000000..a6f560182 --- /dev/null +++ b/src/features/compoundFeatureIndices/idxToIcon.pipe.ts @@ -0,0 +1,32 @@ +import { Pipe, PipeTransform } from "@angular/core" +import { CFIndex, isPoint } from "./util" +import { SxplrTemplate } from "src/atlasComponents/sapi/sxplrTypes" +import { translateV3Entities } from "src/atlasComponents/sapi/translateV3" + +type Icon = { + fontSet: string + fontIcon: string + message: string +} + +@Pipe({ + name: 'idxToIcon', + pure: true +}) + +export class IndexToIconPipe implements PipeTransform{ + transform(index: CFIndex, selectedTemplate: SxplrTemplate): Icon[] { + if (!isPoint(index.index)) { + return [] + } + if (index.index.spaceId !== selectedTemplate.id) { + const tmpl = translateV3Entities.getSpaceFromId(index.index.spaceId) + return [{ + fontSet: 'fas', + fontIcon: 'fa-exclamation-triangle', + message: `This point is in space ${tmpl?.name || 'Unknown'}(id=${index.index.spaceId}). It cannot be shown in the currently selected space ${selectedTemplate.name}(id=${selectedTemplate.id})` + }] + } + return [] + } +} diff --git a/src/features/compoundFeatureIndices/idxToText.pipe.ts b/src/features/compoundFeatureIndices/idxToText.pipe.ts new file mode 100644 index 000000000..b2b0f30d3 --- /dev/null +++ b/src/features/compoundFeatureIndices/idxToText.pipe.ts @@ -0,0 +1,15 @@ +import { Pipe, PipeTransform } from "@angular/core"; +import { CFIndex } from "./util" + +@Pipe({ + name: 'indexToStr', + pure: true +}) +export class IndexToStrPipe implements PipeTransform{ + public transform(value: CFIndex['index']): string { + if (typeof value === "string") { + return value + } + return `Point(${value.loc.map(v => v.toFixed(2)).join(", ")})` + } +} diff --git a/src/features/compoundFeatureIndices/index.ts b/src/features/compoundFeatureIndices/index.ts new file mode 100644 index 000000000..10694c74c --- /dev/null +++ b/src/features/compoundFeatureIndices/index.ts @@ -0,0 +1,3 @@ +export { CompoundFeatureIndices } from "./compoundFeatureIndices.component" +export { CFIndex } from "./util" +export { CompoundFeatureIndicesModule } from "./module" diff --git a/src/features/compoundFeatureIndices/module.ts b/src/features/compoundFeatureIndices/module.ts new file mode 100644 index 000000000..844b48a58 --- /dev/null +++ b/src/features/compoundFeatureIndices/module.ts @@ -0,0 +1,38 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { AngularMaterialModule } from "src/sharedModules"; +import { CompoundFeatureIndices } from "./compoundFeatureIndices.component"; +import { IndexToStrPipe } from "./idxToText.pipe"; +import { IndexToIconPipe } from "./idxToIcon.pipe"; +// import { PointCloudIntents, FilterPointTransformer } from "src/features/pointcloud-intents"; +// import { RENDER_CF_POINT, RenderCfPoint } from "../pointcloud-intents/intents.component"; + + +@NgModule({ + imports: [ + CommonModule, + AngularMaterialModule, + // PointCloudIntents, + ], + declarations: [ + CompoundFeatureIndices, + IndexToStrPipe, + IndexToIconPipe, + // FilterPointTransformer, + ], + exports: [ + CompoundFeatureIndices, + ], + providers: [ + // { + // provide: RENDER_CF_POINT, + // useFactory: () => { + // const pipe = new IndexToStrPipe() + // const renderCfPoint: RenderCfPoint = cfIndex => pipe.transform(cfIndex.index) + // return renderCfPoint + // } + // } + ] +}) + +export class CompoundFeatureIndicesModule{} diff --git a/src/features/compoundFeatureIndices/util.ts b/src/features/compoundFeatureIndices/util.ts new file mode 100644 index 000000000..8c028e502 --- /dev/null +++ b/src/features/compoundFeatureIndices/util.ts @@ -0,0 +1,8 @@ +import { Point, SimpleCompoundFeature } from "src/atlasComponents/sapi/sxplrTypes"; + +export type CFIndex = SimpleCompoundFeature['indices'][number] + +export function isPoint(val: string|Point): val is Point{ + return !!(val as any).spaceId && !!(val as any).loc + } + \ No newline at end of file diff --git a/src/features/entry/entry.component.spec.ts b/src/features/entry/entry.component.spec.ts index dd5fc359a..94c60cf1a 100644 --- a/src/features/entry/entry.component.spec.ts +++ b/src/features/entry/entry.component.spec.ts @@ -5,6 +5,7 @@ import { SAPIModule } from 'src/atlasComponents/sapi'; import { EntryComponent } from './entry.component'; import { provideMockStore } from '@ngrx/store/testing'; import { FeatureModule } from '../module'; +import { FEATURE_CONCEPT_TOKEN, TPRB } from '../util'; describe('EntryComponent', () => { let component: EntryComponent; @@ -18,7 +19,13 @@ describe('EntryComponent', () => { ], declarations: [ ], providers: [ - provideMockStore() + provideMockStore(), + { + provide: FEATURE_CONCEPT_TOKEN, + useValue: { + register(id: string, tprb: TPRB){} + } + } ] }) .compileComponents(); diff --git a/src/features/entry/entry.component.ts b/src/features/entry/entry.component.ts index c6e93a252..6b9542044 100644 --- a/src/features/entry/entry.component.ts +++ b/src/features/entry/entry.component.ts @@ -1,17 +1,19 @@ -import { AfterViewInit, ChangeDetectorRef, Component, OnDestroy, QueryList, TemplateRef, ViewChildren } from '@angular/core'; +import { AfterViewInit, ChangeDetectorRef, Component, Inject, QueryList, TemplateRef, ViewChildren, inject } from '@angular/core'; import { select, Store } from '@ngrx/store'; -import { debounceTime, distinctUntilChanged, map, scan, shareReplay, switchMap, take, withLatestFrom } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, map, scan, shareReplay, switchMap, take, takeUntil, withLatestFrom } from 'rxjs/operators'; import { IDS, SAPI } from 'src/atlasComponents/sapi'; import { Feature } from 'src/atlasComponents/sapi/sxplrTypes'; import { FeatureBase } from '../base'; import * as userInteraction from "src/state/userInteraction" -import { atlasSelection } from 'src/state'; import { CategoryAccDirective } from "../category-acc.directive" -import { combineLatest, concat, forkJoin, merge, of, Subject, Subscription } from 'rxjs'; +import { combineLatest, concat, forkJoin, merge, of, Subject } from 'rxjs'; import { DsExhausted, IsAlreadyPulling, PulledDataSource } from 'src/util/pullable'; import { TranslatedFeature } from '../list/list.directive'; -import { SPECIES_ENUM } from 'src/util/constants'; import { MatDialog } from 'src/sharedModules/angularMaterial.exports'; +import { DestroyDirective } from 'src/util/directives/destroy.directive'; +import { FEATURE_CONCEPT_TOKEN, FeatureConcept, TPRB } from '../util'; +import { SPECIES_ENUM } from 'src/util/constants'; +import { atlasSelection } from 'src/state'; const categoryAcc = >(categories: T[]) => { const returnVal: Record = {} @@ -26,7 +28,6 @@ const categoryAcc = >(categories: T[]) => { } return returnVal } - type ConnectiivtyFilter = { SPECIES: string[] PARCELLATION: string[] @@ -58,18 +59,35 @@ const BANLIST_CONNECTIVITY: ConnectiivtyFilter = { selector: 'sxplr-feature-entry', templateUrl: './entry.flattened.component.html', styleUrls: ['./entry.flattened.component.scss'], - exportAs: 'featureEntryCmp' + exportAs: 'featureEntryCmp', + hostDirectives: [ + DestroyDirective + ] }) -export class EntryComponent extends FeatureBase implements AfterViewInit, OnDestroy { +export class EntryComponent extends FeatureBase implements AfterViewInit { + + ondestroy$ = inject(DestroyDirective).destroyed$ @ViewChildren(CategoryAccDirective) catAccDirs: QueryList - constructor(private sapi: SAPI, private store: Store, private dialog: MatDialog, private cdr: ChangeDetectorRef) { + constructor( + private sapi: SAPI, + private store: Store, + private dialog: MatDialog, + private cdr: ChangeDetectorRef, + @Inject(FEATURE_CONCEPT_TOKEN) private featConcept: FeatureConcept, + ) { super() + + this.TPRBbox$.pipe( + takeUntil(this.ondestroy$) + ).subscribe(tprb => { + this.#tprb = tprb + }) } + #tprb: TPRB - #subscriptions: Subscription[] = [] #catAccDirs = new Subject() features$ = this.#catAccDirs.pipe( switchMap(dirs => concat( @@ -134,43 +152,40 @@ export class EntryComponent extends FeatureBase implements AfterViewInit, OnDest )) ) - ngOnDestroy(): void { - while (this.#subscriptions.length > 0) this.#subscriptions.pop().unsubscribe() - } ngAfterViewInit(): void { - this.#subscriptions.push( - merge( - of(null), - this.catAccDirs.changes - ).pipe( - map(() => Array.from(this.catAccDirs)) - ).subscribe(dirs => this.#catAccDirs.next(dirs)), + merge( + of(null), + this.catAccDirs.changes + ).pipe( + map(() => Array.from(this.catAccDirs)), + takeUntil(this.ondestroy$), + ).subscribe(dirs => this.#catAccDirs.next(dirs)) - this.#pullAll.pipe( - debounceTime(320), - withLatestFrom(this.#catAccDirs), - switchMap(([_, dirs]) => combineLatest(dirs.map(dir => dir.datasource$))), - ).subscribe(async dss => { - await Promise.all( - dss.map(async ds => { - // eslint-disable-next-line no-constant-condition - while (true) { - try { - await ds.pull() - } catch (e) { - if (e instanceof DsExhausted) { - break - } - if (e instanceof IsAlreadyPulling ) { - continue - } - throw e + this.#pullAll.pipe( + debounceTime(320), + withLatestFrom(this.#catAccDirs), + switchMap(([_, dirs]) => combineLatest(dirs.map(dir => dir.datasource$))), + takeUntil(this.ondestroy$), + ).subscribe(async dss => { + await Promise.all( + dss.map(async ds => { + // eslint-disable-next-line no-constant-condition + while (true) { + try { + await ds.pull() + } catch (e) { + if (e instanceof DsExhausted) { + break } + if (e instanceof IsAlreadyPulling ) { + continue + } + throw e } - }) - ) - }) - ) + } + }) + ) + }) } public selectedAtlas$ = this.store.pipe( @@ -205,18 +220,27 @@ export class EntryComponent extends FeatureBase implements AfterViewInit, OnDest ) public cateogryCollections$ = this.TPRBbox$.pipe( - switchMap(({ template, parcellation, region }) => this.featureTypes$.pipe( + switchMap(({ template, parcellation, region, bbox }) => this.featureTypes$.pipe( map(features => { const filteredFeatures = features.filter(v => { - const params = [ - ...(v.path_params || []), - ...(v.query_params || []), + const { path_params, required_query_params } = v + + const requiredParams = [ + ...(path_params || []), + ...(required_query_params || []), ] - return [ - params.includes("space_id") === (!!template) && !!template, - params.includes("parcellation_id") === (!!parcellation) && !!parcellation, - params.includes("region_id") === (!!region) && !!region, - ].some(val => val) + const paramMapped = { + space_id: !!template, + parcellation_id: !!parcellation, + region_id: !!region, + bbox: !!bbox + } + for (const pParam in paramMapped){ + if (requiredParams.includes(pParam) && !paramMapped[pParam]) { + return false + } + } + return true }) return categoryAcc(filteredFeatures) }), @@ -224,6 +248,13 @@ export class EntryComponent extends FeatureBase implements AfterViewInit, OnDest ) onClickFeature(feature: Feature) { + + /** + * register of TPRB (template, parcellation, region, bbox) *has* to + * happen at the moment when feature is selected + */ + this.featConcept.register(feature.id, this.#tprb) + this.store.dispatch( userInteraction.actions.showFeature({ feature diff --git a/src/features/entry/entry.flattened.component.html b/src/features/entry/entry.flattened.component.html index 7ae6abda7..e06d804e0 100644 --- a/src/features/entry/entry.flattened.component.html +++ b/src/features/entry/entry.flattened.component.html @@ -8,7 +8,7 @@ }"> - + @@ -22,7 +22,7 @@ {{ conn.key }} - + - + - + + diff --git a/src/features/feature-view/feature-view.component.html b/src/features/feature-view/feature-view.component.html index ba6567cf1..cf92282b2 100644 --- a/src/features/feature-view/feature-view.component.html +++ b/src/features/feature-view/feature-view.component.html @@ -1,19 +1,31 @@ - - - - - + + + + + + - - - Feature not specified. - - + - + + + + - + @@ -21,28 +33,21 @@ - - - - {{ feature.category }} feature - - - Other feature - - - + + {{ view.category }} +
- {{ feature.name }} + {{ view.name }}
- + @@ -51,17 +56,70 @@ - - - + + + + + + + + + + + + + + + + + + Queried Concepts + + + Concepts queried to get this feature. Please note this property is session dependent. + + + + + + + + + - +
{{ url.text || url.href }}
@@ -69,29 +127,37 @@
- +
{{ url }}
- - - -
Download
-
-
+ + +
Download
+
-
- + + + + + + + + + + - + - + - + + + + + diff --git a/src/features/feature-view/feature-view.component.spec.ts b/src/features/feature-view/feature-view.component.spec.ts index d74ac91e2..567a8c882 100644 --- a/src/features/feature-view/feature-view.component.spec.ts +++ b/src/features/feature-view/feature-view.component.spec.ts @@ -6,6 +6,8 @@ import { DARKTHEME } from 'src/util/injectionTokens'; import { FeatureViewComponent } from './feature-view.component'; import { provideMockStore } from '@ngrx/store/testing'; +import { AngularMaterialModule } from 'src/sharedModules'; +import { FEATURE_CONCEPT_TOKEN } from '../util'; describe('FeatureViewComponent', () => { let component: FeatureViewComponent; @@ -15,6 +17,7 @@ describe('FeatureViewComponent', () => { await TestBed.configureTestingModule({ imports: [ CommonModule, + AngularMaterialModule, ], declarations: [ FeatureViewComponent ], providers: [ @@ -34,6 +37,12 @@ describe('FeatureViewComponent', () => { }, sapiEndpoint$: EMPTY } + }, + { + provide: FEATURE_CONCEPT_TOKEN, + useValue: { + concept$: EMPTY + } } ] }) diff --git a/src/features/feature-view/feature-view.component.ts b/src/features/feature-view/feature-view.component.ts index c6950bbbf..5897f9ebe 100644 --- a/src/features/feature-view/feature-view.component.ts +++ b/src/features/feature-view/feature-view.component.ts @@ -1,58 +1,194 @@ -import { ChangeDetectionStrategy, Component, Inject, Input, OnChanges } from '@angular/core'; -import { BehaviorSubject, Observable, Subject, combineLatest, concat, of } from 'rxjs'; -import { catchError, distinctUntilChanged, map, shareReplay, switchMap } from 'rxjs/operators'; +import { ChangeDetectionStrategy, Component, Inject, Input, inject } from '@angular/core'; +import { BehaviorSubject, EMPTY, Observable, combineLatest, concat, of } from 'rxjs'; +import { catchError, debounceTime, distinctUntilChanged, filter, map, shareReplay, switchMap, takeUntil, withLatestFrom } from 'rxjs/operators'; import { SAPI } from 'src/atlasComponents/sapi/sapi.service'; -import { Feature, VoiFeature } from 'src/atlasComponents/sapi/sxplrTypes'; +import { Feature, SimpleCompoundFeature, VoiFeature } from 'src/atlasComponents/sapi/sxplrTypes'; import { DARKTHEME } from 'src/util/injectionTokens'; import { isVoiData, notQuiteRight } from "../guards" +import { Action, Store, select } from '@ngrx/store'; +import { atlasSelection, userInteraction } from 'src/state'; +import { PathReturn } from 'src/atlasComponents/sapi/typeV3'; +import { CFIndex } from '../compoundFeatureIndices'; +import { ComponentStore } from '@ngrx/component-store'; +import { DestroyDirective } from 'src/util/directives/destroy.directive'; +import { FEATURE_CONCEPT_TOKEN, FeatureConcept } from '../util'; +type FeatureCmpStore = { + selectedCmpFeature: SimpleCompoundFeature|null +} + +type PlotlyResponse = PathReturn<"/feature/{feature_id}/plotly"> + +function isSimpleCompoundFeature(feat: unknown): feat is SimpleCompoundFeature{ + return !!(feat?.['indices']) +} @Component({ selector: 'sxplr-feature-view', templateUrl: './feature-view.component.html', styleUrls: ['./feature-view.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + ComponentStore + ], + hostDirectives: [ + DestroyDirective + ] }) -export class FeatureViewComponent implements OnChanges { +export class FeatureViewComponent { + + destroyed$ = inject(DestroyDirective).destroyed$ + busy$ = new BehaviorSubject(false) + + #feature$ = new BehaviorSubject(null) @Input() - feature: Feature + set feature(val: Feature|SimpleCompoundFeature) { + this.#feature$.next(val) + } + + #featureId = this.#feature$.pipe( + map(f => f.id) + ) + + #featureDetail$ = this.#featureId.pipe( + switchMap(fid => this.sapi.getV3FeatureDetailWithId(fid)), + ) + + #featureDesc$ = this.#feature$.pipe( + switchMap(() => concat( + of(null as string), + this.#featureDetail$.pipe( + map(v => v?.desc), + catchError((err) => { + let errortext = 'Error fetching feature instance' + + if (err.error instanceof Error) { + errortext += `:\n\n${err.error.toString()}` + } else { + errortext += '!' + } + + return of(errortext) + }), + ) + )) + ) + + #voi$: Observable = this.#feature$.pipe( + switchMap(() => concat( + of(null), + this.#featureDetail$.pipe( + catchError(() => of(null)), + map(val => { + if (isVoiData(val)) { + return val + } + return null + }) + ) + )) + ) + + #warnings$ = this.#feature$.pipe( + switchMap(() => concat( + of([] as string[]), + this.#featureDetail$.pipe( + catchError(() => of(null)), + map(notQuiteRight), + ) + )) + ) + #isConnectivity$ = this.#feature$.pipe( + map(v => v.category === "connectivity") + ) + + #selectedRegion$ = this.store.pipe( + select(atlasSelection.selectors.selectedRegions) + ) - #featureId = new BehaviorSubject(null) + #additionalParams$: Observable> = this.#isConnectivity$.pipe( + withLatestFrom(this.#selectedRegion$), + map(([ isConnnectivity, selectedRegions ]) => isConnnectivity + ? {"regions": selectedRegions.map(r => r.name).join(" ")} + : {} ) + ) #plotlyInput$ = combineLatest([ this.#featureId, - this.darktheme$ + this.darktheme$, + this.#additionalParams$, ]).pipe( - map(([ id, darktheme ]) => ({ id, darktheme })), + debounceTime(16), + map(([ id, darktheme, additionalParams ]) => ({ id, darktheme, additionalParams })), distinctUntilChanged((o, n) => o.id === n.id && o.darktheme === n.darktheme), shareReplay(1), ) + + #loadingDetail$ = this.#feature$.pipe( + switchMap(() => concat( + of(true), + this.#featureDetail$.pipe( + catchError(() => of(null)), + map(() => false) + ) + )) + ) - loadingPlotly$ = this.#plotlyInput$.pipe( + #loadingPlotly$ = this.#plotlyInput$.pipe( switchMap(() => concat( of(true), - this.plotly$.pipe( + this.#plotly$.pipe( map(() => false) ) )), - distinctUntilChanged() ) - plotly$ = this.#plotlyInput$.pipe( - switchMap(({ id, darktheme }) => !!id - ? this.sapi.getFeaturePlot(id, { template: darktheme ? 'plotly_dark' : 'plotly_white' }).pipe( - catchError(() => of(null)) + #plotly$: Observable = this.#plotlyInput$.pipe( + switchMap(({ id, darktheme, additionalParams }) => { + if (!id) { + return of(null) + } + return concat( + of(null), + this.sapi.getFeaturePlot( + id, + { + template: darktheme ? 'plotly_dark' : 'plotly_white', + ...additionalParams + } + ).pipe( + catchError(() => of(null)) + ) ) - : of(null)), + }), shareReplay(1), ) + + #detailLinks = this.#feature$.pipe( + switchMap(() => concat( + of([] as string[]), + this.#featureDetail$.pipe( + catchError(() => of(null as null)), + map(val => (val?.link || []).map(l => l.href)) + ) + )) + ) + + #compoundFeatEmts$ = this.#feature$.pipe( + map(f => { + if (isSimpleCompoundFeature(f)) { + return f.indices + } + return null + }) + ) - #detailLinks = new Subject() additionalLinks$ = this.#detailLinks.pipe( distinctUntilChanged((o, n) => o.length == n.length), - map(links => { - const set = new Set((this.feature.link || []).map(v => v.href)) + withLatestFrom(this.#feature$), + map(([links, feature]) => { + const set = new Set((feature.link || []).map(v => v.href)) return links.filter(l => !set.has(l)) }) ) @@ -64,40 +200,152 @@ export class FeatureViewComponent implements OnChanges { )) ) - busy$ = new BehaviorSubject(false) - - voi$ = new BehaviorSubject(null) - - warnings$ = new Subject() + // intents$ = this.#isConnectivity$.pipe( + // withLatestFrom(this.#featureId, this.#selectedRegion$), + // switchMap(([flag, fid, selectedRegion]) => { + // if (!flag) { + // return EMPTY + // } + // return this.sapi.getFeatureIntents(fid, { + // region: selectedRegion.map(r => r.name).join(" ") + // }).pipe( + // switchMap(val => + // this.sapi.iteratePages( + // val, + // page => this.sapi.getFeatureIntents(fid, { + // region: selectedRegion.map(r => r.name).join(" "), + // page: page.toString() + // } + // ) + // )) + // ) + // }) + // ) constructor( private sapi: SAPI, - @Inject(DARKTHEME) public darktheme$: Observable, - ) { } - - ngOnChanges(): void { - - this.voi$.next(null) - this.busy$.next(true) - - this.#featureId.next(this.feature.id) - - this.sapi.getV3FeatureDetailWithId(this.feature.id).subscribe( - val => { - this.busy$.next(false) - - if (isVoiData(val)) { - this.voi$.next(val) + private store: Store, + private readonly cmpStore: ComponentStore, + @Inject(DARKTHEME) public darktheme$: Observable, + @Inject(FEATURE_CONCEPT_TOKEN) private featConcept: FeatureConcept, + ) { + this.cmpStore.setState({ selectedCmpFeature: null }) + + this.#feature$.pipe( + takeUntil(this.destroyed$), + filter(isSimpleCompoundFeature), + ).subscribe(selectedCmpFeature => { + this.cmpStore.patchState({ selectedCmpFeature }) + }) + } + + navigateToRegionByName(regionName: string){ + this.store.dispatch( + atlasSelection.actions.navigateToRegion({ + region: { + name: regionName } + }) + ) + } - this.warnings$.next( - notQuiteRight(val) - ) + onAction(action: Action){ + this.store.dispatch(action) + } - this.#detailLinks.next((val.link || []).map(l => l.href)) - - }, - () => this.busy$.next(false) + #etheralView$ = combineLatest([ + this.cmpStore.state$, + this.#feature$, + this.featConcept.concept$ + ]).pipe( + map(([ { selectedCmpFeature }, feature, selectedConcept ]) => { + const { id: selectedConceptFeatId, concept } = selectedConcept + const prevCmpFeat: SimpleCompoundFeature = selectedCmpFeature?.indices.some(idx => idx.id === feature?.id) && selectedCmpFeature || null + return { + prevCmpFeat, + concept: selectedConceptFeatId === feature.id && concept || null + } + }) + ) + + #specialView$ = combineLatest([ + concat( + of(null as VoiFeature), + this.#voi$ + ), + concat( + of(null as PlotlyResponse), + this.#plotly$, + ), + this.#compoundFeatEmts$, + this.store.pipe( + select(atlasSelection.selectors.selectedTemplate) + ), + ]).pipe( + map(([ voi, plotly, cmpFeatElmts, selectedTemplate ]) => { + return { + voi, plotly, cmpFeatElmts, selectedTemplate, + } + }) + ) + + #baseView$ = combineLatest([ + this.#feature$, + combineLatest([ + this.#loadingDetail$, + this.#loadingPlotly$, + this.busy$, + ]).pipe( + map(flags => flags.some(f => f)) + ), + this.#warnings$, + this.additionalLinks$, + this.downloadLink$, + this.#featureDesc$ + ]).pipe( + map(([ feature, busy, warnings, additionalLinks, downloadLink, desc ]) => { + return { + featureId: feature.id, + name: feature.name, + links: feature.link, + category: feature.category === 'Unknown category' + ? `Other feature` + : `${feature.category} feature`, + busy, + warnings, + additionalLinks, + downloadLink, + desc + } + }) + ) + + view$ = combineLatest([ + this.#baseView$, + this.#specialView$, + this.#etheralView$ + ]).pipe( + map(([obj1, obj2, obj3]) => { + return { + ...obj1, + ...obj2, + ...obj3, + } + }) + ) + + showSubfeature(item: CFIndex|Feature){ + this.store.dispatch( + userInteraction.actions.showFeature({ + feature: item + }) ) } + + clearSelectedFeature(): void{ + this.store.dispatch( + userInteraction.actions.clearShownFeature() + ) + } + } diff --git a/src/features/guards.ts b/src/features/guards.ts index 480e10dbe..6d13d4514 100644 --- a/src/features/guards.ts +++ b/src/features/guards.ts @@ -1,8 +1,9 @@ import { VoiFeature } from "src/atlasComponents/sapi/sxplrTypes" +export { VoiFeature } export function isVoiData(feature: unknown): feature is VoiFeature { - return !!feature['bbox'] + return !!(feature?.['bbox']) } export function notQuiteRight(_feature: unknown): string[] { diff --git a/src/features/list/list.directive.ts b/src/features/list/list.directive.ts index 11f5c9031..999000e36 100644 --- a/src/features/list/list.directive.ts +++ b/src/features/list/list.directive.ts @@ -16,7 +16,7 @@ export type TranslatedFeature = Awaited< ReturnType<(typeof translateV3Entities) selector: '[sxplr-feature-list-directive]', exportAs: 'featureListDirective' }) -export class ListDirective extends FeatureBase implements OnDestroy{ +export class ListDirective extends FeatureBase implements OnDestroy{ @Input() name: string diff --git a/src/features/module.ts b/src/features/module.ts index 43f0832a7..9582f6fcc 100644 --- a/src/features/module.ts +++ b/src/features/module.ts @@ -19,6 +19,14 @@ import { ReadmoreModule } from "src/components/readmore"; import { GroupFeatureTallyPipe } from "./grpFeatToTotal.pipe"; import { PlotlyComponent } from "./plotly"; import { AngularMaterialModule } from "src/sharedModules"; +// import { AtlasColorMapIntents } from "./atlas-colormap-intents"; +import { CompoundFeatureIndicesModule } from "./compoundFeatureIndices" +import { FEATURE_CONCEPT_TOKEN, FeatureConcept, TPRB } from "./util"; +import { BehaviorSubject } from "rxjs"; +import { TPBRViewCmp } from "./TPBRView/TPBRView.component"; +import { DialogModule } from "src/ui/dialogInfo"; +import { CodeSnippet } from "src/atlasComponents/sapi/codeSnippets/codeSnippet.directive"; +import { ExperimentalFlagDirective } from "src/experimental/experimental-flag.directive"; @NgModule({ imports: [ @@ -31,11 +39,17 @@ import { AngularMaterialModule } from "src/sharedModules"; NgLayerCtlModule, ReadmoreModule, AngularMaterialModule, + CompoundFeatureIndicesModule, + DialogModule, /** * standalone components */ PlotlyComponent, + // AtlasColorMapIntents, + TPBRViewCmp, + CodeSnippet, + ExperimentalFlagDirective, ], declarations: [ EntryComponent, @@ -57,6 +71,19 @@ import { AngularMaterialModule } from "src/sharedModules"; VoiBboxDirective, ListDirective, ], + providers: [ + { + provide: FEATURE_CONCEPT_TOKEN, + useFactory: () => { + const obs = new BehaviorSubject<{ id: string, concept: TPRB}>({id: null, concept: {}}) + const returnObj: FeatureConcept = { + register: (id, concept) => obs.next({ id, concept }), + concept$: obs.asObservable() + } + return returnObj + } + } + ], schemas: [ CUSTOM_ELEMENTS_SCHEMA, ] diff --git a/src/features/plotly/plot/plot.component.scss b/src/features/plotly/plot/plot.component.scss index 6daad6f17..e352d500d 100644 --- a/src/features/plotly/plot/plot.component.scss +++ b/src/features/plotly/plot/plot.component.scss @@ -1,6 +1,4 @@ .plotly-root { width:100%; - max-height:600px; - overflow: hidden; } diff --git a/src/features/plotly/plot/plot.component.ts b/src/features/plotly/plot/plot.component.ts index 7aaf1322c..7a48153e9 100644 --- a/src/features/plotly/plot/plot.component.ts +++ b/src/features/plotly/plot/plot.component.ts @@ -1,5 +1,21 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, ElementRef, Input, NgZone, OnChanges } from '@angular/core'; +import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, OnChanges, Output } from '@angular/core'; + +type PlotlyEvent = "plotly_click" + +type PlotlyPoint = { + label: string +} + +type PlotlyEv = { + event: MouseEvent + points: PlotlyPoint[] +} + + +type PlotlyElement = { + on: (eventName: PlotlyEvent, callback: (ev: PlotlyEv) => void) => void +} @Component({ selector: 'sxplr-plotly-component', @@ -16,15 +32,29 @@ export class PlotComponent implements OnChanges { @Input("plotly-json") plotlyJson: any + @Output("plotly-label-clicked") + labelClicked = new EventEmitter() + + bindOnClick(el: PlotlyElement) { + el.on("plotly_click", ev => { + const { points } = ev + for (const pt of points){ + this.labelClicked.emit(pt.label) + } + }) + } + plotlyRef: any - constructor(private el: ElementRef, private zone: NgZone) { } + constructor(private el: ElementRef) { } ngOnChanges(): void { if (!this.plotlyJson) return const rootEl = (this.el.nativeElement as HTMLElement).querySelector(".plotly-root") const { data, layout } = this.plotlyJson - this.plotlyRef = window['Plotly'].newPlot(rootEl, data, layout, { responsive: true }) + this.plotlyRef = window['Plotly'].newPlot(rootEl, data, layout, { responsive: true }); + + this.bindOnClick(rootEl as any) } } diff --git a/src/features/pointcloud-intents/index.ts b/src/features/pointcloud-intents/index.ts new file mode 100644 index 000000000..4f66186c5 --- /dev/null +++ b/src/features/pointcloud-intents/index.ts @@ -0,0 +1,2 @@ +export { isPoint, FilterPointTransformer, CFIndex } from "./util" +// export { PointCloudIntents } from "./intents.component" diff --git a/src/features/pointcloud-intents/intents.component.ts b/src/features/pointcloud-intents/intents.component.ts new file mode 100644 index 000000000..eab9ad9ef --- /dev/null +++ b/src/features/pointcloud-intents/intents.component.ts @@ -0,0 +1,175 @@ +// import { CommonModule } from "@angular/common"; +// import { Component, EventEmitter, Inject, InjectionToken, Input, Optional, Output, inject } from "@angular/core"; +// import { BehaviorSubject, Observable, combineLatest } from "rxjs"; +// import { Point, SxplrTemplate } from "src/atlasComponents/sapi/sxplrTypes"; +// import { PathReturn } from "src/atlasComponents/sapi/typeV3"; +// import { AngularMaterialModule } from "src/sharedModules"; +// import { DestroyDirective } from "src/util/directives/destroy.directive"; +// import { CFIndex } from "./util"; +// import { AnnotationLayer } from "src/atlasComponents/annotations"; +// import { map, takeUntil, withLatestFrom } from "rxjs/operators"; +// import { CLICK_INTERCEPTOR_INJECTOR, ClickInterceptor, HOVER_INTERCEPTOR_INJECTOR, HoverInterceptor, THoverConfig } from "src/util/injectionTokens"; + +// type Intent = PathReturn<"/feature/{feature_id}/intents">['items'][number] + +// type Annotation = { +// id: string +// type: 'point' +// point: [number, number, number] +// } + +// function serializeToId(pt: Point): Annotation{ +// return { +// id: `${pt.spaceId}-${pt.loc.join("-")}`, +// type: 'point', +// point: pt.loc.map(v => v*1e6) as [number, number, number] +// } +// } + +// @Component({ +// selector: 'pointcloud-intents', +// templateUrl: './intents.template.html', +// styleUrls: [ +// './intents.style.css' +// ], +// standalone: true, +// imports: [ +// CommonModule, +// AngularMaterialModule +// ], +// hostDirectives: [ +// DestroyDirective +// ] +// }) + +// export class PointCloudIntents { + +// readonly #destroy$ = inject(DestroyDirective).destroyed$ + +// // not yet used +// #intents: Observable + +// #points$ = new BehaviorSubject[]>([] as CFIndex[]) +// #selectedTemplate$ = new BehaviorSubject(null) + +// @Input('points') +// set points(val: CFIndex[]) { +// this.#points$.next(val) +// } + +// @Input('selected-template') +// set selectedTemplate(tmpl: SxplrTemplate){ +// this.#selectedTemplate$.next(tmpl) +// } + +// #spaceMatchedCfIndices$ = combineLatest([ +// this.#points$, +// this.#selectedTemplate$ +// ]).pipe( +// map(([ points, selectedTemplate ]) => points.filter(p => p.index.spaceId === selectedTemplate?.id)) +// ) + +// #spaceMatchedAnnIdToCfIdx$ = this.#spaceMatchedCfIndices$.pipe( +// map(indices => { +// const idToIndexMap = new Map>() +// for (const idx of indices){ +// idToIndexMap.set( +// serializeToId(idx.index).id, +// idx +// ) +// } +// return idToIndexMap +// }) +// ) + +// @Output('point-clicked') +// pointClicked = new EventEmitter>() + +// annLayer: AnnotationLayer +// constructor( +// @Inject(RENDER_CF_POINT) render: RenderCfPoint, +// @Optional() @Inject(CLICK_INTERCEPTOR_INJECTOR) clickInterceptor: ClickInterceptor, +// @Optional() @Inject(HOVER_INTERCEPTOR_INJECTOR) hoverInterceptor: HoverInterceptor, +// ){ +// this.annLayer = new AnnotationLayer("intents", "#ff0000") +// this.#spaceMatchedCfIndices$.pipe( +// takeUntil(this.#destroy$) +// ).subscribe(indices => { +// const anns = indices.map(idx => serializeToId(idx.index)) +// this.annLayer.addAnnotation(anns) +// }, +// e => { +// console.error("error", e) +// }, +// () => { +// this.annLayer.dispose() +// }) + +// this.annLayer.onHover.pipe( +// takeUntil(this.#destroy$), +// withLatestFrom(this.#spaceMatchedAnnIdToCfIdx$), +// ).subscribe(([hover, map]) => { + +// if (hoverInterceptor && !!this.#hoveredMessage){ +// const { remove } = hoverInterceptor +// remove(this.#hoveredMessage) +// this.#hoveredMessage = null +// } + +// this.#hoveredCfIndex = null + +// if (!hover) { +// return +// } + +// const idx = map.get(hover.id) +// if (!idx) { +// console.error(`Couldn't find AnnId: ${hover.id}`) +// return +// } + +// this.#hoveredCfIndex = idx + +// if (hoverInterceptor) { +// const { append } = hoverInterceptor +// const text = render(idx) +// this.#hoveredMessage = { +// message: `Hovering ${text}` +// } +// append(this.#hoveredMessage) +// } +// }) + +// this.#destroy$.subscribe(() => { +// if (hoverInterceptor) { +// const { remove } = hoverInterceptor +// if (this.#hoveredMessage) { +// remove(this.#hoveredMessage) +// this.#hoveredMessage = null +// } +// } +// }) + +// if (clickInterceptor) { +// const { register, deregister } = clickInterceptor +// const onClickHandler = this.onViewerClick.bind(this) +// register(onClickHandler) +// this.#destroy$.subscribe(() => deregister(onClickHandler)) +// } +// } + +// onViewerClick(){ +// if (this.#hoveredCfIndex) { +// this.pointClicked.next(this.#hoveredCfIndex) +// return false +// } +// return true +// } + +// #hoveredCfIndex: CFIndex = null +// #hoveredMessage: THoverConfig = null + +// } + +// export const RENDER_CF_POINT = new InjectionToken("RENDER_CF_POINT") +// export type RenderCfPoint = (cfIndex: CFIndex) => string diff --git a/src/features/pointcloud-intents/intents.style.css b/src/features/pointcloud-intents/intents.style.css new file mode 100644 index 000000000..e69de29bb diff --git a/src/features/pointcloud-intents/intents.template.html b/src/features/pointcloud-intents/intents.template.html new file mode 100644 index 000000000..e69de29bb diff --git a/src/features/pointcloud-intents/util.ts b/src/features/pointcloud-intents/util.ts new file mode 100644 index 000000000..bae7f91ac --- /dev/null +++ b/src/features/pointcloud-intents/util.ts @@ -0,0 +1,24 @@ +import { Pipe, PipeTransform } from "@angular/core"; +import { Point, SimpleCompoundFeature } from "src/atlasComponents/sapi/sxplrTypes"; + +export function isPoint(val: string|Point): val is Point{ + return !!(val as any).spaceId && !!(val as any).loc +} + + +export type CFIndex = SimpleCompoundFeature['indices'][number] + + +function cfIndexHasPoint(val: CFIndex): val is CFIndex{ + return isPoint(val.index) +} + +@Pipe({ + name: 'filterForPoints', + pure: true +}) +export class FilterPointTransformer implements PipeTransform{ + public transform(value: CFIndex[]): CFIndex[] { + return value.filter(cfIndexHasPoint) + } +} diff --git a/src/features/util.ts b/src/features/util.ts new file mode 100644 index 000000000..928661e75 --- /dev/null +++ b/src/features/util.ts @@ -0,0 +1,19 @@ +import { InjectionToken } from "@angular/core"; +import { Observable } from "rxjs"; +import { SxplrParcellation, SxplrRegion, SxplrTemplate } from "src/atlasComponents/sapi/sxplrTypes"; + +export type BBox = [[number, number, number], [number, number, number]] + +export type TPRB = { + template?: SxplrTemplate + parcellation?: SxplrParcellation + region?: SxplrRegion + bbox?: BBox +} + +export type FeatureConcept = { + register: (id: string, concept: TPRB) => void + concept$: Observable<{ id: string, concept: TPRB }> +} + +export const FEATURE_CONCEPT_TOKEN = new InjectionToken("FEATURE_CONCEPT_TOKEN") diff --git a/src/features/voi-bbox.directive.ts b/src/features/voi-bbox.directive.ts index f150225b9..5408bc08c 100644 --- a/src/features/voi-bbox.directive.ts +++ b/src/features/voi-bbox.directive.ts @@ -1,25 +1,27 @@ -import { Directive, Inject, Input, OnDestroy, Optional } from "@angular/core"; +import { Directive, Inject, Input, Optional, inject } from "@angular/core"; import { Store } from "@ngrx/store"; -import { concat, interval, of, Subject, Subscription } from "rxjs"; -import { debounce, distinctUntilChanged, filter, pairwise, take } from "rxjs/operators"; +import { concat, interval, of, Subject } from "rxjs"; +import { debounce, distinctUntilChanged, filter, pairwise, take, takeUntil } from "rxjs/operators"; import { AnnotationLayer, TNgAnnotationAABBox, TNgAnnotationPoint } from "src/atlasComponents/annotations"; import { Feature, VoiFeature } from "src/atlasComponents/sapi/sxplrTypes"; import { userInteraction } from "src/state"; import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR } from "src/util"; import { arrayEqual } from "src/util/array"; import { isVoiData } from "./guards" +import { DestroyDirective } from "src/util/directives/destroy.directive"; +import { HOVER_INTERCEPTOR_INJECTOR, HoverInterceptor, THoverConfig } from "src/util/injectionTokens"; @Directive({ selector: '[voiBbox]', + hostDirectives: [ DestroyDirective ] }) -export class VoiBboxDirective implements OnDestroy { - - #onDestroyCb: (() => void)[] = [] +export class VoiBboxDirective { + + #destory$ = inject(DestroyDirective).destroyed$ static VOI_LAYER_NAME = 'voi-annotation-layer' static VOI_ANNOTATION_COLOR = "#ffff00" - #voiSubs: Subscription[] = [] private _voiBBoxSvc: AnnotationLayer get voiBBoxSvc(): AnnotationLayer { if (this._voiBBoxSvc) return this._voiBBoxSvc @@ -29,14 +31,15 @@ export class VoiBboxDirective implements OnDestroy { VoiBboxDirective.VOI_ANNOTATION_COLOR ) this._voiBBoxSvc = layer - this.#voiSubs.push( - layer.onHover.subscribe(val => this.handleOnHoverFeature(val || {})) - ) - this.#onDestroyCb.push(() => { + layer.onHover.pipe( + takeUntil(this.#destory$) + ).subscribe(val => this.handleOnHoverFeature(val || {})) + + this.#destory$.subscribe(() => { this._voiBBoxSvc.dispose() this._voiBBoxSvc = null }) - return layer + return this._voiBBoxSvc } catch (e) { return null } @@ -54,25 +57,27 @@ export class VoiBboxDirective implements OnDestroy { return this.#voiFeatures } - ngOnDestroy(): void { - while (this.#onDestroyCb.length > 0) this.#onDestroyCb.pop()() - } + #hoverMsgs: THoverConfig[] = [] constructor( private store: Store, - @Optional() @Inject(CLICK_INTERCEPTOR_INJECTOR) clickInterceptor: ClickInterceptor, + @Optional() @Inject(CLICK_INTERCEPTOR_INJECTOR) + clickInterceptor: ClickInterceptor, + @Optional() @Inject(HOVER_INTERCEPTOR_INJECTOR) + private hoverInterceptor: HoverInterceptor, ){ if (clickInterceptor) { const { register, deregister } = clickInterceptor const handleClick = this.handleClick.bind(this) register(handleClick) - this.#onDestroyCb.push(() => deregister(handleClick)) + this.#destory$.subscribe(() => deregister(handleClick)) } - const sub = concat( + concat( of([] as VoiFeature[]), this.#features$ ).pipe( + takeUntil(this.#destory$), distinctUntilChanged(arrayEqual((o, n) => o.id === n.id)), pairwise(), debounce(() => @@ -109,10 +114,38 @@ export class VoiBboxDirective implements OnDestroy { if (this.voiBBoxSvc) this.voiBBoxSvc.setVisible(true) }) - this.#onDestroyCb.push(() => sub.unsubscribe()) - this.#onDestroyCb.push(() => this.store.dispatch( - userInteraction.actions.setMouseoverVoi({ feature: null }) - )) + this.#destory$.subscribe(() => { + this.store.dispatch( + userInteraction.actions.setMouseoverVoi({ feature: null }) + ) + this.#dismissHoverMsg() + }) + } + + #dismissHoverMsg(){ + if (!this.hoverInterceptor) { + return + } + + const { remove } = this.hoverInterceptor + for (const msg of this.#hoverMsgs){ + remove(msg) + } + } + + #appendHoverMsg(feats: VoiFeature[]){ + if (!this.hoverInterceptor) { + return + } + const { append } = this.hoverInterceptor + this.#hoverMsgs = feats.map(feat => ({ + message: `${feat?.name}`, + fontIcon: 'fa-database', + fontSet: 'fas' + })) + for (const msg of this.#hoverMsgs){ + append(msg) + } } handleClick(){ @@ -135,6 +168,10 @@ export class VoiBboxDirective implements OnDestroy { this.store.dispatch( userInteraction.actions.setMouseoverVoi({ feature }) ) + this.#dismissHoverMsg() + if (feature) { + this.#appendHoverMsg([feature]) + } } #pointsToAABB(pointA: [number, number, number], pointB: [number, number, number]): TNgAnnotationAABBox{ diff --git a/src/index.html b/src/index.html index 00ed70280..95d9da0bf 100644 --- a/src/index.html +++ b/src/index.html @@ -3,7 +3,7 @@ - + @@ -12,7 +12,7 @@ - + diff --git a/src/main.module.ts b/src/main.module.ts index 2735d71ea..aedbee438 100644 --- a/src/main.module.ts +++ b/src/main.module.ts @@ -53,6 +53,7 @@ import { CONST } from "common/constants" import { ViewerCommonEffects } from './viewerModule'; import { environment } from './environments/environment'; import { SAPI } from './atlasComponents/sapi'; +import { GET_ATTR_TOKEN, GetAttr } from './util/constants'; @NgModule({ imports: [ @@ -187,12 +188,20 @@ import { SAPI } from './atlasComponents/sapi'; multi: true, deps: [ AuthService, Store ] }, + { + provide: GET_ATTR_TOKEN, + useFactory: (document: Document) => { + return (attr: string) => { + const rootEl = document.querySelector("atlas-viewer") + return rootEl?.getAttribute(attr) + } + }, + deps: [ DOCUMENT ] + }, { provide: APP_INITIALIZER, - useFactory: (sapi: SAPI, document: Document) => { - - const rootEl = document.querySelector("atlas-viewer") - const overwriteSapiUrl = rootEl?.getAttribute(CONST.OVERWRITE_SAPI_ENDPOINT_ATTR) + useFactory: (sapi: SAPI, getAttr: GetAttr) => { + const overwriteSapiUrl = getAttr(CONST.OVERWRITE_SAPI_ENDPOINT_ATTR) const { SIIBRA_API_ENDPOINTS } = environment const endpoints = (overwriteSapiUrl && [ overwriteSapiUrl ]) || SIIBRA_API_ENDPOINTS.split(',') @@ -207,7 +216,7 @@ import { SAPI } from './atlasComponents/sapi'; } }, multi: true, - deps: [ SAPI, DOCUMENT ] + deps: [ SAPI, GET_ATTR_TOKEN ] } ], bootstrap: [ diff --git a/src/mouseoverModule/index.ts b/src/mouseoverModule/index.ts index 8dea7b959..a419a4a25 100644 --- a/src/mouseoverModule/index.ts +++ b/src/mouseoverModule/index.ts @@ -1,3 +1,2 @@ -export { MouseHoverDirective } from './mouseover.directive' -export { MouseoverModule } from './mouseover.module' -export { TransformOnhoverSegmentPipe } from './transformOnhoverSegment.pipe' \ No newline at end of file +export { MouseOver } from "./mouseover.component" +export { MouseOverSvc } from "./service" diff --git a/src/mouseoverModule/mouseOverCvt.pipe.ts b/src/mouseoverModule/mouseOverCvt.pipe.ts deleted file mode 100644 index fabd1d489..000000000 --- a/src/mouseoverModule/mouseOverCvt.pipe.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Pipe, PipeTransform } from "@angular/core"; -import { TOnHoverObj } from "./util"; - -function render(key: T, value: TOnHoverObj[T]){ - if (!value) return [] - switch (key) { - case 'regions': { - return (value as TOnHoverObj['regions']).map(seg => { - return { - icon: { - fontSet: 'fas', - fontIcon: 'fa-brain', - cls: 'fas fa-brain', - }, - text: seg?.name || "Unknown" - } - }) - } - case 'voi': { - const { name } = value as TOnHoverObj['voi'] - return [{ - icon: { - fontSet: 'fas', - fontIcon: 'fa-database', - cls: 'fas fa-database' - }, - text: name - }] - } - case 'annotation': { - const { annotationType, name } = (value as TOnHoverObj['annotation']) - let fontIcon: string - if (annotationType === 'Point') fontIcon = 'fa-circle' - if (annotationType === 'Line') fontIcon = 'fa-slash' - if (annotationType === 'Polygon') fontIcon = 'fa-draw-polygon' - if (!annotationType) fontIcon = 'fa-file' - return [{ - icon: { - fontSet: 'fas', - fontIcon, - cls: `fas ${fontIcon}`, - }, - text: name || `Unnamed ${annotationType}` - }] - } - default: { - return [{ - icon: { - fontSet: 'fas', - fontIcon: 'fa-file', - cls: 'fas fa-file' - }, - text: `Unknown hovered object` - }] - } - } -} - -type TCvtOutput = { - icon: { - fontSet: string - fontIcon: string - cls: string - } - text: string -} - -@Pipe({ - name: 'mouseoverCvt', - pure: true -}) - -export class MouseOverConvertPipe implements PipeTransform{ - - public transform(dict: TOnHoverObj){ - const output: TCvtOutput[] = [] - for (const key in dict) { - output.push( - ...render(key as keyof TOnHoverObj, dict[key]) - ) - } - return output - } -} \ No newline at end of file diff --git a/src/mouseoverModule/mouseover.component.ts b/src/mouseoverModule/mouseover.component.ts new file mode 100644 index 000000000..4c5b58b5e --- /dev/null +++ b/src/mouseoverModule/mouseover.component.ts @@ -0,0 +1,22 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { AngularMaterialModule } from "src/sharedModules"; +import { MouseOverSvc } from "./service"; + +@Component({ + selector: 'mouseover-info', + templateUrl: './mouseover.template.html', + styleUrls: [ + './mouseover.style.css' + ], + standalone: true, + imports: [ + AngularMaterialModule, + CommonModule + ], +}) + +export class MouseOver { + constructor(private svc: MouseOverSvc) {} + messages$ = this.svc.messages$ +} diff --git a/src/mouseoverModule/mouseover.directive.ts b/src/mouseoverModule/mouseover.directive.ts deleted file mode 100644 index fad1fbf85..000000000 --- a/src/mouseoverModule/mouseover.directive.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Directive } from "@angular/core" -import { select, Store } from "@ngrx/store" -import { merge, Observable } from "rxjs" -import { distinctUntilChanged, map, scan } from "rxjs/operators" -import { TOnHoverObj, temporalPositveScanFn } from "./util" -import { ModularUserAnnotationToolService } from "src/atlasComponents/userAnnotations/tools/service"; -import { userInteraction } from "src/state" -import { arrayEqual } from "src/util/array" - -@Directive({ - selector: '[iav-mouse-hover]', - exportAs: 'iavMouseHover', -}) - -export class MouseHoverDirective { - - public currentOnHoverObs$: Observable = merge( - this.store$.pipe( - select(userInteraction.selectors.mousingOverRegions), - ).pipe( - distinctUntilChanged(arrayEqual((o, n) => o?.name === n?.name)), - map(regions => { - return { regions } - }), - ), - this.annotSvc.hoveringAnnotations$.pipe( - distinctUntilChanged(), - map(annotation => { - return { annotation } - }), - ), - this.store$.pipe( - select(userInteraction.selectors.mousingOverVoiFeature), - distinctUntilChanged((o, n) => o?.id === n?.id), - map(voi => ({ voi })) - ) - ).pipe( - scan(temporalPositveScanFn, []), - map(arr => { - - let returnObj: TOnHoverObj = { - regions: null, - annotation: null, - voi: null - } - - for (const val of arr) { - returnObj = { - ...returnObj, - ...val - } - } - - return returnObj - }), - ) - - constructor( - private store$: Store, - private annotSvc: ModularUserAnnotationToolService, - ) { - } -} diff --git a/src/mouseoverModule/mouseover.module.ts b/src/mouseoverModule/mouseover.module.ts deleted file mode 100644 index b5fbcc9fe..000000000 --- a/src/mouseoverModule/mouseover.module.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { CommonModule } from "@angular/common"; -import { NgModule } from "@angular/core"; -import { TransformOnhoverSegmentPipe } from "src/atlasViewer/onhoverSegment.pipe"; -import { MouseHoverDirective } from "./mouseover.directive"; -import { MouseOverConvertPipe } from "./mouseOverCvt.pipe"; - - -@NgModule({ - imports: [ - CommonModule, - ], - declarations: [ - MouseHoverDirective, - TransformOnhoverSegmentPipe, - MouseOverConvertPipe, - ], - exports: [ - MouseHoverDirective, - TransformOnhoverSegmentPipe, - MouseOverConvertPipe, - ] -}) - -export class MouseoverModule{} \ No newline at end of file diff --git a/src/mouseoverModule/mouseover.style.css b/src/mouseoverModule/mouseover.style.css new file mode 100644 index 000000000..15f65ddeb --- /dev/null +++ b/src/mouseoverModule/mouseover.style.css @@ -0,0 +1,11 @@ +:host +{ + display: inline-block; +} + +.centered +{ + display: flex; + justify-content: center; + align-items: center; +} diff --git a/src/mouseoverModule/mouseover.template.html b/src/mouseoverModule/mouseover.template.html new file mode 100644 index 000000000..00d649b30 --- /dev/null +++ b/src/mouseoverModule/mouseover.template.html @@ -0,0 +1,17 @@ + + + + + + + {{ message.message }} + + + + + + {{ message.message }} + + + + diff --git a/src/mouseoverModule/service.ts b/src/mouseoverModule/service.ts new file mode 100644 index 000000000..284faa28f --- /dev/null +++ b/src/mouseoverModule/service.ts @@ -0,0 +1,34 @@ +import { Injectable } from "@angular/core"; +import { BehaviorSubject } from "rxjs"; +import { debounceTime, shareReplay } from "rxjs/operators"; +import { THoverConfig } from "src/util/injectionTokens"; + +@Injectable({ + providedIn: 'root' +}) +export class MouseOverSvc { + + #messages: THoverConfig[] = [] + + #messages$ = new BehaviorSubject(this.#messages) + messages$ = this.#messages$.pipe( + debounceTime(16), + shareReplay(1), + ) + + set messages(messages: THoverConfig[]){ + this.#messages = messages + this.#messages$.next(this.#messages) + } + + get messages(): THoverConfig[]{ + return this.#messages + } + + append(message: THoverConfig){ + this.messages = this.messages.concat(message) + } + remove(message: THoverConfig){ + this.messages = this.messages.filter(v => v !== message) + } +} diff --git a/src/mouseoverModule/transformOnhoverSegment.pipe.ts b/src/mouseoverModule/transformOnhoverSegment.pipe.ts deleted file mode 100644 index 5199a582a..000000000 --- a/src/mouseoverModule/transformOnhoverSegment.pipe.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Pipe, PipeTransform, SecurityContext } from "@angular/core"; -import { DomSanitizer, SafeHtml } from "@angular/platform-browser"; - -@Pipe({ - name: 'transformOnhoverSegment', -}) - -export class TransformOnhoverSegmentPipe implements PipeTransform { - constructor(private sanitizer: DomSanitizer) { - - } - - private sanitizeHtml(inc: string): SafeHtml { - return this.sanitizer.sanitize(SecurityContext.HTML, inc) - } - - private getStatus(text: string) { - return ` (${this.sanitizeHtml(text)})` - } - - public transform(segment: any | number): SafeHtml { - return this.sanitizer.bypassSecurityTrustHtml(( - ( this.sanitizeHtml(segment.name) || segment) + - (segment.status - ? this.getStatus(segment.status) - : '') - )) - } -} diff --git a/src/mouseoverModule/util.spec.ts b/src/mouseoverModule/util.spec.ts deleted file mode 100644 index 31920019b..000000000 --- a/src/mouseoverModule/util.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import {} from 'jasmine' -import { forkJoin, Subject } from 'rxjs'; -import { scan, skip, take } from 'rxjs/operators'; -import { temporalPositveScanFn } from './util' - -const segmentsPositive = { segments: [{ hello: 'world' }] } as {segments: any} -const segmentsNegative = { segments: [] } - -const userLandmarkPostive = { userLandmark: true } -const userLandmarkNegative = { userLandmark: null } - -describe('temporalPositveScanFn', () => { - const subscriptions = [] - afterAll(() => { - while (subscriptions.length > 0) { subscriptions.pop().unsubscribe() } - }) - - it('should scan obs as expected', (done) => { - - const source = new Subject() - - const testFirstEv = source.pipe( - scan(temporalPositveScanFn, []), - take(1), - ) - - const testSecondEv = source.pipe( - scan(temporalPositveScanFn, []), - skip(1), - take(1), - ) - - const testThirdEv = source.pipe( - scan(temporalPositveScanFn, []), - skip(2), - take(1), - ) - - const testFourthEv = source.pipe( - scan(temporalPositveScanFn, []), - skip(3), - take(1), - ) - - forkJoin([ - testFirstEv, - testSecondEv, - testThirdEv, - testFourthEv, - ]).pipe( - take(1), - ).subscribe(([ arr1, arr2, arr3, arr4 ]) => { - expect(arr1).toEqual([ segmentsPositive ] as any) - expect(arr2).toEqual([ userLandmarkPostive, segmentsPositive ] as any) - expect(arr3).toEqual([ userLandmarkPostive ] as any) - expect(arr4).toEqual([]) - done() - }) - - source.next(segmentsPositive) - source.next(userLandmarkPostive) - source.next(segmentsNegative) - source.next(userLandmarkNegative) - }) -}) diff --git a/src/mouseoverModule/util.ts b/src/mouseoverModule/util.ts deleted file mode 100644 index 208c01cee..000000000 --- a/src/mouseoverModule/util.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { SxplrRegion, VoiFeature } from "src/atlasComponents/sapi/sxplrTypes" -import { IAnnotationGeometry } from "src/atlasComponents/userAnnotations/tools/type" - -export type TOnHoverObj = { - regions: SxplrRegion[] - annotation: IAnnotationGeometry - voi: VoiFeature -} - -/** - * Scan function which prepends newest positive (i.e. defined) value - * - * e.g. const source = new Subject() - * source.pipe( - * scan(temporalPositveScanFn, []) - * ).subscribe(this.log.log) // outputs - * - * - * - */ -export const temporalPositveScanFn = (acc: Array, curr: Partial) => { - - const keys = Object.keys(curr) - - // empty array is truthy - const isPositive = keys.some(key => Array.isArray(curr[key]) - ? curr[key].length > 0 - : !!curr[key] - ) - - return isPositive - ? [curr, ...(acc.filter(item => !keys.some(key => !!item[key])))] as Array - : acc.filter(item => !keys.some(key => !!item[key])) -} diff --git a/src/plugin/README.md b/src/plugin/README.md index 6cee16b9c..2353fb91d 100644 --- a/src/plugin/README.md +++ b/src/plugin/README.md @@ -48,8 +48,10 @@ The API is generated automatically with the following script: npm run api-schema ``` -[handshake API](../api/handshake/README.md.md) +The references can be seen below: -[broadcast API](../api/broadcast/README.md.md) +[handshake API](../api/handshake/README.md) -[request API](../api/request/README.md.md) +[broadcast API](../api/broadcast/README.md) + +[request API](../api/request/README.md) diff --git a/src/routerModule/effects.ts b/src/routerModule/effects.ts index bec4b7c02..6a05b2f1b 100644 --- a/src/routerModule/effects.ts +++ b/src/routerModule/effects.ts @@ -57,7 +57,7 @@ export class RouterEffects { if (humanAtlas) { return atlasSelection.actions.selectATPById({ atlasId: IDS.ATLAES.HUMAN, - parcellationId: IDS.PARCELLATION.JBA30, + parcellationId: IDS.PARCELLATION.JBA31, templateId: IDS.TEMPLATES.MNI152, }) } diff --git a/src/routerModule/routeStateTransform.service.spec.ts b/src/routerModule/routeStateTransform.service.spec.ts index 829e4823b..7f5e4159e 100644 --- a/src/routerModule/routeStateTransform.service.spec.ts +++ b/src/routerModule/routeStateTransform.service.spec.ts @@ -3,7 +3,7 @@ import { of } from "rxjs" import { SAPI } from "src/atlasComponents/sapi" import { RouteStateTransformSvc } from "./routeStateTransform.service" import { DefaultUrlSerializer } from "@angular/router" -import { atlasSelection, userInteraction } from "src/state" +import { atlasAppearance, atlasSelection, userInteraction, userInterface } from "src/state" import { QuickHash } from "src/util/fn" import { NEHUBA_CONFIG_SERVICE_TOKEN } from "src/viewerModule/nehuba/config.service" import { MockStore, provideMockStore } from "@ngrx/store/testing" @@ -135,6 +135,10 @@ describe("> routeStateTransform.service.ts", () => { store.overrideSelector(atlasSelection.selectors.navigation, navigation as any) store.overrideSelector(userInteraction.selectors.selectedFeature, null) + store.overrideSelector(userInterface.selectors.panelMode, "FOUR_PANEL") + store.overrideSelector(userInterface.selectors.panelOrder, "0123") + store.overrideSelector(atlasAppearance.selectors.octantRemoval, false) + store.overrideSelector(atlasAppearance.selectors.showDelineation, true) }) diff --git a/src/share/saneUrl/saneUrl.service.ts b/src/share/saneUrl/saneUrl.service.ts index d838c72b8..6fc94492d 100644 --- a/src/share/saneUrl/saneUrl.service.ts +++ b/src/share/saneUrl/saneUrl.service.ts @@ -2,22 +2,29 @@ import { HttpClient } from "@angular/common/http"; import { Injectable } from "@angular/core"; import { throwError } from "rxjs"; import { catchError, mapTo } from "rxjs/operators"; -import { BACKENDURL } from 'src/util/constants' import { IKeyValStore, NotFoundError } from '../type' +import { environment } from "src/environments/environment"; @Injectable({ providedIn: 'root' }) -export class SaneUrlSvc implements IKeyValStore{ - public saneUrlRoot = `${BACKENDURL}go/` +export class SaneUrlSvc implements IKeyValStore { + + #backendUrl = (() => { + if (environment.BACKEND_URL) { + return environment.BACKEND_URL.replace(/\/$/, '') + } + const url = new URL(window.location.href) + const { protocol, hostname, pathname } = url + return `${protocol}//${hostname}${pathname.replace(/\/$/, '')}` + })() + + public saneUrlRoot = `${this.#backendUrl}/go/` + constructor( private http: HttpClient ){ - if (!BACKENDURL) { - const loc = window.location - this.saneUrlRoot = `${loc.protocol}//${loc.hostname}${!!loc.port ? (':' + loc.port) : ''}${loc.pathname}go/` - } } getKeyVal(key: string) { diff --git a/src/sharedModules/angularMaterial.exports.ts b/src/sharedModules/angularMaterial.exports.ts index bfff6a835..3e3b99556 100644 --- a/src/sharedModules/angularMaterial.exports.ts +++ b/src/sharedModules/angularMaterial.exports.ts @@ -1,7 +1,7 @@ +export { MatTab, MatTabGroup } from "@angular/material/tabs"; export { ErrorStateMatcher } from "@angular/material/core"; -export { MatDialogConfig, MatDialog, MatDialogRef } from "@angular/material/dialog"; +export { MAT_DIALOG_DATA, MatDialogConfig, MatDialog, MatDialogRef } from "@angular/material/dialog"; export { MatSnackBar, MatSnackBarRef, SimpleSnackBar, MatSnackBarConfig } from "@angular/material/snack-bar"; -export { MAT_DIALOG_DATA } from "@angular/material/dialog"; export { MatBottomSheet, MatBottomSheetRef, MatBottomSheetConfig } from "@angular/material/bottom-sheet"; export { MatSlideToggle, MatSlideToggleChange } from "@angular/material/slide-toggle" export { MatTableDataSource } from "@angular/material/table" @@ -10,5 +10,6 @@ export { Clipboard } from "@angular/cdk/clipboard"; export { UntypedFormControl } from "@angular/forms"; export { MatTreeFlatDataSource, MatTreeFlattener } from "@angular/material/tree" export { MatAutocompleteSelectedEvent } from "@angular/material/autocomplete"; - -export { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing' \ No newline at end of file +export { MatPaginator } from "@angular/material/paginator"; +export { MatInput } from "@angular/material/input"; +export { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing' diff --git a/src/sharedModules/angularMaterial.module.ts b/src/sharedModules/angularMaterial.module.ts index 2a533436b..7e873b5e9 100644 --- a/src/sharedModules/angularMaterial.module.ts +++ b/src/sharedModules/angularMaterial.module.ts @@ -34,6 +34,7 @@ import { MatRadioModule } from "@angular/material/radio"; import { MatTableModule } from "@angular/material/table"; import { MatSortModule } from "@angular/material/sort"; import { A11yModule } from "@angular/cdk/a11y"; +import { MatPaginatorModule } from '@angular/material/paginator' const defaultDialogOption: MatDialogConfig = new MatDialogConfig() @@ -72,6 +73,7 @@ const defaultDialogOption: MatDialogConfig = new MatDialogConfig() MatTableModule, MatSortModule, A11yModule, + MatPaginatorModule, ], providers: [{ provide: MAT_DIALOG_DEFAULT_OPTIONS, diff --git a/src/sharedModules/index.ts b/src/sharedModules/index.ts index 2f35196f4..31092dd26 100644 --- a/src/sharedModules/index.ts +++ b/src/sharedModules/index.ts @@ -1,3 +1,5 @@ export { AngularMaterialModule -} from './angularMaterial.module' \ No newline at end of file +} from './angularMaterial.module' + +export * from './angularMaterial.exports' diff --git a/src/state/atlasAppearance/const.ts b/src/state/atlasAppearance/const.ts index ed4497822..16195d9e3 100644 --- a/src/state/atlasAppearance/const.ts +++ b/src/state/atlasAppearance/const.ts @@ -22,7 +22,7 @@ export type ThreeSurferCustomLayer = { } & CustomLayerBase export type ThreeSurferCustomLabelLayer = { - clType: 'baselayer/threesurfer-label' + clType: 'baselayer/threesurfer-label/gii-label' | 'baselayer/threesurfer-label/annot' source: string laterality: 'left' | 'right' } & CustomLayerBase diff --git a/src/state/atlasSelection/actions.ts b/src/state/atlasSelection/actions.ts index 86bff9616..2920b2f94 100644 --- a/src/state/atlasSelection/actions.ts +++ b/src/state/atlasSelection/actions.ts @@ -1,5 +1,5 @@ import { createAction, props } from "@ngrx/store"; -import { SxplrAtlas, SxplrParcellation, SxplrRegion, SxplrTemplate } from "src/atlasComponents/sapi/sxplrTypes"; +import { BoundingBox, SxplrAtlas, SxplrParcellation, SxplrRegion, SxplrTemplate } from "src/atlasComponents/sapi/sxplrTypes"; import { BreadCrumb, nameSpace, ViewerMode, AtlasSelectionState } from "./const" import { TFace, TSandsPoint } from "src/util/types"; @@ -156,7 +156,7 @@ export const navigateTo = createAction( export const navigateToRegion = createAction( `${nameSpace} navigateToRegion`, props<{ - region: SxplrRegion + region: Pick }>() ) @@ -189,3 +189,10 @@ export const selectPoint = createAction( export const clearSelectedPoint = createAction( `${nameSpace} clearPoint` ) + +export const setViewport = createAction( + `${nameSpace} setViewport`, + props<{ + viewport: BoundingBox + }>() +) diff --git a/src/state/atlasSelection/const.ts b/src/state/atlasSelection/const.ts index bc8fa3156..277bc8375 100644 --- a/src/state/atlasSelection/const.ts +++ b/src/state/atlasSelection/const.ts @@ -1,4 +1,4 @@ -import { SxplrAtlas, SxplrTemplate, SxplrParcellation, SxplrRegion } from "src/atlasComponents/sapi/sxplrTypes" +import { SxplrAtlas, SxplrTemplate, SxplrParcellation, SxplrRegion, BoundingBox } from "src/atlasComponents/sapi/sxplrTypes" import { TSandsPoint, TFace } from "src/util/types" export const nameSpace = `[state.atlasSelection]` @@ -14,6 +14,8 @@ export type AtlasSelectionState = { selectedParcellation: SxplrParcellation selectedParcellationAllRegions: SxplrRegion[] + currentViewport: BoundingBox + selectedRegions: SxplrRegion[] standAloneVolumes: string[] diff --git a/src/state/atlasSelection/effects.spec.ts b/src/state/atlasSelection/effects.spec.ts index a64b03233..aeefa764a 100644 --- a/src/state/atlasSelection/effects.spec.ts +++ b/src/state/atlasSelection/effects.spec.ts @@ -3,7 +3,7 @@ import { provideMockActions } from "@ngrx/effects/testing" import { Action } from "@ngrx/store" import { MockStore, provideMockStore } from "@ngrx/store/testing" import { hot } from "jasmine-marbles" -import { NEVER, ReplaySubject, of, throwError } from "rxjs" +import { EMPTY, NEVER, ReplaySubject, of, throwError } from "rxjs" import { SAPI, SAPIModule } from "src/atlasComponents/sapi" import { SxplrRegion, SxplrAtlas, SxplrParcellation, SxplrTemplate } from "src/atlasComponents/sapi/sxplrTypes" import { IDS } from "src/atlasComponents/sapi/constants" @@ -15,6 +15,7 @@ import { BrowserAnimationsModule } from "@angular/platform-browser/animations" import { translateV3Entities } from "src/atlasComponents/sapi/translateV3" import { PathReturn } from "src/atlasComponents/sapi/typeV3" import { MatDialog } from 'src/sharedModules/angularMaterial.exports' +import { InterSpaceCoordXformSvc } from "src/atlasComponents/sapi/core/space/interSpaceCoordXform.service" describe("> effects.ts", () => { describe("> Effect", () => { @@ -104,6 +105,14 @@ describe("> effects.ts", () => { } } }, + { + provide: InterSpaceCoordXformSvc, + useValue: { + transform() { + return EMPTY + } + } + } ] }) }) diff --git a/src/state/atlasSelection/effects.ts b/src/state/atlasSelection/effects.ts index c0ca77e8e..c7cd686d5 100644 --- a/src/state/atlasSelection/effects.ts +++ b/src/state/atlasSelection/effects.ts @@ -1,19 +1,25 @@ import { Injectable } from "@angular/core"; import { Actions, createEffect, ofType } from "@ngrx/effects"; -import { forkJoin, from, NEVER, Observable, of, throwError } from "rxjs"; -import { catchError, filter, map, mapTo, switchMap, take, withLatestFrom } from "rxjs/operators"; +import { combineLatest, concat, forkJoin, from, NEVER, Observable, of, throwError } from "rxjs"; +import { catchError, debounceTime, distinctUntilChanged, filter, map, mapTo, switchMap, take, withLatestFrom } from "rxjs/operators"; import { IDS, SAPI } from "src/atlasComponents/sapi"; import * as mainActions from "../actions" import { select, Store } from "@ngrx/store"; import { selectors, actions, fromRootStore } from '.' import { AtlasSelectionState } from "./const" -import { atlasAppearance, atlasSelection } from ".."; +import { atlasAppearance, atlasSelection, generalActions } from ".."; import { InterSpaceCoordXformSvc } from "src/atlasComponents/sapi/core/space/interSpaceCoordXform.service"; import { SxplrAtlas, SxplrParcellation, SxplrRegion, SxplrTemplate } from "src/atlasComponents/sapi/sxplrTypes"; import { DecisionCollapse } from "src/atlasComponents/sapi/decisionCollapse.service"; import { DialogFallbackCmp } from "src/ui/dialogInfo"; import { MatDialog } from 'src/sharedModules/angularMaterial.exports' +import { ResizeObserverService } from "src/util/windowResize/windowResize.service"; +import { TViewerEvtCtxData } from "src/viewerModule/viewer.interface"; +import { ContextMenuService } from "src/contextMenuModule"; +import { NehubaVCtxToBbox } from "src/viewerModule/pipes/nehubaVCtxToBbox.pipe"; + +const NEHUBA_CTX_BBOX = new NehubaVCtxToBbox() type OnTmplParcHookArg = { previous: { @@ -448,6 +454,48 @@ export class Effect { map(() => actions.clearSelectedRegions()) )) + onViewportChanges = createEffect(() => this.store.pipe( + select(atlasAppearance.selectors.useViewer), + distinctUntilChanged(), + switchMap(useViewer => { + if (useViewer !== "NEHUBA") { + return of(generalActions.noop()) + } + return this.store.pipe( + select(selectors.selectedTemplate), + switchMap(selectedTemplate => combineLatest([ + concat( + of(null), + this.resize.windowResize, + ), + this.ctxMenuSvc.context$ + ]).pipe( + debounceTime(160), + map(([_, ctx]) => { + + const { width, height } = window.screen + const size = Math.max(width, height) + + const result = NEHUBA_CTX_BBOX.transform(ctx, [size, size, size]) + if (!result) { + return generalActions.noop() + } + const [ min, max ] = result + return actions.setViewport({ + viewport: { + spaceId: selectedTemplate.id, + space: selectedTemplate, + minpoint: min, + maxpoint: max, + center: min.map((v, idx) => (v + max[idx])/2) as [number, number, number] + } + }) + }) + )) + ) + }) + )) + constructor( private action: Actions, private sapiSvc: SAPI, @@ -455,6 +503,9 @@ export class Effect { private interSpaceCoordXformSvc: InterSpaceCoordXformSvc, private collapser: DecisionCollapse, private dialog: MatDialog, + private resize: ResizeObserverService, + /** potential issue with circular import. generic should not import specific */ + private ctxMenuSvc: ContextMenuService>, ){ } } \ No newline at end of file diff --git a/src/state/atlasSelection/selectors.ts b/src/state/atlasSelection/selectors.ts index 44f122b36..90d14407e 100644 --- a/src/state/atlasSelection/selectors.ts +++ b/src/state/atlasSelection/selectors.ts @@ -69,3 +69,8 @@ export const relevantSelectedPoint = createSelector( return null } ) + +export const currentViewport = createSelector( + selectStore, + store => store.currentViewport +) diff --git a/src/state/atlasSelection/store.ts b/src/state/atlasSelection/store.ts index 036fae219..4eb6071ff 100644 --- a/src/state/atlasSelection/store.ts +++ b/src/state/atlasSelection/store.ts @@ -13,6 +13,7 @@ export const defaultState: AtlasSelectionState = { viewerMode: null, breadcrumbs: [], selectedPoint: null, + currentViewport: null, } const reducer = createReducer( @@ -145,6 +146,15 @@ const reducer = createReducer( selectedPoint: null } } + ), + on( + actions.setViewport, + (state, { viewport }) => { + return { + ...state, + currentViewport: viewport + } + } ) ) diff --git a/src/theme.scss b/src/theme.scss index 4898089b8..d184be4f4 100644 --- a/src/theme.scss +++ b/src/theme.scss @@ -133,7 +133,7 @@ $sxplr-dark-theme: mat.define-dark-theme(( { @include mat.all-component-themes($sxplr-dark-theme); @include custom-cmp($sxplr-dark-theme); - input[type="text"] + input[type="text"],textarea { caret-color: white!important; } diff --git a/src/ui/bottomMenu/bottomMenuCmp/bottomMenu.component.ts b/src/ui/bottomMenu/bottomMenuCmp/bottomMenu.component.ts index 2fa7ea98e..60c6593d8 100644 --- a/src/ui/bottomMenu/bottomMenuCmp/bottomMenu.component.ts +++ b/src/ui/bottomMenu/bottomMenuCmp/bottomMenu.component.ts @@ -1,8 +1,8 @@ import { Component, EventEmitter, HostBinding, Output } from "@angular/core"; import { Store, select } from "@ngrx/store"; import { combineLatest } from "rxjs"; -import { map } from "rxjs/operators"; -import { MainState, atlasSelection } from "src/state"; +import { map, shareReplay } from "rxjs/operators"; +import { MainState, atlasSelection, userInteraction } from "src/state"; @Component({ selector: 'sxplr-bottom-menu', @@ -26,19 +26,25 @@ export class BottomMenuCmp{ #selectedRegions$ = this.store.pipe( select(atlasSelection.selectors.selectedRegions) ) + #selectedFeature$ = this.store.pipe( + select(userInteraction.selectors.selectedFeature) + ) view$ = combineLatest([ this.#selectedATP$, - this.#selectedRegions$ + this.#selectedRegions$, + this.#selectedFeature$, ]).pipe( - map(([ { atlas, parcellation, template }, selectedRegions ]) => { + map(([ { atlas, parcellation, template }, selectedRegions, selectedFeature ]) => { return { selectedAtlas: atlas, selectedParcellation: parcellation, selectedTemplate: template, - selectedRegions + selectedRegions, + selectedFeature } - }) + }), + shareReplay(1) ) constructor(private store: Store){} @@ -49,6 +55,12 @@ export class BottomMenuCmp{ ) } + clearFeature(){ + this.store.dispatch( + userInteraction.actions.clearShownFeature() + ) + } + onATPMenuOpen(opts: { all: boolean, some: boolean, none: boolean }){ if (opts.all) { this.menuOpen = 'all' diff --git a/src/ui/bottomMenu/bottomMenuCmp/bottomMenu.style.scss b/src/ui/bottomMenu/bottomMenuCmp/bottomMenu.style.scss index 872726cdc..726a53f74 100644 --- a/src/ui/bottomMenu/bottomMenuCmp/bottomMenu.style.scss +++ b/src/ui/bottomMenu/bottomMenuCmp/bottomMenu.style.scss @@ -18,12 +18,12 @@ flex-wrap: nowrap; } -:host sxplr-smart-chip +sxplr-smart-chip.region-chip { margin-left: -2.5rem; } -sxplr-smart-chip .regionname +sxplr-smart-chip.region-chip .regionname { margin-left: 0.5rem; } diff --git a/src/ui/bottomMenu/bottomMenuCmp/bottomMenu.template.html b/src/ui/bottomMenu/bottomMenuCmp/bottomMenu.template.html index aba953d67..3703c3633 100644 --- a/src/ui/bottomMenu/bottomMenuCmp/bottomMenu.template.html +++ b/src/ui/bottomMenu/bottomMenuCmp/bottomMenu.template.html @@ -8,6 +8,7 @@ = {} + + @Output('sxplr-dialog-closed') + closed = new EventEmitter() + #dialogRef: MatDialogRef constructor(private matDialog: MatDialog){} @@ -53,7 +59,12 @@ export class DialogDirective{ this.#dialogRef = this.matDialog.open(tmpl, { autoFocus: null, data: {...this.data, ...data}, - ...(sizeDict[this.size] || {}) + ...(sizeDict[this.size] || {}), + ...this.config + }) + + this.#dialogRef.afterClosed().subscribe(val => { + this.closed.next(val) }) } diff --git a/src/ui/topMenu/topMenuCmp/topMenu.components.ts b/src/ui/topMenu/topMenuCmp/topMenu.components.ts index 580ab86fc..4adf087fe 100644 --- a/src/ui/topMenu/topMenuCmp/topMenu.components.ts +++ b/src/ui/topMenu/topMenuCmp/topMenu.components.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, + Inject, Input, TemplateRef, } from "@angular/core"; @@ -14,6 +15,7 @@ import { TypeMatBtnColor, TypeMatBtnStyle } from "src/components/dynamicMaterial import { select, Store } from "@ngrx/store"; import { userPreference } from "src/state"; import { environment } from "src/environments/environment" +import { GET_ATTR_TOKEN, GetAttr } from "src/util/constants"; @Component({ selector: 'top-menu-cmp', @@ -84,9 +86,15 @@ export class TopMenuCmp { private authService: AuthService, private dialog: MatDialog, public bottomSheet: MatBottomSheet, + @Inject(GET_ATTR_TOKEN) getAttr: GetAttr ) { this.user$ = this.authService.user$ + const experimentalFlag = getAttr(CONST.OVERWRITE_EXPERIMENTAL_FLAG_ATTR) + if (experimentalFlag) { + this.showExperimentalToggle = !!experimentalFlag + } + this.userBtnTooltip$ = this.user$.pipe( map(user => user ? `Logged in as ${(user && user.name) ? user.name : 'Unknown name'}` diff --git a/src/ui/topMenu/topMenuCmp/topMenu.template.html b/src/ui/topMenu/topMenuCmp/topMenu.template.html index 1a36b61c6..43d695b91 100644 --- a/src/ui/topMenu/topMenuCmp/topMenu.template.html +++ b/src/ui/topMenu/topMenuCmp/topMenu.template.html @@ -145,7 +145,7 @@ matTooltip="Toggle experimental flag"> @@ -153,7 +153,7 @@ - +
diff --git a/src/util/constants.ts b/src/util/constants.ts index d0878a40f..e3974a544 100644 --- a/src/util/constants.ts +++ b/src/util/constants.ts @@ -1,5 +1,4 @@ import { HttpHeaders } from "@angular/common/http" -import { environment } from 'src/environments/environment' export const LOCAL_STORAGE_CONST = { GPU_LIMIT: 'fzj.xg.iv.GPU_LIMIT', @@ -12,15 +11,6 @@ export const LOCAL_STORAGE_CONST = { export const COOKIE_VERSION = '0.3.0' export const KG_TOS_VERSION = '0.3.0' -export const BACKENDURL = (() => { - const { BACKEND_URL } = environment - if (!BACKEND_URL) return `` - if (/^http/.test(BACKEND_URL)) return BACKEND_URL - - const url = new URL(window.location.href) - const { protocol, hostname, pathname } = url - return `${protocol}//${hostname}${pathname.replace(/\/$/, '')}/${BACKEND_URL}` -})() export const MIN_REQ_EXPLAINER = ` - Siibra explorer requires **webgl2.0**, and the \`EXT_color_buffer_float\` extension enabled. @@ -31,7 +21,7 @@ export const MIN_REQ_EXPLAINER = ` export const APPEND_SCRIPT_TOKEN: InjectionToken<(url: string) => Promise> = new InjectionToken(`APPEND_SCRIPT_TOKEN`) export const appendScriptFactory = (document: Document, defer: boolean = false) => { - return src => new Promise((rs, rj) => { + return (src: string) => new Promise((rs, rj) => { const scriptEl = document.createElement('script') if (defer) { scriptEl.defer = true @@ -111,3 +101,7 @@ export const parcBanList: string[] = [ "minds/core/parcellationatlas/v1.0.0/887da8eb4c36d944ef626ed5293db3ef", "minds/core/parcellationatlas/v1.0.0/f2b1ac621421708c1bef422bb5058456", ] + +export const GET_ATTR_TOKEN = new InjectionToken("GET_ATTR_TOKEN") + +export type GetAttr = (attr: string) => string|null \ No newline at end of file diff --git a/src/util/directives/floatingMouseContextualContainer.directive.ts b/src/util/directives/floatingMouseContextualContainer.directive.ts index d924d7133..2c2f0def8 100644 --- a/src/util/directives/floatingMouseContextualContainer.directive.ts +++ b/src/util/directives/floatingMouseContextualContainer.directive.ts @@ -3,6 +3,7 @@ import { DomSanitizer, SafeUrl } from "@angular/platform-browser"; @Directive({ selector: '[floatingMouseContextualContainerDirective]', + standalone: true, }) export class FloatingMouseContextualContainerDirective { diff --git a/src/util/directives/mediaQuery.directive.ts b/src/util/directives/mediaQuery.directive.ts index 08d77130d..68fa61fd2 100644 --- a/src/util/directives/mediaQuery.directive.ts +++ b/src/util/directives/mediaQuery.directive.ts @@ -24,7 +24,8 @@ enum EnumMediaBreakPoints{ @Directive({ selector: '[iav-media-query]', - exportAs: 'iavMediaQuery' + exportAs: 'iavMediaQuery', + standalone: true }) export class MediaQueryDirective{ diff --git a/src/util/injectionTokens.ts b/src/util/injectionTokens.ts index 250a8cce5..3b62ed171 100644 --- a/src/util/injectionTokens.ts +++ b/src/util/injectionTokens.ts @@ -19,6 +19,19 @@ export interface ClickInterceptor{ deregister: (interceptorFunction: (ev: any) => any) => void } +export const HOVER_INTERCEPTOR_INJECTOR = new InjectionToken("HOVER_INTERCEPTOR_INJECTOR") + +export type THoverConfig = { + fontSet?: string + fontIcon?: string + message: string +} + +export interface HoverInterceptor { + append(message: THoverConfig): void + remove(message: THoverConfig): void +} + export const CONTEXT_MENU_ITEM_INJECTOR = new InjectionToken('CONTEXT_MENU_ITEM_INJECTOR') export type TContextMenu = { diff --git a/src/util/priority.ts b/src/util/priority.ts index 2dcd8a28b..03e99d3b5 100644 --- a/src/util/priority.ts +++ b/src/util/priority.ts @@ -27,12 +27,26 @@ type Queue = { }) export class PriorityHttpInterceptor implements HttpInterceptor{ + static ErrorToString(err: HttpErrorResponse){ + if (err.status === 504) { + return "Gateway Timeout" + } + if (!!err.error.message) { + try { + const { detail } = JSON.parse(err.error.message) + return detail as string + } catch (e) { + return err.error.message as string + } + } + return err.statusText || err.status.toString() + } private retry = 0 private priorityQueue: Queue[] = [] private currentJob: Set = new Set() - private archive: Map|Error)> = new Map() + private archive: Map|Error)> = new Map() private queue$: Subject = new Subject() private result$: Subject> = new Subject() private error$: Subject = new Subject() @@ -95,11 +109,13 @@ export class PriorityHttpInterceptor implements HttpInterceptor{ }) } if (val instanceof HttpErrorResponse) { - - this.archive.set(urlWithParams, val) + const error = new Error( + PriorityHttpInterceptor.ErrorToString(val) + ) + this.archive.set(urlWithParams, error) this.error$.next({ urlWithParams, - error: new Error(val.toString()), + error, status: val.status }) } @@ -136,10 +152,11 @@ export class PriorityHttpInterceptor implements HttpInterceptor{ const archive = this.archive.get(urlWithParams) if (archive) { if (archive instanceof Error) { - return throwError(archive) - } - if (archive instanceof HttpErrorResponse) { - return throwError(archive) + return throwError({ + urlWithParams, + error: archive, + status: 400 + }) } if (archive instanceof HttpResponse) { return of( archive.clone() ) diff --git a/src/util/pullable.ts b/src/util/pullable.ts index 02bf6dc44..c6e20f526 100644 --- a/src/util/pullable.ts +++ b/src/util/pullable.ts @@ -12,6 +12,7 @@ interface PaginatedArg { pull?: () => Promise children?: PulledDataSource[] annotations?: Record + serialize?: (a: T) => string } export class IsAlreadyPulling extends Error {} @@ -115,20 +116,39 @@ export class ParentDatasource extends PulledDataSource { private _data$ = new BehaviorSubject([]) data$ = this._data$.pipe( shareReplay(1), + map(v => { + if (!this.#serialize) { + return v + } + const seen = new Set() + const returnVal: T[] = [] + for (const item of v){ + const key = this.#serialize(item) + const hasSeen = seen.has(key) + if (!hasSeen) { + returnVal.push(item) + } + seen.add(key) + } + return returnVal + }) ) + + #serialize: (a: T) => string #subscriptions: Subscription[] = [] _children: PulledDataSource[] = [] constructor(arg: PaginatedArg){ super({ pull: async () => [], annotations: arg.annotations }) - const { children } = arg + const { children, serialize } = arg this._children = children + this.#serialize = serialize } set isPulling(val: boolean){ throw new Error(`Cannot set isPulling for parent pullable`) } - get isPUlling(){ + get isPulling(){ return this._children.some(c => c.isPulling) } diff --git a/src/util/util.module.ts b/src/util/util.module.ts index f2b7ccc21..210445361 100644 --- a/src/util/util.module.ts +++ b/src/util/util.module.ts @@ -5,7 +5,6 @@ import { SafeResourcePipe } from "./pipes/safeResource.pipe"; import { CaptureClickListenerDirective } from "./directives/captureClickListener.directive"; import { NmToMm } from "./pipes/nmToMm.pipe"; import { SwitchDirective } from "./directives/switch.directive"; -import { MediaQueryDirective } from './directives/mediaQuery.directive' import { LayoutModule } from "@angular/cdk/layout"; import { MapToPropertyPipe } from "./pipes/mapToProperty.pipe"; import { ClickOutsideDirective } from "src/util/directives/clickOutside.directive"; @@ -38,7 +37,6 @@ import { PrettyPresentPipe } from './pretty-present.pipe'; CaptureClickListenerDirective, NmToMm, SwitchDirective, - MediaQueryDirective, MapToPropertyPipe, ClickOutsideDirective, GetNthElementPipe, @@ -62,7 +60,6 @@ import { PrettyPresentPipe } from './pretty-present.pipe'; CaptureClickListenerDirective, NmToMm, SwitchDirective, - MediaQueryDirective, MapToPropertyPipe, ClickOutsideDirective, GetNthElementPipe, diff --git a/src/viewerModule/module.ts b/src/viewerModule/module.ts index e2b3b4589..cdfcf7f43 100644 --- a/src/viewerModule/module.ts +++ b/src/viewerModule/module.ts @@ -15,14 +15,14 @@ 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, switchMap } from "rxjs/operators"; -import { TContextArg } from "./viewer.interface"; +import { TViewerEvtCtxData } from "./viewer.interface"; import { KeyFrameModule } from "src/keyframesModule/module"; import { ViewerInternalStateSvc } from "./viewerInternalState.service"; 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"; -import { MouseoverModule } from "src/mouseoverModule"; +import { MouseOver, MouseOverSvc } from "src/mouseoverModule"; import { LogoContainer } from "src/ui/logoContainer/logoContainer.component"; import { FloatingMouseContextualContainerDirective } from "src/util/directives/floatingMouseContextualContainer.directive"; import { ShareModule } from "src/share"; @@ -39,6 +39,11 @@ import { CURRENT_TEMPLATE_DIM_INFO, TemplateInfo, Z_TRAVERSAL_MULTIPLIER } from import { Store } from "@ngrx/store"; import { atlasSelection, userPreference } from "src/state"; import { TabComponent } from "src/components/tab/tab.components"; +import { ExperimentalFlagDirective } from "src/experimental/experimental-flag.directive"; +import { HOVER_INTERCEPTOR_INJECTOR } from "src/util/injectionTokens"; +import { ViewerWrapper } from "./viewerWrapper/viewerWrapper.component"; +import { MediaQueryDirective } from "src/util/directives/mediaQuery.directive"; +import { TPBRViewCmp } from "src/features/TPBRView/TPBRView.component"; @NgModule({ imports: [ @@ -58,7 +63,6 @@ import { TabComponent } from "src/components/tab/tab.components"; SapiViewsModule, SapiViewsUtilModule, DialogModule, - MouseoverModule, ShareModule, ATPSelectorModule, FeatureModule, @@ -67,13 +71,20 @@ import { TabComponent } from "src/components/tab/tab.components"; ReactiveFormsModule, BottomMenuModule, TabComponent, + + MouseOver, + MediaQueryDirective, + FloatingMouseContextualContainerDirective, + ExperimentalFlagDirective, + TPBRViewCmp, + ...(environment.ENABLE_LEAP_MOTION ? [LeapModule] : []) ], declarations: [ ViewerCmp, NehubaVCtxToBbox, LogoContainer, - FloatingMouseContextualContainerDirective, + ViewerWrapper, ], providers: [ { @@ -89,11 +100,11 @@ import { TabComponent } from "src/components/tab/tab.components"; }, { provide: CONTEXT_MENU_ITEM_INJECTOR, - useFactory: (svc: ContextMenuService>) => { + useFactory: (svc: ContextMenuService>) => { return { register: svc.register.bind(svc), deregister: svc.deregister.bind(svc) - } as TContextMenu>> + } as TContextMenu>> }, deps: [ ContextMenuService ] }, @@ -137,6 +148,17 @@ import { TabComponent } from "src/components/tab/tab.components"; ), deps: [ Store, SAPI ] }, + + { + provide: HOVER_INTERCEPTOR_INJECTOR, + useFactory: (svc: MouseOverSvc) => { + return { + append: svc.append.bind(svc), + remove: svc.remove.bind(svc), + } + }, + deps: [ MouseOverSvc ] + } ], exports: [ ViewerCmp, diff --git a/src/viewerModule/nehuba/module.ts b/src/viewerModule/nehuba/module.ts index 485c478b8..76b1be3db 100644 --- a/src/viewerModule/nehuba/module.ts +++ b/src/viewerModule/nehuba/module.ts @@ -12,7 +12,6 @@ import { NehubaGlueCmp } from "./nehubaViewerGlue/nehubaViewerGlue.component"; import { UtilModule } from "src/util"; import { ComponentsModule } from "src/components"; import { AngularMaterialModule } from "src/sharedModules"; -import { MouseoverModule } from "src/mouseoverModule"; import { StatusCardComponent } from "./statusCard/statusCard.component"; import { ShareModule } from "src/share"; import { FormsModule, ReactiveFormsModule } from "@angular/forms"; @@ -29,6 +28,9 @@ import { NgAnnotationEffects } from "./annotation/effects"; import { NehubaViewerContainer } from "./nehubaViewerInterface/nehubaViewerContainer.component"; import { NehubaUserLayerModule } from "./userLayers"; import { DialogModule } from "src/ui/dialogInfo"; +import { CoordTextBox } from "src/components/coordTextBox"; +import { ExperimentalFlagDirective } from "src/experimental/experimental-flag.directive"; +import { MediaQueryDirective } from "src/util/directives/mediaQuery.directive"; @NgModule({ imports: [ @@ -38,10 +40,10 @@ import { DialogModule } from "src/ui/dialogInfo"; UtilModule, AngularMaterialModule, ComponentsModule, - MouseoverModule, ShareModule, WindowResizeModule, NehubaUserLayerModule, + MediaQueryDirective, /** * should probably break this into its own... @@ -60,6 +62,9 @@ import { DialogModule } from "src/ui/dialogInfo"; QuickTourModule, NehubaLayoutOverlayModule, DialogModule, + + CoordTextBox, + ExperimentalFlagDirective ], declarations: [ NehubaViewerContainerDirective, diff --git a/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.ts b/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.ts index 0c4b2ca09..6e2f8251f 100644 --- a/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.ts +++ b/src/viewerModule/nehuba/nehubaViewer/nehubaViewer.component.ts @@ -67,7 +67,7 @@ export class NehubaViewerUnit implements OnDestroy { public viewerPosInVoxel$ = new BehaviorSubject(null) public viewerPosInReal$ = new BehaviorSubject<[number, number, number]>(null) public mousePosInVoxel$ = new BehaviorSubject<[number, number, number]>(null) - public mousePosInReal$ = new BehaviorSubject(null) + public mousePosInReal$ = new BehaviorSubject<[number, number, number]>(null) private exportNehuba: any @@ -869,7 +869,7 @@ export class NehubaViewerUnit implements OnDestroy { if (this.#translateVoxelToReal) { const coordInReal = this.#translateVoxelToReal(coordInVoxel) - this.mousePosInReal$.next( coordInReal ) + this.mousePosInReal$.next( coordInReal as [number, number, number] ) } }), diff --git a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.spec.ts b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.spec.ts index 10d189784..263982f2d 100644 --- a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.spec.ts +++ b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.spec.ts @@ -169,85 +169,5 @@ describe('> nehubaViewerGlue.component.ts', () => { expect(fixture.componentInstance).toBeTruthy() }) - describe('> selectHoveredRegion', () => { - let dispatchSpy: jasmine.Spy - let clickIntServ: ClickInterceptorService - beforeEach(() => { - dispatchSpy = spyOn(mockStore, 'dispatch') - clickIntServ = TestBed.inject(ClickInterceptorService) - }) - afterEach(() => { - dispatchSpy.calls.reset() - }) - - describe('> if on hover is empty array', () => { - let fallbackSpy: jasmine.Spy - beforeEach(() => { - fallbackSpy = spyOn(clickIntServ, 'fallback') - TestBed.createComponent(NehubaGlueCmp) - clickIntServ.callRegFns(null) - }) - it('> dispatch not called', () => { - expect(dispatchSpy).not.toHaveBeenCalled() - }) - it('> fallback called', () => { - expect(fallbackSpy).toHaveBeenCalled() - }) - }) - - describe('> if on hover is non object array', () => { - let fallbackSpy: jasmine.Spy - - const testObj0 = { - segment: 'hello world' - } - const testObj1 = 'hello world' - beforeEach(() => { - fallbackSpy = spyOn(clickIntServ, 'fallback') - TestBed.createComponent(NehubaGlueCmp) - clickIntServ.callRegFns(null) - }) - it('> dispatch not called', () => { - expect(dispatchSpy).not.toHaveBeenCalled() - }) - it('> fallback called', () => { - expect(fallbackSpy).toHaveBeenCalled() - }) - }) - - describe('> if on hover array containing at least 1 obj, only dispatch the first obj', () => { - let fallbackSpy: jasmine.Spy - const testObj0 = { - segment: 'hello world' - } - const testObj1 = { - segment: { - foo: 'baz' - } - } - const testObj2 = { - segment: { - hello: 'world' - } - } - beforeEach(() => { - fallbackSpy = spyOn(clickIntServ, 'fallback') - - }) - afterEach(() => { - fallbackSpy.calls.reset() - }) - it('> dispatch called with obj1', () => { - TestBed.createComponent(NehubaGlueCmp) - clickIntServ.callRegFns(null) - const { segment } = testObj1 - }) - it('> fallback called (does not intercept)', () => { - TestBed.createComponent(NehubaGlueCmp) - clickIntServ.callRegFns(null) - expect(fallbackSpy).toHaveBeenCalled() - }) - }) - }) }) diff --git a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts index 1f47b5680..a32c784f1 100644 --- a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts +++ b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts @@ -1,15 +1,10 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Inject, OnDestroy, Optional, Output } from "@angular/core"; -import { select, Store } from "@ngrx/store"; -import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR } from "src/util"; -import { distinctUntilChanged } from "rxjs/operators"; +import { ChangeDetectionStrategy, Component, EventEmitter, OnDestroy, Output } from "@angular/core"; import { IViewer, TViewerEvent } from "../../viewer.interface"; import { NehubaMeshService } from "../mesh.service"; import { NehubaLayerControlService, SET_COLORMAP_OBS, SET_LAYER_VISIBILITY } from "../layerCtrl.service"; import { EXTERNAL_LAYER_CONTROL, NG_LAYER_CONTROL, SET_SEGMENT_VISIBILITY } from "../layerCtrl.service/layerCtrl.util"; -import { SxplrRegion } from "src/atlasComponents/sapi/sxplrTypes"; import { NehubaConfig } from "../config.service"; import { SET_MESHES_TO_LOAD } from "../constants"; -import { atlasSelection, userInteraction } from "src/state"; @Component({ @@ -58,7 +53,6 @@ import { atlasSelection, userInteraction } from "src/state"; export class NehubaGlueCmp implements IViewer<'nehuba'>, OnDestroy { - private onhoverSegments: SxplrRegion[] = [] private onDestroyCb: (() => void)[] = [] public nehubaConfig: NehubaConfig @@ -70,53 +64,4 @@ export class NehubaGlueCmp implements IViewer<'nehuba'>, OnDestroy { @Output() public viewerEvent = new EventEmitter>() - constructor( - private store$: Store, - @Optional() @Inject(CLICK_INTERCEPTOR_INJECTOR) clickInterceptor: ClickInterceptor, - ){ - - /** - * define onclick behaviour - */ - if (clickInterceptor) { - const { deregister, register } = clickInterceptor - const selOnhoverRegion = this.selectHoveredRegion.bind(this) - register(selOnhoverRegion, { last: true }) - this.onDestroyCb.push(() => deregister(selOnhoverRegion)) - } - - /** - * on hover segment - */ - const onhovSegSub = this.store$.pipe( - select(userInteraction.selectors.mousingOverRegions), - distinctUntilChanged(), - ).subscribe(arr => { - this.onhoverSegments = arr - }) - this.onDestroyCb.push(() => onhovSegSub.unsubscribe()) - } - - private selectHoveredRegion(ev: PointerEvent): boolean{ - /** - * If label indicies are not defined by the ontology, it will be a string in the format of `{ngId}#{labelIndex}` - */ - const trueOnhoverSegments = this.onhoverSegments && this.onhoverSegments.filter(v => typeof v === 'object') - if (!trueOnhoverSegments || (trueOnhoverSegments.length === 0)) return true - - if (ev.ctrlKey) { - this.store$.dispatch( - atlasSelection.actions.toggleRegion({ - region: trueOnhoverSegments[0] - }) - ) - } else { - this.store$.dispatch( - atlasSelection.actions.selectRegion({ - region: trueOnhoverSegments[0] - }) - ) - } - return true - } } diff --git a/src/viewerModule/nehuba/statusCard/statusCard.component.spec.ts b/src/viewerModule/nehuba/statusCard/statusCard.component.spec.ts index df15daa10..d582da672 100644 --- a/src/viewerModule/nehuba/statusCard/statusCard.component.spec.ts +++ b/src/viewerModule/nehuba/statusCard/statusCard.component.spec.ts @@ -15,6 +15,7 @@ import { QuickTourModule } from "src/ui/quickTour/module"; import { atlasSelection } from "src/state" import { SxplrTemplate } from "src/atlasComponents/sapi/sxplrTypes" import { NEHUBA_INSTANCE_INJTKN } from "../util" +import { MediaQueryDirective } from "src/util/directives/mediaQuery.directive" const mockNehubaConfig = { dataset: { @@ -60,7 +61,8 @@ describe('> statusCard.component.ts', () => { ReactiveFormsModule, NoopAnimationsModule, UtilModule, - QuickTourModule + QuickTourModule, + MediaQueryDirective, ], declarations: [ StatusCardComponent, diff --git a/src/viewerModule/nehuba/statusCard/statusCard.component.ts b/src/viewerModule/nehuba/statusCard/statusCard.component.ts index 1b1f6f6f0..2f14e2201 100644 --- a/src/viewerModule/nehuba/statusCard/statusCard.component.ts +++ b/src/viewerModule/nehuba/statusCard/statusCard.component.ts @@ -8,9 +8,9 @@ import { import { select, Store } from "@ngrx/store"; import { LoggingService } from "src/logging"; import { NehubaViewerUnit } from "../nehubaViewer/nehubaViewer.component"; -import { Observable, concat, of } from "rxjs"; -import { map, filter, takeUntil, switchMap, shareReplay, debounceTime } from "rxjs/operators"; -import { Clipboard, MatBottomSheet, MatDialog, MatSnackBar } from "src/sharedModules/angularMaterial.exports" +import { Observable, Subject, combineLatest, concat, of } from "rxjs"; +import { map, filter, takeUntil, switchMap, shareReplay, debounceTime, scan } from "rxjs/operators"; +import { Clipboard, MatBottomSheet, MatSnackBar } from "src/sharedModules/angularMaterial.exports" import { ARIA_LABELS, QUICKTOUR_DESC } from 'common/constants' import { FormControl, FormGroup } from "@angular/forms"; @@ -22,6 +22,14 @@ import { SxplrTemplate } from "src/atlasComponents/sapi/sxplrTypes"; import { NEHUBA_CONFIG_SERVICE_TOKEN, NehubaConfigSvc } from "../config.service"; import { DestroyDirective } from "src/util/directives/destroy.directive"; import { getUuid } from "src/util/fn"; +import { Render, TAffine, isAffine, ID_AFFINE } from "src/components/coordTextBox" +import { IDS } from "src/atlasComponents/sapi"; + +type TSpace = { + label: string + affine: TAffine + render?: Render +} @Component({ selector : 'iav-cmp-viewer-nehuba-status', @@ -33,6 +41,62 @@ import { getUuid } from "src/util/fn"; }) export class StatusCardComponent { + #newSpace = new Subject() + additionalSpace$ = combineLatest([ + this.store$.pipe( + select(atlasSelection.selectors.selectedTemplate), + map(tmpl => { + if (tmpl.id === IDS.TEMPLATES.BIG_BRAIN) { + const tspace: TSpace = { + affine: ID_AFFINE, + label: "BigBrain slice index", + render: v => `Slice ${Math.ceil((v[1] + 70.010) / 0.02)}` + } + return [tspace] + } + return [] + }) + ), + concat( + of([] as TSpace[]), + this.#newSpace.pipe( + scan((acc, v) => acc.concat(v), [] as TSpace[]), + ) + ) + ]).pipe( + map(([predefined, custom]) => [...predefined, ...custom]) + ) + readonly idAffStr = `[ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1] +] +` + readonly defaultLabel = `New Space` + reset(label: HTMLInputElement, affine: HTMLTextAreaElement){ + label.value = this.defaultLabel + affine.value = this.idAffStr + } + add(label: HTMLInputElement, affine: HTMLTextAreaElement) { + try { + const aff = JSON.parse(affine.value) + if (!isAffine(aff)) { + throw new Error(`${affine.value} cannot be parsed into 4x4 affine`) + } + this.#newSpace.next({ + label: label.value, + affine: aff + }) + } catch (e) { + console.error(`Error: ${e.toString()}`) + } + + } + + readonly renderMm: Render = v => v.map(i => `${i}mm`).join(", ") + readonly renderDefault: Render = v => v.map(i => i.toFixed(3)).join(", ") + readonly #destroy$ = inject(DestroyDirective).destroyed$ public nehubaViewer: NehubaViewerUnit @@ -47,6 +111,9 @@ export class StatusCardComponent { z: new FormControl(null), }) + #pasted$ = new Subject() + #coordEditDialogClosed = new Subject() + private selectedTemplate: SxplrTemplate private currentNavigation: { position: number[] @@ -54,30 +121,25 @@ export class StatusCardComponent { zoom: number perspectiveOrientation: number[] perspectiveZoom: number -} + } - public readonly navVal$ = this.nehubaViewer$.pipe( + readonly navigation$ = this.nehubaViewer$.pipe( filter(v => !!v), - switchMap(nehubaViewer => - concat( - of(`nehubaViewer initialising`), - nehubaViewer.viewerPosInReal$.pipe( - filter(v => !!v), - map(real => real.map(v => `${ (v / 1e6).toFixed(3) }mm`).join(', ')) - ) - ) - ), + switchMap(nv => nv.viewerPosInReal$.pipe( + map(vals => (vals || [0, 0, 0]).map(v => Number((v / 1e6).toFixed(3)))) + )), shareReplay(1), ) - public readonly mouseVal$ = this.nehubaViewer$.pipe( + + readonly navVal$ = this.navigation$.pipe( + map(v => v.map(v => `${v}mm`).join(", ")) + ) + readonly mouseVal$ = this.nehubaViewer$.pipe( filter(v => !!v), switchMap(nehubaViewer => - concat( - of(``), - nehubaViewer.mousePosInReal$.pipe( - filter(v => !!v), - map(real => real.map(v => `${ (v/1e6).toFixed(3) }mm`).join(', ')) - ) + nehubaViewer.mousePosInReal$.pipe( + filter(v => !!v), + map(real => real.map(v => Number((v/1e6).toFixed(3)))) ), ) ) @@ -113,7 +175,6 @@ export class StatusCardComponent { private store$: Store, private log: LoggingService, private bottomSheet: MatBottomSheet, - private dialog: MatDialog, private clipboard: Clipboard, private snackbar: MatSnackBar, @Inject(NEHUBA_CONFIG_SERVICE_TOKEN) private nehubaConfigSvc: NehubaConfigSvc, @@ -132,9 +193,15 @@ export class StatusCardComponent { this.nehubaViewer$.pipe( filter(nv => !!nv), - switchMap(nv => nv.viewerPosInReal$.pipe( - filter(pos => !!pos), - debounceTime(120), + switchMap(nv => concat( + of(null), + this.#coordEditDialogClosed, + ).pipe( + switchMap(() => nv.viewerPosInReal$.pipe( + filter(pos => !!pos), + debounceTime(120), + shareReplay(1) + )) )), takeUntil(this.#destroy$) ).subscribe(val => { @@ -152,11 +219,14 @@ export class StatusCardComponent { takeUntil(this.#destroy$) ).subscribe() - this.dialogForm.valueChanges.pipe( - map(({ x, y, z }) => [x, y, z].map(v => this.#parseString(v))), - map(allEntries => allEntries.find(val => val.length === 3)), + this.#pasted$.pipe( + filter(v => !!v), // '' is falsy, so filters out null, undefined, '' etc + map(v => this.#parseString(v)), filter(fullEntry => !!fullEntry && fullEntry.every(entry => !Number.isNaN(entry))), - takeUntil(this.#destroy$) + takeUntil(this.#destroy$), + debounceTime(0), + // need to update value on the separate frame to paste action + // otherwise, dialogForm.setValue will have no effect ).subscribe(fullEntry => { this.dialogForm.setValue({ x: `${fullEntry[0]}`, @@ -164,7 +234,6 @@ export class StatusCardComponent { z: `${fullEntry[2]}`, }) }) - } #parseString(input: string): number[]{ @@ -191,7 +260,7 @@ export class StatusCardComponent { } } - public selectPoint(pos: number[]) { + public selectPoint(posNm: number[]) { this.store$.dispatch( atlasSelection.actions.selectPoint({ point: { @@ -200,27 +269,24 @@ export class StatusCardComponent { coordinateSpace: { "@id": this.selectedTemplate.id }, - coordinates: pos.map(v => ({ + coordinates: posNm.map(v => ({ "@id": getUuid(), "@type": "https://openminds.ebrains.eu/core/QuantitativeValue", unit: { "@id": "id.link/mm" }, - value: v * 1e6, + value: v, uncertainty: [0, 0] })) } }) ) - } - - public navigateTo(pos: number[], positionReal=true) { this.store$.dispatch( atlasSelection.actions.navigateTo({ navigation: { - position: pos + position: posNm }, - physical: positionReal, + physical: true, animation: true }) ) @@ -260,17 +326,19 @@ export class StatusCardComponent { ) } - openDialog(tmpl: TemplateRef, options: { ariaLabel: string }): void { - const { ariaLabel } = options - this.dialog.open(tmpl, { - ariaLabel - }) - } - copyString(value: string){ this.clipboard.copy(value) this.snackbar.open(`Copied to clipboard!`, null, { duration: 1000 }) } + + onPaste(ev: ClipboardEvent) { + const text = ev.clipboardData.getData('text/plain') + this.#pasted$.next(text) + } + + onCoordEditDialogClose(){ + this.#coordEditDialogClosed.next(null) + } } diff --git a/src/viewerModule/nehuba/statusCard/statusCard.template.html b/src/viewerModule/nehuba/statusCard/statusCard.template.html index 3dab5a5b1..f3226b50c 100644 --- a/src/viewerModule/nehuba/statusCard/statusCard.template.html +++ b/src/viewerModule/nehuba/statusCard/statusCard.template.html @@ -47,48 +47,70 @@
+ + + + + + + + +
- - - Physical Coord - - - - - - - - + + +
+ + + + + +
+
+ + + + - - - Cursor Position - - - +
+ + +
@@ -106,7 +128,9 @@ @@ -119,11 +143,12 @@ -

- Navigation Coordinate -

-
-
+ +

+ Navigation Coordinate +

+
@@ -131,38 +156,58 @@

- -

+
-
- - +
+ + - - - + + + +
+ +
+ + +

+ Add a new coordinate space +

+ + + + Label + + + + + + + Affine + + + + + - -
- -
-
\ No newline at end of file + + + + + + diff --git a/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.component.ts b/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.component.ts index e44b5b4d0..2df5d1593 100644 --- a/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.component.ts +++ b/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.component.ts @@ -1,8 +1,8 @@ -import { Component, Inject, ViewChild } from "@angular/core"; +import { Component, Inject, inject } from "@angular/core"; import { MAT_DIALOG_DATA } from "src/sharedModules/angularMaterial.exports" import { ARIA_LABELS, CONST } from 'common/constants' -import { BehaviorSubject, Subject, combineLatest, concat, of, timer } from "rxjs"; -import { map, switchMap, take } from "rxjs/operators"; +import { BehaviorSubject, combineLatest, concat, of, timer } from "rxjs"; +import { map, take } from "rxjs/operators"; import { MediaQueryDirective } from "src/util/directives/mediaQuery.directive"; export type UserLayerInfoData = { @@ -16,23 +16,22 @@ export type UserLayerInfoData = { templateUrl: './userlayerInfo.template.html', styleUrls: [ './userlayerInfo.style.css' + ], + hostDirectives: [ + MediaQueryDirective ] }) export class UserLayerInfoCmp { + + private readonly mediaQuery = inject(MediaQueryDirective) + ARIA_LABELS = ARIA_LABELS CONST = CONST public HIDE_NG_TUNE_CTRL = { ONLY_SHOW_OPACITY: 'export-mode,lower_threshold,higher_threshold,brightness,contrast,colormap,hide-threshold-checkbox,hide-zero-value-checkbox' } - #mediaQuery = new Subject() - - @ViewChild(MediaQueryDirective, { read: MediaQueryDirective }) - set mediaQuery(val: MediaQueryDirective) { - this.#mediaQuery.next(val) - } - constructor( @Inject(MAT_DIALOG_DATA) public data: UserLayerInfoData ){ @@ -50,13 +49,9 @@ export class UserLayerInfoCmp { this.#showMore, concat( of(null as MediaQueryDirective), - this.#mediaQuery, - ).pipe( - switchMap(mediaQueryD => mediaQueryD - ? mediaQueryD.mediaBreakPoint$.pipe( - map(val => val >= 2) - ) - : of(false)) + this.mediaQuery.mediaBreakPoint$.pipe( + map(val => val >= 2) + ), ) ]).pipe( map(([ showMore, compact ]) => ({ diff --git a/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.template.html b/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.template.html index dfd465617..7963b8343 100644 --- a/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.template.html +++ b/src/viewerModule/nehuba/userLayers/userlayerInfo/userlayerInfo.template.html @@ -1,6 +1,3 @@ - -
-
diff --git a/src/viewerModule/pipes/nehubaVCtxToBbox.pipe.ts b/src/viewerModule/pipes/nehubaVCtxToBbox.pipe.ts index e3ceaad46..98ac66955 100644 --- a/src/viewerModule/pipes/nehubaVCtxToBbox.pipe.ts +++ b/src/viewerModule/pipes/nehubaVCtxToBbox.pipe.ts @@ -1,4 +1,4 @@ -import { TContextArg } from './../viewer.interface'; +import { TViewerEvtCtxData } from './../viewer.interface'; import { Pipe, PipeTransform } from "@angular/core"; type Point = [number, number, number] @@ -12,26 +12,32 @@ const MAGIC_RADIUS = 256 }) export class NehubaVCtxToBbox implements PipeTransform{ - public transform(event: TContextArg<'nehuba' | 'threeSurfer'>, unit: string = "mm"): BBox{ + public transform(event: TViewerEvtCtxData<'nehuba' | 'threeSurfer'>, boxDims: [number, number, number]=null, unit: string = "mm"): BBox{ if (!event) { return null } if (event.viewerType === 'threeSurfer') { return null } + if (!boxDims) { + boxDims = [MAGIC_RADIUS, MAGIC_RADIUS, MAGIC_RADIUS] + } + let divisor = 1 - if (unit === "mm") { - divisor = 1e6 + if (unit !== "mm") { + console.warn(`unit other than mm is not yet supported`) + return null } - const { payload } = event as TContextArg<'nehuba'> + divisor = 1e6 + const { payload } = event as TViewerEvtCtxData<'nehuba'> if (!payload.nav) return null const { position, zoom } = payload.nav // position is in nm // zoom can be directly applied as a multiple - const min = position.map(v => (v - (MAGIC_RADIUS * zoom)) / divisor) as Point - const max = position.map(v => (v + (MAGIC_RADIUS * zoom)) / divisor) as Point + const min = position.map((v, idx) => (v - (boxDims[idx] * zoom)) / divisor) as Point + const max = position.map((v, idx) => (v + (boxDims[idx] * zoom)) / divisor) as Point return [min, max] } } diff --git a/src/viewerModule/threeSurfer/store/effects.ts b/src/viewerModule/threeSurfer/store/effects.ts index 86992a1c3..7eef500a7 100644 --- a/src/viewerModule/threeSurfer/store/effects.ts +++ b/src/viewerModule/threeSurfer/store/effects.ts @@ -43,7 +43,8 @@ export class ThreeSurferEffects { map( cl => cl.filter(layer => layer.clType === "baselayer/threesurfer" || - layer.clType === "baselayer/threesurfer-label" + layer.clType === "baselayer/threesurfer-label/annot" || + layer.clType === "baselayer/threesurfer-label/gii-label" ) as ThreeSurferCustomLayer[] ) ) @@ -121,9 +122,9 @@ export class ThreeSurferEffects { switchMap(({ labels }) => { const labelMaps: ThreeSurferCustomLabelLayer[] = [] for (const key in labels) { - const { laterality, url } = labels[key] + const { laterality, url, clType } = labels[key] labelMaps.push({ - clType: 'baselayer/threesurfer-label', + clType, id: `${url}-${laterality}`, laterality, source: url diff --git a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts index 9a44a02e1..68822f1b2 100644 --- a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts +++ b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts @@ -1,12 +1,10 @@ -import { Component, Output, EventEmitter, ElementRef, OnDestroy, AfterViewInit, Inject, Optional, ChangeDetectionStrategy } from "@angular/core"; +import { Component, Output, EventEmitter, ElementRef, OnDestroy, AfterViewInit, Optional, ChangeDetectionStrategy } from "@angular/core"; import { EnumViewerEvt, IViewer, TViewerEvent } from "src/viewerModule/viewer.interface"; -import { BehaviorSubject, combineLatest, concat, forkJoin, from, merge, NEVER, Observable, of, Subject } from "rxjs"; +import { BehaviorSubject, combineLatest, concat, forkJoin, from, merge, NEVER, Observable, of, Subject, throwError } from "rxjs"; import { catchError, debounceTime, distinctUntilChanged, filter, map, scan, shareReplay, startWith, switchMap, tap, withLatestFrom } from "rxjs/operators"; import { ComponentStore, LockError } from "src/viewerModule/componentStore"; import { select, Store } from "@ngrx/store"; -import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR } from "src/util"; import { MatSnackBar } from "src/sharedModules/angularMaterial.exports" -import { CONST } from 'common/constants' import { getUuid, switchMapWaitFor } from "src/util/fn"; import { AUTO_ROTATE, TInteralStatePayload, ViewerInternalStateSvc } from "src/viewerModule/viewerInternalState.service"; import { atlasAppearance, atlasSelection } from "src/state"; @@ -97,6 +95,17 @@ type TThreeSurfer = { loadColormap: (url: string) => Promise setupAnimation: () => void dispose: () => void + loadVertexData: (url: string) => Promise<{ + vertex: number[] + labels: { + index: number + name: string + color: number[] + vertices: number[] + }[] + readonly vertexLabels: Uint16Array + readonly colormap: Map + }> control: any camera: any customColormap: WeakMap @@ -254,7 +263,9 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit ) private vertexIndexLayers$: Observable = this.customLayers$.pipe( - map(layers => layers.filter(l => l.clType === "baselayer/threesurfer-label") as ThreeSurferCustomLabelLayer[]), + map(layers => layers.filter(l => + l.clType === "baselayer/threesurfer-label/gii-label" || l.clType === "baselayer/threesurfer-label/annot" + ) as ThreeSurferCustomLabelLayer[]), distinctUntilChanged(arrayEqual((o, n) => o.id === n.id)), ) @@ -267,22 +278,37 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit ), switchMap(layers => forkJoin( - layers.map(layer => - from( - this.tsRef.loadColormap(layer.source) - ).pipe( - map(giiInstance => { - let vertexIndices: number[] = giiInstance[0].getData() - if (giiInstance[0].attributes.DataType === 'NIFTI_TYPE_INT16') { - vertexIndices = (window as any).ThreeSurfer.GiftiBase.castF32UInt16(vertexIndices) - } - return { - indexLayer: layer, - vertexIndices - } - }) - ) - ) + layers.map(layer => { + if (layer.clType === "baselayer/threesurfer-label/gii-label") { + return from( + this.tsRef.loadColormap(layer.source) + ).pipe( + map(giiInstance => { + let vertexIndices: number[] = giiInstance[0].getData() + if (giiInstance[0].attributes.DataType === 'NIFTI_TYPE_INT16') { + vertexIndices = (window as any).ThreeSurfer.GiftiBase.castF32UInt16(vertexIndices) + } + return { + indexLayer: layer, + vertexIndices + } + }) + ) + } + if (layer.clType === "baselayer/threesurfer-label/annot") { + return from( + this.tsRef.loadVertexData(layer.source) + ).pipe( + map(v => { + return { + indexLayer: layer, + vertexIndices: v.vertexLabels + } + }) + ) + } + return throwError(() => new Error(`layer is neither annot nor gii-label`)) + }) ) ), map(layers => { @@ -319,8 +345,13 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit const { label, region } = curr let key : 'left' | 'right' - if ( /left/i.test(region.name) ) key = 'left' - if ( /right/i.test(region.name) ) key = 'right' + if ( + /left/i.test(region.name) || /^lh/i.test(region.name) + ) key = 'left' + if ( + /right/i.test(region.name) || /^rh/i.test(region.name) + ) key = 'right' + if (!key) { /** * TODO @@ -400,7 +431,6 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit private sapi: SAPI, private snackbar: MatSnackBar, @Optional() intViewerStateSvc: ViewerInternalStateSvc, - @Optional() @Inject(CLICK_INTERCEPTOR_INJECTOR) clickInterceptor: ClickInterceptor, ){ if (intViewerStateSvc) { const { @@ -430,37 +460,6 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit this.onDestroyCb.push(() => done()) } - /** - * intercept click and act - */ - if (clickInterceptor) { - const handleClick = (ev: MouseEvent) => { - - // if does not click inside container, ignore - if (!(el.nativeElement as HTMLElement).contains(ev.target as HTMLElement)) { - return true - } - - if (this.mouseoverRegions.length === 0) return true - if (this.mouseoverRegions.length > 1) { - this.snackbar.open(CONST.DOES_NOT_SUPPORT_MULTI_REGION_SELECTION, 'Dismiss', { - duration: 3000 - }) - return true - } - - const regions = this.mouseoverRegions.slice(0, 1) as any[] - this.store$.dispatch( - atlasSelection.actions.setSelectedRegions({ regions }) - ) - return true - } - const { register, deregister } = clickInterceptor - register(handleClick) - this.onDestroyCb.push( - () => { deregister(register) } - ) - } this.domEl = el.nativeElement @@ -726,8 +725,8 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit const { evDetail: detail, latMeshRecord, latLblIdxRecord, latLblIdxReg, meshVisibility } = arg const evMesh = detail.mesh && { faceIndex: detail.mesh.faceIndex, - // typo in three-surfer - verticesIndicies: detail.mesh.verticesIdicies + verticesIndicies: detail.mesh.verticesIndicies, + vertexIndex: detail.mesh.vertexIndex, } const custEv: THandlingCustomEv = { regions: [], @@ -740,9 +739,9 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit const { geometry: evGeometry, - // typo in three-surfer - verticesIdicies: evVerticesIndicies, - } = detail.mesh as { geometry: TThreeGeometry, verticesIdicies: number[] } + verticesIndicies: evVerticesIndicies, + vertexIndex + } = detail.mesh as { geometry: TThreeGeometry, verticesIndicies: number[], vertexIndex: number } for (const laterality in latMeshRecord) { const meshRecord = latMeshRecord[laterality] @@ -771,14 +770,22 @@ export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, AfterViewInit * translate vertex indices to label indicies via set, to remove duplicates */ const labelIndexSet = new Set() - for (const idx of evVerticesIndicies){ - const labelOfInterest = labelIndexRecord.vertexIndices[idx] - if (!labelOfInterest) { - continue - } - labelIndexSet.add(labelOfInterest) + if (labelIndexRecord.vertexIndices[vertexIndex]) { + labelIndexSet.add(labelIndexRecord.vertexIndices[vertexIndex]) } + /** + * old implementation (perhaps less CPU intensive) + * gets all vertices and label them + */ + // for (const idx of evVerticesIndicies){ + // const labelOfInterest = labelIndexRecord.vertexIndices[idx] + // if (!labelOfInterest) { + // continue + // } + // labelIndexSet.add(labelOfInterest) + // } + /** * decode label index to region */ diff --git a/src/viewerModule/viewer.interface.ts b/src/viewerModule/viewer.interface.ts index cbc85aca0..488f8ee12 100644 --- a/src/viewerModule/viewer.interface.ts +++ b/src/viewerModule/viewer.interface.ts @@ -35,7 +35,9 @@ export interface IViewerCtx { 'threeSurfer': TThreeSurferContextInfo } -export type TContextArg = ({ +export type ViewerType = "nehuba" | "threeSurfer" + +export type TViewerEvtCtxData = ({ viewerType: K payload: RecursivePartial }) @@ -45,25 +47,40 @@ export enum EnumViewerEvt { VIEWER_CTX, } -type TViewerEventViewerLoaded = { +export type TViewerEventViewerLoaded = { type: EnumViewerEvt.VIEWERLOADED data: boolean } -export type TViewerEvent = TViewerEventViewerLoaded | - { - type: EnumViewerEvt.VIEWER_CTX - data: TContextArg - } +type TViewerEventCtx = { + type: EnumViewerEvt.VIEWER_CTX + data: TViewerEvtCtxData +} + +export type TViewerEvent< + T extends ViewerType=ViewerType +> = TViewerEventViewerLoaded | TViewerEventCtx + +export function isViewerCtx(ev: TViewerEvent): ev is TViewerEventCtx { + return ev.type === EnumViewerEvt.VIEWER_CTX +} + +export function isNehubaVCtxEvt(ev: TViewerEvent): ev is TViewerEventCtx<"nehuba"> { + return ev.type === EnumViewerEvt.VIEWER_CTX && ev.data.viewerType === "nehuba" +} + +export function isThreeSurferVCtxEvt(ev: TViewerEvent): ev is TViewerEventCtx<"threeSurfer"> { + return ev.type === EnumViewerEvt.VIEWER_CTX && ev.data.viewerType === "threeSurfer" +} -export type TSupportedViewers = keyof IViewerCtx +export type TSupportedViewers = ViewerType -export interface IViewer { +export interface IViewer { viewerCtrlHandler?: IViewerCtrl viewerEvent: EventEmitter> } export interface IGetContextInjArg { - register: (fn: (contextArg: TContextArg) => void) => void - deregister: (fn: (contextArg: TContextArg) => void) => void + register: (fn: (contextArg: TViewerEvtCtxData) => void) => void + deregister: (fn: (contextArg: TViewerEvtCtxData) => void) => void } diff --git a/src/viewerModule/viewerCmp/viewerCmp.component.ts b/src/viewerModule/viewerCmp/viewerCmp.component.ts index 1954de2ba..ac84f5c6e 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.component.ts +++ b/src/viewerModule/viewerCmp/viewerCmp.component.ts @@ -1,11 +1,11 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, TemplateRef, ViewChild, inject } from "@angular/core"; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, TemplateRef, ViewChild, inject } from "@angular/core"; import { select, Store } from "@ngrx/store"; -import { BehaviorSubject, combineLatest, Observable, of, Subscription } from "rxjs"; +import { BehaviorSubject, combineLatest, Observable, of } from "rxjs"; import { debounceTime, distinctUntilChanged, map, shareReplay, switchMap, takeUntil } 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"; -import { EnumViewerEvt, TContextArg, TSupportedViewers, TViewerEvent } from "../viewer.interface"; +import { EnumViewerEvt, TViewerEvtCtxData, TSupportedViewers, TViewerEvent } from "../viewer.interface"; import { ContextMenuService, TContextMenuReg } from "src/contextMenuModule"; import { DialogService } from "src/services/dialogService.service"; import { SAPI } from "src/atlasComponents/sapi"; @@ -55,7 +55,7 @@ interface HasName { ] }) -export class ViewerCmp implements OnDestroy { +export class ViewerCmp { public readonly destroy$ = inject(DestroyDirective).destroyed$ @@ -74,8 +74,6 @@ export class ViewerCmp implements OnDestroy { description: QUICKTOUR_DESC.ATLAS_SELECTOR, } - private subscriptions: Subscription[] = [] - private onDestroyCb: (() => void)[] = [] public viewerLoaded: boolean = false private selectedATP = this.store$.pipe( @@ -238,7 +236,7 @@ export class ViewerCmp implements OnDestroy { constructor( private store$: Store, - private ctxMenuSvc: ContextMenuService>, + private ctxMenuSvc: ContextMenuService>, private dialogSvc: DialogService, private cdr: ChangeDetectorRef, private sapi: SAPI, @@ -293,51 +291,51 @@ export class ViewerCmp implements OnDestroy { this.#fullNavBarSwitch$.next(flag) }) - this.subscriptions.push( - this.templateSelected$.subscribe( - t => this.templateSelected = t - ), - combineLatest([ - this.templateSelected$, - this.parcellationSelected$, - this.selectedAtlas$, - ]).pipe( - debounceTime(160) - ).subscribe(async ([tmpl, parc, atlas]) => { - const regex = /pre.?release/i - const checkPrerelease = (obj: any) => { - if (obj?.name) return regex.test(obj.name) - return false - } - const message: string[] = [] - if (checkPrerelease(atlas)) { - message.push(`- _${atlas.name}_`) - } - if (checkPrerelease(tmpl)) { - message.push(`- _${tmpl.name}_`) - } - if (checkPrerelease(parc)) { - message.push(`- _${parc.name}_`) - } - if (message.length > 0) { - message.unshift(`The following have been tagged pre-release, and may be updated frequently:`) - try { - await this.dialogSvc.getUserConfirm({ - title: `Pre-release warning`, - markdown: message.join('\n\n'), - confirmOnly: true - }) - // eslint-disable-next-line no-empty - } catch (e) { - - } - } - }) + this.templateSelected$.pipe( + takeUntil(this.destroy$) + ).subscribe( + t => this.templateSelected = t ) - } - ngAfterViewInit(): void{ - const cb: TContextMenuReg> = ({ append, context }) => { + combineLatest([ + this.templateSelected$, + this.parcellationSelected$, + this.selectedAtlas$, + ]).pipe( + takeUntil(this.destroy$), + debounceTime(160), + ).subscribe(async ([tmpl, parc, atlas]) => { + const regex = /pre.?release/i + const checkPrerelease = (obj: any) => { + if (obj?.name) return regex.test(obj.name) + return false + } + const message: string[] = [] + if (checkPrerelease(atlas)) { + message.push(`- _${atlas.name}_`) + } + if (checkPrerelease(tmpl)) { + message.push(`- _${tmpl.name}_`) + } + if (checkPrerelease(parc)) { + message.push(`- _${parc.name}_`) + } + if (message.length > 0) { + message.unshift(`The following have been tagged pre-release, and may be updated frequently:`) + try { + await this.dialogSvc.getUserConfirm({ + title: `Pre-release warning`, + markdown: message.join('\n\n'), + confirmOnly: true + }) + // eslint-disable-next-line no-empty + } catch (e) { + + } + } + }) + + const cb: TContextMenuReg> = ({ append, context }) => { if (this.#lastSelectedPoint && this.lastViewedPointTmpl) { const { point, template, face, vertices } = this.#lastSelectedPoint @@ -373,14 +371,14 @@ export class ViewerCmp implements OnDestroy { */ let hoveredRegions = [] if (context.viewerType === 'nehuba') { - hoveredRegions = ((context as TContextArg<'nehuba'>).payload.nehuba || []).reduce( + hoveredRegions = ((context as TViewerEvtCtxData<'nehuba'>).payload.nehuba || []).reduce( (acc, curr) => acc.concat(...curr.regions), [] ) } if (context.viewerType === 'threeSurfer') { - hoveredRegions = (context as TContextArg<'threeSurfer'>).payload.regions + hoveredRegions = (context as TViewerEvtCtxData<'threeSurfer'>).payload.regions } if (hoveredRegions.length > 0) { @@ -397,14 +395,11 @@ export class ViewerCmp implements OnDestroy { return true } this.ctxMenuSvc.register(cb) - this.onDestroyCb.push( - () => this.ctxMenuSvc.deregister(cb) - ) - } - ngOnDestroy(): void { - while (this.subscriptions.length) this.subscriptions.pop().unsubscribe() - while (this.onDestroyCb.length > 0) this.onDestroyCb.pop()() + this.destroy$.subscribe(() => { + this.ctxMenuSvc.deregister(cb) + }) + } public clearRoi(): void{ @@ -484,47 +479,6 @@ export class ViewerCmp implements OnDestroy { ) } - public handleViewerEvent(event: TViewerEvent<'nehuba' | 'threeSurfer'>): void{ - switch(event.type) { - case EnumViewerEvt.VIEWERLOADED: - this.viewerLoaded = event.data - this.cdr.detectChanges() - break - case EnumViewerEvt.VIEWER_CTX: - this.ctxMenuSvc.deepMerge(event.data) - if (event.data.viewerType === "nehuba") { - const { nehuba, nav } = (event.data as TContextArg<"nehuba">).payload - if (nehuba) { - const mousingOverRegions = (nehuba || []).reduce((acc, { regions }) => acc.concat(...regions), []) - this.store$.dispatch( - userInteraction.actions.mouseoverRegions({ - regions: mousingOverRegions - }) - ) - } - if (nav) { - this.store$.dispatch( - userInteraction.actions.mouseoverPosition({ - position: { - loc: nav.position as [number, number, number], - space: this.templateSelected - } - }) - ) - } - } - 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: - } - } public disposeCtxMenu(): void{ this.ctxMenuSvc.dismissCtxMenu() @@ -539,12 +493,6 @@ export class ViewerCmp implements OnDestroy { ) } - clearSelectedFeature(): void{ - this.store$.dispatch( - userInteraction.actions.clearShownFeature() - ) - } - navigateTo(position: number[]): void { this.store$.dispatch( atlasSelection.actions.navigateTo({ @@ -597,4 +545,15 @@ export class ViewerCmp implements OnDestroy { nameEql(a: HasName, b: HasName){ return a.name === b.name } + + handleViewerCtxEvent(event: TViewerEvent) { + if (event.type === EnumViewerEvt.VIEWERLOADED) { + this.viewerLoaded = event.data + this.cdr.detectChanges() + return + } + if (event.type === EnumViewerEvt.VIEWER_CTX) { + this.ctxMenuSvc.deepMerge(event.data) + } + } } diff --git a/src/viewerModule/viewerCmp/viewerCmp.style.css b/src/viewerModule/viewerCmp/viewerCmp.style.css index d72febea4..05cfce23c 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.style.css +++ b/src/viewerModule/viewerCmp/viewerCmp.style.css @@ -93,13 +93,13 @@ mat-drawer display: block; } -mat-list.contextual-block +.contextual-block { display: inline-block; background-color:rgba(200,200,200,0.8); } -:host-context([darktheme="true"]) mat-list.contextual-block +:host-context([darktheme="true"]) .contextual-block { background-color : rgba(30,30,30,0.8); } @@ -155,13 +155,6 @@ sxplr-sapiviews-core-region-region-list-item align-items: center; } -.centered -{ - display: flex; - justify-content: center; - align-items: center; -} - .leave-me-alone { margin-top: 0.5rem; @@ -194,4 +187,11 @@ sxplr-tab { display: inline-flex; flex-direction: column; -} \ No newline at end of file +} + +viewer-wrapper +{ + width: 100%; + height: 100%; + display: block; +} diff --git a/src/viewerModule/viewerCmp/viewerCmp.template.html b/src/viewerModule/viewerCmp/viewerCmp.template.html index 5406251d2..b837c843a 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.template.html +++ b/src/viewerModule/viewerCmp/viewerCmp.template.html @@ -10,21 +10,13 @@
- -
- - - - - {{ cvtOutput.text }} - - -
- + +
+ + +
+
@@ -418,44 +410,9 @@
- - - - - - - - - - - - -
Template not supported by any of the viewers
- - -
- -
-
- - - Loading - {{ (selectedAtlas$ | async).name }} - -
-
-
- - - - -
-
+ +
@@ -577,7 +534,12 @@ (sxplr-sapiviews-core-region-navigate-to)="navigateTo($event)" #regionDirective="sapiViewsCoreRegionRich" > -
+ + + +
+
+ @@ -616,17 +578,6 @@ - - - - - @@ -637,6 +588,18 @@
Multiple regions selected
+ + + + + @@ -866,23 +829,7 @@ - - -
- - -
- +
@@ -897,10 +844,10 @@ [attr.aria-label]="ARIA_LABELS.CLOSE" class="sxplr-mb-2" > - - Back + Dismiss +
@@ -929,16 +876,14 @@ Anchored to current view -
- {{ view.selectedTemplate.name }} -
+ -
- from {{ bbox[0] | numbers | addUnitAndJoin : '' }} -
-
- to {{ bbox[1] | numbers | addUnitAndJoin : '' }} -
+ + +
diff --git a/src/viewerModule/viewerWrapper/viewerWrapper.component.ts b/src/viewerModule/viewerWrapper/viewerWrapper.component.ts new file mode 100644 index 000000000..4e7e7cc5e --- /dev/null +++ b/src/viewerModule/viewerWrapper/viewerWrapper.component.ts @@ -0,0 +1,204 @@ +import { Component, ElementRef, Inject, Optional, Output, inject } from "@angular/core"; +import { Observable, Subject, merge } from "rxjs"; +import { TSupportedViewers, TViewerEvent, isNehubaVCtxEvt, isThreeSurferVCtxEvt, isViewerCtx } from "../viewer.interface"; +import { Store, select } from "@ngrx/store"; +import { MainState, atlasAppearance, atlasSelection, userInteraction } from "src/state"; +import { distinctUntilChanged, filter, finalize, map, shareReplay, takeUntil } from "rxjs/operators"; +import { arrayEqual } from "src/util/array"; +import { DestroyDirective } from "src/util/directives/destroy.directive"; +import { CLICK_INTERCEPTOR_INJECTOR, ClickInterceptor, HOVER_INTERCEPTOR_INJECTOR, HoverInterceptor, THoverConfig } from "src/util/injectionTokens"; +import { SxplrRegion, SxplrTemplate } from "src/atlasComponents/sapi/sxplrTypes"; + +@Component({ + selector: 'viewer-wrapper', + templateUrl: './viewerWrapper.template.html', + styleUrls: [ + './viewerWrapper.style.css' + ], + hostDirectives: [ + DestroyDirective + ] +}) +export class ViewerWrapper { + + #destroy$ = inject(DestroyDirective).destroyed$ + + @Output('viewer-event') + viewerEvent$ = new Subject< + TViewerEvent + >() + + selectedAtlas$ = this.store$.pipe( + select(atlasSelection.selectors.selectedAtlas) + ) + + useViewer$: Observable = this.store$.pipe( + select(atlasAppearance.selectors.useViewer), + map(useviewer => { + if (useviewer === "NEHUBA") return "nehuba" + if (useviewer === "THREESURFER") return "threeSurfer" + if (useviewer === "NOT_SUPPORTED") return "notsupported" + return null + }) + ) + + constructor( + el: ElementRef, + private store$: Store, + @Optional() @Inject(HOVER_INTERCEPTOR_INJECTOR) + hoverInterceptor: HoverInterceptor, + @Optional() @Inject(CLICK_INTERCEPTOR_INJECTOR) + clickInterceptor: ClickInterceptor, + ){ + + this.store$.pipe( + select(atlasSelection.selectors.selectedTemplate), + takeUntil(this.#destroy$) + ).subscribe(tmpl => { + this.#selectedTemplate = tmpl + }) + + /** + * handling nehuba event + */ + this.#nehubaViewerCtxEv$.pipe( + takeUntil(this.#destroy$) + ).subscribe(ev => { + const { nehuba, nav } = ev.data.payload + if (nehuba) { + const mousingOverRegions = (nehuba || []).reduce((acc, { regions }) => acc.concat(...regions), []) + this.store$.dispatch( + userInteraction.actions.mouseoverRegions({ + regions: mousingOverRegions + }) + ) + } + if (nav) { + this.store$.dispatch( + userInteraction.actions.mouseoverPosition({ + position: { + loc: nav.position as [number, number, number], + space: this.#selectedTemplate, + spaceId: this.#selectedTemplate.id, + } + }) + ) + } + }) + + /** + * handling threesurfer event + */ + this.#threeSurferViewerCtxEv$.pipe( + takeUntil(this.#destroy$) + ).subscribe(ev => { + const { regions = [] } = ev.data.payload + this.store$.dispatch( + userInteraction.actions.mouseoverRegions({ + regions: regions as SxplrRegion[] + }) + ) + }) + + if (hoverInterceptor) { + let hoverRegionMessages: THoverConfig[] = [] + const { append, remove } = hoverInterceptor + this.#hoveredRegions$.pipe( + takeUntil(this.#destroy$), + finalize(() => { + for (const msg of hoverRegionMessages) { + remove(msg) + } + }) + ).subscribe(regions => { + + for (const msg of hoverRegionMessages) { + remove(msg) + } + + hoverRegionMessages = regions.map(region => ({ + message: region.name || 'Unknown Region', + fontIcon: 'fa-brain', + fontSet: 'fas' + })) + + for (const msg of hoverRegionMessages){ + append(msg) + } + }) + } + + if (clickInterceptor) { + const { register, deregister } = clickInterceptor + let hoveredRegions: SxplrRegion[] + this.#hoveredRegions$.subscribe(reg => { + hoveredRegions = reg as SxplrRegion[] + }) + const handleClick = (ev: PointerEvent) => { + if (!el?.nativeElement?.contains(ev.target)) { + return true + } + if (hoveredRegions.length === 0) { + return true + } + if (ev.ctrlKey) { + this.store$.dispatch( + atlasSelection.actions.toggleRegion({ + region: hoveredRegions[0] + }) + ) + } else { + this.store$.dispatch( + atlasSelection.actions.selectRegion({ + region: hoveredRegions[0] + }) + ) + } + return true + } + register(handleClick, { last: true }) + this.#destroy$.subscribe(() => { + deregister(handleClick) + }) + } + } + + public handleViewerEvent(event: TViewerEvent): void{ + this.viewerEvent$.next(event) + } + + #viewerCtxEvent$ = this.viewerEvent$.pipe( + filter(isViewerCtx), + shareReplay(1), + ) + + #nehubaViewerCtxEv$ = this.#viewerCtxEvent$.pipe( + filter(isNehubaVCtxEvt) + ) + + #threeSurferViewerCtxEv$ = this.#viewerCtxEvent$.pipe( + filter(isThreeSurferVCtxEvt) + ) + + #hoveredRegions$ = merge( + this.#nehubaViewerCtxEv$.pipe( + filter(ev => !!ev.data.payload.nehuba), + map(ev => { + const { nehuba } = ev.data.payload + return nehuba.map(n => n.regions).flatMap(v => v) + }) + ), + this.#threeSurferViewerCtxEv$.pipe( + map(ev => { + const { regions = [] } = ev.data.payload + return regions + }) + ) + ).pipe( + distinctUntilChanged( + arrayEqual((o, n) => o.name === n.name) + ) + ) + + #selectedTemplate: SxplrTemplate = null +} diff --git a/src/viewerModule/viewerWrapper/viewerWrapper.style.css b/src/viewerModule/viewerWrapper/viewerWrapper.style.css new file mode 100644 index 000000000..e69de29bb diff --git a/src/viewerModule/viewerWrapper/viewerWrapper.template.html b/src/viewerModule/viewerWrapper/viewerWrapper.template.html new file mode 100644 index 000000000..5ea7ec0ce --- /dev/null +++ b/src/viewerModule/viewerWrapper/viewerWrapper.template.html @@ -0,0 +1,37 @@ + + + + + + + + + + + +
Template not supported by any of the viewers
+ + +
+ +
+
+ + + Loading + {{ atlas.name }} + +
+
+
+ + + + +
+