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
+2491
-339
No files matched your search
@@ -1,45 +1,21 @@
|
|||||||
/**
|
import React from 'react';
|
||||||
* Sample React Native App
|
import { StyleSheet } from 'react-native';
|
||||||
* https://github.com/facebook/react-native
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
*
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
* @format
|
import { FightRateApp } from './src/app/FightRateApp';
|
||||||
*/
|
|
||||||
|
|
||||||
import { NewAppScreen } from '@react-native/new-app-screen';
|
|
||||||
import { StatusBar, StyleSheet, useColorScheme, View } from 'react-native';
|
|
||||||
import {
|
|
||||||
SafeAreaProvider,
|
|
||||||
useSafeAreaInsets,
|
|
||||||
} from 'react-native-safe-area-context';
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const isDarkMode = useColorScheme() === 'dark';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaProvider>
|
<GestureHandlerRootView style={styles.root}>
|
||||||
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
|
<SafeAreaProvider>
|
||||||
<AppContent />
|
<FightRateApp />
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
);
|
</GestureHandlerRootView>
|
||||||
}
|
|
||||||
|
|
||||||
function AppContent() {
|
|
||||||
const safeAreaInsets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<NewAppScreen
|
|
||||||
templateFileName="App.tsx"
|
|
||||||
safeAreaInsets={safeAreaInsets}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
root: { flex: 1 },
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
+54
-1
@@ -3,8 +3,61 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { NativeModules } from 'react-native';
|
||||||
import ReactTestRenderer from 'react-test-renderer';
|
import ReactTestRenderer from 'react-test-renderer';
|
||||||
import App from '../App';
|
|
||||||
|
const emptySnapshot = {
|
||||||
|
phase: 'idle',
|
||||||
|
isCapturing: false,
|
||||||
|
isMonitoring: false,
|
||||||
|
startedAt: 0,
|
||||||
|
statusMessage: 'Captura parada.',
|
||||||
|
connectedDevice: null,
|
||||||
|
discoveredDevices: [],
|
||||||
|
samples: [],
|
||||||
|
sessions: [],
|
||||||
|
services: [],
|
||||||
|
characteristics: [],
|
||||||
|
batteryOptimizationIgnored: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
NativeModules.FightRateCapture = {
|
||||||
|
addListener: jest.fn(),
|
||||||
|
removeListeners: jest.fn(),
|
||||||
|
getSnapshot: jest.fn(async () => emptySnapshot),
|
||||||
|
startMonitoring: jest.fn(async () => emptySnapshot),
|
||||||
|
startCapture: jest.fn(async () => emptySnapshot),
|
||||||
|
startWorkout: jest.fn(async () => ({
|
||||||
|
...emptySnapshot,
|
||||||
|
isMonitoring: true,
|
||||||
|
isCapturing: true,
|
||||||
|
})),
|
||||||
|
connectToDevice: jest.fn(async () => emptySnapshot),
|
||||||
|
stopCapture: jest.fn(async () => emptySnapshot),
|
||||||
|
stopWorkout: jest.fn(async () => ({
|
||||||
|
...emptySnapshot,
|
||||||
|
isMonitoring: true,
|
||||||
|
})),
|
||||||
|
stopMonitoring: jest.fn(async () => emptySnapshot),
|
||||||
|
getSessionSamples: jest.fn(async () => []),
|
||||||
|
getContinuousSamples: jest.fn(async () => []),
|
||||||
|
isIgnoringBatteryOptimizations: jest.fn(async () => true),
|
||||||
|
openBatteryOptimizationSettings: jest.fn(async () => true),
|
||||||
|
};
|
||||||
|
|
||||||
|
// react-native-gesture-handler requires a native module; mock it for tests.
|
||||||
|
NativeModules.RNGestureHandlerModule = {
|
||||||
|
attachGestureHandler: jest.fn(),
|
||||||
|
createGestureHandler: jest.fn(),
|
||||||
|
dropGestureHandler: jest.fn(),
|
||||||
|
updateGestureHandler: jest.fn(),
|
||||||
|
flushOperations: jest.fn(),
|
||||||
|
install: jest.fn(),
|
||||||
|
State: { UNDETERMINED: 0, FAILED: 1, BEGAN: 2, CANCELLED: 3, ACTIVE: 4, END: 5 },
|
||||||
|
Directions: { RIGHT: 1, LEFT: 2, UP: 4, DOWN: 8 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const App = require('../App').default;
|
||||||
|
|
||||||
test('renders correctly', async () => {
|
test('renders correctly', async () => {
|
||||||
await ReactTestRenderer.act(() => {
|
await ReactTestRenderer.act(() => {
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import { type HeartRateSample } from '../src/training/trainingTypes';
|
||||||
|
import {
|
||||||
|
applyPan,
|
||||||
|
applyZoomIn,
|
||||||
|
applyZoomOut,
|
||||||
|
buildChartViewport,
|
||||||
|
formatTickLabel,
|
||||||
|
granularityForSpan,
|
||||||
|
isLiveTailSnap,
|
||||||
|
pickSampleAtTimestamp,
|
||||||
|
xFromTimestampMs,
|
||||||
|
timestampMsFromX,
|
||||||
|
type Viewport,
|
||||||
|
} from '../src/training/chartViewport';
|
||||||
|
|
||||||
|
const MIN_SPAN = 1_000;
|
||||||
|
const MAX_SPAN = 24 * 60 * 60 * 1_000;
|
||||||
|
|
||||||
|
function sample(timestamp: number, bpm: number): HeartRateSample {
|
||||||
|
return {
|
||||||
|
timestamp,
|
||||||
|
bpm,
|
||||||
|
contactDetected: null,
|
||||||
|
rrIntervalsMs: [],
|
||||||
|
deviceName: 'Magene H003',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewport(startMs: number, endMs: number, liveTail = true): Viewport {
|
||||||
|
return { startMs, endMs, liveTail };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('granularityForSpan', () => {
|
||||||
|
test('returns minute granularity for spans >= 5 minutes', () => {
|
||||||
|
const g = granularityForSpan(5 * 60 * 1_000);
|
||||||
|
expect(g.kind).toBe('minute');
|
||||||
|
expect(g.stepMs).toBe(60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns halfMinute granularity for spans >= 1 minute and < 5 minutes', () => {
|
||||||
|
expect(granularityForSpan(60 * 1_000).kind).toBe('halfMinute');
|
||||||
|
expect(granularityForSpan(4 * 60 * 1_000).kind).toBe('halfMinute');
|
||||||
|
expect(granularityForSpan(5 * 60 * 1_000).kind).toBe('minute');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns quarterMinute granularity for spans >= 30s and < 1 minute', () => {
|
||||||
|
expect(granularityForSpan(30 * 1_000).kind).toBe('quarterMinute');
|
||||||
|
expect(granularityForSpan(59 * 1_000).kind).toBe('quarterMinute');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns tenSeconds granularity for spans >= 10s and < 30s', () => {
|
||||||
|
expect(granularityForSpan(10 * 1_000).kind).toBe('tenSeconds');
|
||||||
|
expect(granularityForSpan(29 * 1_000).kind).toBe('tenSeconds');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns second granularity for spans >= 2s and < 10s', () => {
|
||||||
|
expect(granularityForSpan(2 * 1_000).kind).toBe('second');
|
||||||
|
expect(granularityForSpan(9 * 1_000).kind).toBe('second');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns halfSecond granularity for spans < 2s', () => {
|
||||||
|
expect(granularityForSpan(1_999).kind).toBe('halfSecond');
|
||||||
|
expect(granularityForSpan(0).kind).toBe('halfSecond');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatTickLabel', () => {
|
||||||
|
const T = new Date('2026-06-14T09:30:45.500Z').getTime();
|
||||||
|
|
||||||
|
test('minute granularity formats HH:mm in local time', () => {
|
||||||
|
const g = granularityForSpan(5 * 60 * 1_000);
|
||||||
|
const label = formatTickLabel(T, g);
|
||||||
|
expect(label).toMatch(/^\d{2}:\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('second granularity formats HH:mm:ss', () => {
|
||||||
|
const g = granularityForSpan(2 * 1_000);
|
||||||
|
const label = formatTickLabel(T, g);
|
||||||
|
expect(label).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('halfSecond granularity formats HH:mm:ss.S', () => {
|
||||||
|
const g = granularityForSpan(500);
|
||||||
|
const label = formatTickLabel(T, g);
|
||||||
|
expect(label).toMatch(/^\d{2}:\d{2}:\d{2}\.\d$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildChartViewport', () => {
|
||||||
|
test('returns null for fewer than 2 samples', () => {
|
||||||
|
expect(buildChartViewport([], { defaultSpanMs: 60_000 })).toBeNull();
|
||||||
|
expect(
|
||||||
|
buildChartViewport([sample(1_000, 90)], { defaultSpanMs: 60_000 }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initial viewport fits the dataset when total span is smaller than defaultSpan', () => {
|
||||||
|
const samples = [
|
||||||
|
sample(1_000, 90),
|
||||||
|
sample(11_000, 100),
|
||||||
|
sample(21_000, 110),
|
||||||
|
];
|
||||||
|
const vp = buildChartViewport(samples, { defaultSpanMs: 60_000 });
|
||||||
|
expect(vp).not.toBeNull();
|
||||||
|
expect(vp!.startMs).toBe(1_000);
|
||||||
|
expect(vp!.endMs).toBe(21_000);
|
||||||
|
expect(vp!.liveTail).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initial viewport keeps defaultSpan ending at the last sample when dataset is larger', () => {
|
||||||
|
const samples = [
|
||||||
|
sample(0, 90),
|
||||||
|
sample(10_000, 100),
|
||||||
|
sample(20_000, 110),
|
||||||
|
sample(30_000, 120),
|
||||||
|
sample(40_000, 130),
|
||||||
|
];
|
||||||
|
const vp = buildChartViewport(samples, { defaultSpanMs: 30_000 });
|
||||||
|
expect(vp).not.toBeNull();
|
||||||
|
expect(vp!.endMs).toBe(40_000);
|
||||||
|
expect(vp!.endMs - vp!.startMs).toBe(30_000);
|
||||||
|
expect(vp!.liveTail).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initial viewport is non-live when liveTail: false is passed', () => {
|
||||||
|
const samples = [sample(0, 90), sample(10_000, 100)];
|
||||||
|
const vp = buildChartViewport(samples, {
|
||||||
|
defaultSpanMs: 60_000,
|
||||||
|
liveTail: false,
|
||||||
|
});
|
||||||
|
expect(vp!.liveTail).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sorts samples defensively before computing viewport', () => {
|
||||||
|
const samples = [sample(30_000, 130), sample(0, 90), sample(15_000, 110)];
|
||||||
|
const vp = buildChartViewport(samples, { defaultSpanMs: 60_000 });
|
||||||
|
expect(vp!.startMs).toBe(0);
|
||||||
|
expect(vp!.endMs).toBe(30_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyZoomIn', () => {
|
||||||
|
test('halves the span around the focus point', () => {
|
||||||
|
const start = viewport(0, 60_000);
|
||||||
|
const z = applyZoomIn(start, 30_000, {
|
||||||
|
minSpanMs: MIN_SPAN,
|
||||||
|
maxSpanMs: MAX_SPAN,
|
||||||
|
});
|
||||||
|
expect(z.endMs - z.startMs).toBe(30_000);
|
||||||
|
expect(z.startMs + (z.endMs - z.startMs) / 2).toBeCloseTo(30_000, 0);
|
||||||
|
expect(z.liveTail).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never goes below minSpanMs', () => {
|
||||||
|
const start = viewport(0, 2_000);
|
||||||
|
const z = applyZoomIn(start, 1_000, {
|
||||||
|
minSpanMs: MIN_SPAN,
|
||||||
|
maxSpanMs: MAX_SPAN,
|
||||||
|
});
|
||||||
|
expect(z.endMs - z.startMs).toBe(MIN_SPAN);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves focus when minSpan is hit', () => {
|
||||||
|
const start = viewport(0, 2_000);
|
||||||
|
const z = applyZoomIn(start, 1_000, {
|
||||||
|
minSpanMs: MIN_SPAN,
|
||||||
|
maxSpanMs: MAX_SPAN,
|
||||||
|
});
|
||||||
|
expect(z.startMs).toBeLessThanOrEqual(1_000);
|
||||||
|
expect(z.endMs).toBeGreaterThanOrEqual(1_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forces liveTail=false (zoom is always a user interaction)', () => {
|
||||||
|
const start = viewport(0, 60_000, true);
|
||||||
|
expect(applyZoomIn(start, 30_000).liveTail).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyZoomOut', () => {
|
||||||
|
test('doubles the span around the focus point', () => {
|
||||||
|
const start = viewport(0, 30_000, false);
|
||||||
|
const z = applyZoomOut(start, 15_000, {
|
||||||
|
minSpanMs: MIN_SPAN,
|
||||||
|
maxSpanMs: MAX_SPAN,
|
||||||
|
});
|
||||||
|
expect(z.endMs - z.startMs).toBe(60_000);
|
||||||
|
expect(z.startMs + (z.endMs - z.startMs) / 2).toBeCloseTo(15_000, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never goes above maxSpanMs', () => {
|
||||||
|
const start = viewport(0, 12 * 60 * 60 * 1_000, false);
|
||||||
|
const z = applyZoomOut(start, 6 * 60 * 60 * 1_000, {
|
||||||
|
minSpanMs: MIN_SPAN,
|
||||||
|
maxSpanMs: MAX_SPAN,
|
||||||
|
});
|
||||||
|
expect(z.endMs - z.startMs).toBe(MAX_SPAN);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forces liveTail=false', () => {
|
||||||
|
expect(applyZoomOut(viewport(0, 30_000, true), 15_000).liveTail).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyPan', () => {
|
||||||
|
test('dx > 0 shifts the viewport forward in time and disables liveTail', () => {
|
||||||
|
const start = viewport(0, 60_000, true);
|
||||||
|
const p = applyPan(start, 100, 1); // 1 pixel per ms
|
||||||
|
expect(p.startMs).toBe(100);
|
||||||
|
expect(p.endMs).toBe(60_100);
|
||||||
|
expect(p.liveTail).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dx < 0 shifts the viewport backward in time', () => {
|
||||||
|
const start = viewport(0, 60_000, true);
|
||||||
|
const p = applyPan(start, -50, 1);
|
||||||
|
expect(p.startMs).toBe(-50);
|
||||||
|
expect(p.endMs).toBe(59_950);
|
||||||
|
expect(p.liveTail).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps liveTail false even if input was live (any pan detaches)', () => {
|
||||||
|
const start = viewport(0, 60_000, true);
|
||||||
|
expect(applyPan(start, 0.5, 1).liveTail).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isLiveTailSnap', () => {
|
||||||
|
test('returns true when endMs is within 5% of last sample timestamp', () => {
|
||||||
|
const vp = viewport(0, 100_000, false);
|
||||||
|
expect(isLiveTailSnap(vp, 104_000)).toBe(true);
|
||||||
|
expect(isLiveTailSnap(vp, 105_000, 0.05)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when endMs is more than 5% behind last sample timestamp', () => {
|
||||||
|
const vp = viewport(0, 100_000, false);
|
||||||
|
expect(isLiveTailSnap(vp, 110_000, 0.05)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses a custom snap fraction', () => {
|
||||||
|
const vp = viewport(0, 100_000, false);
|
||||||
|
expect(isLiveTailSnap(vp, 150_000, 0.5)).toBe(true);
|
||||||
|
expect(isLiveTailSnap(vp, 250_000, 0.5)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pickSampleAtTimestamp', () => {
|
||||||
|
const samples = [
|
||||||
|
sample(0, 80),
|
||||||
|
sample(1_000, 90),
|
||||||
|
sample(2_500, 100),
|
||||||
|
sample(4_000, 110),
|
||||||
|
sample(5_000, 120),
|
||||||
|
];
|
||||||
|
|
||||||
|
test('returns the closest sample within tolerance', () => {
|
||||||
|
expect(pickSampleAtTimestamp(samples, 2_400, 500)).toEqual(samples[2]);
|
||||||
|
expect(pickSampleAtTimestamp(samples, 600, 500)).toEqual(samples[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when no sample is within tolerance', () => {
|
||||||
|
expect(pickSampleAtTimestamp(samples, 3_500, 100)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tolerates unsorted samples by considering the global closest', () => {
|
||||||
|
const shuffled = [
|
||||||
|
samples[3],
|
||||||
|
samples[0],
|
||||||
|
samples[2],
|
||||||
|
samples[4],
|
||||||
|
samples[1],
|
||||||
|
];
|
||||||
|
expect(pickSampleAtTimestamp(shuffled, 2_400, 500)).toEqual(samples[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for empty input', () => {
|
||||||
|
expect(pickSampleAtTimestamp([], 0, 1_000)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('xFromTimestampMs and timestampMsFromX', () => {
|
||||||
|
const vp = viewport(0, 100_000, false);
|
||||||
|
const chartWidth = 320;
|
||||||
|
const padX = 14;
|
||||||
|
|
||||||
|
test('xFromTimestampMs maps edge timestamps to padX and chartWidth-padX', () => {
|
||||||
|
expect(xFromTimestampMs(0, vp, chartWidth, padX)).toBeCloseTo(padX, 5);
|
||||||
|
expect(xFromTimestampMs(100_000, vp, chartWidth, padX)).toBeCloseTo(
|
||||||
|
chartWidth - padX,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
expect(xFromTimestampMs(50_000, vp, chartWidth, padX)).toBeCloseTo(
|
||||||
|
chartWidth / 2,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timestampMsFromX is the inverse of xFromTimestampMs', () => {
|
||||||
|
const ts = 73_421;
|
||||||
|
const x = xFromTimestampMs(ts, vp, chartWidth, padX);
|
||||||
|
const back = timestampMsFromX(x, vp, chartWidth, padX);
|
||||||
|
expect(back).toBeCloseTo(ts, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timestampMsFromX clamps to viewport bounds', () => {
|
||||||
|
const before = timestampMsFromX(-100, vp, chartWidth, padX);
|
||||||
|
expect(before).toBeLessThanOrEqual(vp.startMs);
|
||||||
|
const after = timestampMsFromX(500, vp, chartWidth, padX);
|
||||||
|
expect(after).toBeGreaterThanOrEqual(vp.endMs);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
buildContinuousUsageHistory,
|
||||||
|
filterSamplesByRange,
|
||||||
|
} from '../src/training/continuousUsage';
|
||||||
|
import { type HeartRateSample } from '../src/training/trainingTypes';
|
||||||
|
|
||||||
|
function sample(
|
||||||
|
timestamp: number,
|
||||||
|
bpm: number,
|
||||||
|
deviceName = 'Magene H003',
|
||||||
|
): HeartRateSample {
|
||||||
|
return {
|
||||||
|
timestamp,
|
||||||
|
bpm,
|
||||||
|
contactDetected: null,
|
||||||
|
rrIntervalsMs: [],
|
||||||
|
deviceName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildContinuousUsageHistory', () => {
|
||||||
|
it('groups continuous samples into usage blocks separated by long gaps', () => {
|
||||||
|
const blocks = buildContinuousUsageHistory(
|
||||||
|
[
|
||||||
|
sample(1_000, 120),
|
||||||
|
sample(2_000, 130),
|
||||||
|
sample(10 * 60_000, 140),
|
||||||
|
sample(10 * 60_000 + 1_000, 150),
|
||||||
|
],
|
||||||
|
{ gapMs: 5 * 60_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(blocks).toHaveLength(2);
|
||||||
|
expect(blocks[0]).toMatchObject({
|
||||||
|
startedAt: 10 * 60_000,
|
||||||
|
endedAt: 10 * 60_000 + 1_000,
|
||||||
|
sampleCount: 2,
|
||||||
|
averageBpm: 145,
|
||||||
|
peakBpm: 150,
|
||||||
|
minBpm: 140,
|
||||||
|
});
|
||||||
|
expect(blocks[1]).toMatchObject({
|
||||||
|
startedAt: 1_000,
|
||||||
|
endedAt: 2_000,
|
||||||
|
sampleCount: 2,
|
||||||
|
averageBpm: 125,
|
||||||
|
peakBpm: 130,
|
||||||
|
minBpm: 120,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts samples and ignores invalid BPM values', () => {
|
||||||
|
const blocks = buildContinuousUsageHistory(
|
||||||
|
[sample(5_000, 150), sample(1_000, 0), sample(3_000, 120)],
|
||||||
|
{ gapMs: 10_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(blocks).toHaveLength(1);
|
||||||
|
expect(blocks[0].startedAt).toBe(3_000);
|
||||||
|
expect(blocks[0].endedAt).toBe(5_000);
|
||||||
|
expect(blocks[0].sampleCount).toBe(2);
|
||||||
|
expect(blocks[0].samples.map(item => item.bpm)).toEqual([120, 150]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the most recent block first for history display', () => {
|
||||||
|
const blocks = buildContinuousUsageHistory(
|
||||||
|
[sample(1_000, 110), sample(30 * 60_000, 160)],
|
||||||
|
{ gapMs: 5 * 60_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(blocks.map(block => block.startedAt)).toEqual([30 * 60_000, 1_000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a stable block id while live samples extend the same usage block', () => {
|
||||||
|
const initial = buildContinuousUsageHistory(
|
||||||
|
[sample(1_000, 120), sample(2_000, 130)],
|
||||||
|
{ gapMs: 5 * 60_000 },
|
||||||
|
);
|
||||||
|
const updated = buildContinuousUsageHistory(
|
||||||
|
[sample(1_000, 120), sample(2_000, 130), sample(3_000, 140)],
|
||||||
|
{ gapMs: 5 * 60_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updated[0].id).toBe(initial[0].id);
|
||||||
|
expect(updated[0].sampleCount).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filterSamplesByRange', () => {
|
||||||
|
it('returns samples inside an inclusive custom date/time range', () => {
|
||||||
|
const samples = [
|
||||||
|
sample(Date.UTC(2026, 5, 10, 8, 59), 110),
|
||||||
|
sample(Date.UTC(2026, 5, 10, 9, 0), 120),
|
||||||
|
sample(Date.UTC(2026, 5, 10, 9, 30), 130),
|
||||||
|
sample(Date.UTC(2026, 5, 10, 10, 0), 140),
|
||||||
|
sample(Date.UTC(2026, 5, 10, 10, 1), 150),
|
||||||
|
];
|
||||||
|
|
||||||
|
const filtered = filterSamplesByRange(samples, {
|
||||||
|
start: Date.UTC(2026, 5, 10, 9, 0),
|
||||||
|
end: Date.UTC(2026, 5, 10, 10, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(filtered.map(item => item.bpm)).toEqual([120, 130, 140]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports ranges that cross multiple days', () => {
|
||||||
|
const samples = [
|
||||||
|
sample(Date.UTC(2026, 5, 10, 23, 50), 120),
|
||||||
|
sample(Date.UTC(2026, 5, 11, 0, 10), 130),
|
||||||
|
sample(Date.UTC(2026, 5, 11, 1, 0), 140),
|
||||||
|
];
|
||||||
|
|
||||||
|
const filtered = filterSamplesByRange(samples, {
|
||||||
|
start: Date.UTC(2026, 5, 10, 23, 45),
|
||||||
|
end: Date.UTC(2026, 5, 11, 0, 15),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(filtered.map(item => item.bpm)).toEqual([120, 130]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { parseHeartRateMeasurement } from '../src/ble/heartRateParser';
|
||||||
|
|
||||||
|
describe('parseHeartRateMeasurement', () => {
|
||||||
|
it('parses 8-bit BPM payloads', () => {
|
||||||
|
expect(parseHeartRateMeasurement([0x00, 154])).toMatchObject({
|
||||||
|
bpm: 154,
|
||||||
|
contactSupported: false,
|
||||||
|
contactDetected: null,
|
||||||
|
energyExpended: null,
|
||||||
|
rrIntervalsMs: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses 16-bit BPM payloads', () => {
|
||||||
|
expect(parseHeartRateMeasurement([0x01, 0x2c, 0x01]).bpm).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses contact, energy and RR interval data', () => {
|
||||||
|
const parsed = parseHeartRateMeasurement([
|
||||||
|
0x1e, 160, 0x10, 0x00, 0x00, 0x04,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(parsed.bpm).toBe(160);
|
||||||
|
expect(parsed.contactSupported).toBe(true);
|
||||||
|
expect(parsed.contactDetected).toBe(true);
|
||||||
|
expect(parsed.energyExpended).toBe(16);
|
||||||
|
expect(parsed.rrIntervalsMs).toEqual([1000]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { formatRssi, normalizeRssi } from '../src/ble/rssi';
|
||||||
|
|
||||||
|
describe('RSSI dBm helpers', () => {
|
||||||
|
it('keeps valid BLE RSSI values as dBm', () => {
|
||||||
|
expect(normalizeRssi(-63)).toBe(-63);
|
||||||
|
expect(formatRssi(-63)).toBe('-63 dBm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides Android sentinel and impossible RSSI values', () => {
|
||||||
|
expect(normalizeRssi(-2147483648)).toBeNull();
|
||||||
|
expect(normalizeRssi(127)).toBeNull();
|
||||||
|
expect(formatRssi(-2147483648)).toBe('sem leitura');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import {
|
||||||
|
buildInsightDashboard,
|
||||||
|
calculateBpmVariability,
|
||||||
|
calculateHeartRateDrift,
|
||||||
|
calculateLiveMetrics,
|
||||||
|
calculatePostSessionRecovery,
|
||||||
|
filterReliableHeartRateSamples,
|
||||||
|
isReliableHeartRateSample,
|
||||||
|
summarizeRrAvailability,
|
||||||
|
} from '../src/training/trainingMetrics';
|
||||||
|
import {
|
||||||
|
type CompletedTrainingSession,
|
||||||
|
type HeartRateSample,
|
||||||
|
} from '../src/training/trainingTypes';
|
||||||
|
|
||||||
|
function sample(
|
||||||
|
timestamp: number,
|
||||||
|
bpm: number,
|
||||||
|
rrIntervalsMs: readonly number[] = [],
|
||||||
|
): HeartRateSample {
|
||||||
|
return {
|
||||||
|
timestamp,
|
||||||
|
bpm,
|
||||||
|
contactDetected: null,
|
||||||
|
rrIntervalsMs,
|
||||||
|
deviceName: 'Magene H003',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function session(
|
||||||
|
startedAt: number,
|
||||||
|
endedAt: number,
|
||||||
|
peakBpm = 180,
|
||||||
|
): CompletedTrainingSession {
|
||||||
|
return {
|
||||||
|
id: `session-${startedAt}-${endedAt}`,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
durationMs: endedAt - startedAt,
|
||||||
|
sampleCount: 12,
|
||||||
|
averageBpm: 150,
|
||||||
|
peakBpm,
|
||||||
|
minBpm: 112,
|
||||||
|
deviceName: 'Magene H003',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('sample reliability guards', () => {
|
||||||
|
it('rejects zero BPM and explicit no-contact packets', () => {
|
||||||
|
expect(isReliableHeartRateSample(sample(1_000, 0))).toBe(false);
|
||||||
|
expect(
|
||||||
|
isReliableHeartRateSample({
|
||||||
|
...sample(2_000, 128),
|
||||||
|
contactDetected: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(isReliableHeartRateSample(sample(3_000, 128))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes invalid readings before metrics are calculated', () => {
|
||||||
|
const reliable = filterReliableHeartRateSamples([
|
||||||
|
sample(1_000, 0),
|
||||||
|
{ ...sample(2_000, 125), contactDetected: false },
|
||||||
|
sample(3_000, 130),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(reliable.map(item => item.bpm)).toEqual([130]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks live BPM as unavailable when the latest valid reading is stale', () => {
|
||||||
|
const metrics = calculateLiveMetrics([sample(1_000, 140)], {
|
||||||
|
now: 20_000,
|
||||||
|
staleAfterMs: 12_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(metrics.currentBpm).toBeNull();
|
||||||
|
expect(metrics.averageBpm).toBe(140);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateBpmVariability', () => {
|
||||||
|
it('summarizes BPM range, standard deviation and coefficient of variation', () => {
|
||||||
|
const variability = calculateBpmVariability([
|
||||||
|
sample(1_000, 100),
|
||||||
|
sample(2_000, 120),
|
||||||
|
sample(3_000, 140),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(variability).toMatchObject({
|
||||||
|
averageBpm: 120,
|
||||||
|
minBpm: 100,
|
||||||
|
maxBpm: 140,
|
||||||
|
rangeBpm: 40,
|
||||||
|
standardDeviationBpm: 16.3,
|
||||||
|
coefficientOfVariation: 0.136,
|
||||||
|
classification: 'oscilante',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateHeartRateDrift', () => {
|
||||||
|
it('compares the first and last fifth of a long enough sample window', () => {
|
||||||
|
const start = Date.UTC(2026, 5, 10, 9, 0);
|
||||||
|
const samples = Array.from({ length: 12 }, (_, index) =>
|
||||||
|
sample(start + index * 60_000, index < 6 ? 130 + index : 150 + index),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calculateHeartRateDrift(samples)).toMatchObject({
|
||||||
|
status: 'ok',
|
||||||
|
driftBpm: 30,
|
||||||
|
driftPercent: 23,
|
||||||
|
initialAverageBpm: 131,
|
||||||
|
finalAverageBpm: 161,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not over-interpret short windows as cardiovascular drift', () => {
|
||||||
|
expect(
|
||||||
|
calculateHeartRateDrift([sample(1_000, 130), sample(60_000, 140)]),
|
||||||
|
).toMatchObject({ status: 'short-window' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculatePostSessionRecovery', () => {
|
||||||
|
it('calculates one and two minute recovery from post-window continuous samples', () => {
|
||||||
|
const startedAt = Date.UTC(2026, 5, 10, 10, 0);
|
||||||
|
const endedAt = startedAt + 10 * 60_000;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
calculatePostSessionRecovery(session(startedAt, endedAt, 182), [
|
||||||
|
sample(endedAt + 5_000, 176),
|
||||||
|
sample(endedAt + 61_000, 160),
|
||||||
|
sample(endedAt + 119_000, 148),
|
||||||
|
]),
|
||||||
|
).toMatchObject({
|
||||||
|
status: 'ok',
|
||||||
|
oneMinuteDropBpm: 22,
|
||||||
|
twoMinuteDropBpm: 34,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('summarizeRrAvailability', () => {
|
||||||
|
it('reports RR availability without calculating fake HRV', () => {
|
||||||
|
expect(
|
||||||
|
summarizeRrAvailability([
|
||||||
|
sample(1_000, 120, [1000, 980]),
|
||||||
|
sample(2_000, 121, [990]),
|
||||||
|
]),
|
||||||
|
).toMatchObject({ status: 'ok', intervalCount: 3, sampleCount: 2 });
|
||||||
|
|
||||||
|
expect(summarizeRrAvailability([sample(1_000, 120)])).toMatchObject({
|
||||||
|
status: 'needs-rr',
|
||||||
|
intervalCount: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildInsightDashboard', () => {
|
||||||
|
it('builds compact scientific rows using persisted samples before live fallback', () => {
|
||||||
|
const start = Date.UTC(2026, 5, 10, 9, 0);
|
||||||
|
const persisted = [
|
||||||
|
sample(start, 120),
|
||||||
|
sample(start + 60_000, 140),
|
||||||
|
sample(start + 11 * 60_000, 160),
|
||||||
|
];
|
||||||
|
|
||||||
|
const dashboard = buildInsightDashboard({
|
||||||
|
liveSamples: [sample(start + 12 * 60_000, 170)],
|
||||||
|
continuousSamples: persisted,
|
||||||
|
sessions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dashboard.sampleCount).toBe(3);
|
||||||
|
expect(dashboard.rows.map(row => row.key)).toEqual([
|
||||||
|
'current-bpm',
|
||||||
|
'average-bpm',
|
||||||
|
'bpm-range',
|
||||||
|
'bpm-variability',
|
||||||
|
'zones',
|
||||||
|
'load',
|
||||||
|
'drift',
|
||||||
|
'recovery',
|
||||||
|
'profile',
|
||||||
|
'hrv',
|
||||||
|
]);
|
||||||
|
expect(dashboard.rows.find(row => row.key === 'hrv')).toMatchObject({
|
||||||
|
status: 'needs-rr',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
presets: ['module:@react-native/babel-preset'],
|
presets: ['module:@react-native/babel-preset'],
|
||||||
|
plugins: ['react-native-worklets/plugin'],
|
||||||
};
|
};
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Gesture Handler must be imported before any other React Native code so the
|
||||||
|
// native gesture system is initialized before our screens mount.
|
||||||
|
import 'react-native-gesture-handler';
|
||||||
import { AppRegistry } from 'react-native';
|
import { AppRegistry } from 'react-native';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { name as appName } from './app.json';
|
import { name as appName } from './app.json';
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
preset: '@react-native/jest-preset',
|
preset: '@react-native/jest-preset',
|
||||||
|
setupFiles: ['<rootDir>/jest.setup.js'],
|
||||||
|
transformIgnorePatterns: [
|
||||||
|
'node_modules/(?!(jest-)?@?react-native|@react-native-community|react-native-gesture-handler|react-native-reanimated|react-native-worklets|react-native-svg|react-native-safe-area-context)',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/* eslint-env jest */
|
||||||
|
|
||||||
|
// react-native-gesture-handler and react-native-reanimated both depend on
|
||||||
|
// native modules that are not present in the Jest environment. Mock the
|
||||||
|
// TurboModuleRegistry calls so importing our screens does not throw.
|
||||||
|
|
||||||
|
jest.mock('react-native-gesture-handler', () => {
|
||||||
|
const View = require('react-native').View;
|
||||||
|
const React = require('react');
|
||||||
|
const passthrough = component =>
|
||||||
|
React.forwardRef((props, ref) => {
|
||||||
|
const Comp = component;
|
||||||
|
return React.createElement(Comp, { ...props, ref });
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
GestureHandlerRootView: passthrough(View),
|
||||||
|
GestureDetector: passthrough(View),
|
||||||
|
Gesture: {
|
||||||
|
Pan: () => ({
|
||||||
|
onBegin: () => ({
|
||||||
|
onUpdate: () => ({
|
||||||
|
onEnd: () => ({
|
||||||
|
onFinalize: () => ({ minDistance: () => ({}) }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
Pinch: () => ({
|
||||||
|
onBegin: () => ({
|
||||||
|
onUpdate: () => ({
|
||||||
|
onEnd: () => ({ onFinalize: () => ({}) }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
Tap: () => ({ maxDuration: () => ({ onEnd: () => ({}) }) }),
|
||||||
|
Race: (...args) => ({ _args: args }),
|
||||||
|
Simultaneous: (...args) => ({ _args: args }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('react-native-reanimated', () => {
|
||||||
|
const View = require('react-native').View;
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
default: { View, createAnimatedComponent: c => c, Value: function () {} },
|
||||||
|
runOnJS: fn => fn,
|
||||||
|
useSharedValue: initial => ({ value: initial }),
|
||||||
|
useAnimatedStyle: () => ({}),
|
||||||
|
withTiming: v => v,
|
||||||
|
withSpring: v => v,
|
||||||
|
Easing: { inOut: () => ({}), linear: () => ({}) },
|
||||||
|
};
|
||||||
|
});
|
||||||
Generated
+620
-297
File diff suppressed because it is too large.
Load diff
+12
-6
@@ -7,23 +7,29 @@
|
|||||||
"ios": "react-native run-ios",
|
"ios": "react-native run-ios",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"start": "react-native start",
|
"start": "react-native start",
|
||||||
"test": "jest"
|
"test": "jest",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react-native": "^1.17.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-native": "0.86.0",
|
"react-native": "0.86.0",
|
||||||
"@react-native/new-app-screen": "0.86.0",
|
"react-native-gesture-handler": "^3.0.1",
|
||||||
"react-native-safe-area-context": "^5.5.2"
|
"react-native-reanimated": "^4.4.1",
|
||||||
|
"react-native-safe-area-context": "^5.5.2",
|
||||||
|
"react-native-svg": "^15.15.5",
|
||||||
|
"react-native-worklets": "^0.9.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
"@babel/preset-env": "^7.25.3",
|
"@babel/preset-env": "^7.25.3",
|
||||||
"@babel/runtime": "^7.25.0",
|
"@babel/runtime": "^7.25.0",
|
||||||
"@react-native-community/cli": "20.1.0",
|
"@react-native-community/cli": "^20.1.3",
|
||||||
"@react-native-community/cli-platform-android": "20.1.0",
|
"@react-native-community/cli-platform-android": "^20.1.3",
|
||||||
"@react-native-community/cli-platform-ios": "20.1.0",
|
"@react-native-community/cli-platform-ios": "^20.1.3",
|
||||||
"@react-native/babel-preset": "0.86.0",
|
"@react-native/babel-preset": "0.86.0",
|
||||||
"@react-native/eslint-config": "0.86.0",
|
"@react-native/eslint-config": "0.86.0",
|
||||||
|
"@react-native/jest-preset": "^0.86.0",
|
||||||
"@react-native/metro-config": "0.86.0",
|
"@react-native/metro-config": "0.86.0",
|
||||||
"@react-native/typescript-config": "0.86.0",
|
"@react-native/typescript-config": "0.86.0",
|
||||||
"@types/jest": "^29.5.13",
|
"@types/jest": "^29.5.13",
|
||||||
|
|||||||
@@ -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