feat(chart): TrainingChart dinâmico com viewport, zoom, pan e pin sobrevivente

Substitui o gráfico estático (últimos 120 samples + selectedIndex por
posição) por um modelo de viewport temporal que resolve os problemas de
qualidade em tempo real e abre zoom/pan nos 3 call sites (Captura,
Janela salva e Faixa customizada).

Adições:
- src/training/chartViewport.ts: funções puras (granularityForSpan,
  formatTickLabel, buildChartViewport, applyZoomIn/Out, applyPan,
  isLiveTailSnap, pickSampleAtTimestamp, xFromTimestampMs,
  timestampMsFromX, tipos Viewport/ChartPin/Granularity).
- __tests__/chartViewport.test.ts: 34 testes cobrindo limites,
  ordenação defensiva, snap zone, hit-test e granularidade adaptativa.
- src/components/TrainingChart.tsx: reescrito para consumir viewport.
  - Pin por timestampMs (sobrevive a novas amostras em tempo real).
  - Pinch (Gesture.Pinch) + Pan (Gesture.Pan) + Tap (Gesture.Tap) via
    react-native-gesture-handler v3.
  - Auto-follow: endMs estende com novos samples enquanto liveTail.
  - Snap zone (5% da duração visível) reanexa o live ao final do pan.
  - Eixo X com ticks adaptativos: minute/halfMinute/quarterMinute/
    tenSeconds/second/halfSecond, conforme duração visível.
  - Botões +/- (zoom 2x) e ↺ (reset) na lateral.
  - Indicador 'dados mais novos disponíveis' quando !liveTail.
  - Estado vazio preservado (samples < 2).
- jest.setup.js + jest.config.js: mocks de gesture-handler e
  reanimated para o App.test rodar em Jest (módulos nativos não
  disponíveis no host).

Modificações de setup (dependências aprovadas):
- package.json + package-lock.json: adiciona react-native-gesture-handler
  ^3.0.1, react-native-reanimated ^4.4.1, react-native-worklets ^0.9.2.
- index.js: import 'react-native-gesture-handler' no topo.
- App.tsx: <GestureHandlerRootView> envolvendo SafeAreaProvider.
- babel.config.js: plugin react-native-worklets/plugin.

Verificações:
- npm run typecheck: 0 errors.
- npm run lint: 0 errors, 0 warnings.
- npm test -- --runInBand: 55/55 passam.
- Build Android: não verificado nesta sessão (timeout); o usuário
  valida localmente antes de distribuir.

