feat(mobile): Gewichtsverlauf als Diagramm
found.md: Gewichtsdaten wurden nur als Liste angezeigt. Neues WeightChart (react-native-svg, Port des Web-Diagramms) ab 2 Eintraegen ueber der Liste: x-Achse zeitproportional (unregelmaessige Wiegetermine), runde y-Ticks (1/2/5 x 10^n), Datumslabels nach Pixelabstand, Antippen waehlt die naechste Messung (Wert + Datum im Kopf), Veraenderung seit erster Messung. Linienfarbe als neues Token chartLine (#ea580c), mit dem Dataviz-Validator gegen helle und dunkle Flaechen geprueft. Geometrie rein in src/lib/weight-chart.ts mit Tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
81e617ece5
commit
e60abbabc6
7 files changed
+507
-13
No files matched your search
@@ -0,0 +1,202 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { View, Text, Pressable, StyleSheet, type LayoutChangeEvent } from "react-native";
|
||||
import Svg, { Path, Line, Circle, Text as SvgText, Defs, LinearGradient, Stop } from "react-native-svg";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { Card } from "../Card";
|
||||
import { getTypography, fontFamily, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { buildWeightChart, nearestPointIndex, type WeightEntry } from "../../lib/weight-chart";
|
||||
|
||||
const CHART_HEIGHT = 170;
|
||||
const BOX = { left: 40, right: 12, top: 12, bottom: 26 };
|
||||
const AXIS_FONT_SIZE = 10;
|
||||
const MAX_LABELED_Y_TICKS = 5;
|
||||
const MARKER_RADIUS = 4;
|
||||
const SELECTED_RADIUS = 6;
|
||||
const LONG_SPAN_MS = 300 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Weight history (found.md: Gewichtsdaten als Diagramm) — port of web's
|
||||
* src/components/health/WeightChart.tsx. Tap anywhere on the plot to select
|
||||
* the nearest weigh-in; its value and date show in the header (no floating
|
||||
* tooltip to collide with the edges on a phone). The newest entry is
|
||||
* selected by default. The entry list below the chart is the table view.
|
||||
*/
|
||||
export function WeightChart({ entries }: { entries: WeightEntry[] }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const [width, setWidth] = useState(0);
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
|
||||
const chart = useMemo(
|
||||
() => (width > 0 ? buildWeightChart(entries, { width, height: CHART_HEIGHT, ...BOX }) : null),
|
||||
[entries, width]
|
||||
);
|
||||
|
||||
const formatKg = (kg: number, maximumFractionDigits = 2) =>
|
||||
`${kg.toLocaleString(i18n.language, { maximumFractionDigits })} kg`;
|
||||
const formatDate = (time: number, options: Intl.DateTimeFormatOptions) =>
|
||||
new Date(time).toLocaleDateString(i18n.language, { timeZone: "UTC", ...options });
|
||||
|
||||
const onLayout = (e: LayoutChangeEvent) => setWidth(Math.round(e.nativeEvent.layout.width));
|
||||
|
||||
if (entries.length < 2) return null;
|
||||
|
||||
const points = chart?.points ?? [];
|
||||
const activeIndex = selectedIndex !== null && selectedIndex < points.length ? selectedIndex : points.length - 1;
|
||||
const active = points[activeIndex];
|
||||
const delta = chart?.deltaKg ?? 0;
|
||||
const deltaLabel = `${delta > 0 ? "+" : delta < 0 ? "−" : "±"}${formatKg(Math.abs(delta))}`;
|
||||
const spansLong = points.length > 1 && points[points.length - 1].time - points[0].time > LONG_SPAN_MS;
|
||||
const axisDateOptions: Intl.DateTimeFormatOptions = spansLong
|
||||
? { month: "short", year: "2-digit" }
|
||||
: { day: "2-digit", month: "2-digit" };
|
||||
|
||||
const summary =
|
||||
points.length > 1
|
||||
? t("health.weightChartA11y", {
|
||||
from: formatKg(points[0].kg),
|
||||
fromDate: formatDate(points[0].time, { dateStyle: "medium" }),
|
||||
to: formatKg(points[points.length - 1].kg),
|
||||
toDate: formatDate(points[points.length - 1].time, { dateStyle: "medium" }),
|
||||
})
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.eyebrow}>{t("health.weightChartTitle")}</Text>
|
||||
{active && (
|
||||
<Text style={styles.readout}>
|
||||
{formatKg(active.kg)}
|
||||
<Text style={styles.readoutDate}>{" "}{formatDate(active.time, { dateStyle: "medium" })}</Text>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.deltaChip}>
|
||||
<Text style={styles.deltaText}>{deltaLabel}</Text>
|
||||
<Text style={styles.deltaCaption}>{t("health.weightChartSinceFirst")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onLayout={onLayout}
|
||||
onPress={(e) => chart && setSelectedIndex(nearestPointIndex(chart.points, e.nativeEvent.locationX))}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={summary}
|
||||
style={styles.plot}
|
||||
>
|
||||
{chart && (
|
||||
<Svg width={width} height={CHART_HEIGHT}>
|
||||
<Defs>
|
||||
<LinearGradient id="weightArea" x1="0" y1="0" x2="0" y2="1">
|
||||
<Stop offset="0" stopColor={colors.chartLine} stopOpacity={0.2} />
|
||||
<Stop offset="1" stopColor={colors.chartLine} stopOpacity={0} />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
|
||||
{chart.yTicks.map((tick, i) => (
|
||||
<Line
|
||||
key={`grid-${i}`}
|
||||
x1={BOX.left}
|
||||
x2={width - BOX.right}
|
||||
y1={tick.y}
|
||||
y2={tick.y}
|
||||
stroke={colors.hairlineGray}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
{chart.yTicks.map((tick, i) =>
|
||||
chart.yTicks.length <= MAX_LABELED_Y_TICKS || i % 2 === 0 ? (
|
||||
<SvgText
|
||||
key={`ylabel-${i}`}
|
||||
x={BOX.left - 6}
|
||||
y={tick.y + AXIS_FONT_SIZE / 3}
|
||||
fontSize={AXIS_FONT_SIZE}
|
||||
fontFamily={fontFamily.regular}
|
||||
fill={colors.quietGray}
|
||||
textAnchor="end"
|
||||
>
|
||||
{tick.kg.toLocaleString(i18n.language, {
|
||||
minimumFractionDigits: chart.yTickDecimals,
|
||||
maximumFractionDigits: chart.yTickDecimals,
|
||||
})}
|
||||
</SvgText>
|
||||
) : null
|
||||
)}
|
||||
|
||||
<Path d={chart.areaPath} fill="url(#weightArea)" />
|
||||
<Path
|
||||
d={chart.linePath}
|
||||
fill="none"
|
||||
stroke={colors.chartLine}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{active && (
|
||||
<Line
|
||||
x1={active.x}
|
||||
x2={active.x}
|
||||
y1={BOX.top}
|
||||
y2={chart.baselineY}
|
||||
stroke={colors.quietGray}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
)}
|
||||
|
||||
{chart.points.map((p, i) => (
|
||||
<Circle
|
||||
key={`pt-${i}`}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={i === activeIndex ? SELECTED_RADIUS : MARKER_RADIUS}
|
||||
fill={i === activeIndex ? colors.chartLine : colors.pureWhite}
|
||||
stroke={i === activeIndex ? colors.pureWhite : colors.chartLine}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
|
||||
{chart.xLabelIndices.map((i) => {
|
||||
const p = chart.points[i];
|
||||
const isFirst = i === chart.xLabelIndices[0] && p.x - BOX.left < 20;
|
||||
const isLast = i === chart.points.length - 1;
|
||||
return (
|
||||
<SvgText
|
||||
key={`xlabel-${i}`}
|
||||
x={p.x}
|
||||
y={CHART_HEIGHT - 6}
|
||||
fontSize={AXIS_FONT_SIZE}
|
||||
fontFamily={fontFamily.regular}
|
||||
fill={colors.quietGray}
|
||||
textAnchor={isLast ? "end" : isFirst ? "start" : "middle"}
|
||||
>
|
||||
{formatDate(p.time, axisDateOptions)}
|
||||
</SvgText>
|
||||
);
|
||||
})}
|
||||
</Svg>
|
||||
)}
|
||||
</Pressable>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
card: { gap: 8 },
|
||||
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", gap: 12 },
|
||||
eyebrow: { ...typography.label },
|
||||
readout: { ...typography.title, fontSize: 20, marginTop: 2 },
|
||||
readoutDate: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 12 },
|
||||
deltaChip: { alignItems: "flex-end" },
|
||||
deltaText: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
deltaCaption: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 },
|
||||
plot: { height: CHART_HEIGHT },
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { TextField } from "../TextField";
|
||||
import { DateField } from "../DateField";
|
||||
import { Button } from "../Button";
|
||||
import { Card } from "../Card";
|
||||
import { WeightChart } from "./WeightChart";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
|
||||
|
||||
@@ -82,20 +83,23 @@ export function WeightLogSection({ petId }: { petId: string }) {
|
||||
) : logs.length === 0 ? (
|
||||
<Text style={styles.empty}>{t("health.weightEmpty")}</Text>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<View key={log.id} style={styles.row}>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.rowPrimary}>{(log.weightGrams / 1000).toLocaleString(i18n.language)} kg</Text>
|
||||
<Text style={styles.rowSecondary}>
|
||||
{new Date(log.date).toLocaleDateString(i18n.language)}
|
||||
{log.notes ? ` · ${log.notes}` : ""}
|
||||
</Text>
|
||||
<>
|
||||
<WeightChart entries={logs} />
|
||||
{logs.map((log) => (
|
||||
<View key={log.id} style={styles.row}>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.rowPrimary}>{(log.weightGrams / 1000).toLocaleString(i18n.language)} kg</Text>
|
||||
<Text style={styles.rowSecondary}>
|
||||
{new Date(log.date).toLocaleDateString(i18n.language)}
|
||||
{log.notes ? ` · ${log.notes}` : ""}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => deleteMutation.mutate({ id: log.id, petId })} hitSlop={8}>
|
||||
<Trash2 color={colors.quietGray} size={16} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable onPress={() => deleteMutation.mutate({ id: log.id, petId })} hitSlop={8}>
|
||||
<Trash2 color={colors.quietGray} size={16} />
|
||||
</Pressable>
|
||||
</View>
|
||||
))
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -144,6 +144,9 @@
|
||||
"weightKgPlaceholder": "Gewicht (kg), z.B. 4,2",
|
||||
"notesPlaceholder": "Notizen (optional)",
|
||||
"weightEmpty": "Noch keine Gewichtseinträge",
|
||||
"weightChartTitle": "Verlauf",
|
||||
"weightChartSinceFirst": "seit erster Messung",
|
||||
"weightChartA11y": "Gewichtsverlauf von {{from}} am {{fromDate}} auf {{to}} am {{toDate}}. Tippen, um eine Messung auszuwählen.",
|
||||
"invalidDate": "Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben.",
|
||||
"vetTitle": "Tierarzt",
|
||||
"vetReasonPlaceholder": "Grund, z.B. Jahresuntersuchung",
|
||||
|
||||
@@ -144,6 +144,9 @@
|
||||
"weightKgPlaceholder": "Weight (kg), e.g. 4.2",
|
||||
"notesPlaceholder": "Notes (optional)",
|
||||
"weightEmpty": "No weight entries yet",
|
||||
"weightChartTitle": "History",
|
||||
"weightChartSinceFirst": "since first entry",
|
||||
"weightChartA11y": "Weight history from {{from}} on {{fromDate}} to {{to}} on {{toDate}}. Tap to select an entry.",
|
||||
"invalidDate": "Please enter a valid date in DD.MM.YYYY format.",
|
||||
"vetTitle": "Vet",
|
||||
"vetReasonPlaceholder": "Reason, e.g. annual checkup",
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildWeightChart, nearestPointIndex } from "./weight-chart";
|
||||
|
||||
const box = { width: 300, height: 180, left: 40, right: 10, top: 10, bottom: 30 };
|
||||
|
||||
describe("buildWeightChart", () => {
|
||||
it("needs at least two entries", () => {
|
||||
expect(buildWeightChart([], box)).toBeNull();
|
||||
expect(buildWeightChart([{ date: "2026-01-01", weightGrams: 4000 }], box)).toBeNull();
|
||||
});
|
||||
|
||||
it("sorts by date and spaces x by time, not by index", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-01-31", weightGrams: 4200 },
|
||||
{ date: "2026-01-01", weightGrams: 4000 },
|
||||
{ date: "2026-01-04", weightGrams: 4100 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
expect(chart.points.map((p) => p.kg)).toEqual([4, 4.1, 4.2]);
|
||||
expect(chart.points[0].x).toBe(40);
|
||||
expect(chart.points[2].x).toBe(290);
|
||||
// 3 of 30 days → 10 % of the 250px plot width, not the middle.
|
||||
expect(chart.points[1].x).toBeCloseTo(65, 5);
|
||||
});
|
||||
|
||||
it("puts heavier weights higher and reports the change since the first entry", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-01-01", weightGrams: 4000 },
|
||||
{ date: "2026-02-01", weightGrams: 4350 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
expect(chart.points[1].y).toBeLessThan(chart.points[0].y);
|
||||
expect(chart.deltaKg).toBeCloseTo(0.35, 5);
|
||||
});
|
||||
|
||||
it("keeps a flat series inside the plot and never goes below 0 kg", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-01-01", weightGrams: 300 },
|
||||
{ date: "2026-01-02", weightGrams: 300 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
for (const p of chart.points) {
|
||||
expect(p.y).toBeGreaterThan(box.top);
|
||||
expect(p.y).toBeLessThan(chart.baselineY);
|
||||
}
|
||||
expect(chart.yTicks[0].kg).toBe(0);
|
||||
});
|
||||
|
||||
it("falls back to index spacing when all entries share a date", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-01-01", weightGrams: 4000 },
|
||||
{ date: "2026-01-01", weightGrams: 4100 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
expect(chart.points.map((p) => p.x)).toEqual([40, 290]);
|
||||
});
|
||||
|
||||
it("labels at most a handful of dates and always the newest", () => {
|
||||
const entries = Array.from({ length: 12 }, (_, i) => ({ date: new Date(Date.UTC(2026, 0, 1 + i)), weightGrams: 4000 + i }));
|
||||
const chart = buildWeightChart(entries, box)!;
|
||||
expect(chart.xLabelIndices.length).toBeLessThanOrEqual(4);
|
||||
expect(chart.xLabelIndices).toContain(11);
|
||||
});
|
||||
|
||||
it("never places two date labels closer than a quarter of the plot", () => {
|
||||
// Two weigh-ins a day apart, then one a year later: the first two must not both get a label.
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2025-01-01", weightGrams: 4000 },
|
||||
{ date: "2025-01-02", weightGrams: 4010 },
|
||||
{ date: "2026-01-01", weightGrams: 4500 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
const xs = chart.xLabelIndices.map((i) => chart.points[i].x);
|
||||
for (let i = 1; i < xs.length; i++) expect(xs[i] - xs[i - 1]).toBeGreaterThanOrEqual(250 / 4);
|
||||
expect(chart.xLabelIndices.at(-1)).toBe(2);
|
||||
});
|
||||
|
||||
it("builds closed area and open line paths", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-01-01", weightGrams: 4000 },
|
||||
{ date: "2026-01-11", weightGrams: 4100 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
expect(chart.linePath.startsWith("M 40 ")).toBe(true);
|
||||
expect(chart.areaPath.endsWith("Z")).toBe(true);
|
||||
expect(chart.areaPath).toContain(` ${chart.baselineY} `);
|
||||
});
|
||||
});
|
||||
|
||||
describe("y axis ticks", () => {
|
||||
it("uses round, distinct tick values even for a tiny range", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-09-01", weightGrams: 4200 },
|
||||
{ date: "2026-10-01", weightGrams: 4150 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
const labels = chart.yTicks.map((t) => t.kg.toFixed(chart.yTickDecimals));
|
||||
expect(new Set(labels).size).toBe(labels.length);
|
||||
expect(chart.yTicks[0].kg).toBeLessThanOrEqual(4.15);
|
||||
expect(chart.yTicks.at(-1)!.kg).toBeGreaterThanOrEqual(4.2);
|
||||
expect(chart.yTickDecimals).toBe(2);
|
||||
});
|
||||
|
||||
it("uses whole kilos for a big dog over a wide range", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2025-01-01", weightGrams: 25000 },
|
||||
{ date: "2026-01-01", weightGrams: 33000 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
expect(chart.yTickDecimals).toBe(0);
|
||||
for (const t of chart.yTicks) expect(Number.isInteger(t.kg)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nearestPointIndex", () => {
|
||||
it("picks the point closest to the tap", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-01-01", weightGrams: 4000 },
|
||||
{ date: "2026-01-11", weightGrams: 4100 },
|
||||
{ date: "2026-01-21", weightGrams: 4200 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
expect(nearestPointIndex(chart.points, 0)).toBe(0);
|
||||
expect(nearestPointIndex(chart.points, 170)).toBe(1);
|
||||
expect(nearestPointIndex(chart.points, 999)).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Pure geometry for the mobile weight-history chart (port of web's
|
||||
* src/components/health/WeightChart.tsx). Kept free of React/SVG so it can
|
||||
* be unit-tested.
|
||||
*
|
||||
* One deliberate difference from web: x is proportional to the date, not to
|
||||
* the entry's index — weigh-ins are irregular, and index spacing would make a
|
||||
* jump over three months look as steep as one over three days.
|
||||
*/
|
||||
|
||||
export type WeightEntry = { date: Date | string; weightGrams: number };
|
||||
|
||||
export type ChartBox = { width: number; height: number; left: number; right: number; top: number; bottom: number };
|
||||
|
||||
export type ChartPoint = { x: number; y: number; kg: number; time: number; entry: WeightEntry };
|
||||
|
||||
export type WeightChartGeometry = {
|
||||
points: ChartPoint[];
|
||||
linePath: string;
|
||||
areaPath: string;
|
||||
yTicks: { y: number; kg: number }[];
|
||||
/** Decimal places that make every y tick label distinct (derived from the tick step). */
|
||||
yTickDecimals: number;
|
||||
xLabelIndices: number[];
|
||||
baselineY: number;
|
||||
deltaKg: number;
|
||||
};
|
||||
|
||||
const TARGET_Y_TICKS = 4;
|
||||
const MAX_X_LABELS = 4;
|
||||
const RANGE_PADDING = 0.18;
|
||||
const FLAT_RANGE_PADDING_KG = 0.5;
|
||||
|
||||
/** Rounds a raw step up to 1, 2 or 5 × 10^n, so tick labels are round numbers (4,0 / 4,2 / 4,4 — not 4,13 / 4,27). */
|
||||
function niceStep(rawStep: number): number {
|
||||
const magnitude = 10 ** Math.floor(Math.log10(rawStep));
|
||||
const normalized = rawStep / magnitude;
|
||||
const nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
||||
return nice * magnitude;
|
||||
}
|
||||
|
||||
function smoothPath(points: ChartPoint[]): string {
|
||||
return points
|
||||
.map((p, i) => {
|
||||
if (i === 0) return `M ${p.x} ${p.y}`;
|
||||
const prev = points[i - 1];
|
||||
const cpX = (prev.x + p.x) / 2;
|
||||
return `C ${cpX} ${prev.y} ${cpX} ${p.y} ${p.x} ${p.y}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Date labels chosen by on-screen distance, not by index (x is time-based, so
|
||||
* index spacing could stack labels on top of each other): walk back from the
|
||||
* newest point and keep one whenever it is far enough from the last kept one.
|
||||
*/
|
||||
function pickLabelIndices(points: { x: number }[], plotWidth: number): number[] {
|
||||
const minGap = plotWidth / MAX_X_LABELS;
|
||||
const kept = [points.length - 1];
|
||||
for (let i = points.length - 2; i >= 0 && kept.length < MAX_X_LABELS; i--) {
|
||||
if (points[kept[kept.length - 1]].x - points[i].x >= minGap) kept.push(i);
|
||||
}
|
||||
return kept.reverse();
|
||||
}
|
||||
|
||||
/** Returns null for fewer than two entries — a single weigh-in is not a trend. */
|
||||
export function buildWeightChart(entries: WeightEntry[], box: ChartBox): WeightChartGeometry | null {
|
||||
if (entries.length < 2) return null;
|
||||
|
||||
const sorted = [...entries]
|
||||
.map((entry) => ({ entry, time: new Date(entry.date).getTime(), kg: entry.weightGrams / 1000 }))
|
||||
.sort((a, b) => a.time - b.time);
|
||||
|
||||
const kgs = sorted.map((e) => e.kg);
|
||||
const minKg = Math.min(...kgs);
|
||||
const maxKg = Math.max(...kgs);
|
||||
const rawRange = maxKg - minKg;
|
||||
const pad = rawRange === 0 ? FLAT_RANGE_PADDING_KG : rawRange * RANGE_PADDING;
|
||||
const step = niceStep((maxKg + pad - Math.max(0, minKg - pad)) / TARGET_Y_TICKS);
|
||||
// Snap the padded range outward to whole steps so every gridline sits on a round value.
|
||||
const yMin = Math.max(0, Math.floor((minKg - pad) / step) * step);
|
||||
const yMax = Math.ceil((maxKg + pad) / step) * step;
|
||||
const tickCount = Math.round((yMax - yMin) / step);
|
||||
|
||||
const plotW = box.width - box.left - box.right;
|
||||
const plotH = box.height - box.top - box.bottom;
|
||||
const firstTime = sorted[0].time;
|
||||
const timeSpan = sorted[sorted.length - 1].time - firstTime;
|
||||
|
||||
const xOf = (time: number, index: number) =>
|
||||
box.left +
|
||||
(timeSpan > 0 ? (time - firstTime) / timeSpan : index / (sorted.length - 1)) * plotW;
|
||||
const yOf = (kg: number) => box.top + plotH - ((kg - yMin) / (yMax - yMin)) * plotH;
|
||||
|
||||
const points = sorted.map((e, i) => ({ x: xOf(e.time, i), y: yOf(e.kg), kg: e.kg, time: e.time, entry: e.entry }));
|
||||
const baselineY = box.top + plotH;
|
||||
const linePath = smoothPath(points);
|
||||
const areaPath =
|
||||
`M ${points[0].x} ${baselineY} L ${points[0].x} ${points[0].y} ` +
|
||||
linePath.replace(/^M [^ ]+ [^ ]+ ?/, "") +
|
||||
` L ${points[points.length - 1].x} ${baselineY} Z`;
|
||||
|
||||
const yTicks = Array.from({ length: tickCount + 1 }, (_, i) => {
|
||||
// Rounded to kill float noise (0.1 + 0.2), the step decides the precision.
|
||||
const kg = Number((yMin + step * i).toFixed(6));
|
||||
return { kg, y: yOf(kg) };
|
||||
});
|
||||
|
||||
return {
|
||||
points,
|
||||
linePath,
|
||||
areaPath,
|
||||
yTicks,
|
||||
yTickDecimals: Math.max(0, -Math.floor(Math.log10(step) + 1e-9)),
|
||||
xLabelIndices: pickLabelIndices(points, plotW),
|
||||
baselineY,
|
||||
deltaKg: kgs[kgs.length - 1] - kgs[0],
|
||||
};
|
||||
}
|
||||
|
||||
/** Index of the point closest to a tapped x position. */
|
||||
export function nearestPointIndex(points: ChartPoint[], x: number): number {
|
||||
let best = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
if (Math.abs(points[i].x - x) < Math.abs(points[best].x - x)) best = i;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -26,6 +26,12 @@ export interface ColorTokens {
|
||||
quietGray: string;
|
||||
alertRed: string;
|
||||
alertRedTint: string;
|
||||
/**
|
||||
* Data line in charts (weight history). Checked with the dataviz palette
|
||||
* validator: >= 3:1 against both surfaces and inside the lightness band in
|
||||
* both modes — the brighter marigoldOrange fails one of the two each way.
|
||||
*/
|
||||
chartLine: string;
|
||||
}
|
||||
|
||||
export const colors: ColorTokens = {
|
||||
@@ -41,6 +47,7 @@ export const colors: ColorTokens = {
|
||||
quietGray: "#737373",
|
||||
alertRed: "#dc2626",
|
||||
alertRedTint: "#fee2e2",
|
||||
chartLine: "#ea580c",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -61,6 +68,7 @@ export const darkColors: ColorTokens = {
|
||||
quietGray: "#a3a3a3",
|
||||
alertRed: "#f87171",
|
||||
alertRedTint: "#450a0a",
|
||||
chartLine: "#ea580c",
|
||||
};
|
||||
|
||||
export const radii = {
|
||||
|
||||
Reference in new issue
Block a user