191 lines
13 KiB
Markdown
191 lines
13 KiB
Markdown
# 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).
|