Files
HealthTracking/docs/superpowers/specs/2026-06-14-fightrate-rn-chart-dynamic-zoom-pin.md
T

191 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<number | null>(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<Viewport>(initialViewport);
const [pin, setPin] = useState<ChartPin>(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).