File size: 11,001 Bytes
2706212
2e6a666
2706212
 
 
4b71cdf
2706212
 
 
 
 
 
 
2e6a666
7e30344
2706212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e6a666
2706212
 
7e30344
 
 
 
2706212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e6a666
 
 
2706212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7e30344
 
2706212
 
 
 
 
 
 
7e30344
2706212
 
7e30344
 
 
2706212
 
7e30344
2706212
 
 
 
7e30344
2706212
7e30344
2706212
7e30344
 
 
2706212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e6a666
2706212
 
 
 
 
 
 
 
 
4b71cdf
2706212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e6a666
2706212
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
// "What does this look like?" — a right-side drawer over the rail with generic teaching material for one finding
// type: definition, key signs, sign schematics (round 5), look-alikes, and example films from a separate reference set with the expert outline in
// cyan. It never says anything about the case being read, so it is available before submit.
// Mount <ReferenceDrawer /> once on a page; open it from anywhere with openReference(label) or <InfoButton />.
import { useEffect, useRef, useState } from 'react';
import { track } from '../analytics';
import { useQuery } from '@tanstack/react-query';
import { assetUrl } from '../api/client';
import { fetchReference } from '../api/reference';
import { labelDisplay } from '../api/labels';
import type { ReferenceExample, ReferenceFilm, ReferenceLabel } from './guard';
import { uiTerms } from './guard';
import { openReference, useReference } from './store';
import { SignsToKnow } from './SignsToKnow';
import { VolumeExample } from './VolumeExample';
import s from './Reference.module.css';

const REVIEW: Record<string, string> = {
  ai_draft: 'AI draft — not yet reviewed',
  student_reviewed: 'Student reviewed',
  radiologist_reviewed: 'Radiologist reviewed',
};

export const EXAMPLES_NOTE = 'Examples from a separate reference set, not from your cases.';

/** The small "i" next to a finding name. */
export function InfoButton({ label, display, dark = false }: { label: string; display?: string; dark?: boolean }) {
  const name = display ?? labelDisplay(label);
  return (
    <button
      type="button"
      className={`${s.info} ${dark ? s.infoDark : ''}`}
      aria-label={`What does ${name.toLowerCase()} look like?`}
      title={`What does ${name.toLowerCase()} look like?`}
      data-testid={`info-${label}`}
      onClick={(e) => { e.stopPropagation(); openReference(label); }}
    >
      i
    </button>
  );
}

function Film({ film, finding, caption, showOutline, alt, small = false }: {
  film: ReferenceFilm; finding?: ReferenceExample['finding']; caption: string; showOutline: boolean; alt: string; small?: boolean;
}) {
  const [failed, setFailed] = useState(false);
  const pts = finding?.polygon?.map(([x, y]) => `${x},${y}`).join(' ');
  const b = finding?.bbox;
  return (
    <figure className={`${s.figure} ${small ? s.figureSmall : ''}`} data-testid="reference-example">
      <div className={s.film} style={{ aspectRatio: `${film.width} / ${film.height}` }}>
        {failed ? (
          <p className={s.filmNote}>This example film could not be loaded.</p>
        ) : (
          <img src={assetUrl(film.image_url)} alt={alt} loading="lazy" draggable={false} onError={() => setFailed(true)} />
        )}
        {!failed && showOutline && (pts || b) && (
          <svg viewBox={`0 0 ${film.width} ${film.height}`} preserveAspectRatio="none" aria-hidden="true" data-testid="reference-outline">
            {pts ? <polygon points={pts} className={s.outline} /> : b ? <rect x={b[0]} y={b[1]} width={b[2] - b[0]} height={b[3] - b[1]} className={s.outline} /> : null}
          </svg>
        )}
      </div>
      {caption && <figcaption className={s.caption}>{caption}</figcaption>}
    </figure>
  );
}

