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

13 KiB
Raw Blame History

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):

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:

type Viewport = {
  readonly startMs: number;   // primeira timestamp visível
  readonly endMs: number;     // última timestamp visível (inclusive)
  readonly liveTail: boolean; // true quando endMs === lastSampleTimestamp
};
type ChartPin = {
  readonly timestampMs: number;
  readonly sample: HeartRateSample;
} | null;

Estado interno do TrainingChart (hooks, não Redux):

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).