diff --git a/docs/branch-review-records/f61ebd10b574f637430227c9e288d092da753d278f572e5de06fc2d37830d6f2.record.md b/docs/branch-review-records/f61ebd10b574f637430227c9e288d092da753d278f572e5de06fc2d37830d6f2.record.md new file mode 100644 index 0000000000..1083ba0765 --- /dev/null +++ b/docs/branch-review-records/f61ebd10b574f637430227c9e288d092da753d278f572e5de06fc2d37830d6f2.record.md @@ -0,0 +1 @@ +| 2026-08-13 | codex/diagnosis-map-viewer-20260813 | e2537a49cf2b026b4ed00fd4decd26398a8ed6e9 | PR #1918 diagnosis map viewer heavy review | Fixed the PR-introduced Prettier gate failure in the new DOM regression test; a distinct manual adversarial pass found no additional reproducible P0-P2 defect across responsive layout, interactions, accessibility safeguards, clinical-summary isolation, compare routing, or server-client data boundaries | Exact-head pre-fix CI: Secret Scan, SAST, Build, Unit coverage, Safety and config, Lighthouse budget, Advisory UI, Production UI critical, and Production UI shards 1-3 passed; Static PR checks and PR required failed only format:changed. Offline: exact original blob reconstruction proved a one-blank-line-only fix; TypeScript syntax parse and responsive layout containment and overlap matrices passed. Post-push exact-head CI is recorded in the PR summary. | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 01ccf9680f..0f706d3d38 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1093,6 +1093,7 @@ "tests/app-announcements-root.test.ts", "tests/design-sync-visual-exports.test.ts", "tests/design-system-target-evidence.test.ts", + "tests/diagnosis-map-panel.dom.test.tsx", "tests/sheet.dom.test.tsx", "tests/ui-v2-components.dom.test.tsx" ], diff --git a/src/components/differentials/diagnosis-map-panel.tsx b/src/components/differentials/diagnosis-map-panel.tsx index c9f1bc1c62..4563525efe 100644 --- a/src/components/differentials/diagnosis-map-panel.tsx +++ b/src/components/differentials/diagnosis-map-panel.tsx @@ -1,9 +1,19 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react"; -import { createBrowserStore } from "@/lib/client-store-factory"; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, +} from "react"; import Link from "next/link"; import { + ChevronDown, + ChevronUp, CircleCheck, Crosshair, Filter, @@ -16,38 +26,49 @@ import { ShieldAlert, } from "lucide-react"; +import { useEventCallback } from "@/components/clinical-dashboard/use-event-callback"; import { Sheet } from "@/components/ui/sheet"; import { cn, floatingControl, primaryControl, toolbarButton } from "@/components/ui-primitives"; +import { differentialStatusLabel, type DifferentialRelatedMapDetail } from "@/lib/differential-detail"; +import { differentialSelectedCompareHref } from "@/lib/differentials-navigation"; import type { DifferentialLikelihood, DifferentialMapNode, DifferentialRecord } from "@/lib/differentials"; -type MapPoint = { - id: string; +type ActivePoint = { x: number; y: number; }; -type ActivePoint = { +type MapView = { + scale: number; + pan: ActivePoint; +}; + +type SelectedNode = DifferentialMapNode | "diagnosis"; + +type LayoutNode = { + id: string; x: number; y: number; + width: number; + height: number; }; -type SelectedNode = DifferentialMapNode | "diagnosis"; +type MapLayout = { + focus: LayoutNode; + related: LayoutNode[]; +}; + +type MapMetrics = { + width: number; + height: number; + layout: MapLayout; +}; -const graphSizePx = 672; -const minScale = 0.44; -const maxScale = 1.9; -const largeScreenQuery = "(min-width: 1024px)"; - -const mapPoints: MapPoint[] = [ - { id: "slot-0", x: 50, y: 17 }, - { id: "slot-1", x: 80, y: 34 }, - { id: "slot-2", x: 77, y: 66 }, - { id: "slot-3", x: 50, y: 82 }, - { id: "slot-4", x: 20, y: 45 }, - { id: "slot-5", x: 28, y: 70 }, - { id: "slot-6", x: 24, y: 25 }, - { id: "slot-7", x: 72, y: 19 }, -]; +const fitView: MapView = { scale: 1, pan: { x: 0, y: 0 } }; +const minScale = 0.9; +const maxScale = 2.2; +const phoneLayoutBreakpoint = 640; +const mapVisibilityInset = 48; const likelihoodTone: Record = { "most-likely": @@ -55,14 +76,13 @@ const likelihoodTone: Record = { possible: "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]", "less-likely": "border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]", - "must-not-miss": - "border-[color:var(--danger)] bg-[color:var(--danger-soft)] text-[color:var(--danger)] ring-2 ring-[color:var(--danger)]/20", + "must-not-miss": "border-[color:var(--danger)] bg-[color:var(--danger-soft)] text-[color:var(--danger)]", }; const lineTone: Record = { "most-likely": "var(--clinical-accent)", - possible: "color-mix(in srgb, var(--clinical-accent) 58%, transparent)", - "less-likely": "color-mix(in srgb, var(--decoration-soft) 58%, transparent)", + possible: "color-mix(in srgb, var(--clinical-accent) 62%, transparent)", + "less-likely": "color-mix(in srgb, var(--decoration-soft) 62%, transparent)", "must-not-miss": "var(--danger)", }; @@ -73,6 +93,12 @@ const likelihoodLabels: Record = { "must-not-miss": "Must-not-miss", }; +const statusTone: Record = { + emergent: "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)] text-[color:var(--danger)]", + urgent: "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]", + routine: "border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]", +}; + function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } @@ -85,39 +111,153 @@ function midpoint(a: ActivePoint, b: ActivePoint) { return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; } -function pointForIndex(index: number) { - return mapPoints[index % mapPoints.length]; +function mobileMapMinimumHeight(nodeCount: number, interactive: boolean) { + const rows = Math.max(1, Math.ceil(nodeCount / 2)); + return (interactive ? 168 : 154) + rows * (interactive ? 88 : 80); } -function getNodePoint(node: DifferentialMapNode, nodes: DifferentialMapNode[]) { - const index = nodes.findIndex((item) => item.id === node.id); - return pointForIndex(index < 0 ? 0 : index); -} +function buildMapLayout(width: number, height: number, nodes: DifferentialMapNode[], interactive: boolean): MapLayout { + if (width < phoneLayoutBreakpoint) { + const gutter = width < 340 ? 8 : 12; + const columnGap = width < 340 ? 8 : 12; + const nodeWidth = clamp((width - gutter * 2 - columnGap) / 2, 104, interactive ? 144 : 132); + const nodeHeight = interactive ? 78 : 70; + const focusWidth = clamp(width - gutter * 4, 132, interactive ? 200 : 176); + const focusHeight = interactive ? 78 : 70; + const rowGap = interactive ? 12 : 10; + const connectorGap = interactive ? 52 : 44; + const rows = Math.max(1, Math.ceil(nodes.length / 2)); + const requiredHeight = gutter * 2 + focusHeight + connectorGap + rows * nodeHeight + (rows - 1) * rowGap; + const top = gutter + Math.max(0, (height - requiredHeight) / 2); + const focus = { + id: "diagnosis", + x: width / 2, + y: top + focusHeight / 2, + width: focusWidth, + height: focusHeight, + }; + const firstRowY = top + focusHeight + connectorGap + nodeHeight / 2; + const leftX = gutter + nodeWidth / 2; + const rightX = width - gutter - nodeWidth / 2; + + return { + focus, + related: nodes.map((node, index) => { + const row = Math.floor(index / 2); + const isUnpairedLast = nodes.length % 2 === 1 && index === nodes.length - 1; + return { + id: node.id, + x: isUnpairedLast ? width / 2 : index % 2 === 0 ? leftX : rightX, + y: firstRowY + row * (nodeHeight + rowGap), + width: nodeWidth, + height: nodeHeight, + }; + }), + }; + } -function subscribeLargeScreen(callback: () => void) { - const media = window.matchMedia(largeScreenQuery); - media.addEventListener("change", callback); - return () => media.removeEventListener("change", callback); + const nodeWidth = width < 900 ? 136 : 156; + const nodeHeight = 76; + const focusWidth = width < 900 ? 168 : 188; + const focusHeight = 82; + const center = { x: width / 2, y: height / 2 }; + const radiusX = Math.max(0, width / 2 - nodeWidth / 2 - 20); + const radiusY = Math.max(0, height / 2 - nodeHeight / 2 - 20); + const count = Math.max(1, nodes.length); + + return { + focus: { id: "diagnosis", ...center, width: focusWidth, height: focusHeight }, + related: nodes.map((node, index) => { + const angle = -Math.PI / 2 + (index * Math.PI * 2) / count; + return { + id: node.id, + x: center.x + Math.cos(angle) * radiusX, + y: center.y + Math.sin(angle) * radiusY, + width: nodeWidth, + height: nodeHeight, + }; + }), + }; } -function getLargeScreenSnapshot() { - return window.matchMedia(largeScreenQuery).matches; +function renderedPoint(point: ActivePoint, view: MapView, metrics: Pick) { + const center = { x: metrics.width / 2, y: metrics.height / 2 }; + return { + x: center.x + view.pan.x + (point.x - center.x) * view.scale, + y: center.y + view.pan.y + (point.y - center.y) * view.scale, + }; } -const useLargeScreenStore = createBrowserStore(subscribeLargeScreen, getLargeScreenSnapshot, false); +function clampView(view: MapView, metrics: MapMetrics): MapView { + const scale = clamp(view.scale, minScale, maxScale); + const nodes = [metrics.layout.focus, ...metrics.layout.related]; + const centeredView = { scale, pan: { x: 0, y: 0 } }; + const bounds = nodes.reduce( + (current, node) => { + const point = renderedPoint(node, centeredView, metrics); + return { + left: Math.min(current.left, point.x - node.width / 2), + right: Math.max(current.right, point.x + node.width / 2), + top: Math.min(current.top, point.y - node.height / 2), + bottom: Math.max(current.bottom, point.y + node.height / 2), + }; + }, + { + left: Number.POSITIVE_INFINITY, + right: Number.NEGATIVE_INFINITY, + top: Number.POSITIVE_INFINITY, + bottom: Number.NEGATIVE_INFINITY, + }, + ); -function nodeLabel(node: SelectedNode, record: DifferentialRecord) { - return node === "diagnosis" ? record.title : node.label; + function clampAxis(value: number, start: number, end: number, viewportSize: number) { + const minimum = mapVisibilityInset - end; + const maximum = viewportSize - mapVisibilityInset - start; + if (minimum > maximum) return (viewportSize - start - end) / 2; + return clamp(value, minimum, maximum); + } + + return { + scale, + pan: { + x: clampAxis(view.pan.x, bounds.left, bounds.right, metrics.width), + y: clampAxis(view.pan.y, bounds.top, bounds.bottom, metrics.height), + }, + }; } -function NodeBadge({ label, selected, className }: { label: string; selected?: boolean; className?: string }) { +function zoomAroundAnchor( + view: MapView, + nextScaleValue: number, + previousAnchor: ActivePoint, + nextAnchor: ActivePoint, + metrics: MapMetrics, +) { + const nextScale = clamp(nextScaleValue, minScale, maxScale); + const viewportCenter = { x: metrics.width / 2, y: metrics.height / 2 }; + const modelOffset = { + x: (previousAnchor.x - viewportCenter.x - view.pan.x) / view.scale, + y: (previousAnchor.y - viewportCenter.y - view.pan.y) / view.scale, + }; + return clampView( + { + scale: nextScale, + pan: { + x: nextAnchor.x - viewportCenter.x - modelOffset.x * nextScale, + y: nextAnchor.y - viewportCenter.y - modelOffset.y * nextScale, + }, + }, + metrics, + ); +} + +function NodeBadge({ label, className }: { label: string; className?: string }) { return ( @@ -126,27 +266,43 @@ function NodeBadge({ label, selected, className }: { label: string; selected?: b ); } -function MapLegend({ compact = false }: { compact?: boolean }) { - const entries: Array<{ label: string; className: string }> = [ - { label: "Most likely", className: "bg-[color:var(--clinical-accent)]" }, +function MapLegend({ nodes, compact = false }: { nodes: DifferentialMapNode[]; compact?: boolean }) { + const presentLikelihoods = new Set(nodes.map((node) => node.likelihood)); + const entries: Array<{ id: string; label: string; className: string }> = [ + { id: "focus", label: "Focus diagnosis", className: "bg-[color:var(--clinical-accent)]" }, + ]; + const likelihoodEntries: Array<{ id: DifferentialLikelihood; className: string }> = [ + { id: "most-likely", className: "bg-[color:var(--clinical-accent)]" }, { - label: "Possible", - className: "bg-[color:var(--clinical-accent-soft)] border border-[color:var(--clinical-accent-border)]", + id: "possible", + className: "border border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)]", }, - { label: "Less likely", className: "bg-[color:var(--surface-subtle)] border border-[color:var(--border)]" }, - { label: "Must-not-miss", className: "bg-[color:var(--danger)]" }, + { id: "less-likely", className: "border border-[color:var(--border)] bg-[color:var(--surface-subtle)]" }, + { id: "must-not-miss", className: "bg-[color:var(--danger)]" }, ]; + for (const entry of likelihoodEntries) { + if (presentLikelihoods.has(entry.id)) { + entries.push({ id: entry.id, label: likelihoodLabels[entry.id], className: entry.className }); + } + } return (
{entries.map((entry) => ( - - + + {entry.label} ))} @@ -156,201 +312,545 @@ function MapLegend({ compact = false }: { compact?: boolean }) { function MapGraph({ record, + nodes, selectedId, onSelect, - scale = 1, - pan = { x: 0, y: 0 }, - filtered = false, + view = fitView, + onViewChange, + onReset, + onMetricsChange, interactive = false, - onPointerDown, - onPointerMove, - onPointerUp, + describedBy, }: { record: DifferentialRecord; + nodes: DifferentialMapNode[]; selectedId: string; - onSelect: (node: SelectedNode) => void; - scale?: number; - pan?: ActivePoint; - filtered?: boolean; + onSelect?: (node: SelectedNode) => void; + view?: MapView; + onViewChange?: (view: MapView) => void; + onReset?: () => void; + onMetricsChange?: (metrics: MapMetrics) => void; interactive?: boolean; - onPointerDown?: (event: PointerEvent) => void; - onPointerMove?: (event: PointerEvent) => void; - onPointerUp?: (event: PointerEvent) => void; + describedBy?: string; }) { - const visibleNodes = filtered ? record.related.filter((node) => node.likelihood === "must-not-miss") : record.related; + const canvasRef = useRef(null); + const [viewport, setViewport] = useState({ width: 0, height: 0 }); + const activePointers = useRef(new Map()); + const lastPointer = useRef(null); + const lastPinch = useRef<{ distance: number; center: ActivePoint } | null>(null); + const viewRef = useRef(view); + + useEffect(() => { + viewRef.current = view; + }, [view]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const update = () => { + const rect = canvas.getBoundingClientRect(); + setViewport((current) => { + const next = { width: Math.round(rect.width), height: Math.round(rect.height) }; + return current.width === next.width && current.height === next.height ? current : next; + }); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(canvas); + return () => observer.disconnect(); + }, []); + + const layout = useMemo( + () => buildMapLayout(viewport.width, viewport.height, nodes, interactive), + [interactive, nodes, viewport.height, viewport.width], + ); + const metrics = useMemo( + () => ({ width: viewport.width, height: viewport.height, layout }), + [layout, viewport.height, viewport.width], + ); + + useEffect(() => { + if (metrics.width > 0 && metrics.height > 0) onMetricsChange?.(metrics); + }, [metrics, onMetricsChange]); + + const handleWheel = useEventCallback((event: WheelEvent) => { + if (!interactive) return; + event.preventDefault(); + const anchor = localPoint(event.clientX, event.clientY); + const factor = Math.exp(-event.deltaY * 0.0015); + const next = zoomAroundAnchor(viewRef.current, viewRef.current.scale * factor, anchor, anchor, metrics); + viewRef.current = next; + onViewChange?.(next); + }); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !interactive) return; + canvas.addEventListener("wheel", handleWheel, { passive: false }); + return () => canvas.removeEventListener("wheel", handleWheel); + }, [handleWheel, interactive]); + + function localPoint(clientX: number, clientY: number) { + const rect = canvasRef.current?.getBoundingClientRect(); + return { x: clientX - (rect?.left ?? 0), y: clientY - (rect?.top ?? 0) }; + } + + function commitView(next: MapView) { + if (!interactive || !onViewChange || metrics.width <= 0 || metrics.height <= 0) return; + const clamped = clampView(next, metrics); + viewRef.current = clamped; + onViewChange(clamped); + } + + function handlePointerDown(event: ReactPointerEvent) { + if (!interactive || (event.target instanceof HTMLElement && event.target.closest("button"))) return; + event.currentTarget.setPointerCapture(event.pointerId); + const point = localPoint(event.clientX, event.clientY); + activePointers.current.set(event.pointerId, point); + const pointers = Array.from(activePointers.current.values()); + if (pointers.length === 1) { + lastPointer.current = point; + lastPinch.current = null; + } else if (pointers.length >= 2) { + lastPinch.current = { + distance: distance(pointers[0], pointers[1]), + center: midpoint(pointers[0], pointers[1]), + }; + lastPointer.current = null; + } + } + + function handlePointerMove(event: ReactPointerEvent) { + if (!interactive || !activePointers.current.has(event.pointerId)) return; + const point = localPoint(event.clientX, event.clientY); + activePointers.current.set(event.pointerId, point); + const pointers = Array.from(activePointers.current.values()); + + if (pointers.length === 1 && lastPointer.current) { + commitView({ + ...viewRef.current, + pan: { + x: viewRef.current.pan.x + point.x - lastPointer.current.x, + y: viewRef.current.pan.y + point.y - lastPointer.current.y, + }, + }); + lastPointer.current = point; + return; + } + + if (pointers.length >= 2 && lastPinch.current) { + const nextDistance = distance(pointers[0], pointers[1]); + const nextCenter = midpoint(pointers[0], pointers[1]); + const ratio = nextDistance / Math.max(lastPinch.current.distance, 1); + const next = zoomAroundAnchor( + viewRef.current, + viewRef.current.scale * ratio, + lastPinch.current.center, + nextCenter, + metrics, + ); + viewRef.current = next; + onViewChange?.(next); + lastPinch.current = { distance: nextDistance, center: nextCenter }; + } + } + + function handlePointerUp(event: ReactPointerEvent) { + activePointers.current.delete(event.pointerId); + const pointers = Array.from(activePointers.current.values()); + if (pointers.length === 1) { + lastPointer.current = pointers[0]; + lastPinch.current = null; + } else if (pointers.length >= 2) { + lastPinch.current = { + distance: distance(pointers[0], pointers[1]), + center: midpoint(pointers[0], pointers[1]), + }; + lastPointer.current = null; + } else { + lastPointer.current = null; + lastPinch.current = null; + } + } + + function handleKeyDown(event: ReactKeyboardEvent) { + if (!interactive || event.target !== event.currentTarget) return; + if (event.altKey || event.ctrlKey || event.metaKey) return; + const panDelta: Record = { + ArrowLeft: { x: 32, y: 0 }, + ArrowRight: { x: -32, y: 0 }, + ArrowUp: { x: 0, y: 32 }, + ArrowDown: { x: 0, y: -32 }, + }; + if (event.key in panDelta) { + event.preventDefault(); + const delta = panDelta[event.key]; + commitView({ + ...viewRef.current, + pan: { x: viewRef.current.pan.x + delta.x, y: viewRef.current.pan.y + delta.y }, + }); + return; + } + if (["+", "="].includes(event.key) || event.key === "-") { + event.preventDefault(); + const anchor = { x: metrics.width / 2, y: metrics.height / 2 }; + const change = event.key === "-" ? -0.14 : 0.14; + const next = zoomAroundAnchor(viewRef.current, viewRef.current.scale + change, anchor, anchor, metrics); + viewRef.current = next; + onViewChange?.(next); + return; + } + if (event.key === "0" || event.key === "Home") { + event.preventDefault(); + onReset?.(); + } + } + + const focusPoint = renderedPoint(layout.focus, view, metrics); + const relatedPoints = nodes.map((node, index) => ({ + node, + layout: layout.related[index], + point: renderedPoint(layout.related[index] ?? layout.focus, view, metrics), + })); + const minimumHeight = + viewport.width > 0 && viewport.width < phoneLayoutBreakpoint + ? mobileMapMinimumHeight(nodes.length, interactive) + : interactive + ? 360 + : 320; return (
-
- - {visibleNodes.map((node) => { - const point = getNodePoint(node, record.related); - return ( + {viewport.width > 0 && viewport.height > 0 ? ( + <> + + {relatedPoints.map(({ node, point }) => ( - ); - })} - - - - - {visibleNodes.map((node) => { - const point = getNodePoint(node, record.related); - const isSelected = selectedId === node.id; - return ( + ))} + + + {interactive ? ( - ); - })} -
+ ) : ( +
+ {record.title} +
+ )} - {filtered && visibleNodes.length === 0 ? ( -
- No must-not-miss nodes are tagged for this diagnosis. -
+ {relatedPoints.map(({ node, layout: nodeLayout, point }) => { + const isSelected = selectedId === node.id; + const sharedClassName = cn( + "absolute z-20 grid -translate-x-1/2 -translate-y-1/2 place-items-center rounded-lg border px-2 py-1.5 text-center font-bold leading-tight break-words shadow-[var(--shadow-inset)]", + likelihoodTone[node.likelihood], + "forced-colors:border-[ButtonText] forced-colors:bg-[Canvas] forced-colors:text-[CanvasText]", + ); + const sharedStyle = { + left: point.x, + top: point.y, + width: nodeLayout?.width ?? 120, + minHeight: nodeLayout?.height ?? 72, + }; + if (!interactive) { + return ( +
+ {node.label} +
+ ); + } + return ( + + ); + })} + ) : null}
); } -function NodeDetails({ +function NodeInspector({ record, selected, - added, - onAdd, - knownRelatedSlugs, + relatedMapDetails, + compareIds, + expanded, + onExpandedChange, + onToggleCompare, }: { record: DifferentialRecord; selected: SelectedNode; - added: boolean; - onAdd: () => void; - knownRelatedSlugs?: string[]; + relatedMapDetails: Record; + compareIds: string[]; + expanded: boolean; + onExpandedChange: (expanded: boolean) => void; + onToggleCompare: (slug: string) => void; }) { + const inspectorRef = useRef(null); + const bodyRef = useRef(null); + const contentId = useId(); + const [isWideLayout, setIsWideLayout] = useState(false); const selectedIsDiagnosis = selected === "diagnosis"; - // Open the selected related node's own page when it resolves to a real - // diagnosis; otherwise fall back to (re)opening the current record. - const openHref = - !selectedIsDiagnosis && knownRelatedSlugs?.includes(selected.id) - ? `/differentials/diagnoses/${selected.id}` - : `/differentials/diagnoses/${record.slug}`; - const title = nodeLabel(selected, record); - const likelihood = selectedIsDiagnosis ? "Most likely" : likelihoodLabels[selected.likelihood]; - const details = selectedIsDiagnosis ? record.clinicalHinge : selected.note; - const fitSection = record.sections.find((section) => section.id === "why-it-fits"); - const riskSection = record.sections.find((section) => section.id === "must-not-miss"); + + useEffect(() => { + if (!window.matchMedia) return; + const media = window.matchMedia("(min-width: 1024px)"); + const update = () => setIsWideLayout(media.matches); + update(); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); + const detail = selectedIsDiagnosis ? null : relatedMapDetails[selected.id]; + const title = selectedIsDiagnosis ? record.title : selected.label; + const relationshipLabel = selectedIsDiagnosis ? "Focus diagnosis" : likelihoodLabels[selected.likelihood]; + const status = selectedIsDiagnosis ? record.status : detail?.status; + const clinicalHinge = selectedIsDiagnosis ? record.clinicalHinge : detail?.clinicalHinge; + const safetySummary = selectedIsDiagnosis ? record.safetySnapshot.summary : detail?.safetySummary; + const selectedSlug = selectedIsDiagnosis ? record.slug : detail?.slug; + const inCompare = Boolean(selectedSlug && compareIds.includes(selectedSlug)); + const compareHref = differentialSelectedCompareHref("", compareIds); + + useEffect(() => { + bodyRef.current?.scrollTo?.({ top: 0 }); + }, [title]); + + useEffect(() => { + if (!expanded) return; + const frame = window.requestAnimationFrame(() => { + inspectorRef.current?.scrollIntoView?.({ block: "nearest" }); + }); + return () => window.cancelAnimationFrame(frame); + }, [expanded, title]); return (
-
-
+
-

{title}

-

{details}

-
- -
- -
-
-

- - Why it fits -

-

- {selectedIsDiagnosis ? (fitSection?.summary ?? record.subtitle) : selected.note} +

+ {relationshipLabel}

+

+ {title} +

-
-

- - Must-not-miss -

-

- {selectedIsDiagnosis - ? (riskSection?.summary ?? record.safetySnapshot.summary) - : record.safetySnapshot.summary} -

+
+ {status ? : null} +
-
+ + +
+
+
+
+ {!selectedIsDiagnosis ? ( +
+

+ Relationship to {record.title} +

+

{selected.note}

+
+ ) : null} + + {clinicalHinge ? ( +
+

+ + Clinical hinge +

+

{clinicalHinge}

+
+ ) : null} + + {safetySummary ? ( +
+

+ + Must-not-miss for {title} +

+

{safetySummary}

+
+ ) : null} + + {!selectedIsDiagnosis && !detail ? ( +
+ This relationship is available in the map, but this diagnosis does not currently resolve to a reviewed + catalogue page. +
+ ) : null} +
+
+ +
+
+ {!selectedIsDiagnosis && selectedSlug ? ( + + ) : ( +
+ {selectedIsDiagnosis ? "Focus diagnosis included" : "Catalogue page unavailable"} +
+ )} -
- - - Open diagnosis - + {!selectedIsDiagnosis && selectedSlug ? ( + + Open diagnosis + + ) : compareIds.length >= 2 ? ( + + Compare ({compareIds.length}) + + ) : ( +
+ Select a related diagnosis +
+ )} +
+ + {compareIds.length >= 2 && !selectedIsDiagnosis ? ( + + + Compare ({compareIds.length}) + + ) : null} +
+
); @@ -358,140 +858,98 @@ function NodeDetails({ export function DiagnosisMapPanel({ record, - knownRelatedSlugs, + relatedMapDetails = {}, }: { record: DifferentialRecord; - knownRelatedSlugs?: string[]; + relatedMapDetails?: Record; }) { const [open, setOpen] = useState(false); const [selected, setSelected] = useState("diagnosis"); - const [scale, setScale] = useState(1); - const [pan, setPan] = useState({ x: 0, y: 0 }); + const [view, setView] = useState(fitView); + const [metrics, setMetrics] = useState(null); const [filtered, setFiltered] = useState(false); - const [addedIds, setAddedIds] = useState([]); - const isLargeScreen = useLargeScreenStore(); + const [selectedCompareIds, setSelectedCompareIds] = useState([]); + const [inspectorExpanded, setInspectorExpanded] = useState(false); const openButtonRef = useRef(null); - const activePointers = useRef(new Map()); - const lastPointer = useRef(null); - const lastPinch = useRef<{ distance: number; center: ActivePoint } | null>(null); - const scaleRef = useRef(scale); - const panRef = useRef(pan); - + const metricsSizeRef = useRef(""); + const helpId = useId(); const selectedId = selected === "diagnosis" ? "diagnosis" : selected.id; - const filteredNodes = useMemo( + const hasMustNotMiss = record.related.some((node) => node.likelihood === "must-not-miss"); + const visibleNodes = useMemo( () => record.related.filter((node) => !filtered || node.likelihood === "must-not-miss"), [filtered, record.related], ); + const compareIds = useMemo( + () => [record.slug, ...selectedCompareIds.filter((slug) => slug !== record.slug && relatedMapDetails[slug])], + [record.slug, relatedMapDetails, selectedCompareIds], + ); - useEffect(() => { - scaleRef.current = scale; - }, [scale]); - - useEffect(() => { - panRef.current = pan; - }, [pan]); - - const resetView = useCallback(() => { - setScale(isLargeScreen ? 1 : 0.48); - setPan({ x: 0, y: 0 }); - }, [isLargeScreen]); - - const focusSelected = useCallback(() => { - if (selected === "diagnosis") { - resetView(); - return; - } - - const point = getNodePoint(selected, record.related); - setScale(1.18); - setPan({ - x: (50 - point.x) * (graphSizePx / 100) * 0.92, - y: (52 - point.y) * (graphSizePx / 100) * 0.92, - }); - }, [record.related, resetView, selected]); + const resetView = useCallback(() => setView(fitView), []); - const updateScale = useCallback((nextScale: number) => { - setScale(clamp(nextScale, minScale, maxScale)); + const handleMetricsChange = useCallback((nextMetrics: MapMetrics) => { + const sizeKey = `${nextMetrics.width}x${nextMetrics.height}`; + if (metricsSizeRef.current && metricsSizeRef.current !== sizeKey) setView(fitView); + metricsSizeRef.current = sizeKey; + setMetrics(nextMetrics); }, []); - const handlePointerDown = useCallback((event: PointerEvent) => { - if (event.target instanceof HTMLElement && event.target.closest("button")) return; - - event.currentTarget.setPointerCapture(event.pointerId); - const point = { x: event.clientX, y: event.clientY }; - activePointers.current.set(event.pointerId, point); - const pointers = Array.from(activePointers.current.values()); - if (pointers.length === 1) { - lastPointer.current = point; - lastPinch.current = null; - } else if (pointers.length >= 2) { - lastPinch.current = { - distance: distance(pointers[0], pointers[1]), - center: midpoint(pointers[0], pointers[1]), - }; - lastPointer.current = null; - } + const handleSelect = useCallback((node: SelectedNode) => { + setSelected(node); + if (node !== "diagnosis") setInspectorExpanded(true); }, []); - const handlePointerMove = useCallback((event: PointerEvent) => { - if (!activePointers.current.has(event.pointerId)) return; - - const point = { x: event.clientX, y: event.clientY }; - activePointers.current.set(event.pointerId, point); - const pointers = Array.from(activePointers.current.values()); + const changeScale = useCallback( + (delta: number) => { + if (!metrics) return; + setView((current) => { + const anchor = { x: metrics.width / 2, y: metrics.height / 2 }; + return zoomAroundAnchor(current, current.scale + delta, anchor, anchor, metrics); + }); + }, + [metrics], + ); - if (pointers.length === 1 && lastPointer.current) { - const dx = point.x - lastPointer.current.x; - const dy = point.y - lastPointer.current.y; - const nextPan = { x: panRef.current.x + dx, y: panRef.current.y + dy }; - panRef.current = nextPan; - setPan(nextPan); - lastPointer.current = point; + const focusSelected = useCallback(() => { + if (!metrics) return; + const selectedLayout = + selected === "diagnosis" ? metrics.layout.focus : metrics.layout.related.find((node) => node.id === selected.id); + if (!selectedLayout) { + resetView(); return; } + const scale = 1.35; + const viewportCenter = { x: metrics.width / 2, y: metrics.height / 2 }; + setView( + clampView( + { + scale, + pan: { + x: -(selectedLayout.x - viewportCenter.x) * scale, + y: -(selectedLayout.y - viewportCenter.y) * scale, + }, + }, + metrics, + ), + ); + }, [metrics, resetView, selected]); - if (pointers.length >= 2 && lastPinch.current) { - const nextDistance = distance(pointers[0], pointers[1]); - const nextCenter = midpoint(pointers[0], pointers[1]); - const ratio = nextDistance / Math.max(lastPinch.current.distance, 1); - const nextScale = clamp(scaleRef.current * ratio, minScale, maxScale); - const nextPan = { - x: panRef.current.x + nextCenter.x - lastPinch.current.center.x, - y: panRef.current.y + nextCenter.y - lastPinch.current.center.y, - }; - scaleRef.current = nextScale; - panRef.current = nextPan; - setScale(nextScale); - setPan(nextPan); - lastPinch.current = { distance: nextDistance, center: nextCenter }; - } - }, []); - - const handlePointerUp = useCallback((event: PointerEvent) => { - activePointers.current.delete(event.pointerId); - const pointers = Array.from(activePointers.current.values()); - if (pointers.length === 1) { - lastPointer.current = pointers[0]; - lastPinch.current = null; - } else if (pointers.length >= 2) { - lastPinch.current = { - distance: distance(pointers[0], pointers[1]), - center: midpoint(pointers[0], pointers[1]), - }; - lastPointer.current = null; - } else { - lastPointer.current = null; - lastPinch.current = null; - } - }, []); - - function toggleCompare() { - setAddedIds((current) => - current.includes(selectedId) ? current.filter((id) => id !== selectedId) : [...current, selectedId], + function toggleCompare(slug: string) { + if (!relatedMapDetails[slug]) return; + setSelectedCompareIds((current) => + current.includes(slug) ? current.filter((id) => id !== slug) : [...current, slug], ); } - const renderedPan = isLargeScreen ? pan : { x: pan.x, y: pan.y - 150 }; + function toggleFilter() { + if (!hasMustNotMiss) return; + const nextFiltered = !filtered; + if (nextFiltered && selected !== "diagnosis" && selected.likelihood !== "must-not-miss") { + setSelected("diagnosis"); + setInspectorExpanded(false); + } + setFiltered(nextFiltered); + setView(fitView); + } return ( <> @@ -502,36 +960,33 @@ export function DiagnosisMapPanel({

Diagnosis map

-

- {record.related.length} related differentials. Open the full map to pan, zoom, and inspect each node. -

-

- {record.related.length} related differentials +

+ {record.related.length} related differential{record.related.length === 1 ? "" : "s"}

- +
- -
- - -
+ + +
@@ -539,162 +994,120 @@ export function DiagnosisMapPanel({ open={open} onClose={() => setOpen(false)} title="Diagnosis map" - description={`Explore ${record.title} and ${record.related.length} related differentials.`} + description={`${record.title} · ${record.related.length} related differential${record.related.length === 1 ? "" : "s"}`} closeLabel="Close diagnosis map" mobilePlacement="fullscreen" contentClassName="lg:max-w-[76rem]" - bodyClassName="p-0" + headerClassName="pt-[max(1rem,env(safe-area-inset-top))] lg:pt-5" + bodyClassName="p-0 overflow-y-auto lg:overflow-hidden" portal returnFocusRef={openButtonRef} + testId="diagnosis-map-dialog" headerActions={ } > -
-
-
-
- - - + {hasMustNotMiss ? ( + + ) : null}
- -
-
- -
- -
-
+
+ + {Math.round(view.scale * 100)}% +
-
- -
-
- - Drag to pan - · - Use controls or trackpad to zoom - · - Select a node for details -
+
+
- {!isLargeScreen ? ( -
-
- - {filtered ? ( -

- Filter is showing {filteredNodes.length} must-not-miss node - {filteredNodes.length === 1 ? "" : "s"}. Turn it off to restore the full differential map. -

- ) : null} -
-
- ) : null} - - {isLargeScreen ? ( -
-
- - {filtered ? ( -

- Filter is showing {filteredNodes.length} must-not-miss node - {filteredNodes.length === 1 ? "" : "s"}. Turn it off to restore the full differential map. -

- ) : null} -
+
+
+ +

+ + Drag or use arrow keys to pan · pinch, scroll, or use buttons to zoom +

+ {filtered ? ( +

+ Showing {visibleNodes.length} must-not-miss differential{visibleNodes.length === 1 ? "" : "s"}. Turn + off the filter to restore the full map. +

+ ) : null}
- ) : null} -
-

- - Drag to pan · pinch or use buttons to zoom · tap a node -

+
diff --git a/src/components/differentials/differential-detail-page.tsx b/src/components/differentials/differential-detail-page.tsx index 89da1146f9..8480c9f722 100644 --- a/src/components/differentials/differential-detail-page.tsx +++ b/src/components/differentials/differential-detail-page.tsx @@ -1199,7 +1199,7 @@ export function DifferentialDetailPage({ {activeTab === "compare" ? : null} {activeTab === "map" ? ( - + ) : null} {activeTab === "related" ? ( diff --git a/src/lib/differential-detail.ts b/src/lib/differential-detail.ts index 8a0fd528e3..17cf9d6589 100644 --- a/src/lib/differential-detail.ts +++ b/src/lib/differential-detail.ts @@ -10,6 +10,14 @@ import type { DifferentialRecord, DifferentialSection } from "@/lib/differential export const DETAIL_TAB_IDS = ["overview", "compare", "map", "related", "source"] as const; export type DifferentialDetailTabId = (typeof DETAIL_TAB_IDS)[number]; +export type DifferentialRelatedMapDetail = { + slug: string; + title: string; + status: DifferentialRecord["status"]; + clinicalHinge: string; + safetySummary: string; +}; + export function isDetailTabId(value: string | null | undefined): value is DifferentialDetailTabId { return typeof value === "string" && (DETAIL_TAB_IDS as readonly string[]).includes(value); } @@ -17,6 +25,13 @@ export function isDetailTabId(value: string | null | undefined): value is Differ export type DifferentialDetailContext = { /** related[].id values verified against the diagnosis catalog (safe to link). */ knownRelatedSlugs: string[]; + /** + * Small, server-derived summaries for verified related diagnoses. Keeping this + * separate from `record.related` lets the map inspector show the selected + * diagnosis's own hinge and safety content without shipping the full catalog + * through the client boundary. + */ + relatedMapDetails: Record; /** * Cleaned Watch-for tags + section item labels → diagnosis slug. * Exact catalog title or curated alias only (see differential-diagnosis-links). diff --git a/src/lib/differentials.ts b/src/lib/differentials.ts index 5717f08694..e2d0c3ee61 100644 --- a/src/lib/differentials.ts +++ b/src/lib/differentials.ts @@ -395,6 +395,32 @@ export function getDifferentialDetailContext( const knownRelatedSlugs = [ ...new Set(record.related.map((node) => node.id).filter((id) => routableDiagnosisSlugs.has(id))), ]; + const catalogRecordBySlug = new Map(catalogRecords.map((entry) => [entry.slug, entry])); + const relatedMapDetails: DifferentialDetailContext["relatedMapDetails"] = {}; + for (const slug of knownRelatedSlugs) { + const related = catalogRecordBySlug.get(slug); + const title = typeof related?.title === "string" ? related.title.trim() : ""; + const clinicalHinge = typeof related?.clinicalHinge === "string" ? related.clinicalHinge.trim() : ""; + const safetySummary = + typeof related?.safetySnapshot?.summary === "string" ? related.safetySnapshot.summary.trim() : ""; + const status = related?.status; + if ( + !related || + !title || + !clinicalHinge || + !safetySummary || + (status !== "emergent" && status !== "urgent" && status !== "routine") + ) { + continue; + } + relatedMapDetails[slug] = { + slug: related.slug, + title, + status, + clinicalHinge, + safetySummary, + }; + } const titleMap = buildDiagnosisTitleSlugMap(catalogRecords); // Owner-row payloads can be partial in tests/live drift — never assume tags/items exist. @@ -429,6 +455,7 @@ export function getDifferentialDetailContext( const governance = deriveGovernanceFromSnapshot(snapshot); return { knownRelatedSlugs, + relatedMapDetails, termLinks, overlapLinks, comparePresentation: presentation ? { slug: presentation.id, title: presentation.title } : null, diff --git a/tests/diagnosis-map-panel.dom.test.tsx b/tests/diagnosis-map-panel.dom.test.tsx new file mode 100644 index 0000000000..3e08bedf5c --- /dev/null +++ b/tests/diagnosis-map-panel.dom.test.tsx @@ -0,0 +1,202 @@ +import { createEvent, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { DiagnosisMapPanel } from "@/components/differentials/diagnosis-map-panel"; +import { OverlayRoot } from "@/components/ui/overlay-root"; +import type { DifferentialRelatedMapDetail } from "@/lib/differential-detail"; +import type { DifferentialRecord } from "@/lib/differentials"; + +const record: DifferentialRecord = { + slug: "focus-diagnosis", + title: "Focus diagnosis", + status: "urgent", + subtitle: "Focus subtitle", + clinicalHinge: "Focus clinical hinge.", + safetySnapshot: { summary: "Focus safety summary.", tags: ["Focus risk"] }, + sections: [], + related: [ + { + id: "verified-related", + label: "Verified related diagnosis with a long readable label", + likelihood: "possible", + note: "Relationship note for the verified diagnosis.", + }, + { + id: "unresolved-related", + label: "Unresolved related diagnosis", + likelihood: "less-likely", + note: "Relationship-only note.", + }, + { + id: "dangerous-related", + label: "Dangerous related diagnosis", + likelihood: "must-not-miss", + note: "Urgent relationship note.", + }, + ], + currentPresentation: [], + investigations: [], + immediateActions: [], +}; + +const relatedMapDetails: Record = { + "verified-related": { + slug: "verified-related", + title: "Verified related diagnosis with a long readable label", + status: "emergent", + clinicalHinge: "The verified diagnosis has its own clinical hinge.", + safetySummary: "The verified diagnosis has its own safety summary.", + }, + "dangerous-related": { + slug: "dangerous-related", + title: "Dangerous related diagnosis", + status: "emergent", + clinicalHinge: "Dangerous diagnosis hinge.", + safetySummary: "Dangerous diagnosis safety summary.", + }, +}; + +let originalGetBoundingClientRect: typeof HTMLElement.prototype.getBoundingClientRect; +let originalResizeObserver: typeof ResizeObserver | undefined; +let originalRequestAnimationFrame: typeof window.requestAnimationFrame | undefined; +let originalCancelAnimationFrame: typeof window.cancelAnimationFrame | undefined; + +beforeEach(() => { + originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; + originalResizeObserver = globalThis.ResizeObserver; + originalRequestAnimationFrame = window.requestAnimationFrame; + originalCancelAnimationFrame = window.cancelAnimationFrame; + HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() { + if ( + this instanceof HTMLElement && + ["diagnosis-map-preview-canvas", "diagnosis-map-full-canvas"].includes(this.dataset.testid ?? "") + ) { + return { + x: 0, + y: 0, + top: 0, + left: 0, + right: 390, + bottom: 520, + width: 390, + height: 520, + toJSON: () => ({}), + } as DOMRect; + } + return originalGetBoundingClientRect.call(this); + }; + globalThis.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } as typeof ResizeObserver; + window.requestAnimationFrame ??= (callback) => window.setTimeout(() => callback(performance.now()), 0); + window.cancelAnimationFrame ??= window.clearTimeout; +}); + +afterEach(() => { + HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect; + if (originalResizeObserver) globalThis.ResizeObserver = originalResizeObserver; + else Reflect.deleteProperty(globalThis, "ResizeObserver"); + if (originalRequestAnimationFrame) window.requestAnimationFrame = originalRequestAnimationFrame; + else Reflect.deleteProperty(window, "requestAnimationFrame"); + if (originalCancelAnimationFrame) window.cancelAnimationFrame = originalCancelAnimationFrame; + else Reflect.deleteProperty(window, "cancelAnimationFrame"); + document.body.style.overflow = ""; +}); + +function renderMap() { + return render( + <> + + + , + ); +} + +describe("DiagnosisMapPanel", () => { + it("shows selected-node clinical content, honest actions, and a collapsible in-flow inspector", async () => { + const user = userEvent.setup(); + renderMap(); + + expect(screen.getByLabelText("Map legend")).toHaveTextContent("Focus diagnosis"); + await user.click(screen.getByTestId("open-diagnosis-map")); + + const dialog = await screen.findByTestId("diagnosis-map-dialog"); + expect(within(dialog).getByRole("button", { name: "Must-not-miss" })).toBeInTheDocument(); + + await user.click(within(dialog).getByTestId("diagnosis-map-node-verified-related")); + const inspector = within(dialog).getByTestId("diagnosis-map-node-details"); + expect(within(inspector).getByText("Relationship note for the verified diagnosis.")).toBeInTheDocument(); + expect(within(inspector).getByText("The verified diagnosis has its own clinical hinge.")).toBeInTheDocument(); + expect(within(inspector).getByText("The verified diagnosis has its own safety summary.")).toBeInTheDocument(); + expect(within(inspector).queryByText("Focus safety summary.")).not.toBeInTheDocument(); + expect(within(inspector).getByRole("link", { name: "Open diagnosis" })).toHaveAttribute( + "href", + "/differentials/diagnoses/verified-related", + ); + expect(within(inspector).getByRole("button", { name: "Collapse selected diagnosis details" })).toHaveAttribute( + "aria-expanded", + "true", + ); + + await user.click(within(inspector).getByRole("button", { name: "Add to compare" })); + expect(within(inspector).getByRole("link", { name: "Compare (2)" })).toHaveAttribute( + "href", + "/differentials/compare?ids=focus-diagnosis%2Cverified-related", + ); + + await user.click(within(dialog).getByTestId("diagnosis-map-node-unresolved-related")); + expect(within(inspector).getByText(/does not currently resolve to a reviewed catalogue page/i)).toBeInTheDocument(); + expect(within(inspector).queryByRole("link", { name: "Open diagnosis" })).not.toBeInTheDocument(); + expect(within(inspector).getByText("Catalogue page unavailable")).toBeInTheDocument(); + }); + + it("refits and returns selection to the focus diagnosis when filtering hides the selected node", async () => { + const user = userEvent.setup(); + renderMap(); + await user.click(screen.getByTestId("open-diagnosis-map")); + const dialog = await screen.findByTestId("diagnosis-map-dialog"); + + await user.click(within(dialog).getByTestId("diagnosis-map-node-verified-related")); + await user.click(within(dialog).getByRole("button", { name: "Must-not-miss" })); + + await waitFor(() => { + expect(within(dialog).queryByTestId("diagnosis-map-node-verified-related")).not.toBeInTheDocument(); + }); + expect(within(dialog).getByTestId("diagnosis-map-node-diagnosis")).toHaveAttribute("aria-pressed", "true"); + expect(within(dialog).getByText(/Showing 1 must-not-miss differential/)).toBeInTheDocument(); + }); + + it("keeps collapsed inspector controls inert and preserves modified browser shortcuts", async () => { + const user = userEvent.setup(); + renderMap(); + + expect(screen.getByRole("group", { name: "Map legend" })).toBeInTheDocument(); + await user.click(screen.getByTestId("open-diagnosis-map")); + const dialog = await screen.findByTestId("diagnosis-map-dialog"); + const inspector = within(dialog).getByTestId("diagnosis-map-node-details"); + const inspectorBody = within(inspector).getByTestId("diagnosis-map-inspector-body"); + const inspectorContent = inspectorBody.parentElement?.parentElement; + + expect(inspectorContent).toHaveAttribute("inert"); + + const canvas = within(dialog).getByTestId("diagnosis-map-full-canvas"); + const modifiedReset = createEvent.keyDown(canvas, { key: "0", ctrlKey: true, cancelable: true }); + fireEvent(canvas, modifiedReset); + + expect(modifiedReset.defaultPrevented).toBe(false); + + const wheel = new WheelEvent("wheel", { + bubbles: true, + cancelable: true, + clientX: 195, + clientY: 260, + deltaY: -80, + }); + canvas.dispatchEvent(wheel); + + expect(wheel.defaultPrevented).toBe(true); + }); +}); diff --git a/tests/differential-detail.test.ts b/tests/differential-detail.test.ts index 65cfa16d9a..d0efc3b8f1 100644 --- a/tests/differential-detail.test.ts +++ b/tests/differential-detail.test.ts @@ -249,10 +249,37 @@ describe("getDifferentialDetailContext", () => { const context = getDifferentialDetailContext(delirium!); expect(context.comparePresentation?.slug).toBe("acute-confusion-encephalopathy"); expect(context.knownRelatedSlugs).toContain("akathisia"); + expect(context.relatedMapDetails.akathisia).toMatchObject({ + slug: "akathisia", + title: "Akathisia", + }); + expect(context.relatedMapDetails.akathisia?.clinicalHinge.length).toBeGreaterThan(0); + expect(context.relatedMapDetails.akathisia?.safetySummary.length).toBeGreaterThan(0); expect(context.source.version.length).toBeGreaterThan(0); expect(context.source.sourceStatus).toBe("review_due"); }); + it("omits incomplete owner-catalog clinical summaries instead of falling back across owner scope", () => { + const delirium = getDifferentialRecord("delirium"); + const akathisia = getDifferentialRecord("akathisia"); + expect(delirium).not.toBeNull(); + expect(akathisia).not.toBeNull(); + + const context = getDifferentialDetailContext(delirium!, { + records: [ + { + ...akathisia!, + clinicalHinge: "", + safetySnapshot: { ...akathisia!.safetySnapshot, summary: "" }, + }, + ], + presentations: [], + }); + + expect(context.knownRelatedSlugs).toContain("akathisia"); + expect(context.relatedMapDetails.akathisia).toBeUndefined(); + }); + it("produces catalog-consistent context for every record", () => { const catalogSlugs = new Set(differentialRecords.map((record) => record.slug)); for (const record of differentialRecords) { @@ -260,6 +287,7 @@ describe("getDifferentialDetailContext", () => { expect(context.comparePresentation, `${record.slug} should belong to a presentation`).not.toBeNull(); for (const slug of context.knownRelatedSlugs) { expect(catalogSlugs.has(slug), `related slug ${slug} on ${record.slug}`).toBe(true); + expect(context.relatedMapDetails[slug]?.slug, `map detail ${slug} on ${record.slug}`).toBe(slug); } for (const slug of Object.values(context.termLinks)) { expect(getDifferentialRecord(slug), `term link ${slug} from ${record.slug}`).not.toBeNull(); diff --git a/tests/differential-section-nav.dom.test.tsx b/tests/differential-section-nav.dom.test.tsx index 6a7a054bad..1e821c31ad 100644 --- a/tests/differential-section-nav.dom.test.tsx +++ b/tests/differential-section-nav.dom.test.tsx @@ -43,6 +43,7 @@ function buildContext(overrides: DifferentialDetailContextOverrides = {}): Diffe const { source: sourceOverride, ...rest } = overrides; return { knownRelatedSlugs: [], + relatedMapDetails: {}, termLinks: {}, overlapLinks: {}, comparePresentation: null, diff --git a/tests/differentials-route.test.ts b/tests/differentials-route.test.ts index e1088437f2..246a9678d8 100644 --- a/tests/differentials-route.test.ts +++ b/tests/differentials-route.test.ts @@ -189,6 +189,7 @@ describe("differentials API routes", () => { const payload = (await response.json()) as { detailContext?: { knownRelatedSlugs?: string[]; + relatedMapDetails?: Record; termLinks?: Record; overlapLinks?: Record; comparePresentation?: { slug: string } | null; @@ -198,6 +199,7 @@ describe("differentials API routes", () => { expect(response.status).toBe(200); expectPrivateCache(response); expect(payload.detailContext?.knownRelatedSlugs).toEqual([]); + expect(payload.detailContext?.relatedMapDetails).toEqual({}); expect(payload.detailContext?.termLinks).toEqual({}); expect(payload.detailContext?.overlapLinks).toEqual({}); expect(payload.detailContext?.comparePresentation).toBeNull(); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 675bfb5213..ee790159a6 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -309,6 +309,34 @@ async function expectNoPageHorizontalOverflow(page: Page) { expect(overflow).toBeLessThanOrEqual(2); } +async function expectMapLabelsContained(canvas: Locator) { + await expect + .poll(async () => + canvas.locator("[data-map-node]").evaluateAll((nodes) => { + if (nodes.length === 0) return false; + return nodes.every((node) => { + const card = node.getBoundingClientRect(); + const labels = Array.from(node.querySelectorAll("[data-map-node-label]")); + return ( + labels.length > 0 && + labels.every((label) => { + const range = document.createRange(); + range.selectNodeContents(label); + return Array.from(range.getClientRects()).every( + (rect) => + rect.left >= card.left - 1 && + rect.right <= card.right + 1 && + rect.top >= card.top - 1 && + rect.bottom <= card.bottom + 1, + ); + }) + ); + }); + }), + ) + .toBe(true); +} + async function expectMinTouchTarget(locator: Locator, minSize = 44) { const box = await locator.boundingBox(); expect(box).not.toBeNull(); @@ -2417,6 +2445,84 @@ test.describe("Clinical KB tools launcher", () => { expect(overviewLineCount).toBe(1); }); + test("diagnosis map keeps labels contained and the selected inspector out of the canvas", async ({ page }) => { + await page.setViewportSize({ width: 320, height: 700 }); + await gotoLauncher(page, "/differentials/diagnoses/catatonia-in-mood-disorder?tab=map"); + await visibleByTestId(page, "open-diagnosis-map").click(); + + const dialog = page.getByTestId("diagnosis-map-dialog"); + const canvas = dialog.getByTestId("diagnosis-map-full-canvas"); + const inspector = dialog.getByTestId("diagnosis-map-node-details"); + await expect(dialog).toBeVisible(); + await expect(canvas).toBeVisible(); + await expectMapLabelsContained(canvas); + await expectNoPageHorizontalOverflow(page); + + // All five related nodes are "Possible" for this record, so an empty + // must-not-miss filter must not be advertised as a working control. + await expect(dialog.getByRole("button", { name: "Must-not-miss" })).toHaveCount(0); + await expect(dialog.getByLabel("Map legend")).toContainText("Focus diagnosis"); + await expect(dialog.getByLabel("Map legend")).toContainText("Possible"); + + await canvas.getByTestId("diagnosis-map-node-catatonia-in-psychotic-disorder").click(); + await expect(inspector).toContainText("Psychosis plus marked motor syndrome"); + await expect(inspector).toContainText("Characteristic motor syndrome"); + await expect(inspector).not.toContainText( + "Refusal of intake, immobility complications, dehydration, autonomic change, hyperthermia, DVT/PE, rhabdomyolysis.", + ); + await expect(inspector.getByRole("button", { name: "Collapse selected diagnosis details" })).toHaveAttribute( + "aria-expanded", + "true", + ); + + const nonOverlap = await Promise.all([canvas.boundingBox(), inspector.boundingBox()]); + expect(nonOverlap[0]).not.toBeNull(); + expect(nonOverlap[1]).not.toBeNull(); + expect(nonOverlap[1]!.y).toBeGreaterThanOrEqual(nonOverlap[0]!.y + nonOverlap[0]!.height - 1); + + await inspector.getByRole("button", { name: "Add to compare" }).click(); + await expect(inspector.getByRole("link", { name: "Compare (2)" })).toHaveAttribute( + "href", + "/differentials/compare?ids=catatonia-in-mood-disorder%2Ccatatonia-in-psychotic-disorder", + ); + + const zoom = dialog.getByLabel("Map zoom"); + await expect(zoom).toHaveText("100%"); + await dialog.getByRole("button", { name: "Zoom in" }).click(); + await expect(zoom).toHaveText("114%"); + await dialog.getByRole("button", { name: "Reset map view" }).click(); + await expect(zoom).toHaveText("100%"); + + await canvas.focus(); + await expect(canvas).toBeFocused(); + const focusNode = canvas.getByTestId("diagnosis-map-node-diagnosis"); + const fittedFocusBox = await focusNode.boundingBox(); + await page.keyboard.press("ArrowRight"); + await expect + .poll(async () => (await focusNode.boundingBox())?.x ?? Number.POSITIVE_INFINITY) + .toBeLessThan((fittedFocusBox?.x ?? 0) - 20); + await page.keyboard.press("Home"); + await expect + .poll(async () => Math.abs(((await focusNode.boundingBox())?.x ?? 0) - (fittedFocusBox?.x ?? 0))) + .toBeLessThanOrEqual(1); + + for (const viewport of [ + { width: 390, height: 844 }, + { width: 639, height: 900 }, + { width: 768, height: 1024 }, + { width: 1440, height: 900 }, + { width: 1920, height: 1080 }, + ]) { + await page.setViewportSize(viewport); + await expectMapLabelsContained(canvas); + await expectNoPageHorizontalOverflow(page); + } + + await page.emulateMedia({ reducedMotion: "reduce", forcedColors: "active" }); + await expectMapLabelsContained(canvas); + await expect(canvas.getByTestId("diagnosis-map-node-diagnosis")).toBeVisible(); + }); + test("differentials compare queue launches presentation comparison", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 920 }); const workflow = acuteConfusionPresentationWorkflow;