Biomarker Graph Platform
Reusable graphs over the central biomarker cache: one renderer + pure cores, five mounts, cross-signal overlay, provenance-rendered.
Status: Design 2026-06-11, not built. Companion to
docs/architecture/icu-flowsheet-world-best-master-plan.md(§5). One-sentence pitch: any biomarker that is properly saved (flowsheet, lab, device, assessment) becomes graphable anywhere — as a standalone miniapp, a DynamicContentRenderer module, a widget-rail widget, a draggable card in patient-profile, or a floating cross-overlay window — because graphs bind to the central biomarker cache, never to the surface that charted the value.
1. The core principle: graphs read the cache, not the chart
Today every surface that wants a trend builds its own renderer over its own data (flowsheet RowTimelineSparkline/AlignedTimelineChart/News2TrendChart/IoBalanceChart, lab LabResultsWidget, Patient 360 vitals, dynamic-scheduler dose visualizers). That couples the graph to the writer and makes “show me this HR trend on another page” impossible.
The platform inverts this:
WRITERS (already converge — no new work) READERS (this doc)
flowsheet mirrorSheetVitals ─┐
lab pipeline (ORU/LIS) ─┤→ observations useBiomarkerSeries(patientId, codes, window)
device sync ─┤→ lab_results_ts │
HL7v2 / FHIR write API ─┤ (hypertable) BiomarkerGraphCard (ONE renderer)
assessments / vitals forms ─┘ │
every surface mounts the same card
- Cache =
observations(+lab_results_tshypertable) — already realtime, already fed by every write path (seedocs/architecture/lab-data-pipeline.md). The flowsheet already writes through (mirrorSheetVitals → writeThroughVitals). - Read hook =
web/src/services/observation-core/useObservations.ts— the existing central read;useBiomarkerSeriesis a thin selector over it (group by code, sort by effective time, attach reference ranges). - Cross-over math exists:
web/src/services/ai/clinical-query/correlate.tsalready correlates signals; the platform exposes it as a UI affordance, not just an AI tool.
Invariant #1 — identity is the canonical code. A graph definition references biomarkers by canonical key (LOINC/SNOMED or the observation code), never by flowsheet row id (gtXXXX), never by sheet/template id. The flowsheet’s id→canonical mapping problem (see master plan Tier-1 #12) is solved ONCE in the write path / a mapping table, and every reader benefits.
Invariant #2 — graphs are read-only. No graph surface ever writes observations. Charting stays in the owning miniapps; the platform renders.
Invariant #3 — provenance is rendered. Carried-forward / device-unvalidated / amended points draw differently (hollow marker, stripe). This is the read-side twin of the master plan’s provenance work (§1.2/§4.1 there).
2. Package layout (new kit, additive)
web/packages/medical-kit/src/biomarker-graphs/
├── core/
│ ├── series-core.ts # pure: resolveSeries(observations, code) → {points, unit, range, provenance[]}
│ ├── overlay-core.ts # pure: composeOverlay(seriesA, seriesB, mode) — dual-axis | normalized | ratio
│ ├── graph-config.ts # GraphConfig type + (de)serialize + registry
│ └── correlate-adapter.ts # wraps services/ai/clinical-query/correlate.ts → r, lag, scatter pairs
├── components/
│ ├── BiomarkerGraphCard.tsx # THE renderer: line/area/sparkline/scatter + range bands + event pins
│ ├── GraphOverlayLegend.tsx # per-series chips: colour, unit, axis side, remove ✕
│ ├── GraphConfigPanel.tsx # add/remove biomarkers, window, representation, thresholds (bilingual)
│ └── FloatingGraphWindow.tsx # draggable/resizable floating shell (see §4.4)
├── hooks/
│ ├── useBiomarkerSeries.ts # selector over useObservations (realtime) + per-code mock fallback (isLive badge)
│ └── useGraphLayouts.ts # persisted layouts via the master plan's usePersistedPref (server-backed)
└── __tests__/ # pure-core jest per module
GraphConfig is data, not code (same philosophy as policy_gates/cds_rules/inventory_rules):
type GraphConfig = {
id: string; // slug
title: string; titleTh?: string; // bilingual (project rule)
series: Array<{
code: string; // canonical biomarker key — Invariant #1
label?: string; color?: string;
axis?: 'left' | 'right';
representation?: 'line' | 'area' | 'dots' | 'bars';
}>;
windowHours: number; // 6 | 24 | 72 | sinceAdmission
showRangeBands?: boolean; showEvents?: boolean; // hospital_events pins (reuses events-core)
thresholds?: Array<{ code: string; op: 'gt'|'lt'; value: number; color: string }>;
};
Persistence: a graph_configs table (app-owned, like dynamic_sheet_templates) for shared/admin-authored presets + per-user pinned layouts via useGraphLayouts. Seeds ship bilingual presets: Hemodynamics (HR+MAP+SBP), Respiratory (SpO₂+RR+FiO₂), Sepsis watch (Temp+WBC+Lactate+MAP), Renal (Cr+UO+K), Glucose-insulin.
Render engine: reuse what’s installed — SVG like the proven resource-timeline-core/AlignedTimelineChart approach for the card (no new chart dependency); the pure cores stay renderer-agnostic so a heavier engine can swap in later without touching configs.
3. Cross-overs: the doctor’s compare workflow
The headline feature: drag one biomarker onto another graph → overlaid trends.
BiomarkerGraphCardis a drop target; every legend chip and every biomarker name anywhere (lab widget rows, flowsheet param names, Patient 360 cards) is a drag source carryingapplication/x-medos-biomarker: {code, label}(HTML5 DnD — no new dep; mirror the Patient 360 draggable-card pattern).- Drop →
composeOverlay: units compatible → shared axis; incompatible → dual y-axes (left/right chips); >2 units → normalized 0-1 mode with a clear “normalized” badge (never silently fake a scale). - Correlation strip (opt-in): when exactly 2 series are shown,
correlate-adapterrenders r + lag under the graph and a scatter toggle. Display-only, recommender-stance — never a gate, never an auto-conclusion. - Time cursor is shared across every mounted graph on the page (hover one → vertical line on all; the pattern
SheetTimelineOverviewalready proves).
4. Surfaces (one card, mounted five ways)
4.1 DynamicContentRenderer module (graph as a miniapp)
Add case 'BiomarkerGraph': to the module registry in web/src/common/components/shared-engine/dynamic-core/engine/DynamicContentRenderer.tsx (same pattern as Patient360 at ~:2558). Module props = a GraphConfig id or inline config → admins can place graphs in any workflow tab / Department Command Center / worklist row-detail via existing config systems, zero code per placement.
4.2 Patient-profile draggable cards
Patient-profile miniapp grid mounts BiomarkerGraphCard as cards; layouts per-user via useGraphLayouts. Drag-to-rearrange follows the Patient 360 draggable-card mechanics; “+” opens GraphConfigPanel to pin a new graph.
4.3 Widget rail
Export a widgetSurface from the kit barrel (per docs/architecture/widget-rail-surface-system.md) — a sparkline-density mini card in the 320 px flyout next to OrderActivityDock/LabResultsWidget. Adding it = one barrel export.
4.4 Floating cross-look window
FloatingGraphWindow — FAB + draggable/resizable floating shell (the QueueManagementFloater pattern, docs/architecture/queue-management-floater.md) that stays mounted across miniapps on the page, so a doctor can float a Lactate+MAP overlay while working inside the order system or e-Kardex. Drop targets remain live while floating → drag a lab row from LabResultsWidget straight onto the floating graph. One floater per page; position persisted per-user.
4.5 Flowsheet (the V2 sheet becomes a reader too)
Replace nothing; add an opt-in “Open in graph” action on a row (and in CellDetailPopover) that opens the floating window pre-loaded with that row’s canonical code — using the same id→canonical map as derive-core. The sheet’s own sparklines/timeline stay; the platform adds take-it-anywhere.
Also: Patient 360 (usePatientBodyData already reads observations — its vitals card can become a BiomarkerGraphCard), export/print (graph → the existing print-iframe pattern from sheet-export-dom), and the QR self-service pattern later (share a read-only trend with a patient — patient-qr-self-service-pattern.md).
5. Customizability for assessments & biomarkers
- Any assessment that records numerically graphs for free — assessment forms already write through the observation pipeline (
IpdNursingAssessment→writeThroughVitals); pain scores, GCS components, NEWS2 — each is a code in the cache, so each is a series. New scores from the master plan’s Tier-2 #7 land graphable on day one. - Computed series:
series-coreaccepts a derived spec ({ derive: 'map' | 'shock_index' | ..., inputs }) reusing derive-core’s formula registry — graph MAP even where only SBP/DBP were charted. - Thresholds are per-graph data (
GraphConfig.thresholds) and render alongside — but clinical alarm limits remain the range/CDS systems’ job; graph thresholds are visual annotations only (no alerts fire from a graph). - Admin authoring:
GraphConfigPaneldoubles as the admin editor for shared presets (Builder Studio entry:/admin/builder-studiocard), bilingual labels mandatory.
6. Rollout
| Phase | Deliverable | Verify |
|---|---|---|
| P0 | Kit skeleton: series-core + useBiomarkerSeries (mock fallback) + BiomarkerGraphCard single-series + sandbox ?target=BiomarkerGraph + jest |
sandbox e2e |
| P1 | Overlay: drag-drop chips, dual-axis/normalized modes, overlay-core tests, shared time cursor |
sandbox e2e |
| P2 | Surfaces: DynamicContentRenderer module + patient-profile card + widget-rail export | module-load-clean + attended eyeball |
| P3 | FloatingGraphWindow + “Open in graph” from flowsheet/lab rows + id→canonical map (shared with derive-core wiring) |
attended (cross-miniapp) |
| P4 | graph_configs table + seeds (bilingual presets) + admin panel + per-user layouts on usePersistedPref server backend |
migration + Playwright |
| P5 | Correlation strip + provenance rendering (pairs with master plan §1.2/§4.1) + print/export | attended |
Every phase: additive, fail-soft (no observations → mock + isLive=false badge, like usePatientBodyData), pure-core jest, bilingual, MUI icons only, zero writes to read-model tables from the frontend (read-only by Invariant #2; graph_configs is app-owned config, not a read model).
7. Which Claude model to build it with
Same matrix as the master plan §7: Fable 5 (claude-fable-5, 1M context) for P0-P1 core design + the cross-surface P2/P3 wiring (touches DynamicContentRenderer + patient-profile + floater patterns simultaneously — the 1M window earns its keep); Opus 4.8 (claude-opus-4-8) for the autonomous polish loops (P4/P5, representation variants, preset library) at effort high/xhigh; Sonnet 4.6 for seed presets, i18n fills, and test scaffolds; Haiku 4.5 for explore subagents. The runtime product itself needs no LLM — correlation is deterministic math; keep AI out of the render path.