import type { AngleType, PromotionRequest } from '../../services/ImagingMeasurementService.tsx'; import type { MeasurementRow } from './measurementRows.ts'; // WHICH DRAWINGS CAN BE CHARTED, AND WHAT THE PANEL WOULD SAY ABOUT THEM. // // Pure module: no React, no I/O, no Cornerstone runtime import - the same shape and the same reason as // measurementRows.ts beside it. These are decisions about what may be asserted in a patient's record, // not about how a row looks, and every one of them sits behind a control that is already disabled when // it would refuse. A disabled control is presentation, and presentation is not what a guarantee should // rest on; a disabled BUTTON is also the one control React dispatches no click for at all, so a test // driving the panel's DOM cannot reach the composition below and so could never fail on it. Here it // can be asserted directly. // Cornerstone's own name for the angle tool, and the string the server keys its angle handling on. // Held as a constant because it is asked in several places and a typo in any one of them would // silently turn the type selector, its save gate or the request shape off for angles. export const ANGLE_TOOL_NAME = 'Angle'; // The drawing tools whose measurements can become a charted Observation. This mirrors the server's // tool-to-clinical-concept table, which is the enforcement; this list only decides whether a // measurement is offered with a tick box, and a tick box that can only ever fail is worse than none. export const PROMOTABLE_TOOLS: readonly string[] = ['Length', 'EllipticalROI', ANGLE_TOOL_NAME]; // WHAT THE CLINICIAN CHOOSES BETWEEN, AND WHY THERE IS A CHOICE AT ALL. A length is a length and an // ellipse's area is an area, so their clinical concepts follow from the drawing tool. An angle's does // not: the same two-line gesture measures a Cobb angle, an acetabular angle, or an angle with no // published concept naming it, and nothing in the drawing distinguishes them. Only the person who // drew it knows. // // KEYED BY AngleType SO THE SET CANNOT DRIFT SILENTLY. The concepts are the server's to assign and // live in its own fixed table (measurementCoding.ts); AngleType is the wire contract that table is // reached through, and a Record over it makes the compiler refuse a fourth angle that nothing here // offers. An angle a clinician has no way to select is one they cannot chart at all, and it would // otherwise fail silently rather than at build. // // A COPY FOR READING BACK, NEVER FOR ASSERTING. What leaves the client is the type name and nothing // else - no system, no code, no display - and that is what makes a charted figure provably the // measured one rather than one the browser named. These entries are how a resource the CHART returned // is recognised as one of the three: a concept is identified by its system and code, so matching on // the display text instead would let a re-worded display quietly turn a charted Cobb angle into one // this panel cannot name. The labels are the wording the chart holds, so a choice and the entry it // produces read the same. All of it is pinned by a test that names the server file to check it // against, since nothing in this repo can see that table; a label drift misleads nobody about the // record, because a charted row reads its concept back off the chart's own resource, and a // system-or-code drift stops the panel recognising a concept rather than making it name a wrong one. export interface AngleConcept { label: string; system: string; code: string; } export const ANGLE_TYPE_CONCEPTS: Readonly> = Object.freeze({ cobb: Object.freeze({ label: 'Cobb angle', system: 'http://snomed.info/sct', code: '285285000' }), acetabular: Object.freeze({ label: 'Acetabular angle', system: 'http://dicom.nema.org/resources/ontology/DCM', code: '121224' }), other: Object.freeze({ label: 'Image-derived angle', system: 'http://akashic.health/fhir/CodeSystem/imaging-measurement', code: 'image-angle' }) }); export const ANGLE_TYPE_OPTIONS: readonly { value: AngleType; label: string }[] = (Object.keys(ANGLE_TYPE_CONCEPTS) as AngleType[]) .map(value => ({ value, label: ANGLE_TYPE_CONCEPTS[value].label })); export const isAngleType = (value: string): value is AngleType => ANGLE_TYPE_OPTIONS.some(option => option.value === value); // Both halves of "can this drawing be charted at all", in one place because the tick box and the // promote path have to agree - the second is the guarantee, the first is only the presentation of it. export const isPromotable = (row: MeasurementRow): boolean => PROMOTABLE_TOOLS.includes(row.toolName) && row.measuredLabel !== null && !row.uncalibrated; export const isAngle = (row: MeasurementRow): boolean => row.toolName === ANGLE_TOOL_NAME; // WHICH ANGLE THE CLINICIAN HAS SAID THIS IS - VALIDATED HERE, NOT TRUSTED FROM WHERE IT IS KEPT. // Null means no usable choice has been made. // // The map is TYPED as holding only recognised types, and that type is a claim about the code that // writes it rather than a fact about the value. Asking only whether the entry is `undefined` would // rest the whole requirement on that one writer staying correct - and the value that would slip // through is not exotic: reverting the selector to its placeholder is an ordinary two-click gesture // carrying the empty string, which is not `undefined`. A gate reading only for `undefined` would // enable saving and pass that empty string to a server that refuses it, naming the uid, after the // clinician had already committed. // // So every reader of a chosen type goes through here, and the answer is derived from the offered // options themselves rather than from a second list that could drift from them. export const chosenAngleType = ( row: MeasurementRow, angleTypes: Record ): AngleType | null => { const chosen: string | undefined = angleTypes[row.annotationUid]; return chosen !== undefined && isAngleType(chosen) ? chosen : null; }; // An angle that has not been told which angle it is. Asked in three places - the save button, the // row's own reason, and the composition below - from one definition, because a save gate and the // explanation beside it disagreeing is worse than either being wrong on its own. export const awaitingType = (row: MeasurementRow, angleTypes: Record): boolean => isAngle(row) && chosenAngleType(row, angleTypes) === null; // WHICH OF THESE ANGLES THE CHART ITSELF HOLDS, read off the concept on the charted resource and // matched against this panel's own vocabulary. // // MATCHED ON THE SYSTEM AND THE CODE, WHICH ARE WHAT IDENTIFY A CONCEPT. The display beside them is // prose for a clinician to read and the server may re-word it without changing anything that was // charted - so recognising a concept by its wording would make an ordinary re-wording turn every // already-charted angle into one this panel cannot name, and the notice that says a clinician's // choice disagrees with the record would go silent exactly where it is needed. Both halves are // required together: the same digits name unrelated concepts in different terminologies. // // Null is "cannot tell", never "does not match". A charted concept this panel does not recognise - a // measurement charted by something that is not this panel, a concept added since - is not evidence // that anything disagrees, and reporting one as a disagreement would be the same false alarm as // reading two spellings of one unit as two different measurements. Silence is the honest answer to a // question this side cannot answer. export const chartedAngleType = ( charted: { codeSystem?: string; code?: string } | undefined ): AngleType | null => { if (charted?.codeSystem === undefined || charted.code === undefined) return null; return ANGLE_TYPE_OPTIONS .find(option => ANGLE_TYPE_CONCEPTS[option.value].system === charted.codeSystem && ANGLE_TYPE_CONCEPTS[option.value].code === charted.code)?.value ?? null; }; // WHAT THE PANEL WOULD SEND, AND THE ONLY PLACE IT IS DECIDED. // // Returns null when the selection cannot be sent as it stands. A ticked angle with no type is refused // server-side and the refusal takes the whole batch with it, so the send is abandoned rather than the // angle being dropped out of it: a clinician who ticked an angle and was told nothing would read the // rest landing as all of it landing, and the chart would be missing a finding they believe they filed. // // Emitted in the order the rows are listed rather than the order they were ticked, so what is sent // does not depend on which box the clinician happened to click first. // // Unchartable measurements are left out rather than turning the whole send away, because unlike an // angle awaiting a type they can never be sent at any point - and promotion is all-or-nothing // server-side, so one of them in the batch would cost the clinician every other measurement they // ticked alongside it. export const promotionRequests = ( rows: MeasurementRow[], selected: string[], angleTypes: Record ): PromotionRequest[] | null => { const sending = rows.filter(row => selected.includes(row.annotationUid) && isPromotable(row)); if (sending.some(row => awaitingType(row, angleTypes))) return null; // READ THROUGH chosenAngleType RATHER THAN OUT OF THE MAP, so this stays a real second gate and // not merely a second location for the first one. Were the check above ever removed, an // unrecognised type would still not travel: the row would go out with no type at all and be // refused by name, which is a bad outcome the clinician can act on rather than a wrong concept // filed in a patient's chart. // // The type rides only on an angle: the server refuses one attached to any other tool, and the key // is left off entirely rather than set to undefined, because presence is what it reads. return sending.map(row => { const angleType = chosenAngleType(row, angleTypes); return isAngle(row) && angleType !== null ? { annotationUid: row.annotationUid, angleType } : { annotationUid: row.annotationUid }; }); };