├── .browserslistrc ├── .editorconfig ├── .env.example ├── .eslintrc.cjs ├── .gitattributes ├── .github └── workflows │ ├── checks.yml │ ├── e2e.yml │ └── publish.yml ├── .gitignore ├── .prettierignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── .gitignore ├── .vitepress │ ├── config.ts │ └── theme │ │ ├── custom.css │ │ └── index.ts ├── assets │ ├── 01-volview-welcome-notes.jpg │ ├── 01-volview-welcome.jpg │ ├── 02-volview-about.jpg │ ├── 02-volview-notifications.jpg │ ├── 02-volview-settings.jpg │ ├── 03-volview-sample-datasets.jpg │ ├── 04-volview-active-data.jpg │ ├── 05-volview-select-data-to-delete.jpg │ ├── 06-volview-corner-annotation.jpg │ ├── 07-volview-layout-notes.jpg │ ├── 07-volview-layout.jpg │ ├── 09-volview-layout-3DPrimary.jpg │ ├── 10-volview-wl-pan-zoom-notes.jpg │ ├── 10-volview-wl-pan-zoom.jpg │ ├── 11-volview-paint-notes.jpg │ ├── 11-volview-paint.jpg │ ├── 12-volview-paint-options.jpg │ ├── 13-volview-crop-notes.jpg │ ├── 13-volview-crop.jpg │ ├── 14-volview-ruler.jpg │ ├── 15-volview-crosshairs.jpg │ ├── 16-volview-rendering.jpg │ ├── 17-volview-colormap-notes.jpg │ ├── 17-volview-colormap.jpg │ ├── 17-volview-opacity-notes.jpg │ ├── 17-volview-opacity.jpg │ ├── 17-volview-opacity2.jpg │ ├── 18-volview-presets.jpg │ ├── 19-volview-color.jpg │ ├── 19-volview-color2.jpg │ ├── 20-volview-lightfollowcamera0.jpg │ ├── 20-volview-lightfollowcamera1.jpg │ ├── 20-volview-lightfollowcamera2.jpg │ ├── 20-volview-lightfollowcamera3.jpg │ ├── 21-volview-hybrid-final.jpg │ ├── 21-volview-hybrid0.0.jpg │ ├── 21-volview-hybrid0.5.jpg │ ├── 21-volview-hybrid1.0.jpg │ ├── KWVolViewLogo.png │ ├── KWVolViewLogo.svg │ ├── LocalAmbientOcclusion.jpg │ ├── Notes.pptx │ ├── VolView-Overview.jpg │ ├── VolViewLogo.png │ ├── VolViewLogo.svg │ ├── add-layer.jpg │ ├── icon │ │ ├── favicon-16x16.png │ │ ├── favicon-196x196.png │ │ ├── favicon-32x32.png │ │ └── favicon-96x96.png │ ├── index.md │ └── logo.png ├── authentication.md ├── building_for_production.md ├── configuration_file.md ├── cors.md ├── deploying_volview.md ├── deployment_overview.md ├── gallery.md ├── index.md ├── loading_data.md ├── mouse_controls.md ├── public │ ├── favicon.ico │ └── logo.svg ├── quick_start_guide.md ├── rendering.md ├── server.md ├── state_files.md ├── toolbar.md └── welcome_screen.md ├── index.html ├── netlify.toml ├── netlify └── edge-functions │ └── visits.ts ├── package-lock.json ├── package.json ├── prettier.config.cjs ├── public └── favicon.ico ├── server ├── .flake8 ├── README.md ├── examples │ ├── example_api.py │ ├── example_class_api.py │ └── example_fastapi.py ├── poetry.lock ├── pyproject.toml └── volview_server │ ├── __init__.py │ ├── __main__.py │ ├── api.py │ ├── chunking │ ├── __init__.py │ ├── chunking_packet.py │ └── chunking_server.py │ ├── client_store.py │ ├── exceptions.py │ ├── rpc_router.py │ ├── rpc_server.py │ ├── session.py │ ├── transformers │ ├── __init__.py │ ├── exceptions.py │ ├── image_data.py │ └── itk_helpers.py │ └── volview_api.py ├── src ├── actions │ └── loadUserFiles.ts ├── assets │ ├── KitwareHeadAndNeck.jpg │ ├── logo.png │ ├── logo.svg │ └── samples │ │ ├── 3DUS-Fetus.jpg │ │ ├── CTA-Head_and_Neck.jpg │ │ ├── MRA-Head_and_Neck.jpg │ │ ├── MRI-Cardiac.jpg │ │ └── MRI-PROSTATEx.jpg ├── components │ ├── AboutBox.vue │ ├── AnnotationsModule.vue │ ├── App.vue │ ├── AppBar.vue │ ├── CloseableDialog.vue │ ├── ColorDot.vue │ ├── ColorFunctionSlider.vue │ ├── ControlButton.vue │ ├── ControlsModal.vue │ ├── ControlsStrip.vue │ ├── ControlsStripTools.vue │ ├── CurrentImageProvider.vue │ ├── DataBrowser.vue │ ├── DataSecurityBox.vue │ ├── DicomQuickInfoButton.vue │ ├── DragAndDrop.vue │ ├── EditableChipList.vue │ ├── FillBetweenControls.vue │ ├── GroupableItem.vue │ ├── ImageDataBrowser.vue │ ├── ImageListCard.vue │ ├── IsolatedDialog.vue │ ├── ItemGroup.vue │ ├── LabelControls.vue │ ├── LabelEditor.vue │ ├── LayerList.vue │ ├── LayerProperties.vue │ ├── LayoutGrid.vue │ ├── MeasurementRulerDetails.vue │ ├── MeasurementToolDetails.vue │ ├── MeasurementsToolList.vue │ ├── MenuControlButton.vue │ ├── MessageCenter.vue │ ├── MessageItem.vue │ ├── MessageNotificationContent.vue │ ├── MessageNotifications.vue │ ├── MiniExpansionPanel.vue │ ├── ModulePanel.vue │ ├── MultiObliqueSliceViewer.vue │ ├── ObliqueSliceViewer.vue │ ├── PaintControls.vue │ ├── PatientBrowser.vue │ ├── PatientStudyVolumeBrowser.vue │ ├── PersistentOverlay.vue │ ├── PolygonControls.vue │ ├── ProbeView.vue │ ├── RectangleControls.vue │ ├── RenderingModule.vue │ ├── ResizableNavDrawer.vue │ ├── RulerControls.vue │ ├── SampleDataBrowser.vue │ ├── SaveSegmentGroupDialog.vue │ ├── SaveSession.vue │ ├── SegmentEditor.vue │ ├── SegmentGroupControls.vue │ ├── SegmentGroupOpacity.vue │ ├── SegmentList.vue │ ├── ServerModule.vue │ ├── ServerSettings.vue │ ├── Settings.vue │ ├── SliceSlider.vue │ ├── SliceViewer.vue │ ├── SliceViewerOverlay.vue │ ├── ToolControls.vue │ ├── ToolLabelEditor.vue │ ├── ViewOverlayGrid.vue │ ├── VolumePresets.vue │ ├── VolumeProperties.vue │ ├── VolumeRendering.vue │ ├── VolumeViewer.vue │ ├── WelcomePage.vue │ ├── __tests__ │ │ └── ControlButton.spec.js │ ├── dicom-web │ │ ├── DicomWebSettings.vue │ │ ├── PatientDetails.vue │ │ ├── PatientList.vue │ │ └── StudyVolumeDicomWeb.vue │ ├── icons │ │ ├── KitwareLogoIcon.vue │ │ ├── VolViewFullLogo.vue │ │ └── VolViewLogo.vue │ ├── styles │ │ ├── annotations.css │ │ ├── utils.css │ │ └── vtk-view.css │ ├── tools │ │ ├── AnnotationContextMenu.vue │ │ ├── AnnotationInfo.vue │ │ ├── BoundingRectangle.vue │ │ ├── ResetViews.vue │ │ ├── ResliceCursorTool.vue │ │ ├── ScalarProbe.vue │ │ ├── SelectTool.vue │ │ ├── crop │ │ │ ├── Crop2D.vue │ │ │ ├── Crop2DLineHandle.vue │ │ │ ├── Crop3D.vue │ │ │ ├── CropControls.vue │ │ │ ├── CropTool.vue │ │ │ └── types.ts │ │ ├── crosshairs │ │ │ ├── CrosshairSVG2D.vue │ │ │ ├── CrosshairsTool.vue │ │ │ └── CrosshairsWidget2D.vue │ │ ├── paint │ │ │ ├── PaintTool.vue │ │ │ └── PaintWidget2D.vue │ │ ├── polygon │ │ │ ├── PolygonSVG2D.vue │ │ │ ├── PolygonTool.vue │ │ │ └── PolygonWidget2D.vue │ │ ├── rectangle │ │ │ ├── RectangleSVG2D.vue │ │ │ ├── RectangleTool.vue │ │ │ └── RectangleWidget2D.vue │ │ ├── ruler │ │ │ ├── RulerSVG2D.vue │ │ │ ├── RulerTool.vue │ │ │ └── RulerWidget2D.vue │ │ └── windowing │ │ │ └── WindowLevelControls.vue │ └── vtk │ │ ├── VtkBaseObliqueSliceRepresentation.vue │ │ ├── VtkBaseSliceRepresentation.vue │ │ ├── VtkBaseVolumeRepresentation.vue │ │ ├── VtkImageOutlineRepresentation.vue │ │ ├── VtkLayerSliceRepresentation.vue │ │ ├── VtkMouseInteractionManipulator.vue │ │ ├── VtkOrientationMarker.vue │ │ ├── VtkSegmentationSliceRepresentation.vue │ │ ├── VtkSliceView.vue │ │ ├── VtkSliceViewSlicingKeyManipulator.vue │ │ ├── VtkSliceViewSlicingManipulator.vue │ │ ├── VtkSliceViewWindowManipulator.vue │ │ ├── VtkVolumeView.vue │ │ └── context.ts ├── composables │ ├── __tests__ │ │ └── useOrientationLabels.spec.ts │ ├── actions.ts │ ├── annotationTool.ts │ ├── isViewAnimating.ts │ ├── manageVTKSubscription.ts │ ├── onImageDeleted.ts │ ├── onPausableVTKEvent.ts │ ├── onVTKEvent.ts │ ├── stableDeepRef.ts │ ├── untilLoaded.ts │ ├── useAutoFitState.ts │ ├── useCameraOrientation.ts │ ├── useColoringEffect.ts │ ├── useCroppingEffect.ts │ ├── useCurrentImage.ts │ ├── useErrorMessage.ts │ ├── useFrameOfReference.ts │ ├── useGlobalErrorHook.ts │ ├── useGlobalLayerColorConfig.ts │ ├── useKeyboardShortcuts.ts │ ├── useLayerConfigInitializer.ts │ ├── useMultiSelection.ts │ ├── useMultipleToolSelection.ts │ ├── useOrientationLabels.ts │ ├── usePersistCameraConfig.ts │ ├── usePopperState.ts │ ├── useResizeObserver.ts │ ├── useResizeToFit.ts │ ├── useSegmentGroupConfigInitializer.ts │ ├── useSliceConfig.ts │ ├── useSliceConfigInitializer.ts │ ├── useSliceInfo.ts │ ├── useToast.js │ ├── useVTKWorldToDisplay.ts │ ├── useViewAnimationListener.ts │ ├── useVolumeColoringInitializer.ts │ ├── useVolumeThumbnailing.ts │ ├── useWebGLWatchdog.ts │ ├── useWindowingConfig.ts │ ├── useWindowingConfigInitializer.ts │ └── wheneverImageLoaded.ts ├── config.ts ├── constants.ts ├── core │ ├── dicom-web-api.ts │ ├── dicomTags.ts │ ├── progressiveImage.ts │ ├── provider.ts │ ├── remote │ │ ├── __tests__ │ │ │ └── chunkedParser.spec.ts │ │ ├── chunkedParser.ts │ │ ├── client.ts │ │ ├── storeApi.ts │ │ └── transformers │ │ │ ├── index.ts │ │ │ └── vtkImageData.ts │ ├── stateMachine.ts │ ├── streaming │ │ ├── __tests__ │ │ │ ├── byteDeque.spec.ts │ │ │ ├── cachedStreamFetcher.spec.ts │ │ │ ├── chunkStateMachine.spec.ts │ │ │ ├── requestPool.spec.ts │ │ │ └── streamingByteReader.spec.ts │ │ ├── byteDeque.ts │ │ ├── cachedStreamFetcher.ts │ │ ├── chunk.ts │ │ ├── chunkImage.ts │ │ ├── chunkStateMachine.ts │ │ ├── concatStreams.ts │ │ ├── dicom │ │ │ ├── __tests__ │ │ │ │ └── dicomMetaLoader.spec.ts │ │ │ ├── dicomDataLoader.ts │ │ │ ├── dicomFileDataLoader.ts │ │ │ ├── dicomFileMetaLoader.ts │ │ │ ├── dicomMetaLoader.ts │ │ │ └── dicomParser.ts │ │ ├── dicomChunkImage.ts │ │ ├── httpCodes.ts │ │ ├── requestPool.ts │ │ ├── streamingByteReader.ts │ │ └── types.ts │ ├── thumbnailers │ │ ├── __tests__ │ │ │ └── vtk-image.spec.ts │ │ ├── index.ts │ │ ├── volume-thumbnailer.ts │ │ └── vtk-image.ts │ ├── tools │ │ ├── __tests__ │ │ │ └── paint.spec.ts │ │ └── paint │ │ │ ├── brush.ts │ │ │ ├── ellipse-brush.ts │ │ │ └── index.ts │ ├── viewTypes.ts │ └── vtk │ │ ├── onViewMounted.ts │ │ ├── types.ts │ │ ├── useMouseRangeManipulatorListener.ts │ │ ├── useOrientationMarker.ts │ │ ├── useResliceRepresentation.ts │ │ ├── useSliceRepresentation.ts │ │ ├── useVolumeRepresentation.ts │ │ ├── useVtkComputed.ts │ │ ├── useVtkFilter.ts │ │ ├── useVtkInteractionManipulator.ts │ │ ├── useVtkInteractorStyle.ts │ │ ├── useVtkRepresentation.ts │ │ ├── useVtkView.ts │ │ └── vtkFieldRef.ts ├── env.d.ts ├── global.css ├── global.d.ts ├── io │ ├── __tests__ │ │ └── io.spec.ts │ ├── amazonS3.ts │ ├── dicom.ts │ ├── googleCloudStorage.ts │ ├── import │ │ ├── __tests__ │ │ │ └── dataSource.spec.ts │ │ ├── common.ts │ │ ├── configJson.ts │ │ ├── dataSource.ts │ │ ├── importDataSources.ts │ │ └── processors │ │ │ ├── downloadStream.ts │ │ │ ├── extractArchive.ts │ │ │ ├── extractArchiveTarget.ts │ │ │ ├── handleAmazonS3.ts │ │ │ ├── handleConfig.ts │ │ │ ├── handleDicomFile.ts │ │ │ ├── handleDicomStream.ts │ │ │ ├── handleGoogleCloudStorage.ts │ │ │ ├── importSingleFile.ts │ │ │ ├── openUriStream.ts │ │ │ ├── remoteManifest.ts │ │ │ ├── restoreStateFile.ts │ │ │ ├── updateFileMimeType.ts │ │ │ └── updateUriType.ts │ ├── index.ts │ ├── io.ts │ ├── itk-dicom │ │ ├── .gitignore │ │ ├── CMakeLists.txt │ │ ├── dicom.cpp │ │ └── emscripten-build │ │ │ ├── dicom.js │ │ │ ├── dicom.wasm │ │ │ └── dicom.wasm.zst │ ├── itk │ │ ├── itkConfig.js │ │ └── worker.ts │ ├── magic.ts │ ├── manifest.ts │ ├── mimeTypes.ts │ ├── readWriteImage.ts │ ├── readers.ts │ ├── resample │ │ ├── .gitignore │ │ ├── CMakeLists.txt │ │ ├── emscripten-build │ │ │ ├── resample.js │ │ │ ├── resample.wasm │ │ │ └── resample.wasm.zst │ │ ├── itkWasmUtils.js │ │ ├── resample.cxx │ │ └── resample.ts │ ├── state-file │ │ ├── index.ts │ │ └── schema.ts │ ├── types.ts │ ├── vtk │ │ ├── async.reader.worker.ts │ │ ├── async.ts │ │ ├── async.writer.worker.ts │ │ └── common.ts │ └── zip.ts ├── main.js ├── main.ts ├── plugins │ ├── storeRegistry.ts │ └── vuetify.js ├── shims-itk.d.ts ├── shims-misc.d.ts ├── shims-tsx.d.ts ├── shims-vtk.d.ts ├── shims-vue.d.ts ├── store │ ├── __tests__ │ │ ├── messages.spec.ts │ │ └── rulers.spec.ts │ ├── data-browser.ts │ ├── datasets-dicom.ts │ ├── datasets-files.ts │ ├── datasets-images.ts │ ├── datasets-layers.ts │ ├── datasets-models.ts │ ├── datasets.ts │ ├── dicom-web │ │ ├── dicom-meta-store.ts │ │ └── dicom-web-store.ts │ ├── id.ts │ ├── image-cache.ts │ ├── image-stats.ts │ ├── keyboard-shortcuts.ts │ ├── load-data.ts │ ├── messages.ts │ ├── probe.ts │ ├── remote-save-state.ts │ ├── reslice-cursor.ts │ ├── segmentGroups.ts │ ├── server.ts │ ├── tools │ │ ├── crop.ts │ │ ├── crosshairs.ts │ │ ├── fillBetween.ts │ │ ├── index.ts │ │ ├── paint.ts │ │ ├── polygons.ts │ │ ├── rectangles.ts │ │ ├── rulers.ts │ │ ├── toolSelection.ts │ │ ├── types.ts │ │ ├── useAnnotationTool.ts │ │ └── useLabels.ts │ ├── view-animation.ts │ ├── view-configs.ts │ ├── view-configs │ │ ├── camera.ts │ │ ├── common.ts │ │ ├── layers.ts │ │ ├── segmentGroups.ts │ │ ├── slicing.ts │ │ ├── types.ts │ │ ├── volume-coloring.ts │ │ └── windowing.ts │ └── views.ts ├── types │ ├── annotation-tool.ts │ ├── crop.ts │ ├── image.ts │ ├── index.ts │ ├── layout.ts │ ├── lps.ts │ ├── polygon.ts │ ├── rectangle.ts │ ├── ruler.ts │ ├── segment.ts │ ├── views.ts │ └── vtk-types.ts ├── utils │ ├── __tests__ │ │ ├── allocateImageFromChunks.spec.ts │ │ ├── asyncSelect.spec.ts │ │ ├── evaluateChain.spec.ts │ │ ├── frameOfReference.spec.ts │ │ ├── imageExtractComponentsFilter.ts │ │ ├── lps.spec.ts │ │ ├── parseContentRangeHeader.spec.ts │ │ └── url.spec.ts │ ├── allocateImageFromChunks.ts │ ├── asyncSelect.ts │ ├── batchForNextTask.ts │ ├── camera.ts │ ├── color.ts │ ├── dataSelection.ts │ ├── defineAnnotationToolStore.ts │ ├── doubleKeyRecord.ts │ ├── errorReporting.ts │ ├── evaluateChain.ts │ ├── fetch.ts │ ├── frameOfReference.ts │ ├── functional.ts │ ├── gpuInfo.ts │ ├── guardedWritableRef.ts │ ├── hacks.ts │ ├── histogram.ts │ ├── histogram.worker.ts │ ├── imageExtractComponentsFilter.js │ ├── imageSpace.ts │ ├── index.ts │ ├── loggers.ts │ ├── lps.ts │ ├── manipulators.ts │ ├── parseContentRangeHeader.ts │ ├── path.ts │ ├── promise-worker.ts │ ├── token.ts │ ├── url.ts │ ├── volumeProperties.ts │ ├── vtk-helpers.ts │ ├── watchCompare.ts │ └── workerHandler.js ├── vitest.d.ts └── vtk │ ├── ColorMaps.ts │ ├── CrosshairsWidget │ ├── behavior.ts │ ├── index.d.ts │ ├── index.js │ └── state.ts │ ├── GatedMouseRangeManipulator │ └── index.js │ ├── LabelMap │ ├── index.d.ts │ └── index.js │ ├── LineGlyphRepresentation │ └── index.js │ ├── MedicalColorPresets.json │ ├── PaintBrushContextRepresentation │ └── index.js │ ├── PaintWidget │ ├── behavior.ts │ ├── index.d.ts │ ├── index.js │ └── state.ts │ ├── PiecewiseWidget │ └── index.js │ ├── PolygonWidget │ ├── behavior.ts │ ├── decimate.ts │ ├── index.d.ts │ ├── index.js │ └── state.ts │ ├── RectangleWidget │ ├── RectangleLineRepresentation.js │ ├── index.d.ts │ └── index.js │ ├── RulerWidget │ ├── behavior.ts │ ├── index.d.ts │ ├── index.js │ └── state.ts │ ├── ToolWidgetUtils │ ├── annotationWidgetState.ts │ ├── pointState.js │ ├── types.ts │ └── utils.ts │ ├── TreJsonConverter │ └── index.js │ └── webvr-empty.js ├── tests ├── baseline │ ├── prostate_sample_views-chrome-linux-1200x800-1.png │ ├── prostate_sample_views-chrome-mac_os_x-1200x800-1.png │ └── prostate_sample_views-chrome-windows-1200x800-1.png ├── browserTestUtils.ts ├── e2eTestUtils.ts ├── fixtures │ └── tools-prostate.3-0-0.volview.json ├── pageobjects │ ├── page.ts │ └── volview.page.ts ├── setupVitest.ts └── specs │ ├── layers.e2e.ts │ ├── remote-manifest.e2e.ts │ ├── sample-rendering.e2e.ts │ ├── session-zip.e2e.ts │ ├── state-manifest.e2e.ts │ └── utils.ts ├── tsconfig.json ├── vite.config.ts ├── wdio.chrome.conf.ts ├── wdio.dev.conf.ts └── wdio.shared.conf.ts /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 5% 2 | last 2 versions 3 | not dead 4 | not ie <= 8 -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Base URL for DICOMWeb request. 2 | # If no variable found, DICOMWeb panel in data tab is hidden unless set in settings. 3 | VITE_DICOM_WEB_URL=http://localhost:5173/dicom-web 4 | 5 | # Display name of DICOMWeb host. 6 | VITE_DICOM_WEB_NAME=ACME Hospital 7 | 8 | # Error reporting URL for sentry.io. 9 | VITE_SENTRY_DSN= 10 | 11 | # If this env var exists and is true and there is a `save` URL parameter, 12 | # clicking the save button POSTS the session.volview.zip file to the specifed URL. 13 | VITE_ENABLE_REMOTE_SAVE=true 14 | 15 | # VolView server remote URL 16 | VITE_REMOTE_SERVER_URL=http://localhost:4014 17 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Checks and Tests 2 | on: 3 | pull_request: 4 | merge_group: 5 | 6 | jobs: 7 | checks: 8 | runs-on: ubuntu-latest 9 | name: Code check and tests 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Setup node 13 | uses: actions/setup-node@v1 14 | with: 15 | node-version: 22 16 | - run: npm ci 17 | - name: Enforce code style 18 | run: npx prettier --config ./prettier.config.cjs --list-different "src/**/*.[jt]s" "tests/**/*.[jt]s" "src/**/*.vue" 19 | - name: Tests 20 | run: npm test 21 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: E2E Testing 2 | on: 3 | pull_request: 4 | 5 | jobs: 6 | test: 7 | strategy: 8 | matrix: 9 | os: [ubuntu-latest, macos-latest] 10 | name: E2E Testing on ${{ matrix.os }} 11 | runs-on: ${{ matrix.os }} 12 | env: 13 | DOWNLOAD_TIMEOUT: 60000 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | - name: Install node-canvas deps (macos) 19 | if: matrix.os != 'ubuntu-latest' 20 | run: brew install pkg-config cairo pango libpng jpeg giflib librsvg pixman 21 | - name: Install dependencies 22 | run: npm ci 23 | env: 24 | DETECT_CHROMEDRIVER_VERSION: true 25 | - name: Install xvfb (linux) 26 | if: matrix.os == 'ubuntu-latest' 27 | run: sudo apt-get install xvfb 28 | - name: Run E2E on chrome (linux) 29 | if: matrix.os == 'ubuntu-latest' 30 | run: xvfb-run --server-args="-screen 0, 1400x1000x24" --auto-servernum npm run test:e2e:chrome 31 | - name: Set screen resolution (windows) 32 | if: matrix.os == 'windows-latest' 33 | run: Set-DisplayResolution -Width 1920 -Height 1080 -Force 34 | shell: pwsh 35 | - name: Run E2E on chrome (non-linux) 36 | if: matrix.os != 'ubuntu-latest' 37 | run: npm run test:e2e:chrome 38 | - name: Archive test results 39 | if: success() || failure() 40 | uses: actions/upload-artifact@v4 41 | continue-on-error: true 42 | with: 43 | name: image_comparison_results_${{ matrix.os }} 44 | path: .tmp 45 | retention-days: 5 46 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | # enable manual trigger 7 | workflow_dispatch: 8 | 9 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 10 | permissions: 11 | contents: read 12 | pages: write 13 | id-token: write 14 | 15 | concurrency: 16 | group: 'pages' 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | build: 21 | name: Build Docs 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v3 25 | with: 26 | fetch-depth: 0 27 | - name: Setup Pages 28 | uses: actions/configure-pages@v2 29 | - name: Setup node 30 | uses: actions/setup-node@v3 31 | with: 32 | node-version: 16 33 | - name: Install dependencies 34 | run: npm ci 35 | - name: Build docs 36 | run: npm run docs:build 37 | - name: Upload pages artifact 38 | uses: actions/upload-pages-artifact@v3 39 | with: 40 | path: './docs/.vitepress/dist' 41 | 42 | deploy: 43 | name: Deploy Docs 44 | environment: 45 | name: github-pages 46 | url: ${{ steps.deployment.outputs.page_url }} 47 | runs-on: ubuntu-latest 48 | needs: build 49 | steps: 50 | - name: Deploy to GitHub Pages 51 | id: deployment 52 | uses: actions/deploy-pages@v4 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | *~ 23 | 24 | __pycache__ 25 | venv 26 | 27 | bundle-analysis.html 28 | 29 | # Local Netlify folder 30 | .netlify 31 | 32 | reports 33 | *.log 34 | .tmp/ 35 | 36 | .github/copilot 37 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | public 4 | src/io/itk-dicom 5 | src/io/resample 6 | *.json 7 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | .vitepress/dist 2 | .vitepress/cache 3 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/custom.css: -------------------------------------------------------------------------------- 1 | .gallery { 2 | margin-top: 8px; 3 | } 4 | 5 | .gallery img { 6 | width: 32%; 7 | display: inline-block; 8 | padding: 5px; 9 | } 10 | 11 | .gallery br { 12 | display: none; 13 | } 14 | 15 | footer.VPFooter { 16 | display: block !important; 17 | border: none !important; 18 | } 19 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import DefaultTheme from 'vitepress/theme'; 2 | import './custom.css'; 3 | 4 | export default DefaultTheme; 5 | -------------------------------------------------------------------------------- /docs/assets/01-volview-welcome-notes.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/01-volview-welcome-notes.jpg -------------------------------------------------------------------------------- /docs/assets/01-volview-welcome.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/01-volview-welcome.jpg -------------------------------------------------------------------------------- /docs/assets/02-volview-about.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/02-volview-about.jpg -------------------------------------------------------------------------------- /docs/assets/02-volview-notifications.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/02-volview-notifications.jpg -------------------------------------------------------------------------------- /docs/assets/02-volview-settings.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/02-volview-settings.jpg -------------------------------------------------------------------------------- /docs/assets/03-volview-sample-datasets.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/03-volview-sample-datasets.jpg -------------------------------------------------------------------------------- /docs/assets/04-volview-active-data.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/04-volview-active-data.jpg -------------------------------------------------------------------------------- /docs/assets/05-volview-select-data-to-delete.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/05-volview-select-data-to-delete.jpg -------------------------------------------------------------------------------- /docs/assets/06-volview-corner-annotation.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/06-volview-corner-annotation.jpg -------------------------------------------------------------------------------- /docs/assets/07-volview-layout-notes.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/07-volview-layout-notes.jpg -------------------------------------------------------------------------------- /docs/assets/07-volview-layout.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/07-volview-layout.jpg -------------------------------------------------------------------------------- /docs/assets/09-volview-layout-3DPrimary.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/09-volview-layout-3DPrimary.jpg -------------------------------------------------------------------------------- /docs/assets/10-volview-wl-pan-zoom-notes.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/10-volview-wl-pan-zoom-notes.jpg -------------------------------------------------------------------------------- /docs/assets/10-volview-wl-pan-zoom.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/10-volview-wl-pan-zoom.jpg -------------------------------------------------------------------------------- /docs/assets/11-volview-paint-notes.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/11-volview-paint-notes.jpg -------------------------------------------------------------------------------- /docs/assets/11-volview-paint.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/11-volview-paint.jpg -------------------------------------------------------------------------------- /docs/assets/12-volview-paint-options.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/12-volview-paint-options.jpg -------------------------------------------------------------------------------- /docs/assets/13-volview-crop-notes.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/13-volview-crop-notes.jpg -------------------------------------------------------------------------------- /docs/assets/13-volview-crop.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/13-volview-crop.jpg -------------------------------------------------------------------------------- /docs/assets/14-volview-ruler.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/14-volview-ruler.jpg -------------------------------------------------------------------------------- /docs/assets/15-volview-crosshairs.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/15-volview-crosshairs.jpg -------------------------------------------------------------------------------- /docs/assets/16-volview-rendering.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/16-volview-rendering.jpg -------------------------------------------------------------------------------- /docs/assets/17-volview-colormap-notes.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/17-volview-colormap-notes.jpg -------------------------------------------------------------------------------- /docs/assets/17-volview-colormap.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/17-volview-colormap.jpg -------------------------------------------------------------------------------- /docs/assets/17-volview-opacity-notes.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/17-volview-opacity-notes.jpg -------------------------------------------------------------------------------- /docs/assets/17-volview-opacity.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/17-volview-opacity.jpg -------------------------------------------------------------------------------- /docs/assets/17-volview-opacity2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/17-volview-opacity2.jpg -------------------------------------------------------------------------------- /docs/assets/18-volview-presets.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/18-volview-presets.jpg -------------------------------------------------------------------------------- /docs/assets/19-volview-color.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/19-volview-color.jpg -------------------------------------------------------------------------------- /docs/assets/19-volview-color2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/19-volview-color2.jpg -------------------------------------------------------------------------------- /docs/assets/20-volview-lightfollowcamera0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/20-volview-lightfollowcamera0.jpg -------------------------------------------------------------------------------- /docs/assets/20-volview-lightfollowcamera1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/20-volview-lightfollowcamera1.jpg -------------------------------------------------------------------------------- /docs/assets/20-volview-lightfollowcamera2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/20-volview-lightfollowcamera2.jpg -------------------------------------------------------------------------------- /docs/assets/20-volview-lightfollowcamera3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/20-volview-lightfollowcamera3.jpg -------------------------------------------------------------------------------- /docs/assets/21-volview-hybrid-final.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/21-volview-hybrid-final.jpg -------------------------------------------------------------------------------- /docs/assets/21-volview-hybrid0.0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/21-volview-hybrid0.0.jpg -------------------------------------------------------------------------------- /docs/assets/21-volview-hybrid0.5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/21-volview-hybrid0.5.jpg -------------------------------------------------------------------------------- /docs/assets/21-volview-hybrid1.0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/21-volview-hybrid1.0.jpg -------------------------------------------------------------------------------- /docs/assets/KWVolViewLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/KWVolViewLogo.png -------------------------------------------------------------------------------- /docs/assets/LocalAmbientOcclusion.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/LocalAmbientOcclusion.jpg -------------------------------------------------------------------------------- /docs/assets/Notes.pptx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/Notes.pptx -------------------------------------------------------------------------------- /docs/assets/VolView-Overview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/VolView-Overview.jpg -------------------------------------------------------------------------------- /docs/assets/VolViewLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/VolViewLogo.png -------------------------------------------------------------------------------- /docs/assets/add-layer.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/add-layer.jpg -------------------------------------------------------------------------------- /docs/assets/icon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/icon/favicon-16x16.png -------------------------------------------------------------------------------- /docs/assets/icon/favicon-196x196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/icon/favicon-196x196.png -------------------------------------------------------------------------------- /docs/assets/icon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/icon/favicon-32x32.png -------------------------------------------------------------------------------- /docs/assets/icon/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/icon/favicon-96x96.png -------------------------------------------------------------------------------- /docs/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/assets/logo.png -------------------------------------------------------------------------------- /docs/building_for_production.md: -------------------------------------------------------------------------------- 1 | # Building for Production 2 | 3 | To build VolView, ensure you have the latest `node.js` and `npm` tools installed. `git` is optional for fetching the VolView sources. 4 | 5 | To build, run the following commands. 6 | 7 | ```bash 8 | git clone https://github.com/Kitware/VolView.git 9 | cd VolView/ 10 | npm install 11 | npm run build 12 | ``` 13 | 14 | If all goes well, the build artifacts will be located in `dist/`. This directory should consist of a bunch of static HTML, CSS, JS, font files, and images. These files can be copied to any static site hosting root for immediate deployment. 15 | -------------------------------------------------------------------------------- /docs/cors.md: -------------------------------------------------------------------------------- 1 | # Cross Origin Resource Sharing (CORS) 2 | 3 | CORS is a browser mechanism to let servers protect user data from malicious user-side scripts. In a nutshell, CORS protections prevent browsers from allowing JavaScript to read the responses of cross-origin requests unless certain conditions are met. 4 | 5 | For VolView, this manifests most prominently when fetching remote datasets. If the public VolView instance at requests MRI data from , the request may fail if `example.com` has not whitelisted `volview.kitware.app`. 6 | 7 | The rest of this document describes two ways to resolve this issue. 8 | 9 | ## Whitelist your VolView domain on the data server 10 | 11 | If you have control over the data server, you can whitelist your VolView domain. The simpliest way to do this is to set the `Access-Control-Allow-Origin: MYDOMAIN` header on the server. How this is done depends on the static file server that you are using. An example is provided below. 12 | 13 | ### Nginx example 14 | 15 | Please see [the deployment docs](/deploying_volview) for more info on what an expanded nginx configuration may look like. 16 | 17 | ``` 18 | server { 19 | ... 20 | 21 | # Replace "volview.kitware.app" with the domain on which 22 | # VolView is being hosted. 23 | add_header Access-Control-Allow-Origin "volview.kitware.app" 24 | } 25 | ``` 26 | 27 | ## CORS proxy 28 | 29 | If you do not control the data server, you can use a CORS proxy. A CORS proxy is a lightweight proxy server that is configured to attach CORS headers to responses originating from the data server. -------------------------------------------------------------------------------- /docs/deployment_overview.md: -------------------------------------------------------------------------------- 1 | # Deployment Overview 2 | 3 | VolView is a client-side application, which means deploying VolView does not have much overhead. That being said, this section of the documentation will cover some common deployment environments that may be of interest. 4 | 5 | Generally, most deployments follow a similar pattern: 6 | 7 | 1. [Building VolView for production](./building_for_production) 8 | 2. [Deploying VolView](./deploying_volview) 9 | 10 | The other articles in this section cover other scenarios that may be applicable to your situation. 11 | -------------------------------------------------------------------------------- /docs/mouse_controls.md: -------------------------------------------------------------------------------- 1 | # Keyboard shortcuts 2 | 3 | ## Data management 4 | 5 | | Shortcut | Action | 6 | | -------- | --------------------- | 7 | | Ctrl + . | Delete Current Image | 8 | | Ctrl + / | Clear all data | 9 | 10 | ## Slice 11 | 12 | | Shortcut | Action | 13 | | ------------------- | --------------- | 14 | | Up arrow | Next Slice | 15 | | Right arrow | Next Slice | 16 | | Down array | Previous Slice | 17 | | Left array | Previous Slice | 18 | | Alt + mouse up/down | navigate slices | 19 | 20 | ## Mouse Controls 21 | 22 | | Action | 2D | 3D | 23 | | ------------ | --------------- | ------ | 24 | | Left | window level | rotate | 25 | | Mid | pan | pan | 26 | | Right | zoom | zoom | 27 | | Ctrl + Left | zoom | zoom | 28 | | Shift + Left | pan | pan | 29 | | Wheel | navigate slices | zoom | 30 | -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/state_files.md: -------------------------------------------------------------------------------- 1 | # State Files 2 | 3 | VolView state files are a great way to save your scene and data to either be used later, or for distributing to collaborators and other users. These files store all of the information you need to restore the state of VolView: your data, annotations, camera positions, background colors, colormaps, and more. 4 | 5 | State files can be saved by clicking on "Disk" icon in the top of the toolbar. This button will generate a `*.volview.zip` file that can then be re-opened in VolView at any time. 6 | 7 | When saving VolView state, your data is saved along with the application state. This way, when you send a state file to a collaborator, they too can open the state file and load the previously saved data. However, this means that your state file will be as large as your dataset(s) and may contain patient identifying information. Please follow your institutes HIPAA, IRB and other regulatory and confidentiality requirements. 8 | 9 | State files are loaded by clicking on the "Folder" icon immediately below the save-state Disk icon. This will bring up a file browser for you to select and load your state file. 10 | 11 | TIP: State files are a great way for developers to transfer data into / out of VolView for integration with other systems. For example, they can be used to integrate VolView with access control systems, to streamline workflows, or to ingest results from AI systems. 12 | -------------------------------------------------------------------------------- /docs/welcome_screen.md: -------------------------------------------------------------------------------- 1 | # Welcome Screen 2 | 3 | ![welcome screen](./assets/01-volview-welcome-notes.jpg) 4 | 5 | ## Tabs 6 | 7 | ### Data 8 | 9 | Information on the Patient data and non-DICOM data that have been loaded. The currently display image data is highlighted in blue. 10 | 11 | **Sample Data** presents a variety of DICOM data that can be used to quickly explore the capabilities of VolView. When you select a sample dataset, that data is downloaded from [http://data.kitware.com/](https://data.kitware.com/#collection/586fef9f8d777f05f44a5c86/folder/634713cf11dab81428208e1e). 12 | 13 | ### Annotations 14 | 15 | Lists the ruler and other measures that have been made on the currently loaded data. 16 | 17 | ### Rendering 18 | 19 | Controls for the 3D cinematic volume rendering. 20 | 21 | ## Load / Save State 22 | 23 | Restore or create a local file that captures the current configuration of the application and its data. This includes the layout, annotations, cinematic rendering settings, and all other options specified. The local file is in json format, so it provides a basis for integrating VolView with other applications and workflows. 24 | 25 | ## Notifications and Settings 26 | 27 | Information and error messages collect here. The number of recently posted notifications will appear on top of the notification icon. Settings allow you to toggle between a dark or light theme. 28 | 29 | ## Central Window 30 | 31 | Drag-and-drop DICOM and image data here. Receives files, folders, or zip files. Or you can click within this window to bring up a file browser. 32 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | VolView 9 | 13 | 17 | 18 | 19 | 22 |
23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [[redirects]] 2 | from = "/dicom-web/*" 3 | to = "DICOM_WEB_ADDRESS/:splat" 4 | status = 200 5 | force = true 6 | headers = {apikey = "DICOM_WEB_API_KEY"} 7 | 8 | [build] 9 | command = "sed -i \"s|DICOM_WEB_API_KEY|${DICOM_WEB_API_KEY}|g; s|DICOM_WEB_ADDRESS|${DICOM_WEB_ADDRESS}|g\" netlify.toml && npm run build" 10 | 11 | [dev] 12 | targetPort = 8080 13 | -------------------------------------------------------------------------------- /netlify/edge-functions/visits.ts: -------------------------------------------------------------------------------- 1 | // This file runs in the Deno runtime 2 | 3 | // @ts-ignore 4 | // eslint-disable-next-line import/no-unresolved 5 | import { Context } from 'https://edge.netlify.com'; 6 | 7 | // @ts-ignore 8 | const GOATCOUNTER_SITE = Deno.env.get('GOATCOUNTER_SITE'); 9 | 10 | export default async (request: Request, context: Context) => { 11 | if (!GOATCOUNTER_SITE) return; 12 | 13 | const headers = { 'X-Forwarded-For': context.ip }; 14 | const url = new URL(GOATCOUNTER_SITE); 15 | const { searchParams } = url; 16 | 17 | url.pathname = '/count'; 18 | 19 | const requestUrl = new URL(request.url); 20 | searchParams.set('p', `${requestUrl.host}${requestUrl.pathname}`); 21 | searchParams.set('t', 'VolView'); 22 | 23 | if (request.headers.has('Referer')) { 24 | searchParams.set( 25 | 'r', 26 | encodeURIComponent(request.headers.get('Referer') ?? '') 27 | ); 28 | } 29 | 30 | if (request.headers.has('User-Agent')) { 31 | headers['User-Agent'] = request.headers.get('User-Agent') ?? 'Unknown'; 32 | } 33 | 34 | // don't block the request 35 | fetch(url.toString(), { headers }); 36 | }; 37 | 38 | export const config = { path: '/' }; 39 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | singleQuote: true, 4 | trailingComma: 'es5', 5 | arrowParens: 'always', 6 | endOfLine: 'lf', 7 | tabWidth: 2, 8 | useTabs: false, 9 | }; 10 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/public/favicon.ico -------------------------------------------------------------------------------- /server/.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203 -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # VolView Server 2 | 3 | Visit the [VolView server documentation](../documentation/content/doc/server.md) 4 | for more info on how to use the server. -------------------------------------------------------------------------------- /server/examples/example_fastapi.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | from fastapi import FastAPI 5 | from fastapi.middleware.cors import CORSMiddleware 6 | 7 | from volview_server import VolViewApi 8 | 9 | # Import the VolView example API 10 | sys.path.append(os.path.dirname(os.path.abspath(__file__))) 11 | from example_api import volview 12 | 13 | 14 | app = FastAPI() 15 | 16 | # Adds volview middlware 17 | app.add_middleware(volview) 18 | 19 | # Set CORS configuration 20 | app.add_middleware( 21 | CORSMiddleware, 22 | allow_origins=["*"], 23 | ) 24 | 25 | 26 | @app.get("/") 27 | def index(): 28 | return {"hello": "world"} 29 | -------------------------------------------------------------------------------- /server/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "volview_server" 3 | version = "0.1.0" 4 | description = "The VolView Python Server" 5 | authors = ["Forrest "] 6 | license = "Apache 2.0" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.8.1" 10 | itk = "^5.3.0" 11 | black = "^23.1.0" 12 | flake8 = "^6.0.0" 13 | pytest = "^7.2.1" 14 | numpy = "^1.24.1" 15 | aiohttp = "^3.9.2" 16 | python-socketio = "^5.8.0" 17 | charset-normalizer = "^3.1.0" 18 | 19 | [tool.poetry.dev-dependencies] 20 | 21 | [tool.poetry.group.dev.dependencies] 22 | uvicorn = {extras = ["standard"], version = "^0.22.0"} 23 | 24 | [build-system] 25 | requires = ["poetry-core>=1.0.0"] 26 | build-backend = "poetry.core.masonry.api" 27 | -------------------------------------------------------------------------------- /server/volview_server/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.0" 2 | __author__ = "Kitware, Inc." 3 | __all__ = ["VolViewApi", "RpcRouter", "get_current_client_store", "get_current_session"] 4 | 5 | from volview_server.volview_api import VolViewApi 6 | from volview_server.rpc_router import RpcRouter 7 | from volview_server.client_store import get_current_client_store 8 | from volview_server.session import get_current_session 9 | -------------------------------------------------------------------------------- /server/volview_server/chunking/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["CHUNK_SIZE", "ChunkingAsyncServer"] 2 | 3 | from .chunking_packet import CHUNK_SIZE 4 | from .chunking_server import ChunkingAsyncServer 5 | -------------------------------------------------------------------------------- /server/volview_server/exceptions.py: -------------------------------------------------------------------------------- 1 | class KeyExistsError(Exception): 2 | """A given key already exists.""" 3 | 4 | ... 5 | -------------------------------------------------------------------------------- /server/volview_server/rpc_router.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | import inspect 3 | import enum 4 | from typing import Callable, Tuple, Dict 5 | 6 | from volview_server.exceptions import KeyExistsError 7 | 8 | 9 | class ExposeType(enum.Enum): 10 | RPC = "rpc" 11 | STREAM = "stream" 12 | 13 | 14 | @dataclass 15 | class EndpointInfo: 16 | name: str 17 | type: ExposeType 18 | transform_args: bool = True 19 | 20 | 21 | Endpoint = Tuple[Callable, EndpointInfo] 22 | 23 | 24 | class RpcRouter: 25 | endpoints: Dict[str, Endpoint] 26 | 27 | def __init__(self): 28 | self.endpoints = {} 29 | 30 | def add_endpoint(self, public_name: str, fn: Callable, transform_args=True): 31 | """Adds a public endpoint. 32 | 33 | Arguments: 34 | - public_name: the endpoint name 35 | - fn: the function to call 36 | 37 | Keyword arguments: 38 | - transform_args(=true): transform input arguments and output 39 | results. Disable this if you do not want transform overhead 40 | or you want to explicitly transform your inputs and outputs. 41 | """ 42 | 43 | if public_name in self.endpoints: 44 | raise KeyExistsError(f"{public_name} is already registered") 45 | 46 | expose_type = ExposeType.RPC 47 | if inspect.isasyncgenfunction(fn) or inspect.isgeneratorfunction(fn): 48 | expose_type = ExposeType.STREAM 49 | 50 | info = EndpointInfo(public_name, expose_type, transform_args) 51 | self.endpoints[public_name] = (fn, info) 52 | -------------------------------------------------------------------------------- /server/volview_server/session.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Any, TypeVar 2 | 3 | from volview_server.rpc_server import current_server, current_client_id 4 | 5 | T = TypeVar("T") 6 | 7 | 8 | def get_current_session(default_factory: Callable[[], T] = None) -> T: 9 | """Retrieves the current session object for the current client. 10 | 11 | This should only be called from inside an RPC endpoint. 12 | 13 | If no session exists for the current client and `default_factory` is 14 | provided, then `default_factory` will be invoked and a new session object 15 | will be returned. 16 | 17 | If no `default_factory` is provided, `None` will be returned. 18 | 19 | If there is no current client, then this will raise a RuntimeError. 20 | """ 21 | server = current_server.get() 22 | if not server: 23 | raise RuntimeError("No current server") 24 | 25 | client_id = current_client_id.get() 26 | if not client_id: 27 | raise RuntimeError("No no current client") 28 | 29 | if client_id not in server.sessions and default_factory: 30 | server.sessions[client_id] = default_factory() 31 | return server.sessions.get(client_id, None) 32 | -------------------------------------------------------------------------------- /server/volview_server/transformers/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, List, Any 2 | 3 | Transformer = Callable[[Any], Any] 4 | 5 | from volview_server.transformers.image_data import ( 6 | convert_itk_to_vtkjs_image, 7 | convert_vtkjs_to_itk_image, 8 | ) 9 | 10 | 11 | def pipe(input, *fns: List[Transformer]): 12 | intermediate = input 13 | for fn in fns: 14 | intermediate = fn(intermediate) 15 | return intermediate 16 | 17 | 18 | def transform_object(input: Any, transform: Callable): 19 | output = transform(input) 20 | 21 | if isinstance(output, list) or isinstance(output, tuple): 22 | return [transform_object(item, transform) for item in output] 23 | 24 | if isinstance(output, dict): 25 | return { 26 | key: transform_object(value, transform) for key, value in output.items() 27 | } 28 | 29 | return output 30 | 31 | 32 | default_serializers: List[Transformer] = [convert_itk_to_vtkjs_image] 33 | default_deserializers: List[Transformer] = [convert_vtkjs_to_itk_image] 34 | -------------------------------------------------------------------------------- /server/volview_server/transformers/exceptions.py: -------------------------------------------------------------------------------- 1 | class ConvertError(Exception): 2 | """An error occurred while converting.""" 3 | -------------------------------------------------------------------------------- /server/volview_server/transformers/itk_helpers.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import numpy as np 3 | 4 | TYPE_ARRAY_JS_TO_NUMPY = { 5 | "Int8Array": np.int8, 6 | "Int8ClampedArray": np.int8, 7 | "Int16Array": np.int16, 8 | "Int32Array": np.int32, 9 | "Uint8Array": np.uint8, 10 | "Uint16Array": np.uint16, 11 | "Uint32Array": np.uint32, 12 | "Float32Array": np.float32, 13 | "Float64Array": np.float64, 14 | } 15 | 16 | TYPE_ARRAY_ITKCOMP_TO_JS = { 17 | "SC": "Int8Array", 18 | "UC": "Uint8Array", 19 | "SS": "Int16Array", 20 | "US": "Uint16Array", 21 | "SI": "Int32Array", 22 | "UI": "Uint32Array", 23 | "F": "Float32Array", 24 | "D": "Float64Array", 25 | "B": "Uint8Array", 26 | } 27 | 28 | 29 | def itk_image_pixel_type_to_js(itk_image): 30 | """Gets the JS pixel type from an ITK image.""" 31 | component_str = repr(itk_image).split("itkImagePython.")[1].split(";")[0][8:] 32 | # TODO handle mangling as per 33 | # https://github.com/InsightSoftwareConsortium/itk-jupyter-widgets/blob/master/itkwidgets/trait_types.py#L49 34 | return TYPE_ARRAY_ITKCOMP_TO_JS[component_str[:-1]] 35 | -------------------------------------------------------------------------------- /server/volview_server/volview_api.py: -------------------------------------------------------------------------------- 1 | import socketio 2 | 3 | from volview_server.rpc_server import RpcServer 4 | from volview_server.chunking import CHUNK_SIZE 5 | from volview_server.api import RpcApi 6 | 7 | 8 | class VolViewApi(RpcApi): 9 | def __call__(self, app, server_kwargs={}, asgi_kwargs={}): 10 | """Adds ASGI middleware for accessing VolView's API. 11 | 12 | Args: 13 | - app: the ASGI app to extend 14 | - server_kwargs: RpcServer options 15 | - asgi_kwargs: socketio.ASGIApp options 16 | 17 | RPCServer options: 18 | https://python-socketio.readthedocs.io/en/latest/api.html#asyncserver-class 19 | 20 | ASGIApp options: 21 | https://python-socketio.readthedocs.io/en/latest/api.html#asgiapp-class 22 | """ 23 | server = RpcServer( 24 | self, 25 | # python-socketio kwargs 26 | async_mode="asgi", 27 | async_handlers=True, 28 | # allow upstream handling of CORS 29 | cors_allowed_origins=[], 30 | # default to chunk size 31 | max_http_buffer_size=CHUNK_SIZE, 32 | **server_kwargs, 33 | ) 34 | return socketio.ASGIApp(server.sio, app, **asgi_kwargs) 35 | -------------------------------------------------------------------------------- /src/assets/KitwareHeadAndNeck.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/src/assets/KitwareHeadAndNeck.jpg -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/src/assets/logo.png -------------------------------------------------------------------------------- /src/assets/samples/3DUS-Fetus.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/src/assets/samples/3DUS-Fetus.jpg -------------------------------------------------------------------------------- /src/assets/samples/CTA-Head_and_Neck.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/src/assets/samples/CTA-Head_and_Neck.jpg -------------------------------------------------------------------------------- /src/assets/samples/MRA-Head_and_Neck.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/src/assets/samples/MRA-Head_and_Neck.jpg -------------------------------------------------------------------------------- /src/assets/samples/MRI-Cardiac.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/src/assets/samples/MRI-Cardiac.jpg -------------------------------------------------------------------------------- /src/assets/samples/MRI-PROSTATEx.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/src/assets/samples/MRI-PROSTATEx.jpg -------------------------------------------------------------------------------- /src/components/ColorDot.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 14 | 15 | 23 | -------------------------------------------------------------------------------- /src/components/ControlButton.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 61 | -------------------------------------------------------------------------------- /src/components/CurrentImageProvider.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 20 | -------------------------------------------------------------------------------- /src/components/GroupableItem.vue: -------------------------------------------------------------------------------- 1 | 43 | -------------------------------------------------------------------------------- /src/components/IsolatedDialog.vue: -------------------------------------------------------------------------------- 1 | 8 | 11 | -------------------------------------------------------------------------------- /src/components/LayerList.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 33 | -------------------------------------------------------------------------------- /src/components/MeasurementRulerDetails.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 24 | -------------------------------------------------------------------------------- /src/components/MeasurementToolDetails.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 15 | -------------------------------------------------------------------------------- /src/components/MessageNotificationContent.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 19 | 20 | 28 | -------------------------------------------------------------------------------- /src/components/MiniExpansionPanel.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 44 | -------------------------------------------------------------------------------- /src/components/PolygonControls.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | -------------------------------------------------------------------------------- /src/components/RectangleControls.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | -------------------------------------------------------------------------------- /src/components/RulerControls.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | -------------------------------------------------------------------------------- /src/components/ToolControls.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/components/__tests__/ControlButton.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { shallowMount } from '@vue/test-utils'; 3 | 4 | import ControlButton from '@/src/components/ControlButton.vue'; 5 | 6 | describe('ControlButton.vue', () => { 7 | const propsData = { 8 | size: '80', 9 | name: 'TEST BUTTON', 10 | icon: 'test-icon', 11 | }; 12 | 13 | it('computes icon size', () => { 14 | const wrapper = shallowMount(ControlButton, { propsData }); 15 | expect(wrapper.vm.iconSize).to.equal(48); 16 | }); 17 | 18 | it('computes the button class spec correctly', () => { 19 | let props; 20 | let wrapper; 21 | 22 | props = { ...propsData, buttonClass: 'c1 c2 c3' }; 23 | wrapper = shallowMount(ControlButton, { propsData: props }); 24 | expect(wrapper.vm.classV).to.equal('c1 c2 c3'); 25 | 26 | props = { ...propsData, buttonClass: ['c1', 'c2', 'c3'] }; 27 | wrapper = shallowMount(ControlButton, { propsData: props }); 28 | expect(wrapper.vm.classV).to.equal('c1 c2 c3'); 29 | 30 | props = { 31 | ...propsData, 32 | buttonClass: { 33 | c1: true, 34 | c2: true, 35 | c3: false, 36 | }, 37 | }; 38 | wrapper = shallowMount(ControlButton, { propsData: props }); 39 | expect(wrapper.vm.classV).to.equal('c1 c2'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/components/dicom-web/DicomWebSettings.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 37 | -------------------------------------------------------------------------------- /src/components/icons/KitwareLogoIcon.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 46 | 47 | 55 | -------------------------------------------------------------------------------- /src/components/styles/annotations.css: -------------------------------------------------------------------------------- 1 | .header { 2 | background: rgba(var(--v-theme-surface-variant), 0.08); 3 | padding: 4px; 4 | margin: 8px 0; 5 | width: 100%; 6 | text-align: center; 7 | font-size: 0.875rem; 8 | color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)); 9 | } 10 | -------------------------------------------------------------------------------- /src/components/styles/utils.css: -------------------------------------------------------------------------------- 1 | .flex-equal { 2 | flex: 1; 3 | min-width: 0; 4 | } 5 | 6 | .pointer-events-all { 7 | pointer-events: all; 8 | } 9 | 10 | .view-box { 11 | box-sizing: border-box; 12 | } 13 | 14 | .clickable { 15 | cursor: pointer; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/tools/ResetViews.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 33 | -------------------------------------------------------------------------------- /src/components/tools/crop/CropControls.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 39 | -------------------------------------------------------------------------------- /src/components/tools/crop/types.ts: -------------------------------------------------------------------------------- 1 | export interface CropLine { 2 | startEdge: T; 3 | startCrop: T; 4 | endCrop: T; 5 | endEdge: T; 6 | } 7 | 8 | export interface CropLines { 9 | lowerLine: CropLine; 10 | upperLine: CropLine; 11 | outOfBounds: boolean; 12 | } 13 | -------------------------------------------------------------------------------- /src/components/tools/paint/PaintTool.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/components/vtk/VtkOrientationMarker.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/components/vtk/context.ts: -------------------------------------------------------------------------------- 1 | import { VtkViewApi } from '@/src/types/vtk-types'; 2 | import { InjectionKey } from 'vue'; 3 | 4 | export const VtkViewContext: InjectionKey = Symbol('VtkView'); 5 | -------------------------------------------------------------------------------- /src/composables/__tests__/useOrientationLabels.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | import { toOrderedLabels } from '@/src/composables/useOrientationLabels'; 4 | 5 | const SQRT1_3 = 1 / Math.sqrt(3); 6 | 7 | describe('toOrderedLabels', () => { 8 | it("correctly orders a vector's LPS labels", () => { 9 | type Cases = Array<[[number, number, number], string]>; 10 | const cases: Cases = [ 11 | [[1, 0, 0], 'L'], 12 | [[0, 1, 0], 'P'], 13 | [[0, 0, 1], 'S'], 14 | [[Math.SQRT1_2, Math.SQRT1_2, 0], 'LP'], 15 | [[Math.SQRT1_2, -Math.SQRT1_2, 0], 'LA'], 16 | [[0, Math.SQRT1_2, Math.SQRT1_2], 'PS'], 17 | [[0, Math.SQRT1_2, -Math.SQRT1_2], 'PI'], 18 | [[Math.SQRT1_2, 0, Math.SQRT1_2], 'LS'], 19 | [[-Math.SQRT1_2, 0, Math.SQRT1_2], 'RS'], 20 | [[SQRT1_3, SQRT1_3, SQRT1_3], 'LPS'], 21 | [[-SQRT1_3, SQRT1_3, -SQRT1_3], 'RPI'], 22 | [[0.06, 0.989, 0.128], 'PSL'], 23 | ]; 24 | 25 | cases.forEach(([vector, expected]) => 26 | expect(toOrderedLabels(vector)).to.equal(expected) 27 | ); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/composables/isViewAnimating.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | import { onVTKEvent } from '@/src/composables/onVTKEvent'; 3 | import { View } from '@/src/core/vtk/types'; 4 | 5 | export function isViewAnimating(view: View) { 6 | const isAnimating = ref(false); 7 | 8 | onVTKEvent(view.interactor, 'onStartAnimation', () => { 9 | isAnimating.value = true; 10 | }); 11 | onVTKEvent(view.interactor, 'onEndAnimation', () => { 12 | isAnimating.value = false; 13 | }); 14 | 15 | return isAnimating; 16 | } 17 | -------------------------------------------------------------------------------- /src/composables/manageVTKSubscription.ts: -------------------------------------------------------------------------------- 1 | import { onUnmounted } from 'vue'; 2 | import type { vtkSubscription } from '@kitware/vtk.js/interfaces'; 3 | 4 | export function manageVTKSubscription(subscription: vtkSubscription) { 5 | onUnmounted(() => subscription.unsubscribe()); 6 | } 7 | -------------------------------------------------------------------------------- /src/composables/onImageDeleted.ts: -------------------------------------------------------------------------------- 1 | import { useImageCacheStore } from '@/src/store/image-cache'; 2 | import { storeToRefs } from 'pinia'; 3 | import { watch } from 'vue'; 4 | 5 | export function onImageDeleted(callback: (deletedIDs: string[]) => void) { 6 | const { imageById } = storeToRefs(useImageCacheStore()); 7 | 8 | return watch(imageById, (newIndex, oldIndex) => { 9 | const deleted = Object.keys(oldIndex).filter((id) => !(id in newIndex)); 10 | if (deleted.length) callback(deleted); 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/composables/onPausableVTKEvent.ts: -------------------------------------------------------------------------------- 1 | import { vtkObject } from '@kitware/vtk.js/interfaces'; 2 | import { MaybeRef } from 'vue'; 3 | import { 4 | OnVTKEventOptions, 5 | VTKEventHandler, 6 | VTKEventListener, 7 | onVTKEvent, 8 | } from './onVTKEvent'; 9 | 10 | export function onPausableVTKEvent( 11 | vtkObj: MaybeRef, 12 | eventHookName: T[K] extends VTKEventListener ? K : never, 13 | callback: VTKEventHandler, 14 | options?: OnVTKEventOptions 15 | ) { 16 | let paused = false; 17 | 18 | const pause = () => { 19 | paused = true; 20 | }; 21 | 22 | const resume = () => { 23 | paused = false; 24 | }; 25 | 26 | const withPaused = (fn: () => void) => { 27 | pause(); 28 | try { 29 | fn(); 30 | } finally { 31 | resume(); 32 | } 33 | }; 34 | 35 | const { stop } = onVTKEvent( 36 | vtkObj, 37 | eventHookName, 38 | (obj) => { 39 | if (!paused) callback(obj); 40 | }, 41 | options 42 | ); 43 | 44 | return { 45 | stop, 46 | pause, 47 | resume, 48 | withPaused, 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /src/composables/onVTKEvent.ts: -------------------------------------------------------------------------------- 1 | import { Maybe } from '@/src/types'; 2 | import { vtkObject, vtkSubscription } from '@kitware/vtk.js/interfaces'; 3 | import { MaybeRef, computed, onScopeDispose, unref, watch } from 'vue'; 4 | 5 | export type VTKEventHandler = (ev?: any) => any; 6 | export type VTKEventListener = ( 7 | handler: VTKEventHandler, 8 | priority?: number 9 | ) => vtkSubscription; 10 | export type OnVTKEventOptions = { 11 | priority?: number; 12 | }; 13 | 14 | export function onVTKEvent( 15 | vtkObj: MaybeRef>, 16 | eventHookName: T[K] extends VTKEventListener ? K : never, 17 | callback: VTKEventHandler, 18 | options?: OnVTKEventOptions 19 | ) { 20 | const listenerRef = computed(() => { 21 | const obj = unref(vtkObj); 22 | return obj ? (obj[eventHookName] as VTKEventListener) : null; 23 | }); 24 | 25 | let subscription: Maybe = null; 26 | 27 | const cleanup = () => { 28 | subscription?.unsubscribe(); 29 | subscription = null; 30 | }; 31 | 32 | const stop = watch( 33 | listenerRef, 34 | (listener) => { 35 | cleanup(); 36 | if (listener) { 37 | subscription = listener(callback, options?.priority ?? 0); 38 | } 39 | }, 40 | { immediate: true } 41 | ); 42 | 43 | onScopeDispose(() => { 44 | cleanup(); 45 | }); 46 | 47 | return { 48 | stop: () => { 49 | cleanup(); 50 | stop(); 51 | }, 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/composables/stableDeepRef.ts: -------------------------------------------------------------------------------- 1 | import { Ref, computed, ref } from 'vue'; 2 | import { watchCompare } from '@/src/utils/watchCompare'; 3 | import deepEqual from 'fast-deep-equal'; 4 | 5 | /** 6 | * Ensures that a Ref holds a stable reference by deep comparison. 7 | * @param sourceRef 8 | * @returns 9 | */ 10 | export function stableDeepRef(sourceRef: Ref) { 11 | const stableRef = ref(sourceRef.value) as Ref; 12 | watchCompare( 13 | sourceRef, 14 | (result) => { 15 | stableRef.value = result; 16 | }, 17 | { compare: deepEqual } 18 | ); 19 | 20 | return computed({ 21 | get: () => stableRef.value, 22 | set: (v) => { 23 | // eslint-disable-next-line no-param-reassign 24 | sourceRef.value = v; 25 | }, 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /src/composables/untilLoaded.ts: -------------------------------------------------------------------------------- 1 | import { computed, MaybeRef, unref } from 'vue'; 2 | import { until } from '@vueuse/core'; 3 | import { useImageCacheStore } from '@/src/store/image-cache'; 4 | 5 | export function untilLoaded(imageID: MaybeRef) { 6 | const imageCacheStore = useImageCacheStore(); 7 | const doneLoading = computed(() => { 8 | const image = imageCacheStore.imageById[unref(imageID)]; 9 | if (!image) return false; 10 | return !image.loading.value && image.status.value === 'complete'; 11 | }); 12 | return until(doneLoading).toBe(true); 13 | } 14 | -------------------------------------------------------------------------------- /src/composables/useAutoFitState.ts: -------------------------------------------------------------------------------- 1 | import { onPausableVTKEvent } from '@/src/composables/onPausableVTKEvent'; 2 | import vtkCamera from '@kitware/vtk.js/Rendering/Core/Camera'; 3 | import { MaybeRef, ref } from 'vue'; 4 | 5 | export function useAutoFitState(camera: MaybeRef) { 6 | const autoFit = ref(true); 7 | 8 | const { withPaused } = onPausableVTKEvent(camera, 'onModified', () => { 9 | autoFit.value = false; 10 | }); 11 | 12 | return { autoFit, withoutAutoFitEffect: withPaused }; 13 | } 14 | -------------------------------------------------------------------------------- /src/composables/useCameraOrientation.ts: -------------------------------------------------------------------------------- 1 | import type { Vector3 } from '@kitware/vtk.js/types'; 2 | import { computed, Ref, unref } from 'vue'; 3 | import { MaybeRef } from '@vueuse/core'; 4 | import { mat3 } from 'gl-matrix'; 5 | import { ImageMetadata } from '@/src/types/image'; 6 | import { LPSAxisDir } from '@/src/types/lps'; 7 | import { getLPSDirections } from '@/src/utils/lps'; 8 | 9 | /** 10 | * 11 | * @param {Ref} viewDirection an LPS view look-direction 12 | * @param {Ref} viewUp an LPS view up-direction 13 | * @param {Ref} imageMetadataRef image metadata 14 | */ 15 | export function useCameraOrientation( 16 | viewDirection: MaybeRef, 17 | viewUp: MaybeRef, 18 | imageMetadataRef: Ref 19 | ) { 20 | const orientationMatrix = computed( 21 | () => imageMetadataRef.value.orientation as mat3 22 | ); 23 | const lpsDirections = computed(() => 24 | getLPSDirections(orientationMatrix.value) 25 | ); 26 | const cameraDirVec = computed( 27 | () => lpsDirections.value[unref(viewDirection)] as Vector3 28 | ); 29 | const cameraUpVec = computed( 30 | () => lpsDirections.value[unref(viewUp)] as Vector3 31 | ); 32 | 33 | return { 34 | cameraDirVec, 35 | cameraUpVec, 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /src/composables/useCroppingEffect.ts: -------------------------------------------------------------------------------- 1 | import { croppingPlanesEqual } from '@/src/store/tools/crop'; 2 | import { Maybe } from '@/src/types'; 3 | import vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; 4 | import vtkVolumeMapper from '@kitware/vtk.js/Rendering/Core/VolumeMapper'; 5 | import { watchImmediate } from '@vueuse/core'; 6 | import { MaybeRef, toRef } from 'vue'; 7 | 8 | export function useCroppingEffect( 9 | mapper: vtkVolumeMapper, 10 | planes: MaybeRef> 11 | ) { 12 | // TODO make sure that the default planes are based off of spatial extent 13 | watchImmediate(toRef(planes), (newPlanes, oldPlanes) => { 14 | if (!newPlanes) return; 15 | if (oldPlanes && croppingPlanesEqual(newPlanes, oldPlanes)) return; 16 | 17 | mapper.removeAllClippingPlanes(); 18 | newPlanes.forEach((plane) => mapper.addClippingPlane(plane)); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/composables/useErrorMessage.ts: -------------------------------------------------------------------------------- 1 | import { useMessageStore } from '../store/messages'; 2 | 3 | export async function useErrorMessage(message: string, task: Function) { 4 | try { 5 | return await task(); 6 | } catch (err) { 7 | if (err instanceof Error) { 8 | const messageStore = useMessageStore(); 9 | messageStore.addError(message, { 10 | details: `${err}. More details can be found in the developer's console.`, 11 | }); 12 | } 13 | console.error(err); 14 | } 15 | return undefined; 16 | } 17 | -------------------------------------------------------------------------------- /src/composables/useFrameOfReference.ts: -------------------------------------------------------------------------------- 1 | import { ImageMetadata } from '@/src/types/image'; 2 | import { LPSAxisDir } from '@/src/types/lps'; 3 | import { FrameOfReference } from '@/src/utils/frameOfReference'; 4 | import { getLPSAxisFromDir } from '@/src/utils/lps'; 5 | import type { Vector3 } from '@kitware/vtk.js/types'; 6 | import { vec3 } from 'gl-matrix'; 7 | import { computed, unref } from 'vue'; 8 | import type { ComputedRef, MaybeRef } from 'vue'; 9 | 10 | export function useFrameOfReference( 11 | viewDirection: MaybeRef, 12 | slice: MaybeRef, 13 | imageMetadata: MaybeRef 14 | ): ComputedRef { 15 | const viewAxis = computed(() => getLPSAxisFromDir(unref(viewDirection))); 16 | 17 | return computed(() => { 18 | const { lpsOrientation, indexToWorld } = unref(imageMetadata); 19 | const planeNormal = lpsOrientation[unref(viewDirection)] as Vector3; 20 | 21 | const lpsIdx = lpsOrientation[viewAxis.value]; 22 | const planeOrigin: Vector3 = [0, 0, 0]; 23 | planeOrigin[lpsIdx] = unref(slice); 24 | // convert index pt to world pt 25 | vec3.transformMat4(planeOrigin, planeOrigin, indexToWorld); 26 | 27 | return { 28 | planeNormal, 29 | planeOrigin, 30 | }; 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /src/composables/useGlobalErrorHook.ts: -------------------------------------------------------------------------------- 1 | import { onBeforeUnmount, onMounted } from 'vue'; 2 | import { captureException } from '@sentry/vue'; 3 | import { useMessageStore } from '../store/messages'; 4 | 5 | export function useGlobalErrorHook() { 6 | const messageStore = useMessageStore(); 7 | 8 | const onError = (event: ErrorEvent) => { 9 | console.error(event); 10 | const errorMessage = event.message ?? 'Unknown global error'; 11 | 12 | captureException(event.error ?? errorMessage); 13 | 14 | const details = event.error ? event.error : { details: errorMessage }; 15 | messageStore.addError('Application error (click for details)', details); 16 | }; 17 | 18 | onMounted(() => { 19 | window.addEventListener('error', onError); 20 | }); 21 | 22 | onBeforeUnmount(() => { 23 | window.removeEventListener('error', onError); 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /src/composables/useGlobalLayerColorConfig.ts: -------------------------------------------------------------------------------- 1 | import { computed, MaybeRef, unref } from 'vue'; 2 | import { InitViewSpecs } from '@/src/config'; 3 | import useLayerColoringStore from '@/src/store/view-configs/layers'; 4 | import { LayersConfig } from '@/src/store/view-configs/types'; 5 | import { useSegmentGroupConfigInitializer } from '@/src/composables/useSegmentGroupConfigInitializer'; 6 | 7 | // Returns first existing view's config as the "value" and updates all views' configs with updateConfig() 8 | export const useGlobalLayerColorConfig = (layerId: MaybeRef) => { 9 | const layerColoringStore = useLayerColoringStore(); 10 | 11 | const VIEWS_2D = Object.entries(InitViewSpecs) 12 | .filter(([, { viewType }]) => viewType === '2D') 13 | .map(([viewID]) => viewID); 14 | 15 | useSegmentGroupConfigInitializer(VIEWS_2D[0], unref(layerId)); 16 | 17 | const layerConfigs = computed(() => 18 | VIEWS_2D.map((viewID) => ({ 19 | config: layerColoringStore.getConfig(viewID, unref(layerId)), 20 | viewID, 21 | })) 22 | ); 23 | 24 | const sampledConfig = computed(() => 25 | layerConfigs.value.find(({ config }) => config) 26 | ); 27 | 28 | const updateConfig = (patch: Partial) => { 29 | layerConfigs.value.forEach(({ viewID }) => 30 | layerColoringStore.updateConfig(viewID, unref(layerId), patch) 31 | ); 32 | }; 33 | 34 | return { sampledConfig, updateConfig }; 35 | }; 36 | -------------------------------------------------------------------------------- /src/composables/useLayerConfigInitializer.ts: -------------------------------------------------------------------------------- 1 | import useLayerColoringStore from '@/src/store/view-configs/layers'; 2 | import { watchImmediate } from '@vueuse/core'; 3 | import { MaybeRef, computed, unref } from 'vue'; 4 | 5 | export function useLayerConfigInitializer( 6 | viewId: MaybeRef, 7 | layerId: MaybeRef 8 | ) { 9 | const coloringStore = useLayerColoringStore(); 10 | const colorConfig = computed(() => 11 | coloringStore.getConfig(unref(viewId), unref(layerId)) 12 | ); 13 | 14 | watchImmediate(colorConfig, (config) => { 15 | if (config) return; 16 | 17 | const viewIdVal = unref(viewId); 18 | const layerIdVal = unref(layerId); 19 | coloringStore.resetColorPreset(viewIdVal, layerIdVal); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /src/composables/useMultiSelection.ts: -------------------------------------------------------------------------------- 1 | import { computed, ref, Ref, watch } from 'vue'; 2 | 3 | export function useMultiSelection(allItems: Ref) { 4 | const selected = ref([]) as Ref; 5 | 6 | // remove deleted item 7 | watch(allItems, () => { 8 | selected.value = selected.value.filter((item) => 9 | allItems.value.includes(item) 10 | ); 11 | }); 12 | 13 | const selectedSome = computed(() => selected.value.length > 0); 14 | const selectedAll = computed( 15 | () => 16 | selected.value.length > 0 && 17 | selected.value.length === allItems.value.length 18 | ); 19 | 20 | const toggleSelectAll = () => { 21 | if (selectedAll.value) { 22 | selected.value = []; 23 | } else { 24 | selected.value = allItems.value; 25 | } 26 | }; 27 | 28 | return { selected, selectedAll, selectedSome, toggleSelectAll }; 29 | } 30 | -------------------------------------------------------------------------------- /src/composables/usePopperState.ts: -------------------------------------------------------------------------------- 1 | import { useDebounceFn } from '@vueuse/core'; 2 | import { ref } from 'vue'; 3 | 4 | // reset: isSet = false immediately. After delay, isSet = true 5 | export const usePopperState = (delay: number) => { 6 | const isSet = ref(true); 7 | 8 | const delayedSet = useDebounceFn(() => { 9 | isSet.value = true; 10 | }, delay); 11 | 12 | const reset = () => { 13 | isSet.value = false; 14 | delayedSet(); 15 | }; 16 | 17 | return { isSet, reset }; 18 | }; 19 | -------------------------------------------------------------------------------- /src/composables/useResizeObserver.ts: -------------------------------------------------------------------------------- 1 | import { onBeforeUnmount, Ref, watch } from 'vue'; 2 | 3 | /** 4 | * Invokes a callback whenever an element is resized. 5 | */ 6 | export function useResizeObserver( 7 | targetElRef: Ref, 8 | callback: (entry: ResizeObserverEntry) => void 9 | ) { 10 | const observer = new ResizeObserver((entries) => { 11 | if (entries.length === 1) { 12 | callback(entries[0]); 13 | } 14 | }); 15 | 16 | watch( 17 | targetElRef, 18 | (targetEl, prevTarget) => { 19 | if (prevTarget) { 20 | observer.unobserve(prevTarget); 21 | } 22 | if (targetEl) { 23 | observer.observe(targetEl); 24 | } 25 | }, 26 | { immediate: true } 27 | ); 28 | 29 | onBeforeUnmount(() => { 30 | observer.disconnect(); 31 | }); 32 | 33 | return observer; 34 | } 35 | -------------------------------------------------------------------------------- /src/composables/useResizeToFit.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | import vtkCamera from '@kitware/vtk.js/Rendering/Core/Camera'; 3 | import { manageVTKSubscription } from '@/src/composables/manageVTKSubscription'; 4 | import { vec3 } from 'gl-matrix'; 5 | 6 | export function useResizeToFit( 7 | camera: vtkCamera, 8 | initialValue: boolean = false 9 | ) { 10 | const resizeToFit = ref(initialValue); 11 | 12 | let trackResizeToFit = true; 13 | const cachedCameraInfo = { 14 | position: [0, 0, 0] as vec3, 15 | parallelScale: 0, 16 | }; 17 | 18 | manageVTKSubscription( 19 | camera.onModified(() => { 20 | if (trackResizeToFit && resizeToFit.value) { 21 | const position = camera.getPosition(); 22 | const parallelScale = camera.getParallelScale(); 23 | if ( 24 | !vec3.equals(position, cachedCameraInfo.position) || 25 | parallelScale !== cachedCameraInfo.parallelScale 26 | ) { 27 | resizeToFit.value = false; 28 | } 29 | } 30 | }) 31 | ); 32 | 33 | function ignoreResizeToFitTracking(cb: () => void) { 34 | if (trackResizeToFit) { 35 | trackResizeToFit = false; 36 | try { 37 | cb(); 38 | } finally { 39 | trackResizeToFit = true; 40 | } 41 | } 42 | } 43 | 44 | function resetResizeToFitTracking() { 45 | cachedCameraInfo.position = camera.getPosition(); 46 | cachedCameraInfo.parallelScale = camera.getParallelScale(); 47 | } 48 | 49 | return { resizeToFit, ignoreResizeToFitTracking, resetResizeToFitTracking }; 50 | } 51 | -------------------------------------------------------------------------------- /src/composables/useSegmentGroupConfigInitializer.ts: -------------------------------------------------------------------------------- 1 | import { watchImmediate } from '@vueuse/core'; 2 | import { MaybeRef, computed, unref } from 'vue'; 3 | import useLayerColoringStore from '@/src/store/view-configs/layers'; 4 | import { useSegmentGroupConfigStore } from '@/src/store/view-configs/segmentGroups'; 5 | 6 | function useLayerConfigInitializerForSegmentGroups( 7 | viewId: MaybeRef, 8 | layerId: MaybeRef 9 | ) { 10 | const coloringStore = useLayerColoringStore(); 11 | const colorConfig = computed(() => 12 | coloringStore.getConfig(unref(viewId), unref(layerId)) 13 | ); 14 | 15 | watchImmediate(colorConfig, (config) => { 16 | if (config) return; 17 | 18 | const viewIdVal = unref(viewId); 19 | const layerIdVal = unref(layerId); 20 | coloringStore.initConfig(viewIdVal, layerIdVal); // initConfig instead of resetColorPreset for layers 21 | coloringStore.updateBlendConfig(viewIdVal, layerIdVal, { 22 | opacity: 0.3, 23 | }); 24 | }); 25 | } 26 | 27 | export function useSegmentGroupConfigInitializer( 28 | viewId: MaybeRef, 29 | segmentGroupId: MaybeRef 30 | ) { 31 | useLayerConfigInitializerForSegmentGroups(viewId, segmentGroupId); 32 | 33 | const configStore = useSegmentGroupConfigStore(); 34 | const config = computed(() => 35 | configStore.getConfig(unref(viewId), unref(segmentGroupId)) 36 | ); 37 | 38 | watchImmediate(config, (config_) => { 39 | if (config_) return; 40 | const viewIdVal = unref(viewId); 41 | const layerIdVal = unref(segmentGroupId); 42 | configStore.initConfig(viewIdVal, layerIdVal); 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /src/composables/useSliceConfig.ts: -------------------------------------------------------------------------------- 1 | import useViewSliceStore, { 2 | defaultSliceConfig, 3 | } from '@/src/store/view-configs/slicing'; 4 | import { Maybe } from '@/src/types'; 5 | import type { Vector2 } from '@kitware/vtk.js/types'; 6 | import { unref, MaybeRef, computed } from 'vue'; 7 | 8 | export function useSliceConfig( 9 | viewID: MaybeRef, 10 | imageID: MaybeRef> 11 | ) { 12 | const store = useViewSliceStore(); 13 | const configDefaults = defaultSliceConfig(); 14 | const config = computed(() => store.getConfig(unref(viewID), unref(imageID))); 15 | 16 | const slice = computed({ 17 | get: () => config.value?.slice ?? configDefaults.slice, 18 | set: (val) => { 19 | const imageIdVal = unref(imageID); 20 | if (!imageIdVal || val == null) return; 21 | store.updateConfig(unref(viewID), imageIdVal, { slice: val }); 22 | 23 | // Update other synchronized views if any 24 | if (config.value?.syncState) { 25 | store.updateSyncConfigs(); 26 | } 27 | }, 28 | }); 29 | const range = computed((): Vector2 => { 30 | const { min, max } = config.value ?? {}; 31 | if (min == null || max == null) 32 | return [configDefaults.min, configDefaults.max]; 33 | return [min, max]; 34 | }); 35 | 36 | return { config, slice, range }; 37 | } 38 | -------------------------------------------------------------------------------- /src/composables/useSliceInfo.ts: -------------------------------------------------------------------------------- 1 | import type { Vector3 } from '@kitware/vtk.js/types'; 2 | import { computed } from 'vue'; 3 | import { MaybeRef } from '@vueuse/core'; 4 | import { getLPSAxisFromDir } from '@/src/utils/lps'; 5 | import { useImage } from '@/src/composables/useCurrentImage'; 6 | import { Maybe } from '@/src/types'; 7 | import { useSliceConfig } from '@/src/composables/useSliceConfig'; 8 | 9 | /** 10 | * Returns information about the current slice. 11 | * 12 | * axisName: the name of the axis 13 | * axisIndex: corresponding index in an LPS coordinate array 14 | * number: slice value 15 | * planeNormal: slice plane normal 16 | * planeOrigin: slice plane origin 17 | * @param viewID 18 | */ 19 | export function useSliceInfo( 20 | viewID: MaybeRef, 21 | imageID: MaybeRef> 22 | ) { 23 | const { metadata: imageMetadata } = useImage(imageID); 24 | const { slice, config } = useSliceConfig(viewID, imageID); 25 | return computed(() => { 26 | if (!config.value) return null; 27 | const { lpsOrientation } = imageMetadata.value; 28 | const { axisDirection } = config.value; 29 | const axis = getLPSAxisFromDir(axisDirection); 30 | const planeOrigin = [0, 0, 0] as Vector3; 31 | planeOrigin[lpsOrientation[axis]] = slice.value; 32 | return { 33 | axisName: axis, 34 | axisIndex: lpsOrientation[axis], 35 | slice: slice.value, 36 | planeNormal: lpsOrientation[axisDirection] as Vector3, 37 | planeOrigin, 38 | }; 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /src/composables/useToast.js: -------------------------------------------------------------------------------- 1 | import { createToastInterface } from 'vue-toastification'; 2 | 3 | const toast = createToastInterface({ 4 | position: 'bottom-left', 5 | timeout: 3000, 6 | hideProgressBar: true, 7 | maxToasts: 3, 8 | transition: 'Vue-Toastification__fade', 9 | }); 10 | 11 | export const useToast = () => toast; 12 | -------------------------------------------------------------------------------- /src/composables/useViewAnimationListener.ts: -------------------------------------------------------------------------------- 1 | import { View } from '@/src/core/vtk/types'; 2 | import useViewAnimationStore, { 3 | matchesViewFilter, 4 | } from '@/src/store/view-animation'; 5 | import { Maybe } from '@/src/types'; 6 | import { storeToRefs } from 'pinia'; 7 | import { MaybeRef, computed, unref, watchEffect } from 'vue'; 8 | 9 | export function useViewAnimationListener( 10 | view: MaybeRef>, 11 | viewId: MaybeRef, 12 | viewType: MaybeRef 13 | ) { 14 | const store = useViewAnimationStore(); 15 | const { animating, viewFilter } = storeToRefs(store); 16 | const canAnimate = computed(() => 17 | matchesViewFilter(unref(viewId), unref(viewType), viewFilter.value) 18 | ); 19 | 20 | let requested = false; 21 | 22 | watchEffect(() => { 23 | const viewVal = unref(view); 24 | if (!viewVal) return; 25 | 26 | if (!animating.value) { 27 | viewVal.interactor.cancelAnimation(store); 28 | requested = false; 29 | } else if (!requested && canAnimate.value) { 30 | viewVal.interactor.requestAnimation(store); 31 | requested = true; 32 | } 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /src/composables/useVolumeColoringInitializer.ts: -------------------------------------------------------------------------------- 1 | import { useImage } from '@/src/composables/useCurrentImage'; 2 | import useVolumeColoringStore from '@/src/store/view-configs/volume-coloring'; 3 | import { Maybe } from '@/src/types'; 4 | import { watchImmediate } from '@vueuse/core'; 5 | import { MaybeRef, computed, unref } from 'vue'; 6 | 7 | export function useVolumeColoringInitializer( 8 | viewId: MaybeRef, 9 | imageId: MaybeRef> 10 | ) { 11 | const store = useVolumeColoringStore(); 12 | const coloringConfig = computed(() => 13 | store.getConfig(unref(viewId), unref(imageId)) 14 | ); 15 | 16 | const { imageData } = useImage(imageId); 17 | 18 | const viewIdRef = computed(() => unref(viewId)); 19 | const imageIdRef = computed(() => unref(imageId)); 20 | watchImmediate([coloringConfig, viewIdRef, imageIdRef], () => { 21 | if (coloringConfig.value) return; 22 | 23 | const viewIdVal = unref(viewId); 24 | const imageIdVal = unref(imageId); 25 | if (!imageIdVal || !imageData.value) return; 26 | 27 | store.resetToDefaultColoring(viewIdVal, imageIdVal, imageData.value); 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /src/composables/wheneverImageLoaded.ts: -------------------------------------------------------------------------------- 1 | import { useImageCacheStore } from '@/src/store/image-cache'; 2 | import { Maybe } from '@/src/types'; 3 | import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; 4 | import { computed, MaybeRef, unref, WatchCallback, watch } from 'vue'; 5 | 6 | export function wheneverImageLoaded( 7 | imageId: MaybeRef>, 8 | cb: WatchCallback<{ id: string; imageData: vtkImageData }> 9 | ) { 10 | const imageCacheStore = useImageCacheStore(); 11 | const image = computed(() => { 12 | const id = unref(imageId); 13 | if (!id) return null; 14 | return imageCacheStore.imageById[id]; 15 | }); 16 | const imageIsLoaded = computed(() => image.value?.loaded.value ?? false); 17 | return watch( 18 | [() => unref(imageId), imageIsLoaded], 19 | ([id, loaded], _, onCleanup) => { 20 | if (loaded && id) { 21 | cb( 22 | { id, imageData: image.value!.getVtkImageData() }, 23 | undefined, 24 | onCleanup 25 | ); 26 | } 27 | } 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/core/provider.ts: -------------------------------------------------------------------------------- 1 | import PaintTool from './tools/paint'; 2 | 3 | /** 4 | * Pinia plugin for injecting tool services. 5 | */ 6 | export function CorePiniaProviderPlugin({ 7 | paint, 8 | }: { 9 | paint?: PaintTool; 10 | } = {}) { 11 | const dependencies = { 12 | $paint: paint ?? new PaintTool(), 13 | }; 14 | return () => dependencies; 15 | } 16 | -------------------------------------------------------------------------------- /src/core/remote/storeApi.ts: -------------------------------------------------------------------------------- 1 | import { getPiniaStore } from '@/src/plugins/storeRegistry'; 2 | 3 | type PropKey = number | string; 4 | 5 | type RestrictHook = (storeName: string, propPath: PropKey[]) => boolean; 6 | 7 | const restrictServerStore: RestrictHook = (storeName) => { 8 | return storeName === 'server'; 9 | }; 10 | 11 | const restrictPiniaProperties: RestrictHook = (storeName, propPath) => { 12 | return propPath.includes('_p'); 13 | }; 14 | 15 | const RestrictHooks: RestrictHook[] = [ 16 | restrictServerStore, 17 | restrictPiniaProperties, 18 | ]; 19 | 20 | function getStoreProperty( 21 | storeName: string, 22 | propPath: PropKey[] 23 | ): T { 24 | if (RestrictHooks.some((hook) => hook(storeName, propPath))) { 25 | throw new Error('Cannot access the requested store and property'); 26 | } 27 | 28 | const store = getPiniaStore(storeName); 29 | if (!store) { 30 | throw new Error(`${storeName} does not exist or is not initialized`); 31 | } 32 | 33 | let value: any = store; 34 | while (propPath.length) { 35 | const prop = propPath.shift()! as string; 36 | value = value[prop]; 37 | } 38 | return value as T; 39 | } 40 | 41 | async function callStoreMethod( 42 | storeName: string, 43 | propPath: PropKey[], 44 | args: unknown[] 45 | ) { 46 | const method = getStoreProperty<(...args: any[]) => any>(storeName, propPath); 47 | return method(...args); 48 | } 49 | 50 | export const StoreApi = { 51 | getStoreProperty, 52 | callStoreMethod, 53 | }; 54 | -------------------------------------------------------------------------------- /src/core/remote/transformers/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | serializeVtkImageData, 3 | deserializeVtkImageData, 4 | } from '@/src/core/remote/transformers/vtkImageData'; 5 | 6 | export const DefaultSerializeTransformers = [serializeVtkImageData]; 7 | export const DefaultDeserializeTransformers = [deserializeVtkImageData]; 8 | 9 | type ObjectTransformer = (obj: any) => any; 10 | 11 | export function transformObject(input: any, transform: ObjectTransformer): any { 12 | const output = transform(input); 13 | 14 | if (!output || typeof output !== 'object') { 15 | return output; 16 | } 17 | 18 | if (Array.isArray(output)) { 19 | return output.map((o) => transformObject(o, transform)); 20 | } 21 | 22 | return Object.entries(output).reduce( 23 | (obj, [key, value]) => ({ ...obj, [key]: value }), 24 | {} 25 | ); 26 | } 27 | 28 | export function transformObjects( 29 | args: any[], 30 | transform: ObjectTransformer 31 | ): any[] { 32 | return args.map((arg) => transformObject(arg, transform)); 33 | } 34 | -------------------------------------------------------------------------------- /src/core/remote/transformers/vtkImageData.ts: -------------------------------------------------------------------------------- 1 | import vtk from '@kitware/vtk.js/vtk'; 2 | import { TypedArrayConstructorName } from '@/src/types'; 3 | import { TypedArrayConstructorNames } from '@/src/utils'; 4 | import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; 5 | 6 | const AllowedTypedArrays = new Set(TypedArrayConstructorNames); 7 | 8 | function isTypedArrayName(name: string): name is TypedArrayConstructorName { 9 | return AllowedTypedArrays.has(name); 10 | } 11 | 12 | function isImageData(obj: any): obj is vtkImageData { 13 | return obj?.isA?.('vtkImageData'); 14 | } 15 | 16 | function wrapValuesInTypedArray(serializedImageData: any) { 17 | // convert data values to a typed array for smaller packets. 18 | const { arrays } = serializedImageData.pointData; 19 | arrays.forEach((da: any) => { 20 | const { data } = da; 21 | if (isTypedArrayName(data.dataType)) { 22 | data.values = new globalThis[data.dataType as TypedArrayConstructorName]( 23 | data.values 24 | ); 25 | } 26 | }); 27 | return serializedImageData; 28 | } 29 | 30 | export function serializeVtkImageData(obj: any): any { 31 | if (!isImageData(obj)) { 32 | return obj; 33 | } 34 | 35 | const serialized = obj.toJSON() as any; 36 | return wrapValuesInTypedArray(serialized); 37 | } 38 | 39 | export function deserializeVtkImageData(obj: any) { 40 | if (obj?.vtkClass !== 'vtkImageData') { 41 | return obj; 42 | } 43 | 44 | try { 45 | return vtk(wrapValuesInTypedArray(obj)); 46 | } catch (e) { 47 | return obj; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/core/streaming/__tests__/chunkStateMachine.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChunkState, 3 | ChunkStateMachine, 4 | TransitionEvent, 5 | } from '@/src/core/streaming/chunkStateMachine'; 6 | import { describe, expect, it } from 'vitest'; 7 | 8 | describe('chunk', () => { 9 | describe('state machine', () => { 10 | it('should transition properly', () => { 11 | const machine = new ChunkStateMachine(); 12 | 13 | expect(machine.state).to.equal(ChunkState.Init); 14 | 15 | [ 16 | TransitionEvent.LoadData, 17 | TransitionEvent.MetaLoaded, 18 | TransitionEvent.DataLoaded, 19 | TransitionEvent.Cancel, 20 | ].forEach((event) => { 21 | machine.send(event); 22 | expect(machine.state).to.equal(ChunkState.Init); 23 | }); 24 | 25 | machine.send(TransitionEvent.LoadMeta); 26 | expect(machine.state).to.equal(ChunkState.MetaLoading); 27 | 28 | machine.send(TransitionEvent.MetaLoaded); 29 | expect(machine.state).to.equal(ChunkState.MetaOnly); 30 | 31 | machine.send(TransitionEvent.LoadData); 32 | expect(machine.state).to.equal(ChunkState.DataLoading); 33 | 34 | machine.send(TransitionEvent.DataLoaded); 35 | expect(machine.state).to.equal(ChunkState.Loaded); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/core/streaming/__tests__/requestPool.spec.ts: -------------------------------------------------------------------------------- 1 | import { RequestPool } from '@/src/core/streaming/requestPool'; 2 | import { describe, it, expect } from 'vitest'; 3 | 4 | // @ts-ignore 5 | const fetch = async () => { 6 | return new Promise(() => {}); 7 | }; 8 | 9 | describe('requestPool', () => { 10 | it('should not have more active requests than the pool size', () => { 11 | const N = 4; 12 | const pool = new RequestPool(N, fetch); 13 | for (let i = 0; i < 10; i++) { 14 | pool.fetch('http://localhost/url'); 15 | } 16 | expect(pool.activeConnections).to.equal(N); 17 | }); 18 | 19 | it('should support removal of requests via an AbortController', async () => { 20 | const N = 4; 21 | const pool = new RequestPool(N); 22 | const controllers: AbortController[] = []; 23 | const promises: Promise[] = []; 24 | 25 | for (let i = 0; i < 10; i++) { 26 | const controller = new AbortController(); 27 | controllers.push(controller); 28 | promises.push( 29 | pool.fetch('http://localhost/url', { signal: controller.signal }) 30 | ); 31 | } 32 | 33 | controllers.forEach((controller) => { 34 | controller.abort('cancelled'); 35 | }); 36 | 37 | // eslint-disable-next-line no-restricted-syntax 38 | for (const p of promises) { 39 | // eslint-disable-next-line no-await-in-loop 40 | await expect(p).rejects.toThrow('cancelled'); 41 | } 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/core/streaming/chunkImage.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ProgressiveImage, 3 | ProgressiveImageEvents, 4 | } from '@/src/core/progressiveImage'; 5 | import { Chunk } from '@/src/core/streaming/chunk'; 6 | import { Extent } from '@kitware/vtk.js/types'; 7 | 8 | export enum ThumbnailStrategy { 9 | MiddleSlice, 10 | } 11 | 12 | export enum ChunkStatus { 13 | NotLoaded, 14 | Loading, 15 | Loaded, 16 | Errored, 17 | } 18 | 19 | export interface ChunkLoadedInfo { 20 | updatedExtent: Extent; 21 | chunk: Chunk; 22 | } 23 | 24 | export interface ChunkErrorInfo { 25 | error: unknown; 26 | chunk: Chunk; 27 | } 28 | 29 | export type ChunkImageEvents = { 30 | chunkLoad: ChunkLoadedInfo; 31 | chunkError: ChunkErrorInfo; 32 | } & ProgressiveImageEvents; 33 | 34 | export interface ChunkImage extends ProgressiveImage { 35 | addChunks(chunks: Chunk[]): void; 36 | getThumbnail(strategy: ThumbnailStrategy): Promise; 37 | addEventListener( 38 | type: T, 39 | callback: (info: ChunkImageEvents[T]) => void 40 | ): void; 41 | removeEventListener( 42 | type: T, 43 | callback: (info: ChunkImageEvents[T]) => void 44 | ): void; 45 | getChunkStatuses(): Array; 46 | } 47 | -------------------------------------------------------------------------------- /src/core/streaming/chunkStateMachine.ts: -------------------------------------------------------------------------------- 1 | import StateMachine from '@/src/core/stateMachine'; 2 | 3 | export enum ChunkState { 4 | Init = 'Init', 5 | MetaLoading = 'MetaLoading', 6 | MetaOnly = 'MetaOnly', 7 | DataLoading = 'DataLoading', 8 | Loaded = 'Loaded', 9 | } 10 | 11 | export enum TransitionEvent { 12 | LoadMeta = 'LoadMeta', 13 | MetaLoaded = 'MetaLoaded', 14 | LoadData = 'LoadData', 15 | DataLoaded = 'DataLoaded', 16 | Cancel = 'Cancel', 17 | } 18 | 19 | export class ChunkStateMachine extends StateMachine< 20 | ChunkState, 21 | TransitionEvent 22 | > { 23 | constructor() { 24 | super(ChunkState.Init, { 25 | [ChunkState.Init]: { 26 | [TransitionEvent.LoadMeta]: ChunkState.MetaLoading, 27 | }, 28 | [ChunkState.MetaLoading]: { 29 | [TransitionEvent.Cancel]: ChunkState.Init, 30 | [TransitionEvent.MetaLoaded]: ChunkState.MetaOnly, 31 | }, 32 | [ChunkState.MetaOnly]: { 33 | [TransitionEvent.LoadData]: ChunkState.DataLoading, 34 | }, 35 | [ChunkState.DataLoading]: { 36 | [TransitionEvent.DataLoaded]: ChunkState.Loaded, 37 | [TransitionEvent.Cancel]: ChunkState.MetaOnly, 38 | }, 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/core/streaming/concatStreams.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Concatenates multiple streams together in order. 3 | * @param streams 4 | * @returns 5 | */ 6 | export function concatStreams( 7 | ...streams: ReadableStream[] 8 | ): ReadableStream { 9 | let reader: ReadableStreamDefaultReader | null = null; 10 | return new ReadableStream({ 11 | async pull(controller) { 12 | let enqueued = false; 13 | while (!enqueued && streams.length) { 14 | if (!reader) { 15 | reader = streams[0].getReader(); 16 | } 17 | 18 | // eslint-disable-next-line no-await-in-loop 19 | const result = await reader.read(); 20 | 21 | if (result.value) { 22 | controller.enqueue(result.value); 23 | enqueued = true; 24 | } 25 | 26 | if (result.done) { 27 | streams.shift(); 28 | reader = null; 29 | } 30 | } 31 | 32 | if (streams.length === 0) { 33 | controller.close(); 34 | } 35 | }, 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /src/core/streaming/dicom/__tests__/dicomMetaLoader.spec.ts: -------------------------------------------------------------------------------- 1 | import { DicomMetaLoader } from '@/src/core/streaming/dicom/dicomMetaLoader'; 2 | import { RequestPool } from '@/src/core/streaming/requestPool'; 3 | import { CachedStreamFetcher } from '@/src/core/streaming/cachedStreamFetcher'; 4 | import { describe, it, expect } from 'vitest'; 5 | 6 | describe('dicomMetaLoader', () => { 7 | it('should load only metadata', async () => { 8 | const pool = new RequestPool(); 9 | const fetcher = new CachedStreamFetcher( 10 | 'https://data.kitware.com/api/v1/file/57b5d4648d777f10f2693e7e/download', 11 | { 12 | fetch: pool.fetch, 13 | } 14 | ); 15 | const loader = new DicomMetaLoader(fetcher, () => { 16 | return []; 17 | }); 18 | await loader.load(); 19 | 20 | const downloaded = fetcher.cachedChunks.reduce( 21 | (sum, chunk) => sum + chunk.length, 22 | 0 23 | ); 24 | // metadata header fits within 4096 25 | expect(downloaded).to.be.lessThanOrEqual(4096); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/core/streaming/dicom/dicomDataLoader.ts: -------------------------------------------------------------------------------- 1 | import { DataLoader, Fetcher } from '@/src/core/streaming/types'; 2 | import { Maybe } from '@/src/types'; 3 | 4 | export class DicomDataLoader implements DataLoader { 5 | public data: Maybe; 6 | private fetcher: Fetcher; 7 | 8 | constructor(fetcher: Fetcher) { 9 | this.fetcher = fetcher; 10 | } 11 | 12 | async load() { 13 | this.data = await this.fetcher.blob(); 14 | } 15 | 16 | stop() { 17 | this.fetcher.close(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/core/streaming/dicom/dicomFileDataLoader.ts: -------------------------------------------------------------------------------- 1 | import { DataLoader } from '@/src/core/streaming/types'; 2 | 3 | export class DicomFileDataLoader implements DataLoader { 4 | public data: Blob; 5 | 6 | constructor(data: Blob) { 7 | this.data = data; 8 | } 9 | 10 | // Data is provided, so load/stop does nothing. 11 | // eslint-disable-next-line class-methods-use-this 12 | load() {} 13 | // eslint-disable-next-line class-methods-use-this 14 | stop() {} 15 | } 16 | -------------------------------------------------------------------------------- /src/core/streaming/dicom/dicomFileMetaLoader.ts: -------------------------------------------------------------------------------- 1 | import { ReadDicomTagsFunction } from '@/src/core/streaming/dicom/dicomMetaLoader'; 2 | import { MetaLoader } from '@/src/core/streaming/types'; 3 | import { Maybe } from '@/src/types'; 4 | 5 | export class DicomFileMetaLoader implements MetaLoader { 6 | public tags: Maybe>; 7 | private file: File; 8 | 9 | constructor(file: File, private readDicomTags: ReadDicomTagsFunction) { 10 | this.file = file; 11 | } 12 | 13 | get meta() { 14 | return this.tags; 15 | } 16 | 17 | get metaBlob() { 18 | return this.file; 19 | } 20 | 21 | async load() { 22 | if (this.tags) return; 23 | this.tags = await this.readDicomTags(this.file); 24 | } 25 | 26 | // eslint-disable-next-line class-methods-use-this 27 | stop() { 28 | // do nothing 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/core/streaming/httpCodes.ts: -------------------------------------------------------------------------------- 1 | export const HTTP_STATUS_OK = 200; 2 | export const HTTP_STATUS_PARTIAL_CONTENT = 206; 3 | export const HTTP_STATUS_REQUESTED_RANGE_NOT_SATISFIABLE = 416; 4 | 5 | export class HttpNotFound extends Error { 6 | constructor(url: string) { 7 | super(`The following resource could not be found: ${url}`); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/core/streaming/types.ts: -------------------------------------------------------------------------------- 1 | import { Maybe } from '@/src/types'; 2 | import { Awaitable } from '@vueuse/core'; 3 | 4 | export type LoaderEvents = { 5 | error: any; 6 | done: any; 7 | }; 8 | 9 | interface Loader { 10 | load(): Awaitable; 11 | stop(): Awaitable; 12 | } 13 | 14 | /** 15 | * A metadata loader. 16 | */ 17 | export interface MetaLoader extends Loader { 18 | meta: Maybe>; 19 | metaBlob: Maybe; 20 | } 21 | 22 | /** 23 | * A data loader. 24 | */ 25 | export interface DataLoader extends Loader { 26 | data: Maybe; 27 | } 28 | 29 | /** 30 | * Init options for a Fetcher. 31 | */ 32 | export interface FetcherInit { 33 | abortController?: AbortController; 34 | } 35 | 36 | /** 37 | * A fetcher that caches an incoming stream. 38 | */ 39 | export interface Fetcher { 40 | connect(): Promise; 41 | getStream(): ReadableStream; 42 | blob(): Promise; 43 | close(): void; 44 | cachedChunks: Uint8Array[]; 45 | connected: boolean; 46 | size: number; 47 | abortSignal?: AbortSignal; 48 | } 49 | -------------------------------------------------------------------------------- /src/core/thumbnailers/index.ts: -------------------------------------------------------------------------------- 1 | export enum ThumbnailSlice { 2 | First, 3 | Middle, 4 | Last, 5 | } 6 | -------------------------------------------------------------------------------- /src/core/tools/paint/brush.ts: -------------------------------------------------------------------------------- 1 | import type { Vector2 } from '@kitware/vtk.js/types'; 2 | 3 | export interface IBrushStencil { 4 | pixels: Uint8Array; 5 | size: [number, number]; 6 | } 7 | export interface IPaintBrush { 8 | /** 9 | * Sets the size of the stencil, pre-scaling. 10 | */ 11 | setSize(size: number): void; 12 | /** 13 | * Sets the scale of the stencil. 14 | */ 15 | setScale(scale: Vector2): void; 16 | /** 17 | * Returns the stencil with the applied scaling. 18 | */ 19 | getStencil(): IBrushStencil; 20 | } 21 | -------------------------------------------------------------------------------- /src/core/viewTypes.ts: -------------------------------------------------------------------------------- 1 | import { Component } from 'vue'; 2 | import MultiObliqueSliceViewer from '@/src/components/MultiObliqueSliceViewer.vue'; 3 | import ObliqueSliceViewer from '@/src/components/ObliqueSliceViewer.vue'; 4 | import SliceViewer from '@/src/components/SliceViewer.vue'; 5 | import VolumeViewer from '@/src/components/VolumeViewer.vue'; 6 | 7 | export const ViewTypeToComponent: Record = { 8 | '2D': SliceViewer, 9 | '3D': VolumeViewer, 10 | Oblique: ObliqueSliceViewer, 11 | Oblique3D: MultiObliqueSliceViewer, 12 | }; 13 | -------------------------------------------------------------------------------- /src/core/vtk/onViewMounted.ts: -------------------------------------------------------------------------------- 1 | import { vtkFieldRef } from '@/src/core/vtk/vtkFieldRef'; 2 | import vtkOpenGLRenderWindow from '@kitware/vtk.js/Rendering/OpenGL/RenderWindow'; 3 | import { whenever } from '@vueuse/core'; 4 | import { computed, onUnmounted } from 'vue'; 5 | 6 | function isViewMounted(renderWindowView: vtkOpenGLRenderWindow) { 7 | const container = vtkFieldRef(renderWindowView, 'container'); 8 | return computed(() => !!container.value); 9 | } 10 | 11 | export function onViewMounted( 12 | renderWindowView: vtkOpenGLRenderWindow, 13 | callback: () => void 14 | ) { 15 | const isMounted = isViewMounted(renderWindowView); 16 | whenever(isMounted, () => { 17 | callback(); 18 | }); 19 | } 20 | 21 | export function onViewUnmounted( 22 | renderWindowView: vtkOpenGLRenderWindow, 23 | callback: () => void 24 | ) { 25 | const isMounted = isViewMounted(renderWindowView); 26 | whenever( 27 | computed(() => !isMounted.value), 28 | () => { 29 | callback(); 30 | } 31 | ); 32 | 33 | onUnmounted(() => { 34 | callback(); 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /src/core/vtk/types.ts: -------------------------------------------------------------------------------- 1 | import vtkAbstractMapper from '@kitware/vtk.js/Rendering/Core/AbstractMapper'; 2 | import vtkProp from '@kitware/vtk.js/Rendering/Core/Prop'; 3 | import vtkRenderWindow from '@kitware/vtk.js/Rendering/Core/RenderWindow'; 4 | import vtkRenderWindowInteractor from '@kitware/vtk.js/Rendering/Core/RenderWindowInteractor'; 5 | import vtkRenderer from '@kitware/vtk.js/Rendering/Core/Renderer'; 6 | import vtkOpenGLRenderWindow from '@kitware/vtk.js/Rendering/OpenGL/RenderWindow'; 7 | import vtkWidgetManager from '@kitware/vtk.js/Widgets/Core/WidgetManager'; 8 | import { vtkObject } from '@kitware/vtk.js/interfaces'; 9 | 10 | export type VtkObjectConstructor = { 11 | newInstance(props?: any): T; 12 | }; 13 | 14 | export interface RequestRenderOptions { 15 | immediate?: boolean; 16 | } 17 | 18 | export interface View { 19 | renderWindow: vtkRenderWindow; 20 | renderer: vtkRenderer; 21 | interactor: vtkRenderWindowInteractor; 22 | renderWindowView: vtkOpenGLRenderWindow; 23 | widgetManager: vtkWidgetManager; 24 | requestRender(opts?: RequestRenderOptions): void; 25 | } 26 | 27 | export type vtkPropWithMapperProperty< 28 | M extends vtkAbstractMapper = vtkAbstractMapper, 29 | P extends vtkObject = vtkObject 30 | > = vtkProp & { 31 | setMapper(m: M): void; 32 | getProperty(): P; 33 | }; 34 | 35 | export interface Representation< 36 | Actor extends vtkPropWithMapperProperty, 37 | Mapper extends vtkAbstractMapper 38 | > { 39 | actor: Actor; 40 | mapper: Mapper; 41 | property: ReturnType; 42 | } 43 | -------------------------------------------------------------------------------- /src/core/vtk/useResliceRepresentation.ts: -------------------------------------------------------------------------------- 1 | import { MaybeRef } from 'vue'; 2 | import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; 3 | import vtkImageSlice from '@kitware/vtk.js/Rendering/Core/ImageSlice'; 4 | import { useVtkRepresentation } from '@/src/core/vtk/useVtkRepresentation'; 5 | import { Maybe } from '@/src/types'; 6 | import { View } from '@/src/core/vtk/types'; 7 | import vtkImageResliceMapper from '@kitware/vtk.js/Rendering/Core/ImageResliceMapper'; 8 | import { onVTKEvent } from '@/src/composables/onVTKEvent'; 9 | import { vtkFieldRef } from '@/src/core/vtk/vtkFieldRef'; 10 | 11 | export function useResliceRepresentation( 12 | view: View, 13 | imageData: MaybeRef> 14 | ) { 15 | const sliceRep = useVtkRepresentation({ 16 | view, 17 | data: imageData, 18 | vtkActorClass: vtkImageSlice, 19 | vtkMapperClass: vtkImageResliceMapper, 20 | }); 21 | 22 | const plane = vtkFieldRef(sliceRep.mapper, 'slicePlane'); 23 | onVTKEvent(plane, 'onModified', () => { 24 | view.requestRender(); 25 | }); 26 | 27 | return sliceRep; 28 | } 29 | -------------------------------------------------------------------------------- /src/core/vtk/useSliceRepresentation.ts: -------------------------------------------------------------------------------- 1 | import { MaybeRef } from 'vue'; 2 | import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; 3 | import vtkImageMapper from '@kitware/vtk.js/Rendering/Core/ImageMapper'; 4 | import vtkImageSlice from '@kitware/vtk.js/Rendering/Core/ImageSlice'; 5 | import { useVtkRepresentation } from '@/src/core/vtk/useVtkRepresentation'; 6 | import { Maybe } from '@/src/types'; 7 | import { View } from '@/src/core/vtk/types'; 8 | 9 | export function useSliceRepresentation( 10 | view: View, 11 | imageData: MaybeRef> 12 | ) { 13 | const sliceRep = useVtkRepresentation({ 14 | view, 15 | data: imageData, 16 | vtkActorClass: vtkImageSlice, 17 | vtkMapperClass: vtkImageMapper, 18 | }); 19 | 20 | return sliceRep; 21 | } 22 | -------------------------------------------------------------------------------- /src/core/vtk/useVolumeRepresentation.ts: -------------------------------------------------------------------------------- 1 | import { MaybeRef } from 'vue'; 2 | import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; 3 | import { useVtkRepresentation } from '@/src/core/vtk/useVtkRepresentation'; 4 | import { Maybe } from '@/src/types'; 5 | import { View } from '@/src/core/vtk/types'; 6 | import vtkVolume from '@kitware/vtk.js/Rendering/Core/Volume'; 7 | import vtkVolumeMapper from '@kitware/vtk.js/Rendering/Core/VolumeMapper'; 8 | 9 | export function useVolumeRepresentation( 10 | view: View, 11 | imageData: MaybeRef> 12 | ) { 13 | const volRep = useVtkRepresentation({ 14 | view, 15 | data: imageData, 16 | vtkActorClass: vtkVolume, 17 | vtkMapperClass: vtkVolumeMapper, 18 | }); 19 | 20 | return volRep; 21 | } 22 | -------------------------------------------------------------------------------- /src/core/vtk/useVtkFilter.ts: -------------------------------------------------------------------------------- 1 | import { VtkObjectConstructor } from '@/src/core/vtk/types'; 2 | import { vtkFieldRef } from '@/src/core/vtk/vtkFieldRef'; 3 | import { Maybe } from '@/src/types'; 4 | import { vtkAlgorithm, vtkObject } from '@kitware/vtk.js/interfaces'; 5 | import { computedWithControl } from '@vueuse/core'; 6 | import { ComputedRef, MaybeRef, onScopeDispose, unref, watchEffect } from 'vue'; 7 | 8 | export function useVtkFilter( 9 | filterClass: VtkObjectConstructor, 10 | ...inputData: MaybeRef>[] 11 | ) { 12 | const filter = filterClass.newInstance(); 13 | const mtime = vtkFieldRef(filter as vtkObject, 'mTime'); 14 | 15 | watchEffect(() => { 16 | inputData 17 | .map((input) => unref(input)) 18 | .forEach((input, port) => { 19 | if (input) filter.setInputData(unref(input), port); 20 | }); 21 | }); 22 | 23 | let cache: Record> = {}; 24 | 25 | const getOutputData = (port = 0) => { 26 | if (!(port in cache)) { 27 | cache[port] = computedWithControl(mtime, () => { 28 | if (!filter.getInputData(port)) return null; 29 | return filter.getOutputData(port) as D; 30 | }); 31 | } 32 | return cache[port] as ComputedRef; 33 | }; 34 | 35 | onScopeDispose(() => { 36 | cache = {}; 37 | filter.delete(); 38 | }); 39 | 40 | return { filter, getOutputData }; 41 | } 42 | -------------------------------------------------------------------------------- /src/core/vtk/useVtkInteractorStyle.ts: -------------------------------------------------------------------------------- 1 | import { VtkObjectConstructor } from '@/src/core/vtk/types'; 2 | import vtkInteractorStyle from '@kitware/vtk.js/Rendering/Core/InteractorStyle'; 3 | import { vtkWarningMacro } from '@kitware/vtk.js/macros'; 4 | import { onScopeDispose } from 'vue'; 5 | import type { View } from '@/src/core/vtk/types'; 6 | 7 | export function useVtkInteractorStyle( 8 | vtkCtor: VtkObjectConstructor, 9 | view: View 10 | ) { 11 | const style = vtkCtor.newInstance(); 12 | 13 | if (view.interactor.getInteractorStyle()) { 14 | vtkWarningMacro('Overwriting an already set interactor style'); 15 | } 16 | view.interactor.setInteractorStyle(style); 17 | 18 | onScopeDispose(() => { 19 | if (view.interactor.getInteractorStyle() === style) 20 | view.interactor.setInteractorStyle(null); 21 | style.delete(); 22 | }); 23 | 24 | return { interactorStyle: style }; 25 | } 26 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_DICOM_WEB_URL: string; 5 | readonly VITE_DICOM_WEB_NAME: string; 6 | readonly VITE_ENABLE_REMOTE_SAVE: boolean; 7 | readonly VITE_REMOTE_SERVER_URL: string; 8 | } 9 | 10 | interface ImportMeta { 11 | readonly env: ImportMetaEnv; 12 | } 13 | 14 | declare const __VERSIONS__: Record; 15 | -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | html { 2 | overflow: hidden !important; 3 | } 4 | 5 | body { 6 | position: fixed; 7 | width: 100%; 8 | height: 100%; 9 | user-select: none; 10 | } 11 | 12 | .no-select { 13 | user-select: none; 14 | } 15 | 16 | .text-ellipsis { 17 | overflow: hidden; 18 | text-overflow: ellipsis; 19 | white-space: nowrap; 20 | } 21 | 22 | .v-card--variant-outlined { 23 | border: thin solid rgb(var(--v-theme-on-surface-variant)) !important; 24 | } 25 | 26 | .v-tooltip .v-overlay__content { 27 | background-color: rgba(255, 255, 255, 0.9) !important; 28 | } 29 | 30 | ul:not([class]), 31 | ol:not([class]) { 32 | padding-left: 20px; 33 | margin-bottom: 16px; 34 | } 35 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | import 'pinia'; 2 | import type { Framework } from 'vuetify/types'; 3 | import PaintTool from './core/tools/paint'; 4 | 5 | declare module 'pinia' { 6 | export interface PiniaCustomProperties { 7 | // from CorePiniaProviderPlugin 8 | $paint: PaintTool; 9 | } 10 | } 11 | 12 | declare module 'vue/types/vue' { 13 | interface Vue { 14 | $vuetify: Framework; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/io/__tests__/io.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { retypeFile } from '@/src/io'; 3 | 4 | function makeEmptyFile(name: string) { 5 | return new File([], name); 6 | } 7 | 8 | function makeDicomFile(name: string) { 9 | const buffer = new Uint8Array(132); 10 | buffer[128] = 'D'.charCodeAt(0); 11 | buffer[129] = 'I'.charCodeAt(0); 12 | buffer[130] = 'C'.charCodeAt(0); 13 | buffer[131] = 'M'.charCodeAt(0); 14 | return new File([buffer.buffer], name); 15 | } 16 | 17 | describe('I/O', () => { 18 | it('should detect dicom files', async () => { 19 | expect((await retypeFile(makeDicomFile('file.DCM'))).type).to.equal( 20 | 'application/dicom' 21 | ); 22 | expect((await retypeFile(makeDicomFile('somedicom'))).type).to.equal( 23 | 'application/dicom' 24 | ); 25 | }); 26 | 27 | it('should retype files based on extension', async () => { 28 | expect((await retypeFile(makeEmptyFile('test.VTI'))).type).to.equal( 29 | 'application/vnd.unknown.vti' 30 | ); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/io/import/processors/downloadStream.ts: -------------------------------------------------------------------------------- 1 | import { Skip } from '@/src/utils/evaluateChain'; 2 | import { ImportHandler, asIntermediateResult } from '@/src/io/import/common'; 3 | import { ensureError } from '@/src/utils'; 4 | 5 | /** 6 | * Downloads a URL to a file DataSource. 7 | * 8 | * Input: { uriSrc } 9 | * Output: { fileSrc, uriSrc } 10 | * 11 | * Provides optional caching if the execution context provides a cache. 12 | * @param dataSource 13 | * @returns 14 | */ 15 | const downloadStream: ImportHandler = async (dataSource) => { 16 | if (dataSource.type !== 'uri') return Skip; 17 | if (!dataSource.fetcher) return Skip; 18 | 19 | const { fetcher } = dataSource; 20 | await fetcher.connect(); 21 | 22 | try { 23 | const blob = await fetcher.blob(); 24 | const file = new File([blob], dataSource.name, { 25 | type: dataSource.mime, 26 | }); 27 | 28 | return asIntermediateResult([ 29 | { 30 | type: 'file', 31 | file, 32 | fileType: file.type, 33 | parent: dataSource, 34 | }, 35 | ]); 36 | } catch (err) { 37 | throw new Error( 38 | `Could not download stream associated with URL ${dataSource.uri}`, 39 | { 40 | cause: ensureError(err), 41 | } 42 | ); 43 | } 44 | }; 45 | 46 | export default downloadStream; 47 | -------------------------------------------------------------------------------- /src/io/import/processors/extractArchive.ts: -------------------------------------------------------------------------------- 1 | import { extractFilesFromZip } from '@/src/io/zip'; 2 | import { 3 | ImportHandler, 4 | asIntermediateResult, 5 | isArchive, 6 | } from '@/src/io/import/common'; 7 | import { Skip } from '@/src/utils/evaluateChain'; 8 | import { DataSource } from '@/src/io/import/dataSource'; 9 | 10 | /** 11 | * Extracts all files from an archive. 12 | * @param dataSource 13 | */ 14 | const extractArchive: ImportHandler = async (dataSource) => { 15 | if (isArchive(dataSource)) { 16 | const files = await extractFilesFromZip(dataSource.file); 17 | const newSources = files.map((entry): DataSource => { 18 | return { 19 | type: 'file', 20 | file: entry.file, 21 | fileType: '', 22 | parent: { 23 | type: 'archive', 24 | path: entry.archivePath, 25 | parent: dataSource, 26 | }, 27 | }; 28 | }); 29 | return asIntermediateResult(newSources); 30 | } 31 | return Skip; 32 | }; 33 | 34 | export default extractArchive; 35 | -------------------------------------------------------------------------------- /src/io/import/processors/extractArchiveTarget.ts: -------------------------------------------------------------------------------- 1 | import { 2 | asIntermediateResult, 3 | ImportHandler, 4 | isArchive, 5 | } from '@/src/io/import/common'; 6 | import { extractFileFromZip } from '@/src/io/zip'; 7 | import { Skip } from '@/src/utils/evaluateChain'; 8 | 9 | /** 10 | * Extracts a single target file from an archive. 11 | * 12 | * If the fileSrc already exists, nothing is done. Otherwise, attempt to 13 | * extract the file from the parent archive. 14 | * 15 | * Input data source must be of the following form: 16 | * { archiveSrc, parent: DataSource with a fileSrc } 17 | * @param dataSource 18 | * @returns 19 | */ 20 | const extractArchiveTarget: ImportHandler = async (dataSource) => { 21 | if (dataSource.type !== 'archive') return Skip; 22 | 23 | if (!isArchive(dataSource.parent)) { 24 | throw new Error('Parent is not a supported archive file'); 25 | } 26 | 27 | const targetFile = await extractFileFromZip( 28 | dataSource.parent.file, 29 | dataSource.path 30 | ); 31 | 32 | return asIntermediateResult([ 33 | { 34 | type: 'file', 35 | file: targetFile, 36 | fileType: '', 37 | parent: dataSource, 38 | }, 39 | ]); 40 | }; 41 | 42 | export default extractArchiveTarget; 43 | -------------------------------------------------------------------------------- /src/io/import/processors/handleAmazonS3.ts: -------------------------------------------------------------------------------- 1 | import { Skip } from '@/src/utils/evaluateChain'; 2 | import { getObjectsFromS3, isAmazonS3Uri } from '@/src/io/amazonS3'; 3 | import { ImportHandler, asIntermediateResult } from '@/src/io/import/common'; 4 | import { DataSource } from '@/src/io/import/dataSource'; 5 | 6 | const handleAmazonS3: ImportHandler = async (dataSource) => { 7 | if (dataSource.type === 'uri' && isAmazonS3Uri(dataSource.uri)) { 8 | try { 9 | const newSources: DataSource[] = []; 10 | await getObjectsFromS3(dataSource.uri, (name, url) => { 11 | newSources.push({ 12 | type: 'uri', 13 | uri: url, 14 | name, 15 | parent: dataSource, 16 | }); 17 | }); 18 | return asIntermediateResult(newSources); 19 | } catch (err) { 20 | throw new Error(`Could not download S3 URI ${dataSource.uri}`, { 21 | cause: err instanceof Error ? err : undefined, 22 | }); 23 | } 24 | } 25 | return Skip; 26 | }; 27 | 28 | export default handleAmazonS3; 29 | -------------------------------------------------------------------------------- /src/io/import/processors/handleConfig.ts: -------------------------------------------------------------------------------- 1 | import { ImportHandler, asConfigResult } from '@/src/io/import/common'; 2 | import { ensureError } from '@/src/utils'; 3 | import { readConfigFile } from '@/src/io/import/configJson'; 4 | import { Skip } from '@/src/utils/evaluateChain'; 5 | 6 | /** 7 | * Reads a JSON file with label config and updates stores. 8 | * @param dataSource 9 | * @returns 10 | */ 11 | const handleConfig: ImportHandler = async (dataSource) => { 12 | if ( 13 | dataSource.type === 'file' && 14 | dataSource.fileType === 'application/json' 15 | ) { 16 | try { 17 | const manifest = await readConfigFile(dataSource.file); 18 | // Don't consume JSON if it has no known key 19 | if (Object.keys(manifest).length === 0) { 20 | return Skip; 21 | } 22 | return asConfigResult(dataSource, manifest); 23 | } catch (err) { 24 | throw new Error('Failed to parse config file', { 25 | cause: ensureError(err), 26 | }); 27 | } 28 | } 29 | return Skip; 30 | }; 31 | 32 | export default handleConfig; 33 | -------------------------------------------------------------------------------- /src/io/import/processors/handleDicomFile.ts: -------------------------------------------------------------------------------- 1 | import { Skip } from '@/src/utils/evaluateChain'; 2 | import { Chunk } from '@/src/core/streaming/chunk'; 3 | import { DicomFileDataLoader } from '@/src/core/streaming/dicom/dicomFileDataLoader'; 4 | import { DicomFileMetaLoader } from '@/src/core/streaming/dicom/dicomFileMetaLoader'; 5 | import { ReadDicomTagsFunction } from '@/src/core/streaming/dicom/dicomMetaLoader'; 6 | import { ImportHandler, asIntermediateResult } from '@/src/io/import/common'; 7 | import { getWorker } from '@/src/io/itk/worker'; 8 | import { FILE_EXT_TO_MIME } from '@/src/io/mimeTypes'; 9 | import { readDicomTags } from '@itk-wasm/dicom'; 10 | 11 | /** 12 | * Adds DICOM files to the extra context. 13 | * @param dataSource 14 | * @returns 15 | */ 16 | const handleDicomFile: ImportHandler = async (dataSource) => { 17 | if ( 18 | dataSource.type !== 'file' || 19 | dataSource.fileType !== FILE_EXT_TO_MIME.dcm 20 | ) { 21 | return Skip; 22 | } 23 | 24 | const readTags: ReadDicomTagsFunction = async (file) => { 25 | const result = await readDicomTags(file, { webWorker: getWorker() }); 26 | return result.tags; 27 | }; 28 | 29 | const metaLoader = new DicomFileMetaLoader(dataSource.file, readTags); 30 | const dataLoader = new DicomFileDataLoader(dataSource.file); 31 | const chunk = new Chunk({ 32 | metaLoader, 33 | dataLoader, 34 | }); 35 | 36 | await chunk.loadMeta(); 37 | 38 | return asIntermediateResult([ 39 | { 40 | type: 'chunk', 41 | chunk, 42 | mime: FILE_EXT_TO_MIME.dcm, 43 | parent: dataSource, 44 | }, 45 | ]); 46 | }; 47 | 48 | export default handleDicomFile; 49 | -------------------------------------------------------------------------------- /src/io/import/processors/handleGoogleCloudStorage.ts: -------------------------------------------------------------------------------- 1 | import { Skip } from '@/src/utils/evaluateChain'; 2 | import { 3 | getObjectsFromGsUri, 4 | isGoogleCloudStorageUri, 5 | } from '@/src/io/googleCloudStorage'; 6 | import { ImportHandler, asIntermediateResult } from '@/src/io/import/common'; 7 | import { DataSource } from '@/src/io/import/dataSource'; 8 | 9 | const handleGoogleCloudStorage: ImportHandler = async (dataSource) => { 10 | if (dataSource.type === 'uri' && isGoogleCloudStorageUri(dataSource.uri)) { 11 | try { 12 | const newSources: DataSource[] = []; 13 | await getObjectsFromGsUri(dataSource.uri, (object) => { 14 | newSources.push({ 15 | type: 'uri', 16 | uri: object.mediaLink, 17 | name: object.name, 18 | parent: dataSource, 19 | }); 20 | }); 21 | return asIntermediateResult(newSources); 22 | } catch (err) { 23 | throw new Error(`Could not download GCS URI ${dataSource.uri}`, { 24 | cause: err instanceof Error ? err : undefined, 25 | }); 26 | } 27 | } 28 | return Skip; 29 | }; 30 | 31 | export default handleGoogleCloudStorage; 32 | -------------------------------------------------------------------------------- /src/io/import/processors/openUriStream.ts: -------------------------------------------------------------------------------- 1 | import { Skip } from '@/src/utils/evaluateChain'; 2 | import { CachedStreamFetcher } from '@/src/core/streaming/cachedStreamFetcher'; 3 | import { getRequestPool } from '@/src/core/streaming/requestPool'; 4 | import { ImportHandler, asIntermediateResult } from '@/src/io/import/common'; 5 | import { canFetchUrl } from '@/src/utils/fetch'; 6 | 7 | const openUriStream: ImportHandler = async (dataSource, context) => { 8 | if (dataSource.type !== 'uri' || !canFetchUrl(dataSource.uri)) { 9 | return Skip; 10 | } 11 | 12 | if (dataSource.fetcher?.connected) { 13 | return Skip; 14 | } 15 | 16 | const fetcher = new CachedStreamFetcher(dataSource.uri, { 17 | fetch: (...args) => getRequestPool().fetch(...args), 18 | }); 19 | 20 | await fetcher.connect(); 21 | 22 | // ensure we close the connection on completion 23 | context?.onCleanup?.(() => { 24 | fetcher.close(); 25 | }); 26 | 27 | return asIntermediateResult([ 28 | { 29 | ...dataSource, 30 | fetcher, 31 | }, 32 | ]); 33 | }; 34 | 35 | export default openUriStream; 36 | -------------------------------------------------------------------------------- /src/io/import/processors/remoteManifest.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from '@/src/io/import/dataSource'; 2 | import { ImportHandler, asIntermediateResult } from '@/src/io/import/common'; 3 | import { readRemoteManifestFile } from '@/src/io/manifest'; 4 | import { Skip } from '@/src/utils/evaluateChain'; 5 | import { ZodError } from 'zod'; 6 | 7 | /** 8 | * Reads a JSON file that conforms to the remote manifest spec. 9 | * @param dataSource 10 | * @returns 11 | */ 12 | const handleRemoteManifest: ImportHandler = async (dataSource) => { 13 | if ( 14 | dataSource.type !== 'file' || 15 | dataSource.fileType !== 'application/json' 16 | ) { 17 | return Skip; 18 | } 19 | 20 | try { 21 | const remotes: DataSource[] = []; 22 | const manifest = await readRemoteManifestFile(dataSource.file); 23 | manifest.resources.forEach((res) => { 24 | remotes.push({ 25 | type: 'uri', 26 | uri: res.url, 27 | name: res.name ?? new URL(res.url, window.location.origin).pathname, 28 | parent: dataSource, 29 | }); 30 | }); 31 | 32 | return asIntermediateResult(remotes); 33 | } catch (err) { 34 | if (err instanceof ZodError) return Skip; 35 | throw err; 36 | } 37 | }; 38 | 39 | export default handleRemoteManifest; 40 | -------------------------------------------------------------------------------- /src/io/import/processors/updateFileMimeType.ts: -------------------------------------------------------------------------------- 1 | import { Skip } from '@/src/utils/evaluateChain'; 2 | import { getFileMimeType } from '@/src/io'; 3 | import { ImportHandler, asIntermediateResult } from '@/src/io/import/common'; 4 | 5 | /** 6 | * Transforms a file data source to have a mime type 7 | * @param dataSource 8 | */ 9 | const updateFileMimeType: ImportHandler = async (dataSource) => { 10 | if (dataSource.type !== 'file' || dataSource.fileType !== '') return Skip; 11 | 12 | const mime = await getFileMimeType(dataSource.file); 13 | if (!mime) { 14 | throw new Error('File is unsupported'); 15 | } 16 | 17 | return asIntermediateResult([ 18 | { 19 | ...dataSource, 20 | fileType: mime, 21 | }, 22 | ]); 23 | }; 24 | 25 | export default updateFileMimeType; 26 | -------------------------------------------------------------------------------- /src/io/index.ts: -------------------------------------------------------------------------------- 1 | import type { vtkObject } from '@kitware/vtk.js/interfaces'; 2 | 3 | export * from './io'; 4 | 5 | export type ReaderType = (file: File) => vtkObject | Promise; 6 | export type FileReaderMap = Map; 7 | 8 | /** 9 | * A map of the currently registered file readers. 10 | * 11 | * Maps mime type to reader. 12 | */ 13 | export const FILE_READERS: FileReaderMap = new Map(); 14 | -------------------------------------------------------------------------------- /src/io/itk-dicom/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !*.cpp 3 | !*.hpp 4 | !emscripten-build/ 5 | !emscripten-build/dicom* -------------------------------------------------------------------------------- /src/io/itk-dicom/emscripten-build/dicom.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/src/io/itk-dicom/emscripten-build/dicom.wasm -------------------------------------------------------------------------------- /src/io/itk-dicom/emscripten-build/dicom.wasm.zst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/src/io/itk-dicom/emscripten-build/dicom.wasm.zst -------------------------------------------------------------------------------- /src/io/itk/itkConfig.js: -------------------------------------------------------------------------------- 1 | const fullUrl = (relative) => { 2 | // ex: /itk/image-io 3 | const u = new URL(document.location); // ex: http://localhost:8043/orthanc/volview/index.html 4 | const origin = u.origin; // ex: http://localhost:8043 5 | const pathParts = u.pathname.split('/'); // ex: ['', 'orthanc', 'volview', 'index.html'] 6 | pathParts.pop(); // ex: ['', 'orthanc', 'volview'] 7 | 8 | const url = origin + pathParts.join('/') + relative; // ex http://localhost:8043/orthanc/volview/itk/image-io 9 | return url; 10 | }; 11 | 12 | const itkConfig = { 13 | pipelineWorkerUrl: fullUrl('/itk/itk-wasm-pipeline.min.worker.js'), 14 | imageIOUrl: fullUrl('/itk/image-io'), 15 | meshIOUrl: fullUrl('/itk/mesh-io'), 16 | pipelinesUrl: fullUrl('/itk/pipelines'), 17 | }; 18 | 19 | export default itkConfig; 20 | -------------------------------------------------------------------------------- /src/io/itk/worker.ts: -------------------------------------------------------------------------------- 1 | import { 2 | readDicomTags, 3 | readImageDicomFileSeriesWorkerFunction, 4 | } from '@itk-wasm/dicom'; 5 | import { readImage } from '@itk-wasm/image-io'; 6 | import { WorkerPool, createWebWorker, setDefaultWebWorker } from 'itk-wasm'; 7 | 8 | const DEFAULT_NUM_WORKERS = 4; 9 | 10 | let readDicomSeriesWorkerPool: WorkerPool | null = null; 11 | let webWorker: Worker | null = null; 12 | 13 | export async function ensureWorker() { 14 | if (webWorker) return; 15 | webWorker = await createWebWorker(null); 16 | setDefaultWebWorker(webWorker); 17 | } 18 | 19 | export function ensureDicomSeriesWorkerPool() { 20 | if (readDicomSeriesWorkerPool) return; 21 | // copied from read-image-dicom-file-series.ts 22 | const numberOfWorkers = 23 | typeof globalThis.navigator?.hardwareConcurrency === 'number' 24 | ? globalThis.navigator.hardwareConcurrency 25 | : DEFAULT_NUM_WORKERS; 26 | readDicomSeriesWorkerPool = new WorkerPool( 27 | numberOfWorkers, 28 | readImageDicomFileSeriesWorkerFunction 29 | ); 30 | } 31 | 32 | export function getWorker() { 33 | return webWorker; 34 | } 35 | 36 | export function getDicomSeriesWorkerPool() { 37 | return readDicomSeriesWorkerPool; 38 | } 39 | 40 | export async function initItkWorker() { 41 | await Promise.all([ensureWorker(), ensureDicomSeriesWorkerPool()]); 42 | 43 | // preload 44 | try { 45 | await readDicomTags(new File([], 'a.dcm')); 46 | } catch (err) { 47 | // ignore 48 | } 49 | try { 50 | await readImage(new File([], 'a.dcm')); 51 | } catch (err) { 52 | // ignore 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/io/manifest.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const RemoteResource = z.object({ 4 | url: z.string(), 5 | name: z.optional(z.string()), 6 | }); 7 | 8 | export const RemoteDataManifest = z.object({ 9 | resources: z.array(RemoteResource), 10 | }); 11 | 12 | export async function readRemoteManifestFile(manifestFile: File) { 13 | const decoder = new TextDecoder(); 14 | const ab = await manifestFile.arrayBuffer(); 15 | const text = decoder.decode(new Uint8Array(ab)); 16 | const manifest = RemoteDataManifest.parse(JSON.parse(text)); 17 | return manifest; 18 | } 19 | -------------------------------------------------------------------------------- /src/io/readWriteImage.ts: -------------------------------------------------------------------------------- 1 | import vtkITKHelper from '@kitware/vtk.js/Common/DataModel/ITKHelper'; 2 | import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; 3 | import { copyImage } from 'itk-wasm'; 4 | import { 5 | readImage as readImageItk, 6 | writeImage as writeImageItk, 7 | } from '@itk-wasm/image-io'; 8 | import { vtiReader, vtiWriter } from '@/src/io/vtk/async'; 9 | import { getWorker } from '@/src/io/itk/worker'; 10 | 11 | export const readImage = async (file: File) => { 12 | if (file.name.endsWith('.vti')) 13 | return (await vtiReader(file)) as vtkImageData; 14 | 15 | const { image } = await readImageItk(file, { webWorker: getWorker() }); 16 | return vtkITKHelper.convertItkToVtkImage(image); 17 | }; 18 | 19 | export const writeImage = async (format: string, image: vtkImageData) => { 20 | if (format === 'vti') { 21 | return vtiWriter(image); 22 | } 23 | // copyImage so writeImage does not detach live data when passing to worker 24 | const itkImage = copyImage(vtkITKHelper.convertVtkToItkImage(image)); 25 | 26 | const result = await writeImageItk(itkImage, `image.${format}`, { 27 | webWorker: getWorker(), 28 | }); 29 | return result.serializedImage.data; 30 | }; 31 | -------------------------------------------------------------------------------- /src/io/readers.ts: -------------------------------------------------------------------------------- 1 | import { convertItkToVtkImage } from '@kitware/vtk.js/Common/DataModel/ITKHelper'; 2 | import { readImage, extensionToImageIo } from '@itk-wasm/image-io'; 3 | import { getWorker } from '@/src/io/itk/worker'; 4 | import { FileReaderMap } from '.'; 5 | 6 | import { stlReader, vtiReader, vtpReader } from './vtk/async'; 7 | import { FILE_EXT_TO_MIME } from './mimeTypes'; 8 | 9 | export const ITK_IMAGE_MIME_TYPES = Array.from( 10 | new Set( 11 | Array.from(extensionToImageIo.keys()).map( 12 | (ext) => FILE_EXT_TO_MIME[ext.toLowerCase()] 13 | ) 14 | ) 15 | ); 16 | 17 | async function itkReader(file: File) { 18 | const { image } = await readImage(file, { 19 | webWorker: getWorker(), 20 | }); 21 | return convertItkToVtkImage(image); 22 | } 23 | 24 | /** 25 | * Resets the file reader map to the default values. 26 | */ 27 | export function registerAllReaders(readerMap: FileReaderMap) { 28 | readerMap.set('application/vnd.unknown.vti', vtiReader); 29 | readerMap.set('application/vnd.unknown.vtp', vtpReader); 30 | readerMap.set('model/stl', stlReader); 31 | 32 | ITK_IMAGE_MIME_TYPES.forEach((mime) => { 33 | readerMap.set(mime, itkReader); 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /src/io/resample/.gitignore: -------------------------------------------------------------------------------- 1 | /emscripten-build/* 2 | !emscripten-build/resample* -------------------------------------------------------------------------------- /src/io/resample/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.16) 2 | project(Resample) 3 | 4 | set(CMAKE_CXX_STANDARD 17) 5 | 6 | find_package(ITK REQUIRED 7 | COMPONENTS 8 | WebAssemblyInterface 9 | ITKImageGrid 10 | ITKImageFunction 11 | GenericLabelInterpolator 12 | ) 13 | include(${ITK_USE_FILE}) 14 | 15 | add_executable(resample resample.cxx) 16 | target_link_libraries(resample PUBLIC ${ITK_LIBRARIES}) 17 | 18 | -------------------------------------------------------------------------------- /src/io/resample/emscripten-build/resample.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/src/io/resample/emscripten-build/resample.wasm -------------------------------------------------------------------------------- /src/io/resample/emscripten-build/resample.wasm.zst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/src/io/resample/emscripten-build/resample.wasm.zst -------------------------------------------------------------------------------- /src/io/resample/resample.ts: -------------------------------------------------------------------------------- 1 | import { Image } from 'itk-wasm'; 2 | import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; 3 | import vtkITKHelper from '@kitware/vtk.js/Common/DataModel/ITKHelper'; 4 | import { compareImageSpaces } from '@/src/utils/imageSpace'; 5 | import { runWasm } from './itkWasmUtils'; 6 | 7 | 8 | export async function resample(fixed: Image, moving: Image, label = false) { 9 | const labelFlag = label ? ['--label'] : []; 10 | const { size, spacing, origin, direction } = fixed; 11 | const args = [ 12 | ...labelFlag, 13 | '--size', 14 | size.join(','), 15 | '--spacing', 16 | spacing.join(','), 17 | '--origin', 18 | origin.join(','), 19 | '--direction', 20 | direction.join(','), 21 | ]; 22 | 23 | return runWasm('resample', args, [moving]); 24 | } 25 | 26 | export async function ensureSameSpace(target: vtkImageData, resampleCandidate: vtkImageData, label = false) { 27 | if (compareImageSpaces(target, resampleCandidate)) { 28 | return resampleCandidate; // could still be different pixel dimensions 29 | } 30 | const itkImage = await resample( 31 | vtkITKHelper.convertVtkToItkImage(target), 32 | vtkITKHelper.convertVtkToItkImage(resampleCandidate), 33 | label 34 | ); 35 | return vtkITKHelper.convertItkToVtkImage(itkImage); 36 | } -------------------------------------------------------------------------------- /src/io/types.ts: -------------------------------------------------------------------------------- 1 | export interface FileEntry { 2 | file: File; 3 | archivePath: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/io/vtk/async.reader.worker.ts: -------------------------------------------------------------------------------- 1 | import vtkSTLReader from '@kitware/vtk.js/IO/Geometry/STLReader'; 2 | import vtkXMLImageDataReader from '@kitware/vtk.js/IO/XML/XMLImageDataReader'; 3 | import vtkXMLPolyDataReader from '@kitware/vtk.js/IO/XML/XMLPolyDataReader'; 4 | 5 | import workerHandler from '@/src/utils/workerHandler'; 6 | import { readFile } from './common'; 7 | 8 | const Readers = { 9 | stl: { 10 | readerClass: vtkSTLReader, 11 | asBinary: true, 12 | }, 13 | vti: { 14 | readerClass: vtkXMLImageDataReader, 15 | asBinary: true, 16 | }, 17 | vtp: { 18 | readerClass: vtkXMLPolyDataReader, 19 | asBinary: true, 20 | }, 21 | }; 22 | 23 | export interface ReaderWorkerInput { 24 | file: File; 25 | readerName: keyof typeof Readers; 26 | } 27 | 28 | workerHandler.registerHandler(async (data: ReaderWorkerInput) => { 29 | try { 30 | const { file, readerName } = data; 31 | if (!file) { 32 | throw new Error('No file provided'); 33 | } 34 | if (!(readerName in Readers)) { 35 | throw new Error(`No reader found for ${file.name}`); 36 | } 37 | 38 | const { readerClass, asBinary } = Readers[readerName]; 39 | const ds = await readFile(file, readerClass, asBinary); 40 | return { 41 | status: 'success', 42 | obj: ds.getState(), 43 | }; 44 | } catch (error) { 45 | return { 46 | status: 'fail', 47 | error: error as Error, 48 | }; 49 | } 50 | }); 51 | -------------------------------------------------------------------------------- /src/io/vtk/async.writer.worker.ts: -------------------------------------------------------------------------------- 1 | import vtkXMLImageDataWriter from '@kitware/vtk.js/IO/XML/XMLImageDataWriter'; 2 | import vtkDataSet from '@kitware/vtk.js/Common/DataModel/DataSet'; 3 | 4 | import workerHandler from '@/src/utils/workerHandler'; 5 | import vtkLabelMap from '@/src/vtk/LabelMap'; 6 | import vtk from '@kitware/vtk.js/vtk'; 7 | 8 | import { writeData, StateObject } from './common'; 9 | 10 | const Writers = { 11 | vti: { 12 | writerClass: vtkXMLImageDataWriter, 13 | }, 14 | }; 15 | 16 | export interface WorkerInput { 17 | obj: StateObject; 18 | writerName: keyof typeof Writers; 19 | } 20 | 21 | vtk.register('vtkLabelMap', vtkLabelMap.newInstance); 22 | 23 | workerHandler.registerHandler(async (inputData: WorkerInput) => { 24 | try { 25 | const { obj, writerName } = inputData; 26 | if (!obj) { 27 | throw new Error('No data provided'); 28 | } 29 | if (!(writerName in Writers)) { 30 | throw new Error(`No writer found for ${writerName}`); 31 | } 32 | 33 | const { writerClass } = Writers[writerName]; 34 | 35 | const serialized = await writeData(writerClass, vtk(obj) as vtkDataSet); 36 | return { 37 | status: 'success', 38 | data: serialized, 39 | }; 40 | } catch (error) { 41 | return { 42 | status: 'fail', 43 | error: error as Error, 44 | }; 45 | } 46 | }); 47 | -------------------------------------------------------------------------------- /src/io/vtk/common.ts: -------------------------------------------------------------------------------- 1 | import { vtkReader, vtkWriter, vtkClass } from '@/src/types/vtk-types'; 2 | import { readFileAsArrayBuffer, readFileAsUTF8Text } from '@/src/io'; 3 | import vtkDataSet from '@kitware/vtk.js/Common/DataModel/DataSet'; 4 | 5 | export async function readFile( 6 | file: File, 7 | vtkReaderClass: vtkClass, 8 | asBinary = true 9 | ) { 10 | const reader: vtkReader = vtkReaderClass.newInstance() as vtkReader; 11 | if (asBinary) { 12 | const buffer = await readFileAsArrayBuffer(file); 13 | reader.parseAsArrayBuffer(buffer); 14 | } else { 15 | const buffer = await readFileAsUTF8Text(file); 16 | reader.parseAsText(buffer); 17 | } 18 | return reader.getOutputData(); 19 | } 20 | 21 | export async function writeData(vtkWriterClass: vtkClass, data: vtkDataSet) { 22 | const writer: vtkWriter = vtkWriterClass.newInstance() as vtkWriter; 23 | 24 | return writer.write(data); 25 | } 26 | 27 | export interface StateObject { 28 | vtkClass: string; 29 | [attrName: string]: unknown; 30 | } 31 | -------------------------------------------------------------------------------- /src/io/zip.ts: -------------------------------------------------------------------------------- 1 | import JSZip from 'jszip'; 2 | import { basename, dirname } from '@/src/utils/path'; 3 | import { FileEntry } from './types'; 4 | 5 | export async function extractFilesFromZip(zipFile: File): Promise { 6 | const zip = await JSZip.loadAsync(zipFile); 7 | const promises: Promise[] = []; 8 | const paths: string[] = []; 9 | zip.forEach((relPath, file) => { 10 | if (!file.dir) { 11 | const fileName = basename(file.name); 12 | const path = dirname(file.name); 13 | const fileEntry = zip.file(file.name); 14 | if (fileEntry) { 15 | promises.push( 16 | fileEntry.async('blob').then((blob) => new File([blob], fileName)) 17 | ); 18 | paths.push(path); 19 | } 20 | } 21 | }); 22 | 23 | return Promise.all(promises).then((files) => { 24 | return files.map((file, index) => { 25 | return { 26 | file, 27 | archivePath: `${paths[index]}/${file.name}`, 28 | }; 29 | }); 30 | }); 31 | } 32 | 33 | export async function extractFileFromZip( 34 | zipFile: File, 35 | filePath: string 36 | ): Promise { 37 | const zip = await JSZip.loadAsync(zipFile); 38 | const zippedFile = zip.file(filePath); 39 | 40 | if (!zippedFile) 41 | throw new Error(`File ${filePath} does not exist in the zip file`); 42 | if (zippedFile.dir) throw new Error(`Given file path is a directory`); 43 | 44 | const blob = await zippedFile.async('blob'); 45 | return new File([blob], basename(zippedFile.name)); 46 | } 47 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import './main.js'; 2 | -------------------------------------------------------------------------------- /src/plugins/storeRegistry.ts: -------------------------------------------------------------------------------- 1 | import { PiniaPluginContext, StoreGeneric } from 'pinia'; 2 | 3 | const stores = new Map(); 4 | 5 | /** 6 | * Gets a pinia store by it's ID. 7 | * 8 | * Assumes the store has already been initialized via useStore. 9 | * @param id 10 | * @returns 11 | */ 12 | export function getPiniaStore(id: string) { 13 | return stores.get(id); 14 | } 15 | 16 | export function StoreRegistry(context: PiniaPluginContext) { 17 | const { store } = context; 18 | stores.set(store.$id, store); 19 | } 20 | -------------------------------------------------------------------------------- /src/plugins/vuetify.js: -------------------------------------------------------------------------------- 1 | import { createVuetify } from 'vuetify'; 2 | import { useLocalStorage } from '@vueuse/core'; 3 | 4 | import KitwareMark from '@/src/components/icons/KitwareLogoIcon.vue'; 5 | import { 6 | DefaultTheme, 7 | DarkTheme, 8 | LightTheme, 9 | ThemeStorageKey, 10 | } from '@/src/constants'; 11 | 12 | const vuetify = createVuetify({ 13 | icons: { 14 | values: { 15 | kitwareMark: { 16 | component: KitwareMark, 17 | }, 18 | }, 19 | }, 20 | theme: { 21 | defaultTheme: DefaultTheme, 22 | themes: { 23 | [DarkTheme]: { 24 | dark: true, 25 | colors: { 26 | 'selection-bg-color': '#01579b', 27 | 'selection-border-color': '#01579b', 28 | }, 29 | }, 30 | [LightTheme]: { 31 | dark: false, 32 | colors: { 33 | 'selection-bg-color': '#b3e5fc', 34 | 'selection-border-color': '#b3e5fc', 35 | surface: '#f0f0f0', 36 | 'on-surface-variant': '#d0d0d0', 37 | }, 38 | }, 39 | }, 40 | }, 41 | display: { 42 | mobileBreakpoint: 'lg', 43 | thresholds: { 44 | lg: 1024, 45 | }, 46 | }, 47 | }); 48 | 49 | const theme = useLocalStorage(ThemeStorageKey, DefaultTheme); 50 | if (theme.value !== DarkTheme && theme.value !== LightTheme) { 51 | theme.value = DefaultTheme; 52 | } 53 | vuetify.theme.global.name.value = theme.value; 54 | 55 | export default vuetify; 56 | -------------------------------------------------------------------------------- /src/shims-itk.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'itk/IOTypes' { 2 | const IOTypes = { 3 | Text: 'Text', 4 | Binary: 'Binary', 5 | Image: 'Image', 6 | Mesh: 'Mesh', 7 | vtkPolyData: 'vtkPolyData', 8 | } as const; 9 | 10 | export default IOTypes; 11 | } 12 | 13 | declare module 'itk/runPipelineBrowser' { 14 | async function runPipelineBrowser( 15 | webWorker: Worker | null | boolean, 16 | pipelinePath: string | URL, 17 | args: string[], 18 | outputs: PipelineOutput[] | null, 19 | inputs: PipelineInput[] | null 20 | ): Promise; 21 | 22 | export = runPipelineBrowser; 23 | } 24 | 25 | declare module 'itk/extensionToImageIO' { 26 | export default {} as Map; 27 | } 28 | 29 | declare module 'itk/readImageArrayBuffer' { 30 | export default () => any; 31 | } 32 | 33 | declare module 'itk/ImageType' { 34 | export interface ImageType { 35 | dimension: number; 36 | componentType: string; 37 | pixelType: string; 38 | components: number; 39 | } 40 | 41 | export default ImageType; 42 | } 43 | 44 | declare module 'itk/Image' { 45 | import ImageType from 'itk/ImageType'; 46 | type TypedArray = 47 | | Uint8Array 48 | | Uint8ClampedArray 49 | | Int8Array 50 | | Uint16Array 51 | | Int16Array 52 | | Uint32Array 53 | | Int32Array 54 | | Float32Array 55 | | Float64Array; 56 | 57 | export interface Image { 58 | imageType: ImageType; 59 | origin: number[]; 60 | spacing: number[]; 61 | direction: number[]; 62 | size: number[]; 63 | data: TypedArray | null; 64 | } 65 | 66 | export default Image; 67 | } 68 | -------------------------------------------------------------------------------- /src/shims-misc.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.jpg' { 2 | const url: string; 3 | export default url; 4 | } 5 | -------------------------------------------------------------------------------- /src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue'; 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue'; 3 | 4 | export default Vue; 5 | } 6 | -------------------------------------------------------------------------------- /src/store/data-browser.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { ref } from 'vue'; 3 | 4 | export const useDataBrowserStore = defineStore('data-browser', () => { 5 | const hideSampleData = ref(false); 6 | return { 7 | hideSampleData, 8 | }; 9 | }); 10 | -------------------------------------------------------------------------------- /src/store/datasets-files.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { FileSource } from '@/src/io/import/dataSource'; 3 | 4 | interface State { 5 | byDataID: Record; 6 | } 7 | 8 | /** 9 | * Store the File objects associated with a given dataset. 10 | */ 11 | export const useFileStore = defineStore('files', { 12 | state: (): State => ({ 13 | byDataID: {}, 14 | }), 15 | 16 | getters: { 17 | // Returns DataSource[] used to build a dataID 18 | getDataSources: (state) => (dataID: string) => state.byDataID[dataID] ?? [], 19 | 20 | // Returns [File] used to build a dataID 21 | getFiles: (state) => (dataID: string) => 22 | (state.byDataID[dataID] ?? []).map((ds) => ds.file), 23 | }, 24 | 25 | actions: { 26 | remove(dataID: string) { 27 | if (dataID in this.byDataID) { 28 | delete this.byDataID[dataID]; 29 | } 30 | }, 31 | 32 | add(dataID: string, files: FileSource[]) { 33 | this.byDataID[dataID] = files; 34 | }, 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /src/store/datasets-models.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import vtkPolyData from '@kitware/vtk.js/Common/DataModel/PolyData'; 3 | import { useIdStore } from '@/src/store/id'; 4 | 5 | interface State { 6 | idList: string[]; // list of IDs 7 | dataIndex: Record; // ID -> VTK object 8 | metadata: Record; // ID -> metadata 9 | } 10 | export const useModelStore = defineStore('models', { 11 | state: (): State => ({ 12 | idList: [], 13 | dataIndex: {}, 14 | metadata: {}, 15 | }), 16 | actions: { 17 | addVTKPolyData(name: string, polyData: vtkPolyData) { 18 | const id = useIdStore().nextId(); 19 | this.idList.push(id); 20 | this.dataIndex[id] = polyData; 21 | return id; 22 | }, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /src/store/id.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | 3 | const START_ID = 0; 4 | 5 | export const useIdStore = defineStore('id', () => { 6 | let id: number = START_ID; 7 | return { 8 | nextId() { 9 | return String(++id); 10 | }, 11 | reset() { 12 | id = START_ID; 13 | }, 14 | }; 15 | }); 16 | -------------------------------------------------------------------------------- /src/store/keyboard-shortcuts.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | import { defineStore } from 'pinia'; 3 | 4 | export const useKeyboardShortcutsStore = defineStore( 5 | 'keyboardShortcuts', 6 | () => { 7 | const settingsOpen = ref(false); 8 | 9 | return { 10 | settingsOpen, 11 | }; 12 | } 13 | ); 14 | -------------------------------------------------------------------------------- /src/store/probe.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | import { defineStore } from 'pinia'; 3 | import { vec3 } from 'gl-matrix'; 4 | 5 | export type ProbeSample = { 6 | id: string; 7 | name: string; 8 | displayValues: (string | number)[]; 9 | }; 10 | 11 | export type ProbeData = 12 | | { 13 | pos: vec3; 14 | samples: ProbeSample[]; 15 | } 16 | | undefined; 17 | 18 | export const useProbeStore = defineStore('probe', () => { 19 | const probeData = ref(undefined); 20 | 21 | const updateProbeData = (data: ProbeData) => { 22 | probeData.value = data; 23 | }; 24 | 25 | const clearProbeData = () => { 26 | probeData.value = undefined; 27 | }; 28 | 29 | return { 30 | probeData, 31 | updateProbeData, 32 | clearProbeData, 33 | }; 34 | }); 35 | -------------------------------------------------------------------------------- /src/store/remote-save-state.ts: -------------------------------------------------------------------------------- 1 | import { serialize } from '@/src/io/state-file'; 2 | import { useMessageStore } from '@/src/store/messages'; 3 | import { $fetch } from '@/src/utils/fetch'; 4 | import { defineStore } from 'pinia'; 5 | import { ref } from 'vue'; 6 | 7 | const useRemoteSaveStateStore = defineStore('remoteSaveState', () => { 8 | const saveUrl = ref(''); 9 | const isSaving = ref(false); 10 | 11 | const messageStore = useMessageStore(); 12 | 13 | const setSaveUrl = (url: string) => { 14 | saveUrl.value = url; 15 | }; 16 | 17 | const saveState = async () => { 18 | if (!saveUrl.value || isSaving.value) return; 19 | try { 20 | isSaving.value = true; 21 | 22 | const blob = await serialize(); 23 | const saveResult = await $fetch(saveUrl.value, { 24 | method: 'POST', 25 | headers: { 26 | 'Content-Type': 'application/zip', 27 | 'Content-Length': blob.size.toString(), 28 | }, 29 | body: blob, 30 | }); 31 | 32 | if (saveResult.ok) messageStore.addSuccess('Save Successful'); 33 | else messageStore.addError('Save Failed', 'Network response not OK'); 34 | } catch (error) { 35 | messageStore.addError('Save Failed with error', `Failed from: ${error}`); 36 | } finally { 37 | isSaving.value = false; 38 | } 39 | }; 40 | 41 | return { 42 | saveUrl, 43 | setSaveUrl, 44 | isSaving, 45 | saveState, 46 | }; 47 | }); 48 | 49 | export default useRemoteSaveStateStore; 50 | -------------------------------------------------------------------------------- /src/store/server.ts: -------------------------------------------------------------------------------- 1 | import RpcClient from '@/src/core/remote/client'; 2 | import { StoreApi } from '@/src/core/remote/storeApi'; 3 | import { defineStore } from 'pinia'; 4 | import { markRaw, ref } from 'vue'; 5 | 6 | const { VITE_REMOTE_SERVER_URL } = import.meta.env; 7 | 8 | export enum ConnectionState { 9 | Disconnected, 10 | Pending, 11 | Connected, 12 | } 13 | 14 | export const useServerStore = defineStore('server', () => { 15 | const url = ref(VITE_REMOTE_SERVER_URL ?? ''); 16 | const connState = ref(ConnectionState.Disconnected); 17 | 18 | const client = new RpcClient(StoreApi); 19 | 20 | client.socket.on('disconnect', () => { 21 | connState.value = ConnectionState.Disconnected; 22 | }); 23 | 24 | async function connect() { 25 | if (!url.value) { 26 | return; 27 | } 28 | 29 | connState.value = ConnectionState.Pending; 30 | try { 31 | await client.connect(url.value); 32 | connState.value = ConnectionState.Connected; 33 | } catch (err) { 34 | connState.value = ConnectionState.Disconnected; 35 | } 36 | } 37 | 38 | async function disconnect() { 39 | await client.disconnect(); 40 | } 41 | 42 | function setUrl(newUrl: string) { 43 | url.value = newUrl; 44 | disconnect(); 45 | } 46 | 47 | return { 48 | url, 49 | client: markRaw(client), 50 | connState, 51 | connect, 52 | disconnect, 53 | setUrl, 54 | }; 55 | }); 56 | -------------------------------------------------------------------------------- /src/store/tools/rectangles.ts: -------------------------------------------------------------------------------- 1 | import { defineAnnotationToolStore } from '@/src/utils/defineAnnotationToolStore'; 2 | import type { Vector3 } from '@kitware/vtk.js/types'; 3 | import { Manifest, StateFile } from '@/src/io/state-file/schema'; 4 | import { RECTANGLE_LABEL_DEFAULTS } from '@/src/config'; 5 | import { ToolID } from '@/src/types/annotation-tool'; 6 | 7 | import { useAnnotationTool } from './useAnnotationTool'; 8 | 9 | const rectangleDefaults = () => ({ 10 | firstPoint: [0, 0, 0] as Vector3, 11 | secondPoint: [0, 0, 0] as Vector3, 12 | id: '' as ToolID, 13 | name: 'Rectangle', 14 | fillColor: 'transparent', 15 | }); 16 | 17 | const newLabelDefault = { 18 | fillColor: 'transparent', 19 | }; 20 | 21 | export const useRectangleStore = defineAnnotationToolStore('rectangles', () => { 22 | const toolAPI = useAnnotationTool({ 23 | toolDefaults: rectangleDefaults, 24 | initialLabels: RECTANGLE_LABEL_DEFAULTS, 25 | newLabelDefault, 26 | }); 27 | 28 | function getPoints(id: ToolID) { 29 | const tool = toolAPI.toolByID.value[id]; 30 | return [tool.firstPoint, tool.secondPoint]; 31 | } 32 | 33 | // --- serialization --- // 34 | 35 | function serialize(state: StateFile) { 36 | state.manifest.tools.rectangles = toolAPI.serializeTools(); 37 | } 38 | 39 | function deserialize(manifest: Manifest, dataIDMap: Record) { 40 | toolAPI.deserializeTools(manifest.tools.rectangles, dataIDMap); 41 | } 42 | 43 | return { 44 | ...toolAPI, 45 | getPoints, 46 | serialize, 47 | deserialize, 48 | }; 49 | }); 50 | -------------------------------------------------------------------------------- /src/store/tools/types.ts: -------------------------------------------------------------------------------- 1 | import type { Manifest, StateFile } from '@/src/io/state-file/schema'; 2 | import { Store } from 'pinia'; 3 | 4 | export enum AnnotationToolType { 5 | Rectangle = 'Rectangle', 6 | Ruler = 'Ruler', 7 | Polygon = 'Polygon', 8 | } 9 | 10 | export enum Tools { 11 | WindowLevel = 'WindowLevel', 12 | Pan = 'Pan', 13 | Zoom = 'Zoom', 14 | Crop = 'Crop', 15 | Paint = 'Paint', 16 | Select = 'Select', 17 | Crosshairs = 'Crosshairs', 18 | Rectangle = 'Rectangle', 19 | Ruler = 'Ruler', 20 | Polygon = 'Polygon', 21 | } 22 | 23 | export interface IActivatableTool { 24 | activateTool: () => boolean; 25 | deactivateTool: () => void; 26 | } 27 | 28 | export interface ISerializableTool { 29 | serialize: (state: StateFile) => void; 30 | deserialize: (manifest: Manifest, dataIDMap: Record) => void; 31 | } 32 | 33 | export interface IToolStore 34 | extends Partial, 35 | Partial, 36 | Store {} 37 | -------------------------------------------------------------------------------- /src/store/view-configs/common.ts: -------------------------------------------------------------------------------- 1 | import { DoubleKeyRecord } from '@/src/utils/doubleKeyRecord'; 2 | import { StateFile, ViewConfig } from '../../io/state-file/schema'; 3 | import { ensureDefault } from '../../utils'; 4 | 5 | type ViewConfigStateKey = keyof ViewConfig; 6 | 7 | const serializeViewConfig = < 8 | K extends ViewConfigStateKey, 9 | V extends ViewConfig[K] 10 | >( 11 | stateFile: StateFile, 12 | viewConfigs: DoubleKeyRecord, 13 | viewConfigStateKey: K 14 | ) => { 15 | const dataIDs = stateFile.manifest.datasets.map((dataset) => dataset.id); 16 | const { views } = stateFile.manifest; 17 | 18 | views.forEach((view) => { 19 | dataIDs.forEach((dataID) => { 20 | const { config } = view; 21 | 22 | const viewConfig = viewConfigs[view.id]?.[dataID]; 23 | if (viewConfig !== undefined) { 24 | const configForData = ensureDefault(dataID, config, {} as ViewConfig); 25 | 26 | configForData[viewConfigStateKey] = viewConfig as ViewConfig[K]; 27 | } 28 | }); 29 | }); 30 | }; 31 | 32 | /** 33 | * @param viewConfigs Expected to be a DoubleKeyRecord. Index is ordered as (ViewID, DataID) 34 | * @param viewConfigStateKey 35 | * @returns 36 | */ 37 | export const createViewConfigSerializer = < 38 | K extends ViewConfigStateKey, 39 | V extends ViewConfig[K] 40 | >( 41 | viewConfigs: DoubleKeyRecord, 42 | viewConfigStateKey: K 43 | ) => { 44 | return (stateFile: StateFile) => { 45 | serializeViewConfig(stateFile, viewConfigs, viewConfigStateKey); 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /src/store/view-configs/types.ts: -------------------------------------------------------------------------------- 1 | import type { Vector3 } from '@kitware/vtk.js/types'; 2 | import { LPSAxisDir } from '@/src/types/lps'; 3 | import { 4 | ColorTransferFunction, 5 | CVRConfig, 6 | BlendConfig, 7 | OpacityFunction, 8 | } from '@/src/types/views'; 9 | import { WLAutoRanges } from '@/src/constants'; 10 | 11 | export interface CameraConfig { 12 | parallelScale?: number; 13 | position?: Vector3; 14 | focalPoint?: Vector3; 15 | directionOfProjection?: Vector3; 16 | viewUp?: Vector3; 17 | syncState?: boolean; 18 | } 19 | 20 | export interface SliceConfig { 21 | slice: number; 22 | min: number; 23 | max: number; 24 | axisDirection: LPSAxisDir; 25 | syncState: boolean; 26 | } 27 | 28 | export interface VolumeColorConfig { 29 | colorBy: { 30 | arrayName: string; 31 | location: string; 32 | }; 33 | transferFunction: ColorTransferFunction; 34 | opacityFunction: OpacityFunction; 35 | cvr: CVRConfig; 36 | } 37 | 38 | export interface WindowLevelConfig { 39 | width?: number; 40 | level?: number; 41 | auto: keyof typeof WLAutoRanges; // User-selected percentile range 42 | useAuto?: boolean; // Whether to use the percentage histogram range 43 | userTriggered?: boolean; // Whether the user has changed the window/level 44 | } 45 | 46 | export interface LayersConfig { 47 | colorBy: { 48 | arrayName: string; 49 | location: string; 50 | }; 51 | transferFunction: ColorTransferFunction; 52 | opacityFunction: OpacityFunction; 53 | blendConfig: BlendConfig; 54 | } 55 | 56 | export interface SegmentGroupConfig { 57 | outlineOpacity: number; 58 | outlineThickness: number; 59 | } 60 | -------------------------------------------------------------------------------- /src/types/annotation-tool.ts: -------------------------------------------------------------------------------- 1 | import { FrameOfReference } from '../utils/frameOfReference'; 2 | 3 | export type ToolID = string & { __type: 'ToolID' }; 4 | 5 | export type AnnotationTool = { 6 | id: ToolID; 7 | /** 8 | * The associated image dataset. 9 | * 10 | * The tool currently does not store orientation info, 11 | * and so depends on the associated image space. 12 | */ 13 | imageID: string; 14 | slice: number; 15 | frameOfReference: FrameOfReference; 16 | 17 | /** 18 | * Is this tool unfinished? 19 | */ 20 | placing?: boolean; 21 | 22 | label?: string; 23 | labelName?: string; 24 | 25 | color: string; 26 | strokeWidth?: number; 27 | 28 | name: string; 29 | 30 | hidden?: boolean; 31 | }; 32 | -------------------------------------------------------------------------------- /src/types/crop.ts: -------------------------------------------------------------------------------- 1 | export type LPSCroppingPlanes = { 2 | Sagittal: [number, number]; 3 | Coronal: [number, number]; 4 | Axial: [number, number]; 5 | }; 6 | -------------------------------------------------------------------------------- /src/types/image.ts: -------------------------------------------------------------------------------- 1 | import { mat3, mat4, vec3 } from 'gl-matrix'; 2 | import type { Bounds } from '@kitware/vtk.js/types'; 3 | import { LPSDirections } from './lps'; 4 | 5 | export interface ImageMetadata { 6 | name: string; 7 | orientation: mat3; 8 | lpsOrientation: LPSDirections; 9 | spacing: vec3; 10 | origin: vec3; 11 | dimensions: vec3; 12 | worldBounds: Bounds; 13 | worldToIndex: mat4; 14 | indexToWorld: mat4; 15 | } 16 | -------------------------------------------------------------------------------- /src/types/layout.ts: -------------------------------------------------------------------------------- 1 | export enum LayoutDirection { 2 | V = 'V', 3 | H = 'H', 4 | } 5 | 6 | export type Layout = { 7 | direction: LayoutDirection; 8 | items: ReadonlyArray; 9 | name?: string; 10 | }; 11 | -------------------------------------------------------------------------------- /src/types/lps.ts: -------------------------------------------------------------------------------- 1 | import type { Vector2 } from '@kitware/vtk.js/types'; 2 | import { vec3 } from 'gl-matrix'; 3 | 4 | export type LPSAxis = 'Axial' | 'Sagittal' | 'Coronal'; 5 | 6 | export type LPSAxisDir = 7 | | 'Left' 8 | | 'Right' 9 | | 'Posterior' 10 | | 'Anterior' 11 | | 'Superior' 12 | | 'Inferior'; 13 | 14 | export interface LPSDirections { 15 | // Maps LPS direction to world-space direction (not index-space direction) 16 | // These should match columns of the current image orientation matrix. 17 | Left: vec3; 18 | Right: vec3; 19 | Posterior: vec3; 20 | Anterior: vec3; 21 | Superior: vec3; 22 | Inferior: vec3; 23 | 24 | // maps LPS axis to column in direction matrix 25 | Coronal: 0 | 1 | 2; 26 | Sagittal: 0 | 1 | 2; 27 | Axial: 0 | 1 | 2; 28 | } 29 | 30 | export interface LPSPoint { 31 | Sagittal: number; 32 | Coronal: number; 33 | Axial: number; 34 | } 35 | 36 | export interface LPSBounds { 37 | Sagittal: Vector2; 38 | Coronal: Vector2; 39 | Axial: Vector2; 40 | } 41 | -------------------------------------------------------------------------------- /src/types/polygon.ts: -------------------------------------------------------------------------------- 1 | import type { Vector3 } from '@kitware/vtk.js/types'; 2 | import { AnnotationTool } from './annotation-tool'; 3 | 4 | export type Polygon = { 5 | /** 6 | * Points is in image index space. 7 | */ 8 | points: Array; 9 | } & AnnotationTool; 10 | -------------------------------------------------------------------------------- /src/types/rectangle.ts: -------------------------------------------------------------------------------- 1 | import { Ruler } from './ruler'; 2 | 3 | export type Rectangle = Ruler & { 4 | fillColor: string; 5 | }; 6 | -------------------------------------------------------------------------------- /src/types/ruler.ts: -------------------------------------------------------------------------------- 1 | import type { Vector3 } from '@kitware/vtk.js/types'; 2 | import { AnnotationTool } from './annotation-tool'; 3 | 4 | export type Ruler = { 5 | /** 6 | * Point is in image index space. 7 | */ 8 | firstPoint: Vector3; 9 | /** 10 | * Point is in image index space. 11 | */ 12 | secondPoint: Vector3; 13 | } & AnnotationTool; 14 | -------------------------------------------------------------------------------- /src/types/segment.ts: -------------------------------------------------------------------------------- 1 | import { RGBAColor } from '@kitware/vtk.js/types'; 2 | 3 | export interface SegmentMask { 4 | value: number; 5 | name: string; 6 | color: RGBAColor; 7 | visible: boolean; 8 | } 9 | -------------------------------------------------------------------------------- /src/types/vtk-types.ts: -------------------------------------------------------------------------------- 1 | import { vtkAlgorithm, vtkObject } from '@kitware/vtk.js/interfaces'; 2 | import vtkDataSet from '@kitware/vtk.js/Common/DataModel/DataSet'; 3 | import { View } from '@/src/core/vtk/types'; 4 | import vtkInteractorStyle from '@kitware/vtk.js/Rendering/Core/InteractorStyle'; 5 | 6 | export interface vtkClass { 7 | newInstance: () => vtkObject; 8 | extend: (publiAPI: any, model: any) => void; 9 | } 10 | 11 | export interface vtkReader extends vtkObject, vtkAlgorithm { 12 | parseAsArrayBuffer: (ab: ArrayBufferLike) => void; 13 | parseAsText: (text: string) => void; 14 | } 15 | 16 | export interface vtkWriter extends vtkObject { 17 | write: (data: vtkDataSet) => any; 18 | } 19 | 20 | export interface VtkViewApi extends View { 21 | interactorStyle?: vtkInteractorStyle; 22 | resetCamera(): void; 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/__tests__/allocateImageFromChunks.spec.ts: -------------------------------------------------------------------------------- 1 | import { getTypedArrayForDataRange } from '@/src/utils/allocateImageFromChunks'; 2 | import { describe, it, expect } from 'vitest'; 3 | 4 | describe('getTypedArrayForDataRange', () => { 5 | it('should handle edge cases', () => { 6 | expect(getTypedArrayForDataRange(-(2 ** 7), 2 ** 7 - 1)).toBe(Int8Array); 7 | expect(getTypedArrayForDataRange(-(2 ** 15), 2 ** 15 - 1)).toBe(Int16Array); 8 | expect(getTypedArrayForDataRange(-(2 ** 31), 2 ** 31 - 1)).toBe(Int32Array); 9 | expect(getTypedArrayForDataRange(0, 2 ** 8 - 1)).toBe(Uint8Array); 10 | expect(getTypedArrayForDataRange(0, 2 ** 16 - 1)).toBe(Uint16Array); 11 | expect(getTypedArrayForDataRange(0, 2 ** 32 - 1)).toBe(Uint32Array); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/utils/__tests__/asyncSelect.spec.ts: -------------------------------------------------------------------------------- 1 | import { asyncSelect } from '@/src/utils/asyncSelect'; 2 | import { it, describe, expect } from 'vitest'; 3 | 4 | function sleep(ms: number) { 5 | return new Promise((resolve) => { 6 | setTimeout(resolve, ms); 7 | }); 8 | } 9 | 10 | describe('asyncSelect', () => { 11 | it('should act similar to Promise.race()', async () => { 12 | const promises = [sleep(11), sleep(1), sleep(111)]; 13 | const { promise, index } = await asyncSelect(promises); 14 | await expect(promise).toEqual(promises[1]); 15 | expect(index).to.equal(1); 16 | }); 17 | 18 | it('should return the rest of the unselected promises', async () => { 19 | const promises = [sleep(1), sleep(11), sleep(111)]; 20 | const { rest } = await asyncSelect(promises); 21 | expect(rest).to.deep.equal(promises.slice(1)); 22 | }); 23 | 24 | it('should handle rejected promises', async () => { 25 | const promises = [ 26 | sleep(11), 27 | sleep(1), 28 | sleep(111), 29 | new Promise((resolve, reject) => { 30 | reject(new Error('Error')); 31 | }), 32 | ]; 33 | const { promise, index } = await asyncSelect(promises); 34 | await expect(promise).rejects.toBeInstanceOf(Error); 35 | expect(index).to.equal(3); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/utils/__tests__/parseContentRangeHeader.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseContentRangeHeader } from '@/src/utils/parseContentRangeHeader'; 2 | import { describe, expect, it } from 'vitest'; 3 | 4 | describe('parseContentRangeHeader', () => { 5 | it('should handle valid ranges', () => { 6 | let range = parseContentRangeHeader('bytes 0-1/123'); 7 | expect(range.type).toEqual('range'); 8 | if (range.type !== 'range') return; // ts can't narrow on expect() 9 | 10 | expect(range.start).toEqual(0); 11 | expect(range.end).toEqual(1); 12 | expect(range.length).toEqual(123); 13 | 14 | range = parseContentRangeHeader('bytes 2-5/*'); 15 | expect(range.type).toEqual('range'); 16 | if (range.type !== 'range') return; // ts can't narrow on expect() 17 | 18 | expect(range.start).toEqual(2); 19 | expect(range.end).toEqual(5); 20 | expect(range.length).to.be.null; 21 | }); 22 | 23 | it('should handle unsatisfied ranges', () => { 24 | const range = parseContentRangeHeader('bytes */12'); 25 | expect(range.type).toEqual('unsatisfied-range'); 26 | if (range.type !== 'unsatisfied-range') return; // ts can't narrow on expect() 27 | 28 | expect(range.length).toEqual(12); 29 | }); 30 | 31 | it('should handle invalid ranges', () => { 32 | [ 33 | '', 34 | 'bytes', 35 | 'bytes */*', 36 | 'byte 0-1/2', 37 | 'bytes 1-0/2', 38 | 'bytes 0-1/1', 39 | 'bytes 1-3/2', 40 | 'bytes 1-/2', 41 | 'bytes -1/2', 42 | ].forEach((range) => { 43 | expect(parseContentRangeHeader(range).type).toEqual('invalid-range'); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/utils/__tests__/url.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseUrl } from '@/src/utils/url'; 2 | import { describe, expect, it } from 'vitest'; 3 | 4 | describe('utils/url', () => { 5 | describe('parseUrl', () => { 6 | it('should parse a URL', () => { 7 | expect(parseUrl('https://example.com/path').pathname).to.equal('/path'); 8 | expect(parseUrl('gs://bucket/').protocol).to.equal('gs:'); 9 | expect(parseUrl('gs://bucket/path/object').pathname).to.equal( 10 | '/path/object' 11 | ); 12 | expect(parseUrl('path/object', 'gs://bucket').protocol).to.equal('gs:'); 13 | }); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/utils/asyncSelect.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The same as Promise.race(), but returns richer promise information. 3 | * 4 | * Return object structure: 5 | * - promise: the settled promise 6 | * - index: the index of the settled promise 7 | * - rest: the rest of the unselected promises 8 | * @param promises 9 | * @returns 10 | */ 11 | export function asyncSelect(promises: Promise[]) { 12 | return Promise.race( 13 | promises.map((p, i) => { 14 | const info = { promise: p, index: i }; 15 | return p.catch(() => {}).then(() => info); 16 | }) 17 | ).then(({ promise, index }) => { 18 | const rest = [...promises]; 19 | rest.splice(index, 1); 20 | 21 | return { promise, index, rest }; 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/batchForNextTask.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Batches a function for the next JS task. 3 | * 4 | * Returns a function that wraps the given callback. 5 | * @param fn 6 | * @returns 7 | */ 8 | export function batchForNextTask void>(fn: T) { 9 | let timeout: NodeJS.Timeout | null = null; 10 | const wrapper = ((...args: any[]) => { 11 | if (timeout != null) return; 12 | timeout = setTimeout(() => { 13 | timeout = null; 14 | fn(...args); 15 | }, 0); 16 | }) as T; 17 | return wrapper; 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/color.ts: -------------------------------------------------------------------------------- 1 | import type { RGBAColor } from '@kitware/vtk.js/types'; 2 | 3 | /** 4 | * Converts an RGBA tuple to a hex string with alpha. 5 | * 6 | * Adds the prefix '#'. 7 | * 8 | * @param rgba a 4-tuple with components ranging from 0-255. 9 | */ 10 | export function rgbaToHexa(rgba: RGBAColor) { 11 | const hexa = rgba.map((comp) => `0${comp.toString(16)}`.slice(-2)); 12 | return `#${hexa.join('')}`; 13 | } 14 | 15 | /** 16 | * Parses a hex color with optional alpha channel. 17 | * 18 | * Returns an RGBA array with components scaled to [0,255]. 19 | * 20 | * @param hexa a hexa color in the format "[#]RRGGBB[AA]" 21 | */ 22 | export function hexaToRGBA(hexa: string): RGBAColor { 23 | const values = hexa.startsWith('#') ? hexa.substring(1) : hexa; 24 | const rgba: RGBAColor = [0, 0, 0, 255]; 25 | const length = Math.min(4, values.length / 2); 26 | for (let i = 0; i < length; i++) { 27 | rgba[i] = Number.parseInt(values.substring(2 * i, 2 * i + 2), 16); 28 | } 29 | return rgba; 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/dataSelection.ts: -------------------------------------------------------------------------------- 1 | import { getDisplayName, useDICOMStore } from '@/src/store/datasets-dicom'; 2 | import { useImageCacheStore } from '@/src/store/image-cache'; 3 | import { Maybe } from '@/src/types'; 4 | 5 | export type DataSelection = string; 6 | 7 | export const selectionEquals = (a: DataSelection, b: DataSelection) => a === b; 8 | 9 | export const isDicomImage = (imageID: Maybe) => { 10 | if (!imageID) return false; 11 | const store = useDICOMStore(); 12 | return imageID in store.volumeInfo; 13 | }; 14 | 15 | export const isRegularImage = (imageID: Maybe) => { 16 | if (!imageID) return false; 17 | return !isDicomImage(imageID); 18 | }; 19 | 20 | export const getImage = (imageID: string) => { 21 | return useImageCacheStore().getVtkImageData(imageID); 22 | }; 23 | 24 | const getImageName = (imageID: string) => { 25 | return useImageCacheStore().getImageMetadata(imageID)?.name ?? null; 26 | }; 27 | 28 | export const getSelectionName = (selection: string) => { 29 | if (isDicomImage(selection)) { 30 | return getDisplayName(useDICOMStore().volumeInfo[selection]); 31 | } 32 | return getImageName(selection); 33 | }; 34 | -------------------------------------------------------------------------------- /src/utils/defineAnnotationToolStore.ts: -------------------------------------------------------------------------------- 1 | import { AnnotationToolAPI } from '@/src/store/tools/useAnnotationTool'; 2 | import { AnnotationTool } from '@/src/types/annotation-tool'; 3 | import { defineStore } from 'pinia'; 4 | 5 | /** 6 | * Type helper for enforcing the typing for annotation tool stores. 7 | * 8 | * Requires setup store usage rather than template usage. 9 | * @param name 10 | * @param setup 11 | * @returns 12 | */ 13 | export function defineAnnotationToolStore< 14 | T extends AnnotationTool, 15 | S extends AnnotationToolAPI 16 | >(name: string, setup: () => S) { 17 | return defineStore(name, setup); 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/doubleKeyRecord.ts: -------------------------------------------------------------------------------- 1 | import { Maybe } from '../types'; 2 | 3 | export type DoubleKeyRecord = Record>; 4 | 5 | /* eslint-disable no-param-reassign */ 6 | 7 | export function patchDoubleKeyRecord( 8 | record: DoubleKeyRecord, 9 | k1: string, 10 | k2: string, 11 | patch: Partial 12 | ) { 13 | if (!(k1 in record)) { 14 | record[k1] = {}; 15 | } 16 | 17 | // triggers shallow record[k1][k2] watchers 18 | record[k1][k2] = { 19 | ...record[k1][k2], 20 | ...patch, 21 | }; 22 | } 23 | 24 | export function deleteSecondKey(record: DoubleKeyRecord, k2: string) { 25 | Object.keys(record).forEach((k1) => { 26 | delete record[k1][k2]; 27 | }); 28 | } 29 | 30 | export function deleteFirstKey(record: DoubleKeyRecord, k1: string) { 31 | delete record[k1]; 32 | } 33 | 34 | export function deleteEntry( 35 | record: DoubleKeyRecord, 36 | k1: string, 37 | k2: string 38 | ) { 39 | if (record[k1]) { 40 | delete record[k1][k2]; 41 | } 42 | } 43 | 44 | /* eslint-enable no-param-reassign */ 45 | 46 | export function getDoubleKeyRecord( 47 | record: DoubleKeyRecord, 48 | k1: Maybe, 49 | k2: Maybe 50 | ): Maybe { 51 | if (k1 == null || k2 == null) return null; 52 | return record[k1]?.[k2]; 53 | } 54 | -------------------------------------------------------------------------------- /src/utils/errorReporting.ts: -------------------------------------------------------------------------------- 1 | import { getGPUInfo } from '@/src/utils/gpuInfo'; 2 | import * as Sentry from '@sentry/vue'; 3 | import { useLocalStorage } from '@vueuse/core'; 4 | import { defineStore } from 'pinia'; 5 | import { App, ref, watch } from 'vue'; 6 | 7 | const { VITE_SENTRY_DSN } = import.meta.env; 8 | 9 | export const LOCAL_STORAGE_KEY = 'error-reporting-off'; 10 | 11 | export const errorReportingConfigured = !!VITE_SENTRY_DSN; 12 | 13 | export const init = (app: App) => { 14 | const sentryOff = localStorage.getItem(LOCAL_STORAGE_KEY); 15 | if (sentryOff !== 'true' && errorReportingConfigured) 16 | Sentry.init({ 17 | app, 18 | dsn: VITE_SENTRY_DSN, 19 | }); 20 | 21 | try { 22 | Sentry.setContext('gpu', getGPUInfo()); 23 | } catch (err) { 24 | Sentry.captureException(err); 25 | } 26 | }; 27 | 28 | const setEnabled = (enabled: boolean) => { 29 | const options = Sentry.getCurrentHub().getClient()?.getOptions(); 30 | if (!options) return; 31 | options.enabled = enabled; 32 | }; 33 | 34 | export const useErrorReporting = defineStore('error-reporting', () => { 35 | const disableReportingStorage = useLocalStorage(LOCAL_STORAGE_KEY, 'false'); 36 | 37 | const disableReporting = ref(disableReportingStorage.value === 'true'); 38 | 39 | // sync boolean to local storage 40 | watch(disableReporting, () => { 41 | disableReportingStorage.value = String(disableReporting.value); 42 | setEnabled(!disableReporting.value); 43 | }); 44 | 45 | return { disableReporting }; 46 | }); 47 | -------------------------------------------------------------------------------- /src/utils/evaluateChain.ts: -------------------------------------------------------------------------------- 1 | import { Awaitable } from '@vueuse/core'; 2 | 3 | export const Skip = Symbol('Chain:Skip'); 4 | 5 | export type ChainHandler = ( 6 | input: Input, 7 | context?: Context 8 | ) => Awaitable; 9 | 10 | export async function evaluateChain( 11 | data: Input, 12 | handlers: Array>, 13 | context?: Context 14 | ) { 15 | /* eslint-disable no-await-in-loop */ 16 | for (let i = 0; i < handlers.length; i++) { 17 | const handler = handlers[i]; 18 | const response = await handler(data, context); 19 | if (response !== Skip) { 20 | return response; 21 | } 22 | } 23 | /* eslint-enable no-await-in-loop */ 24 | 25 | throw new Error('Unhandled request'); 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/functional.ts: -------------------------------------------------------------------------------- 1 | type FlowFunction = (o: T) => T; 2 | 3 | export function flow(...fns: Array>) { 4 | return (input: T) => fns.reduce((result, fn) => fn(result), input); 5 | } 6 | 7 | // Pipe code from 8 | // https://dev.to/ecyrbe/how-to-use-advanced-typescript-to-define-a-pipe-function-381h 9 | // Changed second parameter to rest/spread argument. 10 | type AnyFunc = (...arg: any) => any; 11 | 12 | type LastFnReturnType, Else = never> = F extends [ 13 | ...any[], 14 | (...arg: any) => infer R 15 | ] 16 | ? R 17 | : Else; 18 | 19 | type PipeArgs = F extends [ 20 | (...args: infer A) => infer B 21 | ] 22 | ? [...Acc, (...args: A) => B] 23 | : F extends [(...args: infer A) => any, ...infer Tail] 24 | ? Tail extends [(arg: infer B) => any, ...any[]] 25 | ? PipeArgs B]> 26 | : Acc 27 | : Acc; 28 | 29 | // Example: 30 | // const myNumber = pipe( 31 | // "1", 32 | // (a: string) => Number(a), 33 | // (c: number) => c + 1, 34 | // (d: number) => `${d}`, 35 | // (e: string) => Number(e) 36 | // ); 37 | export function pipe( 38 | arg: Parameters[0], 39 | ...fns: PipeArgs extends F ? F : PipeArgs 40 | ): LastFnReturnType> { 41 | return (fns.slice(1) as AnyFunc[]).reduce((acc, fn) => fn(acc), fns[0](arg)); 42 | } 43 | -------------------------------------------------------------------------------- /src/utils/gpuInfo.ts: -------------------------------------------------------------------------------- 1 | function getContextFromOffscreenCanvas() { 2 | if (typeof OffscreenCanvas === 'undefined') return null; 3 | const canvas = new OffscreenCanvas(1, 1); 4 | return canvas.getContext('webgl2') as WebGL2RenderingContext | null; 5 | } 6 | 7 | function getContextFromHTMLCanvas() { 8 | if (typeof document === 'undefined') return null; 9 | const canvas = document.createElement('canvas'); 10 | return canvas.getContext('webgl2') as WebGL2RenderingContext | null; 11 | } 12 | 13 | /** 14 | * Retrieves the GPU renderer and vendor info. 15 | * @returns 16 | */ 17 | export function getGPUInfo() { 18 | // try offscreencanvas to support usage in webworkers 19 | const gl = getContextFromOffscreenCanvas() ?? getContextFromHTMLCanvas(); 20 | if (!gl) { 21 | throw new Error('Cannot get a webgl2 context'); 22 | } 23 | 24 | const info = { 25 | renderer: '', 26 | vendor: '', 27 | }; 28 | 29 | const dbg = gl.getExtension('WEBGL_debug_renderer_info'); 30 | if (dbg) { 31 | info.renderer = gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL); 32 | info.vendor = gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL); 33 | } else { 34 | info.renderer = gl.getParameter(gl.RENDERER); 35 | info.vendor = gl.getParameter(gl.VENDOR); 36 | } 37 | 38 | return info; 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/guardedWritableRef.ts: -------------------------------------------------------------------------------- 1 | import { Ref, computed } from 'vue'; 2 | 3 | export function guardedWritableRef( 4 | obj: Ref, 5 | accept: (incoming: T, current: T) => boolean 6 | ) { 7 | return computed({ 8 | get: () => obj.value, 9 | set: (v) => { 10 | if (accept(v, obj.value)) { 11 | // eslint-disable-next-line no-param-reassign 12 | obj.value = v; 13 | } 14 | }, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/hacks.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * document.exitPointerLock is undefined on iOS. 3 | * - Tested on iOS Safari 15.6.1. 4 | */ 5 | export function patchExitPointerLock() { 6 | const { exitPointerLock } = document; 7 | document.exitPointerLock = () => { 8 | try { 9 | exitPointerLock?.call(document); 10 | } catch (e) { 11 | // ignore if undefined 12 | } 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/histogram.ts: -------------------------------------------------------------------------------- 1 | import { TypedArray } from '@kitware/vtk.js/types'; 2 | 3 | export function histogram( 4 | data: number[] | TypedArray, 5 | dataRange: number[], 6 | numberOfBins: number 7 | ) { 8 | const [min, max] = dataRange; 9 | const width = (max - min + 1) / numberOfBins; 10 | if (width === 0) return []; 11 | const hist = new Array(numberOfBins).fill(0); 12 | data.forEach((value) => hist[Math.floor((value - min) / width)]++); 13 | return hist; 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/histogram.worker.ts: -------------------------------------------------------------------------------- 1 | import * as Comlink from 'comlink'; 2 | import { histogram } from '@/src/utils/histogram'; 3 | 4 | export interface HistogramWorker { 5 | histogram: typeof histogram; 6 | } 7 | 8 | Comlink.expose({ histogram }); 9 | -------------------------------------------------------------------------------- /src/utils/imageSpace.ts: -------------------------------------------------------------------------------- 1 | import { EPSILON } from '@/src/constants'; 2 | import { areEquals } from '@kitware/vtk.js/Common/Core/Math'; 3 | import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; 4 | import { Vector3 } from '@kitware/vtk.js/types'; 5 | 6 | // give more fp tolerance due to transforms 7 | const RELAXED_EPSILON = EPSILON * 1e2; 8 | 9 | function getImageWorldCorners(im: vtkImageData) { 10 | const extent = im.getExtent(); 11 | const worldCorners: Vector3[] = []; 12 | for (let i = 0; i < 2; i++) { 13 | for (let j = 0; j < 2; j++) { 14 | for (let k = 0; k < 2; k++) { 15 | worldCorners.push( 16 | im.indexToWorld([extent[i], extent[j], extent[k]]) as Vector3 17 | ); 18 | } 19 | } 20 | } 21 | return worldCorners; 22 | } 23 | 24 | /** 25 | * Determines if two images occupy the same space. 26 | * 27 | * This will produce invalid results under certain scenarios: 28 | * - image direction matrices are not invertible 29 | * @param im1 30 | * @param im2 31 | */ 32 | export function compareImageSpaces( 33 | im1: vtkImageData, 34 | im2: vtkImageData, 35 | eps = RELAXED_EPSILON 36 | ) { 37 | const corners1 = getImageWorldCorners(im1); 38 | const corners2 = getImageWorldCorners(im2); 39 | return corners1.every((p1) => corners2.some((p2) => areEquals(p1, p2, eps))); 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/loggers.ts: -------------------------------------------------------------------------------- 1 | import { NOOP } from '@/src/constants'; 2 | 3 | /** 4 | * Recursively logs error causes 5 | * @param error 6 | */ 7 | export function logError(error: unknown) { 8 | let cur = error; 9 | while (cur) { 10 | if (cur !== error) { 11 | console.error('The above error was caused by:', cur); 12 | } else { 13 | console.error(cur); 14 | } 15 | 16 | if (cur instanceof Error) { 17 | cur = cur.cause; 18 | } else { 19 | cur = null; 20 | } 21 | } 22 | } 23 | 24 | const isProd = process.env.NODE_ENV === 'production'; 25 | 26 | export const debug = { 27 | debug: isProd ? NOOP : console.debug, 28 | log: isProd ? NOOP : console.log, 29 | info: isProd ? NOOP : console.info, 30 | warn: isProd ? NOOP : console.warn, 31 | error: isProd ? NOOP : console.error, 32 | }; 33 | -------------------------------------------------------------------------------- /src/utils/manipulators.ts: -------------------------------------------------------------------------------- 1 | import type { Vector3 } from '@kitware/vtk.js/types'; 2 | import vtkPlaneManipulator from '@kitware/vtk.js/Widgets/Manipulators/PlaneManipulator'; 3 | import { vec3 } from 'gl-matrix'; 4 | import { ImageMetadata } from '../types/image'; 5 | import { LPSAxisDir } from '../types/lps'; 6 | import { getLPSAxisFromDir } from './lps'; 7 | 8 | export function updatePlaneManipulatorFor2DView( 9 | manipulator: vtkPlaneManipulator, 10 | viewDir: LPSAxisDir, 11 | slice: number, 12 | imageMetadata: ImageMetadata 13 | ) { 14 | const { lpsOrientation } = imageMetadata; 15 | const axis = lpsOrientation[getLPSAxisFromDir(viewDir)]; 16 | 17 | const normal: vec3 = lpsOrientation[viewDir]; 18 | const origin: vec3 = [0, 0, 0]; 19 | origin[axis] = slice; 20 | 21 | vec3.transformMat4(origin, origin, imageMetadata.indexToWorld); 22 | 23 | manipulator.setUserNormal(normal as Vector3); 24 | manipulator.setUserOrigin(origin as Vector3); 25 | } 26 | 27 | export function createPlaneManipulatorFor2DView( 28 | viewDir: LPSAxisDir, 29 | slice: number, 30 | imageMetadata: ImageMetadata 31 | ) { 32 | const manipulator = vtkPlaneManipulator.newInstance(); 33 | updatePlaneManipulatorFor2DView(manipulator, viewDir, slice, imageMetadata); 34 | return manipulator; 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/parseContentRangeHeader.ts: -------------------------------------------------------------------------------- 1 | const CONTENT_RANGE_REGEXP = 2 | /^bytes (?(?\d+)-(?\d+)|\*)\/(?\d+|\*)$/; 3 | 4 | export type ContentRange = 5 | | { type: 'empty-range' } 6 | | { type: 'invalid-range' } 7 | | { type: 'unsatisfied-range'; length: number } 8 | | { type: 'range'; start: number; end: number; length: number | null }; 9 | 10 | /** 11 | * Parses a Content-Range header. 12 | * 13 | * Only supports bytes ranges. 14 | * @param headerValue 15 | * @returns 16 | */ 17 | export function parseContentRangeHeader( 18 | headerValue: string | null 19 | ): ContentRange { 20 | if (headerValue == null) return { type: 'empty-range' }; 21 | if (headerValue.length === 0) return { type: 'invalid-range' }; 22 | 23 | const match = CONTENT_RANGE_REGEXP.exec(headerValue); 24 | const groups = match?.groups; 25 | if (!groups) return { type: 'invalid-range' }; 26 | 27 | const length = groups.length === '*' ? null : parseInt(groups.length, 10); 28 | 29 | if (groups.range === '*') { 30 | if (length === null) return { type: 'invalid-range' }; 31 | return { type: 'unsatisfied-range', length }; 32 | } 33 | 34 | const start = parseInt(groups.start, 10); 35 | const end = parseInt(groups.end, 10); 36 | 37 | if (end < start || (length !== null && length <= end)) 38 | return { type: 'invalid-range' }; 39 | 40 | return { type: 'range', start, end, length }; 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/path.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns the parent directory of a path. 3 | * @param path 4 | * @returns 5 | */ 6 | export function dirname(path: string) { 7 | const p = path.split(/\/+/g); 8 | p.splice(-1, 1); 9 | return p.join('/'); 10 | } 11 | 12 | /** 13 | * Returns the base name of a path. 14 | * @param path 15 | * @returns 16 | */ 17 | export function basename(path: string) { 18 | return path.split(/\/+/g).at(-1) ?? path; 19 | } 20 | 21 | /** 22 | * Normalizes a string. 23 | * 24 | * "a//b" and "a/b/" become "a/b". 25 | * @param path 26 | * @returns 27 | */ 28 | export function normalize(path: string) { 29 | return path.replace(/\/+/g, '/').replace(/\/$/, ''); 30 | } 31 | 32 | /** 33 | * Joins path segments with / and normalizes the result. 34 | * @param segments 35 | */ 36 | export function join(...segments: string[]) { 37 | return normalize(segments.join('/')); 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/promise-worker.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { defer, Deferred } from './index'; 3 | 4 | // to be used to instantiate a worker 5 | export default class PromiseWorker { 6 | private msgID: number; 7 | private worker: Worker; 8 | private waiting: Record>; 9 | 10 | constructor(worker: Worker) { 11 | this.msgID = 0; 12 | this.worker = worker; 13 | this.waiting = {}; 14 | 15 | this.worker.onmessage = this.handleMessage.bind(this); 16 | } 17 | 18 | postMessage(message: any, transferables: any[] = []) { 19 | const wrappedMsg = { 20 | id: this.msgID, 21 | message, 22 | }; 23 | 24 | const deferred = defer(); 25 | this.waiting[wrappedMsg.id] = deferred; 26 | 27 | this.worker.postMessage(wrappedMsg, transferables); 28 | 29 | this.msgID += 1; 30 | return deferred.promise; 31 | } 32 | 33 | handleMessage(ev: any) { 34 | const { id, error, message } = ev.data; 35 | if (id in this.waiting) { 36 | const deferred = this.waiting[id]; 37 | delete this.waiting[id]; 38 | if (error) { 39 | deferred.reject(new Error(error)); 40 | } else { 41 | deferred.resolve(message); 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/utils/token.ts: -------------------------------------------------------------------------------- 1 | import { setGlobalHeader } from '@/src/utils/fetch'; 2 | import vtkURLExtract from '@kitware/vtk.js/Common/Core/URLExtract'; 3 | import { UrlParams } from '@vueuse/core'; 4 | 5 | export function stripTokenFromUrl() { 6 | const params = new URLSearchParams(window.location.search); 7 | const url = new URL(window.location.toString()); 8 | params.delete('token'); 9 | url.search = `?${params.toString()}`; 10 | window.history.replaceState(null, '', url.toString()); 11 | } 12 | 13 | export function populateAuthorizationToken() { 14 | const urlParams = vtkURLExtract.extractURLParameters() as UrlParams; 15 | 16 | if (urlParams.token) { 17 | setGlobalHeader('Authorization', `Bearer ${urlParams.token}`); 18 | } 19 | 20 | if (urlParams.tokenUrl) { 21 | fetch(String(urlParams.tokenUrl), { 22 | method: String(urlParams.tokenUrlMethod || 'GET'), 23 | }) 24 | .then((response) => { 25 | if (response.status % 100 !== 2) { 26 | throw new Error('received non-200 response'); 27 | } 28 | return response.text(); 29 | }) 30 | .then((text) => { 31 | setGlobalHeader('Authorization', `Bearer ${text}`); 32 | }) 33 | .catch((err) => { 34 | console.error('error while fetching token from tokenUrl:', err); 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/workerHandler.js: -------------------------------------------------------------------------------- 1 | class WorkerHandler { 2 | constructor() { 3 | this.handler = null; 4 | onmessage = this.preHandler.bind(this); 5 | } 6 | 7 | registerHandler(func) { 8 | this.handler = func; 9 | } 10 | 11 | preHandler(ev) { 12 | if (this.handler) { 13 | const { id, message } = ev.data; 14 | 15 | let transferables = []; 16 | const setTransferables = (iter) => { 17 | transferables = Array.from(iter); 18 | }; 19 | 20 | try { 21 | Promise.resolve(this.handler(message, setTransferables)).then( 22 | (result) => { 23 | const msg = { 24 | id, 25 | message: result, 26 | }; 27 | postMessage(msg, transferables); 28 | } 29 | ); 30 | } catch (error) { 31 | const msg = { 32 | id, 33 | error: error.message, 34 | }; 35 | postMessage(msg); 36 | } 37 | } 38 | } 39 | } 40 | 41 | // To be used inside the worker 42 | export default new WorkerHandler(); 43 | -------------------------------------------------------------------------------- /src/vitest.d.ts: -------------------------------------------------------------------------------- 1 | import 'vitest'; 2 | 3 | interface CustomMatchers { 4 | toAlmostEqual: (val: any) => R; 5 | } 6 | 7 | declare module 'vitest' { 8 | interface Assertion extends CustomMatchers {} 9 | interface AsymmetricMatchersContaining extends CustomMatchers {} 10 | } 11 | -------------------------------------------------------------------------------- /src/vtk/CrosshairsWidget/index.d.ts: -------------------------------------------------------------------------------- 1 | import vtkAbstractWidget from '@kitware/vtk.js/Widgets/Core/AbstractWidget'; 2 | import vtkAbstractWidgetFactory from '@kitware/vtk.js/Widgets/Core/AbstractWidgetFactory'; 3 | import vtkPlaneManipulator from '@kitware/vtk.js/Widgets/Manipulators/PlaneManipulator'; 4 | import { mat4, vec3 } from 'gl-matrix'; 5 | import { CrosshairsWidgetState } from './state'; 6 | 7 | export interface vtkCrosshairsViewWidget extends vtkAbstractWidget { 8 | setManipulator(manipulator: vtkPlaneManipulator): boolean; 9 | getManipulator(): vtkPlaneManipulator; 10 | } 11 | 12 | export interface vtkCrosshairsWidget extends vtkAbstractWidgetFactory { 13 | getWidgetState(): CrosshairsWidgetState; 14 | getManipulator(): vtkPlaneManipulator; 15 | } 16 | 17 | export function newInstance(): vtkCrosshairsWidget; 18 | 19 | export declare const vtkCrosshairsWidget: { 20 | newInstance: typeof newInstance; 21 | }; 22 | export default vtkCrosshairsWidget; 23 | -------------------------------------------------------------------------------- /src/vtk/LabelMap/index.d.ts: -------------------------------------------------------------------------------- 1 | import { SegmentMask } from '@/src/types/segment'; 2 | import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; 3 | import type { Vector4 } from '@kitware/vtk.js/types'; 4 | 5 | export interface vtkLabelMap extends vtkImageData { 6 | /** 7 | * Sets the segments of the labelmap. 8 | * @param segments 9 | */ 10 | setSegments(segments: SegmentMask[]): boolean; 11 | 12 | /** 13 | * Gets the segments of the labelmap. 14 | */ 15 | getSegments(): SegmentMask[]; 16 | 17 | /** 18 | * Replaces a labelmap value with another value. 19 | * @param from 20 | * @param to 21 | */ 22 | replaceLabelValue(from: number, to: number): void; 23 | } 24 | 25 | export function newInstance(initialValues?: any): vtkLabelMap; 26 | 27 | export declare const vtkLabelMap: { 28 | newInstance: typeof newInstance; 29 | }; 30 | export default vtkLabelMap; 31 | -------------------------------------------------------------------------------- /src/vtk/PaintWidget/index.d.ts: -------------------------------------------------------------------------------- 1 | import vtkAbstractWidget from '@kitware/vtk.js/Widgets/Core/AbstractWidget'; 2 | import vtkAbstractWidgetFactory from '@kitware/vtk.js/Widgets/Core/AbstractWidgetFactory'; 3 | import vtkPlaneManipulator from '@kitware/vtk.js/Widgets/Manipulators/PlaneManipulator'; 4 | import { mat4, vec3 } from 'gl-matrix'; 5 | import { PaintWidgetState } from './state'; 6 | 7 | export interface vtkPaintViewWidget extends vtkAbstractWidget { 8 | setManipulator(manipulator: vtkPlaneManipulator): boolean; 9 | getManipulator(): vtkPlaneManipulator; 10 | setSlicingIndex(index: number): boolean; 11 | setIndexToWorld(transform: mat4): boolean; 12 | getIndexToWorld(): mat4; 13 | setWorldToIndex(transform: mat4): boolean; 14 | getWorldToIndex(): mat4; 15 | } 16 | 17 | export interface vtkPaintWidget extends vtkAbstractWidgetFactory { 18 | getWidgetState(): PaintWidgetState; 19 | } 20 | 21 | export function newInstance(): vtkPaintWidget; 22 | 23 | export function shouldIgnoreEvent(ev: any): boolean; 24 | 25 | export declare const vtkPaintWidget: { 26 | newInstance: typeof newInstance; 27 | }; 28 | export default vtkPaintWidget; 29 | -------------------------------------------------------------------------------- /src/vtk/PaintWidget/state.ts: -------------------------------------------------------------------------------- 1 | import { IBrushStencil } from '@/src/core/tools/paint/brush'; 2 | import type { Vector3 } from '@kitware/vtk.js/types'; 3 | import vtkStateBuilder from '@kitware/vtk.js/Widgets/Core/StateBuilder'; 4 | import vtkWidgetState from '@kitware/vtk.js/Widgets/Core/WidgetState'; 5 | 6 | export interface PaintPointWidgetState extends vtkWidgetState { 7 | setOrigin(origin: Vector3 | null): boolean; 8 | getOrigin(): Vector3 | null; 9 | setScale1(scale: number): boolean; 10 | getScale1(): number; 11 | setVisible(visible: boolean): boolean; 12 | getVisible(): boolean; 13 | setColor(color: number): boolean; 14 | getColor(): number; 15 | } 16 | 17 | export interface PaintWidgetState extends vtkWidgetState { 18 | getBrush(): PaintPointWidgetState; 19 | getStencil(): IBrushStencil; 20 | setStencil(stencil: IBrushStencil): boolean; 21 | } 22 | 23 | export default function generateState() { 24 | return vtkStateBuilder 25 | .createBuilder() 26 | .addStateFromMixin({ 27 | labels: ['brush'], 28 | name: 'brush', 29 | mixins: ['origin', 'scale1', 'visible', 'color'], 30 | initialValues: { 31 | scale1: 1, 32 | origin: null, 33 | visible: true, 34 | }, 35 | }) 36 | .addField({ 37 | name: 'stencil', 38 | initialValue: null, 39 | }) 40 | .build() as PaintWidgetState; 41 | } 42 | -------------------------------------------------------------------------------- /src/vtk/RectangleWidget/index.d.ts: -------------------------------------------------------------------------------- 1 | import { vtkSubscription } from '@kitware/vtk.js/interfaces'; 2 | import vtkAbstractWidget from '@kitware/vtk.js/Widgets/Core/AbstractWidget'; 3 | import vtkAbstractWidgetFactory from '@kitware/vtk.js/Widgets/Core/AbstractWidgetFactory'; 4 | import vtkPlaneManipulator from '@kitware/vtk.js/Widgets/Manipulators/PlaneManipulator'; 5 | import { RectangleWidgetState } from './state'; 6 | import { useRectangleStore } from '@/src/store/tools/rectangles'; 7 | import vtkRulerWidget, { 8 | IRulerWidgetInitialValues, 9 | vtkRulerViewWidget, 10 | vtkRulerWidgetPointState, 11 | } from '../RulerWidget'; 12 | 13 | export { InteractionState } from '../RulerWidget'; 14 | 15 | export interface vtkRectangleWidgetPointState 16 | extends vtkRulerWidgetPointState {} 17 | 18 | export interface vtkRectangleWidgetState extends vtkRulerWidgetState {} 19 | 20 | export interface vtkRectangleViewWidget extends vtkRulerViewWidget {} 21 | 22 | export interface IRectangleWidgetInitialValues 23 | extends IRulerWidgetInitialValues {} 24 | 25 | export interface vtkRectangleWidget extends vtkRulerWidget {} 26 | 27 | function newInstance( 28 | initialValues: IRectangleWidgetInitialValues 29 | ): vtkRectangleWidget; 30 | 31 | export declare const vtkRectangleWidget: { 32 | newInstance: typeof newInstance; 33 | }; 34 | 35 | export default vtkRectangleWidget; 36 | -------------------------------------------------------------------------------- /src/vtk/ToolWidgetUtils/annotationWidgetState.ts: -------------------------------------------------------------------------------- 1 | import { useAnnotationToolStore } from '@/src/store/tools'; 2 | import { IAnnotationToolWidgetInitialValues } from '@/src/vtk/ToolWidgetUtils/types'; 3 | import vtkWidgetState from '@kitware/vtk.js/Widgets/Core/WidgetState'; 4 | import macro from '@kitware/vtk.js/macros'; 5 | 6 | function extend( 7 | publicAPI: any, 8 | model: any, 9 | initialValues: IAnnotationToolWidgetInitialValues 10 | ) { 11 | vtkWidgetState.extend(publicAPI, model, initialValues); 12 | macro.get(publicAPI, model, ['id', 'toolType']); 13 | 14 | publicAPI.getStore = () => useAnnotationToolStore(model.toolType); 15 | } 16 | 17 | export declare const vtkAnnotationWidgetState: { 18 | extend: typeof extend; 19 | }; 20 | 21 | export default { extend }; 22 | -------------------------------------------------------------------------------- /src/vtk/ToolWidgetUtils/pointState.js: -------------------------------------------------------------------------------- 1 | import macro from '@kitware/vtk.js/macros'; 2 | import vtkWidgetState from '@kitware/vtk.js/Widgets/Core/WidgetState'; 3 | import visibleMixin from '@kitware/vtk.js/Widgets/Core/StateBuilder/visibleMixin'; 4 | import scale1Mixin from '@kitware/vtk.js/Widgets/Core/StateBuilder/scale1Mixin'; 5 | import { watchStore } from '@/src/vtk/ToolWidgetUtils/utils'; 6 | import { PICKABLE_ANNOTATION_TOOL_HANDLE_RADIUS } from '@/src/constants'; 7 | import { toRaw } from 'vue'; 8 | 9 | const DIAMETER = PICKABLE_ANNOTATION_TOOL_HANDLE_RADIUS * 2; 10 | 11 | function _createPointState( 12 | publicAPI, 13 | model, 14 | { id, store, key, visible = true } 15 | ) { 16 | Object.assign(model, { 17 | id, 18 | _store: store, 19 | key, 20 | }); 21 | vtkWidgetState.extend(publicAPI, model, {}); 22 | visibleMixin.extend(publicAPI, model, { visible }); 23 | scale1Mixin.extend(publicAPI, model, { scale1: DIAMETER }); 24 | 25 | const getTool = () => { 26 | return model._store.toolByID[model.id]; 27 | }; 28 | 29 | const updateTool = (patch) => model._store.updateTool(model.id, patch); 30 | 31 | publicAPI.getOrigin = () => { 32 | return toRaw(getTool()?.[model.key]); 33 | }; 34 | 35 | publicAPI.setOrigin = (xyz) => { 36 | updateTool({ 37 | [model.key]: xyz, 38 | }); 39 | publicAPI.modified(); 40 | }; 41 | 42 | watchStore(publicAPI, model._store, () => getTool()?.[model.key]); 43 | } 44 | 45 | const createPointState = macro.newInstance( 46 | _createPointState, 47 | 'vtkPointWidgetState' 48 | ); 49 | 50 | export default createPointState; 51 | -------------------------------------------------------------------------------- /src/vtk/ToolWidgetUtils/types.ts: -------------------------------------------------------------------------------- 1 | import { VTKEventHandler } from '@/src/composables/onVTKEvent'; 2 | import { AnnotationToolType } from '@/src/store/tools/types'; 3 | import { ToolID } from '@/src/types/annotation-tool'; 4 | import vtkAbstractWidget from '@kitware/vtk.js/Widgets/Core/AbstractWidget'; 5 | import vtkWidgetState from '@kitware/vtk.js/Widgets/Core/WidgetState'; 6 | import vtkPlaneManipulator from '@kitware/vtk.js/Widgets/Manipulators/PlaneManipulator'; 7 | import { vtkSubscription } from '@kitware/vtk.js/interfaces'; 8 | import type { Vector2, Vector3 } from '@kitware/vtk.js/types'; 9 | 10 | export type WidgetAction = { 11 | name: string; 12 | func: () => void; 13 | }; 14 | 15 | export type ContextMenuEvent = { 16 | displayXY: Vector2; 17 | widgetActions: Array; 18 | }; 19 | 20 | export interface vtkAnnotationWidgetPointState extends vtkWidgetState { 21 | getVisible(): boolean; 22 | getOrigin(): Vector3 | null; 23 | } 24 | 25 | export interface vtkAnnotationWidgetState extends vtkWidgetState { 26 | getId(): ToolID; 27 | getToolType(): AnnotationToolType; 28 | } 29 | 30 | export interface IAnnotationToolWidgetInitialValues { 31 | id: ToolID; 32 | } 33 | 34 | export interface vtkAnnotationToolWidget extends vtkAbstractWidget { 35 | setManipulator(manipulator: vtkPlaneManipulator): boolean; 36 | getManipulator(): vtkPlaneManipulator; 37 | onRightClickEvent(cb: VTKEventHandler): vtkSubscription; 38 | onPlacedEvent(cb: VTKEventHandler): vtkSubscription; 39 | onHoverEvent(cb: VTKEventHandler): vtkSubscription; 40 | resetInteractions(): void; 41 | getWidgetState(): vtkAnnotationWidgetState; 42 | } 43 | -------------------------------------------------------------------------------- /src/vtk/ToolWidgetUtils/utils.ts: -------------------------------------------------------------------------------- 1 | import { Maybe } from '@/src/types'; 2 | import { Store } from 'pinia'; 3 | 4 | export function watchStore( 5 | publicAPI: any, 6 | store: Store, 7 | getter: () => Maybe, 8 | cmp: (a: Maybe, b: Maybe) => boolean 9 | ) { 10 | let cached = getter(); 11 | const unsubscribe = store.$subscribe(() => { 12 | const val = getter(); 13 | if (cmp ? cmp(cached, val) : cached !== val) { 14 | cached = val; 15 | publicAPI.modified(); 16 | } 17 | }); 18 | 19 | const originalDelete = publicAPI.delete; 20 | publicAPI.delete = () => { 21 | unsubscribe(); 22 | originalDelete(); 23 | }; 24 | } 25 | 26 | export function watchState( 27 | publicAPI: any, 28 | state: any, 29 | callback: () => unknown 30 | ) { 31 | let subscription = state.onModified(callback); 32 | const originalDelete = publicAPI.delete; 33 | publicAPI.delete = () => { 34 | subscription.unsubscribe(); 35 | subscription = null; 36 | originalDelete(); 37 | }; 38 | } 39 | 40 | export const computeWorldCoords = (model: any) => (event: any) => { 41 | const manipulator = 42 | model.activeState?.getManipulator?.() ?? model.manipulator; 43 | if (!manipulator) { 44 | console.error('No manipulator'); 45 | return undefined; 46 | } 47 | const { worldCoords } = manipulator.handleEvent( 48 | event, 49 | model._apiSpecificRenderWindow 50 | ); 51 | if (!worldCoords) 52 | console.warn('Event cannot be converted to world coordinates'); 53 | return worldCoords; 54 | }; 55 | -------------------------------------------------------------------------------- /src/vtk/webvr-empty.js: -------------------------------------------------------------------------------- 1 | export default function () {} 2 | -------------------------------------------------------------------------------- /tests/baseline/prostate_sample_views-chrome-linux-1200x800-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/tests/baseline/prostate_sample_views-chrome-linux-1200x800-1.png -------------------------------------------------------------------------------- /tests/baseline/prostate_sample_views-chrome-mac_os_x-1200x800-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/tests/baseline/prostate_sample_views-chrome-mac_os_x-1200x800-1.png -------------------------------------------------------------------------------- /tests/baseline/prostate_sample_views-chrome-windows-1200x800-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kitware/VolView/6c6329ad92c7eacfc7788952cd5829c15b329341/tests/baseline/prostate_sample_views-chrome-windows-1200x800-1.png -------------------------------------------------------------------------------- /tests/browserTestUtils.ts: -------------------------------------------------------------------------------- 1 | export function makeEmptyFile(name: string) { 2 | return new File([], name); 3 | } 4 | 5 | export function makeDicomFile(name: string) { 6 | const buffer = new Uint8Array(132); 7 | buffer[128] = 'D'.charCodeAt(0); 8 | buffer[129] = 'I'.charCodeAt(0); 9 | buffer[130] = 'C'.charCodeAt(0); 10 | buffer[131] = 'M'.charCodeAt(0); 11 | return new File([buffer.buffer], name); 12 | } 13 | -------------------------------------------------------------------------------- /tests/e2eTestUtils.ts: -------------------------------------------------------------------------------- 1 | import { dirname } from 'path'; 2 | import { fileURLToPath } from 'url'; 3 | 4 | export function projectRoot() { 5 | return dirname(dirname(fileURLToPath(import.meta.url))); 6 | } 7 | -------------------------------------------------------------------------------- /tests/pageobjects/page.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * main page object containing all methods, selectors and functionality 3 | * that is shared across all page objects 4 | */ 5 | export default class Page { 6 | /** 7 | * Opens a sub page of the page 8 | * @param path path of the sub page (e.g. /path/to/page.html) 9 | */ 10 | public open(path: string = '/') { 11 | return browser.url(path); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/setupVitest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'vitest'; 2 | 3 | const EPSILON = 1e-6; 4 | 5 | function numAlmostEqual(a: number, b: number) { 6 | return Math.abs(a - b) <= EPSILON; 7 | } 8 | 9 | function pass(received: any, expected: any) { 10 | if (typeof received === 'number' && typeof expected === 'number') { 11 | return numAlmostEqual(received, expected); 12 | } 13 | if (Array.isArray(received) && Array.isArray(expected)) { 14 | return ( 15 | received.length === expected.length && 16 | received.every((val, idx) => numAlmostEqual(val, expected[idx])) 17 | ); 18 | } 19 | throw new Error('toAlmostEqual does not support given types'); 20 | } 21 | 22 | expect.extend({ 23 | toAlmostEqual(received, expected) { 24 | const { isNot } = this; 25 | return { 26 | pass: pass(received, expected), 27 | message: () => 28 | `${received} is${isNot ? ' not' : ''} almost equal to ${expected}`, 29 | }; 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /tests/specs/remote-manifest.e2e.ts: -------------------------------------------------------------------------------- 1 | import { volViewPage } from '../pageobjects/volview.page'; 2 | import { downloadFile, writeManifestToFile, openVolViewPage } from './utils'; 3 | 4 | describe('VolView loading of remoteManifest.json', () => { 5 | it('should show error when there is no name and URL is malformed', async () => { 6 | const manifest = { 7 | resources: [{ url: 'foo' }], 8 | }; 9 | const fileName = 'remoteFilesBadUrl.json'; 10 | await writeManifestToFile(manifest, fileName); 11 | await openVolViewPage(fileName); 12 | 13 | await volViewPage.waitForNotification(); 14 | }); 15 | 16 | it('should load relative URI with no name property', async () => { 17 | const dicom = '1-001.dcm'; 18 | await downloadFile( 19 | 'https://data.kitware.com/api/v1/file/655d42a694ef39bf0a4a8bb3/download', 20 | dicom 21 | ); 22 | 23 | const manifest = { 24 | resources: [{ url: `/tmp/${dicom}` }], 25 | }; 26 | const fileName = 'remoteFilesRelativeURI.json'; 27 | await writeManifestToFile(manifest, fileName); 28 | await openVolViewPage(fileName); 29 | await volViewPage.waitForViews(); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /tests/specs/sample-rendering.e2e.ts: -------------------------------------------------------------------------------- 1 | import AppPage from '../pageobjects/volview.page'; 2 | 3 | // handle pixel jitter in 3D view 4 | const THRESHOLD = 10; // percent 5 | 6 | describe('VolView', () => { 7 | it('should load and render a sample dataset', async () => { 8 | await AppPage.open(); 9 | await AppPage.downloadProstateSample(); 10 | await AppPage.waitForViews(); 11 | await browser.pause(5000); 12 | 13 | await expect( 14 | await browser.checkElement( 15 | await $('div[data-testid~="layout-grid"]'), 16 | 'prostate_sample_views' 17 | ) 18 | ).toBeLessThan(THRESHOLD); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "target": "es2019", 5 | "module": "esnext", 6 | "strict": true, 7 | "jsx": "preserve", 8 | "importHelpers": true, 9 | "moduleResolution": "node", 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "sourceMap": true, 15 | "baseUrl": ".", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "types": [ 19 | "node", 20 | "@wdio/globals/types", 21 | "expect-webdriverio", 22 | "@wdio/mocha-framework", 23 | "@wdio/visual-service" 24 | ], 25 | "paths": { 26 | "@/*": [ 27 | "./*" 28 | ] 29 | }, 30 | "lib": [ 31 | "esnext", 32 | "dom", 33 | "dom.iterable", 34 | "scripthost", 35 | "WebWorker" 36 | ] 37 | }, 38 | "include": [ 39 | "src/**/*.ts", 40 | "src/**/*.tsx", 41 | "src/**/*.vue", 42 | "test/**/*.ts", 43 | "tests/**/*.ts", 44 | "tests/**/*.tsx" 45 | ], 46 | "exclude": [ 47 | "node_modules" 48 | ], 49 | "ts-node": { 50 | "esm": true, 51 | "experimentalSpecifierResolution": "node" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /wdio.chrome.conf.ts: -------------------------------------------------------------------------------- 1 | import { config, TEMP_DIR } from './wdio.shared.conf'; 2 | 3 | config.capabilities = [ 4 | { 5 | browserName: 'chrome', 6 | // this overrides the default chrome download directory with our temporary one 7 | 'goog:chromeOptions': { 8 | prefs: { 9 | directory_upgrade: true, 10 | prompt_for_download: false, 11 | 'download.default_directory': TEMP_DIR, 12 | }, 13 | }, 14 | }, 15 | ]; 16 | 17 | export { config }; 18 | -------------------------------------------------------------------------------- /wdio.dev.conf.ts: -------------------------------------------------------------------------------- 1 | import { config as baseConfig } from './wdio.chrome.conf'; 2 | 3 | const DEV_SERVER_PORT = '8080'; 4 | 5 | export const config = { 6 | ...baseConfig, 7 | baseUrl: `http://localhost:${DEV_SERVER_PORT}`, 8 | filesToWatch: ['./src/**/*.ts', './src/**/*.js', './src/**/*.vue'], 9 | // sample-rendering.e2e.ts does not work locally 10 | exclude: ['./tests/specs/sample-rendering.e2e.ts'], 11 | }; 12 | --------------------------------------------------------------------------------