Files
HealthLog/src/components/timeline/series-geometry.ts
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

246 lines
8.4 KiB
TypeScript

/**
* The value lines and lab lines under the timeline's lanes, as pure
* geometry (v1.42, #613; lab lines #1299). `timeline-geometry.ts` stacks
* them under the lanes; each row is laid out here on its own scale.
*/
import type {
TimelineBucket,
TimelineLabPoint,
TimelineLabSeries,
TimelineSeries,
} from "@/lib/day/contract";
import { bucketAfter, bucketIndex, dayNumber } from "./timeline-dates";
import type { Scale, TimeWindow } from "./timeline-geometry";
/** Height of one value line, label included. */
export const SERIES_HEIGHT = 60;
/* ─── Value lines ─────────────────────────────────────────────────────────── */
export interface SeriesPointLayout {
/** The bucket's first day. */
t: string;
mean: number;
/** Readings behind the mean. */
count: number;
x: number;
y: number;
/** Fewer than {@link THIN_BUCKET_COUNT} readings: drawn hollow. */
thin: boolean;
}
export interface SeriesLayout {
key: string;
unit: string | null;
top: number;
height: number;
/** SVG path data of the solid runs; empty when no two points touch. */
path: string;
/** SVG path data of the dashed bridges over short gaps; may be empty. */
bridges: string;
/** The most recent mean inside the window, or null. */
latest: number | null;
points: SeriesPointLayout[];
}
/** A bucket mean from fewer readings than this is drawn hollow. */
export const THIN_BUCKET_COUNT = 3;
/**
* The most missing buckets a dashed bridge spans. Beyond it the line stops
* and starts again, so a long silence reads as one and not as a trend.
*
* The rule is one length, about six months between the two points a bridge
* joins, never more, and never more than a third of what the zoom shows:
* one missing quarter (the points are six months apart), two missing months
* in a year (three months apart, a sixth of the window) and four missing
* weeks in three months (five weeks apart, the window's third). A longer
* bridge would draw a slope through a stretch where nothing was measured.
*/
export const MAX_BRIDGED_GAP: Readonly<Record<TimelineBucket, number>> = {
quarter: 1,
month: 2,
week: 4,
// Days come only with a chosen range under six weeks. Three missing days
// (points four days apart) bridge a weekend away or a forgotten cuff;
// a longer silence in a window that short is a break worth seeing, and
// the bridge stays well under a third of even a two-week range.
day: 3,
};
/**
* One neutral line per series, scaled to its own range inside the window.
* A point sits in the middle of its bucket, and every bucket with a reading
* gets its point, so one month with no neighbours stays visible. Missing
* buckets stay missing: a short gap is bridged by a dashed stroke
* ({@link MAX_BRIDGED_GAP}) that joins the two real points and invents none
* between them, a longer one breaks the line.
*/
export function layoutSeries(
series: readonly TimelineSeries[],
top: number,
window: TimeWindow,
scale: Scale,
bucket: TimelineBucket,
): SeriesLayout[] {
return series.map((s, index) => {
const rowTop = top + index * SERIES_HEIGHT + 6;
const height = SERIES_HEIGHT - 16;
// A bucket that starts before the window still belongs to it while any
// of its days lie inside.
const inside = s.points
.filter((p) => p.t <= window.to && bucketAfter(p.t, bucket) > window.from)
.sort((a, b) => (a.t < b.t ? -1 : 1));
if (inside.length === 0) {
return {
key: s.key,
unit: s.unit,
top: rowTop,
height,
path: "",
bridges: "",
latest: null,
points: [],
};
}
let lo = Math.min(...inside.map((p) => p.mean));
let hi = Math.max(...inside.map((p) => p.mean));
const pad = Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.01, 0.5);
lo -= pad;
hi += pad;
const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo));
const points = inside.map((p) => {
const mid = (dayNumber(p.t) + dayNumber(bucketAfter(p.t, bucket))) / 2;
const x = Math.min(scale.x1, Math.max(scale.x0, scale.x(mid)));
return {
t: p.t,
mean: p.mean,
count: p.count,
x,
y: y(p.mean),
thin: p.count < THIN_BUCKET_COUNT,
};
});
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, read by the renderer, not a person
const xy = (p: SeriesPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
let path = "";
let bridges = "";
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const cur = points[i];
const missing =
bucketIndex(cur.t, bucket) - bucketIndex(prev.t, bucket) - 1;
if (missing === 0) {
// Continue the run, or open a new one at the previous point.
path += path.endsWith(xy(prev))
? `L${xy(cur)}`
: `M${xy(prev)}L${xy(cur)}`;
} else if (missing <= MAX_BRIDGED_GAP[bucket]) {
bridges += `M${xy(prev)}L${xy(cur)}`;
}
}
return {
key: s.key,
unit: s.unit,
top: rowTop,
height,
path,
bridges,
latest: inside[inside.length - 1].mean,
points,
};
});
}
/* ─── Lab lines ───────────────────────────────────────────────────────────── */
export interface LabPointLayout extends TimelineLabPoint {
x: number;
y: number;
}
export interface LabSeriesLayout {
key: string;
biomarkerId: string;
unit: string;
top: number;
height: number;
/** SVG path data joining the points in time order; empty under two. */
path: string;
/**
* The reference range as y positions inside the row: `top` / `bottom` of
* the band, null on a side the range leaves open.
*/
band: { top: number | null; bottom: number | null } | null;
/** The newest point inside the window, or null. */
latest: LabPointLayout | null;
points: LabPointLayout[];
excluded: number;
}
/**
* One row per lab marker, below the value lines and in their rhythm, each on
* its own scale. A lab line is its readings: a point per reading on the day
* of the draw, joined in time order by a thin stroke, never a mean and never
* a value between readings (#1299). The scale holds the points and the
* reference range together, as the labs page's chart does, so the band
* behind the points is always in view and a point outside it reads as
* outside at a glance.
*/
export function layoutLabSeries(
series: readonly TimelineLabSeries[],
top: number,
window: TimeWindow,
scale: Scale,
): LabSeriesLayout[] {
return series.map((s, index) => {
const rowTop = top + index * SERIES_HEIGHT + 6;
const height = SERIES_HEIGHT - 16;
const inside = s.points.filter(
(p) => p.date >= window.from && p.date <= window.to,
);
const bounds = [s.band?.low, s.band?.high].filter(
(b): b is number => typeof b === "number",
);
const span = [...inside.map((p) => p.value), ...bounds];
let lo = span.length > 0 ? Math.min(...span) : 0;
let hi = span.length > 0 ? Math.max(...span) : 1;
// A lone value, or a range of one number, still needs a span to sit in.
const pad = hi > lo ? (hi - lo) * 0.12 : Math.abs(hi) * 0.1 || 1;
lo -= pad;
hi += pad;
const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo));
const points = inside.map((p) => ({
...p,
x: Math.min(scale.x1, Math.max(scale.x0, scale.xMid(p.date))),
y: y(p.value),
}));
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, read by the renderer, not a person
const xy = (p: LabPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
const path =
points.length > 1
? points.map((p, i) => `${i === 0 ? "M" : "L"}${xy(p)}`).join("")
: "";
const band =
s.band && (s.band.low !== null || s.band.high !== null)
? {
top: s.band.high === null ? null : y(s.band.high),
bottom: s.band.low === null ? null : y(s.band.low),
}
: null;
return {
key: s.key,
biomarkerId: s.biomarkerId,
unit: s.unit,
top: rowTop,
height,
path,
band,
latest: points.at(-1) ?? null,
points,
excluded: s.excluded,
};
});
}