Files
HealthLog/src/components/timeline/timeline-chart.tsx
T
Marc-André Bombeck b8a983bdec feat(timeline): draw lab markers as lines under the values
With the server naming lab markers as value lines, the page offers them:
the values menu lists the markers the record has numeric readings for,
under the groups the labs list sorts them into, and a choice is stored
and requested like any measurement line. The phone toolbar gains the same
menu as an icon button, since the chronicle shows the chosen lines too.

A lab row sits under the value lines in their rhythm, on its own scale.
Each reading is its own point on the day of the draw, joined in time
order by a thin stroke and never averaged. The reference range is painted
behind the points as the labs page paints it, a faint band with dashed
edges, and the scale holds it so the band is always in view. A reading
outside its range is a triangle pointing the way it left and is worded as
outside wherever it is written out, so colour is never the only sign.

A point reads on its own by pointer, keyboard and tap: the day, the value
with every digit it was stored with, the verdict, the range and the
report it was read from (a link once pinned). The selection bar lists
the bucket's readings with their report, a lab day's row in the phone
chronicle lists that day's readings, a screen-reader table lists them
all, and a marker with readings in a unit that does not convert says how
many were left out. Nothing else on the timeline changes.

To make room without growing the large timeline files, the legend, the
screen-reader tables and the value-line geometry move into modules of
their own, and the chart, the chronicle, the point cards and the page
each hand a part to a helper; the size baselines are written back lower.

Refs #1299
2026-10-29 20:31:36 +01:00

1094 lines
30 KiB
TypeScript

