diff --git a/.github/workflows/docker_img.yml b/.github/workflows/docker_img.yml new file mode 100644 index 0000000000000000000000000000000000000000..a4db882dd1b3455b742dece71837b6c3bf4b7e5f --- /dev/null +++ b/.github/workflows/docker_img.yml @@ -0,0 +1,70 @@ +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-explorer/' + + 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 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..1297909f46e45ece59d07e3ba48eed20094126ce 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.11", "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/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/index.html b/src/index.html index a1df7bb2632c13eb82c0b1537111cd2c935fff8c..af97a404f1035de4183c237a63bb6e1b551e1b5f 100644 --- a/src/index.html +++ b/src/index.html @@ -14,7 +14,7 @@ <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/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/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/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/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/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..f7fe2681a531f5830db8807bb0409ec06bb17105 100644 --- a/src/viewerModule/constants.ts +++ b/src/viewerModule/constants.ts @@ -4,3 +4,9 @@ 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..7bfc1b341467602de9ddcdcdefa5d75c889e00a8 100644 --- a/src/viewerModule/module.ts +++ b/src/viewerModule/module.ts @@ -18,6 +18,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 +37,7 @@ import { ViewerCmp } from "./viewerCmp/viewerCmp.component"; AtlasCmptConnModule, ComponentsModule, BSFeatureModule, + QuickTourModule, ], 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/nehubaViewerGlue/nehubaViewerGlue.component.ts b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts index 004f45f50516bcc6dc5ea1d55a575ac750be0781..0d8466e55ab61d2cab646e291873793a81e42688 100644 --- a/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts +++ b/src/viewerModule/nehuba/nehubaViewerGlue/nehubaViewerGlue.component.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, EventEmitter, Inject, Input, OnChanges, OnDestroy, Optional, Output, SimpleChanges, ViewChild } from "@angular/core"; +import { Component, ElementRef, EventEmitter, Inject, Input, OnChanges, OnDestroy, Optional, Output, SimpleChanges, TemplateRef, ViewChild } from "@angular/core"; import { select, Store } from "@ngrx/store"; import { asyncScheduler, combineLatest, fromEvent, merge, Observable, of, Subject } from "rxjs"; import { ngViewerActionAddNgLayer, ngViewerActionRemoveNgLayer, ngViewerActionToggleMax } from "src/services/state/ngViewerState/actions"; @@ -9,7 +9,7 @@ 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"; @@ -21,6 +21,7 @@ import { cvtNavigationObjToNehubaConfig, getFourPanel, getHorizontalOneThree, ge 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"; @@ -104,6 +105,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 => ({ @@ -114,6 +130,10 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ }))), ) + ngAfterViewInit(){ + this.setQuickTourPos() + } + public panelOrder$ = this.store$.pipe( select(ngViewerSelectorPanelOrder), distinctUntilChanged(), @@ -197,7 +217,7 @@ export class NehubaGlueCmp implements IViewer, OnChanges, OnDestroy{ const overwritingInitState = this.navigation ? cvtNavigationObjToNehubaConfig(this.navigation, initialNgState) : {} - + deepCopiedState.nehubaConfig.dataset.initialNgState = { ...initialNgState, ...overwritingInitState, @@ -273,7 +293,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 +314,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 +379,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 +577,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 +595,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 +610,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 +704,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) @@ -785,4 +805,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/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/threeSurfer/threeSurferGlue/threeSurfer.component.ts b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts index a77b837ca751aabe9f642c7ec330cab6ff5f3ecc..b68770f6675d0f7bec017b2ed0e22663427fb44b 100644 --- a/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts +++ b/src/viewerModule/threeSurfer/threeSurferGlue/threeSurfer.component.ts @@ -68,7 +68,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, diff --git a/src/viewerModule/viewerCmp/viewerCmp.component.ts b/src/viewerModule/viewerCmp/viewerCmp.component.ts index 5c075f9841a69eaf2763a399ffd469abd7bcaa2a..3a7183062394728fdf901aaf1ccd4d966294d4c6 100644 --- a/src/viewerModule/viewerCmp/viewerCmp.component.ts +++ b/src/viewerModule/viewerCmp/viewerCmp.component.ts @@ -1,17 +1,20 @@ -import { Component, Inject, Input, OnDestroy, Optional, ViewChild } from "@angular/core"; +import { Component, ElementRef, Inject, Input, OnDestroy, Optional, ViewChild } 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, TSupportedViewer } from "../constants"; +import { QuickTourThis, IQuickTourData } from "src/ui/quickTour"; +import { MatDrawer } from "@angular/material/sidenav"; +import { ComponentStore } from "../componentStore"; @Component({ selector: 'iav-cmp-viewer-container', @@ -57,12 +60,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,6 +84,21 @@ 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[] = [] @@ -155,8 +177,24 @@ export class ViewerCmp implements OnDestroy { constructor( private store$: Store<any>, + private viewerCmpLocalUiStore: ComponentStore<IViewerCmpUiState>, @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(), @@ -171,6 +209,26 @@ export class ViewerCmp implements OnDestroy { while (this.subscriptions.length) this.subscriptions.pop().unsubscribe() } + 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 +237,7 @@ export class ViewerCmp implements OnDestroy { } } } - + public clearAdditionalLayer(layer: { ['@id']: string }){ this.store$.dispatch( viewerStateRemoveAdditionalLayer({ @@ -195,7 +253,7 @@ export class ViewerCmp implements OnDestroy { }) ) } - + public selectParcellation(parc: any) { this.store$.dispatch( viewerStateHelperSelectParcellationWithId({ @@ -230,4 +288,19 @@ 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 + ) + } } 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..c401208018a6e89b00075de69239b8d976084111 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> @@ -257,7 +268,7 @@ <!-- 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 +522,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 +862,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">