diff --git a/.github/workflows/deploy-to-firebase.yml b/.github/workflows/deploy-to-firebase.yml new file mode 100644 index 0000000..35c6d70 --- /dev/null +++ b/.github/workflows/deploy-to-firebase.yml @@ -0,0 +1,37 @@ +name: slim/deploy-to-firebase + +on: + pull_request: + branches: [master] + push: + branches: [master] + +jobs: + deploy-firebase: + name: "Deploy to Firebase" + if: "${{ github.event.pull_request.head.repo.full_name == github.repository }}" + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4.1.4 + + - name: Setup Node + uses: actions/setup-node@v4.0.2 + with: + node-version: 20.8.1 + + - name: Install Yarn + run: sudo npm i -g yarn + + - name: Install dependencies + run: yarn + + - name: Build + run: REACT_APP_CONFIG=preview PUBLIC_URL=/ yarn build + + - name: Deploy + uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: "${{ secrets.GITHUB_TOKEN }}" + firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT_SLIM }}" + projectId: idc-external-006 diff --git a/.github/workflows/deploy-to-github-pages.yml b/.github/workflows/deploy-to-github-pages.yml new file mode 100644 index 0000000..01dd662 --- /dev/null +++ b/.github/workflows/deploy-to-github-pages.yml @@ -0,0 +1,33 @@ +name: slim/deploy-to-github-pages + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + deploy-to-github-pages: + name: "Deploy to GitHub Pages" + runs-on: ubuntu-latest + steps: + - name: Checkout to repository + uses: actions/checkout@v4.1.4 + + - name: Setup Node + uses: actions/setup-node@v4.0.2 + with: + node-version: 20.8.1 + + - name: Install Yarn + run: sudo npm i -g yarn + + - name: Install dependencies + run: yarn + + - name: Build and deploy to GitHub Pages + run: | + git remote set-url origin https://git:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git + yarn deploy -- -u "github-actions-bot " + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..116146e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,40 @@ +name: slim/release + +on: + push: + branches: + - master + +jobs: + release: + name: "Bump version and cut a release" + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4.1.4 + with: + ref: master + persist-credentials: false + + - name: Setup node + uses: actions/setup-node@v4.0.2 + with: + node-version: 20.8.1 + + - name: Install dependencies + run: yarn + + - name: Build + run: yarn build + + - name: Zip build + run: zip -r build.zip build + + - name: Bump version and cut a release + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }} + GIT_AUTHOR_NAME: ${{ vars.RELEASE_GIT_AUTHOR_NAME }} + GIT_AUTHOR_EMAIL: ${{ vars.RELEASE_GIT_AUTHOR_EMAIL }} + GIT_COMMITTER_NAME: ${{ vars.RELEASE_GIT_COMMITTER_NAME }} + GIT_COMMITTER_EMAIL: ${{ vars.RELEASE_GIT_COMMITTER_EMAIL }} + run: npx semantic-release --branches master diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..7a4a11c --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,32 @@ +name: slim/build-and-run-unit-tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + build-and-test: + name: "Build and run unit tests" + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4.1.4 + + - name: Setup Node + uses: actions/setup-node@v4.0.2 + with: + node-version: 20.8.1 + + - name: Install dependencies + run: yarn + + - name: Build + run: yarn build + + - name: Lint + run: yarn lint + + - name: Test + run: yarn test diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..c9bc452 --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,43 @@ +{ + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + [ + "@semantic-release/changelog", + { + "changelogFile": "docs/CHANGELOG.md" + } + ], + [ + "@semantic-release/npm", + { + "npmPublish": false + } + ], + [ + "@semantic-release/git", + { + "assets": [ + "docs", + "package.json" + ], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ], + [ + "@semantic-release/github", + { + "assets": [ + { + "path": "build.zip", + "label": "slim-${nextRelease.gitTag}.zip" + }, + { + "path": "docs/CHANGELOG.md", + "label": "${nextRelease.gitTag}-CHANGELOG.md" + } + ] + } + ] + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f59b039..c87fcfa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,10 +13,10 @@ RUN apt-get update && \ nginx && \ apt-get clean -RUN curl -fsSL https://deb.nodesource.com/setup_16.x | bash - && \ +RUN curl -fsSL https://deb.nodesource.com/setup_21.x | bash - && \ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \ echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \ - curl -sS https://deb.nodesource.com/setup_16.x | bash - && \ + curl -sS https://deb.nodesource.com/setup_21.x | bash - && \ apt-get update && \ apt-get install -y --no-install-suggests --no-install-recommends \ nodejs \ diff --git a/README.md b/README.md index 9df10f0..68f0a1a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ [![DOI](https://zenodo.org/badge/335130719.svg)](https://zenodo.org/badge/latestdoi/335130719) -[![Build Status](https://github.com/imagingdatacommons/slim/actions/workflows/run_unit_tests.yml/badge.svg)](https://github.com/imagingdatacommons/slim/actions) +[![Build Status](https://github.com/imagingdatacommons/slim/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/imagingdatacommons/slim/actions) # Slim: Interoperable slide microscopy viewer and annotation tool for imaging data science and computational pathology @@ -11,7 +11,7 @@ It relies on [DICOMweb](https://www.dicomstandard.org/dicomweb/) RESTful service ### National Cancer Institute's Imaging Data Commons -*Slim* serves as the slide microscopy viewer of the [National Cancer Institute's Imaging Data Commons (IDC)](https://datacommons.cancer.gov/repository/imaging-data-commons). +*Slim* is used as the slide microscopy viewer by the [National Cancer Institute's Imaging Data Commons (IDC)](https://imaging.datacommons.cancer.gov). IDC CPTAC C3L-00965-26 @@ -21,15 +21,10 @@ The IDC viewer uses the [Google Cloud Healthcare API](https://cloud.google.com/h ### Demo -Explore additional slide microscopy imaging data sets and advanced viewer features at [imagingdatacommons.github.io/slim](https://imagingdatacommons.github.io/slim/). +Below you will find links to the representative DICOM SM images opened in Slim viewer: -IDC HTAN HTA9_1_32 - -The demo viewer uses an instance of the open-source [DCM4CHEE Archive](https://github.com/dcm4che/dcm4chee-arc-light) as DICOMweb server. -It includes brightfield and fluorescence microscopy images that were collected for different research projects, including [The Cancer Genome Atlas (TCGA)](https://www.cancer.gov/about-nci/organization/ccg/research/structural-genomics/tcga), the [Clinical Proteomic Tumor Analysis Consortium (CPTAC)](https://gdc.cancer.gov/about-gdc/contributed-genomic-data-cancer-research/clinical-proteomic-tumor-analysis-consortium-cptac), the [Human Tumor Atlas Network (HTAN)](https://www.cancer.gov/research/key-initiatives/moonshot-cancer-initiative/implementation/human-tumor-atlas)). -These images were originally stored in SVS-TIFF or OME-TIFF format and were subsequently converted into DICOM format for ingestion into the IDC. -In addition, the demo includes images that were collected for interoperability demonstrations at DICOM WG-26 Pathology Connectathons or Hackathons. -These images were directly stored in DICOM format and did not require conversion. +* H&E: https://viewer.imaging.datacommons.cancer.gov/slim/studies/2.25.211094631316408413440371843585977094852/series/1.3.6.1.4.1.5962.99.1.208792987.352384958.1640886332827.2.0 +* multichannel fluorescence: https://viewer.imaging.datacommons.cancer.gov/slim/studies/2.25.93749216439228361118017742627453453196/series/1.3.6.1.4.1.5962.99.1.2344794501.795090168.1655907236229.4.0?state=1.2.826.0.1.3680043.10.511.3.79630386778396943986328353882008803 ## Features diff --git a/craco.config.js b/craco.config.js index 5610213..184d7f3 100644 --- a/craco.config.js +++ b/craco.config.js @@ -77,7 +77,7 @@ module.exports = { '@cornerstonejs/codec-charls/decodewasmjs': '@cornerstonejs/codec-charls/dist/charlswasm_decode.js', '@cornerstonejs/codec-charls/decodewasm': '@cornerstonejs/codec-charls/dist/charlswasm_decode.wasm', '@cornerstonejs/codec-openjpeg/decodewasmjs': '@cornerstonejs/codec-openjpeg/dist/openjpegwasm_decode.js', - '@cornerstonejs/codec-openjpeg/decodewasm': '@cornerstonejs/codec-openjpeg/dist/openjpegwasm_decode.wasm', + '@cornerstonejs/codec-openjpeg/decodewasm': '@cornerstonejs/codec-openjpeg/dist/openjpegwasm_decode.wasm' } return config } diff --git a/docker-compose.yml b/docker-compose.yml index 4c7ad6d..7bf7bfb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3.7' +version: "3.7" volumes: db_data: {} @@ -62,9 +62,15 @@ services: - 12575 env_file: docker-compose.env environment: + # Used to set the initial and maximal Java heap size to avoid + # problems retrieving large WSI bulk data annotations. + # + # The default is "-Xms64m -Xmx512m -XX:MetaspaceSize=96M -XX:MaxMetaspaceSize=256m" + # Reference: https://github.com/dcm4che-dockerfiles/dcm4chee-arc-psql + JBOSS_JAVA_SIZING: "-Xms64m -Xmx4096m -XX:MetaspaceSize=96M -XX:MaxMetaspaceSize=4096m" WILDFLY_CHOWN: /opt/wildfly/standalone /storage WILDFLY_WAIT_FOR: ldap:389 db:5432 - HTTP_PROXY_ADDRESS_FORWARDING: 'true' + HTTP_PROXY_ADDRESS_FORWARDING: "true" ARCHIVE_HOST: localhost depends_on: - ldap diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..9b769c9 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,46 @@ +## [0.31.3](https://github.com/ImagingDataCommons/slim/compare/v0.31.2...v0.31.3) (2024-05-09) + + +### Bug Fixes + +* **README.md:** Update README.md to fix build status ([#210](https://github.com/ImagingDataCommons/slim/issues/210)) ([7574a93](https://github.com/ImagingDataCommons/slim/commit/7574a93b0c9a303202f135566328c69eb605cd69)) + +## [0.31.2](https://github.com/ImagingDataCommons/slim/compare/v0.31.1...v0.31.2) (2024-05-08) + + +### Bug Fixes + +* **ROI:** Avoid removing highlighting of ROI after closing ROI info dialog and use double click to open it ([#197](https://github.com/ImagingDataCommons/slim/issues/197)) ([a76a79f](https://github.com/ImagingDataCommons/slim/commit/a76a79f46c09f876933e9a6d57b667d673f13e96)) + +## [0.31.1](https://github.com/ImagingDataCommons/slim/compare/v0.31.0...v0.31.1) (2024-05-08) + + +### Bug Fixes + +* **.github:** Refactor workflows ([#205](https://github.com/ImagingDataCommons/slim/issues/205)) ([552f99f](https://github.com/ImagingDataCommons/slim/commit/552f99f3052c039801f0a6b86564445a3497cc26)) + +# [0.31.0](https://github.com/ImagingDataCommons/slim/compare/v0.30.0...v0.31.0) (2024-05-07) + + +### Bug Fixes + +* **package.json:** Fix Inefficient Regular Expression Complexity in nth-check (vulnerability) ([#151](https://github.com/ImagingDataCommons/slim/issues/151)) ([4f42258](https://github.com/ImagingDataCommons/slim/commit/4f4225889cedb853c79db84bac8aee94f0b41715)) +* security issues ([#179](https://github.com/ImagingDataCommons/slim/issues/179)) ([eb8ddc0](https://github.com/ImagingDataCommons/slim/commit/eb8ddc093427547e7e178973fc871c47fa18ed61)) + + +### Features + +* add secondary dicom server ([#188](https://github.com/ImagingDataCommons/slim/issues/188)) ([356009f](https://github.com/ImagingDataCommons/slim/commit/356009f6a86cd96bfa6c6b478adb46683fbdcd3d)) + +# [0.31.0](https://github.com/ImagingDataCommons/slim/compare/v0.30.0...v0.31.0) (2024-05-07) + + +### Bug Fixes + +* **package.json:** Fix Inefficient Regular Expression Complexity in nth-check (vulnerability) ([#151](https://github.com/ImagingDataCommons/slim/issues/151)) ([4f42258](https://github.com/ImagingDataCommons/slim/commit/4f4225889cedb853c79db84bac8aee94f0b41715)) +* security issues ([#179](https://github.com/ImagingDataCommons/slim/issues/179)) ([eb8ddc0](https://github.com/ImagingDataCommons/slim/commit/eb8ddc093427547e7e178973fc871c47fa18ed61)) + + +### Features + +* add secondary dicom server ([#188](https://github.com/ImagingDataCommons/slim/issues/188)) ([356009f](https://github.com/ImagingDataCommons/slim/commit/356009f6a86cd96bfa6c6b478adb46683fbdcd3d)) diff --git a/package.json b/package.json index a641156..bed00d3 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,14 @@ { "name": "slim", - "version": "0.14.1", + "version": "0.31.3", "homepage": "https://github.com/imagingdatacommons/slim", "private": true, + "author": "ImagingDataCommons", + "proxy": "http://localhost:8008", "scripts": { "start": "rm -rf ./node_modules/.cache/default-development && craco start", "build": "craco build", + "build:firebase": "REACT_APP_CONFIG=gcp PUBLIC_URL=/ craco build", "lint": "ts-standard --env jest 'src/**/*.{tsx,ts}'", "fmt": "ts-standard --env jest 'src/**/*.{tsx,ts}' --fix", "test": "ts-standard --env jest 'src/**/*.{tsx,ts}' && craco test --setupFiles ./src/setupTests.tsx --watchAll=false", @@ -47,10 +50,10 @@ "classnames": "^2.2.6", "copy-webpack-plugin": "^10.2.4", "craco-less": "^2.0.0", - "dcmjs": "^0.19.1", + "dcmjs": "^0.29.8", "detect-browser": "^5.2.1", "dicom-microscopy-viewer": "^0.45.1", - "dicomweb-client": "^0.8.4", + "dicomweb-client": "^0.10.3", "gh-pages": "^5.0.0", "oidc-client": "^1.11.5", "react": "^18.2.0", @@ -61,7 +64,13 @@ "react-test-renderer": "^18.2.0", "retry": "^0.13.1", "ts-standard": "^11.0.0", - "typescript": "^4.7.4" + "typescript": "^4.7.4", + "@semantic-release/changelog": "^6.0.3", + "@semantic-release/commit-analyzer": "^12.0.0", + "@semantic-release/git": "^10.0.1", + "@semantic-release/github": "^10.0.3", + "@semantic-release/npm": "^12.0.0", + "@semantic-release/release-notes-generator": "^13.0.0" }, "dependencies": { "react-error-boundary": "^3.1.4" diff --git a/public/config/local.js b/public/config/local.js index a9b4348..13b5621 100644 --- a/public/config/local.js +++ b/public/config/local.js @@ -34,7 +34,7 @@ window.config = { fill: { color: [255, 255, 255, 0.2] } - }, + } }, { finding: { diff --git a/public/config/preview.js b/public/config/preview.js index e89bd98..9436e24 100644 --- a/public/config/preview.js +++ b/public/config/preview.js @@ -7,6 +7,11 @@ window.config = { write: false } ], + disableWorklist: false, + disableAnnotationTools: false, + enableServerSelection: true, + mode: "light", + preload: true, annotations: [ { finding: { value: '85756007', schemeDesignator: 'SCT', meaning: 'Tissue' }, @@ -19,6 +24,6 @@ window.config = { color: [255, 255, 255, 0.2] } } - } + }, ] } diff --git a/src/App.tsx b/src/App.tsx index 56bf03f..efc5de9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,8 +54,9 @@ function ParametrizedCaseViewer ({ clients, user, app, config }: { ) } -function _createClientMapping ({ baseUri, settings, onError }: { +function _createClientMapping ({ baseUri, gcpBaseUrl, settings, onError }: { baseUri: string + gcpBaseUrl: string settings: ServerSettings[] onError: ( error: dwc.api.DICOMwebClientError, @@ -63,6 +64,8 @@ function _createClientMapping ({ baseUri, settings, onError }: { ) => void }): { [sopClassUID: string]: DicomWebManager } { const storageClassMapping: { [key: string]: number } = { default: 0 } + const clientMapping: { [sopClassUID: string]: DicomWebManager } = {} + settings.forEach(serverSettings => { if (serverSettings.storageClasses != null) { serverSettings.storageClasses.forEach(sopClassUID => { @@ -80,7 +83,18 @@ function _createClientMapping ({ baseUri, settings, onError }: { } }) } else { + if (window.location.pathname.includes('/projects/')) { + const pathname = window.location.pathname.split('/study/')[0] + const pathUrl = `${gcpBaseUrl}${pathname}/dicomWeb` + serverSettings.url = pathUrl + } + storageClassMapping.default += 1 + clientMapping.default = new DicomWebManager({ + baseUri, + settings: [serverSettings], + onError + }) } }) @@ -94,6 +108,7 @@ function _createClientMapping ({ baseUri, settings, onError }: { ) ) } + for (const key in storageClassMapping) { if (key === 'default') { continue @@ -111,7 +126,6 @@ function _createClientMapping ({ baseUri, settings, onError }: { } } - const clientMapping: { [sopClassUID: string]: DicomWebManager } = {} if (Object.keys(storageClassMapping).length > 1) { settings.forEach(server => { const client = new DicomWebManager({ @@ -125,13 +139,8 @@ function _createClientMapping ({ baseUri, settings, onError }: { }) } }) - clientMapping.default = clientMapping[ - StorageClasses.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE - ] - } else { - const client = new DicomWebManager({ baseUri, settings, onError }) - clientMapping.default = client } + Object.values(StorageClasses).forEach(sopClassUID => { if (!(sopClassUID in clientMapping)) { clientMapping[sopClassUID] = clientMapping.default @@ -174,6 +183,18 @@ class App extends React.Component { 'User is not authorized to access DICOMweb resources.') ) } + + const logServerError = (): void => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + NotificationMiddleware.onError( + NotificationMiddlewareContext.DICOMWEB, + new CustomError( + errorTypes.COMMUNICATION, + 'An unexpected server error occured.' + ) + ) + } + if (serverSettings.errorMessages !== undefined) { serverSettings.errorMessages.forEach((setting: ErrorMessageSettings) => { if (error.status === setting.status) { @@ -184,15 +205,11 @@ class App extends React.Component { } }) } else if (error.status === 500) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - NotificationMiddleware.onError( - NotificationMiddlewareContext.DICOMWEB, - new CustomError( - errorTypes.COMMUNICATION, - 'An unexpected server error occured.') - ) + logServerError() } }) + } else if (error.status === 500) { + logServerError() } } @@ -230,10 +247,12 @@ class App extends React.Component { this.handleServerSelection = this.handleServerSelection.bind(this) message.config({ duration: 5 }) + this.addGcpSecondaryAnnotationServer(props.config) this.state = { clients: _createClientMapping({ baseUri, + gcpBaseUrl: props.config.gcpBaseUrl ?? 'https://healthcare.googleapis.com/v1', settings: props.config.servers, onError: this.handleDICOMwebError }), @@ -242,6 +261,33 @@ class App extends React.Component { } } + addGcpSecondaryAnnotationServer (config: AppProps['config']): void { + const serverId = 'gcp_secondary_annotation_server' + const urlParams = new URLSearchParams(window.location.search) + const url = urlParams.get('gcp') + const gcpSecondaryAnnotationServer = config.servers.find( + (server) => server.id === serverId + ) + if (gcpSecondaryAnnotationServer === undefined && typeof url === 'string') { + config.servers.push({ + id: serverId, + write: true, + url, + storageClasses: [ + StorageClasses.COMPREHENSIVE_SR, + StorageClasses.COMPREHENSIVE_3D_SR, + StorageClasses.SEGMENTATION, + StorageClasses.MICROSCOPY_BULK_SIMPLE_ANNOTATION, + StorageClasses.PARAMETRIC_MAP, + StorageClasses.ADVANCED_BLENDING_PRESENTATION_STATE, + StorageClasses.COLOR_SOFTCOPY_PRESENTATION_STATE, + StorageClasses.GRAYSCALE_SOFTCOPY_PRESENTATION_STATE, + StorageClasses.PSEUDOCOLOR_SOFTCOPY_PRESENTATION_STATE + ] + }) + } + } + handleServerSelection ({ url }: { url: string }): void { console.info('select DICOMweb server: ', url) const tmpClient = new DicomWebManager({ @@ -312,7 +358,8 @@ class App extends React.Component { isLoading: false, wasAuthSuccessful: true }) - }).catch(() => { + }).catch((error) => { + console.error(error) // eslint-disable-next-line @typescript-eslint/no-floating-promises NotificationMiddleware.onError( NotificationMiddlewareContext.AUTH, @@ -465,6 +512,29 @@ class App extends React.Component { } /> + +
+ + + + + } + /> +}): JSX.Element => { + const { types } = category + + const onCheckCategoryChange = (e: any): void => { + const isVisible = e.target.checked + types.forEach((type: Type) => { + handleChangeCheckedType({ type, isVisible }) + }) + } + + const checkAll = types.every((type: Type) => + type.uids.every((uid: string) => checkedAnnotationGroupUids.has(uid)) + ) + const indeterminate = + !checkAll && + types.some((type: Type) => + type.uids.some((uid: string) => checkedAnnotationGroupUids.has(uid)) + ) + + const handleChangeCheckedType = ({ + type, + isVisible + }: { + type: Type + isVisible: boolean + }): void => { + type.uids.forEach((uid: string) => { + onChange({ annotationGroupUID: uid, isVisible }) + }) + } + + return ( + + +
+ + + + {category.CodeMeaning} + + + + {types.map((type: Type) => { + const { CodeMeaning, CodingSchemeDesignator, CodeValue, uids } = + type + const isChecked = uids.every((uid: string) => + checkedAnnotationGroupUids.has(uid) + ) + const indeterminateType = + !isChecked && + uids.some((uid: string) => checkedAnnotationGroupUids.has(uid)) + return ( +
+ + handleChangeCheckedType({ + type, + isVisible: e.target.checked + })} + > + + {CodeMeaning} + + +
+ ) + })} +
+
+
+ ) +} + +export default AnnotationGroupItem diff --git a/src/components/AnnotationCategoryList.tsx b/src/components/AnnotationCategoryList.tsx new file mode 100644 index 0000000..4411d99 --- /dev/null +++ b/src/components/AnnotationCategoryList.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import { Menu } from 'antd' +import * as dmv from 'dicom-microscopy-viewer' +import AnnotationCategoryItem from './AnnotationCategoryItem' + +export interface Type { + CodeValue: string + CodeMeaning: string + CodingSchemeDesignator: string + uids: string[] +} +export interface Category { + CodeValue: string + CodeMeaning: string + CodingSchemeDesignator: string + types: Type[] +} + +const getCategories = (annotationGroups: any): Record => { + const categories = annotationGroups?.reduce( + ( + categoriesAcc: Record }>, + annotationGroup: dmv.annotation.AnnotationGroup + ) => { + const { propertyCategory, propertyType, uid } = annotationGroup + const categoryKey = propertyCategory.CodeMeaning + const typeKey = propertyType.CodeMeaning + + const oldCategory = categoriesAcc[categoryKey] ?? { + ...propertyCategory, + types: {} + } + const oldType = oldCategory.types[typeKey] ?? { + ...propertyType, + uids: [] + } + + return { + ...categoriesAcc, + [categoryKey]: { + ...oldCategory, + types: { + ...oldCategory.types, + [typeKey]: { ...oldType, uids: [...oldType.uids, uid] } + } + } + } + }, + {} + ) + + // Normalizing types so that it's an array instead of an object: + Object.keys(categories).forEach((categoryKey: string) => { + const category = categories[categoryKey] + const { types } = category + const typesArr = Object.keys(types).map( + (typeKey: string) => types[typeKey] + ) + categories[categoryKey].types = typesArr + }) + + return categories +} + +const AnnotationCategoryList = ({ + annotationGroups, + onChange, + checkedAnnotationGroupUids +}: { + annotationGroups: dmv.annotation.AnnotationGroup[] + onChange: Function + checkedAnnotationGroupUids: Set +}): JSX.Element => { + const categories: Record = getCategories(annotationGroups) + + if (Object.keys(categories).length === 0) { + return <> + } + + const items = Object.keys(categories).map((categoryKey: any) => { + const category = categories[categoryKey] + return ( + + ) + }) + + return {items} +} +export default AnnotationCategoryList diff --git a/src/components/AnnotationGroupItem.tsx b/src/components/AnnotationGroupItem.tsx index e33f265..c990a88 100644 --- a/src/components/AnnotationGroupItem.tsx +++ b/src/components/AnnotationGroupItem.tsx @@ -330,6 +330,10 @@ class AnnotationGroupItem extends React.Component { isLoading: false }) } - ).catch(() => { + ).catch((error) => { + console.error(error) // eslint-disable-next-line @typescript-eslint/no-floating-promises NotificationMiddleware.onError( NotificationMiddlewareContext.SLIM, @@ -195,12 +196,21 @@ class Viewer extends React.Component { `/studies/${this.props.studyInstanceUID}` + `/series/${seriesInstanceUID}` ) + + if (this.props.location.pathname.includes('/projects/')) { + urlPath = this.props.location.pathname + if (!this.props.location.pathname.includes('/series/')) { + urlPath += `/series/${seriesInstanceUID}` + } + } + if ( this.props.location.pathname.includes('/series/') && this.props.location.search != null ) { urlPath += this.props.location.search } + this.props.navigate(urlPath, { replace: true }) } @@ -225,8 +235,8 @@ class Viewer extends React.Component { */ let selectedSeriesInstanceUID: string if (this.props.location.pathname.includes('series/')) { - const fragments = this.props.location.pathname.split('/') - selectedSeriesInstanceUID = fragments[4] + const seriesFragment = this.props.location.pathname.split('series/')[1] + selectedSeriesInstanceUID = seriesFragment.includes('/') ? seriesFragment.split('/')[0] : seriesFragment } else { selectedSeriesInstanceUID = volumeInstances[0].SeriesInstanceUID } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index a0c261e..d793b3e 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -48,12 +48,17 @@ interface HeaderProps extends RouteComponentProps { showServerSelectionButton: boolean } +interface ExtendedCustomError extends CustomError { + source: string +} + interface HeaderState { selectedServerUrl?: string isServerSelectionModalVisible: boolean isServerSelectionDisabled: boolean - errorObj: CustomError[] + errorObj: ExtendedCustomError[] errorCategory: string[] + warnings: string[] } /** @@ -66,23 +71,49 @@ class Header extends React.Component { isServerSelectionModalVisible: false, isServerSelectionDisabled: true, errorObj: [], - errorCategory: [] + errorCategory: [], + warnings: [] } - const onErrorHandler = ({ error }: { - category: string + const onErrorHandler = ({ source, error }: { + source: string error: CustomError }): void => { - this.setState({ - errorObj: [...this.state.errorObj, error], - errorCategory: [...this.state.errorCategory, error.type] - }) + this.setState(state => ({ + ...state, + errorObj: [...state.errorObj, { ...error, source }], + errorCategory: [...state.errorCategory, error.type] + })) + } + + const onWarningHandler = (warning: string): void => { + this.setState(state => ({ + ...state, + warnings: [...state.warnings, warning] + })) } NotificationMiddleware.subscribe( NotificationMiddlewareEvents.OnError, onErrorHandler ) + + NotificationMiddleware.subscribe( + NotificationMiddlewareEvents.OnWarning, + onWarningHandler + ) + } + + componentDidUpdate (prevProps: Readonly, prevState: Readonly): void { + if (((prevState.warnings.length > 0) || (prevState.errorObj.length > 0)) && this.props.location.pathname !== prevProps.location.pathname) { + this.setState({ + isServerSelectionModalVisible: false, + isServerSelectionDisabled: true, + errorObj: [], + errorCategory: [], + warnings: [] + }) + } } handleInfoButtonClick = (): void => { @@ -163,7 +194,7 @@ class Header extends React.Component { if (errorNum > 0) { for (let i = 0; i < errorNum; i++) { const category = this.state.errorCategory[i] as ObjectKey - errorMsgs[category].push(this.state.errorObj[i].message) + errorMsgs[category].push(`${this.state.errorObj[i].message as string} (Source: ${this.state.errorObj[i].source})`) } } @@ -173,6 +204,10 @@ class Header extends React.Component { ) + const showWarningCount = (warncount: number): JSX.Element => ( + + ) + Modal.info({ title: 'Debug Information\n (Check console for more information)', width: 800, @@ -222,6 +257,17 @@ class Header extends React.Component { ))} + +
    + {this.state.warnings.map(warning => ( +
  1. {warning}
  2. + ))} +
+
), onOk (): void {} @@ -280,11 +326,13 @@ class Header extends React.Component { const debugButton = ( -