Spaces:
Running
Running
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>
);
}
|