import { useEffect, useState, useMemo, useCallback, useRef, lazy, Suspense } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { ImagingStudy } from 'fhir/r4'; import { DismissibleAlert } from '../../../../components/ui/dismissible-alert'; import { Text } from '../../../../components/ui/text'; import { ChevronLeft } from 'lucide-react'; import { useImagingStudyServiceContext, useImagingFrameService, useImagingAnnotationService, useImagingMeasurementService, useAuthServiceContext } from '../../../../services'; import type { PromotedMeasurement, PromotionRequest } from '../../../../services'; import { AppSpinner } from '../../../../components'; import { formatDate } from '../../../../utils/dateUtils.ts'; import { healthImagingToWadoRs } from '../../../../components/DicomViewport/healthImagingToWadoRs.ts'; import type { WadoRsFrame } from '../../../../components/DicomViewport/healthImagingToWadoRs.ts'; import { fromStored } from '../../../../components/DicomViewport/annotationKeying.ts'; import type { StoredAnnotation, StoredAnnotationState } from '../../../../components/DicomViewport/annotationKeying.ts'; import type { AnnotationEdition, OwnedAnnotationRequest } from '../../../../services/ImagingAnnotationService.tsx'; import { SaveRetiredError } from '../../../../utils/saveOperation'; import { viewerEpoch } from '../../../../utils/writeThroughHold'; import './ImagingStudyViewer.css'; import StudyReportCard from './StudyReportCard.tsx'; // lazy + dynamic import, NOT a static one. Loading @cornerstonejs/core, /tools and // /dicom-image-loader costs every user of the plugin bundle size they only need when they actually // open a study. Enforced by bundleIsolation.test.ts - if you change this line, that test fails, and // the correct response is to change it back. const DicomViewport = lazy(() => import('../../../../components/DicomViewport')); const modalitiesOf = (study: ImagingStudy): string[] => [...new Set([ ...(study.modality ?? []).map((coding) => coding.code), ...(study.series ?? []).map((series) => series.modality?.code), ].filter((code): code is string => Boolean(code)))]; // Names the open study, so a reader can tell it from the patient's other studies. // The order's booked modality, when the caller has one, wins over the file's own tag (often OT). export const studyTitle = (study: ImagingStudy, modality?: string): string => study.description ?? (modality || modalitiesOf(study).join(', ') || 'Imaging study'); export const studyFacts = (study: ImagingStudy, modality?: string): string => [ modality || modalitiesOf(study).join(', ') || undefined, study.started ? formatDate(study.started) : undefined, study.numberOfInstances ? `${study.numberOfInstances} image${study.numberOfInstances === 1 ? '' : 's'}` : undefined, ].filter(Boolean).join(' · '); const IMAGESET_EXTENSION_URL = 'http://akashic.health/fhir/StructureDefinition/healthimaging-imageset'; const imageSetIdsFor = (study: ImagingStudy): string[] => { const ids: string[] = []; for (const series of study.series ?? []) { for (const extension of series.extension ?? []) { if (extension.url !== IMAGESET_EXTENSION_URL) continue; for (const child of extension.extension ?? []) { if (child.url === 'imageSetId' && child.valueString) { ids.push(child.valueString); } } } } return ids; }; // The order the study was taken for, whose filed result is the study's report. const orderIdOf = (study: ImagingStudy | null): string | undefined => study?.basedOn?.map(reference => reference.reference?.match(/^ServiceRequest\/([^/]+)$/)?.[1]).find(Boolean); export const ImagingStudyViewer = ({ studyId: givenStudyId, embedded = false, modality }: { studyId?: string; embedded?: boolean; modality?: string } = {}) => { const params = useParams<{ studyId: string }>(); const studyId = givenStudyId ?? params.studyId; const navigate = useNavigate(); const { fetchImagingStudyById } = useImagingStudyServiceContext(); const { fetchImageSetMetadata } = useImagingFrameService(); const { fetchAnnotations, saveAnnotations } = useImagingAnnotationService(); const { promoteMeasurements, fetchPromotedMeasurements, promotionStatus, continuePromotion } = useImagingMeasurementService(); // ALL THREE COME FROM useAuthServiceContext - there is no wpOrganization on the organization // service. This mirrors HttpService.tsx:33 exactly, which destructures the same three and builds // the same headers; deviating would mean two places deciding what organization a request belongs // to, which is how they drift. const { getValidToken, wpOrganization, actingAsRef, userRole, USER_ROLES } = useAuthServiceContext(); const [study, setStudy] = useState(null); const [frames, setFrames] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [initialAnnotations, setInitialAnnotations] = useState[]>([]); const [unresolvedAnnotations, setUnresolvedAnnotations] = useState([]); const [annotationsNotice, setAnnotationsNotice] = useState(null); // The edition the saved measurements were read at, for this opening of the viewer; a reload is a new opening. const [opening, setOpening] = useState(0); // An opening is a study and a reload count. Its read answers for that opening alone; any other opening is // still loading - so a reload or a change of study never starts at the edition another opening read. const viewKey = `${studyId}|${opening}`; const [loaded, setLoaded] = useState<{ key: string; edition: AnnotationEdition | null | 'failed' }>({ key: '', edition: 'failed' }); const loadedEdition: AnnotationEdition | null | 'loading' | 'failed' = loaded.key === viewKey ? loaded.edition : 'loading'; // Drawings in this view that are not saved and cannot be saved now: closing the view says it loses them. const [unsaved, setUnsaved] = useState(false); // The practice the read is asked in, as it stands at the moment of asking, without re-running the read. const organizationRef = useRef(wpOrganization); organizationRef.current = wpOrganization; // PRESENTATION ONLY. Both /R4/imaging/annotations routes are allowedRoles: [PRACTITIONERS] // server-side (routes.ts) - PATIENTS and RELATED_PERSONS are absent deliberately, so a patient // viewing their own longitudinal record never even gets annotations offered to them. This flag // only decides whether the client bothers fetching them and shows the measurement toolbar; a // rendered control here is never proof of server permission. const canMeasure = userRole === USER_ROLES.PRACTITIONER; // wpOrganization and actingAsRef are ALREADY refs on the auth context, so no local mirroring // is needed - and the memo below must stay stable for a different reason than it first appears: // `auth` feeds DicomViewport's provider effect, and a provider whose identity changed every render // would re-register on every render. Ref identities never change, and getValidToken is itself a // stable useCallback, so this memo is computed once. // // Reading `.current` INSIDE the getters (rather than closing over a value) is what makes a token // refresh mid-scroll visible to the loader - see dicomLoader.ts's module-level provider slot. const auth = useMemo(() => ({ getToken: () => getValidToken(), getOrganizationId: () => wpOrganization.current?.id, getActingAs: () => actingAsRef.current ? `RelatedPerson/${actingAsRef.current.relatedPersonId}` : undefined }), [getValidToken, wpOrganization, actingAsRef]); // I3: NO `canMeasure` HERE. This effect owns the expensive part - the ImagingStudy read and // every image-set metadata blob (over a megabyte decompressed each). `canMeasure` derives from // `userRole`, which AuthService initialises `undefined` and settles moments later once // workspaces resolve, and changes again on a workspace switch. Depending on it here meant a // hard refresh on a study URL re-ran this WHOLE effect the instant the role settled - the study // fetch, every metadata blob, and (via `frames` changing) DicomViewport tearing down and // rebuilding with ~250 frames re-prefetched, on exactly the route the prefetch tuning targets. // Annotations are fetched in their own effect below, once frames are already here. useEffect(() => { if (!studyId) return; let cancelled = false; const load = async () => { try { setLoading(true); setError(null); const loaded = await fetchImagingStudyById(studyId); if (cancelled) return; setStudy(loaded); const imageSetIds = imageSetIdsFor(loaded); if (imageSetIds.length === 0) { setFrames([]); return; } // Sequential, not Promise.all: each metadata blob is over a megabyte decompressed, and // fetching every image set at once would hold all of them in memory simultaneously for a // multi-series study when they are only ever appended one at a time. const collected: WadoRsFrame[] = []; for (const imageSetId of imageSetIds) { const metadata = await fetchImageSetMetadata(imageSetId, studyId); if (cancelled) return; collected.push(...healthImagingToWadoRs(metadata, imageSetId, studyId)); } if (!cancelled) setFrames(collected); } catch (loadError) { // The raw error can carry the study id in a url - log the message only, show nothing. console.error('Failed to load imaging study:', loadError instanceof Error ? loadError.message : loadError); if (!cancelled) setError('This imaging study could not be loaded.'); } finally { if (!cancelled) setLoading(false); } }; void load(); return () => { cancelled = true; }; }, [studyId, fetchImagingStudyById, fetchImageSetMetadata]); // I3: SPLIT OUT OF THE EFFECT ABOVE, READING ITS ALREADY-LOADED `frames` RATHER THAN // RE-FETCHING ANYTHING. Keyed on `[studyId, canMeasure, frames]` so it reruns - cheaply, a // single small GET - whenever the role settles or a workspace switch changes it, without ever // re-running the expensive study/metadata fetch above. Guarded on `frames.length === 0` so it // does nothing until the image effect has actually produced something to key annotations // against; both annotation routes are Practitioners-only server-side, so fetching for anyone // else would only ever 403 - noise, not a real "annotations unavailable" case worth showing a // patient who was never going to see this feature. // // KNOWN, DEFERRED LATENT ISSUE (not fixed this round): if `studyId` changes while `frames` // still momentarily holds the PREVIOUS study's value (this effect and the one above both react // to `studyId` changing, but only the image effect's async work actually clears `frames`), this // can fire once against stale frames before the image effect's own update corrects it. Harmless // in practice - a mismatched SOPInstanceUID lookup just yields nothing - but noted rather than // silently relied upon. useEffect(() => { if (!studyId || !canMeasure || frames.length === 0) { setInitialAnnotations([]); setUnresolvedAnnotations([]); setAnnotationsNotice(null); return; } let cancelled = false; const forOpening = `${studyId}|${opening}`; // This read belongs to the person, practice and opening that asked: one answering for anyone else is not used. const epoch = viewerEpoch(); const pinned = organizationRef.current.current?.id; const belongs = () => !cancelled && viewerEpoch() === epoch && organizationRef.current.current?.id === pinned; const owned: OwnedAnnotationRequest = { admit: () => { if (!belongs()) throw new SaveRetiredError(); }, organizationId: pinned }; const loadAnnotations = async () => { let restoredAnnotations: Record[] = []; let restoredUnresolved: StoredAnnotation[] = []; let annotationsMessage: string | null = null; let edition: AnnotationEdition | null | 'failed' = 'failed'; try { const saved = await fetchAnnotations(studyId, owned); const { annotations, dropped, unresolved } = fromStored(saved.annotations, frames); edition = saved.edition; restoredAnnotations = annotations; restoredUnresolved = unresolved; // NEVER SILENT. A series that failed to import while the rest of the study // succeeded is a real case, not a hypothetical - a practitioner whose earlier // measurement referenced that series needs to know it did not just disappear. if (dropped > 0) { annotationsMessage = `${dropped} measurement${dropped === 1 ? '' : 's'} could not be shown — their images are not part of this study.`; } } catch (annotationError) { // A FAILED ANNOTATION LOAD MUST NOT BLOCK VIEWING THE IMAGES. Caught here, // separately from the image-loading effect above - this never touches `error` or // `frames`. Losing the pictures because scratch-work measurements failed to load // would be a far worse trade than showing the images without them. console.error('Failed to load annotations:', annotationError instanceof Error ? annotationError.message : annotationError); annotationsMessage = 'Measurements could not be loaded for this study, so new measurements are not being saved.'; } if (!belongs()) return; setInitialAnnotations(restoredAnnotations); setUnresolvedAnnotations(restoredUnresolved); setAnnotationsNotice(annotationsMessage); setLoaded({ key: forOpening, edition }); }; void loadAnnotations(); return () => { cancelled = true; }; }, [studyId, canMeasure, frames, fetchAnnotations, opening]); // GUARDED, NOT ASSERTED. useParams types studyId as `string | undefined`; the load effect // above already bails out when it is absent, so DicomViewport (which requires a real // studyId - see its own prop comment on why) only ever renders once frames exist, which itself // requires studyId to have been truthy. This turns that invariant into something the type // checker can see, rather than a non-null assertion papering over it. const onSave = useCallback(async (state: StoredAnnotationState, basedOn: AnnotationEdition | null, owned: OwnedAnnotationRequest) => { if (!studyId) throw new SaveRetiredError(); return saveAnnotations(studyId, state, basedOn, owned); }, [studyId, saveAnnotations]); const onRead = useCallback(async (owned: OwnedAnnotationRequest) => { if (!studyId) throw new SaveRetiredError(); return fetchAnnotations(studyId, owned); }, [studyId, fetchAnnotations]); // A new opening: the viewport is rebuilt and the saved measurements read again. The viewport asks first. const onReload = useCallback(() => { setUnsaved(false); setOpening((current) => current + 1); }, []); // THE CLOSE BOUNDARY: leaving a view whose drawings are not saved, and cannot be saved now, says they are lost. const leave = useCallback(() => { if (unsaved && !window.confirm('Drawings in this view are not saved and will be lost. Leave anyway?')) return; navigate('..'); }, [unsaved, navigate]); // FROM THE SERVER'S OWN COPY OF THE STUDY, never from anything the browser chose. The chart // lookup below is scoped by patient as well as by measurement, and a patient id the client picked // is a patient id the client could pick wrongly. const patientId = useMemo(() => { const reference = study?.subject?.reference; return reference?.startsWith('Patient/') ? reference.slice('Patient/'.length) : undefined; }, [study]); // REJECTS RATHER THAN RESOLVING EMPTY WHEN IT CANNOT ASK. The viewport reads a rejection as "chart // status unknown" and turns promotion off; an empty array would read as "nothing is charted", // which for a question that was never put to the chart is how a measurement already in the record // gets promoted a second time. fetchPromotedMeasurements rejects for the same reason when its own // search cannot be trusted, and this only extends that to the case where there is nothing to // search with yet. const onRefreshChartState = useCallback(async (annotationUids: string[]): Promise => { if (!studyId || !patientId) { throw new Error('ImagingStudyViewer: chart status cannot be confirmed before the study has loaded'); } return fetchPromotedMeasurements(studyId, patientId, annotationUids); }, [studyId, patientId, fetchPromotedMeasurements]); // ANNOTATION IDS, AND FOR AN ANGLE WHICH ANGLE IT IS. Nothing else: the value, the clinical code, // the subject, the performer and the date are all derived server-side from the caller's own saved // annotations and identity - that is what makes a charted measurement provably the one that was // drawn, rather than one asserted by whoever holds the browser. The angle type is the single // exception and only looks like one: it is a choice no drawing records and only a clinician can // make, and it selects among the server's own fixed concepts rather than naming one. The viewport // awaits its pending autosave before calling this, because the value is read out of the SAVED // document. // // Passed through unaltered - a page that reshaped or defaulted what the panel composed would be a // second place deciding what is being asserted about the patient. The operation is minted by the // viewport at the press and travels straight through; this page never invents one. const onPromote = useCallback(async ( measurements: PromotionRequest[], basedOn: AnnotationEdition, owned: OwnedAnnotationRequest, operation: string ) => { if (!studyId) { throw new Error('ImagingStudyViewer: no study is open'); } return promoteMeasurements(studyId, measurements, basedOn, operation, owned); }, [studyId, promoteMeasurements]); // Read-only; scoped to the study this viewer has open, same as every other read on this page. const onPromotionStatus = useCallback(async (owned?: OwnedAnnotationRequest) => { if (!studyId) { throw new Error('ImagingStudyViewer: no study is open'); } return promotionStatus(studyId, owned); }, [studyId, promotionStatus]); const onContinuePromotion = useCallback(async ( operation: string, effects: { annotationUid: string; withdrawal?: string }[], owned?: OwnedAnnotationRequest ) => { if (!studyId) { throw new Error('ImagingStudyViewer: no study is open'); } return continuePromotion(studyId, operation, effects, owned); }, [studyId, continuePromotion]); return (
{/* Beside a report the viewer is its dark pane, marked as a reference copy: the diagnostic read is on the workstation. */} {embedded ? (
{study ? studyTitle(study, modality) : 'Study'} {study ? {studyFacts(study, modality)} : null} For reference
) : (
{study && (

{studyTitle(study)}

{studyFacts(study)}
)}
)} {error && {error}} {annotationsNotice && {annotationsNotice}} {unsaved && Drawings in this view are not saved and will be lost when it closes.} {loading && (
)} {!loading && !error && frames.length === 0 && ( This study has no viewable images. )} {!loading && !error && frames.length > 0 && studyId && (
}> } initialAnnotations={initialAnnotations} unresolvedAnnotations={unresolvedAnnotations} loadedEdition={loadedEdition} onSave={onSave} onRead={onRead} onReload={onReload} onUnsavedChange={setUnsaved} onRefreshChartState={onRefreshChartState} onPromote={onPromote} onPromotionStatus={onPromotionStatus} onContinuePromotion={onContinuePromotion}/> )} ); }; export default ImagingStudyViewer;