Não comitado neste commit (mudanças pré-existentes não relacionadas):
- android/* (migração fightratern → healthtracking/fightrate, mipmaps)
- README.md
- app.json
This commit is contained in:
FightRate Agent committed 2026-06-15 00:10:55 -03:00
1 parent 86908495d2
commit 41b49ba442
15 files changed
+2492 -340

No files matched your search

+811
View File
@@ -0,0 +1,811 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
type LayoutChangeEvent,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { runOnJS } from 'react-native-reanimated';
import Svg, { Circle, Line, Path, Text as SvgText } from 'react-native-svg';
import { Minus, Plus, RotateCcw } from 'lucide-react-native';
import { type HeartRateSample } from '../training/trainingTypes';
import {
applyPan,
applyZoomIn,
applyZoomOut,
buildChartViewport,
formatTickLabel,
granularityForSpan,
isLiveTailSnap,
pickSampleAtTimestamp,
timestampMsFromX,
xFromTimestampMs,
type Granularity,
type Viewport,
} from '../training/chartViewport';
import { colors, radius, spacing, typography } from '../theme';
type Props = {
readonly samples: readonly HeartRateSample[];
readonly defaultSpanMs?: number;
readonly liveTail?: boolean;
};
type ChartPoint = {
readonly x: number;
readonly y: number;
readonly sample: HeartRateSample;
};
const CHART_WIDTH = 320;
const CHART_HEIGHT = 148;
const PAD_X = 14;
const PAD_Y = 18;
const DEFAULT_SPAN_MS = 5 * 60_000;
const MIN_SPAN_MS = 1_000;
const MAX_SPAN_MS = 24 * 60 * 60 * 1_000;
const TICK_COUNT_TARGET = 4;
export function TrainingChart({
samples,
defaultSpanMs = DEFAULT_SPAN_MS,
liveTail = true,
}: Props) {
const [layoutWidth, setLayoutWidth] = useState(CHART_WIDTH);
const [viewport, setViewport] = useState<Viewport | null>(() =>
buildChartViewport(samples, { defaultSpanMs, liveTail }),
);
const [pinTimestampMs, setPinTimestampMs] = useState<number | null>(null);
// Recompute viewport when samples shrink or when initial dataset changes
// shape. We do not recompute on every new sample in live mode: the auto-follow
// effect below handles that without resetting the user's pan/zoom state.
useEffect(() => {
setViewport(current => {
if (current === null) {
return buildChartViewport(samples, { defaultSpanMs, liveTail });
}
return current;
});
}, [samples, defaultSpanMs, liveTail]);
const lastSampleTimestamp = useMemo(() => {
if (samples.length === 0) return null;
return samples[samples.length - 1].timestamp;
}, [samples]);
// Auto-follow: when liveTail is on and the dataset extends past the current
// viewport end, slide endMs forward. Pin is untouched.
useEffect(() => {
if (!liveTail) return;
if (lastSampleTimestamp === null) return;
setViewport(current => {
if (current === null) return current;
if (!current.liveTail) return current;
if (lastSampleTimestamp <= current.endMs) return current;
const span = current.endMs - current.startMs;
return {
startMs: lastSampleTimestamp - span,
endMs: lastSampleTimestamp,
liveTail: true,
};
});
}, [lastSampleTimestamp, liveTail]);
// Re-evaluate the liveTail flag whenever the user finishes a pan: if the
// viewport's end is back inside the snap zone, re-attach to live.
const tryResumeLiveTail = useCallback(
(candidate: Viewport) => {
if (lastSampleTimestamp === null) {
setViewport(candidate);
return;
}
const resume = isLiveTailSnap(candidate, lastSampleTimestamp);
setViewport({ ...candidate, liveTail: resume });
},
[lastSampleTimestamp],
);
const onLayout = useCallback((event: LayoutChangeEvent) => {
setLayoutWidth(Math.max(1, event.nativeEvent.layout.width));
}, []);
// -------- Gestures --------
// Track the viewport snapshot captured at gesture start so incremental deltas
// compose against a stable baseline (avoids drift across multi-frame moves).
const viewportAtGestureStart = useRef<Viewport | null>(null);
const onPanBegin = useCallback(() => {
viewportAtGestureStart.current = viewport;
}, [viewport]);
const onPanUpdate = useCallback(
(dxPixels: number) => {
const baseline = viewportAtGestureStart.current;
if (baseline === null) return;
const usableWidth = Math.max(1, layoutWidth - PAD_X * 2);
const pixelsPerMs =
usableWidth / Math.max(1, baseline.endMs - baseline.startMs);
setViewport(applyPan(baseline, dxPixels, pixelsPerMs));
},
[layoutWidth],
);
const onPanEnd = useCallback(() => {
setViewport(current => {
if (current === null) return current;
tryResumeLiveTail(current);
return current;
});
}, [tryResumeLiveTail]);
const onPinchBegin = useCallback(() => {
viewportAtGestureStart.current = viewport;
}, [viewport]);
const onPinchUpdate = useCallback(
(scale: number, focusX: number) => {
const baseline = viewportAtGestureStart.current;
if (baseline === null) return;
const usableWidth = Math.max(1, layoutWidth - PAD_X * 2);
const focusTimestamp = timestampMsFromX(
focusX,
baseline,
CHART_WIDTH,
PAD_X,
);
// Map focusX from layout space to viewBox space.
const focusXInViewBox = (focusX / layoutWidth) * CHART_WIDTH;
const focusTimestampFromLayout = timestampMsFromX(
focusXInViewBox,
baseline,
CHART_WIDTH,
PAD_X,
);
const target = (baseline.endMs - baseline.startMs) / Math.max(0.1, scale);
const next = applyZoom(baseline, focusTimestamp, target, {
minSpanMs: MIN_SPAN_MS,
maxSpanMs: MAX_SPAN_MS,
});
// Keep the focus point visually anchored by re-aiming the result so the
// timestamp under the pinch focus lands at the same x as before.
const afterX = xFromTimestampMs(
focusTimestampFromLayout,
next,
CHART_WIDTH,
PAD_X,
);
const pixelDelta =
(afterX - focusXInViewBox) * (usableWidth / (CHART_WIDTH - PAD_X * 2));
setViewport(
applyPan(
next,
-pixelDelta,
usableWidth / Math.max(1, next.endMs - next.startMs),
),
);
},
[layoutWidth],
);
const onPinchEnd = useCallback(() => {
viewportAtGestureStart.current = null;
onPanEnd();
}, [onPanEnd]);
const onZoomInPress = useCallback(() => {
setViewport(current => {
if (current === null) return current;
const focusMs = (current.startMs + current.endMs) / 2;
return applyZoomIn(current, focusMs, {
minSpanMs: MIN_SPAN_MS,
maxSpanMs: MAX_SPAN_MS,
});
});
}, []);
const onZoomOutPress = useCallback(() => {
setViewport(current => {
if (current === null) return current;
const focusMs = (current.startMs + current.endMs) / 2;
return applyZoomOut(current, focusMs, {
minSpanMs: MIN_SPAN_MS,
maxSpanMs: MAX_SPAN_MS,
});
});
}, []);
const onResetPress = useCallback(() => {
const fresh = buildChartViewport(samples, { defaultSpanMs, liveTail });
if (fresh !== null) {
setViewport(fresh);
setPinTimestampMs(null);
}
}, [samples, defaultSpanMs, liveTail]);
const onTapAtX = useCallback(
(xInViewBox: number) => {
if (viewport === null) return;
const tapTimestamp = timestampMsFromX(
xInViewBox,
viewport,
CHART_WIDTH,
PAD_X,
);
const tolerance = (viewport.endMs - viewport.startMs) * 0.1;
const sample = pickSampleAtTimestamp(samples, tapTimestamp, tolerance);
if (sample === null) {
setPinTimestampMs(null);
return;
}
setPinTimestampMs(previous => {
if (previous === sample.timestamp) return null;
return sample.timestamp;
});
},
[samples, viewport],
);
// Pan uses absolute deltas via the JS bridge: each frame, runOnJS forwards
// the cumulative translationX to applyPan over the captured baseline.
const panGesture = useMemo(
() =>
Gesture.Pan()
.onBegin(() => {
runOnJS(onPanBegin)();
})
.onUpdate(event => {
runOnJS(onPanUpdate)(event.translationX);
})
.onEnd(() => {
runOnJS(onPanEnd)();
})
.onFinalize(() => {
runOnJS(onPanEnd)();
})
.minDistance(4),
[onPanBegin, onPanUpdate, onPanEnd],
);
const pinchGesture = useMemo(
() =>
Gesture.Pinch()
.onBegin(() => {
runOnJS(onPinchBegin)();
})
.onUpdate(event => {
runOnJS(onPinchUpdate)(event.scale, event.focalX);
})
.onEnd(() => {
runOnJS(onPinchEnd)();
})
.onFinalize(() => {
runOnJS(onPinchEnd)();
}),
[onPinchBegin, onPinchUpdate, onPinchEnd],
);
const tapGesture = useMemo(
() =>
Gesture.Tap()
.maxDuration(220)
.onEnd(event => {
// Convert focalX (layout space) to viewBox space before hit-testing.
const xInViewBox = (event.x / Math.max(1, layoutWidth)) * CHART_WIDTH;
runOnJS(onTapAtX)(xInViewBox);
}),
[layoutWidth, onTapAtX],
);
const composed = useMemo(
() =>
Gesture.Race(tapGesture, Gesture.Simultaneous(panGesture, pinchGesture)),
[tapGesture, panGesture, pinchGesture],
);
// -------- Render data --------
const renderable = useMemo(() => {
if (viewport === null) return null;
if (samples.length < 2) return null;
const visible = samples.filter(
sample =>
sample.timestamp >= viewport.startMs &&
sample.timestamp <= viewport.endMs,
);
if (visible.length < 1) return null;
const bpms = visible.map(sample => sample.bpm);
const minBpm = Math.min(...bpms);
const maxBpm = Math.max(...bpms);
const bpmRange = Math.max(1, maxBpm - minBpm);
const span = Math.max(1, viewport.endMs - viewport.startMs);
const points: ChartPoint[] = visible.map(sample => {
const x = xFromTimestampMs(
sample.timestamp,
viewport,
CHART_WIDTH,
PAD_X,
);
const y =
CHART_HEIGHT -
PAD_Y -
((sample.bpm - minBpm) / bpmRange) * (CHART_HEIGHT - PAD_Y * 2);
return { x, y, sample };
});
const path = points
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
.join(' ');
return {
visible,
minBpm,
maxBpm,
lastPoint: points[points.length - 1],
points,
path,
span,
};
}, [samples, viewport]);
const granularity: Granularity = useMemo(
() =>
granularityForSpan(
viewport ? viewport.endMs - viewport.startMs : DEFAULT_SPAN_MS,
),
[viewport],
);
const ticks = useMemo(() => {
if (viewport === null) return [];
return buildTicks(viewport, granularity, TICK_COUNT_TARGET);
}, [viewport, granularity]);
const pinPoint = useMemo(() => {
if (renderable === null || pinTimestampMs === null) return null;
const sample = samples.find(s => s.timestamp === pinTimestampMs);
if (sample === undefined) return null;
if (
sample.timestamp < viewport!.startMs ||
sample.timestamp > viewport!.endMs
) {
return { sample, offViewport: true };
}
return { sample, offViewport: false };
}, [renderable, pinTimestampMs, samples, viewport]);
// -------- JSX --------
if (viewport === null) {
return (
<View style={styles.emptyWrap}>
<Text style={styles.emptyTitle}>Aguardando amostras reais</Text>
<Text style={styles.emptyCopy}>
O gráfico aparece quando o sensor enviar BPM pelo characteristic 2A37.
</Text>
</View>
);
}
if (renderable === null) {
return (
<View style={styles.emptyWrap}>
<Text style={styles.emptyTitle}>Sem amostras na janela</Text>
<Text style={styles.emptyCopy}>
Toque em [↺] para voltar ao fit ou [+] [−] para ajustar a janela.
</Text>
</View>
);
}
return (
<View
style={styles.wrap}
accessibilityLabel="Gráfico dinâmico de BPM. Pinch para zoom, arraste para navegar, toque em um ponto para fixar."
>
<View style={styles.chartRow}>
<GestureDetector gesture={composed}>
<View style={styles.chartSurface} onLayout={onLayout}>
<Svg
height={CHART_HEIGHT}
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
width="100%"
>
<Line
stroke="rgba(255,255,255,0.08)"
strokeWidth="1"
x1={PAD_X}
x2={CHART_WIDTH - PAD_X}
y1={PAD_Y}
y2={PAD_Y}
/>
<Line
stroke="rgba(255,255,255,0.06)"
strokeWidth="1"
x1={PAD_X}
x2={CHART_WIDTH - PAD_X}
y1={CHART_HEIGHT / 2}
y2={CHART_HEIGHT / 2}
/>
<Line
stroke="rgba(255,255,255,0.08)"
strokeWidth="1"
x1={PAD_X}
x2={CHART_WIDTH - PAD_X}
y1={CHART_HEIGHT - PAD_Y}
y2={CHART_HEIGHT - PAD_Y}
/>
<SvgText
fill={colors.textMuted}
fontSize="10"
fontWeight="800"
x={PAD_X}
y={14}
>
{renderable.maxBpm}
</SvgText>
<SvgText
fill={colors.textMuted}
fontSize="10"
fontWeight="800"
x={PAD_X}
y={CHART_HEIGHT - 4}
>
{renderable.minBpm}
</SvgText>
{ticks.map((tick, index) => (
<React.Fragment key={`${tick.timestampMs}-${index}`}>
<Line
stroke="rgba(255,255,255,0.06)"
strokeWidth="1"
x1={tick.x}
x2={tick.x}
y1={PAD_Y}
y2={CHART_HEIGHT - PAD_Y}
/>
<SvgText
fill={colors.textDim}
fontSize="9"
fontWeight="700"
textAnchor="middle"
x={tick.x}
y={CHART_HEIGHT - 3}
>
{tick.label}
</SvgText>
</React.Fragment>
))}
<Path
d={renderable.path}
fill="none"
stroke={colors.action}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="4"
/>
<Circle
cx={renderable.lastPoint.x}
cy={renderable.lastPoint.y}
fill={colors.stopSurface}
r="5"
/>
<Circle
cx={renderable.lastPoint.x}
cy={renderable.lastPoint.y}
fill={colors.action}
r="3"
/>
{pinPoint && !pinPoint.offViewport ? (
<>
<Line
stroke="rgba(255, 218, 121, 0.62)"
strokeDasharray="4 5"
strokeWidth="1.5"
x1={
pinPoint.sample.timestamp >= viewport.startMs &&
pinPoint.sample.timestamp <= viewport.endMs
? xFromTimestampMs(
pinPoint.sample.timestamp,
viewport,
CHART_WIDTH,
PAD_X,
)
: PAD_X
}
x2={
pinPoint.sample.timestamp >= viewport.startMs &&
pinPoint.sample.timestamp <= viewport.endMs
? xFromTimestampMs(
pinPoint.sample.timestamp,
viewport,
CHART_WIDTH,
PAD_X,
)
: PAD_X
}
y1={PAD_Y}
y2={CHART_HEIGHT - PAD_Y}
/>
<PinMarker
timestampMs={pinPoint.sample.timestamp}
bpm={pinPoint.sample.bpm}
minBpm={renderable.minBpm}
maxBpm={renderable.maxBpm}
viewport={viewport}
/>
</>
) : null}
</Svg>
</View>
</GestureDetector>
<View style={styles.controls}>
<ControlButton
accessibilityLabel="Diminuir zoom"
icon={<Plus color={colors.text} size={16} strokeWidth={2.6} />}
onPress={onZoomInPress}
/>
<ControlButton
accessibilityLabel="Aumentar zoom"
icon={<Minus color={colors.text} size={16} strokeWidth={2.6} />}
onPress={onZoomOutPress}
/>
<ControlButton
accessibilityLabel="Voltar ao fit"
icon={<RotateCcw color={colors.text} size={16} strokeWidth={2.4} />}
onPress={onResetPress}
/>
</View>
</View>
<View style={styles.readout}>
<Text style={styles.readoutLabel}>
{pinPoint
? 'AMOSTRA PINADA'
: viewport.liveTail
? 'AO VIVO'
: 'JANELA'}
</Text>
<Text style={styles.readoutValue}>
{pinPoint
? `${formatSampleTime(pinPoint.sample.timestamp)} · ${
pinPoint.sample.bpm
} BPM`
: `${formatSampleTime(renderable.lastPoint.sample.timestamp)} · ${
renderable.lastPoint.sample.bpm
} BPM`}
</Text>
</View>
{!viewport.liveTail &&
liveTail &&
renderable.lastPoint.sample.timestamp < (lastSampleTimestamp ?? 0) ? (
<Text style={styles.tailNotice}>
Dados mais novos disponíveis. Arraste para a direita ou toque em ↺.
</Text>
) : null}
{pinPoint && pinPoint.offViewport ? (
<Text style={styles.pinOffNotice}>
PIN FORA DA JANELA. Toque em ↺ para retornar.
</Text>
) : null}
</View>
);
}
// -------- Helpers --------
function PinMarker({
timestampMs,
bpm,
minBpm,
maxBpm,
viewport,
}: {
readonly timestampMs: number;
readonly bpm: number;
readonly minBpm: number;
readonly maxBpm: number;
readonly viewport: Viewport;
}) {
const x = xFromTimestampMs(timestampMs, viewport, CHART_WIDTH, PAD_X);
const bpmRange = Math.max(1, maxBpm - minBpm);
const y =
CHART_HEIGHT -
PAD_Y -
((bpm - minBpm) / bpmRange) * (CHART_HEIGHT - PAD_Y * 2);
return (
<>
<Circle
cx={x}
cy={y}
fill={colors.canvas}
r="7"
stroke={colors.warning}
strokeWidth="2"
/>
<Circle cx={x} cy={y} fill={colors.warning} r="3" />
</>
);
}
function ControlButton({
accessibilityLabel,
icon,
onPress,
}: {
readonly accessibilityLabel: string;
readonly icon: React.ReactNode;
readonly onPress: () => void;
}) {
return (
<Pressable
accessibilityLabel={accessibilityLabel}
accessibilityRole="button"
onPress={onPress}
style={({ pressed }) => [
styles.controlButton,
pressed ? styles.controlButtonPressed : null,
]}
>
{icon}
</Pressable>
);
}
type Tick = {
readonly timestampMs: number;
readonly x: number;
readonly label: string;
};
function buildTicks(
viewport: Viewport,
granularity: Granularity,
target: number,
): readonly Tick[] {
const ticks: Tick[] = [];
const step = granularity.stepMs;
// Snap start to the next multiple of step so labels look aligned.
const firstTick = Math.ceil(viewport.startMs / step) * step;
for (let t = firstTick; t <= viewport.endMs; t += step) {
ticks.push({
timestampMs: t,
x: xFromTimestampMs(t, viewport, CHART_WIDTH, PAD_X),
label: formatTickLabel(t, granularity),
});
if (ticks.length >= target + 2) break;
}
return ticks;
}
function applyZoom(
viewport: Viewport,
focusMs: number,
targetSpan: number,
options: { minSpanMs: number; maxSpanMs: number },
): Viewport {
const span = Math.max(
options.minSpanMs,
Math.min(options.maxSpanMs, targetSpan),
);
const currentSpan = Math.max(1, viewport.endMs - viewport.startMs);
const focusRatio = (focusMs - viewport.startMs) / currentSpan;
const newStart = focusMs - focusRatio * span;
return {
startMs: newStart,
endMs: newStart + span,
liveTail: false,
};
}
function formatSampleTime(timestamp: number) {
return new Date(timestamp).toLocaleTimeString('pt-BR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
const styles = StyleSheet.create({
wrap: {
borderWidth: 1,
borderColor: colors.line,
borderRadius: radius.md,
backgroundColor: colors.surfaceHot,
overflow: 'hidden',
},
chartRow: {
flexDirection: 'row',
alignItems: 'stretch',
},
chartSurface: {
flex: 1,
minHeight: CHART_HEIGHT,
},
controls: {
borderLeftWidth: 1,
borderLeftColor: colors.line,
paddingVertical: spacing.xs,
paddingHorizontal: spacing.xxs,
gap: spacing.xs,
justifyContent: 'flex-start',
alignItems: 'center',
},
controlButton: {
width: 32,
height: 32,
borderRadius: radius.xs,
borderWidth: 1,
borderColor: colors.line,
backgroundColor: colors.surfaceRaised,
alignItems: 'center',
justifyContent: 'center',
},
controlButtonPressed: {
opacity: 0.6,
},
readout: {
borderTopWidth: 1,
borderTopColor: colors.line,
minHeight: 42,
paddingHorizontal: spacing.sm,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm,
},
readoutLabel: {
...typography.label,
color: colors.warning,
},
readoutValue: {
...typography.label,
color: colors.text,
flexShrink: 1,
textAlign: 'right',
},
tailNotice: {
...typography.label,
color: colors.textDim,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderTopWidth: 1,
borderTopColor: colors.line,
},
pinOffNotice: {
...typography.label,
color: colors.warning,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderTopWidth: 1,
borderTopColor: colors.line,
},
emptyWrap: {
minHeight: CHART_HEIGHT,
borderWidth: 1,
borderColor: colors.line,
borderRadius: radius.md,
backgroundColor: colors.surfaceHot,
padding: spacing.md,
justifyContent: 'center',
},
emptyTitle: {
...typography.label,
color: colors.warning,
},
emptyCopy: {
...typography.body,
color: colors.textMuted,
marginTop: spacing.xs,
},
});