feat: annotation persistence (local storage)
Some checks failed
Some checks failed
This commit is contained in:
parent
f7612cdd0a
commit
bbe37ff388
@ -8,7 +8,7 @@ import {
|
|||||||
} from '@cornerstonejs/core';
|
} from '@cornerstonejs/core';
|
||||||
import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools';
|
import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools';
|
||||||
import { utilities as csMetadataUtilities } from '@cornerstonejs/metadata';
|
import { utilities as csMetadataUtilities } from '@cornerstonejs/metadata';
|
||||||
import { Types } from '@ohif/core';
|
import { Types, AnnotationPersistenceService } from '@ohif/core';
|
||||||
import Enums from './enums';
|
import Enums from './enums';
|
||||||
|
|
||||||
import init from './init';
|
import init from './init';
|
||||||
@ -216,6 +216,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
|
|||||||
servicesManager.registerService(CornerstoneCacheService.REGISTRATION);
|
servicesManager.registerService(CornerstoneCacheService.REGISTRATION);
|
||||||
servicesManager.registerService(ColorbarService.REGISTRATION);
|
servicesManager.registerService(ColorbarService.REGISTRATION);
|
||||||
servicesManager.registerService(ViewedDataService.REGISTRATION);
|
servicesManager.registerService(ViewedDataService.REGISTRATION);
|
||||||
|
servicesManager.registerService(AnnotationPersistenceService.REGISTRATION);
|
||||||
|
|
||||||
const { syncGroupService } = servicesManager.services;
|
const { syncGroupService } = servicesManager.services;
|
||||||
syncGroupService.registerCustomSynchronizer('frameview', createFrameViewSynchronizer);
|
syncGroupService.registerCustomSynchronizer('frameview', createFrameViewSynchronizer);
|
||||||
|
|||||||
@ -462,6 +462,12 @@ const connectMeasurementServiceToTools = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { referenceSeriesUID, referenceStudyUID, SOPInstanceUID, metadata } = measurement;
|
const { referenceSeriesUID, referenceStudyUID, SOPInstanceUID, metadata } = measurement;
|
||||||
|
const persistedAnnotation = data?.annotation || {};
|
||||||
|
const persistedData = persistedAnnotation.data || {};
|
||||||
|
const persistedHandles = persistedData.handles || {};
|
||||||
|
const handlePoints = Array.isArray(persistedHandles.points)
|
||||||
|
? persistedHandles.points.filter(point => Array.isArray(point) && point.length >= 2)
|
||||||
|
: [];
|
||||||
|
|
||||||
const instance = DicomMetadataStore.getInstance(
|
const instance = DicomMetadataStore.getInstance(
|
||||||
referenceStudyUID,
|
referenceStudyUID,
|
||||||
@ -506,11 +512,11 @@ const connectMeasurementServiceToTools = ({
|
|||||||
* Don't remove this destructuring of data here.
|
* Don't remove this destructuring of data here.
|
||||||
* This is used to pass annotation specific data forward e.g. contour
|
* This is used to pass annotation specific data forward e.g. contour
|
||||||
*/
|
*/
|
||||||
...(data.annotation.data || {}),
|
...(persistedData || {}),
|
||||||
text: data.annotation.data.text,
|
text: persistedData.text,
|
||||||
handles: { ...data.annotation.data.handles },
|
handles: { ...persistedHandles, points: handlePoints },
|
||||||
cachedStats: { ...data.annotation.data.cachedStats },
|
cachedStats: { ...(persistedData.cachedStats || {}) },
|
||||||
label: data.annotation.data.label,
|
label: persistedData.label,
|
||||||
frameNumber,
|
frameNumber,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -534,7 +540,9 @@ const connectMeasurementServiceToTools = ({
|
|||||||
commandsManager.run('cancelMeasurement');
|
commandsManager.run('cancelMeasurement');
|
||||||
|
|
||||||
const removedAnnotation = annotation.state.getAnnotation(removedMeasurementId);
|
const removedAnnotation = annotation.state.getAnnotation(removedMeasurementId);
|
||||||
removeAnnotation(removedMeasurementId);
|
if (removedAnnotation) {
|
||||||
|
removeAnnotation(removedMeasurementId);
|
||||||
|
}
|
||||||
// Ensure `removedAnnotation` is available before triggering the memo,
|
// Ensure `removedAnnotation` is available before triggering the memo,
|
||||||
// as it can be undefined during an undo operation
|
// as it can be undefined during an undo operation
|
||||||
if (removedAnnotation) {
|
if (removedAnnotation) {
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import {
|
|||||||
WorkflowStepsService,
|
WorkflowStepsService,
|
||||||
StudyPrefetcherService,
|
StudyPrefetcherService,
|
||||||
MultiMonitorService,
|
MultiMonitorService,
|
||||||
|
AnnotationPersistenceService,
|
||||||
} from './services';
|
} from './services';
|
||||||
|
|
||||||
import { DisplaySetMessage, DisplaySetMessageList } from './services/DisplaySetService';
|
import { DisplaySetMessage, DisplaySetMessageList } from './services/DisplaySetService';
|
||||||
@ -91,6 +92,7 @@ const OHIF = {
|
|||||||
useActiveViewportDisplaySets,
|
useActiveViewportDisplaySets,
|
||||||
WorkflowStepsService,
|
WorkflowStepsService,
|
||||||
StudyPrefetcherService,
|
StudyPrefetcherService,
|
||||||
|
AnnotationPersistenceService,
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -138,6 +140,7 @@ export {
|
|||||||
PanelService,
|
PanelService,
|
||||||
WorkflowStepsService,
|
WorkflowStepsService,
|
||||||
StudyPrefetcherService,
|
StudyPrefetcherService,
|
||||||
|
AnnotationPersistenceService,
|
||||||
useSystem,
|
useSystem,
|
||||||
useActiveViewportDisplaySets,
|
useActiveViewportDisplaySets,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,259 @@
|
|||||||
|
import AnnotationPersistenceService from './AnnotationPersistenceService';
|
||||||
|
|
||||||
|
const mockAnnotationManager = { addAnnotation: jest.fn() };
|
||||||
|
const mockAnnotations = new Map<string, any>();
|
||||||
|
|
||||||
|
jest.mock('@cornerstonejs/tools', () => ({
|
||||||
|
annotation: {
|
||||||
|
state: {
|
||||||
|
getAnnotationManager: jest.fn(() => mockAnnotationManager),
|
||||||
|
getAnnotation: jest.fn((uid: string) => mockAnnotations.get(uid)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
class FakeMeasurementService {
|
||||||
|
public EVENTS = {
|
||||||
|
MEASUREMENT_ADDED: 'MEASUREMENT_ADDED',
|
||||||
|
MEASUREMENT_UPDATED: 'MEASUREMENT_UPDATED',
|
||||||
|
MEASUREMENT_REMOVED: 'MEASUREMENT_REMOVED',
|
||||||
|
};
|
||||||
|
|
||||||
|
private listeners = new Map<string, Array<(event: any) => void>>();
|
||||||
|
public measurements = new Map<string, any>();
|
||||||
|
public addOrUpdateMeasurement = jest.fn((measurement: any) => {
|
||||||
|
this.measurements.set(measurement.uid, measurement);
|
||||||
|
return measurement.uid;
|
||||||
|
});
|
||||||
|
|
||||||
|
public getSource = jest.fn((name: string, version: string) => {
|
||||||
|
return { name, version, uid: 'test-source-uid' };
|
||||||
|
});
|
||||||
|
|
||||||
|
public subscribe(eventName: string, callback: (event: any) => void) {
|
||||||
|
if (!this.listeners.has(eventName)) {
|
||||||
|
this.listeners.set(eventName, []);
|
||||||
|
}
|
||||||
|
this.listeners.get(eventName).push(callback);
|
||||||
|
return { unsubscribe: () => undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
public emit(eventName: string, payload: any) {
|
||||||
|
this.listeners.get(eventName)?.forEach(callback => callback(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDisplaySetService {
|
||||||
|
public EVENTS = {
|
||||||
|
DISPLAY_SETS_ADDED: 'DISPLAY_SETS_ADDED',
|
||||||
|
};
|
||||||
|
|
||||||
|
private listeners = new Map<string, Array<(event: any) => void>>();
|
||||||
|
|
||||||
|
public subscribe(eventName: string, callback: (event: any) => void) {
|
||||||
|
if (!this.listeners.has(eventName)) {
|
||||||
|
this.listeners.set(eventName, []);
|
||||||
|
}
|
||||||
|
this.listeners.get(eventName).push(callback);
|
||||||
|
return { unsubscribe: () => undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
public emit(eventName: string, payload: any) {
|
||||||
|
this.listeners.get(eventName)?.forEach(callback => callback(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeBackend {
|
||||||
|
public records: any[] = [];
|
||||||
|
|
||||||
|
public save(record: any) {
|
||||||
|
this.records.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public update(record: any) {
|
||||||
|
this.records = this.records.filter(existing => existing.uid !== record.uid);
|
||||||
|
this.records.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public delete(uid: string) {
|
||||||
|
this.records = this.records.filter(existing => existing.uid !== uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public load(studyInstanceUID: string) {
|
||||||
|
return this.records.filter(entry => entry.studyInstanceUID === studyInstanceUID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AnnotationPersistenceService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockAnnotationManager.addAnnotation.mockClear();
|
||||||
|
mockAnnotations.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists created measurements and restores them for a study', async () => {
|
||||||
|
const backend = new FakeBackend();
|
||||||
|
const measurementService = new FakeMeasurementService();
|
||||||
|
const displaySetService = new FakeDisplaySetService();
|
||||||
|
const service = new AnnotationPersistenceService(
|
||||||
|
backend as any,
|
||||||
|
{
|
||||||
|
services: {
|
||||||
|
measurementService: measurementService as any,
|
||||||
|
displaySetService: displaySetService as any,
|
||||||
|
},
|
||||||
|
} as any
|
||||||
|
);
|
||||||
|
|
||||||
|
const measurement = {
|
||||||
|
uid: 'measurement-1',
|
||||||
|
toolName: 'Length',
|
||||||
|
label: 'Length',
|
||||||
|
referenceStudyUID: 'study-1',
|
||||||
|
referenceSeriesUID: 'series-1',
|
||||||
|
referencedImageId: '1.2.3.4',
|
||||||
|
frameNumber: 1,
|
||||||
|
metadata: { toolName: 'Length', referencedImageId: '1.2.3.4' },
|
||||||
|
data: { cachedStats: {} },
|
||||||
|
points: [
|
||||||
|
[1, 2, 3],
|
||||||
|
[4, 5, 6],
|
||||||
|
],
|
||||||
|
source: { name: 'cornerstone', version: '3d', uid: 'source-1' },
|
||||||
|
isLocked: false,
|
||||||
|
isVisible: true,
|
||||||
|
displayText: { primary: ['1 mm'], secondary: [] },
|
||||||
|
type: 'value_type::polyline',
|
||||||
|
};
|
||||||
|
|
||||||
|
const annotation = {
|
||||||
|
annotationUID: 'measurement-1',
|
||||||
|
metadata: { toolName: 'Length', referencedImageId: '1.2.3.4' },
|
||||||
|
data: {
|
||||||
|
label: 'Length',
|
||||||
|
handles: {
|
||||||
|
points: [
|
||||||
|
[1, 2, 3],
|
||||||
|
[4, 5, 6],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
cachedStats: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockAnnotations.set('measurement-1', annotation);
|
||||||
|
|
||||||
|
measurementService.emit(measurementService.EVENTS.MEASUREMENT_ADDED, {
|
||||||
|
source: measurement.source,
|
||||||
|
measurement,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(backend.records).toHaveLength(1);
|
||||||
|
expect(backend.records[0].studyInstanceUID).toBe('study-1');
|
||||||
|
expect(backend.records[0].annotation.annotationUID).toBe('measurement-1');
|
||||||
|
|
||||||
|
backend.save({
|
||||||
|
uid: 'measurement-1',
|
||||||
|
studyInstanceUID: 'study-1',
|
||||||
|
annotation,
|
||||||
|
measurement,
|
||||||
|
});
|
||||||
|
backend.records = backend.records.filter(entry => entry.uid === 'measurement-1');
|
||||||
|
await service.restorePersistedAnnotations('study-1');
|
||||||
|
|
||||||
|
expect(measurementService.addOrUpdateMeasurement).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ uid: 'measurement-1' }),
|
||||||
|
expect.objectContaining({
|
||||||
|
annotation: expect.objectContaining({ annotationUID: 'measurement-1' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the correct registered source on restore and skips re-saving', async () => {
|
||||||
|
const backend = new FakeBackend();
|
||||||
|
const measurementService = new FakeMeasurementService();
|
||||||
|
const displaySetService = new FakeDisplaySetService();
|
||||||
|
const service = new AnnotationPersistenceService(
|
||||||
|
backend as any,
|
||||||
|
{
|
||||||
|
services: {
|
||||||
|
measurementService: measurementService as any,
|
||||||
|
displaySetService: displaySetService as any,
|
||||||
|
},
|
||||||
|
} as any
|
||||||
|
);
|
||||||
|
|
||||||
|
const measurement = {
|
||||||
|
uid: 'measurement-2',
|
||||||
|
referenceStudyUID: 'study-2',
|
||||||
|
source: { name: 'Cornerstone3DTools', version: '0.1' },
|
||||||
|
};
|
||||||
|
|
||||||
|
backend.save({
|
||||||
|
uid: 'measurement-2',
|
||||||
|
studyInstanceUID: 'study-2',
|
||||||
|
measurement,
|
||||||
|
annotation: { annotationUID: 'measurement-2' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveSpy = jest.spyOn(backend, 'save');
|
||||||
|
const updateSpy = jest.spyOn(backend, 'update');
|
||||||
|
await service.restorePersistedAnnotations('study-2');
|
||||||
|
|
||||||
|
expect(measurementService.getSource).toHaveBeenCalledWith('Cornerstone3DTools', '0.1');
|
||||||
|
expect(measurementService.addOrUpdateMeasurement).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
uid: 'measurement-2',
|
||||||
|
source: expect.objectContaining({ name: 'Cornerstone3DTools', uid: 'test-source-uid' }),
|
||||||
|
}),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate MEASUREMENT_ADDED being fired by addOrUpdateMeasurement
|
||||||
|
measurementService.emit(measurementService.EVENTS.MEASUREMENT_ADDED, {
|
||||||
|
measurement: { uid: 'measurement-2', referenceStudyUID: 'study-2' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not re-save during restore
|
||||||
|
expect(saveSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Simulate MEASUREMENT_UPDATED
|
||||||
|
measurementService.emit(measurementService.EVENTS.MEASUREMENT_UPDATED, {
|
||||||
|
measurement: { uid: 'measurement-2', referenceStudyUID: 'study-2' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second update should save/update
|
||||||
|
measurementService.emit(measurementService.EVENTS.MEASUREMENT_UPDATED, {
|
||||||
|
measurement: { uid: 'measurement-2', referenceStudyUID: 'study-2' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait a tick for async updates to run
|
||||||
|
await new Promise(process.nextTick);
|
||||||
|
|
||||||
|
expect(updateSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes measurement from backend when MEASUREMENT_REMOVED fires', async () => {
|
||||||
|
const backend = new FakeBackend();
|
||||||
|
const measurementService = new FakeMeasurementService();
|
||||||
|
const service = new AnnotationPersistenceService(
|
||||||
|
backend as any,
|
||||||
|
{
|
||||||
|
services: {
|
||||||
|
measurementService: measurementService as any,
|
||||||
|
},
|
||||||
|
} as any
|
||||||
|
);
|
||||||
|
|
||||||
|
backend.records.push({ uid: 'measurement-3', studyInstanceUID: 'study-3' });
|
||||||
|
|
||||||
|
measurementService.emit(measurementService.EVENTS.MEASUREMENT_REMOVED, {
|
||||||
|
measurement: 'measurement-3',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait a tick for async delete to run
|
||||||
|
await new Promise(process.nextTick);
|
||||||
|
|
||||||
|
expect(backend.records).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@ -0,0 +1,304 @@
|
|||||||
|
import { annotation } from '@cornerstonejs/tools';
|
||||||
|
import LocalStorageAnnotationPersistenceBackend from './LocalStorageAnnotationPersistenceBackend';
|
||||||
|
|
||||||
|
export interface AnnotationPersistenceBackend {
|
||||||
|
save(record: Record<string, any>): Promise<void> | void;
|
||||||
|
update(record: Record<string, any>): Promise<void> | void;
|
||||||
|
delete(uid: string): Promise<void> | void;
|
||||||
|
load(studyInstanceUID: string): Promise<any[]> | any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnotationPersistenceServiceOptions {
|
||||||
|
services: {
|
||||||
|
measurementService: any;
|
||||||
|
displaySetService?: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnnotationPersistenceRecord = {
|
||||||
|
uid: string;
|
||||||
|
studyInstanceUID: string;
|
||||||
|
seriesInstanceUID?: string;
|
||||||
|
annotation: Record<string, any>;
|
||||||
|
measurement: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class AnnotationPersistenceService {
|
||||||
|
public static REGISTRATION = {
|
||||||
|
name: 'annotationPersistenceService',
|
||||||
|
altName: 'AnnotationPersistenceService',
|
||||||
|
create: ({ servicesManager }: { servicesManager: any }) => {
|
||||||
|
const backend = new LocalStorageAnnotationPersistenceBackend();
|
||||||
|
return new AnnotationPersistenceService(backend, {
|
||||||
|
services: {
|
||||||
|
measurementService: servicesManager.services.measurementService,
|
||||||
|
displaySetService: servicesManager.services.displaySetService,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
private backend: AnnotationPersistenceBackend;
|
||||||
|
private services: AnnotationPersistenceServiceOptions['services'];
|
||||||
|
private currentStudyInstanceUID?: string;
|
||||||
|
private restoredUIDs = new Set<string>();
|
||||||
|
|
||||||
|
constructor(backend: AnnotationPersistenceBackend, options: AnnotationPersistenceServiceOptions) {
|
||||||
|
this.backend = backend;
|
||||||
|
this.services = options.services;
|
||||||
|
this._bindEvents();
|
||||||
|
this._bindDisplaySetEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async saveAnnotation(measurement: Record<string, any>): Promise<void> {
|
||||||
|
if (!measurement?.uid || !measurement.referenceStudyUID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = this._buildRecord(measurement);
|
||||||
|
try {
|
||||||
|
await this.backend.save(record);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Annotation persistence save failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateAnnotation(measurement: Record<string, any>): Promise<void> {
|
||||||
|
if (!measurement?.uid || !measurement.referenceStudyUID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = this._buildRecord(measurement);
|
||||||
|
try {
|
||||||
|
await this.backend.update(record);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Annotation persistence update failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteAnnotation(uid: string): Promise<void> {
|
||||||
|
if (!uid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.backend.delete(uid);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Annotation persistence delete failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async restorePersistedAnnotations(studyInstanceUID: string): Promise<void> {
|
||||||
|
if (!studyInstanceUID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentStudyInstanceUID = studyInstanceUID;
|
||||||
|
try {
|
||||||
|
const records = await this.backend.load(studyInstanceUID);
|
||||||
|
for (const record of records) {
|
||||||
|
if (!record?.measurement?.uid || this.restoredUIDs.has(record.measurement.uid)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.restoredUIDs.add(record.measurement.uid);
|
||||||
|
this._restoreMeasurement(record);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Annotation persistence restore failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _bindEvents(): void {
|
||||||
|
const measurementService = this.services.measurementService;
|
||||||
|
if (!measurementService) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
measurementService.subscribe(measurementService.EVENTS.MEASUREMENT_ADDED, ({ measurement }) => {
|
||||||
|
if (this.restoredUIDs.has(measurement.uid)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.saveAnnotation(measurement).catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
measurementService.subscribe(
|
||||||
|
measurementService.EVENTS.MEASUREMENT_UPDATED,
|
||||||
|
({ measurement }) => {
|
||||||
|
if (this.restoredUIDs.has(measurement.uid)) {
|
||||||
|
this.restoredUIDs.delete(measurement.uid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.updateAnnotation(measurement).catch(() => undefined);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
measurementService.subscribe(
|
||||||
|
measurementService.EVENTS.MEASUREMENT_REMOVED,
|
||||||
|
({ measurement }) => {
|
||||||
|
this.deleteAnnotation(measurement).catch(() => undefined);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _bindDisplaySetEvents(): void {
|
||||||
|
const displaySetService = this.services.displaySetService;
|
||||||
|
if (!displaySetService?.subscribe) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
displaySetService.subscribe(
|
||||||
|
displaySetService.EVENTS.DISPLAY_SETS_ADDED,
|
||||||
|
({ displaySetsAdded }) => {
|
||||||
|
const studyInstanceUIDs = displaySetsAdded?.map(displaySet => displaySet?.StudyInstanceUID);
|
||||||
|
studyInstanceUIDs?.forEach(studyInstanceUID => {
|
||||||
|
if (studyInstanceUID) {
|
||||||
|
this.restorePersistedAnnotations(studyInstanceUID).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildRecord(measurement: Record<string, any>): AnnotationPersistenceRecord {
|
||||||
|
const annotationManager = annotation.state.getAnnotationManager();
|
||||||
|
const annotationInstance = annotation.state.getAnnotation(measurement.uid);
|
||||||
|
const studyInstanceUID = measurement.referenceStudyUID;
|
||||||
|
const seriesInstanceUID = measurement.referenceSeriesUID;
|
||||||
|
|
||||||
|
const annotationPayload = annotationInstance || {
|
||||||
|
annotationUID: measurement.uid,
|
||||||
|
metadata: {
|
||||||
|
...measurement.metadata,
|
||||||
|
toolName: measurement.toolName,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
label: measurement.label,
|
||||||
|
handles: measurement.points ? { points: measurement.points } : undefined,
|
||||||
|
cachedStats: measurement.data || {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
uid: measurement.uid,
|
||||||
|
studyInstanceUID,
|
||||||
|
seriesInstanceUID,
|
||||||
|
annotation: annotationPayload,
|
||||||
|
measurement: {
|
||||||
|
...measurement,
|
||||||
|
annotationUID: measurement.uid,
|
||||||
|
metadata: {
|
||||||
|
...(measurement.metadata || {}),
|
||||||
|
toolName: measurement.toolName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _restoreMeasurement(record: AnnotationPersistenceRecord): void {
|
||||||
|
const measurementService = this.services.measurementService;
|
||||||
|
if (!measurementService) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistedSource = record.measurement.source;
|
||||||
|
const resolvedSource =
|
||||||
|
(persistedSource?.name && persistedSource?.version
|
||||||
|
? measurementService.getSource(persistedSource.name, persistedSource.version)
|
||||||
|
: null) ||
|
||||||
|
measurementService.getSource('Cornerstone3DTools', '0.1');
|
||||||
|
|
||||||
|
if (!resolvedSource) {
|
||||||
|
console.warn('Annotation persistence: no registered source found, skipping restore');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const measurement = {
|
||||||
|
...record.measurement,
|
||||||
|
uid: record.measurement.uid || record.uid,
|
||||||
|
referenceStudyUID: record.studyInstanceUID,
|
||||||
|
referenceSeriesUID: record.seriesInstanceUID,
|
||||||
|
source: resolvedSource,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.currentStudyInstanceUID &&
|
||||||
|
measurement.referenceStudyUID !== this.currentStudyInstanceUID
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedAnnotation = this._normalizePersistedAnnotation(record.annotation);
|
||||||
|
const rebuiltAnnotation = this._buildRestoredAnnotation(measurement, normalizedAnnotation);
|
||||||
|
|
||||||
|
measurementService.addOrUpdateMeasurement?.(measurement, {
|
||||||
|
annotation: rebuiltAnnotation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _normalizePersistedAnnotation(
|
||||||
|
annotationPayload?: Record<string, any>
|
||||||
|
): Record<string, any> {
|
||||||
|
if (!annotationPayload) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedData = annotationPayload.data || {};
|
||||||
|
const normalizedHandles = normalizedData.handles || {};
|
||||||
|
const rawPoints = Array.isArray(normalizedHandles.points)
|
||||||
|
? normalizedHandles.points
|
||||||
|
: Array.isArray(normalizedData.points)
|
||||||
|
? normalizedData.points
|
||||||
|
: Array.isArray(annotationPayload.points)
|
||||||
|
? annotationPayload.points
|
||||||
|
: [];
|
||||||
|
const normalizedPoints = rawPoints.filter(point => Array.isArray(point) && point.length >= 2);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...annotationPayload,
|
||||||
|
data: {
|
||||||
|
...normalizedData,
|
||||||
|
handles: {
|
||||||
|
...normalizedHandles,
|
||||||
|
points: normalizedPoints,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildRestoredAnnotation(
|
||||||
|
measurement: Record<string, any>,
|
||||||
|
annotationPayload?: Record<string, any>
|
||||||
|
): Record<string, any> {
|
||||||
|
const normalizedAnnotation = this._normalizePersistedAnnotation(annotationPayload);
|
||||||
|
const persistedData = normalizedAnnotation.data || {};
|
||||||
|
const persistedHandles = persistedData.handles || {};
|
||||||
|
const points =
|
||||||
|
Array.isArray(persistedHandles.points) && persistedHandles.points.length
|
||||||
|
? persistedHandles.points
|
||||||
|
: Array.isArray(measurement.points)
|
||||||
|
? measurement.points.filter(point => Array.isArray(point) && point.length >= 2)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
annotationUID: measurement.uid || normalizedAnnotation.annotationUID,
|
||||||
|
metadata: {
|
||||||
|
...(normalizedAnnotation.metadata || {}),
|
||||||
|
toolName: measurement.toolName || normalizedAnnotation.metadata?.toolName,
|
||||||
|
FrameOfReferenceUID: measurement.FrameOfReferenceUID,
|
||||||
|
referencedImageId: measurement.referencedImageId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
...(persistedData || {}),
|
||||||
|
label: persistedData.label || measurement.label,
|
||||||
|
handles: {
|
||||||
|
...persistedHandles,
|
||||||
|
points,
|
||||||
|
},
|
||||||
|
cachedStats: { ...(persistedData.cachedStats || measurement.data || {}) },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
export interface AnnotationPersistenceBackend {
|
||||||
|
save(record: Record<string, any>): Promise<void> | void;
|
||||||
|
update(record: Record<string, any>): Promise<void> | void;
|
||||||
|
delete(uid: string): Promise<void> | void;
|
||||||
|
load(studyInstanceUID: string): Promise<any[]> | any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'ohif.annotation.persistence';
|
||||||
|
|
||||||
|
export default class LocalStorageAnnotationPersistenceBackend
|
||||||
|
implements AnnotationPersistenceBackend
|
||||||
|
{
|
||||||
|
public save(record: Record<string, any>): void {
|
||||||
|
const entries = this._readEntries();
|
||||||
|
const nextEntries = entries.filter(existing => existing.uid !== record.uid);
|
||||||
|
nextEntries.push(record);
|
||||||
|
this._writeEntries(nextEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public update(record: Record<string, any>): void {
|
||||||
|
this.save(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public delete(uid: string): void {
|
||||||
|
const entries = this._readEntries().filter(existing => existing.uid !== uid);
|
||||||
|
this._writeEntries(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public load(studyInstanceUID: string): any[] {
|
||||||
|
return this._readEntries().filter(entry => entry.studyInstanceUID === studyInstanceUID);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _readEntries(): Record<string, any>[] {
|
||||||
|
if (typeof window === 'undefined' || !window.localStorage) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to read persisted annotations from storage:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _writeEntries(entries: Record<string, any>[]): void {
|
||||||
|
if (typeof window === 'undefined' || !window.localStorage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
import AnnotationPersistenceService from './AnnotationPersistenceService';
|
||||||
|
import type {
|
||||||
|
AnnotationPersistenceBackend,
|
||||||
|
AnnotationPersistenceRecord,
|
||||||
|
AnnotationPersistenceServiceOptions,
|
||||||
|
} from './AnnotationPersistenceService';
|
||||||
|
import LocalStorageAnnotationPersistenceBackend from './LocalStorageAnnotationPersistenceBackend';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AnnotationPersistenceBackend,
|
||||||
|
AnnotationPersistenceRecord,
|
||||||
|
AnnotationPersistenceServiceOptions,
|
||||||
|
};
|
||||||
|
export { LocalStorageAnnotationPersistenceBackend };
|
||||||
|
|
||||||
|
export default AnnotationPersistenceService;
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
import AnnotationPersistenceService, {
|
||||||
|
AnnotationPersistenceServiceOptions,
|
||||||
|
} from './AnnotationPersistenceService';
|
||||||
|
import LocalStorageAnnotationPersistenceBackend from './LocalStorageAnnotationPersistenceBackend';
|
||||||
|
|
||||||
|
const registration = {
|
||||||
|
name: 'annotationPersistenceService',
|
||||||
|
altName: 'AnnotationPersistenceService',
|
||||||
|
create: ({ servicesManager }: { servicesManager: any }) => {
|
||||||
|
const backend = new LocalStorageAnnotationPersistenceBackend();
|
||||||
|
return new AnnotationPersistenceService(backend, {
|
||||||
|
services: {
|
||||||
|
measurementService: servicesManager.services.measurementService,
|
||||||
|
displaySetService: servicesManager.services.displaySetService,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default registration;
|
||||||
@ -385,6 +385,45 @@ class MeasurementService extends PubSubService {
|
|||||||
return updatedMeasurement.uid;
|
return updatedMeasurement.uid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public addOrUpdateMeasurement(measurement, restoreData?: any) {
|
||||||
|
if (!measurement?.uid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingMeasurement = this.measurements.get(measurement.uid);
|
||||||
|
const restoredMeasurement = {
|
||||||
|
...(existingMeasurement || {}),
|
||||||
|
...measurement,
|
||||||
|
modifiedTimestamp: Math.floor(Date.now() / 1000),
|
||||||
|
uid: measurement.uid,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.measurements.set(restoredMeasurement.uid, restoredMeasurement);
|
||||||
|
|
||||||
|
if (existingMeasurement) {
|
||||||
|
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
|
||||||
|
source: restoredMeasurement.source,
|
||||||
|
measurement: restoredMeasurement,
|
||||||
|
notYetUpdatedAtSource: false,
|
||||||
|
});
|
||||||
|
return restoredMeasurement.uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._broadcastEvent(this.EVENTS.MEASUREMENT_ADDED, {
|
||||||
|
source: restoredMeasurement.source,
|
||||||
|
measurement: restoredMeasurement,
|
||||||
|
});
|
||||||
|
|
||||||
|
this._broadcastEvent(this.EVENTS.RAW_MEASUREMENT_ADDED, {
|
||||||
|
source: restoredMeasurement.source,
|
||||||
|
measurement: restoredMeasurement,
|
||||||
|
data: restoreData || {},
|
||||||
|
dataSource: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
return restoredMeasurement.uid;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a raw measurement into a source so that it may be
|
* Add a raw measurement into a source so that it may be
|
||||||
* Converted to/from annotation in the same way. E.g. import serialized data
|
* Converted to/from annotation in the same way. E.g. import serialized data
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import PanelService from './PanelService';
|
|||||||
import WorkflowStepsService from './WorkflowStepsService';
|
import WorkflowStepsService from './WorkflowStepsService';
|
||||||
import StudyPrefetcherService from './StudyPrefetcherService';
|
import StudyPrefetcherService from './StudyPrefetcherService';
|
||||||
import { MultiMonitorService } from './MultiMonitorService';
|
import { MultiMonitorService } from './MultiMonitorService';
|
||||||
|
import AnnotationPersistenceService from './AnnotationPersistenceService';
|
||||||
|
|
||||||
import type Services from '../types/Services';
|
import type Services from '../types/Services';
|
||||||
|
|
||||||
@ -46,4 +47,5 @@ export {
|
|||||||
PanelService,
|
PanelService,
|
||||||
WorkflowStepsService,
|
WorkflowStepsService,
|
||||||
StudyPrefetcherService,
|
StudyPrefetcherService,
|
||||||
|
AnnotationPersistenceService,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { ShepherdBase } from 'shepherd.js';
|
import { ShepherdBase } from 'shepherd.js';
|
||||||
import { offset, flip, shift, detectOverflow } from '@floating-ui/dom';
|
import { offset, flip, shift } from '@floating-ui/dom';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the list of tours that have been shown from localStorage.
|
* Retrieves the list of tours that have been shown from localStorage.
|
||||||
@ -51,41 +51,13 @@ const defaultShowHandler = (Shepherd: ShepherdBase) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom middleware for adjusting Shepherd step positioning when overflowing.
|
|
||||||
*
|
|
||||||
* @type {object}
|
|
||||||
* @property {string} name - The name of the middleware.
|
|
||||||
* @property {function} fn - The function that adjusts the position of the step when overflowing.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const customMiddleware = {
|
|
||||||
name: 'customOverflowMiddleware',
|
|
||||||
async fn(state) {
|
|
||||||
const overflow = await detectOverflow(state, {
|
|
||||||
boundary: document.querySelector('body'),
|
|
||||||
padding: 24,
|
|
||||||
});
|
|
||||||
|
|
||||||
const xAdjustment =
|
|
||||||
overflow.left > 0 ? overflow.left : overflow.right > 0 ? -overflow.right : 0;
|
|
||||||
const yAdjustment =
|
|
||||||
overflow.top > 0 ? overflow.top : overflow.bottom > 0 ? -overflow.bottom : 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: state.x + xAdjustment,
|
|
||||||
y: state.y + yAdjustment,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default Floating UI middleware for positioning steps in Shepherd.js.
|
* Default Floating UI middleware for positioning steps in Shepherd.js.
|
||||||
* Includes offset, shift, flip, and custom overflow middleware.
|
* Includes offset, shift, and flip middleware.
|
||||||
*
|
*
|
||||||
* @type {Array<object>}
|
* @type {Array<object>}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const middleware = [offset(15), shift(), flip(), customMiddleware];
|
const middleware = [offset(15), shift({ padding: 24 }), flip()];
|
||||||
|
|
||||||
export { hasTourBeenShown, markTourAsShown, middleware, defaultShowHandler };
|
export { hasTourBeenShown, markTourAsShown, middleware, defaultShowHandler };
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user