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:
1 parent
86908495d2
commit
41b49ba442
15 files changed
+2492
-340
No files matched your search
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import { type HeartRateSample } from './trainingTypes';
|
||||
|
||||
export type GranularityKind =
|
||||
| 'minute'
|
||||
| 'halfMinute'
|
||||
| 'quarterMinute'
|
||||
| 'tenSeconds'
|
||||
| 'second'
|
||||
| 'halfSecond';
|
||||
|
||||
export type Granularity = {
|
||||
readonly kind: GranularityKind;
|
||||
readonly stepMs: number;
|
||||
};
|
||||
|
||||
const HALF_SECOND_MS = 500;
|
||||
const SECOND_MS = 1_000;
|
||||
const TEN_SECONDS_MS = 10_000;
|
||||
const QUARTER_MINUTE_MS = 15_000;
|
||||
const HALF_MINUTE_MS = 30_000;
|
||||
const MINUTE_MS = 60_000;
|
||||
const FIVE_MINUTES_MS = 5 * MINUTE_MS;
|
||||
|
||||
export function granularityForSpan(spanMs: number): Granularity {
|
||||
if (spanMs < 2 * SECOND_MS) {
|
||||
return { kind: 'halfSecond', stepMs: HALF_SECOND_MS };
|
||||
}
|
||||
if (spanMs < TEN_SECONDS_MS) {
|
||||
return { kind: 'second', stepMs: SECOND_MS };
|
||||
}
|
||||
if (spanMs < HALF_MINUTE_MS) {
|
||||
return { kind: 'tenSeconds', stepMs: TEN_SECONDS_MS };
|
||||
}
|
||||
if (spanMs < MINUTE_MS) {
|
||||
return { kind: 'quarterMinute', stepMs: QUARTER_MINUTE_MS };
|
||||
}
|
||||
if (spanMs < FIVE_MINUTES_MS) {
|
||||
return { kind: 'halfMinute', stepMs: HALF_MINUTE_MS };
|
||||
}
|
||||
return { kind: 'minute', stepMs: MINUTE_MS };
|
||||
}
|
||||
|
||||
function pad2(value: number): string {
|
||||
return value < 10 ? `0${value}` : `${value}`;
|
||||
}
|
||||
|
||||
export function formatTickLabel(
|
||||
timestampMs: number,
|
||||
granularity: Granularity,
|
||||
): string {
|
||||
const date = new Date(timestampMs);
|
||||
const hours = pad2(date.getHours());
|
||||
const minutes = pad2(date.getMinutes());
|
||||
const seconds = pad2(date.getSeconds());
|
||||
|
||||
if (granularity.kind === 'minute') {
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
if (granularity.kind === 'halfSecond') {
|
||||
const tenths = Math.floor(date.getMilliseconds() / 100);
|
||||
return `${hours}:${minutes}:${seconds}.${tenths}`;
|
||||
}
|
||||
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
export type Viewport = {
|
||||
readonly startMs: number;
|
||||
readonly endMs: number;
|
||||
readonly liveTail: boolean;
|
||||
};
|
||||
|
||||
export type BuildViewportOptions = {
|
||||
readonly defaultSpanMs: number;
|
||||
readonly liveTail?: boolean;
|
||||
};
|
||||
|
||||
export function buildChartViewport(
|
||||
samples: readonly HeartRateSample[],
|
||||
options: BuildViewportOptions,
|
||||
): Viewport | null {
|
||||
if (samples.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sortedSamples = samples
|
||||
.slice()
|
||||
.sort((left, right) => left.timestamp - right.timestamp);
|
||||
const firstTimestamp = sortedSamples[0].timestamp;
|
||||
const lastTimestamp = sortedSamples[sortedSamples.length - 1].timestamp;
|
||||
const totalSpan = lastTimestamp - firstTimestamp;
|
||||
const liveTail = options.liveTail ?? true;
|
||||
|
||||
if (totalSpan <= 0) {
|
||||
return {
|
||||
startMs: firstTimestamp,
|
||||
endMs: firstTimestamp + options.defaultSpanMs,
|
||||
liveTail,
|
||||
};
|
||||
}
|
||||
|
||||
if (totalSpan <= options.defaultSpanMs) {
|
||||
return {
|
||||
startMs: firstTimestamp,
|
||||
endMs: lastTimestamp,
|
||||
liveTail,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
startMs: lastTimestamp - options.defaultSpanMs,
|
||||
endMs: lastTimestamp,
|
||||
liveTail,
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_MIN_SPAN_MS = 1_000;
|
||||
const DEFAULT_MAX_SPAN_MS = 24 * 60 * 60 * 1_000;
|
||||
const DEFAULT_SNAP_FRACTION = 0.05;
|
||||
|
||||
export type ZoomOptions = {
|
||||
readonly minSpanMs?: number;
|
||||
readonly maxSpanMs?: number;
|
||||
};
|
||||
|
||||
export function applyZoomIn(
|
||||
viewport: Viewport,
|
||||
focusMs: number,
|
||||
options: ZoomOptions = {},
|
||||
): Viewport {
|
||||
const minSpan = options.minSpanMs ?? DEFAULT_MIN_SPAN_MS;
|
||||
const maxSpan = options.maxSpanMs ?? DEFAULT_MAX_SPAN_MS;
|
||||
const currentSpan = viewport.endMs - viewport.startMs;
|
||||
const targetSpan = clampSpan(currentSpan / 2, minSpan, maxSpan);
|
||||
return zoomAroundFocus(viewport, focusMs, targetSpan);
|
||||
}
|
||||
|
||||
export function applyZoomOut(
|
||||
viewport: Viewport,
|
||||
focusMs: number,
|
||||
options: ZoomOptions = {},
|
||||
): Viewport {
|
||||
const minSpan = options.minSpanMs ?? DEFAULT_MIN_SPAN_MS;
|
||||
const maxSpan = options.maxSpanMs ?? DEFAULT_MAX_SPAN_MS;
|
||||
const currentSpan = viewport.endMs - viewport.startMs;
|
||||
const targetSpan = clampSpan(currentSpan * 2, minSpan, maxSpan);
|
||||
return zoomAroundFocus(viewport, focusMs, targetSpan);
|
||||
}
|
||||
|
||||
function clampSpan(span: number, min: number, max: number): number {
|
||||
if (span < min) return min;
|
||||
if (span > max) return max;
|
||||
return span;
|
||||
}
|
||||
|
||||
function zoomAroundFocus(
|
||||
viewport: Viewport,
|
||||
focusMs: number,
|
||||
newSpan: number,
|
||||
): Viewport {
|
||||
const currentSpan = Math.max(1, viewport.endMs - viewport.startMs);
|
||||
const focusRatio = (focusMs - viewport.startMs) / currentSpan;
|
||||
const newStart = focusMs - focusRatio * newSpan;
|
||||
return {
|
||||
startMs: newStart,
|
||||
endMs: newStart + newSpan,
|
||||
liveTail: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyPan(
|
||||
viewport: Viewport,
|
||||
dxPixels: number,
|
||||
pixelsPerMs: number,
|
||||
): Viewport {
|
||||
if (pixelsPerMs === 0) {
|
||||
return {
|
||||
startMs: viewport.startMs,
|
||||
endMs: viewport.endMs,
|
||||
liveTail: false,
|
||||
};
|
||||
}
|
||||
const dxMs = dxPixels / pixelsPerMs;
|
||||
return {
|
||||
startMs: viewport.startMs + dxMs,
|
||||
endMs: viewport.endMs + dxMs,
|
||||
liveTail: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function isLiveTailSnap(
|
||||
viewport: Viewport,
|
||||
lastSampleTimestampMs: number,
|
||||
snapFraction: number = DEFAULT_SNAP_FRACTION,
|
||||
): boolean {
|
||||
const visibleSpan = viewport.endMs - viewport.startMs;
|
||||
if (visibleSpan <= 0) return false;
|
||||
const distance = lastSampleTimestampMs - viewport.endMs;
|
||||
if (distance < 0) return false;
|
||||
return distance <= visibleSpan * snapFraction;
|
||||
}
|
||||
|
||||
export function pickSampleAtTimestamp(
|
||||
samples: readonly HeartRateSample[],
|
||||
timestampMs: number,
|
||||
toleranceMs: number,
|
||||
): HeartRateSample | null {
|
||||
if (samples.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let closest: HeartRateSample | null = null;
|
||||
let closestDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const candidate of samples) {
|
||||
const distance = Math.abs(candidate.timestamp - timestampMs);
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closest = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return closestDistance <= toleranceMs ? closest : null;
|
||||
}
|
||||
|
||||
export function xFromTimestampMs(
|
||||
timestampMs: number,
|
||||
viewport: Viewport,
|
||||
chartWidth: number,
|
||||
padX: number,
|
||||
): number {
|
||||
const span = Math.max(1, viewport.endMs - viewport.startMs);
|
||||
const ratio = (timestampMs - viewport.startMs) / span;
|
||||
return padX + ratio * (chartWidth - 2 * padX);
|
||||
}
|
||||
|
||||
export function timestampMsFromX(
|
||||
x: number,
|
||||
viewport: Viewport,
|
||||
chartWidth: number,
|
||||
padX: number,
|
||||
): number {
|
||||
const usableWidth = chartWidth - 2 * padX;
|
||||
if (usableWidth <= 0) {
|
||||
return viewport.startMs;
|
||||
}
|
||||
const ratio = (x - padX) / usableWidth;
|
||||
const ts = viewport.startMs + ratio * (viewport.endMs - viewport.startMs);
|
||||
if (ts < viewport.startMs) return viewport.startMs;
|
||||
if (ts > viewport.endMs) return viewport.endMs;
|
||||
return ts;
|
||||
}
|
||||
Reference in new issue
Block a user