feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
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},
|
||||
});
|
||||
Reference in New Issue
Block a user