import type { WadoRsFrame } from './healthImagingToWadoRs.ts'; import { measurementFigure } from '../../utils/measurementFigure.ts'; // Turns Cornerstone's live annotation state into the plain rows the measurement panel lists. // // Pure module: no I/O, no React, no Cornerstone runtime import - types only. That is what lets the // panel it feeds be rendered and asserted without a WebGL context, and it is why the shape-reading // below is written against `unknown` with guards rather than against Cornerstone's own annotation // types: importing those would tie this file, and everything that tests it, to the package the // viewport is deliberately isolated behind. export interface MeasurementRow { annotationUid: string; // Cornerstone's tool name, carried through verbatim rather than mapped to something friendlier. // The panel decides from this string whether a measurement can be charted at all, and the server // keys its tool-to-clinical-concept table on the same string, so the two must be comparing the // same value. toolName: string; // What the clinician reads to recognise which drawing this row is: the tool plus its measured // figure when one can be read confidently, the tool alone when it cannot. label: string; // The measured figure on its own, or null when it could not be read confidently. // // TWO CONSUMERS, AND BOTH NEED THE null CASE SEPARABLE FROM `label`. A measurement whose figure // could not be read is one the server refuses - and it refuses the WHOLE batch it appears in, so // offering it a tick box costs the clinician every other measurement they ticked alongside it. // Deciding that from the tool name alone is not enough: an elliptical region is a chartable tool // whose statistics may nonetheless be unreadable, and its row reads simply "Ellipse". // // It is also what the panel shows the chart's own figure beside, so the clinician can see a // measurement they have adjusted since promoting it. Both come from measurementFigure so that // identical values render identically. measuredLabel: string | null; sliceLabel: string; // Set when the figure is in pixels (no pixel spacing) or states no unit; neither is a size. uncalibrated?: 'pixels' | 'no-unit'; } const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); // Display names for the tools this viewport registers. An unknown tool falls back to its own name // rather than being hidden: a measurement that silently disappears from this list reads as data // loss, and the panel is the one place a clinician looks to confirm nothing was lost. const TOOL_LABELS: Readonly> = Object.freeze({ Length: 'Length', EllipticalROI: 'Ellipse', Angle: 'Angle' }); const finite = (value: unknown): number | null => typeof value === 'number' && Number.isFinite(value) ? value : null; // The single cachedStats entry, whatever its key, and only when there is exactly one. The key is // `imageId:` and that url is minted per import, so a document restored after a re-import // can carry a stale key alongside a fresh one. The server reads the value the same way and refuses a // measurement it cannot resolve to exactly one entry, so picking a number out of an ambiguous pair // here would put a figure in front of the clinician that is not the figure being charted. const soleStats = (data: Record): Record | null => { const cached = isRecord(data.cachedStats) ? data.cachedStats : null; if (!cached) return null; const entries = Object.values(cached).filter(isRecord); return entries.length === 1 ? entries[0] : null; }; // FOR DISPLAY ONLY, AND NEVER SENT ANYWHERE. The value that reaches the chart is read server-side // out of the saved annotation document; this exists so the clinician can tell two rows apart, which // a tool name and a slice number alone cannot do for two calipers on one image. const sizeUnitProblem = (toolName: string, data: Record): MeasurementRow['uncalibrated'] => { const stats = soleStats(data); if (!stats || (toolName !== 'Length' && toolName !== 'EllipticalROI')) return undefined; const unit = toolName === 'Length' ? stats.unit : stats.areaUnit; if (typeof unit !== 'string' || unit === '') return 'no-unit'; return unit.startsWith('px') ? 'pixels' : undefined; }; const measuredText = (toolName: string, data: Record): string | null => { const stats = soleStats(data); if (!stats) return null; if (toolName === 'Length') { const length = finite(stats.length); const unit = typeof stats.unit === 'string' ? stats.unit : ''; return length === null ? null : measurementFigure(length, unit); } if (toolName === 'EllipticalROI') { const area = finite(stats.area); const unit = typeof stats.areaUnit === 'string' ? stats.areaUnit : ''; return area === null ? null : measurementFigure(area, unit); } if (toolName === 'Angle') { const angle = finite(stats.angle); // 'deg' AS A LITERAL, because Cornerstone records no unit for an angle - it is degrees by // construction, and it is the same literal the server supplies when it reads this same // annotation to chart it. Through the shared formatter like every other tool: an angle IS // charted now, so its figure is compared by eye against the one the chart holds, and a second // spelling of degrees here would show every charted angle as disagreeing with its own drawing. // The formatter binds the degree sign tight to the number, so the row still reads "37.2°". return angle === null ? null : measurementFigure(angle, 'deg'); } return null; }; // `annotations` is Cornerstone's whole annotation store, which is a module-level singleton shared by // every viewport open in the page - so the frame lookup below is what scopes this to the study being // listed, exactly as toStored's own lookup scopes what gets persisted. export const toMeasurementRows = (annotations: unknown[], frames: WadoRsFrame[]): MeasurementRow[] => { const sliceByImageId = new Map(); frames.forEach((frame, index) => { if (!sliceByImageId.has(frame.imageId)) sliceByImageId.set(frame.imageId, index); }); const rows: MeasurementRow[] = []; for (const raw of annotations) { if (!isRecord(raw)) continue; const metadata = isRecord(raw.metadata) ? raw.metadata : null; const annotationUid = typeof raw.annotationUID === 'string' ? raw.annotationUID : null; const toolName = metadata && typeof metadata.toolName === 'string' ? metadata.toolName : null; const imageId = metadata && typeof metadata.referencedImageId === 'string' ? metadata.referencedImageId : null; if (!annotationUid || !toolName || !imageId) continue; const sliceIndex = sliceByImageId.get(imageId); // NOT LISTED, AND NOT AN OMISSION WORTH APOLOGISING FOR. An annotation whose image is not // among these frames belongs to another study open in the same page, or to a series that // failed to import - and toStored drops exactly the same set, so it never reaches the saved // document either. Listing it would offer a tick box for a uid the server can only refuse. if (sliceIndex === undefined) continue; const toolLabel = TOOL_LABELS[toolName] ?? toolName; const measured = measuredText(toolName, isRecord(raw.data) ? raw.data : {}); rows.push({ annotationUid, toolName, label: measured === null ? toolLabel : `${toolLabel} ${measured}`, measuredLabel: measured, // The same phrasing and the same 1-based numbering the viewport's own counter and its // aria-label use, so a row and the image it names read as the same place. sliceLabel: `Image ${sliceIndex + 1} of ${frames.length}`, ...((problem) => (problem ? { uncalibrated: problem } : {}))(sizeUnitProblem(toolName, isRecord(raw.data) ? raw.data : {})) }); } return rows; };