function Card({ card, normals, firstSigns }: { card: ReferenceLabel; normals: ReferenceFilm[]; firstSigns: string[] }) {
  const [outlines, setOutlines] = useState(true);
  const examples = card.examples.slice(0, 3);
  const volumetric = card.modality !== 'cxr';
  const scan = card.modality === 'mr' ? 'brain MRI' : 'abdominal CT';
  // Normal chest films are the comparison for a chest finding only; CT / MR normals of the same scan type, when sent.
  const normalsHere = normals.filter((n) => n.modality === card.modality);
  const cap = (e: ReferenceExample) => {
    const where = e.finding?.relative_location ?? '';
    return where ? where.charAt(0).toUpperCase() + where.slice(1) : card.display;
  };
  return (
    <>
      {card.one_liner ? <p className={s.lede} data-testid="reference-definition">{uiTerms(card.one_liner)}</p> : <p className={s.muted}>No definition for this finding yet.</p>}

      {card.key_signs.length > 0 && (
        <section className={s.section}>
          <h3 className={s.h3}>Key signs</h3>
          <ul className={s.list}>{card.key_signs.map((k, i) => <li key={i}>{uiTerms(k)}</li>)}</ul>
        </section>
      )}

      {/* Round 5: the signs drawn and explained; the ones drawn on the film just read come first. */}
      <SignsToKnow label={card.label} first={firstSigns} />

      {card.commonly_confused_with.length > 0 && (
        <section className={s.section}>
          <h3 className={s.h3}>Often confused with</h3>
          <div className={s.chips}>
            {card.commonly_confused_with.map((l) => (
              <button key={l} type="button" className={s.chip} onClick={() => openReference(l)} title={`Open ${labelDisplay(l).toLowerCase()}`}>
                {labelDisplay(l)}
              </button>
            ))}
          </div>
        </section>
      )}

      {card.mimics.length > 0 && (
        <section className={s.section}>
          <h3 className={s.h3}>Commonly mistaken normal structures</h3>
          <ul className={s.list}>{card.mimics.map((k, i) => <li key={i}>{uiTerms(k)}</li>)}</ul>
        </section>
      )}

      {card.search_tip && (
        <section className={s.section}>
          <h3 className={s.h3}>How to look for it</h3>
          <p className={s.p}>{uiTerms(card.search_tip)}</p>
        </section>
      )}

      <section className={s.section} data-testid="reference-examples">
        <div className={s.row}>
          <h3 className={s.h3}>{volumetric ? 'Example studies' : 'Example films'}</h3>
          {examples.some((e) => e.finding?.polygon || e.finding?.bbox || e.volume) && (
            <button type="button" className={s.link} aria-pressed={outlines} onClick={() => setOutlines((v) => !v)}>
              {outlines ? 'Hide outlines' : 'Show outlines'}
            </button>
          )}
        </div>
        <p className={s.note} data-testid="reference-note">{EXAMPLES_NOTE}</p>
        {examples.length === 0 ? (
          <p className={s.muted} data-testid="reference-no-examples">No example {volumetric ? 'studies' : 'films'} for this finding yet.</p>
        ) : (
          <>
            {examples.map((e) => (e.volume
              ? <VolumeExample key={e.case_id || e.volume.volume_url} ex={e} outline={outlines} alt={`Example ${scan} with ${card.display.toLowerCase()}${outlines ? ' outlined' : ''}`} />
              : <Film key={e.case_id || e.image_url} film={e} finding={e.finding} caption={cap(e)} showOutline={outlines}
                alt={`Example chest radiograph with ${card.display.toLowerCase()}${outlines ? ' outlined' : ''}`} />
            ))}
            <p className={s.key}><span className={s.keyCyan} /> {volumetric ? 'Reference segmentation · scroll the slider to move through the slices' : 'Expert outline · patient right is on the image left'}</p>
          </>
        )}
      </section>

      {normalsHere.length > 0 && (
        <section className={s.section} data-testid="reference-normals">
          <h3 className={s.h3}>{volumetric ? 'No lesion, for comparison' : 'Normal chest, for comparison'}</h3>
          <div className={s.normals}>
            {normalsHere.slice(0, 3).map((n, i) => (n.volume
              ? <VolumeExample key={n.case_id || n.volume.volume_url} ex={{ ...n, finding: null }} outline={false} alt={`${scan} with no labelled lesion, example ${i + 1}`} small />
              : <Film key={n.case_id || n.image_url} film={n} caption="" showOutline={false} alt={`Normal chest radiograph, example ${i + 1}`} small />
            ))}
          </div>
        </section>
      )}

      <div className={s.foot}>
        {card.radiopaedia_url && (
          <a href={card.radiopaedia_url} target="_blank" rel="noopener noreferrer" className={s.ext} data-testid="reference-radiopaedia">
            Read more on Radiopaedia <span aria-hidden="true">↗</span><span className={s.sr}> (opens in a new tab)</span>
          </a>
        )}
        {card.review_status && <span className={s.badge}>{REVIEW[card.review_status] ?? card.review_status.replace(/_/g, ' ')}</span>}
      </div>
    </>
  );
}

