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
313 lines
9.8 KiB
TypeScript
313 lines
9.8 KiB
TypeScript
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);
|
|
});
|
|
});
|