"use client";
/**
* The timeline's lanes and value lines, as one hand-drawn SVG (v1.42, #613).
*
* Recharts draws every value chart in the app and stays there; it cannot lay
* periods and events out in lanes on a shared time axis, so this one surface
* is plain SVG, like the mood and intake heatmaps. Nothing is decided here:
* `timeline-geometry.ts` places every mark and label, this file paints them.
*
* Reading it without sight or without a mouse:
* - The drawing itself is `aria-hidden`. A visually hidden table beside it
* lists every entry with its lane, start and end, and a one-line summary
* names the window and the lanes.
* - The chart is one tab stop. Left and right move the selection by a day,
* a month or a year with the zoom; Home and End jump to the oldest data
* and to today; Enter opens the selected day. The selection bar under the
* chart announces each move.
* - A pointer selects with a click and opens the day with a double click.
*
* The width comes from a ResizeObserver, so a docked day panel that narrows
* the card re-runs the layout: labels that no longer fit are left out, never
* overlapped.
*/
import { metricPageHref } from "@/lib/insights/metric-page";
import Link from "next/link";
import { SeriesPointTips } from "./series-point-tips";
import {
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
} from "react";
import {
CalendarHeart,
FileText,
Flag,
FlaskConical,
Pill,
Shield,
Stethoscope,
Syringe,
Thermometer,
type LucideIcon,
} from "lucide-react";
import type {
TimelineBucket,
TimelineLaneKey,
TimelineResponse,
TimelineZoom,
} from "@/lib/day/contract";
import { resolveIntlLocale } from "@/lib/format-locale";
import { useTranslations } from "@/lib/i18n/context";
import { useItemWords, type ItemWordsFn } from "./item-words";
import {
LabLines,
LabSeriesTable,
useLabValueText,
type LabValueText,
} from "./lab-lines";
import { SeriesTable, TimelineTable, bucketText } from "./timeline-tables";
import { TIMELINE_LANE_LABEL_KEY } from "./label-keys";
import {
bucketStart,
formatAtPrecision,
formatMonthShort,
formatMonthYear,
} from "./timeline-dates";
import {
AXIS_HEIGHT,
LANE_COLOR,
dateAtPointer,
estimateTextWidth,
fitText,
seriesNameLines,
gridTicks,
todayLabelFits,
wrapToWidth,
layoutTimeline,
stepSelection,
zoomShape,
type PlacedPoint,
type PlacedSpan,
type TimeWindow,
} from "./timeline-geometry";
import { SERIES_HEIGHT } from "./series-geometry";
import {
formatSeriesValue,
type MeanPart,
type SeriesValueFormat,
} from "./series-format";
/** Where a value line's name and latest value start, right of its swatch. */
const SERIES_TEXT_X = 16;
export const LANE_ICON: Readonly<Record<TimelineLaneKey, LucideIcon>> = {
life: Flag,
illness: Thermometer,
allergies: Shield,
medications: Pill,
vaccinations: Syringe,
visits: Stethoscope,
labs: FlaskConical,
documents: FileText,
cycle: CalendarHeart,
};
export interface TimelineChartProps {
timeline: TimelineResponse;
window: TimeWindow;
zoom: TimelineZoom;
today: string;
selected: string | null;
hiddenLanes: ReadonlySet<TimelineLaneKey>;
seriesLabel: (key: string) => string;
/** The colour of a value line (`series-colors.ts`), as a token string. */
seriesColor: (key: string) => string;
/** How a value reads (`useSeriesValueFormat`). */
seriesFormat: SeriesValueFormat;
onSelect: (date: string) => void;
onOpenDay: (date: string) => void;
}
/** Observed content width of an element, 0 until measured. */
function useElementWidth<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el) return;
setWidth(el.clientWidth);
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
const next = Math.floor(entries[0]?.contentRect.width ?? el.clientWidth);
setWidth((prev) => (prev === next ? prev : next));
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return { ref, width };
}
export function TimelineChart({
timeline,
window,
zoom,
today,
selected,
hiddenLanes,
seriesLabel,
seriesColor,
seriesFormat,
onSelect,
onOpenDay,
}: TimelineChartProps) {
const { t, locale } = useTranslations();
const words = useItemWords();
const fmt = seriesFormat;
const labValueText = useLabValueText();
const intl = resolveIntlLocale(locale);
const { ref, width } = useElementWidth<HTMLDivElement>();
const lanes = useMemo(
() => timeline.lanes.filter((l) => !hiddenLanes.has(l.key)),
[timeline.lanes, hiddenLanes],
);
const layout = useMemo(
() =>
width > 0
? layoutTimeline({
width,
window,
lanes,
series: timeline.series,
labSeries: timeline.labSeries,
bucket: timeline.bucket,
words,
startMissing: t("timeline.startUnknown"),
today,
})
: null,
[
width,
window,
lanes,
timeline.series,
timeline.labSeries,
timeline.bucket,
words,
t,
today,
],
);
const floor = timeline.range.dataFrom ?? window.from;
const shape = zoomShape(zoom, window);
function handleKey(event: KeyboardEvent<HTMLDivElement>) {
const current = selected ?? today;
let next: string | null = null;
if (event.key === "ArrowLeft")
next = stepSelection(current, -1, shape, today, floor);
else if (event.key === "ArrowRight")
next = stepSelection(current, 1, shape, today, floor);
else if (event.key === "Home") next = floor;
else if (event.key === "End") next = today;
else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpenDay(current);
return;
}
if (next !== null) {
event.preventDefault();
onSelect(next);
}
}
function pointerDate(event: React.MouseEvent<SVGSVGElement>): string | null {
if (!layout) return null;
const rect = event.currentTarget.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
if (x < layout.scale.x0) return null;
return dateAtPointer(layout, x, y);
}
const summary = t("timeline.chartLabel", {
from: formatMonthYear(window.from, intl),
to: formatMonthYear(window.to, intl),
});
return (
<div className="space-y-3">
<div
ref={ref}
role="group"
aria-roledescription={t("timeline.title")}
aria-label={summary}
aria-describedby="timeline-chart-help"
tabIndex={0}
onKeyDown={handleKey}
data-slot="timeline-chart"
data-width={width}
data-window-from={window.from}
data-window-to={window.to}
className="focus-visible:ring-ring/50 relative rounded-lg outline-none focus-visible:ring-2"
>
<p id="timeline-chart-help" className="sr-only">
{t("timeline.keyboardHint")}
</p>
{layout ? (
<svg
width={layout.width}
height={layout.height}
viewBox={`0 0 ${layout.width} ${layout.height}`}
aria-hidden="true"
className="block select-none"
onClick={(e) => {
const date = pointerDate(e);
if (date) onSelect(date);
}}
onDoubleClick={(e) => {
const date = pointerDate(e);
if (date) onOpenDay(date);
}}
>
<ChartBody
layout={layout}
window={window}
zoom={shape}
today={today}
selected={selected}
dataFrom={timeline.range.dataFrom}
bucket={timeline.bucket}
seriesLabel={seriesLabel}
seriesColor={seriesColor}
intl={intl}
t={t}
fmt={fmt}
labValueText={labValueText}
/>
</svg>
) : (
<div className="h-64" />
)}
{layout ? (
<>
<SeriesNameLinks layout={layout} seriesLabel={seriesLabel} />
<SeriesPointTips
layout={layout}
today={today}
seriesLabel={seriesLabel}
seriesColor={seriesColor}
fmt={fmt}
bucketLabel={(start) =>
bucketText(start, timeline.bucket, intl, t)
}
onSelect={onSelect}
onOpenDay={onOpenDay}
/>
</>
) : null}
</div>
<TimelineTable
timeline={timeline}
hiddenLanes={hiddenLanes}
intl={intl}
/>
<SeriesTable
timeline={timeline}
window={window}
seriesLabel={seriesLabel}
fmt={fmt}
intl={intl}
/>
<LabSeriesTable
labSeries={timeline.labSeries}
window={window}
label={seriesLabel}
valueText={labValueText}
intl={intl}
/>
</div>
);
}
/**
* A value line's name leads to its metric's page, as a value tile in the day
* view does (`metricPageHref`, one map for both). The names are drawn in the
* SVG; the links lie over them as plain HTML, so they take focus, a hover
* and a 44 px target over the line's height. A kind without a page keeps a
* plain name. Enter on a link follows it and never reaches the chart's own
* Enter, which opens the day.
*/
export function SeriesNameLinks({
layout,
seriesLabel,
}: {
layout: NonNullable<ReturnType<typeof layoutTimeline>>;
seriesLabel: (key: string) => string;
}) {
// A lab line's name leads to its marker's page on the labs list.
const rows = [
...layout.series.map((s) => ({
key: s.key,
top: s.top,
href: metricPageHref(s.key),
})),
...layout.labSeries.map((s) => ({
key: s.key,
top: s.top,
href: `/labs/${encodeURIComponent(s.biomarkerId)}`,
})),
];
return (
<>
{rows.map((series) => {
const href = series.href;
if (!href) return null;
return (
<Link
key={series.key}
href={href}
data-slot="timeline-series-link"
data-series={series.key}
aria-label={seriesLabel(series.key)}
onKeyDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
className="hover:bg-foreground/5 focus-visible:ring-ring/50 absolute left-0 rounded-md outline-none focus-visible:ring-2"
style={{
top: series.top - 4,
width: layout.scale.x0 - 8,
height: Math.max(44, SERIES_HEIGHT - 12),
}}
/>
);
})}
</>
);
}
interface ChartBodyProps {
layout: NonNullable<ReturnType<typeof layoutTimeline>>;
window: TimeWindow;
zoom: Exclude<TimelineZoom, "range">;
today: string;
selected: string | null;
dataFrom: string | null;
bucket: TimelineBucket;
seriesLabel: (key: string) => string;
seriesColor: (key: string) => string;
intl: string;
t: ReturnType<typeof useTranslations>["t"];
fmt: SeriesValueFormat;
labValueText: LabValueText;
}
function ChartBody({
layout,
window,
zoom,
today,
selected,
dataFrom,
bucket,
seriesLabel,
seriesColor,
intl,
t,
fmt,
labValueText,
}: ChartBodyProps) {
const { scale, height, width } = layout;
const todayInWindow = today >= window.from && today <= window.to;
const todayX = scale.xMid(today);
const todayLabel = t("timeline.today");
const todayLabelWidth = estimateTextWidth(todayLabel, 11);
const ticks = gridTicks(window, zoom, scale, {
year: (k) => k.slice(0, 4),
monthShort: (k) => formatMonthShort(k, intl),
monthYear: (k) => formatMonthYear(k, intl),
});
const showTodayLabel =
todayInWindow && todayLabelFits(ticks, todayX, todayLabelWidth, scale.x0);
const noDataUntil =
dataFrom && dataFrom > window.from && dataFrom <= window.to
? scale.x(dataFrom)
: null;
// The bucket that holds the selected day: its point on every line is
// ringed in the line's colour, so the selection reads on each scale.
const selectedBucket =
selected && selected >= window.from && selected <= window.to
? bucketStart(selected, bucket)
: null;
return (
<g fontFamily="inherit">
<defs>
<linearGradient id="timeline-fade">
<stop offset="0" stopColor="var(--card)" />
<stop offset="1" stopColor="var(--card)" stopOpacity="0" />
</linearGradient>
</defs>
{noDataUntil !== null && (
<NoDataShade
x0={scale.x0}
until={noDataUntil}
height={height}
label={t("timeline.noDataYet")}
/>
)}
{/* Grid and axis labels. */}
{ticks.map((tick) => (
<g key={tick.key}>
<line
x1={tick.x}
x2={tick.x}
y1={tick.major ? AXIS_HEIGHT - 6 : AXIS_HEIGHT}
y2={height}
stroke="var(--border)"
strokeOpacity={tick.major ? 0.9 : 0.45}
/>
{tick.label && (
<text
x={tick.x + 6}
y={14}
fontSize={12}
fontWeight={500}
fill="var(--muted-foreground)"
>
{tick.label}
</text>
)}
</g>
))}
{/* Lanes. */}
{layout.lanes.map((lane, index) => {
const Icon = LANE_ICON[lane.key];
const color = LANE_COLOR[lane.key];
return (
<g key={lane.key} data-lane={lane.key}>
{index > 0 && (
<line
x1={0}
x2={width}
y1={lane.top}
y2={lane.top}
stroke="var(--border)"
strokeOpacity={0.6}
/>
)}
<Icon
x={0}
y={lane.top + 7}
width={16}
height={16}
color="var(--muted-foreground)"
strokeWidth={2}
/>
<text
x={24}
y={lane.top + 19}
fontSize={13}
fontWeight={500}
fill="var(--foreground)"
>
{fitText(t(TIMELINE_LANE_LABEL_KEY[lane.key]), scale.x0 - 30, 13)}
</text>
{/* A medication's bar is drawn in pieces of one item, so a
piece is keyed by its item and its own first day. */}
{lane.spans.map((span) => (
<SpanMark
key={`${span.item.id}:${span.segment?.start ?? span.item.start}`}
span={span}
color={color}
x0={scale.x0}
intl={intl}
t={t}
/>
))}
{lane.points.map((point) => (
<PointMark
key={point.item.id}
point={point}
color={color}
intl={intl}
/>
))}
</g>
);
})}
{/* Value lines: one scale each, each in its own colour (the colour
its type carries in the measurement list). The colour names the
line, never a judgement of its values. */}
<SeriesLines
layout={layout}
width={width}
selectedBucket={selectedBucket}
seriesLabel={seriesLabel}
seriesColor={seriesColor}
t={t}
fmt={fmt}
/>
{/* Lab lines (#1299): single readings, under the value lines. */}
<LabLines
layout={layout}
selectedBucket={selectedBucket}
bucket={bucket}
seriesLabel={seriesLabel}
seriesColor={seriesColor}
valueText={labValueText}
/>
{/* Today. */}
{todayInWindow && (
<g data-slot="timeline-today">
<line
x1={todayX}
x2={todayX}
y1={AXIS_HEIGHT - 6}
y2={height}
stroke="var(--muted-foreground)"
strokeDasharray="2 3"
/>
{showTodayLabel && (
<text
x={todayX - 4}
y={14}
textAnchor="end"
fontSize={11}
fill="var(--muted-foreground)"
>
{todayLabel}
</text>
)}
</g>
)}
{/* Selection. */}
{selected && selected >= window.from && selected <= window.to && (
<g data-slot="timeline-selection" data-date={selected}>
<line
x1={scale.xMid(selected)}
x2={scale.xMid(selected)}
y1={AXIS_HEIGHT - 6}
y2={height}
stroke="var(--foreground)"
strokeWidth={1.2}
strokeOpacity={0.8}
/>
<circle
cx={scale.xMid(selected)}
cy={AXIS_HEIGHT - 6}
r={3.5}
fill="var(--foreground)"
/>
</g>
)}
{/* The names last, over the today and selection lines, each on a
halo of the card's colour: a line passing a name runs behind it
and never through its letters. */}
<g data-slot="timeline-labels">
{layout.lanes.flatMap((lane) =>
lane.labels.map((label, i) => (
<text
key={`label-${lane.key}-${label.itemId}-${i}`}
data-label-lane={lane.key}
data-label-item={label.itemId}
x={label.x}
y={label.y}
fontSize={11}
fill={
label.strong ? "var(--foreground)" : "var(--muted-foreground)"
}
stroke="var(--card)"
strokeWidth={3}
strokeLinejoin="round"
paintOrder="stroke"
>
{label.text}
</text>
)),
)}
</g>
</g>
);
}
/**
* The stretch before the record's first entry, shaded and named, so an empty
* start reads as "no data yet" and not as a quiet period.
*/
function NoDataShade({
x0,
until,
height,
label,
}: {
x0: number;
until: number;
height: number;
label: string;
}) {
return (
<g data-slot="timeline-no-data">
<rect
x={x0}
y={AXIS_HEIGHT}
width={Math.max(0, until - x0)}
height={height - AXIS_HEIGHT}
fill="var(--muted)"
opacity={0.4}
/>
{wrapToWidth(label, until - x0 - 12).map((line, i, lines) => (
<text
key={line}
x={x0 + 8}
y={height - 8 - (lines.length - 1 - i) * 13}
fontSize={11}
fill="var(--muted-foreground)"
>
{line}
</text>
))}
</g>
);
}
/**
* The value lines under the lanes, each in its own colour: the swatch beside
* the name, the line, the dashed bridge over a gap, the points (hollow where
* a mean rests on one or two readings) and the ring on the selected bucket.
* Every mark of a line inherits one `color` from its group, so they cannot
* drift apart.
*/
export function SeriesLines({
layout,
width,
selectedBucket,
seriesLabel,
seriesColor,
t,
fmt,
}: {
layout: NonNullable<ReturnType<typeof layoutTimeline>>;
width: number;
selectedBucket: string | null;
seriesLabel: (key: string) => string;
seriesColor: (key: string) => string;
t: ReturnType<typeof useTranslations>["t"];
fmt: SeriesValueFormat;
}) {
const { scale, seriesTop } = layout;
const nameLines = (key: string) =>
seriesNameLines(seriesLabel(key), scale.x0 - 12 - SERIES_TEXT_X, 13);
return (
<>
{layout.series.length > 0 && (
<line
x1={0}
x2={width}
y1={seriesTop - 18}
y2={seriesTop - 18}
stroke="var(--border)"
/>
)}
{layout.series.map((series) => {
const color = seriesColor(series.key);
return (
<g
key={series.key}
data-series={series.key}
data-color={color}
color={color}
>
<line
data-slot="timeline-series-swatch"
x1={1.5}
x2={10.5}
y1={series.top + 8}
y2={series.top + 8}
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
/>
<text
data-slot="timeline-series-name"
x={SERIES_TEXT_X}
y={series.top + 12}
fontSize={13}
fontWeight={500}
fill="var(--foreground)"
>
{nameLines(series.key).map((line, i) => (
<tspan key={i} x={SERIES_TEXT_X} dy={i === 0 ? 0 : 15}>
{line}
</tspan>
))}
</text>
<text
x={SERIES_TEXT_X}
y={series.top + 30 + (nameLines(series.key).length - 1) * 15}
fontSize={12}
fill="var(--muted-foreground)"
>
{series.latest === null
? t("timeline.values.noneInWindow")
: formatSeriesValue(
series.key,
series.latest,
series.unit,
fmt,
)}
</text>
{series.path && (
<path
d={series.path}
data-slot="timeline-series-line"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinejoin="round"
strokeLinecap="round"
/>
)}
{series.bridges && (
<path
d={series.bridges}
data-slot="timeline-series-bridge"
fill="none"
stroke="currentColor"
strokeWidth={1.2}
strokeOpacity={0.7}
strokeDasharray="3 3"
strokeLinecap="round"
/>
)}
{series.points.map((point) => (
<circle
key={point.t}
data-slot="timeline-series-point"
data-t={point.t}
data-count={point.count}
data-thin={point.thin ? "true" : "false"}
cx={point.x}
cy={point.y}
r={point.thin ? 2.75 : 2.5}
fill={point.thin ? "var(--card)" : "currentColor"}
stroke="currentColor"
strokeWidth={point.thin ? 1.4 : 0}
/>
))}
{series.points
.filter((point) => point.t === selectedBucket)
.map((point) => (
<circle
key={`selected-${point.t}`}
data-slot="timeline-series-selected"
cx={point.x}
cy={point.y}
r={5}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
/>
))}
</g>
);
})}
</>
);
}
/**
* A bar's hover title: "Ramipril 5 mg: 12 Aug 2020 to 1 Jun 2021", or
* "since 12 Aug 2020" while it runs on. A dose's piece of a medication's
* bar names that dose and its own days.
*/
function spanTitle(
span: PlacedSpan,
intl: string,
t: ReturnType<typeof useTranslations>["t"],
words: ItemWordsFn,
): string {
const { item, segment } = span;
const { label, sub, tag } = words(item);
const name = [label, segment ? segment.dose : sub, tag ? `(${tag})` : null]
.filter(Boolean)
.join(" ");
const start = segment?.start ?? item.start;
const end = segment ? segment.end : item.open ? null : item.end;
const from = formatAtPrecision(start, item.precision, intl);
const when =
end === null
? t("timeline.selection.since", { date: from })
: t("timeline.selection.range", {
from,
to: formatAtPrecision(end, item.precision, intl),
});
return `${name}: ${when}`;
}
export function SpanMark({
span,
color,
x0,
intl,
t,
}: {
span: PlacedSpan;
color: string;
x0: number;
intl: string;
t: ReturnType<typeof useTranslations>["t"];
}) {
const { item, xStart, xEnd, y } = span;
const words = useItemWords();
const title = spanTitle(span, intl, t, words);
if (span.pause) {
return (
<g data-kind="pause" data-item={item.id} data-row={span.row}>
<title>{title}</title>
<rect
x={xStart}
y={y - 4.5}
width={xEnd - xStart}
height={9}
rx={2}
fill="var(--card)"
stroke={color}
strokeDasharray="2 2"
/>
</g>
);
}
const thick = item.open ? 3 : 7;
// Only a bar's first piece draws an unknown start, only its last an open
// end; a bar that is not cut is both.
const first = span.segment?.first ?? true;
const last = span.segment?.last ?? true;
return (
<g
data-kind={item.kind}
data-item={item.id}
data-dose={span.segment?.dose ?? undefined}
data-row={span.row}
>
<title>{title}</title>
{!item.startKnown && first && (
<line
x1={Math.max(x0, xStart - 28)}
x2={xStart}
y1={y}
y2={y}
stroke={color}
strokeWidth={3}
strokeDasharray="3 4"
opacity={0.6}
/>
)}
<rect
x={xStart}
y={y - thick / 2}
width={Math.max(xEnd - xStart, 4)}
height={thick}
rx={thick / 2}
fill={color}
opacity={item.open ? 0.75 : 0.9}
/>
{item.open && last && (
<path
d={`M${xEnd + 1} ${y - 4} l6 4 l-6 4z`}
fill={color}
opacity={0.75}
/>
)}
{span.clippedLeft && item.startKnown && (
<rect
x={x0}
y={y - 4}
width={22}
height={8}
fill="url(#timeline-fade)"
/>
)}
</g>
);
}
export function PointMark({
point,
color,
intl,
}: {
point: PlacedPoint;
color: string;
intl: string;
}) {
const { x, y, item, shape } = point;
const { label, sub, tag } = useItemWords()(item);
const title = `${[label, sub, tag ? `(${tag})` : null].filter(Boolean).join(" ")}: ${formatAtPrecision(item.start, item.precision, intl)}`;
let glyph: React.ReactNode;
switch (shape) {
case "diamond":
glyph = (
<rect
x={x - 5}
y={y - 5}
width={10}
height={10}
transform={`rotate(45 ${x} ${y})`}
fill="var(--card)"
stroke={color}
strokeWidth={1.6}
/>
);
break;
case "square":
glyph = (
<rect
x={x - 4.5}
y={y - 4.5}
width={9}
height={9}
rx={2}
fill={color}
/>
);
break;
case "tick":
glyph = (
<rect
x={x - 1}
y={y - 6}
width={2}
height={12}
rx={1}
fill={color}
opacity={0.7}
/>
);
break;
case "ring":
glyph = (
<circle
cx={x}
cy={y}
r={3.5}
fill="var(--card)"
stroke={color}
strokeWidth={1.6}
/>
);
break;
case "doseMark":
glyph = (
<line
x1={x}
x2={x}
y1={y - 6}
y2={y + 6}
stroke={color}
strokeWidth={2}
/>
);
break;
default:
glyph = (
<circle
cx={x}
cy={y}
r={4}
fill={color}
stroke="var(--card)"
strokeWidth={1.5}
/>
);
}
return (
<g data-kind={item.kind} data-item={item.id} data-row={point.row}>
<title>{title}</title>
{glyph}
</g>
);
}
/**
* A means line with each value marked by its line's colour: "● 129/82 mmHg
* ● 82,6 kg". The values stand apart by space, not by a separator glyph;
* a screen reader hears a comma between them. The dot is decoration; the
* text reads the same without it.
*
* Each dot and its value are one unit that never breaks: a line wraps only
* between units (the caller's container is a wrapping flex row), so a dot
* cannot stay behind at the end of a line while its value moves to the
* next. The dot sits on the middle of the text line, and a name too long for
* the row is cut short inside its unit. `lead` (the "Ø" in front of the
* selection bar's means) rides in the first unit for the same reason.
*/
export function MeanPartsLine({
parts,
seriesColor,
lead,
}: {
parts: readonly MeanPart[];
seriesColor: (key: string) => string;
lead?: React.ReactNode;
}) {
return parts.map((part, i) => (
<span
key={part.key}
data-series={part.key}
data-slot="timeline-mean-part"
// The readings behind a mean: a hover title, and the same words for a
// screen reader, but not on the line itself.
title={part.detail}
className="inline-flex max-w-full min-w-0 items-center gap-1 whitespace-nowrap"
>
{i === 0 && lead ? <span className="shrink-0">{lead}</span> : null}
{i > 0 ? <span className="sr-only">, </span> : null}
<span
data-slot="timeline-series-dot"
className="size-2 shrink-0 rounded-full"
style={{ background: seriesColor(part.key) }}
aria-hidden="true"
/>
<span className="min-w-0 truncate">{part.text}</span>
{part.detail ? (
<span className="sr-only" data-slot="timeline-mean-detail">
{`, ${part.detail}`}
</span>
) : null}
</span>
));
}
/** The row the units of a `MeanPartsLine` wrap in: breaks only between them. */
export const MEAN_PARTS_ROW =
"text-muted-foreground inline-flex max-w-full flex-wrap items-center gap-x-3 gap-y-1 text-xs tabular-nums";
/** Inline style for a lane colour dot in HTML (chips, chronicle rails). */
export function laneDotStyle(lane: TimelineLaneKey): React.CSSProperties {
return { background: LANE_COLOR[lane] };
}