export function ReferenceDrawer() {
  const label = useReference((st) => st.label);
  const signIds = useReference((st) => st.signIds);
  const close = useReference((st) => st.close);
  const ref = useRef<HTMLElement>(null);
  const closeRef = useRef<HTMLButtonElement>(null);
  const q = useQuery({ queryKey: ['reference'], queryFn: fetchReference, enabled: !!label, staleTime: Infinity, retry: false });

  // Close when the page that mounted the drawer goes away.
  useEffect(() => () => useReference.getState().close(), []);
  // Focus moves into the drawer when it opens and returns to where it was when it closes.
  const open = !!label;
  useEffect(() => { if (open) track('reference_open'); }, [open]);
  useEffect(() => {
    if (!open) return;
    const prev = document.activeElement as HTMLElement | null;
    closeRef.current?.focus({ preventScroll: true });
    return () => { if (prev && document.contains(prev)) prev.focus?.({ preventScroll: true }); };
  }, [open]);
  useEffect(() => { ref.current?.scrollTo?.({ top: 0 }); }, [label]);

  if (!label) return null;
  const card = q.data?.labels.find((l) => l.label === label) ?? null;
  const title = card?.display ?? labelDisplay(label);
  return (
    <aside
      ref={ref}
      className={s.drawer}
      role="dialog"
      aria-labelledby="reference-h"
      data-testid="reference-drawer"
      data-label={label}
      // Keys typed in the drawer stay in the drawer: reading-room shortcuts (N, H, Enter…) must not fire behind it.
      onKeyDown={(e) => {
        if (e.key === 'Escape') { e.preventDefault(); close(); }
        if (e.key !== 'Tab') e.stopPropagation();
      }}
    >
      <header className={s.head}>
        <div>
          <p className={s.kicker}>What does this look like?</p>
          <h2 id="reference-h" className={s.title}>{title}</h2>
        </div>
        <button ref={closeRef} type="button" className={s.close} onClick={close} data-testid="reference-close">Close</button>
      </header>
      <p className={s.generic}>General teaching material. It says nothing about the film you are reading.</p>
      {q.isPending ? (
        <p className={s.muted}>Loading the reference…</p>
      ) : q.isError ? (
        <p className={s.notice} data-testid="reference-unavailable">
          The reference is not available right now. <button type="button" className={s.link} onClick={() => q.refetch()}>Try again</button>
        </p>
      ) : card ? (
        <Card key={card.label} card={card} normals={q.data?.normal_examples ?? []} firstSigns={signIds} />
      ) : (
        <p className={s.notice} data-testid="reference-empty">There is no reference card for {title.toLowerCase()} yet.</p>
      )}
    </aside>
  );
}