From f00379a672970f73bded4c882fc7c5586040a37f Mon Sep 17 00:00:00 2001 From: kenza Date: Sun, 11 Oct 2026 09:34:47 +0100 Subject: [PATCH] Integration TrizRis OHIF: key images, annotations, layouts, access control and reconstruction --- .../src/Viewport/OHIFCornerstoneViewport.tsx | 302 ++++++++++++++++ extensions/cornerstone/src/commandsModule.ts | 57 ++++ .../toolbarButtonsCustomization.ts | 91 ++++- .../src/getHangingProtocolModule.ts | 5 + .../src/hooks/useSavedKeyImages.ts | 37 ++ extensions/cornerstone/src/hps/mip.ts | 75 ++++ .../src/stores/useKeyImagesStore.ts | 74 ++++ .../keyImages/deleteKeyImageFromTrizRis.ts | 32 ++ .../keyImages/exportKeyImagesToTrizRis.ts | 322 ++++++++++++++++++ .../keyImages/extractDicomInfoFromImageId.ts | 25 ++ .../keyImages/loadKeyImagesFromTrizRis.ts | 41 +++ extensions/default/src/commandsModule.ts | 68 +++- .../default/src/services/TrizRisAccessApi.ts | 29 ++ .../src/services/TrizRisImageViewStateApi.ts | 61 ++++ .../default/src/services/TrizRisLayoutApi.ts | 59 ++++ modes/basic/src/index.tsx | 63 +++- modes/basic/src/initToolGroups.ts | 6 +- platform/app/.webpack/webpack.pwa.js | 9 + platform/app/config/trizris.shared.js | 5 + platform/app/public/config/default.js | 86 +++-- platform/app/public/config/dev.js | 284 ++++++++++++--- platform/app/src/routes/Mode/Mode.tsx | 2 + platform/app/src/routes/NoAccess.tsx | 23 ++ .../app/src/routes/TrizRisProtectedRoute.tsx | 72 ++++ platform/app/src/routes/index.tsx | 46 ++- platform/core/src/config/TrizRisConfig.ts | 13 + .../AnnotationPersistenceService.ts | 20 +- .../TrizRisAnnotationPersistenceBackend.ts | 96 ++++++ 28 files changed, 1898 insertions(+), 105 deletions(-) create mode 100644 extensions/cornerstone/src/hooks/useSavedKeyImages.ts create mode 100644 extensions/cornerstone/src/hps/mip.ts create mode 100644 extensions/cornerstone/src/stores/useKeyImagesStore.ts create mode 100644 extensions/cornerstone/src/utils/keyImages/deleteKeyImageFromTrizRis.ts create mode 100644 extensions/cornerstone/src/utils/keyImages/exportKeyImagesToTrizRis.ts create mode 100644 extensions/cornerstone/src/utils/keyImages/extractDicomInfoFromImageId.ts create mode 100644 extensions/cornerstone/src/utils/keyImages/loadKeyImagesFromTrizRis.ts create mode 100644 extensions/default/src/services/TrizRisAccessApi.ts create mode 100644 extensions/default/src/services/TrizRisImageViewStateApi.ts create mode 100644 extensions/default/src/services/TrizRisLayoutApi.ts create mode 100644 platform/app/config/trizris.shared.js create mode 100644 platform/app/src/routes/NoAccess.tsx create mode 100644 platform/app/src/routes/TrizRisProtectedRoute.tsx create mode 100644 platform/core/src/config/TrizRisConfig.ts create mode 100644 platform/core/src/services/AnnotationPersistenceService/TrizRisAnnotationPersistenceBackend.ts diff --git a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx index 328975ec9..1bf19d6aa 100644 --- a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx +++ b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx @@ -4,6 +4,7 @@ import { Enums, eventTarget, getEnabledElement } from '@cornerstonejs/core'; import { MeasurementService, useViewportRef } from '@ohif/core'; import { useViewportDialog } from '@ohif/ui-next'; import type { Types as csTypes } from '@cornerstonejs/core'; +import { deleteKeyImageFromTrizRis } from '../utils/keyImages/deleteKeyImageFromTrizRis'; import { setEnabledElement } from '../state'; @@ -17,6 +18,13 @@ import { getViewportPresentations } from '../utils/presentations/getViewportPres import { useSynchronizersStore } from '../stores/useSynchronizersStore'; import ActiveViewportBehavior from '../utils/ActiveViewportBehavior'; import { WITH_NAVIGATION } from '../services/ViewportService/CornerstoneViewportService'; +import { useKeyImagesStore } from '../stores/useKeyImagesStore'; +import { loadKeyImagesFromTrizRis } from '../utils/keyImages/loadKeyImagesFromTrizRis'; +import { useSavedKeyImages } from '../hooks/useSavedKeyImages'; +import { + saveImageViewState, + getImageViewState, +} from '../../../default/src/services/TrizRisImageViewStateApi'; const STACK = 'stack'; @@ -80,7 +88,13 @@ const OHIFCornerstoneViewport = React.memo( const [scrollbarHeight, setScrollbarHeight] = useState('100px'); const [enabledVPElement, setEnabledVPElement] = useState(null); + const [currentImageId, setCurrentImageId] = useState(null); + const { savedKeyImageSOPs, removeSavedKeyImage, addSavedKeyImage } = useSavedKeyImages(); + const keyImages = useKeyImagesStore(state => state.keyImages); + const addKeyImage = useKeyImagesStore(state => state.addKeyImage); + const removeKeyImage = useKeyImagesStore(state => state.removeKeyImage); const elementRef = useRef() as React.MutableRefObject; + const viewportRef = useViewportRef(viewportId); const { @@ -142,6 +156,201 @@ const OHIFCornerstoneViewport = React.memo( }; }, [onResize]); + // AJOUTER ICI + useEffect(() => { + const element = enabledVPElement; + let isRestoringViewState = true; + let lastRestoredImageId: string | null = null; + const lastSavedViewState = new Map(); + if (!element) { + return; + } + + const updateCurrentImage = () => { + try { + const enabledElement = getEnabledElement(element); + const viewport = enabledElement?.viewport; + const imageId = viewport?.getCurrentImageId?.(); + + if (imageId) { + setCurrentImageId(imageId); + + if (lastRestoredImageId === imageId) { + return; + } + + lastRestoredImageId = imageId; + isRestoringViewState = true; + + const match = imageId.match(/studies\/([^/]+)\/series\/([^/]+)\/instances\/([^/]+)/); + + if (match) { + const studyInstanceUID = match[1]; + const seriesInstanceUID = match[2]; + const sopInstanceUID = match[3]; + + getImageViewState(studyInstanceUID, seriesInstanceUID, sopInstanceUID) + .then(state => { + console.log('### VIEW STATE RECUPERE ###'); + console.log(state); + + if (state) { + const imageKey = `${state.studyInstanceUID}|${state.seriesInstanceUID}|${state.sopInstanceUID}`; + + lastSavedViewState.set(imageKey, { + zoom: state.zoom, + rotation: state.rotation, + }); + const enabledElement = getEnabledElement(element); + const viewport = enabledElement?.viewport; + + if (!viewport) { + // ===== CORRECTION 1 ===== + isRestoringViewState = false; + return; + } + + const currentImageId = viewport.getCurrentImageId?.(); + + if (currentImageId !== imageId) { + // ===== CORRECTION 2 ===== + isRestoringViewState = false; + return; + } + + viewport.setZoom(state.zoom); + + viewport.setViewPresentation({ + rotation: state.rotation, + }); + + viewport.render(); + + console.log('### VIEW STATE APPLIQUE ###'); + console.log('zoom =', state.zoom); + console.log('rotation =', state.rotation); + } + + // ===== CORRECTION 3 ===== + // Important : même si state == null, + // on réactive la sauvegarde + isRestoringViewState = false; + }) + .catch(error => { + console.error('Erreur récupération zoom/rotation :', error); + + // ===== CORRECTION 4 ===== + // Si le GET échoue, on ne reste pas bloqué + isRestoringViewState = false; + }); + } else { + // ===== CORRECTION 5 ===== + // Si l'imageId ne correspond pas au regex, + // on réactive quand même la sauvegarde + isRestoringViewState = false; + } + + console.log('### IMAGE COURANTE VIEWPORT', viewportId, '###'); + + console.log(imageId); + } + } catch (error) { + console.error('Erreur récupération image courante :', error); + + // ===== CORRECTION 6 ===== + isRestoringViewState = false; + } + }; + + updateCurrentImage(); + let saveViewStateTimeout: ReturnType; + const handleCameraModified = () => { + clearTimeout(saveViewStateTimeout); + + saveViewStateTimeout = setTimeout(() => { + if (isRestoringViewState) { + console.log('### SAUVEGARDE IGNOREE - RESTAURATION EN COURS ###'); + return; + } + try { + const enabledElement = getEnabledElement(element); + const viewport = enabledElement?.viewport; + + if (!viewport) { + return; + } + + const imageId = viewport.getCurrentImageId?.(); + + if (!imageId) { + return; + } + + const zoom = viewport.getZoom(); + const rotation = viewport.getRotation(); + + console.log('### CAMERA MODIFIED TERMINE ###'); + console.log('zoom =', zoom); + console.log('rotation =', rotation); + + const match = imageId.match(/studies\/([^/]+)\/series\/([^/]+)\/instances\/([^/]+)/); + + if (!match) { + return; + } + + const studyInstanceUID = match[1]; + const seriesInstanceUID = match[2]; + const sopInstanceUID = match[3]; + + const imageKey = `${studyInstanceUID}|${seriesInstanceUID}|${sopInstanceUID}`; + + const previousState = lastSavedViewState.get(imageKey); + + if ( + previousState && + Math.abs(previousState.zoom - zoom) < 0.000001 && + previousState.rotation === rotation + ) { + console.log('### VIEW STATE INCHANGE -> PAS DE SAUVEGARDE ###'); + return; + } + console.log('### SAUVEGARDE VIEW STATE ###'); + console.log('studyInstanceUID =', studyInstanceUID); + console.log('seriesInstanceUID =', seriesInstanceUID); + console.log('sopInstanceUID =', sopInstanceUID); + saveImageViewState({ + studyInstanceUID, + seriesInstanceUID, + sopInstanceUID, + zoom, + rotation, + }) + .then(() => { + lastSavedViewState.set(imageKey, { + zoom, + rotation, + }); + }) + .catch(error => { + console.error('Erreur sauvegarde zoom/rotation :', error); + }); + } catch (error) { + console.error('Erreur récupération zoom/rotation :', error); + } + }, 400); + }; + element.addEventListener(Enums.Events.IMAGE_RENDERED, updateCurrentImage); + + element.addEventListener(Enums.Events.CAMERA_MODIFIED, handleCameraModified); + + return () => { + clearTimeout(saveViewStateTimeout); + element.removeEventListener(Enums.Events.IMAGE_RENDERED, updateCurrentImage); + element.removeEventListener(Enums.Events.CAMERA_MODIFIED, handleCameraModified); + }; + }, [enabledVPElement, viewportId]); + const cleanUpServices = useCallback( viewportInfo => { const renderingEngineId = viewportInfo.getRenderingEngineId(); @@ -304,11 +513,104 @@ const OHIFCornerstoneViewport = React.memo( loadViewportData(); }, [viewportOptions, displaySets, dataSource]); + const getSopInstanceUIDFromImageId = (imageId: string | null) => { + if (!imageId) { + return null; + } + + const match = imageId.match(/\/instances\/([^/]+)/i); + + return match ? decodeURIComponent(match[1]) : null; + }; + + const currentSopInstanceUID = getSopInstanceUIDFromImageId(currentImageId); + + const isSavedKeyImage = !!( + currentSopInstanceUID && savedKeyImageSOPs.includes(currentSopInstanceUID) + ); + + const isCurrentKeyImage = !!(currentImageId && (keyImages[currentImageId] || isSavedKeyImage)); + const toggleCurrentKeyImage = async () => { + if (!currentImageId) { + console.log('Aucune image actuellement affichée'); + return; + } + + const sopInstanceUID = getSopInstanceUIDFromImageId(currentImageId); + + if (!sopInstanceUID) { + console.error('SOP Instance UID introuvable'); + return; + } + + /* + * L'image est actuellement Key Image + * soit parce qu'elle est dans le store local, + * soit parce qu'elle vient de la base TrizRis. + */ + if (isCurrentKeyImage) { + console.log('### DESACTIVATION KEY IMAGE ###'); + + /* + * Si elle était sauvegardée dans TrizRis, + * supprimer fichier + base. + */ + if (isSavedKeyImage) { + await deleteKeyImageFromTrizRis(currentImageId); + + removeSavedKeyImage(sopInstanceUID); + } + + /* + * Si elle existe aussi dans le store local, + * on la retire explicitement. + */ + if (keyImages[currentImageId]) { + removeKeyImage(currentImageId); + } + + console.log('### KEY IMAGE DESACTIVE ###'); + } else { + /* + * Elle n'était pas active : + * on l'ajoute explicitement au store. + */ + addKeyImage(currentImageId, viewportId); + + console.log('### KEY IMAGE ACTIVE ###'); + } + }; const Notification = customizationService.getCustomization('ui.notificationComponent'); return (
+
{ + if (!imageId) { + return null; + } + const imageIdWithoutPrefix = imageId.replace(/^wadors:/, '').replace(/^dicomweb:/, ''); + + const match = imageIdWithoutPrefix.match( + /\/studies\/([^/]+)\/series\/([^/]+)\/instances\/([^/]+)(?:\/frames\/(\d+))?/i + ); + + if (!match) { + console.error('Impossible d extraire les UID depuis imageId :', imageId); + + return null; + } + + return { + imageId, + studyInstanceUID: decodeURIComponent(match[1]), + seriesInstanceUID: decodeURIComponent(match[2]), + sopInstanceUID: decodeURIComponent(match[3]), + frameNumber: match[4] || null, + }; + }; const actions = { jumpToMeasurementViewport: ({ annotationUID, measurement }) => { cornerstoneTools.annotation.selection.setAnnotationSelected(annotationUID, true); @@ -1136,6 +1162,34 @@ function commandsModule({ bindings, }); }, + //key image button + toggleKeyImageMode: async () => { + console.log('### KEY IMAGES BUTTON CLICKED ###'); + + const keyImages = useKeyImagesStore.getState().keyImages; + + const liste = Object.values(keyImages); + + console.log('Nombre de Key Images =', liste.length); + + if (liste.length === 0) { + console.log('Aucune Key Image sélectionnée'); + + return; + } + + console.log('### EXPORT DE TOUTES LES KEY IMAGES ###'); + + for (let i = 0; i < liste.length; i++) { + const keyImage = liste[i]; + + console.log(`Export ${i + 1} / ${liste.length}`); + + await exportKeyImageToTrizRis(keyImage.imageId, keyImage.viewportId); + } + + console.log('### EXPORT KEY IMAGES TERMINE ###'); + }, // capture viewport showDownloadViewportModal: () => { const { activeViewportId } = viewportGridService.getState(); @@ -2700,6 +2754,9 @@ function commandsModule({ deleteActiveAnnotation: { commandFn: actions.deleteActiveAnnotation, }, + toggleKeyImageMode: { + commandFn: actions.toggleKeyImageMode, + }, setDisplaySetsForViewports: actions.setDisplaySetsForViewports, undo: actions.undo, redo: actions.redo, diff --git a/extensions/cornerstone/src/customizations/toolbarButtonsCustomization.ts b/extensions/cornerstone/src/customizations/toolbarButtonsCustomization.ts index 699792184..a134b2e1a 100644 --- a/extensions/cornerstone/src/customizations/toolbarButtonsCustomization.ts +++ b/extensions/cornerstone/src/customizations/toolbarButtonsCustomization.ts @@ -38,6 +38,13 @@ const toolbarButtons: Button[] = [ buttonSection: true, }, }, + { + id: 'Reconstruction', + uiType: 'ohif.toolButtonList', + props: { + buttonSection: true, + }, + }, { id: 'AdvancedRenderingControls', uiType: 'ohif.advancedRenderingControls', @@ -633,6 +640,86 @@ const toolbarButtons: Button[] = [ ], }, }, + { + id: 'MPR', + uiType: 'ohif.toolButton', + props: { + icon: 'layout-advanced-mpr', + label: 'MPR', + tooltip: 'Reconstruction multiplanaire', + commands: { + commandName: 'setHangingProtocol', + commandOptions: { + protocolId: 'mpr', + }, + }, + evaluate: 'evaluate.action', + }, + }, + { + id: 'MPR3D', + uiType: 'ohif.toolButton', + props: { + icon: 'layout-advanced-mpr', + label: 'MPR + 3D', + tooltip: 'Reconstruction multiplanaire avec rendu 3D', + commands: { + commandName: 'setHangingProtocol', + commandOptions: { + protocolId: 'mprAnd3DVolumeViewport', + }, + }, + evaluate: 'evaluate.action', + }, + }, + { + id: 'MIP', + uiType: 'ohif.toolButton', + props: { + icon: 'layout-advanced-mpr', + label: 'MIP', + tooltip: 'Maximum Intensity Projection', + commands: { + commandName: 'setHangingProtocol', + commandOptions: { + protocolId: 'mip', + }, + }, + evaluate: 'evaluate.action', + }, + }, + { + id: 'KeyImages', + uiType: 'ohif.toolButton', + props: { + icon: 'Export', + label: 'Key Images', + commands: 'toggleKeyImageMode', + evaluate: [ + 'evaluate.action', + { + name: 'evaluate.viewport.supported', + unsupportedViewportTypes: ['video', 'wholeSlide'], + }, + ], + }, + }, + { + id: 'Only3D', + uiType: 'ohif.toolButton', + props: { + icon: 'layout-advanced-mpr', + label: '3D', + tooltip: 'Rendu volumique 3D', + commands: { + commandName: 'setHangingProtocol', + commandOptions: { + protocolId: 'only3D', + }, + }, + evaluate: 'evaluate.action', + }, + }, { id: 'Layout', uiType: 'ohif.layoutSelector', @@ -723,11 +810,13 @@ export const toolbarSections = { 'TrackballRotate', 'WindowLevel', 'Capture', + 'KeyImages', 'Layout', 'Crosshairs', 'MoreTools', + 'Reconstruction', ], - + Reconstruction: ['MPR', 'MPR3D', 'MIP', 'Only3D'], [TOOLBAR_SECTIONS.viewportActionMenu.topLeft]: ['orientationMenu', 'dataOverlayMenu'], [TOOLBAR_SECTIONS.viewportActionMenu.bottomMiddle]: ['AdvancedRenderingControls'], diff --git a/extensions/cornerstone/src/getHangingProtocolModule.ts b/extensions/cornerstone/src/getHangingProtocolModule.ts index 6a1fc8a1c..6671badf7 100644 --- a/extensions/cornerstone/src/getHangingProtocolModule.ts +++ b/extensions/cornerstone/src/getHangingProtocolModule.ts @@ -6,9 +6,14 @@ import { only3D } from './hps/only3D'; import { primary3D } from './hps/primary3D'; import { primaryAxial } from './hps/primaryAxial'; import { frameView } from './hps/frameView'; +import { mip } from './hps/mip'; function getHangingProtocolModule() { return [ + { + name: mip.id, + protocol: mip, + }, { name: mpr.id, protocol: mpr, diff --git a/extensions/cornerstone/src/hooks/useSavedKeyImages.ts b/extensions/cornerstone/src/hooks/useSavedKeyImages.ts new file mode 100644 index 000000000..72632a099 --- /dev/null +++ b/extensions/cornerstone/src/hooks/useSavedKeyImages.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; + +import { loadKeyImagesFromTrizRis } from '../utils/keyImages/loadKeyImagesFromTrizRis'; + +export const useSavedKeyImages = () => { + const [savedKeyImageSOPs, setSavedKeyImageSOPs] = useState([]); + + useEffect(() => { + const chargerKeyImages = async () => { + const sopInstanceUIDs = await loadKeyImagesFromTrizRis(); + + setSavedKeyImageSOPs(sopInstanceUIDs); + }; + + chargerKeyImages(); + }, []); + + const removeSavedKeyImage = (sopInstanceUID: string) => { + setSavedKeyImageSOPs(prev => prev.filter(sop => sop !== sopInstanceUID)); + }; + + const addSavedKeyImage = (sopInstanceUID: string) => { + setSavedKeyImageSOPs(prev => { + if (prev.includes(sopInstanceUID)) { + return prev; + } + + return [...prev, sopInstanceUID]; + }); + }; + + return { + savedKeyImageSOPs, + removeSavedKeyImage, + addSavedKeyImage, + }; +}; diff --git a/extensions/cornerstone/src/hps/mip.ts b/extensions/cornerstone/src/hps/mip.ts new file mode 100644 index 000000000..1a209137f --- /dev/null +++ b/extensions/cornerstone/src/hps/mip.ts @@ -0,0 +1,75 @@ +import { HYDRATE_SEG_SYNC_GROUP, VOI_SYNC_GROUP } from './mpr'; + +export const mip = { + id: 'mip', + locked: true, + name: 'MIP', + + createdDate: '2026-10-08T00:00:00.000Z', + modifiedDate: '2026-10-08T00:00:00.000Z', + + availableTo: {}, + editableBy: {}, + + protocolMatchingRules: [], + + imageLoadStrategy: 'interleaveCenter', + + displaySetSelectors: { + activeDisplaySet: { + seriesMatchingRules: [ + { + weight: 1, + attribute: 'isReconstructable', + constraint: { + equals: { + value: true, + }, + }, + required: true, + }, + ], + }, + }, + + stages: [ + { + id: 'mipStage', + name: 'MIP', + + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 1, + columns: 1, + }, + }, + + viewports: [ + { + viewportOptions: { + viewportId: 'mip-axial', + toolGroupId: 'mpr', + viewportType: 'volume', + orientation: 'axial', + + initialImageOptions: { + preset: 'middle', + }, + + syncGroups: [VOI_SYNC_GROUP, HYDRATE_SEG_SYNC_GROUP], + }, + + displaySets: [ + { + id: 'activeDisplaySet', + options: { + blendMode: 'MIP', + }, + }, + ], + }, + ], + }, + ], +}; diff --git a/extensions/cornerstone/src/stores/useKeyImagesStore.ts b/extensions/cornerstone/src/stores/useKeyImagesStore.ts new file mode 100644 index 000000000..f00717ba8 --- /dev/null +++ b/extensions/cornerstone/src/stores/useKeyImagesStore.ts @@ -0,0 +1,74 @@ +import { create } from 'zustand'; + +type KeyImageInfo = { + imageId: string; + viewportId?: string; +}; + +type KeyImagesState = { + keyImages: Record; + + addKeyImage: (imageId: string, viewportId?: string) => void; + + removeKeyImage: (imageId: string) => void; + + toggleKeyImage: (imageId: string, viewportId?: string) => void; + + isKeyImage: (imageId: string) => boolean; + + clearKeyImages: () => void; +}; + +export const useKeyImagesStore = create((set, get) => ({ + keyImages: {}, + + addKeyImage: (imageId, viewportId) => { + set(state => ({ + keyImages: { + ...state.keyImages, + [imageId]: { + imageId, + viewportId, + }, + }, + })); + + console.log('### KEY IMAGE AJOUTEE ###', imageId); + }, + + removeKeyImage: imageId => { + set(state => { + const newKeyImages = { + ...state.keyImages, + }; + + delete newKeyImages[imageId]; + + return { + keyImages: newKeyImages, + }; + }); + + console.log('### KEY IMAGE RETIREE ###', imageId); + }, + + toggleKeyImage: (imageId, viewportId) => { + const existe = !!get().keyImages[imageId]; + + if (existe) { + get().removeKeyImage(imageId); + } else { + get().addKeyImage(imageId, viewportId); + } + }, + + isKeyImage: imageId => { + return !!get().keyImages[imageId]; + }, + + clearKeyImages: () => { + set({ + keyImages: {}, + }); + }, +})); diff --git a/extensions/cornerstone/src/utils/keyImages/deleteKeyImageFromTrizRis.ts b/extensions/cornerstone/src/utils/keyImages/deleteKeyImageFromTrizRis.ts new file mode 100644 index 000000000..7a1a2bce2 --- /dev/null +++ b/extensions/cornerstone/src/utils/keyImages/deleteKeyImageFromTrizRis.ts @@ -0,0 +1,32 @@ +import { extractDicomInfoFromImageId } from './extractDicomInfoFromImageId'; +import { TrizRisConfig } from '@ohif/core/src/config/TrizRisConfig'; + +export const deleteKeyImageFromTrizRis = async (imageId: string) => { + const dicomInfo = extractDicomInfoFromImageId(imageId); + + if (!dicomInfo) { + console.error('Informations DICOM introuvables'); + return; + } + + const response = await fetch(`${TrizRisConfig.apiBaseUrl}/dicom/key-image`, { + method: 'DELETE', + headers: { + 'X-SOP-Instance-UID': dicomInfo.sopInstanceUID, + + ...(dicomInfo.frameNumber + ? { + 'X-Frame-Number': dicomInfo.frameNumber, + } + : {}), + }, + }); + + const resultat = await response.text(); + + console.log('### SUPPRESSION KEY IMAGE TRIZRIS ###'); + + console.log('HTTP =', response.status); + + console.log('Résultat =', resultat); +}; diff --git a/extensions/cornerstone/src/utils/keyImages/exportKeyImagesToTrizRis.ts b/extensions/cornerstone/src/utils/keyImages/exportKeyImagesToTrizRis.ts new file mode 100644 index 000000000..2fcd49d8d --- /dev/null +++ b/extensions/cornerstone/src/utils/keyImages/exportKeyImagesToTrizRis.ts @@ -0,0 +1,322 @@ +import html2canvas from 'html2canvas'; + +import { Enums, getEnabledElement, getRenderingEngine } from '@cornerstonejs/core'; + +import { getEnabledElement as OHIFgetEnabledElement } from '../../state'; + +import { extractDicomInfoFromImageId } from './extractDicomInfoFromImageId'; + +import { ToolGroupManager } from '@cornerstonejs/tools'; + +import { TrizRisConfig } from '@ohif/core/src/config/TrizRisConfig'; + +const attendreRenduImage = (element: HTMLElement) => { + return new Promise(resolve => { + let termine = false; + + const terminer = () => { + if (termine) { + return; + } + + termine = true; + + element.removeEventListener(Enums.Events.IMAGE_RENDERED, handler as EventListener); + + resolve(); + }; + + const handler = () => { + terminer(); + }; + + element.addEventListener(Enums.Events.IMAGE_RENDERED, handler as EventListener); + + // Sécurité si jamais l'événement ne remonte pas + setTimeout(() => { + terminer(); + }, 1500); + }); +}; + +export const exportKeyImageToTrizRis = async (imageId: string, viewportId: string) => { + console.log('===================================='); + console.log('### DEBUT EXPORT KEY IMAGE ###'); + console.log('ImageId demandé =', imageId); + console.log('Viewport source =', viewportId); + + const dicomInfo = extractDicomInfoFromImageId(imageId); + + if (!dicomInfo) { + console.error('Informations DICOM introuvables'); + + return; + } + + /* + * ------------------------------------------------ + * 1. Retrouver le viewport OHIF visible + * ------------------------------------------------ + * + * On l'utilise uniquement pour retrouver + * le RenderingEngine et le type du viewport. + * + * ON NE MODIFIE PLUS SON IMAGE. + */ + const enabledElementOHIF = OHIFgetEnabledElement(viewportId); + + if (!enabledElementOHIF?.element) { + console.error('Impossible de trouver le viewport OHIF :', viewportId); + + return; + } + + const sourceElement = enabledElementOHIF.element as HTMLElement; + + const sourceEnabledElement = getEnabledElement(sourceElement); + + if (!sourceEnabledElement) { + console.error('EnabledElement Cornerstone source introuvable'); + + return; + } + + const sourceViewport = sourceEnabledElement.viewport; + + const renderingEngineId = sourceEnabledElement.renderingEngineId; + + console.log('RenderingEngine ID =', renderingEngineId); + + console.log('Image visible utilisateur =', sourceViewport.getCurrentImageId?.()); + + const sourceToolGroup = ToolGroupManager.getToolGroupForViewport( + sourceViewport.id, + renderingEngineId + ); + + if (!sourceToolGroup) { + console.warn('Aucun ToolGroup trouvé pour le viewport source'); + } + + /* + * ------------------------------------------------ + * 2. Retrouver le RenderingEngine + * ------------------------------------------------ + */ + const renderingEngine = getRenderingEngine(renderingEngineId); + + if (!renderingEngine) { + console.error('RenderingEngine introuvable'); + + return; + } + + /* + * ------------------------------------------------ + * 3. Créer un viewport caché hors écran + * ------------------------------------------------ + */ + const hiddenViewportId = `key-image-export-${Date.now()}-${Math.random() + .toString(36) + .substring(2)}`; + + const hiddenElement = document.createElement('div'); + + hiddenElement.setAttribute('data-key-image-viewport', hiddenViewportId); + + hiddenElement.style.position = 'fixed'; + + /* + * IMPORTANT : + * ne pas utiliser display:none. + * + * Cornerstone doit réellement pouvoir rendre + * dans le div. + */ + hiddenElement.style.left = '-10000px'; + hiddenElement.style.top = '0'; + + hiddenElement.style.width = '512px'; + hiddenElement.style.height = '512px'; + + hiddenElement.style.pointerEvents = 'none'; + + hiddenElement.style.background = 'black'; + + document.body.appendChild(hiddenElement); + + try { + /* + * ------------------------------------------------ + * 4. Activer le viewport caché Cornerstone + * ------------------------------------------------ + */ + console.log('### CREATION VIEWPORT CACHE ###'); + + renderingEngine.enableElement({ + viewportId: hiddenViewportId, + + element: hiddenElement, + + /* + * On utilise le même type de viewport + * que le viewport source. + */ + type: sourceViewport.type, + + defaultOptions: { + background: sourceViewport.defaultOptions?.background, + + orientation: sourceViewport.defaultOptions?.orientation, + }, + }); + + if (sourceToolGroup) { + sourceToolGroup.addViewport(hiddenViewportId, renderingEngineId); + } + + const captureViewport = renderingEngine.getViewport(hiddenViewportId); + + if (!captureViewport) { + console.error('Viewport caché introuvable'); + + return; + } + + /* + * ------------------------------------------------ + * 5. Charger LA Key Image dans le viewport caché + * ------------------------------------------------ + */ + console.log('### CHARGEMENT KEY IMAGE DANS VIEWPORT CACHE ###'); + + const renderPromise = attendreRenduImage(hiddenElement); + + await captureViewport.setStack([imageId], 0); + + captureViewport.render(); + + await renderPromise; + + const imageChargee = captureViewport.getCurrentImageId?.(); + + console.log('Image chargée dans viewport caché =', imageChargee); + + /* + * Sécurité : + * vérifier qu'on capture bien la bonne image. + */ + if (imageChargee !== imageId) { + console.error('ERREUR : le viewport caché n affiche pas la Key Image demandée'); + + console.error('Demandée =', imageId); + + console.error('Affichée =', imageChargee); + + return; + } + + /* + * ------------------------------------------------ + * 6. Transformer le viewport caché en PNG + * ------------------------------------------------ + */ + console.log('### CREATION PNG ###'); + + const canvas = await html2canvas(hiddenElement); + + const blob = await new Promise((resolve, reject) => { + canvas.toBlob( + blob => { + if (blob) { + resolve(blob); + } else { + reject(new Error('Impossible de créer le PNG')); + } + }, + + 'image/png', + + 1.0 + ); + }); + + console.log('### PNG KEY IMAGE CREE ###'); + + console.log('StudyInstanceUID =', dicomInfo.studyInstanceUID); + + console.log('SeriesInstanceUID =', dicomInfo.seriesInstanceUID); + + console.log('SOPInstanceUID =', dicomInfo.sopInstanceUID); + + console.log('FrameNumber =', dicomInfo.frameNumber); + + console.log('Taille PNG =', blob.size); + + /* + * ------------------------------------------------ + * 7. Envoyer vers TrizRis + * ------------------------------------------------ + */ + console.log('### ENVOI VERS TRIZRIS ###'); + + const response = await fetch(`${TrizRisConfig.apiBaseUrl}/dicom/save-image`, { + method: 'POST', + + headers: { + 'Content-Type': 'image/png', + + 'X-Study-Instance-UID': dicomInfo.studyInstanceUID, + + 'X-Series-Instance-UID': dicomInfo.seriesInstanceUID, + + 'X-SOP-Instance-UID': dicomInfo.sopInstanceUID, + + ...(dicomInfo.frameNumber + ? { + 'X-Frame-Number': dicomInfo.frameNumber, + } + : {}), + }, + + body: blob, + }); + + const resultat = await response.text(); + + console.log('### REPONSE TRIZRIS ###'); + + console.log('HTTP =', response.status); + + console.log('Résultat =', resultat); + } catch (error) { + console.error('Erreur export Key Image :', error); + } finally { + /* + * ------------------------------------------------ + * 8. Détruire le viewport caché + * ------------------------------------------------ + */ + console.log('### SUPPRESSION VIEWPORT CACHE ###'); + if (sourceToolGroup) { + try { + sourceToolGroup.removeViewports(renderingEngineId, hiddenViewportId); + } catch (error) { + console.warn('Erreur suppression viewport caché du ToolGroup', error); + } + } + try { + renderingEngine.disableElement(hiddenViewportId); + } catch (error) { + console.warn('Erreur lors de la désactivation du viewport caché :', error); + } + + if (hiddenElement.parentNode) { + hiddenElement.parentNode.removeChild(hiddenElement); + } + + console.log('### FIN EXPORT KEY IMAGE ###'); + + console.log('===================================='); + } +}; diff --git a/extensions/cornerstone/src/utils/keyImages/extractDicomInfoFromImageId.ts b/extensions/cornerstone/src/utils/keyImages/extractDicomInfoFromImageId.ts new file mode 100644 index 000000000..af35c5fca --- /dev/null +++ b/extensions/cornerstone/src/utils/keyImages/extractDicomInfoFromImageId.ts @@ -0,0 +1,25 @@ +export const extractDicomInfoFromImageId = (imageId: string) => { + if (!imageId) { + return null; + } + + const imageIdWithoutPrefix = imageId.replace(/^wadors:/, '').replace(/^dicomweb:/, ''); + + const match = imageIdWithoutPrefix.match( + /\/studies\/([^/]+)\/series\/([^/]+)\/instances\/([^/]+)(?:\/frames\/(\d+))?/i + ); + + if (!match) { + console.error('Impossible d extraire les UID depuis imageId :', imageId); + + return null; + } + + return { + imageId, + studyInstanceUID: decodeURIComponent(match[1]), + seriesInstanceUID: decodeURIComponent(match[2]), + sopInstanceUID: decodeURIComponent(match[3]), + frameNumber: match[4] || null, + }; +}; diff --git a/extensions/cornerstone/src/utils/keyImages/loadKeyImagesFromTrizRis.ts b/extensions/cornerstone/src/utils/keyImages/loadKeyImagesFromTrizRis.ts new file mode 100644 index 000000000..3484e5b92 --- /dev/null +++ b/extensions/cornerstone/src/utils/keyImages/loadKeyImagesFromTrizRis.ts @@ -0,0 +1,41 @@ +import { TrizRisConfig } from '@ohif/core/src/config/TrizRisConfig'; + +export const loadKeyImagesFromTrizRis = async (): Promise => { + try { + const response = await fetch(`${TrizRisConfig.apiBaseUrl}/dicom/key-images`); + + if (!response.ok) { + console.error('Erreur chargement Key Images :', response.status); + + return []; + } + + const text = await response.text(); + + /* + * Ton endpoint renvoie actuellement du XML : + * + * + * ... + * + */ + + const parser = new DOMParser(); + + const xml = parser.parseFromString(text, 'application/xml'); + + const items = Array.from(xml.getElementsByTagName('item')); + + const sopInstanceUIDs = items.map(item => item.textContent?.trim()).filter(Boolean) as string[]; + + console.log('### KEY IMAGES CHARGEES DEPUIS TRIZRIS ###'); + + console.log(sopInstanceUIDs); + + return sopInstanceUIDs; + } catch (error) { + console.error('Erreur récupération Key Images :', error); + + return []; + } +}; diff --git a/extensions/default/src/commandsModule.ts b/extensions/default/src/commandsModule.ts index 573830c42..c3230f1db 100644 --- a/extensions/default/src/commandsModule.ts +++ b/extensions/default/src/commandsModule.ts @@ -28,6 +28,7 @@ import { useViewportsByPositionStore } from './stores/useViewportsByPositionStor import { useToggleOneUpViewportGridStore } from './stores/useToggleOneUpViewportGridStore'; import requestDisplaySetCreationForStudy from './Panels/requestDisplaySetCreationForStudy'; import promptSaveReport from './utils/promptSaveReport'; +import { saveLayoutPreference } from './services/TrizRisLayoutApi'; export type HangingProtocolParams = { protocolId?: string; @@ -498,12 +499,21 @@ const commandsModule = ({ /** * Changes the viewport grid layout in terms of the MxN layout. */ - setViewportGridLayout: ({ numRows, numCols, isHangingProtocolLayout = false }) => { - const { protocol } = hangingProtocolService.getActiveProtocol(); - const onLayoutChange = protocol.callbacks?.onLayoutChange; - if (commandsManager.run(onLayoutChange, { numRows, numCols }) === false) { - // Don't apply the layout if the run command returns false - return; + setViewportGridLayout: ({ + numRows, + numCols, + isHangingProtocolLayout = false, + savePreference = true, + }) => { + const activeProtocol = hangingProtocolService.getActiveProtocol(); + const protocol = activeProtocol?.protocol; + + const onLayoutChange = protocol?.callbacks?.onLayoutChange; + + if (onLayoutChange) { + if (commandsManager.run(onLayoutChange, { numRows, numCols }) === false) { + return; + } } const completeLayout = () => { @@ -525,9 +535,49 @@ const commandsModule = ({ findOrCreateViewport, isHangingProtocolLayout, }); + + console.log('### LAYOUT CHOISI ###'); + console.log('numRows =', numRows); + console.log('numCols =', numCols); + console.log('isHangingProtocolLayout =', isHangingProtocolLayout); + + if (!isHangingProtocolLayout && savePreference) { + const hangingProtocolState = hangingProtocolService.getState(); + + const studyInstanceUID = hangingProtocolState.activeStudyUID; + + console.log('### STUDY UID POUR LAYOUT ###', studyInstanceUID); + if (!studyInstanceUID) { + console.error('StudyInstanceUID introuvable dans URL'); + return; + } + saveLayoutPreference(numRows, numCols, studyInstanceUID) + .then(() => { + console.log('### LAYOUT SAUVEGARDE AVEC SUCCES ###'); + }) + .catch(error => { + console.error('### ERREUR SAUVEGARDE LAYOUT ###', error); + }); + } }; // Need to finish any work in the callback - window.setTimeout(completeLayout, 0); + const applyLayoutWhenReady = () => { + const hpState = hangingProtocolService.getState(); + + if (!hpState) { + console.log('### HANGING PROTOCOL PAS ENCORE PRET -> ATTENTE ###'); + + window.setTimeout(applyLayoutWhenReady, 100); + + return; + } + + console.log('### HANGING PROTOCOL PRET -> APPLICATION LAYOUT ###'); + + completeLayout(); + }; + + applyLayoutWhenReady(); }, toggleOneUp() { @@ -805,9 +855,7 @@ const commandsModule = ({ } const reportBlob = datasetToDicomBlob(instances[0]); const type = defaultContentType || 'application/dicom'; - await navigator.clipboard.write([ - new ClipboardItem({ [type]: reportBlob }), - ]); + await navigator.clipboard.write([new ClipboardItem({ [type]: reportBlob })]); }; } diff --git a/extensions/default/src/services/TrizRisAccessApi.ts b/extensions/default/src/services/TrizRisAccessApi.ts new file mode 100644 index 000000000..ee4c5e262 --- /dev/null +++ b/extensions/default/src/services/TrizRisAccessApi.ts @@ -0,0 +1,29 @@ +import { TrizRisConfig } from '@ohif/core/src/config/TrizRisConfig'; + +const ACCESS_URL = `${TrizRisConfig.apiBaseUrl}/ohif/access`; + +export async function checkTrizRisAccess(): Promise { + try { + const response = await fetch(ACCESS_URL, { + method: 'GET', + + headers: { + Accept: 'application/json', + }, + + credentials: 'include', + }); + + if (!response.ok) { + return false; + } + + const result = await response.json(); + + return result.authorized === true; + } catch (error) { + console.error('### ERREUR VERIFICATION ACCES TRIZRIS ###', error); + + return false; + } +} diff --git a/extensions/default/src/services/TrizRisImageViewStateApi.ts b/extensions/default/src/services/TrizRisImageViewStateApi.ts new file mode 100644 index 000000000..8cb80120b --- /dev/null +++ b/extensions/default/src/services/TrizRisImageViewStateApi.ts @@ -0,0 +1,61 @@ +import { TrizRisConfig } from '@ohif/core/src/config/TrizRisConfig'; +const BASE_URL = `${TrizRisConfig.apiBaseUrl}/image-view-state/current`; + +export type ImageViewState = { + zoom: number; + rotation: number; + studyInstanceUID: string; + seriesInstanceUID: string; + sopInstanceUID: string; +}; + +export async function saveImageViewState(state: ImageViewState): Promise { + const response = await fetch(BASE_URL, { + method: 'POST', + + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + + credentials: 'include', + + body: JSON.stringify(state), + }); + + if (!response.ok) { + throw new Error(`Erreur sauvegarde image view state : ${response.status}`); + } +} + +export async function getImageViewState( + studyInstanceUID: string, + seriesInstanceUID: string, + sopInstanceUID: string +): Promise { + const url = + `${BASE_URL}` + + `?studyInstanceUID=${encodeURIComponent(studyInstanceUID)}` + + `&seriesInstanceUID=${encodeURIComponent(seriesInstanceUID)}` + + `&sopInstanceUID=${encodeURIComponent(sopInstanceUID)}`; + + const response = await fetch(url, { + method: 'GET', + + headers: { + Accept: 'application/json', + }, + + credentials: 'include', + }); + + if (response.status === 204) { + return null; + } + + if (!response.ok) { + throw new Error(`Erreur récupération image view state : ${response.status}`); + } + + return response.json(); +} diff --git a/extensions/default/src/services/TrizRisLayoutApi.ts b/extensions/default/src/services/TrizRisLayoutApi.ts new file mode 100644 index 000000000..6f403c927 --- /dev/null +++ b/extensions/default/src/services/TrizRisLayoutApi.ts @@ -0,0 +1,59 @@ +import { TrizRisConfig } from '@ohif/core/src/config/TrizRisConfig'; +const BASE_URL = `${TrizRisConfig.apiBaseUrl}/layout/current`; + +export type SavedLayout = { + numRows: number; + numCols: number; +}; + +export async function saveLayoutPreference( + numRows: number, + numCols: number, + studyInstanceUID: string +): Promise { + const response = await fetch(BASE_URL, { + method: 'POST', + + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + + credentials: 'include', + + body: JSON.stringify({ + numRows, + numCols, + studyInstanceUID, + }), + }); + + if (!response.ok) { + throw new Error(`Erreur sauvegarde layout TrizRis : ${response.status}`); + } +} + +export async function getLayoutPreference(studyInstanceUID: string): Promise { + const response = await fetch( + `${BASE_URL}?studyInstanceUID=${encodeURIComponent(studyInstanceUID)}`, + { + method: 'GET', + + headers: { + Accept: 'application/json', + }, + + credentials: 'include', + } + ); + + if (response.status === 204) { + return null; + } + + if (!response.ok) { + throw new Error(`Erreur récupération layout TrizRis : ${response.status}`); + } + + return response.json(); +} diff --git a/modes/basic/src/index.tsx b/modes/basic/src/index.tsx index 46bb3a10c..016f9afa0 100644 --- a/modes/basic/src/index.tsx +++ b/modes/basic/src/index.tsx @@ -1,7 +1,8 @@ import update from 'immutability-helper'; import { utils } from '@ohif/core'; - +import { getLayoutPreference } from '../../../extensions/default/src/services/TrizRisLayoutApi'; import initToolGroups from './initToolGroups'; +import { checkTrizRisAccess } from '../../../extensions/default/src/services/TrizRisAccessApi'; import { addActivatePanelTriggers, applyToolGroupAdditions, @@ -182,9 +183,19 @@ export const defaultActivatePanelTriggers = [ }, ]; -export function onModeEnter({ servicesManager, extensionManager, commandsManager }: withAppTypes) { - const { measurementService, toolbarService, toolGroupService, customizationService } = - servicesManager.services; +export function onModeEnter({ + servicesManager, + extensionManager, + commandsManager, + studyInstanceUIDs, +}: withAppTypes) { + const { + measurementService, + toolbarService, + toolGroupService, + customizationService, + hangingProtocolService, + } = servicesManager.services; measurementService.clearMeasurements(); @@ -220,7 +231,7 @@ export function onModeEnter({ servicesManager, extensionManager, commandsManager ); // Note: the mode's `modeCustomizations` are NOT applied here — the mode - // route applies them right after the mode scope is reset, before the app + // route applies them right after the mode scope is reset, before the appf // config / URL `mode` phase blocks, so the final value of every key is // decided purely by customization scope precedence and application order. @@ -230,6 +241,47 @@ export function onModeEnter({ servicesManager, extensionManager, commandsManager this._unsubscriptions.push( ...addActivatePanelTriggers({ servicesManager }, this.activatePanelTriggers) ); + + const restoreLayoutTrizRis = async (attempt = 0) => { + try { + const studyInstanceUID = studyInstanceUIDs?.[0]; + + if (!studyInstanceUID) { + console.log('### STUDY UID PAS ENCORE DISPONIBLE ###'); + return; + } + + console.log('### STUDY UID DIRECT POUR LAYOUT ###', studyInstanceUID); + console.log('### STUDY UID POUR LAYOUT ###', studyInstanceUID); + + const layout = await getLayoutPreference(studyInstanceUID); + + console.log('### LAYOUT RECUPERE DEPUIS TRIZRIS ###', layout); + + if (!layout) { + console.log('### AUCUNE PREFERENCE LAYOUT -> OHIF / HANGING PROTOCOL CONSERVE ###'); + + return; + } + + console.log('### PREFERENCE LAYOUT TROUVEE -> PRIORITE TRIZRIS ###'); + + commandsManager.runCommand('setViewportGridLayout', { + numRows: layout.numRows, + numCols: layout.numCols, + isHangingProtocolLayout: false, + savePreference: false, + }); + + console.log('### LAYOUT TRIZRIS APPLIQUE ###', layout.numRows, 'x', layout.numCols); + + console.log('### LAYOUT TRIZRIS APPLIQUE ###', layout.numRows, 'x', layout.numCols); + } catch (error) { + console.error('### ERREUR RESTAURATION LAYOUT TRIZRIS ###', error); + } + }; + + restoreLayoutTrizRis(); } export function onModeExit({ servicesManager }: withAppTypes) { @@ -240,6 +292,7 @@ export function onModeExit({ servicesManager }: withAppTypes) { cornerstoneViewportService, uiDialogService, uiModalService, + hangingProtocolService, } = servicesManager.services; this._unsubscriptions?.forEach(unsubscribe => unsubscribe()); diff --git a/modes/basic/src/initToolGroups.ts b/modes/basic/src/initToolGroups.ts index 1b9b77668..5bc2c9a9c 100644 --- a/modes/basic/src/initToolGroups.ts +++ b/modes/basic/src/initToolGroups.ts @@ -83,11 +83,15 @@ function initDefaultToolGroup(extensionManager, toolGroupService, commandsManage { toolName: toolNames.LivewireContour }, { toolName: toolNames.WindowLevelRegion }, ], - enabled: [{ toolName: toolNames.ImageOverlayViewer }, { toolName: toolNames.ReferenceLines }], + enabled: [{ toolName: toolNames.ImageOverlayViewer }], + disabled: [ { toolName: toolNames.AdvancedMagnify, }, + { + toolName: toolNames.ReferenceLines, + }, ], }; diff --git a/platform/app/.webpack/webpack.pwa.js b/platform/app/.webpack/webpack.pwa.js index 82dc4e983..6fbd6a081 100644 --- a/platform/app/.webpack/webpack.pwa.js +++ b/platform/app/.webpack/webpack.pwa.js @@ -209,6 +209,15 @@ module.exports = (env, argv) => { context: ['/dicomweb'], target: 'http://localhost:5000', }, + + { + context: ['/orthanc'], + target: 'http://localhost:8042', + changeOrigin: true, + pathRewrite: { + '^/orthanc': '', + }, + }, ], static: [ { diff --git a/platform/app/config/trizris.shared.js b/platform/app/config/trizris.shared.js new file mode 100644 index 000000000..6212ce8ad --- /dev/null +++ b/platform/app/config/trizris.shared.js @@ -0,0 +1,5 @@ +module.exports = { + TRIZRIS_BASE_URL: 'http://localhost:9090/TrizRis', + ORTHANC_BASE_URL: 'http://localhost:8042', + OHIF_BASE_URL: 'http://localhost:3000', +}; diff --git a/platform/app/public/config/default.js b/platform/app/public/config/default.js index 35307320e..101355af5 100644 --- a/platform/app/public/config/default.js +++ b/platform/app/public/config/default.js @@ -16,12 +16,18 @@ // your own DICOMweb server. For a fully-featured setup with every data source // and customization loading enabled, see config/dev.js (local development) and // config/netlify.js (the public demo deploy). + window.config = { name: 'config/default.js', + routerBasename: null, + // whiteLabeling: {}, + extensions: [], + modes: [], + customizationService: {}, // --- URL-driven customizations (?customization=) ---------------------------- @@ -44,68 +50,112 @@ window.config = { // ("next") API instead of the legacy Stack/Volume viewport classes. genericViewports: { enabled: false, + // Render backend selection: 'cpu' | 'webgl' | 'auto' | a backend id // registered via cornerstone's registerRenderBackend (e.g. a webgpu // backend), or a map with per-viewport-type overrides, e.g. // { default: 'webgl', orthographic: 'cpu' }. The matching URL params take // precedence per-session: ?viewportRendering=cpu and // ?orthographic.viewportRendering=cpu. + // viewportRendering: 'auto', }, + // ---------------------------------------------------------------------------- + showStudyList: true, + // some windows systems have issues with more than 3 web workers maxNumberOfWebWorkers: 3, + // below flag is for performance reasons, but it might not work for all servers showWarningMessageForCrossOrigin: true, + showCPUFallbackMessage: true, + showLoadingIndicator: true, + experimentalStudyBrowserSort: false, + strictZSpacingForVolumeViewport: true, + groupEnabledModesFirst: true, + allowMultiSelectExport: false, + maxNumRequests: { interaction: 100, thumbnail: 5, + // Prefetch number is dependent on the http protocol. For http 2 or // above, the number of requests can be go a lot higher. prefetch: 25, }, - showErrorDetails: 'always', // 'always', 'dev', 'production' + + showErrorDetails: 'always', + // `dangerouslyUseDynamicConfig` (load configuration from a `configUrl` query // parameter) is intentionally left OFF in the secure default build. See // config/dev.js for the documented shape. - defaultDataSourceName: 'ohif', + + // ✅ MODIFIÉ : on indique que la source par défaut est Orthanc + defaultDataSourceName: 'orthanc', + dataSources: [ { - // Read-only public demo server. Replace with your own DICOMweb server. namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', - sourceName: 'ohif', + + // ✅ MODIFIÉ : nom logique de la datasource + sourceName: 'orthanc', + configuration: { - friendlyName: 'AWS S3 Static wado server', - name: 'aws', - wadoUriRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', - qidoRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', - wadoRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', - qidoSupportsIncludeField: false, + // ✅ MODIFIÉ + friendlyName: 'Orthanc local', + + // ✅ MODIFIÉ + name: 'Orthanc', + + // ✅ Orthanc WADO-URI + wadoUriRoot: '/orthanc/wado', + + // ✅ Orthanc QIDO-RS + qidoRoot: '/orthanc/dicom-web', + + // ✅ Orthanc WADO-RS + wadoRoot: '/orthanc/dicom-web', + + qidoSupportsIncludeField: true, + imageRendering: 'wadors', - thumbnailRendering: 'thumbnail', + + thumbnailRendering: 'wadors', + thumbnailRequestStrategy: 'fetch', + enableStudyLazyLoad: true, - supportsFuzzyMatching: true, + + supportsFuzzyMatching: false, + supportsWildcard: true, - staticWado: true, + + // ✅ MODIFIÉ + // Avant tu avais true car la config venait d'un serveur WADO statique. + // Avec Orthanc DICOMweb, mets false. + staticWado: false, + // Multiframe SEG loads fetch the whole instance as a single Part 10 // object by default and wait for it: the per-frame endpoint is // efficient, but SEG frames are so small and numerous that one bulk - // fetch beats hundreds of tiny requests. Per-frame loading is the - // exception — set loadMultiframeAsPart10: false here to force it. + // fetch beats hundreds of tiny requests. singlepart: 'bulkdata,video', + bulkDataURI: { enabled: true, relativeResolution: 'studies', + transform: url => url.replace('/pixeldata.mp4', '/rendered'), }, + omitQuotationForMultipartRequest: true, }, }, @@ -114,15 +164,13 @@ window.config = { // default because they broaden the attack surface of a default deployment. // Enable them only in a deployment you control (see config/dev.js): // - dicomlocal: loads DICOM files from the user's machine. - // - dicomjson: loads metadata from an arbitrary `?url=` (gate with - // `dangerouslyAllowedOriginsForAuthenticatedEnvironments`). + // - dicomjson: loads metadata from an arbitrary `?url=`. // - dicomwebproxy: delegating proxy driven by `?url=`. ], + httpErrorHandler: error => { - // This is 429 when rejected from the public idc sandbox too often. console.warn(error.status); - // Could use services manager here to bring up a dialog/modal if needed. console.warn('test, navigate to https://ohif.org/'); }, }; diff --git a/platform/app/public/config/dev.js b/platform/app/public/config/dev.js index d1ae75503..ca768361e 100644 --- a/platform/app/public/config/dev.js +++ b/platform/app/public/config/dev.js @@ -10,52 +10,64 @@ // customizations, can be exercised locally exactly as it runs on the demo. // The locked-down config/default.js is what a plain production build emits // instead. + window.config = { name: 'config/dev.js', + routerBasename: null, + // whiteLabeling: {}, + extensions: [], + modes: [], + customizationService: ['@ohif/extension-default.customizationModule.theme'], - // URL-driven customizations (?customization=). The `default` prefix (no - // slashes) is used for values without a leading slash; every other prefix - // must start AND end with a slash and matches the leading `/segment/` of the - // value. Files are fetched and parsed as JSONC data — never executed. - // e.g. ?customization=tools/ctPresets -> ./customizations/tools/ctPresets.jsonc customizationUrlPrefixes: { default: './customizations/', }, showStudyList: true, - // some windows systems have issues with more than 3 web workers + maxNumberOfWebWorkers: 3, - // below flag is for performance reasons, but it might not work for all servers + showWarningMessageForCrossOrigin: true, + showCPUFallbackMessage: true, + showLoadingIndicator: true, + experimentalStudyBrowserSort: false, + strictZSpacingForVolumeViewport: true, + groupEnabledModesFirst: true, + allowMultiSelectExport: false, + maxNumRequests: { interaction: 100, thumbnail: 5, - // Prefetch number is dependent on the http protocol. For http 2 or - // above, the number of requests can be go a lot higher. prefetch: 25, }, - showErrorDetails: 'always', // 'always', 'dev', 'production' + + showErrorDetails: 'always', + // filterQueryParam: false, - // Defines multi-monitor layouts + multimonitor: [ { id: 'split', + test: ({ multimonitor }) => multimonitor === 'split', + screens: [ { id: 'ohif0', + screen: null, + location: { screen: 0, width: 0.5, @@ -63,17 +75,22 @@ window.config = { left: 0, top: 0, }, + options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no', }, + { id: 'ohif1', + screen: null, + location: { width: 0.5, height: 1, left: 0.5, top: 0, }, + options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no', }, ], @@ -81,195 +98,362 @@ window.config = { { id: '2', + test: ({ multimonitor }) => multimonitor === '2', + screens: [ { id: 'ohif0', + screen: 0, + location: { width: 1, height: 1, left: 0, top: 0, }, + options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no', }, + { id: 'ohif1', + screen: 1, + location: { width: 1, height: 1, left: 0, top: 0, }, + options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no', }, ], }, ], - defaultDataSourceName: 'ohif', - /* Dynamic config allows user to pass "configUrl" query string this allows to load config without recompiling application. The regex will ensure valid configuration source */ - // dangerouslyUseDynamicConfig: { - // enabled: true, - // regex: /.*/, - // }, + + // ✅ MODIFIÉ + // Avant : + // defaultDataSourceName: 'ohif', + // + // Maintenant OHIF utilise Orthanc par défaut. + defaultDataSourceName: 'orthanc', + dataSources: [ + // ========================================================= + // AWS source 1 + // ========================================================= { namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'ohif', + configuration: { friendlyName: 'AWS S3 Static wado server', + name: 'aws', + wadoUriRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', + qidoRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', + wadoRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', + qidoSupportsIncludeField: false, + imageRendering: 'wadors', + thumbnailRendering: 'thumbnail', + thumbnailRequestStrategy: 'fetch', + enableStudyLazyLoad: true, + supportsFuzzyMatching: true, + supportsWildcard: true, + staticWado: true, + singlepart: 'bulkdata,video', + bulkDataURI: { enabled: true, + relativeResolution: 'studies', + transform: url => url.replace('/pixeldata.mp4', '/rendered'), }, + omitQuotationForMultipartRequest: true, }, }, + // ========================================================= + // AWS source 2 + // ========================================================= { namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'ohif2', + configuration: { friendlyName: 'AWS S3 Static wado secondary server', + name: 'aws', + wadoUriRoot: 'https://dd14fa38qiwhyfd.cloudfront.net/dicomweb', + qidoRoot: 'https://dd14fa38qiwhyfd.cloudfront.net/dicomweb', + wadoRoot: 'https://dd14fa38qiwhyfd.cloudfront.net/dicomweb', + qidoSupportsIncludeField: false, + supportsReject: false, + imageRendering: 'wadors', + thumbnailRendering: 'wadors', + enableStudyLazyLoad: true, + supportsFuzzyMatching: false, + supportsWildcard: true, + staticWado: true, + singlepart: 'bulkdata,video', + bulkDataURI: { enabled: true, relativeResolution: 'studies', }, - omitQuotationForMultipartRequest: true, - }, - }, - { - namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', - sourceName: 'ohif3', - configuration: { - friendlyName: 'AWS S3 Static wado secondary server', - name: 'aws', - wadoUriRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb', - qidoRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb', - wadoRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb', - qidoSupportsIncludeField: false, - supportsReject: false, - imageRendering: 'wadors', - thumbnailRendering: 'wadors', - enableStudyLazyLoad: true, - supportsFuzzyMatching: false, - supportsWildcard: true, - staticWado: true, - singlepart: 'bulkdata,video', - bulkDataURI: { - enabled: true, - relativeResolution: 'studies', - }, + omitQuotationForMultipartRequest: true, }, }, + // ========================================================= + // AWS source 3 + // ========================================================= { namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + + sourceName: 'ohif3', + + configuration: { + friendlyName: 'AWS S3 Static wado secondary server', + + name: 'aws', + + wadoUriRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb', + + qidoRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb', + + wadoRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb', + + qidoSupportsIncludeField: false, + + supportsReject: false, + + imageRendering: 'wadors', + + thumbnailRendering: 'wadors', + + enableStudyLazyLoad: true, + + supportsFuzzyMatching: false, + + supportsWildcard: true, + + staticWado: true, + + singlepart: 'bulkdata,video', + + bulkDataURI: { + enabled: true, + relativeResolution: 'studies', + }, + + omitQuotationForMultipartRequest: true, + }, + }, + + // ========================================================= + // local5000 + // ========================================================= + { + namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'local5000', + configuration: { friendlyName: 'Static WADO Local Data', + name: 'DCM4CHEE', + qidoRoot: 'http://localhost:5000/dicomweb', + wadoRoot: 'http://localhost:5000/dicomweb', + qidoSupportsIncludeField: false, + supportsReject: true, + supportsStow: true, + imageRendering: 'wadors', + thumbnailRendering: 'wadors', + enableStudyLazyLoad: true, + supportsFuzzyMatching: false, + supportsWildcard: true, + staticWado: true, + singlepart: 'video', + bulkDataURI: { enabled: true, relativeResolution: 'studies', }, }, }, + + // ========================================================= + // ✅ ORTHANC LOCAL VIA PROXY OHIF + // ========================================================= { namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'orthanc', + configuration: { - friendlyName: 'local Orthanc DICOMWeb Server', - name: 'DCM4CHEE', - wadoUriRoot: 'http://localhost/pacs/dicom-web', - qidoRoot: 'http://localhost/pacs/dicom-web', - wadoRoot: 'http://localhost/pacs/dicom-web', + // ✅ MODIFIÉ + friendlyName: 'Orthanc local', + + // ✅ MODIFIÉ + name: 'Orthanc', + + /* + * ✅ MODIFIÉ IMPORTANT + * + * AVANT : + * http://localhost:8042/wado + * + * MAINTENANT : + * on passe par le serveur OHIF lui-même. + */ + wadoUriRoot: '/orthanc/wado', + + /* + * ✅ MODIFIÉ IMPORTANT + * + * AVANT : + * http://localhost:8042/dicom-web + */ + qidoRoot: '/orthanc/dicom-web', + + /* + * ✅ MODIFIÉ IMPORTANT + * + * AVANT : + * http://localhost:8042/dicom-web + */ + wadoRoot: '/orthanc/dicom-web', + qidoSupportsIncludeField: true, + supportsReject: true, + dicomUploadEnabled: true, + imageRendering: 'wadors', + thumbnailRendering: 'wadors', + + // ✅ AJOUTÉ + thumbnailRequestStrategy: 'fetch', + enableStudyLazyLoad: true, - supportsFuzzyMatching: true, + + // ✅ MODIFIÉ + supportsFuzzyMatching: false, + supportsWildcard: true, + + // ✅ AJOUTÉ + staticWado: false, + + // ✅ AJOUTÉ + singlepart: 'bulkdata,video', + omitQuotationForMultipartRequest: true, + bulkDataURI: { enabled: true, + + // ✅ AJOUTÉ + relativeResolution: 'studies', }, }, }, + // ========================================================= + // dicomweb proxy + // ========================================================= { namespace: '@ohif/extension-default.dataSourcesModule.dicomwebproxy', + sourceName: 'dicomwebproxy', + configuration: { friendlyName: 'dicomweb delegating proxy', + name: 'dicomwebproxy', }, }, + + // ========================================================= + // dicomjson + // ========================================================= { namespace: '@ohif/extension-default.dataSourcesModule.dicomjson', + sourceName: 'dicomjson', + configuration: { friendlyName: 'dicom json', + name: 'json', }, }, + + // ========================================================= + // dicomlocal + // ========================================================= { namespace: '@ohif/extension-default.dataSourcesModule.dicomlocal', + sourceName: 'dicomlocal', + configuration: { friendlyName: 'dicom local', }, }, ], + httpErrorHandler: error => { - // This is 429 when rejected from the public idc sandbox too often. console.warn(error.status); - // Could use services manager here to bring up a dialog/modal if needed. console.warn('test, navigate to https://ohif.org/'); }, }; diff --git a/platform/app/src/routes/Mode/Mode.tsx b/platform/app/src/routes/Mode/Mode.tsx index fcdef7cd6..dcba03a87 100644 --- a/platform/app/src/routes/Mode/Mode.tsx +++ b/platform/app/src/routes/Mode/Mode.tsx @@ -317,6 +317,8 @@ export default function ModeRoute({ extensionManager, commandsManager, appConfig, + // ===== TRIZRIS ===== + studyInstanceUIDs, }); // Move hotkeys setup here, after onModeEnter diff --git a/platform/app/src/routes/NoAccess.tsx b/platform/app/src/routes/NoAccess.tsx new file mode 100644 index 000000000..f9b81e632 --- /dev/null +++ b/platform/app/src/routes/NoAccess.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +export default function NoAccess() { + return ( +
+

Accès refusé

+ +

Vous devez être connecté à TrizRis avec un compte médecin pour accéder au viewer.

+
+ ); +} diff --git a/platform/app/src/routes/TrizRisProtectedRoute.tsx b/platform/app/src/routes/TrizRisProtectedRoute.tsx new file mode 100644 index 000000000..e0a2c6393 --- /dev/null +++ b/platform/app/src/routes/TrizRisProtectedRoute.tsx @@ -0,0 +1,72 @@ +import React, { useEffect, useState } from 'react'; +import { Navigate } from 'react-router-dom'; + +import { checkTrizRisAccess } from '../../../../extensions/default/src/services/TrizRisAccessApi'; + +type Props = { + children: React.ReactNode; +}; + +export default function TrizRisProtectedRoute({ children }: Props) { + const [checking, setChecking] = useState(true); + const [authorized, setAuthorized] = useState(false); + + useEffect(() => { + let mounted = true; + + const checkAccess = async () => { + try { + const result = await checkTrizRisAccess(); + + if (!mounted) { + return; + } + + console.log('### VERIFICATION ACCES TRIZRIS ###', result); + + setAuthorized(result); + } catch (error) { + console.error('### ERREUR VERIFICATION ACCES TRIZRIS ###', error); + + if (mounted) { + setAuthorized(false); + } + } finally { + if (mounted) { + setChecking(false); + } + } + }; + + checkAccess(); + + return () => { + mounted = false; + }; + }, []); + + // Pendant la vérification : + // on n'affiche PAS encore le viewer. + if (checking) { + return ( +
+ ); + } + + if (!authorized) { + return ( + + ); + } + + return <>{children}; +} diff --git a/platform/app/src/routes/index.tsx b/platform/app/src/routes/index.tsx index d1354f97d..184561c35 100644 --- a/platform/app/src/routes/index.tsx +++ b/platform/app/src/routes/index.tsx @@ -18,6 +18,8 @@ import PropTypes from 'prop-types'; import { routerBasename } from '../utils/publicUrl'; import { useAppConfig } from '@state'; import { history } from '../utils/history'; +import TrizRisProtectedRoute from './TrizRisProtectedRoute'; +import NoAccess from './NoAccess'; const NotFoundServer = ({ message = 'Unable to query for studies at this time. Check your data source configuration or network connection', @@ -71,6 +73,10 @@ NotFoundStudy.propTypes = { // TODO: Include "routes" debug route if dev build const bakedInRoutes = [ + { + path: `/no-access`, + children: NoAccess, + }, { path: `/notfoundserver`, children: NotFoundServer, @@ -105,7 +111,7 @@ const createRoutes = ({ hotkeysManager, showStudyList, }: withAppTypes) => { - const routes = + const routes = ( buildModeRoutes({ modes, dataSources, @@ -113,7 +119,11 @@ const createRoutes = ({ servicesManager, commandsManager, hotkeysManager, - }) || []; + }) || [] + ).map(route => ({ + ...route, + trizRisProtected: true, + })); const { customizationService } = servicesManager.services; @@ -176,23 +186,27 @@ const createRoutes = ({ return ( {allRoutes.map((route, i) => { - return route.private === true ? ( + let element = ; + + if (route.trizRisProtected === true) { + element = {element}; + } + + if (route.private === true) { + element = ( + userAuthenticationService.handleUnauthenticated()} + > + {element} + + ); + } + + return ( userAuthenticationService.handleUnauthenticated()} - > - - - } - > - ) : ( - } + element={element} /> ); })} diff --git a/platform/core/src/config/TrizRisConfig.ts b/platform/core/src/config/TrizRisConfig.ts new file mode 100644 index 000000000..bd9e08f8f --- /dev/null +++ b/platform/core/src/config/TrizRisConfig.ts @@ -0,0 +1,13 @@ +const TRIZRIS_BASE_URL = 'http://localhost:9090/TrizRis'; +const ORTHANC_BASE_URL = 'http://localhost:8042'; + +export const TrizRisConfig = { + apiBaseUrl: `${TRIZRIS_BASE_URL}/api`, + accessUrl: `${TRIZRIS_BASE_URL}/ohif/access`, + + orthancBaseUrl: ORTHANC_BASE_URL, + orthancDicomWebUrl: `${ORTHANC_BASE_URL}/dicom-web`, + orthancWadoUrl: `${ORTHANC_BASE_URL}/wado`, + + ohifBaseUrl: 'http://localhost:3000', +}; diff --git a/platform/core/src/services/AnnotationPersistenceService/AnnotationPersistenceService.ts b/platform/core/src/services/AnnotationPersistenceService/AnnotationPersistenceService.ts index d1dc4fe87..1b7427be8 100644 --- a/platform/core/src/services/AnnotationPersistenceService/AnnotationPersistenceService.ts +++ b/platform/core/src/services/AnnotationPersistenceService/AnnotationPersistenceService.ts @@ -1,5 +1,6 @@ import { annotation } from '@cornerstonejs/tools'; import LocalStorageAnnotationPersistenceBackend from './LocalStorageAnnotationPersistenceBackend'; +import TrizRisAnnotationPersistenceBackend from './TrizRisAnnotationPersistenceBackend'; export interface AnnotationPersistenceBackend { save(record: Record): Promise | void; @@ -28,7 +29,8 @@ export default class AnnotationPersistenceService { name: 'annotationPersistenceService', altName: 'AnnotationPersistenceService', create: ({ servicesManager }: { servicesManager: any }) => { - const backend = new LocalStorageAnnotationPersistenceBackend(); + // = new LocalStorageAnnotationPersistenceBackend(); + const backend = new TrizRisAnnotationPersistenceBackend(); return new AnnotationPersistenceService(backend, { services: { measurementService: servicesManager.services.measurementService, @@ -207,8 +209,7 @@ export default class AnnotationPersistenceService { const resolvedSource = (persistedSource?.name && persistedSource?.version ? measurementService.getSource(persistedSource.name, persistedSource.version) - : null) || - measurementService.getSource('Cornerstone3DTools', '0.1'); + : null) || measurementService.getSource('Cornerstone3DTools', '0.1'); if (!resolvedSource) { console.warn('Annotation persistence: no registered source found, skipping restore'); @@ -232,7 +233,20 @@ export default class AnnotationPersistenceService { const normalizedAnnotation = this._normalizePersistedAnnotation(record.annotation); const rebuiltAnnotation = this._buildRestoredAnnotation(measurement, normalizedAnnotation); + console.log('===================================='); + console.log('### RESTAURATION MEASUREMENT ###'); + console.log('referencedImageId =', rebuiltAnnotation?.metadata?.referencedImageId); + + console.log('FrameOfReferenceUID =', rebuiltAnnotation?.metadata?.FrameOfReferenceUID); + + console.log('points JSON =', JSON.stringify(rebuiltAnnotation?.data?.handles?.points)); + + console.log('cachedStats JSON =', JSON.stringify(rebuiltAnnotation?.data?.cachedStats)); + + console.log('measurement JSON =', JSON.stringify(measurement)); + + console.log('===================================='); measurementService.addOrUpdateMeasurement?.(measurement, { annotation: rebuiltAnnotation, }); diff --git a/platform/core/src/services/AnnotationPersistenceService/TrizRisAnnotationPersistenceBackend.ts b/platform/core/src/services/AnnotationPersistenceService/TrizRisAnnotationPersistenceBackend.ts new file mode 100644 index 000000000..45a462122 --- /dev/null +++ b/platform/core/src/services/AnnotationPersistenceService/TrizRisAnnotationPersistenceBackend.ts @@ -0,0 +1,96 @@ +export interface AnnotationPersistenceBackend { + save(record: Record): Promise | void; + update(record: Record): Promise | void; + delete(uid: string): Promise | void; + load(studyInstanceUID: string): Promise | any[]; +} + +const BASE_URL = 'http://localhost:9090/TrizRis/api/annotation'; + +export default class TrizRisAnnotationPersistenceBackend implements AnnotationPersistenceBackend { + public async save(record: Record): Promise { + console.log('### SAUVEGARDE ANNOTATION TRIZRIS ###'); + + console.log(record); + + const response = await fetch(BASE_URL, { + method: 'POST', + + headers: { + 'Content-Type': 'application/json', + }, + + body: JSON.stringify(record), + }); + + if (!response.ok) { + throw new Error(`Erreur sauvegarde annotation : ${response.status}`); + } + + const resultat = await response.text(); + + console.log('Réponse TrizRis =', resultat); + } + + public async update(record: Record): Promise { + console.log('### MODIFICATION ANNOTATION TRIZRIS ###'); + + console.log(record); + + const response = await fetch(BASE_URL, { + method: 'PUT', + + headers: { + 'Content-Type': 'application/json', + }, + + body: JSON.stringify(record), + }); + + if (!response.ok) { + throw new Error(`Erreur modification annotation : ${response.status}`); + } + + const resultat = await response.text(); + + console.log('Réponse TrizRis =', resultat); + } + + public async delete(uid: string): Promise { + console.log('### SUPPRESSION ANNOTATION TRIZRIS ###'); + + console.log('UID =', uid); + + const response = await fetch(`${BASE_URL}/${encodeURIComponent(uid)}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error(`Erreur suppression annotation : ${response.status}`); + } + + const resultat = await response.text(); + + console.log('Réponse TrizRis =', resultat); + } + + public async load(studyInstanceUID: string): Promise { + console.log('### CHARGEMENT ANNOTATIONS TRIZRIS ###'); + + console.log('StudyInstanceUID =', studyInstanceUID); + + const response = await fetch( + `${BASE_URL}?studyInstanceUID=${encodeURIComponent(studyInstanceUID)}` + ); + + if (!response.ok) { + throw new Error(`Erreur chargement annotations : ${response.status}`); + } + + const records = await response.json(); + + console.log('Annotations reçues =', records); + + return Array.isArray(records) ? records : []; + } +}