import React, { useCallback, useId, useMemo, useState } from 'react'; import { Text } from '../ui/text'; import { formatDate } from '../../utils/dateUtils.ts'; import { PromotionRefusalError, type AngleType, type PromotedMeasurement, type PromotionRequest, type PromotionResult, type PromotionOperationStatus } from '../../services/ImagingMeasurementService.tsx'; import type { MeasurementRow } from './measurementRows.ts'; import { ANGLE_TYPE_OPTIONS, PROMOTABLE_TOOLS, awaitingType, chartedAngleType, chosenAngleType, isAngle, isAngleType, isPromotable, promotionRequests } from './measurementPromotion.ts'; import './MeasurementPanel.css'; // The review step between drawing a measurement and it becoming part of the patient's record. // // PLAIN DATA IN, ANNOTATION IDS OUT. This component knows nothing about Cornerstone: it is handed // rows, told what the chart already holds, and hands back the id of the row sent to the chart - plus, // for an angle, which angle the clinician says it is, because that is the one thing about a drawing // that no drawing records. That is what lets it be rendered and asserted in a plain DOM, which the // viewport it sits under cannot be - jsdom has no WebGL. It is also the shape the promotion contract // requires: the value, the clinical code, the subject and the performer are all derived server-side // from the saved annotation document and the caller's own identity, so a measurement value or an // attribution never leaves here. Sending one would defeat the reason the promotion route exists at // all - that a charted number is provably the number that was measured, not one asserted by whoever // holds the browser. An angle type is not an exception to that: it selects among the server's own // fixed concepts rather than naming one, and the figure it is attached to is still the saved one. export interface MeasurementPanelProps { rows: MeasurementRow[]; promoted: PromotedMeasurement[]; // Whether the chart could actually be asked what it already holds. FALSE IS NOT "NOTHING IS // CHARTED" - it is "the question could not be answered", and the two must never be collapsed. // An unanswered question rendered as an empty chart invites the clinician to promote a // measurement that is already there. chartStateAvailable: boolean; // One entry per measurement, not a bare list of ids: an angle also carries which angle the // clinician says it is. That is the single clinical choice this side is allowed to make, and it // travels with the measurement it belongs to rather than as a setting on the request, because the // server refuses a type attached to anything that is not an angle. Resolves to a PromotionResult // (never void); a `retired` answer publishes nothing. onPromote: (measurements: PromotionRequest[]) => Promise; // Operations the server has not yet finished with, read-only - rendered as their own rows // (including a uid with no current drawing) and blocking that uid's own To chart until resolved. outstanding?: PromotionOperationStatus[]; // False while what is pending has not been read: an unread status cannot show nothing is pending. promotionStatusAvailable?: boolean; // Carries forward exactly the not-dispatched tokens of one operation. onContinue?: (operation: string, effects: { annotationUid: string; withdrawal: string }[]) => Promise; // Re-reads status alone; sends nothing. onCheck?: () => Promise; // Asks the server, by name, what became of one operation whose outcome is unknown - read-only. onRecover?: (operation: string, annotationUids: string[]) => Promise; } // What one status read did: `superseded` and `retired` publish nothing, a later read or opening owns the answer. export type PromotionStatusOutcome = 'available' | 'unavailable' | 'superseded' | 'retired'; // SHOWN ON THE ROW, NOT INSTEAD OF IT. Dropping unpromotable measurements from this list would read // as data loss to the clinician who drew them, and offering them To chart would offer an action that // always fails. A row carrying its own reason and no To chart says "cannot be charted" outright. const NOT_PROMOTABLE_REASON = 'Cannot be saved to the chart: this kind of measurement has no clinical code.'; // A SECOND, INDEPENDENT REASON, AND IT IS NOT THE TOOL'S FAULT. An elliptical region is a chartable // tool, but one whose statistics cannot be read to a single confident value has no figure to chart - // its row reads simply "Ellipse". The server refuses it, so the row offers no To chart. // The tool list above cannot see this case at all: the tool is promotable, this particular drawing is // not. const NO_VALUE_REASON = 'Cannot be saved to the chart: this measurement has no value yet. Finish or redraw it.'; // The server refuses a figure in pixels rather than chart it as millimetres. const IN_PIXELS_REASON = 'Cannot be saved to the chart: this image does not record its pixel size, so the figure is in pixels, not millimetres.'; const NO_UNIT_REASON = 'Cannot be saved to the chart: the figure records no unit, so its size is not known.'; // What the panel says about a measurement it can no longer point at on the image. Deleting a caliper // after promoting it does not retract the finding - an assertion in the chart is not scratch work - // so the entry stays listed. Hiding it would leave a chart entry whose origin the clinician can no // longer see, which is strictly worse than a row with nothing to click. const DELETED_DRAWING = 'The measurement it was made from is no longer on this study.'; // Shown when the figure on screen is not the figure in the chart; the row offers To chart again so // the chart can be amended to the number the clinician has corrected on screen. const DIFFERS_FROM_CHART = 'This differs from what is in the chart. Press To chart to update it.'; // SAID BEFORE THE CLINICIAN COMMITS, NOT AFTER. An angle with no type is refused by the server, so the // row says what it needs while its To chart is off, rather than explaining a rejection afterwards. const NEEDS_ANGLE_TYPE = 'Choose which angle this is before it can be saved to the chart.'; // WHICH ROW THE LAST REFUSAL WAS ABOUT. Every refusal that concerns one measurement names it, and a // refusal a clinician cannot trace to a row is barely better than none - the reason appears at the top // of the panel, and this is what says which of the listed measurements it is about. Past tense on // purpose: it describes the save that was attempted, so it stays true after the row is corrected and // before the next save replaces it. const REFUSED_MEASUREMENT = 'The last save to the chart was refused because of this measurement.'; const CHART_STATE_UNAVAILABLE = 'Chart status unavailable. Whether these measurements are already in the chart could not be confirmed, ' + 'so saving is turned off until it can be.'; const PROMOTE_FAILED = 'These measurements could not be saved to the chart. Check what is listed as saved before trying again.'; // NEVER "NOT SAVED". A write that was admitted, dispatched without proof, or whose amend attempt is // unresolved may already be sitting in the chart - "not saved" would be as wrong as "saved" here. const MAY_ALREADY_CHARTED = 'This measurement may already be in the chart. Use Check before trying again.'; // Said on an outstanding row whose write is proved unsent - the one clinical state this panel is // allowed to call "not saved", because a withdrawal token only exists for a write that never left. const OUTSTANDING_NOT_SAVED = 'Not saved yet.'; const OUTSTANDING_CHARTED = 'Saved to chart.'; const OUTSTANDING_NOT_TAKEN = 'This attempt did not save it. Check the chart before trying again.'; const PROMOTION_STATUS_UNAVAILABLE = 'Pending saves to the chart could not be checked, so saving is turned off until they can be.'; const CHECK_FAILED = 'What is pending could not be checked. Try Check again before saving.'; const isTerminalGood = (clinical: string): boolean => clinical === 'charted' || clinical === 'amended' || clinical === 'unchanged'; const isUnresolved = (clinical: string): boolean => clinical === 'unknown' || clinical === 'admission-unknown'; const outstandingWording = (clinical: string): string => { if (isTerminalGood(clinical)) return OUTSTANDING_CHARTED; if (clinical === 'not-dispatched') return OUTSTANDING_NOT_SAVED; if (clinical === 'unknown' || clinical === 'admission-unknown') return MAY_ALREADY_CHARTED; return OUTSTANDING_NOT_TAKEN; }; export const MeasurementPanel: React.FC = ({ rows, promoted, chartStateAvailable, onPromote, outstanding, promotionStatusAvailable = true, onContinue, onCheck, onRecover }) => { // The colons useId emits are stripped because these ids end up in `for`/`id` attribute pairs - // the same reason the viewport strips them from its own generated ids. const panelId = useId().replace(/:/g, ''); const [promotedInSession, setPromotedInSession] = useState([]); const [busy, setBusy] = useState(false); const [promoteError, setPromoteError] = useState(null); // The measurement the last refusal named, so the reason shown at the top of the panel can be // traced to a row. Null both when nothing was refused and when the refusal named nothing - a row // must never be marked as the culprit on a guess. const [refusedUid, setRefusedUid] = useState(null); // WHICH ANGLE EACH DRAWN ANGLE IS, AS THE CLINICIAN HAS SAID SO FAR. Keyed by annotation uid and // held only here: nothing in the drawing records it, the saved annotation document does not carry // it, and the chart's own answer is read back separately from the promoted entry. A choice made // here is a statement about one measurement, never a mode the panel is in. const [angleTypes, setAngleTypes] = useState>({}); // A PROMOTION THAT SUCCEEDED STAYS SUCCEEDED. `promoted` is re-read from the chart after every // promotion, and that read can fail on its own - leaving it empty while the measurement really is // charted. Rendering that as "not promoted yet" would invite the clinician to promote the same // drawing a second time, so what this panel watched land is remembered here and unioned with // whatever the chart currently reports. const chartedUids = useMemo( () => new Set([...promoted.map(entry => entry.annotationUid), ...promotedInSession]), [promoted, promotedInSession] ); const detailByUid = useMemo( () => new Map(promoted.map(entry => [entry.annotationUid, entry])), [promoted] ); // WHAT THE CHART SAYS EACH ANGLE IS, UNDER WHATEVER THE CLINICIAN HAS SINCE SAID ABOUT IT. The // choices above live only for as long as the page does, and nothing in a drawing records which // angle it is - so after a reload a charted Cobb angle would come back with its selector on the // placeholder, and sending the row to correct a nudged caliper would then be blocked for want of a // type the clinician had already stated and the chart already holds. That is not merely an extra // click: re-stating a concept under pressure to unblock a save is how a Cobb angle in the record // gets replaced by the unnamed one, and this panel would show the same "differs from the chart" // line for that as for the corrected figure they actually meant. // // Seeded ONLY where the chart's own concept can be named (see chartedAngleType), because the // placeholder is the honest reading of a concept this panel does not recognise. A local choice // always wins, so re-picking still corrects the record rather than being overwritten by it - and // taking a choice back on a charted row falls to what the chart holds rather than to nothing, // which is the same rule stated once: the chart is the authority until the clinician overrules it. const effectiveAngleTypes = useMemo(() => { const charted: Record = {}; for (const entry of promoted) { const type = chartedAngleType(entry); if (type !== null) charted[entry.annotationUid] = type; } return { ...charted, ...angleTypes }; }, [promoted, angleTypes]); // Anything that is not one of the offered types - the empty placeholder, or a value a browser // extension or an autofill put there - clears the choice rather than being stored. An unrecognised // type is refused by the server naming the uid, and a stored one would leave the save gate below // satisfied by a value that can only fail. const chooseAngleType = useCallback((annotationUid: string, value: string) => { setAngleTypes(current => { if (isAngleType(value)) return { ...current, [annotationUid]: value }; const cleared = { ...current }; delete cleared[annotationUid]; return cleared; }); }, []); // Shared by To chart and Continue saving: both answer with the same result union. const consumeResult = useCallback((result: PromotionResult) => { // An answer this panel cannot read proves nothing landed or did not: it may already be charted. if (!result || typeof result !== 'object' || !('kind' in result)) { setPromoteError(MAY_ALREADY_CHARTED); } else if (result.kind === 'retired') { // Publishes nothing: no success, no refusal, no cache change - it never belonged to // what is on screen any more. } else if (result.kind === 'unknown') { setPromoteError(MAY_ALREADY_CHARTED); } else { // settled | incomplete: every effect is read, not only the one just pressed - a mixed // batch answer speaks for every uid it names. const terminal = result.effects.filter(effect => isTerminalGood(effect.clinical)).map(effect => effect.annotationUid); if (terminal.length > 0) { setPromotedInSession(current => Array.from(new Set([...current, ...terminal]))); } if (result.effects.some(effect => effect.clinical === 'not-dispatched')) { setPromoteError(PROMOTE_FAILED); } else if (result.effects.some(effect => effect.clinical === 'unknown' || effect.clinical === 'admission-unknown')) { setPromoteError(MAY_ALREADY_CHARTED); } } }, []); const reportFailure = useCallback((error: unknown) => { // Message only, never the error itself: a failed request can carry the study url and the // ids inside it. What the clinician needs is that nothing can be assumed to have landed. console.error( 'MeasurementPanel: saving measurements to the chart failed:', error instanceof Error ? error.message : error ); // A REFUSAL SAYS WHAT IS WRONG AND WITH WHICH MEASUREMENT, and it is read generically: // there are many refusals that name a uid, and recognising particular messages would // leave every other one as an anonymous failure. The message is the server's own fixed // prose and carries no measured value or identity, so it is shown as it stands; anything // this side cannot read as a refusal keeps the generic wording, because a failure of // unknown shape is exactly the case where nothing can be assumed about what landed. const refusal = error instanceof PromotionRefusalError ? error : null; setPromoteError(refusal ? refusal.message : PROMOTE_FAILED); setRefusedUid(refusal?.annotationUid ?? null); }, []); // One measurement per press, as imaging.html draws it: each row carries its own To chart. const promote =useCallback(async (annotationUid: string) => { // COMPOSED IN measurementPromotion.ts, NOT HERE, and re-decided from the rows rather than // trusted from the button - see promotionRequests there for what it leaves out and why. Null // is "this row cannot be sent as it stands"; there is nothing to send either way. const measurements = promotionRequests(rows, [annotationUid], effectiveAngleTypes); if (measurements === null || measurements.length === 0) return; setBusy(true); setPromoteError(null); setRefusedUid(null); try { consumeResult(await onPromote(measurements)); } catch (error) { reportFailure(error); } finally { setBusy(false); } }, [rows, effectiveAngleTypes, onPromote, consumeResult, reportFailure]); const continueSaving = useCallback(async (operation: string, tokens: { annotationUid: string; withdrawal: string }[]) => { if (!onContinue) return; setBusy(true); setPromoteError(null); setRefusedUid(null); try { consumeResult(await onContinue(operation, tokens)); } catch (error) { reportFailure(error); } finally { setBusy(false); } }, [onContinue, consumeResult, reportFailure]); const check = useCallback(async () => { if (!onCheck) return; try { const outcome = await onCheck(); if (outcome === 'unavailable') setPromoteError(CHECK_FAILED); else if (outcome === 'available') setPromoteError(current => current === CHECK_FAILED ? null : current); } catch { setPromoteError(CHECK_FAILED); } }, [onCheck]); // An unknown outcome is settled only by the server's answer for that operation, never by a // status read that merely fails to list it. const recover = useCallback(async (operation: string, annotationUids: string[]) => { if (!onRecover) return; setBusy(true); setPromoteError(null); setRefusedUid(null); try { consumeResult(await onRecover(operation, annotationUids)); } catch (error) { reportFailure(error); } finally { setBusy(false); } }, [onRecover, consumeResult, reportFailure]); // WHAT THIS PANEL IS ENTITLED TO CLAIM ABOUT ONE ROW. While the chart can be read, that is // whatever the chart says plus whatever this panel watched land. While it cannot, it is ONLY what // this panel watched land - first-hand knowledge of a promotion it performed itself, which no // failed lookup can take away. A measurement charted in an earlier session falls out of this set // when the lookup fails, which is correct: the panel genuinely does not know, and the grouping is // dropped below rather than asserting the opposite. const isCharted = (annotationUid: string): boolean => chartStateAvailable ? chartedUids.has(annotationUid) : promotedInSession.includes(annotationUid); // ANY NAMED EFFECT BLOCKS ITS OWN To chart, whatever changes on screen meanwhile - a value edit, // a close/reopen or a practice switch never clears an outstanding write on its own; only the // server resolving it (and the caller dropping it from `outstanding`) does. const outstandingUids = useMemo( () => new Set((outstanding ?? []).flatMap(op => op.effects.map(effect => effect.annotationUid))), [outstanding] ); const drawnUids = new Set(rows.map(row => row.annotationUid)); const chartedRows = rows.filter(row => isCharted(row.annotationUid)); const unchartedRows = rows.filter(row => !isCharted(row.annotationUid)); // DELETING A CALIPER DOES NOT RETRACT THE FINDING IT PRODUCED. Scratch work is erasable; an // assertion in the patient's chart is not, and it goes on being visible in the observations list // whatever happens in the viewer. Dropping it from this panel because there is no longer a drawing // to hang it on would leave a chart entry the clinician can no longer see the origin of, which is // the worse of the two outcomes: nothing here can remove it, so the only question is whether they // are told it exists. Empty while the chart cannot be read - an entry with no drawing to show it // against is a claim about the record, and a panel that has just said it could not read the record // must not make one. const orphanedCharted = chartStateAvailable ? promoted.filter(entry => !drawnUids.has(entry.annotationUid)) : []; // NOTHING DRAWN IS NOT THE SAME AS NOTHING TO SHOW. A study whose only measurement was promoted // and then deleted has no rows at all, and returning the empty state here would hide that chart // entry behind the words "no measurements have been drawn" - which is both false about the chart // and the exact disappearance the entry is kept for. if (rows.length === 0 && orphanedCharted.length === 0 && (outstanding ?? []).length === 0) { return (

