13 KiB
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,buildChartre-cria o arraypoints, 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
onResponderMoveusaevent.nativeEvent.locationXdividido porlayoutWidthpara 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-svgcomo renderer. - Adicionar
react-native-gesture-handler(~v2) ereact-native-reanimated(~v3) para pinch/pan nativos. Setup padrão de RN bare: importreact-native-gesture-handlerno topo deindex.js, envolverAppcomGestureHandlerRootView. Habilitar reanimated via Babel plugin (react-native-reanimated/pluginembabel.config.js). - Sem troca de chart lib.
react-native-svgcontinua 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
windowSamplesque 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.
panajustastartMseendMsproporcionalmente ao movimento em pixels (relaçãopixelPorMs = layoutWidth / (endMs - startMs)). - Quando o usuário inicia pan,
liveTailvai parafalseimediatamente. liveTailretorna atruesomente quando, ao final do gesto, oendMsdo viewport estiver a menos de5%da duração visível da última amostra conhecida (snap zone).- Quando
liveTailestátruee novas amostras chegam, o viewport estende oendMsautomaticamente (a "esteira" do tempo real). - Quando
liveTailestáfalse, novas amostras não movem o viewport (mas olastPointcontinua 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
timestampMscontinua 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).
- novas amostras chegarem (o
- Pin some se:
- o usuário tocar fora do gráfico;
- o usuário tocar de novo no mesmo ponto pinado (toggle);
- o
timestampMsdo 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)). pickPointAtXmapeiae.x(em pixels no SVG) paratimestampMs = startMs + (e.x - PAD_X) / (CHART_WIDTH - 2*PAD_X) * (endMs - startMs), e então busca a amostra desamplesmais 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): GranularityformatTickLabel(timestampMs: number, granularity: Granularity): stringbuildChartViewport(samples, options)— calcula o viewport inicialapplyZoomIn(viewport, focusMs)/applyZoomOut(viewport, focusMs)applyPan(viewport, dxPixels, pixelsPerMs)isLiveTailSnap(viewport, lastSampleTimestampMs)— regra do snap zonepickSampleAtTimestamp(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, fundocolors.surfaceHot, raioradius.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— adicionarimport 'react-native-gesture-handler'no topo.FightRateRN/babel.config.js— adicionarreact-native-reanimated/plugin(deve ser o último da lista).FightRateRN/App.tsx— envolver conteúdo emGestureHandlerRootView(verificar se já está).package.json— adicionarreact-native-gesture-handlerereact-native-reanimated.android/app/src/main/java/com/healthtracking/fightrate/MainActivity.kt— verificar se herda deReactActivity(gesture-handler detecta automaticamente; nada a fazer em geral).docs/FIGHTRATE_DEVELOPMENT_WORKFLOW.md— atualizar a nota de que a skillfightrate-quality-looprecomenda aprovação antes de adicionar dependências (já cobre).
Não modificados
useHeartRateMonitor.ts— o gráfico consomesamplescomo prop. O comportamento de auto-follow é responsabilidade do gráfico, não do hook.CaptureScreen.tsx,HistoryScreen.tsx— call sites doTrainingChart. A interface do componente (samples) permanece compatível; nenhumpropnovo obrigatório é exposto ao pai nesta fatia (zoom/pan/pin são internos).
Critérios de sucesso
- Auto-follow funcional: ao entrar no
CaptureScreene 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. - 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.
- Pan sai do live: arrastar para a esquerda →
liveTail = false; novas amostras param de mover o viewport. O indicador "off-screen latest" aparece à direita. - Retomada do live: arrastar de volta até a snap zone (últimos 5% da duração visível) →
liveTail = truee o gráfico volta a rolar. - Pinch: 2 dedos abrindo → zoom in em torno do ponto médio; fechar → zoom out. Não dispara tap acidental.
- Botões
+/−: zoom in/out de 2x no centro. - Granularidade adaptativa: 10 min de janela visível → labels em
HH:mm; 1 min →HH:mm:ss30s; 30 s → 15 s; 10 s → 10 s; 2 s → 1 s; < 2 s → 0.5 s. Trocas suaves, sem flicker. - 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. - 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".
- Sem regressão visual: tokens do Corner Instrument mantidos (cores, raio, borda, tipografia label, fundo
colors.surfaceHot). - Sem gráfico fake: se
samples.length < 2, mostra o estado vazio atual (sem render de linha). Nenhuma mudança nesse fluxo. - Testes passam:
npm test -- --runInBandcobrechartViewport.ts(granularidade, zoom, pan, snap, pick).App.test.tsxcontinua passando. - Build OK:
npm run typecheckenpm run lintexit 0. Build Android (./gradlew :app:assembleDebugemFightRateRN/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
MainActivitypode precisar decreateReactActivityDelegateatualizado). Mitigação: seguir o passo-a-passo da documentação oficial; se o build falhar, voltar à abordagemPanResponder + botõese reusar o resto do spec. - Risco: reanimated v3 + RN 0.86 — compatibilidade.
Mitigação: travar versões compatíveis (
react-native-reanimated@~3.16.0recomendado para RN 0.86). Documentar a versão exata nopackage.jsonapós smoke test. - Risco: conflito entre gesto de pan do
ScrollViewpai e pan do gráfico. Mitigação: usarsimultaneousWithExternalGesturenoGestureDetectordo gráfico para desativar o pan doScrollViewapenas dentro da área do gráfico; testar comCaptureScreen(que está dentro deScrollView). - Risco: a busca binária de
pickSampleAtTimestampprecisa desamplesordenados. 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
CaptureScreenou daHistoryScreen(apenas a parte doTrainingChart). - 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).