# FightRate RN — Gráfico dinâmico: zoom, pan, pin e auto-follow ## Objetivo Substituir o `TrainingChart` atual (estático, 120 últimos samples, sem zoom) por um gráfico dinâmico com viewport temporal, zoom por pinch, pan, granularidade adaptativa do eixo X, e seleção pinada que **sobrevive a novas amostras em tempo real**. O comportamento de auto-follow deve retomar automaticamente quando o usuário arrasta o viewport até a borda direita (último ponto conhecido). Aplica-se aos três call sites atuais: `CaptureScreen` (tempo real), `HistoryScreen` (janela salva) e `HistoryScreen` (faixa customizada). ## Problema atual `src/components/TrainingChart.tsx` (versão vigente): ```ts const chart = useMemo(() => buildChart(samples.slice(-120)), [samples]); const [selectedIndex, setSelectedIndex] = useState(null); ``` - `samples.slice(-120)` corta o array por contagem, não por janela temporal. Janelas longas (1h+) ficam visualmente compactadas a 120 pontos; janelas curtas (10s) ficam visualizadas com 120 pontos (impossível na prática, mas o problema é simétrico). - `selectedIndex` é a **posição no array de pontos**. Quando novas amostras chegam, `buildChart` re-cria o array `points`, e o índice anteriormente válido passa a apontar para uma amostra diferente — o ponto pinado "pula" e, no limite, some do viewport. Em tempo real isso é especialmente grave: o atleta clica num pico, e o gráfico "come" o pin a cada BLE notify. - Sem zoom (não há pinch). O gráfico é uma janela fixa do início ao fim do dataset. - Eixo X fixo, em segundos totais. Granularidade dos labels nunca se adapta. - Hit-testing em `onResponderMove` usa `event.nativeEvent.locationX` dividido por `layoutWidth` para mapear ao viewBox 320-wide — perde precisão fora do viewBox e não tolera gestos de 2 dedos. ## Decisões ### Stack - Manter `react-native-svg` como renderer. - Adicionar `react-native-gesture-handler` (~v2) e `react-native-reanimated` (~v3) para pinch/pan nativos. Setup padrão de RN bare: import `react-native-gesture-handler` no topo de `index.js`, envolver `App` com `GestureHandlerRootView`. Habilitar reanimated via Babel plugin (`react-native-reanimated/plugin` em `babel.config.js`). - Sem troca de chart lib. `react-native-svg` continua sendo o motor de render. ### Modelo conceitual Substituir "render dos últimos N samples" por **viewport temporal**: ```ts type Viewport = { readonly startMs: number; // primeira timestamp visível readonly endMs: number; // última timestamp visível (inclusive) readonly liveTail: boolean; // true quando endMs === lastSampleTimestamp }; ``` ```ts type ChartPin = { readonly timestampMs: number; readonly sample: HeartRateSample; } | null; ``` Estado interno do `TrainingChart` (hooks, não Redux): ```ts const [viewport, setViewport] = useState(initialViewport); const [pin, setPin] = useState(null); ``` `viewport` é o único "contrato" entre zoom/pan e o render. O SVG é derivado do `viewport` + samples. `pin` é independente de posição no array — é por `timestampMs`. Amostras novas entram, o `viewport` é recalculado somente se `liveTail` estiver `true`; o `pin` continua válido. ### Granularidade adaptativa do eixo X A duração visível do viewport (`endMs - startMs`) decide a granularidade dos ticks: | Duração visível | Granularidade dos ticks principais | Label | |---|---|---| | `>= 5 min` | 1 min | `HH:mm` | | `>= 1 min` | 30 s | `HH:mm:ss` | | `>= 30 s` | 15 s | `HH:mm:ss` | | `>= 10 s` | 10 s | `HH:mm:ss` | | `>= 2 s` | 1 s | `HH:mm:ss` | | `< 2 s` | 0.5 s | `HH:mm:ss.S` | Regras: - Mostrar no máximo 4–5 ticks para não poluir. - Tick 0 (início do viewport) sempre renderizado. - Não usar ticks decorativos. Se o dataset não tem amostra no tick, o tick vira referência de eixo apenas. ### Zoom - **Pinch** (dois dedos): escala o viewport em torno do ponto focado (ponto médio entre os dois dedos em coordenadas de tempo). - **Botões `+` / `−`** no canto superior direito do painel gráfico: zoom in/out de 2x mantendo o centro do viewport. - **Double-tap**: zoom in de 2x no ponto clicado; três taps em 1.2 s volta ao fit da janela selecionada (de acordo com o `windowSamples` que o pai passou). - Limites: mínimo 1 s visível; máximo = janela total selecionada (fit). - Os gestos de pinch e pan **não devem disparar tap**; gesto horizontal no gráfico move viewport; gesto vertical isolado é ignorado (a altura do gráfico não muda). ### Pan - 1 dedo arrastando = pan. `pan` ajusta `startMs` e `endMs` proporcionalmente ao movimento em pixels (relação `pixelPorMs = layoutWidth / (endMs - startMs)`). - Quando o usuário inicia pan, `liveTail` vai para `false` imediatamente. - `liveTail` retorna a `true` **somente** quando, ao final do gesto, o `endMs` do viewport estiver a menos de `5%` da duração visível da última amostra conhecida (snap zone). - Quando `liveTail` está `true` e novas amostras chegam, o viewport estende o `endMs` automaticamente (a "esteira" do tempo real). - Quando `liveTail` está `false`, novas amostras **não** movem o viewport (mas o `lastPoint` continua sendo o último da coleção, para render do "tail off-screen indicator"). ### Pin (clique em ponto) - Tap em um ponto = `setPin({ timestampMs, sample })`. - Pin sobrevive a: - novas amostras chegarem (o `timestampMs` continua existindo no array de samples, a menos que o ponto esteja fora do novo viewport); - zoom in/out (o pin continua naquele `timestampMs`); - pan (idem). - Pin some se: - o usuário tocar fora do gráfico; - o usuário tocar de novo no mesmo ponto pinado (toggle); - o `timestampMs` do pin ficar **fora do viewport** e o usuário não voltar para ele (auto-clear opcional, mas default é manter — fica "off-screen" e um pequeno indicador pode avisar). - Render do pin: círculo âmbar + linha tracejada vertical até o eixo X (visual atual mantido, refinado). - O `readout` (painel inferior) mostra o sample pinado. Sem pin, mostra "TOQUE/ARRASTE PARA INSPECIONAR" e mantém o BPM atual. ### Hit-testing - O gesto de tap é resolvido por `Gesture.Tap().onEnd((e) => pickPointAtX(e.x, viewport, samples))`. - `pickPointAtX` mapeia `e.x` (em pixels no SVG) para `timestampMs = startMs + (e.x - PAD_X) / (CHART_WIDTH - 2*PAD_X) * (endMs - startMs)`, e então busca a amostra de `samples` mais próxima no tempo (busca binária por timestamp, fallback linear para datasets pequenos). - Tolerância de snap: se a distância para a amostra mais próxima for maior que `10%` da duração visível, **não** seta pin (considera tap em área vazia). ## Estrutura de código ### Novos arquivos - `src/training/chartViewport.ts` — funções puras (testáveis) para: - `granularityForSpan(spanMs: number): Granularity` - `formatTickLabel(timestampMs: number, granularity: Granularity): string` - `buildChartViewport(samples, options)` — calcula o viewport inicial - `applyZoomIn(viewport, focusMs)` / `applyZoomOut(viewport, focusMs)` - `applyPan(viewport, dxPixels, pixelsPerMs)` - `isLiveTailSnap(viewport, lastSampleTimestampMs)` — regra do snap zone - `pickSampleAtTimestamp(samples, timestampMs, toleranceMs)` - tipos: `Viewport`, `ChartPin`, `Granularity` - `src/training/__tests__/chartViewport.test.ts` — testes TDD das funções puras. ### Modificados - `src/components/TrainingChart.tsx` — reescrito. Mantém: - viewBox 320×148 (compatibilidade com tokens); - cor de linha `colors.action`, fundo `colors.surfaceHot`, raio `radius.md`; - painel `readout` (label + valor) abaixo do gráfico; - pin: círculo âmbar + linha tracejada. Acrescenta: - pinch + pan via `react-native-gesture-handler`; - tap handler para pin; - eixo X com ticks adaptativos (função pura); - barra/cluster de botões `+` / `−` no canto; - indicador "off-screen latest" quando `!liveTail` (uma seta/marca à direita mostrando que há dados mais novos). - `src/components/ActionButton.tsx` — pode receber uso novo (botão pequeno icon-only para +/−). Avaliar reuso antes de criar componente novo. - `FightRateRN/index.js` — adicionar `import 'react-native-gesture-handler'` no topo. - `FightRateRN/babel.config.js` — adicionar `react-native-reanimated/plugin` (deve ser o último da lista). - `FightRateRN/App.tsx` — envolver conteúdo em `GestureHandlerRootView` (verificar se já está). - `package.json` — adicionar `react-native-gesture-handler` e `react-native-reanimated`. - `android/app/src/main/java/com/healthtracking/fightrate/MainActivity.kt` — verificar se herda de `ReactActivity` (gesture-handler detecta automaticamente; nada a fazer em geral). - `docs/FIGHTRATE_DEVELOPMENT_WORKFLOW.md` — atualizar a nota de que a skill `fightrate-quality-loop` recomenda aprovação antes de adicionar dependências (já cobre). ### Não modificados - `useHeartRateMonitor.ts` — o gráfico consome `samples` como prop. O comportamento de auto-follow é responsabilidade do gráfico, não do hook. - `CaptureScreen.tsx`, `HistoryScreen.tsx` — call sites do `TrainingChart`. A interface do componente (`samples`) permanece compatível; nenhum `prop` novo obrigatório é exposto ao pai nesta fatia (zoom/pan/pin são internos). ## Critérios de sucesso 1. **Auto-follow funcional**: ao entrar no `CaptureScreen` e a captura estar rodando, o gráfico se mantém mostrando os últimos N segundos; cada novo BPM real reposiciona o viewport para incluir a nova amostra à direita. 2. **Pin sobrevive a novas amostras**: tocar num ponto, deixar o sensor entregar 5 novas leituras → o pin continua visível na mesma posição, com o mesmo BPM e timestamp. O gráfico pode crescer à direita. 3. **Pan sai do live**: arrastar para a esquerda → `liveTail = false`; novas amostras param de mover o viewport. O indicador "off-screen latest" aparece à direita. 4. **Retomada do live**: arrastar de volta até a snap zone (últimos 5% da duração visível) → `liveTail = true` e o gráfico volta a rolar. 5. **Pinch**: 2 dedos abrindo → zoom in em torno do ponto médio; fechar → zoom out. Não dispara tap acidental. 6. **Botões `+` / `−`**: zoom in/out de 2x no centro. 7. **Granularidade adaptativa**: 10 min de janela visível → labels em `HH:mm`; 1 min → `HH:mm:ss` 30s; 30 s → 15 s; 10 s → 10 s; 2 s → 1 s; < 2 s → 0.5 s. Trocas suaves, sem flicker. 8. **Hit-test estável**: tap a 30% da largura do gráfico fixa o pin na amostra mais próxima no tempo, mesmo que o layout tenha width real diferente de `CHART_WIDTH`. 9. **Hit-test fora da área**: tap longe de qualquer amostra (gap > 10% da duração) não seta pin; o readout continua mostrando "TOQUE/ARRASTE PARA INSPECIONAR". 10. **Sem regressão visual**: tokens do Corner Instrument mantidos (cores, raio, borda, tipografia label, fundo `colors.surfaceHot`). 11. **Sem gráfico fake**: se `samples.length < 2`, mostra o estado vazio atual (sem render de linha). Nenhuma mudança nesse fluxo. 12. **Testes passam**: `npm test -- --runInBand` cobre `chartViewport.ts` (granularidade, zoom, pan, snap, pick). `App.test.tsx` continua passando. 13. **Build OK**: `npm run typecheck` e `npm run lint` exit 0. Build Android (`./gradlew :app:assembleDebug` em `FightRateRN/android`) só é rodado se JDK/JBR estiver disponível no ambiente. ## Riscos e mitigações - **Risco**: gesture-handler requer setup de imports no Android (já é o caso em RN 0.86, mas o `MainActivity` pode precisar de `createReactActivityDelegate` atualizado). **Mitigação**: seguir o passo-a-passo da documentação oficial; se o build falhar, voltar à abordagem `PanResponder + botões` e reusar o resto do spec. - **Risco**: reanimated v3 + RN 0.86 — compatibilidade. **Mitigação**: travar versões compatíveis (`react-native-reanimated@~3.16.0` recomendado para RN 0.86). Documentar a versão exata no `package.json` após smoke test. - **Risco**: conflito entre gesto de pan do `ScrollView` pai e pan do gráfico. **Mitigação**: usar `simultaneousWithExternalGesture` no `GestureDetector` do gráfico para desativar o pan do `ScrollView` apenas dentro da área do gráfico; testar com `CaptureScreen` (que está dentro de `ScrollView`). - **Risco**: a busca binária de `pickSampleAtTimestamp` precisa de `samples` ordenados. Garantir invariante no hook e/ou ordenar defensivamente. **Mitigação**: função pura de ordenação dedicada; teste cobre o caso de samples fora de ordem. ## Não-objetivos (fora do escopo) - Crosshair, brush selection, comparação de duas janelas no mesmo gráfico. - Exportar gráfico como imagem. - Reorganizar layout da `CaptureScreen` ou da `HistoryScreen` (apenas a parte do `TrainingChart`). - Trocar a paleta de cores. - Adicionar leitura de RR no eixo Y (permanece em BPM). - Sincronizar dois gráficos lado a lado. - Persistir o último zoom/pan entre sessões (state efêmero).