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';
|
||||
import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools';
|
||||
import { utilities as csMetadataUtilities } from '@cornerstonejs/metadata';
|
||||
import { Types } from '@ohif/core';
|
||||
import { Types, AnnotationPersistenceService } from '@ohif/core';
|
||||
import Enums from './enums';
|
||||
|
||||
import init from './init';
|
||||
@ -216,6 +216,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
|
||||
servicesManager.registerService(CornerstoneCacheService.REGISTRATION);
|
||||
servicesManager.registerService(ColorbarService.REGISTRATION);
|
||||
servicesManager.registerService(ViewedDataService.REGISTRATION);
|
||||
servicesManager.registerService(AnnotationPersistenceService.REGISTRATION);
|
||||
|
||||
const { syncGroupService } = servicesManager.services;
|
||||
syncGroupService.registerCustomSynchronizer('frameview', createFrameViewSynchronizer);
|
||||
|
||||
@ -462,6 +462,12 @@ const connectMeasurementServiceToTools = ({
|
||||
}
|
||||
|
||||
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(
|
||||
referenceStudyUID,
|
||||
@ -506,11 +512,11 @@ const connectMeasurementServiceToTools = ({
|
||||
* Don't remove this destructuring of data here.
|
||||
* This is used to pass annotation specific data forward e.g. contour
|
||||
*/
|
||||
...(data.annotation.data || {}),
|
||||
text: data.annotation.data.text,
|
||||
handles: { ...data.annotation.data.handles },
|
||||
cachedStats: { ...data.annotation.data.cachedStats },
|
||||
label: data.annotation.data.label,
|
||||
...(persistedData || {}),
|
||||
text: persistedData.text,
|
||||
handles: { ...persistedHandles, points: handlePoints },
|
||||
cachedStats: { ...(persistedData.cachedStats || {}) },
|
||||
label: persistedData.label,
|
||||
frameNumber,
|
||||
},
|
||||
};
|
||||
@ -534,7 +540,9 @@ const connectMeasurementServiceToTools = ({
|
||||
commandsManager.run('cancelMeasurement');
|
||||
|
||||
const removedAnnotation = annotation.state.getAnnotation(removedMeasurementId);
|
||||
removeAnnotation(removedMeasurementId);
|
||||
if (removedAnnotation) {
|
||||
removeAnnotation(removedMeasurementId);
|
||||
}
|
||||
// Ensure `removedAnnotation` is available before triggering the memo,
|
||||
// as it can be undefined during an undo operation
|
||||
if (removedAnnotation) {
|
||||
|
||||
@ -35,6 +35,7 @@ import {
|
||||
WorkflowStepsService,
|
||||
StudyPrefetcherService,
|
||||
MultiMonitorService,
|
||||
AnnotationPersistenceService,
|
||||
} from './services';
|
||||
|
||||
import { DisplaySetMessage, DisplaySetMessageList } from './services/DisplaySetService';
|
||||
@ -91,6 +92,7 @@ const OHIF = {
|
||||
useActiveViewportDisplaySets,
|
||||
WorkflowStepsService,
|
||||
StudyPrefetcherService,
|
||||
AnnotationPersistenceService,
|
||||
};
|
||||
|
||||
export {
|
||||
@ -138,6 +140,7 @@ export {
|
||||
PanelService,
|
||||
WorkflowStepsService,
|
||||
StudyPrefetcherService,
|
||||
AnnotationPersistenceService,
|
||||
useSystem,
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
* 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 StudyPrefetcherService from './StudyPrefetcherService';
|
||||
import { MultiMonitorService } from './MultiMonitorService';
|
||||
import AnnotationPersistenceService from './AnnotationPersistenceService';
|
||||
|
||||
import type Services from '../types/Services';
|
||||
|
||||
@ -46,4 +47,5 @@ export {
|
||||
PanelService,
|
||||
WorkflowStepsService,
|
||||
StudyPrefetcherService,
|
||||
AnnotationPersistenceService,
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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.
|
||||
@ -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.
|
||||
* Includes offset, shift, flip, and custom overflow middleware.
|
||||
* Includes offset, shift, and flip middleware.
|
||||
*
|
||||
* @type {Array<object>}
|
||||
*/
|
||||
|
||||
const middleware = [offset(15), shift(), flip(), customMiddleware];
|
||||
const middleware = [offset(15), shift({ padding: 24 }), flip()];
|
||||
|
||||
export { hasTourBeenShown, markTourAsShown, middleware, defaultShowHandler };
|
||||
|
||||
Loading…
Reference in New Issue
Block a user