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
@@ -1,45 +1,21 @@
|
||||
/**
|
||||
* Sample React Native App
|
||||
* https://github.com/facebook/react-native
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
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';
|
||||
import React from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { FightRateApp } from './src/app/FightRateApp';
|
||||
|
||||
function App() {
|
||||
const isDarkMode = useColorScheme() === 'dark';
|
||||
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
|
||||
<AppContent />
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<NewAppScreen
|
||||
templateFileName="App.tsx"
|
||||
safeAreaInsets={safeAreaInsets}
|
||||
/>
|
||||
</View>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<SafeAreaProvider>
|
||||
<FightRateApp />
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
root: { flex: 1 },
|
||||
});
|
||||
|
||||
export default App;
|
||||
+54
-1
@@ -3,8 +3,61 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { NativeModules } from 'react-native';
|
||||
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 () => {
|
||||
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 = {
|
||||
presets: ['module:@react-native/babel-preset'],
|
||||
plugins: ['react-native-worklets/plugin'],
|
||||
};
|
||||
@@ -2,6 +2,9 @@
|
||||
* @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 App from './App';
|
||||
import { name as appName } from './app.json';
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
module.exports = {
|
||||
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
+13
-7
@@ -7,23 +7,29 @@
|
||||
"ios": "react-native run-ios",
|
||||
"lint": "eslint .",
|
||||
"start": "react-native start",
|
||||
"test": "jest"
|
||||
"test": "jest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react-native": "^1.17.0",
|
||||
"react": "19.2.3",
|
||||
"react-native": "0.86.0",
|
||||
"@react-native/new-app-screen": "0.86.0",
|
||||
"react-native-safe-area-context": "^5.5.2"
|
||||
"react-native-gesture-handler": "^3.0.1",
|
||||
"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": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-env": "^7.25.3",
|
||||
"@babel/runtime": "^7.25.0",
|
||||
"@react-native-community/cli": "20.1.0",
|
||||
"@react-native-community/cli-platform-android": "20.1.0",
|
||||
"@react-native-community/cli-platform-ios": "20.1.0",
|
||||
"@react-native-community/cli": "^20.1.3",
|
||||
"@react-native-community/cli-platform-android": "^20.1.3",
|
||||
"@react-native-community/cli-platform-ios": "^20.1.3",
|
||||
"@react-native/babel-preset": "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/typescript-config": "0.86.0",
|
||||
"@types/jest": "^29.5.13",
|
||||
@@ -38,4 +44,4 @@
|
||||
"engines": {
|
||||
"node": ">= 22.11.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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