mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Three defects on one card of the step sub-page, all of them a surface answering a question that belongs to the metric. "Your usual range is -5,595.6-13,387.6 steps", under a chart whose own y-axis refuses to draw a negative step. Same app, same question, two answers: the axis clamped and the sentence beneath it did not. Both now read the same fact. `VALUE_RANGES` already declares every metric's plausibility floor for ingest; `value-domain.ts` reads it and answers "can this metric be negative" for the padded y-domain and for the median +/- k.MAD band alike. The band's lower edge floors at zero for a non-negative metric; `spread` stays the unclamped dispersion so a deviation-in-sigma calculation is unaffected. `buildBaselineBand` now takes the metric it is describing as a required argument, so a new caller cannot quietly reintroduce a negative edge. Two types carried no plausibility range at all, which let any number through ingest and read as "unknown" on the negativity question: walking steadiness gets the same 0-100 percent band as its two gait siblings, the loud-listening event a floor of 1 with a generous ceiling. "13,387.6 steps" also said steps to a reader of German. Five sub-page units are English words, not symbols - steps, flights, falls, years, and a "count" that was a poor label in any language. They now travel as i18n keys through a new `unitKey` on the metric-page scaffold and resolve in all six locales. The thirteen real symbols (bpm, mmHg, %, m/s, kg/m2, ...) stay literals; translating a symbol would be the opposite mistake. And the Min read "104.0 steps". Precision was a per-page prop defaulting to one decimal, so every page had to remember that its metric is a count - and the step page did not. It follows the metric now: a discrete metric (tallies, category events, closed integer instrument scores, ordinal levels) resolves zero decimals wherever it is rendered, and a page only passes the prop to override that.
54 lines
2.3 KiB
TypeScript
54 lines
2.3 KiB
TypeScript
/**
|
|
* Shared Y-axis domain computation for every chart surface (dashboard
|
|
* charts, insights sub-page charts, mini charts). One source of truth so
|
|
* the padding rules cannot drift per chart.
|
|
*
|
|
* Padding rules:
|
|
* - Flat series (min === max): pad symmetrically by 5% of the magnitude
|
|
* (at least 1 unit), with a little extra headroom on top.
|
|
* - Otherwise: pad the bottom by 8% of the span (min 0.5) and the top by
|
|
* 16% of the span (min 1).
|
|
* - Zero clamp: the lower bound never dips below 0 when the charted metrics
|
|
* cannot be negative — padding only extends upward or down to 0. Counts,
|
|
* steps, weights etc. must not render a negative axis. Series with genuine
|
|
* negative values (deltas, differences) keep the full downward padding.
|
|
*
|
|
* "Cannot be negative" is answered by `areAllNonNegativeMetrics` off the
|
|
* metric's own declared plausibility domain — the SAME fact the personal
|
|
* baseline band floors against, so the axis and the sentence printed under
|
|
* it can no longer disagree. It also covers the derived overlays: a trend
|
|
* line extrapolating a step count below zero now clips at the axis floor
|
|
* instead of dragging the whole chart into negative territory. The observed
|
|
* minimum stays a second, weaker trigger, so a caller that names no type
|
|
* (a mini chart over an anonymous series) keeps the data-derived behaviour.
|
|
*/
|
|
import { areAllNonNegativeMetrics } from "@/lib/measurements/value-domain";
|
|
|
|
export function computePaddedYDomain(
|
|
values: readonly number[],
|
|
types: readonly (string | null | undefined)[] = [],
|
|
): [number, number] | undefined {
|
|
const finite = values.filter(
|
|
(value): value is number =>
|
|
typeof value === "number" && Number.isFinite(value),
|
|
);
|
|
if (!finite.length) return undefined;
|
|
|
|
const min = Math.min(...finite);
|
|
const max = Math.max(...finite);
|
|
// Non-negative series never get a negative lower bound.
|
|
const nonNegative = min >= 0 || areAllNonNegativeMetrics(types);
|
|
const clampLower = (lower: number) =>
|
|
nonNegative ? Math.max(0, lower) : lower;
|
|
|
|
if (min === max) {
|
|
const delta = Math.max(Math.abs(min) * 0.05, 1);
|
|
return [clampLower(min - delta), max + delta * 1.35];
|
|
}
|
|
|
|
const span = max - min;
|
|
const paddingBottom = Math.max(span * 0.08, 0.5);
|
|
const paddingTop = Math.max(span * 0.16, 1);
|
|
return [clampLower(min - paddingBottom), max + paddingTop];
|
|
}
|