From 0427c87f0bdc7cde9abe51c12156f29769314e62 Mon Sep 17 00:00:00 2001 From: John Levander Date: Fri, 18 Sep 2026 00:03:06 -0400 Subject: [PATCH 1/3] Linked, aligned overview + detail on both patient timelines Replaces the document timeline's bare range slider with an overview + detail control, and moves the Event Timeline onto the same implementation. An overview strip under each chart shows every document or event across the full date range on its own fixed axis, and two handles select the range the chart above shows. This fixes the old slider, which was drawn against the full range while sitting under a zoomed axis, so its handles read months off. The two timelines are linked: one date range covering every document and event, one zoom and pan state, and one announcement for screen readers. They share a horizontal frame, so their strips, handles and axes line up vertically from 620px wide. - New shared pieces: pure viewport math (utils/patientView/timelineViewport.js), a viewport hook and its linked wrapper, and the strip, axis and zoom controls under components/patient/timeline/ - Event Timeline: no mouse-wheel zoom (the page scrolls), 16x zoom cap, pan by a fifth of the visible window rather than of the whole range, and evenly spaced axis labels. Drops d3-zoom, d3-brush, d3-selection and d3-axis; declares d3-scale and d3-interpolate, which it imports directly - Event and birth dates are built at local midnight like document dates, so linked timelines print the same calendar date in every time zone - Accessibility: focus rings on the handles, multi-thumb slider semantics, a debounced range live region, and explicit fills on every SVG text Also includes in-progress work already in the tree from other sessions: the cohort filter layout and filter sets, the patient grid and its detail panel, the patient drawer, document mentions, and the mock API. Co-Authored-By: Claude Opus 5 --- .claude/launch.json | 8 + CHANGELOG.md | 32 +- docs/explore-patient/document-timeline.md | 41 + docs/explore-patient/event-timeline.md | 17 +- mock-api/README.md | 7 + mock-api/server.js | 82 +- package-lock.json | 115 +-- package.json | 6 +- src/components/EmbeddedPatientView.jsx | 15 + src/components/PatientGrid.jsx | 449 +++++++---- ...mbeddedPatientView.documentDrawer.test.jsx | 67 +- src/components/__tests__/PatientGrid.test.jsx | 77 +- .../patient/CancerTumorSummaryCard.jsx | 62 +- .../patient/EventRelationTimelineCard.jsx | 753 +++++++++++------- .../patient/PatientDemographicsCard.jsx | 60 +- .../patient/PatientDocumentsCard.jsx | 696 ++++++++-------- .../EventRelationTimelineCard.test.jsx | 276 ++++++- .../__tests__/PatientDocumentsCard.test.jsx | 354 +++++++- .../patient/timeline/TimelineAxis.jsx | 114 +++ .../patient/timeline/TimelineLinkProvider.jsx | 106 +++ .../timeline/TimelineOverviewStrip.jsx | 490 ++++++++++++ .../patient/timeline/TimelineZoomControls.jsx | 127 +++ .../__tests__/TimelineLinkProvider.test.jsx | 308 +++++++ .../__tests__/TimelineOverviewStrip.test.jsx | 322 ++++++++ .../patientGrid/PatientDetailPanel.jsx | 298 ++++--- src/components/patientGrid/PatientGridRow.jsx | 71 +- .../patientGrid/patientGridColumns.jsx | 50 +- src/constants/eventRelationTimeline.js | 10 +- src/constants/timelineFrame.js | 25 + .../__tests__/eventRelationTimeline.test.js | 54 +- .../__tests__/patientDemographics.test.js | 15 +- src/controllers/eventRelationTimeline.js | 49 +- src/controllers/patientDemographics.js | 24 +- .../__tests__/useTimelineViewport.test.js | 184 +++++ src/hooks/useLinkedTimelineViewport.js | 56 ++ src/hooks/useTimelineViewport.js | 258 ++++++ .../eventRelationTimelineLayout.test.js | 123 ++- .../__tests__/timelineChartLayout.test.js | 55 ++ .../__tests__/timelineViewport.test.js | 317 ++++++++ src/utils/patientView/documentMentions.js | 38 + .../eventRelationTimelineLayout.js | 74 +- src/utils/patientView/timelineChartLayout.js | 130 ++- src/utils/patientView/timelineViewport.js | 497 ++++++++++++ src/views/__tests__/filterLayout.test.js | 117 ++- src/views/__tests__/filterSets.test.js | 8 +- src/views/__tests__/filters.test.jsx | 89 ++- src/views/filterLayout.js | 45 +- src/views/filterSets.js | 6 +- src/views/filters.jsx | 349 ++++---- src/views/filters/PatientDrawer.jsx | 190 +++-- src/views/filters/layoutConfig.js | 111 +++ src/views/patient.jsx | 15 + 52 files changed, 6142 insertions(+), 1700 deletions(-) create mode 100644 src/components/patient/timeline/TimelineAxis.jsx create mode 100644 src/components/patient/timeline/TimelineLinkProvider.jsx create mode 100644 src/components/patient/timeline/TimelineOverviewStrip.jsx create mode 100644 src/components/patient/timeline/TimelineZoomControls.jsx create mode 100644 src/components/patient/timeline/__tests__/TimelineLinkProvider.test.jsx create mode 100644 src/components/patient/timeline/__tests__/TimelineOverviewStrip.test.jsx create mode 100644 src/constants/timelineFrame.js create mode 100644 src/hooks/__tests__/useTimelineViewport.test.js create mode 100644 src/hooks/useLinkedTimelineViewport.js create mode 100644 src/hooks/useTimelineViewport.js create mode 100644 src/utils/patientView/__tests__/timelineChartLayout.test.js create mode 100644 src/utils/patientView/__tests__/timelineViewport.test.js create mode 100644 src/utils/patientView/timelineViewport.js diff --git a/.claude/launch.json b/.claude/launch.json index 6d9d5c1..15ae9e8 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -12,6 +12,14 @@ "port": 3000, "autoPort": true }, + { + "name": "viz2-mock-api", + "runtimeExecutable": "node", + "runtimeArgs": [ + "/Volumes/Samsung-Ext/dev/deep/DeepPhe-Visualizer-v2/mock-api/server.js" + ], + "port": 3333 + }, { "name": "viz2-docs", "runtimeExecutable": "npm", diff --git a/CHANGELOG.md b/CHANGELOG.md index b83d684..4aa30b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Event Timeline** panel on the patient view: temporal relations packed into overlap-free lanes by Finding, Disease, Stage/Grade, and Treatment, with - relation-specific end caps (On / Overlaps / After / Before), scroll-to-zoom - and a draggable overview band, a per-lane density strip when a lane is - collapsed, and a Patient Age axis derived from the patient's date of birth + relation-specific end caps (On / Overlaps / After / Before), a per-lane + density strip when a lane is collapsed, and a Patient Age axis derived from + the patient's date of birth +- **Overview + detail date range** on both the **Patient Document Timeline** and + the **Event Timeline**, from one shared implementation. An overview strip under + each chart shows every document or event across the full date range with fixed + end dates, and its two handles select the range the chart shows. Each handle + labels its date while zoomed, handles can't cross, and range changes are + announced to screen readers. Both timelines share the same controls: zoom %, + zoom in and out, pan earlier and later, reset, and the `+` / `-` / `0` / + `←` / `→` keys +- The Patient Document Timeline and the Event Timeline are **linked**: they + share one date range covering every document and event, a slider, button or + key in either moves both, and their strips, handles and date axes line up + vertically on screens 620px and wider. A handle released within a few pixels + of either end snaps to it - Selecting a mark in the Event Timeline highlights its concepts in the Document Viewer, and clearing the concept there clears the mark - Patient demographics (gender, race, birth date) are filled from a bundled @@ -28,6 +41,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Document Viewer ### Changed +- The Patient Document Timeline's date range slider was drawn against the full + date range but sat under the zoomed axis, so its handle positions were easy to + misread by months. Its handles now sit on the overview strip's own fixed axis +- The Event Timeline no longer zooms on mouse-wheel scroll (the page scrolls, + as on the document timeline), zooms up to 1600% rather than without limit, and + labels its axes with evenly spaced dates. Event and birth dates are now built + at local midnight, like document dates, so linked timelines print the same + calendar date for the same position in every time zone. Its pan buttons + move a fifth of the visible range; they previously moved a fifth of the whole + range, which skipped past events at high zoom +- `d3-zoom`, `d3-brush`, `d3-selection`, and `d3-axis` are no longer + dependencies. `d3-scale` and `d3-interpolate`, which the Event Timeline + imports directly, are now declared rather than resolved through `@mui/x-charts` - **Patient Details** and **Cancer and Tumor Detail** now flow across the full panel width instead of stacking in a single column, reclaiming roughly 300px of vertical space above the fold on a wide screen diff --git a/docs/explore-patient/document-timeline.md b/docs/explore-patient/document-timeline.md index 98dcbba..33977ca 100644 --- a/docs/explore-patient/document-timeline.md +++ b/docs/explore-patient/document-timeline.md @@ -16,6 +16,47 @@ The **Patient Document Timeline** plots a patient's notes over time so you can p - **Episode colors** group documents that belong to the same care episode. - A **document count** near the heading shows how many notes the patient has. +## Zoom into a date range + +The chart has two parts: + +- The **detail view** on top shows the documents in the selected date range. Its date axis always runs from the start to the end of that range. +- The **overview strip** underneath always shows the patient's full date range. A small tick marks every document, in its report type's row and episode color. The dates at its left and right ends never change. + +Two handles on the overview strip select the range the detail view shows. + +- **Drag a handle** to move that end of the range. Each handle shows its date underneath while you are zoomed in. The handles cannot cross. +- **Drag the shaded window** between the handles to move the range without changing its length. +- **Press the strip outside the window** to center the range there. +- Use the buttons beside the heading to **zoom in**, **zoom out**, **pan earlier**, **pan later**, or **reset** to the full range. The percentage shows the current zoom, up to 1600%. +- When zoomed in, you can also **drag the detail view** sideways to pan. + +Scrolling the mouse wheel over the chart scrolls the page; it does not zoom. + +With the keyboard: + +| Key | Where | Action | +| --- | --- | --- | +| `+` / `-` | Anywhere in the chart | Zoom in / out | +| `0` | Anywhere in the chart | Reset to the full range | +| `←` / `→` | A document point | Pan earlier / later | +| `←` / `→` | A handle or the window | Move it by 5% of the full range (**Shift**: 20%) | +| `Home` / `End` | A handle or the window | Move to the start or end of the full range | + +Screen readers announce the selected range, for example "Showing Nov 22, 2010 to Mar 4, 2011", shortly after you stop changing it. + +### Linked to the Event Timeline + +When the patient also has an [Event Timeline](event-timeline.md), the two timelines are linked: + +- They share one date range, wide enough for every document and every event. If events go back further than the first document, the document timeline starts earlier too, with empty space before its first document. +- Moving a handle, a button, or a key in either timeline moves both. +- Their overview strips, handles, and date axes line up, so a date sits directly above the same date in the other timeline. + +The strips line up on screens at least 620 pixels wide. On narrower screens the timelines stay linked but do not line up. + +A handle released within a few pixels of either end snaps to that end. + ## Open a document - **Click a point** to open that document in the [Document Viewer](document-viewer.md). diff --git a/docs/explore-patient/event-timeline.md b/docs/explore-patient/event-timeline.md index d65548b..7578e86 100644 --- a/docs/explore-patient/event-timeline.md +++ b/docs/explore-patient/event-timeline.md @@ -51,12 +51,21 @@ The age axis only appears when the patient's date of birth is known. When it is ## Change the date range -The chart opens showing the full range of the patient's events, padded slightly at both ends. +The chart opens showing the full range of the patient's events, padded by 50 days at both ends. It works the same way as the [Patient Document Timeline](document-timeline.md#zoom-into-a-date-range). -- **Scroll** over the chart to zoom the date axis in and out. -- **Drag** on the **Date** band beneath the chart to select a narrower window. +The **Date** strip beneath the chart is an overview of the full range. It never moves: its left and right dates stay fixed, and it draws a small copy of every mark, one row per lane. Two handles on it select the range the chart above shows. -The two stay in step: zooming moves the band, and dragging the band rescales the chart. +- **Drag a handle** to move that end of the range. While zoomed in, each handle shows its date underneath. The handles cannot cross. +- **Drag the shaded window** to move the range, or **press the strip outside the window** to center the range there. +- Use the buttons above the chart to **zoom in**, **zoom out**, **pan earlier**, **pan later**, or **reset**. Each pan moves the range by a fifth of its length. Zoom goes up to 1600%. +- When zoomed in, **drag the chart** sideways to pan. +- With a mark focused, press `+` or `-` to zoom, `0` to reset, and `←` or `→` to pan. On a focused handle, `←` and `→` move it, and `Home` and `End` jump to either end. + +Scrolling the mouse wheel over the chart scrolls the page; it does not zoom. Collapsing a lane, or switching **Showing**, keeps the current range. + +The Event Timeline is **linked to the [Patient Document Timeline](document-timeline.md#linked-to-the-event-timeline)**. Both show the same date range, changing it in either one changes both, and their strips and date axes line up vertically, so an event sits directly below the documents written at the same time. + +All dates on this chart are calendar dates, shown the same in every time zone. ## Collapse a lane diff --git a/mock-api/README.md b/mock-api/README.md index f9a0fa1..3cd0b78 100644 --- a/mock-api/README.md +++ b/mock-api/README.md @@ -5,6 +5,13 @@ Serves the DeepPhe data API from the fixtures vendored in `mock-api/fixtures/` `public/data/demographics/patient_demographics.json`. No sibling checkout is required; set `FIXTURE_DIR` to serve from elsewhere. +`FIXTURE_DIR` can point at either layout: + +- flat patient files, such as `mock-api/fixtures/fake_patient1.json` +- directory-shaped DeepPhe JSON output, such as + `dphe_output_500/json/fake_patient500/fake_patient500.json` plus that + patient's `*_Doc.json`, `*_Cancers.json`, and `*_Concepts.json` files + ```bash npm run mock-api # http://localhost:3333 npm start # the app expects the API on 3333 diff --git a/mock-api/server.js b/mock-api/server.js index 98379b2..97aa113 100644 --- a/mock-api/server.js +++ b/mock-api/server.js @@ -39,17 +39,85 @@ const demographicsRecords = readJson(DEMOGRAPHICS_FILE, []); // anything, so serve the real API's spec verbatim. const openApiSpec = readJson(path.join(__dirname, "openapi.json"), { openapi: "3.0.0", paths: {} }); -const patientIds = fs - .readdirSync(FIXTURE_DIR) - .filter((name) => /^fake_patient\d+\.json$/.test(name)) - .map((name) => name.replace(/\.json$/, "")) - .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); +function sortPatientIds(left, right) { + return left.localeCompare(right, undefined, { numeric: true }); +} + +function sortFileNames(left, right) { + return left.localeCompare(right, undefined, { numeric: true }); +} + +function discoverPatientSources(fixtureDir) { + const sources = new Map(); + const entries = fs.readdirSync(fixtureDir, { withFileTypes: true }); + + entries.forEach((entry) => { + if (entry.isFile() && /^fake_patient\d+\.json$/.test(entry.name)) { + const patientId = entry.name.replace(/\.json$/, ""); + sources.set(patientId, { + kind: "flat", + path: path.join(fixtureDir, entry.name), + }); + return; + } + + if (entry.isDirectory() && /^fake_patient\d+$/.test(entry.name)) { + const patientId = entry.name; + const patientDir = path.join(fixtureDir, patientId); + const patientFile = path.join(patientDir, `${patientId}.json`); + if (fs.existsSync(patientFile)) { + sources.set(patientId, { + kind: "directory", + path: patientDir, + }); + } + } + }); + + return sources; +} + +function loadDirectoryPatient(patientId, patientDir) { + const patient = readJson(path.join(patientDir, `${patientId}.json`), { + id: patientId, + name: patientId, + }); + const fileNames = fs.readdirSync(patientDir).sort(sortFileNames); + const documents = fileNames + .filter((fileName) => /_Doc\.json$/.test(fileName)) + .map((fileName) => readJson(path.join(patientDir, fileName), null)) + .filter(Boolean); + const cancers = readJson(path.join(patientDir, `${patientId}_Cancers.json`), []); + const conceptsPayload = readJson(path.join(patientDir, `${patientId}_Concepts.json`), { + concepts: [], + conceptRelations: [], + }); + + return { + ...patient, + documents, + cancers: Array.isArray(cancers) ? cancers : [], + concepts: Array.isArray(conceptsPayload?.concepts) ? conceptsPayload.concepts : [], + conceptRelations: Array.isArray(conceptsPayload?.conceptRelations) + ? conceptsPayload.conceptRelations + : [], + }; +} + +const patientSources = discoverPatientSources(FIXTURE_DIR); +const patientIds = [...patientSources.keys()].sort(sortPatientIds); const fixtureCache = new Map(); function loadPatient(patientId) { - if (!patientIds.includes(patientId)) return null; + const source = patientSources.get(patientId); + if (!source) return null; if (!fixtureCache.has(patientId)) { - fixtureCache.set(patientId, readJson(path.join(FIXTURE_DIR, `${patientId}.json`), null)); + fixtureCache.set( + patientId, + source.kind === "directory" + ? loadDirectoryPatient(patientId, source.path) + : readJson(source.path, null) + ); } return fixtureCache.get(patientId); } diff --git a/package-lock.json b/package-lock.json index 2f78753..ace31b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,10 +20,8 @@ "@mui/x-charts": "^7.10.0", "@mui/x-data-grid": "^7.6.2", "@tanstack/react-table": "^8.21.3", - "d3-axis": "^3.0.0", - "d3-brush": "^3.0.0", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", "express": "^5.2.1", "html-react-parser": "^5.2.11", "http-proxy-middleware": "^2.0.9", @@ -8752,31 +8750,6 @@ "node": ">=12" } }, - "node_modules/d3-axis": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", - "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-brush": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", - "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "3", - "d3-transition": "3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -8798,37 +8771,6 @@ "node": ">=12" } }, - "node_modules/d3-dispatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-drag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", - "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-selection": "3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -8875,15 +8817,6 @@ "node": ">=12" } }, - "node_modules/d3-selection": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", - "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -8920,50 +8853,6 @@ "node": ">=12" } }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-transition": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", - "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-dispatch": "1 - 3", - "d3-ease": "1 - 3", - "d3-interpolate": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "d3-selection": "2 - 3" - } - }, - "node_modules/d3-zoom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", - "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "2 - 3", - "d3-transition": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", diff --git a/package.json b/package.json index a27d216..102c920 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,8 @@ "@mui/x-charts": "^7.10.0", "@mui/x-data-grid": "^7.6.2", "@tanstack/react-table": "^8.21.3", - "d3-axis": "^3.0.0", - "d3-brush": "^3.0.0", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", "express": "^5.2.1", "html-react-parser": "^5.2.11", "http-proxy-middleware": "^2.0.9", diff --git a/src/components/EmbeddedPatientView.jsx b/src/components/EmbeddedPatientView.jsx index 44f43c7..df104c0 100644 --- a/src/components/EmbeddedPatientView.jsx +++ b/src/components/EmbeddedPatientView.jsx @@ -6,6 +6,7 @@ import CancerTumorSummaryCard from "./patient/CancerTumorSummaryCard"; import PatientDocumentsCard from "./patient/PatientDocumentsCard"; import PatientDocumentDrawer from "./patient/PatientDocumentDrawer"; import EventRelationTimelineCard from "./patient/EventRelationTimelineCard"; +import TimelineLinkProvider from "./patient/timeline/TimelineLinkProvider"; import PatientSummaryCard from "./patient/PatientSummaryCard"; import { getInstances } from "../controllers/omap"; import { loadPatientFilterSummary } from "../controllers/patient"; @@ -13,6 +14,7 @@ import { shouldShowEventRelationTimeline } from "../controllers/eventRelationTim import { usePatientData } from "../hooks/usePatientData"; import { asRowArray, getValueFromRow } from "../utils/dataProcessing"; import { resolveFactSelection } from "../utils/patientView/factLinking"; +import { findDocumentIdsForConceptIds } from "../utils/patientView/documentMentions"; import { resolveSummarySelection } from "../utils/patientView/summarySelection"; /** @@ -427,6 +429,15 @@ export default function EmbeddedPatientView({ patientId = "" }) { () => getSummaryDetailSections(patientSummaryData || patientData?.rawPatient || patientData), [patientData, patientSummaryData] ); + const eventRelatedDocumentIds = useMemo( + () => + findDocumentIdsForConceptIds( + patientData?.documents, + patientData?.concepts, + timelineConceptIds + ), + [patientData, timelineConceptIds] + ); // Timeline report metadata keyed by document id, for labeling the document // picker with the same "type · date" the timeline shows. @@ -938,6 +949,8 @@ export default function EmbeddedPatientView({ patientId = "" }) { /> + {/* Links the two timelines: one date range, one zoom, aligned strips. */} + toggleSection("timeline")} @@ -982,6 +996,7 @@ export default function EmbeddedPatientView({ patientId = "" }) { /> ) : null} + {hasSummary ? ( [columnId, false])); +} + +function getInitialColumnVisibility() { + if (typeof window !== "undefined" && window.innerWidth < 1200) { + return getCoreColumnVisibility(); + } + + return {}; +} function toArray(value) { return Array.isArray(value) ? value : []; @@ -90,7 +109,7 @@ function PatientGrid({ const [globalFilter, setGlobalFilter] = useState(""); const [sorting, setSorting] = useState([]); const [expandedRows, setExpandedRows] = useState({}); - const [columnVisibility, setColumnVisibility] = useState({}); + const [columnVisibility, setColumnVisibility] = useState(getInitialColumnVisibility); const [columnSizing, setColumnSizing] = useState({}); const [columnSizingInfo, setColumnSizingInfo] = useState({}); const [columnMenuAnchorEl, setColumnMenuAnchorEl] = useState(null); @@ -137,7 +156,9 @@ function PatientGrid({ }, getRowCanExpand: (row) => Boolean(row?.original?._raw), globalFilterFn: (row, _, filterValue) => { - const query = String(filterValue ?? "").trim().toLowerCase(); + const query = String(filterValue ?? "") + .trim() + .toLowerCase(); if (!query) { return true; } @@ -184,9 +205,7 @@ function PatientGrid({ getSortedRowModel: getSortedRowModel(), }); - const tableMinWidth = table - .getVisibleLeafColumns() - .reduce((totalWidth, column) => totalWidth + column.getSize(), 0); + const tableMinWidth = table.getVisibleLeafColumns().reduce((totalWidth, column) => totalWidth + column.getSize(), 0); const filteredCount = table.getFilteredRowModel().rows.length; const loadedRowCount = data.length; @@ -196,10 +215,7 @@ function PatientGrid({ Number.isFinite(Number(totalCohortCount)) ? Number(totalCohortCount) : Number(cohortSize) || 0 ); const safeCurrentPage = Math.max(0, Number(currentPage) || 0); - const safeTotalPages = Math.max( - Number(totalPages) || 0, - Math.ceil(safeTotalCohortCount / safePageSize) - ); + const safeTotalPages = Math.max(Number(totalPages) || 0, Math.ceil(safeTotalCohortCount / safePageSize)); const visibleColumnCount = table.getVisibleLeafColumns().length; const totalColumnCount = columns.length; const isColumnMenuOpen = Boolean(columnMenuAnchorEl); @@ -225,14 +241,14 @@ function PatientGrid({ } > {showToolbarTitle || showToolbarSubtitle ? ( - + {showToolbarTitle ? ( {title} @@ -250,22 +266,26 @@ function PatientGrid({ ) : null} ) : ( - + )} setGlobalFilter(event.target.value)} - inputProps={{ "aria-label": "Search patient details" }} + inputProps={{ "aria-label": "Search loaded patient page" }} sx={{ width: { xs: "100%", sm: 290 } }} InputProps={{ startAdornment: ( @@ -277,23 +297,27 @@ function PatientGrid({ /> - } aria-label="Toggle visible patient columns" onClick={(event) => setColumnMenuAnchorEl(event.currentTarget)} > - - + {`Columns ${visibleColumnCount}/${totalColumnCount}`} + - exportFilteredSortedRowsToCsv(table, "cohort-patients.csv")} + variant="outlined" + startIcon={} + aria-label="Export current patient page to CSV" + onClick={() => exportFilteredSortedRowsToCsv(table, `cohort-patients-page-${safeCurrentPage + 1}.csv`)} > - - + Export page + {collapsible ? ( @@ -331,6 +355,29 @@ function PatientGrid({ }, }} > + + + + + {showSummaryRow ? ( - + {isSearchActive ? `Showing ${filteredCount.toLocaleString()} of ${loadedRowCount.toLocaleString()} loaded (filtered) · ${safeTotalCohortCount.toLocaleString()} in cohort` @@ -394,148 +441,205 @@ function PatientGrid({ minHeight: embedded ? 0 : undefined, }} > - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const canSort = header.column.getCanSort(); - return ( - - alpha( - muiTheme.palette.background.paper, - muiTheme.palette.mode === "dark" ? 0.92 : 0.98 - ), - }} - > - {flexRender(header.column.columnDef.header, header.getContext())} - {canSort ? : null} - {header.column.getCanResize() ? ( - { - event.stopPropagation(); - header.getResizeHandler()(event); - }} - onTouchStart={(event) => { - event.stopPropagation(); - header.getResizeHandler()(event); - }} - sx={{ - position: "absolute", - top: 0, - right: -5, - height: "100%", - width: 10, - cursor: "col-resize", - touchAction: "none", - zIndex: 2, - "&::after": { - content: '""', + +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const canSort = header.column.getCanSort(); + const sortDirection = header.column.getIsSorted(); + const columnMeta = header.column.columnDef.meta || {}; + const headerDefinition = header.column.columnDef.header; + const headerLabel = typeof headerDefinition === "string" ? headerDefinition : header.column.id; + return ( + `2px 0 4px ${alpha(muiTheme.palette.common.black, 0.1)}` + : "none", + }} + > + {canSort ? ( + + {flexRender(headerDefinition, header.getContext())} + + ) : ( + flexRender(headerDefinition, header.getContext()) + )} + {header.column.getCanResize() ? ( + { + event.stopPropagation(); + header.getResizeHandler()(event); + }} + onTouchStart={(event) => { + event.stopPropagation(); + header.getResizeHandler()(event); + }} + onKeyDown={(event) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") { + return; + } + + event.preventDefault(); + event.stopPropagation(); + const direction = event.key === "ArrowRight" ? 1 : -1; + const step = event.shiftKey ? 25 : 10; + const minSize = header.column.columnDef.minSize || 20; + const maxSize = header.column.columnDef.maxSize || 1000; + const nextSize = Math.max( + minSize, + Math.min(maxSize, header.column.getSize() + direction * step) + ); + setColumnSizing((previous) => ({ + ...previous, + [header.column.id]: nextSize, + })); + }} + sx={{ position: "absolute", - top: "20%", - bottom: "20%", - left: "50%", - transform: "translateX(-50%)", - width: 2, - borderRadius: 999, - bgcolor: header.column.getIsResizing() - ? "primary.main" - : alpha(theme.palette.text.primary, 0.18), - }, - }} - /> - ) : null} - - ); - })} - - ))} - - - - {isLoading ? ( - - - - - Loading patient details... - - - - ) : error ? ( - - - - - {String(error || "Failed to load patient details.")} - - - - - - ) : ( - table.getRowModel().rows.map((row, rowIndex) => ( - - )) - )} - - {!isLoading && !error && table.getRowModel().rows.length === 0 ? ( - - - No patients match your search. - - - ) : null} - -
-
+ top: 0, + right: -5, + height: "100%", + width: 10, + cursor: "col-resize", + touchAction: "none", + zIndex: 2, + "&:focus-visible": { + outline: "2px solid", + outlineColor: "primary.main", + outlineOffset: -2, + }, + "&::after": { + content: '""', + position: "absolute", + top: "20%", + bottom: "20%", + left: "50%", + transform: "translateX(-50%)", + width: 2, + borderRadius: 999, + bgcolor: header.column.getIsResizing() + ? "primary.main" + : alpha(theme.palette.text.primary, 0.18), + }, + }} + /> + ) : null} + + ); + })} + + ))} + + + + {isLoading ? ( + + + + + Loading patient details... + + + + ) : error ? ( + + + + {String(error || "Failed to load patient details.")} + + + + + ) : ( + table + .getRowModel() + .rows.map((row, rowIndex) => ( + + )) + )} + + {!isLoading && !error && table.getRowModel().rows.length === 0 ? ( + + + No patients match your search. + + + ) : null} + + + + + + Search, sort, and export apply to this loaded page. + = Math.max(0, safeTotalPages - 1), + disabled: isLoading || safeTotalPages <= 0 || safeCurrentPage >= Math.max(0, safeTotalPages - 1), }} sx={{ + ml: { sm: "auto" }, ".MuiTablePagination-selectLabel, .MuiTablePagination-displayedRows": { color: "text.secondary", }, }} /> +
{/* Right-click context menu on detail rows */} @@ -566,9 +669,7 @@ function PatientGrid({ open={Boolean(contextMenu)} onClose={handleContextMenuClose} anchorReference="anchorPosition" - anchorPosition={ - contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined - } + anchorPosition={contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined} > {contextMenu && Array.isArray(openPatientIds) && openPatientIds.includes(contextMenu.patientId) ? ( [ diff --git a/src/components/__tests__/EmbeddedPatientView.documentDrawer.test.jsx b/src/components/__tests__/EmbeddedPatientView.documentDrawer.test.jsx index 7a3013e..bc0b91b 100644 --- a/src/components/__tests__/EmbeddedPatientView.documentDrawer.test.jsx +++ b/src/components/__tests__/EmbeddedPatientView.documentDrawer.test.jsx @@ -53,7 +53,12 @@ jest.mock("../patient/PatientDocumentsCard", () => { }, "Open timeline document" ), - React.createElement("span", null, ` selected:${props.selectedDocumentId || "none"}`) + React.createElement("span", null, ` selected:${props.selectedDocumentId || "none"}`), + React.createElement( + "span", + null, + ` eventRelated:${(props.eventRelatedDocumentIds || []).join(",") || "none"}` + ) ); }; }); @@ -65,6 +70,14 @@ jest.mock("../patient/EventRelationTimelineCard", () => { return React.createElement( "section", { "data-testid": "mock-event-timeline-card" }, + React.createElement( + "button", + { + type: "button", + onClick: () => props.onSelectConceptIds?.(["concept-neoplasm"]), + }, + "Select event concepts" + ), `Event Timeline selected:${props.selectedDocument?.id || "none"}` ); }; @@ -267,11 +280,11 @@ describe("EmbeddedPatientView document drawer layout", () => { await waitFor(() => { expect(document.body.querySelector('[data-testid="patient-document-drawer"]')).not.toBeNull(); - expect(document.body.textContent).toContain("Clinical Note"); - expect( - container.querySelector('[data-testid="mock-event-timeline-card"]').textContent - ).toContain("selected:doc-1"); }); + expect(document.body.textContent).toContain("Clinical Note"); + expect( + container.querySelector('[data-testid="mock-event-timeline-card"]').textContent + ).toContain("selected:doc-1"); act(() => { container @@ -281,10 +294,10 @@ describe("EmbeddedPatientView document drawer layout", () => { await waitFor(() => { expect(document.body.textContent).toContain("Pathology Report"); - expect( - container.querySelector('[data-testid="mock-event-timeline-card"]').textContent - ).toContain("selected:doc-2"); }); + expect( + container.querySelector('[data-testid="mock-event-timeline-card"]').textContent + ).toContain("selected:doc-2"); act(() => { container @@ -294,10 +307,10 @@ describe("EmbeddedPatientView document drawer layout", () => { await waitFor(() => { expect(document.body.textContent).toContain("Related Consult"); - expect( - container.querySelector('[data-testid="mock-event-timeline-card"]').textContent - ).toContain("selected:doc-3"); }); + expect( + container.querySelector('[data-testid="mock-event-timeline-card"]').textContent + ).toContain("selected:doc-3"); expect(container.querySelector('[data-testid="patient-document-panel"]')).toBeNull(); @@ -310,10 +323,10 @@ describe("EmbeddedPatientView document drawer layout", () => { await waitFor(() => { expect(document.body.querySelector('[data-testid="patient-document-drawer"]')).toBeNull(); - expect( - container.querySelector('[data-testid="mock-event-timeline-card"]').textContent - ).toContain("selected:doc-3"); }); + expect( + container.querySelector('[data-testid="mock-event-timeline-card"]').textContent + ).toContain("selected:doc-3"); // The same report can be reopened after dismissing its drawer. act(() => { @@ -327,4 +340,30 @@ describe("EmbeddedPatientView document drawer layout", () => { unmount(); } }); + + it("passes selected event concept document ids into the document timeline", async () => { + const { container, unmount } = renderComponent( + + ); + + try { + await flushEffects(); + + expect(container.querySelector('[data-testid="mock-document-timeline-card"]').textContent) + .toContain("eventRelated:none"); + + act(() => { + container + .querySelector('[data-testid="mock-event-timeline-card"] button') + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitFor(() => { + expect(container.querySelector('[data-testid="mock-document-timeline-card"]').textContent) + .toContain("eventRelated:doc-1,doc-2,doc-3"); + }); + } finally { + unmount(); + } + }); }); diff --git a/src/components/__tests__/PatientGrid.test.jsx b/src/components/__tests__/PatientGrid.test.jsx index 4338245..f543399 100644 --- a/src/components/__tests__/PatientGrid.test.jsx +++ b/src/components/__tests__/PatientGrid.test.jsx @@ -99,9 +99,7 @@ describe("PatientGrid column sizing", () => { ); const patientIdHeader = container.querySelector('thead th[data-column-id="patientId"]'); - const patientIdResizeHandle = container.querySelector( - '[data-testid="patient-grid-column-resizer-patientId"]' - ); + const patientIdResizeHandle = container.querySelector('[data-testid="patient-grid-column-resizer-patientId"]'); expect(patientIdHeader).not.toBeNull(); expect(patientIdResizeHandle).not.toBeNull(); @@ -123,6 +121,49 @@ describe("PatientGrid column sizing", () => { unmount(); }); + + it("supports keyboard resizing for patient columns", async () => { + const { container, unmount } = renderComponent( + + ); + + const patientIdHeader = container.querySelector('thead th[data-column-id="patientId"]'); + const patientIdResizeHandle = container.querySelector('[data-testid="patient-grid-column-resizer-patientId"]'); + + expect(patientIdResizeHandle?.getAttribute("tabindex")).toBe("0"); + expect(patientIdResizeHandle?.getAttribute("aria-valuenow")).toBe("200"); + + await act(async () => { + patientIdResizeHandle?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowRight" })); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(Number(patientIdHeader?.getAttribute("data-column-size"))).toBe(210); + }); + + unmount(); + }); + + it("keeps page-scoped tools explicit and patient identity columns pinned", () => { + const { container, unmount } = renderComponent( + + ); + + expect(container.querySelector('input[placeholder="Search current page…"]')).not.toBeNull(); + expect(container.querySelector('input[aria-label="Search loaded patient page"]')).not.toBeNull(); + expect(container.querySelector('button[aria-label="Export current patient page to CSV"]')).not.toBeNull(); + expect(container.querySelector('[aria-label="Sort Patient ID on current page"]')).not.toBeNull(); + expect(container.textContent).toContain("Search, sort, and export apply to this loaded page."); + expect(container.querySelector('tbody td[data-column-id="expand"]')?.getAttribute("data-pinned-column")).toBe( + "true" + ); + expect(container.querySelector('tbody td[data-column-id="patientId"]')?.getAttribute("data-pinned-column")).toBe( + "true" + ); + + unmount(); + }); }); describe("PatientGrid detail panel actions", () => { @@ -139,12 +180,7 @@ describe("PatientGrid detail panel actions", () => { }; const { container, unmount } = renderComponent( - + ); const expandButton = container.querySelector('button[aria-label="Expand row details"]'); @@ -155,9 +191,7 @@ describe("PatientGrid detail panel actions", () => { let slider = null; await waitFor(() => { - slider = container.querySelector( - 'input[aria-label="Minimum patient drawer finding confidence percent"]' - ); + slider = container.querySelector('input[aria-label="Minimum patient drawer finding confidence percent"]'); expect(slider).not.toBeNull(); }); @@ -170,6 +204,17 @@ describe("PatientGrid detail panel actions", () => { expect(container.textContent).not.toContain("Low Confidence Finding"); expect(container.textContent).toContain("1 finding hidden below 50% confidence."); + await act(async () => { + const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; + valueSetter?.call(slider, "75"); + slider.dispatchEvent(new Event("change", { bubbles: true })); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(container.textContent).toContain("Confidence: >=75%"); + }); + unmount(); }); @@ -184,13 +229,7 @@ describe("PatientGrid detail panel actions", () => { }; const { container, unmount } = renderComponent( - + ); const expandButton = container.querySelector('button[aria-label="Expand row details"]'); diff --git a/src/components/patient/CancerTumorSummaryCard.jsx b/src/components/patient/CancerTumorSummaryCard.jsx index 3ffc80e..9ae8b12 100644 --- a/src/components/patient/CancerTumorSummaryCard.jsx +++ b/src/components/patient/CancerTumorSummaryCard.jsx @@ -42,10 +42,11 @@ function FactBadge({ fact, onSelect = undefined, isActive = false }) { sx={{ minWidth: 0, px: 0.8, - py: 0.05, - lineHeight: 1.1, + py: 0, + minHeight: 18, + lineHeight: 1.05, textTransform: "none", - fontSize: "0.73rem", + fontSize: "0.68rem", fontWeight: 600, borderRadius: 999, maxWidth: "100%", @@ -84,8 +85,8 @@ function FactBadgeGroup({ display: "inline-flex", alignItems: "center", flexWrap: "wrap", - columnGap: 0.55, - rowGap: 0.35, + columnGap: 0.4, + rowGap: 0.18, minWidth: 0, ...sx, }} @@ -296,11 +297,14 @@ export default function CancerTumorSummaryCard({ {normalizedCancers.length > 0 ? ( @@ -308,10 +312,11 @@ export default function CancerTumorSummaryCard({ variant="caption" sx={{ display: "inline-block", - px: 1, - py: 0.25, + px: 0.75, + py: 0.1, borderRadius: 999, fontWeight: 600, + lineHeight: 1.15, bgcolor: "info.main", color: "#fff", }} @@ -336,9 +341,9 @@ export default function CancerTumorSummaryCard({ @@ -384,12 +389,12 @@ export default function CancerTumorSummaryCard({ border: 1, borderColor: "divider", borderRadius: 1, - p: 1.25, + p: 0.6, bgcolor: "background.paper", display: "grid", - gap: 0.75, + gap: 0.32, minWidth: 0, - "& .MuiTypography-root": { lineHeight: 1.2 }, + "& .MuiTypography-root": { lineHeight: 1.08 }, }} > {/* Cancer-level facts flow and wrap so label/value groups pack @@ -400,15 +405,20 @@ export default function CancerTumorSummaryCard({ display: "flex", flexWrap: "wrap", alignItems: "center", - columnGap: 1.75, - rowGap: 0.5, + columnGap: 1, + rowGap: 0.22, minWidth: 0, }} > Cancer {cancerIndex + 1} @@ -438,7 +448,7 @@ export default function CancerTumorSummaryCard({ display: "inline-flex", flexWrap: "nowrap", alignItems: "center", - gap: "6px 8px", + gap: "2px 6px", minWidth: 0, }} > @@ -478,9 +488,9 @@ export default function CancerTumorSummaryCard({ display: "flex", flexWrap: "wrap", alignItems: "center", - columnGap: 1.75, - rowGap: 0.5, - pt: 0.6, + columnGap: 1, + rowGap: 0.22, + pt: 0.32, minWidth: 0, borderTop: 1, borderColor: "divider", @@ -522,7 +532,7 @@ export default function CancerTumorSummaryCard({ border: 1, borderColor: "divider", borderRadius: 1, - p: 1, + p: 0.75, bgcolor: "background.default", }} > diff --git a/src/components/patient/EventRelationTimelineCard.jsx b/src/components/patient/EventRelationTimelineCard.jsx index b59dac8..4943006 100644 --- a/src/components/patient/EventRelationTimelineCard.jsx +++ b/src/components/patient/EventRelationTimelineCard.jsx @@ -21,15 +21,11 @@ import { Stack, Typography, } from "@mui/material"; -import { styled } from "@mui/material/styles"; -import { select } from "d3-selection"; -import { zoom as d3Zoom, zoomIdentity } from "d3-zoom"; -import { brushX, brushSelection } from "d3-brush"; -import { axisBottom, axisTop } from "d3-axis"; +import { alpha, styled, useTheme } from "@mui/material/styles"; +import { scaleTime } from "d3-scale"; import { AGE_AREA, COLLAPSED_CAP_COLOR, - DATE_ANCHOR_COLOR, EVENT_RELATION_TIMELINE_SCOPE_ALL, EVENT_RELATION_TIMELINE_SCOPE_CURRENT_REPORT, GAPS, @@ -37,7 +33,6 @@ import { LEGEND, MARGINS, NEGATED_RELATION_COLOR, - OVERVIEW, PADDING, RELATION_COLOR, SELECTED_OUTLINE_COLOR, @@ -54,15 +49,89 @@ import { } from "../../controllers/eventRelationTimeline"; import { buildEventRelationHeatmap, + buildEventRelationOverviewRows, buildEventRelationTooltip, computeEventRelationAgeAxis, computeEventRelationDomain, computeEventRelationTimelineLayout, getEventRelationGlyph, } from "../../utils/patientView/eventRelationTimelineLayout"; +import { + resolveResponsiveTickCount, + resolveTicks, +} from "../../utils/patientView/timelineChartLayout"; +import { + describeViewportRange, + getMinimumWindowRatio, + viewportToDateWindow, +} from "../../utils/patientView/timelineViewport"; +import { clientXToSvgX } from "../../hooks/useTimelineViewport"; +import useLinkedTimelineViewport from "../../hooks/useLinkedTimelineViewport"; +import { TIMELINE_CONTENT_PADDING_X, TIMELINE_PLOT_INSET } from "../../constants/timelineFrame"; import SectionCollapseToggle from "./SectionCollapseToggle"; +import TimelineAxis from "./timeline/TimelineAxis"; +import TimelineOverviewStrip from "./timeline/TimelineOverviewStrip"; +import TimelineZoomControls from "./timeline/TimelineZoomControls"; const DEFAULT_CONTAINER_WIDTH = 1040; +const AXIS_FONT_SIZE = 10; + +const ZOOM_CONTROL_LABELS = { + group: "Event timeline zoom controls", + zoomIn: "Zoom in event timeline", + zoomOut: "Zoom out event timeline", + panEarlier: "Pan event timeline earlier", + panLater: "Pan event timeline later", + reset: "Reset event timeline zoom", +}; + +const OVERVIEW_STRIP_LABELS = { + group: "Event timeline date range", + window: "Pan event timeline date range", + start: "Start of event timeline date range", + end: "End of event timeline date range", +}; + +/** + * Theme-aware colors for the event timeline chrome (axes, gridlines and the + * selection halo). The alpha hardcoded light-mode grays and a black selection + * outline, which disappear or turn glaringly bright on the dark themes. The + * semantic mark colors (green relations, red negation) are intentionally left + * out — they carry meaning and read on every palette. + */ +export function getEventTimelineColors(theme) { + const palette = theme?.palette || {}; + const textSecondary = palette.text?.secondary || "#505A5F"; + const textPrimary = palette.text?.primary || "#0B0C0C"; + const isDark = palette.mode === "dark"; + + return { + // Row and axis titles: the lane labels ("Finding (5):"), "Patient Age" and + // "Date". The alpha left these unstyled, so they rendered black in every + // theme — invisible on the dark ones. + labelText: textPrimary, + // Age-axis tick labels (alpha: #444) — read as text, so aim for AA contrast. + ageText: textSecondary, + // Structural hairlines: lane-group dividers, heatmap frame, legend rule, and + // the scope setViewMode(event.target.value)} - sx={{ fontSize: 12, height: 26, minWidth: 180, bgcolor: "background.paper" }} - inputProps={{ - "aria-label": "Event relation timeline scope", - "data-testid": "event-relation-scope", - }} - > - - - - {selectedDocument && onOpenReport ? ( - - ) : null} -
- {selectedDocument ? ( - - Current report: {selectedDocument.name || selectedDocument.id} - - ) : null} - - {visibleSpans.length === 0 ? ( - - {effectiveViewMode === EVENT_RELATION_TIMELINE_SCOPE_CURRENT_REPORT - ? "No event relations match the current report." - : "No matched event relations are available to display."} - - ) : ( - - {/* ---- legend svg (alpha: legendSvg) ---- */} +
+ + + {zoomControls} + + Showing: + + + {selectedDocument && onOpenReport ? ( + + ) : null} +
- + {selectedDocument ? ( + + Current report: {selectedDocument.name || selectedDocument.id} + + ) : null} + + {visibleSpans.length === 0 ? ( + + {effectiveViewMode === EVENT_RELATION_TIMELINE_SCOPE_CURRENT_REPORT + ? "No event relations match the current report." + : "No matched event relations are available to display."} + + ) : ( + <> {/* ---- main timeline svg ---- */} - + + {/* Press-and-drag here pans a zoomed chart. Spans sit above it. */} + handlePlotPointerDown(event, { + clientToX: clientToPlotX, + plotWidth: dimensions.svgWidth, + }) + } /> {/* axes (not clipped) */} - - + - + {/* labels + toggles */} {groups.map((group) => ( @@ -939,6 +1088,7 @@ export default function EventRelationTimelineCard({ > @@ -1051,8 +1201,10 @@ export default function EventRelationTimelineCard({ isSelected={(span.conceptIds || []).some((id) => activeSelectionSet.has(id) )} + hasActiveSelection={activeSelectionSet.size > 0} idPrefix={generatedId} onToggle={handleSpanToggle} + selectedOutlineColor={colors.selectedOutline} /> ))} @@ -1064,7 +1216,7 @@ export default function EventRelationTimelineCard({ x2={dimensions.svgWidth} y1={group.height} y2={group.height} - stroke="#ccc" + stroke={colors.structureLine} strokeWidth={1} /> ) : null} @@ -1078,10 +1230,11 @@ export default function EventRelationTimelineCard({ {ageAxis.available ? ( {encounter.age} @@ -1134,7 +1287,7 @@ export default function EventRelationTimelineCard({ y={AGE_AREA.height / 2} textAnchor="middle" fontSize={11} - fill="#444" + fill={colors.ageText} > {interior.age} @@ -1144,25 +1297,36 @@ export default function EventRelationTimelineCard({ ) : null} - {/* overview + brush */} - + {/* overview strip: the full date range, never zoomed */} + Date - - @@ -1179,8 +1343,9 @@ export default function EventRelationTimelineCard({ } relations`} {selectedSpanCount > 0 ? ` | ${selectedSpanCount} selected` : ""} + + )} - )} )} diff --git a/src/components/patient/PatientDemographicsCard.jsx b/src/components/patient/PatientDemographicsCard.jsx index 7e4c96a..81d40b4 100644 --- a/src/components/patient/PatientDemographicsCard.jsx +++ b/src/components/patient/PatientDemographicsCard.jsx @@ -4,26 +4,35 @@ import { Box, Card, CardContent, - CardHeader, Typography, } from "@mui/material"; function DemographicItem({ label, value = "", capitalize = false }) { return ( - + {label} @@ -51,32 +60,45 @@ export default function PatientDemographicsCard({ patientData = null }) { return ( - - - {/* Fields flow across the full panel width and reflow to fewer columns - as it narrows, rather than stacking in one column and leaving the - rest of the row empty. */} + > + + Patient Details + diff --git a/src/components/patient/PatientDocumentsCard.jsx b/src/components/patient/PatientDocumentsCard.jsx index 92b97da..dd189c0 100644 --- a/src/components/patient/PatientDocumentsCard.jsx +++ b/src/components/patient/PatientDocumentsCard.jsx @@ -7,48 +7,41 @@ import { CardHeader, Divider, FormControl, - IconButton, InputLabel, Select, Stack, Tooltip, Typography, } from "@mui/material"; -import ZoomInIcon from "@mui/icons-material/ZoomIn"; -import ZoomOutIcon from "@mui/icons-material/ZoomOut"; -import RestartAltIcon from "@mui/icons-material/RestartAlt"; import { alpha, useTheme } from "@mui/material/styles"; import { + buildDocumentOverviewRows, buildTimelineChartModel, + resolveResponsiveTickCount, resolveTicks, + resolveTimelineDateDomain, } from "../../utils/patientView/timelineChartLayout"; +import { + computeOverviewStripLayout, + describeViewportRange, + getMinimumWindowRatio, + viewportToDateWindow, +} from "../../utils/patientView/timelineViewport"; +import { clientXToSvgX } from "../../hooks/useTimelineViewport"; +import useLinkedTimelineViewport from "../../hooks/useLinkedTimelineViewport"; +import { + COMPACT_TIMELINE_WIDTH, + DOCUMENT_CHART_BORDER_WIDTH, + TIMELINE_CONTENT_PADDING_X, + TIMELINE_PLOT_INSET, +} from "../../constants/timelineFrame"; import { getReadableTextColor } from "../../utils/colorContrast"; import SectionCollapseToggle from "./SectionCollapseToggle"; +import TimelineAxis from "./timeline/TimelineAxis"; +import TimelineOverviewStrip from "./timeline/TimelineOverviewStrip"; +import TimelineZoomControls from "./timeline/TimelineZoomControls"; -const MIN_ZOOM = 1; -const MAX_ZOOM = 16; -const ZOOM_STEP = 1.5; -// Pointer travel (in viewBox units) beyond which a press is treated as a pan -// drag rather than a document click. -const DRAG_THRESHOLD = 5; - -function clampZoom(zoom) { - if (!Number.isFinite(zoom)) { - return MIN_ZOOM; - } - return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom)); -} - -// panRatio is the visible window's left edge expressed as a fraction of the full -// date domain. At a given zoom the window is 1/zoom wide, so the furthest the -// left edge can travel is 1 - 1/zoom. -function clampPanRatio(panRatio, zoom) { - const maxPan = Math.max(0, 1 - 1 / zoom); - if (!Number.isFinite(panRatio)) { - return 0; - } - return Math.min(maxPan, Math.max(0, panRatio)); -} +export { resolveResponsiveTickCount }; const EPISODE_SELECT_ALL = "__all__"; const EPISODE_SELECT_HIDDEN = "__hidden__"; @@ -56,23 +49,42 @@ const EPISODE_SELECT_HIDDEN = "__hidden__"; // Below this rendered width the left label gutter would swallow the plot, so we // switch to a "compact" layout: report-type labels sit above each lane, fonts // and dots grow, and fewer date ticks are drawn. -const COMPACT_WIDTH_BREAKPOINT = 620; -const MIN_TICK_SPACING = 150; - -export function resolveResponsiveTickCount(plotWidth, maxTickCount = 7) { - const numericPlotWidth = Math.max(0, Number(plotWidth) || 0); - const numericMaxTickCount = Math.max(2, Number(maxTickCount) || 7); - return Math.min( - numericMaxTickCount, - Math.max(2, Math.floor(numericPlotWidth / MIN_TICK_SPACING) + 1) - ); -} +const COMPACT_WIDTH_BREAKPOINT = COMPACT_TIMELINE_WIDTH; +// Space between the detail axis's labels and the top of the overview strip. +const OVERVIEW_STRIP_GAP = 6; + +const ZOOM_CONTROL_LABELS = { + group: "Document timeline zoom controls", + zoomIn: "Zoom in timeline", + zoomOut: "Zoom out timeline", + panEarlier: "Pan timeline earlier", + panLater: "Pan timeline later", + reset: "Reset timeline zoom", +}; + +const OVERVIEW_STRIP_LABELS = { + group: "Document timeline date range", + window: "Pan document timeline date range", + start: "Start of document timeline date range", + end: "End of document timeline date range", +}; + +const visuallyHiddenSx = { + position: "absolute", + width: "1px", + height: "1px", + overflow: "hidden", + clip: "rect(0,0,0,0)", + whiteSpace: "nowrap", +}; export function getTimelineSvgColors(theme) { const textColor = theme?.palette?.text?.secondary || "#505A5F"; const axisColor = theme?.palette?.text?.disabled || textColor; const selectedMarkerColor = theme?.palette?.text?.primary || textColor; const relatedStrokeColor = theme?.palette?.primary?.main || textColor; + const eventRelatedStrokeColor = + theme?.palette?.mode === "dark" ? "#F6C744" : "#8A6400"; const pointStrokeColor = theme?.palette?.mode === "dark" ? theme?.palette?.background?.default || "#0B1220" @@ -82,10 +94,13 @@ export function getTimelineSvgColors(theme) { candidates: [theme?.palette?.info?.contrastText], }); + // The overview strip's colors are shared with the Event Timeline; see + // getOverviewStripColors in timeline/TimelineOverviewStrip.jsx. return { axisColor, docCountBadgeBackground, docCountBadgeText, + eventRelatedStrokeColor, pointStrokeColor, relatedStrokeColor, selectedMarkerColor, @@ -100,15 +115,18 @@ export function getTimelineSvgColors(theme) { function computeChartLayout({ width }) { const compact = width < COMPACT_WIDTH_BREAKPOINT; - const plotTop = compact ? 12 : 10; - // Footer holds the date axis line, tick marks, tick labels (~baselineY+28) and - // the "Date" title (~baselineY+30); trimmed to just clear the label descenders. - const footerHeight = compact ? 40 : 38; - const plotLeft = compact ? 16 : 236; - const plotRight = compact ? 16 : 20; + const plotTop = compact ? 10 : 8; + // Footer holds the detail date axis and its labels. The overview strip below + // it grows with the report-type count, so the card adds its height. + const tickLabelOffset = compact ? 20 : 19; + const footerHeight = 5 + tickLabelOffset + 4; + // Wide layouts share their plot edges with the Event Timeline so the two line + // up (constants/timelineFrame.js). The chart's own border is inside the frame. + const plotLeft = compact ? 16 : TIMELINE_PLOT_INSET.left - DOCUMENT_CHART_BORDER_WIDTH; + const plotRight = compact ? 16 : TIMELINE_PLOT_INSET.right - DOCUMENT_CHART_BORDER_WIDTH; // Vertical distance between report-type lanes. Kept just above the selected // ring diameter so lanes stay tight without dots colliding across rows. - const rowHeight = compact ? 52 : 40; + const rowHeight = compact ? 42 : 30; const dimensions = { // Keep the viewBox at the rendered width so preserveAspectRatio never @@ -127,26 +145,34 @@ function computeChartLayout({ width }) { const typeScale = { compact, labelMode: compact ? "top" : "left", - rowLabelFont: compact ? 15 : 13, - tickFont: compact ? 14 : 13.5, - axisTitleFont: compact ? 13 : 12, + rowLabelFont: compact ? 13 : 12, + tickFont: 12, + tickLabelOffset, + axisTitleFont: compact ? 12 : 11, tickCount: resolveResponsiveTickCount(plotWidth, maxTickCount), - pointRadius: compact ? 6 : 4.5, - selectedRadius: compact ? 9 : 7, - selectedRingRadius: compact ? 17 : 14, - relatedRingRadius: compact ? 9 : 7, + pointRadius: compact ? 5 : 4, + selectedRadius: compact ? 7 : 5.5, + selectedRingRadius: compact ? 13 : 10, + relatedRingRadius: compact ? 8 : 6.5, + eventRelatedRingRadius: compact ? 11 : 8.5, }; return { dimensions, typeScale }; } -function getPointAriaLabel(point) { +function getPointAriaLabel(point, { isRelated = false, isEventRelated = false } = {}) { + const states = [ + isRelated ? "Linked to selected cancer or tumor fact" : "", + isEventRelated ? "Linked to selected event timeline concept" : "", + ].filter(Boolean); + return [ `Document ${point.name || point.id}`, `ID ${point.id}`, `Type ${point.type}`, `Episode ${point.episodeLabel}`, `Date ${point.dateLabel}`, + ...states, ].join(". "); } @@ -213,6 +239,7 @@ export default function PatientDocumentsCard({ timelineData = null, selectedDocumentId = "", relatedDocumentIds = [], + eventRelatedDocumentIds = [], onSelectDocument = undefined, embedded = false, expanded = true, @@ -228,14 +255,7 @@ export default function PatientDocumentsCard({ const selectedMarkerColor = timelineColors.selectedMarkerColor; const [hiddenEpisodes, setHiddenEpisodes] = useState(() => new Set()); const [episodeSelections, setEpisodeSelections] = useState({}); - // Horizontal (time-axis) zoom + pan. zoom === 1 shows the full date domain; - // panRatio is the visible window's left edge as a fraction of that domain. - const [viewport, setViewport] = useState({ zoom: MIN_ZOOM, panRatio: 0 }); const svgRef = useRef(null); - const dragStateRef = useRef(null); - // True while a click-drag pan is in progress, so the cursor can switch to - // "grabbing" for immediate feedback. - const [isDragging, setIsDragging] = useState(false); // Rendered width of the chart container, tracked so labels and ticks can // respond without coupling the chart to an arbitrary panel height. const [chartWidth, setChartWidth] = useState(1200); @@ -248,7 +268,17 @@ export default function PatientDocumentsCard({ resizeObserverRef.current.disconnect(); resizeObserverRef.current = null; } - if (!node || typeof ResizeObserver === "undefined") { + if (!node) { + return; + } + // Measure now rather than waiting for the observer's first callback, so the + // first paint is already at the real width and lines up with the Event + // Timeline (which measures the same way). + const initialWidth = node.clientWidth; + if (initialWidth > 0) { + setChartWidth((previous) => (Math.abs(previous - initialWidth) < 2 ? previous : initialWidth)); + } + if (typeof ResizeObserver === "undefined") { return; } const observer = new ResizeObserver((entries) => { @@ -278,6 +308,15 @@ export default function PatientDocumentsCard({ ), [relatedDocumentIds] ); + const eventRelatedIdSet = useMemo( + () => + new Set( + (Array.isArray(eventRelatedDocumentIds) ? eventRelatedDocumentIds : []) + .map((documentId) => String(documentId || "").trim()) + .filter(Boolean) + ), + [eventRelatedDocumentIds] + ); // The model applies the measured width and derives height directly from its // report-type row count. @@ -288,21 +327,67 @@ export default function PatientDocumentsCard({ }), [chartWidth] ); - const chartModel = useMemo( - () => buildTimelineChartModel(timelineData || {}, layoutDimensions), - [timelineData, layoutDimensions] - ); const timelineSignature = useMemo( - () => chartModel.points.map((point) => point.id).join("|"), - [chartModel.points] + () => + (Array.isArray(timelineData?.reportData) ? timelineData.reportData : []) + .map((report) => String(report?.id || "").trim()) + .filter(Boolean) + .join("|"), + [timelineData] ); useEffect(() => { setHiddenEpisodes(new Set()); setEpisodeSelections({}); - setViewport({ zoom: MIN_ZOOM, panRatio: 0 }); }, [timelineSignature]); + // Horizontal (time-axis) zoom and pan. Under a TimelineLinkProvider this is + // shared with the Event Timeline, over a date domain covering both; alone, it + // is this card's own and resets when the set of documents changes. + const ownDateDomain = useMemo(() => resolveTimelineDateDomain(timelineData || {}), [timelineData]); + const { + viewport, + setViewport, + zoomIn, + zoomOut, + panEarlier, + panLater, + reset: handleResetView, + handlePlotKeyDown, + handlePlotPointerDown, + consumeDragClick, + isDragging, + announcement: rangeAnnouncement, + isAnnouncer, + domain: sharedDateDomain, + isZoomed, + zoomPercent, + canZoomIn, + canZoomOut, + canPanEarlier, + canPanLater, + canReset, + } = useLinkedTimelineViewport({ + id: "document-timeline", + priority: 0, + domain: ownDateDomain, + resetKey: timelineSignature, + describeRange: (nextViewport) => + ownDateDomain + ? describeViewportRange(nextViewport, ownDateDomain.startDate, ownDateDomain.endDate) + : "", + }); + + const chartModel = useMemo( + () => + buildTimelineChartModel(timelineData || {}, { + ...layoutDimensions, + dateDomain: sharedDateDomain, + }), + [timelineData, layoutDimensions, sharedDateDomain] + ); + const { dateDomain } = chartModel; + const visiblePoints = useMemo( () => chartModel.points.filter((point) => { @@ -368,13 +453,27 @@ export default function PatientDocumentsCard({ }; // --- Time-axis zoom + pan ------------------------------------------------- - const { dimensions, dateDomain } = chartModel; + const { dimensions } = chartModel; const plotLeft = dimensions.plotLeft; const plotWidth = dimensions.plotWidth; - const domainStartMs = dateDomain.startDate.getTime(); - const fullSpanMs = Math.max(1, dateDomain.endDate.getTime() - domainStartMs); - const isZoomed = viewport.zoom > MIN_ZOOM + 1e-6; const clipPathId = useId(); + const axisY = dimensions.baselineY + 5; + const minWindowRatio = getMinimumWindowRatio(plotWidth); + + // The overview strip sits under the detail axis and grows with the number of + // report-type rows it miniaturizes. + const overviewTop = dimensions.svgHeight + OVERVIEW_STRIP_GAP; + const chartHeight = + overviewTop + + computeOverviewStripLayout({ rowCount: chartModel.rows.length, compact: typeScale.compact }) + .height; + const overviewRows = useMemo( + () => + buildDocumentOverviewRows(chartModel.rows, visiblePoints, { + stroke: timelineColors.pointStrokeColor, + }), + [chartModel.rows, visiblePoints, timelineColors.pointStrokeColor] + ); // Map a base (full-domain) x-coordinate into the current zoom/pan window. const transformX = useCallback( @@ -385,225 +484,50 @@ export default function PatientDocumentsCard({ [plotLeft, plotWidth, viewport.zoom, viewport.panRatio] ); - // Re-derive axis ticks for the visible date window so labels stay meaningful - // (and pick finer granularity) as the user zooms in. + // The detail axis covers exactly the visible window, so its first and last + // labels are the selection's edges. const displayTicks = useMemo(() => { - const visibleSpanMs = fullSpanMs / viewport.zoom; - const visibleStartMs = domainStartMs + viewport.panRatio * fullSpanMs; - return resolveTicks( - new Date(visibleStartMs), - new Date(visibleStartMs + visibleSpanMs), - plotWidth, - plotLeft, - typeScale.tickCount + const { startDate, endDate } = viewportToDateWindow( + viewport, + dateDomain.startDate, + dateDomain.endDate ); - }, [ - fullSpanMs, - domainStartMs, - viewport.zoom, - viewport.panRatio, - plotWidth, - plotLeft, - typeScale.tickCount, - ]); - - const clientToSvgX = useCallback((clientX) => { - const svg = svgRef.current; - if ( - !svg || - typeof svg.getScreenCTM !== "function" || - typeof svg.createSVGPoint !== "function" - ) { - return null; - } - const ctm = svg.getScreenCTM(); - if (!ctm) { - return null; - } - const svgPoint = svg.createSVGPoint(); - svgPoint.x = clientX; - svgPoint.y = 0; - return svgPoint.matrixTransform(ctm.inverse()).x; - }, []); - - // Zoom by a multiplicative factor while keeping the date under `anchorSvgX` - // pinned in place (defaults to the plot center when no anchor is given). - const applyZoomFactor = useCallback( - (factor, anchorSvgX) => { - setViewport((current) => { - const nextZoom = clampZoom(current.zoom * factor); - if (nextZoom === current.zoom) { - return current; - } - const anchor = Number.isFinite(anchorSvgX) ? anchorSvgX : plotLeft + plotWidth / 2; - const anchorRatioFull = - current.panRatio + (anchor - plotLeft) / (plotWidth * current.zoom); - const nextPanRatio = clampPanRatio( - anchorRatioFull - (anchor - plotLeft) / (plotWidth * nextZoom), - nextZoom - ); - return { zoom: nextZoom, panRatio: nextPanRatio }; - }); - }, - [plotLeft, plotWidth] - ); + return resolveTicks(startDate, endDate, plotWidth, plotLeft, typeScale.tickCount); + }, [viewport, dateDomain, plotWidth, plotLeft, typeScale.tickCount]); - const nudgePan = useCallback((direction) => { - setViewport((current) => ({ - zoom: current.zoom, - panRatio: clampPanRatio( - current.panRatio + direction * (0.2 / current.zoom), - current.zoom - ), - })); - }, []); + const clientToSvgX = useCallback((clientX) => clientXToSvgX(svgRef.current, clientX), []); - const handleResetView = useCallback(() => { - setViewport({ zoom: MIN_ZOOM, panRatio: 0 }); - }, []); - - // Drag-to-pan. Window listeners (rather than pointer capture) keep child - // circle clicks working normally; a moved drag suppresses the trailing click. - const justDraggedRef = useRef(false); const handlePointerDown = (event) => { - if (event.button !== 0 || viewport.zoom <= MIN_ZOOM) { - return; - } - const startSvgX = clientToSvgX(event.clientX); - if (startSvgX == null) { - return; - } - // Suppress the browser's native text/element selection so a horizontal - // press-and-drag reads as a pan rather than a selection gesture. - event.preventDefault(); - setIsDragging(true); - const drag = { startSvgX, startPanRatio: viewport.panRatio, moved: false }; - dragStateRef.current = drag; - - const handleMove = (moveEvent) => { - const svgX = clientToSvgX(moveEvent.clientX); - if (svgX == null) { - return; - } - const deltaSvgX = svgX - drag.startSvgX; - if (!drag.moved && Math.abs(deltaSvgX) < DRAG_THRESHOLD) { - return; - } - drag.moved = true; - setViewport((current) => ({ - zoom: current.zoom, - panRatio: clampPanRatio( - drag.startPanRatio - deltaSvgX / (plotWidth * current.zoom), - current.zoom - ), - })); - }; - const handleUp = () => { - window.removeEventListener("pointermove", handleMove); - window.removeEventListener("pointerup", handleUp); - setIsDragging(false); - if (drag.moved) { - // Swallow the click synthesized from this pointerup, then clear. - justDraggedRef.current = true; - window.setTimeout(() => { - justDraggedRef.current = false; - }, 0); - } - dragStateRef.current = null; - }; - window.addEventListener("pointermove", handleMove); - window.addEventListener("pointerup", handleUp); + handlePlotPointerDown(event, { clientToX: clientToSvgX, plotWidth }); }; const handleSelectPoint = (documentId) => { - if (justDraggedRef.current) { - justDraggedRef.current = false; + if (consumeDragClick()) { return; } onSelectDocument?.(documentId); }; - const handleChartKeyDown = (event) => { - switch (event.key) { - case "+": - case "=": - event.preventDefault(); - applyZoomFactor(ZOOM_STEP); - break; - case "-": - case "_": - event.preventDefault(); - applyZoomFactor(1 / ZOOM_STEP); - break; - case "0": - event.preventDefault(); - handleResetView(); - break; - case "ArrowLeft": - event.preventDefault(); - nudgePan(-1); - break; - case "ArrowRight": - event.preventDefault(); - nudgePan(1); - break; - default: - break; - } - }; - // Zoom/pan controls live in the card header, on the same line as the title. // Only meaningful when the zoomable chart is shown (not the collapsed-timestamp // dropdown mode and with at least one dated report). const showZoomControls = expanded && !isCollapsedTimestampMode && chartModel.totalReports > 0; const zoomControls = ( - - - {`${Math.round(viewport.zoom * 100)}%`} - - - - applyZoomFactor(1 / ZOOM_STEP)} - disabled={viewport.zoom <= MIN_ZOOM + 1e-6} - > - - - - - - - applyZoomFactor(ZOOM_STEP)} - disabled={viewport.zoom >= MAX_ZOOM - 1e-6} - > - - - - - - - - - - - - + zoomIn()} + onZoomOut={() => zoomOut()} + onPanEarlier={panEarlier} + onPanLater={panLater} + onReset={handleResetView} + canZoomIn={canZoomIn} + canZoomOut={canZoomOut} + canPanEarlier={canPanEarlier} + canPanLater={canPanLater} + canReset={canReset} + labels={ZOOM_CONTROL_LABELS} + /> ); return ( @@ -626,20 +550,27 @@ export default function PatientDocumentsCard({ > + {showZoomControls ? zoomControls : null} {chartModel.totalReports > 0 ? ( } /> + {/* Outside the collapsible body, so linked range changes are still + announced while this section is collapsed. One linked timeline owns it. */} + {isAnnouncer ? ( + + {rangeAnnouncement} + + ) : null} {expanded ? ( <> ) : ( - + {isCollapsedTimestampMode ? ( @@ -712,20 +655,20 @@ export default function PatientDocumentsCard({ overflow: "hidden", bgcolor: "background.paper", width: "100%", - // The SVG model includes exactly one compact lane per report - // type plus the date-axis footer—no viewport-sized filler. + // The SVG holds exactly one compact lane per report type, the + // date axis and the overview strip—no viewport-sized filler. // Add the two border pixels outside the content-sized SVG. - height: `calc(${chartModel.dimensions.svgHeight}px + 2px)`, + height: `calc(${chartHeight}px + 2px)`, }} > Timeline chart showing one row per report type with clickable document points positioned by report date. Zoomable and pannable along the date axis using the - zoom controls or keyboard, and pannable by drag or arrow keys. + zoom controls, the keyboard, or the handles on the overview strip, which shows + every document across the full date range. {chartModel.rows.map((row) => ( @@ -798,50 +742,26 @@ export default function PatientDocumentsCard({ ))} - - {displayTicks.map((tick, tickIndex) => { - const isFirstTick = tickIndex === 0; - const isLastTick = tickIndex === displayTicks.length - 1; - const tickLabelX = isFirstTick ? tick.x + 6 : isLastTick ? tick.x - 6 : tick.x; - const tickTextAnchor = isFirstTick ? "start" : isLastTick ? "end" : "middle"; - - return ( - - - - {tick.label} - - - ); - })} - {typeScale.labelMode === "left" ? ( ) : null} + + {visiblePoints.map((point) => { const isSelected = point.id === selectedDocumentId; const isRelated = relatedIdSet.has(point.id); + const isEventRelated = eventRelatedIdSet.has(point.id); const pointX = transformX(point.x); + const pointLabel = getPointAriaLabel(point, { + isRelated, + isEventRelated, + }); return ( + {isEventRelated ? ( + + ) : null} + {isRelated && !isSelected ? ( ) : null} + ); @@ -927,10 +906,12 @@ export default function PatientDocumentsCard({ - + Click a point to load that document (Tab + Enter/Space for keyboard selection). - Use the +/− buttons to zoom the date axis; drag or press ←/→ to pan. Scrolling - moves through the patient view. + Use the zoom buttons, the + and − keys, or the handles on the overview strip below + the chart to choose the date range; drag the chart or press ←/→ to pan. The + overview strip always shows the full date range, and its handles respond to ←/→, + Home, and End. Scrolling moves through the patient view. ) : ( @@ -940,29 +921,43 @@ export default function PatientDocumentsCard({ )} - {selectedPoint ? ( - - - Selected: {selectedPoint.name} • {selectedPoint.type} •{" "} - {selectedPoint.dateLabel} + + {selectedPoint ? ( + + + Selected: {selectedPoint.name} • {selectedPoint.type} •{" "} + {selectedPoint.dateLabel} + + + ) : null} + + {hiddenEpisodes.size > 0 ? ( + + Hidden: {chartModel.points.length - visiblePoints.length} doc(s) - - ) : null} + ) : null} - {hiddenEpisodes.size > 0 ? ( - - Hidden by episode filter: {chartModel.points.length - visiblePoints.length} document(s). - - ) : null} + {relatedIdSet.size > 0 ? ( + + Dashed outline: fact-linked documents + + ) : null} - {relatedIdSet.size > 0 ? ( - - - Dashed-outline points are fact-linked documents derived from the selected cancer/tumor - fact. + {eventRelatedIdSet.size > 0 ? ( + + Gold outline: event-linked documents - - ) : null} + ) : null} + )} @@ -980,6 +975,7 @@ PatientDocumentsCard.propTypes = { }), selectedDocumentId: PropTypes.string, relatedDocumentIds: PropTypes.arrayOf(PropTypes.string), + eventRelatedDocumentIds: PropTypes.arrayOf(PropTypes.string), onSelectDocument: PropTypes.func, embedded: PropTypes.bool, expanded: PropTypes.bool, diff --git a/src/components/patient/__tests__/EventRelationTimelineCard.test.jsx b/src/components/patient/__tests__/EventRelationTimelineCard.test.jsx index ad47a1f..74d845f 100644 --- a/src/components/patient/__tests__/EventRelationTimelineCard.test.jsx +++ b/src/components/patient/__tests__/EventRelationTimelineCard.test.jsx @@ -2,8 +2,22 @@ import React from "react"; import { act } from "react"; import { createRoot } from "react-dom/client"; -import EventRelationTimelineCard from "../EventRelationTimelineCard"; +import { getContrastRatio } from "@mui/material/styles"; +import EventRelationTimelineCard, { + getEventTimelineColors, +} from "../EventRelationTimelineCard"; +import { THEME_OPTIONS, getThemeByKey } from "../../../themes"; +import { WCAG_AA_TEXT_CONTRAST, WCAG_UI_CONTRAST } from "../../../utils/colorContrast"; import { resetStaticEventRelationTimelineCacheForTests } from "../../../clients/eventRelationTimeline"; +import { TIMELINE_PLOT_INSET } from "../../../constants/timelineFrame"; + +// Composited panel background the timeline SVG sits on, per theme (the vapor +// paper is a translucent white over its near-black page). +const TIMELINE_THEME_BACKGROUNDS = { + obsidian: "#1A2332", + vapor: "#161425", + govuk: "#FFFFFF", +}; const TEST_TSV = [ "PatientID\tConceptID\tRelation1\tDate1\tRelation2\tDate2", @@ -91,6 +105,14 @@ function setNativeSelectValue(selectNode, value) { selectNode.dispatchEvent(new Event("change", { bubbles: true })); } +function zoomInWithButton(container) { + act(() => { + container + .querySelector('button[aria-label="Zoom in event timeline"]') + .dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); +} + function mockTimelineFetch(tsvText = TEST_TSV) { global.fetch = jest.fn(async () => ({ ok: true, @@ -127,6 +149,9 @@ describe("EventRelationTimelineCard", () => { // Three rows, none sharing a lane group + date range, so three merged spans. expect(container.querySelectorAll("[data-event-relation-id]")).toHaveLength(3); expect(container.textContent).toContain("Event Timeline"); + expect(container.querySelector(".event-relation-legend")).not.toBeNull(); + expect(container.querySelector(".event-relation-legend svg[aria-label]")).not.toBeNull(); + expect(container.querySelector("[data-testid='event-relation-scope']")).not.toBeNull(); // Group labels count merged spans, in the alpha's "Group (n):" form. expect(container.textContent).toContain("Finding (2):"); expect(container.textContent).toContain("Treatment (1):"); @@ -195,37 +220,212 @@ describe("EventRelationTimelineCard", () => { expect(container.querySelector("select")).not.toBeNull(); act(() => setNativeSelectValue(container.querySelector("select"), "all")); expect(container.querySelectorAll("[data-event-relation-id]")).toHaveLength(3); - expect(container.querySelector(".brush .overlay")).not.toBeNull(); + expect(container.querySelector("[data-testid='event-timeline-overview-band']")).not.toBeNull(); + expect(container.querySelector("[data-testid='event-timeline-slider-start']")).not.toBeNull(); + expect(container.querySelector("[data-testid='event-timeline-slider-end']")).not.toBeNull(); const markX = () => container.querySelector("[data-concept-ids='c-treatment'] .relation-icon").getAttribute("x1"); const beforeZoom = markX(); - act(() => container.querySelector(".zoom_ER").dispatchEvent( - new WheelEvent("wheel", { deltaY: -200, clientX: 300, bubbles: true, cancelable: true }) - )); + zoomInWithButton(container); expect(markX()).not.toBe(beforeZoom); } finally { unmount(); } }); + it("leaves wheel gestures to scroll the page instead of zooming", async () => { + mockTimelineFetch(); + const { container, unmount } = renderComponent( + + ); + + try { + await waitFor(() => expect(container.querySelectorAll("[data-event-relation-id]")).toHaveLength(3)); + const markX = () => + container.querySelector("[data-concept-ids='c-treatment'] .relation-icon").getAttribute("x1"); + const before = markX(); + const wheelEvent = new WheelEvent("wheel", { + deltaY: -200, + clientX: 300, + bubbles: true, + cancelable: true, + }); + + act(() => { + container.querySelector(".zoom_ER").dispatchEvent(wheelEvent); + }); + + expect(wheelEvent.defaultPrevented).toBe(false); + expect(markX()).toBe(before); + expect(container.textContent).toContain("100%"); + } finally { + unmount(); + } + }); + + it("keeps the overview axis ends fixed and draws a miniature span per mark", async () => { + mockTimelineFetch(); + const { container, unmount } = renderComponent( + + ); + + try { + await waitFor(() => expect(container.querySelectorAll("[data-event-relation-id]")).toHaveLength(3)); + + const ticks = [...container.querySelectorAll("[data-testid='event-timeline-overview-tick']")]; + expect(ticks).toHaveLength(3); + expect(ticks.filter((tick) => tick.dataset.row === "Finding")).toHaveLength(2); + expect(ticks.map((tick) => tick.getAttribute("fill")).sort()).toEqual([ + "rgb(255, 0, 0)", + "rgb(49, 163, 84)", + "rgb(49, 163, 84)", + ]); + + const overviewLabels = () => + [ + ...container + .querySelector("[data-testid='event-timeline-overview-axis']") + .querySelectorAll("text"), + ].map((node) => node.textContent); + const detailLabels = () => + [...container.querySelectorAll(".main-ER-x-axis-bottom text")].map((node) => node.textContent); + const initialOverview = overviewLabels(); + const initialDetail = detailLabels(); + // Domain = the data ±50 days. + expect(initialOverview[0]).toBe("Nov 2009"); + expect(initialOverview[initialOverview.length - 1]).toBe("Mar 2011"); + + // The plot starts TIMELINE_PLOT_INSET.left into the SVG; in jsdom clientX + // is in viewBox units. + const selection = container.querySelector("[data-testid='event-timeline-slider-selection']"); + const startX = Number(selection.getAttribute("x")) + TIMELINE_PLOT_INSET.left; + const width = Number(selection.getAttribute("width")); + const startHandle = container.querySelector("[data-testid='event-timeline-slider-start']"); + act(() => { + startHandle.dispatchEvent( + new MouseEvent("pointerdown", { bubbles: true, cancelable: true, button: 0, clientX: startX }) + ); + window.dispatchEvent( + new MouseEvent("pointermove", { bubbles: true, cancelable: true, clientX: startX + width / 2 }) + ); + window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, cancelable: true })); + }); + + expect(overviewLabels()).toEqual(initialOverview); + expect(detailLabels()).not.toEqual(initialDetail); + expect(container.textContent).toContain("200%"); + expect( + container.querySelector("[data-testid='event-timeline-slider-start-label']").textContent + ).toMatch(/, 2010$/); + } finally { + unmount(); + } + }); + + it("zooms and pans from the keyboard, and keeps the zoom when a lane collapses", async () => { + mockTimelineFetch(); + const { container, unmount } = renderComponent( + + ); + + try { + await waitFor(() => expect(container.querySelectorAll("[data-event-relation-id]")).toHaveLength(3)); + const chart = container.querySelector('svg[aria-label="Event relation timeline chart"]'); + const span = () => container.querySelector("[data-concept-ids='c-treatment']"); + const press = (key) => + act(() => { + span().dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key })); + }); + expect(chart).not.toBeNull(); + + press("+"); + press("+"); + expect(container.textContent).toContain("225%"); + const windowX = () => + Number( + container + .querySelector("[data-testid='event-timeline-slider-selection']") + .getAttribute("x") + ); + const centered = windowX(); + press("ArrowRight"); + expect(windowX()).toBeGreaterThan(centered); + press("ArrowLeft"); + expect(windowX()).toBeCloseTo(centered, 3); + + const zoomedMarkX = span().querySelector(".relation-icon").getAttribute("x1"); + act(() => { + container + .querySelector(".group-toggle") + .dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(container.querySelector(".group-toggle").getAttribute("aria-expanded")).toBe("false"); + expect(container.textContent).toContain("225%"); + expect(windowX()).toBeCloseTo(centered, 3); + expect(span().querySelector(".relation-icon").getAttribute("x1")).toBe(zoomedMarkX); + + press("0"); + expect(container.textContent).toContain("100%"); + } finally { + unmount(); + } + }); + + it("zooms from the legend controls and can reset the event range", async () => { + mockTimelineFetch(); + const { container, unmount } = renderComponent( + + ); + + try { + await waitFor(() => { + expect(container.querySelectorAll("[data-event-relation-id]")).toHaveLength(3); + }); + + const markX = () => + container + .querySelector("[data-concept-ids='c-treatment'] .relation-icon") + .getAttribute("x1"); + const baselineX = markX(); + + await act(async () => { + container + .querySelector('button[aria-label="Zoom in event timeline"]') + .dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(markX()).not.toBe(baselineX); + + await act(async () => { + container + .querySelector('button[aria-label="Reset event timeline zoom"]') + .dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(markX()).toBe(baselineX); + } finally { + unmount(); + } + }); + it("preserves zoom and restores interactions after reopening the panel", async () => { mockTimelineFetch(); const props = { patientId: "fake_patient1", concepts: CONCEPTS }; const { container, rerender, unmount } = renderComponent(); try { await waitFor(() => expect(container.querySelectorAll("[data-event-relation-id]")).toHaveLength(3)); - const zoom = () => act(() => container.querySelector(".zoom_ER").dispatchEvent( - new WheelEvent("wheel", { deltaY: -200, clientX: 300, bubbles: true, cancelable: true }) - )); const markX = () => container.querySelector("[data-concept-ids='c-treatment'] .relation-icon").getAttribute("x1"); - zoom(); + const windowWidth = () => + container + .querySelector("[data-testid='event-timeline-slider-selection']") + .getAttribute("width"); + zoomInWithButton(container); const zoomedX = markX(); - const brushWidth = container.querySelector(".brush .selection").getAttribute("width"); + const zoomedWindowWidth = windowWidth(); rerender(); rerender(); expect(markX()).toBe(zoomedX); - expect(container.querySelector(".brush .overlay")).not.toBeNull(); - expect(container.querySelector(".brush .selection").getAttribute("width")).toBe(brushWidth); - zoom(); + expect(windowWidth()).toBe(zoomedWindowWidth); + zoomInWithButton(container); expect(markX()).not.toBe(zoomedX); } finally { unmount(); @@ -263,8 +463,20 @@ describe("EventRelationTimelineCard", () => { expect( container.querySelector("[data-concept-ids='c-treatment']").dataset.selected ).toBe("true"); + expect(container.querySelector("[data-concept-ids='c-finding']").dataset.selectionState) + .toBe("unselected"); - // Clicking again clears the same ids. + await act(async () => { + container + .querySelector("[data-concept-ids='c-finding']") + .dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect( + container.querySelector("[data-concept-ids='c-finding']").dataset.selected + ).toBe("true"); + + // Deselecting one event removes only that event's concepts. await act(async () => { container .querySelector("[data-concept-ids='c-treatment']") @@ -272,9 +484,15 @@ describe("EventRelationTimelineCard", () => { }); expect(handleSelect).toHaveBeenLastCalledWith( - [], + ["c-finding"], expect.objectContaining({ isDeselecting: true }) ); + expect( + container.querySelector("[data-concept-ids='c-treatment']").dataset.selected + ).toBe("false"); + expect( + container.querySelector("[data-concept-ids='c-finding']").dataset.selected + ).toBe("true"); } finally { unmount(); } @@ -320,6 +538,34 @@ describe("EventRelationTimelineCard", () => { } }); + it("keeps age labels, toggles, and the selection halo legible in every theme", () => { + THEME_OPTIONS.forEach(({ key }) => { + const theme = getThemeByKey(key); + const background = TIMELINE_THEME_BACKGROUNDS[key]; + const colors = getEventTimelineColors(theme); + + // Age-axis tick labels read as text (alpha hardcoded #444 → 1.65:1 on dark). + expect(getContrastRatio(colors.ageText, background)).toBeGreaterThanOrEqual( + WCAG_AA_TEXT_CONTRAST + ); + // Group collapse chevrons are a UI control (alpha #666 → 2.95:1 on dark). + expect(getContrastRatio(colors.toggleIcon, background)).toBeGreaterThanOrEqual( + WCAG_UI_CONTRAST + ); + // The selected span/marker halo must be visible (alpha "black" vanished on dark). + expect(getContrastRatio(colors.selectedOutline, background)).toBeGreaterThanOrEqual( + WCAG_UI_CONTRAST + ); + // Date axis labels read as text; the axis line is a UI component. + expect(getContrastRatio(colors.axisText, background)).toBeGreaterThanOrEqual( + WCAG_AA_TEXT_CONTRAST + ); + expect(getContrastRatio(colors.axisLine, background)).toBeGreaterThanOrEqual( + WCAG_UI_CONTRAST + ); + }); + }); + it("does not fetch or render for non-target patients", () => { mockTimelineFetch(); const { container, unmount } = renderComponent( diff --git a/src/components/patient/__tests__/PatientDocumentsCard.test.jsx b/src/components/patient/__tests__/PatientDocumentsCard.test.jsx index ffc8dd3..c07a4bd 100644 --- a/src/components/patient/__tests__/PatientDocumentsCard.test.jsx +++ b/src/components/patient/__tests__/PatientDocumentsCard.test.jsx @@ -108,9 +108,18 @@ describe("PatientDocumentsCard", () => { expect(container.querySelectorAll("circle[data-document-id]")).toHaveLength(3); expect(container.querySelectorAll("select")).toHaveLength(0); - // Height is content-sized: plotTop(10) + rows(2) * rowHeight(40) + footer(38). + // Height is content-sized: plotTop(8) + rows(2) * rowHeight(30) + axis + // footer(28) + gap(6) + overview strip for 2 rows(52). const viewBox = svg.getAttribute("viewBox").split(" ").map(Number); - expect(viewBox[3]).toBe(128); + expect(viewBox[3]).toBe(154); + + const sliderHandle = container.querySelector("[data-testid='document-timeline-slider-end']"); + const handleCoordinates = sliderHandle + .getAttribute("d") + .match(/-?\d+(?:\.\d+)?/g) + .map(Number); + const handleMaxY = Math.max(...handleCoordinates.filter((_, index) => index % 2 === 1)); + expect(handleMaxY).toBeLessThanOrEqual(viewBox[3] - 1); unmount(); }); @@ -136,6 +145,9 @@ describe("PatientDocumentsCard", () => { expect(getContrastRatio(colors.relatedStrokeColor, background)).toBeGreaterThanOrEqual( WCAG_UI_CONTRAST ); + expect(getContrastRatio(colors.eventRelatedStrokeColor, background)).toBeGreaterThanOrEqual( + WCAG_UI_CONTRAST + ); expect(getContrastRatio(colors.docCountBadgeText, colors.docCountBadgeBackground)).toBeGreaterThanOrEqual( WCAG_AA_TEXT_CONTRAST ); @@ -180,6 +192,120 @@ describe("PatientDocumentsCard", () => { unmount(); }); + it("marks event-linked documents with a distinct accessible gold ring", () => { + const { container, unmount } = renderComponent( + + ); + + const eventRing = container.querySelector( + '.patient-timeline-event-related-ring[data-document-id="doc-2"]' + ); + const eventPoint = container.querySelector('circle[data-document-id="doc-2"].patient-timeline-point'); + const factPoint = container.querySelector('circle[data-document-id="doc-1"].patient-timeline-point'); + + expect(eventRing).not.toBeNull(); + expect(eventPoint.dataset.eventRelated).toBe("true"); + expect(eventPoint.getAttribute("aria-label")).toContain( + "Linked to selected event timeline concept" + ); + expect(factPoint.dataset.related).toBe("true"); + expect(container.textContent).toContain("Gold outline: event-linked documents"); + + unmount(); + }); + + it("zooms from the compact date range slider and syncs reset", () => { + const { container, unmount } = renderComponent( + + ); + + const readSpread = () => { + const xs = [...container.querySelectorAll("circle[data-document-id]")].map((circle) => + Number(circle.getAttribute("cx")) + ); + return Math.max(...xs) - Math.min(...xs); + }; + const selection = () => container.querySelector("[data-testid='document-timeline-slider-selection']"); + + const baselineSpread = readSpread(); + const baselineSelectionWidth = Number(selection().getAttribute("width")); + const endHandle = container.querySelector("[data-testid='document-timeline-slider-end']"); + const selectionEndX = + Number(selection().getAttribute("x")) + Number(selection().getAttribute("width")); + + act(() => { + endHandle.dispatchEvent( + new MouseEvent("pointerdown", { + bubbles: true, + cancelable: true, + button: 0, + clientX: selectionEndX, + }) + ); + window.dispatchEvent( + new MouseEvent("pointermove", { + bubbles: true, + cancelable: true, + clientX: selectionEndX - baselineSelectionWidth / 2, + }) + ); + window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, cancelable: true })); + }); + + expect(readSpread()).toBeGreaterThan(baselineSpread + 1); + expect(Number(selection().getAttribute("width"))).toBeLessThan(baselineSelectionWidth); + expect(container.querySelector(".patient-timeline-event-related-ring")).not.toBeNull(); + + act(() => { + container + .querySelector('[aria-label="Reset timeline zoom"]') + .dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(readSpread()).toBeCloseTo(baselineSpread, 3); + expect(Number(selection().getAttribute("width"))).toBeCloseTo(baselineSelectionWidth, 3); + + unmount(); + }); + + it("supports keyboard control for the compact date range slider", () => { + const { container, unmount } = renderComponent( + + ); + + const selection = () => container.querySelector("[data-testid='document-timeline-slider-selection']"); + const endHandle = () => container.querySelector("[data-testid='document-timeline-slider-end']"); + const baselineSelectionWidth = Number(selection().getAttribute("width")); + + expect(selection().getAttribute("role")).toBe("slider"); + expect(selection().getAttribute("aria-label")).toBe("Pan document timeline date range"); + expect(selection().getAttribute("aria-valuetext")).toContain(" to "); + expect(endHandle().getAttribute("role")).toBe("slider"); + expect(endHandle().getAttribute("aria-label")).toBe("End of document timeline date range"); + + act(() => { + endHandle().dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "ArrowLeft", + }) + ); + }); + + expect(Number(selection().getAttribute("width"))).toBeLessThan(baselineSelectionWidth); + expect(Number(endHandle().getAttribute("aria-valuenow"))).toBeLessThan(1000); + + unmount(); + }); + it("spreads timeline points along the date axis when zoomed in, and restores on reset", () => { const { container, unmount } = renderComponent( @@ -235,6 +361,228 @@ describe("PatientDocumentsCard", () => { unmount(); }); + describe("overview strip", () => { + // Spread across 14 months and two calendar years, like a real patient. + function buildLongTimelineData() { + return transformDocumentTimeline({ + patientId: "patient-long", + patientName: "Patient Long", + documents: [ + ["doc-a", "201001250900", "Clinical note"], + ["doc-b", "201006151000", "Surgical pathology"], + ["doc-c", "201011221100", "Clinical note"], + ["doc-d", "201012091200", "Radiology report"], + ["doc-e", "201101311300", "Surgical pathology"], + ["doc-f", "201102270900", "Clinical note"], + ].map(([id, date, type]) => ({ id, name: id, date, type, episode: "Diagnostic" })), + }); + } + + const byTestId = (container, testId) => container.querySelector(`[data-testid='${testId}']`); + const texts = (nodes) => [...nodes].map((node) => node.textContent); + const pointer = (type, clientX) => + new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, clientX }); + + function dragHandle(container, handleTestId, fromX, toX) { + act(() => { + byTestId(container, handleTestId).dispatchEvent(pointer("pointerdown", fromX)); + window.dispatchEvent(pointer("pointermove", toX)); + window.dispatchEvent(pointer("pointerup", toX)); + }); + } + + function readWindow(container) { + const selection = byTestId(container, "document-timeline-slider-selection"); + const x = Number(selection.getAttribute("x")); + return { x, end: x + Number(selection.getAttribute("width")) }; + } + + it("keeps the overview axis's end labels fixed while the detail axis follows the handles", () => { + const { container, unmount } = renderComponent( + + ); + + const overviewLabels = () => + texts( + byTestId(container, "document-timeline-overview-axis").querySelectorAll("text") + ); + const detailLabels = () => + texts(byTestId(container, "document-timeline-detail-axis").querySelectorAll("text")); + + const initialOverview = overviewLabels(); + const initialDetail = detailLabels(); + expect(initialOverview[0]).toBe("Jan 2010"); + expect(initialOverview[initialOverview.length - 1]).toBe("Mar 2011"); + // At 100% the handle labels would repeat those end labels. + expect(byTestId(container, "document-timeline-slider-start-label")).toBeNull(); + + const { x, end } = readWindow(container); + const width = end - x; + dragHandle(container, "document-timeline-slider-start", x, x + width * 0.7); + dragHandle(container, "document-timeline-slider-end", end, end - width * 0.1); + + expect(overviewLabels()).toEqual(initialOverview); + expect(detailLabels()).not.toEqual(initialDetail); + + // The detail axis's end labels and the handle labels name the same dates. + const startLabel = byTestId(container, "document-timeline-slider-start-label"); + const endLabel = byTestId(container, "document-timeline-slider-end-label"); + expect(startLabel.textContent).toMatch(/^[A-Z][a-z]{2} \d{1,2}, 2010$/); + expect(endLabel.textContent).toMatch(/^[A-Z][a-z]{2} \d{1,2}, 2011$/); + const detail = detailLabels(); + expect(startLabel.textContent.startsWith(detail[0])).toBe(true); + expect(endLabel.textContent.startsWith(detail[detail.length - 1])).toBe(true); + expect( + byTestId(container, "document-timeline-slider-start").getAttribute("aria-valuetext") + ).toBe(startLabel.textContent); + + unmount(); + }); + + it("never lets the handles cross, by pointer or keyboard", () => { + const { container, unmount } = renderComponent( + + ); + + const { x, end } = readWindow(container); + dragHandle(container, "document-timeline-slider-start", x, end + 200); + const afterDrag = readWindow(container); + expect(afterDrag.x).toBeLessThan(afterDrag.end); + expect(afterDrag.end).toBeCloseTo(end, 3); + + const startHandle = byTestId(container, "document-timeline-slider-start"); + for (let press = 0; press < 5; press += 1) { + act(() => { + startHandle.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowRight" }) + ); + }); + } + const afterKeys = readWindow(container); + expect(afterKeys.x).toBeLessThan(afterKeys.end); + expect(afterKeys.end).toBeCloseTo(end, 3); + expect(Number(startHandle.getAttribute("aria-valuenow"))).toBeLessThanOrEqual( + Number(startHandle.getAttribute("aria-valuemax")) + ); + + unmount(); + }); + + it("recenters the window when the band is pressed", () => { + const { container, unmount } = renderComponent( + + ); + + act(() => { + container + .querySelector('[aria-label="Zoom in timeline"]') + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + const before = readWindow(container); + const band = byTestId(container, "document-timeline-overview-band"); + const bandRight = Number(band.getAttribute("x")) + Number(band.getAttribute("width")); + + act(() => { + band.dispatchEvent(pointer("pointerdown", bandRight - 1)); + }); + + const after = readWindow(container); + expect(after.end - after.x).toBeCloseTo(before.end - before.x, 3); + expect(after.end).toBeCloseTo(bandRight, 3); + + unmount(); + }); + + it("draws one miniature tick per document in its report type's row", () => { + const { container, unmount } = renderComponent( + + ); + + const ticks = [...container.querySelectorAll("[data-testid='document-timeline-overview-tick']")]; + expect(ticks).toHaveLength(6); + expect(ticks.filter((tick) => tick.dataset.row === "Clinical note")).toHaveLength(3); + + // Miniature positions never move with the viewport. + const before = ticks.map((tick) => tick.getAttribute("x")); + act(() => { + container + .querySelector('[aria-label="Zoom in timeline"]') + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect( + [...container.querySelectorAll("[data-testid='document-timeline-overview-tick']")].map( + (tick) => tick.getAttribute("x") + ) + ).toEqual(before); + + unmount(); + }); + + it("pans with the header buttons and disables each at its end of the range", () => { + const { container, unmount } = renderComponent( + + ); + const button = (label) => container.querySelector(`[aria-label="${label}"]`); + const click = (label) => + act(() => { + button(label).dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(button("Pan timeline earlier").disabled).toBe(true); + expect(button("Pan timeline later").disabled).toBe(true); + + click("Zoom in timeline"); + click("Zoom in timeline"); + expect(container.textContent).toContain("225%"); + const centered = readWindow(container); + + click("Pan timeline later"); + const panned = readWindow(container); + const windowWidth = centered.end - centered.x; + // One step is 20% of the visible window, not of the whole range. + expect(panned.x - centered.x).toBeCloseTo(windowWidth * 0.2, 3); + + for (let press = 0; press < 10; press += 1) { + if (!button("Pan timeline later").disabled) { + click("Pan timeline later"); + } + } + expect(button("Pan timeline later").disabled).toBe(true); + expect(button("Pan timeline earlier").disabled).toBe(false); + + unmount(); + }); + + it("announces the selected range once the reader stops changing it", () => { + jest.useFakeTimers(); + const { container, unmount } = renderComponent( + + ); + try { + const status = byTestId(container, "document-timeline-range-status"); + expect(status.getAttribute("aria-live")).toBe("polite"); + expect(status.textContent).toBe(""); + + act(() => { + container + .querySelector('[aria-label="Zoom in timeline"]') + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(status.textContent).toBe(""); + + act(() => { + jest.advanceTimersByTime(300); + }); + expect(status.textContent).toMatch( + /^Showing [A-Z][a-z]{2} \d{1,2}, 2010 to [A-Z][a-z]{2} \d{1,2}, 201[01]$/ + ); + } finally { + unmount(); + jest.useRealTimers(); + } + }); + }); + it("hides an episode when its dropdown is set to hidden in collapsed-date mode", () => { const { container, unmount } = renderComponent( @@ -248,7 +596,7 @@ describe("PatientDocumentsCard", () => { collapsedModeFilter.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); }); - expect(container.textContent).toContain("Hidden by episode filter: 2 document(s)."); + expect(container.textContent).toContain("Hidden: 2 doc(s)"); unmount(); }); diff --git a/src/components/patient/timeline/TimelineAxis.jsx b/src/components/patient/timeline/TimelineAxis.jsx new file mode 100644 index 0000000..bf494d1 --- /dev/null +++ b/src/components/patient/timeline/TimelineAxis.jsx @@ -0,0 +1,114 @@ +import React from "react"; +import PropTypes from "prop-types"; + +// Horizontal gap between an end label and the plot edge it is anchored to. +const EDGE_LABEL_INSET = 6; + +function resolveAnchor(tick, index, count) { + if (tick.anchor) { + return tick.anchor; + } + if (count > 1 && index === 0) { + return "start"; + } + if (count > 1 && index === count - 1) { + return "end"; + } + return "middle"; +} + +/** + * SVG date axis drawn from `resolveTicks` output: a baseline, a tick mark per + * tick and its label. The end labels anchor inward so they stay inside the plot. + * Every stroke and fill is explicit: an SVG `` without a fill renders + * black, which vanishes on the dark themes. + */ +export default function TimelineAxis({ + ticks = [], + x1, + x2, + y = 0, + orientation = "bottom", + tickSize = 5, + labelOffset = 19, + lineColor, + textColor, + lineWidth = 1, + fontSize = 12, + className = undefined, + labelClassName = undefined, + testId = undefined, + showLine = true, +}) { + const direction = orientation === "top" ? -1 : 1; + const labelY = y + direction * labelOffset; + + return ( + + {showLine ? ( + + ) : null} + {ticks.map((tick, index) => { + const anchor = resolveAnchor(tick, index, ticks.length); + const labelX = + anchor === "start" + ? tick.x + EDGE_LABEL_INSET + : anchor === "end" + ? tick.x - EDGE_LABEL_INSET + : tick.x; + const tickKey = + tick.date instanceof Date && !Number.isNaN(tick.date.getTime()) + ? tick.date.toISOString() + : String(tick.x); + + return ( + + + + {tick.label} + + + ); + })} + + ); +} + +TimelineAxis.propTypes = { + ticks: PropTypes.arrayOf( + PropTypes.shape({ + x: PropTypes.number.isRequired, + label: PropTypes.string, + date: PropTypes.instanceOf(Date), + anchor: PropTypes.oneOf(["start", "middle", "end"]), + }) + ), + x1: PropTypes.number.isRequired, + x2: PropTypes.number.isRequired, + y: PropTypes.number, + orientation: PropTypes.oneOf(["top", "bottom"]), + tickSize: PropTypes.number, + labelOffset: PropTypes.number, + lineColor: PropTypes.string.isRequired, + textColor: PropTypes.string.isRequired, + lineWidth: PropTypes.number, + fontSize: PropTypes.number, + className: PropTypes.string, + labelClassName: PropTypes.string, + testId: PropTypes.string, + showLine: PropTypes.bool, +}; diff --git a/src/components/patient/timeline/TimelineLinkProvider.jsx b/src/components/patient/timeline/TimelineLinkProvider.jsx new file mode 100644 index 0000000..ee982ed --- /dev/null +++ b/src/components/patient/timeline/TimelineLinkProvider.jsx @@ -0,0 +1,106 @@ +import React, { createContext, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import PropTypes from "prop-types"; +import useTimelineViewport from "../../../hooks/useTimelineViewport"; +import { + describeViewportRange, + rebaseViewport, + sameDateDomain, +} from "../../../utils/patientView/timelineViewport"; + +export const TimelineLinkContext = createContext(null); + +/** + * Links every patient timeline rendered inside it: one zoom and pan state, one + * date range covering every linked timeline's data, and one screen-reader + * announcement. Moving a slider, button or key in any linked timeline moves them + * all, and with the shared frame (constants/timelineFrame.js) their strips and + * handles line up. + * + * Renders no DOM. Timelines join through useLinkedTimelineViewport; outside a + * provider each timeline keeps its own state. + */ +export default function TimelineLinkProvider({ resetKey = "", children }) { + // id -> { startMs, endMs, priority } + const [registrations, setRegistrations] = useState({}); + + const registerTimeline = useCallback((id, domain, priority = 0) => { + const startMs = domain.startDate.getTime(); + const endMs = domain.endDate.getTime(); + setRegistrations((previous) => { + const current = previous[id]; + if ( + current && + current.startMs === startMs && + current.endMs === endMs && + current.priority === priority + ) { + return previous; + } + return { ...previous, [id]: { startMs, endMs, priority } }; + }); + }, []); + + const unregisterTimeline = useCallback((id) => { + setRegistrations((previous) => { + if (!(id in previous)) { + return previous; + } + const next = { ...previous }; + delete next[id]; + return next; + }); + }, []); + + const entries = Object.entries(registrations); + const startMs = entries.length ? Math.min(...entries.map(([, entry]) => entry.startMs)) : NaN; + const endMs = entries.length ? Math.max(...entries.map(([, entry]) => entry.endMs)) : NaN; + const domain = useMemo( + () => + Number.isFinite(startMs) && Number.isFinite(endMs) + ? { startDate: new Date(startMs), endDate: new Date(endMs) } + : null, + [startMs, endMs] + ); + + // The linked timeline with the lowest priority owns the live region, so a + // range change is announced once, not once per timeline. + const announcerId = entries.length + ? entries.reduce((best, entry) => (entry[1].priority < best[1].priority ? entry : best))[0] + : null; + + const viewportApi = useTimelineViewport({ + resetKey, + describeRange: (viewport) => + domain ? describeViewportRange(viewport, domain.startDate, domain.endDate) : "", + }); + const { setViewport } = viewportApi; + + // A timeline's data can arrive after another's (the Event Timeline loads + // separately) and widen the shared domain. Keep a zoomed reader's dates on + // screen instead of letting their window drift. Layout effect, so the chart + // never paints the old viewport against the new domain. + const previousDomainRef = useRef(domain); + useLayoutEffect(() => { + const previous = previousDomainRef.current; + previousDomainRef.current = domain; + if (!previous || !domain || sameDateDomain(previous, domain)) { + return; + } + setViewport((current) => rebaseViewport(current, previous, domain), { announce: false }); + }, [domain, setViewport]); + + const value = { + ...viewportApi, + domain, + announcerId, + registerTimeline, + unregisterTimeline, + }; + + return {children}; +} + +TimelineLinkProvider.propTypes = { + resetKey: PropTypes.string, + children: PropTypes.node, +}; diff --git a/src/components/patient/timeline/TimelineOverviewStrip.jsx b/src/components/patient/timeline/TimelineOverviewStrip.jsx new file mode 100644 index 0000000..be9531a --- /dev/null +++ b/src/components/patient/timeline/TimelineOverviewStrip.jsx @@ -0,0 +1,490 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import PropTypes from "prop-types"; +import { styled, useTheme } from "@mui/material/styles"; +import TimelineAxis from "./TimelineAxis"; +import { + applySliderKey, + centerViewportOn, + computeOverviewStripLayout, + formatHandleDate, + getMiniRowY, + getSnapRatio, + getViewportWindow, + isViewportZoomed, + layoutHandleLabels, + moveViewportEdge, + moveViewportWindow, + ratioToDate, + ratioToX, + resolveOverviewTicks, + snapRatioToEnds, + spansMultipleYears, + xToRatio, +} from "../../../utils/patientView/timelineViewport"; + +// Pointer target around each 6px-wide handle. It reaches 12px outward, and at +// most a third of the window inward, so a narrow window stays draggable. +const HANDLE_HIT_OUTER = 12; +const HANDLE_HIT_INNER_MAX = 12; +const HANDLE_HALF_WIDTH = 3; +const MINI_MARK_MIN_WIDTH = 3; + +/** + * Overview-strip colors, shared by both timelines so the control looks the + * same in each. Text and outlines come from theme tokens that meet WCAG AA on + * every palette. + */ +export function getOverviewStripColors(theme) { + const palette = theme?.palette || {}; + const textSecondary = palette.text?.secondary || "#505A5F"; + const textPrimary = palette.text?.primary || "#0B0C0C"; + const mutedLine = palette.text?.disabled || textSecondary; + const accent = palette.primary?.main || textSecondary; + + return { + // Light enough that pale miniature marks still show through. + trackFill: mutedLine, + trackOpacity: 0.16, + trackStroke: mutedLine, + windowFill: accent, + windowFillOpacity: 0.1, + windowStroke: accent, + handleFill: accent, + handleStroke: textPrimary, + // Dims the band outside the selection. The opaque page background, not the + // (translucent on Vapor) paper, so it darkens or lightens on every theme. + shade: palette.background?.default || "#FFFFFF", + shadeOpacity: 0.5, + handleLabelText: textPrimary, + axisText: textSecondary, + axisLine: mutedLine, + }; +} + +const focusRingStyles = ({ theme }) => { + const custom = theme.custom || {}; + const focusRing = custom.focusRing || theme.palette.primary.main; + const focusRingWidth = Number.parseFloat(custom.focusRingWidth) || 2; + + return { + "&:focus": { outline: "none" }, + "&:focus-visible": { + outline: `${focusRingWidth}px solid ${focusRing}`, + outlineOffset: custom.focusRingOffset || "2px", + }, + }; +}; + +const FocusableRect = styled("rect")(focusRingStyles); +const FocusablePath = styled("path")(focusRingStyles); + +/** + * The overview half of a timeline's overview + detail view, drawn as a `` + * inside the chart's SVG. It shows the whole date range and never moves: a + * miniature of every mark, a window over the range the detail view shows, two + * handles to resize that window, each handle's date, and a fixed date axis whose + * ends always show the domain limits. + * + * Everything here is in full-domain coordinates. The strip knows nothing about + * documents or events: each timeline passes generic rows of marks. + */ +export default function TimelineOverviewStrip({ + plotLeft, + plotWidth, + top, + rows = [], + viewport, + onViewportChange, + minWindowRatio, + domainStart, + domainEnd, + timeZone = undefined, + tickCount = 5, + compact = false, + fontSize = 12, + clientToX, + labels, + testIdPrefix, + className = undefined, + labelClassName = undefined, +}) { + const theme = useTheme(); + const colors = useMemo(() => getOverviewStripColors(theme), [theme]); + const [dragMode, setDragMode] = useState(null); + const endDragRef = useRef(null); + + useEffect( + () => () => { + endDragRef.current?.(); + }, + [] + ); + + const layout = useMemo( + () => computeOverviewStripLayout({ rowCount: rows.length, compact }), + [rows.length, compact] + ); + const plotRight = plotLeft + plotWidth; + const { startRatio, endRatio } = getViewportWindow(viewport); + const windowX = ratioToX(startRatio, plotLeft, plotWidth); + const windowEndX = ratioToX(endRatio, plotLeft, plotWidth); + const windowWidth = Math.max(0, windowEndX - windowX); + const isZoomed = isViewportZoomed(viewport); + + const includeYear = spansMultipleYears(domainStart, domainEnd, timeZone); + const startLabel = formatHandleDate(ratioToDate(startRatio, domainStart, domainEnd), { + includeYear, + timeZone, + }); + const endLabel = formatHandleDate(ratioToDate(endRatio, domainStart, domainEnd), { + includeYear, + timeZone, + }); + const handleLabels = layoutHandleLabels({ + startX: windowX, + endX: windowEndX, + startLabel, + endLabel, + plotLeft, + plotWidth, + fontSize, + zoomed: isZoomed, + }); + + // Memoized on the domain and width only: dragging a handle never moves these. + const domainStartMs = domainStart instanceof Date ? domainStart.getTime() : NaN; + const domainEndMs = domainEnd instanceof Date ? domainEnd.getTime() : NaN; + const axisTicks = useMemo( + () => + resolveOverviewTicks({ + startDate: new Date(domainStartMs), + endDate: new Date(domainEndMs), + plotLeft, + plotWidth, + tickCount, + timeZone, + fontSize, + endLabelsOnly: compact, + }), + [domainStartMs, domainEndMs, plotLeft, plotWidth, tickCount, timeZone, fontSize, compact] + ); + + // Aria bounds are in thousandths of the domain. Each handle is bounded by the + // other, less the minimum window. + const toAriaValue = (ratio) => Math.round(Math.min(1, Math.max(0, ratio)) * 1000); + const minimumWidthRatio = Math.max(0, Number(minWindowRatio) || 0); + + const handlePointerDown = (mode, event) => { + if (event.button !== 0) { + return; + } + event.preventDefault(); + event.stopPropagation(); + + const initialX = clientToX(event.clientX); + if (initialX == null) { + return; + } + const initialRatio = xToRatio(initialX, plotLeft, plotWidth); + + if (mode === "track") { + onViewportChange(centerViewportOn(viewport, initialRatio)); + return; + } + + const initialViewport = viewport; + const initialStartRatio = startRatio; + const windowRatio = endRatio - startRatio; + const snapRatio = getSnapRatio(plotWidth); + endDragRef.current?.(); + setDragMode(mode); + + const handleMove = (moveEvent) => { + const x = clientToX(moveEvent.clientX); + if (x == null) { + return; + } + const ratio = xToRatio(x, plotLeft, plotWidth); + if (mode === "start" || mode === "end") { + onViewportChange( + moveViewportEdge( + initialViewport, + mode, + snapRatioToEnds(ratio, snapRatio), + minWindowRatio + ) + ); + return; + } + // Snap whichever edge of the moving window reaches an end of the strip. + const nextStart = initialStartRatio + (ratio - initialRatio); + const snappedStart = + nextStart <= snapRatio + ? 0 + : nextStart + windowRatio >= 1 - snapRatio + ? 1 - windowRatio + : nextStart; + onViewportChange(moveViewportWindow(initialViewport, snappedStart)); + }; + const endDrag = () => { + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", endDrag); + window.removeEventListener("pointercancel", endDrag); + endDragRef.current = null; + setDragMode(null); + }; + + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", endDrag); + window.addEventListener("pointercancel", endDrag); + endDragRef.current = endDrag; + }; + + const handleKeyDown = (mode, event) => { + const next = applySliderKey(viewport, mode, event.key, { + shiftKey: event.shiftKey, + minWindowRatio, + }); + if (!next) { + return; + } + event.preventDefault(); + // Keep the plot's own ←/→ panning from also firing. + event.stopPropagation(); + onViewportChange(next); + }; + + const handleInnerReach = Math.min(HANDLE_HIT_INNER_MAX, windowWidth / 3); + const handleBottom = layout.bandBottom + layout.handleOverhang; + const handles = [ + { + key: "start", + x: windowX, + hitX: windowX - HANDLE_HIT_OUTER, + label: labels.start, + valueText: startLabel, + valueNow: toAriaValue(startRatio), + valueMin: 0, + valueMax: toAriaValue(endRatio - minimumWidthRatio), + }, + { + key: "end", + x: windowEndX, + hitX: windowEndX - handleInnerReach, + label: labels.end, + valueText: endLabel, + valueNow: toAriaValue(endRatio), + valueMin: toAriaValue(startRatio + minimumWidthRatio), + valueMax: 1000, + }, + ]; + + return ( + + + ); +} + +TimelineOverviewStrip.propTypes = { + plotLeft: PropTypes.number.isRequired, + plotWidth: PropTypes.number.isRequired, + top: PropTypes.number.isRequired, + rows: PropTypes.arrayOf( + PropTypes.shape({ + key: PropTypes.string.isRequired, + marks: PropTypes.arrayOf( + PropTypes.shape({ + key: PropTypes.string, + x1: PropTypes.number.isRequired, + x2: PropTypes.number.isRequired, + color: PropTypes.string.isRequired, + // Optional outline, e.g. to match the detail view's marks. + stroke: PropTypes.string, + }) + ), + }) + ), + viewport: PropTypes.shape({ + zoom: PropTypes.number.isRequired, + panRatio: PropTypes.number.isRequired, + }).isRequired, + onViewportChange: PropTypes.func.isRequired, + minWindowRatio: PropTypes.number.isRequired, + domainStart: PropTypes.instanceOf(Date).isRequired, + domainEnd: PropTypes.instanceOf(Date).isRequired, + timeZone: PropTypes.string, + tickCount: PropTypes.number, + compact: PropTypes.bool, + fontSize: PropTypes.number, + clientToX: PropTypes.func.isRequired, + labels: PropTypes.shape({ + group: PropTypes.string.isRequired, + window: PropTypes.string.isRequired, + start: PropTypes.string.isRequired, + end: PropTypes.string.isRequired, + }).isRequired, + testIdPrefix: PropTypes.string.isRequired, + className: PropTypes.string, + labelClassName: PropTypes.string, +}; diff --git a/src/components/patient/timeline/TimelineZoomControls.jsx b/src/components/patient/timeline/TimelineZoomControls.jsx new file mode 100644 index 0000000..43f8707 --- /dev/null +++ b/src/components/patient/timeline/TimelineZoomControls.jsx @@ -0,0 +1,127 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { IconButton, Stack, Tooltip, Typography } from "@mui/material"; +import KeyboardArrowLeftIcon from "@mui/icons-material/KeyboardArrowLeft"; +import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; +import RestartAltIcon from "@mui/icons-material/RestartAlt"; +import ZoomInIcon from "@mui/icons-material/ZoomIn"; +import ZoomOutIcon from "@mui/icons-material/ZoomOut"; + +const BUTTON_SX = { width: 24, height: 24, p: 0.25 }; + +function ControlButton({ title, label, onClick, disabled, children }) { + return ( + + {/* A disabled button fires no events, so the tooltip needs a wrapper. */} + + + {children} + + + + ); +} + +ControlButton.propTypes = { + title: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, + disabled: PropTypes.bool.isRequired, + children: PropTypes.node.isRequired, +}; + +/** + * The shared zoom and pan controls for both patient timelines: zoom %, pan + * earlier, zoom out, zoom in, pan later and reset. Each button is disabled when + * it would do nothing, and its tooltip names the matching key. + */ +export default function TimelineZoomControls({ + zoomPercent, + onZoomIn, + onZoomOut, + onPanEarlier, + onPanLater, + onReset, + canZoomIn, + canZoomOut, + canPanEarlier, + canPanLater, + canReset, + disabled = false, + labels, +}) { + return ( + + + {`${zoomPercent}%`} + + + + + + + + + + + + + + + + + + ); +} + +TimelineZoomControls.propTypes = { + zoomPercent: PropTypes.number.isRequired, + onZoomIn: PropTypes.func.isRequired, + onZoomOut: PropTypes.func.isRequired, + onPanEarlier: PropTypes.func.isRequired, + onPanLater: PropTypes.func.isRequired, + onReset: PropTypes.func.isRequired, + canZoomIn: PropTypes.bool.isRequired, + canZoomOut: PropTypes.bool.isRequired, + canPanEarlier: PropTypes.bool.isRequired, + canPanLater: PropTypes.bool.isRequired, + canReset: PropTypes.bool.isRequired, + disabled: PropTypes.bool, + labels: PropTypes.shape({ + group: PropTypes.string.isRequired, + zoomIn: PropTypes.string.isRequired, + zoomOut: PropTypes.string.isRequired, + panEarlier: PropTypes.string.isRequired, + panLater: PropTypes.string.isRequired, + reset: PropTypes.string.isRequired, + }).isRequired, +}; diff --git a/src/components/patient/timeline/__tests__/TimelineLinkProvider.test.jsx b/src/components/patient/timeline/__tests__/TimelineLinkProvider.test.jsx new file mode 100644 index 0000000..24b9d40 --- /dev/null +++ b/src/components/patient/timeline/__tests__/TimelineLinkProvider.test.jsx @@ -0,0 +1,308 @@ +/* eslint-disable testing-library/no-unnecessary-act, testing-library/no-container, testing-library/no-node-access */ +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import TimelineLinkProvider from "../TimelineLinkProvider"; +import useLinkedTimelineViewport from "../../../../hooks/useLinkedTimelineViewport"; +import PatientDocumentsCard from "../../PatientDocumentsCard"; +import EventRelationTimelineCard from "../../EventRelationTimelineCard"; +import { transformDocumentTimeline } from "../../../../utils/patientView/transformDocumentTimeline"; +import { resetStaticEventRelationTimelineCacheForTests } from "../../../../clients/eventRelationTimeline"; +import { + DOCUMENT_CHART_BORDER_WIDTH, + TIMELINE_PLOT_INSET, +} from "../../../../constants/timelineFrame"; + +const DOCUMENTS_DOMAIN = { startDate: new Date(2010, 0, 1), endDate: new Date(2011, 0, 1) }; +const EVENTS_DOMAIN = { startDate: new Date(2009, 0, 1), endDate: new Date(2011, 0, 1) }; + +function renderRoot(element) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(element); + }); + return { + container, + rerender: (next) => + act(() => { + root.render(next); + }), + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function Probe({ id, domain, priority, results }) { + // eslint-disable-next-line no-param-reassign + results[id] = useLinkedTimelineViewport({ id, domain, priority }); + return null; +} + +describe("TimelineLinkProvider", () => { + it("shares one viewport and one domain covering every linked timeline", () => { + const results = {}; + const { unmount } = renderRoot( + + + + + ); + + expect(results.documents.isLinked).toBe(true); + expect(results.documents.domain).toEqual(EVENTS_DOMAIN); + expect(results.events.domain).toEqual(EVENTS_DOMAIN); + // One live region: the lowest priority announces. + expect(results.documents.isAnnouncer).toBe(true); + expect(results.events.isAnnouncer).toBe(false); + + act(() => results.events.zoomIn()); + expect(results.documents.viewport).toEqual(results.events.viewport); + expect(results.documents.zoomPercent).toBe(150); + + act(() => results.documents.panLater()); + expect(results.events.viewport).toEqual(results.documents.viewport); + expect(results.events.canPanEarlier).toBe(true); + + unmount(); + }); + + it("keeps a zoomed range on screen when a late timeline widens the domain", () => { + const results = {}; + const tree = (withEvents) => ( + + + {withEvents ? ( + + ) : null} + + ); + const { rerender, unmount } = renderRoot(tree(false)); + expect(results.documents.domain).toEqual(DOCUMENTS_DOMAIN); + + act(() => results.documents.zoomIn()); + act(() => results.documents.zoomIn()); + const shownBefore = viewportDates(results.documents.viewport, DOCUMENTS_DOMAIN); + + rerender(tree(true)); + expect(results.documents.domain).toEqual(EVENTS_DOMAIN); + const shownAfter = viewportDates(results.documents.viewport, EVENTS_DOMAIN); + expect(shownAfter.start).toBeCloseTo(shownBefore.start, -1); + expect(shownAfter.end).toBeCloseTo(shownBefore.end, -1); + + // Leaving drops that timeline's data from the domain again. + rerender(tree(false)); + expect(results.documents.domain).toEqual(DOCUMENTS_DOMAIN); + + unmount(); + }); + + it("opens a widened domain at 100% when the reader hasn't zoomed", () => { + const results = {}; + const tree = (withEvents) => ( + + + {withEvents ? ( + + ) : null} + + ); + const { rerender, unmount } = renderRoot(tree(false)); + rerender(tree(true)); + expect(results.documents.viewport).toEqual({ zoom: 1, panRatio: 0 }); + unmount(); + }); + + it("resets for a new patient, and leaves unlinked timelines independent", () => { + const results = {}; + const tree = (resetKey) => ( + <> + + + + + + ); + const { rerender, unmount } = renderRoot(tree("patient-1")); + + expect(results.alone.isLinked).toBe(false); + expect(results.alone.isAnnouncer).toBe(true); + expect(results.alone.domain).toEqual(EVENTS_DOMAIN); + + act(() => results.documents.zoomIn()); + expect(results.alone.zoomPercent).toBe(100); + + rerender(tree("patient-2")); + expect(results.documents.viewport).toEqual({ zoom: 1, panRatio: 0 }); + + unmount(); + }); +}); + +function viewportDates(viewport, domain) { + const spanMs = domain.endDate.getTime() - domain.startDate.getTime(); + return { + start: domain.startDate.getTime() + viewport.panRatio * spanMs, + end: domain.startDate.getTime() + (viewport.panRatio + 1 / viewport.zoom) * spanMs, + }; +} + +describe("linked document and event timelines", () => { + const TSV = [ + "PatientID\tConceptID\tRelation1\tDate1\tRelation2\tDate2", + "fake_patient1\tc-finding\tOverlaps\t2009-06-01\tOverlaps\t2010-03-01", + "fake_patient1\tc-treatment\tOn\t2010-05-31\tOn\t2010-05-31", + ].join("\n"); + const CONCEPTS = [ + { id: "c-finding", name: "Estrogen Receptor Status", dpheGroup: "Clinical Test Result", mentionIds: [] }, + { id: "c-treatment", name: "Chemotherapy", dpheGroup: "Intervention or Procedure", mentionIds: [] }, + ]; + const TIMELINE_DATA = transformDocumentTimeline({ + patientId: "fake_patient1", + patientName: "Fake Patient1", + documents: [ + { id: "doc-1", name: "One", date: "201001250900", type: "Clinical note", episode: "Diagnostic" }, + { id: "doc-2", name: "Two", date: "201006151000", type: "Clinical note", episode: "Diagnostic" }, + { id: "doc-3", name: "Three", date: "201011221100", type: "Pathology", episode: "Treatment" }, + ], + }); + // The Event Timeline measures its container; the document chart measures the + // box inside its 1px border. Same column, so the Event container is 2px wider. + const CONTENT_WIDTH = 1200; + const originalFetch = global.fetch; + const OriginalResizeObserver = global.ResizeObserver; + + beforeEach(() => { + global.fetch = jest.fn(async () => ({ ok: true, status: 200, text: async () => TSV })); + global.ResizeObserver = class { + constructor(callback) { + this.callback = callback; + } + + observe(node) { + const isDocumentChart = Boolean( + node.querySelector?.("svg[aria-label='Patient document timeline chart']") + ); + const width = isDocumentChart + ? CONTENT_WIDTH - 2 * DOCUMENT_CHART_BORDER_WIDTH + : CONTENT_WIDTH; + this.callback([{ contentRect: { width } }]); + } + + disconnect() {} + }; + }); + + afterEach(() => { + resetStaticEventRelationTimelineCacheForTests(); + global.fetch = originalFetch; + global.ResizeObserver = OriginalResizeObserver; + }); + + async function renderLinked() { + const view = renderRoot( + + + + + ); + for (let attempt = 0; attempt < 50; attempt += 1) { + if (view.container.querySelector("[data-testid='event-timeline-overview']")) { + break; + } + // eslint-disable-next-line no-await-in-loop + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } + return view; + } + + // Handle and window positions relative to each card's content edge, which is + // where the two cards line up in the patient views. + function stripGeometry(container, prefix) { + const selection = container.querySelector(`[data-testid='${prefix}-slider-selection']`); + const band = container.querySelector(`[data-testid='${prefix}-overview-band']`); + const offset = + prefix === "document-timeline" + ? DOCUMENT_CHART_BORDER_WIDTH + : Number( + /translate\(([\d.]+),/.exec( + container.querySelector(".overview").getAttribute("transform") + )[1] + ); + const at = (node, attribute) => Number(node.getAttribute(attribute)) + (attribute === "x" ? offset : 0); + return { + bandLeft: at(band, "x"), + bandRight: at(band, "x") + Number(band.getAttribute("width")), + windowLeft: at(selection, "x"), + windowWidth: Number(selection.getAttribute("width")), + startLabel: container + .querySelector(`[data-testid='${prefix}-slider-start']`) + .getAttribute("aria-valuetext"), + }; + } + + it("lines both strips up and moves the event slider with the document slider", async () => { + const { container, unmount } = await renderLinked(); + try { + const documents = stripGeometry(container, "document-timeline"); + const events = stripGeometry(container, "event-timeline"); + + // Same plot edges from the content edge, so the same pixels. + expect(documents.bandLeft).toBe(TIMELINE_PLOT_INSET.left); + expect(events.bandLeft).toBe(TIMELINE_PLOT_INSET.left); + expect(documents.bandRight).toBe(events.bandRight); + expect(CONTENT_WIDTH - events.bandRight).toBe(TIMELINE_PLOT_INSET.right); + + // One shared domain: events start in 2009, so the documents' strip does too. + const overviewLabels = (prefix) => + [...container.querySelectorAll(`[data-testid='${prefix}-overview-axis'] text`)].map( + (node) => node.textContent + ); + expect(overviewLabels("document-timeline")[0]).toBe("Apr 2009"); + expect(overviewLabels("event-timeline")[0]).toBe("Apr 2009"); + + // Drag the document timeline's start handle to the middle of its band. + const startHandle = container.querySelector("[data-testid='document-timeline-slider-start']"); + // jsdom keeps MouseEvent clientX an integer. + const from = documents.windowLeft - DOCUMENT_CHART_BORDER_WIDTH; + const to = Math.round(from + documents.windowWidth / 2); + act(() => { + startHandle.dispatchEvent( + new MouseEvent("pointerdown", { bubbles: true, cancelable: true, button: 0, clientX: from }) + ); + window.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX: to })); + window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: to })); + }); + + const draggedDocuments = stripGeometry(container, "document-timeline"); + const draggedEvents = stripGeometry(container, "event-timeline"); + expect(draggedDocuments.windowLeft).toBeCloseTo(to + DOCUMENT_CHART_BORDER_WIDTH, 6); + expect(draggedEvents.windowLeft).toBeCloseTo(draggedDocuments.windowLeft, 6); + expect(draggedEvents.windowWidth).toBeCloseTo(draggedDocuments.windowWidth, 6); + expect(draggedEvents.startLabel).toBe(draggedDocuments.startLabel); + expect(container.textContent).not.toContain("100%"); + + // And back the other way, from the Event Timeline's controls. + act(() => { + container + .querySelector('button[aria-label="Reset event timeline zoom"]') + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(stripGeometry(container, "document-timeline").windowWidth).toBeCloseTo( + documents.windowWidth, + 6 + ); + + // One live region between the two cards. + expect(container.querySelectorAll("[aria-live='polite'][data-testid$='-range-status']")).toHaveLength(1); + expect(container.querySelector("[data-testid='document-timeline-range-status']")).not.toBeNull(); + } finally { + unmount(); + } + }); +}); diff --git a/src/components/patient/timeline/__tests__/TimelineOverviewStrip.test.jsx b/src/components/patient/timeline/__tests__/TimelineOverviewStrip.test.jsx new file mode 100644 index 0000000..8f6f7a3 --- /dev/null +++ b/src/components/patient/timeline/__tests__/TimelineOverviewStrip.test.jsx @@ -0,0 +1,322 @@ +/* eslint-disable testing-library/no-unnecessary-act, testing-library/no-container, testing-library/no-node-access */ +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { ThemeProvider, getContrastRatio } from "@mui/material/styles"; +import TimelineOverviewStrip, { getOverviewStripColors } from "../TimelineOverviewStrip"; +import TimelineZoomControls from "../TimelineZoomControls"; +import { THEME_OPTIONS, getThemeByKey } from "../../../../themes"; +import { WCAG_AA_TEXT_CONTRAST, WCAG_UI_CONTRAST } from "../../../../utils/colorContrast"; +import { getViewportWindow, viewportToDateWindow } from "../../../../utils/patientView/timelineViewport"; + +// Composited panel backgrounds the timelines sit on (see the card tests). +const PANEL_BACKGROUNDS = { obsidian: "#1A2332", vapor: "#161425", govuk: "#FFFFFF" }; + +const DOMAIN_START = new Date(2010, 0, 4); +const DOMAIN_END = new Date(2011, 2, 20); +const LABELS = { group: "Range", window: "Window", start: "Start", end: "End" }; +const ROWS = [ + { key: "Clinical note", marks: [{ key: "a", x1: 120, x2: 120, color: "#A5D6A7" }] }, + { + key: "Pathology", + marks: [ + { key: "b", x1: 300, x2: 300, color: "#FFAB91" }, + { key: "c", x1: 500, x2: 640, color: "#CE93D8" }, + ], + }, +]; + +function renderStrip(props = {}) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const onViewportChange = jest.fn(); + const baseProps = { + plotLeft: 100, + plotWidth: 800, + top: 0, + rows: ROWS, + viewport: { zoom: 1, panRatio: 0 }, + onViewportChange, + minWindowRatio: 1 / 16, + domainStart: DOMAIN_START, + domainEnd: DOMAIN_END, + tickCount: 5, + clientToX: (clientX) => clientX, + labels: LABELS, + testIdPrefix: "test-timeline", + }; + const render = (nextProps) => + act(() => { + root.render( + + + + ); + }); + + render(props); + return { + container, + onViewportChange, + rerender: render, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +const byTestId = (container, testId) => container.querySelector(`[data-testid='${testId}']`); + +describe("TimelineOverviewStrip", () => { + it("draws one mark per input at its full-domain position, at least 3px wide", () => { + const { container, unmount } = renderStrip(); + + const marks = [...container.querySelectorAll("[data-testid='test-timeline-overview-tick']")]; + expect(marks).toHaveLength(3); + expect(marks.map((mark) => [mark.dataset.row, Number(mark.getAttribute("x")), Number(mark.getAttribute("width"))])).toEqual([ + ["Clinical note", 118.5, 3], + ["Pathology", 298.5, 3], + ["Pathology", 500, 140], + ]); + expect(marks[0].closest("g").getAttribute("aria-hidden")).toBe("true"); + + unmount(); + }); + + it("keeps the fixed axis ends and the marks in place whatever the viewport", () => { + const { container, rerender, unmount } = renderStrip(); + const axisLabels = () => + [...byTestId(container, "test-timeline-overview-axis").querySelectorAll("text")].map( + (node) => node.textContent + ); + const markXs = () => + [...container.querySelectorAll("[data-testid='test-timeline-overview-tick']")].map((mark) => + mark.getAttribute("x") + ); + + const initialLabels = axisLabels(); + const initialMarks = markXs(); + expect(initialLabels[0]).toBe("Jan 2010"); + expect(initialLabels[initialLabels.length - 1]).toBe("Mar 2011"); + + [{ zoom: 4, panRatio: 0.1 }, { zoom: 16, panRatio: 0.9375 }].forEach((viewport) => { + rerender({ viewport }); + expect(axisLabels()).toEqual(initialLabels); + expect(markXs()).toEqual(initialMarks); + + // The window's edges sit at the viewport's dates, and so do its labels. + const selection = byTestId(container, "test-timeline-slider-selection"); + const { startRatio, endRatio } = getViewportWindow(viewport); + expect(Number(selection.getAttribute("x"))).toBeCloseTo(100 + startRatio * 800, 6); + expect(Number(selection.getAttribute("width"))).toBeCloseTo((endRatio - startRatio) * 800, 6); + const { startDate } = viewportToDateWindow(viewport, DOMAIN_START, DOMAIN_END); + expect(byTestId(container, "test-timeline-slider-start").getAttribute("aria-valuetext")).toBe( + new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(startDate) + ); + }); + + unmount(); + }); + + it("hides handle labels at 100% and merges them when they would overlap", () => { + const { container, rerender, unmount } = renderStrip(); + expect(byTestId(container, "test-timeline-slider-start-label")).toBeNull(); + + rerender({ viewport: { zoom: 2, panRatio: 0.25 } }); + expect(byTestId(container, "test-timeline-slider-start-label")).not.toBeNull(); + expect(byTestId(container, "test-timeline-slider-end-label")).not.toBeNull(); + + rerender({ viewport: { zoom: 16, panRatio: 0.5 } }); + expect(byTestId(container, "test-timeline-slider-start-label")).toBeNull(); + expect(byTestId(container, "test-timeline-slider-range-label").textContent).toContain(" – "); + + unmount(); + }); + + it("bounds each handle's slider value by the other handle", () => { + const { container, unmount } = renderStrip({ viewport: { zoom: 4, panRatio: 0.5 } }); + + const start = byTestId(container, "test-timeline-slider-start"); + const end = byTestId(container, "test-timeline-slider-end"); + expect(start.getAttribute("role")).toBe("slider"); + expect(start.getAttribute("aria-valuenow")).toBe("500"); + expect(start.getAttribute("aria-valuemax")).toBe(String(Math.round((0.75 - 1 / 16) * 1000))); + expect(end.getAttribute("aria-valuenow")).toBe("750"); + expect(end.getAttribute("aria-valuemin")).toBe(String(Math.round((0.5 + 1 / 16) * 1000))); + expect(end.getAttribute("aria-valuemax")).toBe("1000"); + + unmount(); + }); + + it("snaps a handle dragged within a few pixels of either end to that end", () => { + const { container, onViewportChange, unmount } = renderStrip({ viewport: { zoom: 2, panRatio: 0.25 } }); + const pointer = (type, clientX) => + new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, clientX }); + const drag = (testId, from, to) => + act(() => { + byTestId(container, testId).dispatchEvent(pointer("pointerdown", from)); + window.dispatchEvent(pointer("pointermove", to)); + window.dispatchEvent(pointer("pointerup", to)); + }); + + // The band runs from x=100 to x=900; the window from 300 to 700. + drag("test-timeline-slider-start", 300, 104); + expect(getViewportWindow(onViewportChange.mock.calls.at(-1)[0]).startRatio).toBe(0); + + drag("test-timeline-slider-end", 700, 896); + expect(getViewportWindow(onViewportChange.mock.calls.at(-1)[0]).endRatio).toBe(1); + + // Farther than the snap distance, the handle stays where it was dropped. + drag("test-timeline-slider-start", 300, 120); + expect(getViewportWindow(onViewportChange.mock.calls.at(-1)[0]).startRatio).toBeCloseTo(20 / 800, 10); + + unmount(); + }); + + it("stops a drag on pointercancel", () => { + const { container, onViewportChange, unmount } = renderStrip({ viewport: { zoom: 2, panRatio: 0.25 } }); + const pointer = (type, clientX) => + new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, clientX }); + + act(() => { + byTestId(container, "test-timeline-slider-selection").dispatchEvent(pointer("pointerdown", 400)); + window.dispatchEvent(pointer("pointermove", 450)); + }); + expect(onViewportChange).toHaveBeenCalledTimes(1); + + act(() => { + window.dispatchEvent(new MouseEvent("pointercancel", { bubbles: true })); + window.dispatchEvent(pointer("pointermove", 500)); + }); + expect(onViewportChange).toHaveBeenCalledTimes(1); + + unmount(); + }); + + it("uses WCAG-readable colors in every theme", () => { + THEME_OPTIONS.forEach(({ key }) => { + const colors = getOverviewStripColors(getThemeByKey(key)); + const background = PANEL_BACKGROUNDS[key]; + + expect(getContrastRatio(colors.handleLabelText, background)).toBeGreaterThanOrEqual(WCAG_AA_TEXT_CONTRAST); + expect(getContrastRatio(colors.axisText, background)).toBeGreaterThanOrEqual(WCAG_AA_TEXT_CONTRAST); + expect(getContrastRatio(colors.windowStroke, background)).toBeGreaterThanOrEqual(WCAG_UI_CONTRAST); + expect(getContrastRatio(colors.handleStroke, background)).toBeGreaterThanOrEqual(WCAG_UI_CONTRAST); + expect(getContrastRatio(colors.axisLine, background)).toBeGreaterThanOrEqual(WCAG_UI_CONTRAST); + }); + }); + + it("gives the handles and window a focus-visible ring from the theme", () => { + const theme = getThemeByKey("obsidian"); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + + + {}} + minWindowRatio={1 / 16} + domainStart={DOMAIN_START} + domainEnd={DOMAIN_END} + clientToX={(clientX) => clientX} + labels={LABELS} + testIdPrefix="themed" + /> + + + ); + }); + + const handle = container.querySelector("[data-testid='themed-slider-start']"); + expect(handle.getAttribute("tabindex")).toBe("0"); + const focusRule = [...document.styleSheets] + .flatMap((sheet) => { + try { + return [...sheet.cssRules]; + } catch { + return []; + } + }) + .find((rule) => { + const cssText = rule.cssText.toLowerCase(); + return ( + cssText.includes(":focus-visible") && + cssText.includes(theme.custom.focusRing.toLowerCase()) && + handle + .getAttribute("class") + .split(" ") + .some((name) => cssText.includes(name.toLowerCase())) + ); + }); + expect(focusRule).toBeDefined(); + + act(() => root.unmount()); + container.remove(); + }); +}); + +describe("TimelineZoomControls", () => { + it("disables every button when told to, and each when it can't act", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const handlers = { + onZoomIn: jest.fn(), + onZoomOut: jest.fn(), + onPanEarlier: jest.fn(), + onPanLater: jest.fn(), + onReset: jest.fn(), + }; + const labels = { + group: "Zoom", + zoomIn: "In", + zoomOut: "Out", + panEarlier: "Earlier", + panLater: "Later", + reset: "Reset", + }; + const render = (props) => + act(() => { + root.render( + + ); + }); + const button = (label) => container.querySelector(`button[aria-label="${label}"]`); + + render({}); + expect(container.textContent).toContain("150%"); + expect(button("Earlier").disabled).toBe(true); + expect(button("Later").disabled).toBe(false); + act(() => { + button("Later").dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(handlers.onPanLater).toHaveBeenCalledTimes(1); + + render({ disabled: true }); + ["In", "Out", "Earlier", "Later", "Reset"].forEach((label) => { + expect(button(label).disabled).toBe(true); + }); + + act(() => root.unmount()); + container.remove(); + }); +}); diff --git a/src/components/patientGrid/PatientDetailPanel.jsx b/src/components/patientGrid/PatientDetailPanel.jsx index 1906d26..9f9acae 100644 --- a/src/components/patientGrid/PatientDetailPanel.jsx +++ b/src/components/patientGrid/PatientDetailPanel.jsx @@ -7,7 +7,7 @@ import ArticleOutlinedIcon from "@mui/icons-material/ArticleOutlined"; // Responsive column counts for the detail Masonry. Kept as a named constant so // the SSR-fallback resolution below stays in sync with the `columns` prop. -const DETAIL_PANEL_COLUMNS = { xs: 1, sm: 2, md: 3, lg: 4, xl: 5 }; +const DETAIL_PANEL_COLUMNS = { xs: 1, sm: 2, md: 2, lg: 3, xl: 4 }; // Rough per-section height used only to seed Masonry's first-paint SSR layout. const DETAIL_PANEL_SECTION_HEIGHT_PX = 140; @@ -88,14 +88,7 @@ function getDetailItemConfidence(item) { function renderDetailItemChips(item) { const chips = FLAG_CHIP_DEFINITIONS.filter((flag) => Boolean(item?.[flag.key])).map((flag) => ( - + )); if (item?.source) { @@ -172,14 +165,8 @@ function DetailPanel({ row, onPatientOpen }) { })) .filter((section) => section.items.length > 0); }, [allDetails, thresholdFraction]); - const totalItemCount = allDetails.reduce( - (count, section) => count + section.items.length, - 0 - ); - const visibleItemCount = details.reduce( - (count, section) => count + section.items.length, - 0 - ); + const totalItemCount = allDetails.reduce((count, section) => count + section.items.length, 0); + const visibleItemCount = details.reduce((count, section) => count + section.items.length, 0); const hiddenItemCount = totalItemCount - visibleItemCount; const patientId = String(row?.original?.patientId || "").trim(); const canShowDocumentViewerButton = typeof onPatientOpen === "function" && Boolean(patientId); @@ -222,7 +209,7 @@ function DetailPanel({ row, onPatientOpen }) { Confidence:{" "} - {">=50%"} + {`>=${confidenceThreshold}%`} - setConfidenceThreshold(Array.isArray(value) ? value[0] : value) - } + onChange={(_event, value) => setConfidenceThreshold(Array.isArray(value) ? value[0] : value)} aria-label="Minimum patient drawer finding confidence percent" valueLabelDisplay="auto" valueLabelFormat={(value) => `${value}%`} @@ -312,164 +297,159 @@ function DetailPanel({ row, onPatientOpen }) { > {detailPanelHeader} {hiddenItemCount > 0 ? ( - + {hiddenItemCount} finding{hiddenItemCount === 1 ? "" : "s"} hidden below {confidenceThreshold}% confidence. ) : null} {details.length > 0 ? ( {details.map((section) => ( - - - {section.label} - - - {section.items.map((item, itemIndex) => { - const itemName = String(item?.name ?? item?.value ?? "").trim() || "Unnamed"; - const badges = renderDetailItemChips(item) - .map((chipNode, chipIndex) => ({ - key: chipNode?.key || `${section.key}-${itemIndex}-chip-${chipIndex}`, - label: chipNode?.props?.label, - color: chipNode?.props?.color || "default", - })) - .filter((badge) => Boolean(String(badge.label || "").trim())); + + {section.label} + - return ( - - - {itemName} - + + {section.items.map((item, itemIndex) => { + const itemName = String(item?.name ?? item?.value ?? "").trim() || "Unnamed"; + const badges = renderDetailItemChips(item) + .map((chipNode, chipIndex) => ({ + key: chipNode?.key || `${section.key}-${itemIndex}-chip-${chipIndex}`, + label: chipNode?.props?.label, + color: chipNode?.props?.color || "default", + })) + .filter((badge) => Boolean(String(badge.label || "").trim())); - {badges.length > 0 ? ( - + - {badges.map((badge) => { - const badgeColor = badgeColorByChipColor[badge.color] || badgeColorByChipColor.default; - return ( - - {badge.label} - - ); - })} - - ) : null} - - ); - })} + {itemName} + + + {badges.length > 0 ? ( + + {badges.map((badge) => { + const badgeColor = badgeColorByChipColor[badge.color] || badgeColorByChipColor.default; + return ( + + {badge.label} + + ); + })} + + ) : null} + + ); + })} + - ))} ) : ( diff --git a/src/components/patientGrid/PatientGridRow.jsx b/src/components/patientGrid/PatientGridRow.jsx index aed91f7..528567c 100644 --- a/src/components/patientGrid/PatientGridRow.jsx +++ b/src/components/patientGrid/PatientGridRow.jsx @@ -1,6 +1,6 @@ import React from "react"; import PropTypes from "prop-types"; -import { TableCell, TableRow } from "@mui/material"; +import { Box, TableCell, TableRow } from "@mui/material"; import { alpha, useTheme } from "@mui/material/styles"; import { flexRender } from "@tanstack/react-table"; import DetailPanel from "./PatientDetailPanel"; @@ -11,6 +11,8 @@ import DetailPanel from "./PatientDetailPanel"; // the detail panel would never appear. Expansion is read live at render time. function PatientGridRow({ row, rowIndex, columnCount, onToggleExpansion, onPatientOpen, onDetailContextMenu }) { const theme = useTheme(); + const rowBackground = rowIndex % 2 === 0 ? theme.palette.background.paper : theme.palette.background.default; + const rowHoverBackground = theme.custom?.rowHoverBg || alpha(theme.palette.primary.main, 0.08); return ( <> @@ -19,32 +21,40 @@ function PatientGridRow({ row, rowIndex, columnCount, onToggleExpansion, onPatie onClick={row.getCanExpand() ? () => onToggleExpansion(row.id) : undefined} sx={{ cursor: row.getCanExpand() ? "pointer" : "default", - bgcolor: - rowIndex % 2 === 0 - ? "transparent" - : alpha( - theme.palette.mode === "dark" ? theme.palette.common.white : theme.palette.common.black, - theme.palette.mode === "dark" ? 0.02 : 0.03 - ), + bgcolor: rowBackground, + "&:hover > td[data-pinned-column='true']": { + background: `linear-gradient(${rowHoverBackground}, ${rowHoverBackground}), ${rowBackground}`, + }, }} > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} + {row.getVisibleCells().map((cell) => { + const columnMeta = cell.column.columnDef.meta || {}; + + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} {row.getIsExpanded() ? ( @@ -66,7 +76,16 @@ function PatientGridRow({ row, rowIndex, columnCount, onToggleExpansion, onPatie bgcolor: theme.custom?.rowHoverBg || alpha(theme.palette.primary.main, 0.08), }} > - + + + ) : null} diff --git a/src/components/patientGrid/patientGridColumns.jsx b/src/components/patientGrid/patientGridColumns.jsx index 857d73c..d9e83c5 100644 --- a/src/components/patientGrid/patientGridColumns.jsx +++ b/src/components/patientGrid/patientGridColumns.jsx @@ -1,9 +1,6 @@ import React from "react"; -import PropTypes from "prop-types"; import { Box, IconButton, Tooltip, Typography } from "@mui/material"; import { visuallyHidden } from "@mui/utils"; -import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; -import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; import ChevronRightIcon from "@mui/icons-material/ChevronRight"; const MIN_TOOLTIP_TEXT_LENGTH = 25; @@ -107,7 +104,7 @@ export function createColumns({ onToggleRow }) { size: 40, minSize: 40, maxSize: 40, - meta: { exportable: false }, + meta: { exportable: false, pinned: true, stickyLeft: 0 }, enableSorting: false, enableHiding: false, enableResizing: false, @@ -149,6 +146,7 @@ export function createColumns({ onToggleRow }) { header: "Patient ID", size: 200, minSize: 130, + meta: { pinned: true, stickyLeft: 40 }, cell: ({ getValue }) => { const value = String(getValue() || ""); return ( @@ -176,11 +174,10 @@ export function createColumns({ onToggleRow }) { accessorKey: "docCount", id: "docCount", header: "Document Count", - size: 115, - minSize: 105, + size: 160, + minSize: 130, sortingFn: (rowA, rowB, columnId) => - getDocumentCountValue(rowA.getValue(columnId)) - - getDocumentCountValue(rowB.getValue(columnId)), + getDocumentCountValue(rowA.getValue(columnId)) - getDocumentCountValue(rowB.getValue(columnId)), cell: ({ getValue }) => { const documentCount = getDocumentCountValue(getValue()); return renderTruncatedCell({ @@ -194,8 +191,8 @@ export function createColumns({ onToggleRow }) { accessorKey: "ageAtDx", id: "ageAtDx", header: "Age at Dx", - size: 65, - minSize: 65, + size: 90, + minSize: 85, sortingFn: (rowA, rowB, columnId) => getAgeSortValue(rowA.getValue(columnId)) - getAgeSortValue(rowB.getValue(columnId)), cell: ({ getValue }) => { @@ -212,8 +209,8 @@ export function createColumns({ onToggleRow }) { accessorKey: "gender", id: "gender", header: "Gender", - size: 65, - minSize: 65, + size: 85, + minSize: 80, cell: ({ getValue }) => renderTruncatedCell({ displayValue: String(getValue() || "—"), @@ -236,8 +233,8 @@ export function createColumns({ onToggleRow }) { accessorKey: "ethnicity", id: "ethnicity", header: "Ethnicity", - size: 115, - minSize: 105, + size: 125, + minSize: 110, cell: ({ getValue }) => renderTruncatedCell({ displayValue: String(getValue() || "—"), @@ -248,8 +245,8 @@ export function createColumns({ onToggleRow }) { accessorKey: "cancerType", id: "cancerType", header: "Cancer Type", - size: 100, - minSize: 90, + size: 110, + minSize: 100, cell: ({ getValue }) => renderTruncatedCell({ displayValue: String(getValue() || "—"), @@ -260,8 +257,8 @@ export function createColumns({ onToggleRow }) { accessorKey: "stage", id: "stage", header: "Stage", - size: 90, - minSize: 90, + size: 110, + minSize: 100, sortingFn: (rowA, rowB) => Number(rowA.original?.stageSortRank ?? Number.NEGATIVE_INFINITY) - Number(rowB.original?.stageSortRank ?? Number.NEGATIVE_INFINITY), @@ -353,20 +350,3 @@ export function createColumns({ onToggleRow }) { }, ]; } - -export function SortIndicator({ column }) { - const sort = column.getIsSorted(); - if (!sort) { - return null; - } - - if (sort === "asc") { - return ; - } - - return ; -} - -SortIndicator.propTypes = { - column: PropTypes.object.isRequired, -}; diff --git a/src/constants/eventRelationTimeline.js b/src/constants/eventRelationTimeline.js index 9861cbb..268be44 100644 --- a/src/constants/eventRelationTimeline.js +++ b/src/constants/eventRelationTimeline.js @@ -81,12 +81,20 @@ export const TIMESPAN = Object.freeze({ padding: 8 }); export const LANE = Object.freeze({ height: 15, GROUP_TOP_PADDING: 10 }); -export const OVERVIEW = Object.freeze({ height: 10 }); +// The overview strip's own height comes from computeOverviewStripLayout in +// utils/patientView/timelineViewport.js, shared with the document timeline. +// This is only the space above it, clear of the age axis's guidelines. +export const OVERVIEW_TOP_GAP = 8; export const AGE_AREA = Object.freeze({ height: 10, bottomPad: 10 }); export const GAPS = Object.freeze({ legendToMain: 5, pad: 25 }); +// Chart coordinates still reserve MARGINS.top and LEGEND.height for the alpha's +// in-SVG legend, which is now HTML above the chart. The viewBox starts this far +// down to trim most of that empty band. +export const VIEWBOX_TOP = 20; + export const TIMELINE_PADDING_DAYS = 50; // Right-hand gutter the alpha subtracts from the container to get the plot width. diff --git a/src/constants/timelineFrame.js b/src/constants/timelineFrame.js new file mode 100644 index 0000000..6de9e75 --- /dev/null +++ b/src/constants/timelineFrame.js @@ -0,0 +1,25 @@ +// Horizontal frame shared by the patient timelines. +// +// The Patient Document Timeline and the Event Timeline stack in one column and +// share one date range, so a date must land at the same x in both, and so must +// their overview strips and handles. Both cards pad their content by +// TIMELINE_CONTENT_PADDING_X, and both plots start and end TIMELINE_PLOT_INSET +// pixels inside that padding. Each card subtracts its own chrome (the document +// chart's border) to get its SVG gutters. +// +// Alignment holds from COMPACT_TIMELINE_WIDTH up. Below it the document timeline +// moves its lane labels above the lanes and the Event Timeline has no narrow +// layout, so the two stay linked but no longer line up. + +// Theme spacing units, for CardContent `px`. +export const TIMELINE_CONTENT_PADDING_X = 1; + +// Pixels from the content edge to the plot. The left inset fits the longest +// lane label ("Surgical Pathology Report (4):"); the right one fits the Event +// Timeline's lane collapse chevrons. +export const TIMELINE_PLOT_INSET = Object.freeze({ left: 237, right: 26 }); + +// The document chart sits in a box with a 1px border; the Event chart has none. +export const DOCUMENT_CHART_BORDER_WIDTH = 1; + +export const COMPACT_TIMELINE_WIDTH = 620; diff --git a/src/controllers/__tests__/eventRelationTimeline.test.js b/src/controllers/__tests__/eventRelationTimeline.test.js index 6eac64d..0362e47 100644 --- a/src/controllers/__tests__/eventRelationTimeline.test.js +++ b/src/controllers/__tests__/eventRelationTimeline.test.js @@ -58,6 +58,12 @@ const CONCEPT_FIXTURES = [ mentionIds: [`mention-${suffix}`], })); +// TSV dates are calendar dates at local midnight, like the document timeline's. +function localMidnight(isoDate) { + const [year, month, day] = isoDate.split("-").map(Number); + return new Date(year, month - 1, day).getTime(); +} + function getSpanCountsByLaneGroup(model) { return model.spans.reduce((counts, span) => { counts[span.laneGroup] = (counts[span.laneGroup] || 0) + 1; @@ -65,6 +71,21 @@ function getSpanCountsByLaneGroup(model) { }, {}); } +function relationFixture(conceptId, relation1, relation2, start, end) { + return { + sourceRowNumber: 2, + patientId: "fake_patient1", + conceptId, + relation1, + relation2, + relationKey: `${relation1}/${relation2}`, + date1Label: start, + date2Label: end, + date1Time: localMidnight(start), + date2Time: localMidnight(end), + }; +} + describe("event relation timeline controller", () => { it("parses the static TSV without dropping the final unterminated row", () => { const rows = parseEventRelationTimelineTsv(STATIC_TSV); @@ -129,8 +150,8 @@ describe("event relation timeline controller", () => { laneGroup: "Treatment", date1Label: "2010-01-31", date2Label: "2010-05-31", - date1Time: Date.UTC(2010, 0, 31), - date2Time: Date.UTC(2010, 4, 31), + date1Time: new Date(2010, 0, 31).getTime(), + date2Time: new Date(2010, 4, 31).getTime(), relation1: "On", relation2: "Before", relationKey: "On/Before", @@ -146,8 +167,8 @@ describe("event relation timeline controller", () => { laneGroup: "Treatment", date1Label: "2010-01-31", date2Label: "2010-05-31", - date1Time: Date.UTC(2010, 0, 31), - date2Time: Date.UTC(2010, 4, 31), + date1Time: new Date(2010, 0, 31).getTime(), + date2Time: new Date(2010, 4, 31).getTime(), relation1: "After", relation2: "Before", relationKey: "After/Before", @@ -196,6 +217,31 @@ describe("event relation timeline controller", () => { }); }); + it("orders legend relations from data start relations, then end relations", () => { + const model = buildEventRelationTimelineModel({ + relations: [ + relationFixture("c-finding", "After", "Before", "2010-01-01", "2010-02-01"), + relationFixture("c-treatment", "On", "Overlaps", "2010-03-01", "2010-04-01"), + ], + concepts: [ + { + id: "c-finding", + name: "Finding", + dpheGroup: "Clinical Test Result", + mentionIds: ["m-finding"], + }, + { + id: "c-treatment", + name: "Treatment", + dpheGroup: "Intervention or Procedure", + mentionIds: ["m-treatment"], + }, + ], + }); + + expect(model.presentRelations).toEqual(["After", "On", "Before", "Overlaps"]); + }); + it("limits timeline availability to the canonical fake patient id", () => { expect(shouldShowEventRelationTimeline("fake_patient1")).toBe(true); expect(shouldShowEventRelationTimeline("fake_patient_1")).toBe(false); diff --git a/src/controllers/__tests__/patientDemographics.test.js b/src/controllers/__tests__/patientDemographics.test.js index 5db06c8..b0c4ae9 100644 --- a/src/controllers/__tests__/patientDemographics.test.js +++ b/src/controllers/__tests__/patientDemographics.test.js @@ -26,8 +26,8 @@ const RECORDS = [ describe("patient demographics controller", () => { it("parses both the ISO and MM-DD-YYYY birth date shapes", () => { - expect(parsePatientBirthDate("1960-04-01").toISOString()).toBe("1960-04-01T00:00:00.000Z"); - expect(parsePatientBirthDate("04-01-1960").toISOString()).toBe("1960-04-01T00:00:00.000Z"); + expect(parsePatientBirthDate("1960-04-01").getTime()).toBe(new Date(1960, 3, 1).getTime()); + expect(parsePatientBirthDate("04-01-1960").getTime()).toBe(new Date(1960, 3, 1).getTime()); expect(parsePatientBirthDate("")).toBeNull(); expect(parsePatientBirthDate("unknown")).toBeNull(); expect(parsePatientBirthDate("1960-13-01")).toBeNull(); @@ -40,18 +40,19 @@ describe("patient demographics controller", () => { }); it("counts whole years completed, not calendar-year differences", () => { - const dob = new Date(Date.UTC(1960, 3, 1)); + // Birth dates, like the timelines' dates, are local midnight. + const dob = new Date(1960, 3, 1); - expect(getAgeOnDate(dob, new Date(Date.UTC(2010, 2, 31)))).toBe(49); - expect(getAgeOnDate(dob, new Date(Date.UTC(2010, 3, 1)))).toBe(50); + expect(getAgeOnDate(dob, new Date(2010, 2, 31))).toBe(49); + expect(getAgeOnDate(dob, new Date(2010, 3, 1))).toBe(50); }); it("looks records up by patient id", () => { expect(findPatientDemographicsRecord(RECORDS, "fake_patient2").Gender).toBe("male"); expect(findPatientDemographicsRecord(RECORDS, "nobody")).toBeNull(); expect(findPatientDemographicsRecord(null, "fake_patient1")).toBeNull(); - expect(findDemographicsBirthDate(RECORDS, "fake_patient1").toISOString()).toBe( - "1960-04-01T00:00:00.000Z" + expect(findDemographicsBirthDate(RECORDS, "fake_patient1").getTime()).toBe( + new Date(1960, 3, 1).getTime() ); }); diff --git a/src/controllers/eventRelationTimeline.js b/src/controllers/eventRelationTimeline.js index eb352eb..2bf89b2 100644 --- a/src/controllers/eventRelationTimeline.js +++ b/src/controllers/eventRelationTimeline.js @@ -39,7 +39,10 @@ function normalizeRelation(value) { return relation || ""; } -function parseUtcDate(value, rowNumber, columnName) { +// A TSV date is a calendar date, built at local midnight like the document +// timeline's dates. The two timelines are linked and aligned, so they must +// share one convention or a date would label differently in each. +function parseCalendarDate(value, rowNumber, columnName) { const normalized = normalizeString(value); const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(normalized); @@ -50,12 +53,12 @@ function parseUtcDate(value, rowNumber, columnName) { const year = Number(match[1]); const monthIndex = Number(match[2]) - 1; const day = Number(match[3]); - const date = new Date(Date.UTC(year, monthIndex, day)); + const date = new Date(year, monthIndex, day); if ( - date.getUTCFullYear() !== year || - date.getUTCMonth() !== monthIndex || - date.getUTCDate() !== day + date.getFullYear() !== year || + date.getMonth() !== monthIndex || + date.getDate() !== day ) { throw new Error(`Invalid ${columnName} value on row ${rowNumber}: ${normalized}`); } @@ -68,9 +71,9 @@ function formatDateLabel(date) { return ""; } - const year = date.getUTCFullYear(); - const month = `${date.getUTCMonth() + 1}`.padStart(2, "0"); - const day = `${date.getUTCDate()}`.padStart(2, "0"); + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); return `${year}-${month}-${day}`; } @@ -118,8 +121,8 @@ export function parseEventRelationTimelineTsv(tsvText) { const conceptId = normalizeString(columns[columnIndexByName.ConceptID]); const relation1 = normalizeRelation(columns[columnIndexByName.Relation1]); const relation2 = normalizeRelation(columns[columnIndexByName.Relation2]); - const date1 = parseUtcDate(columns[columnIndexByName.Date1], rowNumber, "Date1"); - const date2 = parseUtcDate(columns[columnIndexByName.Date2], rowNumber, "Date2"); + const date1 = parseCalendarDate(columns[columnIndexByName.Date1], rowNumber, "Date1"); + const date2 = parseCalendarDate(columns[columnIndexByName.Date2], rowNumber, "Date2"); if (!patientId) { throw new Error(`Missing PatientID value on row ${rowNumber}.`); @@ -251,6 +254,21 @@ function countBy(items, getKey) { }, {}); } +function getPresentRelations(spans = []) { + const orderedRelations = []; + const addRelation = (relation) => { + const normalizedRelation = normalizeRelation(relation); + if (normalizedRelation && !orderedRelations.includes(normalizedRelation)) { + orderedRelations.push(normalizedRelation); + } + }; + + (Array.isArray(spans) ? spans : []).forEach((span) => addRelation(span.relation1)); + (Array.isArray(spans) ? spans : []).forEach((span) => addRelation(span.relation2)); + + return orderedRelations; +} + export function buildEventRelationTimelineModel({ tsvText = "", relations = undefined, @@ -302,10 +320,9 @@ export function buildEventRelationTimelineModel({ laneGroupCounts: countBy(spans, (span) => span.laneGroup), dpheGroupCounts: countBy(rows, (row) => row.dpheGroup), relationCounts: countBy(spans, (span) => span.relationKey), - // Distinct relations present, for the legend (alpha: allRelations). - presentRelations: TEMPORAL_RELATIONS.filter((relation) => - spans.some((span) => span.relation1 === relation || span.relation2 === relation) - ), + // Distinct relations present, for the legend (alpha: start relations first, + // then end relations, both deduped in data order). + presentRelations: getPresentRelations(spans), unmatchedConceptIds: [...unmatchedConceptIds].sort(), uncategorizedConceptIds: [...uncategorizedConceptIds].sort(), currentReportConceptIds: [...selectedDocumentConceptIds].sort(), @@ -328,8 +345,6 @@ export function filterEventRelationTimelineModel(model, scope) { spans, laneGroupCounts: countBy(spans, (span) => span.laneGroup), relationCounts: countBy(spans, (span) => span.relationKey), - presentRelations: TEMPORAL_RELATIONS.filter((relation) => - spans.some((span) => span.relation1 === relation || span.relation2 === relation) - ), + presentRelations: getPresentRelations(spans), }; } diff --git a/src/controllers/patientDemographics.js b/src/controllers/patientDemographics.js index ce9f8c4..40b1814 100644 --- a/src/controllers/patientDemographics.js +++ b/src/controllers/patientDemographics.js @@ -43,11 +43,13 @@ export function parsePatientBirthDate(value) { return null; } - const date = new Date(Date.UTC(year, monthIndex, day)); + // Local midnight, like the timelines' dates: the Event Timeline's age axis + // compares the two by calendar fields. + const date = new Date(year, monthIndex, day); if ( - date.getUTCFullYear() !== year || - date.getUTCMonth() !== monthIndex || - date.getUTCDate() !== day + date.getFullYear() !== year || + date.getMonth() !== monthIndex || + date.getDate() !== day ) { return null; } @@ -62,20 +64,20 @@ export function toIsoBirthDate(value) { return ""; } - const month = `${date.getUTCMonth() + 1}`.padStart(2, "0"); - const day = `${date.getUTCDate()}`.padStart(2, "0"); - return `${date.getUTCFullYear()}-${month}-${day}`; + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + return `${date.getFullYear()}-${month}-${day}`; } -/** Whole years completed at `onDate`. */ +/** Whole years completed at `onDate`, by local calendar date. */ export function getAgeOnDate(birthDate, onDate) { if (!(birthDate instanceof Date) || !(onDate instanceof Date)) { return null; } - let age = onDate.getUTCFullYear() - birthDate.getUTCFullYear(); - const monthDelta = onDate.getUTCMonth() - birthDate.getUTCMonth(); - if (monthDelta < 0 || (monthDelta === 0 && onDate.getUTCDate() < birthDate.getUTCDate())) { + let age = onDate.getFullYear() - birthDate.getFullYear(); + const monthDelta = onDate.getMonth() - birthDate.getMonth(); + if (monthDelta < 0 || (monthDelta === 0 && onDate.getDate() < birthDate.getDate())) { age -= 1; } diff --git a/src/hooks/__tests__/useTimelineViewport.test.js b/src/hooks/__tests__/useTimelineViewport.test.js new file mode 100644 index 0000000..249bffd --- /dev/null +++ b/src/hooks/__tests__/useTimelineViewport.test.js @@ -0,0 +1,184 @@ +/* eslint-disable testing-library/no-unnecessary-act */ +/* eslint-disable testing-library/render-result-naming-convention */ +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import useTimelineViewport, { RANGE_ANNOUNCEMENT_DELAY_MS } from "../useTimelineViewport"; + +function renderHook(initialProps) { + const result = { current: null }; + function Harness(props) { + result.current = useTimelineViewport(props); + return null; + } + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(React.createElement(Harness, initialProps)); + }); + return { + result, + rerender(nextProps) { + act(() => { + root.render(React.createElement(Harness, nextProps)); + }); + }, + unmount() { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function keyEvent(key, extra = {}) { + return { key, preventDefault: jest.fn(), ...extra }; +} + +describe("useTimelineViewport", () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it("zooms, pans and resets, reporting which controls can act", () => { + const { result, unmount } = renderHook({ resetKey: "a" }); + + expect(result.current).toMatchObject({ + zoomPercent: 100, + canZoomOut: false, + canPanEarlier: false, + canPanLater: false, + canReset: false, + canZoomIn: true, + }); + + act(() => result.current.zoomIn()); + act(() => result.current.zoomIn()); + expect(result.current.zoomPercent).toBe(225); + expect(result.current.canPanEarlier).toBe(true); + expect(result.current.canPanLater).toBe(true); + + const centered = result.current.viewport.panRatio; + act(() => result.current.panLater()); + expect(result.current.viewport.panRatio - centered).toBeCloseTo(0.2 / 2.25, 10); + + act(() => result.current.reset()); + expect(result.current.viewport).toEqual({ zoom: 1, panRatio: 0 }); + + unmount(); + }); + + it("maps +, -, 0 and the arrow keys, and ignores modified keys", () => { + const { result, unmount } = renderHook({ resetKey: "a" }); + + const plus = keyEvent("+"); + act(() => result.current.handlePlotKeyDown(plus)); + expect(plus.preventDefault).toHaveBeenCalled(); + expect(result.current.zoomPercent).toBe(150); + + act(() => result.current.handlePlotKeyDown(keyEvent("ArrowRight"))); + expect(result.current.viewport.panRatio).toBeGreaterThan(1 / 6); + + const browserBack = keyEvent("ArrowLeft", { metaKey: true }); + const before = result.current.viewport; + act(() => result.current.handlePlotKeyDown(browserBack)); + expect(browserBack.preventDefault).not.toHaveBeenCalled(); + expect(result.current.viewport).toBe(before); + + act(() => result.current.handlePlotKeyDown(keyEvent("-"))); + expect(result.current.zoomPercent).toBe(100); + + const letter = keyEvent("a"); + act(() => result.current.handlePlotKeyDown(letter)); + expect(letter.preventDefault).not.toHaveBeenCalled(); + + unmount(); + }); + + it("resets when the reset key changes, without announcing it", () => { + jest.useFakeTimers(); + const describeRange = jest.fn(() => "Showing everything"); + const { result, rerender, unmount } = renderHook({ resetKey: "patient-1", describeRange }); + + act(() => result.current.zoomIn()); + act(() => { + jest.advanceTimersByTime(RANGE_ANNOUNCEMENT_DELAY_MS); + }); + expect(result.current.announcement).toBe("Showing everything"); + expect(describeRange).toHaveBeenCalledTimes(1); + + rerender({ resetKey: "patient-2", describeRange }); + act(() => { + jest.advanceTimersByTime(RANGE_ANNOUNCEMENT_DELAY_MS * 2); + }); + expect(result.current.viewport).toEqual({ zoom: 1, panRatio: 0 }); + expect(describeRange).toHaveBeenCalledTimes(1); + + // Same key: the reader's zoom survives a re-render. + act(() => result.current.zoomIn()); + rerender({ resetKey: "patient-2", describeRange }); + expect(result.current.zoomPercent).toBe(150); + + unmount(); + }); + + it("debounces announcements until changes stop", () => { + jest.useFakeTimers(); + const describeRange = jest.fn((viewport) => `zoom ${viewport.zoom}`); + const { result, unmount } = renderHook({ resetKey: "a", describeRange }); + + act(() => result.current.zoomIn()); + act(() => { + jest.advanceTimersByTime(RANGE_ANNOUNCEMENT_DELAY_MS - 50); + }); + act(() => result.current.zoomIn()); + act(() => { + jest.advanceTimersByTime(RANGE_ANNOUNCEMENT_DELAY_MS - 50); + }); + expect(result.current.announcement).toBe(""); + + act(() => { + jest.advanceTimersByTime(50); + }); + expect(result.current.announcement).toBe("zoom 2.25"); + expect(describeRange).toHaveBeenCalledTimes(1); + + unmount(); + }); + + it("pans on a drag past the threshold and swallows the click that ends it", () => { + const { result, unmount } = renderHook({ resetKey: "a" }); + const clientToX = (clientX) => clientX; + const pointerDown = (clientX) => ({ button: 0, clientX, preventDefault: jest.fn() }); + const move = (clientX) => + window.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX })); + const up = () => window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })); + + // At 100% there is nothing to pan. + act(() => result.current.handlePlotPointerDown(pointerDown(500), { clientToX, plotWidth: 1000 })); + expect(result.current.isDragging).toBe(false); + + act(() => result.current.zoomIn()); + act(() => result.current.zoomIn()); + const start = result.current.viewport.panRatio; + + act(() => result.current.handlePlotPointerDown(pointerDown(500), { clientToX, plotWidth: 1000 })); + expect(result.current.isDragging).toBe(true); + act(() => move(503)); + expect(result.current.viewport.panRatio).toBe(start); + act(() => move(400)); + // Dragging right-to-left by 100 of 1000 units at 225% moves later. + expect(result.current.viewport.panRatio).toBeCloseTo(start + 100 / (1000 * 2.25), 10); + act(() => up()); + expect(result.current.isDragging).toBe(false); + expect(result.current.consumeDragClick()).toBe(true); + expect(result.current.consumeDragClick()).toBe(false); + + // A press that never moves is a plain click. + act(() => result.current.handlePlotPointerDown(pointerDown(500), { clientToX, plotWidth: 1000 })); + act(() => up()); + expect(result.current.consumeDragClick()).toBe(false); + + unmount(); + }); +}); diff --git a/src/hooks/useLinkedTimelineViewport.js b/src/hooks/useLinkedTimelineViewport.js new file mode 100644 index 0000000..3f377cf --- /dev/null +++ b/src/hooks/useLinkedTimelineViewport.js @@ -0,0 +1,56 @@ +import { useContext, useEffect } from "react"; +import useTimelineViewport from "./useTimelineViewport"; +import { TimelineLinkContext } from "../components/patient/timeline/TimelineLinkProvider"; + +/** + * A timeline's zoom and pan state, linked to every other timeline under the same + * TimelineLinkProvider, or its own when there is no provider. + * + * - `id`: unique among the linked timelines. + * - `domain`: this timeline's own `{ startDate, endDate }`, or null while it has + * nothing to draw. Linked timelines render against the returned `domain`, + * which covers every linked timeline's data. + * - `priority`: the lowest-priority linked timeline announces range changes. + * - `resetKey`, `describeRange`: used only when unlinked (see useTimelineViewport). + * + * Returns everything useTimelineViewport does, plus `domain`, `isLinked`, and + * `isAnnouncer` (whether this timeline should render the range live region). + */ +export default function useLinkedTimelineViewport({ + id, + domain = null, + priority = 0, + resetKey = "", + describeRange = undefined, +}) { + const link = useContext(TimelineLinkContext); + const local = useTimelineViewport({ + resetKey: link ? "" : resetKey, + describeRange: link ? undefined : describeRange, + }); + + const registerTimeline = link?.registerTimeline; + const unregisterTimeline = link?.unregisterTimeline; + const startMs = domain?.startDate instanceof Date ? domain.startDate.getTime() : NaN; + const endMs = domain?.endDate instanceof Date ? domain.endDate.getTime() : NaN; + + useEffect(() => { + if (!registerTimeline || !Number.isFinite(startMs) || !Number.isFinite(endMs)) { + return undefined; + } + registerTimeline(id, { startDate: new Date(startMs), endDate: new Date(endMs) }, priority); + return () => unregisterTimeline(id); + }, [registerTimeline, unregisterTimeline, id, startMs, endMs, priority]); + + if (!link) { + return { ...local, domain, isLinked: false, isAnnouncer: true }; + } + + return { + ...link, + // Until this timeline has registered, the shared domain may not cover it. + domain: link.domain || domain, + isLinked: true, + isAnnouncer: link.announcerId === id, + }; +} diff --git a/src/hooks/useTimelineViewport.js b/src/hooks/useTimelineViewport.js new file mode 100644 index 0000000..ce0150d --- /dev/null +++ b/src/hooks/useTimelineViewport.js @@ -0,0 +1,258 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + DEFAULT_VIEWPORT, + ZOOM_STEP, + canPanEarlier, + canPanLater, + canZoomIn, + isViewportZoomed, + normalizeViewport, + panViewport, + viewportsEqual, + zoomViewport, +} from "../utils/patientView/timelineViewport"; + +// Pointer travel, in SVG units, before a press on the plot becomes a pan drag +// rather than a click on whatever is under it. +export const PLOT_DRAG_THRESHOLD = 5; +// Range announcements wait this long after the last change, so a drag or a held +// arrow key doesn't flood screen readers. +export const RANGE_ANNOUNCEMENT_DELAY_MS = 300; + +/** + * Maps a pointer's clientX into the SVG's viewBox x. jsdom has no SVG geometry, + * so there clientX is taken to already be in viewBox units, which lets pointer + * tests drive the range logic directly. + */ +export function clientXToSvgX(svg, clientX) { + if (!svg) { + return null; + } + + if (typeof svg.getScreenCTM === "function" && typeof svg.createSVGPoint === "function") { + const ctm = svg.getScreenCTM(); + if (ctm) { + const svgPoint = svg.createSVGPoint(); + svgPoint.x = clientX; + svgPoint.y = 0; + return svgPoint.matrixTransform(ctm.inverse()).x; + } + } + + const viewBoxValues = String(svg.getAttribute("viewBox") || "") + .split(/\s+/) + .map(Number); + const viewBoxWidth = Number.isFinite(viewBoxValues[2]) ? viewBoxValues[2] : 0; + const rect = typeof svg.getBoundingClientRect === "function" ? svg.getBoundingClientRect() : null; + if (rect && rect.width > 0 && viewBoxWidth > 0) { + return ((clientX - rect.left) / rect.width) * viewBoxWidth + viewBoxValues[0]; + } + + return Number.isFinite(clientX) ? clientX : null; +} + +/** + * Zoom and pan state for a patient timeline's overview + detail view. + * + * The `{ zoom, panRatio }` viewport is the single source of truth: the detail + * axis, the marks and the overview window all read it, so they can't drift + * apart. It is ratio-based, so resizing the chart or collapsing a lane keeps + * the reader's date range. It resets when `resetKey` changes (a new patient or + * a new date domain). + * + * `describeRange(viewport)` returns the text announced, debounced, after the + * reader changes the range. + */ +export default function useTimelineViewport({ resetKey = "", describeRange = undefined } = {}) { + const [viewport, setViewportState] = useState(DEFAULT_VIEWPORT); + const [announcement, setAnnouncement] = useState(""); + const [isDragging, setIsDragging] = useState(false); + const viewportRef = useRef(viewport); + viewportRef.current = viewport; + const describeRangeRef = useRef(describeRange); + describeRangeRef.current = describeRange; + const shouldAnnounceRef = useRef(false); + const suppressClickRef = useRef(false); + const endDragRef = useRef(null); + + useEffect(() => { + shouldAnnounceRef.current = false; + setViewportState(DEFAULT_VIEWPORT); + }, [resetKey]); + + useEffect( + () => () => { + endDragRef.current?.(); + }, + [] + ); + + // Every reader-driven change goes through here and is announced; the reset + // above is not, so loading a new patient isn't read out as a range change. + // Pass `{ announce: false }` for changes the reader didn't make. + const setViewport = useCallback((nextViewport, { announce = true } = {}) => { + setViewportState((previous) => { + const candidate = + typeof nextViewport === "function" ? nextViewport(previous) : nextViewport; + if (!candidate) { + return previous; + } + const next = normalizeViewport(candidate); + if (viewportsEqual(previous, next)) { + return previous; + } + if (announce) { + shouldAnnounceRef.current = true; + } + return next; + }); + }, []); + + useEffect(() => { + if (!shouldAnnounceRef.current) { + return undefined; + } + shouldAnnounceRef.current = false; + const timer = window.setTimeout(() => { + setAnnouncement(describeRangeRef.current ? describeRangeRef.current(viewport) : ""); + }, RANGE_ANNOUNCEMENT_DELAY_MS); + return () => window.clearTimeout(timer); + }, [viewport]); + + const zoomIn = useCallback( + (anchorFraction) => setViewport((current) => zoomViewport(current, ZOOM_STEP, anchorFraction)), + [setViewport] + ); + const zoomOut = useCallback( + (anchorFraction) => + setViewport((current) => zoomViewport(current, 1 / ZOOM_STEP, anchorFraction)), + [setViewport] + ); + const panEarlier = useCallback( + () => setViewport((current) => panViewport(current, -1)), + [setViewport] + ); + const panLater = useCallback( + () => setViewport((current) => panViewport(current, 1)), + [setViewport] + ); + const reset = useCallback(() => setViewport(DEFAULT_VIEWPORT), [setViewport]); + + /** `+`/`=` zoom in, `-`/`_` zoom out, `0` resets, `←`/`→` pan. */ + const handlePlotKeyDown = useCallback( + (event) => { + if (event.altKey || event.ctrlKey || event.metaKey) { + return; + } + const actions = { + "+": zoomIn, + "=": zoomIn, + "-": zoomOut, + _: zoomOut, + 0: reset, + ArrowLeft: panEarlier, + ArrowRight: panLater, + }; + const action = actions[event.key]; + if (!action) { + return; + } + event.preventDefault(); + action(); + }, + [panEarlier, panLater, reset, zoomIn, zoomOut] + ); + + /** + * Press-and-drag on the plot pans a zoomed chart. Window listeners (not + * pointer capture) keep clicks on the marks working; a drag that moved + * swallows the click that follows it (see `consumeDragClick`). + * `clientToX` maps clientX into the plot's SVG units. + */ + const handlePlotPointerDown = useCallback( + (event, { clientToX, plotWidth }) => { + if (event.button !== 0 || !isViewportZoomed(viewportRef.current) || !(plotWidth > 0)) { + return; + } + const startX = clientToX(event.clientX); + if (startX == null) { + return; + } + // Stop the browser starting a text or element selection mid-drag. + event.preventDefault(); + endDragRef.current?.(); + + const startPanRatio = viewportRef.current.panRatio; + let moved = false; + setIsDragging(true); + + const handleMove = (moveEvent) => { + const x = clientToX(moveEvent.clientX); + if (x == null) { + return; + } + const deltaX = x - startX; + if (!moved && Math.abs(deltaX) < PLOT_DRAG_THRESHOLD) { + return; + } + moved = true; + setViewport((current) => ({ + zoom: current.zoom, + panRatio: startPanRatio - deltaX / (plotWidth * current.zoom), + })); + }; + const endDrag = () => { + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", endDrag); + window.removeEventListener("pointercancel", endDrag); + endDragRef.current = null; + setIsDragging(false); + if (moved) { + suppressClickRef.current = true; + window.setTimeout(() => { + suppressClickRef.current = false; + }, 0); + } + }; + + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", endDrag); + window.addEventListener("pointercancel", endDrag); + endDragRef.current = endDrag; + }, + [setViewport] + ); + + /** True (once) when a click was the tail end of a pan drag and should be ignored. */ + const consumeDragClick = useCallback(() => { + if (!suppressClickRef.current) { + return false; + } + suppressClickRef.current = false; + return true; + }, []); + + const isZoomed = isViewportZoomed(viewport); + + return { + viewport, + setViewport, + zoomIn, + zoomOut, + panEarlier, + panLater, + reset, + handlePlotKeyDown, + handlePlotPointerDown, + consumeDragClick, + isDragging, + announcement, + isZoomed, + zoomPercent: Math.round(viewport.zoom * 100), + canZoomIn: canZoomIn(viewport), + canZoomOut: isZoomed, + canPanEarlier: canPanEarlier(viewport), + canPanLater: canPanLater(viewport), + canReset: isZoomed, + }; +} diff --git a/src/utils/patientView/__tests__/eventRelationTimelineLayout.test.js b/src/utils/patientView/__tests__/eventRelationTimelineLayout.test.js index 3cd7af2..3b78e3c 100644 --- a/src/utils/patientView/__tests__/eventRelationTimelineLayout.test.js +++ b/src/utils/patientView/__tests__/eventRelationTimelineLayout.test.js @@ -1,5 +1,7 @@ +import { scaleTime } from "d3-scale"; import { buildEventRelationHeatmap, + buildEventRelationOverviewRows, computeEventRelationAgeAxis, buildEventRelationTooltip, checkOverlapWithPadding, @@ -9,15 +11,23 @@ import { getEventRelationGlyph, packSpansIntoLanes, } from "../eventRelationTimelineLayout"; -import { zoomIdentity } from "d3-zoom"; +import { buildDocumentOverviewRows } from "../timelineChartLayout"; +import { computeOverviewStripLayout, viewportToDateWindow } from "../timelineViewport"; import { LANE, MARGINS, + NEGATED_RELATION_COLOR, PLOT_RIGHT_GUTTER, + RELATION_COLOR, TIMELINE_PADDING_DAYS, + VIEWBOX_TOP, } from "../../../constants/eventRelationTimeline"; -const MS_PER_DAY = 24 * 60 * 60 * 1000; +// TSV dates are calendar dates at local midnight, like the document timeline's. +function localMidnight(isoDate) { + const [year, month, day] = isoDate.split("-").map(Number); + return new Date(year, month - 1, day).getTime(); +} function span(id, laneGroup, startIso, endIso, overrides = {}) { return { @@ -25,8 +35,8 @@ function span(id, laneGroup, startIso, endIso, overrides = {}) { laneGroup, start: startIso, end: endIso, - startTime: Date.parse(`${startIso}T00:00:00Z`), - endTime: Date.parse(`${endIso}T00:00:00Z`), + startTime: localMidnight(startIso), + endTime: localMidnight(endIso), relation1: "Overlaps", relation2: "Overlaps", relationKey: "Overlaps/Overlaps", @@ -45,12 +55,10 @@ describe("event relation timeline layout", () => { ]; const domain = computeEventRelationDomain(spans); - expect(domain.startDate.getTime()).toBe( - Date.parse("2009-03-15T00:00:00Z") - TIMELINE_PADDING_DAYS * MS_PER_DAY - ); - expect(domain.endDate.getTime()).toBe( - Date.parse("2011-02-20T00:00:00Z") + TIMELINE_PADDING_DAYS * MS_PER_DAY - ); + // Padded by calendar days, so both ends stay on local midnight. + expect(TIMELINE_PADDING_DAYS).toBe(50); + expect(domain.startDate.getTime()).toBe(new Date(2009, 0, 24).getTime()); + expect(domain.endDate.getTime()).toBe(new Date(2011, 3, 11).getTime()); }); it("packs non-overlapping spans onto one lane and pushes overlaps down", () => { @@ -196,14 +204,14 @@ describe("event relation timeline layout", () => { const axis = computeEventRelationAgeAxis( layout.domain, layout.mainX, - new Date(Date.UTC(1960, 3, 1)) + new Date(1960, 3, 1) ); expect(axis.available).toBe(true); expect(axis.encounters.map((e) => e.age)).toEqual([48, 51]); // Birthdays falling inside the domain, labelled with the age reached. expect(axis.interiors.map((i) => i.age)).toEqual([49, 50, 51]); - expect(axis.interiors[0].date.toISOString()).toBe("2009-04-01T00:00:00.000Z"); + expect(axis.interiors[0].date.getTime()).toBe(new Date(2009, 3, 1).getTime()); expect(axis.interiors.every((i) => i.x > 0 && i.x <= layout.dimensions.svgWidth)).toBe( true ); @@ -214,14 +222,18 @@ describe("event relation timeline layout", () => { containerWidth: 1040, spans: [span("a", "Finding", "2009-01-28", "2011-03-01")], }); - const zoomedX = zoomIdentity - .translate(-layout.dimensions.svgWidth * 0.35, 0) - .scale(1.8) - .rescaleX(layout.mainX); + // 180% zoom showing roughly the middle of the range, derived the way the + // card derives its zoomed scale. + const { startDate, endDate } = viewportToDateWindow( + { zoom: 1.8, panRatio: 0.35 / 1.8 }, + layout.domain.startDate, + layout.domain.endDate + ); + const zoomedX = scaleTime().domain([startDate, endDate]).range(layout.mainX.range()); const axis = computeEventRelationAgeAxis( layout.domain, zoomedX, - new Date(Date.UTC(1960, 3, 1)) + new Date(1960, 3, 1) ); const allTicks = [...axis.encounters, ...axis.interiors]; @@ -263,6 +275,83 @@ describe("event relation timeline layout", () => { expect(withoutAge.dimensions.overviewTop).toBeLessThan(withAge.dimensions.overviewTop); }); + it("takes its plot gutters from the frame it shares with the document timeline", () => { + const spans = [span("a", "Finding", "2010-01-01", "2010-06-01")]; + const framed = computeEventRelationTimelineLayout({ + containerWidth: 1200, + spans, + plotInsets: { left: 237, right: 26 }, + }); + + expect(framed.dimensions.marginLeft).toBe(237); + expect(framed.dimensions.svgWidth).toBe(1200 - 237 - 26); + expect(framed.dimensions.viewBoxWidth).toBe(1200); + expect(framed.mainX.range()).toEqual([0, 1200 - 237 - 26]); + }); + + it("sizes the SVG to end below an overview strip with a mini-row per lane group", () => { + const oneGroup = computeEventRelationTimelineLayout({ + containerWidth: 1040, + spans: [span("a", "Finding", "2010-01-01", "2010-06-01")], + showAgeAxis: false, + }); + const twoGroups = computeEventRelationTimelineLayout({ + containerWidth: 1040, + spans: [ + span("a", "Finding", "2010-01-01", "2010-06-01"), + span("b", "Treatment", "2010-02-01", "2010-03-01"), + ], + showAgeAxis: false, + }); + + [oneGroup, twoGroups].forEach((layout) => { + const { dimensions, groups } = layout; + expect(dimensions.overviewHeight).toBe( + computeOverviewStripLayout({ rowCount: groups.length }).height + ); + expect(dimensions.viewBoxTop + dimensions.svgTotalHeight).toBe( + dimensions.overviewTop + dimensions.overviewHeight + MARGINS.bottom + ); + expect(dimensions.viewBoxTop).toBe(VIEWBOX_TOP); + }); + }); + + it("builds overview rows from lane groups at full-domain positions", () => { + const layout = computeEventRelationTimelineLayout({ + containerWidth: 1040, + spans: [ + span("a", "Finding", "2010-01-01", "2010-06-01"), + span("b", "Treatment", "2010-02-01", "2010-02-01", { negated: true }), + ], + collapsedGroups: new Set(["Treatment"]), + }); + + const rows = buildEventRelationOverviewRows(layout.groups); + expect(rows.map((row) => row.key)).toEqual(["Finding", "Treatment"]); + expect(rows[0].marks[0]).toEqual({ + key: "a", + x1: layout.mainX(new Date(2010, 0, 1)), + x2: layout.mainX(new Date(2010, 5, 1)), + color: RELATION_COLOR, + }); + // A collapsed group keeps its row; negation keeps its color. + expect(rows[1].marks[0].color).toBe(NEGATED_RELATION_COLOR); + expect(rows[1].marks[0].x1).toBe(rows[1].marks[0].x2); + + const documentRows = buildDocumentOverviewRows( + [{ type: "Note" }, { type: "Pathology" }], + [ + { id: "d1", type: "Note", x: 40, episodeColor: "#111111", dateObject: new Date(2010, 0, 1) }, + { id: "d2", type: "Pathology", x: 90, episodeColor: "#222222", dateObject: new Date(2010, 1, 1) }, + { id: "undated", type: "Note", x: 0, episodeColor: "#333333", dateObject: null }, + ] + ); + expect(documentRows).toEqual([ + { key: "Note", marks: [{ key: "d1", x1: 40, x2: 40, color: "#111111" }] }, + { key: "Pathology", marks: [{ key: "d2", x1: 90, x2: 90, color: "#222222" }] }, + ]); + }); + it("formats the alpha's duration and tooltip text", () => { expect(formatEventRelationDuration("2010-01-01", "2011-03-15")).toBe( "1 year, 2 months, 13 days" diff --git a/src/utils/patientView/__tests__/timelineChartLayout.test.js b/src/utils/patientView/__tests__/timelineChartLayout.test.js new file mode 100644 index 0000000..4d8e493 --- /dev/null +++ b/src/utils/patientView/__tests__/timelineChartLayout.test.js @@ -0,0 +1,55 @@ +import { buildTimelineChartModel, resolveTimelineDateDomain } from "../timelineChartLayout"; + +function timelineData(dates) { + return { + reportData: dates.map((date, index) => ({ + id: `doc-${index + 1}`, + name: `Doc ${index + 1}`, + date, + type: "Clinical note", + episode: "Diagnostic", + })), + }; +} + +const LAYOUT = { svgWidth: 1000, plotLeft: 200, plotRight: 25 }; + +describe("document timeline date domain", () => { + it("pads its dated documents by 21 days", () => { + const domain = resolveTimelineDateDomain(timelineData(["201003161030", "201102011200"])); + expect(domain.startDate.getTime()).toBe(new Date(2010, 1, 23, 10, 30).getTime()); + expect(domain.endDate.getTime()).toBe(new Date(2011, 1, 22, 12, 0).getTime()); + }); + + it("has no domain when the chart isn't drawn", () => { + expect(resolveTimelineDateDomain({})).toBeNull(); + expect(resolveTimelineDateDomain(timelineData(["not a date"]))).toBeNull(); + // Every document on one timestamp: the chart gives way to episode dropdowns. + expect(resolveTimelineDateDomain(timelineData(["201003161030", "201003161030"]))).toBeNull(); + // A single document still draws, padded either side. + expect(resolveTimelineDateDomain(timelineData(["201003161030"]))).not.toBeNull(); + }); + + it("matches the domain the chart model builds by itself", () => { + const data = timelineData(["201003161030", "201006011000", "201102011200"]); + expect(buildTimelineChartModel(data, LAYOUT).dateDomain).toEqual( + resolveTimelineDateDomain(data) + ); + }); + + it("places documents against a shared domain when one is given", () => { + const data = timelineData(["201003161030", "201102011200"]); + const shared = { startDate: new Date(2009, 0, 1), endDate: new Date(2011, 5, 1) }; + const model = buildTimelineChartModel(data, { ...LAYOUT, dateDomain: shared }); + + expect(model.dateDomain).toEqual(shared); + const spanMs = shared.endDate.getTime() - shared.startDate.getTime(); + const expectedX = + LAYOUT.plotLeft + + model.dimensions.plotWidth * + ((new Date(2010, 2, 16, 10, 30).getTime() - shared.startDate.getTime()) / spanMs); + expect(model.points[0].x).toBeCloseTo(expectedX, 6); + // The domain override is not a layout dimension. + expect(model.dimensions.dateDomain).toBeUndefined(); + }); +}); diff --git a/src/utils/patientView/__tests__/timelineViewport.test.js b/src/utils/patientView/__tests__/timelineViewport.test.js new file mode 100644 index 0000000..f4b2a18 --- /dev/null +++ b/src/utils/patientView/__tests__/timelineViewport.test.js @@ -0,0 +1,317 @@ +import { + DEFAULT_VIEWPORT, + MAX_ZOOM, + PAN_STEP_RATIO, + applySliderKey, + canPanEarlier, + canPanLater, + clampPanRatio, + clampZoom, + computeOverviewStripLayout, + dateWindowToViewport, + describeViewportRange, + estimateLabelWidth, + formatHandleDate, + getMinimumWindowRatio, + getViewportWindow, + layoutHandleLabels, + moveViewportEdge, + moveViewportWindow, + centerViewportOn, + getSnapRatio, + panViewport, + rebaseViewport, + resolveOverviewTicks, + sameDateDomain, + snapRatioToEnds, + spansMultipleYears, + unionDateDomains, + viewportFromWindow, + viewportToDateWindow, + zoomViewport, +} from "../timelineViewport"; +import { resolveTicks } from "../timelineChartLayout"; + +const DOMAIN_START = new Date(2010, 0, 4); +const DOMAIN_END = new Date(2011, 2, 20); + +describe("timeline viewport math", () => { + it("clamps zoom to 1×–16× and the pan to both domain ends", () => { + expect(clampZoom(0.2)).toBe(1); + expect(clampZoom(40)).toBe(MAX_ZOOM); + expect(clampZoom(Number.NaN)).toBe(1); + expect(clampPanRatio(-0.3, 4)).toBe(0); + expect(clampPanRatio(0.9, 4)).toBe(0.75); + expect(clampPanRatio(0.5, 1)).toBe(0); + }); + + it("keeps the anchor's date fixed while zooming", () => { + const start = { zoom: 2, panRatio: 0.3 }; + const anchorFraction = 0.25; + const anchorRatio = start.panRatio + anchorFraction / start.zoom; + + const zoomed = zoomViewport(start, 1.5, anchorFraction); + expect(zoomed.zoom).toBe(3); + expect(zoomed.panRatio + anchorFraction / zoomed.zoom).toBeCloseTo(anchorRatio, 10); + + // At the cap nothing moves. + expect(zoomViewport({ zoom: MAX_ZOOM, panRatio: 0.5 }, 1.5)).toEqual({ + zoom: MAX_ZOOM, + panRatio: 0.5, + }); + }); + + it("pans by 20% of the visible window and stops at each end", () => { + const viewport = { zoom: 4, panRatio: 0.4 }; + const { startRatio, endRatio } = getViewportWindow(viewport); + const later = panViewport(viewport, 1); + expect(later.panRatio - viewport.panRatio).toBeCloseTo(PAN_STEP_RATIO * (endRatio - startRatio), 10); + expect(panViewport(viewport, -1).panRatio).toBeCloseTo(0.35, 10); + + expect(panViewport({ zoom: 4, panRatio: 0.74 }, 1).panRatio).toBe(0.75); + expect(panViewport({ zoom: 4, panRatio: 0.01 }, -1).panRatio).toBe(0); + expect(canPanEarlier({ zoom: 4, panRatio: 0 })).toBe(false); + expect(canPanLater({ zoom: 4, panRatio: 0.75 })).toBe(false); + expect(canPanEarlier(DEFAULT_VIEWPORT)).toBe(false); + expect(canPanLater(DEFAULT_VIEWPORT)).toBe(false); + }); + + it("moves one edge at a time, enforcing the minimum window and no crossing", () => { + const minimum = 0.1; + const viewport = viewportFromWindow(0.2, 0.6, minimum); + + const startMoved = moveViewportEdge(viewport, "start", 0.3, minimum); + expect(getViewportWindow(startMoved).startRatio).toBeCloseTo(0.3, 10); + expect(getViewportWindow(startMoved).endRatio).toBeCloseTo(0.6, 10); + + // Dragged past the other handle, the start stops the minimum short of it. + const pastEnd = getViewportWindow(moveViewportEdge(viewport, "start", 0.95, minimum)); + expect(pastEnd.startRatio).toBeCloseTo(0.5, 10); + expect(pastEnd.endRatio).toBeCloseTo(0.6, 10); + + const pastStart = getViewportWindow(moveViewportEdge(viewport, "end", -1, minimum)); + expect(pastStart.startRatio).toBeCloseTo(0.2, 10); + expect(pastStart.endRatio).toBeCloseTo(0.3, 10); + + const pastDomain = getViewportWindow(moveViewportEdge(viewport, "end", 1.4, minimum)); + expect(pastDomain.endRatio).toBe(1); + + // The minimum is never narrower than MAX_ZOOM allows. + expect(getMinimumWindowRatio(1064)).toBeCloseTo(1 / MAX_ZOOM, 10); + expect(getMinimumWindowRatio(64)).toBeCloseTo(8 / 64, 10); + }); + + it("drags and recenters the window without changing its width", () => { + const viewport = { zoom: 5, panRatio: 0.1 }; + expect(moveViewportWindow(viewport, 0.9)).toEqual({ zoom: 5, panRatio: 0.8 }); + expect(centerViewportOn(viewport, 0.5).panRatio).toBeCloseTo(0.4, 10); + expect(centerViewportOn(viewport, 0).panRatio).toBe(0); + }); + + it("handles the slider keys like a multi-thumb slider", () => { + const minimum = 0.1; + const viewport = viewportFromWindow(0.4, 0.6, minimum); + const windowOf = (next) => getViewportWindow(next); + + expect(windowOf(applySliderKey(viewport, "start", "ArrowLeft", { minWindowRatio: minimum })).startRatio).toBeCloseTo(0.35, 10); + expect(windowOf(applySliderKey(viewport, "end", "ArrowRight", { shiftKey: true, minWindowRatio: minimum })).endRatio).toBeCloseTo(0.8, 10); + + const panned = windowOf(applySliderKey(viewport, "window", "ArrowRight", { minWindowRatio: minimum })); + expect(panned.startRatio).toBeCloseTo(0.45, 10); + expect(panned.endRatio).toBeCloseTo(0.65, 10); + + expect(windowOf(applySliderKey(viewport, "window", "Home", { minWindowRatio: minimum })).startRatio).toBe(0); + expect(windowOf(applySliderKey(viewport, "window", "End", { minWindowRatio: minimum })).endRatio).toBeCloseTo(1, 10); + + const endHome = windowOf(applySliderKey(viewport, "end", "Home", { minWindowRatio: minimum })); + expect(endHome.endRatio - endHome.startRatio).toBeCloseTo(minimum, 10); + + // Holding ArrowRight on the start handle never passes the end handle. + let held = viewport; + for (let press = 0; press < 20; press += 1) { + held = applySliderKey(held, "start", "ArrowRight", { minWindowRatio: minimum }); + } + expect(windowOf(held).endRatio - windowOf(held).startRatio).toBeCloseTo(minimum, 10); + expect(windowOf(held).endRatio).toBeCloseTo(0.6, 10); + + expect(applySliderKey(viewport, "start", "Enter", { minWindowRatio: minimum })).toBeNull(); + }); + + it("round-trips between a viewport and its date window", () => { + const viewport = { zoom: 3.2, panRatio: 0.41 }; + const { startDate, endDate } = viewportToDateWindow(viewport, DOMAIN_START, DOMAIN_END); + const roundTripped = dateWindowToViewport(startDate, endDate, DOMAIN_START, DOMAIN_END); + + expect(roundTripped.zoom).toBeCloseTo(viewport.zoom, 6); + expect(roundTripped.panRatio).toBeCloseTo(viewport.panRatio, 6); + expect(viewportToDateWindow(DEFAULT_VIEWPORT, DOMAIN_START, DOMAIN_END)).toEqual({ + startDate: DOMAIN_START, + endDate: DOMAIN_END, + }); + }); + + it("maps the overview window's edges to the detail axis's end dates for any viewport", () => { + const plotLeft = 236; + const plotWidth = 1064; + [DEFAULT_VIEWPORT, { zoom: 2, panRatio: 0.5 }, { zoom: 16, panRatio: 0.9375 }, { zoom: 7.3, panRatio: 0.21 }].forEach( + (viewport) => { + const { startRatio, endRatio } = getViewportWindow(viewport); + const { startDate, endDate } = viewportToDateWindow(viewport, DOMAIN_START, DOMAIN_END); + const detailTicks = resolveTicks(startDate, endDate, plotWidth, plotLeft, 7); + const spanMs = DOMAIN_END.getTime() - DOMAIN_START.getTime(); + const windowLeftDate = DOMAIN_START.getTime() + startRatio * spanMs; + const windowRightDate = DOMAIN_START.getTime() + endRatio * spanMs; + + // A Date holds whole milliseconds, so allow the truncation. + expect(Math.abs(detailTicks[0].date.getTime() - windowLeftDate)).toBeLessThan(1); + expect( + Math.abs(detailTicks[detailTicks.length - 1].date.getTime() - windowRightDate) + ).toBeLessThan(1); + } + ); + }); +}); + +describe("linked timeline domains", () => { + it("snaps a ratio within the snap distance to either end", () => { + const snap = getSnapRatio(600); + expect(snap).toBeCloseTo(6 / 600, 10); + expect(snapRatioToEnds(0.009, snap)).toBe(0); + expect(snapRatioToEnds(0.991, snap)).toBe(1); + expect(snapRatioToEnds(0.02, snap)).toBe(0.02); + expect(snapRatioToEnds(-0.4, snap)).toBe(0); + + // Snapping both edges gives exactly 100%, not "100%" at 1.004×. + const almostFull = moveViewportEdge( + viewportFromWindow(0.3, 1), + "start", + snapRatioToEnds(0.004, snap) + ); + expect(almostFull).toEqual({ zoom: 1, panRatio: 0 }); + }); + + it("covers every valid domain, ignoring missing ones", () => { + const documents = { startDate: new Date(2010, 0, 4), endDate: new Date(2011, 2, 22) }; + const events = { startDate: new Date(2008, 10, 20), endDate: new Date(2011, 3, 20) }; + + expect(unionDateDomains([documents, null, events])).toEqual({ + startDate: events.startDate, + endDate: events.endDate, + }); + expect(unionDateDomains([documents])).toEqual(documents); + expect(unionDateDomains([null, { startDate: "nope" }])).toBeNull(); + expect(sameDateDomain(documents, { ...documents })).toBe(true); + expect(sameDateDomain(documents, events)).toBe(false); + }); + + it("keeps a zoomed reader's dates when the domain widens, and 100% at 100%", () => { + const narrow = { startDate: new Date(2010, 0, 1), endDate: new Date(2011, 0, 1) }; + const wide = { startDate: new Date(2009, 0, 1), endDate: new Date(2011, 0, 1) }; + + const zoomed = { zoom: 4, panRatio: 0.5 }; + const before = viewportToDateWindow(zoomed, narrow.startDate, narrow.endDate); + const rebased = rebaseViewport(zoomed, narrow, wide); + const after = viewportToDateWindow(rebased, wide.startDate, wide.endDate); + expect(after.startDate.getTime()).toBeCloseTo(before.startDate.getTime(), -1); + expect(after.endDate.getTime()).toBeCloseTo(before.endDate.getTime(), -1); + expect(rebased.zoom).toBeGreaterThan(zoomed.zoom); + + expect(rebaseViewport(DEFAULT_VIEWPORT, narrow, wide)).toEqual(DEFAULT_VIEWPORT); + }); +}); + +describe("timeline date labels", () => { + it("prints a UTC-midnight date as its own calendar day when formatted in UTC", () => { + // In any timezone west of UTC, local formatting would print Dec 31, 2009. + const utcMidnight = new Date(Date.UTC(2010, 0, 1)); + expect(formatHandleDate(utcMidnight, { timeZone: "UTC" })).toBe("Jan 1"); + expect(formatHandleDate(utcMidnight, { includeYear: true, timeZone: "UTC" })).toBe("Jan 1, 2010"); + expect(resolveTicks(utcMidnight, new Date(Date.UTC(2010, 1, 1)), 100, 0, 2, { timeZone: "UTC" })[0].label).toBe("Jan 1"); + }); + + it("adds the year only when the domain crosses a calendar year", () => { + expect(spansMultipleYears(new Date(2010, 0, 4), new Date(2011, 2, 20))).toBe(true); + expect(spansMultipleYears(new Date(2010, 0, 4), new Date(2010, 11, 20))).toBe(false); + expect(formatHandleDate(new Date(2010, 10, 12))).toBe("Nov 12"); + expect(formatHandleDate(new Date(Number.NaN))).toBe("Unknown date"); + expect( + describeViewportRange({ zoom: 2, panRatio: 0 }, new Date(2010, 0, 1), new Date(2011, 0, 1)) + ).toMatch(/^Showing Jan 1, 2010 to Jul 2, 2010$/); + }); + + it("always labels both overview ends and drops interior ticks that collide with them", () => { + const ticks = resolveOverviewTicks({ + startDate: DOMAIN_START, + endDate: DOMAIN_END, + plotLeft: 0, + plotWidth: 1000, + tickCount: 7, + }); + expect(ticks[0]).toMatchObject({ label: "Jan 2010", anchor: "start", x: 0 }); + expect(ticks[ticks.length - 1]).toMatchObject({ label: "Mar 2011", anchor: "end", x: 1000 }); + expect(ticks).toHaveLength(7); + + // So narrow that every interior label would touch an end label. + const crowded = resolveOverviewTicks({ + startDate: DOMAIN_START, + endDate: DOMAIN_END, + plotLeft: 0, + plotWidth: 150, + tickCount: 7, + }); + expect(crowded.map((tick) => tick.label)).toEqual(["Jan 2010", "Mar 2011"]); + + const endsOnly = resolveOverviewTicks({ + startDate: DOMAIN_START, + endDate: DOMAIN_END, + plotLeft: 0, + plotWidth: 1000, + tickCount: 7, + endLabelsOnly: true, + }); + expect(endsOnly).toHaveLength(2); + }); + + it("lays out the handle labels: separate, merged, clamped, or hidden", () => { + const base = { + startLabel: "Nov 12, 2010", + endLabel: "Mar 4, 2011", + plotLeft: 100, + plotWidth: 800, + fontSize: 12, + }; + const labelWidth = estimateLabelWidth("Nov 12, 2010", 12); + + expect(layoutHandleLabels({ ...base, startX: 300, endX: 700, zoomed: false })).toEqual({ + visible: false, + merged: false, + labels: [], + }); + + const separate = layoutHandleLabels({ ...base, startX: 300, endX: 700 }); + expect(separate.merged).toBe(false); + expect(separate.labels.map((label) => label.x)).toEqual([300, 700]); + + const merged = layoutHandleLabels({ ...base, startX: 480, endX: 520 }); + expect(merged.merged).toBe(true); + expect(merged.labels).toHaveLength(1); + expect(merged.labels[0].label).toBe("Nov 12, 2010 – Mar 4, 2011"); + expect(merged.labels[0].x).toBe(500); + + const clamped = layoutHandleLabels({ ...base, startX: 100, endX: 900 }); + expect(clamped.labels[0].left).toBeCloseTo(100, 10); + expect(clamped.labels[1].right).toBeLessThanOrEqual(900); + expect(clamped.labels[0].x).toBeCloseTo(100 + labelWidth / 2, 10); + }); + + it("sizes the strip from its row count", () => { + const oneRow = computeOverviewStripLayout({ rowCount: 1 }); + const fourRows = computeOverviewStripLayout({ rowCount: 4 }); + expect(fourRows.bandHeight - oneRow.bandHeight).toBe(3 * (4 + 2)); + expect(fourRows.height - oneRow.height).toBe(18); + expect(oneRow.bandTop).toBe(oneRow.handleOverhang); + expect(oneRow.handleLabelBaseline).toBeGreaterThan(oneRow.bandBottom + oneRow.handleOverhang); + expect(oneRow.axisLabelBaseline).toBeGreaterThan(oneRow.handleLabelBaseline); + expect(computeOverviewStripLayout({ rowCount: 2, compact: true }).miniRowHeight).toBe(5); + }); +}); diff --git a/src/utils/patientView/documentMentions.js b/src/utils/patientView/documentMentions.js index 6df7595..84045d3 100644 --- a/src/utils/patientView/documentMentions.js +++ b/src/utils/patientView/documentMentions.js @@ -103,6 +103,44 @@ export function getDocumentConcepts(document = {}, concepts = []) { }); } +export function findDocumentIdsForConceptIds(documents = [], concepts = [], conceptIds = []) { + const selectedConceptIds = new Set( + normalizeArray(conceptIds) + .map((conceptId) => String(conceptId || "").trim()) + .filter(Boolean) + ); + + if (selectedConceptIds.size === 0) { + return []; + } + + const selectedMentionIds = new Set(); + normalizeArray(concepts).forEach((concept) => { + const conceptId = String(concept?.id || "").trim(); + if (!selectedConceptIds.has(conceptId)) { + return; + } + + normalizeArray(concept?.mentionIds) + .map((mentionId) => String(mentionId || "").trim()) + .filter(Boolean) + .forEach((mentionId) => selectedMentionIds.add(mentionId)); + }); + + if (selectedMentionIds.size === 0) { + return []; + } + + return normalizeArray(documents) + .filter((document) => + normalizeArray(document?.mentions).some((mention) => + selectedMentionIds.has(String(mention?.id || "").trim()) + ) + ) + .map((document) => String(document?.id || "").trim()) + .filter(Boolean); +} + export function buildGroupColorByName(concepts = []) { const groupNames = [...new Set(normalizeArray(concepts).map((concept) => getGroupName(concept)))]; diff --git a/src/utils/patientView/eventRelationTimelineLayout.js b/src/utils/patientView/eventRelationTimelineLayout.js index 5a60f4d..fba785c 100644 --- a/src/utils/patientView/eventRelationTimelineLayout.js +++ b/src/utils/patientView/eventRelationTimelineLayout.js @@ -9,11 +9,15 @@ import { LANE_GROUP_ORDER, LEGEND, MARGINS, - OVERVIEW, + NEGATED_RELATION_COLOR, + OVERVIEW_TOP_GAP, PLOT_RIGHT_GUTTER, + RELATION_COLOR, TIMELINE_PADDING_DAYS, + VIEWBOX_TOP, } from "../../constants/eventRelationTimeline"; import { getAgeOnDate } from "../../controllers/patientDemographics"; +import { computeOverviewStripLayout } from "./timelineViewport"; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -81,12 +85,16 @@ export function computeEventRelationDomain(spans = []) { const times = (spans || []).flatMap((span) => [span.startTime, span.endTime]); if (times.length === 0) { - const now = Date.UTC(2010, 0, 1); + const now = new Date(2010, 0, 1).getTime(); return { startDate: new Date(now), endDate: new Date(now + MS_PER_DAY) }; } - const startDate = new Date(Math.min(...times) - TIMELINE_PADDING_DAYS * MS_PER_DAY); - const endDate = new Date(Math.max(...times) + TIMELINE_PADDING_DAYS * MS_PER_DAY); + // Pad by calendar days (dates are local midnight), so a daylight-saving + // change inside the padding can't move the domain ends off midnight. + const startDate = new Date(Math.min(...times)); + startDate.setDate(startDate.getDate() - TIMELINE_PADDING_DAYS); + const endDate = new Date(Math.max(...times)); + endDate.setDate(endDate.getDate() + TIMELINE_PADDING_DAYS); return { startDate, endDate }; } @@ -101,12 +109,14 @@ export function computeEventRelationTimelineLayout({ domain: suppliedDomain, collapsedGroups = new Set(), showAgeAxis = true, + // Label gutter and right gutter, in px. The alpha's by default; the card + // passes the frame it shares with the document timeline. + plotInsets = { left: MARGINS.left, right: PLOT_RIGHT_GUTTER }, } = {}) { const measuredWidth = Math.round(Number(containerWidth) || 1040); - const svgWidth = Math.max( - 240, - measuredWidth - MARGINS.left - PLOT_RIGHT_GUTTER - ); + const insetLeft = Number(plotInsets?.left ?? MARGINS.left); + const insetRight = Number(plotInsets?.right ?? PLOT_RIGHT_GUTTER); + const svgWidth = Math.max(240, measuredWidth - insetLeft - insetRight); const domain = suppliedDomain || computeEventRelationDomain(spans); const mainX = scaleTime() .domain([domain.startDate, domain.endDate]) @@ -153,17 +163,12 @@ export function computeEventRelationTimelineLayout({ const mainTop = MARGINS.top + LEGEND.height + GAPS.legendToMain; const ageTop = mainTop + totalContentHeight + GAPS.pad; const ageBandHeight = showAgeAxis ? AGE_AREA.height + AGE_AREA.bottomPad : 0; - const overviewTop = ageTop + ageBandHeight; - const svgTotalHeight = - MARGINS.top + - LEGEND.height + - GAPS.legendToMain + - totalContentHeight + - GAPS.pad + - OVERVIEW.height + - GAPS.pad + - (showAgeAxis ? AGE_AREA.height : 0) + - MARGINS.bottom; + const overviewTop = ageTop + ageBandHeight + OVERVIEW_TOP_GAP; + // The overview strip has one mini-row per drawn lane group. + const overview = computeOverviewStripLayout({ rowCount: groups.length }); + // The viewBox starts VIEWBOX_TOP down (the legend is HTML above the SVG), so + // the drawable height runs from there to the bottom of the overview strip. + const svgTotalHeight = overviewTop + overview.height + MARGINS.bottom - VIEWBOX_TOP; // Unique event dates get a dashed vertical guideline across every lane. const uniqueDates = [ @@ -180,14 +185,16 @@ export function computeEventRelationTimelineLayout({ // The plot has a minimum width, so on a narrow container the drawn area // is wider than the container. The viewBox must describe the content, not // the container, or the right-hand end of the chart is clipped away. - viewBoxWidth: MARGINS.left + svgWidth + PLOT_RIGHT_GUTTER, + viewBoxWidth: insetLeft + svgWidth + insetRight, + viewBoxTop: VIEWBOX_TOP, svgWidth, svgTotalHeight, totalContentHeight, - marginLeft: MARGINS.left, + marginLeft: insetLeft, mainTop, ageTop, overviewTop, + overviewHeight: overview.height, showAgeAxis, }, groups, @@ -196,6 +203,23 @@ export function computeEventRelationTimelineLayout({ }; } +/** + * Overview-strip rows for the event timeline: one row per drawn lane group, in + * chart order, with each merged span at its full-domain (`mainX`) position. + * Collapsed groups keep their row. + */ +export function buildEventRelationOverviewRows(groups = []) { + return (groups || []).map((group) => ({ + key: group.key, + marks: (group.spans || []).map((span) => ({ + key: span.id, + x1: span.x1, + x2: span.x2, + color: span.negated ? NEGATED_RELATION_COLOR : RELATION_COLOR, + })), + })); +} + /** * Alpha `drawLanes`: which end caps a relation pair gets. `kind: "tick"` is the * same-day On/On case the alpha draws as a bare vertical stroke rather than a @@ -371,11 +395,9 @@ export function computeEventRelationAgeAxis(domain, xScale, birthDate = null) { const interiors = []; for (let age = startAge + 1; age <= endAge; age += 1) { const birthday = new Date( - Date.UTC( - birthDate.getUTCFullYear() + age, - birthDate.getUTCMonth(), - birthDate.getUTCDate() - ) + birthDate.getFullYear() + age, + birthDate.getMonth(), + birthDate.getDate() ); if (birthday > domain.startDate && birthday < domain.endDate) { diff --git a/src/utils/patientView/timelineChartLayout.js b/src/utils/patientView/timelineChartLayout.js index 9f719f5..06975e7 100644 --- a/src/utils/patientView/timelineChartLayout.js +++ b/src/utils/patientView/timelineChartLayout.js @@ -90,7 +90,22 @@ function formatDateTimeLabel(date) { return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`; } -function formatTickLabel(date, dateRangeDays) { +// Below this many pixels per tick, labels start to crowd each other. +export const MIN_TICK_SPACING = 150; + +export function resolveResponsiveTickCount(plotWidth, maxTickCount = 7) { + const numericPlotWidth = Math.max(0, Number(plotWidth) || 0); + const numericMaxTickCount = Math.max(2, Number(maxTickCount) || 7); + return Math.min( + numericMaxTickCount, + Math.max(2, Math.floor(numericPlotWidth / MIN_TICK_SPACING) + 1) + ); +} + +// `timeZone` must match how the timeline built its dates: document dates are +// local (`new Date(year, month, day)`), event dates are UTC midnight. Formatting +// a UTC-midnight date in local time prints the previous day across the Americas. +export function formatTickLabel(date, dateRangeDays, { timeZone } = {}) { if (!(date instanceof Date) || Number.isNaN(date.getTime())) { return ""; } @@ -98,11 +113,11 @@ function formatTickLabel(date, dateRangeDays) { const options = dateRangeDays >= 370 ? { month: "short", year: "numeric" } - : dateRangeDays >= 120 - ? { month: "short", day: "numeric" } - : { month: "short", day: "numeric" }; + : { month: "short", day: "numeric" }; - return new Intl.DateTimeFormat("en-US", options).format(date); + return new Intl.DateTimeFormat("en-US", timeZone ? { ...options, timeZone } : options).format( + date + ); } function formatDateKey(date) { @@ -219,7 +234,14 @@ function resolveEpisodeLegend(reports, episodeCounts = {}) { .filter((episode) => episode.count > 0); } -export function resolveTicks(startDate, endDate, width, plotLeft, tickCount = 7) { +export function resolveTicks( + startDate, + endDate, + width, + plotLeft, + tickCount = 7, + { timeZone } = {} +) { if (!(startDate instanceof Date) || !(endDate instanceof Date)) { return []; } @@ -237,11 +259,37 @@ export function resolveTicks(startDate, endDate, width, plotLeft, tickCount = 7) return { date, x: plotLeft + plotWidth * ratio, - label: formatTickLabel(date, dateRangeDays), + label: formatTickLabel(date, dateRangeDays, { timeZone }), }; }); } +/** + * Overview-strip rows for the document timeline: one row per report type, in + * lane order, with a zero-width mark per dated document at its full-domain x. + * `stroke` outlines each mark like the detail dots, so pale episode colors + * stay visible on the strip. + */ +export function buildDocumentOverviewRows(rows = [], points = [], { stroke } = {}) { + return (rows || []).map((row) => ({ + key: row.type, + marks: (points || []) + .filter( + (point) => + point.type === row.type && + point.dateObject instanceof Date && + !Number.isNaN(point.dateObject.getTime()) + ) + .map((point) => ({ + key: point.id, + x1: point.x, + x2: point.x, + color: point.episodeColor, + ...(stroke ? { stroke } : {}), + })), + })); +} + function buildPointLayout({ reports, reportTypes, dimensions, dateDomain }) { const rowByType = new Map( reportTypes.map((reportType, index) => [ @@ -333,7 +381,41 @@ function buildPointLayout({ reports, reportTypes, dimensions, dateDomain }) { }; } +function resolveReportDate(report) { + return parseDocumentDate(report?.timelineDate || report?.date) || parseDocumentDate(report?.date); +} + +/** + * The document timeline's own date domain: its dated documents padded by + * `datePaddingDays`. Null whenever the chart isn't drawn (no dated documents, + * or every document sharing one timestamp), so a linked timeline never widens + * the shared domain for a chart nobody can see. + */ +export function resolveTimelineDateDomain( + timelineData = {}, + { datePaddingDays = DEFAULT_DIMENSIONS.datePaddingDays } = {} +) { + const reportsWithIds = (Array.isArray(timelineData?.reportData) ? timelineData.reportData : []) + .filter((report) => normalizeString(report?.id)); + const datedReports = reportsWithIds + .map((report) => ({ dateObject: resolveReportDate(report) })) + .filter((report) => report.dateObject instanceof Date && !Number.isNaN(report.dateObject.getTime())); + const uniqueTimestamps = new Set(datedReports.map((report) => report.dateObject.getTime())); + + // Mirrors buildTimelineChartModel's `hasDateCollapse`. + if (datedReports.length === 0 || (reportsWithIds.length > 1 && uniqueTimestamps.size === 1)) { + return null; + } + return resolveDateDomain(datedReports, datePaddingDays); +} + +/** + * `options` holds the layout dimensions, plus an optional `dateDomain` that + * replaces the documents' own domain (used when the timeline is linked to the + * Event Timeline and both share one). + */ export function buildTimelineChartModel(timelineData = {}, options = {}) { + const { dateDomain: dateDomainOverride, ...layoutOptions } = options || {}; const reportData = Array.isArray(timelineData?.reportData) ? timelineData.reportData : []; const reports = reportData @@ -343,18 +425,17 @@ export function buildTimelineChartModel(timelineData = {}, options = {}) { return null; } - const dateObject = parseDocumentDate(report?.date); - const timelineDateObject = parseDocumentDate(report?.timelineDate || report?.date); const episodeLabel = normalizeEpisodeLabel(report?.episode); + const resolvedDate = resolveReportDate(report); return { id, type: normalizeString(report?.type, "Unknown"), name: normalizeString(report?.name, id), formattedDate: normalizeString(report?.formattedDate), - dateObject: timelineDateObject || dateObject, - dateLabel: formatDateLabel(timelineDateObject || dateObject), - dateTimeLabel: formatDateTimeLabel(timelineDateObject || dateObject), + dateObject: resolvedDate, + dateLabel: formatDateLabel(resolvedDate), + dateTimeLabel: formatDateTimeLabel(resolvedDate), episodeLabel, episodeColor: EPISODE_COLORS[episodeLabel] || "#9E9E9E", dateSource: normalizeString(report?.timelineDateSource, "date"), @@ -384,18 +465,27 @@ export function buildTimelineChartModel(timelineData = {}, options = {}) { }); const reportTypes = resolveReportTypes(reports, timelineData?.reportTypes); - const dateDomain = resolveDateDomain( - reports, - Number(options.datePaddingDays || DEFAULT_DIMENSIONS.datePaddingDays) - ); + const hasDomainOverride = + dateDomainOverride?.startDate instanceof Date && + dateDomainOverride?.endDate instanceof Date && + dateDomainOverride.endDate.getTime() > dateDomainOverride.startDate.getTime(); + const dateDomain = hasDomainOverride + ? { + startDate: new Date(dateDomainOverride.startDate.getTime()), + endDate: new Date(dateDomainOverride.endDate.getTime()), + } + : resolveDateDomain( + reports, + Number(layoutOptions.datePaddingDays || DEFAULT_DIMENSIONS.datePaddingDays) + ); const dimensions = { ...DEFAULT_DIMENSIONS, - ...options, + ...layoutOptions, plotWidth: - Number(options?.svgWidth || DEFAULT_DIMENSIONS.svgWidth) - - Number(options?.plotLeft || DEFAULT_DIMENSIONS.plotLeft) - - Number(options?.plotRight || DEFAULT_DIMENSIONS.plotRight), + Number(layoutOptions.svgWidth || DEFAULT_DIMENSIONS.svgWidth) - + Number(layoutOptions.plotLeft || DEFAULT_DIMENSIONS.plotLeft) - + Number(layoutOptions.plotRight || DEFAULT_DIMENSIONS.plotRight), }; const { points, rows } = buildPointLayout({ diff --git a/src/utils/patientView/timelineViewport.js b/src/utils/patientView/timelineViewport.js new file mode 100644 index 0000000..8e5d865 --- /dev/null +++ b/src/utils/patientView/timelineViewport.js @@ -0,0 +1,497 @@ +// Shared zoom, pan and overview-strip math for the patient timelines (the +// Patient Document Timeline and the Event Timeline). +// +// A viewport is `{ zoom, panRatio }`. `zoom` 1 shows the whole date domain; +// `panRatio` is the visible window's left edge as a fraction of that domain. +// At a given zoom the window is 1/zoom wide, so the left edge can travel from 0 +// to 1 - 1/zoom. Everything here works in domain ratios (0..1), which lets one +// implementation drive both timelines whatever their pixel offsets. +// +// Pure functions only: no React, no DOM. + +import { resolveTicks } from "./timelineChartLayout"; + +export const MIN_ZOOM = 1; +export const MAX_ZOOM = 16; +export const ZOOM_STEP = 1.5; +// Pan buttons and the ←/→ keys move the window by this fraction of its width. +export const PAN_STEP_RATIO = 0.2; +// Smallest selection window in SVG units; MAX_ZOOM takes over on wide plots. +export const SLIDER_MIN_WIDTH = 8; +// Handle and window keyboard steps, as a fraction of the full domain. +export const SLIDER_KEY_STEP_RATIO = 0.05; +export const SLIDER_KEY_LARGE_STEP_RATIO = 0.2; +// A handle or window edge dragged this close (in SVG units) to either end of +// the strip snaps to it. Without it a handle released a few pixels short reads +// "100%" while still being zoomed. +export const SNAP_DISTANCE_PX = 6; + +const EPSILON = 1e-6; + +export const DEFAULT_VIEWPORT = Object.freeze({ zoom: MIN_ZOOM, panRatio: 0 }); + +export function clampZoom(zoom) { + if (!Number.isFinite(zoom)) { + return MIN_ZOOM; + } + return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom)); +} + +export function clampPanRatio(panRatio, zoom) { + const maxPan = Math.max(0, 1 - 1 / clampZoom(zoom)); + if (!Number.isFinite(panRatio)) { + return 0; + } + return Math.min(maxPan, Math.max(0, panRatio)); +} + +export function normalizeViewport(viewport) { + const zoom = clampZoom(Number(viewport?.zoom)); + return { zoom, panRatio: clampPanRatio(Number(viewport?.panRatio), zoom) }; +} + +export function viewportsEqual(left, right) { + return ( + Math.abs((left?.zoom ?? NaN) - (right?.zoom ?? NaN)) < EPSILON && + Math.abs((left?.panRatio ?? NaN) - (right?.panRatio ?? NaN)) < EPSILON + ); +} + +export function isViewportZoomed(viewport) { + return normalizeViewport(viewport).zoom > MIN_ZOOM + EPSILON; +} + +export function canZoomIn(viewport) { + return normalizeViewport(viewport).zoom < MAX_ZOOM - EPSILON; +} + +export function canPanEarlier(viewport) { + const { zoom, panRatio } = normalizeViewport(viewport); + return zoom > MIN_ZOOM + EPSILON && panRatio > EPSILON; +} + +export function canPanLater(viewport) { + const { zoom, panRatio } = normalizeViewport(viewport); + return zoom > MIN_ZOOM + EPSILON && panRatio < 1 - 1 / zoom - EPSILON; +} + +/** The visible window as start and end ratios of the full domain. */ +export function getViewportWindow(viewport) { + const { zoom, panRatio } = normalizeViewport(viewport); + return { startRatio: panRatio, endRatio: Math.min(1, panRatio + 1 / zoom) }; +} + +/** Narrowest window allowed on a plot this wide, as a domain ratio. */ +export function getMinimumWindowRatio(plotWidth) { + const width = Number(plotWidth); + const pixelRatio = width > 0 ? SLIDER_MIN_WIDTH / width : 0; + return Math.min(1, Math.max(1 / MAX_ZOOM, pixelRatio)); +} + +function resolveMinimumWindowRatio(minWindowRatio) { + const ratio = Number(minWindowRatio); + return Math.min(1, Math.max(1 / MAX_ZOOM, Number.isFinite(ratio) ? ratio : 0)); +} + +/** The viewport that shows [startRatio, endRatio], kept inside the domain. */ +export function viewportFromWindow(startRatio, endRatio, minWindowRatio) { + const start = Number(startRatio); + const end = Number(endRatio); + if (!Number.isFinite(start) || !Number.isFinite(end)) { + return { ...DEFAULT_VIEWPORT }; + } + + const width = Math.min(1, Math.max(resolveMinimumWindowRatio(minWindowRatio), end - start)); + // Floating-point window edges like 0.3 + 0.7 can land a hair short of 1. + if (width >= 1 - EPSILON) { + return { ...DEFAULT_VIEWPORT }; + } + const zoom = clampZoom(1 / width); + return { zoom, panRatio: clampPanRatio(Math.min(1 - width, Math.max(0, start)), zoom) }; +} + +/** + * Zoom by `factor`, keeping the date at `anchorFraction` of the visible plot + * (0 = left edge, 1 = right edge) in place. Defaults to the plot center. + */ +export function zoomViewport(viewport, factor, anchorFraction = 0.5) { + const current = normalizeViewport(viewport); + const nextZoom = clampZoom(current.zoom * Number(factor)); + const anchor = Number.isFinite(anchorFraction) ? Math.min(1, Math.max(0, anchorFraction)) : 0.5; + const anchorRatio = current.panRatio + anchor / current.zoom; + return { + zoom: nextZoom, + panRatio: clampPanRatio(anchorRatio - anchor / nextZoom, nextZoom), + }; +} + +/** Move the window by PAN_STEP_RATIO of its own width; -1 is earlier. */ +export function panViewport(viewport, direction, stepRatio = PAN_STEP_RATIO) { + const current = normalizeViewport(viewport); + return { + zoom: current.zoom, + panRatio: clampPanRatio( + current.panRatio + (Math.sign(direction) * stepRatio) / current.zoom, + current.zoom + ), + }; +} + +/** Move the window's left edge to `startRatio`, keeping its width. */ +export function moveViewportWindow(viewport, startRatio) { + const current = normalizeViewport(viewport); + return { zoom: current.zoom, panRatio: clampPanRatio(startRatio, current.zoom) }; +} + +/** Snaps a ratio within `snapRatio` of either end of the domain to that end. */ +export function snapRatioToEnds(ratio, snapRatio) { + const value = Number(ratio); + const threshold = Math.max(0, Number(snapRatio) || 0); + if (value <= threshold) { + return 0; + } + if (value >= 1 - threshold) { + return 1; + } + return value; +} + +/** The snap distance as a domain ratio, for a plot this wide. */ +export function getSnapRatio(plotWidth) { + const width = Number(plotWidth); + return width > 0 ? SNAP_DISTANCE_PX / width : 0; +} + +/** Recenter the window on `ratio`, keeping its width. */ +export function centerViewportOn(viewport, ratio) { + const current = normalizeViewport(viewport); + return moveViewportWindow(current, Number(ratio) - 1 / (2 * current.zoom)); +} + +/** + * Move one edge of the window and leave the other where it is. The moving edge + * stops `minWindowRatio` short of the other one, so the handles never cross. + */ +export function moveViewportEdge(viewport, edge, ratio, minWindowRatio) { + const minWidth = resolveMinimumWindowRatio(minWindowRatio); + const { startRatio, endRatio } = getViewportWindow(viewport); + const target = Number.isFinite(Number(ratio)) ? Number(ratio) : edge === "end" ? endRatio : startRatio; + + if (edge === "start") { + const nextStart = Math.min(Math.max(0, endRatio - minWidth), Math.max(0, target)); + return viewportFromWindow(nextStart, endRatio, minWidth); + } + + const nextEnd = Math.max(Math.min(1, startRatio + minWidth), Math.min(1, target)); + return viewportFromWindow(startRatio, nextEnd, minWidth); +} + +/** + * Keyboard behavior of the overview strip's three sliders (`start`, `end` and + * the `window` between them). Returns the next viewport, or null for keys the + * sliders don't handle. + */ +export function applySliderKey(viewport, mode, key, { shiftKey = false, minWindowRatio } = {}) { + const minWidth = resolveMinimumWindowRatio(minWindowRatio); + const step = shiftKey ? SLIDER_KEY_LARGE_STEP_RATIO : SLIDER_KEY_STEP_RATIO; + const { startRatio, endRatio } = getViewportWindow(viewport); + const width = endRatio - startRatio; + + switch (key) { + case "ArrowLeft": + case "ArrowRight": { + const delta = key === "ArrowLeft" ? -step : step; + if (mode === "start") { + return moveViewportEdge(viewport, "start", startRatio + delta, minWidth); + } + if (mode === "end") { + return moveViewportEdge(viewport, "end", endRatio + delta, minWidth); + } + return moveViewportWindow(viewport, startRatio + delta); + } + case "Home": + if (mode === "end") { + return moveViewportEdge(viewport, "end", startRatio + minWidth, minWidth); + } + return moveViewportWindow(viewport, 0); + case "End": + if (mode === "start") { + return moveViewportEdge(viewport, "start", endRatio - minWidth, minWidth); + } + return moveViewportWindow(viewport, 1 - width); + default: + return null; + } +} + +function toMs(date) { + const ms = date instanceof Date ? date.getTime() : Number(date); + return Number.isFinite(ms) ? ms : NaN; +} + +export function ratioToDate(ratio, domainStart, domainEnd) { + const startMs = toMs(domainStart); + const spanMs = Math.max(1, toMs(domainEnd) - startMs); + return new Date(startMs + Number(ratio) * spanMs); +} + +export function dateToRatio(date, domainStart, domainEnd) { + const startMs = toMs(domainStart); + const spanMs = Math.max(1, toMs(domainEnd) - startMs); + return (toMs(date) - startMs) / spanMs; +} + +/** The dates at the visible window's left and right edges. */ +export function viewportToDateWindow(viewport, domainStart, domainEnd) { + const { startRatio, endRatio } = getViewportWindow(viewport); + return { + startDate: ratioToDate(startRatio, domainStart, domainEnd), + endDate: ratioToDate(endRatio, domainStart, domainEnd), + }; +} + +export function dateWindowToViewport(startDate, endDate, domainStart, domainEnd, minWindowRatio) { + return viewportFromWindow( + dateToRatio(startDate, domainStart, domainEnd), + dateToRatio(endDate, domainStart, domainEnd), + minWindowRatio + ); +} + +/** + * The smallest date domain covering every given `{ startDate, endDate }`, or + * null when none is valid. Linked timelines share it so the same date lands at + * the same x in each. + */ +export function unionDateDomains(domains = []) { + let startMs = Infinity; + let endMs = -Infinity; + (domains || []).forEach((domain) => { + const domainStartMs = toMs(domain?.startDate); + const domainEndMs = toMs(domain?.endDate); + if (Number.isFinite(domainStartMs) && Number.isFinite(domainEndMs)) { + startMs = Math.min(startMs, domainStartMs); + endMs = Math.max(endMs, domainEndMs); + } + }); + if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) { + return null; + } + return { startDate: new Date(startMs), endDate: new Date(endMs) }; +} + +export function sameDateDomain(left, right) { + return ( + toMs(left?.startDate) === toMs(right?.startDate) && toMs(left?.endDate) === toMs(right?.endDate) + ); +} + +/** + * Carries a viewport across a domain change. A reader at 100% stays at 100% of + * the new domain; a zoomed reader keeps the same dates on screen, clamped to the + * new domain. + */ +export function rebaseViewport(viewport, fromDomain, toDomain) { + if (!isViewportZoomed(viewport) || !fromDomain || !toDomain) { + return { ...DEFAULT_VIEWPORT }; + } + const { startDate, endDate } = viewportToDateWindow( + viewport, + fromDomain.startDate, + fromDomain.endDate + ); + return dateWindowToViewport(startDate, endDate, toDomain.startDate, toDomain.endDate); +} + +export function ratioToX(ratio, plotLeft, plotWidth) { + return Number(plotLeft) + Number(ratio) * Number(plotWidth); +} + +export function xToRatio(x, plotLeft, plotWidth) { + const width = Number(plotWidth); + return width > 0 ? (Number(x) - Number(plotLeft)) / width : 0; +} + +// Dates --------------------------------------------------------------------- + +function isValidDate(date) { + return date instanceof Date && !Number.isNaN(date.getTime()); +} + +function formatDate(date, options, timeZone) { + return new Intl.DateTimeFormat("en-US", timeZone ? { ...options, timeZone } : options).format( + date + ); +} + +/** True when the two dates fall in different calendar years in `timeZone`. */ +export function spansMultipleYears(startDate, endDate, timeZone) { + if (!isValidDate(startDate) || !isValidDate(endDate)) { + return false; + } + return ( + formatDate(startDate, { year: "numeric" }, timeZone) !== + formatDate(endDate, { year: "numeric" }, timeZone) + ); +} + +/** + * Handle labels are `MMM D`, plus the year when the domain crosses a calendar + * year: `Jan 27` alone is ambiguous on a 2010–2011 timeline. Not + * `formatTickLabel`, which drops the day on ranges of a year or more. + */ +export function formatHandleDate(date, { includeYear = false, timeZone } = {}) { + if (!isValidDate(date)) { + return "Unknown date"; + } + const options = includeYear + ? { month: "short", day: "numeric", year: "numeric" } + : { month: "short", day: "numeric" }; + return formatDate(date, options, timeZone); +} + +/** `Showing Nov 12, 2010 to Mar 4, 2011`, for the range live region. */ +export function describeViewportRange(viewport, domainStart, domainEnd, { timeZone } = {}) { + const { startDate, endDate } = viewportToDateWindow(viewport, domainStart, domainEnd); + const includeYear = spansMultipleYears(domainStart, domainEnd, timeZone); + return `Showing ${formatHandleDate(startDate, { includeYear, timeZone })} to ${formatHandleDate( + endDate, + { includeYear, timeZone } + )}`; +} + +// Labels -------------------------------------------------------------------- + +export const LABEL_GAP = 8; +const LABEL_EDGE_INSET = 6; + +/** Rough rendered width of a label. SVG text can't be measured in jsdom. */ +export function estimateLabelWidth(label, fontSize = 12) { + return String(label ?? "").length * Number(fontSize) * 0.58; +} + +/** + * Fixed overview-axis ticks. Both domain ends are always labeled; an interior + * tick is dropped when its label would run into either end label. + */ +export function resolveOverviewTicks({ + startDate, + endDate, + plotLeft, + plotWidth, + tickCount = 5, + timeZone, + fontSize = 12, + endLabelsOnly = false, +}) { + const ticks = resolveTicks(startDate, endDate, plotWidth, plotLeft, Math.max(2, tickCount), { + timeZone, + }); + if (ticks.length < 2) { + return ticks; + } + + const first = ticks[0]; + const last = ticks[ticks.length - 1]; + const firstLabelRight = first.x + LABEL_EDGE_INSET + estimateLabelWidth(first.label, fontSize); + const lastLabelLeft = last.x - LABEL_EDGE_INSET - estimateLabelWidth(last.label, fontSize); + const interior = endLabelsOnly + ? [] + : ticks.slice(1, -1).filter((tick) => { + const halfWidth = estimateLabelWidth(tick.label, fontSize) / 2; + return ( + tick.x - halfWidth >= firstLabelRight + LABEL_GAP && + tick.x + halfWidth <= lastLabelLeft - LABEL_GAP + ); + }); + + return [ + { ...first, anchor: "start" }, + ...interior.map((tick) => ({ ...tick, anchor: "middle" })), + { ...last, anchor: "end" }, + ]; +} + +/** + * One label centered under each handle, clamped inside the plot. When the two + * would overlap they merge into one range label centered under the window. + * Hidden at 100% zoom, where they would repeat the fixed axis's end labels. + */ +export function layoutHandleLabels({ + startX, + endX, + startLabel, + endLabel, + plotLeft, + plotWidth, + fontSize = 12, + zoomed = true, +}) { + if (!zoomed) { + return { visible: false, merged: false, labels: [] }; + } + + const plotRight = plotLeft + plotWidth; + const place = (key, centerX, label) => { + const width = estimateLabelWidth(label, fontSize); + const halfWidth = width / 2; + const x = + width >= plotWidth + ? plotLeft + plotWidth / 2 + : Math.min(plotRight - halfWidth, Math.max(plotLeft + halfWidth, centerX)); + return { key, label, x, left: x - halfWidth, right: x + halfWidth }; + }; + + const start = place("start", startX, startLabel); + const end = place("end", endX, endLabel); + if (start.right + LABEL_GAP <= end.left) { + return { visible: true, merged: false, labels: [start, end] }; + } + + return { + visible: true, + merged: true, + labels: [place("range", (startX + endX) / 2, `${startLabel} – ${endLabel}`)], + }; +} + +// Strip geometry ------------------------------------------------------------ + +/** + * Vertical layout of the overview strip, top to bottom, relative to its top: + * handle overhang, the miniature band (one mini-row per lane), handle overhang, + * the handle date labels, then the fixed axis. Both timelines size their SVG + * from `height`. + */ +export function computeOverviewStripLayout({ rowCount = 1, compact = false } = {}) { + const rows = Math.max(1, Math.round(Number(rowCount) || 1)); + const miniRowHeight = compact ? 5 : 4; + const miniRowGap = 2; + const handleOverhang = compact ? 5 : 4; + const bandTop = handleOverhang; + const bandHeight = rows * miniRowHeight + (rows - 1) * miniRowGap; + const bandBottom = bandTop + bandHeight; + const handleLabelRowTop = bandBottom + handleOverhang; + const handleLabelBaseline = handleLabelRowTop + 11; + const axisLineY = handleLabelRowTop + 14 + 2; + const axisLabelBaseline = axisLineY + 14; + + return { + rowCount: rows, + miniRowHeight, + miniRowGap, + handleOverhang, + bandTop, + bandHeight, + bandBottom, + handleLabelBaseline, + axisLineY, + axisTickSize: 3, + axisLabelBaseline, + height: axisLabelBaseline + 4, + }; +} + +export function getMiniRowY(layout, rowIndex) { + return layout.bandTop + rowIndex * (layout.miniRowHeight + layout.miniRowGap); +} diff --git a/src/views/__tests__/filterLayout.test.js b/src/views/__tests__/filterLayout.test.js index 60383f9..1ffd7db 100644 --- a/src/views/__tests__/filterLayout.test.js +++ b/src/views/__tests__/filterLayout.test.js @@ -3,16 +3,70 @@ import { buildTallestAlignedLayout, estimateCardHeight, } from "../filterLayout"; -import { OVERSIZED_MIN_ROWS_BY_DENSITY, getOversizedRowThreshold } from "../filters/layoutConfig"; +import { + OVERSIZED_MIN_ROWS_BY_DENSITY, + buildBalancedMasonryLayout, + getOversizedRowThreshold, +} from "../filters/layoutConfig"; describe("filterLayout helpers", () => { + it("globally balances outer domains while keeping the priority row anchored", () => { + const layout = buildBalancedMasonryLayout( + [390, 606, 914, 1296, 482, 914, 698, 698, 914].map((height, index) => ({ + id: String(index), + height, + })), + 3, + 16 + ); + + expect([layout.columnById["0"], layout.columnById["1"], layout.columnById["2"]]).toEqual([ + 1, 2, 3, + ]); + expect(layout.columnById).toEqual({ + 0: 1, + 1: 2, + 2: 3, + 3: 1, + 4: 3, + 5: 2, + 6: 1, + 7: 2, + 8: 3, + }); + expect(Math.max(...layout.columnHeights) - Math.min(...layout.columnHeights)).toBe(166); + }); + + it("keeps the naturally balanced two-column clinical sequence", () => { + const layout = buildBalancedMasonryLayout( + [390, 606, 914, 1296, 482, 914, 698, 698, 914].map((height, index) => ({ + id: String(index), + height, + })), + 2, + 16 + ); + + expect(layout.columnById).toEqual({ + 0: 1, + 1: 2, + 2: 1, + 3: 2, + 4: 1, + 5: 2, + 6: 1, + 7: 2, + 8: 1, + }); + }); + it("estimates card height from row count", () => { expect(estimateCardHeight(3)).toBe(228); expect(estimateCardHeight(2, 20, 80)).toBe(120); expect(estimateCardHeight(-5, 20, 80)).toBe(80); }); - it("stretches shorter solo measured cards to align with the tallest column", () => { + it("keeps shorter solo measured cards at their natural height", () => { const layout = buildTallestAlignedLayout( ["A", "B", "C"], { A: 120, B: 120, C: 120 }, @@ -23,7 +77,7 @@ describe("filterLayout helpers", () => { expect(layout.tallestFilterBoxHeight).toBe(120); expect(layout.tallestMeasuredFilterBoxHeight).toBe(160); - expect(layout.cardHeightOverrideByClass).toEqual({ B: 160, C: 160 }); + expect(layout.cardHeightOverrideByClass).toEqual({}); expect(layout.cardMarginBottomByClass).toEqual({ A: 0, B: 0, C: 0 }); }); @@ -41,8 +95,8 @@ describe("filterLayout helpers", () => { expect(layout.baseCardHeightByClass).toEqual({ A: 200, B: 180, C: 150 }); expect(layout.measuredCardHeightByClass).toEqual({ A: 200, B: 180, C: 150 }); - expect(layout.resolvedCardHeightByClass).toEqual({ A: 354, B: 180, C: 150 }); - expect(layout.cardHeightOverrideByClass).toEqual({ A: 354 }); + expect(layout.resolvedCardHeightByClass).toEqual({ A: 200, B: 180, C: 150 }); + expect(layout.cardHeightOverrideByClass).toEqual({}); expect(layout.cardMarginBottomByClass).toEqual({ A: 0, B: 24, C: 0 }); expect(layout.sectionHeight).toBe(378); }); @@ -63,7 +117,7 @@ describe("filterLayout helpers", () => { expect(layout.baseCardHeightByClass).toEqual({ A: 250, B: 90 }); expect(layout.measuredCardHeightByClass).toEqual({ A: 0, B: 0 }); - expect(layout.cardHeightOverrideByClass).toEqual({ B: 250 }); + expect(layout.cardHeightOverrideByClass).toEqual({}); expect(layout.cardMarginBottomByClass).toEqual({ A: 0, B: 0 }); expect(layout.sectionHeight).toBe(270); }); @@ -166,6 +220,40 @@ describe("filterLayout helpers", () => { expect(longColumn).toEqual(["LongList"]); }); + it("does not inflate short cards when an oversized sibling activates wrapper packing", () => { + const layout = buildFilterSectionLayout({ + classNames: ["LongList", "FourRows", "TwoRows"], + rowCountByClass: { LongList: 25, FourRows: 4, TwoRows: 2 }, + measuredCardHeightByClass: { + LongList: 212, + FourRows: 172, + TwoRows: 112, + }, + desiredCardHeightByClass: { + LongList: 804, + FourRows: 172, + TwoRows: 112, + }, + naturalGapPx: 16, + maxColumns: 3, + cardBottomMargin: 24, + stackableCardMaxHeight: 212, + oversizedRowThreshold: 24, + }); + + expect(layout.columnGroups).toEqual([ + ["LongList"], + ["FourRows"], + ["TwoRows"], + ]); + expect(layout.cardHeightOverrideByClass).toEqual({}); + expect(layout.resolvedCardHeightByClass).toEqual({ + LongList: 212, + FourRows: 172, + TwoRows: 112, + }); + }); + it("does not dedicate a column to a card with only 24 rows (boundary)", () => { // Same shapes as above but one row fewer: not oversized, so the height // packer is free to pair the short LongList card with a sibling. @@ -201,7 +289,7 @@ describe("filterLayout helpers", () => { expect(compactMin - 1 > threshold).toBe(false); // one fewer does not }); - it("keeps demographics column bottoms aligned so Ethnicity ends level with Race", () => { + it("keeps demographics cards content-sized with only the rendered gap", () => { const layout = buildFilterSectionLayout({ classNames: ["AGE_AT_DX", "RACE", "GENDER", "ETHNICITY"], baseCardHeightByClass: { @@ -242,10 +330,10 @@ describe("filterLayout helpers", () => { ) ); - expect(columnHeights).toEqual([516, 516, 516]); - expect(layout.resolvedCardHeightByClass.AGE_AT_DX).toBe(516); + expect(columnHeights).toEqual([480, 516, 480]); + expect(layout.resolvedCardHeightByClass.AGE_AT_DX).toBe(480); expect(layout.resolvedCardHeightByClass.RACE).toBe(516); - expect(layout.cardMarginBottomByClass.GENDER).toBe(60); + expect(layout.cardMarginBottomByClass.GENDER).toBe(24); expect(layout.cardMarginBottomByClass.ETHNICITY).toBe(0); }); @@ -306,7 +394,7 @@ describe("filterLayout helpers", () => { expect(layout.columnGroups).toEqual([["A"], ["B", "C"]]); }); - it("equalizes column heights for mixed solo and multi-card columns", () => { + it("keeps mixed solo and multi-card columns at natural heights", () => { const layout = buildFilterSectionLayout({ classNames: ["A", "B", "C", "D", "E"], measuredCardHeightByClass: { @@ -334,7 +422,7 @@ describe("filterLayout helpers", () => { ); const columnHeights = layout.columnGroups.map(getColumnHeight); - expect(columnHeights).toEqual([624, 624, 624]); + expect(columnHeights).toEqual([520, 624, 484]); expect(layout.sectionHeight).toBe(648); }); @@ -370,8 +458,9 @@ describe("filterLayout helpers", () => { ["Histologic Features"], ["Pathologic Process"], ]); - expect(layout.cardHeightOverrideByClass["Histologic Features"]).toBe(464); - expect(layout.cardHeightOverrideByClass["Pathologic Process"]).toBe(464); + expect(layout.cardHeightOverrideByClass).toEqual({}); + expect(layout.scrollableCardStretchByClass["Histologic Features"]).toBe(464); + expect(layout.scrollableCardStretchByClass["Pathologic Process"]).toBe(444); }); it("LPT distributes equal-height Compact+ cards across bins, balancing by height", () => { diff --git a/src/views/__tests__/filterSets.test.js b/src/views/__tests__/filterSets.test.js index 7da4901..49fb829 100644 --- a/src/views/__tests__/filterSets.test.js +++ b/src/views/__tests__/filterSets.test.js @@ -122,12 +122,14 @@ describe("filterSets", () => { expect(FILTER_ENTRY_BY_TYPE_CLASS.get("attributes:Lymph Involvement")).toMatchObject({ type: "attributes", key: "Lymph Involvement", - maxHeightPx: 300, enabled: true, }); + expect( + FILTER_ENTRY_BY_TYPE_CLASS.get("attributes:Lymph Involvement").maxHeightPx + ).toBeUndefined(); }); - it("passes maxHeightPx through configured set resolution and defaults to undefined otherwise", () => { + it("uses the shared card-height policy for configured and extra filters", () => { const resolvedSets = resolveFilterSetsWithExtras( ["lymph involvement", "novel class"], "attributes" @@ -136,8 +138,8 @@ describe("filterSets", () => { expect(resolvedSets.map((set) => set.label)).toEqual(["Staging & Disease Extent", "Uncategorized"]); expect(resolvedSets[0].filters[0]).toMatchObject({ key: "lymph involvement", - maxHeightPx: 300, }); + expect(resolvedSets[0].filters[0].maxHeightPx).toBeUndefined(); expect(resolvedSets[1].filters[0].maxHeightPx).toBeUndefined(); }); diff --git a/src/views/__tests__/filters.test.jsx b/src/views/__tests__/filters.test.jsx index 8dafc44..54343a5 100644 --- a/src/views/__tests__/filters.test.jsx +++ b/src/views/__tests__/filters.test.jsx @@ -188,7 +188,7 @@ function findPatientGridDrawer(container) { } function findPatientGridDrawerToggle(container) { - return container.querySelector('[data-testid="patient-grid-drawer-toggle"]'); + return container.querySelector('[data-testid="patient-grid-drawer-minimize"]'); } function findFilterLayoutModeToggle(container) { @@ -979,8 +979,7 @@ describe("FiltersView", () => { }); it("caps shared filter-card heights at the default card height cap", async () => { - // Default per-card height cap; configured maxHeightPx values below it - // (e.g. Stage 150) still win. + // Every configured filter now shares the density-level height policy. const DEFAULT_CARD_HEIGHT_CAP = 300; const originalMatchMedia = window.matchMedia; const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; @@ -1171,6 +1170,57 @@ describe("FiltersView", () => { } }); + it("keeps short standard-density cards natural when a long sibling activates wrapper packing", async () => { + const originalMatchMedia = window.matchMedia; + window.localStorage.setItem("filterPageCompactMode", "standard"); + + getAttributeSummary.mockResolvedValue({ + classes: ["M Stage", "Disease Stage Qualifier"], + instancesByClass: { + "M Stage": [ + { value: "M0", count: 10 }, + { value: "M1", count: 2 }, + ], + "Disease Stage Qualifier": Array.from({ length: 25 }, (_, index) => ({ + value: `Qualifier ${index + 1}`, + count: 25 - index, + })), + }, + }); + + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: createMinWidthMatchMedia(1280), + }); + + const { unmount } = renderComponent(); + + try { + await waitFor(() => { + expect(findOpenFilterButton("M Stage")).not.toBeUndefined(); + expect(findOpenFilterButton("Disease Stage Qualifier")).not.toBeUndefined(); + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + const mStageCard = findFilterCardByTitle("M Stage"); + expect(mStageCard).not.toBeNull(); + expect(mStageCard.hasAttribute("data-card-height-override")).toBe(false); + expect(Number.parseFloat(String(mStageCard.style.minHeight || "0"))).toBe(0); + expect(Number(mStageCard.getAttribute("data-card-height-cap"))).toBe(212); + } finally { + unmount(); + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: originalMatchMedia, + }); + } + }); + it("does not measure pre-stretched card overrides during initial card measurement", async () => { const originalMatchMedia = window.matchMedia; const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; @@ -1254,10 +1304,12 @@ describe("FiltersView", () => { await clickAsync(findPatientGridDrawerToggle(container)); await waitFor(() => { - const collapsedSummary = container.querySelector('[data-testid="patient-grid-collapsed-summary"]'); - expect(String(collapsedSummary?.textContent || "")).toContain( - "4 40-49 year old white female with breast cancer, and T stage T2." - ); + const patientGridDrawer = findPatientGridDrawer(container); + const selectedPatientsTab = container.querySelector("#drawer-tab-0"); + const drawerPanel = container.querySelector("#drawer-tabpanel-0"); + expect(drawerPanel?.hidden).toBe(true); + expect(String(selectedPatientsTab?.textContent || "")).toContain("Selected Patients (4)"); + expect(String(patientGridDrawer?.textContent || "")).toContain("Gender (Female)"); }); unmount(); @@ -1609,14 +1661,13 @@ describe("FiltersView", () => { expect(identifiedPanel).not.toBeNull(); expect(patientGridDrawer).not.toBeNull(); - const searchInput = patientGridDrawer.querySelector( - 'input[placeholder="Search patient details..."]' - ); + const searchInput = patientGridDrawer.querySelector('input[placeholder="Search current page…"]'); expect(searchInput).not.toBeNull(); - expect(searchInput?.getAttribute("aria-label")).toBe("Search patient details"); + expect(searchInput?.getAttribute("aria-label")).toBe("Search loaded patient page"); - const patientGridHeading = patientGridDrawer.querySelector("h2"); - expect(String(patientGridHeading?.textContent || "")).toContain("Selected Patients (12)"); + const selectedPatientsTab = patientGridDrawer.querySelector("#drawer-tab-0"); + expect(String(selectedPatientsTab?.textContent || "")).toContain("Selected Patients (12)"); + expect(selectedPatientsTab?.getAttribute("aria-label")).toContain("Filters: Gender (Female)"); const headerCells = Array.from(patientGridDrawer.querySelectorAll("thead th")); expect(headerCells.length).toBeGreaterThan(0); @@ -1642,20 +1693,20 @@ describe("FiltersView", () => { await waitFor(() => { const drawerToggle = findPatientGridDrawerToggle(container); - const drawerPanel = container.querySelector("#patient-grid-drawer-panel"); - const collapsedSummary = container.querySelector('[data-testid="patient-grid-collapsed-summary"]'); + const drawerPanel = container.querySelector("#drawer-tabpanel-0"); + const selectedPatientsTab = container.querySelector("#drawer-tab-0"); expect(drawerToggle?.getAttribute("aria-expanded")).toBe("false"); expect(drawerPanel?.hidden).toBe(true); - const collapsedSummaryText = String(collapsedSummary?.textContent || "").replace(/\s+/g, " "); - expect(collapsedSummaryText).toContain("Gender (Female)"); - expect(collapsedSummaryText).toContain("12"); + const collapsedTabText = String(selectedPatientsTab?.textContent || "").replace(/\s+/g, " "); + expect(collapsedTabText).toContain("Gender (Female)"); + expect(collapsedTabText).toContain("12"); }); await clickAsync(findPatientGridDrawerToggle(container)); await waitFor(() => { const drawerToggle = findPatientGridDrawerToggle(container); - const drawerPanel = container.querySelector("#patient-grid-drawer-panel"); + const drawerPanel = container.querySelector("#drawer-tabpanel-0"); expect(drawerToggle?.getAttribute("aria-expanded")).toBe("true"); expect(drawerPanel?.hidden).toBe(false); }); diff --git a/src/views/filterLayout.js b/src/views/filterLayout.js index 116c587..20417d6 100644 --- a/src/views/filterLayout.js +++ b/src/views/filterLayout.js @@ -420,32 +420,20 @@ export function buildTallestAlignedLayout( 0 ); const slack = alignmentTargetHeight - naturalHeight; + // Only scrollable cards may consume column slack, and only up to their + // measured uncapped content height. The renderer uses this separate map in + // Compact+ mode. It must never leak into the general card-height map: + // doing so made one- and two-row cards expand to the height of a long + // sibling, leaving large empty panels throughout the page. distributeSlack(group, slack); - }); - - activeColumnGroups.forEach((group) => { - if (group.length === 1) { - const className = group[0]; - const cardHeight = getEffectiveHeight(className); - const shouldStretchSoloCard = - cardHeight > 0 && cardHeight + 0.5 < alignmentTargetHeight; - if (shouldStretchSoloCard) { - cardHeightOverrideByClass[className] = alignmentTargetHeight; - } - cardMarginBottomByClass[className] = 0; - return; - } - - const groupHeight = group.reduce( - (sum, className) => sum + getEffectiveHeight(className), - 0 - ); - const gapCount = Math.max(1, group.length - 1); - const gapPx = Math.max(0, (alignmentTargetHeight - groupHeight) / gapCount); + // Column balancing controls placement, not card size. Record only the + // actual rendered gap between naturally sized cards. Uneven column bottoms + // are preferable to padding a card or a gap with meaningless whitespace; + // the outer masonry can use the resulting real heights for its own balance. group.forEach((className, index) => { cardMarginBottomByClass[className] = - index < group.length - 1 ? gapPx : 0; + index < group.length - 1 ? resolvedNaturalGap : 0; }); }); @@ -493,10 +481,6 @@ export function buildFilterSectionLayout({ ); measuredCardHeightByClass[className] = measuredHeight; - scrollableCardByClass[className] = - measuredHeight > 0 && measuredHeight < stackableCardMaxHeight - LAYOUT_EPSILON - ? false - : estimatedHeight > stackableCardMaxHeight + LAYOUT_EPSILON; baseCardHeightByClass[className] = measuredHeight > 0 ? measuredHeight @@ -511,6 +495,15 @@ export function buildFilterSectionLayout({ const observedDesired = toPositiveNumber(inputDesiredCardHeightByClass[className]); desiredCardHeightByClass[className] = observedDesired > 0 ? observedDesired : estimatedHeight; + // Scrollability is a property of the card's uncapped content, not of its + // current DOM box. A short card may temporarily measure at the shared cap + // after an earlier layout pass; treating that forced measurement as proof + // of scrollability is what allowed empty cards to keep stretching. Once a + // desired DOM measurement exists it is authoritative, with the row-count + // estimate serving only as the first-paint fallback. + scrollableCardByClass[className] = + desiredCardHeightByClass[className] > + stackableCardMaxHeight + LAYOUT_EPSILON; }); const { diff --git a/src/views/filterSets.js b/src/views/filterSets.js index f97ab94..4aded72 100644 --- a/src/views/filterSets.js +++ b/src/views/filterSets.js @@ -195,7 +195,6 @@ const FILTER_SET_CONFIG = [ { key: "Stage", type: "attributes", - maxHeightPx: 150, hasRollup: true, displayMode: "distribution", defaultSortMode: "alpha-asc", @@ -238,15 +237,14 @@ const FILTER_SET_CONFIG = [ }, { key: "Generic TNM Finding", type: "concepts", enabled: true }, { key: "Pathologic TNM Finding", type: "concepts", enabled: true }, - { key: "Lymph Involvement", type: "attributes", maxHeightPx: 300 }, + { key: "Lymph Involvement", type: "attributes" }, { key: "Lymph Node", type: "concepts", enabled: true }, - { key: "Metastatic Site", type: "attributes", maxHeightPx: 220 }, + { key: "Metastatic Site", type: "attributes" }, { key: "Behavior", type: "attributes", displayName: "Metastatic Behavior", displayMode: "compact", - maxHeightPx: 220, defaultSortMode: "value-desc", customSortOrder: [ "Metastatic", diff --git a/src/views/filters.jsx b/src/views/filters.jsx index 5923b55..3c191b7 100644 --- a/src/views/filters.jsx +++ b/src/views/filters.jsx @@ -47,6 +47,7 @@ import { FILTER_SECTION_LABEL_SX, FILTER_SECTION_LANE_MIN_WIDTH_PX, FILTER_SECTION_LAYOUT_COLUMNS, + buildBalancedMasonryLayout, capColumnsByWidth, getFilterSetCardColumnsByBreakpoint, getFilterSetPriorityIndex, @@ -78,14 +79,13 @@ import { isAttributeRollupClass, normalizeChartSortMode, normalizeInstanceValues, - prettifyClassName, toChartData, toDisplayInstanceValue, toSortMode, withCompactCustomSortOrder, withCompactFilterLabels, } from "./filters/filterDefinitions"; -import { buildIdentifiedSummary, formatSelectionText } from "./filters/cohortNarrative"; +import { formatSelectionText } from "./filters/cohortNarrative"; import { buildPatientIdIndex, computeFilterPatientSet } from "./filters/clientFilterMath"; import { buildActiveFilters, @@ -99,8 +99,6 @@ import { } from "./filters/filterRequest"; import { buildPatientSummaryFromFilterSummary, - formatItemCount, - formatMs, getZeroResultHint, normalizeCountResponse, normalizePatientIds, @@ -119,7 +117,6 @@ import { // identity every render, defeating React.memo on HorizontalBarFilter. const EMPTY_SELECTION = Object.freeze([]); -const SLOW_QUERY_THRESHOLD_MS = 100; const PATIENT_GRID_DEFAULT_PAGE_SIZE = 10; const PATIENT_GRID_MAXIMIZED_PAGE_SIZE = 40; const INLINE_PATIENT_IDS_THRESHOLD = 20; @@ -478,6 +475,11 @@ function FiltersView() { // the resize-quiet detector below (markFirstLayoutSettled). const [hasSettledFirstLayout, setHasSettledFirstLayout] = useState(false); const hasSettledFirstLayoutRef = useRef(false); + const outerMasonryRef = useRef(null); + const [balancedOuterLayout, setBalancedOuterLayout] = useState({ + columnById: {}, + height: 0, + }); const cardMeasureRefs = useRef({}); const patientSummaryCacheRef = useRef(new Map()); const rowCountResultCacheRef = useRef(new Map()); @@ -1482,6 +1484,91 @@ function FiltersView() { omopFilterSets, ]); + // MUI Masonry's default algorithm is greedy: every new section goes into the + // column that happens to be shortest at that moment. That leaves a large + // final imbalance for this page's mix of short and tall clinical domains. + // Measure the finished section heights and choose the best complete + // assignment instead. CSS order changes only visual columns; DOM and keyboard + // order remain the configured clinical priority order. + useEffect(() => { + // SSR, jsdom, and older browsers cannot provide the live child-size signal + // this enhancement needs. In those environments the sequential Masonry + // fallback remains fully usable and avoids scheduling stray state updates. + if (!canRenderFilterSections || typeof ResizeObserver === "undefined") { + return undefined; + } + + const node = outerMasonryRef.current; + if (!node) { + return undefined; + } + + const spacingPx = isCompactPlusDensity ? stackGapPx : isCompactDensity ? 8 : 16; + const canScheduleFrame = + typeof requestAnimationFrame === "function" && + typeof cancelAnimationFrame === "function"; + let frameId = null; + let observer = null; + + const measure = () => { + frameId = null; + const items = Array.from(node.children) + .filter((child) => child.classList?.contains("filter-set-layout-item")) + .map((child) => ({ + id: child.getAttribute("data-filter-set-id"), + height: child.getBoundingClientRect().height, + })); + const nextLayout = buildBalancedMasonryLayout( + items, + resolvedFilterSectionLayoutColumns, + spacingPx + ); + setBalancedOuterLayout((previous) => { + const previousEntries = Object.entries(previous.columnById); + const nextEntries = Object.entries(nextLayout.columnById); + const sameColumns = + previousEntries.length === nextEntries.length && + nextEntries.every(([id, column]) => previous.columnById[id] === column); + return sameColumns && Math.abs(previous.height - nextLayout.height) <= 1 + ? previous + : nextLayout; + }); + }; + + const scheduleMeasure = () => { + if (!canScheduleFrame) { + measure(); + return; + } + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + frameId = requestAnimationFrame(measure); + }; + + observer = new ResizeObserver(scheduleMeasure); + Array.from(node.children).forEach((child) => { + if (child.classList?.contains("filter-set-layout-item")) { + observer.observe(child); + } + }); + scheduleMeasure(); + + return () => { + if (frameId !== null && canScheduleFrame) { + cancelAnimationFrame(frameId); + } + observer?.disconnect(); + }; + }, [ + canRenderFilterSections, + filterSectionsForDisplay, + isCompactDensity, + isCompactPlusDensity, + resolvedFilterSectionLayoutColumns, + stackGapPx, + ]); + // Reveal the filter grid only once its layout has stopped moving. The three // nested MUI Masonry grids (section lanes + the cards inside each section) // each measure their children with a POST-paint ResizeObserver and then @@ -1837,13 +1924,6 @@ function FiltersView() { }; }, [totalPatientCount]); - const timing = useMemo(() => countResult?.timing || {}, [countResult?.timing]); - const isSlowQuery = Number(timing.totalMs || 0) > SLOW_QUERY_THRESHOLD_MS; - const zeroResultHint = countResult?.count === 0 ? getZeroResultHint(activeFilters, timing.itemCounts) : ""; - const identifiedSummary = useMemo( - () => buildIdentifiedSummary(activeFilters, countResult?.count), - [activeFilters, countResult?.count] - ); const cohortSize = Number(countResult?.count || 0); const patientGridPageSize = isPatientGridDockMaximized ? PATIENT_GRID_MAXIMIZED_PAGE_SIZE @@ -1893,11 +1973,15 @@ function FiltersView() { const patientGridDrawerStatusText = isPatientGridPageLoading ? "Updating matched patients…" : cohortSize > 0 - ? `Showing page ${(currentPatientGridPage + 1).toLocaleString()} of ${Math.max( + ? `Page ${(currentPatientGridPage + 1).toLocaleString()} of ${Math.max( 1, totalPatientGridPages ).toLocaleString()} · ${cohortSize.toLocaleString()} matched patient${cohortSize === 1 ? "" : "s"}.` - : "No matched patients."; + : "No patients matched these criteria."; + const patientGridDrawerEmptyStateHint = + countResult?.count === 0 + ? getZeroResultHint(activeFilters, countResult?.timing?.itemCounts) + : ""; const patientGridDrawerFilterSummaryText = useMemo(() => { if (activeFilters.length === 0) { return ""; @@ -1913,126 +1997,14 @@ function FiltersView() { }) .join(", "); }, [activeFilters]); - const patientGridCollapsedHeaderSummary = useMemo(() => { - const hasFilterSummaries = activeFilters.length > 0; - const hasPerfSummary = SHOULD_LOG_FILTERS_PERF && Boolean(countResult); - const showSlowWarning = false; - const hasSlowWarning = Boolean(countResult) && isSlowQuery; - const shouldShowSlowWarning = hasSlowWarning && showSlowWarning; - const hasZeroHint = Boolean(countResult) && Boolean(zeroResultHint); - const hasIdentifiedSummary = Boolean(countResult && identifiedSummary); - const hasStatusCopy = - isCountLoading || - Boolean(countError) || - hasIdentifiedSummary || - hasFilterSummaries || - shouldShowSlowWarning || - hasZeroHint || - hasPerfSummary; - - if (!hasStatusCopy) { - return null; - } - - return ( - - {isCountLoading ? ( - - Querying patient count... - - ) : null} - {countError ? {countError} : null} - {hasIdentifiedSummary ? ( - - {identifiedSummary} - - ) : null} - {hasFilterSummaries ? ( - - {activeFilters.map((filter, index) => ( - - - {prettifyClassName(filter.class, filter.type)} ( - {formatSelectionText( - filter.instances.map((value) => toDisplayInstanceValue(filter.type, filter.class, value)) - )} - ) - - - {formatItemCount(timing.itemCounts?.[index])} - - - ))} - - ) : null} - {shouldShowSlowWarning ? ( - - Query took {formatMs(timing.totalMs)} ms. Consider narrowing selections for faster response. - - ) : null} - {hasZeroHint ? {zeroResultHint} : null} - {hasPerfSummary ? ( - - Query {formatMs(timing.queryMs)} ms | Bitmap {formatMs(timing.bitmapMs)} ms | Resolve{" "} - {formatMs(timing.resolveMs)} ms | Total {formatMs(timing.totalMs)} ms - - ) : null} - - ); - }, [ - activeFilters, - countError, - countResult, - custom, - identifiedSummary, - isCountLoading, - isSlowQuery, - timing, - zeroResultHint, - ]); const patientGridDrawerBottomPadding = isPatientGridDockVisible ? { - xs: isPatientGridDockMaximized ? "calc(100vh - 96px)" : isPatientGridDockExpanded ? "68vh" : "104px", + xs: isPatientGridDockMaximized ? "calc(100vh - 96px)" : isPatientGridDockExpanded ? "68vh" : "64px", md: isPatientGridDockMaximized ? "calc(100vh - 124px)" : isPatientGridDockExpanded ? "min(78vh, 820px)" - : "116px", + : "72px", } : 0; // Drive the table's loading from the patient-set page fetch only, not the @@ -2604,16 +2576,24 @@ function FiltersView() { Boolean(String(activeFilterSearchQuery || "").trim()); const FILTER_PANEL_SPACING_PX = isCompactPlusDensity ? stackGapPx : isCompactDensity ? 8 : 16; const FILTER_PANEL_SPACING_UNITS = FILTER_PANEL_SPACING_PX / 8; - // Hard cap every filter card. Anything taller scrolls inside the - // chart viewport (overflowY: auto on .horizontal-bar-filter-chart-viewport) - // so the page stays scannable even for cards with hundreds of values. - const FILTER_CARD_MAX_HEIGHT_PX = isCompactPlusDensity ? 300 : 200; + // Keep first-paint estimates aligned with HorizontalBarFilter's real row and + // chrome dimensions. The previous 36px/120px estimate made short cards look + // scrollable before measurement and fed false height pressure into packing. + const ROW_HEIGHT_ESTIMATE = isCompactDensity ? 20 : 30; + const CARD_OVERHEAD_ESTIMATE = isCompactDensity ? 32 : 54; + const FILTER_CARD_VISIBLE_ROW_TARGET = isCompactDensity ? 7 : 5; + const FILTER_CARD_HEIGHT_TOLERANCE_PX = 8; + // Short and medium cards remain content-sized. Long cards show a useful + // density-specific number of complete rows, then scroll inside the chart. + // Compact+ deliberately keeps its roomier exploration cap. + const FILTER_CARD_MAX_HEIGHT_PX = isCompactPlusDensity + ? 300 + : CARD_OVERHEAD_ESTIMATE + + FILTER_CARD_VISIBLE_ROW_TARGET * ROW_HEIGHT_ESTIMATE + + FILTER_CARD_HEIGHT_TOLERANCE_PX; const FILTER_SECTION_HEIGHT_CAP_PX = 700; const FILTER_CARD_CHART_HEIGHT_OFFSET_PX = 150; const PER_CARD_COLUMN_CHART_HEIGHT_CAP_PX = 340; - const ROW_HEIGHT_ESTIMATE = isCompactDensity ? 24 : 36; - const CARD_OVERHEAD_ESTIMATE = isCompactDensity ? 60 : 120; - const NATURAL_STACK_GAP_PX = isCompactDensity ? 8 : 24; const CARD_BOTTOM_MARGIN = isCompactDensity ? 12 : 24; // A filter card with enough charted values claims its own dedicated column // before LPT packs the rest — the point where a value list reads as its own @@ -2759,7 +2739,10 @@ function FiltersView() { rowCountByClass, measuredCardHeightByClass, desiredCardHeightByClass, - naturalGapPx: isCompactPlusDensity ? stackGapPx : NATURAL_STACK_GAP_PX, + // Use the same gap for layout math and rendering. A larger invisible + // "natural" gap used to distort section-height estimates in standard + // density even though Masonry rendered only FILTER_PANEL_SPACING_PX. + naturalGapPx: FILTER_PANEL_SPACING_PX, maxColumns: resolvedSectionMaxColumns, categoryMaxHeight: sectionHeightCap, cardBottomMargin: CARD_BOTTOM_MARGIN, @@ -2856,13 +2839,29 @@ function FiltersView() { isPerCardColumnLayout && !isCompactPlusDensity ? sectionColumnCaps : Math.min(sectionColumnCaps, Math.max(1, Number(resolvedSectionColumnCap) || 1)); - // Each section lane gets roughly an equal slice of the filter area, so cap - // card columns to keep each card at least FILTER_CARD_MIN_WIDTH_PX wide. - const approxLaneWidth = + // Each section lane gets roughly an equal slice of the filter area. MUI + // Masonry then subtracts one full spacing value from EVERY child column + // (`width: calc(100% / columns - spacing)`), while the outer Masonry and + // the lane rule/padding consume more of that slice. Account for all three + // costs before deciding that another card column fits. The previous + // filterAreaWidth / laneCount estimate switched to two 209px cards at the + // 1024px breakpoint even though our declared minimum is 240px. + const approxLaneContentWidth = filterAreaWidth > 0 - ? filterAreaWidth / Math.max(1, resolvedFilterSectionLayoutColumns) + ? Math.max( + 0, + filterAreaWidth / Math.max(1, resolvedFilterSectionLayoutColumns) - + FILTER_PANEL_SPACING_PX - + (isCompactDensity ? 10 : 14) + ) : 0; - return capColumnsByWidth(breakpointCap, approxLaneWidth, FILTER_CARD_MIN_WIDTH_PX); + const minimumMasonryColumnFootprint = + FILTER_CARD_MIN_WIDTH_PX + FILTER_PANEL_SPACING_PX; + return capColumnsByWidth( + breakpointCap, + approxLaneContentWidth, + minimumMasonryColumnFootprint + ); }; const getFilterSectionColumnSx = (span = null) => { void span; @@ -3023,8 +3022,22 @@ function FiltersView() { const requestedCardHeightOverride = Math.max(0, Number(cardHeightOverride) || 0); const cardMarginBottom = Math.max(0, Number(cardMarginBottomByClass[className]) || 0); const rowCount = classChartData.length; - const estimatedCardHeight = estimateCardHeight(rowCount, ROW_HEIGHT_ESTIMATE, CARD_OVERHEAD_ESTIMATE); - const shouldStretchScrollableCard = isCompactPlusDensity && estimatedCardHeight > resolvedCardHeightCapPx; + const estimatedCardHeight = estimateCardHeight( + rowCount, + ROW_HEIGHT_ESTIMATE, + CARD_OVERHEAD_ESTIMATE + ); + const desiredCardHeight = Math.max( + 0, + Number( + cardDesiredHeightByKey[ + getCardMeasureKey(filterType, className) + ] + ) || estimatedCardHeight + ); + const shouldStretchScrollableCard = + isCompactPlusDensity && + desiredCardHeight > resolvedCardHeightCapPx + 0.5; // Stretched cards grow to natural desired height — see renderOmopFilterCard // for the equivalent reasoning. Pre-measurement (measuredCardHeight === 0) // we still gate via the measured>0 ternary below to avoid oscillation. @@ -3158,18 +3171,11 @@ function FiltersView() { }); const filterByClassName = Object.fromEntries(renderedFilters.map((filter) => [filter.key, filter])); const orderedClassNames = renderedFilters.map((filter) => filter.key); - const shouldStackEthnicityUnderGender = - !isCompactPlusDensity && - filterSet.id === "demographics" && - orderedClassNames.includes("GENDER") && - orderedClassNames.includes("ETHNICITY"); const injectedAttributeCardCount = inlineAttributeFilterSets.reduce( (sum, attributeFilterSet) => sum + (attributeFilterSet.filters?.length || 0), 0 ); - const omopGridItemCount = shouldStackEthnicityUnderGender - ? Math.max(1, renderedFilters.length - 1) - : Math.max(1, renderedFilters.length); + const omopGridItemCount = Math.max(1, renderedFilters.length); const compactPlusOmopGridItemCount = useColumnWrappers ? Math.max(1, columnGroups.length) : omopGridItemCount; const totalGridItemCount = Math.max(1, compactPlusOmopGridItemCount + injectedAttributeCardCount); @@ -3200,8 +3206,20 @@ function FiltersView() { const requestedCardHeightOverride = Math.max(0, Number(cardHeightOverride) || 0); const cardMarginBottom = Math.max(0, Number(cardMarginBottomByClass[className]) || 0); const rowCount = classChartData.length; - const estimatedCardHeight = estimateCardHeight(rowCount, ROW_HEIGHT_ESTIMATE, CARD_OVERHEAD_ESTIMATE); - const shouldStretchScrollableCard = isCompactPlusDensity && estimatedCardHeight > resolvedCardHeightCapPx; + const estimatedCardHeight = estimateCardHeight( + rowCount, + ROW_HEIGHT_ESTIMATE, + CARD_OVERHEAD_ESTIMATE + ); + const desiredCardHeight = Math.max( + 0, + Number( + cardDesiredHeightByKey[getCardMeasureKey("omop", className)] + ) || estimatedCardHeight + ); + const shouldStretchScrollableCard = + isCompactPlusDensity && + desiredCardHeight > resolvedCardHeightCapPx + 0.5; // Stretched cards grow to their natural desired height (requestedCardHeightOverride // is already capped at the chart's natural content height by distributeSlack). // Deliberately no longer bound by resolvedSectionHeightCapPx — oversized @@ -3318,23 +3336,13 @@ function FiltersView() { > {compactPlusOmopColumns || orderedClassNames.map((className, classIndex) => { - if (shouldStackEthnicityUnderGender && className === "ETHNICITY") { - return null; - } - const stackedEthnicityCard = shouldStackEthnicityUnderGender && className === "GENDER"; return ( {renderOmopFilterCard(className, classIndex)} - {stackedEthnicityCard ? renderOmopFilterCard("ETHNICITY", classIndex + 0.25, "stacked:") : null} ); })} @@ -3505,6 +3513,7 @@ function FiltersView() { {canRenderFilterSections ? ( filterSectionsForDisplay.length > 0 ? ( {kind === "omop" ? renderOmopFilterSet(filterSet, "omop", { @@ -3582,7 +3599,7 @@ function FiltersView() { pageError={patientGridPageError} onRetryPatientSummary={handleRetryPatientSummary} statusText={patientGridDrawerStatusText} - collapsedHeaderSummary={patientGridCollapsedHeaderSummary} + emptyStateHint={patientGridDrawerEmptyStateHint} onOpenPatientTab={handleOpenPatientTab} setIsExpanded={setIsPatientGridDockExpanded} setIsMaximized={setIsPatientGridDockMaximized} diff --git a/src/views/filters/PatientDrawer.jsx b/src/views/filters/PatientDrawer.jsx index 045a344..4a91578 100644 --- a/src/views/filters/PatientDrawer.jsx +++ b/src/views/filters/PatientDrawer.jsx @@ -1,7 +1,8 @@ import React from "react"; import PropTypes from "prop-types"; -import { Box, IconButton, Paper, Tab, Tabs, Tooltip, Typography } from "@mui/material"; +import { Alert, Box, IconButton, Paper, Tab, Tabs, Tooltip, Typography } from "@mui/material"; import AddIcon from "@mui/icons-material/Add"; +import CloseIcon from "@mui/icons-material/Close"; import CloseFullscreenIcon from "@mui/icons-material/CloseFullscreen"; import OpenInFullIcon from "@mui/icons-material/OpenInFull"; import RemoveIcon from "@mui/icons-material/Remove"; @@ -28,7 +29,7 @@ export default function PatientDrawer({ pageError = "", onRetryPatientSummary = undefined, statusText = "", - collapsedHeaderSummary = null, + emptyStateHint = "", onOpenPatientTab = undefined, setIsExpanded = undefined, setIsMaximized = undefined, @@ -38,15 +39,14 @@ export default function PatientDrawer({ } const cohortLabel = Math.max(0, Number(cohortSize) || 0).toLocaleString(); + const activePatientId = activeDrawerTab > 0 ? openPatientIds[activeDrawerTab - 1] || "" : ""; return ( theme.zIndex.modal - 1, @@ -84,31 +84,6 @@ export default function PatientDrawer({ flexDirection: "column", }} > - {filterSummaryText ? ( - - - {`Filters: ${filterSummaryText}`} - - - ) : null} { const isInteractiveTarget = event.target.closest('button, [role="tab"], [role="button"]'); @@ -144,59 +119,61 @@ export default function PatientDrawer({ }} > + + {`Selected Patients (${cohortLabel})`} + + {filterSummaryText ? ( + + {`— ${filterSummaryText}`} + + ) : null} + + } id="drawer-tab-0" aria-controls="drawer-tabpanel-0" - sx={{ textTransform: "none", fontWeight: 600 }} + aria-label={ + filterSummaryText + ? `Selected Patients (${cohortLabel}). Filters: ${filterSummaryText}` + : `Selected Patients (${cohortLabel})` + } + sx={{ + textTransform: "none", + fontWeight: 600, + maxWidth: { xs: 260, sm: 520, lg: 760 }, + }} /> {openPatientIds.map((patientId, index) => ( { + if (event.key === "Delete" || event.key === "Backspace") { + event.preventDefault(); + onClosePatientTab?.(patientId, event); + } + }} sx={{ textTransform: "none" }} label={ - - - {patientId} - - onClosePatientTab?.(patientId, event)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onClosePatientTab?.(patientId, event); - } - }} - tabIndex={0} - sx={{ - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - width: 16, - height: 16, - borderRadius: "50%", - fontSize: "0.6rem", - lineHeight: 1, - ml: 0.25, - "&:hover": { bgcolor: "action.hover" }, - "&:focus-visible": { - outline: "2px solid", - outlineColor: "primary.main", - outlineOffset: 1, - }, - }} - > - × - - + + {patientId} + } /> ))} @@ -206,6 +183,17 @@ export default function PatientDrawer({ aria-label="Drawer window controls" sx={{ display: "inline-flex", alignItems: "center", gap: 0.25, ml: 0.25 }} > + {activePatientId ? ( + + onClosePatientTab?.(activePatientId, event)} + > + + + + ) : null} @@ -332,7 +322,7 @@ PatientDrawer.propTypes = { pageError: PropTypes.string, onRetryPatientSummary: PropTypes.func, statusText: PropTypes.string, - collapsedHeaderSummary: PropTypes.node, + emptyStateHint: PropTypes.string, onOpenPatientTab: PropTypes.func, setIsExpanded: PropTypes.func, setIsMaximized: PropTypes.func, diff --git a/src/views/filters/layoutConfig.js b/src/views/filters/layoutConfig.js index 4f8d4df..c039084 100644 --- a/src/views/filters/layoutConfig.js +++ b/src/views/filters/layoutConfig.js @@ -29,6 +29,117 @@ export function capColumnsByWidth(configuredColumns, availableWidthPx, minColumn return Math.max(1, Math.min(cap, widthCap)); } +/** + * Assign measured masonry items to globally balanced columns while keeping the + * first visible row anchored left-to-right. Items remain in their original DOM + * order; callers apply the returned column number with CSS `order`. + * + * The section count on the filter page is deliberately small, so an exact + * search gives a visibly better result than the usual one-item-at-a-time + * shortest-column heuristic. A bounded LPT fallback keeps the helper safe for + * unexpectedly large custom configurations. + */ +export function buildBalancedMasonryLayout(items = [], columnCount = 1, spacingPx = 0) { + const normalizedItems = (Array.isArray(items) ? items : []) + .map((item, index) => ({ + id: String(item?.id ?? index), + height: Math.max(0, Number(item?.height) || 0), + })) + .filter((item) => item.height > 0); + const resolvedColumnCount = Math.max( + 1, + Math.min(normalizedItems.length || 1, Math.floor(Number(columnCount) || 1)) + ); + const resolvedSpacing = Math.max(0, Number(spacingPx) || 0); + const columnById = {}; + + if (normalizedItems.length === 0) { + return { columnById, columnHeights: [], height: 0 }; + } + + const columnHeights = Array(resolvedColumnCount).fill(0); + const assignments = Array(normalizedItems.length).fill(0); + const itemOuterHeight = (item) => item.height + resolvedSpacing; + + // Preserve the page's priority hierarchy: the first N domains always form + // the first visible row, one per column. + for (let index = 0; index < resolvedColumnCount; index += 1) { + assignments[index] = index; + columnHeights[index] += itemOuterHeight(normalizedItems[index]); + } + + const remainingCount = normalizedItems.length - resolvedColumnCount; + const searchSize = resolvedColumnCount ** Math.max(0, remainingCount); + let best = null; + + const considerCurrentAssignment = () => { + const maxHeight = Math.max(...columnHeights); + const minHeight = Math.min(...columnHeights); + const range = maxHeight - minHeight; + const sequentialPenalty = assignments.reduce( + (sum, columnIndex, itemIndex) => + sum + (columnIndex === itemIndex % resolvedColumnCount ? 0 : 1), + 0 + ); + const score = [range, maxHeight, sequentialPenalty]; + const isBetter = + !best || + score.some( + (value, scoreIndex) => + value < best.score[scoreIndex] && + score.slice(0, scoreIndex).every((entry, entryIndex) => entry === best.score[entryIndex]) + ); + if (isBetter) { + best = { + score, + assignments: [...assignments], + columnHeights: [...columnHeights], + }; + } + }; + + if (searchSize <= 100000) { + const search = (itemIndex) => { + if (itemIndex >= normalizedItems.length) { + considerCurrentAssignment(); + return; + } + const outerHeight = itemOuterHeight(normalizedItems[itemIndex]); + for (let columnIndex = 0; columnIndex < resolvedColumnCount; columnIndex += 1) { + assignments[itemIndex] = columnIndex; + columnHeights[columnIndex] += outerHeight; + search(itemIndex + 1); + columnHeights[columnIndex] -= outerHeight; + } + }; + search(resolvedColumnCount); + } else { + // Large custom layouts: place the tallest remaining domains first into the + // current shortest column, then restore source order within each column. + normalizedItems + .map((item, index) => ({ item, index })) + .slice(resolvedColumnCount) + .sort((left, right) => right.item.height - left.item.height || left.index - right.index) + .forEach(({ item, index }) => { + const shortestColumn = columnHeights.indexOf(Math.min(...columnHeights)); + assignments[index] = shortestColumn; + columnHeights[shortestColumn] += itemOuterHeight(item); + }); + considerCurrentAssignment(); + } + + best.assignments.forEach((columnIndex, itemIndex) => { + columnById[normalizedItems[itemIndex].id] = columnIndex + 1; + }); + + return { + columnById, + columnHeights: best.columnHeights, + // MUI Masonry adds one spacing unit beyond its measured max column height. + height: Math.ceil(Math.max(...best.columnHeights) + resolvedSpacing), + }; +} + export const FILTER_SECTION_LABEL_SX = { display: "block", fontSize: "0.85rem", diff --git a/src/views/patient.jsx b/src/views/patient.jsx index fdfda25..6b1a737 100644 --- a/src/views/patient.jsx +++ b/src/views/patient.jsx @@ -19,6 +19,7 @@ import CancerTumorSummaryCard from "../components/patient/CancerTumorSummaryCard import PatientDocumentsCard from "../components/patient/PatientDocumentsCard"; import PatientDocumentDrawer from "../components/patient/PatientDocumentDrawer"; import EventRelationTimelineCard from "../components/patient/EventRelationTimelineCard"; +import TimelineLinkProvider from "../components/patient/timeline/TimelineLinkProvider"; import PatientSummaryCard from "../components/patient/PatientSummaryCard"; import { loadPatientFilterSummary, @@ -27,6 +28,7 @@ import { } from "../controllers/patient"; import { shouldShowEventRelationTimeline } from "../controllers/eventRelationTimeline"; import { usePatientData } from "../hooks/usePatientData"; +import { findDocumentIdsForConceptIds } from "../utils/patientView/documentMentions"; import { resolveFactSelection } from "../utils/patientView/factLinking"; import { resolveSummarySelection } from "../utils/patientView/summarySelection"; import { getThemeByKey } from "../themes"; @@ -244,6 +246,15 @@ export default function PatientView() { () => getSummaryDetailSections(patientSummaryData || patientData?.rawPatient || patientData), [patientData, patientSummaryData] ); + const eventRelatedDocumentIds = useMemo( + () => + findDocumentIdsForConceptIds( + patientData?.documents, + patientData?.concepts, + timelineConceptIds + ), + [patientData, timelineConceptIds] + ); const reportById = useMemo(() => { const map = new Map(); @@ -678,6 +689,8 @@ export default function PatientView() { /> + {/* Links the two timelines: one date range, one zoom, aligned strips. */} + @@ -725,6 +739,7 @@ export default function PatientView() { /> ) : null} + Date: Fri, 18 Sep 2026 00:20:17 -0400 Subject: [PATCH 2/3] Add a View switch, and the improved patient view behind it A radio in the patient view header switches between two presentations and remembers the choice: "Alpha", the reading ported from DeepPhe-Viz-v2-alpha, and "Improved", the readability work. Both show the same data. Improved: - Cancers are compared in one matrix (attributes as rows, a column per cancer, tumors nested under their parent) instead of near-identical cards that hid their differences. Rows that differ are marked, a tumor value matching its cancer says so once rather than repeating, and each column reports how many of its fields are documented. - Undocumented values read as a muted dash rather than occupying the weight of a real finding. They are never hidden: "unknown" is itself a clinical finding. - One containment level (panel surface and spacing, not a border around a bordered card) and four type steps for titles, labels, values and axis ticks. - Event Timeline: alternate lane groups are banded, negated marks are dashed as well as red (color alone fails WCAG 1.4.1 for the most common color blindness), the legend gains a key for it and names what the relations are relative to, and the status line explains why 56 relations draw as 34 spans. - Both timelines name the range they show against the whole range, and zoom in/out read as + and - rather than two near-identical magnifiers. - One date format for values: "Jan 23, 2010" rather than ISO here, slashes there. New: constants/patientViewPresentation.js and patientViewTypography.js, PatientViewPresentationProvider (mounted at the app root) with usePatientViewPresentation, PatientViewPresentationToggle, controllers/cancerComparison.js and CancerComparisonMatrix. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 7 + docs/explore-patient/event-timeline.md | 6 +- docs/explore-patient/overview.md | 16 ++ src/App.js | 3 + src/components/EmbeddedPatientView.jsx | 11 +- .../patient/CancerComparisonMatrix.jsx | 231 ++++++++++++++++++ .../patient/CancerTumorSummaryCard.jsx | 14 +- .../patient/EventRelationTimelineCard.jsx | 129 +++++++++- .../patient/PatientDemographicsCard.jsx | 26 +- .../patient/PatientDocumentsCard.jsx | 30 ++- .../PatientViewPresentationProvider.jsx | 63 +++++ .../patient/PatientViewPresentationToggle.jsx | 78 ++++++ .../__tests__/CancerTumorSummaryCard.test.jsx | 94 ++++++- .../EventRelationTimelineCard.test.jsx | 60 ++++- .../__tests__/PatientDocumentsCard.test.jsx | 27 ++ .../PatientViewPresentation.test.jsx | 118 +++++++++ .../timeline/TimelineOverviewStrip.jsx | 19 +- .../patient/timeline/TimelineZoomControls.jsx | 13 +- src/constants/patientViewPresentation.js | 37 +++ src/constants/patientViewTypography.js | 21 ++ .../__tests__/cancerComparison.test.js | 106 ++++++++ src/controllers/cancerComparison.js | 210 ++++++++++++++++ src/hooks/usePatientViewPresentation.js | 22 ++ .../eventRelationTimelineLayout.js | 9 +- src/views/patient.jsx | 15 +- 25 files changed, 1328 insertions(+), 37 deletions(-) create mode 100644 src/components/patient/CancerComparisonMatrix.jsx create mode 100644 src/components/patient/PatientViewPresentationProvider.jsx create mode 100644 src/components/patient/PatientViewPresentationToggle.jsx create mode 100644 src/components/patient/__tests__/PatientViewPresentation.test.jsx create mode 100644 src/constants/patientViewPresentation.js create mode 100644 src/constants/patientViewTypography.js create mode 100644 src/controllers/__tests__/cancerComparison.test.js create mode 100644 src/controllers/cancerComparison.js create mode 100644 src/hooks/usePatientViewPresentation.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aa30b5..4550b59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 collapsible concept column inside the Document Viewer - Clickable **Patient Summary** findings that open their source note in the Document Viewer +- A **View** control on the patient view switches between the **Improved** + presentation (default) and the **Alpha** one, and remembers the choice. The + improved view compares the cancers in one matrix with tumors nested and + undocumented fields shown as a dash, drops a level of nested borders, uses + four text sizes, bands alternate event lanes, marks negated events with a + dashed line as well as red, names the visible date range against the whole + range, and replaces the two magnifier icons with + and − ### Changed - The Patient Document Timeline's date range slider was drawn against the full diff --git a/docs/explore-patient/event-timeline.md b/docs/explore-patient/event-timeline.md index 7578e86..ac3f4c0 100644 --- a/docs/explore-patient/event-timeline.md +++ b/docs/explore-patient/event-timeline.md @@ -18,6 +18,8 @@ Events are grouped into four lanes, labelled on the left with the number of mark - **Stage, Grade**; and - **Treatment**. +The Improved view bands alternate lanes, so a mark reads against its own lane rather than blending into the next. + Within a lane, marks are packed onto as few rows as possible — two events share a row when their date ranges do not overlap. A row is not a single concept, so hover a mark to read which concepts it covers. Where several concepts in the same lane share exactly the same start and end date, they are drawn as **one mark**. The tooltip lists every concept it stands for, with a `(xN)` count when a name repeats. @@ -35,7 +37,7 @@ The shape of each end tells you how precisely the date is known: The legend above the chart shows each of these. -Marks are **green**. A **red** mark means the concept is **negated** — the note records its absence, not its presence. Colour is not the only cue: the tooltip and the screen-reader label both state the negation, and the relation and dates are given in words. +Marks are **green**. A **red** mark means the concept is **negated** — the note records its absence, not its presence. In the **Improved** view (see [Choose how the patient view reads](overview.md#choose-how-the-patient-view-reads)) a negated mark is also **dashed**, so the distinction does not depend on telling red from green, and the legend includes a key for it. In either view the tooltip and the screen-reader label state the negation, and the relation and dates are given in words. Dashed vertical guidelines mark every date that carries an event, so you can line marks up across lanes. @@ -91,7 +93,7 @@ The **Showing** control above the chart filters the timeline: - **All Patient Events** — every temporal relation for the patient. - **Filtered Patient Events** — only relations whose concepts appear in the report currently open in the Document Viewer. -The count beside the panel title tells you how many relations are in view. +The count beside the panel title tells you how many relations are in view. Below the chart, the Improved view spells out the difference between the two numbers: relations that share a lane and both dates are drawn as one span. The current report name appears above the chart. Filtering preserves the date range so that the remaining events stay in the same positions. If a report has no matching events, the Showing control remains available to return to all events. diff --git a/docs/explore-patient/overview.md b/docs/explore-patient/overview.md index 4dbef29..c455093 100644 --- a/docs/explore-patient/overview.md +++ b/docs/explore-patient/overview.md @@ -20,6 +20,22 @@ The patient opens as a tab in the drawer. **Standalone Patient View (from Home).** A separate page where you look a patient up by ID. It shows demographics, cancer and tumor detail, the document timeline, the Event Timeline, and the Document Viewer, but **not** the structured Patient Summary. See [Standalone Patient View](standalone-patient-view.md). +## Choose how the patient view reads + +The **View** control at the top of the patient view switches between two presentations. Your choice is remembered, and it applies to both the embedded and the standalone view. + +**Improved** (the default) is laid out for scanning: + +- The cancers are compared in **one matrix** — attributes down the side, one column per cancer, tumors nested underneath — so differences like a grade of 3 against 1 sit in a single column. Values a tumor shares with its cancer read "Same as cancer" rather than repeating. +- **Undocumented values are shown as a dash**, never hidden, and each cancer says how many of its fields are documented. +- Panels use one level of containment, and one set of text sizes for panel titles, field labels, values and axis ticks. +- The timelines say which range they are showing, band alternate event lanes, and mark negated events with a **dashed** line as well as red. +- Zoom in and out read as **+** and **−** rather than two similar magnifiers. + +**Alpha** keeps the original reading, faithful to the DeepPhe-Viz-v2-alpha code the Event Timeline was ported from: per-cancer cards, solid marks that carry negation in color alone, and the alpha's wording and controls. + +Both show the same data. Only the presentation changes. + ## What the embedded view contains Depending on the available data, the embedded view can include: diff --git a/src/App.js b/src/App.js index 517cc52..57c4257 100644 --- a/src/App.js +++ b/src/App.js @@ -5,6 +5,7 @@ import FiltersView from "./views/filters"; import AccessibilityStatement from "./views/AccessibilityStatement"; import FilterSetsConfigView from "./views/FilterSetsConfigView"; import PatientView from "./views/patient"; +import PatientViewPresentationProvider from "./components/patient/PatientViewPresentationProvider"; import PerfPanel from "./components/PerfPanel"; import FeedbackWidget from "./components/FeedbackWidget"; @@ -41,6 +42,7 @@ function App() { v7_relativeSplatPath: true, }} > + } /> } /> @@ -49,6 +51,7 @@ function App() { } /> } /> + {SHOW_PERF_TRACKER ? : null} diff --git a/src/components/EmbeddedPatientView.jsx b/src/components/EmbeddedPatientView.jsx index df104c0..87e0001 100644 --- a/src/components/EmbeddedPatientView.jsx +++ b/src/components/EmbeddedPatientView.jsx @@ -7,6 +7,7 @@ import PatientDocumentsCard from "./patient/PatientDocumentsCard"; import PatientDocumentDrawer from "./patient/PatientDocumentDrawer"; import EventRelationTimelineCard from "./patient/EventRelationTimelineCard"; import TimelineLinkProvider from "./patient/timeline/TimelineLinkProvider"; +import PatientViewPresentationToggle from "./patient/PatientViewPresentationToggle"; import PatientSummaryCard from "./patient/PatientSummaryCard"; import { getInstances } from "../controllers/omap"; import { loadPatientFilterSummary } from "../controllers/patient"; @@ -16,6 +17,7 @@ import { asRowArray, getValueFromRow } from "../utils/dataProcessing"; import { resolveFactSelection } from "../utils/patientView/factLinking"; import { findDocumentIdsForConceptIds } from "../utils/patientView/documentMentions"; import { resolveSummarySelection } from "../utils/patientView/summarySelection"; +import usePatientViewPresentation from "../hooks/usePatientViewPresentation"; /** * @typedef {Object} SelectionContext @@ -224,6 +226,7 @@ async function loadPatientOmopDetails(patientId) { export default function EmbeddedPatientView({ patientId = "" }) { const theme = useTheme(); + const { isImproved } = usePatientViewPresentation(); const { patientData, timelineData, cancerSummary, isLoading, errorMessage, loadPatient } = usePatientData(); const [factSelection, setFactSelection] = useState(null); @@ -683,9 +686,12 @@ export default function EmbeddedPatientView({ patientId = "" }) { minHeight: 0, display: "flex", flexDirection: "column", - border: 1, + // One containment level in the improved view: the panel's own surface and + // the gap to the next, rather than a border around a bordered card. + border: isImproved ? 0 : 1, borderColor: "divider", borderRadius: 1, + bgcolor: "background.paper", }; return ( @@ -750,6 +756,9 @@ export default function EmbeddedPatientView({ patientId = "" }) { ))} + + + ) : null} diff --git a/src/components/patient/CancerComparisonMatrix.jsx b/src/components/patient/CancerComparisonMatrix.jsx new file mode 100644 index 0000000..76439f5 --- /dev/null +++ b/src/components/patient/CancerComparisonMatrix.jsx @@ -0,0 +1,231 @@ +import React, { useMemo } from "react"; +import PropTypes from "prop-types"; +import { Box, Button, Tooltip, Typography } from "@mui/material"; +import { buildCancerComparisonMatrix, getFactLabel } from "../../controllers/cancerComparison"; + +const UNDOCUMENTED_TITLE = "Not documented in this patient's notes"; + +/** + * One fact. Still a button — selecting it links the fact to the documents that + * mention it — but it reads as a value with an affordance, not as an input box. + */ +function FactValue({ fact, isActive, onSelect }) { + return ( + + ); +} + +FactValue.propTypes = { + fact: PropTypes.shape({ id: PropTypes.string }).isRequired, + isActive: PropTypes.bool.isRequired, + onSelect: PropTypes.func, +}; + +function MatrixCell({ cell, activeFactId, onFactSelect }) { + if (cell.isUnknown) { + return ( + + + — + + + ); + } + + if (cell.matchesCancer) { + return ( + + Same as cancer + + ); + } + + return ( + + {cell.facts.map((fact) => ( + + ))} + + ); +} + +MatrixCell.propTypes = { + cell: PropTypes.shape({ + isUnknown: PropTypes.bool, + matchesCancer: PropTypes.bool, + facts: PropTypes.array, + }).isRequired, + activeFactId: PropTypes.string, + onFactSelect: PropTypes.func, +}; + +/** + * The cancers side by side: attributes down the left, one column per cancer, + * tumors in their own sections underneath their parent. Rows whose values + * differ between cancers are marked, since those are the reason to compare. + */ +export default function CancerComparisonMatrix({ + cancers = [], + activeFactId = "", + onFactSelect = undefined, +}) { + const matrix = useMemo(() => buildCancerComparisonMatrix(cancers), [cancers]); + + if (!matrix.hasData) { + return ( + + No cancer summary data available for this patient. + + ); + } + + const gridTemplateColumns = `minmax(104px, auto) repeat(${matrix.columns.length}, minmax(0, 1fr))`; + + return ( + + + + + + Attribute + + + {matrix.columns.map((column) => ( + + + + {column.label} + + + + {`${column.documentedCount} of ${column.fieldCount} documented`} + + + ))} + + + + {matrix.sections.map((section) => ( + + + + + {section.label} + + + + {section.rows.map((row) => ( + + + + {row.label} + + {row.differs ? ( + + differs + + ) : null} + + {row.cells.map((cell) => ( + + + + ))} + + ))} + + ))} + + ); +} + +CancerComparisonMatrix.propTypes = { + cancers: PropTypes.arrayOf(PropTypes.object), + activeFactId: PropTypes.string, + onFactSelect: PropTypes.func, +}; diff --git a/src/components/patient/CancerTumorSummaryCard.jsx b/src/components/patient/CancerTumorSummaryCard.jsx index 9ae8b12..ef381dd 100644 --- a/src/components/patient/CancerTumorSummaryCard.jsx +++ b/src/components/patient/CancerTumorSummaryCard.jsx @@ -12,6 +12,9 @@ import { Typography, } from "@mui/material"; import { toDisplayName } from "../../utils/displayNames"; +import usePatientViewPresentation from "../../hooks/usePatientViewPresentation"; +import CancerComparisonMatrix from "./CancerComparisonMatrix"; +import { PATIENT_VIEW_TYPE } from "../../constants/patientViewTypography"; import SectionCollapseToggle from "./SectionCollapseToggle"; const SUMMARY_CATEGORY_PRIORITY = [ @@ -279,6 +282,9 @@ export default function CancerTumorSummaryCard({ }) { const activeFactId = String(factSelection?.factId || "").trim(); const normalizedCancers = Array.isArray(cancers) ? cancers : []; + // The improved view compares the cancers in one matrix; the alpha view keeps + // the per-cancer cards. + const { isImproved } = usePatientViewPresentation(); return ( @@ -357,6 +363,12 @@ export default function CancerTumorSummaryCard({ No cancer summary data available for this patient. + ) : isImproved ? ( + ) : ( /* Cancer records sit side by side and reflow to fewer columns as the panel narrows; stacked full-width rows left most of the row empty. */ diff --git a/src/components/patient/EventRelationTimelineCard.jsx b/src/components/patient/EventRelationTimelineCard.jsx index 4943006..387d80d 100644 --- a/src/components/patient/EventRelationTimelineCard.jsx +++ b/src/components/patient/EventRelationTimelineCard.jsx @@ -62,12 +62,15 @@ import { } from "../../utils/patientView/timelineChartLayout"; import { describeViewportRange, + formatHandleDate, getMinimumWindowRatio, viewportToDateWindow, } from "../../utils/patientView/timelineViewport"; import { clientXToSvgX } from "../../hooks/useTimelineViewport"; +import usePatientViewPresentation from "../../hooks/usePatientViewPresentation"; import useLinkedTimelineViewport from "../../hooks/useLinkedTimelineViewport"; import { TIMELINE_CONTENT_PADDING_X, TIMELINE_PLOT_INSET } from "../../constants/timelineFrame"; +import { PATIENT_VIEW_TYPE } from "../../constants/patientViewTypography"; import SectionCollapseToggle from "./SectionCollapseToggle"; import TimelineAxis from "./timeline/TimelineAxis"; import TimelineOverviewStrip from "./timeline/TimelineOverviewStrip"; @@ -75,6 +78,8 @@ import TimelineZoomControls from "./timeline/TimelineZoomControls"; const DEFAULT_CONTAINER_WIDTH = 1040; const AXIS_FONT_SIZE = 10; +// Dash pattern that marks a negated span without relying on its red color. +const NEGATED_DASH_ARRAY = "7 4"; const ZOOM_CONTROL_LABELS = { group: "Event timeline zoom controls", @@ -130,6 +135,9 @@ export function getEventTimelineColors(theme) { // shared with the document timeline (getOverviewStripColors). axisText: textSecondary, axisLine: palette.text?.disabled || textSecondary, + // Banding behind alternate lane groups, so a mark reads against its own + // group rather than blending into the one below it. + laneStripe: alpha(textSecondary, isDark ? 0.1 : 0.06), }; } @@ -207,6 +215,44 @@ RelationLegendItem.propTypes = { relation: PropTypes.string.isRequired, }; +const NEGATED_LEGEND_TITLE = "Negated: the note records the concept's absence"; + +/** Explains what red means, and carries the same dash the marks use. */ +function NegatedLegendItem() { + return ( + + + {NEGATED_LEGEND_TITLE} + + + Negated + + ); +} + const visuallyHiddenSx = { position: "absolute", // "1px", not 1: MUI's sx treats a bare 1 on width/height as 100%, which makes @@ -320,15 +366,24 @@ function SpanMark({ idPrefix, onToggle, selectedOutlineColor = SELECTED_OUTLINE_COLOR, + dashNegated = false, + formatDate = (value) => value, }) { const glyph = getEventRelationGlyph({ ...span, x1, x2 }, { expanded }); const stroke = span.negated ? NEGATED_RELATION_COLOR : RELATION_COLOR; + // Red vs green is the alpha's only visual cue for negation, which fails for + // the most common color blindness. The improved view dashes negated marks so + // the distinction survives without color (WCAG 1.4.1). + const strokeDasharray = dashNegated && span.negated ? NEGATED_DASH_ARRAY : undefined; const isDimmed = hasActiveSelection && !isSelected; const strokeOpacity = isDimmed ? 0.3 : expanded ? 0.75 : 0.5; - const tooltip = buildEventRelationTooltip(span, { includeDuration: glyph.kind === "span" }); - const label = `${span.conceptLabels.join(", ")}. ${span.laneGroup}. ${span.relation1} ${ + const tooltip = buildEventRelationTooltip(span, { + includeDuration: glyph.kind === "span", + formatDate, + }); + const label = `${span.conceptLabels.join(", ")}. ${span.laneGroup}. ${span.relation1} ${formatDate( span.start - }; ${span.relation2} ${span.end}.${span.negated ? " Negated." : ""}`; + )}; ${span.relation2} ${formatDate(span.end)}.${span.negated ? " Negated." : ""}`; const selectionClass = isSelected ? "selected" : isDimmed ? "unselected" : ""; const handleKeyDown = (event) => { @@ -380,6 +435,7 @@ function SpanMark({ stroke={stroke} strokeWidth={4} strokeOpacity={strokeOpacity} + strokeDasharray={strokeDasharray} /> ); @@ -417,6 +473,7 @@ function SpanMark({ stroke={stroke} strokeWidth={5} strokeOpacity={strokeOpacity} + strokeDasharray={strokeDasharray} markerStart={markerUrl(idPrefix, glyph.markerStart, isSelected)} markerEnd={markerUrl(idPrefix, glyph.markerEnd, isSelected)} /> @@ -437,6 +494,8 @@ SpanMark.propTypes = { idPrefix: PropTypes.string.isRequired, onToggle: PropTypes.func.isRequired, selectedOutlineColor: PropTypes.string, + dashNegated: PropTypes.bool, + formatDate: PropTypes.func, }; export default function EventRelationTimelineCard({ @@ -456,6 +515,7 @@ export default function EventRelationTimelineCard({ const shouldRender = shouldShowEventRelationTimeline(patientId); const theme = useTheme(); const colors = useMemo(() => getEventTimelineColors(theme), [theme]); + const { isImproved } = usePatientViewPresentation(); const generatedId = useId().replace(/:/g, ""); const panelBodyId = collapsiblePanelId || `${generatedId}-event-relation-timeline-body`; const descriptionId = `${generatedId}-event-relation-description`; @@ -684,6 +744,30 @@ export default function EventRelationTimelineCard({ const overviewRows = useMemo(() => buildEventRelationOverviewRows(groups), [groups]); + // "Viewing of ": the chart's axis and the strip's axis + // cover different spans, and saying so beats leaving readers to infer it. + const formatRange = (startDate, endDate) => + `${formatHandleDate(startDate, { includeYear: true })} – ${formatHandleDate(endDate, { + includeYear: true, + })}`; + // One date format across the view: "Jan 23, 2010" everywhere a date is read, + // rather than ISO here and slashes there. + const formatSpanDate = useCallback( + (isoDate) => { + if (!isImproved) { + return isoDate; + } + const [year, month, day] = String(isoDate || "").split("-").map(Number); + return Number.isFinite(year) + ? formatHandleDate(new Date(year, (month || 1) - 1, day || 1), { includeYear: true }) + : isoDate; + }, + [isImproved] + ); + const [visibleStartDate, visibleEndDate] = visibleX.domain(); + const visibleRangeLabel = formatRange(visibleStartDate, visibleEndDate); + const fullRangeLabel = formatRange(domain.startDate, domain.endDate); + const ageAxis = useMemo( () => computeEventRelationAgeAxis(domain, visibleX, resolvedBirthDate), [domain, visibleX, resolvedBirthDate] @@ -736,6 +820,7 @@ export default function EventRelationTimelineCard({ const cardError = loadError || modelState.error; const visibleSpans = visibleModel?.spans || []; + const hasNegatedSpan = visibleSpans.some((span) => span.negated); const presentRelations = visibleModel?.presentRelations || []; const relationChipLabel = effectiveViewMode === EVENT_RELATION_TIMELINE_SCOPE_CURRENT_REPORT @@ -777,7 +862,10 @@ export default function EventRelationTimelineCard({ > @@ -889,11 +977,14 @@ export default function EventRelationTimelineCard({ whiteSpace: "nowrap", }} > - Event Occurrence: + {isImproved + ? "Event occurrence, relative to the date at that end:" + : "Event Occurrence:"} {presentRelations.map((relation) => ( ))} + {isImproved && hasNegatedSpan ? : null} + {isImproved && groupIndex % 2 === 1 ? ( + + ) : null} {!group.expanded ? ( {heatmap.map((bin, binIndex) => ( @@ -1205,6 +1308,8 @@ export default function EventRelationTimelineCard({ idPrefix={generatedId} onToggle={handleSpanToggle} selectedOutlineColor={colors.selectedOutline} + dashNegated={isImproved} + formatDate={formatSpanDate} /> ))} @@ -1338,9 +1443,17 @@ export default function EventRelationTimelineCard({ color="text.secondary" sx={{ display: "block", mt: 0.5 }} > - {`${baseModel.patientId || patientId} | ${visibleSpans.length} spans from ${ - visibleModel?.matchedRowCount || 0 - } relations`} + {isImproved + ? `${baseModel.patientId || patientId} | ${ + isZoomed + ? `Viewing ${visibleRangeLabel} of ${fullRangeLabel}` + : `Showing the full range: ${fullRangeLabel}` + } | ${ + visibleModel?.matchedRowCount || 0 + } relations drawn as ${visibleSpans.length} spans (relations sharing a lane and both dates merge into one)` + : `${baseModel.patientId || patientId} | ${visibleSpans.length} spans from ${ + visibleModel?.matchedRowCount || 0 + } relations`} {selectedSpanCount > 0 ? ` | ${selectedSpanCount} selected` : ""} diff --git a/src/components/patient/PatientDemographicsCard.jsx b/src/components/patient/PatientDemographicsCard.jsx index 81d40b4..2c1486c 100644 --- a/src/components/patient/PatientDemographicsCard.jsx +++ b/src/components/patient/PatientDemographicsCard.jsx @@ -1,5 +1,7 @@ import React from "react"; import PropTypes from "prop-types"; +import { PATIENT_VIEW_TYPE } from "../../constants/patientViewTypography"; +import usePatientViewPresentation from "../../hooks/usePatientViewPresentation"; import { Box, Card, @@ -7,7 +9,7 @@ import { Typography, } from "@mui/material"; -function DemographicItem({ label, value = "", capitalize = false }) { +function DemographicItem({ label, value = "", capitalize = false, typeSteps = false }) { return ( {label} @@ -31,6 +38,7 @@ function DemographicItem({ label, value = "", capitalize = false }) { sx={{ fontWeight: 600, lineHeight: 1.12, + ...(typeSteps ? PATIENT_VIEW_TYPE.value : {}), overflowWrap: "anywhere", minWidth: 0, ...(capitalize ? { textTransform: "capitalize" } : {}), @@ -44,6 +52,7 @@ function DemographicItem({ label, value = "", capitalize = false }) { DemographicItem.propTypes = { label: PropTypes.string.isRequired, + typeSteps: PropTypes.bool, value: PropTypes.string, capitalize: PropTypes.bool, }; @@ -57,6 +66,7 @@ function getRaceEthnicity(demographics = {}) { export default function PatientDemographicsCard({ patientData = null }) { const demographics = patientData?.demographics || {}; + const { isImproved } = usePatientViewPresentation(); return ( - - + + - - - + + + diff --git a/src/components/patient/PatientDocumentsCard.jsx b/src/components/patient/PatientDocumentsCard.jsx index dd189c0..fdbbc07 100644 --- a/src/components/patient/PatientDocumentsCard.jsx +++ b/src/components/patient/PatientDocumentsCard.jsx @@ -24,11 +24,13 @@ import { import { computeOverviewStripLayout, describeViewportRange, + formatHandleDate, getMinimumWindowRatio, viewportToDateWindow, } from "../../utils/patientView/timelineViewport"; import { clientXToSvgX } from "../../hooks/useTimelineViewport"; import useLinkedTimelineViewport from "../../hooks/useLinkedTimelineViewport"; +import usePatientViewPresentation from "../../hooks/usePatientViewPresentation"; import { COMPACT_TIMELINE_WIDTH, DOCUMENT_CHART_BORDER_WIDTH, @@ -36,6 +38,7 @@ import { TIMELINE_PLOT_INSET, } from "../../constants/timelineFrame"; import { getReadableTextColor } from "../../utils/colorContrast"; +import { PATIENT_VIEW_TYPE } from "../../constants/patientViewTypography"; import SectionCollapseToggle from "./SectionCollapseToggle"; import TimelineAxis from "./timeline/TimelineAxis"; import TimelineOverviewStrip from "./timeline/TimelineOverviewStrip"; @@ -248,6 +251,7 @@ export default function PatientDocumentsCard({ sectionLabel = "Patient Document Timeline", }) { const theme = useTheme(); + const { isImproved } = usePatientViewPresentation(); const timelineColors = getTimelineSvgColors(theme); // High-contrast foreground for the "currently viewed" marker so its ring stays // visible on every theme (near-black on light themes, near-white on dark ones). @@ -497,6 +501,14 @@ export default function PatientDocumentsCard({ const clientToSvgX = useCallback((clientX) => clientXToSvgX(svgRef.current, clientX), []); + const formatRange = (startDate, endDate) => + `${formatHandleDate(startDate, { includeYear: true })} – ${formatHandleDate(endDate, { + includeYear: true, + })}`; + const visibleWindow = viewportToDateWindow(viewport, dateDomain.startDate, dateDomain.endDate); + const visibleRangeLabel = formatRange(visibleWindow.startDate, visibleWindow.endDate); + const fullRangeLabel = formatRange(dateDomain.startDate, dateDomain.endDate); + const handlePointerDown = (event) => { handlePlotPointerDown(event, { clientToX: clientToSvgX, plotWidth }); }; @@ -550,7 +562,10 @@ export default function PatientDocumentsCard({ > + {isImproved ? ( + + {isZoomed + ? `Viewing ${visibleRangeLabel} of ${fullRangeLabel}` + : `Showing the full range: ${fullRangeLabel}`} + + ) : null} + Click a point to load that document (Tab + Enter/Space for keyboard selection). Use the zoom buttons, the + and − keys, or the handles on the overview strip below diff --git a/src/components/patient/PatientViewPresentationProvider.jsx b/src/components/patient/PatientViewPresentationProvider.jsx new file mode 100644 index 0000000..9800ae0 --- /dev/null +++ b/src/components/patient/PatientViewPresentationProvider.jsx @@ -0,0 +1,63 @@ +import React, { createContext, useCallback, useEffect, useMemo, useState } from "react"; +import PropTypes from "prop-types"; +import { + DEFAULT_PATIENT_VIEW_PRESENTATION, + PATIENT_VIEW_PRESENTATION, + PATIENT_VIEW_PRESENTATION_STORAGE_KEY, + normalizePatientViewPresentation, +} from "../../constants/patientViewPresentation"; + +export const PatientViewPresentationContext = createContext(null); + +function readStoredPresentation() { + try { + return normalizePatientViewPresentation( + localStorage.getItem(PATIENT_VIEW_PRESENTATION_STORAGE_KEY) + ); + } catch { + // localStorage unavailable (private window, blocked site data). + return DEFAULT_PATIENT_VIEW_PRESENTATION; + } +} + +/** + * Holds the patient view's presentation choice for every panel under it, and + * remembers it across visits. Renders no DOM. Panels read it through + * usePatientViewPresentation, which falls back to the default without a + * provider, so a panel rendered on its own still works. + */ +export default function PatientViewPresentationProvider({ children }) { + const [presentation, setPresentationState] = useState(readStoredPresentation); + + const setPresentation = useCallback((nextPresentation) => { + setPresentationState(normalizePatientViewPresentation(nextPresentation)); + }, []); + + useEffect(() => { + try { + localStorage.setItem(PATIENT_VIEW_PRESENTATION_STORAGE_KEY, presentation); + } catch { + // Not remembering the choice is better than breaking the view. + } + }, [presentation]); + + const value = useMemo( + () => ({ + presentation, + setPresentation, + isImproved: presentation === PATIENT_VIEW_PRESENTATION.IMPROVED, + isAlpha: presentation === PATIENT_VIEW_PRESENTATION.ALPHA, + }), + [presentation, setPresentation] + ); + + return ( + + {children} + + ); +} + +PatientViewPresentationProvider.propTypes = { + children: PropTypes.node, +}; diff --git a/src/components/patient/PatientViewPresentationToggle.jsx b/src/components/patient/PatientViewPresentationToggle.jsx new file mode 100644 index 0000000..10cddef --- /dev/null +++ b/src/components/patient/PatientViewPresentationToggle.jsx @@ -0,0 +1,78 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { + FormControlLabel, + FormLabel, + Radio, + RadioGroup, + Stack, + Tooltip, + Typography, +} from "@mui/material"; +import usePatientViewPresentation from "../../hooks/usePatientViewPresentation"; +import { PATIENT_VIEW_PRESENTATION_OPTIONS } from "../../constants/patientViewPresentation"; + +/** + * Switches the patient view between the alpha's reading and the improved one. + * Both stay available so they can be compared directly. + */ +export default function PatientViewPresentationToggle({ dense = false }) { + const { presentation, setPresentation } = usePatientViewPresentation(); + const groupLabelId = "patient-view-presentation-label"; + + return ( + + + View + + setPresentation(event.target.value)} + sx={{ gap: 0.25 }} + > + {PATIENT_VIEW_PRESENTATION_OPTIONS.map((option) => ( + + + } + label={ + + {option.label} + + } + /> + + ))} + + + ); +} + +PatientViewPresentationToggle.propTypes = { + dense: PropTypes.bool, +}; diff --git a/src/components/patient/__tests__/CancerTumorSummaryCard.test.jsx b/src/components/patient/__tests__/CancerTumorSummaryCard.test.jsx index 468c22a..26e86db 100644 --- a/src/components/patient/__tests__/CancerTumorSummaryCard.test.jsx +++ b/src/components/patient/__tests__/CancerTumorSummaryCard.test.jsx @@ -3,6 +3,14 @@ import React from "react"; import { act } from "react"; import { createRoot } from "react-dom/client"; import CancerTumorSummaryCard from "../CancerTumorSummaryCard"; +import PatientViewPresentationProvider from "../PatientViewPresentationProvider"; +import { PATIENT_VIEW_PRESENTATION_STORAGE_KEY } from "../../../constants/patientViewPresentation"; + +/** The per-cancer cards are the alpha reading; the matrix is the default. */ +function renderAlpha(element) { + localStorage.setItem(PATIENT_VIEW_PRESENTATION_STORAGE_KEY, "alpha"); + return renderComponent({element}); +} function renderComponent(element) { const container = document.createElement("div"); @@ -59,9 +67,13 @@ const cancers = [ ]; describe("CancerTumorSummaryCard", () => { + beforeEach(() => { + localStorage.clear(); + }); + it("uses a content-height grid while preserving every fact and click behavior", () => { const onFactSelect = jest.fn(); - const { container, unmount } = renderComponent( + const { container, unmount } = renderAlpha( { it("exposes an accessible collapse toggle and hides the body when collapsed", () => { const onToggleExpanded = jest.fn(); - const { container, unmount } = renderComponent( + const { container, unmount } = renderAlpha( { unmount(); }); + + describe("improved view", () => { + const twoCancers = [ + cancers[0], + { + cancerId: "cancer-2", + title: "cancer-2", + collatedCancerFacts: [ + { + categoryName: "Location", + facts: [fact("c2-location", "Upper-Outer Quadrant of the Breast")], + }, + { categoryName: "Grade", facts: [fact("c2-grade", "1")] }, + ], + tnm: [{ data: { T: [fact("c2-t", "1")], N: [], M: [] } }], + tumors: { + listViewData: [ + { + id: "tumor-2", + data: [{ category: "Location", facts: [fact("t2-location", "Nipple")] }], + }, + ], + }, + }, + ]; + + it("compares the cancers in one matrix instead of separate cards", () => { + const onFactSelect = jest.fn(); + const { container, unmount } = renderComponent( + + ); + + try { + const matrix = container.querySelector("[data-testid='cancer-comparison-matrix']"); + expect(matrix).not.toBeNull(); + expect(container.querySelectorAll("[data-testid='cancer-summary-record']")).toHaveLength(0); + + const headers = [...matrix.querySelectorAll("th[scope='col']")].map((n) => n.textContent); + expect(headers[1]).toContain("Cancer 1"); + expect(headers[2]).toContain("Cancer 2"); + // Sparse records read as sparse. + expect(headers[1]).toMatch(/\d+ of \d+ documented/); + + // The differences are what the reader came for. + const gradeRow = [...matrix.querySelectorAll("th[scope='row']")].find((n) => + n.textContent.startsWith("Grade") + ); + expect(gradeRow.getAttribute("data-differs")).toBe("true"); + expect(gradeRow.textContent).toContain("differs"); + const locationRow = [...matrix.querySelectorAll("th[scope='row']")].find((n) => + n.textContent.startsWith("Location") + ); + expect(locationRow.getAttribute("data-differs")).toBe("false"); + + // Undocumented values are demoted, not hidden. + expect(matrix.textContent).toContain("—"); + expect(matrix.querySelector("[aria-label='Not documented']")).not.toBeNull(); + + // A tumor repeating its cancer's location says so once. + expect(matrix.textContent).toContain("Same as cancer"); + expect(matrix.textContent).toContain("Tumor 1"); + + // Values stay clickable, and link to their documents. + const gradeValue = [...matrix.querySelectorAll("button")].find( + (button) => button.textContent === "3" + ); + act(() => { + gradeValue.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onFactSelect).toHaveBeenCalledWith("c-grade"); + } finally { + unmount(); + } + }); + }); + it("renders only the header when collapsed", () => { const { container, unmount } = renderComponent( { it("lays cancer records out in a multi-column grid, with fact details spanning it", () => { const twoCancers = [cancers[0], { ...cancers[0], cancerId: "cancer-2", title: "cancer-2" }]; - const { container, unmount } = renderComponent( + const { container, unmount } = renderAlpha( { + beforeEach(() => { + localStorage.clear(); + }); + afterEach(() => { resetStaticEventRelationTimelineCacheForTests(); jest.clearAllMocks(); @@ -155,7 +161,7 @@ describe("EventRelationTimelineCard", () => { // Group labels count merged spans, in the alpha's "Group (n):" form. expect(container.textContent).toContain("Finding (2):"); expect(container.textContent).toContain("Treatment (1):"); - expect(container.textContent).toContain("3 spans from 3 relations"); + expect(container.textContent).toContain("3 relations drawn as 3 spans"); // Alpha colouring: green marks, red reserved for negation. const negatedMark = container.querySelector( '[data-negated="true"] .relation-icon' @@ -566,6 +572,58 @@ describe("EventRelationTimelineCard", () => { }); }); + it("reads the improved way by default, and the alpha way when switched", async () => { + mockTimelineFetch(); + const { container, unmount } = renderComponent( + + + + + ); + + try { + await waitFor(() => expect(container.querySelectorAll("[data-event-relation-id]")).toHaveLength(3)); + + // Improved: banded lane groups, a non-color cue for negation and a key + // for it, and a status line that explains the span/relation counts. + expect(container.querySelectorAll("[data-testid='event-timeline-lane-stripe']").length).toBeGreaterThan(0); + expect( + container.querySelector('[data-negated="true"] .relation-icon').getAttribute("stroke-dasharray") + ).toBe("7 4"); + expect( + container.querySelector('[data-negated="false"] .relation-icon').getAttribute("stroke-dasharray") + ).toBeNull(); + expect(container.textContent).toContain("Negated"); + expect(container.textContent).toContain("relations drawn as"); + expect(container.textContent).toContain("Showing the full range:"); + // Zoom in/out read as + and -, not two near-identical magnifiers. + expect(container.querySelector('button[aria-label="Zoom in event timeline"] svg') + .getAttribute("data-testid")).toBe("AddIcon"); + + // Switching to Alpha restores the ported reading. + act(() => { + container + .querySelector("[data-testid='patient-view-presentation-alpha']") + .click(); + }); + + expect(container.querySelectorAll("[data-testid='event-timeline-lane-stripe']")).toHaveLength(0); + expect( + container.querySelector('[data-negated="true"] .relation-icon').getAttribute("stroke-dasharray") + ).toBeNull(); + expect(container.textContent).toContain("3 spans from 3 relations"); + expect(container.textContent).not.toContain("relations drawn as"); + expect(container.querySelector('button[aria-label="Zoom in event timeline"] svg') + .getAttribute("data-testid")).toBe("ZoomInIcon"); + // Both readings keep the negation in text, for screen readers. + expect( + container.querySelector('[data-negated="true"]').getAttribute("aria-label") + ).toContain("Negated."); + } finally { + unmount(); + } + }); + it("does not fetch or render for non-target patients", () => { mockTimelineFetch(); const { container, unmount } = renderComponent( diff --git a/src/components/patient/__tests__/PatientDocumentsCard.test.jsx b/src/components/patient/__tests__/PatientDocumentsCard.test.jsx index c07a4bd..acce084 100644 --- a/src/components/patient/__tests__/PatientDocumentsCard.test.jsx +++ b/src/components/patient/__tests__/PatientDocumentsCard.test.jsx @@ -553,6 +553,33 @@ describe("PatientDocumentsCard", () => { unmount(); }); + + it("names the visible range against the whole range, in the improved view only", () => { + const { container, unmount } = renderComponent( + + ); + const caption = () => byTestId(container, "document-timeline-range-caption"); + + // At 100% there is no window to contrast with the whole range. + expect(caption().textContent).toMatch( + /^Showing the full range: [A-Z][a-z]{2} \d{1,2}, 2010 – [A-Z][a-z]{2} \d{1,2}, 2011$/ + ); + + act(() => { + container + .querySelector('[aria-label="Zoom in timeline"]') + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + // Zoomed, it names the window against the whole range. + expect(caption().textContent).toMatch( + /^Viewing [A-Z][a-z]{2} \d{1,2}, 20\d{2} – [A-Z][a-z]{2} \d{1,2}, 20\d{2} of [A-Z][a-z]{2} \d{1,2}, 2010 – [A-Z][a-z]{2} \d{1,2}, 2011$/ + ); + const [visible, whole] = caption().textContent.replace("Viewing ", "").split(" of "); + expect(visible).not.toBe(whole); + + unmount(); + }); + it("announces the selected range once the reader stops changing it", () => { jest.useFakeTimers(); const { container, unmount } = renderComponent( diff --git a/src/components/patient/__tests__/PatientViewPresentation.test.jsx b/src/components/patient/__tests__/PatientViewPresentation.test.jsx new file mode 100644 index 0000000..6ff27c8 --- /dev/null +++ b/src/components/patient/__tests__/PatientViewPresentation.test.jsx @@ -0,0 +1,118 @@ +/* eslint-disable testing-library/no-unnecessary-act, testing-library/no-container, testing-library/no-node-access */ +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import PatientViewPresentationProvider from "../PatientViewPresentationProvider"; +import PatientViewPresentationToggle from "../PatientViewPresentationToggle"; +import usePatientViewPresentation from "../../../hooks/usePatientViewPresentation"; +import { + DEFAULT_PATIENT_VIEW_PRESENTATION, + PATIENT_VIEW_PRESENTATION_STORAGE_KEY, +} from "../../../constants/patientViewPresentation"; + +function renderRoot(element) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(element); + }); + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function Probe({ seen }) { + const presentation = usePatientViewPresentation(); + // eslint-disable-next-line no-param-reassign + seen.current = presentation; + return {presentation.presentation}; +} + +describe("patient view presentation", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("opens on the improved view and switches on the radio", () => { + const seen = { current: null }; + const { container, unmount } = renderRoot( + + + + + ); + + expect(seen.current.presentation).toBe(DEFAULT_PATIENT_VIEW_PRESENTATION); + expect(seen.current.isImproved).toBe(true); + const radios = container.querySelectorAll('input[type="radio"]'); + expect(radios).toHaveLength(2); + expect(container.querySelector('[data-testid="patient-view-presentation-improved"]').checked).toBe( + true + ); + + act(() => { + container.querySelector('[data-testid="patient-view-presentation-alpha"]').click(); + }); + + expect(seen.current.presentation).toBe("alpha"); + expect(seen.current.isAlpha).toBe(true); + expect(localStorage.getItem(PATIENT_VIEW_PRESENTATION_STORAGE_KEY)).toBe("alpha"); + + unmount(); + }); + + it("remembers the choice for the next visit, and ignores a bad stored value", () => { + localStorage.setItem(PATIENT_VIEW_PRESENTATION_STORAGE_KEY, "alpha"); + const seen = { current: null }; + const first = renderRoot( + + + + ); + expect(seen.current.isAlpha).toBe(true); + first.unmount(); + + localStorage.setItem(PATIENT_VIEW_PRESENTATION_STORAGE_KEY, "nonsense"); + const second = renderRoot( + + + + ); + expect(seen.current.presentation).toBe(DEFAULT_PATIENT_VIEW_PRESENTATION); + second.unmount(); + }); + + it("reports the default for a panel rendered without a provider", () => { + const seen = { current: null }; + const { unmount } = renderRoot(); + + expect(seen.current.presentation).toBe(DEFAULT_PATIENT_VIEW_PRESENTATION); + // Writes are ignored rather than throwing. + act(() => seen.current.setPresentation("alpha")); + expect(seen.current.presentation).toBe(DEFAULT_PATIENT_VIEW_PRESENTATION); + + unmount(); + }); + + it("is announced as a labelled radio group", () => { + const { container, unmount } = renderRoot( + + + + ); + + const group = container.querySelector('[role="radiogroup"]'); + expect(group).not.toBeNull(); + const label = container.querySelector(`#${group.getAttribute("aria-labelledby")}`); + expect(label.textContent).toBe("View"); + expect(container.textContent).toContain("Alpha"); + expect(container.textContent).toContain("Improved"); + + unmount(); + }); +}); diff --git a/src/components/patient/timeline/TimelineOverviewStrip.jsx b/src/components/patient/timeline/TimelineOverviewStrip.jsx index be9531a..50bbeb4 100644 --- a/src/components/patient/timeline/TimelineOverviewStrip.jsx +++ b/src/components/patient/timeline/TimelineOverviewStrip.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import PropTypes from "prop-types"; import { styled, useTheme } from "@mui/material/styles"; +import usePatientViewPresentation from "../../../hooks/usePatientViewPresentation"; import TimelineAxis from "./TimelineAxis"; import { applySliderKey, @@ -34,7 +35,7 @@ const MINI_MARK_MIN_WIDTH = 3; * same in each. Text and outlines come from theme tokens that meet WCAG AA on * every palette. */ -export function getOverviewStripColors(theme) { +export function getOverviewStripColors(theme, { tintedTrack = false } = {}) { const palette = theme?.palette || {}; const textSecondary = palette.text?.secondary || "#505A5F"; const textPrimary = palette.text?.primary || "#0B0C0C"; @@ -42,10 +43,12 @@ export function getOverviewStripColors(theme) { const accent = palette.primary?.main || textSecondary; return { - // Light enough that pale miniature marks still show through. - trackFill: mutedLine, - trackOpacity: 0.16, - trackStroke: mutedLine, + // Light enough that pale miniature marks still show through. The improved + // view tints the track so it reads as its own surface, not a second plot: + // its axis covers the whole date range, the one above it only the selection. + trackFill: tintedTrack ? accent : mutedLine, + trackOpacity: tintedTrack ? 0.08 : 0.16, + trackStroke: tintedTrack ? accent : mutedLine, windowFill: accent, windowFillOpacity: 0.1, windowStroke: accent, @@ -109,7 +112,11 @@ export default function TimelineOverviewStrip({ labelClassName = undefined, }) { const theme = useTheme(); - const colors = useMemo(() => getOverviewStripColors(theme), [theme]); + const { isImproved } = usePatientViewPresentation(); + const colors = useMemo( + () => getOverviewStripColors(theme, { tintedTrack: isImproved }), + [theme, isImproved] + ); const [dragMode, setDragMode] = useState(null); const endDragRef = useRef(null); diff --git a/src/components/patient/timeline/TimelineZoomControls.jsx b/src/components/patient/timeline/TimelineZoomControls.jsx index 43f8707..7ad873a 100644 --- a/src/components/patient/timeline/TimelineZoomControls.jsx +++ b/src/components/patient/timeline/TimelineZoomControls.jsx @@ -1,11 +1,14 @@ import React from "react"; import PropTypes from "prop-types"; import { IconButton, Stack, Tooltip, Typography } from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; import KeyboardArrowLeftIcon from "@mui/icons-material/KeyboardArrowLeft"; import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; +import RemoveIcon from "@mui/icons-material/Remove"; import RestartAltIcon from "@mui/icons-material/RestartAlt"; import ZoomInIcon from "@mui/icons-material/ZoomIn"; import ZoomOutIcon from "@mui/icons-material/ZoomOut"; +import usePatientViewPresentation from "../../../hooks/usePatientViewPresentation"; const BUTTON_SX = { width: 24, height: 24, p: 0.25 }; @@ -50,6 +53,12 @@ export default function TimelineZoomControls({ disabled = false, labels, }) { + // Two magnifiers that differ only by the +/- inside them are a known + // recognition failure; the improved view uses plain + and - instead. + const { isImproved } = usePatientViewPresentation(); + const ZoomOutGlyph = isImproved ? RemoveIcon : ZoomOutIcon; + const ZoomInGlyph = isImproved ? AddIcon : ZoomInIcon; + return ( - + - + option.value === value) + ? value + : DEFAULT_PATIENT_VIEW_PRESENTATION; +} diff --git a/src/constants/patientViewTypography.js b/src/constants/patientViewTypography.js new file mode 100644 index 0000000..4285aed --- /dev/null +++ b/src/constants/patientViewTypography.js @@ -0,0 +1,21 @@ +// Four type steps for the patient view, and no more. +// +// Panel titles, field labels, values and axis ticks all used to sit within a +// couple of pixels of each other, so nothing ranked and the eye had nowhere to +// land. These are the only sizes the improved view uses for those four roles. + +export const PATIENT_VIEW_TYPE = Object.freeze({ + // 16/600 — panel titles. + panelTitle: Object.freeze({ fontSize: "1rem", fontWeight: 600, lineHeight: 1.25 }), + // 12/600 muted — field labels. Pair with color="text.secondary". + fieldLabel: Object.freeze({ + fontSize: "0.75rem", + fontWeight: 600, + lineHeight: 1.2, + letterSpacing: 0.2, + }), + // 14/500 — values. + value: Object.freeze({ fontSize: "0.875rem", fontWeight: 500, lineHeight: 1.3 }), + // 11/400 muted — axis ticks and other chart chrome. + axisTick: Object.freeze({ fontSize: "0.6875rem", fontWeight: 400, lineHeight: 1.2 }), +}); diff --git a/src/controllers/__tests__/cancerComparison.test.js b/src/controllers/__tests__/cancerComparison.test.js new file mode 100644 index 0000000..81a7a5e --- /dev/null +++ b/src/controllers/__tests__/cancerComparison.test.js @@ -0,0 +1,106 @@ +import { buildCancerComparisonMatrix } from "../cancerComparison"; + +const fact = (id, value) => ({ id, value }); + +const CANCERS = [ + { + cancerId: "cancer-1", + title: "patient_cancer_1", + collatedCancerFacts: [ + { categoryName: "Location", facts: [fact("c1-loc", "Lower-Outer Quadrant of the Breast")] }, + { categoryName: "Grade", facts: [fact("c1-grade", "3")] }, + { categoryName: "Genes", facts: [fact("c1-gene", "ERBB2 Gene")] }, + ], + tnm: [{ data: { T: [fact("c1-t", "1")], N: [], M: [fact("c1-m", "0")] } }], + tumors: { + listViewData: [ + { + id: "tumor-1", + data: [ + { category: "Location", facts: [fact("t1-loc", "Lower-Outer Quadrant of the Breast")] }, + { category: "Laterality", facts: [fact("t1-lat", "Right")] }, + ], + }, + ], + }, + }, + { + cancerId: "cancer-2", + title: "patient_cancer_2", + collatedCancerFacts: [ + { categoryName: "Location", facts: [fact("c2-loc", "Lower-Outer Quadrant of the Breast")] }, + { categoryName: "Grade", facts: [fact("c2-grade", "1")] }, + ], + tnm: [{ data: { T: [], N: [], M: [] } }], + tumors: { + listViewData: [ + { + id: "tumor-2", + data: [{ category: "Location", facts: [fact("t2-loc", "Nipple")] }], + }, + ], + }, + }, +]; + +const rowByLabel = (matrix, sectionKey, label) => + matrix.sections + .find((section) => section.key === sectionKey) + .rows.find((row) => row.label === label); + +describe("cancer comparison matrix", () => { + it("puts one column per cancer, with how much of each is documented", () => { + const matrix = buildCancerComparisonMatrix(CANCERS); + + expect(matrix.hasData).toBe(true); + expect(matrix.columns.map((column) => column.label)).toEqual(["Cancer 1", "Cancer 2"]); + expect(matrix.columns[0].title).toBe("patient_cancer_1"); + // Same denominator for both, so the columns are comparable. + expect(matrix.columns[0].fieldCount).toBe(matrix.columns[1].fieldCount); + expect(matrix.columns[0].documentedCount).toBeGreaterThan(matrix.columns[1].documentedCount); + }); + + it("orders attributes clinically and keeps every TNM row", () => { + const matrix = buildCancerComparisonMatrix(CANCERS); + const labels = matrix.sections.find((section) => section.key === "cancer").rows.map((row) => row.label); + + expect(labels.slice(0, 3)).toEqual(["Location", "Grade", "Gene(s)"]); + expect(labels).toEqual(expect.arrayContaining(["TNM T", "TNM N", "TNM M"])); + }); + + it("marks the rows where the cancers disagree", () => { + const matrix = buildCancerComparisonMatrix(CANCERS); + + // The whole point of the matrix: grade 3 vs 1, and a gene on cancer 1 only. + expect(rowByLabel(matrix, "cancer", "Grade").differs).toBe(true); + expect(rowByLabel(matrix, "cancer", "Gene(s)").differs).toBe(true); + // Shared values are not noise to flag. + expect(rowByLabel(matrix, "cancer", "Location").differs).toBe(false); + }); + + it("flags undocumented cells instead of dropping them", () => { + const matrix = buildCancerComparisonMatrix(CANCERS); + const nodes = rowByLabel(matrix, "cancer", "TNM N"); + + expect(nodes.cells.every((cell) => cell.isUnknown)).toBe(true); + expect(rowByLabel(matrix, "cancer", "Gene(s)").cells[1].isUnknown).toBe(true); + expect(rowByLabel(matrix, "cancer", "Gene(s)").cells[0].facts).toHaveLength(1); + }); + + it("nests tumors and says when a tumor just repeats its cancer", () => { + const matrix = buildCancerComparisonMatrix(CANCERS); + const tumorSection = matrix.sections.find((section) => section.key === "tumor-1"); + + expect(tumorSection.label).toBe("Tumor 1"); + const location = tumorSection.rows.find((row) => row.label === "Location"); + // Cancer 1's tumor repeats the cancer's location; cancer 2's differs. + expect(location.cells[0].matchesCancer).toBe(true); + expect(location.cells[1].matchesCancer).toBe(false); + expect(location.differs).toBe(true); + }); + + it("has nothing to show without cancers", () => { + expect(buildCancerComparisonMatrix([]).hasData).toBe(false); + expect(buildCancerComparisonMatrix(null).hasData).toBe(false); + }); +}); diff --git a/src/controllers/cancerComparison.js b/src/controllers/cancerComparison.js new file mode 100644 index 0000000..0e3acea --- /dev/null +++ b/src/controllers/cancerComparison.js @@ -0,0 +1,210 @@ +import { toDisplayName } from "../utils/displayNames"; + +// Rows the matrix shows, in clinical reading order. Anything else a cancer +// carries is appended after these, so a new category is never silently dropped. +export const CANCER_COMPARISON_CATEGORIES = Object.freeze([ + "Location", + "Laterality", + "Histology", + "Grade", + "Stage", + "Metastatic Site", + "Genes", +]); + +const TNM_KEYS = Object.freeze(["T", "N", "M"]); + +export function getFactLabel(fact = {}) { + const rawLabel = String(fact?.value || fact?.prettyName || fact?.name || "").trim(); + if (!rawLabel) { + return String(fact?.id || "Unknown").trim(); + } + return toDisplayName(rawLabel) || rawLabel; +} + +function normalizeCategoryName(value) { + return String(value || "").trim(); +} + +/** Cancer-level facts keyed by their category name. */ +function getCancerFactsByCategory(cancer = {}) { + const byCategory = new Map(); + (Array.isArray(cancer?.collatedCancerFacts) ? cancer.collatedCancerFacts : []).forEach((group) => { + const categoryName = normalizeCategoryName(group?.categoryName || group?.category); + if (!categoryName) { + return; + } + const facts = (Array.isArray(group?.facts) ? group.facts : []).filter((fact) => + String(fact?.id || "").trim() + ); + byCategory.set(categoryName, [...(byCategory.get(categoryName) || []), ...facts]); + }); + + const tnmData = cancer?.tnm?.[0]?.data || {}; + TNM_KEYS.forEach((key) => { + const facts = Array.isArray(tnmData[key]) ? tnmData[key] : []; + byCategory.set(`TNM ${key}`, facts); + }); + + return byCategory; +} + +/** One tumor's facts keyed by category name. */ +function getTumorFactsByCategory(tumor = {}) { + const byCategory = new Map(); + (Array.isArray(tumor?.data) ? tumor.data : []).forEach((category) => { + const categoryName = normalizeCategoryName(category?.category); + if (!categoryName) { + return; + } + const facts = (Array.isArray(category?.facts) ? category.facts : []).filter((fact) => + String(fact?.id || "").trim() + ); + byCategory.set(categoryName, [...(byCategory.get(categoryName) || []), ...facts]); + }); + return byCategory; +} + +function getTumors(cancer = {}) { + return Array.isArray(cancer?.tumors?.listViewData) ? cancer.tumors.listViewData : []; +} + +/** The cell's values as one comparable string: same facts, same key. */ +function toValueKey(facts = []) { + return facts + .map((fact) => getFactLabel(fact).toLowerCase()) + .sort() + .join("|"); +} + +function orderCategories(presentCategories) { + const ordered = CANCER_COMPARISON_CATEGORIES.filter((category) => + presentCategories.has(category) + ); + const extras = [...presentCategories] + .filter((category) => !CANCER_COMPARISON_CATEGORIES.includes(category)) + .sort((left, right) => left.localeCompare(right, undefined, { sensitivity: "base" })); + return [...ordered, ...extras]; +} + +function buildRow({ key, label, factsByColumn, columns, cancerValueKeys }) { + const cells = columns.map((column) => { + const facts = factsByColumn.get(column.key) || []; + const valueKey = toValueKey(facts); + return { + columnKey: column.key, + facts, + isUnknown: facts.length === 0, + // A tumor repeating its cancer's value is noise; the matrix says so once + // instead of printing the same long location six times. + matchesCancer: Boolean(valueKey) && cancerValueKeys?.get(column.key) === valueKey, + valueKey, + }; + }); + + return { + key, + label, + cells, + // What the reader is here for: where the cancers disagree. + differs: new Set(cells.map((cell) => cell.valueKey)).size > 1, + documentedCount: cells.filter((cell) => !cell.isUnknown).length, + }; +} + +/** + * Reshapes the cancer summary into a comparison matrix: one column per cancer, + * one row per attribute, tumors nested in their own sections. Three near + * identical cards hide their differences; a matrix puts them in one column. + * + * Returns `{ columns, sections, hasData }`, with each cell carrying its facts + * (so they stay clickable), whether it is undocumented, and whether a tumor + * value simply repeats its cancer's. + */ +export function buildCancerComparisonMatrix(cancers = []) { + const normalizedCancers = (Array.isArray(cancers) ? cancers : []).filter(Boolean); + + const columns = normalizedCancers.map((cancer, index) => ({ + key: String(cancer?.cancerId || cancer?.title || `cancer-${index + 1}`).trim(), + label: `Cancer ${index + 1}`, + title: String(cancer?.title || "").trim(), + })); + + const cancerFacts = normalizedCancers.map(getCancerFactsByCategory); + const tumorsByCancer = normalizedCancers.map(getTumors); + const tumorCount = tumorsByCancer.reduce((most, tumors) => Math.max(most, tumors.length), 0); + + const cancerCategories = new Set(); + cancerFacts.forEach((byCategory) => { + byCategory.forEach((facts, category) => { + if (facts.length > 0 || category.startsWith("TNM ")) { + cancerCategories.add(category); + } + }); + }); + + const cancerRows = orderCategories(cancerCategories).map((category) => + buildRow({ + key: `cancer:${category}`, + label: category === "Genes" ? "Gene(s)" : category, + columns, + factsByColumn: new Map( + columns.map((column, index) => [column.key, cancerFacts[index].get(category) || []]) + ), + }) + ); + + const sections = [{ key: "cancer", label: "Cancer", rows: cancerRows }]; + + for (let tumorIndex = 0; tumorIndex < tumorCount; tumorIndex += 1) { + const tumorFacts = tumorsByCancer.map((tumors) => getTumorFactsByCategory(tumors[tumorIndex])); + const categories = new Set(); + tumorFacts.forEach((byCategory) => { + byCategory.forEach((facts, category) => { + if (facts.length > 0) { + categories.add(category); + } + }); + }); + if (categories.size === 0) { + continue; + } + + const rows = orderCategories(categories).map((category) => { + const cancerValueKeys = new Map( + columns.map((column, index) => [ + column.key, + toValueKey(cancerFacts[index].get(category) || []), + ]) + ); + return buildRow({ + key: `tumor-${tumorIndex + 1}:${category}`, + label: category === "Genes" ? "Gene(s)" : category, + columns, + factsByColumn: new Map( + columns.map((column, index) => [column.key, tumorFacts[index].get(category) || []]) + ), + cancerValueKeys, + }); + }); + + sections.push({ key: `tumor-${tumorIndex + 1}`, label: `Tumor ${tumorIndex + 1}`, rows }); + } + + // "5 of 9 documented" per cancer, so a sparse record reads as sparse. + const allRows = sections.flatMap((section) => section.rows); + const columnsWithCompleteness = columns.map((column) => { + const cells = allRows.map((row) => row.cells.find((cell) => cell.columnKey === column.key)); + return { + ...column, + documentedCount: cells.filter((cell) => cell && !cell.isUnknown).length, + fieldCount: cells.length, + }; + }); + + return { + columns: columnsWithCompleteness, + sections, + hasData: columns.length > 0 && allRows.length > 0, + }; +} diff --git a/src/hooks/usePatientViewPresentation.js b/src/hooks/usePatientViewPresentation.js new file mode 100644 index 0000000..4604efe --- /dev/null +++ b/src/hooks/usePatientViewPresentation.js @@ -0,0 +1,22 @@ +import { useContext } from "react"; +import { PatientViewPresentationContext } from "../components/patient/PatientViewPresentationProvider"; +import { + DEFAULT_PATIENT_VIEW_PRESENTATION, + PATIENT_VIEW_PRESENTATION, +} from "../constants/patientViewPresentation"; + +const FALLBACK = Object.freeze({ + presentation: DEFAULT_PATIENT_VIEW_PRESENTATION, + setPresentation: () => {}, + isImproved: DEFAULT_PATIENT_VIEW_PRESENTATION === PATIENT_VIEW_PRESENTATION.IMPROVED, + isAlpha: DEFAULT_PATIENT_VIEW_PRESENTATION === PATIENT_VIEW_PRESENTATION.ALPHA, +}); + +/** + * Which presentation the patient view is set to: `{ presentation, + * setPresentation, isImproved, isAlpha }`. Outside a + * PatientViewPresentationProvider it reports the default and ignores writes. + */ +export default function usePatientViewPresentation() { + return useContext(PatientViewPresentationContext) || FALLBACK; +} diff --git a/src/utils/patientView/eventRelationTimelineLayout.js b/src/utils/patientView/eventRelationTimelineLayout.js index fba785c..14e1faa 100644 --- a/src/utils/patientView/eventRelationTimelineLayout.js +++ b/src/utils/patientView/eventRelationTimelineLayout.js @@ -294,7 +294,10 @@ export function formatEventRelationDuration(startLabel, endLabel) { } /** Alpha tooltip text, including its "Name (xN)" duplicate collapsing. */ -export function buildEventRelationTooltip(span = {}, { includeDuration = true } = {}) { +export function buildEventRelationTooltip( + span = {}, + { includeDuration = true, formatDate = (value) => value } = {} +) { const nameCounts = {}; (span.conceptLabels || []).forEach((name) => { const key = name || "Unknown"; @@ -310,7 +313,9 @@ export function buildEventRelationTooltip(span = {}, { includeDuration = true } ? `Duration: ${formatEventRelationDuration(span.start, span.end)}\n` : ""; - return `${durationLine}${span.relation1}: ${span.start}\n${span.relation2}: ${span.end}\nConcept Name: ${conceptNamesDisplay}`; + return `${durationLine}${span.relation1}: ${formatDate(span.start)}\n${span.relation2}: ${formatDate( + span.end + )}\nConcept Name: ${conceptNamesDisplay}`; } /** diff --git a/src/views/patient.jsx b/src/views/patient.jsx index 6b1a737..7ce9c4a 100644 --- a/src/views/patient.jsx +++ b/src/views/patient.jsx @@ -20,6 +20,7 @@ import PatientDocumentsCard from "../components/patient/PatientDocumentsCard"; import PatientDocumentDrawer from "../components/patient/PatientDocumentDrawer"; import EventRelationTimelineCard from "../components/patient/EventRelationTimelineCard"; import TimelineLinkProvider from "../components/patient/timeline/TimelineLinkProvider"; +import PatientViewPresentationToggle from "../components/patient/PatientViewPresentationToggle"; import PatientSummaryCard from "../components/patient/PatientSummaryCard"; import { loadPatientFilterSummary, @@ -32,6 +33,7 @@ import { findDocumentIdsForConceptIds } from "../utils/patientView/documentMenti import { resolveFactSelection } from "../utils/patientView/factLinking"; import { resolveSummarySelection } from "../utils/patientView/summarySelection"; import { getThemeByKey } from "../themes"; +import usePatientViewPresentation from "../hooks/usePatientViewPresentation"; const DEFAULT_THEME_KEY = "govuk"; const DETAIL_SECTION_DEFINITIONS = [ @@ -168,6 +170,10 @@ function getPatientIdFromSearchParams(searchParams) { export default function PatientView() { const [searchParams] = useSearchParams(); + // One containment level in the improved view: the panels' own paper surface + // and the spacing between them, instead of a border around a bordered card. + const { isImproved } = usePatientViewPresentation(); + const panelBorderWidth = isImproved ? 0 : 1; const [patientIdInput, setPatientIdInput] = useState(""); const { patientData, timelineData, cancerSummary, isLoading, errorMessage, loadPatient } = usePatientData(); @@ -614,7 +620,10 @@ export default function PatientView() { - {isLoading ? : null} + + + {isLoading ? : null} + @@ -672,7 +681,7 @@ export default function PatientView() { sx={{ minWidth: 0, minHeight: 0, - border: 1, + border: panelBorderWidth, borderColor: "divider", borderRadius: 1, overflow: "hidden", @@ -695,7 +704,7 @@ export default function PatientView() { sx={{ minWidth: 0, minHeight: 0, - border: 1, + border: panelBorderWidth, borderColor: "divider", borderRadius: 1, overflow: "visible", From b67e19fed88fd1e7ed24036ef690263239f65227 Mon Sep 17 00:00:00 2001 From: John Levander Date: Mon, 28 Sep 2026 13:17:35 -0400 Subject: [PATCH 3/3] Keep the cancer comparison short enough to leave the timelines in view The matrix was ~530px tall for three cancers, pushing both timelines below the fold. It now reads in about a third of that, without dropping anything: - TNM reads as one row ("T1 M0"), the way a stage is read, rather than three rows a third full each. - Rows with nothing to compare fold behind a count: nothing documented for any cancer, or a tumor row that only repeats its cancer. One click shows them, and the per-cancer completeness count still counts every field. - Denser rows, with "differs" beside the label rather than on its own line. - In the patient drawer the panel opens folded to its header, which reports how many attributes differ; the standalone view still opens it expanded. - Cells render plain buttons instead of an MUI Button and Tooltip each, which also cuts the panel's share of the (slow) re-render when the theme changes. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 6 + src/components/EmbeddedPatientView.jsx | 5 +- .../patient/CancerComparisonMatrix.jsx | 347 ++++++++++-------- .../patient/CancerTumorSummaryCard.jsx | 22 +- .../__tests__/CancerTumorSummaryCard.test.jsx | 39 +- .../__tests__/cancerComparison.test.js | 44 ++- src/controllers/cancerComparison.js | 27 +- 7 files changed, 326 insertions(+), 164 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4550b59..79cc5fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 four text sizes, bands alternate event lanes, marks negated events with a dashed line as well as red, names the visible date range against the whole range, and replaces the two magnifier icons with + and − +- The improved Cancer and Tumor Detail keeps its height down: TNM reads as one + row ("T1 M0") rather than three, rows with nothing to compare (nothing + documented, or a tumor only repeating its cancer) fold behind a count, and in + the patient drawer the panel opens folded to its header, which reports how + many attributes differ. Nothing is dropped: the completeness count still + counts every field, and one click shows the folded rows ### Changed - The Patient Document Timeline's date range slider was drawn against the full diff --git a/src/components/EmbeddedPatientView.jsx b/src/components/EmbeddedPatientView.jsx index 87e0001..e6adabd 100644 --- a/src/components/EmbeddedPatientView.jsx +++ b/src/components/EmbeddedPatientView.jsx @@ -250,7 +250,10 @@ export default function EmbeddedPatientView({ patientId = "" }) { // expanded; collapsing is opt-in and preserves the multi-panel comparison // workflow (e.g. keep the timeline + document open, hide the rest). const [collapsedSections, setCollapsedSections] = useState({ - cancer: false, + // The improved view opens with the cancer detail folded to its header, so + // the timelines stay above the fold; its header says whether the cancers + // differ, and one click opens the comparison. + cancer: isImproved, timeline: false, eventTimeline: false, summary: false, diff --git a/src/components/patient/CancerComparisonMatrix.jsx b/src/components/patient/CancerComparisonMatrix.jsx index 76439f5..d986099 100644 --- a/src/components/patient/CancerComparisonMatrix.jsx +++ b/src/components/patient/CancerComparisonMatrix.jsx @@ -1,76 +1,83 @@ -import React, { useMemo } from "react"; +import React, { useMemo, useState } from "react"; import PropTypes from "prop-types"; -import { Box, Button, Tooltip, Typography } from "@mui/material"; +import { Box, Typography } from "@mui/material"; import { buildCancerComparisonMatrix, getFactLabel } from "../../controllers/cancerComparison"; +import { PATIENT_VIEW_TYPE } from "../../constants/patientViewTypography"; const UNDOCUMENTED_TITLE = "Not documented in this patient's notes"; -/** - * One fact. Still a button — selecting it links the fact to the documents that - * mention it — but it reads as a value with an affordance, not as an input box. - */ -function FactValue({ fact, isActive, onSelect }) { - return ( - - ); -} - -FactValue.propTypes = { - fact: PropTypes.shape({ id: PropTypes.string }).isRequired, - isActive: PropTypes.bool.isRequired, - onSelect: PropTypes.func, -}; +// A plain button, not an MUI one: these are values with an affordance, not +// inputs, and the matrix renders one per cell on a panel that re-renders with +// the theme. +const valueButtonSx = (isActive) => ({ + appearance: "none", + background: "none", + border: 0, + p: 0, + m: 0, + font: "inherit", + textAlign: "left", + cursor: "pointer", + color: isActive ? "primary.main" : "text.primary", + textDecoration: isActive ? "underline" : "none", + textUnderlineOffset: 3, + ...PATIENT_VIEW_TYPE.value, + "&:hover": { textDecoration: "underline" }, + "&:focus-visible": { + outline: (theme) => + `${Number.parseFloat(theme.custom?.focusRingWidth) || 2}px solid ${ + theme.custom?.focusRing || theme.palette.primary.main + }`, + outlineOffset: 2, + }, +}); function MatrixCell({ cell, activeFactId, onFactSelect }) { if (cell.isUnknown) { return ( - - - — - - + + — + ); } if (cell.matchesCancer) { return ( - + Same as cancer ); } return ( - - {cell.facts.map((fact) => ( - - ))} + + {cell.facts.map((fact) => { + const factId = String(fact.id || "").trim(); + return ( + onFactSelect?.(factId)} + aria-pressed={activeFactId === factId} + sx={valueButtonSx(activeFactId === factId)} + > + {fact.comparisonPrefix ? `${fact.comparisonPrefix}${getFactLabel(fact)}` : getFactLabel(fact)} + + ); + })} ); } @@ -87,15 +94,32 @@ MatrixCell.propTypes = { /** * The cancers side by side: attributes down the left, one column per cancer, - * tumors in their own sections underneath their parent. Rows whose values - * differ between cancers are marked, since those are the reason to compare. + * tumors in their own sections underneath their parent. Rows where the cancers + * disagree are marked, since those are the reason to compare. Rows with nothing + * documented for any cancer fold away behind a count, so the panel stays short + * enough to leave the timelines above the fold. */ export default function CancerComparisonMatrix({ cancers = [], + matrix: providedMatrix = null, activeFactId = "", onFactSelect = undefined, }) { - const matrix = useMemo(() => buildCancerComparisonMatrix(cancers), [cancers]); + const builtMatrix = useMemo( + () => (providedMatrix ? null : buildCancerComparisonMatrix(cancers)), + [providedMatrix, cancers] + ); + const matrix = providedMatrix || builtMatrix; + const [showUndocumented, setShowUndocumented] = useState(false); + + const emptyRowCount = useMemo( + () => + matrix.sections.reduce( + (total, section) => total + section.rows.filter((row) => row.isFoldable).length, + 0 + ), + [matrix] + ); if (!matrix.hasData) { return ( @@ -105,127 +129,156 @@ export default function CancerComparisonMatrix({ ); } - const gridTemplateColumns = `minmax(104px, auto) repeat(${matrix.columns.length}, minmax(0, 1fr))`; + const visibleSections = matrix.sections + .map((section) => ({ + ...section, + rows: showUndocumented ? section.rows : section.rows.filter((row) => !row.isFoldable), + })) + .filter((section) => section.rows.length > 0); return ( - - - - - - Attribute - - - {matrix.columns.map((column) => ( - - - - {column.label} - - - - {`${column.documentedCount} of ${column.fieldCount} documented`} - - - ))} - - - - {matrix.sections.map((section) => ( - + + + - + - {section.label} + Attribute + {matrix.columns.map((column) => ( + + + {column.label} + + + {`${column.documentedCount}/${column.fieldCount} documented`} + + + ))} - {section.rows.map((row) => ( - + + + {visibleSections.map((section) => ( + + - {row.label} + {section.label} - {row.differs ? ( + + + {section.rows.map((row) => ( + + - differs + {row.label} - ) : null} - - {row.cells.map((cell) => ( - - + {row.differs ? ( + + differs + + ) : null} - ))} - - ))} + {row.cells.map((cell) => ( + + + + ))} + + ))} + + ))} + + + {emptyRowCount > 0 ? ( + setShowUndocumented((previous) => !previous)} + aria-expanded={showUndocumented} + sx={{ + ...valueButtonSx(false), + color: "text.secondary", + ...PATIENT_VIEW_TYPE.fieldLabel, + mt: 0.5, + ml: 0.5, + }} + > + {showUndocumented + ? `Hide ${emptyRowCount} undocumented or repeated field${emptyRowCount === 1 ? "" : "s"}` + : `Show ${emptyRowCount} undocumented or repeated field${emptyRowCount === 1 ? "" : "s"}`} - ))} + ) : null} ); } CancerComparisonMatrix.propTypes = { cancers: PropTypes.arrayOf(PropTypes.object), + matrix: PropTypes.object, activeFactId: PropTypes.string, onFactSelect: PropTypes.func, }; diff --git a/src/components/patient/CancerTumorSummaryCard.jsx b/src/components/patient/CancerTumorSummaryCard.jsx index ef381dd..5fa9065 100644 --- a/src/components/patient/CancerTumorSummaryCard.jsx +++ b/src/components/patient/CancerTumorSummaryCard.jsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo } from "react"; import PropTypes from "prop-types"; import { Box, @@ -13,6 +13,7 @@ import { } from "@mui/material"; import { toDisplayName } from "../../utils/displayNames"; import usePatientViewPresentation from "../../hooks/usePatientViewPresentation"; +import { buildCancerComparisonMatrix } from "../../controllers/cancerComparison"; import CancerComparisonMatrix from "./CancerComparisonMatrix"; import { PATIENT_VIEW_TYPE } from "../../constants/patientViewTypography"; import SectionCollapseToggle from "./SectionCollapseToggle"; @@ -281,10 +282,21 @@ export default function CancerTumorSummaryCard({ sectionLabel = "Cancer and Tumor Detail", }) { const activeFactId = String(factSelection?.factId || "").trim(); - const normalizedCancers = Array.isArray(cancers) ? cancers : []; + const normalizedCancers = useMemo(() => (Array.isArray(cancers) ? cancers : []), [cancers]); // The improved view compares the cancers in one matrix; the alpha view keeps // the per-cancer cards. const { isImproved } = usePatientViewPresentation(); + const comparison = useMemo( + () => (isImproved ? buildCancerComparisonMatrix(normalizedCancers) : null), + [isImproved, normalizedCancers] + ); + // With the panel collapsed, the header still says whether the cancers differ. + const differingCount = comparison + ? comparison.sections.reduce( + (total, section) => total + section.rows.filter((row) => row.differs).length, + 0 + ) + : 0; return ( ) : null} + {isImproved && differingCount > 0 ? ( + + {`${differingCount} attribute${differingCount === 1 ? "" : "s"} differ`} + + ) : null} {onToggleExpanded ? ( ) : isImproved ? ( { }, ]; + + it("folds away rows with nothing documented, and can show them", () => { + // Neither cancer stages N or M, so those rows carry nothing to compare. + const sparseCancers = twoCancers.map((cancer) => ({ + ...cancer, + tnm: [{ data: { T: [], N: [], M: [] } }], + })); + const { container, unmount } = renderComponent( + + ); + + try { + const rowLabels = () => + [...container.querySelectorAll("th[scope='row']")].map((n) => n.textContent); + const toggle = () => + container.querySelector("[data-testid='cancer-comparison-undocumented-toggle']"); + + // Neither cancer is staged, so the TNM row is folded away by default. + expect(rowLabels().some((label) => label.startsWith("TNM"))).toBe(false); + expect(toggle().textContent).toMatch(/^Show \d+ undocumented or repeated fields?$/); + expect(toggle().getAttribute("aria-expanded")).toBe("false"); + const foldedCount = rowLabels().length; + + act(() => { + toggle().dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(rowLabels().some((label) => label.startsWith("TNM"))).toBe(true); + expect(rowLabels().length).toBeGreaterThan(foldedCount); + expect(toggle().textContent).toMatch(/^Hide \d+ undocumented or repeated fields?$/); + // The completeness count still counts every field, folded or not. + expect(container.textContent).toMatch(/\d+\/\d+ documented/); + } finally { + unmount(); + } + }); + it("compares the cancers in one matrix instead of separate cards", () => { const onFactSelect = jest.fn(); const { container, unmount } = renderComponent( @@ -180,7 +217,7 @@ describe("CancerTumorSummaryCard", () => { expect(headers[1]).toContain("Cancer 1"); expect(headers[2]).toContain("Cancer 2"); // Sparse records read as sparse. - expect(headers[1]).toMatch(/\d+ of \d+ documented/); + expect(headers[1]).toMatch(/\d+\/\d+ documented/); // The differences are what the reader came for. const gradeRow = [...matrix.querySelectorAll("th[scope='row']")].find((n) => diff --git a/src/controllers/__tests__/cancerComparison.test.js b/src/controllers/__tests__/cancerComparison.test.js index 81a7a5e..d5a2984 100644 --- a/src/controllers/__tests__/cancerComparison.test.js +++ b/src/controllers/__tests__/cancerComparison.test.js @@ -60,12 +60,18 @@ describe("cancer comparison matrix", () => { expect(matrix.columns[0].documentedCount).toBeGreaterThan(matrix.columns[1].documentedCount); }); - it("orders attributes clinically and keeps every TNM row", () => { + it("orders attributes clinically and reads TNM as one row", () => { const matrix = buildCancerComparisonMatrix(CANCERS); - const labels = matrix.sections.find((section) => section.key === "cancer").rows.map((row) => row.label); + const cancerSection = matrix.sections.find((section) => section.key === "cancer"); + const labels = cancerSection.rows.map((row) => row.label); expect(labels.slice(0, 3)).toEqual(["Location", "Grade", "Gene(s)"]); - expect(labels).toEqual(expect.arrayContaining(["TNM T", "TNM N", "TNM M"])); + // "T1 N0 M0" is how the stage is read, so it is one row, not three. + expect(labels).toContain("TNM"); + expect(labels).not.toContain("TNM T"); + const tnm = cancerSection.rows.find((row) => row.label === "TNM"); + expect(tnm.cells[0].facts.map((f) => f.comparisonPrefix)).toEqual(["T", "M"]); + expect(tnm.cells[1].isUnknown).toBe(true); }); it("marks the rows where the cancers disagree", () => { @@ -80,11 +86,35 @@ describe("cancer comparison matrix", () => { it("flags undocumented cells instead of dropping them", () => { const matrix = buildCancerComparisonMatrix(CANCERS); - const nodes = rowByLabel(matrix, "cancer", "TNM N"); + const genes = rowByLabel(matrix, "cancer", "Gene(s)"); - expect(nodes.cells.every((cell) => cell.isUnknown)).toBe(true); - expect(rowByLabel(matrix, "cancer", "Gene(s)").cells[1].isUnknown).toBe(true); - expect(rowByLabel(matrix, "cancer", "Gene(s)").cells[0].facts).toHaveLength(1); + expect(genes.cells[1].isUnknown).toBe(true); + expect(genes.cells[0].facts).toHaveLength(1); + expect(genes.allUnknown).toBe(false); + expect(genes.isFoldable).toBe(false); + }); + + it("folds rows that hold nothing to compare", () => { + const matrix = buildCancerComparisonMatrix([ + { + cancerId: "only", + collatedCancerFacts: [{ categoryName: "Grade", facts: [fact("g", "3")] }], + tnm: [{ data: { T: [], N: [], M: [] } }], + tumors: { + listViewData: [{ id: "t", data: [{ category: "Grade", facts: [fact("tg", "3")] }] }], + }, + }, + ]); + + // Nothing staged at all: the TNM row has nothing to say. + expect(rowByLabel(matrix, "cancer", "TNM").allUnknown).toBe(true); + expect(rowByLabel(matrix, "cancer", "TNM").isFoldable).toBe(true); + // The tumor only repeats its cancer's grade. + const tumorGrade = rowByLabel(matrix, "tumor-1", "Grade"); + expect(tumorGrade.repeatsCancer).toBe(true); + expect(tumorGrade.isFoldable).toBe(true); + // A row that says something stays. + expect(rowByLabel(matrix, "cancer", "Grade").isFoldable).toBe(false); }); it("nests tumors and says when a tumor just repeats its cancer", () => { diff --git a/src/controllers/cancerComparison.js b/src/controllers/cancerComparison.js index 0e3acea..9dd43cd 100644 --- a/src/controllers/cancerComparison.js +++ b/src/controllers/cancerComparison.js @@ -13,6 +13,7 @@ export const CANCER_COMPARISON_CATEGORIES = Object.freeze([ ]); const TNM_KEYS = Object.freeze(["T", "N", "M"]); +export const TNM_CATEGORY = "TNM"; export function getFactLabel(fact = {}) { const rawLabel = String(fact?.value || fact?.prettyName || fact?.name || "").trim(); @@ -40,11 +41,15 @@ function getCancerFactsByCategory(cancer = {}) { byCategory.set(categoryName, [...(byCategory.get(categoryName) || []), ...facts]); }); + // TNM is read as one value ("T1 N0 M0"), so it is one row with the stage + // letter carried on each fact rather than three rows a third full. const tnmData = cancer?.tnm?.[0]?.data || {}; - TNM_KEYS.forEach((key) => { - const facts = Array.isArray(tnmData[key]) ? tnmData[key] : []; - byCategory.set(`TNM ${key}`, facts); - }); + const tnmFacts = TNM_KEYS.flatMap((key) => + (Array.isArray(tnmData[key]) ? tnmData[key] : []) + .filter((fact) => String(fact?.id || "").trim()) + .map((fact) => ({ ...fact, comparisonPrefix: key })) + ); + byCategory.set(TNM_CATEGORY, tnmFacts); return byCategory; } @@ -102,13 +107,23 @@ function buildRow({ key, label, factsByColumn, columns, cancerValueKeys }) { }; }); + const documentedCount = cells.filter((cell) => !cell.isUnknown).length; + const allUnknown = documentedCount === 0; + // A tumor row that only repeats its cancer adds nothing to the comparison. + const repeatsCancer = + !allUnknown && cells.every((cell) => cell.matchesCancer || cell.isUnknown); + return { key, label, cells, // What the reader is here for: where the cancers disagree. differs: new Set(cells.map((cell) => cell.valueKey)).size > 1, - documentedCount: cells.filter((cell) => !cell.isUnknown).length, + documentedCount, + allUnknown, + repeatsCancer, + // Nothing to compare: folded away behind a count rather than taking a line. + isFoldable: allUnknown || repeatsCancer, }; } @@ -137,7 +152,7 @@ export function buildCancerComparisonMatrix(cancers = []) { const cancerCategories = new Set(); cancerFacts.forEach((byCategory) => { byCategory.forEach((facts, category) => { - if (facts.length > 0 || category.startsWith("TNM ")) { + if (facts.length > 0 || category === TNM_CATEGORY) { cancerCategories.add(category); } });