Measurements on this study

No measurements have been drawn on this study.
); } const attributionOf = (detail: PromotedMeasurement | undefined): string[] => [ detail?.performerDisplay, detail?.issued ? formatDate(detail.issued) : undefined ].filter((part): part is string => typeof part === 'string' && part.length > 0); // WHAT THE CHART HOLDS, IN THE CHART'S OWN WORDS. The concept comes first and the figure second // because that is the order the entry reads in the record: what was measured, then how much. Both // are read off the returned resource, never from anything chosen in this browser - the two diverge // the moment a clinician re-picks an angle's type, and a row restating the local choice would go on // naming a concept the chart has already moved past. Neither is claimed when the chart has not // reported it: a promotion this panel watched land has no resource read back yet. const chartedSummary = (detail: PromotedMeasurement | undefined): string => { const held = [detail?.codeDisplay, detail?.chartedLabel] .filter((part): part is string => typeof part === 'string' && part.length > 0); return held.length === 0 ? 'Saved to chart' : `Saved to chart as ${held.join(', ')}`; }; // ONE ROW SHAPE, CHARTED OR NOT. A charted row whose drawing has moved offers To chart again, so the // amend half of this feature keeps a control; an unchanged charted row has nothing to send. const measurementRow = (row: MeasurementRow) => { const promotable = isPromotable(row); const rowId = `${panelId}-${row.annotationUid}`; const charted = isCharted(row.annotationUid); const detail = detailByUid.get(row.annotationUid); const attribution = attributionOf(detail); // Both figures are produced by one formatter, so equal values render equal and a difference // here is a real one. Only claimed when both are actually known: a charted entry this panel // watched land in this session has no figure read back from the chart yet. const figureDiffers = charted && row.measuredLabel !== null && detail?.chartedLabel !== undefined && detail.chartedLabel !== row.measuredLabel; // A TYPE CORRECTION IS A PENDING DIFFERENCE EXACTLY AS A NUDGED CALIPER IS. A clinician who // charted a Cobb angle, realised it was acetabular and re-picked has changed nothing in the // record until they save - and without this the row would show the chart's concept beside // their new choice with nothing saying the two disagree, which is the very reading the figure // notice exists to prevent. Both are folded into one notice: two lines saying the same // measurement is out of date would be noise, and the action for both is identical. // // Claimed only when BOTH sides can actually be named. An unmade choice is not a disagreement, // and a charted concept this panel cannot name (see chartedAngleType) is a question it cannot // answer rather than an answer of "no". const selectedType = chosenAngleType(row, effectiveAngleTypes); const chartedType = chartedAngleType(detail); const typeDiffers = charted && isAngle(row) && selectedType !== null && chartedType !== null && selectedType !== chartedType; // A row the chart refuses is never asked to be saved again; its reason says why instead. const differs = promotable && (figureDiffers || typeDiffers); const needsType = promotable && awaitingType(row, effectiveAngleTypes); // Offered while the row is not in the chart, or the chart holds something it no longer shows. const offered = promotable && (!charted || differs); const refused = refusedUid === row.annotationUid; const hasOutstanding = outstandingUids.has(row.annotationUid); return (
  • {row.label} {row.sliceLabel} {/* PART OF THE CONTROL'S NAME, NOT OF THE PAGE — see the clip rule in the stylesheet. Two identical measurements on one image would otherwise present two To chart buttons with the same name, leaving someone using a screen reader no way to tell which of them they are about to chart. */} {row.annotationUid}
    {offered && ( )} {isAngle(row) && ( // ON THE ROW, NEXT TO ITS OWN TO CHART. Which angle this is belongs to one // measurement, so a study with two angles on it has two independent statements to // make - a single control above the list would make it impossible to say that one // is a Cobb angle and the other acetabular. // // STILL LIVE ON A CHARTED ROW, which is what makes a correction possible: a // clinician who charted a Cobb angle and then realised it was acetabular picks // again and saves, and the server amends the entry in place. Left enabled while // the chart cannot be read, too - stating which angle this is charts nothing on // its own, and the choice is kept for when saving comes back. Off only while a // save is in flight, or when the drawing // has no figure to chart at all and no choice could send it. )} {charted && ( // NAMES WHAT THE CHART HOLDS, not what is beside it on this row and not what is // chosen in the selector above. Both agree until the caliper moves or the type is // re-picked, and after that a bare "saved to chart" asserts something about the // record that is no longer true. {chartedSummary(detail)} )} {charted && attribution.length > 0 && ( {attribution.join(' · ')} )} {differs && (

    {DIFFERS_FROM_CHART}

    )} {needsType && (

    {NEEDS_ANGLE_TYPE}

    )} {refused && (

    {REFUSED_MEASUREMENT}

    )} {!promotable && (

    {row.uncalibrated === 'no-unit' ? NO_UNIT_REASON : row.uncalibrated ? IN_PIXELS_REASON : PROMOTABLE_TOOLS.includes(row.toolName) ? NO_VALUE_REASON : NOT_PROMOTABLE_REASON}

    )}
  • ); }; // No To chart: there is no drawing left to promote, and the server reads the value it charts out // of the saved annotation document, which no longer holds this uid either. What is offered instead // is the knowledge that the entry exists and where it came from. const orphanedRow = (entry: PromotedMeasurement) => { const attribution = attributionOf(entry); return (
  • {/* THE CHART'S OWN WORDS ARE ALL THERE IS TO NAME IT BY. Every other row is recognisable from the drawing it points at; this one has none left, so the concept the entry carries is the most identifying thing about it. */} {[entry.codeDisplay, entry.chartedLabel] .filter((part): part is string => typeof part === 'string' && part.length > 0) .join(', ') || 'Measurement'} Saved to chart {attribution.length > 0 && ( {attribution.join(' · ')} )}

    {DELETED_DRAWING}

  • ); }; return (

    Measurements on this study

    {!chartStateAvailable && (

    {CHART_STATE_UNAVAILABLE}

    )} {!promotionStatusAvailable && (

    {PROMOTION_STATUS_UNAVAILABLE}

    )} {/* Reachable with nothing listed as pending: a failed first read has no operation row to hang a Check on. */} {!promotionStatusAvailable && onCheck && ( )} {promoteError && (

    {promoteError}

    )} {/* NO GROUP HEADINGS WHILE THE CHART CANNOT BE READ. "Not in the chart" is a claim about the record, and a panel that has just said it could not read the record must not turn round and make one — a measurement charted in an earlier session would otherwise move under that heading the moment a lookup failed, which is the same false reassurance as showing it unpromoted. One flat list says only what is true: here is what is drawn, and here is the one thing known first-hand about it. */} {chartStateAvailable ? ( <> {unchartedRows.length > 0 && ( <>

    Not in the chart

      {unchartedRows.map(measurementRow)}
    )} {(chartedRows.length > 0 || orphanedCharted.length > 0) && ( <>

    In the chart

      {chartedRows.map(measurementRow)} {orphanedCharted.map(orphanedRow)}
    )} ) : (
      {rows.map(measurementRow)}
    )} {busy && Saving to chart…} {/* ONE BLOCK PER OPERATION STILL OUTSTANDING, each with its own Check and - only once there is a token to carry forward - its own Continue saving. A uid with no drawing left to show it against is still listed: deleting a caliper never withdraws a write already under way. */} {(outstanding ?? []).length > 0 && ( <>

    Pending

      {(outstanding ?? []).map(op => { const unresolved = op.effects.filter(effect => isUnresolved(effect.clinical)).map(effect => effect.annotationUid); const tokens = op.effects .filter(effect => effect.clinical === 'not-dispatched' && typeof effect.withdrawal === 'string' && effect.withdrawal.length > 0) .map(effect => ({ annotationUid: effect.annotationUid, withdrawal: effect.withdrawal as string })); return (
      • {op.effects.map(effect => (
      • {effect.annotationUid}{' '} {outstandingWording(effect.clinical)}
      • ))}
      {tokens.length > 0 && ( )}
    • ); })}
    )}
    ); }; export default MeasurementPanel;