mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
A custom symptom could feed pattern discovery and the Coach but could not be looked at over time. The timeline now offers every symptom the record has logged, hidden ones included, beside the measurements and lab markers, and draws a chosen one as a value line. `values` takes `symptom:<definitionId>`, checked for its shape and capped like a lab marker; an id the record does not hold draws nothing. A day's value is its highest intensity on the record's local day; a point is the mean of the bucket's day values over the days logged, with the number of those days, and a day without an entry is not a zero. The line sits on a fixed 0 to 10 scale. Symptoms are read under the illness lane's gate, the module and sharing domain the symptom routes use. An `all` window reaches back to a chosen symptom's first entry. The response carries `symptomSeries` and `availableSymptoms`; the OpenAPI contract is regenerated. The page words a point's count as days and the value out of ten. The date-key schema moves to its own module so the symptom schemas can share it.
257 lines
8.8 KiB
TypeScript
257 lines
8.8 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 {
|
|
TIMELINE_SYMPTOM_SCALE,
|
|
timelineSymptomDefinitionId,
|
|
type TimelineBucket,
|
|
type TimelineLabPoint,
|
|
type TimelineLabSeries,
|
|
type 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
|
|
* symptom's to the fixed intensity scale, `TIMELINE_SYMPTOM_SCALE`).
|
|
* 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: [],
|
|
};
|
|
}
|
|
// A symptom line sits on the intensity scale whatever the window
|
|
// holds (#981): a 3 reads as a 3, not as the top of a quiet stretch.
|
|
const fixed =
|
|
timelineSymptomDefinitionId(s.key) !== null
|
|
? TIMELINE_SYMPTOM_SCALE
|
|
: null;
|
|
let lo = fixed ? fixed.min : Math.min(...inside.map((p) => p.mean));
|
|
let hi = fixed ? fixed.max : Math.max(...inside.map((p) => p.mean));
|
|
const pad = fixed
|
|
? (hi - lo) * 0.05
|
|
: 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,
|
|
};
|
|
});
|
|
}
|