Vitals Charting with Recharts
By David Le -- Part 6 of the FhirHub Series
Clinical data is only useful if clinicians can spot trends at a glance. FhirHub uses Recharts to render interactive line charts for vital signs, with reference range overlays, multi-vital selection, and time range filtering.
The Charting Challenge
Vital signs charting in a healthcare context has specific requirements beyond a typical dashboard:
- Multiple data series -- Blood pressure alone needs two lines (systolic + diastolic)
- Reference ranges -- Clinicians need to see normal vs. abnormal at a glance
- Time filtering -- Compare trends over days, months, or years
- Responsive design -- Must work on desktop monitors and tablets at the bedside
Component Architecture
The VitalsChart component handles all of this in a single file:
// frontend/src/components/patients/vitals-chart.tsx
interface VitalReading {
date: string;
systolic?: number;
diastolic?: number;
heartRate?: number;
temperature?: number;
weight?: number;
respiratoryRate?: number;
oxygenSaturation?: number;
}
interface VitalsChartProps {
data: VitalReading[];
className?: string;
}
The data shape is flat -- each reading is a single object with optional fields for each vital type. This works well with Recharts, which expects uniform data point objects.
Vital Type Selection
Users select which vitals to display using filter pills:
const vitalTypes = [
{ id: "bloodPressure", label: "Blood Pressure" },
{ id: "heartRate", label: "Heart Rate" },
{ id: "temperature", label: "Temperature" },
{ id: "weight", label: "Weight" },
{ id: "respiratoryRate", label: "Respiratory Rate" },
{ id: "oxygenSaturation", label: "O2 Saturation" },
];
State management is straightforward React:
const [selectedVitals, setSelectedVitals] = useState<string[]>([
"bloodPressure",
]);
const [timeRange, setTimeRange] = useState("30d");
Blood pressure is selected by default since it's the most commonly monitored vital sign.
Reference Range Lines
Reference lines help clinicians identify abnormal values without looking at exact numbers:
{showBloodPressure && (
<>
<ReferenceLine
y={referenceRanges.systolic.high}
stroke="#EF4444"
strokeDasharray="3 3"
opacity={0.5}
/>
<ReferenceLine
y={referenceRanges.diastolic.high}
stroke="#F97316"
strokeDasharray="3 3"
opacity={0.5}
/>
</>
)}
Dashed reference lines at the normal range boundaries provide visual context. A systolic reading above the red dashed line immediately signals elevated blood pressure.
The reference ranges used in the chart:
const referenceRanges = {
systolic: { low: 90, high: 120, critical: 140 },
diastolic: { low: 60, high: 80, critical: 90 },
heartRate: { low: 60, high: 100 },
temperature: { low: 97, high: 99 },
oxygenSaturation: { low: 95, high: 100 },
respiratoryRate: { low: 12, high: 20 },
};
Color Coding
Each vital type has a distinct color for clarity:
const vitalColors = {
systolic: "#EF4444", // Red
diastolic: "#F97316", // Orange
heartRate: "#3B82F6", // Blue
temperature: "#10B981", // Green
weight: "#8B5CF6", // Purple
respiratoryRate: "#EC4899", // Pink
oxygenSaturation: "#06B6D4", // Cyan
};
Red and orange for blood pressure is a deliberate choice -- it draws attention to the most clinically significant vital.
Responsive Container
The chart uses Recharts' ResponsiveContainer to fill available space:
<div className="h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="#374151"
opacity={0.3}
/>
<XAxis
dataKey="date"
stroke="#9CA3AF"
fontSize={12}
tickFormatter={(value) => {
const date = new Date(value);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}}
/>
<YAxis stroke="#9CA3AF" fontSize={12} />
<Tooltip
contentStyle={{
backgroundColor: "#1F2937",
border: "1px solid #374151",
borderRadius: "8px",
}}
labelStyle={{ color: "#F9FAFB" }}
/>
<Legend />
{/* Data lines rendered conditionally */}
</LineChart>
</ResponsiveContainer>
</div>
The dark-themed tooltip styling matches the DaisyUI dark theme used throughout FhirHub.
Data Lines
Each vital type is conditionally rendered based on the user's selection:
{showBloodPressure && (
<>
<Line
type="monotone"
dataKey="systolic"
name="Systolic"
stroke={vitalColors.systolic}
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
/>
<Line
type="monotone"
dataKey="diastolic"
name="Diastolic"
stroke={vitalColors.diastolic}
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
/>
</>
)}
The monotone interpolation type creates smooth curves between data points, which is appropriate for vital signs trends. The active dot enlarges on hover for easier interaction.
Reference Ranges Legend
Below the chart, a contextual legend shows normal ranges for the selected vital types:
function VitalsReferenceRanges({ selectedVitals }: { selectedVitals: string[] }) {
const ranges = [
{ id: "bloodPressure", label: "Blood Pressure", normal: "90-120 / 60-80 mmHg" },
{ id: "heartRate", label: "Heart Rate", normal: "60-100 bpm" },
{ id: "temperature", label: "Temperature", normal: "97-99°F" },
{ id: "oxygenSaturation", label: "O2 Saturation", normal: "95-100%" },
];
const activeRanges = ranges.filter((r) => selectedVitals.includes(r.id));
// ...render active ranges
}
This updates dynamically -- if you deselect blood pressure and select heart rate, only the heart rate range is shown.
Data Flow
The full data flow from FHIR server to chart:
HAPI FHIR (Observation resources)
→ .NET API (HapiFhirPatientRepository)
→ PatientService.GetVitalsChartAsync()
→ VitalsChartDTO (date + vital values)
→ Frontend API client
→ VitalsChart component
→ Recharts LineChart
The API's /api/patients/{id}/vitals/chart endpoint is specifically designed for the chart -- it returns pre-formatted data with dates as keys and vital values pre-extracted from the FHIR Observation resources.
What's Next
In Part 7, we'll look at the clinical reference ranges system in depth -- covering AHA blood pressure guidelines, 21+ lab test ranges, and how FhirHub computes FHIR interpretation codes.