164 lines
5.7 KiB
TypeScript
164 lines
5.7 KiB
TypeScript
import React, {useMemo} from 'react';
|
|
import {StyleSheet, Text, View} from 'react-native';
|
|
import Svg, {Circle, Line, Path, Rect} from 'react-native-svg';
|
|
|
|
import {colors, spacing} from '../../../theme';
|
|
import type {MetricTrendPoint} from '../insights/metricInsights';
|
|
|
|
interface MetricTrendChartProps {
|
|
points: readonly MetricTrendPoint[];
|
|
color: string;
|
|
unit: string;
|
|
digits: number;
|
|
referenceRange: {minimum: number; maximum: number} | null;
|
|
}
|
|
|
|
const WIDTH = 320;
|
|
const HEIGHT = 104;
|
|
const PAD_X = 8;
|
|
const PAD_Y = 13;
|
|
|
|
const dateLabel = (value: string): string =>
|
|
new Date(value).toLocaleDateString('fr-FR', {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
});
|
|
|
|
const createPath = (coordinates: readonly {x: number; y: number}[]): string => {
|
|
if (coordinates.length === 0) return '';
|
|
if (coordinates.length === 1) {
|
|
const only = coordinates[0]!;
|
|
return `M ${only.x} ${only.y}`;
|
|
}
|
|
return coordinates.reduce((path, point, index) => {
|
|
if (index === 0) return `M ${point.x} ${point.y}`;
|
|
const previous = coordinates[index - 1]!;
|
|
const controlX = (previous.x + point.x) / 2;
|
|
return `${path} C ${controlX} ${previous.y}, ${controlX} ${point.y}, ${point.x} ${point.y}`;
|
|
}, '');
|
|
};
|
|
|
|
export function MetricTrendChart({
|
|
points,
|
|
color,
|
|
unit,
|
|
digits,
|
|
referenceRange,
|
|
}: MetricTrendChartProps): React.JSX.Element {
|
|
const model = useMemo(() => {
|
|
if (points.length === 0) return null;
|
|
const allValues = [
|
|
...points.map(point => point.value),
|
|
...(referenceRange === null
|
|
? []
|
|
: [referenceRange.minimum, referenceRange.maximum]),
|
|
];
|
|
const rawMinimum = Math.min(...allValues);
|
|
const rawMaximum = Math.max(...allValues);
|
|
const padding = Math.max((rawMaximum - rawMinimum) * 0.18, Math.abs(rawMaximum) * 0.02, 0.5);
|
|
const minimum = rawMinimum - padding;
|
|
const maximum = rawMaximum + padding;
|
|
const scaleY = (value: number): number =>
|
|
PAD_Y + ((maximum - value) / Math.max(maximum - minimum, 0.0001)) * (HEIGHT - PAD_Y * 2);
|
|
const coordinates = points.map((point, index) => ({
|
|
x:
|
|
points.length === 1
|
|
? WIDTH / 2
|
|
: PAD_X + (index / (points.length - 1)) * (WIDTH - PAD_X * 2),
|
|
y: scaleY(point.value),
|
|
}));
|
|
return {
|
|
coordinates,
|
|
path: createPath(coordinates),
|
|
rangeY:
|
|
referenceRange === null
|
|
? null
|
|
: {
|
|
top: scaleY(referenceRange.maximum),
|
|
bottom: scaleY(referenceRange.minimum),
|
|
},
|
|
};
|
|
}, [points, referenceRange]);
|
|
|
|
if (model === null || points.length < 2) {
|
|
return (
|
|
<View style={styles.empty} accessibilityLabel="Tendance indisponible">
|
|
<Text style={styles.emptyTitle}>Tendance en construction</Text>
|
|
<Text style={styles.emptyText}>
|
|
Encore {Math.max(0, 2 - points.length)} mesure comparable pour tracer une évolution.
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const first = points[0]!;
|
|
const last = points[points.length - 1]!;
|
|
const firstCoordinate = model.coordinates[0]!;
|
|
const lastCoordinate = model.coordinates[model.coordinates.length - 1]!;
|
|
const valueLabel = `${last.value.toFixed(digits)} ${unit}`;
|
|
|
|
return (
|
|
<View
|
|
accessible
|
|
accessibilityRole="image"
|
|
accessibilityLabel={`Tendance de ${points.length} mesures, de ${first.value.toFixed(digits)} à ${valueLabel}`}>
|
|
<Svg width="100%" height={HEIGHT} viewBox={`0 0 ${WIDTH} ${HEIGHT}`}>
|
|
{model.rangeY === null ? null : (
|
|
<Rect
|
|
x={PAD_X}
|
|
y={model.rangeY.top}
|
|
width={WIDTH - PAD_X * 2}
|
|
height={Math.max(2, model.rangeY.bottom - model.rangeY.top)}
|
|
fill={colors.accent}
|
|
opacity={0.08}
|
|
rx={4}
|
|
/>
|
|
)}
|
|
<Line x1={PAD_X} y1={HEIGHT - PAD_Y} x2={WIDTH - PAD_X} y2={HEIGHT - PAD_Y} stroke={colors.border} strokeWidth={1} />
|
|
<Line x1={firstCoordinate.x} y1={PAD_Y} x2={firstCoordinate.x} y2={HEIGHT - PAD_Y} stroke={colors.border} strokeWidth={1} />
|
|
<Line x1={lastCoordinate.x} y1={PAD_Y} x2={lastCoordinate.x} y2={HEIGHT - PAD_Y} stroke={colors.border} strokeWidth={1} />
|
|
<Path d={model.path} fill="none" stroke={color} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
|
|
{model.coordinates.map((coordinate, index) => (
|
|
<Circle
|
|
key={points[index]!.id}
|
|
cx={coordinate.x}
|
|
cy={coordinate.y}
|
|
r={index === 0 || index === model.coordinates.length - 1 ? 4 : 2.2}
|
|
fill={color}
|
|
opacity={index === 0 || index === model.coordinates.length - 1 ? 1 : 0.62}
|
|
/>
|
|
))}
|
|
</Svg>
|
|
<View style={styles.labels}>
|
|
<Text style={styles.date}>{dateLabel(first.measuredAt)}</Text>
|
|
<Text style={[styles.lastValue, {color}]}>{valueLabel}</Text>
|
|
<Text style={styles.date}>{dateLabel(last.measuredAt)}</Text>
|
|
</View>
|
|
<Text style={styles.caption}>
|
|
{points.length} mesures réelles{referenceRange === null ? '' : ' · zone pâle : repère personnel'}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
empty: {
|
|
minHeight: 96,
|
|
justifyContent: 'center',
|
|
paddingVertical: spacing.md,
|
|
borderTopWidth: StyleSheet.hairlineWidth,
|
|
borderTopColor: colors.border,
|
|
},
|
|
emptyTitle: {color: colors.text, fontSize: 13, fontWeight: '700'},
|
|
emptyText: {color: colors.muted, fontSize: 11, lineHeight: 16, marginTop: 3},
|
|
labels: {
|
|
marginTop: -2,
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
},
|
|
date: {color: colors.muted, fontSize: 10},
|
|
lastValue: {fontSize: 11, fontWeight: '800', fontVariant: ['tabular-nums']},
|
|
caption: {color: colors.muted, fontSize: 9, marginTop: spacing.xs},
|
|
});
|