diff --git a/.github/workflows/docker_img.yml b/.github/workflows/docker_img.yml new file mode 100644 index 0000000000000000000000000000000000000000..6ce3cb74ab8c9b5df5d7580a39cbe5c9d3ebac98 --- /dev/null +++ b/.github/workflows/docker_img.yml @@ -0,0 +1,132 @@ +name: '[docker image]' + +on: [ 'push' ] + +jobs: + build-docker-img: + runs-on: ubuntu-latest + env: + MATOMO_ID_DEV: '7' + MATAMO_URL_DEV: 'https://stats-dev.humanbrainproject.eu/' + MATOMO_ID_PROD: '12' + MATAMO_URL_PROD: 'https://stats.humanbrainproject.eu/' + PRODUCTION: 'true' + DOCKER_REGISTRY: 'docker-registry.ebrains.eu/siibra/' + + steps: + - uses: actions/checkout@v2 + - name: 'Set matomo env var' + run: | + echo "Using github.ref: $GITHUB_REF" + + echo "BRANCH_NAME=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV + + if [[ "$GITHUB_REF" == 'refs/heads/master' ]] || [[ "$GITHUB_REF" == 'refs/heads/staging' ]] + then + echo "Either master or staging, using prod env..." + echo "MATAMO_URL=${{ env.MATAMO_URL_PROD }}" >> $GITHUB_ENV + echo "MATOMO_ID=${{ env.MATOMO_ID_PROD }}" >> $GITHUB_ENV + + else + echo "Using dev env..." + echo "MATAMO_URL=${{ env.MATAMO_URL_DEV }}" >> $GITHUB_ENV + echo "MATOMO_ID=${{ env.MATOMO_ID_DEV }}" >> $GITHUB_ENV + fi + + - name: 'Set version variable' + run: | + if [[ "$GITHUB_REF" == 'refs/heads/master' ]] || [[ "$GITHUB_REF" == 'refs/heads/staging' ]] + then + echo "Either master or staging, using package.json" + VERSION=$(jq -r '.version' package.json) + else + echo "Using git hash" + VERSION=$(git rev-parse --short HEAD) + fi + echo "VERSION=$VERSION" >> $GITHUB_ENV + - name: 'Build docker image' + run: | + DOCKER_BUILT_TAG=${{ env.DOCKER_REGISTRY }}siibra-explorer:$BRANCH_NAME + echo "Building $DOCKER_BUILT_TAG" + docker build \ + --build-arg VERSION=$VERSION \ + --build-arg MATAMO_URL=$MATAMO_URL \ + --build-arg MATAMO_ID=$MATAMO_ID \ + -t $DOCKER_BUILT_TAG \ + . + echo "Successfully built $DOCKER_BUILT_TAG" + echo "DOCKER_BUILT_TAG=$DOCKER_BUILT_TAG" >> $GITHUB_ENV + + - name: 'Push to docker registry' + run: | + echo "Login to docker registry" + docker login \ + -u "${{ secrets.EBRAINS_DOCKER_REG_USER }}" \ + -p "${{ secrets.EBRAINS_DOCKER_REG_TOKEN }}" \ + docker-registry.ebrains.eu + echo "Pushing $DOCKER_BUILT_TAG" + docker push $DOCKER_BUILT_TAG + + trigger-deploy: + if: success() + runs-on: ubuntu-latest + env: + GITHUB_API_ROOT: https://api.github.com/repos/fzj-inm1-bda/siibra-explorer + + needs: build-docker-img + steps: + - uses: actions/checkout@v2 + - name: Set env var + run: | + echo "Using github.ref: $GITHUB_REF" + BRANCH_NAME=${GITHUB_REF#refs/heads/} + echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV + + echo "Branch is $BRANCH_NAME ." + if [[ "$BRANCH_NAME" == 'master' ]] || [[ "$BRANCH_NAME" == 'staging' ]] + then + echo "OKD_URL=https://okd.hbp.eu:443" >> $GITHUB_ENV + echo "OKD_SECRET=${{ secrets.OKD_PROD_SECRET }}" >> $GITHUB_ENV + echo "OKD_PROJECT=interactive-viewer" >> $GITHUB_ENV + echo "Deploy on prod cluster..." + else + echo "OKD_URL=https://okd-dev.hbp.eu:443" >> $GITHUB_ENV + echo "OKD_SECRET=${{ secrets.OKD_DEV_SECRET }}" >> $GITHUB_ENV + echo "OKD_PROJECT=interactive-atlas-viewer" >> $GITHUB_ENV + echo "Deploy on dev cluster..." + fi + - name: 'Login via oc cli & deploy' + run: | + oc login $OKD_URL --token=$OKD_SECRET + oc project $OKD_PROJECT + + # sanitized branchname == remove _ / and lowercase everything + SANITIZED_BRANCH_NAME=$(echo ${BRANCH_NAME//[_\/]/} | awk '{ print tolower($0) }') + echo "SANITIZED_BRANCH_NAME=$SANITIZED_BRANCH_NAME" >> $GITHUB_ENV + echo "Working branch name: $BRANCH_NAME, sanitized branch name: $SANITIZED_BRANCH_NAME" + + # check if the deploy already exist + if oc get dc siibra-explorer-branch-deploy-$SANITIZED_BRANCH_NAME; then + # trigger redeploy if deployconfig exists already + echo "dc siibra-explorer-branch-deploy-$SANITIZED_BRANCH_NAME already exist, redeploy..." + oc rollout latest dc/siibra-explorer-branch-deploy-$SANITIZED_BRANCH_NAME + else + # create new app if deployconfig does not yet exist + echo "dc siibra-explorer-branch-deploy-$SANITIZED_BRANCH_NAME does not yet exist, create new app..." + oc new-app --template siibra-explorer-branch-deploy \ + -p BRANCH_NAME=$BRANCH_NAME \ + -p SANITIZED_BRANCH_NAME=$SANITIZED_BRANCH_NAME + fi + - name: 'Update status badge' + if: success() + run: | + curl -v \ + -X POST \ + -H "Authorization: Token ${{ secrets.WORKFLOW_TOKEN }}" \ + -H 'accept: application/vnd.github.v3+json' \ + ${GITHUB_API_ROOT}/statuses/${GITHUB_SHA} \ + -d '{ + "target_url":"https://siibra-explorer.apps-dev.hbp.eu/${{ env.SANITIZED_BRANCH_NAME }}", + "name": "Deployed at OKD", + "state": "success" + }' diff --git a/.github/workflows/on_branch_del.yml b/.github/workflows/on_branch_del.yml new file mode 100644 index 0000000000000000000000000000000000000000..2e04886d1d7c4ebd964f793d5acfff076498e4a5 --- /dev/null +++ b/.github/workflows/on_branch_del.yml @@ -0,0 +1,44 @@ +name: '[undeploy from OKD]' + +# only trigger on delete non master/staging branch +on: + delete: + branches: + - '!master' + - '!staging' + +jobs: + remove-deploy: + runs-on: ubuntu-latest + steps: + - uses: action/checkout@v2 + - name: 'Set env var' + run: | + echo "Using github.ref: $GITHUB_REF" + BRANCH_NAME=${GITHUB_REF#refs/heads/} + echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV + + echo "OKD_URL=https://okd-dev.hbp.eu:443" >> $GITHUB_ENV + echo "OKD_SECRET=${{ secrets.OKD_DEV_SECRET }}" >> $GITHUB_ENV + echo "OKD_PROJECT=interactive-atlas-viewer" >> $GITHUB_ENV + echo "Remove deploy from dev cluster..." + + - name: 'Login via oc cli' + run: | + oc login $OKD_URL --token=$OKD_SECRET + oc project $OKD_PROJECT + + # sanitized branchname == remove _ / and lowercase everything + SANITIZED_BRANCH_NAME=$(echo ${BRANCH_NAME//[_\/]/} | awk '{ print tolower($0) }') + echo "SANITIZED_BRANCH_NAME=$SANITIZED_BRANCH_NAME" >> $GITHUB_ENV + echo "Working branch name: $BRANCH_NAME, sanitized branch name: $SANITIZED_BRANCH_NAME" + + - name: 'List and delete all labelled resoures' + run: | + oc get all \ + -l template=siibra-explorer-branch-deploy-template \ + -l app=siibra-explorer-branch-deploy-$SANITIZED_BRANCH_NAME + + oc delete all \ + -l template=siibra-explorer-branch-deploy-template \ + -l app=siibra-explorer-branch-deploy-$SANITIZED_BRANCH_NAME diff --git a/.openshift/okd_branch_tmpl.yaml b/.openshift/okd_branch_tmpl.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8025740e97007793ab85161c7caa901145b230c8 --- /dev/null +++ b/.openshift/okd_branch_tmpl.yaml @@ -0,0 +1,148 @@ +apiVersion: v1 +kind: Template +metadata: + name: siibra-explorer-branch-deploy + annotations: + description: "Deploy branch of siibra-explorer" + tags: "nodejs,siibra-explorer" +objects: +- apiVersion: v1 + kind: DeploymentConfig + metadata: + name: siibra-explorer-branch-deploy-${SANITIZED_BRANCH_NAME} + labels: + app: siibra-explorer-branch-deploy-${SANITIZED_BRANCH_NAME} + spec: + replicas: 1 + revisionHistoryLimit: 10 + selector: + deploymentconfig: siibra-explorer-branch-deploy-${SANITIZED_BRANCH_NAME} + template: + metadata: + labels: + app: siibra-explorer-branch-deploy + deploymentconfig: siibra-explorer-branch-deploy-${SANITIZED_BRANCH_NAME} + spec: + containers: + - env: + - name: SESSION_SECRET + value: ${SESSION_SECRET} + - name: HOSTNAME + value: https://siibra-explorer.apps-dev.hbp.eu + - name: HOST_PATHNAME + value: /${SANITIZED_BRANCH_NAME} + - name: IAV_STAGE + value: ${SANITIZED_BRANCH_NAME} + - name: BUILD_TEXT + value: ${SANITIZED_BRANCH_NAME} + - name: SCRIPT_SRC + value: '["stats-dev.humanbrainproject.eu"]' + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + key: database-password + name: redis-rate-limiting-db-ephemeral + - name: REGIONAL_FEATURE_ENDPOINT_ARRAY + value: '["https://jugit.fz-juelich.de/x.gui/20201104_ieegcoord_output/-/raw/master/output/brainscape-json-left-v2.json","https://jugit.fz-juelich.de/x.gui/20201104_ieegcoord_output/-/raw/master/output/brainscape-json-right-v2.json","https://jugit.fz-juelich.de/x.gui/20201113_receptornewui/-/raw/master/output/receptor.json"]' + envFrom: + - configMapRef: + name: hbp-oauth-config-map + - configMapRef: + name: fluent-logging + - configMapRef: + name: plugins + - configMapRef: + name: other-deploy-config + - configMapRef: + name: obj-storage-env-var + prefix: OBJ_STORAGE_ + - configMapRef: + name: obj-stoage-credentials + prefix: OBJ_STORAGE_ + + image: "docker-registry.ebrains.eu/siibra/siibra-explorer:${BRANCH_NAME}" + imagePullPolicy: Always + livenessProbe: + failureThreshold: 3 + httpGet: + path: /${SANITIZED_BRANCH_NAME}/ready + port: 8080 + scheme: HTTP + initialDelaySeconds: 10 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 1 + readinessProbe: + failureThreshold: 3 + httpGet: + path: /${SANITIZED_BRANCH_NAME}/ready + port: 8080 + scheme: HTTP + initialDelaySeconds: 3 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 6 + name: siibra-explorer-${SANITIZED_BRANCH_NAME} + ports: + - containerPort: 8080 + protocol: TCP + resources: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + dnsPolicy: ClusterFirst + restartPolicy: Always + schedulerName: default-scheduler + securityContext: {} + terminationGracePeriodSeconds: 30 +- apiVersion: v1 + kind: Service + metadata: + labels: + app: siibra-explorer-branch-deploy-${SANITIZED_BRANCH_NAME} + name: siibra-explorer-branch-deploy-${SANITIZED_BRANCH_NAME} + spec: + ports: + - name: 8080-tcp + port: 8080 + protocol: TCP + targetPort: 8080 + selector: + deploymentconfig: siibra-explorer-branch-deploy-${SANITIZED_BRANCH_NAME} + type: ClusterIP +- apiVersion: v1 + kind: Route + metadata: + labels: + app: siibra-explorer-branch-deploy-${SANITIZED_BRANCH_NAME} + name: siibra-explorer-branch-deploy-${SANITIZED_BRANCH_NAME} + spec: + host: siibra-explorer.apps-dev.hbp.eu + path: /${SANITIZED_BRANCH_NAME} + port: + targetPort: 8080-tcp + tls: + insecureEdgeTerminationPolicy: Redirect + termination: edge + to: + kind: Service + name: siibra-explorer-branch-deploy-${SANITIZED_BRANCH_NAME} + weight: 100 + wildcardPolicy: None + +parameters: +- description: Session secret + from: '[A-Z0-9]{16}' + generate: expression + name: SESSION_SECRET +- name: BRANCH_NAME + required: true +- name: SANITIZED_BRANCH_NAME + required: true + description: | + A lot of routing/naming follow special rules: + - does not allow special characters, except for - or . . + - only allows lower case. + Strip all special characters from BRANCH_NAME, change to all lower case and pass it as SANITIZED_BRANCH_NAME + +labels: + template: siibra-explorer-branch-deploy-template diff --git a/Dockerfile b/Dockerfile index 7c9bd9f950c437b328f4ba41a9ca59177da4a795..1b9302b48d60cce33ada1cd676b1040edfe10d4d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,9 @@ ENV KIOSK_MODE=${KIOSK_MODE:-false} COPY . /iv WORKDIR /iv +# When building in local, where node_module already exist, prebuilt binary may throw an error +RUN rm -rf ./node_modules + ARG VERSION ENV VERSION=${VERSION} @@ -34,15 +37,6 @@ WORKDIR /iv RUN for f in $(find . -type f); do gzip < $f > $f.gz && brotli < $f > $f.br; done -# Building doc -FROM python:3.7 as doc-builder - -COPY . /iav -WORKDIR /iav - -RUN pip install mkdocs mkdocs-material mdx_truly_sane_lists errandkun -RUN mkdocs build - # prod container FROM node:12-alpine @@ -61,12 +55,15 @@ COPY --from=builder /iv/deploy . # Copy built interactive viewer COPY --from=compressor /iv ./public -# Copy docs -COPY --from=doc-builder /iav/site ./docs - # Copy the resources files needed to respond to queries # is this even necessary any more? COPY --from=compressor /iv/res/json ./res + +RUN chown -R node:node /iv-app + +USER node RUN npm i -ENTRYPOINT [ "node", "server.js" ] \ No newline at end of file +EXPOSE 8080 +ENV PORT 8080 +ENTRYPOINT [ "node", "server.js" ] diff --git a/README.md b/README.md index cd293c3bff01b68474c9fa7389e4961a01e1914a..c6e738a0957b6dc71ebe988167863aff00f053a6 100644 --- a/README.md +++ b/README.md @@ -7,140 +7,32 @@ Interactive Atlas Viewer is an frontend module wrapping around [nehuba](https:// A live version of the Interactive Atlas Viewer is available at [https://interactive-viewer.apps.hbp.eu](https://interactive-viewer.apps.hbp.eu). This section is useful for developers who would like to develop this project. ### General information + Interactive atlas viewer is built with [Angular (v9.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. -### Develop viewer +### Develop #### Prerequisites - node >= 12 -#### Buildtime environments +#### Environments It is recommended to manage your environments with `.env` file. -As interactive atlas viewer uses [webpack define plugin](https://webpack.js.org/plugins/define-plugin/), where necessary, the environmental variables are `JSON.stringify`'ed and directly replaced in the code. - -| name | description | default | example | -| --- | --- | --- | --- | -| `VERSION` | printed in console on viewer startup | `GIT_HASH` \|\| unspecificed hash | v2.2.2 | -| `PRODUCTION` | if the build is for production, toggles optimisations such as minification | `undefined` | true | -| `BACKEND_URL` | backend that the viewer calls to fetch available template spaces, parcellations, plugins, datasets | `null` | https://interactive-viewer.apps.hbp.eu/ | -| `BS_REST_URL` | [brainscape-api](https://jugit.fz-juelich.de/v.marcenko/brainscapes-api) used to fetch different resources | https://brainscapes.apps-dev.hbp.eu/v1_0 | -| `DATASET_PREVIEW_URL` | dataset preview url used by component <https://github.com/fzj-inm1-bda/kg-dataset-previewer>. Useful for diagnosing issues with dataset previews.| https://hbp-kg-dataset-previewer.apps.hbp.eu/datasetPreview | http://localhost:1234/datasetPreview | -| `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` | -| `KIOSK_MODE` | after 5 minutes of inactivity, shows overlay inviting users to interact | `false` | `true` | -| `BUILD_TEXT` | overlay text at bottom right of the viewer. set to `''` to hide. | | +##### Buildtime environments -#### Deploy environments +Please see [build_env.md](build_env.md) -It is recommended to manage your environments with `.env` file. +##### Deploy environments -##### Application - -| name | description | default | example | -| --- | --- | --- | --- | -| `PORT` | port to listen on | 3000 | -| `HOST_PATHNAME` | pathname to listen on, restrictions: leading slash, no trailing slash | `''` | `/viewer` | -| `SESSIONSECRET` | session secret for cookie session | -| `NODE_ENV` | determines where the built viewer will be served from | | `production` | -| `PRECOMPUTED_SERVER` | redirect data uri to another server. Useful for offline demos | | `http://localhost:8080/precomputed/` | -| `LOCAL_CDN` | rewrite cdns to local server. useful for offlnie demo | | `http://localhost:7080/` | -| `PLUGIN_URLS` | semi colon separated urls to be returned when user queries plugins | `''` -| `STAGING_PLUGIN_URLS` | semi colon separated urls to be returned when user queries plugins | `''` -| `USE_LOGO` | possible values are `hbp`, `ebrains`, `fzj` | `hbp` | `ebrains` | - -##### ebrains user authentication - -| name | description | default | example | -| --- | --- | --- | --- | -| `HOSTNAME` | -| `HBP_CLIENTID` | `{HOSTNAME}{HOST_PATHNAME}/hbp-oidc/cb` | -| `HBP_CLIENTSECRET` | -| `HBP_CLIENTID_V2` | `{HOSTNAME}{HOST_PATHNAME}/hbp-oidc-v2/cb` -| `HBP_CLIENTSECRET_V2` | - -##### Querying ebrains knowledge graph - -| name | description | default | example | -| --- | --- | --- | --- | -| `REFRESH_TOKEN` | -| `ACCESS_TOKEN` | **nb** as access tokens are usually short lived, this should only be set for development purposes -| `CACHE_DATASET_FILENAME` | | `deploy/dataset/cachedKgDataset.json` | -| `KG_ROOT` | | `https://kg.humanbrainproject.eu/query` | -| `KG_SEARCH_VOCAB` | | `https://schema.hbp.eu/myQuery/` | -| `KG_DATASET_SEARCH_QUERY_NAME` | | `interactiveViewerKgQuery-v0_3` | -| `KG_DATASET_SEARCH_PATH` | | `/minds/core/dataset/v1.0.0` | -| `KG_SEARCH_SIZE` | | `1000` | -| `KG_SPATIAL_DATASET_SEARCH_QUERY_NAME` | | `iav-spatial-query-v2` | -| `KG_SPATIAL_DATASET_SEARCH_PATH` | | `/neuroglancer/seeg/coordinate/v1.0.0` | - -##### Logging - -| name | description | default | example | -| --- | --- | --- | --- | -| `FLUENT_PROTOCOL` | protocol for fluent logging | `http` | -| `FLUENT_HOST` | host for fluent logging | `localhost` | -| `FLUENT_PORT` | port for fluent logging | 24224 | -| `IAV_NAME` | application name to be logged | `IAV` | -| `IAV_STAGE` | deploy of the application | `unnamed-stage` | - -##### CSP - -| name | description | default | example | -| --- | --- | --- | --- | -| `DISABLE_CSP` | disable csp | | `true` | -| `CSP_REPORT_URI` | report uri for csp violations | `/report-violation` | -| `NODE_ENV` | set to `production` to disable [`reportOnly`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only) | `null` | -| `SCRIPT_SRC` | `JSON.stringify`'ed array of allowed scriptSrc | `[]` | -| `CSP_CONNECT_SRC` | `JSON.stringify`'ed array of allowed dataSrc | `[]` | -| `WHITE_LIST_SRC` | `JSON.stringify`'ed array of allowed src | `[]` | -| `PROXY_HOSTNAME_WHITELIST` | - -##### Rate limiting - -| name | description | default | example | -| --- | --- | --- | --- | -| `REDIS_PROTO` | fall back to `REDIS_RATE_LIMITING_DB_EPHEMERAL_PORT_6379_TCP_PROTO` | -| `REDIS_ADDR` | fall back to `REDIS_RATE_LIMITING_DB_EPHEMERAL_PORT_6379_TCP_ADDR` | -| `REDIS_PORT` | fall back to `REDIS_RATE_LIMITING_DB_EPHEMERAL_PORT_6379_TCP_PORT` | -| `REDIS_USERNAME` | -| `REDIS_PASSWORD` | -| `DISABLE_LIMITER` | disable rate limiting (maybe required for automated tests) | - -##### SaneUrl - -| name | description | default | example | -| --- | --- | --- | --- | -| `OBJ_STORAGE_AUTH_URL` | -| `OBJ_STORAGE_IDP_NAME` | -| `OBJ_STORAGE_IDP_PROTO` | -| `OBJ_STORAGE_IDP_URL` | -| `OBJ_STORAGE_USERNAME` | -| `OBJ_STORAGE_PASSWORD` | -| `OBJ_STORAGE_PROJECT_ID` | -| `OBJ_STORAGE_ROOT_URL` | - -##### Test deploy denvironments - -| name | description | default | example | -| --- | --- | --- | --- | -| `SERVICE_ACCOUNT_CRED` | -| `SERVICE_ACCOUNT_CRED_PATH` | -| `WAXHOLM_RAT_GOOGLE_SHEET_ID` | -| `SKIP_RETRY_TEST` | retry tests contains some timeouts, which may slow down tests | +Please see [deploy_env.md](deploy_env.md) ##### e2e test environments -| name | description | default | example | -| --- | --- | --- | --- | -| PROTRACTOR_SPECS | specs relative to `./e2e/` | `./src/**/*.prod.e2e-spec.js` | | -| DISABLE_CHROME_HEADLESS | disable headless chrome, spawns chrome window | `unset` (falsy) | 1 | -| ENABLE_GPU | uses GPU. nb, in headless mode, will show requirement not met | `unset` (falsy) | 1 | +Please see [e2e_env.md](e2e_env.md) #### Start dev server diff --git a/build_env.md b/build_env.md new file mode 100644 index 0000000000000000000000000000000000000000..27ff68e52725910cb9144da5711f4df8d74e6f64 --- /dev/null +++ b/build_env.md @@ -0,0 +1,16 @@ +# Build-time environment variables + +As interactive atlas viewer uses [webpack define plugin](https://webpack.js.org/plugins/define-plugin/), where necessary, the environmental variables are `JSON.stringify`'ed and directly replaced in the code. + +| name | description | default | example | +| --- | --- | --- | --- | +| `VERSION` | printed in console on viewer startup | `GIT_HASH` \|\| unspecificed hash | v2.2.2 | +| `PRODUCTION` | if the build is for production, toggles optimisations such as minification | `undefined` | true | +| `BACKEND_URL` | backend that the viewer calls to fetch available template spaces, parcellations, plugins, datasets | `null` | https://interactive-viewer.apps.hbp.eu/ | +| `BS_REST_URL` | [brainscape-api](https://jugit.fz-juelich.de/v.marcenko/brainscapes-api) used to fetch different resources | https://brainscapes.apps-dev.hbp.eu/v1_0 | +| `DATASET_PREVIEW_URL` | dataset preview url used by component <https://github.com/fzj-inm1-bda/kg-dataset-previewer>. Useful for diagnosing issues with dataset previews.| https://hbp-kg-dataset-previewer.apps.hbp.eu/datasetPreview | http://localhost:1234/datasetPreview | +| `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` | +| `KIOSK_MODE` | after 5 minutes of inactivity, shows overlay inviting users to interact | `false` | `true` | +| `BUILD_TEXT` | overlay text at bottom right of the viewer. set to `''` to hide. | | \ No newline at end of file diff --git a/common/constants.js b/common/constants.js index 24e3792ad0611389e2c7342b1fa10d2ae0bd9961..97fcd1559c745356cb46e0739791bb79e5c5dc5c 100644 --- a/common/constants.js +++ b/common/constants.js @@ -79,4 +79,16 @@ RECEPTOR_PR_CAPTION: `For a single tissue sample, an exemplary density distribution for a single receptor from the pial surface to the border between layer VI and the white matter.`, RECEPTOR_AR_CAPTION: `An exemplary density distribution of a single receptor for one laminar cross-section in a single tissue sample.`, } + + exports.QUICKTOUR_DESC ={ + REGION_SEARCH: `Use the region quick search for finding, selecting and navigating brain regions in the selected parcellation map.`, + ATLAS_SELECTOR: `This is the atlas selector. Click here to choose between EBRAINS reference atlases of different species.`, + CHIPS: `These "chips" indicate the currently selected parcellation map as well as selected region. Click the chip to see different versions, if any. Click (i) to read more about a selected item. Click (x) to clear a selection.`, + SLICE_VIEW: `The planar views allow you to zoom in to full resolution (mouse wheel), pan the view (click+drag), and select oblique sections (shift+click+drag). You can double-click brain regions to select them.`, + PERSPECTIVE_VIEW: `The 3D view gives an overview of the brain with limited resolution. It can be independently rotated. Click the „eye“ icon on the bottom left to toggle pure surface view.`, + VIEW_ICONS: `Use these icons in any of the views to maximize it and zoom in/out.`, + TOP_MENU: `These icons provide access to plugins, pinned datasets, and user documentation. Use the profile icon to login with your EBRAINS account.`, + LAYER_SELECTOR: `This is the atlas layer browser. If an atlas supports multiple template spaces or parcellation maps, you will find them here.`, + STATUS_CARD: `This is the coordinate navigator. Expand it to manipulate voxel and physical coordinates, to reset the view, or to create persistent links to the current view for sharing.`, + } })(typeof exports === 'undefined' ? module.exports : exports) diff --git a/deploy/csp/index.js b/deploy/csp/index.js index 23deb8495200643408dcd388732f22c0144947f9..928402e53ace8cec953238727a4eb45dd6ce8b5e 100644 --- a/deploy/csp/index.js +++ b/deploy/csp/index.js @@ -120,7 +120,7 @@ module.exports = (app) => { 'unpkg.com/react@16/umd/', // plugin load external lib -> react 'unpkg.com/kg-dataset-previewer@1.2.0/', // preview component 'cdnjs.cloudflare.com/ajax/libs/mathjax/', // math jax - 'https://unpkg.com/three-surfer@0.0.5/dist/bundle.js', // for threeSurfer (freesurfer support in browser) + 'https://unpkg.com/three-surfer@0.0.8/dist/bundle.js', // for threeSurfer (freesurfer support in browser) (req, res) => res.locals.nonce ? `'nonce-${res.locals.nonce}'` : null, ...SCRIPT_SRC, ...WHITE_LIST_SRC, diff --git a/deploy/datasets/query.js b/deploy/datasets/query.js index 1b4ce0d4c5d13479e6f59a05df634fa741b403d8..500bc6867925210e3509905dbeed3595f76f26d3 100644 --- a/deploy/datasets/query.js +++ b/deploy/datasets/query.js @@ -7,29 +7,11 @@ const { getPreviewFile, hasPreview } = require('./supplements/previewFile') const { constants, init: kgQueryUtilInit, getUserKGRequestParam, filterDatasets, filterDatasetsByRegion } = require('./util') const ibc = require('./importIBS') const { returnAdditionalDatasets } = require('../regionalFeatures') +const { store } = require('../lruStore') -let cachedData = null - -const CACHE_DATASET_FILENAME = process.env.CACHE_DATASET_FILENAME || path.join(__dirname, 'cachedKgDataset.json') - -fs.readFile(CACHE_DATASET_FILENAME, 'utf-8', (err, data) => { - /** - * the file may or may not be present on init - */ - if (err) { - console.warn(`read cache failed`, err) - return - } - - try { - cachedData = JSON.parse(data) - }catch (e) { - /** - * parsing saved cached json error - */ - console.error(e) - } -}) +const IAV_DS_CACHE_KEY = 'IAV_DS_CACHE_KEY' +const IAV_DS_TIMESTAMP_KEY = 'IAV_DS_TIMESTAMP_KEY' +const IAV_DS_REFRESH_TIMESTAMP_KEY = 'IAV_DS_REFRESH_TIMESTAMP_KEY' const { KG_ROOT, KG_SEARCH_VOCAB } = constants @@ -67,50 +49,46 @@ const fetchDatasetFromKg = async ({ user } = {}) => { (err, resp, body) => { if (err) return reject(err) if (resp.statusCode >= 400) return reject(resp.statusCode) - try { - const json = JSON.parse(body) - return resolve(json) - }catch (e) { - console.warn(`parsing json obj error`, body) - reject(e) - } + resolve(body) }) }) } -const cacheData = ({ results, ...rest }) => { - cachedData = results - otherQueryResult = rest - fs.writeFile(CACHE_DATASET_FILENAME, JSON.stringify(results), (err) => { - if (err) console.error('writing cached data fail') - }) - return cachedData +const refreshCache = async () => { + store.set(IAV_DS_REFRESH_TIMESTAMP_KEY, new Date().toString()) + const text = await fetchDatasetFromKg() + await store.set(IAV_DS_CACHE_KEY, text) + await store.set(IAV_DS_REFRESH_TIMESTAMP_KEY, null) + await store.set(IAV_DS_TIMESTAMP_KEY, new Date().toString()) } -let fetchingPublicDataInProgress = false -let getPublicDsPr - const getPublicDs = async () => { console.log(`fetching public ds ...`) - /** - * every request to public ds will trigger a refresh pull from master KG (throttled pending on resolved request) - */ - if (!fetchingPublicDataInProgress) { - fetchingPublicDataInProgress = true - getPublicDsPr = fetchDatasetFromKg() - .then(_ => { - console.log(`public ds fetched!`) - fetchingPublicDataInProgress = false - getPublicDsPr = null - return _ - }) - .then(cacheData) + let cachedData = await store.get(IAV_DS_CACHE_KEY) + if (!cachedData) { + await refreshCache() + cachedData = await store.get(IAV_DS_CACHE_KEY) } - if (cachedData) return Promise.resolve(cachedData) - if (getPublicDsPr) return getPublicDsPr - throw `cached Data not yet resolved, neither is get public ds defined` + const timestamp = await store.get(IAV_DS_TIMESTAMP_KEY) + const refreshTimestamp = await store.get(IAV_DS_REFRESH_TIMESTAMP_KEY) + + if ( + new Date() - new Date(timestamp) > 1e3 * 60 * 30 + ) { + if ( + !refreshTimestamp || + new Date() - new Date(refreshTimestamp) > 1e3 * 60 * 5 + ) { + refreshCache() + } + } + if (cachedData) { + const { results } = JSON.parse(cachedData) + return Promise.resolve(results) + } + throw new Error(`cacheData not defined!`) } /** @@ -118,7 +96,9 @@ const getPublicDs = async () => { * getting individual ds is too slow */ const getDs = ({ user }) => (false && user - ? fetchDatasetFromKg({ user }).then(({ results }) => results) + ? fetchDatasetFromKg({ user }) + .then(text => JSON.parse(text)) + .then(({ results }) => results) : getPublicDs() ).then(async datasets => { diff --git a/deploy_env.md b/deploy_env.md new file mode 100644 index 0000000000000000000000000000000000000000..a2526621d19ebda48b735b15533f176592589f9d --- /dev/null +++ b/deploy_env.md @@ -0,0 +1,94 @@ +# Deploy Environment Variables + +##### Application + +| name | description | default | example | +| --- | --- | --- | --- | +| `PORT` | port to listen on | 3000 | +| `HOST_PATHNAME` | pathname to listen on, restrictions: leading slash, no trailing slash | `''` | `/viewer` | +| `SESSIONSECRET` | session secret for cookie session | +| `NODE_ENV` | determines where the built viewer will be served from | | `production` | +| `PRECOMPUTED_SERVER` | redirect data uri to another server. Useful for offline demos | | `http://localhost:8080/precomputed/` | +| `LOCAL_CDN` | rewrite cdns to local server. useful for offlnie demo | | `http://localhost:7080/` | +| `PLUGIN_URLS` | semi colon separated urls to be returned when user queries plugins | `''` +| `STAGING_PLUGIN_URLS` | semi colon separated urls to be returned when user queries plugins | `''` +| `USE_LOGO` | possible values are `hbp`, `ebrains`, `fzj` | `hbp` | `ebrains` | + +##### ebrains user authentication + +| name | description | default | example | +| --- | --- | --- | --- | +| `HOSTNAME` | +| `HBP_CLIENTID` | `{HOSTNAME}{HOST_PATHNAME}/hbp-oidc/cb` | +| `HBP_CLIENTSECRET` | +| `HBP_CLIENTID_V2` | `{HOSTNAME}{HOST_PATHNAME}/hbp-oidc-v2/cb` +| `HBP_CLIENTSECRET_V2` | + +##### Querying ebrains knowledge graph + +| name | description | default | example | +| --- | --- | --- | --- | +| `REFRESH_TOKEN` | +| `ACCESS_TOKEN` | **nb** as access tokens are usually short lived, this should only be set for development purposes +| `KG_ROOT` | | `https://kg.humanbrainproject.eu/query` | +| `KG_SEARCH_VOCAB` | | `https://schema.hbp.eu/myQuery/` | +| `KG_DATASET_SEARCH_QUERY_NAME` | | `interactiveViewerKgQuery-v0_3` | +| `KG_DATASET_SEARCH_PATH` | | `/minds/core/dataset/v1.0.0` | +| `KG_SEARCH_SIZE` | | `1000` | +| `KG_SPATIAL_DATASET_SEARCH_QUERY_NAME` | | `iav-spatial-query-v2` | +| `KG_SPATIAL_DATASET_SEARCH_PATH` | | `/neuroglancer/seeg/coordinate/v1.0.0` | + +##### Logging + +| name | description | default | example | +| --- | --- | --- | --- | +| `FLUENT_PROTOCOL` | protocol for fluent logging | `http` | +| `FLUENT_HOST` | host for fluent logging | `localhost` | +| `FLUENT_PORT` | port for fluent logging | 24224 | +| `IAV_NAME` | application name to be logged | `IAV` | +| `IAV_STAGE` | deploy of the application | `unnamed-stage` | + +##### CSP + +| name | description | default | example | +| --- | --- | --- | --- | +| `DISABLE_CSP` | disable csp | | `true` | +| `CSP_REPORT_URI` | report uri for csp violations | `/report-violation` | +| `NODE_ENV` | set to `production` to disable [`reportOnly`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only) | `null` | +| `SCRIPT_SRC` | `JSON.stringify`'ed array of allowed scriptSrc | `[]` | +| `CSP_CONNECT_SRC` | `JSON.stringify`'ed array of allowed dataSrc | `[]` | +| `WHITE_LIST_SRC` | `JSON.stringify`'ed array of allowed src | `[]` | +| `PROXY_HOSTNAME_WHITELIST` | + +##### Rate limiting + +| name | description | default | example | +| --- | --- | --- | --- | +| `REDIS_PROTO` | fall back to `REDIS_RATE_LIMITING_DB_EPHEMERAL_PORT_6379_TCP_PROTO` | +| `REDIS_ADDR` | fall back to `REDIS_RATE_LIMITING_DB_EPHEMERAL_PORT_6379_TCP_ADDR` | +| `REDIS_PORT` | fall back to `REDIS_RATE_LIMITING_DB_EPHEMERAL_PORT_6379_TCP_PORT` | +| `REDIS_USERNAME` | +| `REDIS_PASSWORD` | +| `DISABLE_LIMITER` | disable rate limiting (maybe required for automated tests) | + +##### SaneUrl + +| name | description | default | example | +| --- | --- | --- | --- | +| `OBJ_STORAGE_AUTH_URL` | +| `OBJ_STORAGE_IDP_NAME` | +| `OBJ_STORAGE_IDP_PROTO` | +| `OBJ_STORAGE_IDP_URL` | +| `OBJ_STORAGE_USERNAME` | +| `OBJ_STORAGE_PASSWORD` | +| `OBJ_STORAGE_PROJECT_ID` | +| `OBJ_STORAGE_ROOT_URL` | + +##### Test deploy denvironments + +| name | description | default | example | +| --- | --- | --- | --- | +| `SERVICE_ACCOUNT_CRED` | +| `SERVICE_ACCOUNT_CRED_PATH` | +| `WAXHOLM_RAT_GOOGLE_SHEET_ID` | +| `SKIP_RETRY_TEST` | retry tests contains some timeouts, which may slow down tests | diff --git a/docs/releases/v2.4.0.md b/docs/releases/v2.4.0.md index 8bae0f274f49d9690cc13820501a925945794a43..4204e56f644b676abcb5295e08bf152af6241b73 100644 --- a/docs/releases/v2.4.0.md +++ b/docs/releases/v2.4.0.md @@ -5,6 +5,8 @@ - fixes UI issue, where chips wraps on smaller screen (#740) - regions with the same labelIndex without colour will no longer have the same colour (#750) - fixes gpuLimit not applying properly (#765) +- fixes minor issue when switching back to multi version parc (#831) +- fixes chips wrapping and scrolling on smaller devices (#720) ## New features @@ -13,6 +15,7 @@ - Preliminary support for displaying of SWC (#907) - Preliminary support for freesurfer (#900) - Allow for finer controls over meshes display (#883, #470) +- Added `quick tour` feature (#899) ## Under the hood stuff diff --git a/e2e/protractor.conf.js b/e2e/protractor.conf.js index 44b20685fae5e095094e36b6482e52f6f4890333..500dbdf9f082055cd82cbdeecd0a2d33c1370a7a 100644 --- a/e2e/protractor.conf.js +++ b/e2e/protractor.conf.js @@ -42,7 +42,7 @@ const localConfig = { // polyfill for node10 or lower if (typeof globalThis === 'undefined') global.globalThis = {} jasmine.getEnv().addReporter({ - specDone: async ({ status, id, fullName, ...rest }) => { + specDone: async ({ status, id, message, fullName, ...rest }) => { if (status === 'failed') { console.log(`spec failed, taking screenshot`) const b64 = await globalThis.IAVBase.takeScreenshot() @@ -55,7 +55,7 @@ const localConfig = { ) await asyncWrite( path.join(dir, `${id}.txt`), - JSON.stringify({ id, status, fullName }, null, 2), + JSON.stringify({ id, status, message, fullName }, null, 2), 'utf-8' ) } diff --git a/e2e/src/advanced/urlParsing.prod.e2e-spec.js b/e2e/src/advanced/urlParsing.prod.e2e-spec.js index 9158ecb3f134271d4600843e1fa57a0047366318..c761e47e3e8c84dcbb34a03bf5f01916490359a7 100644 --- a/e2e/src/advanced/urlParsing.prod.e2e-spec.js +++ b/e2e/src/advanced/urlParsing.prod.e2e-spec.js @@ -1,6 +1,4 @@ const { AtlasPage } = require("../util") -const proxy = require('selenium-webdriver/proxy') -const { ARIA_LABELS } = require('../../../common/constants') describe('> url parsing', () => { let iavPage @@ -77,12 +75,19 @@ describe('> url parsing', () => { await iavPage.wait(5000) await iavPage.waitForAsync() const log = await iavPage.getLog() - const filteredLog = log.filter(({ message }) => !/Access-Control-Allow-Origin/.test(message)) + const filteredLog = log + // in headless & non gpu mode, a lot of webgl warnings are thrown + .filter(({ level }) => level.toString() === 'SEVERE') + .filter(({ message }) => !/Access-Control-Allow-Origin/.test(message)) // expecting some errors in the console. In catastrophic event, there will most likely be looped errors (on each render cycle) + // capture logs and write to spec https://stackoverflow.com/a/24980483/6059235 expect( filteredLog.length - ).toBeLessThan(50) + ).toBeLessThan( + 50, + JSON.stringify(filteredLog) + ) }) it('> if niftiLayers are defined, parcellation layer should be hidden', async () => { diff --git a/e2e_env.md b/e2e_env.md new file mode 100644 index 0000000000000000000000000000000000000000..0323b6a621a52a11aa6e2b4783d7452ce748b0da --- /dev/null +++ b/e2e_env.md @@ -0,0 +1,7 @@ +# End-to-end Tests Environment Variables + +| name | description | default | example | +| --- | --- | --- | --- | +| PROTRACTOR_SPECS | specs relative to `./e2e/` | `./src/**/*.prod.e2e-spec.js` | | +| DISABLE_CHROME_HEADLESS | disable headless chrome, spawns chrome window | `unset` (falsy) | 1 | +| ENABLE_GPU | uses GPU. nb, in headless mode, will show requirement not met | `unset` (falsy) | 1 | diff --git a/package.json b/package.json index 7fd591c0f83d2e6391a39f258cebb462ac07fca5..142c217a90860f13d7adff252947760e658af95a 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "webpack": "^4.41.2", "webpack-cli": "^3.3.2", "webpack-closure-compiler": "^2.1.6", - "webpack-dev-server": "^3.10.3", + "webpack-dev-server": "^3.11.2", "webpack-merge": "^4.1.2" }, "dependencies": { @@ -80,7 +80,7 @@ "@ngrx/effects": "^9.1.1", "@ngrx/store": "^9.1.1", "@types/node": "12.12.39", - "export-nehuba": "0.0.9", + "export-nehuba": "0.0.12", "hbp-connectivity-component": "^0.3.18", "zone.js": "^0.10.2" } diff --git a/src/atlasComponents/parcellation/regionSearch/regionSearch.component.ts b/src/atlasComponents/parcellation/regionSearch/regionSearch.component.ts index 87a600839de215e162a3baf9dd6ba82d7446c8eb..b3db031c4416658fed8c1de4d7936607f3f5e74e 100644 --- a/src/atlasComponents/parcellation/regionSearch/regionSearch.component.ts +++ b/src/atlasComponents/parcellation/regionSearch/regionSearch.component.ts @@ -4,7 +4,7 @@ import { select, Store } from "@ngrx/store"; import { combineLatest, Observable, Subject, merge } from "rxjs"; import { debounceTime, distinctUntilChanged, filter, map, shareReplay, startWith, take, tap, withLatestFrom } from "rxjs/operators"; import { VIEWER_STATE_ACTION_TYPES } from "src/services/effect/effect"; -import { ADD_TO_REGIONS_SELECTION_WITH_IDS, CHANGE_NAVIGATION, SELECT_REGIONS } from "src/services/state/viewerState.store"; +import { CHANGE_NAVIGATION, SELECT_REGIONS } from "src/services/state/viewerState.store"; import { getMultiNgIdsRegionsLabelIndexMap } from "src/services/stateStore.service"; import { LoggingService } from "src/logging"; import { MatDialog } from "@angular/material/dialog"; @@ -13,6 +13,7 @@ import { PureContantService } from "src/util"; import { viewerStateToggleRegionSelect, viewerStateNavigateToRegion, viewerStateSetSelectedRegions, viewerStateSetSelectedRegionsWithIds } from "src/services/state/viewerState.store.helper"; import { ARIA_LABELS, CONST } from 'common/constants' import { serialiseParcellationRegion } from "common/util" +import { actionAddToRegionsSelectionWithIds } from "src/services/state/viewerState/actions"; const filterRegionBasedOnText = searchTerm => region => `${region.name.toLowerCase()}${region.status? ' (' + region.status + ')' : null}`.includes(searchTerm.toLowerCase()) || (region.relatedAreas && region.relatedAreas.some(relatedArea => relatedArea.name && relatedArea.name.toLowerCase().includes(searchTerm.toLowerCase()))) @@ -140,10 +141,11 @@ export class RegionTextSearchAutocomplete { deselecRegionIds: [id], }) } else { - this.store$.dispatch({ - type: ADD_TO_REGIONS_SELECTION_WITH_IDS, - selectRegionIds : [id], - }) + this.store$.dispatch( + actionAddToRegionsSelectionWithIds({ + selectRegionIds : [id], + }) + ) } } diff --git a/src/atlasComponents/regionalFeatures/singleFeatures/iEEGRecordings/iEEGRecordings/iEEGRecordings.component.ts b/src/atlasComponents/regionalFeatures/singleFeatures/iEEGRecordings/iEEGRecordings/iEEGRecordings.component.ts index 072cbabf90ec1d731fe4a767dcd78ee76ba7d4b9..eb9e3c32e9d0ad71e1cd57c5ab1c33157eeae450 100644 --- a/src/atlasComponents/regionalFeatures/singleFeatures/iEEGRecordings/iEEGRecordings/iEEGRecordings.component.ts +++ b/src/atlasComponents/regionalFeatures/singleFeatures/iEEGRecordings/iEEGRecordings/iEEGRecordings.component.ts @@ -150,18 +150,18 @@ export class IEEGRecordingsCmp extends RegionFeatureBase implements ISingleFeatu }, []) ) - private clickIntp(ev: any, next: Function) { + private clickIntp(ev: any): boolean { let hoveredLandmark = null this.regionFeatureService.onHoverLandmarks$.pipe( take(1) ).subscribe(val => { hoveredLandmark = val }) - if (!hoveredLandmark) return next() + if (!hoveredLandmark) return true const isOne = this.landmarksLoaded.some(lm => { return lm['_']['electrodeId'] === hoveredLandmark['_']['electrodeId'] }) - if (!isOne) return next() + if (!isOne) return true this.exploreElectrode$.next(hoveredLandmark['_']['electrodeId']) } } diff --git a/src/atlasComponents/uiSelectors/atlasLayerSelector/atlasLayerSelector.component.ts b/src/atlasComponents/uiSelectors/atlasLayerSelector/atlasLayerSelector.component.ts index 046aa3448b8566a3c9fcd0179560e5d6aa1908ef..c85dff73d22f01af6888cb6a451b40ed46e12af0 100644 --- a/src/atlasComponents/uiSelectors/atlasLayerSelector/atlasLayerSelector.component.ts +++ b/src/atlasComponents/uiSelectors/atlasLayerSelector/atlasLayerSelector.component.ts @@ -1,17 +1,39 @@ -import { Component, OnInit, ViewChildren, QueryList, HostBinding } from "@angular/core"; +import { Component, OnInit, ViewChildren, QueryList, HostBinding, ViewChild, TemplateRef, ElementRef } from "@angular/core"; import { select, Store } from "@ngrx/store"; import { distinctUntilChanged, map, withLatestFrom, shareReplay, groupBy, mergeMap, toArray, switchMap, scan, filter, startWith } from "rxjs/operators"; import { Observable, Subscription, from, zip, of, combineLatest } from "rxjs"; import { viewerStateSelectTemplateWithId, viewerStateToggleLayer } from "src/services/state/viewerState.store.helper"; import { MatMenuTrigger } from "@angular/material/menu"; import { viewerStateGetSelectedAtlas, viewerStateAtlasLatestParcellationSelector, viewerStateSelectedTemplateFullInfoSelector, viewerStateSelectedTemplatePureSelector, viewerStateSelectedParcellationSelector } from "src/services/state/viewerState/selectors"; -import { ARIA_LABELS } from 'common/constants' +import { ARIA_LABELS, QUICKTOUR_DESC } from 'common/constants' +import { IQuickTourData } from "src/ui/quickTour/constrants"; +import { animate, state, style, transition, trigger } from "@angular/animations"; @Component({ selector: 'atlas-layer-selector', templateUrl: './atlasLayerSelector.template.html', styleUrls: ['./atlasLayerSelector.style.css'], - exportAs: 'atlasLayerSelector' + exportAs: 'atlasLayerSelector', + animations: [ + trigger('toggleAtlasLayerSelector', [ + state('false', style({ + transform: 'scale(0)', + opacity: 0, + transformOrigin: '0% 100%' + })), + state('true', style({ + transform: 'scale(1)', + opacity: 1, + transformOrigin: '0% 100%' + })), + transition('false => true', [ + animate('200ms cubic-bezier(0.35, 0, 0.25, 1)') + ]), + transition('true => false', [ + animate('200ms cubic-bezier(0.35, 0, 0.25, 1)') + ]) + ]) + ] }) export class AtlasLayerSelector implements OnInit { @@ -20,6 +42,9 @@ export class AtlasLayerSelector implements OnInit { @ViewChildren(MatMenuTrigger) matMenuTriggers: QueryList<MatMenuTrigger> public atlas: any + @ViewChild('selectorPanelTmpl', { read: ElementRef }) + selectorPanelTemplateRef: ElementRef + public selectedAtlas$: Observable<any> = this.store$.pipe( select(viewerStateGetSelectedAtlas), distinctUntilChanged(), @@ -48,7 +73,7 @@ export class AtlasLayerSelector implements OnInit { ) public nonGroupedLayers$: Observable<any[]> = this.atlasLayersLatest$.pipe( - map(allParcellations => + map(allParcellations => allParcellations .filter(p => !p['groupName']) .filter(p => !p['baseLayer']) @@ -57,7 +82,7 @@ export class AtlasLayerSelector implements OnInit { public groupedLayers$: Observable<any[]> = combineLatest([ this.atlasLayersLatest$.pipe( - map(allParcellations => + map(allParcellations => allParcellations.filter(p => !p['baseLayer']) ), ), @@ -90,6 +115,11 @@ export class AtlasLayerSelector implements OnInit { @HostBinding('attr.data-opened') public selectorExpanded: boolean = false public selectedTemplatePreviewUrl: string = '' + + public quickTourData: IQuickTourData = { + order: 4, + description: QUICKTOUR_DESC.LAYER_SELECTOR, + } public availableTemplates$ = this.store$.pipe<any[]>( select(viewerStateSelectedTemplateFullInfoSelector) @@ -144,6 +174,10 @@ export class AtlasLayerSelector implements OnInit { ) } + toggleSelector() { + this.selectorExpanded = !this.selectorExpanded + } + selectTemplateWithName(template) { this.store$.dispatch( viewerStateSelectTemplateWithId({ payload: template }) @@ -204,7 +238,7 @@ export class AtlasLayerSelector implements OnInit { if (this.templateIncludesGroup(layer)) return layer.name else return `${layer.name} 🔄` } - + return layer.name } diff --git a/src/atlasComponents/uiSelectors/atlasLayerSelector/atlasLayerSelector.style.css b/src/atlasComponents/uiSelectors/atlasLayerSelector/atlasLayerSelector.style.css index 4e20ddc781223acdeb53989f89b946ed306d8194..9d43dc845dcf18755d5326699abaf0ce4139011b 100644 --- a/src/atlasComponents/uiSelectors/atlasLayerSelector/atlasLayerSelector.style.css +++ b/src/atlasComponents/uiSelectors/atlasLayerSelector/atlasLayerSelector.style.css @@ -1,7 +1,5 @@ .singleLayerImageContainer img { flex-shrink: 0; - /*min-width: 100%;*/ - /*min-height: 100%;*/ width: 70px; height: 70px; border-radius: 10px; @@ -26,81 +24,6 @@ border: 2px solid #FED363; } -.scale-up-bl { - -webkit-animation: scale-up-bl .2s cubic-bezier(.39, .575, .565, 1.000) both; - animation: scale-up-bl .2s cubic-bezier(.39, .575, .565, 1.000) both; -} - -@-webkit-keyframes scale-up-bl { - 0% { - -webkit-transform: scale(.5); - transform: scale(.5); - -webkit-transform-origin: 0 100%; - transform-origin: 0 100%; - } - 100% { - -webkit-transform: scale(1); - transform: scale(1); - -webkit-transform-origin: 0 100%; - transform-origin: 0 100%; - } -} - -@keyframes scale-up-bl { - 0% { - -webkit-transform: scale(.5); - transform: scale(.5); - -webkit-transform-origin: 0 100%; - transform-origin: 0 100%; - } - 100% { - -webkit-transform: scale(1); - transform: scale(1); - -webkit-transform-origin: 0 100%; - transform-origin: 0 100%; - } -} - - -.scale-down-bl { - -webkit-animation: scale-down-bl .2s cubic-bezier(.25, .46, .45, .94) both; - animation: scale-down-bl .2s cubic-bezier(.25, .46, .45, .94) both; -} - -@-webkit-keyframes scale-down-bl { - 0% { - -webkit-transform: scale(1); - transform: scale(1); - -webkit-transform-origin: 0 100%; - transform-origin: 0 100%; - opacity: 1; - } - 100% { - -webkit-transform: scale(.5); - transform: scale(.5); - -webkit-transform-origin: 0 100%; - transform-origin: 0 100%; - opacity: 0; - } -} - -@keyframes scale-down-bl { - 0% { - -webkit-transform: scale(1); - transform: scale(1); - -webkit-transform-origin: 0 100%; - transform-origin: 0 100%; - opacity: 1; - } - 100% { - -webkit-transform: scale(.5); - transform: scale(.5); - -webkit-transform-origin: 0 100%; - transform-origin: 0 100%; - opacity: 0; - } -} - .folder-container { margin-right:0.5rem; diff --git a/src/atlasComponents/uiSelectors/atlasLayerSelector/atlasLayerSelector.template.html b/src/atlasComponents/uiSelectors/atlasLayerSelector/atlasLayerSelector.template.html index 350ed7322fe270dbe4a56c140693d99685954b30..c6e4db70b3c81bb994a7290d78f486df3287c16b 100644 --- a/src/atlasComponents/uiSelectors/atlasLayerSelector/atlasLayerSelector.template.html +++ b/src/atlasComponents/uiSelectors/atlasLayerSelector/atlasLayerSelector.template.html @@ -2,7 +2,11 @@ <!-- selector panel when expanded --> - <mat-card class="selector-container position-absolute" [ngClass]="selectorExpanded ? 'scale-up-bl pe-all' : 'scale-down-bl pe-none'"> + <mat-card class="selector-container position-absolute" + [ngClass]="{'pe-all': selectorExpanded}" + [@toggleAtlasLayerSelector]="selectorExpanded" + (@toggleAtlasLayerSelector.done)="atlasSelectorTour?.attachTo(selectorExpanded ? selectorPanelTemplateRef : null)" + #selectorPanelTmpl> <mat-card-content> <!-- templates --> @@ -63,13 +67,17 @@ </mat-card> <!-- place holder when not expanded --> - <div class="position-relative m-2 cursor-pointer scale-up-bl pe-all"> + <div class="position-relative m-2 cursor-pointer scale-up-bl pe-all" + quick-tour + [quick-tour-description]="quickTourData.description" + [quick-tour-order]="quickTourData.order" + #atlasSelectorTour="quickTour"> <button color="primary" - matTooltip="Select layer" - mat-mini-fab - *ngIf="shouldShowRenderPlaceHolder$ | async" - [attr.aria-label]="TOGGLE_ATLAS_LAYER_SELECTOR" - (click)="selectorExpanded = !selectorExpanded"> + matTooltip="Select layer" + mat-mini-fab + *ngIf="shouldShowRenderPlaceHolder$ | async" + [attr.aria-label]="TOGGLE_ATLAS_LAYER_SELECTOR" + (click)="toggleSelector()"> <i class="fas fa-layer-group"></i> </button> </div> diff --git a/src/atlasComponents/uiSelectors/module.ts b/src/atlasComponents/uiSelectors/module.ts index 034f9445353f4e43e0a2f7e8e1b62055eccdb419..96ee9a085d82d4d04e0f8bc4c85567a982b6ef0a 100644 --- a/src/atlasComponents/uiSelectors/module.ts +++ b/src/atlasComponents/uiSelectors/module.ts @@ -5,6 +5,7 @@ import { UtilModule } from "src/util"; import { DatabrowserModule } from "src/atlasComponents/databrowserModule"; import { AtlasDropdownSelector } from "./atlasDropdown/atlasDropdown.component"; import { AtlasLayerSelector } from "./atlasLayerSelector/atlasLayerSelector.component"; +import {QuickTourModule} from "src/ui/quickTour/module"; @NgModule({ imports: [ @@ -12,6 +13,7 @@ import { AtlasLayerSelector } from "./atlasLayerSelector/atlasLayerSelector.comp AngularMaterialModule, UtilModule, DatabrowserModule, + QuickTourModule ], declarations: [ AtlasDropdownSelector, @@ -23,4 +25,4 @@ import { AtlasLayerSelector } from "./atlasLayerSelector/atlasLayerSelector.comp ] }) -export class AtlasCmpUiSelectorsModule{} \ No newline at end of file +export class AtlasCmpUiSelectorsModule{} diff --git a/src/atlasComponents/userAnnotations/editAnnotation/editAnnotation.component.ts b/src/atlasComponents/userAnnotations/editAnnotation/editAnnotation.component.ts index 67f1bdf8d0055d37fc1cd26f754bc7c3d35c7e1b..f797b1e85f591d66d3d9f55803f116d4e258c0db 100644 --- a/src/atlasComponents/userAnnotations/editAnnotation/editAnnotation.component.ts +++ b/src/atlasComponents/userAnnotations/editAnnotation/editAnnotation.component.ts @@ -77,13 +77,14 @@ export class EditAnnotationComponent implements OnInit, OnDestroy { return (window as any).interactiveViewer } - private viewer: any + private get viewer(){ + return (window as any).viewer + } constructor( private formBuilder: FormBuilder, private changeDetectionRef: ChangeDetectorRef, private store: Store<any>, - @Optional() @Inject(NEHUBA_INSTANCE_INJTKN) nehuba$: Observable<any> ) { this.annotationForm = this.formBuilder.group({ id: [{value: null, disabled: true}], @@ -99,10 +100,6 @@ export class EditAnnotationComponent implements OnInit, OnDestroy { type: [{value: 'point'}], annotationVisible: [true] }) - - this.subscriptions.push( - nehuba$.subscribe(v => this.viewer = v) - ) } ngOnInit() { diff --git a/src/atlasComponents/userAnnotations/userAnnotationsCmp/userAnnotationsCmp.components.ts b/src/atlasComponents/userAnnotations/userAnnotationsCmp/userAnnotationsCmp.components.ts index 3960a64d5379fa3045523ce2c9e013a817a4001e..9e19c5f2d6d0666575c9276543e480d8f233512d 100644 --- a/src/atlasComponents/userAnnotations/userAnnotationsCmp/userAnnotationsCmp.components.ts +++ b/src/atlasComponents/userAnnotations/userAnnotationsCmp/userAnnotationsCmp.components.ts @@ -1,9 +1,16 @@ -import { Component, EventEmitter, Inject, OnDestroy, OnInit, Optional, Output} from "@angular/core"; +import { Component, EventEmitter, OnDestroy, OnInit, Output} from "@angular/core"; import { Observable, Subscription } from "rxjs"; -import { NEHUBA_INSTANCE_INJTKN } from "src/viewerModule/nehuba/util"; +import { distinctUntilChanged } from "rxjs/operators"; import { getUuid } from 'src/util/fn' const USER_ANNOTATION_LAYER_NAME = 'USER_ANNOTATION_LAYER_NAME' +const USER_ANNOTATION_LAYER_SPEC = { + "type": "annotation", + "tool": "annotateBoundingBox", + "name": USER_ANNOTATION_LAYER_NAME, + "annotationColor": "#ffee00", + "annotations": [], +} const USER_ANNOTATION_STORE_KEY = `user_landmarks_demo_1` @Component({ @@ -23,27 +30,27 @@ export class UserAnnotationsComponent implements OnInit, OnDestroy { public expanded = -1 public annotations = [] + private hoverAnnotation$: Observable<{id: string, partIndex: number}> @Output() close: EventEmitter<any> = new EventEmitter() private subscription: Subscription[] = [] - constructor( - @Optional() @Inject(NEHUBA_INSTANCE_INJTKN) nehuba$: Observable<any> - ) { - if (nehuba$) { - this.subscription.push( - nehuba$.subscribe(v => this.viewer = v) - ) - } - } + private onDestroyCb: (() => void )[] = [] - private viewer: any + private get viewer(){ + return (window as any).viewer + } ngOnDestroy(): void { + while(this.onDestroyCb.length) this.onDestroyCb.pop()() + while(this.subscription.length) this.subscription.pop().unsubscribe() + + if (!this.viewer) { + throw new Error(`this.viewer is undefined`) + } const annotationLayer = this.viewer.layerManager.getLayerByName(USER_ANNOTATION_LAYER_NAME) if (annotationLayer) { - this.viewer?.layerManager.removeManagedLayer( - this.viewer.layerManager.getLayerByName(USER_ANNOTATION_LAYER_NAME)) + this.viewer.layerManager.removeManagedLayer(annotationLayer) } } @@ -58,15 +65,39 @@ export class UserAnnotationsComponent implements OnInit, OnDestroy { } public loadAnnotationLayer() { - return Object.keys(this.annotationLayerObj) - .filter(key => - /* if the layer exists, it will not be loaded */ - !this.viewer?.layerManager.getLayerByName(key)) - .map(key => { - this.viewer?.layerManager.addManagedLayer( - this.viewer.layerSpecification.getLayer(key, this.annotationLayerObj[key])) - return this.annotationLayerObj[key] + if (!this.viewer) { + throw new Error(`viewer is not initialised`) + } + + const layer = this.viewer.layerSpecification.getLayer( + USER_ANNOTATION_LAYER_NAME, + USER_ANNOTATION_LAYER_SPEC + ) + + const addedLayer = this.viewer.layerManager.addManagedLayer(layer) + + this.hoverAnnotation$ = new Observable<{id: string, partIndex: number}>(obs => { + const mouseState = this.viewer.mouseState + const cb: () => void = mouseState.changed.add(() => { + if (mouseState.active && mouseState.pickedAnnotationLayer === addedLayer.layer.annotationLayerState.value) { + obs.next({ + id: mouseState.pickedAnnotationId, + partIndex: mouseState.pickedOffset + }) + } else { + obs.next(null) + } + }) + this.onDestroyCb.push(() => { + cb() + obs.complete() }) + }).pipe( + distinctUntilChanged((o, n) => { + if (o === n) return true + return `${o?.id || ''}${o?.partIndex || ''}` === `${n?.id || ''}${n?.partIndex || ''}` + }) + ) } saveAnnotation(annotation) { @@ -170,12 +201,4 @@ export class UserAnnotationsComponent implements OnInit, OnDestroy { // }) // ) // } - - public annotationLayerObj = {"user_annotations": { - "type": "annotation", - "tool": "annotateBoundingBox", - "name": USER_ANNOTATION_LAYER_NAME, - "annotationColor": "#ffee00", - "annotations": [], - }} } diff --git a/src/atlasViewer/atlasViewer.apiService.service.ts b/src/atlasViewer/atlasViewer.apiService.service.ts index 5b3fe24f2717e0df81e7bb2951ac799165b17e1f..e7253b284cfa9a4ebba5c4f53d61e7247c89037d 100644 --- a/src/atlasViewer/atlasViewer.apiService.service.ts +++ b/src/atlasViewer/atlasViewer.apiService.service.ts @@ -83,7 +83,7 @@ export class AtlasViewerAPIServices implements OnDestroy{ private s: Subscription[] = [] - private onMouseClick(ev: any, next){ + private onMouseClick(ev: any): boolean{ const { rs, spec } = this.getNextUserRegionSelectHandler() || {} if (!!rs) { @@ -112,10 +112,11 @@ export class AtlasViewerAPIServices implements OnDestroy{ mousePositionReal = floatArr && Array.from(floatArr).map((val: number) => val / 1e6) }) } - return rs({ + rs({ type: spec.type, payload: mousePositionReal }) + return false } /** @@ -125,10 +126,11 @@ export class AtlasViewerAPIServices implements OnDestroy{ if (!!moSegments && Array.isArray(moSegments) && moSegments.length > 0) { this.popUserRegionSelectHandler() - return rs({ + rs({ type: spec.type, payload: moSegments }) + return false } } } else { @@ -138,11 +140,12 @@ export class AtlasViewerAPIServices implements OnDestroy{ */ if (!!moSegments && Array.isArray(moSegments) && moSegments.length > 0) { this.popUserRegionSelectHandler() - return rs(moSegments[0]) + rs(moSegments[0]) + return false } } } - next() + return true } constructor( diff --git a/src/atlasViewer/atlasViewer.component.ts b/src/atlasViewer/atlasViewer.component.ts index 7a2af6e9ed3a85bac69a58b7ae6422232377180e..346cc6c126115e9d5e4627d4d8ef2cef01e11498 100644 --- a/src/atlasViewer/atlasViewer.component.ts +++ b/src/atlasViewer/atlasViewer.component.ts @@ -329,8 +329,8 @@ export class AtlasViewer implements OnDestroy, OnInit, AfterViewInit { this.subscriptions.forEach(s => s.unsubscribe()) } - public mouseClickDocument(_event: MouseEvent) { - this.clickIntService.run(_event) + public mouseClickDocument(event: MouseEvent) { + this.clickIntService.callRegFns(event) } /** diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 3452a951549a93196fbed836a0915efa513601cc..504e7299ca852190d15e4218da246f132bbc6315 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -1,6 +1,7 @@ import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing" import { TestBed } from "@angular/core/testing" import { hot } from "jasmine-marbles" +import { PureContantService } from "src/util" import { AuthService } from "./auth.service" describe('>auth.service.ts', () => { @@ -11,7 +12,13 @@ describe('>auth.service.ts', () => { HttpClientTestingModule ], providers: [ - AuthService + AuthService, + { + provide: PureContantService, + useValue: { + backendUrl: `http://localhost:3000/` + } + } ] }) }) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 890fe89874e89f13217774198d4a9d2550a424fe..ae56dcc710806d93c05b0803cc42f0ac656accb1 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -2,6 +2,7 @@ import { HttpClient } from "@angular/common/http"; import { Injectable, OnDestroy } from "@angular/core"; import { Observable, of, Subscription } from "rxjs"; import { catchError, map, shareReplay } from "rxjs/operators"; +import { PureContantService } from "src/util"; import { BACKENDURL } from "src/util/constants"; const IV_REDIRECT_TOKEN = `IV_REDIRECT_TOKEN` @@ -38,8 +39,11 @@ export class AuthService implements OnDestroy { href: 'hbp-oidc-v2/auth' }] - constructor(private httpClient: HttpClient) { - this.user$ = this.httpClient.get<TUserRouteResp>(`${BACKENDURL}user`).pipe( + constructor( + private httpClient: HttpClient, + private constantSvc: PureContantService, + ) { + this.user$ = this.httpClient.get<TUserRouteResp>(`${this.constantSvc.backendUrl}user`).pipe( map(json => { if (json.error) { throw new Error(json.message || 'User not loggedin.') diff --git a/src/contextMenuModule/ctxMenuHost.directive.ts b/src/contextMenuModule/ctxMenuHost.directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..b403b359bceece402b9be5594825f8e15a872371 --- /dev/null +++ b/src/contextMenuModule/ctxMenuHost.directive.ts @@ -0,0 +1,30 @@ +import { AfterViewInit, Directive, HostListener, Input, OnDestroy, TemplateRef, ViewContainerRef } from "@angular/core"; +import { ContextMenuService } from "./service"; + +@Directive({ + selector: '[ctx-menu-host]' +}) + +export class CtxMenuHost implements OnDestroy, AfterViewInit{ + + @Input('ctx-menu-host-tmpl') + tmplRef: TemplateRef<any> + + @HostListener('contextmenu', ['$event']) + onClickListener(ev: MouseEvent){ + this.svc.showCtxMenu(ev, this.tmplRef) + } + + constructor( + private vcr: ViewContainerRef, + private svc: ContextMenuService + ){ + } + + ngAfterViewInit(){ + this.svc.vcr = this.vcr + } + ngOnDestroy(){ + this.svc.vcr = null + } +} diff --git a/src/contextMenuModule/dismissCtxMenu.directive.ts b/src/contextMenuModule/dismissCtxMenu.directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ee7e048fff9b6dd486c874dcf984770b687d63c --- /dev/null +++ b/src/contextMenuModule/dismissCtxMenu.directive.ts @@ -0,0 +1,19 @@ +import { Directive, HostListener } from "@angular/core"; +import { ContextMenuService } from "./service"; + +@Directive({ + selector: '[ctx-menu-dismiss]' +}) + +export class DismissCtxMenuDirective{ + @HostListener('click', ['$event']) + onClickListener(ev: MouseEvent) { + this.svc.dismissCtxMenu() + } + + constructor( + private svc: ContextMenuService + ){ + + } +} diff --git a/src/contextMenuModule/index.ts b/src/contextMenuModule/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b04167fec1094c0d95e4a7fc175f450444d3572 --- /dev/null +++ b/src/contextMenuModule/index.ts @@ -0,0 +1,3 @@ +export { ContextMenuModule } from './module' +export { ContextMenuService } from './service' +export { DismissCtxMenuDirective } from './dismissCtxMenu.directive' diff --git a/src/contextMenuModule/module.ts b/src/contextMenuModule/module.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3529c0b58e9099b46a6733ca3af91905f3d5538 --- /dev/null +++ b/src/contextMenuModule/module.ts @@ -0,0 +1,25 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module"; +import { CtxMenuHost } from "./ctxMenuHost.directive"; +import { DismissCtxMenuDirective } from "./dismissCtxMenu.directive"; + +@NgModule({ + imports: [ + AngularMaterialModule, + CommonModule, + ], + declarations: [ + DismissCtxMenuDirective, + CtxMenuHost, + ], + exports: [ + DismissCtxMenuDirective, + CtxMenuHost, + ], + providers: [ + + ] +}) + +export class ContextMenuModule{} diff --git a/src/contextMenuModule/service.ts b/src/contextMenuModule/service.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2dfbe63b54c48e94af6729d64943e4cdca0bbb6 --- /dev/null +++ b/src/contextMenuModule/service.ts @@ -0,0 +1,91 @@ +import { Overlay, OverlayRef } from "@angular/cdk/overlay" +import { TemplatePortal } from "@angular/cdk/portal" +import { Injectable, TemplateRef, ViewContainerRef } from "@angular/core" +import { ReplaySubject, Subject, Subscription } from "rxjs" +import { RegDeregController } from "src/util/regDereg.base" + +type TTmplRef = { + tmpl: TemplateRef<any> + data: any +} + +@Injectable({ + providedIn: 'root' +}) +export class ContextMenuService extends RegDeregController<unknown, { tmpl: TemplateRef<any>, data: any}>{ + + public vcr: ViewContainerRef + private overlayRef: OverlayRef + + private subs: Subscription[] = [] + + public context$ = new Subject() + public context: any + + public tmplRefs$ = new ReplaySubject<TTmplRef[]>(1) + public tmplRefs: TTmplRef[] = [] + + constructor( + private overlay: Overlay, + ){ + super() + this.subs.push( + this.context$.subscribe(v => this.context = v) + ) + } + + callRegFns(){ + const tmplRefs: TTmplRef[] = [] + for (const fn of this.callbacks){ + const resp = fn(this.context) + if (resp) { + const { tmpl, data } = resp + tmplRefs.push({ tmpl, data }) + } + } + this.tmplRefs = tmplRefs + this.tmplRefs$.next(tmplRefs) + } + + dismissCtxMenu(){ + if (this.overlayRef) { + this.overlayRef.dispose() + this.overlayRef = null + } + } + + showCtxMenu(ev: MouseEvent, tmplRef: TemplateRef<any>){ + if (!this.vcr) { + console.warn(`[ctx-menu-host] not attached to any component!`) + return + } + this.dismissCtxMenu() + this.callRegFns() + + const { x, y } = ev + const positionStrategy = this.overlay.position() + .flexibleConnectedTo({ x, y }) + .withPositions([ + { + originX: 'end', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + } + ]) + + this.overlayRef = this.overlay.create({ + positionStrategy, + }) + + this.overlayRef.attach( + new TemplatePortal( + tmplRef, + this.vcr, + { + tmplRefs: this.tmplRefs + } + ) + ) + } +} diff --git a/src/glue.spec.ts b/src/glue.spec.ts index 92466c534536e6df36120aa565b7355639cbf7da..79f82db2535f0ab4defa6c9ad669a7ea8beb34e7 100644 --- a/src/glue.spec.ts +++ b/src/glue.spec.ts @@ -1217,94 +1217,44 @@ describe('> glue.ts', () => { interceptorService = new ClickInterceptorService() }) - describe('> #addInterceptor', () => { - it('> adds interceptor fn', () => { - const fn = (ev: any, next: Function) => {} - interceptorService.addInterceptor(fn) - expect(interceptorService['clickInterceptorStack'].indexOf(fn)).toBeGreaterThanOrEqual(0) - }) - it('> when config not supplied, or last not present, will add fn to the first of the queue', () => { - - const dummy = (ev: any, next: Function) => {} - interceptorService.addInterceptor(dummy) - - const fn = (ev: any, next: Function) => {} - interceptorService.addInterceptor(fn) - expect(interceptorService['clickInterceptorStack'].indexOf(fn)).toEqual(0) - - const fn2 = (ev: any, next: Function) => {} - interceptorService.addInterceptor(fn2, {}) - expect(interceptorService['clickInterceptorStack'].indexOf(fn)).toEqual(1) - expect(interceptorService['clickInterceptorStack'].indexOf(fn2)).toEqual(0) - }) - it('> when last is supplied as a config param, will add the fn at the end', () => { - - const dummy = (ev: any, next: Function) => {} - interceptorService.addInterceptor(dummy) - - const fn = (ev: any, next: Function) => {} - interceptorService.addInterceptor(fn, { last: true }) - expect(interceptorService['clickInterceptorStack'].indexOf(fn)).toEqual(1) - - }) - }) - - describe('> deregister', () => { - it('> if the fn exist in the register, it will be removed', () => { - - const fn = (ev: any, next: Function) => {} - const fn2 = (ev: any, next: Function) => {} - interceptorService.addInterceptor(fn) - expect(interceptorService['clickInterceptorStack'].indexOf(fn)).toBeGreaterThanOrEqual(0) - expect(interceptorService['clickInterceptorStack'].length).toEqual(1) - - interceptorService.removeInterceptor(fn) - expect(interceptorService['clickInterceptorStack'].indexOf(fn)).toBeLessThan(0) - expect(interceptorService['clickInterceptorStack'].length).toEqual(0) + describe('> # callRegFns', () => { + let spy1: jasmine.Spy, + spy2: jasmine.Spy, + spy3: jasmine.Spy + beforeEach(() => { + spy1 = jasmine.createSpy('spy1') + spy2 = jasmine.createSpy('spy2') + spy3 = jasmine.createSpy('spy3') + interceptorService['callbacks'] = [ + spy1, + spy2, + spy3, + ] + spy1.and.returnValue(true) + spy2.and.returnValue(true) + spy3.and.returnValue(true) }) + it('> fns are all called', () => { - it('> if fn does not exist in register, it will not be removed', () => { - - const fn = (ev: any, next: Function) => {} - const fn2 = (ev: any, next: Function) => {} - interceptorService.addInterceptor(fn) - expect(interceptorService['clickInterceptorStack'].indexOf(fn)).toBeGreaterThanOrEqual(0) - expect(interceptorService['clickInterceptorStack'].length).toEqual(1) - - interceptorService.removeInterceptor(fn2) - expect(interceptorService['clickInterceptorStack'].indexOf(fn)).toBeGreaterThanOrEqual(0) - expect(interceptorService['clickInterceptorStack'].length).toEqual(1) + interceptorService.callRegFns('stuff') + expect(spy1).toHaveBeenCalled() + expect(spy2).toHaveBeenCalled() + expect(spy3).toHaveBeenCalled() }) - }) - - describe('> # run', () => { it('> will run fns from first idx to last idx', () => { - const callNext = (ev: any, next: Function) => next() - const fn = jasmine.createSpy().and.callFake(callNext) - const fn2 = jasmine.createSpy().and.callFake(callNext) - interceptorService.addInterceptor(fn) - interceptorService.addInterceptor(fn2) - interceptorService.run({}) - - expect(fn2).toHaveBeenCalledBefore(fn) + interceptorService.callRegFns('stuff') + expect(spy1).toHaveBeenCalledBefore(spy2) + expect(spy2).toHaveBeenCalledBefore(spy3) }) it('> will stop at when next is not called', () => { - const callNext = (ev: any, next: Function) => next() - const halt = (ev: any, next: Function) => {} - const fn = jasmine.createSpy().and.callFake(callNext) - const fn2 = jasmine.createSpy().and.callFake(halt) - const fn3 = jasmine.createSpy().and.callFake(callNext) - - interceptorService.addInterceptor(fn) - interceptorService.addInterceptor(fn2) - interceptorService.addInterceptor(fn3) - interceptorService.run({}) + spy2.and.returnValue(false) + interceptorService.callRegFns('stuff') - expect(fn3).toHaveBeenCalled() - expect(fn2).toHaveBeenCalled() - expect(fn).not.toHaveBeenCalled() + expect(spy1).toHaveBeenCalled() + expect(spy2).toHaveBeenCalled() + expect(spy3).not.toHaveBeenCalled() }) }) }) diff --git a/src/glue.ts b/src/glue.ts index 451e16b0968e76c0b5c0d0d677fc2c5cf012374e..a2c1652af870a4b992b9392b131da237447a15ef 100644 --- a/src/glue.ts +++ b/src/glue.ts @@ -18,7 +18,7 @@ import { viewerStateSelectedRegionsSelector, viewerStateSelectedTemplateSelector import { ngViewerSelectorClearView } from "./services/state/ngViewerState/selectors" import { ngViewerActionClearView } from './services/state/ngViewerState/actions' import { generalActionError } from "./services/stateStore.helper" -import { TClickInterceptorConfig } from "./util/injectionTokens" +import { RegDeregController } from "./util/regDereg.base" const PREVIEW_FILE_TYPES_NO_UI = [ EnumPreviewFileTypes.NIFTI, @@ -726,34 +726,13 @@ export const gluActionSetFavDataset = createAction( providedIn: 'root' }) -export class ClickInterceptorService{ - private clickInterceptorStack: Function[] = [] - - removeInterceptor(fn: Function) { - const idx = this.clickInterceptorStack.findIndex(int => int === fn) - if (idx < 0) { - console.warn(`clickInterceptorService could not remove the function. Did you pass the EXACT reference? - Anonymouse functions such as () => {} or .bind will create a new reference! - You may want to assign .bind to a ref, and pass it to register and unregister functions`) - } else { - this.clickInterceptorStack.splice(idx, 1) - } - } - addInterceptor(fn: Function, config?: TClickInterceptorConfig) { - if (config?.last) { - this.clickInterceptorStack.push(fn) - } else { - this.clickInterceptorStack.unshift(fn) - } - } +export class ClickInterceptorService extends RegDeregController<any, boolean>{ - run(ev: any){ + callRegFns(ev: any){ let intercepted = false - for (const clickInc of this.clickInterceptorStack) { - let runNext = false - clickInc(ev, () => { - runNext = true - }) + for (const clickInc of this.callbacks) { + + const runNext = clickInc(ev) if (!runNext) { intercepted = true break diff --git a/src/index.html b/src/index.html index a1df7bb2632c13eb82c0b1537111cd2c935fff8c..39aafb5fb94360cdc71dba34b69214dfa8e76fdb 100644 --- a/src/index.html +++ b/src/index.html @@ -11,10 +11,11 @@ <link rel="stylesheet" href="icons/iav-icons.css"> <link rel="stylesheet" href="theme.css"> <link rel="stylesheet" href="version.css"> + <link rel="icon" type="image/png" href="res/favicons/favicon-128-light.png"/> <script src="https://unpkg.com/kg-dataset-previewer@1.2.0/dist/kg-dataset-previewer/kg-dataset-previewer.js" defer> </script> - <script src="https://unpkg.com/three-surfer@0.0.5/dist/bundle.js" defer></script> + <script src="https://unpkg.com/three-surfer@0.0.8/dist/bundle.js" defer></script> <title>Interactive Atlas Viewer</title> <script type="application/ld+json"> diff --git a/src/main-common.ts b/src/main-common.ts index 6bab08d71f1a06e189d01db82b306d9fe098d49a..aebfaffc5d3208e82c686c67bacf27d9b332cef4 100644 --- a/src/main-common.ts +++ b/src/main-common.ts @@ -18,6 +18,11 @@ import '!!file-loader?context=src/res&name=icons/iav-icons.ttf!src/res/icons/iav import '!!file-loader?context=src/res&name=icons/iav-icons.woff!src/res/icons/iav-icons.woff' import '!!file-loader?context=src/res&name=icons/iav-icons.svg!src/res/icons/iav-icons.svg' +/** + * favicons + */ +import '!!file-loader?context=src/res/favicons&name=favicon-128-light.png!src/res/favicons/favicon-128-light.png' + import 'zone.js' import { enableProdMode } from '@angular/core'; diff --git a/src/main.module.ts b/src/main.module.ts index f4a8fcaa08008d4069692958c6c32adf8735037d..90c7080be8366e2e1e3ecc7f4784c77f6321fbb1 100644 --- a/src/main.module.ts +++ b/src/main.module.ts @@ -229,8 +229,16 @@ export function debug(reducer: ActionReducer<any>): ActionReducer<any> { provide: CLICK_INTERCEPTOR_INJECTOR, useFactory: (clickIntService: ClickInterceptorService) => { return { - deregister: clickIntService.removeInterceptor.bind(clickIntService), - register: clickIntService.addInterceptor.bind(clickIntService) + deregister: clickIntService.deregister.bind(clickIntService), + register: (fn: (arg: any) => boolean, config?) => { + if (config?.last) { + clickIntService.register(fn) + } else { + clickIntService.register(fn, { + first: true + }) + } + } } as ClickInterceptor }, deps: [ diff --git a/src/plugin/atlasViewer.pluginService.service.spec.ts b/src/plugin/atlasViewer.pluginService.service.spec.ts index fe239f32b6d5121562cd4bd19cc53f1f7bf356f4..918063217692a6b3033aa0065036d85c969b9c3e 100644 --- a/src/plugin/atlasViewer.pluginService.service.spec.ts +++ b/src/plugin/atlasViewer.pluginService.service.spec.ts @@ -7,6 +7,7 @@ import { ComponentsModule } from "src/components" import { DialogService } from "src/services/dialogService.service" import { selectorPluginCspPermission } from "src/services/state/userConfigState.helper" import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module" +import { PureContantService } from "src/util" import { APPEND_SCRIPT_TOKEN, REMOVE_SCRIPT_TOKEN } from "src/util/constants" import { WidgetModule, WidgetServices } from "src/widget" import { PluginServices } from "./atlasViewer.pluginService.service" @@ -71,6 +72,12 @@ describe('> atlasViewer.pluginService.service.ts', () => { useValue: { getUserConfirm: () => Promise.resolve() } + }, + { + provide: PureContantService, + useValue: { + backendUrl: `http://localhost:3000/` + } } ] }).compileComponents().then(() => { diff --git a/src/plugin/atlasViewer.pluginService.service.ts b/src/plugin/atlasViewer.pluginService.service.ts index 1e35d24ec50d34909a8766d79ad13a0d218d1b17..42ac1eb77d55de2adb541adf8c98e60e079ae199 100644 --- a/src/plugin/atlasViewer.pluginService.service.ts +++ b/src/plugin/atlasViewer.pluginService.service.ts @@ -8,12 +8,13 @@ import { catchError, filter, map, mapTo, shareReplay, switchMap, switchMapTo, ta import { LoggingService } from 'src/logging'; import { PluginHandler } from 'src/util/pluginHandler'; import { WidgetUnit, WidgetServices } from "src/widget"; -import { APPEND_SCRIPT_TOKEN, REMOVE_SCRIPT_TOKEN, BACKENDURL, getHttpHeader } from 'src/util/constants'; +import { APPEND_SCRIPT_TOKEN, REMOVE_SCRIPT_TOKEN, getHttpHeader } from 'src/util/constants'; import { PluginFactoryDirective } from './pluginFactory.directive'; import { selectorPluginCspPermission } from 'src/services/state/userConfigState.helper'; import { DialogService } from 'src/services/dialogService.service'; import { DomSanitizer } from '@angular/platform-browser'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { PureContantService } from 'src/util'; const requiresReloadMd = `\n\n***\n\n**warning**: interactive atlas viewer **will** be reloaded in order for the change to take effect.` @@ -56,6 +57,7 @@ export class PluginServices { private http: HttpClient, private log: LoggingService, private sanitizer: DomSanitizer, + private constantSvc: PureContantService, @Inject(APPEND_SCRIPT_TOKEN) private appendSrc: (src: string) => Promise<HTMLScriptElement>, @Inject(REMOVE_SCRIPT_TOKEN) private removeSrc: (src: HTMLScriptElement) => void, ) { @@ -65,8 +67,7 @@ export class PluginServices { /** * TODO convert to rxjs streams, instead of Promise.all */ - - const pluginManifestsUrl = `${BACKENDURL.replace(/\/$/,'/')}plugins/manifests` + const pluginManifestsUrl = `${this.constantSvc.backendUrl}plugins/manifests` this.http.get<IPluginManifest[]>(pluginManifestsUrl, { responseType: 'json', @@ -162,7 +163,7 @@ export class PluginServices { }) this.http.delete( - `${BACKENDURL.replace(/\/+$/g, '/')}user/pluginPermissions/${encodeURIComponent(pluginKey)}`, + `${this.constantSvc.backendUrl}user/pluginPermissions/${encodeURIComponent(pluginKey)}`, { headers: getHttpHeader() } @@ -239,7 +240,7 @@ export class PluginServices { catchError(() => of(false)), filter(v => !!v), switchMapTo( - this.http.post(`${BACKENDURL.replace(/\/+$/g, '/')}user/pluginPermissions`, + this.http.post(`${this.constantSvc.backendUrl}user/pluginPermissions`, { [pluginKey]: csp }, { responseType: 'json', @@ -254,7 +255,7 @@ export class PluginServices { }), take(1), ).subscribe( - val => val ? rs() : rj(), + val => val ? rs(null) : rj(`val is falsy`), err => rj(err) ) }) diff --git a/src/res/css/extra_styles.css b/src/res/css/extra_styles.css index 3aa834fd880fdbb63398bba87fb98fa8cbf3bd5f..fbe0d58637112890ef94b5f53e34b4d68aa20da0 100644 --- a/src/res/css/extra_styles.css +++ b/src/res/css/extra_styles.css @@ -443,6 +443,11 @@ markdown-dom p opacity: 0.5!important; } +.muted-3 +{ + opacity: 0.3!important; +} + .card { background:none; diff --git a/src/res/ext/freesurfer.json b/src/res/ext/freesurfer.json index 375f315742168745de2669c6b039c5965ece4aae..caf6816aa78a641f61e045240312e5c0a1d4d61a 100644 --- a/src/res/ext/freesurfer.json +++ b/src/res/ext/freesurfer.json @@ -6,6 +6,7 @@ "three-surfer": { "@context": { "rootUrl": "https://neuroglancer.humanbrainproject.eu/precomputed/freesurfer/20210305", + "rootUrl2": "https://neuroglancer-dev.humanbrainproject.eu/precomputed/data-repo/20210415-new-freesurfer", "fsa_fsa": "/fsaverage/fsaverage", "fsa_fsa6": "/fsaverage/fsaverage6", "label_fsa_lh": "https://neuroglancer.humanbrainproject.eu/precomputed/freesurfer/20210305/julichbrain_labels/lh.JulichBrain_MPMAtlas_l_N10_nlin2Stdicbm152asym2009c_publicDOI_83fb39b2811305777db0eb80a0fc8b53.allSub_RF_ANTs_MNI152_orig_to_fsaverage.gii", @@ -15,9 +16,105 @@ "label_bv_hcp32k_lh": "https://neuroglancer.humanbrainproject.eu/precomputed/freesurfer/20210305/julichbrain_labels/lh.JulichBrain_MPMAtlas_l_N10_nlin2Stdicbm152asym2009c_publicDOI_83fb39b2811305777db0eb80a0fc8b53.allSub_RF_ANTs_MNI152_orig_to_hcp32k.gii", "label_bv_hcp32k_rh": "https://neuroglancer.humanbrainproject.eu/precomputed/freesurfer/20210305/julichbrain_labels/rh.JulichBrain_MPMAtlas_r_N10_nlin2Stdicbm152asym2009c_publicDOI_172e93a5bec140c111ac862268f0d046.allSub_RF_ANTs_MNI152_orig_to_hcp32k.gii", "label_ants_hcp32k_lh": "https://neuroglancer.humanbrainproject.eu/precomputed/freesurfer/20210305/julichbrain_labels/lh.JulichBrain_MPMAtlas_l_N10_nlin2Stdicbm152asym2009c_publicDOI_83fb39b2811305777db0eb80a0fc8b53.BV_MNI152_orig_to_hcp32k.gii", - "label_ants_hcp32k_rh": "https://neuroglancer.humanbrainproject.eu/precomputed/freesurfer/20210305/julichbrain_labels/rh.JulichBrain_MPMAtlas_r_N10_nlin2Stdicbm152asym2009c_publicDOI_172e93a5bec140c111ac862268f0d046.BV_MNI152_orig_to_hcp32k.gii" + "label_ants_hcp32k_rh": "https://neuroglancer.humanbrainproject.eu/precomputed/freesurfer/20210305/julichbrain_labels/rh.JulichBrain_MPMAtlas_r_N10_nlin2Stdicbm152asym2009c_publicDOI_172e93a5bec140c111ac862268f0d046.BV_MNI152_orig_to_hcp32k.gii", + "label_fsaverage6_cleaned_lh":"/lh.JulichBrain_MPMAtlas_l_N10_nlin2Stdicbm152asym2009c_publicDOI_83fb39b2811305777db0eb80a0fc8b53.BV_MNI152_orig_to_fsaverage6_cleaned.gii", + "label_fsaverage_cleaned_lh":"/lh.JulichBrain_MPMAtlas_l_N10_nlin2Stdicbm152asym2009c_publicDOI_83fb39b2811305777db0eb80a0fc8b53.BV_MNI152_orig_to_fsaverage_cleaned.gii", + "label_hcp32k_cleaned_lh":"/lh.JulichBrain_MPMAtlas_l_N10_nlin2Stdicbm152asym2009c_publicDOI_83fb39b2811305777db0eb80a0fc8b53.BV_MNI152_orig_to_hcp32k_cleaned.gii", + "label_fsaverage6_cleaned_rh":"/rh.JulichBrain_MPMAtlas_r_N10_nlin2Stdicbm152asym2009c_publicDOI_172e93a5bec140c111ac862268f0d046.BV_MNI152_orig_to_fsaverage6_cleaned.gii", + "label_fsaverage_cleaned_rh":"/rh.JulichBrain_MPMAtlas_r_N10_nlin2Stdicbm152asym2009c_publicDOI_172e93a5bec140c111ac862268f0d046.BV_MNI152_orig_to_fsaverage_cleaned.gii", + "label_hcp32k_cleaned_rh":"/rh.JulichBrain_MPMAtlas_r_N10_nlin2Stdicbm152asym2009c_publicDOI_172e93a5bec140c111ac862268f0d046.BV_MNI152_orig_to_hcp32k_cleaned.gii" }, "modes": [ + { + "name": "cleaned/fsaverage6/white", + "meshes": [ + { + "mesh": "rootUrl:fsa_fsa6:/lh.white.gii", + "colormap": "rootUrl2:label_fsaverage6_cleaned_lh:", + "hemisphere": "left" + }, + { + "mesh": "rootUrl:fsa_fsa6:/rh.white.gii", + "colormap": "rootUrl2:label_fsaverage6_cleaned_rh:", + "hemisphere": "right" + } + ] + }, + { + "name": "cleaned/fsaverage/white", + "meshes": [ + { + "mesh": "rootUrl:fsa_fsa:/lh.white.gii", + "colormap": "rootUrl2:label_fsaverage_cleaned_lh:", + "hemisphere": "left" + }, + { + "mesh": "rootUrl:fsa_fsa:/rh.white.gii", + "colormap": "rootUrl2:label_fsaverage_cleaned_rh:", + "hemisphere": "right" + } + ] + }, + { + "name": "cleaned/fsaverage6/pial", + "meshes": [ + { + "mesh": "rootUrl:fsa_fsa6:/lh.pial.gii", + "colormap": "rootUrl2:label_fsaverage6_cleaned_lh:", + "hemisphere": "left" + }, + { + "mesh": "rootUrl:fsa_fsa6:/rh.pial.gii", + "colormap": "rootUrl2:label_fsaverage6_cleaned_rh:", + "hemisphere": "right" + } + ] + }, + { + "name": "cleaned/fsaverage/pial", + "meshes": [ + { + "mesh": "rootUrl:fsa_fsa:/lh.pial.gii", + "colormap": "rootUrl2:label_fsaverage_cleaned_lh:", + "hemisphere": "left" + }, + { + "mesh": "rootUrl:fsa_fsa:/rh.pial.gii", + "colormap": "rootUrl2:label_fsaverage_cleaned_rh:", + "hemisphere": "right" + } + ] + }, + { + "name": "cleaned/fsaverage6/inflated", + "meshes": [ + { + "mesh": "rootUrl2:/fsa6-translated-inflated/lh.inflated.surf.gii", + "colormap": "rootUrl2:label_fsaverage6_cleaned_lh:", + "hemisphere": "left" + }, + { + "mesh": "rootUrl2:/fsa6-translated-inflated/rh.inflated.surf.gii", + "colormap": "rootUrl2:label_fsaverage6_cleaned_rh:", + "hemisphere": "right" + } + ] + }, + { + "name": "cleaned/fsaverage/inflated", + "meshes": [ + { + "mesh": "rootUrl2:/fsa7-translated-inflated/lh.inflated.surf.gii", + "colormap": "rootUrl2:label_fsaverage_cleaned_lh:", + "hemisphere": "left" + }, + { + "mesh": "rootUrl2:/fsa7-translated-inflated/rh.inflated.surf.gii", + "colormap": "rootUrl2:label_fsaverage_cleaned_rh:", + "hemisphere": "right" + } + ] + }, { "name": "fsaverage/fsaverage/pial", "meshes": [ @@ -49,20 +146,15 @@ ] }, { - "name": "fsaverage/fsaverage/inflated-left", + "name": "fsaverage/fsaverage/inflated", "meshes": [ { - "mesh": "rootUrl:fsa_fsa:/lh.inflated.gii", + "mesh": "rootUrl2:/fsa7-translated-inflated/lh.inflated.surf.gii", "colormap": "label_fsa_lh:", "hemisphere": "left" - } - ] - }, - { - "name": "fsaverage/fsaverage/inflated-right", - "meshes": [ + }, { - "mesh": "rootUrl:fsa_fsa:/rh.inflated.gii", + "mesh": "rootUrl2:/fsa7-translated-inflated/rh.inflated.surf.gii", "colormap": "label_fsa_rh:", "hemisphere": "right" } @@ -99,20 +191,15 @@ ] }, { - "name": "fsaverage/fsaverage6/inflated-left", + "name": "fsaverage/fsaverage6/inflated", "meshes": [ { - "mesh": "rootUrl:fsa_fsa6:/lh.inflated.gii", + "mesh": "rootUrl2:/fsa6-translated-inflated/lh.inflated.surf.gii", "colormap": "label_fsa6_lh:", "hemisphere": "left" - } - ] - }, - { - "name": "fsaverage/fsaverage6/inflated-right", - "meshes": [ + }, { - "mesh": "rootUrl:fsa_fsa6:/rh.inflated.gii", + "mesh": "rootUrl2:/fsa6-translated-inflated/rh.inflated.surf.gii", "colormap": "label_fsa6_rh:", "hemisphere": "right" } diff --git a/src/res/favicons/favicon-128-dark.svg b/src/res/favicons/favicon-128-dark.svg new file mode 100644 index 0000000000000000000000000000000000000000..49a9402c31fd7252b2dd3bc92e629d3a36d81a17 --- /dev/null +++ b/src/res/favicons/favicon-128-dark.svg @@ -0,0 +1,65 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + width="128mm" + height="128mm" + viewBox="0 0 128 128" + version="1.1" + id="svg8" + inkscape:version="1.0.2 (394de47547, 2021-03-26)" + sodipodi:docname="favicon-128-dark.svg"> + <defs + id="defs2" /> + <sodipodi:namedview + id="base" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageopacity="0.0" + inkscape:pageshadow="2" + inkscape:zoom="1.4" + inkscape:cx="324.70471" + inkscape:cy="279.91944" + inkscape:document-units="mm" + inkscape:current-layer="layer2" + inkscape:document-rotation="0" + showgrid="false" + fit-margin-top="0" + fit-margin-left="0" + fit-margin-right="0" + fit-margin-bottom="0" + inkscape:window-width="1853" + inkscape:window-height="1025" + inkscape:window-x="67" + inkscape:window-y="27" + inkscape:window-maximized="1" /> + <metadata + id="metadata5"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <g + inkscape:groupmode="layer" + id="layer2" + inkscape:label="main" + style="display:inline" + transform="translate(-79.414325,-20.601907)"> + <path + style="fill:#ffffff;stroke-width:0.264583" + d="m 162.83944,128.89555 c -1.71263,-0.76119 -2.51354,-2.36516 -2.51354,-5.03388 0,-2.25335 0.78988,-3.77883 2.38125,-4.59886 2.34364,-1.20767 5.04289,-0.21921 5.96015,2.18259 0.5625,1.4729 0.4413,4.34887 -0.24282,5.76143 -0.83944,1.73329 -3.604,2.5692 -5.58504,1.68872 z m 3.49261,-1.63593 c 1.05602,-0.66537 1.47806,-3.54272 0.76867,-5.24053 -1.06024,-2.53752 -4.2164,-2.24958 -4.94489,0.45112 -0.36533,1.3544 -0.16041,3.36382 0.43584,4.27382 0.74822,1.14193 2.38765,1.36792 3.74038,0.51559 z m -39.87282,-3.28224 v -5.02708 h 3.3073 3.30729 v 0.66146 c 0,0.65913 -0.009,0.66145 -2.51354,0.66145 h -2.51355 v 1.45521 1.45521 h 2.24896 2.24896 v 0.79375 0.79375 h -2.24896 -2.24896 v 1.45521 1.45521 h 2.4915 c 2.49379,0 2.90743,0.15781 2.6006,0.99219 -0.0884,0.24024 -1.01868,0.33072 -3.40061,0.33072 h -3.27899 z m 7.9375,4.81268 c 0,-0.13636 0.65485,-1.20951 1.45521,-2.38478 0.80037,-1.17527 1.45521,-2.3002 1.45521,-2.49985 0,-0.19964 -0.60994,-1.24739 -1.35542,-2.32833 -1.81084,-2.62569 -1.8116,-2.62907 -0.59112,-2.61522 0.96613,0.011 1.07801,0.10373 2.1,1.74113 l 1.0795,1.72954 1.13439,-1.74113 c 1.02835,-1.57837 1.21805,-1.74112 2.02935,-1.74112 0.49223,0 0.89497,0.0976 0.89497,0.2168 0,0.11923 -0.45048,0.82058 -1.00107,1.55853 -0.55059,0.73796 -1.22383,1.75537 -1.49608,2.26091 l -0.495,0.91916 1.62837,2.43443 c 0.8956,1.33894 1.62836,2.48616 1.62836,2.54938 0,0.0632 -0.44834,0.11495 -0.99631,0.11495 -0.9347,0 -1.06576,-0.10637 -2.11905,-1.71979 -0.61751,-0.94588 -1.21097,-1.71645 -1.31879,-1.71237 -0.10783,0.004 -0.67231,0.74822 -1.25439,1.65365 -0.89834,1.39735 -1.18833,1.65876 -1.91823,1.72913 -0.47294,0.0456 -0.8599,-0.0286 -0.8599,-0.16502 z m 9.58531,-4.74653 0.072,-4.96094 2.36594,-0.0772 c 3.55309,-0.11592 4.9101,0.71487 4.9101,3.00606 0,2.39475 -0.96061,3.25698 -3.89065,3.49217 l -1.6656,0.1337 v 1.68357 1.68356 h -0.93188 -0.93188 z m 5.23136,-0.59532 c 0.52634,-0.52634 0.69769,-1.71985 0.32668,-2.27549 -0.36931,-0.55309 -1.35454,-0.89951 -2.55824,-0.89951 h -1.20802 v 1.85209 1.85208 h 1.45521 c 1.10243,0 1.58349,-0.12828 1.98437,-0.52917 z m 3.70417,0.51241 v -5.04385 l 0.85989,0.0829 0.8599,0.0829 0.0727,4.29948 0.0727,4.29948 h 2.2424 c 2.21551,0 2.24241,0.008 2.24241,0.66146 v 0.66145 h -3.175 -3.175 z m 17.99166,0.0168 v -5.02708 l 2.71198,0.001 c 2.22127,7.9e-4 2.86498,0.0943 3.55752,0.51654 1.45573,0.88762 1.51248,3.41795 0.10255,4.57238 l -0.73112,0.59863 0.8837,2.07396 c 0.48604,1.14069 0.88371,2.1305 0.88371,2.19958 0,0.0691 -0.38396,0.0883 -0.85324,0.0427 -0.77802,-0.0756 -0.91584,-0.23454 -1.56315,-1.8027 -0.91281,-2.21133 -1.12685,-2.42774 -2.30398,-2.32964 l -0.96817,0.0806 -0.0773,2.05052 -0.0773,2.05052 h -0.78261 -0.78263 v -5.02708 z m 5.1237,-1.07753 c 0.83208,-0.65453 0.88983,-1.37562 0.16797,-2.09748 -0.41157,-0.41157 -0.88194,-0.52917 -2.11667,-0.52917 h -1.5875 v 1.5875 1.5875 h 1.41953 c 1.03547,0 1.60814,-0.14836 2.11667,-0.54837 z m 4.4013,1.07752 v -5.02708 h 3.3073 3.30729 v 0.66146 c 0,0.65913 -0.009,0.66145 -2.51354,0.66145 h -2.51355 v 1.45521 1.45521 h 2.24896 2.24896 v 0.79375 0.79375 h -2.24896 -2.24896 v 1.45521 1.45521 h 2.51355 c 2.50472,0 2.51354,0.002 2.51354,0.66146 v 0.66145 h -3.30729 -3.3073 z m 8.73125,0 v -5.02708 h 2.75783 c 2.5548,0 2.81563,0.0486 3.543,0.66068 0.43184,0.36337 0.86108,0.96314 0.95387,1.33283 0.25133,1.00138 -0.29507,2.66624 -1.05728,3.22147 l -0.67115,0.4889 0.931,2.17514 0.931,2.17514 h -0.87632 c -0.83195,0 -0.914,-0.0904 -1.62079,-1.78593 -0.98454,-2.36185 -1.10215,-2.48856 -2.22268,-2.39463 l -0.94868,0.0795 -0.0773,2.05052 -0.0773,2.05052 h -0.78261 -0.78263 z m 4.96821,-0.94104 c 0.70292,-0.49234 0.77514,-1.58868 0.15016,-2.27927 -0.32907,-0.36362 -0.82218,-0.48386 -1.98437,-0.48386 h -1.5465 v 1.5875 1.5875 h 1.39634 c 0.83558,0 1.63249,-0.1654 1.98437,-0.41187 z m -97.572367,-9.5378 c -1.95888,-0.34634 -5.82084,-1.69103 -5.82084,-2.02677 0,-0.26055 1.56112,-3.71342 1.77614,-3.92844 0.17279,-0.17279 0.61281,-0.12624 1.29339,0.13682 1.5231,0.58871 4.90435,1.21598 6.554677,1.21598 2.60677,0 5.19386,-1.69122 5.19148,-3.39374 -0.003,-1.9815 -1.93734,-3.38013 -6.084437,-4.39883 -4.65894,-1.144439 -6.70269,-2.519669 -8.03124,-5.404209 -0.71141,-1.5446 -0.75135,-4.927329 -0.0761,-6.446859 0.70597,-1.588706 2.48912,-3.227144 4.27091,-3.924299 1.36156,-0.532739 2.0582,-0.621779 4.894797,-0.625631 3.42639,-0.0047 5.55454,0.402283 7.71765,1.475732 l 0.97077,0.481753 -0.70619,2.010008 c -0.80831,2.300676 -0.49127,2.212587 -4.26017,1.183737 -3.97503,-1.085119 -6.995447,-0.45827 -8.048857,1.670449 -0.41938,0.84746 -0.44249,1.10637 -0.15967,1.78914 0.64008,1.54529 2.27792,2.40158 6.602567,3.4519 5.57621,1.35429 8.19428,4.460389 7.88051,9.349499 -0.21116,3.29034 -1.90364,5.60538 -4.96953,6.79752 -1.17157,0.45555 -2.12561,0.57923 -4.89479,0.63451 -1.891777,0.0378 -3.737247,0.0161 -4.101047,-0.0483 z m 44.068567,-0.278 c -2.90689,-0.89207 -5.0522,-3.00939 -6.05006,-5.97114 -0.5442,-1.61522 -0.55975,-2.09043 -0.56972,-17.403258 l -0.0102,-15.742712 2.44739,-0.07608 2.4474,-0.07607 v 7.060514 7.060515 l 0.60161,-0.892728 c 1.56474,-2.321923 5.21496,-3.2175 9.04643,-2.219529 3.20198,0.834009 5.19421,2.399257 6.39662,5.02566 0.87053,1.901499 1.1483,3.650869 1.28015,8.062509 0.27496,9.199609 -2.10057,13.619369 -8.19669,15.250219 -1.75078,0.46837 -5.75011,0.42623 -7.39288,-0.0779 z m 6.49531,-4.951 c 1.40139,-0.66989 2.60987,-1.95865 3.38206,-3.60674 0.64143,-1.36901 0.68161,-1.69814 0.67829,-5.556249 -0.003,-3.1889 -0.10522,-4.39714 -0.46075,-5.43234 -1.99463,-5.807759 -9.23139,-5.807759 -11.22454,0 -0.5714,1.66496 -0.66746,9.662769 -0.13719,11.422529 0.38795,1.28749 1.62048,2.60265 3.10346,3.31151 1.26411,0.60425 3.22666,0.54581 4.65867,-0.13871 z m 36.45034,5.14101 c -2.86373,-0.55868 -5.63198,-2.86262 -6.48265,-5.39534 -0.73178,-2.17877 -0.62908,-5.24545 0.23901,-7.13684 1.7518,-3.816799 4.64695,-5.101829 11.69101,-5.189139 2.03163,-0.0252 3.74448,-0.0964 3.80632,-0.15823 0.2984,-0.2984 -0.60554,-3.52815 -1.21109,-4.32724 -1.09959,-1.451009 -2.65508,-1.980129 -5.34563,-1.818389 -1.24652,0.0749 -3.25381,0.3917 -4.46064,0.70393 -1.20684,0.31223 -2.30355,0.50013 -2.43715,0.41756 -0.1336,-0.0826 -0.32573,-0.59243 -0.42697,-1.13303 -0.10123,-0.540596 -0.34775,-1.375097 -0.54783,-1.854445 -0.20007,-0.479348 -0.29566,-0.98175 -0.21241,-1.116446 0.21936,-0.354926 3.38152,-1.248228 5.68631,-1.606368 2.64307,-0.410704 6.3822,-0.15327 8.3655,0.575956 2.33627,0.859007 4.12344,2.790721 4.91623,5.313863 0.57018,1.814649 0.60889,2.375279 0.60882,8.817039 -5e-5,5.765429 -0.0739,7.117489 -0.45609,8.351249 -0.88316,2.85093 -2.77719,4.61583 -5.82399,5.42691 -1.45391,0.38704 -6.18998,0.46426 -7.90875,0.12896 z m 6.92994,-5.20516 c 1.77043,-1.03673 2.34719,-2.50803 2.35786,-6.01479 l 0.006,-2.07533 -3.77031,0.097 c -3.29921,0.0848 -3.91253,0.17213 -4.90845,0.69861 -1.27367,0.67331 -2.69893,2.64825 -2.69733,3.73762 0.001,0.77735 0.83464,2.2531 1.67343,2.96288 1.53862,1.30197 5.57201,1.62847 7.33849,0.59406 z m -76.09176,4.86829 c -0.0718,-0.1876 -0.0996,-6.59187 -0.0618,-14.231709 l 0.0688,-13.890624 h 2.38125 2.38125 v 14.155204 14.155209 l -2.31943,0.0765 c -1.70628,0.0563 -2.35396,-0.0137 -2.45004,-0.26459 z m 10.10425,-0.0596 c -0.0802,-0.20897 -0.12269,-6.60857 -0.0945,-14.221349 l 0.0513,-13.841424 h 2.38125 2.38125 l 0.0684,14.221354 0.0684,14.221349 h -2.35524 c -1.80279,0 -2.38944,-0.0891 -2.50104,-0.37993 z m 35.11607,-0.30847 c -0.095,-0.37862 -0.11053,-4.99229 -0.0344,-10.25261 0.12618,-8.723899 0.18658,-9.715399 0.68747,-11.285189 1.29659,-4.063497 4.3606,-6.218859 9.29103,-6.535729 2.06626,-0.132795 5.29988,0.255791 5.29988,0.636886 0,0.08132 -0.2566,1.08418 -0.57023,2.228585 l -0.57023,2.080739 -2.15656,-0.16577 c -2.49177,-0.19153 -3.87041,0.25949 -5.3705,1.756959 -0.75709,0.75576 -1.76191,3.14282 -1.62992,3.87205 0.0228,0.12569 0.0273,4.30641 0.01,9.290499 l -0.0313,9.06198 h -2.37621 c -2.33876,0 -2.37893,-0.0108 -2.54898,-0.6884 z m -66.274827,-31.525 c -0.51012,-0.545486 -1.07676,-1.190866 -1.25921,-1.434176 -1.24584,-1.661446 -2.15941,-7.647514 -1.27868,-8.378449 0.35742,-0.296635 0.50679,-0.203116 1.06376,0.665993 0.37336,0.58261 0.71063,1.59372 0.79586,2.385928 0.0814,0.75616 0.33924,1.744826 0.57309,2.197036 0.23384,0.45221 0.49499,1.440876 0.58032,2.197036 0.0853,0.756161 0.32722,1.715942 0.53753,2.132844 0.60857,1.20638 0.0232,1.341514 -1.01267,0.233788 z m 2.33518,-0.912463 c -0.78294,-1.207471 -0.949,-2.328288 -0.34497,-2.328288 0.41496,0 2.3036,2.906953 2.1013,3.234277 -0.31707,0.513043 -1.09712,0.110664 -1.75633,-0.905989 z m 19.784637,0.858874 c -0.29105,-0.120382 -0.81183,-0.523184 -1.15731,-0.895117 -0.55328,-0.595656 -0.61424,-0.868323 -0.51154,-2.288169 0.14787,-2.044287 0.70615,-2.673019 2.52899,-2.848171 1.4406,-0.138425 2.76691,0.371144 3.23306,1.242139 0.42376,0.791816 0.32892,2.99125 -0.15903,3.6879 -0.76268,1.088877 -2.67211,1.623444 -3.93417,1.101418 z m 10.26163,0.07799 c -0.32245,-0.06853 -0.90073,-0.498295 -1.28506,-0.955046 -0.61462,-0.730435 -0.68434,-1.004578 -0.57889,-2.276186 0.10157,-1.224812 0.25311,-1.564764 0.99171,-2.224699 0.78581,-0.702127 1.01127,-0.767402 2.28555,-0.661731 1.64496,0.136414 2.35778,0.662517 2.77233,2.04614 0.37045,1.236472 -0.0824,3.038228 -0.89777,3.571827 -0.65206,0.426738 -2.38899,0.690716 -3.28787,0.499695 z m 39.75546,-1.455516 c -0.29653,-0.177535 -1.20228,-0.272483 -2.11667,-0.221882 -0.87909,0.04865 -4.69397,0.09826 -8.47751,0.110241 -6.435,0.02037 -6.93758,-0.01251 -7.78397,-0.509799 -1.26347,-0.74232 -1.8095,-2.382676 -1.36737,-4.107791 0.1744,-0.680468 0.2409,-1.540798 0.14777,-1.911847 -0.21966,-0.875197 -1.27098,-1.856041 -1.86604,-1.740942 -0.5115,0.09893 -1.67879,2.164519 -1.69064,2.991662 -0.0104,0.726839 -0.82481,2.78697 -1.10174,2.78697 -0.12575,0 -0.22863,-0.9525 -0.22863,-2.116667 0,-1.76389 -0.0882,-2.20486 -0.52917,-2.645833 -0.29104,-0.291042 -0.7205,-0.529167 -0.95435,-0.529167 -0.30134,0 -0.36952,-0.134858 -0.23409,-0.463021 0.1051,-0.254661 0.28802,-0.868269 0.40649,-1.363577 0.15095,-0.631092 0.8118,-1.46236 2.20859,-2.778125 1.43622,-1.352905 2.18899,-2.308765 2.69387,-3.420658 0.74796,-1.647211 1.82649,-2.555189 3.03725,-2.556962 0.3715,-5.29e-4 1.97885,0.490347 3.57187,1.090869 3.83806,1.446829 4.56223,1.507868 7.1508,0.602739 3.01212,-1.053232 4.23878,-0.986837 6.91838,0.374465 1.19732,0.608267 2.36214,1.105937 2.58849,1.105937 0.22635,0 0.97971,-0.287911 1.67413,-0.639802 1.74679,-0.885163 3.01721,-1.087554 3.93068,-0.626192 0.9818,0.495877 2.03292,1.101442 2.19747,1.265994 0.15255,0.152551 2.72448,1.837262 3.70417,2.426375 0.3638,0.218763 1.43537,0.542631 2.38125,0.719709 2.99201,0.560125 3.39696,0.760338 3.98279,1.969132 0.35752,0.737711 0.51671,1.548122 0.48115,2.449343 -0.0926,2.345902 2.17289,5.515176 2.79099,3.904435 0.0923,-0.240493 0.0322,-0.848045 -0.13348,-1.350118 -0.34463,-1.044218 -0.0719,-1.472047 1.11487,-1.748684 0.95905,-0.223563 1.99025,0.640426 2.77445,2.324568 0.80436,1.727398 0.79157,1.946883 -0.16307,2.799185 -1.41862,1.26654 -5.07464,1.602407 -21.82791,2.005256 -3.35735,0.08073 -4.91543,0.02297 -5.28082,-0.195813 z m 5.68028,-4.32481 c 1.18291,-0.746736 2.5284,-2.584754 2.23713,-3.056043 -0.18437,-0.298304 -1.0193,-0.247404 -2.83503,0.172836 -0.59591,0.137922 -0.7276,0.07816 -0.7276,-0.330192 0,-0.274224 0.28708,-0.921067 0.63796,-1.437425 0.83064,-1.222407 1.15012,-3.186075 0.59351,-3.648019 -0.44514,-0.369435 -0.4109,-0.415042 -1.86137,2.47964 -0.4375,0.873125 -0.951,1.744832 -1.14111,1.937128 -0.49165,0.497301 -0.42942,1.224616 0.21434,2.505184 0.62047,1.234238 1.25359,2.039979 1.60294,2.039979 0.12586,0 0.70151,-0.298389 1.27923,-0.663088 z m 10.83558,-0.900856 c 0.47081,-1.136634 0.25503,-1.478764 -0.93265,-1.478764 -1.28599,0 -1.48663,-0.589621 -0.52713,-1.549122 0.69901,-0.699008 0.88547,-1.597345 0.39453,-1.900764 -0.7978,-0.493069 -2.04158,2.305775 -1.70522,3.83721 0.0986,0.448789 0.47652,1.169297 0.83988,1.601128 0.77873,0.925462 1.40457,0.760236 1.93059,-0.509688 z M 155.5634,74.14944 c 0.50932,-0.1802 1.46083,-0.36475 2.11447,-0.41011 1.26066,-0.08749 3.35317,-0.704985 3.9048,-1.1523 0.47723,-0.386982 0.40641,-1.377841 -0.13229,-1.850882 -0.72957,-0.640646 -1.61816,-0.828932 -4.0435,-0.856782 -1.93665,-0.02225 -2.46318,-0.133008 -3.70416,-0.779272 -0.79564,-0.41434 -1.88046,-1.095203 -2.41073,-1.513027 -1.80091,-1.41904 -1.90833,-1.058617 -0.7384,2.477529 0.9759,2.94967 1.26258,3.424655 2.40734,3.988726 1.04811,0.516448 1.37981,0.528698 2.60247,0.09612 z m -48.71741,4.518051 c -0.75584,-0.625237 -1.23923,-1.693741 -1.41492,-3.127595 -0.14698,-1.199446 -0.0682,-1.678781 0.4775,-2.905003 1.67233,-3.757871 4.19907,-6.216615 6.89,-6.704573 0.38534,-0.06988 0.94096,-0.20333 1.23472,-0.296563 0.8268,-0.262419 2.84262,0.848153 3.54476,1.95291 1.04663,1.646785 2.41915,2.48593 3.62924,2.21887 0.57393,-0.126664 1.59669,-2.515833 1.75413,-4.097681 0.10368,-1.041612 -0.0254,-1.552559 -0.76848,-3.042708 l -0.89308,-1.790886 0.0841,-4.630208 c 0.0791,-4.356544 0.12399,-4.708397 0.75971,-5.953124 1.38713,-2.71598 2.27736,-3.644731 4.64358,-4.844518 2.07098,-1.050084 2.38275,-1.130576 4.58507,-1.183796 1.68752,-0.04078 2.86165,0.08791 4.10738,0.450175 1.84993,0.537972 2.92387,0.440743 2.37931,-0.215411 -0.17951,-0.216298 -1.12844,-0.434549 -2.3449,-0.539321 -3.51928,-0.30311 -4.34481,-1.003481 -2.47202,-2.097247 1.16316,-0.679324 3.50373,-0.620097 4.69949,0.118919 1.13086,0.698912 1.48162,1.769436 1.12483,3.433052 -0.33237,1.549826 -1.02426,2.121199 -3.13572,2.589562 -3.05232,0.677061 -7.3992,3.86716 -7.94452,5.830339 -0.14608,0.525889 -0.26767,1.432412 -0.27021,2.014495 -0.003,0.582084 -0.3046,2.248959 -0.67128,3.704167 -0.36667,1.455208 -0.73302,3.419739 -0.81411,4.365625 -0.22832,2.663033 -1.21812,5.429419 -3.31335,9.260416 -0.74008,1.353177 -0.71792,1.34838 -1.93549,0.418983 -1.45883,-1.113549 -3.33052,-1.596128 -4.85879,-1.252749 -1.07379,0.241266 -1.13531,0.220337 -1.51593,-0.515715 -0.46045,-0.890413 -1.38975,-1.026083 -2.04778,-0.298961 -0.45402,0.501682 -1.74517,3.401002 -2.45636,5.515801 -0.57096,1.69781 -0.80836,1.953474 -1.81395,1.953474 -0.46369,0 -1.02299,-0.148828 -1.24288,-0.330729 z M 98.677983,78.0112 c -1.64347,-0.791178 -1.71155,-1.369084 -0.39143,-3.322897 l 0.93148,-1.378624 -0.16733,-2.513542 c -0.16726,-2.512375 -0.16697,-2.51401 0.64084,-3.523012 0.444487,-0.555207 1.357347,-1.448175 2.028557,-1.984375 1.50167,-1.199612 2.83836,-3.509435 3.53031,-6.100426 0.44623,-1.670909 0.71416,-2.14621 1.86903,-3.31561 0.74148,-0.7508 1.34814,-1.445249 1.34814,-1.543219 0,-0.09797 -0.44112,-0.260884 -0.98026,-0.362027 -0.78348,-0.146981 -1.2525,-0.04136 -2.33637,0.526111 -1.64659,0.862102 -6.434197,5.505765 -7.306207,7.086542 -0.70093,1.270653 -1.40155,1.571013 -1.95645,0.838744 -0.60987,-0.80482 -0.44244,-1.492498 0.76857,-3.156788 2.8822,-3.960987 9.692297,-9.847603 12.857717,-11.114151 0.67892,-0.27165 1.26459,-0.808894 1.82554,-1.674598 1.53853,-2.374361 3.00047,-3.179549 6.28369,-3.460849 2.2984,-0.196922 2.70621,-0.326718 5.75862,-1.83285 2.69184,-1.328216 3.72518,-1.692106 5.72239,-2.01513 1.34053,-0.216816 2.65699,-0.511765 2.92545,-0.655442 0.58973,-0.315611 3.31898,-0.450679 3.5919,-0.177759 0.43429,0.434287 -0.515,0.79676 -2.597,0.99163 -1.21017,0.113269 -2.3964,0.283952 -2.63606,0.379295 -0.23967,0.09534 -1.212,1.100689 -2.16074,2.2341 -1.01357,1.210845 -2.25242,2.362992 -3.00384,2.793592 -0.70336,0.403061 -1.83688,1.271526 -2.51892,1.929918 l -1.24008,1.197078 -1.32251,-0.568928 c -2.84275,-1.222917 -4.1526,-0.612939 -8.0301,3.739483 -0.87946,0.987176 -1.52487,1.869009 -1.43425,1.959629 0.29663,0.296624 2.30602,-0.789364 2.83493,-1.532155 0.60988,-0.856496 2.60978,-1.695103 3.4753,-1.457285 0.33374,0.0917 0.95095,0.451141 1.37159,0.798764 0.68607,0.566978 0.80202,0.900305 1.1264,3.238272 0.35442,2.55451 0.33043,3.267697 -0.10995,3.267697 -0.26228,0 -0.75383,-1.332357 -1.00139,-2.714281 -0.33349,-1.861585 -1.51416,-2.765989 -2.5869,-1.981584 -0.76289,0.557842 -0.60384,2.166263 0.49759,5.031949 0.51792,1.347499 0.48262,3.30014 -0.08,4.426415 -0.40788,0.816483 -0.58782,0.929423 -1.52094,0.95463 -0.58209,0.01572 -1.45983,-0.133104 -1.95054,-0.330729 -1.71944,-0.692467 -3.48462,-0.309946 -4.72035,1.022914 -0.30704,0.331179 -1.00125,1.554644 -1.54268,2.71881 -0.54142,1.164167 -1.32765,2.616944 -1.74717,3.22839 -1.03788,1.512737 -1.66114,3.05924 -2.05775,5.105985 -0.39069,2.016209 -0.80523,3.23025 -1.19565,3.501678 -0.47925,0.333179 -1.826457,0.210013 -2.793157,-0.255365 z M 111.11171,56.257296 c 0.50839,-0.421452 0.84203,-0.848598 0.74141,-0.949211 -0.27914,-0.279141 -2.59179,0.931217 -2.59179,1.35645 0,0.604784 0.85716,0.416139 1.85038,-0.407239 z m 84.18841,20.177317 c -0.13816,-0.263506 -0.33393,-0.86451 -0.43506,-1.335569 -0.10683,-0.497625 -0.70194,-1.382482 -1.42037,-2.11193 -2.29928,-2.334538 -2.41739,-2.571001 -2.26763,-4.539729 0.21107,-2.774878 0.36379,-3.472079 0.76052,-3.472079 0.63467,0 1.40388,1.403969 2.47809,4.523023 0.57161,1.659681 1.23753,3.54285 1.47984,4.184821 0.48135,1.275268 0.46439,2.965154 -0.0314,3.127886 -0.17205,0.05647 -0.42586,-0.112919 -0.56401,-0.376423 z M 94.706333,75.158263 c -0.22702,-0.273544 -0.29095,-0.977987 -0.20182,-2.223799 0.2774,-3.877016 2.71375,-5.428858 3.0488,-1.941946 0.0993,1.033404 -0.0147,1.568677 -0.56575,2.656088 -0.37956,0.749028 -0.92057,1.485205 -1.20224,1.635948 -0.6582,0.352256 -0.68438,0.349192 -1.07899,-0.126291 z m 36.118527,-1.612561 c -0.43656,-0.352642 -1.24024,-0.777213 -1.78594,-0.943493 -0.5457,-0.166283 -0.99219,-0.390584 -0.99219,-0.498446 0,-0.107863 0.40601,-0.799434 0.90224,-1.536825 0.93743,-1.393023 1.47901,-3.261314 1.47901,-5.102153 0,-0.589584 0.34745,-2.280364 0.7721,-3.757289 0.42465,-1.476923 0.92012,-3.816408 1.10103,-5.198856 0.18483,-1.412357 0.54155,-2.874148 0.81425,-3.336655 0.62083,-1.052955 2.20045,-2.146321 3.83533,-2.654702 1.40618,-0.437267 6.26149,-0.792274 7.29343,-0.533273 0.90311,0.226668 1.41689,1.927111 0.99684,3.299206 -0.69251,2.26206 -2.18236,4.54834 -2.96391,4.54834 -0.19087,0 -1.13856,-0.714375 -2.10597,-1.5875 -0.96741,-0.873125 -1.88849,-1.5875 -2.04685,-1.5875 -0.56821,0 -0.27265,0.768589 0.50983,1.325759 1.31448,0.93599 1.64904,1.905288 1.6843,4.879657 0.0412,3.472669 -0.18968,3.887793 -3.19943,5.753764 -2.54476,1.577689 -3.07918,2.289058 -3.05237,4.062997 0.0158,1.042474 -0.029,1.105736 -0.94376,1.333505 -0.80241,0.199792 -1.00503,0.39824 -1.23225,1.20687 l -0.27194,0.967759 z m 66.46138,-0.989121 c -0.5873,-0.896326 -1.24159,-2.538227 -1.24159,-3.11567 0,-1.01968 0.506,-0.437943 1.23364,1.418297 0.41291,1.053332 0.79042,2.005832 0.83893,2.116666 0.21156,0.483397 -0.4623,0.143378 -0.83098,-0.419293 z m -10.62496,-4.387858 c -0.74348,-1.467175 -1.52951,-2.134615 -2.51561,-2.136073 -0.79888,-0.0011 -2.34116,-0.890641 -2.56762,-1.480802 -0.1076,-0.280408 0.27483,-0.856596 1.1483,-1.730062 l 1.30963,-1.309632 0.94246,0.739388 c 0.51835,0.406665 1.60228,1.156108 2.40874,1.665431 2.13443,1.348009 2.61454,2.849054 1.49214,4.665125 -0.78952,1.277474 -1.42085,1.159814 -2.21804,-0.413375 z M 98.289873,64.644577 c 0.36497,-2.188118 2.784567,-5.754687 3.904027,-5.754687 0.6448,0 1.24659,1.058637 1.24659,2.192932 0,1.805676 -0.95241,3.033776 -3.12364,4.027792 -2.058507,0.942417 -2.254397,0.897377 -2.026977,-0.466037 z m 78.572487,-1.361808 c -0.43656,-0.1919 -1.40796,-0.83626 -2.15866,-1.431914 -1.26545,-1.004086 -1.39427,-1.214099 -1.76775,-2.881884 -0.80814,-3.608758 -0.81237,-3.706524 -0.17516,-4.047548 0.94421,-0.505328 1.6849,-0.128453 2.32108,1.181013 0.32065,0.659973 0.67505,1.199954 0.78757,1.199954 0.11252,0 0.50102,0.274021 0.86333,0.608936 1.24587,1.151649 2.37855,1.098232 2.37855,-0.112165 0,-0.734095 0.76639,-0.603171 1.06488,0.181911 0.35599,0.936332 0.32361,3.121237 -0.0587,3.960299 -0.24556,0.538949 -1.95577,1.75859 -2.38471,1.700663 -0.0422,-0.0057 -0.43388,-0.167365 -0.87044,-0.359265 z m -23.28333,-1.326793 c -0.43657,-0.194032 -1.08454,-0.667903 -1.43995,-1.053049 -0.35541,-0.385144 -1.20594,-1.059447 -1.89007,-1.498452 -1.82383,-1.170353 -2.01802,-1.846149 -1.34551,-4.682577 0.8795,-3.709448 1.00084,-5.662962 0.41352,-6.657218 -0.45099,-0.763458 -0.60348,-0.831858 -1.81929,-0.816044 -2.07484,0.02698 -3.86372,-0.28203 -4.02211,-0.694785 -0.21217,-0.552892 0.42363,-1.895366 1.17907,-2.489595 1.04836,-0.824644 3.44682,-0.552608 6.08657,0.690346 1.18199,0.556554 2.22833,2.214846 2.37051,3.756896 0.10896,1.181819 0.0561,1.305808 -0.96551,2.264767 -1.11944,1.050798 -1.26421,1.455655 -0.81839,2.28868 0.78046,1.458306 3.70434,0.582895 6.48449,-1.94146 2.17936,-1.978842 2.85522,-2.274866 3.61792,-1.584629 0.56315,0.509646 1.30639,1.77982 1.2482,2.133148 -0.18524,1.124831 0.0825,1.711129 0.94621,2.072013 1.34762,0.56307 2.94048,0.494101 3.59003,-0.155443 0.29635,-0.296352 0.49466,-0.623774 0.4407,-0.727604 -0.054,-0.103833 -0.34944,-0.739167 -0.65662,-1.411851 -0.56018,-1.226733 -1.82619,-2.423994 -3.43175,-3.245398 -1.13077,-0.578495 -1.23169,-1.584637 -0.1323,-1.318829 1.6267,0.393293 3.46133,2.419038 5.19579,5.737048 1.56504,2.993911 2.02713,4.707498 1.58214,5.867138 -0.19206,0.500501 -3.46411,3.046645 -3.91525,3.046645 -0.16475,0 -1.00647,-0.714375 -1.87049,-1.5875 l -1.57093,-1.5875 -1.72808,0.0011 c -2.02318,0.0013 -3.21164,0.64234 -5.03823,2.717451 -1.19436,1.356873 -1.32646,1.402998 -2.51067,0.87667 z m 36.01631,-0.557363 c -0.23642,-0.236421 -0.42986,-0.581491 -0.42986,-0.766823 0,-0.387451 0.83901,-1.212734 1.2329,-1.212734 0.38808,0 0.95133,1.172803 0.84102,1.751161 -0.12301,0.644908 -1.09282,0.779637 -1.64406,0.228396 z m -6.92417,-1.83524 c -0.35719,-0.357187 -0.64944,-0.797271 -0.64944,-0.977963 0,-0.180695 -0.30102,-0.76291 -0.66893,-1.293813 -0.7211,-1.040545 -0.66351,-1.71863 0.16838,-1.982663 0.6233,-0.197829 4.86662,2.046462 5.26038,2.78221 0.75395,1.408774 0.0455,2.121662 -2.10862,2.121662 -1.08304,0 -1.48167,-0.129326 -2.00177,-0.649433 z m -9.8238,-7.622696 c -1.09021,-0.619228 -3.74463,-4.163287 -3.11822,-4.163287 1.08493,0 4.81042,3.354615 4.53181,4.080663 -0.19158,0.499237 -0.63453,0.525127 -1.41359,0.08262 z M 154.17434,44.22376 c -0.83675,-0.935689 -1.52136,-1.839057 -1.52136,-2.007484 0,-0.444583 1.94781,-0.08303 3.63802,0.675296 0.95763,0.429645 1.38907,0.776737 1.38907,1.117513 0,0.503175 -1.27908,1.916344 -1.73427,1.916074 -0.13756,-8e-5 -0.93472,-0.76571 -1.77146,-1.701399 z M 142.05395,42.5991 c -0.41029,-0.494368 -0.0619,-1.25153 0.63937,-1.389547 0.3082,-0.06066 0.43466,0.103124 0.43466,0.562915 0,0.907598 -0.61439,1.380466 -1.07403,0.826632 z" + id="path965" /> + </g> +</svg> diff --git a/src/res/favicons/favicon-128-light.png b/src/res/favicons/favicon-128-light.png new file mode 100644 index 0000000000000000000000000000000000000000..2e7496a4689c3a850f19de1b8722ce816af66b49 Binary files /dev/null and b/src/res/favicons/favicon-128-light.png differ diff --git a/src/res/favicons/favicon-128-light.svg b/src/res/favicons/favicon-128-light.svg new file mode 100644 index 0000000000000000000000000000000000000000..8e5472b32795cab964d9e78f993908d64edaae47 --- /dev/null +++ b/src/res/favicons/favicon-128-light.svg @@ -0,0 +1,65 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + width="128mm" + height="128mm" + viewBox="0 0 128 128" + version="1.1" + id="svg8" + inkscape:version="1.0.2 (394de47547, 2021-03-26)" + sodipodi:docname="favicon-128-light.svg"> + <defs + id="defs2" /> + <sodipodi:namedview + id="base" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageopacity="0.0" + inkscape:pageshadow="2" + inkscape:zoom="1.4" + inkscape:cx="324.70471" + inkscape:cy="279.91944" + inkscape:document-units="mm" + inkscape:current-layer="layer2" + inkscape:document-rotation="0" + showgrid="false" + fit-margin-top="0" + fit-margin-left="0" + fit-margin-right="0" + fit-margin-bottom="0" + inkscape:window-width="1853" + inkscape:window-height="1025" + inkscape:window-x="67" + inkscape:window-y="27" + inkscape:window-maximized="1" /> + <metadata + id="metadata5"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <g + inkscape:groupmode="layer" + id="layer2" + inkscape:label="main" + style="display:inline" + transform="translate(-79.414325,-20.601907)"> + <path + style="fill:#000000;stroke-width:0.264583" + d="m 162.83944,128.89555 c -1.71263,-0.76119 -2.51354,-2.36516 -2.51354,-5.03388 0,-2.25335 0.78988,-3.77883 2.38125,-4.59886 2.34364,-1.20767 5.04289,-0.21921 5.96015,2.18259 0.5625,1.4729 0.4413,4.34887 -0.24282,5.76143 -0.83944,1.73329 -3.604,2.5692 -5.58504,1.68872 z m 3.49261,-1.63593 c 1.05602,-0.66537 1.47806,-3.54272 0.76867,-5.24053 -1.06024,-2.53752 -4.2164,-2.24958 -4.94489,0.45112 -0.36533,1.3544 -0.16041,3.36382 0.43584,4.27382 0.74822,1.14193 2.38765,1.36792 3.74038,0.51559 z m -39.87282,-3.28224 v -5.02708 h 3.3073 3.30729 v 0.66146 c 0,0.65913 -0.009,0.66145 -2.51354,0.66145 h -2.51355 v 1.45521 1.45521 h 2.24896 2.24896 v 0.79375 0.79375 h -2.24896 -2.24896 v 1.45521 1.45521 h 2.4915 c 2.49379,0 2.90743,0.15781 2.6006,0.99219 -0.0884,0.24024 -1.01868,0.33072 -3.40061,0.33072 h -3.27899 z m 7.9375,4.81268 c 0,-0.13636 0.65485,-1.20951 1.45521,-2.38478 0.80037,-1.17527 1.45521,-2.3002 1.45521,-2.49985 0,-0.19964 -0.60994,-1.24739 -1.35542,-2.32833 -1.81084,-2.62569 -1.8116,-2.62907 -0.59112,-2.61522 0.96613,0.011 1.07801,0.10373 2.1,1.74113 l 1.0795,1.72954 1.13439,-1.74113 c 1.02835,-1.57837 1.21805,-1.74112 2.02935,-1.74112 0.49223,0 0.89497,0.0976 0.89497,0.2168 0,0.11923 -0.45048,0.82058 -1.00107,1.55853 -0.55059,0.73796 -1.22383,1.75537 -1.49608,2.26091 l -0.495,0.91916 1.62837,2.43443 c 0.8956,1.33894 1.62836,2.48616 1.62836,2.54938 0,0.0632 -0.44834,0.11495 -0.99631,0.11495 -0.9347,0 -1.06576,-0.10637 -2.11905,-1.71979 -0.61751,-0.94588 -1.21097,-1.71645 -1.31879,-1.71237 -0.10783,0.004 -0.67231,0.74822 -1.25439,1.65365 -0.89834,1.39735 -1.18833,1.65876 -1.91823,1.72913 -0.47294,0.0456 -0.8599,-0.0286 -0.8599,-0.16502 z m 9.58531,-4.74653 0.072,-4.96094 2.36594,-0.0772 c 3.55309,-0.11592 4.9101,0.71487 4.9101,3.00606 0,2.39475 -0.96061,3.25698 -3.89065,3.49217 l -1.6656,0.1337 v 1.68357 1.68356 h -0.93188 -0.93188 z m 5.23136,-0.59532 c 0.52634,-0.52634 0.69769,-1.71985 0.32668,-2.27549 -0.36931,-0.55309 -1.35454,-0.89951 -2.55824,-0.89951 h -1.20802 v 1.85209 1.85208 h 1.45521 c 1.10243,0 1.58349,-0.12828 1.98437,-0.52917 z m 3.70417,0.51241 v -5.04385 l 0.85989,0.0829 0.8599,0.0829 0.0727,4.29948 0.0727,4.29948 h 2.2424 c 2.21551,0 2.24241,0.008 2.24241,0.66146 v 0.66145 h -3.175 -3.175 z m 17.99166,0.0168 v -5.02708 l 2.71198,0.001 c 2.22127,7.9e-4 2.86498,0.0943 3.55752,0.51654 1.45573,0.88762 1.51248,3.41795 0.10255,4.57238 l -0.73112,0.59863 0.8837,2.07396 c 0.48604,1.14069 0.88371,2.1305 0.88371,2.19958 0,0.0691 -0.38396,0.0883 -0.85324,0.0427 -0.77802,-0.0756 -0.91584,-0.23454 -1.56315,-1.8027 -0.91281,-2.21133 -1.12685,-2.42774 -2.30398,-2.32964 l -0.96817,0.0806 -0.0773,2.05052 -0.0773,2.05052 h -0.78261 -0.78263 v -5.02708 z m 5.1237,-1.07753 c 0.83208,-0.65453 0.88983,-1.37562 0.16797,-2.09748 -0.41157,-0.41157 -0.88194,-0.52917 -2.11667,-0.52917 h -1.5875 v 1.5875 1.5875 h 1.41953 c 1.03547,0 1.60814,-0.14836 2.11667,-0.54837 z m 4.4013,1.07752 v -5.02708 h 3.3073 3.30729 v 0.66146 c 0,0.65913 -0.009,0.66145 -2.51354,0.66145 h -2.51355 v 1.45521 1.45521 h 2.24896 2.24896 v 0.79375 0.79375 h -2.24896 -2.24896 v 1.45521 1.45521 h 2.51355 c 2.50472,0 2.51354,0.002 2.51354,0.66146 v 0.66145 h -3.30729 -3.3073 z m 8.73125,0 v -5.02708 h 2.75783 c 2.5548,0 2.81563,0.0486 3.543,0.66068 0.43184,0.36337 0.86108,0.96314 0.95387,1.33283 0.25133,1.00138 -0.29507,2.66624 -1.05728,3.22147 l -0.67115,0.4889 0.931,2.17514 0.931,2.17514 h -0.87632 c -0.83195,0 -0.914,-0.0904 -1.62079,-1.78593 -0.98454,-2.36185 -1.10215,-2.48856 -2.22268,-2.39463 l -0.94868,0.0795 -0.0773,2.05052 -0.0773,2.05052 h -0.78261 -0.78263 z m 4.96821,-0.94104 c 0.70292,-0.49234 0.77514,-1.58868 0.15016,-2.27927 -0.32907,-0.36362 -0.82218,-0.48386 -1.98437,-0.48386 h -1.5465 v 1.5875 1.5875 h 1.39634 c 0.83558,0 1.63249,-0.1654 1.98437,-0.41187 z m -97.572367,-9.5378 c -1.95888,-0.34634 -5.82084,-1.69103 -5.82084,-2.02677 0,-0.26055 1.56112,-3.71342 1.77614,-3.92844 0.17279,-0.17279 0.61281,-0.12624 1.29339,0.13682 1.5231,0.58871 4.90435,1.21598 6.554677,1.21598 2.60677,0 5.19386,-1.69122 5.19148,-3.39374 -0.003,-1.9815 -1.93734,-3.38013 -6.084437,-4.39883 -4.65894,-1.144439 -6.70269,-2.519669 -8.03124,-5.404209 -0.71141,-1.5446 -0.75135,-4.927329 -0.0761,-6.446859 0.70597,-1.588706 2.48912,-3.227144 4.27091,-3.924299 1.36156,-0.532739 2.0582,-0.621779 4.894797,-0.625631 3.42639,-0.0047 5.55454,0.402283 7.71765,1.475732 l 0.97077,0.481753 -0.70619,2.010008 c -0.80831,2.300676 -0.49127,2.212587 -4.26017,1.183737 -3.97503,-1.085119 -6.995447,-0.45827 -8.048857,1.670449 -0.41938,0.84746 -0.44249,1.10637 -0.15967,1.78914 0.64008,1.54529 2.27792,2.40158 6.602567,3.4519 5.57621,1.35429 8.19428,4.460389 7.88051,9.349499 -0.21116,3.29034 -1.90364,5.60538 -4.96953,6.79752 -1.17157,0.45555 -2.12561,0.57923 -4.89479,0.63451 -1.891777,0.0378 -3.737247,0.0161 -4.101047,-0.0483 z m 44.068567,-0.278 c -2.90689,-0.89207 -5.0522,-3.00939 -6.05006,-5.97114 -0.5442,-1.61522 -0.55975,-2.09043 -0.56972,-17.403258 l -0.0102,-15.742712 2.44739,-0.07608 2.4474,-0.07607 v 7.060514 7.060515 l 0.60161,-0.892728 c 1.56474,-2.321923 5.21496,-3.2175 9.04643,-2.219529 3.20198,0.834009 5.19421,2.399257 6.39662,5.02566 0.87053,1.901499 1.1483,3.650869 1.28015,8.062509 0.27496,9.199609 -2.10057,13.619369 -8.19669,15.250219 -1.75078,0.46837 -5.75011,0.42623 -7.39288,-0.0779 z m 6.49531,-4.951 c 1.40139,-0.66989 2.60987,-1.95865 3.38206,-3.60674 0.64143,-1.36901 0.68161,-1.69814 0.67829,-5.556249 -0.003,-3.1889 -0.10522,-4.39714 -0.46075,-5.43234 -1.99463,-5.807759 -9.23139,-5.807759 -11.22454,0 -0.5714,1.66496 -0.66746,9.662769 -0.13719,11.422529 0.38795,1.28749 1.62048,2.60265 3.10346,3.31151 1.26411,0.60425 3.22666,0.54581 4.65867,-0.13871 z m 36.45034,5.14101 c -2.86373,-0.55868 -5.63198,-2.86262 -6.48265,-5.39534 -0.73178,-2.17877 -0.62908,-5.24545 0.23901,-7.13684 1.7518,-3.816799 4.64695,-5.101829 11.69101,-5.189139 2.03163,-0.0252 3.74448,-0.0964 3.80632,-0.15823 0.2984,-0.2984 -0.60554,-3.52815 -1.21109,-4.32724 -1.09959,-1.451009 -2.65508,-1.980129 -5.34563,-1.818389 -1.24652,0.0749 -3.25381,0.3917 -4.46064,0.70393 -1.20684,0.31223 -2.30355,0.50013 -2.43715,0.41756 -0.1336,-0.0826 -0.32573,-0.59243 -0.42697,-1.13303 -0.10123,-0.540596 -0.34775,-1.375097 -0.54783,-1.854445 -0.20007,-0.479348 -0.29566,-0.98175 -0.21241,-1.116446 0.21936,-0.354926 3.38152,-1.248228 5.68631,-1.606368 2.64307,-0.410704 6.3822,-0.15327 8.3655,0.575956 2.33627,0.859007 4.12344,2.790721 4.91623,5.313863 0.57018,1.814649 0.60889,2.375279 0.60882,8.817039 -5e-5,5.765429 -0.0739,7.117489 -0.45609,8.351249 -0.88316,2.85093 -2.77719,4.61583 -5.82399,5.42691 -1.45391,0.38704 -6.18998,0.46426 -7.90875,0.12896 z m 6.92994,-5.20516 c 1.77043,-1.03673 2.34719,-2.50803 2.35786,-6.01479 l 0.006,-2.07533 -3.77031,0.097 c -3.29921,0.0848 -3.91253,0.17213 -4.90845,0.69861 -1.27367,0.67331 -2.69893,2.64825 -2.69733,3.73762 0.001,0.77735 0.83464,2.2531 1.67343,2.96288 1.53862,1.30197 5.57201,1.62847 7.33849,0.59406 z m -76.09176,4.86829 c -0.0718,-0.1876 -0.0996,-6.59187 -0.0618,-14.231709 l 0.0688,-13.890624 h 2.38125 2.38125 v 14.155204 14.155209 l -2.31943,0.0765 c -1.70628,0.0563 -2.35396,-0.0137 -2.45004,-0.26459 z m 10.10425,-0.0596 c -0.0802,-0.20897 -0.12269,-6.60857 -0.0945,-14.221349 l 0.0513,-13.841424 h 2.38125 2.38125 l 0.0684,14.221354 0.0684,14.221349 h -2.35524 c -1.80279,0 -2.38944,-0.0891 -2.50104,-0.37993 z m 35.11607,-0.30847 c -0.095,-0.37862 -0.11053,-4.99229 -0.0344,-10.25261 0.12618,-8.723899 0.18658,-9.715399 0.68747,-11.285189 1.29659,-4.063497 4.3606,-6.218859 9.29103,-6.535729 2.06626,-0.132795 5.29988,0.255791 5.29988,0.636886 0,0.08132 -0.2566,1.08418 -0.57023,2.228585 l -0.57023,2.080739 -2.15656,-0.16577 c -2.49177,-0.19153 -3.87041,0.25949 -5.3705,1.756959 -0.75709,0.75576 -1.76191,3.14282 -1.62992,3.87205 0.0228,0.12569 0.0273,4.30641 0.01,9.290499 l -0.0313,9.06198 h -2.37621 c -2.33876,0 -2.37893,-0.0108 -2.54898,-0.6884 z m -66.274827,-31.525 c -0.51012,-0.545486 -1.07676,-1.190866 -1.25921,-1.434176 -1.24584,-1.661446 -2.15941,-7.647514 -1.27868,-8.378449 0.35742,-0.296635 0.50679,-0.203116 1.06376,0.665993 0.37336,0.58261 0.71063,1.59372 0.79586,2.385928 0.0814,0.75616 0.33924,1.744826 0.57309,2.197036 0.23384,0.45221 0.49499,1.440876 0.58032,2.197036 0.0853,0.756161 0.32722,1.715942 0.53753,2.132844 0.60857,1.20638 0.0232,1.341514 -1.01267,0.233788 z m 2.33518,-0.912463 c -0.78294,-1.207471 -0.949,-2.328288 -0.34497,-2.328288 0.41496,0 2.3036,2.906953 2.1013,3.234277 -0.31707,0.513043 -1.09712,0.110664 -1.75633,-0.905989 z m 19.784637,0.858874 c -0.29105,-0.120382 -0.81183,-0.523184 -1.15731,-0.895117 -0.55328,-0.595656 -0.61424,-0.868323 -0.51154,-2.288169 0.14787,-2.044287 0.70615,-2.673019 2.52899,-2.848171 1.4406,-0.138425 2.76691,0.371144 3.23306,1.242139 0.42376,0.791816 0.32892,2.99125 -0.15903,3.6879 -0.76268,1.088877 -2.67211,1.623444 -3.93417,1.101418 z m 10.26163,0.07799 c -0.32245,-0.06853 -0.90073,-0.498295 -1.28506,-0.955046 -0.61462,-0.730435 -0.68434,-1.004578 -0.57889,-2.276186 0.10157,-1.224812 0.25311,-1.564764 0.99171,-2.224699 0.78581,-0.702127 1.01127,-0.767402 2.28555,-0.661731 1.64496,0.136414 2.35778,0.662517 2.77233,2.04614 0.37045,1.236472 -0.0824,3.038228 -0.89777,3.571827 -0.65206,0.426738 -2.38899,0.690716 -3.28787,0.499695 z m 39.75546,-1.455516 c -0.29653,-0.177535 -1.20228,-0.272483 -2.11667,-0.221882 -0.87909,0.04865 -4.69397,0.09826 -8.47751,0.110241 -6.435,0.02037 -6.93758,-0.01251 -7.78397,-0.509799 -1.26347,-0.74232 -1.8095,-2.382676 -1.36737,-4.107791 0.1744,-0.680468 0.2409,-1.540798 0.14777,-1.911847 -0.21966,-0.875197 -1.27098,-1.856041 -1.86604,-1.740942 -0.5115,0.09893 -1.67879,2.164519 -1.69064,2.991662 -0.0104,0.726839 -0.82481,2.78697 -1.10174,2.78697 -0.12575,0 -0.22863,-0.9525 -0.22863,-2.116667 0,-1.76389 -0.0882,-2.20486 -0.52917,-2.645833 -0.29104,-0.291042 -0.7205,-0.529167 -0.95435,-0.529167 -0.30134,0 -0.36952,-0.134858 -0.23409,-0.463021 0.1051,-0.254661 0.28802,-0.868269 0.40649,-1.363577 0.15095,-0.631092 0.8118,-1.46236 2.20859,-2.778125 1.43622,-1.352905 2.18899,-2.308765 2.69387,-3.420658 0.74796,-1.647211 1.82649,-2.555189 3.03725,-2.556962 0.3715,-5.29e-4 1.97885,0.490347 3.57187,1.090869 3.83806,1.446829 4.56223,1.507868 7.1508,0.602739 3.01212,-1.053232 4.23878,-0.986837 6.91838,0.374465 1.19732,0.608267 2.36214,1.105937 2.58849,1.105937 0.22635,0 0.97971,-0.287911 1.67413,-0.639802 1.74679,-0.885163 3.01721,-1.087554 3.93068,-0.626192 0.9818,0.495877 2.03292,1.101442 2.19747,1.265994 0.15255,0.152551 2.72448,1.837262 3.70417,2.426375 0.3638,0.218763 1.43537,0.542631 2.38125,0.719709 2.99201,0.560125 3.39696,0.760338 3.98279,1.969132 0.35752,0.737711 0.51671,1.548122 0.48115,2.449343 -0.0926,2.345902 2.17289,5.515176 2.79099,3.904435 0.0923,-0.240493 0.0322,-0.848045 -0.13348,-1.350118 -0.34463,-1.044218 -0.0719,-1.472047 1.11487,-1.748684 0.95905,-0.223563 1.99025,0.640426 2.77445,2.324568 0.80436,1.727398 0.79157,1.946883 -0.16307,2.799185 -1.41862,1.26654 -5.07464,1.602407 -21.82791,2.005256 -3.35735,0.08073 -4.91543,0.02297 -5.28082,-0.195813 z m 5.68028,-4.32481 c 1.18291,-0.746736 2.5284,-2.584754 2.23713,-3.056043 -0.18437,-0.298304 -1.0193,-0.247404 -2.83503,0.172836 -0.59591,0.137922 -0.7276,0.07816 -0.7276,-0.330192 0,-0.274224 0.28708,-0.921067 0.63796,-1.437425 0.83064,-1.222407 1.15012,-3.186075 0.59351,-3.648019 -0.44514,-0.369435 -0.4109,-0.415042 -1.86137,2.47964 -0.4375,0.873125 -0.951,1.744832 -1.14111,1.937128 -0.49165,0.497301 -0.42942,1.224616 0.21434,2.505184 0.62047,1.234238 1.25359,2.039979 1.60294,2.039979 0.12586,0 0.70151,-0.298389 1.27923,-0.663088 z m 10.83558,-0.900856 c 0.47081,-1.136634 0.25503,-1.478764 -0.93265,-1.478764 -1.28599,0 -1.48663,-0.589621 -0.52713,-1.549122 0.69901,-0.699008 0.88547,-1.597345 0.39453,-1.900764 -0.7978,-0.493069 -2.04158,2.305775 -1.70522,3.83721 0.0986,0.448789 0.47652,1.169297 0.83988,1.601128 0.77873,0.925462 1.40457,0.760236 1.93059,-0.509688 z M 155.5634,74.14944 c 0.50932,-0.1802 1.46083,-0.36475 2.11447,-0.41011 1.26066,-0.08749 3.35317,-0.704985 3.9048,-1.1523 0.47723,-0.386982 0.40641,-1.377841 -0.13229,-1.850882 -0.72957,-0.640646 -1.61816,-0.828932 -4.0435,-0.856782 -1.93665,-0.02225 -2.46318,-0.133008 -3.70416,-0.779272 -0.79564,-0.41434 -1.88046,-1.095203 -2.41073,-1.513027 -1.80091,-1.41904 -1.90833,-1.058617 -0.7384,2.477529 0.9759,2.94967 1.26258,3.424655 2.40734,3.988726 1.04811,0.516448 1.37981,0.528698 2.60247,0.09612 z m -48.71741,4.518051 c -0.75584,-0.625237 -1.23923,-1.693741 -1.41492,-3.127595 -0.14698,-1.199446 -0.0682,-1.678781 0.4775,-2.905003 1.67233,-3.757871 4.19907,-6.216615 6.89,-6.704573 0.38534,-0.06988 0.94096,-0.20333 1.23472,-0.296563 0.8268,-0.262419 2.84262,0.848153 3.54476,1.95291 1.04663,1.646785 2.41915,2.48593 3.62924,2.21887 0.57393,-0.126664 1.59669,-2.515833 1.75413,-4.097681 0.10368,-1.041612 -0.0254,-1.552559 -0.76848,-3.042708 l -0.89308,-1.790886 0.0841,-4.630208 c 0.0791,-4.356544 0.12399,-4.708397 0.75971,-5.953124 1.38713,-2.71598 2.27736,-3.644731 4.64358,-4.844518 2.07098,-1.050084 2.38275,-1.130576 4.58507,-1.183796 1.68752,-0.04078 2.86165,0.08791 4.10738,0.450175 1.84993,0.537972 2.92387,0.440743 2.37931,-0.215411 -0.17951,-0.216298 -1.12844,-0.434549 -2.3449,-0.539321 -3.51928,-0.30311 -4.34481,-1.003481 -2.47202,-2.097247 1.16316,-0.679324 3.50373,-0.620097 4.69949,0.118919 1.13086,0.698912 1.48162,1.769436 1.12483,3.433052 -0.33237,1.549826 -1.02426,2.121199 -3.13572,2.589562 -3.05232,0.677061 -7.3992,3.86716 -7.94452,5.830339 -0.14608,0.525889 -0.26767,1.432412 -0.27021,2.014495 -0.003,0.582084 -0.3046,2.248959 -0.67128,3.704167 -0.36667,1.455208 -0.73302,3.419739 -0.81411,4.365625 -0.22832,2.663033 -1.21812,5.429419 -3.31335,9.260416 -0.74008,1.353177 -0.71792,1.34838 -1.93549,0.418983 -1.45883,-1.113549 -3.33052,-1.596128 -4.85879,-1.252749 -1.07379,0.241266 -1.13531,0.220337 -1.51593,-0.515715 -0.46045,-0.890413 -1.38975,-1.026083 -2.04778,-0.298961 -0.45402,0.501682 -1.74517,3.401002 -2.45636,5.515801 -0.57096,1.69781 -0.80836,1.953474 -1.81395,1.953474 -0.46369,0 -1.02299,-0.148828 -1.24288,-0.330729 z M 98.677983,78.0112 c -1.64347,-0.791178 -1.71155,-1.369084 -0.39143,-3.322897 l 0.93148,-1.378624 -0.16733,-2.513542 c -0.16726,-2.512375 -0.16697,-2.51401 0.64084,-3.523012 0.444487,-0.555207 1.357347,-1.448175 2.028557,-1.984375 1.50167,-1.199612 2.83836,-3.509435 3.53031,-6.100426 0.44623,-1.670909 0.71416,-2.14621 1.86903,-3.31561 0.74148,-0.7508 1.34814,-1.445249 1.34814,-1.543219 0,-0.09797 -0.44112,-0.260884 -0.98026,-0.362027 -0.78348,-0.146981 -1.2525,-0.04136 -2.33637,0.526111 -1.64659,0.862102 -6.434197,5.505765 -7.306207,7.086542 -0.70093,1.270653 -1.40155,1.571013 -1.95645,0.838744 -0.60987,-0.80482 -0.44244,-1.492498 0.76857,-3.156788 2.8822,-3.960987 9.692297,-9.847603 12.857717,-11.114151 0.67892,-0.27165 1.26459,-0.808894 1.82554,-1.674598 1.53853,-2.374361 3.00047,-3.179549 6.28369,-3.460849 2.2984,-0.196922 2.70621,-0.326718 5.75862,-1.83285 2.69184,-1.328216 3.72518,-1.692106 5.72239,-2.01513 1.34053,-0.216816 2.65699,-0.511765 2.92545,-0.655442 0.58973,-0.315611 3.31898,-0.450679 3.5919,-0.177759 0.43429,0.434287 -0.515,0.79676 -2.597,0.99163 -1.21017,0.113269 -2.3964,0.283952 -2.63606,0.379295 -0.23967,0.09534 -1.212,1.100689 -2.16074,2.2341 -1.01357,1.210845 -2.25242,2.362992 -3.00384,2.793592 -0.70336,0.403061 -1.83688,1.271526 -2.51892,1.929918 l -1.24008,1.197078 -1.32251,-0.568928 c -2.84275,-1.222917 -4.1526,-0.612939 -8.0301,3.739483 -0.87946,0.987176 -1.52487,1.869009 -1.43425,1.959629 0.29663,0.296624 2.30602,-0.789364 2.83493,-1.532155 0.60988,-0.856496 2.60978,-1.695103 3.4753,-1.457285 0.33374,0.0917 0.95095,0.451141 1.37159,0.798764 0.68607,0.566978 0.80202,0.900305 1.1264,3.238272 0.35442,2.55451 0.33043,3.267697 -0.10995,3.267697 -0.26228,0 -0.75383,-1.332357 -1.00139,-2.714281 -0.33349,-1.861585 -1.51416,-2.765989 -2.5869,-1.981584 -0.76289,0.557842 -0.60384,2.166263 0.49759,5.031949 0.51792,1.347499 0.48262,3.30014 -0.08,4.426415 -0.40788,0.816483 -0.58782,0.929423 -1.52094,0.95463 -0.58209,0.01572 -1.45983,-0.133104 -1.95054,-0.330729 -1.71944,-0.692467 -3.48462,-0.309946 -4.72035,1.022914 -0.30704,0.331179 -1.00125,1.554644 -1.54268,2.71881 -0.54142,1.164167 -1.32765,2.616944 -1.74717,3.22839 -1.03788,1.512737 -1.66114,3.05924 -2.05775,5.105985 -0.39069,2.016209 -0.80523,3.23025 -1.19565,3.501678 -0.47925,0.333179 -1.826457,0.210013 -2.793157,-0.255365 z M 111.11171,56.257296 c 0.50839,-0.421452 0.84203,-0.848598 0.74141,-0.949211 -0.27914,-0.279141 -2.59179,0.931217 -2.59179,1.35645 0,0.604784 0.85716,0.416139 1.85038,-0.407239 z m 84.18841,20.177317 c -0.13816,-0.263506 -0.33393,-0.86451 -0.43506,-1.335569 -0.10683,-0.497625 -0.70194,-1.382482 -1.42037,-2.11193 -2.29928,-2.334538 -2.41739,-2.571001 -2.26763,-4.539729 0.21107,-2.774878 0.36379,-3.472079 0.76052,-3.472079 0.63467,0 1.40388,1.403969 2.47809,4.523023 0.57161,1.659681 1.23753,3.54285 1.47984,4.184821 0.48135,1.275268 0.46439,2.965154 -0.0314,3.127886 -0.17205,0.05647 -0.42586,-0.112919 -0.56401,-0.376423 z M 94.706333,75.158263 c -0.22702,-0.273544 -0.29095,-0.977987 -0.20182,-2.223799 0.2774,-3.877016 2.71375,-5.428858 3.0488,-1.941946 0.0993,1.033404 -0.0147,1.568677 -0.56575,2.656088 -0.37956,0.749028 -0.92057,1.485205 -1.20224,1.635948 -0.6582,0.352256 -0.68438,0.349192 -1.07899,-0.126291 z m 36.118527,-1.612561 c -0.43656,-0.352642 -1.24024,-0.777213 -1.78594,-0.943493 -0.5457,-0.166283 -0.99219,-0.390584 -0.99219,-0.498446 0,-0.107863 0.40601,-0.799434 0.90224,-1.536825 0.93743,-1.393023 1.47901,-3.261314 1.47901,-5.102153 0,-0.589584 0.34745,-2.280364 0.7721,-3.757289 0.42465,-1.476923 0.92012,-3.816408 1.10103,-5.198856 0.18483,-1.412357 0.54155,-2.874148 0.81425,-3.336655 0.62083,-1.052955 2.20045,-2.146321 3.83533,-2.654702 1.40618,-0.437267 6.26149,-0.792274 7.29343,-0.533273 0.90311,0.226668 1.41689,1.927111 0.99684,3.299206 -0.69251,2.26206 -2.18236,4.54834 -2.96391,4.54834 -0.19087,0 -1.13856,-0.714375 -2.10597,-1.5875 -0.96741,-0.873125 -1.88849,-1.5875 -2.04685,-1.5875 -0.56821,0 -0.27265,0.768589 0.50983,1.325759 1.31448,0.93599 1.64904,1.905288 1.6843,4.879657 0.0412,3.472669 -0.18968,3.887793 -3.19943,5.753764 -2.54476,1.577689 -3.07918,2.289058 -3.05237,4.062997 0.0158,1.042474 -0.029,1.105736 -0.94376,1.333505 -0.80241,0.199792 -1.00503,0.39824 -1.23225,1.20687 l -0.27194,0.967759 z m 66.46138,-0.989121 c -0.5873,-0.896326 -1.24159,-2.538227 -1.24159,-3.11567 0,-1.01968 0.506,-0.437943 1.23364,1.418297 0.41291,1.053332 0.79042,2.005832 0.83893,2.116666 0.21156,0.483397 -0.4623,0.143378 -0.83098,-0.419293 z m -10.62496,-4.387858 c -0.74348,-1.467175 -1.52951,-2.134615 -2.51561,-2.136073 -0.79888,-0.0011 -2.34116,-0.890641 -2.56762,-1.480802 -0.1076,-0.280408 0.27483,-0.856596 1.1483,-1.730062 l 1.30963,-1.309632 0.94246,0.739388 c 0.51835,0.406665 1.60228,1.156108 2.40874,1.665431 2.13443,1.348009 2.61454,2.849054 1.49214,4.665125 -0.78952,1.277474 -1.42085,1.159814 -2.21804,-0.413375 z M 98.289873,64.644577 c 0.36497,-2.188118 2.784567,-5.754687 3.904027,-5.754687 0.6448,0 1.24659,1.058637 1.24659,2.192932 0,1.805676 -0.95241,3.033776 -3.12364,4.027792 -2.058507,0.942417 -2.254397,0.897377 -2.026977,-0.466037 z m 78.572487,-1.361808 c -0.43656,-0.1919 -1.40796,-0.83626 -2.15866,-1.431914 -1.26545,-1.004086 -1.39427,-1.214099 -1.76775,-2.881884 -0.80814,-3.608758 -0.81237,-3.706524 -0.17516,-4.047548 0.94421,-0.505328 1.6849,-0.128453 2.32108,1.181013 0.32065,0.659973 0.67505,1.199954 0.78757,1.199954 0.11252,0 0.50102,0.274021 0.86333,0.608936 1.24587,1.151649 2.37855,1.098232 2.37855,-0.112165 0,-0.734095 0.76639,-0.603171 1.06488,0.181911 0.35599,0.936332 0.32361,3.121237 -0.0587,3.960299 -0.24556,0.538949 -1.95577,1.75859 -2.38471,1.700663 -0.0422,-0.0057 -0.43388,-0.167365 -0.87044,-0.359265 z m -23.28333,-1.326793 c -0.43657,-0.194032 -1.08454,-0.667903 -1.43995,-1.053049 -0.35541,-0.385144 -1.20594,-1.059447 -1.89007,-1.498452 -1.82383,-1.170353 -2.01802,-1.846149 -1.34551,-4.682577 0.8795,-3.709448 1.00084,-5.662962 0.41352,-6.657218 -0.45099,-0.763458 -0.60348,-0.831858 -1.81929,-0.816044 -2.07484,0.02698 -3.86372,-0.28203 -4.02211,-0.694785 -0.21217,-0.552892 0.42363,-1.895366 1.17907,-2.489595 1.04836,-0.824644 3.44682,-0.552608 6.08657,0.690346 1.18199,0.556554 2.22833,2.214846 2.37051,3.756896 0.10896,1.181819 0.0561,1.305808 -0.96551,2.264767 -1.11944,1.050798 -1.26421,1.455655 -0.81839,2.28868 0.78046,1.458306 3.70434,0.582895 6.48449,-1.94146 2.17936,-1.978842 2.85522,-2.274866 3.61792,-1.584629 0.56315,0.509646 1.30639,1.77982 1.2482,2.133148 -0.18524,1.124831 0.0825,1.711129 0.94621,2.072013 1.34762,0.56307 2.94048,0.494101 3.59003,-0.155443 0.29635,-0.296352 0.49466,-0.623774 0.4407,-0.727604 -0.054,-0.103833 -0.34944,-0.739167 -0.65662,-1.411851 -0.56018,-1.226733 -1.82619,-2.423994 -3.43175,-3.245398 -1.13077,-0.578495 -1.23169,-1.584637 -0.1323,-1.318829 1.6267,0.393293 3.46133,2.419038 5.19579,5.737048 1.56504,2.993911 2.02713,4.707498 1.58214,5.867138 -0.19206,0.500501 -3.46411,3.046645 -3.91525,3.046645 -0.16475,0 -1.00647,-0.714375 -1.87049,-1.5875 l -1.57093,-1.5875 -1.72808,0.0011 c -2.02318,0.0013 -3.21164,0.64234 -5.03823,2.717451 -1.19436,1.356873 -1.32646,1.402998 -2.51067,0.87667 z m 36.01631,-0.557363 c -0.23642,-0.236421 -0.42986,-0.581491 -0.42986,-0.766823 0,-0.387451 0.83901,-1.212734 1.2329,-1.212734 0.38808,0 0.95133,1.172803 0.84102,1.751161 -0.12301,0.644908 -1.09282,0.779637 -1.64406,0.228396 z m -6.92417,-1.83524 c -0.35719,-0.357187 -0.64944,-0.797271 -0.64944,-0.977963 0,-0.180695 -0.30102,-0.76291 -0.66893,-1.293813 -0.7211,-1.040545 -0.66351,-1.71863 0.16838,-1.982663 0.6233,-0.197829 4.86662,2.046462 5.26038,2.78221 0.75395,1.408774 0.0455,2.121662 -2.10862,2.121662 -1.08304,0 -1.48167,-0.129326 -2.00177,-0.649433 z m -9.8238,-7.622696 c -1.09021,-0.619228 -3.74463,-4.163287 -3.11822,-4.163287 1.08493,0 4.81042,3.354615 4.53181,4.080663 -0.19158,0.499237 -0.63453,0.525127 -1.41359,0.08262 z M 154.17434,44.22376 c -0.83675,-0.935689 -1.52136,-1.839057 -1.52136,-2.007484 0,-0.444583 1.94781,-0.08303 3.63802,0.675296 0.95763,0.429645 1.38907,0.776737 1.38907,1.117513 0,0.503175 -1.27908,1.916344 -1.73427,1.916074 -0.13756,-8e-5 -0.93472,-0.76571 -1.77146,-1.701399 z M 142.05395,42.5991 c -0.41029,-0.494368 -0.0619,-1.25153 0.63937,-1.389547 0.3082,-0.06066 0.43466,0.103124 0.43466,0.562915 0,0.907598 -0.61439,1.380466 -1.07403,0.826632 z" + id="path965" /> + </g> +</svg> diff --git a/src/services/effect/effect.ts b/src/services/effect/effect.ts index 2b6f202f21728f6d085f31e6e4c3f17e757504dc..10e5506143f0963ddaea23b81bd3e09509c8623b 100644 --- a/src/services/effect/effect.ts +++ b/src/services/effect/effect.ts @@ -4,11 +4,11 @@ import { select, Store } from "@ngrx/store"; import { merge, Observable, Subscription, combineLatest } from "rxjs"; import { filter, map, shareReplay, switchMap, take, withLatestFrom, mapTo, distinctUntilChanged } from "rxjs/operators"; import { LoggingService } from "src/logging"; -import { ADD_TO_REGIONS_SELECTION_WITH_IDS, DESELECT_REGIONS, SELECT_PARCELLATION, SELECT_REGIONS, SELECT_REGIONS_WITH_ID, SELECT_LANDMARKS } from "../state/viewerState.store"; import { IavRootStoreInterface, recursiveFindRegionWithLabelIndexId } from '../stateStore.service'; import { viewerStateNewViewer, viewerStateSelectAtlas, viewerStateSetSelectedRegionsWithIds, viewerStateToggleLayer } from "../state/viewerState.store.helper"; import { deserialiseParcRegionId, serialiseParcellationRegion } from "common/util" import { getGetRegionFromLabelIndexId } from 'src/util/fn' +import { actionAddToRegionsSelectionWithIds, actionSelectLandmarks, viewerStateSelectParcellation, viewerStateSelectRegionWithIdDeprecated, viewerStateSetSelectedRegions } from "../state/viewerState/actions"; @Injectable({ providedIn: 'root', @@ -53,26 +53,10 @@ export class UseEffects implements OnDestroy { /** * only allow 1 selection at a time */ - return { - type: SELECT_REGIONS, + return viewerStateSetSelectedRegions({ selectRegions: selectRegions.slice(0,1) - } - }) - ) - - this.onDeselectRegions = this.actions$.pipe( - ofType(DESELECT_REGIONS), - withLatestFrom(this.regionsSelected$), - map(([action, regionsSelected]) => { - const { deselectRegions } = action - const selectRegions = regionsSelected.filter(r => { - return !(deselectRegions as any[]).find(dr => compareRegions(dr, r)) }) - return { - type: SELECT_REGIONS, - selectRegions, - } - }), + }) ) this.onDeselectRegionsWithId$ = this.actions$.pipe( @@ -84,16 +68,15 @@ export class UseEffects implements OnDestroy { withLatestFrom(this.regionsSelected$), map(([ deselecRegionIds, alreadySelectedRegions ]) => { const deselectSet = new Set(deselecRegionIds) - return { - type: SELECT_REGIONS, + return viewerStateSetSelectedRegions({ selectRegions: alreadySelectedRegions .filter(({ ngId, labelIndex }) => !deselectSet.has(serialiseParcellationRegion({ ngId, labelIndex }))), - } + }) }), ) this.addToSelectedRegions$ = this.actions$.pipe( - ofType(ADD_TO_REGIONS_SELECTION_WITH_IDS), + ofType(actionAddToRegionsSelectionWithIds.type), map(action => { const { selectRegionIds } = action return selectRegionIds @@ -106,10 +89,9 @@ export class UseEffects implements OnDestroy { map(this.convertRegionIdsToRegion), withLatestFrom(this.regionsSelected$), map(([ selectedRegions, alreadySelectedRegions ]) => { - return { - type: SELECT_REGIONS, + return viewerStateSetSelectedRegions({ selectRegions: this.removeDuplicatedRegions(selectedRegions, alreadySelectedRegions), - } + }) }), ) } @@ -125,7 +107,7 @@ export class UseEffects implements OnDestroy { private subscriptions: Subscription[] = [] private parcellationSelected$ = this.actions$.pipe( - ofType(SELECT_PARCELLATION), + ofType(viewerStateSelectParcellation.type), ) @@ -136,9 +118,6 @@ export class UseEffects implements OnDestroy { shareReplay(1), ) - @Effect() - public onDeselectRegions: Observable<any> - @Effect() public onDeselectRegionsWithId$: Observable<any> @@ -192,7 +171,7 @@ export class UseEffects implements OnDestroy { */ @Effect() public onSelectRegionWithId = this.actions$.pipe( - ofType(SELECT_REGIONS_WITH_ID), + ofType(viewerStateSelectRegionWithIdDeprecated.type), map(action => { const { selectRegionIds } = action return selectRegionIds @@ -204,10 +183,9 @@ export class UseEffects implements OnDestroy { )), map(this.convertRegionIdsToRegion), map(selectRegions => { - return { - type: SELECT_REGIONS, - selectRegions, - } + return viewerStateSetSelectedRegions({ + selectRegions + }) }), ) @@ -227,10 +205,11 @@ export class UseEffects implements OnDestroy { ofType(viewerStateSelectAtlas.type) ) ).pipe( - mapTo({ - type: SELECT_REGIONS, - selectRegions: [], - }) + mapTo( + viewerStateSetSelectedRegions({ + selectRegions: [] + }) + ) ) /** @@ -241,10 +220,11 @@ export class UseEffects implements OnDestroy { @Effect() public onNewViewerResetLandmarkSelected$ = this.actions$.pipe( ofType(viewerStateNewViewer.type), - mapTo({ - type: SELECT_LANDMARKS, - landmarks: [] - }) + mapTo( + actionSelectLandmarks({ + landmarks: [] + }) + ) ) } diff --git a/src/services/state/ngViewerState.store.spec.ts b/src/services/state/ngViewerState.store.spec.ts index 40fb2682d0f4705c23db0db61cfe637ad4fc6882..21173833cf134a9e53274c3017abf97bdef947fb 100644 --- a/src/services/state/ngViewerState.store.spec.ts +++ b/src/services/state/ngViewerState.store.spec.ts @@ -24,8 +24,9 @@ describe('> ngViewerState.store.ts', () => { provideMockStore({ initialState: initState }), { provide: PureContantService, - useValue: class { - useTouchUI$ = of(false) + useValue: { + useTouchUI$: of(false), + backendUrl: `http://localhost:3000/` } } ] diff --git a/src/services/state/ngViewerState.store.ts b/src/services/state/ngViewerState.store.ts index 69c1ee08d890385126ddea84be2a3780be3650c2..b028a6be9f12ed69d22a7da64de7df40831c7dc9 100644 --- a/src/services/state/ngViewerState.store.ts +++ b/src/services/state/ngViewerState.store.ts @@ -4,7 +4,7 @@ import { Effect, Actions, ofType } from '@ngrx/effects'; import { withLatestFrom, map, distinctUntilChanged, scan, shareReplay, filter, mapTo, debounceTime, catchError, skip, throttleTime } from 'rxjs/operators'; import { getNgIds } from 'src/util/fn'; import { Action, select, Store, createReducer, on } from '@ngrx/store' -import { BACKENDURL, CYCLE_PANEL_MESSAGE } from 'src/util/constants'; +import { CYCLE_PANEL_MESSAGE } from 'src/util/constants'; import { HttpClient } from '@angular/common/http'; import { INgLayerInterface, ngViewerActionAddNgLayer, ngViewerActionRemoveNgLayer, ngViewerActionSetPerspOctantRemoval } from './ngViewerState.store.helper' import { PureContantService } from 'src/util'; @@ -208,7 +208,7 @@ export class NgViewerUseEffect implements OnDestroy { distinctUntilChanged(), throttleTime(1000) ).subscribe(ngViewerState => { - this.http.post(`${BACKENDURL}user/config`, JSON.stringify({ ngViewerState }), { + this.http.post(`${this.pureConstantService.backendUrl}user/config`, JSON.stringify({ ngViewerState }), { headers: { 'Content-type': 'application/json' } @@ -216,7 +216,7 @@ export class NgViewerUseEffect implements OnDestroy { }) ) - this.applySavedUserConfig$ = this.http.get<TUserConfigResp>(`${BACKENDURL}user/config`).pipe( + this.applySavedUserConfig$ = this.http.get<TUserConfigResp>(`${this.pureConstantService.backendUrl}user/config`).pipe( map(json => { if (json.error) { throw new Error(json.message || 'User not loggedin.') diff --git a/src/services/state/userConfigState.store.spec.ts b/src/services/state/userConfigState.store.spec.ts index 00ef5fb3326a16378fe3fdb8d1a89cc0b4a35389..6b497bec1c9e9e9a0a536021532c2add3aa14be2 100644 --- a/src/services/state/userConfigState.store.spec.ts +++ b/src/services/state/userConfigState.store.spec.ts @@ -5,6 +5,7 @@ import { Action } from "@ngrx/store" import { MockStore, provideMockStore } from "@ngrx/store/testing" import { from, Observable } from "rxjs" import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module" +import { PureContantService } from "src/util" import { DialogService } from "../dialogService.service" import { actionUpdatePluginCsp, UserConfigStateUseEffect } from "./userConfigState.store" import { viewerStateSelectedParcellationSelector, viewerStateSelectedRegionsSelector, viewerStateSelectedTemplateSelector } from "./viewerState/selectors" @@ -28,7 +29,13 @@ describe('> userConfigState.store.spec.ts', () => { } } }), - DialogService + DialogService, + { + provide: PureContantService, + useValue: { + backendUrl: 'http://localhost:3000/' + } + } ] }) diff --git a/src/services/state/userConfigState.store.ts b/src/services/state/userConfigState.store.ts index 964cd35efeb63378715fefb1144ff38a61f92412..81fef3205c4020651d6169012d43c19da67ec0f9 100644 --- a/src/services/state/userConfigState.store.ts +++ b/src/services/state/userConfigState.store.ts @@ -12,6 +12,7 @@ import { serialiseParcellationRegion } from 'common/util' import { HttpClient } from "@angular/common/http"; import { actionSetMobileUi, viewerStateNewViewer, viewerStateSelectParcellation, viewerStateSetSelectedRegions } from "./viewerState/actions"; import { viewerStateSelectedParcellationSelector, viewerStateSelectedRegionsSelector, viewerStateSelectedTemplateSelector } from "./viewerState/selectors"; +import { PureContantService } from "src/util"; interface ICsp{ 'connect-src'?: string[] @@ -119,6 +120,7 @@ export class UserConfigStateUseEffect implements OnDestroy { private store$: Store<any>, private dialogService: DialogService, private http: HttpClient, + private constantSvc: PureContantService, ) { const viewerState$ = this.store$.pipe( select('viewerState'), @@ -409,7 +411,7 @@ export class UserConfigStateUseEffect implements OnDestroy { public restoreSRSsFromStorage$: Observable<any> @Effect() - public setInitPluginPermission$ = this.http.get(`${BACKENDURL.replace(/\/+$/g, '/')}user/pluginPermissions`, { + public setInitPluginPermission$ = this.http.get(`${this.constantSvc.backendUrl}user/pluginPermissions`, { responseType: 'json' }).pipe( /** diff --git a/src/services/state/viewerState.store.helper.spec.ts b/src/services/state/viewerState.store.helper.spec.ts index c31955d5498e5a2b37f1e78fb26c84a3337b0ec3..fa0845554d221ea0051658ba8d096145913be883 100644 --- a/src/services/state/viewerState.store.helper.spec.ts +++ b/src/services/state/viewerState.store.helper.spec.ts @@ -1,6 +1,252 @@ -import { isNewerThan } from "./viewerState.store.helper" +import { TestBed } from "@angular/core/testing" +import { Action } from "@ngrx/store" +import { provideMockActions } from "@ngrx/effects/testing" +import { MockStore, provideMockStore } from "@ngrx/store/testing" +import { Observable, of } from "rxjs" +import { isNewerThan, ViewerStateHelperEffect } from "./viewerState.store.helper" +import { viewerStateGetSelectedAtlas, viewerStateSelectedTemplateSelector } from "./viewerState/selectors" +import { viewerStateHelperSelectParcellationWithId, viewerStateRemoveAdditionalLayer } from "./viewerState/actions" +import { generalActionError } from "../stateStore.helper" +import { hot } from "jasmine-marbles" describe('> viewerState.store.helper.ts', () => { + const tmplId = 'test-tmpl-id' + const tmplId0 = 'test-tmpl-id-0' + describe('> ViewerStateHelperEffect', () => { + let effect: ViewerStateHelperEffect + let mockStore: MockStore + let actions$: Observable<Action> + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + ViewerStateHelperEffect, + provideMockStore(), + provideMockActions(() => actions$) + ] + }) + + mockStore = TestBed.inject(MockStore) + mockStore.overrideSelector(viewerStateSelectedTemplateSelector, { + ['@id']: tmplId + }) + + actions$ = of( + viewerStateRemoveAdditionalLayer({ + payload: { + ['@id']: 'bla' + } + }) + ) + }) + + describe('> if selected atlas has no matching tmpl space', () => { + beforeEach(() => { + mockStore.overrideSelector(viewerStateGetSelectedAtlas, { + templateSpaces: [{ + ['@id']: tmplId0 + }] + }) + }) + it('> should emit gernal error', () => { + effect = TestBed.inject(ViewerStateHelperEffect) + effect.onRemoveAdditionalLayer$.subscribe(val => { + expect(val.type === generalActionError.type) + }) + }) + }) + + describe('> if selected atlas has matching tmpl', () => { + + const parcId0 = 'test-parc-id-0' + const parcId1 = 'test-parc-id-1' + const tmpSp = { + ['@id']: tmplId, + availableIn: [{ + ['@id']: parcId0 + }], + } + beforeEach(() => { + mockStore.overrideSelector(viewerStateGetSelectedAtlas, { + templateSpaces: [ + tmpSp + ], + parcellations: [], + }) + }) + + describe('> if parc is empty array', () => { + it('> should emit with falsy as payload', () => { + effect = TestBed.inject(ViewerStateHelperEffect) + expect( + effect.onRemoveAdditionalLayer$ + ).toBeObservable( + hot('(a|)', { + a: viewerStateHelperSelectParcellationWithId({ + payload: undefined + }) + }) + ) + }) + }) + describe('> if no parc has eligible @id', () => { + beforeEach(() => { + mockStore.overrideSelector(viewerStateGetSelectedAtlas, { + templateSpaces: [ + tmpSp + ], + parcellations: [{ + ['@id']: parcId1 + }] + }) + }) + it('> should emit with falsy as payload', () => { + effect = TestBed.inject(ViewerStateHelperEffect) + expect( + effect.onRemoveAdditionalLayer$ + ).toBeObservable( + hot('(a|)', { + a: viewerStateHelperSelectParcellationWithId({ + payload: undefined + }) + }) + ) + }) + }) + + describe('> if some parc has eligible @id', () => { + describe('> if no @version is available', () => { + const parc1 = { + ['@id']: parcId0, + name: 'p0-0', + baseLayer: true + } + const parc2 = { + ['@id']: parcId0, + name: 'p0-1', + baseLayer: true + } + beforeEach(() => { + + mockStore.overrideSelector(viewerStateGetSelectedAtlas, { + templateSpaces: [ + tmpSp + ], + parcellations: [ + parc1, + parc2 + ] + }) + }) + it('> selects the first parc', () => { + + effect = TestBed.inject(ViewerStateHelperEffect) + expect( + effect.onRemoveAdditionalLayer$ + ).toBeObservable( + hot('(a|)', { + a: viewerStateHelperSelectParcellationWithId({ + payload: parc1 + }) + }) + ) + }) + }) + + describe('> if @version is available', () => { + + describe('> if there exist an entry without @next attribute', () => { + + const parc1 = { + ['@id']: parcId0, + name: 'p0-0', + baseLayer: true, + ['@version']: { + ['@next']: 'random-value' + } + } + const parc2 = { + ['@id']: parcId0, + name: 'p0-1', + baseLayer: true, + ['@version']: { + ['@next']: null + } + } + beforeEach(() => { + + mockStore.overrideSelector(viewerStateGetSelectedAtlas, { + templateSpaces: [ + tmpSp + ], + parcellations: [ + parc1, + parc2 + ] + }) + }) + it('> selects the first one without @next attribute', () => { + + effect = TestBed.inject(ViewerStateHelperEffect) + expect( + effect.onRemoveAdditionalLayer$ + ).toBeObservable( + hot('(a|)', { + a: viewerStateHelperSelectParcellationWithId({ + payload: parc2 + }) + }) + ) + }) + }) + describe('> if there exist no entry without @next attribute', () => { + + const parc1 = { + ['@id']: parcId0, + name: 'p0-0', + baseLayer: true, + ['@version']: { + ['@next']: 'random-value' + } + } + const parc2 = { + ['@id']: parcId0, + name: 'p0-1', + baseLayer: true, + ['@version']: { + ['@next']: 'another-random-value' + } + } + beforeEach(() => { + + mockStore.overrideSelector(viewerStateGetSelectedAtlas, { + templateSpaces: [ + tmpSp + ], + parcellations: [ + parc1, + parc2 + ] + }) + }) + it('> selects the first one without @next attribute', () => { + + effect = TestBed.inject(ViewerStateHelperEffect) + expect( + effect.onRemoveAdditionalLayer$ + ).toBeObservable( + hot('(a|)', { + a: viewerStateHelperSelectParcellationWithId({ + payload: parc1 + }) + }) + ) + }) + }) + }) + }) + }) + }) + describe('> isNewerThan', () => { describe('> ill formed versions', () => { it('> in circular references, throws', () => { diff --git a/src/services/state/viewerState.store.helper.ts b/src/services/state/viewerState.store.helper.ts index 0e3b6dc333de2d8dc85b8fa1fb7d57c4dd47feb5..c636776f348b7233366e222de22d6faa6122e74b 100644 --- a/src/services/state/viewerState.store.helper.ts +++ b/src/services/state/viewerState.store.helper.ts @@ -112,7 +112,7 @@ export const viewerStateMetaReducers = [ export class ViewerStateHelperEffect{ @Effect() - selectParcellationWithId$: Observable<any> = this.actions$.pipe( + onRemoveAdditionalLayer$: Observable<any> = this.actions$.pipe( ofType(viewerStateRemoveAdditionalLayer.type), withLatestFrom( this.store$.pipe( @@ -133,7 +133,8 @@ export class ViewerStateHelperEffect{ const eligibleParcIdSet = new Set( tmpl.availableIn.map(p => p['@id']) ) - const baseLayer = selectedAtlas['parcellations'].find(fullP => fullP['baseLayer'] && eligibleParcIdSet.has(fullP['@id'])) + const baseLayers = selectedAtlas['parcellations'].filter(fullP => fullP['baseLayer'] && eligibleParcIdSet.has(fullP['@id'])) + const baseLayer = baseLayers.find(layer => !!layer['@version'] && !layer['@version']['@next']) || baseLayers[0] return viewerStateHelperSelectParcellationWithId({ payload: baseLayer }) }) ) diff --git a/src/services/state/viewerState.store.ts b/src/services/state/viewerState.store.ts index e0c165c85d0292d77789ce23a1cb7646786358f0..ef175c5b682df0cf6bbe3f8ef25da79eedd655a3 100644 --- a/src/services/state/viewerState.store.ts +++ b/src/services/state/viewerState.store.ts @@ -24,7 +24,7 @@ import { viewerStateNewViewer } from './viewerState.store.helper'; import { cvtNehubaConfigToNavigationObj } from 'src/state'; -import { viewerStateChangeNavigation, viewerStateNehubaLayerchanged } from './viewerState/actions'; +import { actionSelectLandmarks, viewerStateChangeNavigation, viewerStateNehubaLayerchanged } from './viewerState/actions'; import { serialiseParcellationRegion } from "common/util" export interface StateInterface { @@ -169,7 +169,7 @@ export const getStateStore = ({ state = defaultState } = {}) => (prevState: Part landmarksSelected : prevState.landmarksSelected.filter(lm => action.deselectLandmarks.findIndex(dLm => dLm.name === lm.name) < 0), } } - case SELECT_LANDMARKS : { + case actionSelectLandmarks.type: { return { ...prevState, landmarksSelected : action.landmarks, @@ -250,15 +250,10 @@ export const CHANGE_NAVIGATION = viewerStateChangeNavigation.type export const SELECT_PARCELLATION = viewerStateSelectParcellation.type -export const DESELECT_REGIONS = `DESELECT_REGIONS` -export const SELECT_REGIONS = `SELECT_REGIONS` -export const SELECT_REGIONS_WITH_ID = viewerStateSelectRegionWithIdDeprecated.type -export const SELECT_LANDMARKS = `SELECT_LANDMARKS` +export const SELECT_REGIONS = viewerStateSetSelectedRegions.type export const DESELECT_LANDMARKS = `DESELECT_LANDMARKS` export const USER_LANDMARKS = `USER_LANDMARKS` -export const ADD_TO_REGIONS_SELECTION_WITH_IDS = `ADD_TO_REGIONS_SELECTION_WITH_IDS` - export const SET_CONNECTIVITY_REGION = `SET_CONNECTIVITY_REGION` export const CLEAR_CONNECTIVITY_REGION = `CLEAR_CONNECTIVITY_REGION` export const SET_OVERWRITTEN_COLOR_MAP = `SET_OVERWRITTEN_COLOR_MAP` @@ -375,7 +370,7 @@ export class ViewerStateUseEffect { startWith([]), )), map(([{ segments }, regionsSelected]) => { - const selectedSet = new Set(regionsSelected.map(serialiseParcellationRegion)) + const selectedSet = new Set<string>(regionsSelected.map(serialiseParcellationRegion)) const toggleArr = segments.map(({ segment, layer }) => serialiseParcellationRegion({ ngId: layer.name, ...segment })) const deleteFlag = toggleArr.some(id => selectedSet.has(id)) @@ -384,10 +379,9 @@ export class ViewerStateUseEffect { if (deleteFlag) { selectedSet.delete(id) } else { selectedSet.add(id) } } - return { - type: SELECT_REGIONS_WITH_ID, + return viewerStateSelectRegionWithIdDeprecated({ selectRegionIds: [...selectedSet], - } + }) }), ) @@ -405,10 +399,9 @@ export class ViewerStateUseEffect { ? selectedSpatialDatas.filter((_, idx) => idx !== selectedIdx) : selectedSpatialDatas.concat(landmark) - return { - type: SELECT_LANDMARKS, + return actionSelectLandmarks({ landmarks: newSelectedSpatialDatas, - } + }) }), ) diff --git a/src/services/state/viewerState/actions.ts b/src/services/state/viewerState/actions.ts index 4dd72c7aa8268cb5b18b71b8ed8d5c5c17f235cd..7ef58b79f35746da5aa6836cf386fc89c2724c4b 100644 --- a/src/services/state/viewerState/actions.ts +++ b/src/services/state/viewerState/actions.ts @@ -88,7 +88,7 @@ export const viewerStateRemoveAdditionalLayer = createAction( export const viewerStateSelectRegionWithIdDeprecated = createAction( `[viewerState] [deprecated] selectRegionsWithId`, - props<{ selectRegionIds: number[] }>() + props<{ selectRegionIds: string[] }>() ) export const viewerStateDblClickOnViewer = createAction( @@ -124,4 +124,18 @@ export const viewerStateChangeNavigation = createAction( export const actionSetMobileUi = createAction( `[viewerState] setMobileUi`, props<{ payload: { useMobileUI: boolean } }>() -) \ No newline at end of file +) + +export const actionAddToRegionsSelectionWithIds = createAction( + `[viewerState] addToRegionSelectionWithIds`, + props<{ + selectRegionIds: string[] + }>() +) + +export const actionSelectLandmarks = createAction( + `[viewerState] selectLandmarks`, + props<{ + landmarks: any[] + }>() +) diff --git a/src/services/stateStore.service.ts b/src/services/stateStore.service.ts index 252ae89a3a54a998b4d24fdbc31b1b0b5061b30c..8a3d26cd970f83eb53bd1fd04fae703175caec1e 100644 --- a/src/services/stateStore.service.ts +++ b/src/services/stateStore.service.ts @@ -53,7 +53,7 @@ export { ViewerStateInterface, ViewerActionInterface, viewerState } export { IUiState, UIActionInterface, uiState } export { userConfigState, USER_CONFIG_ACTION_TYPES} -export { CHANGE_NAVIGATION, DESELECT_LANDMARKS, FETCHED_TEMPLATE, SELECT_LANDMARKS, SELECT_PARCELLATION, SELECT_REGIONS, USER_LANDMARKS } from './state/viewerState.store' +export { CHANGE_NAVIGATION, DESELECT_LANDMARKS, FETCHED_TEMPLATE, SELECT_PARCELLATION, SELECT_REGIONS, USER_LANDMARKS } from './state/viewerState.store' export { IDataEntry, IParcellationRegion, FETCHED_DATAENTRIES, FETCHED_SPATIAL_DATA, ILandmark, IOtherLandmarkGeometry, IPlaneLandmarkGeometry, IPointLandmarkGeometry, IProperty, IPublication, IReferenceSpace, IFile, IFileSupplementData } from './state/dataStore.store' export { CLOSE_SIDE_PANEL, MOUSE_OVER_SEGMENT, OPEN_SIDE_PANEL, COLLAPSE_SIDE_PANEL_CURRENT_VIEW, EXPAND_SIDE_PANEL_CURRENT_VIEW } from './state/uiState.store' export { UserConfigStateUseEffect } from './state/userConfigState.store' diff --git a/src/ui/help/helpOnePager/helpOnePager.template.html b/src/ui/help/helpOnePager/helpOnePager.template.html index c309155ae5174ca4c62034fc8ab758913cbc71a3..3a0d4edf74d1d972d846ac8d8b409005ce0694f2 100644 --- a/src/ui/help/helpOnePager/helpOnePager.template.html +++ b/src/ui/help/helpOnePager/helpOnePager.template.html @@ -2,6 +2,12 @@ </markdown-dom> <div mat-dialog-actions align="center"> + <button mat-button color="primary" mat-dialog-close quick-tour-opener> + <span class="d-flex align-items-center"> + Take a tour + <i class = "far fa-play-circle ml-1"></i> + </span> + </button> <a *ngIf="extQuickStarter" [href]="extQuickStarter" @@ -16,4 +22,4 @@ </a> <button mat-button mat-dialog-close cdkFocusInitial>close</button> -</div> \ No newline at end of file +</div> diff --git a/src/ui/help/module.ts b/src/ui/help/module.ts index 4a34ab43a3cf9efa449eb2edcd2150921a53e744..9decffaa2985077251f945e7a55c20b1924390d7 100644 --- a/src/ui/help/module.ts +++ b/src/ui/help/module.ts @@ -5,6 +5,7 @@ import { UtilModule } from "src/util"; import { AngularMaterialModule } from "../sharedModules/angularMaterial.module"; import { AboutCmp } from './about/about.component' import { HelpOnePager } from "./helpOnePager/helpOnePager.component"; +import {QuickTourModule} from "src/ui/quickTour/module"; @NgModule({ imports: [ @@ -12,6 +13,7 @@ import { HelpOnePager } from "./helpOnePager/helpOnePager.component"; AngularMaterialModule, ComponentsModule, UtilModule, + QuickTourModule ], declarations: [ AboutCmp, @@ -23,4 +25,4 @@ import { HelpOnePager } from "./helpOnePager/helpOnePager.component"; ] }) -export class HelpModule{} \ No newline at end of file +export class HelpModule{} diff --git a/src/ui/logoContainer/logoContainer.component.ts b/src/ui/logoContainer/logoContainer.component.ts index 6af1710795ec7efa9d2a9b8347e38b0e4c3269f2..b7a85fda32a4147929b64e02be4f72c8481066b4 100644 --- a/src/ui/logoContainer/logoContainer.component.ts +++ b/src/ui/logoContainer/logoContainer.component.ts @@ -1,5 +1,4 @@ import { Component } from "@angular/core"; -import { BACKENDURL } from "src/util/constants"; import { PureContantService } from "src/util"; import { Subscription } from "rxjs"; @@ -13,20 +12,20 @@ import { Subscription } from "rxjs"; export class LogoContainer { // only used to define size - public imgSrc = `${BACKENDURL}logo` + public imgSrc = `${this.pureConstantService.backendUrl}logo` public containerStyle = { - backgroundImage: `url('${BACKENDURL}logo')` + backgroundImage: `url('${this.pureConstantService.backendUrl}logo')` } private subscriptions: Subscription[] = [] constructor( - pureConstantService: PureContantService + private pureConstantService: PureContantService ){ this.subscriptions.push( pureConstantService.darktheme$.subscribe(flag => { this.containerStyle = { - backgroundImage: `url('${BACKENDURL}logo${!!flag ? '?darktheme=true' : ''}')` + backgroundImage: `url('${this.pureConstantService.backendUrl}logo${!!flag ? '?darktheme=true' : ''}')` } }) ) diff --git a/src/ui/quickTour/arrowCmp/arrow.component.ts b/src/ui/quickTour/arrowCmp/arrow.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..20ba5136030656d9435a27ea71baf375691e9e8a --- /dev/null +++ b/src/ui/quickTour/arrowCmp/arrow.component.ts @@ -0,0 +1,66 @@ +import { Component, HostBinding, Input, OnChanges } from "@angular/core"; + +@Component({ + selector: 'quick-tour-arrow', + templateUrl: './arrow.template.html', + styleUrls: [ + './arrow.style.css' + ] +}) + +export class ArrowComponent implements OnChanges{ + + @HostBinding('style.transform') + transform = `translate(0px, 0px)` + + stemStyle = {} + + headTranslate = 'translate(0px, 0px)' + + headStyle = { + transform: `rotate(0deg)` + } + + @Input('quick-tour-arrow-from') + fromPos: [number, number] + + @Input('quick-tour-arrow-to') + toPos: [number, number] + + @Input('quick-tour-arrow-type') + type: 'straight' | 'concave-from-top' | 'concave-from-bottom' = 'straight' + + ngOnChanges(){ + let rotate: string + switch(this.type) { + case 'concave-from-top': { + rotate = '0deg' + break + } + case 'concave-from-bottom': { + rotate = '180deg' + break + } + default: { + rotate = `${(Math.PI / 2) + Math.atan2( + (this.toPos[1] - this.fromPos[1]), + (this.toPos[0] - this.fromPos[0]) + )}rad` + } + } + + this.transform = `translate(${this.fromPos[0]}px, ${this.fromPos[1]}px)` + + this.headTranslate = ` + translateX(-1.2rem) + translate(${this.toPos[0] - this.fromPos[0]}px, ${this.toPos[1] - this.fromPos[1]}px) + rotate(${rotate}) + ` + const x = (this.toPos[0] - this.fromPos[0]) / 100 + const y = (this.toPos[1] - this.fromPos[1]) / 100 + + this.stemStyle = { + transform: `scale(${x}, ${y})` + } + } +} diff --git a/src/ui/quickTour/arrowCmp/arrow.style.css b/src/ui/quickTour/arrowCmp/arrow.style.css new file mode 100644 index 0000000000000000000000000000000000000000..5d4c5150c47ba915166d959e67a5f18c49ee289e --- /dev/null +++ b/src/ui/quickTour/arrowCmp/arrow.style.css @@ -0,0 +1,49 @@ +:host +{ + width: 0px; + height: 0px; +} + +:host-context([darktheme="true"]) svg +{ + stroke: rgb(200, 200, 200); +} + +svg +{ + pointer-events: none; + stroke-width: 5px; + stroke: rgb(255, 255, 255); + stroke-linecap: round; + stroke-linejoin: round; +} + +/* https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/vector-effect */ +svg path +{ + vector-effect: non-scaling-stroke; +} + +#arrow_head +{ + width: 2.4rem; + height: 2.4rem; + transform-origin: 50% 0; + stroke-width: 3px; +} + +#arrow_head #arrow_head_group +{ + transform-origin: 50% 10%; + transform: scale(0.95, 0.80); +} + +#arrow_stem +{ + transform-origin: 0 0; + width: 100px; + height: 100px; + position: absolute; + left: 0; + top: 0; +} diff --git a/src/ui/quickTour/arrowCmp/arrow.template.html b/src/ui/quickTour/arrowCmp/arrow.template.html new file mode 100644 index 0000000000000000000000000000000000000000..b6dc3e8ac6da7af3ee3a3539648eb5dbaf142a44 --- /dev/null +++ b/src/ui/quickTour/arrowCmp/arrow.template.html @@ -0,0 +1,56 @@ +<!-- arrow head point to top --> + +<svg id="arrow_head" + [style.transform]="headTranslate" + fill="none" + viewBox="0 0 100 100" + xmlns="http://www.w3.org/2000/svg"> + <g id="arrow_head_group"> + <path id="arrrow_head_path" d="M.5 75.5c10-43 31-75 50-75s37 27 50 74"/></g> +</svg> + +<!-- stem --> +<ng-container [ngSwitch]="type"> + <!-- arrow stem, bottom left top right, concave from top left --> + <ng-template [ngSwitchCase]="'concave-from-top'"> + <svg id="arrow_stem" + [style]="stemStyle" + viewBox="0 0 100 100" + fill="none" + preserveAspectRatio="none" + xmlns="http://www.w3.org/2000/svg"> + <g> + <path id="arrow_stem_concave_2" d="M0 0.499998C0 0.499998 26.0937 -0.22897 42 4C56.0254 7.72889 65.0753 9.44672 76 19C89.5421 30.8421 92.3854 42.6124 97 60C100.968 74.9525 100 99.5 100 99.5" /> + </g> + </svg> + + </ng-template> + <ng-template [ngSwitchCase]="'concave-from-bottom'"> + <svg id="arrow_stem" + [style]="stemStyle" + viewBox="0 0 100 100" + fill="none" + preserveAspectRatio="none" + xmlns="http://www.w3.org/2000/svg"> + <g> + <path id="arrow_stem_concave_2" d="M0 0.499998C0 0.499998 26.0937 -0.22897 42 4C56.0254 7.72889 65.0753 9.44672 76 19C89.5421 30.8421 92.3854 42.6124 97 60C100.968 74.9525 100 99.5 100 99.5" /> + </g> + </svg> + + </ng-template> + + <!-- arrow stem, straight --> + <ng-template ngSwitchDefault> + <svg id="arrow_stem" + [style]="stemStyle" + viewBox="0 0 100 100" + fill="none" + preserveAspectRatio="none" + xmlns="http://www.w3.org/2000/svg"> + + <g id="arrow_stem_straight"> + <path id="arrow_stem_straight_path" d="M0 0 100 100" /> + </g> + </svg> + </ng-template> +</ng-container> diff --git a/src/ui/quickTour/constrants.ts b/src/ui/quickTour/constrants.ts new file mode 100644 index 0000000000000000000000000000000000000000..118ec16f3042beb0a2fd1870a8b9a047f82ff18e --- /dev/null +++ b/src/ui/quickTour/constrants.ts @@ -0,0 +1,25 @@ +import { InjectionToken, TemplateRef } from "@angular/core" + +type TPosition = 'top' | 'top-right' | 'right' | 'bottom-right' | 'bottom' | 'bottom-left' | 'left' | 'top-left' + +type TCustomPosition = { + left: number + top: number +} + +export interface IQuickTourData { + order: number + description: string + tourPosition?: TPosition + overwritePosition?: IQuickTourOverwritePosition + overwriteArrow?: TemplateRef<any> | string +} + +export interface IQuickTourOverwritePosition { + dialog: TCustomPosition + arrow: TCustomPosition +} + +export type TQuickTourPosition = TPosition + +export const QUICK_TOUR_CMP_INJTKN = new InjectionToken('QUICK_TOUR_CMP_INJTKN') diff --git a/src/ui/quickTour/index.ts b/src/ui/quickTour/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8208c2cc7010ca2395c5b205369bef4784822946 --- /dev/null +++ b/src/ui/quickTour/index.ts @@ -0,0 +1,11 @@ +export { + QuickTourModule +} from './module' + +export { + QuickTourThis +} from './quickTourThis.directive' + +export { + IQuickTourData +} from './constrants' diff --git a/src/ui/quickTour/module.ts b/src/ui/quickTour/module.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b68f709b3ab1fa16e24b22f9dd8463684d17596 --- /dev/null +++ b/src/ui/quickTour/module.ts @@ -0,0 +1,43 @@ +import { FullscreenOverlayContainer, OverlayContainer } from "@angular/cdk/overlay"; +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { UtilModule } from "src/util"; +import { AngularMaterialModule } from "../sharedModules/angularMaterial.module"; +import { QuickTourThis } from "src/ui/quickTour/quickTourThis.directive"; +import { QuickTourService } from "src/ui/quickTour/quickTour.service"; +import { QuickTourComponent } from "src/ui/quickTour/quickTourComponent/quickTour.component"; +import { QuickTourDirective } from "src/ui/quickTour/quickTour.directive"; +import { ArrowComponent } from "./arrowCmp/arrow.component"; +import { WindowResizeModule } from "src/util/windowResize"; +import { QUICK_TOUR_CMP_INJTKN } from "./constrants"; + +@NgModule({ + imports: [ + CommonModule, + AngularMaterialModule, + UtilModule, + WindowResizeModule, + ], + declarations:[ + QuickTourThis, + QuickTourComponent, + QuickTourDirective, + ArrowComponent, + ], + exports: [ + QuickTourDirective, + QuickTourThis, + ], + providers:[ + { + provide: OverlayContainer, + useClass: FullscreenOverlayContainer + }, + QuickTourService, + { + provide: QUICK_TOUR_CMP_INJTKN, + useValue: QuickTourComponent + } + ] +}) +export class QuickTourModule{} diff --git a/src/ui/quickTour/quickTour.directive.ts b/src/ui/quickTour/quickTour.directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b593a895cffd7237eadde787a35e4dc2815c1a8 --- /dev/null +++ b/src/ui/quickTour/quickTour.directive.ts @@ -0,0 +1,25 @@ +import { Directive, HostListener } from "@angular/core"; +import { QuickTourService } from "./quickTour.service"; + +@Directive({ + selector: '[quick-tour-opener]' +}) + +export class QuickTourDirective { + + constructor( + private quickTourService: QuickTourService + ){} + + @HostListener('window:keydown', ['$event']) + keyListener(ev: KeyboardEvent){ + if (ev.key === 'Escape') { + this.quickTourService.endTour() + } + } + + @HostListener('click') + onClick(){ + this.quickTourService.startTour() + } +} diff --git a/src/ui/quickTour/quickTour.service.ts b/src/ui/quickTour/quickTour.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..78bd0c164431b0b40c119cbce56f6380eaec28a4 --- /dev/null +++ b/src/ui/quickTour/quickTour.service.ts @@ -0,0 +1,109 @@ +import { ComponentRef, Inject, Injectable } from "@angular/core"; +import { BehaviorSubject, Subject } from "rxjs"; +import { Overlay, OverlayRef } from "@angular/cdk/overlay"; +import { ComponentPortal } from "@angular/cdk/portal"; +import { QuickTourThis } from "./quickTourThis.directive"; +import { DoublyLinkedList, IDoublyLinkedItem } from 'src/util' +import { QUICK_TOUR_CMP_INJTKN } from "./constrants"; + +export function findInLinkedList<T extends object>(first: IDoublyLinkedItem<T>, predicate: (linkedObj: IDoublyLinkedItem<T>) => boolean): IDoublyLinkedItem<T>{ + let compareObj = first, + returnObj: IDoublyLinkedItem<T> = null + + do { + if (predicate(compareObj)) { + returnObj = compareObj + break + } + compareObj = compareObj.next + } while(!!compareObj) + + return returnObj +} + +@Injectable() +export class QuickTourService { + + private overlayRef: OverlayRef + private cmpRef: ComponentRef<any> + + public currSlideNum: number = null + public currentTip$: BehaviorSubject<IDoublyLinkedItem<QuickTourThis>> = new BehaviorSubject(null) + public detectChanges$: Subject<null> = new Subject() + + public currActiveSlide: IDoublyLinkedItem<QuickTourThis> + public slides = new DoublyLinkedList<QuickTourThis>() + + constructor( + private overlay: Overlay, + /** + * quickTourService cannot directly reference quickTourComponent + * since quickTourComponent DI quickTourService + * makes sense, since we want to keep the dependency of svc on cmp as loosely (or non existent) as possible + */ + @Inject(QUICK_TOUR_CMP_INJTKN) private quickTourCmp: any, + ){ + } + + public register(dir: QuickTourThis) { + this.slides.insertAfter( + dir, + linkedItem => { + const nextItem = linkedItem.next + if (nextItem && nextItem.thisObj.order < dir.order) { + return false + } + return linkedItem.thisObj.order < dir.order + } + ) + } + + public unregister(dir: QuickTourThis){ + this.slides.remove(dir) + } + + public startTour() { + if (!this.overlayRef) { + this.overlayRef = this.overlay.create({ + height: '0px', + width: '0px', + hasBackdrop: true, + backdropClass: ['pe-none', 'cdk-overlay-dark-backdrop'], + positionStrategy: this.overlay.position().global(), + }) + } + + if (!this.cmpRef) { + this.cmpRef = this.overlayRef.attach( + new ComponentPortal(this.quickTourCmp) + ) + + this.currActiveSlide = this.slides.first + this.currentTip$.next(this.currActiveSlide) + } + } + + public endTour() { + if (this.overlayRef) { + this.overlayRef.dispose() + this.overlayRef = null + this.cmpRef = null + } + } + + public nextSlide() { + this.currActiveSlide = this.currActiveSlide.next + this.currentTip$.next(this.currActiveSlide) + } + + public previousSlide() { + this.currActiveSlide = this.currActiveSlide.prev + this.currentTip$.next(this.currActiveSlide) + } + + changeDetected(dir: QuickTourThis) { + if (this.currActiveSlide?.thisObj === dir) { + this.detectChanges$.next(null) + } + } +} diff --git a/src/ui/quickTour/quickTourComponent/quickTour.component.ts b/src/ui/quickTour/quickTourComponent/quickTour.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..38d87fc5e663ab019389a0688a0d8a116f3a2cfc --- /dev/null +++ b/src/ui/quickTour/quickTourComponent/quickTour.component.ts @@ -0,0 +1,341 @@ +import { + Component, + ElementRef, + HostListener, + SecurityContext, + TemplateRef, + ViewChild, +} from "@angular/core"; +import { combineLatest, Subscription } from "rxjs"; +import { QuickTourService } from "../quickTour.service"; +import { debounceTime, map, shareReplay, tap } from "rxjs/operators"; +import { DomSanitizer } from "@angular/platform-browser"; +import { QuickTourThis } from "../quickTourThis.directive"; +import { clamp } from "src/util/generator"; + +@Component({ + templateUrl : './quickTour.template.html', + styleUrls : [ + './quickTour.style.css' + ], +}) +export class QuickTourComponent { + + static TourCardMargin = 24 + static TourCardWidthPx = 256 + static TourCardHeightPx = 64 + static TourCardWindowMargin = 8 + + @ViewChild('quickTourDialog', { read: ElementRef }) + private quickTourDialog: ElementRef + + public tourCardWidth = `${QuickTourComponent.TourCardWidthPx}px` + public arrowTmpl: TemplateRef<any> + public arrowSrc: string + + @HostListener('window:keydown', ['$event']) + keydownListener(ev: KeyboardEvent){ + if (ev.key === 'Escape') { + this.quickTourService.endTour() + } + } + + public tourCardTransform = `translate(-500px, -500px)` + public customArrowTransform = `translate(-500px, -500px)` + + public arrowFrom: [number, number] = [0, 0] + public arrowTo: [number, number] = [0, 0] + public arrowType: 'straight' | 'concave-from-top' | 'concave-from-bottom' = 'straight' + + private subscriptions: Subscription[] = [] + private currTipLinkedObj$ = this.quickTourService.currentTip$.pipe( + shareReplay(1) + ) + + public isLast$ = this.currTipLinkedObj$.pipe( + map(val => !val.next) + ) + + public isFirst$ = this.currTipLinkedObj$.pipe( + map(val => !val.prev) + ) + + public description$ = this.currTipLinkedObj$.pipe( + map(val => val.thisObj.description) + ) + + private quickTourSize$ = this.currTipLinkedObj$.pipe( + map(val => val.list.size()) + ) + + private quickTourIdx$ = this.currTipLinkedObj$.pipe( + map(val => val.index) + ) + + public quickTourProgress$ = combineLatest([ + this.quickTourSize$, + this.quickTourIdx$ + ]).pipe( + map(([ size, idx ]) => { + return Array(size).fill(false).map((_, index) => index === idx ? 'active' : _) + }) + ) + + public overwrittenPosition = this.currTipLinkedObj$.pipe( + map(val => val.thisObj.overwritePosition) + ) + + private currTip: QuickTourThis + + constructor( + public quickTourService: QuickTourService, + private sanitizer: DomSanitizer, + ) { + + this.subscriptions.push( + + this.quickTourService.detectChanges$.pipe( + /** + * the debounce does two things: + * - debounce expensive calculate transform call + * - allow change detection to finish rendering element + */ + debounceTime(16) + ).subscribe(() => { + this.calculateTransforms() + }), + + this.quickTourService.currentTip$.pipe( + /** + * subscriber is quite expensive. + * only calculate at most once every 16 ms + */ + debounceTime(16) + ).subscribe(linkedObj => { + this.arrowTmpl = null + this.arrowSrc = null + this.currTip = null + + if (!linkedObj) { + // exit quick tour? + return + } + this.currTip = linkedObj.thisObj + this.calculateTransforms() + }) + ) + } + + nextSlide(){ + this.quickTourService.nextSlide() + } + + prevSlide(){ + this.quickTourService.previousSlide() + } + + endTour(){ + this.quickTourService.endTour() + } + + calculateTransforms() { + if (!this.currTip) { + return + } + const tip = this.currTip + + if (tip.overWriteArrow) { + if (typeof tip.overWriteArrow === 'string') { + this.arrowSrc = tip.overWriteArrow + } else { + this.arrowTmpl = tip.overWriteArrow + } + } + + if (tip.overwritePosition) { + const { dialog, arrow } = tip.overwritePosition + const { top: dialogTop, left: dialogLeft } = dialog + const { + top: arrowTop, + left: arrowLeft, + } = arrow + this.tourCardTransform = this.sanitizer.sanitize( + SecurityContext.STYLE, + `translate(${dialogLeft}, ${dialogTop})` + ) + this.customArrowTransform = this.sanitizer.sanitize( + SecurityContext.STYLE, + `translate(${arrowLeft}, ${arrowTop})` + ) + return + } + + const { x: hostX, y: hostY, width: hostWidth, height: hostHeight } = tip.getHostPos() + const { innerWidth, innerHeight } = window + + const { position: tipPosition } = this.currTip + const translate: {x: number, y: number} = { x: hostX + hostWidth / 2, y: hostY + hostHeight / 2 } + + const hostCogX = hostX + hostWidth / 2 + const hostCogY = hostY + hostHeight / 2 + + let calcedPos: string = '' + + /** + * if position is unspecfied, try to figure out position + */ + if (!tipPosition) { + + // if host centre of grav is to the right of the screen + // position tour el to the left, otherwise, right + calcedPos += hostCogX > (innerWidth / 2) + ? 'left' + : 'right' + + // if host centre of grav is to the bottom of the screen + // position tour el to the top, otherwise, bottom + calcedPos += hostCogY > (innerHeight / 2) + ? 'top' + : 'bottom' + } + + /** + * if the directive specified where helper should appear + * set the offset directly + */ + + const usePosition = tipPosition || calcedPos + + /** + * default behaviour: center + * overwrite if align keywords appear + */ + + if (usePosition.includes('top')) { + translate.y = hostY + } + if (usePosition.includes('bottom')) { + translate.y = hostY + hostHeight + } + if (usePosition.includes('left')) { + translate.x = hostCogX + } + if (usePosition.includes('right')) { + translate.x = hostCogX + } + + /** + * set tour card transform + * set a given margin, so + */ + const { width: cmpWidth, height: cmpHeight } = this.quickTourDialog + ? (this.quickTourDialog.nativeElement as HTMLElement).getBoundingClientRect() + : {} as any + + const tourCardMargin = QuickTourComponent.TourCardMargin + const tourCardWidth = cmpWidth || QuickTourComponent.TourCardWidthPx + const tourCardHeight = cmpHeight || QuickTourComponent.TourCardHeightPx + const tourCardWindowMargin = QuickTourComponent.TourCardWindowMargin + + /** + * catch if element is off screen + * clamp it inside the viewport + */ + const tourCardTranslate = [ + clamp(translate.x, 0, innerWidth), + clamp(translate.y, 0, innerHeight), + ] + if (usePosition.includes('top')) { + tourCardTranslate[1] += -1 * tourCardMargin - tourCardHeight + } + if (usePosition.includes('bottom')) { + tourCardTranslate[1] += tourCardMargin + } + if (usePosition.includes('left')) { + tourCardTranslate[0] += -1 * tourCardMargin - tourCardWidth + } + if (usePosition.includes('right')) { + tourCardTranslate[0] += tourCardMargin + } + tourCardTranslate[0] = clamp( + tourCardTranslate[0], + tourCardWindowMargin, + innerWidth - tourCardWidth - tourCardWindowMargin + ) + + tourCardTranslate[1] = clamp( + tourCardTranslate[1], + tourCardWindowMargin, + innerHeight - tourCardHeight - tourCardWindowMargin + ) + this.tourCardTransform = `translate(${tourCardTranslate[0]}px, ${tourCardTranslate[1]}px)` + + /** + * set arrow from / to + */ + + const { + arrowTo + } = (() => { + if (usePosition.includes('top')) { + return { + arrowTo: [ hostCogX, hostY ] + } + } + if (usePosition.includes('bottom')) { + return { + arrowTo: [ hostCogX, hostY + hostHeight ] + } + } + if (usePosition.includes('left')) { + return { + arrowTo: [ hostX, hostCogY ] + } + } + if (usePosition.includes('right')) { + return { + arrowTo: [ hostX + hostWidth, hostCogY ] + } + } + })() + + + const arrowFrom = [ arrowTo[0], arrowTo[1] ] + + if (usePosition.includes('top')) { + arrowFrom[1] -= tourCardMargin + (tourCardHeight / 2) + this.arrowType = 'concave-from-bottom' + } + if (usePosition.includes('bottom')) { + arrowFrom[1] += tourCardMargin + (tourCardHeight / 2) + this.arrowType = 'concave-from-top' + } + if (usePosition.includes('left')) { + arrowFrom[0] -= tourCardMargin + this.arrowType = 'straight' + } + if (usePosition.includes('right')) { + arrowFrom[0] += tourCardMargin + this.arrowType = 'straight' + } + this.arrowFrom = arrowFrom as [number, number] + this.arrowTo = arrowTo as [number, number] + + /** + * set arrow type + */ + + this.arrowType = 'straight' + + if (usePosition.includes('top')) { + this.arrowType = 'concave-from-bottom' + } + if (usePosition.includes('bottom')) { + this.arrowType = 'concave-from-top' + } + } + + handleWindowResize(){ + this.calculateTransforms() + } +} diff --git a/src/ui/quickTour/quickTourComponent/quickTour.style.css b/src/ui/quickTour/quickTourComponent/quickTour.style.css new file mode 100644 index 0000000000000000000000000000000000000000..c6d9f7bc0c7df887ee87b0114e6b37c18187f437 --- /dev/null +++ b/src/ui/quickTour/quickTourComponent/quickTour.style.css @@ -0,0 +1,23 @@ +:host +{ + display: inline-flex; + width: 0px; + height: 0px; + position: relative; +} + +mat-card +{ + position: absolute; + margin: 0; + z-index: 10; +} + +.custom-svg >>> svg +{ + pointer-events: none; + stroke-width: 3px; + stroke: rgb(255, 255, 255); + stroke-linecap: round; + stroke-linejoin: round; +} diff --git a/src/ui/quickTour/quickTourComponent/quickTour.template.html b/src/ui/quickTour/quickTourComponent/quickTour.template.html new file mode 100644 index 0000000000000000000000000000000000000000..14854ea6fc57de360936e43f881420dd432e90e2 --- /dev/null +++ b/src/ui/quickTour/quickTourComponent/quickTour.template.html @@ -0,0 +1,49 @@ +<mat-card + iav-window-resize + [iav-window-resize-time]="64" + (iav-window-resize-event)="handleWindowResize()" + [style.width]="tourCardWidth" + [style.transform]="tourCardTransform" + #quickTourDialog> + <mat-card-content> + {{ description$ | async }} + </mat-card-content> + <mat-card-actions> + <button mat-icon-button + (click)="prevSlide()" + [disabled]="isFirst$ | async"> + <i class="fas fa-chevron-left"></i> + </button> + <button mat-icon-button + (click)="nextSlide()" + [disabled]="isLast$ | async"> + <i class="fas fa-chevron-right"></i> + </button> + <button mat-icon-button + (click)="endTour()"> + <i class="fas fa-times"></i> + </button> + <span class="muted d-inline-flex align-items-center"> + <i *ngFor="let active of quickTourProgress$ | async" + [ngClass]="{ 'fa-xs muted-3': !active }" + class="ml-1 fas fa-circle"></i> + </span> + </mat-card-actions> +</mat-card> + +<div *ngIf="arrowTmpl" [style.transform]="customArrowTransform" + class="custom-svg"> + <ng-container *ngTemplateOutlet="arrowTmpl"> + </ng-container> +</div> + +<ng-template [ngIf]="arrowSrc"> + arrow src not yet implmented +</ng-template> + +<quick-tour-arrow + *ngIf="!arrowTmpl && !arrowSrc" + [quick-tour-arrow-to]="arrowTo" + [quick-tour-arrow-from]="arrowFrom" + [quick-tour-arrow-type]="arrowType"> +</quick-tour-arrow> \ No newline at end of file diff --git a/src/ui/quickTour/quickTourThis.directive.ts b/src/ui/quickTour/quickTourThis.directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..ceec5f72cd5cf083917583c439519d5be0598150 --- /dev/null +++ b/src/ui/quickTour/quickTourThis.directive.ts @@ -0,0 +1,45 @@ +import { Directive, ElementRef, Input, OnChanges, OnDestroy, OnInit, TemplateRef } from "@angular/core"; +import { QuickTourService } from "src/ui/quickTour/quickTour.service"; +import { IQuickTourOverwritePosition, TQuickTourPosition } from "src/ui/quickTour/constrants"; + +@Directive({ + selector: '[quick-tour]', + exportAs: 'quickTour' +}) +export class QuickTourThis implements OnInit, OnChanges, OnDestroy { + + @Input('quick-tour-order') order: number = 0 + @Input('quick-tour-description') description: string = 'No description' + @Input('quick-tour-position') position: TQuickTourPosition + @Input('quick-tour-overwrite-position') overwritePosition: IQuickTourOverwritePosition + @Input('quick-tour-overwrite-arrow') overWriteArrow: TemplateRef<any> | string + + private attachedTmpl: ElementRef + + constructor( + private quickTourService: QuickTourService, + private el: ElementRef + ) {} + + public getHostPos() { + const { x, y, width, height } = (this.attachedTmpl?.nativeElement || this.el.nativeElement as HTMLElement).getBoundingClientRect() + return { x, y, width, height } + } + + ngOnInit() { + this.quickTourService.register(this) + } + + ngOnChanges() { + this.quickTourService.changeDetected(this) + } + + ngOnDestroy() { + this.quickTourService.unregister(this) + } + + attachTo(tmp: ElementRef){ + this.attachedTmpl = tmp + this.quickTourService.changeDetected(this) + } +} diff --git a/src/ui/topMenu/module.ts b/src/ui/topMenu/module.ts index 97f253f6bae0bb4599f869b3c8ffa5e999044545..0d5b1aabd1cfca8d3a9bcf80b4bbd63687f2f1bc 100644 --- a/src/ui/topMenu/module.ts +++ b/src/ui/topMenu/module.ts @@ -13,7 +13,8 @@ import { KgTosModule } from "../kgtos/module"; import { ScreenshotModule } from "../screenshot"; import { AngularMaterialModule } from "../sharedModules/angularMaterial.module"; import { TopMenuCmp } from "./topMenuCmp/topMenu.components"; -import {UserAnnotationsModule} from "src/atlasComponents/userAnnotations"; +import { UserAnnotationsModule } from "src/atlasComponents/userAnnotations"; +import { QuickTourModule } from "src/ui/quickTour/module"; @NgModule({ imports: [ @@ -30,7 +31,8 @@ import {UserAnnotationsModule} from "src/atlasComponents/userAnnotations"; PluginModule, AuthModule, ScreenshotModule, - UserAnnotationsModule + UserAnnotationsModule, + QuickTourModule, ], declarations: [ TopMenuCmp diff --git a/src/ui/topMenu/topMenuCmp/topMenu.components.ts b/src/ui/topMenu/topMenuCmp/topMenu.components.ts index 4a78e0d12fb6231da10f7d954c8e6bdaa4bc850d..a15328676e0e0504285ed0d46d1d4b80b51fceac 100644 --- a/src/ui/topMenu/topMenuCmp/topMenu.components.ts +++ b/src/ui/topMenu/topMenuCmp/topMenu.components.ts @@ -1,6 +1,5 @@ import { ChangeDetectionStrategy, - ChangeDetectorRef, Component, Input, TemplateRef, @@ -12,7 +11,8 @@ import { AuthService } from "src/auth"; import { IavRootStoreInterface, IDataEntry } from "src/services/stateStore.service"; import { MatDialog, MatDialogConfig, MatDialogRef } from "@angular/material/dialog"; import { MatBottomSheet } from "@angular/material/bottom-sheet"; -import { CONST } from 'common/constants' +import { CONST, QUICKTOUR_DESC } from 'common/constants' +import { IQuickTourData } from "src/ui/quickTour/constrants"; @Component({ selector: 'top-menu-cmp', @@ -51,12 +51,16 @@ export class TopMenuCmp { public pluginTooltipText: string = `Plugins and Tools` public screenshotTooltipText: string = 'Take screenshot' + public quickTourData: IQuickTourData = { + description: QUICKTOUR_DESC.TOP_MENU, + order: 8, + } + constructor( private store$: Store<IavRootStoreInterface>, private authService: AuthService, private dialog: MatDialog, public bottomSheet: MatBottomSheet, - private changeDetectionRef: ChangeDetectorRef, ) { this.user$ = this.authService.user$ diff --git a/src/ui/topMenu/topMenuCmp/topMenu.template.html b/src/ui/topMenu/topMenuCmp/topMenu.template.html index 11894f5e232bee1d2743b6b8e3136e0d3bd44415..aec870d1ad3362964b3cbe06cbfce568390928c0 100644 --- a/src/ui/topMenu/topMenuCmp/topMenu.template.html +++ b/src/ui/topMenu/topMenuCmp/topMenu.template.html @@ -48,6 +48,9 @@ <ng-template #fullTmpl> <div class="d-flex flex-row-reverse" + quick-tour + [quick-tour-description]="quickTourData.description" + [quick-tour-order]="quickTourData.order" [iav-key-listener]="keyListenerConfig" (iav-key-event)="openTmplWithDialog(helperOnePager)"> diff --git a/src/util/LinkedList.spec.ts b/src/util/LinkedList.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..e81a9a874f9256ececad6424c5c91810cf21d165 --- /dev/null +++ b/src/util/LinkedList.spec.ts @@ -0,0 +1,183 @@ +import { DoublyLinkedList, FindInLinkedList } from './LinkedList' + +describe('> LinkedList.ts', () => { + describe('> DoublyLinkedList', () => { + let linkedList: DoublyLinkedList<{}> + beforeEach(() => { + linkedList = new DoublyLinkedList() + }) + + it('> expect size === 0', () => { + expect( + linkedList.size() + ).toEqual(0) + }) + + describe('> insert into empty linked list', () => { + const first = {} + beforeEach(() => { + linkedList.insertAfter( + first, + () => true + ) + }) + it('> first === inserted element', () => { + expect( + linkedList.first.thisObj + ).toBe(first) + }) + it('> last === inserted element', () => { + expect( + linkedList.last.thisObj + ).toBe(first) + }) + it('> expect size === 1', () => { + expect( + linkedList.size() + ).toEqual(1) + }) + + describe('> inserting same item', () => { + beforeEach(() => { + linkedList.insertAfter( + first, + () => true + ) + }) + it('> second insertion will not be counted', () => { + expect( + linkedList.size() + ).toEqual(1) + }) + + it('> next will be null', () => { + expect( + linkedList.first.next + ).toBeFalsy() + }) + }) + }) + + describe('> insert into occupied linked list', () => { + const obj1 = { + name: 'obj1' + } + const obj2 = { + name: 'obj2' + } + const obj3 = { + name: 'obj3' + } + const predicateSpy = jasmine.createSpy('predicate') + beforeEach(() => { + linkedList.insertAfter( + obj1, + linkedItem => !linkedItem.next + ) + linkedList.insertAfter( + obj2, + linkedItem => !linkedItem.next + ) + }) + + afterEach(() => { + predicateSpy.calls.reset() + }) + + it('> adding obj calls predicateSpy', () => { + predicateSpy.and.returnValue(true) + linkedList.insertAfter( + obj3, + predicateSpy + ) + expect(predicateSpy).toHaveBeenCalled() + }) + + describe('> inserts are the right positions', () => { + describe('> predicate returns false', () => { + beforeEach(() => { + predicateSpy.and.returnValue(false) + linkedList.insertAfter( + obj3, + predicateSpy + ) + }) + it('> inserts at first position', () => { + expect( + linkedList.first.thisObj + ).toBe(obj3) + }) + + it('> first.prev is falsy', () => { + expect( + linkedList.first.prev + ).toBeFalsy() + }) + + it('> first.next.thisObj to be obj1', () => { + expect( + linkedList.first.next.thisObj + ).toBe(obj1) + }) + }) + describe('> predicate returns true for obj1', () => { + beforeEach(() => { + predicateSpy.and.callFake(function(){ + return arguments[0].thisObj === obj1 + }) + linkedList.insertAfter( + obj3, + predicateSpy + ) + }) + + it('> first.next is obj3', () => { + expect( + linkedList.first.next.thisObj + ).toBe(obj3) + }) + + it('> last.prev is obj3', () => { + expect( + linkedList.last.prev.thisObj + ).toBe(obj3) + }) + + }) + describe('> predicate returns true for obj2', () => { + beforeEach(() => { + predicateSpy.and.callFake(function(){ + return arguments[0].thisObj === obj2 + }) + linkedList.insertAfter( + obj3, + predicateSpy + ) + }) + + it('> inserts at last', () => { + expect( + linkedList.last.thisObj + ).toBe(obj3) + }) + + it('> last.next is empty', () => { + expect( + linkedList.last.next + ).toBeFalsy() + }) + + it('> last.prev to be obj2', () => { + expect( + linkedList.last.prev.thisObj + ).toBe(obj2) + }) + }) + }) + }) + }) + + describe('> FindInLinkedList', () => { + + }) +}) diff --git a/src/util/LinkedList.ts b/src/util/LinkedList.ts new file mode 100644 index 0000000000000000000000000000000000000000..478362128b9212a34bbff8baa8a08404bc82ac84 --- /dev/null +++ b/src/util/LinkedList.ts @@ -0,0 +1,105 @@ +export interface IDoublyLinkedItem<T extends object> { + next: IDoublyLinkedItem<T> + prev: IDoublyLinkedItem<T> + thisObj: T + readonly index: number + list: DoublyLinkedList<T> +} + +export class DoublyLinkedList<T extends object>{ + + public first: IDoublyLinkedItem<T> + public last: IDoublyLinkedItem<T> + private _map = new WeakMap<T, IDoublyLinkedItem<T>>() + private _size: number = 0 + insertAfter(element: T, predicate: (cmpObj: IDoublyLinkedItem<T>) => boolean){ + if (this._map.get(element)) { + console.warn(`element has already been added to the doublylinkedlist`) + return + } + + const insertAfter = FindInLinkedList<T>( + this, + predicate + ) + + /** + * if predicate can be found, then insert after the found entry + * if not, then the previous first entry will be the next element + */ + const newDoublyLinkedItemNext = insertAfter + ? insertAfter.next + : this.first + + const newDoublyLinkedItem: IDoublyLinkedItem<T> = { + prev: insertAfter, + next: newDoublyLinkedItemNext, + thisObj: element, + get index() { + let count = 0, prev: IDoublyLinkedItem<T> + prev = this.prev + while (prev) { + prev = prev.prev + count ++ + } + return count + }, + list: this + } + + /** + * set next of prev item + * if prev is null, set first as this doublyitem + */ + if (insertAfter) insertAfter.next = newDoublyLinkedItem + else this.first = newDoublyLinkedItem + + /** + * set prev of next item + * if next is null, set last as this doublyitem + */ + if (newDoublyLinkedItemNext) newDoublyLinkedItemNext.prev = newDoublyLinkedItem + else this.last = newDoublyLinkedItem + + this._map.set(element, newDoublyLinkedItem) + this._size ++ + } + remove(element: T) { + const doublyLinkedItem = this._map.get(element) + if (!doublyLinkedItem) { + console.error(`doubly linked item not found`) + return + } + const { next, prev } = doublyLinkedItem + + if (prev) prev.next = next + if (next) next.prev = prev + + if (doublyLinkedItem === this.first) this.first = this.first.next + if (doublyLinkedItem === this.last) this.last = this.last.prev + + // weakmap does not need to explicitly remove key/val + // decrement the size + this._size -- + } + size(){ + return this._size + } +} + +export function FindInLinkedList<T extends object>(list: DoublyLinkedList<T>, predicate: (element: IDoublyLinkedItem<T>) => boolean){ + let compareObj = list.first, + returnObj: IDoublyLinkedItem<T> = null + + if (!compareObj) return null + + do { + if (predicate(compareObj)) { + returnObj = compareObj + break + } + compareObj = compareObj.next + } while(!!compareObj) + + return returnObj +} diff --git a/src/util/index.ts b/src/util/index.ts index 25e810ea70b989a4e67ea70ae107fb598444037e..eeb79967a2de60a22bcd7d70fe13b24e9a3d8fb6 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -1,3 +1,8 @@ export { UtilModule } from './util.module' export { PureContantService } from './pureConstant.service' export { CLICK_INTERCEPTOR_INJECTOR, ClickInterceptor } from './injectionTokens' +export { + DoublyLinkedList, + FindInLinkedList, + IDoublyLinkedItem +} from './LinkedList' diff --git a/src/util/injectionTokens.ts b/src/util/injectionTokens.ts index acd5142e83ad88f25f76622c2b53bf282b53cce1..1c0795c53d019a7c5574e9f85033fa1313c49997 100644 --- a/src/util/injectionTokens.ts +++ b/src/util/injectionTokens.ts @@ -7,6 +7,6 @@ export type TClickInterceptorConfig = { } export interface ClickInterceptor{ - register: (interceptorFunction: (ev: any, next: Function) => void, config?: TClickInterceptorConfig) => void + register: (interceptorFunction: (ev: any) => boolean, config?: TClickInterceptorConfig) => void deregister: (interceptorFunction: Function) => void } diff --git a/src/util/regDereg.base.spec.ts b/src/util/regDereg.base.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..0534070fe768c00b011e0a69ea972722add1b239 --- /dev/null +++ b/src/util/regDereg.base.spec.ts @@ -0,0 +1,81 @@ +import { RegDereg } from "./regDereg.base" + +describe('> regDereg.base.ts', () => { + describe('> RegDereg', () => { + let regDereg: RegDereg<string, boolean> + beforeEach(() => { + regDereg = new RegDereg() + }) + + describe('> #register', () => { + it('> adds interceptor fn', () => { + let nextReturnVal = false + const fn = (ev: any) => nextReturnVal + regDereg.register(fn) + expect(regDereg['callbacks'].indexOf(fn)).toBeGreaterThanOrEqual(0) + }) + it('> when config not supplied, or first not present, will add fn to the last of the queue', () => { + let dummyReturn = false + const dummy = (ev: any) => dummyReturn + regDereg.register(dummy) + + let fnReturn = false + const fn = (ev: any) => fnReturn + regDereg.register(fn) + expect(regDereg['callbacks'].indexOf(fn)).toEqual(1) + + let fn2Return = false + const fn2 = (ev: any) => fn2Return + regDereg.register(fn2, {}) + expect(regDereg['callbacks'].indexOf(fn)).toEqual(1) + expect(regDereg['callbacks'].indexOf(fn2)).toEqual(2) + }) + it('> when first is supplied as a config param, will add the fn at the front', () => { + + let dummyReturn = false + const dummy = (ev: any) => dummyReturn + regDereg.register(dummy) + + let fnReturn = false + const fn = (ev: any) => fnReturn + regDereg.register(fn, { + first: true + }) + expect(regDereg['callbacks'].indexOf(fn)).toEqual(0) + + }) + }) + + describe('> deregister', () => { + it('> if the fn exist in the register, it will be removed', () => { + + let fnReturn = false + let fn2Return = false + const fn = (ev: any) => fnReturn + const fn2 = (ev: any) => fn2Return + regDereg.register(fn) + expect(regDereg['callbacks'].indexOf(fn)).toBeGreaterThanOrEqual(0) + expect(regDereg['callbacks'].length).toEqual(1) + + regDereg.deregister(fn) + expect(regDereg['callbacks'].indexOf(fn)).toBeLessThan(0) + expect(regDereg['callbacks'].length).toEqual(0) + }) + + it('> if fn does not exist in register, it will not be removed', () => { + + let fnReturn = false + let fn2Return = false + const fn = (ev: any) => fnReturn + const fn2 = (ev: any) => fn2Return + regDereg.register(fn) + expect(regDereg['callbacks'].indexOf(fn)).toBeGreaterThanOrEqual(0) + expect(regDereg['callbacks'].length).toEqual(1) + + regDereg.deregister(fn2) + expect(regDereg['callbacks'].indexOf(fn)).toBeGreaterThanOrEqual(0) + expect(regDereg['callbacks'].length).toEqual(1) + }) + }) + }) +}) diff --git a/src/util/regDereg.base.ts b/src/util/regDereg.base.ts new file mode 100644 index 0000000000000000000000000000000000000000..8322eed9a4ded888dd1fc7fc96b016fceef4f386 --- /dev/null +++ b/src/util/regDereg.base.ts @@ -0,0 +1,45 @@ +type TRegDeregConfig = { + first?: boolean +} + +/** + * this is base register/dregister class + * a pattern which is observed very frequently + */ +export class RegDereg<T, Y = void> { + + // eslint-disable-next-line @typescript-eslint/no-empty-function + constructor(){} + public allowDuplicate = false + protected callbacks: ((allArg: T) => Y)[] = [] + register(fn: (allArg: T) => Y, config?: TRegDeregConfig) { + if (!this.allowDuplicate) { + if (this.callbacks.indexOf(fn) >= 0) { + console.warn(`[RegDereg] #register: function has already been regsitered`) + return + } + } + if (config?.first) { + this.callbacks.unshift(fn) + } else { + this.callbacks.push(fn) + } + } + deregister(fn: (allArg: T) => Y){ + this.callbacks = this.callbacks.filter(f => f !== fn ) + } +} + +export class RegDeregController<T, Y = void> extends RegDereg<T, Y>{ + constructor(){ + super() + } + /** + * Can be overwritten by inherited class + */ + callRegFns(arg: T) { + for (const fn of this.callbacks) { + fn(arg) + } + } +} diff --git a/src/util/windowResize/index.ts b/src/util/windowResize/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd22fb2f4a0c9a9fa9687f14ebe94cfbf5b0f671 --- /dev/null +++ b/src/util/windowResize/index.ts @@ -0,0 +1,2 @@ +export { WindowResizeModule } from './module' +export { ResizeObserverDirective } from './windowResize.directive' diff --git a/src/util/windowResize/module.ts b/src/util/windowResize/module.ts new file mode 100644 index 0000000000000000000000000000000000000000..61392d6f4ea9770288d253264095ad63cff1d96c --- /dev/null +++ b/src/util/windowResize/module.ts @@ -0,0 +1,17 @@ +import { NgModule } from "@angular/core"; +import { ResizeObserverDirective } from "./windowResize.directive"; +import { ResizeObserverService } from "./windowResize.service"; + +@NgModule({ + declarations: [ + ResizeObserverDirective + ], + exports: [ + ResizeObserverDirective + ], + providers: [ + ResizeObserverService + ] +}) + +export class WindowResizeModule{} diff --git a/src/util/windowResize/windowResize.directive.ts b/src/util/windowResize/windowResize.directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce8de31804fcaec53296925c5eef5235979719b0 --- /dev/null +++ b/src/util/windowResize/windowResize.directive.ts @@ -0,0 +1,64 @@ +import { Directive, EventEmitter, Input, OnChanges, OnInit, Output } from "@angular/core"; +import { Subscription } from "rxjs"; +import { ResizeObserverService } from "./windowResize.service"; + +@Directive({ + selector: '[iav-window-resize]', + exportAs: 'iavWindowResize' +}) + +export class ResizeObserverDirective implements OnChanges, OnInit { + @Input('iav-window-resize-type') + type: 'debounce' | 'throttle' = 'throttle' + + @Input('iav-window-resize-time') + time: number = 160 + + @Input('iav-window-resize-throttle-leading') + throttleLeading = false + + @Input('iav-window-resize-throttle-trailing') + throttleTrailing = true + + @Output('iav-window-resize-event') + ev: EventEmitter<Event> = new EventEmitter() + + private sub: Subscription[] = [] + + constructor(private svc: ResizeObserverService){} + + ngOnInit(){ + this.configure() + } + ngOnChanges(){ + this.configure() + } + + configure(){ + while(this.sub.length > 0) this.sub.pop().unsubscribe() + + let sub: Subscription + if (this.type === 'throttle') { + sub = this.svc.getThrottledResize( + this.time, + { + leading: this.throttleLeading, + trailing: this.throttleTrailing + } + ).subscribe(event => this.ev.emit(event)) + } + + if (this.type === 'debounce') { + sub = this.svc.getDebouncedResize( + this.time, + ).subscribe(event => this.ev.emit(event)) + } + + if (!this.type) { + sub = this.svc.windowResize.pipe( + ).subscribe(event => this.ev.emit(event)) + } + + this.sub.push(sub) + } +} diff --git a/src/util/windowResize/windowResize.service.ts b/src/util/windowResize/windowResize.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..f50c7df0e929a935b56176311b887d585d6412eb --- /dev/null +++ b/src/util/windowResize/windowResize.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from "@angular/core"; +import { asyncScheduler, fromEvent } from "rxjs"; +import { debounceTime, shareReplay, tap, throttleTime } from "rxjs/operators"; + +interface IThrottleConfig { + leading: boolean + trailing: boolean +} + +@Injectable({ + providedIn: 'root' +}) + +export class ResizeObserverService { + public windowResize = fromEvent(window, 'resize').pipe( + shareReplay(1) + ) + + public getThrottledResize(time: number, config?: IThrottleConfig){ + return this.windowResize.pipe( + throttleTime(time, asyncScheduler, config || { leading: false, trailing: true }), + ) + } + + public getDebouncedResize(time: number) { + return this.windowResize.pipe( + debounceTime(time) + ) + } +} diff --git a/src/viewerModule/componentStore.ts b/src/viewerModule/componentStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca3d8e5082ed0529924f7fdd33b792f62aa3f0aa --- /dev/null +++ b/src/viewerModule/componentStore.ts @@ -0,0 +1,24 @@ +import { Injectable } from "@angular/core"; +import { select } from "@ngrx/store"; +import { ReplaySubject, Subject } from "rxjs"; +import { shareReplay } from "rxjs/operators"; + +/** + * polyfill for ngrx component store + * until upgrade to v11 + * where component store becomes generally available + */ + +@Injectable() +export class ComponentStore<T>{ + private _state$: Subject<T> = new ReplaySubject<T>(1) + setState(state: T){ + this._state$.next(state) + } + select(selectorFn: (state: T) => unknown) { + return this._state$.pipe( + select(selectorFn), + shareReplay(1), + ) + } +} diff --git a/src/viewerModule/constants.ts b/src/viewerModule/constants.ts index cb9b4aa477635fa2a7e7591fbdb9f172cb72182a..aa974a56e592618f67ae248f9b1224c15466ff2c 100644 --- a/src/viewerModule/constants.ts +++ b/src/viewerModule/constants.ts @@ -1,6 +1,10 @@ import { InjectionToken } from "@angular/core"; import { Observable } from "rxjs"; -export type TSupportedViewer = 'notsupported' | 'nehuba' | 'threeSurfer' | null - export const VIEWERMODULE_DARKTHEME = new InjectionToken<Observable<boolean>>('VIEWERMODULE_DARKTHEME') + +export interface IViewerCmpUiState { + sideNav: { + activePanelsTitle: string[] + } +} diff --git a/src/viewerModule/module.ts b/src/viewerModule/module.ts index cec938a068e649b42055bd79dd15509736610df1..d31ef339fad75c9ab61802e3c6a5dfb7c607ed85 100644 --- a/src/viewerModule/module.ts +++ b/src/viewerModule/module.ts @@ -9,6 +9,7 @@ import { BSFeatureModule, BS_DARKTHEME, } from "src/atlasComponents/regionalFea import { SplashUiModule } from "src/atlasComponents/splashScreen"; import { AtlasCmpUiSelectorsModule } from "src/atlasComponents/uiSelectors"; import { ComponentsModule } from "src/components"; +import { ContextMenuModule } from "src/contextMenuModule"; import { LayoutModule } from "src/layouts/layout.module"; import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module"; import { TopMenuModule } from "src/ui/topMenu/module"; @@ -18,6 +19,7 @@ import { NehubaModule } from "./nehuba"; import { ThreeSurferModule } from "./threeSurfer"; import { RegionAccordionTooltipTextPipe } from "./util/regionAccordionTooltipText.pipe"; import { ViewerCmp } from "./viewerCmp/viewerCmp.component"; +import {QuickTourModule} from "src/ui/quickTour/module"; @NgModule({ imports: [ @@ -36,6 +38,8 @@ import { ViewerCmp } from "./viewerCmp/viewerCmp.component"; AtlasCmptConnModule, ComponentsModule, BSFeatureModule, + QuickTourModule, + ContextMenuModule, ], declarations: [ ViewerCmp, diff --git a/src/viewerModule/nehuba/module.ts b/src/viewerModule/nehuba/module.ts index e4a6808a53f84691b01e8f8bd5ff1489fa3dc4a3..ea0c01d87e5432bfaad22a7b91ae7edb373fac55 100644 --- a/src/viewerModule/nehuba/module.ts +++ b/src/viewerModule/nehuba/module.ts @@ -23,6 +23,8 @@ import { FormsModule, ReactiveFormsModule } from "@angular/forms"; import { BehaviorSubject } from "rxjs"; import { StateModule } from "src/state"; import { AuthModule } from "src/auth"; +import {QuickTourModule} from "src/ui/quickTour/module"; +import { WindowResizeModule } from "src/util/windowResize"; import { ViewerCtrlModule } from "./viewerCtrl"; @NgModule({ @@ -37,6 +39,7 @@ import { ViewerCtrlModule } from "./viewerCtrl"; ComponentsModule, MouseoverModule, ShareModule, + WindowResizeModule, ViewerCtrlModule, /** @@ -48,7 +51,8 @@ import { ViewerCtrlModule } from "./viewerCtrl"; StoreModule.forFeature( NEHUBA_VIEWER_FEATURE_KEY, reducer - ) + ), + QuickTourModule ], declarations: [ NehubaViewerContainerDirective, diff --git a/src/viewerModule/nehuba/navigation.service/navigation.service.ts b/src/viewerModule/nehuba/navigation.service/navigation.service.ts index 6d6e16b83ad5201ffe61770f93ce3db6806f7e73..9344da35e6024e63a7c50d23bfd13a76a9eba203 100644 --- a/src/viewerModule/nehuba/navigation.service/navigation.service.ts +++ b/src/viewerModule/nehuba/navigation.service/navigation.service.ts @@ -1,6 +1,6 @@ import { Inject, Injectable, OnDestroy, Optional } from "@angular/core"; import { select, Store } from "@ngrx/store"; -import { Observable, Subscription } from "rxjs"; +import { Observable, ReplaySubject, Subscription } from "rxjs"; import { debounceTime } from "rxjs/operators"; import { selectViewerConfigAnimationFlag } from "src/services/state/viewerConfig/selectors"; import { viewerStateChangeNavigation } from "src/services/state/viewerState/actions"; @@ -19,6 +19,7 @@ export class NehubaNavigationService implements OnDestroy{ private nehubaViewerInstance: NehubaViewerUnit public storeNav: INavObj public viewerNav: INavObj + public viewerNav$ = new ReplaySubject<INavObj>(1) // if set, ignores store attempt to update nav private viewerNavLock: boolean = false @@ -105,6 +106,7 @@ export class NehubaNavigationService implements OnDestroy{ this.nehubaViewerInstance.viewerPositionChange.subscribe( (val: INavObj) => { this.viewerNav = val + this.viewerNav$.next(val) this.viewerNavLock = true } ), diff --git a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.spec.ts b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.spec.ts index 9865869b2083f56907e8d79c0570b866ab23a5c1..1459d4e14021804607718b0d13cffa6c1262fdc9 100644 --- a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.spec.ts +++ b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.spec.ts @@ -26,8 +26,8 @@ describe('> nehubaViewerGlue.component.ts', () => { provide: CLICK_INTERCEPTOR_INJECTOR, useFactory: (clickIntService: ClickInterceptorService) => { return { - deregister: clickIntService.removeInterceptor.bind(clickIntService), - register: clickIntService.addInterceptor.bind(clickIntService) + deregister: clickIntService.deregister.bind(clickIntService), + register: arg => clickIntService.register(arg) } as ClickInterceptor }, deps: [ @@ -72,7 +72,7 @@ describe('> nehubaViewerGlue.component.ts', () => { beforeEach(() => { fallbackSpy = spyOn(clickIntServ, 'fallback') TestBed.createComponent(NehubaGlueCmp) - clickIntServ.run(null) + clickIntServ.callRegFns(null) }) it('> dispatch not called', () => { expect(dispatchSpy).not.toHaveBeenCalled() @@ -92,7 +92,7 @@ describe('> nehubaViewerGlue.component.ts', () => { fallbackSpy = spyOn(clickIntServ, 'fallback') mockStore.overrideSelector(uiStateMouseOverSegmentsSelector, ['hello world', testObj0]) TestBed.createComponent(NehubaGlueCmp) - clickIntServ.run(null) + clickIntServ.callRegFns(null) }) it('> dispatch not called', () => { expect(dispatchSpy).not.toHaveBeenCalled() @@ -127,7 +127,7 @@ describe('> nehubaViewerGlue.component.ts', () => { }) it('> dispatch called with obj1', () => { TestBed.createComponent(NehubaGlueCmp) - clickIntServ.run(null) + clickIntServ.callRegFns(null) const { segment } = testObj1 expect(dispatchSpy).toHaveBeenCalledWith( viewerStateSetSelectedRegions({ @@ -137,7 +137,7 @@ describe('> nehubaViewerGlue.component.ts', () => { }) it('> fallback called (does not intercept)', () => { TestBed.createComponent(NehubaGlueCmp) - clickIntServ.run(null) + 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 004f45f50516bcc6dc5ea1d55a575ac750be0781..06461155be7e199c7d414e41e6df0711f2395b9c 100644 --- a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts +++ b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts @@ -1,6 +1,6 @@ -import { Component, ElementRef, EventEmitter, Inject, Input, OnChanges, OnDestroy, Optional, Output, SimpleChanges, ViewChild } from "@angular/core"; +import { AfterViewInit, Component, ElementRef, EventEmitter, Inject, Input, OnChanges, OnDestroy, Optional, Output, SimpleChanges, ViewChild } from "@angular/core"; import { select, Store } from "@ngrx/store"; -import { asyncScheduler, combineLatest, fromEvent, merge, Observable, of, Subject } from "rxjs"; +import { asyncScheduler, combineLatest, fromEvent, merge, Observable, of, Subject, Subscription } from "rxjs"; import { ngViewerActionAddNgLayer, ngViewerActionRemoveNgLayer, ngViewerActionToggleMax } from "src/services/state/ngViewerState/actions"; import { ClickInterceptor, CLICK_INTERCEPTOR_INJECTOR } from "src/util"; import { uiStateMouseOverSegmentsSelector } from "src/services/state/uiState/selectors"; @@ -9,20 +9,22 @@ import { viewerStateAddUserLandmarks, viewerStateChangeNavigation, viewerStateMo import { ngViewerSelectorLayers, ngViewerSelectorClearView, ngViewerSelectorPanelOrder, ngViewerSelectorPanelMode } from "src/services/state/ngViewerState/selectors"; import { viewerStateCustomLandmarkSelector, viewerStateNavigationStateSelector, viewerStateSelectedRegionsSelector } from "src/services/state/viewerState/selectors"; import { serialiseParcellationRegion } from 'common/util' -import { ARIA_LABELS, IDS } from 'common/constants' +import { ARIA_LABELS, IDS, QUICKTOUR_DESC } from 'common/constants' import { PANELS } from "src/services/state/ngViewerState/constants"; import { LoggingService } from "src/logging"; import { getMultiNgIdsRegionsLabelIndexMap, SET_MESHES_TO_LOAD } from "../constants"; -import { IViewer, TViewerEvent } from "../../viewer.interface"; +import { EnumViewerEvt, IViewer, TViewerEvent } from "../../viewer.interface"; import { NehubaViewerUnit } from "../nehubaViewer/nehubaViewer.component"; -import { NehubaViewerContainerDirective } from "../nehubaViewerInterface/nehubaViewerInterface.directive"; +import { NehubaViewerContainerDirective, TMouseoverEvent } from "../nehubaViewerInterface/nehubaViewerInterface.directive"; import { cvtNavigationObjToNehubaConfig, getFourPanel, getHorizontalOneThree, getSinglePanel, getVerticalOneThree, scanSliceViewRenderFn, takeOnePipe } from "../util"; import { API_SERVICE_SET_VIEWER_HANDLE_TOKEN, TSetViewerHandle } from "src/atlasViewer/atlasViewer.apiService.service"; import { MouseHoverDirective } from "src/mouseoverModule"; import { NehubaMeshService } from "../mesh.service"; +import { IQuickTourData } from "src/ui/quickTour/constrants"; import { NehubaLayerControlService, IColorMap, SET_COLORMAP_OBS, SET_LAYER_VISIBILITY } from "../layerCtrl.service"; import { switchMapWaitFor } from "src/util/fn"; +import { INavObj } from "../navigation.service"; interface INgLayerInterface { name: string // displayName @@ -63,7 +65,7 @@ interface INgLayerInterface { ] }) -export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ +export class NehubaGlueCmp implements IViewer<'nehuba'>, OnChanges, OnDestroy, AfterViewInit { public ARIA_LABELS = ARIA_LABELS public IDS = IDS @@ -104,6 +106,21 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ private findPanelIndex = (panel: HTMLElement) => this.viewPanelWeakMap.get(panel) public nanometersToOffsetPixelsFn: Array<(...arg) => any> = [] + public quickTourSliceViewSlide: IQuickTourData = { + order: 1, + description: QUICKTOUR_DESC.SLICE_VIEW, + } + + public quickTour3dViewSlide: IQuickTourData = { + order: 2, + description: QUICKTOUR_DESC.PERSPECTIVE_VIEW, + } + + public quickTourIconsSlide: IQuickTourData = { + order: 3, + description: QUICKTOUR_DESC.VIEW_ICONS, + } + public customLandmarks$: Observable<any> = this.store$.pipe( select(viewerStateCustomLandmarkSelector), map(lms => lms.map(lm => ({ @@ -140,6 +157,43 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ } } + ngAfterViewInit(){ + this.setQuickTourPos() + + const { + mouseOverSegments, + navigationEmitter, + mousePosEmitter, + } = this.nehubaContainerDirective + const sub = combineLatest([ + mouseOverSegments, + navigationEmitter, + mousePosEmitter, + ]).pipe( + throttleTime(16, asyncScheduler, { trailing: true }) + ).subscribe(([ seg, nav, mouse ]: [ TMouseoverEvent [], INavObj, { real: number[], voxel: number[] } ]) => { + this.viewerEvent.emit({ + type: EnumViewerEvt.VIEWER_CTX, + data: { + viewerType: 'nehuba', + payload: { + nav, + mouse, + nehuba: seg.map(v => { + return { + layerName: v.layer.name, + labelIndices: [ Number(v.segmentId) ] + } + }) + } + } + }) + }) + this.onDestroyCb.push( + () => sub.unsubscribe() + ) + } + ngOnDestroy() { while (this.onDestroyCb.length) this.onDestroyCb.pop()() } @@ -197,7 +251,7 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ const overwritingInitState = this.navigation ? cvtNavigationObjToNehubaConfig(this.navigation, initialNgState) : {} - + deepCopiedState.nehubaConfig.dataset.initialNgState = { ...initialNgState, ...overwritingInitState, @@ -252,7 +306,7 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ } @Output() - public viewerEvent = new EventEmitter<TViewerEvent>() + public viewerEvent = new EventEmitter<TViewerEvent<'nehuba'>>() constructor( private store$: Store<any>, @@ -262,10 +316,7 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ @Optional() @Inject(API_SERVICE_SET_VIEWER_HANDLE_TOKEN) setViewerHandle: TSetViewerHandle, @Optional() private layerCtrlService: NehubaLayerControlService, ){ - this.viewerEvent.emit({ - type: 'MOUSEOVER_ANNOTATION', - data: {} - }) + /** * define onclick behaviour */ @@ -273,7 +324,7 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ const { deregister, register } = clickInterceptor const selOnhoverRegion = this.selectHoveredRegion.bind(this) register(selOnhoverRegion, { last: true }) - this.onDestroyCb.push(() => deregister(selOnhoverRegion)) + this.onDestroyCb.push(() => deregister(selOnhoverRegion)) } /** @@ -294,7 +345,7 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ /** * TODO smarter with event stream */ - if (!viewPanels.every(v => !!v)) { + if (!viewPanels.every(v => !!v)) { this.log.error(`on relayout, not every view panel is populated. This should not occur!`) return } @@ -359,7 +410,7 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ const newLayers = ngLayers.filter(l => this.ngLayersRegister.layers?.findIndex(ol => ol.name === l.name) < 0) const removeLayers = this.ngLayersRegister.layers.filter(l => ngLayers?.findIndex(nl => nl.name === l.name) < 0) - + if (newLayers?.length > 0) { const newLayersObj: any = {} newLayers.forEach(({ name, source, ...rest }) => newLayersObj[name] = { @@ -557,7 +608,7 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ * TODO reenable with updated select_regions api */ this.log.warn(`showSegment is temporarily disabled`) - + // if(!this.selectedRegionIndexSet.has(labelIndex)) // this.store.dispatch({ // type : SELECT_REGIONS, @@ -575,7 +626,7 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ if (!landmarks.every(l => l.position.constructor === Array) || !landmarks.every(l => l.position.every(v => !isNaN(v))) || !landmarks.every(l => l.position.length == 3)) { throw new Error('position needs to be a length 3 tuple of numbers ') } - + this.store$.dispatch(viewerStateAddUserLandmarks({ landmarks })) @@ -590,7 +641,7 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ * TODO reenable with updated select_regions api */ this.log.warn(`hideSegment is temporarily disabled`) - + // if(this.selectedRegionIndexSet.has(labelIndex)){ // this.store.dispatch({ // type :SELECT_REGIONS, @@ -684,7 +735,7 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ }) }) this.onDestroyCb.push(() => setupViewerApiSub.unsubscribe()) - + // listen to navigation change from store const navSub = this.store$.pipe( select(viewerStateNavigationStateSelector) @@ -694,24 +745,24 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ handleViewerLoadedEvent(flag: boolean) { this.viewerEvent.emit({ - type: 'VIEWERLOADED', + type: EnumViewerEvt.VIEWERLOADED, data: flag }) this.viewerLoaded = flag } - private selectHoveredRegion(_ev: any, next: Function){ + private selectHoveredRegion(_ev: any): 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 next() + if (!trueOnhoverSegments || (trueOnhoverSegments.length === 0)) return true this.store$.dispatch( viewerStateSetSelectedRegions({ selectRegions: trueOnhoverSegments.slice(0, 1) }) ) - next() + return true } private waitForNehuba = switchMapWaitFor({ @@ -785,4 +836,28 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ ) } -} \ No newline at end of file + public quickTourOverwritingPos = { + 'dialog': { + left: '0px', + top: '0px', + }, + 'arrow': { + left: '0px', + top: '0px', + } + } + + setQuickTourPos(){ + const { innerWidth, innerHeight } = window + this.quickTourOverwritingPos = { + 'dialog': { + left: `${innerWidth / 2}px`, + top: `${innerHeight / 2}px`, + }, + 'arrow': { + left: `${innerWidth / 2 - 48}px`, + top: `${innerHeight / 2 - 48}px`, + } + } + } +} diff --git a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.template.html b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.template.html index d5f86d163f05dcb062d6883476a76670d9e132f0..d79c49a59f4708a74e9ee9520fcd4038b6be0980 100644 --- a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.template.html +++ b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.template.html @@ -16,7 +16,15 @@ </div> <current-layout *ngIf="viewerLoaded" class="position-absolute w-100 h-100 d-block pe-none top-0 left-0"> - <div class="w-100 h-100 position-relative" cell-i> + <div class="w-100 h-100 position-relative" cell-i + iav-window-resize + [iav-window-resize-time]="64" + (iav-window-resize-event)="setQuickTourPos()" + quick-tour + [quick-tour-description]="quickTourSliceViewSlide.description" + [quick-tour-order]="quickTourSliceViewSlide.order" + [quick-tour-overwrite-arrow]="sliceViewArrow" + [quick-tour-overwrite-position]="quickTourOverwritingPos"> <ng-content *ngTemplateOutlet="ngPanelOverlayTmpl; context: { panelIndex: panelOrder$ | async | getNthElement : 0 | parseAsNumber }"></ng-content> </div> <div class="w-100 h-100 position-relative" cell-ii> @@ -25,7 +33,10 @@ <div class="w-100 h-100 position-relative" cell-iii> <ng-content *ngTemplateOutlet="ngPanelOverlayTmpl; context: { panelIndex: panelOrder$ | async | getNthElement : 2 | parseAsNumber }"></ng-content> </div> - <div class="w-100 h-100 position-relative" cell-iv> + <div class="w-100 h-100 position-relative" cell-iv + quick-tour + [quick-tour-description]="quickTour3dViewSlide.description" + [quick-tour-order]="quickTour3dViewSlide.order"> <ng-content *ngTemplateOutlet="ngPanelOverlayTmpl; context: { panelIndex: panelOrder$ | async | getNthElement : 3 | parseAsNumber }"></ng-content> </div> </current-layout> @@ -44,7 +55,7 @@ <spinner-cmp *ngIf="showPerpsectiveScreen$ | async"> </spinner-cmp> - + <mat-list> <mat-list-item> {{ showPerpsectiveScreen$ | async }} @@ -113,6 +124,13 @@ [attr.data-viewer-controller-visible]="visible" [attr.data-viewer-controller-index]="panelIndex"> + <div class="position-absolute w-100 h-100 pe-none" + *ngIf="panelIndex === 1" + quick-tour + [quick-tour-description]="quickTourIconsSlide.description" + [quick-tour-order]="quickTourIconsSlide.order"> + </div> + <!-- perspective specific control --> <ng-container *ngIf="panelIndex === 3"> @@ -154,3 +172,21 @@ (click)="$event.stopPropagation()"> </viewer-ctrl-component> </mat-menu> + +<ng-template #sliceViewArrow> + <svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path id="quarter_circle" d="M22.6151 96.5C22.6151 96.5 18.5 84.1266 18.5 76.5C18.5001 62 18.1151 59.5 22.6151 47C27.115 34.5 39.3315 27.7229 47.5 25C56.5 22 63 22.5 72.5 24C83.1615 25.6834 83.5 26 91 29" /> + <g id="arrow_top_left"> + <path id="arrow_stem" d="M37 40C35.5882 38.5882 17.6863 20.6863 12 15" /> + <path id="arrow_head" d="M6 24C6.38926 21.7912 6.68496 18.3286 6.71205 16.0803C6.73751 13.9665 6.632 13.6135 6.52632 11.5C6.46368 10.2469 6.52632 11.5 6 8C11 10 9.71916 9.74382 11 9.99999C13.5 10.5 13.743 10.7451 17 11C20 11.2348 21.1276 11 22 11" stroke-linecap="round" stroke-linejoin="round"/> + </g> + <g id="arrow_left"> + <path id="arrow_stem_2" d="M29.4229 78.5771C27.1573 78.5771 18.3177 78.5771 9.19238 78.5771" /> + <path id="arrow_head_2" d="M13.3137 89.6274C12.0271 87.7903 9.78778 85.1328 8.2171 83.5238C6.74048 82.0112 6.41626 81.8362 4.84703 80.4164C3.91668 79.5747 4.84703 80.4164 2 78.3137C6.94975 76.1924 5.86291 76.9169 6.94974 76.1924C9.07106 74.7782 9.41624 74.7797 11.8995 72.6569C14.1868 70.7016 14.8181 69.7382 15.435 69.1213" stroke-linecap="round" stroke-linejoin="round"/> + </g> + <g id="arrow_top"> + <path id="arrow_stem_3" d="M77.0057 32.0057C77.0057 30.3124 77.0057 16.2488 77.0057 9.42862" /> + <path id="arrow_head_3" d="M68.4342 11.1429C69.9189 10.1032 72.0665 8.29351 73.3667 7.02421C74.5891 5.83091 74.7305 5.5689 75.8779 4.30076C76.5581 3.54892 75.8779 4.30076 77.5771 2C79.2914 6.00002 78.7059 5.12172 79.2915 6.00002C80.4343 7.71431 80.4331 7.99326 82.1486 10C83.7287 11.8485 84.5072 12.3587 85.0058 12.8572" stroke-linecap="round" stroke-linejoin="round"/> + </g> + </svg> +</ng-template> diff --git a/src/viewerModule/nehuba/nehubaViewerInterface/nehubaViewerInterface.directive.ts b/src/viewerModule/nehuba/nehubaViewerInterface/nehubaViewerInterface.directive.ts index 33891485ba4928a4a4f5a8f0e817fda7acd67dcd..1b6fb599e73e9808098fe45872860672a26b5742 100644 --- a/src/viewerModule/nehuba/nehubaViewerInterface/nehubaViewerInterface.directive.ts +++ b/src/viewerModule/nehuba/nehubaViewerInterface/nehubaViewerInterface.directive.ts @@ -1,7 +1,7 @@ import { Directive, ViewContainerRef, ComponentFactoryResolver, ComponentFactory, ComponentRef, OnInit, OnDestroy, Output, EventEmitter, Optional } from "@angular/core"; import { NehubaViewerUnit, INehubaLifecycleHook } from "../nehubaViewer/nehubaViewer.component"; import { Store, select } from "@ngrx/store"; -import { Subscription, Observable, fromEvent, asyncScheduler } from "rxjs"; +import { Subscription, Observable, fromEvent, asyncScheduler, combineLatest } from "rxjs"; import { distinctUntilChanged, filter, debounceTime, scan, map, throttleTime, switchMapTo } from "rxjs/operators"; import { takeOnePipe } from "../util"; import { ngViewerActionNehubaReady } from "src/services/state/ngViewerState/actions"; @@ -12,7 +12,7 @@ import { LoggingService } from "src/logging"; import { uiActionMouseoverLandmark, uiActionMouseoverSegments } from "src/services/state/uiState/actions"; import { IViewerConfigState } from "src/services/state/viewerConfig.store.helper"; import { arrayOfPrimitiveEqual } from 'src/util/fn' -import { NehubaNavigationService } from "../navigation.service"; +import { INavObj, NehubaNavigationService } from "../navigation.service"; const defaultNehubaConfig = { "configName": "", @@ -111,6 +111,14 @@ interface IProcessedVolume{ } } +export type TMouseoverEvent = { + layer: { + name: string + } + segment: any | string + segmentId: string +} + const processStandaloneVolume: (url: string) => Promise<IProcessedVolume> = async (url: string) => { const protocol = determineProtocol(url) if (protocol === 'nifti'){ @@ -202,6 +210,15 @@ export class NehubaViewerContainerDirective implements OnInit, OnDestroy{ public viewportToDatas: [any, any, any] = [null, null, null] + @Output('iav-nehuba-viewer-container-mouseover') + public mouseOverSegments = new EventEmitter<TMouseoverEvent[]>() + + @Output('iav-nehuba-viewer-container-navigation') + public navigationEmitter = new EventEmitter<INavObj>() + + @Output('iav-nehuba-viewer-container-mouse-pos') + public mousePosEmitter = new EventEmitter<{ voxel: number[], real: number[] }>() + @Output() public iavNehubaViewerContainerViewerLoading: EventEmitter<boolean> = new EventEmitter() @@ -279,7 +296,9 @@ export class NehubaViewerContainerDirective implements OnInit, OnDestroy{ this.nehubaViewerInstance.applyPerformanceConfig(config) } }), - + this.navService.viewerNav$.subscribe(v => { + this.navigationEmitter.emit(v) + }) ) } @@ -353,20 +372,7 @@ export class NehubaViewerContainerDirective implements OnInit, OnDestroy{ this.nehubaViewerInstance.mouseoverSegmentEmitter.pipe( scan(accumulatorFn, new Map()), map((map: Map<string, any>) => Array.from(map.entries()).filter(([_ngId, { segmentId }]) => segmentId)), - ).subscribe(arrOfArr => { - this.store$.dispatch( - uiActionMouseoverSegments({ - segments: arrOfArr.map( ([ngId, {segment, segmentId}]) => { - return { - layer: { - name: ngId, - }, - segment: segment || `${ngId}#${segmentId}`, - } - } ) - }) - ) - }), + ).subscribe(val => this.handleMouseoverSegments(val)), this.nehubaViewerInstance.mouseoverLandmarkEmitter.pipe( distinctUntilChanged() @@ -394,6 +400,16 @@ export class NehubaViewerContainerDirective implements OnInit, OnDestroy{ ).subscribe((events: CustomEvent[]) => { [0, 1, 2].forEach(idx => this.viewportToDatas[idx] = events[idx].detail.viewportToData) }), + + combineLatest([ + this.nehubaViewerInstance.mousePosInVoxel$, + this.nehubaViewerInstance.mousePosInReal$ + ]).subscribe(([ voxel, real ]) => { + this.mousePosEmitter.emit({ + voxel, + real + }) + }) ) } @@ -421,4 +437,22 @@ export class NehubaViewerContainerDirective implements OnInit, OnDestroy{ isReady() { return !!(this.cr?.instance?.nehubaViewer?.ngviewer) } + + handleMouseoverSegments(arrOfArr: [string, any][]) { + const payload = arrOfArr.map( ([ngId, {segment, segmentId}]) => { + return { + layer: { + name: ngId, + }, + segment: segment || `${ngId}#${segmentId}`, + segmentId + } + }) + this.mouseOverSegments.emit(payload) + this.store$.dispatch( + uiActionMouseoverSegments({ + segments: payload + }) + ) + } } diff --git a/src/viewerModule/nehuba/statusCard/statusCard.component.spec.ts b/src/viewerModule/nehuba/statusCard/statusCard.component.spec.ts index bf9068df5e635e014f21a875fbd9a133cf5db5db..698b858476e4b330ef7c84904c518a893dcb8ff5 100644 --- a/src/viewerModule/nehuba/statusCard/statusCard.component.spec.ts +++ b/src/viewerModule/nehuba/statusCard/statusCard.component.spec.ts @@ -16,6 +16,7 @@ import { viewerConfigSelectorUseMobileUi } from "src/services/state/viewerConfig import { viewerStateNavigationStateSelector, viewerStateSelectedTemplatePureSelector } from "src/services/state/viewerState/selectors" import * as util from '../util' import { viewerStateChangeNavigation } from "src/services/state/viewerState/actions" +import {QuickTourModule} from "src/ui/quickTour/module"; @Directive({ selector: '[iav-auth-auth-state]', @@ -46,6 +47,7 @@ describe('> statusCard.component.ts', () => { ReactiveFormsModule, NoopAnimationsModule, UtilModule, + QuickTourModule ], declarations: [ StatusCardComponent, @@ -88,13 +90,13 @@ describe('> statusCard.component.ts', () => { }) it('> toggle can be found', () => { - + const slider = fixture.debugElement.query( By.directive(MatSlideToggle) ) expect(slider).toBeTruthy() }) - + it('> toggling voxel/real toggle also toggles statusPanelRealSpace flag', () => { - + const prevFlag = fixture.componentInstance.statusPanelRealSpace const sliderEl = fixture.debugElement.query( By.directive(MatSlideToggle) ) const slider = sliderEl.injector.get(MatSlideToggle) @@ -102,21 +104,21 @@ describe('> statusCard.component.ts', () => { fixture.detectChanges() expect(fixture.componentInstance.statusPanelRealSpace).toEqual(!prevFlag) }) - + describe('> textNavigationTo', () => { it('> takes into account of statusPanelRealSpace panel', () => { const setNavigationStateSpy = jasmine.createSpy('setNavigationState') fixture.componentInstance.nehubaViewer = { setNavigationState: setNavigationStateSpy, } as any - + fixture.componentInstance.statusPanelRealSpace = true fixture.componentInstance.textNavigateTo('1, 0, 0') expect(setNavigationStateSpy).toHaveBeenCalledWith({ position: [1e6, 0, 0], positionReal: true }) - + fixture.componentInstance.statusPanelRealSpace = false fixture.componentInstance.textNavigateTo('1, 0, 0') expect(setNavigationStateSpy).toHaveBeenCalledWith({ @@ -152,9 +154,9 @@ describe('> statusCard.component.ts', () => { const mockStore = TestBed.inject(MockStore) mockStore.overrideSelector(viewerStateSelectedTemplatePureSelector, mockTemplate) mockStore.overrideSelector(viewerStateNavigationStateSelector, mockCurrNavigation) - + spyOnProperty(util, 'getNavigationStateFromConfig').and.returnValue(getNavigationStateFromConfigSpy) - + fixture = TestBed.createComponent(StatusCardComponent) fixture.detectChanges() fixture.componentInstance.showFull = true @@ -176,7 +178,7 @@ describe('> statusCard.component.ts', () => { const idspatchSpy = spyOn(mockStore, 'dispatch') fixture.componentInstance.resetNavigation({ [method]: true, }) fixture.detectChanges() - + const overrideObj = {} if (method === 'rotation') overrideObj['orientation'] = mockNavState['orientation'] if (method === 'position') overrideObj['position'] = mockNavState['position'] @@ -195,7 +197,7 @@ describe('> statusCard.component.ts', () => { }) } }) - - + + }) }) diff --git a/src/viewerModule/nehuba/statusCard/statusCard.component.ts b/src/viewerModule/nehuba/statusCard/statusCard.component.ts index b90ee5b3a823cdefb9926c29807dbd05e7391e9c..c0cc7d57ebd8407e9f100d921f8115a6c1f216fc 100644 --- a/src/viewerModule/nehuba/statusCard/statusCard.component.ts +++ b/src/viewerModule/nehuba/statusCard/statusCard.component.ts @@ -1,4 +1,12 @@ -import { Component, OnInit, OnChanges, TemplateRef, HostBinding, Optional, Inject } from "@angular/core"; +import { + Component, + OnInit, + OnChanges, + TemplateRef, + HostBinding, + Optional, + Inject, +} from "@angular/core"; import { select, Store } from "@ngrx/store"; import { LoggingService } from "src/logging"; import { NehubaViewerUnit } from "../nehubaViewer/nehubaViewer.component"; @@ -6,12 +14,13 @@ import { Observable, Subscription, of, combineLatest } from "rxjs"; import { map, filter, startWith } from "rxjs/operators"; import { MatBottomSheet } from "@angular/material/bottom-sheet"; import { MatDialog } from "@angular/material/dialog"; -import { ARIA_LABELS } from 'common/constants' +import { ARIA_LABELS, QUICKTOUR_DESC } from 'common/constants' import { FormControl } from "@angular/forms"; import { viewerStateNavigationStateSelector, viewerStateSelectedTemplatePureSelector } from "src/services/state/viewerState/selectors"; import { viewerStateChangeNavigation } from "src/services/state/viewerState/actions"; import { getNavigationStateFromConfig, NEHUBA_INSTANCE_INJTKN } from '../util' +import { IQuickTourData } from "src/ui/quickTour/constrants"; @Component({ selector : 'iav-cmp-viewer-nehuba-status', @@ -43,6 +52,11 @@ export class StatusCardComponent implements OnInit, OnChanges{ public useTouchInterface$: Observable<boolean> + public quickTourData: IQuickTourData = { + description: QUICKTOUR_DESC.STATUS_CARD, + order: 6, + } + public SHARE_BTN_ARIA_LABEL = ARIA_LABELS.SHARE_BTN public COPY_URL_TO_CLIPBOARD_ARIA_LABEL = ARIA_LABELS.SHARE_COPY_URL_CLIPBOARD public SHARE_CUSTOM_URL_ARIA_LABEL = ARIA_LABELS.SHARE_CUSTOM_URL diff --git a/src/viewerModule/nehuba/statusCard/statusCard.template.html b/src/viewerModule/nehuba/statusCard/statusCard.template.html index 0489d088a7818527404627c3f230661d3dd30408..5e4d793a725e31f41f5bd45406a11b95ef1340a6 100644 --- a/src/viewerModule/nehuba/statusCard/statusCard.template.html +++ b/src/viewerModule/nehuba/statusCard/statusCard.template.html @@ -1,108 +1,115 @@ -<mat-card class="expandedContainer p-2 pt-1" *ngIf="showFull; else showMin"> - <mat-card-content> +<div quick-tour + [quick-tour-description]="quickTourData.description" + [quick-tour-order]="quickTourData.order" + #statusCardQT="quickTour"> + <mat-card *ngIf="showFull; else showMin" + class="expandedContainer p-2 pt-1"> + + <mat-card-content> - <!-- reset --> - <div class="d-flex"> - <span class="flex-grow-0 d-flex align-items-center"> - Reset - </span> + <!-- reset --> + <div class="d-flex"> + <span class="flex-grow-0 d-flex align-items-center"> + Reset + </span> - <div class="flex-grow-1"></div> - - <button - mat-icon-button - (click)="resetNavigation({position:true})" - matTooltip="Reset position"> - <i class="iavic iavic-translation"></i> - </button> - - <button - mat-icon-button - (click)="resetNavigation({rotation:true})" - matTooltip="Reset rotation"> - <i class="iavic iavic-rotation"></i> - </button> - - <button - mat-icon-button - (click)="resetNavigation({zoom:true})" - matTooltip="Reset zoom"> - <i class="iavic iavic-scaling"></i> - </button> - - <mat-divider [vertical]="true"></mat-divider> - - <button mat-icon-button - [attr.aria-label]="HIDE_FULL_STATUS_PANEL_ARIA_LABEL" - (click)="showFull = false"> - <i class="fas fa-angle-up"></i> - </button> - </div> + <div class="flex-grow-1"></div> - <!-- space --> - <div class="d-flex"> - <span class="d-flex align-items-center"> - Voxel space - </span> + <button + mat-icon-button + (click)="resetNavigation({position:true})" + matTooltip="Reset position"> + <i class="iavic iavic-translation"></i> + </button> - <mat-slide-toggle - [formControl]="statusPanelFormCtrl" - class="pl-2 pr-2"> - </mat-slide-toggle> - - <span class="d-flex align-items center"> - Physical space - </span> - </div> + <button + mat-icon-button + (click)="resetNavigation({rotation:true})" + matTooltip="Reset rotation"> + <i class="iavic iavic-rotation"></i> + </button> + + <button + mat-icon-button + (click)="resetNavigation({zoom:true})" + matTooltip="Reset zoom"> + <i class="iavic iavic-scaling"></i> + </button> + + <mat-divider [vertical]="true"></mat-divider> + + <button mat-icon-button + [attr.aria-label]="HIDE_FULL_STATUS_PANEL_ARIA_LABEL" + (click)="statusCardQT.ngOnChanges(); showFull = false"> + <i class="fas fa-angle-up"></i> + </button> + </div> + + <!-- space --> + <div class="d-flex"> + <span class="d-flex align-items-center"> + Voxel space + </span> + + <mat-slide-toggle + [formControl]="statusPanelFormCtrl" + class="pl-2 pr-2"> + </mat-slide-toggle> - <!-- coord --> - <div class="d-flex"> + <span class="d-flex align-items center"> + Physical space + </span> + </div> - <mat-form-field class="flex-grow-1"> + <!-- coord --> + <div class="d-flex"> + + <mat-form-field class="flex-grow-1"> + <mat-label> + {{ (statusPanelRealSpace$ | async) ? 'Physical Coord' : 'Voxel Coord' }} + </mat-label> + <input type="text" + matInput + (keydown.enter)="textNavigateTo(navInput.value)" + (keydown.tab)="textNavigateTo(navInput.value)" + [value]="navVal$ | async" + #navInput="matInput"> + + </mat-form-field> + + <div class="w-0 position-relative"> + <button + (click)="showBottomSheet(shareTmpl)" + [attr.aria-label]="SHARE_BTN_ARIA_LABEL" + mat-icon-button + class="position-absolute share-btn"> + <i class="fas fa-share-square"></i> + </button> + </div> + </div> + + <!-- cursor pos --> + <mat-form-field *ngIf="!(useTouchInterface$ | async)" + class="w-100"> <mat-label> - {{ (statusPanelRealSpace$ | async) ? 'Physical Coord' : 'Voxel Coord' }} + Cursor Position </mat-label> <input type="text" matInput - (keydown.enter)="textNavigateTo(navInput.value)" - (keydown.tab)="textNavigateTo(navInput.value)" - [value]="navVal$ | async" - #navInput="matInput"> - + [readonly]="true" + [value]="mouseVal$ | async"> </mat-form-field> - <div class="w-0 position-relative"> - <button - (click)="showBottomSheet(shareTmpl)" - [attr.aria-label]="SHARE_BTN_ARIA_LABEL" - mat-icon-button - class="position-absolute share-btn"> - <i class="fas fa-share-square"></i> - </button> - </div> - </div> - - <!-- cursor pos --> - <mat-form-field *ngIf="!(useTouchInterface$ | async)" - class="w-100"> - <mat-label> - Cursor Position - </mat-label> - <input type="text" - matInput - [readonly]="true" - [value]="mouseVal$ | async"> - </mat-form-field> - - </mat-card-content> -</mat-card> + </mat-card-content> + </mat-card> +</div> <!-- minimised status bar --> <ng-template #showMin> <div class="iv-custom-comp text overflow-visible text-nowrap d-inline-flex align-items-center m-1 mt-3" iav-media-query #media="iavMediaQuery"> - + <i aria-label="viewer navigation" class="fas fa-compass"></i> <span *ngIf="(media.mediaBreakPoint$ | async) < 3" class="pl-2"> {{ navVal$ | async }} @@ -123,7 +130,7 @@ <button mat-icon-button [attr.aria-label]="SHOW_FULL_STATUS_PANEL_ARIA_LABEL" - (click)="showFull = true"> + (click)="statusCardQT.ngOnChanges(); showFull = true"> <i class="fas fa-angle-down"></i> </button> </div> @@ -150,12 +157,12 @@ <mat-list-item (click)="openDialog(shareSaneUrl, { ariaLabel: SHARE_CUSTOM_URL_DIALOG_ARIA_LABEL })" [attr.aria-label]="SHARE_CUSTOM_URL_ARIA_LABEL" [attr.tab-index]="10"> - <mat-icon + <mat-icon class="mr-4" fontSet="fas" fontIcon="fa-link"> </mat-icon> - + <span> Create custom URL </span> diff --git a/src/viewerModule/nehuba/types.ts b/src/viewerModule/nehuba/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..35cca295acae535d68ba6bb98ce282650c830e45 --- /dev/null +++ b/src/viewerModule/nehuba/types.ts @@ -0,0 +1,13 @@ +import { INavObj } from "./navigation.service"; + +export type TNehubaContextInfo = { + nav: INavObj + mouse: { + real: number[] + voxel: number[] + } + nehuba: { + layerName: string + labelIndices: number[] + }[] +} diff --git a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts index a77b837ca751aabe9f642c7ec330cab6ff5f3ecc..96ba31a146366dffa8868c69f0ef4b01abec1c49 100644 --- a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts +++ b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts @@ -1,9 +1,18 @@ import { Component, Input, Output, EventEmitter, ElementRef, OnChanges, OnDestroy, AfterViewInit } from "@angular/core"; -import { IViewer, TViewerEvent } from "src/viewerModule/viewer.interface"; +import { EnumViewerEvt, IViewer, TViewerEvent } from "src/viewerModule/viewer.interface"; import { TThreeSurferConfig, TThreeSurferMode } from "../types"; import { parseContext } from "../util"; import { retry } from 'common/util' +type THandlingCustomEv = { + regions: ({ name?: string, error?: string })[] + event: CustomEvent + evMesh?: { + faceIndex: number + verticesIndicies: number[] + } +} + @Component({ selector: 'three-surfer-glue-cmp', templateUrl: './threeSurfer.template.html', @@ -12,7 +21,7 @@ import { retry } from 'common/util' ] }) -export class ThreeSurferGlueCmp implements IViewer, OnChanges, AfterViewInit, OnDestroy { +export class ThreeSurferGlueCmp implements IViewer<'threeSurfer'>, OnChanges, AfterViewInit, OnDestroy { @Input() selectedTemplate: any @@ -21,7 +30,7 @@ export class ThreeSurferGlueCmp implements IViewer, OnChanges, AfterViewInit, On selectedParcellation: any @Output() - viewerEvent = new EventEmitter<TViewerEvent>() + viewerEvent = new EventEmitter<TViewerEvent<'threeSurfer'>>() private domEl: HTMLElement private config: TThreeSurferConfig @@ -68,7 +77,11 @@ export class ThreeSurferGlueCmp implements IViewer, OnChanges, AfterViewInit, On const tsC = await this.tsRef.loadColormap( parseContext(colormap, [this.config['@context']]) ) - const colorIdx = tsC[0].getData() + + let colorIdx = tsC[0].getData() + if (tsC[0].attributes.DataType === 'NIFTI_TYPE_INT16') { + colorIdx = (window as any).ThreeSurfer.GiftiBase.castF32UInt16(colorIdx) + } this.loadedMeshes.push({ threeSurfer: tsM, @@ -125,7 +138,7 @@ export class ThreeSurferGlueCmp implements IViewer, OnChanges, AfterViewInit, On this.loadMode(this.config.modes[0]) this.viewerEvent.emit({ - type: 'VIEWERLOADED', + type: EnumViewerEvt.VIEWERLOADED, data: true }) } @@ -133,21 +146,31 @@ export class ThreeSurferGlueCmp implements IViewer, OnChanges, AfterViewInit, On ngAfterViewInit(){ const customEvHandler = (ev: CustomEvent) => { + const evMesh = ev.detail?.mesh && { + faceIndex: ev.detail.mesh.faceIndex, + // typo in three-surfer + verticesIndicies: ev.detail.mesh.verticesIdicies + } + const custEv: THandlingCustomEv = { + event: ev, + regions: [], + evMesh + } if (!ev.detail.mesh) { - return this.handleMouseoverEvent([]) + return this.handleMouseoverEvent(custEv) } const evGeom = ev.detail.mesh.geometry const evVertIdx = ev.detail.mesh.verticesIdicies const found = this.loadedMeshes.find(({ threeSurfer }) => threeSurfer === evGeom) - if (!found) return this.handleMouseoverEvent([]) + if (!found) return this.handleMouseoverEvent(custEv) const { hemisphere: key, vIdxArr } = found if (!key || !evVertIdx) { - return this.handleMouseoverEvent([]) + return this.handleMouseoverEvent(custEv) } const labelIdxSet = new Set<number>() @@ -158,30 +181,32 @@ export class ThreeSurferGlueCmp implements IViewer, OnChanges, AfterViewInit, On ) } if (labelIdxSet.size === 0) { - return this.handleMouseoverEvent([]) + return this.handleMouseoverEvent(custEv) } const foundRegion = this.selectedParcellation.regions.find(({ name }) => name === key) if (!foundRegion) { - return this.handleMouseoverEvent( - Array.from(labelIdxSet).map(v => { - return `unknown#${v}` - }) - ) + custEv.regions = Array.from(labelIdxSet).map(v => { + return { + error: `unknown#${v}` + } + }) + return this.handleMouseoverEvent(custEv) } - return this.handleMouseoverEvent( - Array.from(labelIdxSet) - .map(lblIdx => { - const ontoR = foundRegion.children.find(ontR => Number(ontR.grayvalue) === lblIdx) - if (ontoR) { - return ontoR.name - } else { - return `unkonwn#${lblIdx}` + custEv.regions = Array.from(labelIdxSet) + .map(lblIdx => { + const ontoR = foundRegion.children.find(ontR => Number(ontR.grayvalue) === lblIdx) + if (ontoR) { + return ontoR + } else { + return { + error: `unkonwn#${lblIdx}` } - }) - ) + } + }) + return this.handleMouseoverEvent(custEv) } @@ -192,8 +217,26 @@ export class ThreeSurferGlueCmp implements IViewer, OnChanges, AfterViewInit, On } public mouseoverText: string - private handleMouseoverEvent(mouseover: any[]){ - this.mouseoverText = mouseover.length === 0 ? null : mouseover.join(' / ') + private handleMouseoverEvent(ev: THandlingCustomEv){ + const { regions: mouseover, evMesh } = ev + this.viewerEvent.emit({ + type: EnumViewerEvt.VIEWER_CTX, + data: { + viewerType: 'threeSurfer', + payload: { + fsversion: this.selectedMode, + faceIndex: evMesh?.faceIndex, + vertexIndices: evMesh?.verticesIndicies, + position: [], + _mouseoverRegion: mouseover.filter(el => !el.error) + } + } + }) + this.mouseoverText = mouseover.length === 0 ? + null : + mouseover.map( + el => el.name || el.error + ).join(' / ') } private onDestroyCb: (() => void) [] = [] diff --git a/src/viewerModule/threeSurfer/types.ts b/src/viewerModule/threeSurfer/types.ts index eb06df0b2c4cc95ef2c748489396cef48c37c172..d6f987870b2f1f6a628f69a0742c8e4177b4b38a 100644 --- a/src/viewerModule/threeSurfer/types.ts +++ b/src/viewerModule/threeSurfer/types.ts @@ -15,3 +15,11 @@ export type TThreeSurferConfig = { ['@context']: IContext modes: TThreeSurferMode[] } + +export type TThreeSurferContextInfo = { + position: number[] + faceIndex: number + vertexIndices: number[] + fsversion: string + _mouseoverRegion?: any[] +} diff --git a/src/viewerModule/viewer.interface.ts b/src/viewerModule/viewer.interface.ts index fdd5600b3a67aafb26c06335c4ec93d5742c0bb5..195ee958adbbeb7af67ff27000745d0cc8a67376 100644 --- a/src/viewerModule/viewer.interface.ts +++ b/src/viewerModule/viewer.interface.ts @@ -1,4 +1,6 @@ import { EventEmitter } from "@angular/core"; +import { TNehubaContextInfo } from "./nehuba/types"; +import { TThreeSurferContextInfo } from "./threeSurfer/types"; type TLayersColorMap = Map<string, Map<number, { red: number, green: number, blue: number }>> @@ -27,22 +29,43 @@ interface IViewerCtrl { getLayersColourMap(): TLayersColorMap } -type TViewerEventMOAnno = { - type: "MOUSEOVER_ANNOTATION" - data: any +export interface IViewerCtx { + 'nehuba': TNehubaContextInfo + 'threeSurfer': TThreeSurferContextInfo +} + +export type TContextArg<K extends keyof IViewerCtx> = ({ + viewerType: K + payload: IViewerCtx[K] +}) + +export enum EnumViewerEvt { + VIEWERLOADED, + VIEWER_CTX, } type TViewerEventViewerLoaded = { - type: "VIEWERLOADED" + type: EnumViewerEvt.VIEWERLOADED data: boolean } -export type TViewerEvent = TViewerEventMOAnno | TViewerEventViewerLoaded +export type TViewerEvent<T extends keyof IViewerCtx> = TViewerEventViewerLoaded | + { + type: EnumViewerEvt.VIEWER_CTX + data: TContextArg<T> + } + +export type TSupportedViewers = keyof IViewerCtx -export type IViewer = { +export interface IViewer<K extends keyof IViewerCtx> { selectedTemplate: any selectedParcellation: any viewerCtrlHandler?: IViewerCtrl - viewerEvent: EventEmitter<TViewerEvent> -} \ No newline at end of file + viewerEvent: EventEmitter<TViewerEvent<K>> +} + +export interface IGetContextInjArg { + register: (fn: (contextArg: TContextArg<TSupportedViewers>) => void) => void + deregister: (fn: (contextArg: TContextArg<TSupportedViewers>) => void) => void +} diff --git a/src/viewerModule/viewerCmp/viewerCmp.component.ts b/src/viewerModule/viewerCmp/viewerCmp.component.ts index 5c075f9841a69eaf2763a399ffd469abd7bcaa2a..aa6d2af847e6cb6570e982bdc096d7cfaaa3f10a 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.component.ts +++ b/src/viewerModule/viewerCmp/viewerCmp.component.ts @@ -1,17 +1,23 @@ -import { Component, Inject, Input, OnDestroy, Optional, ViewChild } from "@angular/core"; +import { Component, ElementRef, Inject, Input, OnDestroy, Optional, TemplateRef, ViewChild, ViewContainerRef } from "@angular/core"; import { select, Store } from "@ngrx/store"; import { combineLatest, Observable, Subject, Subscription } from "rxjs"; import { distinctUntilChanged, filter, map, startWith } from "rxjs/operators"; import { viewerStateHelperSelectParcellationWithId, viewerStateRemoveAdditionalLayer, viewerStateSetSelectedRegions } from "src/services/state/viewerState/actions"; import { viewerStateContextedSelectedRegionsSelector, viewerStateGetOverlayingAdditionalParcellations, viewerStateParcVersionSelector, viewerStateSelectedParcellationSelector, viewerStateSelectedTemplateSelector, viewerStateStandAloneVolumes } from "src/services/state/viewerState/selectors" -import { CONST, ARIA_LABELS } from 'common/constants' +import { CONST, ARIA_LABELS, QUICKTOUR_DESC } from 'common/constants' import { ngViewerActionClearView } from "src/services/state/ngViewerState/actions"; import { ngViewerSelectorClearViewEntries } from "src/services/state/ngViewerState/selectors"; import { uiActionHideAllDatasets, uiActionHideDatasetWithId } from "src/services/state/uiState/actions"; import { REGION_OF_INTEREST } from "src/util/interfaces"; import { animate, state, style, transition, trigger } from "@angular/animations"; import { SwitchDirective } from "src/util/directives/switch.directive"; -import { TSupportedViewer } from "../constants"; +import { IViewerCmpUiState } from "../constants"; +import { QuickTourThis, IQuickTourData } from "src/ui/quickTour"; +import { MatDrawer } from "@angular/material/sidenav"; +import { ComponentStore } from "../componentStore"; +import { EnumViewerEvt, TContextArg, TSupportedViewers, TViewerEvent } from "../viewer.interface"; +import { getGetRegionFromLabelIndexId } from "src/util/fn"; +import { ContextMenuService } from "src/contextMenuModule"; @Component({ selector: 'iav-cmp-viewer-container', @@ -57,12 +63,16 @@ import { TSupportedViewer } from "../constants"; provide: REGION_OF_INTEREST, useFactory: (store: Store<any>) => store.pipe( select(viewerStateContextedSelectedRegionsSelector), - map(rs => rs[0] || null) + map(rs => { + if (!rs[0]) return null + return rs[0] + }) ), deps: [ Store ] - } + }, + ComponentStore ] }) @@ -77,9 +87,25 @@ export class ViewerCmp implements OnDestroy { @ViewChild('sideNavFullLeftSwitch', { static: true }) private sidenavLeftSwitch: SwitchDirective + + public quickTourRegionSearch: IQuickTourData = { + order: 7, + description: QUICKTOUR_DESC.REGION_SEARCH, + } + public quickTourAtlasSelector: IQuickTourData = { + order: 0, + description: QUICKTOUR_DESC.ATLAS_SELECTOR, + } + public quickTourChips: IQuickTourData = { + order: 5, + description: QUICKTOUR_DESC.CHIPS, + } + + @Input() ismobile = false private subscriptions: Subscription[] = [] + private onDestroyCb: (() => void)[] = [] public viewerLoaded: boolean = false public templateSelected$ = this.store$.pipe( @@ -101,7 +127,7 @@ export class ViewerCmp implements OnDestroy { map(v => v.length > 0) ) - public useViewer$: Observable<TSupportedViewer> = combineLatest([ + public useViewer$: Observable<TSupportedViewers | 'notsupported'> = combineLatest([ this.templateSelected$, this.isStandaloneVolumes$, ]).pipe( @@ -153,24 +179,126 @@ export class ViewerCmp implements OnDestroy { map(([ regions, layers ]) => regions.length === 0 && layers.length === 0) ) + @ViewChild('viewerStatusCtxMenu', { read: TemplateRef }) + private viewerStatusCtxMenu: TemplateRef<any> + + public context: TContextArg<TSupportedViewers> + private templateSelected: any + private getRegionFromlabelIndexId: Function + constructor( private store$: Store<any>, + private viewerCmpLocalUiStore: ComponentStore<IViewerCmpUiState>, + private viewerModuleSvc: ContextMenuService, @Optional() @Inject(REGION_OF_INTEREST) public regionOfInterest$: Observable<any> ){ + this.viewerCmpLocalUiStore.setState({ + sideNav: { + activePanelsTitle: [] + } + }) + + this.activePanelTitles$ = this.viewerCmpLocalUiStore.select( + state => state.sideNav.activePanelsTitle + ) as Observable<string[]> + this.subscriptions.push( + this.activePanelTitles$.subscribe( + (activePanelTitles: string[]) => this.activePanelTitles = activePanelTitles + ) + ) + this.subscriptions.push( this.alwaysHideMinorPanel$.pipe( distinctUntilChanged(), filter(flag => !flag), ).subscribe(() => { this.openSideNavs() - }) + }), + this.viewerModuleSvc.context$.subscribe( + (ctx: any) => this.context = ctx + ), + this.templateSelected$.subscribe( + t => this.templateSelected = t + ), + this.parcellationSelected$.subscribe( + p => { + this.getRegionFromlabelIndexId = !!p + ? getGetRegionFromLabelIndexId({ parcellation: p }) + : null + } + ) + ) + } + + ngAfterViewInit(){ + const cb = (context: TContextArg<'nehuba' | 'threeSurfer'>) => { + let hoveredRegions = [] + + if (context.viewerType === 'nehuba') { + hoveredRegions = (context as TContextArg<'nehuba'>).payload.nehuba.reduce( + (acc, curr) => acc.concat( + curr.labelIndices.map( + lblIdx => { + const labelIndexId = `${curr.layerName}#${lblIdx}` + if (!!this.getRegionFromlabelIndexId) { + return this.getRegionFromlabelIndexId({ + labelIndexId: `${curr.layerName}#${lblIdx}` + }) + } + return labelIndexId + } + ) + ), + [] + ) + } + + if (context.viewerType === 'threeSurfer') { + hoveredRegions = (context as TContextArg<'threeSurfer'>).payload._mouseoverRegion + } + + return { + tmpl: this.viewerStatusCtxMenu, + data: { + context, + metadata: { + template: this.templateSelected, + hoveredRegions + } + } + } + } + this.viewerModuleSvc.register(cb) + this.onDestroyCb.push( + () => this.viewerModuleSvc.deregister(cb) ) } ngOnDestroy() { while (this.subscriptions.length) this.subscriptions.pop().unsubscribe() + while (this.onDestroyCb.length > 0) this.onDestroyCb.pop()() } + public activePanelTitles$: Observable<string[]> + private activePanelTitles: string[] = [] + handleExpansionPanelClosedEv(title: string){ + this.viewerCmpLocalUiStore.setState({ + sideNav: { + activePanelsTitle: this.activePanelTitles.filter(n => n !== title) + } + }) + } + handleExpansionPanelAfterExpandEv(title: string){ + if (this.activePanelTitles.includes(title)) return + this.viewerCmpLocalUiStore.setState({ + sideNav: { + activePanelsTitle: [ + ...this.activePanelTitles, + title + ] + } + }) + } public bindFns(fns){ return () => { @@ -179,7 +307,7 @@ export class ViewerCmp implements OnDestroy { } } } - + public clearAdditionalLayer(layer: { ['@id']: string }){ this.store$.dispatch( viewerStateRemoveAdditionalLayer({ @@ -188,6 +316,14 @@ export class ViewerCmp implements OnDestroy { ) } + public selectRoi(roi: any) { + this.store$.dispatch( + viewerStateSetSelectedRegions({ + selectRegions: [ roi ] + }) + ) + } + public clearSelectedRegions(){ this.store$.dispatch( viewerStateSetSelectedRegions({ @@ -195,7 +331,7 @@ export class ViewerCmp implements OnDestroy { }) ) } - + public selectParcellation(parc: any) { this.store$.dispatch( viewerStateHelperSelectParcellationWithId({ @@ -230,4 +366,35 @@ export class ViewerCmp implements OnDestroy { : uiActionHideAllDatasets() ) } + + @ViewChild('regionSelRef', { read: ElementRef }) + regionSelRef: ElementRef<any> + + @ViewChild('regionSearchQuickTour', { read: QuickTourThis }) + regionSearchQuickTour: QuickTourThis + + @ViewChild('matDrawerLeft', { read: MatDrawer }) + matDrawerLeft: MatDrawer + + handleSideNavAnimationDone(sideNavExpanded: boolean) { + this.regionSearchQuickTour?.attachTo( + !sideNavExpanded ? null : this.regionSelRef + ) + } + + public handleViewerEvent(event: TViewerEvent<'nehuba' | 'threeSurfer'>){ + switch(event.type) { + case EnumViewerEvt.VIEWERLOADED: + this.viewerLoaded = event.data + break + case EnumViewerEvt.VIEWER_CTX: + this.viewerModuleSvc.context$.next(event.data) + break + default: + } + } + + public disposeCtxMenu(){ + this.viewerModuleSvc.dismissCtxMenu() + } } diff --git a/src/viewerModule/viewerCmp/viewerCmp.style.css b/src/viewerModule/viewerCmp/viewerCmp.style.css index af05bcb9091017bba2154587fea00f258843cf38..2f8a5085ce35e8cf8382f3d1ef01b626b14eb5fe 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.style.css +++ b/src/viewerModule/viewerCmp/viewerCmp.style.css @@ -45,3 +45,8 @@ { flex-wrap: nowrap; } + +mat-drawer +{ + overflow-x: hidden; +} diff --git a/src/viewerModule/viewerCmp/viewerCmp.template.html b/src/viewerModule/viewerCmp/viewerCmp.template.html index 728a6b3be42e01f09fc9d00a4ed553a539de0bc2..22b8b3f220a4f2394af393bfcb50621d5f94b359 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.template.html +++ b/src/viewerModule/viewerCmp/viewerCmp.template.html @@ -15,7 +15,7 @@ <!-- sidenav-content --> - <!-- (closedStart)="sideNavFullLeftSwitch.switchState && matDrawerLeft.close()" + <!-- (closedStart)="sideNavwFullLeftSwitch.switchState && matDrawerLeft.close()" (openedStart)="sideNavFullLeftSwitch.switchState && matDrawerLeft.open()" --> <mat-drawer class="box-shadow-none border-0 pe-none bg-none col-10 col-sm-10 col-md-5 col-lg-4 col-xl-3 col-xxl-2" mode="side" @@ -23,10 +23,11 @@ [opened]="sideNavTopSwitch.switchState" [autoFocus]="false" [disableClose]="true" + (openedChange)="handleSideNavAnimationDone($event)" #matDrawerTop="matDrawer"> <div class="h-0 w-100 region-text-search-autocomplete-position"> - <ng-container *ngTemplateOutlet="autocompleteTmpl"> + <ng-container *ngTemplateOutlet="autocompleteTmpl; context: { showTour: true }"> </ng-container> </div> @@ -65,7 +66,11 @@ <div *ngIf="viewerLoaded" class="pe-all tab-toggle-container" - (click)="sideNavTopSwitch && sideNavTopSwitch.toggle()"> + (click)="sideNavTopSwitch && sideNavTopSwitch.toggle()" + quick-tour + [quick-tour-description]="quickTourRegionSearch.description" + [quick-tour-order]="quickTourRegionSearch.order" + #regionSearchQuickTour="quickTour"> <ng-container *ngTemplateOutlet="tabTmpl; context: { isOpen: sideNavTopSwitch.switchState, regionSelected: selectedRegions$ | async, @@ -91,7 +96,10 @@ </top-menu-cmp> <div *ngIf="viewerLoaded" - class="iv-custom-comp bg card m-2 mat-elevation-z2"> + class="iv-custom-comp bg card m-2 mat-elevation-z2" + quick-tour + [quick-tour-description]="quickTourAtlasSelector.description" + [quick-tour-order]="quickTourAtlasSelector.order"> <atlas-dropdown-selector class="pe-all mt-2"> </atlas-dropdown-selector> </div> @@ -196,9 +204,12 @@ </atlas-layer-selector> <!-- chips --> - <div class="flex-grow-1 flex-shrink-1 overflow-x-auto"> + <div class="flex-grow-1 flex-shrink-1 overflow-x-auto pe-all"> - <mat-chip-list class="d-inline-block"> + <mat-chip-list class="d-inline-block" + quick-tour + [quick-tour-description]="quickTourChips.description" + [quick-tour-order]="quickTourChips.order"> <!-- additional layer --> <ng-container> @@ -234,14 +245,16 @@ <iav-layout-fourcorners> <div iavLayoutFourCornersContent class="w-100 h-100 position-absolute"> - <div class="h-100 w-100 overflow-hidden position-relative"> + <div class="h-100 w-100 overflow-hidden position-relative" + ctx-menu-host + [ctx-menu-host-tmpl]="viewerCtxMenuTmpl"> <ng-container [ngSwitch]="useViewer$ | async"> <!-- nehuba viewer --> <iav-cmp-viewer-nehuba-glue class="d-block w-100 h-100 position-absolute left-0 top-0" *ngSwitchCase="'nehuba'" - (viewerEvent)="viewerLoaded = $event.data" + (viewerEvent)="handleViewerEvent($event)" [selectedTemplate]="templateSelected$ | async" [selectedParcellation]="parcellationSelected$ | async" #iavCmpViewerNehubaGlue="iavCmpViewerNehubaGlue"> @@ -250,14 +263,14 @@ <!-- three surfer (free surfer viewer) --> <three-surfer-glue-cmp class="d-block w-100 h-100 position-absolute left-0 top-0" *ngSwitchCase="'threeSurfer'" - (viewerEvent)="viewerLoaded = $event.data" + (viewerEvent)="handleViewerEvent($event)" [selectedTemplate]="templateSelected$ | async" [selectedParcellation]="parcellationSelected$ | async"> </three-surfer-glue-cmp> <!-- if not supported, show not supported message --> <div *ngSwitchCase="'notsupported'">Template not supported by any of the viewers</div> - + <!-- by default, show splash screen --> <div *ngSwitchDefault> <ui-splashscreen class="position-absolute left-0 top-0"> @@ -511,10 +524,15 @@ <!-- auto complete search box --> -<ng-template #autocompleteTmpl> +<ng-template #autocompleteTmpl let-showTour="showTour"> <div class="iv-custom-comp bg card w-100 mat-elevation-z8 pe-all"> <region-text-search-autocomplete class="w-100 pt-2 flex-shrink-0 flex-grow-0"> </region-text-search-autocomplete> + + <div class="w-100 h-100 position-absolute pe-none" + *ngIf="showTour" + #regionSelRef> + </div> </div> </ng-template> @@ -846,8 +864,11 @@ let-iavNgIf="iavNgIf" let-content="content"> <mat-expansion-panel + [expanded]="activePanelTitles$ | async | arrayContains : title" [attr.data-opened]="expansionPanel.expanded" [attr.data-mat-expansion-title]="title" + (closed)="handleExpansionPanelClosedEv(title)" + (afterExpand)="handleExpansionPanelAfterExpandEv(title)" hideToggle *ngIf="iavNgIf" #expansionPanel="matExpansionPanel"> @@ -995,3 +1016,87 @@ </span> </div> </ng-template> + +<!-- context menu template --> +<ng-template #viewerCtxMenuTmpl let-tmplRefs="tmplRefs"> + <mat-card class="p-0" (iav-outsideClick)="disposeCtxMenu()"> + <mat-card-content *ngFor="let tmplRef of tmplRefs"> + <ng-container *ngTemplateOutlet="tmplRef.tmpl; context: { $implicit: tmplRef.data } "> + </ng-container> + </mat-card-content> + </mat-card> +</ng-template> + +<!-- viewer status ctx menu --> +<ng-template #viewerStatusCtxMenu let-data> + <mat-list> + + <!-- ref space & position --> + <ng-container [ngSwitch]="data.context.viewerType"> + + <!-- volumetric i.e. nehuba --> + <ng-container *ngSwitchCase="'nehuba'"> + <mat-list-item> + <span mat-line> + {{ data.context.payload.mouse.real | nmToMm | addUnitAndJoin : '' }} (mm) + </span> + <span mat-line class="text-muted"> + <i class="fas fa-map"></i> + <span> + {{ data.metadata.template.displayName || data.metadata.template.name }} + </span> + </span> + + <button mat-icon-button> + <i class="fas fa-thumbtack"></i> + </button> + </mat-list-item> + </ng-container> + + <ng-container *ngSwitchCase="'threeSurfer'"> + <mat-list-item> + <span mat-line> + face#{{ data.context.payload.faceIndex }} + </span> + <span mat-line> + vertices#{{ data.context.payload.vertexIndices | addUnitAndJoin : '' }} + </span> + <span mat-line class="text-muted"> + <i class="fas fa-map"></i> + <span> + {{ data.context.payload.fsversion }} + </span> + </span> + </mat-list-item> + </ng-container> + + <ng-container *ngSwitchDefault> + DEFAULT + </ng-container> + </ng-container> + + <!-- hovered ROIs --> + <ng-template [ngIf]="data.metadata.hoveredRegions.length > 0"> + <mat-divider></mat-divider> + + <mat-list-item *ngFor="let hoveredR of data.metadata.hoveredRegions"> + <span mat-line> + {{ hoveredR.displayName || hoveredR.name }} + </span> + <span mat-line class="text-muted"> + <i class="fas fa-brain"></i> + <span> + Brain region + </span> + </span> + + <!-- lookup region --> + <button mat-icon-button + (click)="selectRoi(hoveredR)" + ctx-menu-dismiss> + <i class="fas fa-search"></i> + </button> + </mat-list-item> + </ng-template> + </mat-list> +</ng-template>