Changes include: - Updated architecture documentation - Enhanced module definitions (OQI-001 to OQI-008) - ML integration documentation updates - Trading strategies documentation - Orchestration and inventory updates - Docker configuration updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
563 lines
16 KiB
Markdown
563 lines
16 KiB
Markdown
---
|
|
id: "US-TRD-017"
|
|
title: "Zoom y Pan en el Chart"
|
|
type: "User Story"
|
|
status: "Done"
|
|
priority: "Alta"
|
|
epic: "OQI-003"
|
|
story_points: 3
|
|
created_date: "2025-12-05"
|
|
updated_date: "2026-01-04"
|
|
---
|
|
|
|
# US-TRD-017: Zoom y Pan en el Chart
|
|
|
|
## Metadata
|
|
|
|
| Campo | Valor |
|
|
|-------|-------|
|
|
| **ID** | US-TRD-017 |
|
|
| **Épica** | OQI-003 - Trading y Charts |
|
|
| **Módulo** | trading |
|
|
| **Prioridad** | P1 |
|
|
| **Story Points** | 3 |
|
|
| **Sprint** | Sprint 5 |
|
|
| **Estado** | Pendiente |
|
|
| **Asignado a** | Por asignar |
|
|
|
|
---
|
|
|
|
## Historia de Usuario
|
|
|
|
**Como** trader,
|
|
**quiero** hacer zoom y desplazarme (pan) en el chart usando mouse, trackpad o touch,
|
|
**para** analizar detalles específicos del precio en diferentes escalas temporales y niveles de zoom.
|
|
|
|
## Descripción Detallada
|
|
|
|
El usuario debe poder navegar por el chart de forma fluida, haciendo zoom in/out para ver más o menos velas, y desplazándose horizontalmente para ver datos históricos. La navegación debe ser intuitiva usando mouse wheel, pinch gestures, o botones.
|
|
|
|
## Mockups/Wireframes
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ BTCUSDT $97,234.50 +2.34% ▲ │
|
|
├─────────────────────────────────────────────────────────────────┤
|
|
│ [1m] [5m] [15m] [1h] [4h] [1D] [1W] [Indicators ▼] │
|
|
├─────────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ ZOOM CONTROLS: │
|
|
│ [+] [-] [Fit] [Auto] │
|
|
│ │
|
|
│ ┌────────────────────────────────────────────────────────┐ │
|
|
│ │ │ │
|
|
│ │ ◄── Pan Left Chart Area Pan Right ──►│ │
|
|
│ │ │ │
|
|
│ │ ████ │ │
|
|
│ │ ████ ████ Zoom Level: 100% │ │
|
|
│ │ ████ ████ ████ Candles visible: 168 │ │
|
|
│ │ ████ ████ │ │
|
|
│ │ ████ │ │
|
|
│ │ │ │
|
|
│ │ [ Scroll to zoom ] [ Drag to pan ] │ │
|
|
│ │ [ Pinch gesture ] [ Double-click reset ] │ │
|
|
│ │ │ │
|
|
│ └────────────────────────────────────────────────────────┘ │
|
|
│ │
|
|
│ TIMELINE: │
|
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
|
│ │ ░░░░░░░░░░████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░ │ │
|
|
│ │ Nov 1 Dec 5 (Current view) Jan 1 │ │
|
|
│ └──────────────────────────────────────────────────────────┘ │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
|
|
GESTOS SOPORTADOS:
|
|
┌─────────────────────────────────────┐
|
|
│ Mouse: │
|
|
│ • Scroll wheel: Zoom in/out │
|
|
│ • Click + Drag: Pan left/right │
|
|
│ • Double-click: Reset zoom │
|
|
│ │
|
|
│ Trackpad: │
|
|
│ • Pinch: Zoom in/out │
|
|
│ • Two-finger drag: Pan │
|
|
│ │
|
|
│ Touch (Mobile): │
|
|
│ • Pinch: Zoom in/out │
|
|
│ • Swipe: Pan left/right │
|
|
│ • Double-tap: Reset zoom │
|
|
│ │
|
|
│ Keyboard: │
|
|
│ • +/-: Zoom in/out │
|
|
│ • Arrow keys: Pan left/right │
|
|
│ • Home/End: Go to start/end │
|
|
│ • 0: Reset zoom to 100% │
|
|
└─────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Criterios de Aceptación
|
|
|
|
**Escenario 1: Zoom in con mouse wheel**
|
|
```gherkin
|
|
DADO que el usuario está viendo el chart con 168 velas visibles
|
|
CUANDO hace scroll hacia adelante (wheel up)
|
|
ENTONCES el chart hace zoom in
|
|
Y muestra menos velas (ej: 84 velas)
|
|
Y las velas se ven más grandes y detalladas
|
|
Y el zoom se centra en la posición del cursor
|
|
```
|
|
|
|
**Escenario 2: Zoom out con mouse wheel**
|
|
```gherkin
|
|
DADO que el usuario está viendo el chart con 168 velas
|
|
CUANDO hace scroll hacia atrás (wheel down)
|
|
ENTONCES el chart hace zoom out
|
|
Y muestra más velas (ej: 336 velas)
|
|
Y las velas se ven más pequeñas
|
|
Y puede ver un rango temporal más amplio
|
|
```
|
|
|
|
**Escenario 3: Pan con click y drag**
|
|
```gherkin
|
|
DADO que el usuario está viendo el chart
|
|
CUANDO hace click y arrastra hacia la izquierda
|
|
ENTONCES el chart se desplaza hacia la derecha
|
|
Y muestra datos históricos más antiguos
|
|
Y el desplazamiento es fluido y sigue el cursor
|
|
|
|
CUANDO arrastra hacia la derecha
|
|
ENTONCES el chart se desplaza hacia la izquierda
|
|
Y muestra datos más recientes
|
|
```
|
|
|
|
**Escenario 4: Zoom con pinch gesture (mobile/trackpad)**
|
|
```gherkin
|
|
DADO que el usuario está en mobile o usando trackpad
|
|
CUANDO hace pinch out (separar dedos)
|
|
ENTONCES el chart hace zoom in
|
|
Y las velas se agrandan
|
|
|
|
CUANDO hace pinch in (juntar dedos)
|
|
ENTONCES el chart hace zoom out
|
|
Y se ven más velas
|
|
```
|
|
|
|
**Escenario 5: Reset zoom con double-click**
|
|
```gherkin
|
|
DADO que el usuario ha hecho zoom y pan
|
|
CUANDO hace double-click en el chart
|
|
ENTONCES el zoom se resetea a 100%
|
|
Y se muestra el rango por defecto (168 velas)
|
|
Y se centra en las velas más recientes
|
|
```
|
|
|
|
**Escenario 6: Zoom con botones**
|
|
```gherkin
|
|
DADO que el usuario hace click en botón [+]
|
|
ENTONCES el chart hace zoom in un 20%
|
|
|
|
DADO que hace click en botón [-]
|
|
ENTONCES el chart hace zoom out un 20%
|
|
|
|
DADO que hace click en botón [Fit]
|
|
ENTONCES el chart se ajusta para mostrar todas las velas disponibles
|
|
|
|
DADO que hace click en botón [Auto]
|
|
ENTONCES el chart vuelve al zoom automático (latest candles)
|
|
```
|
|
|
|
**Escenario 7: Pan con teclado**
|
|
```gherkin
|
|
DADO que el usuario presiona flecha izquierda
|
|
ENTONCES el chart se desplaza hacia la izquierda (muestra datos antiguos)
|
|
|
|
DADO que presiona flecha derecha
|
|
ENTONCES el chart se desplaza hacia la derecha (muestra datos recientes)
|
|
|
|
DADO que presiona Home
|
|
ENTONCES el chart va al inicio (primera vela disponible)
|
|
|
|
DADO que presiona End
|
|
ENTONCES el chart va al final (última vela - presente)
|
|
```
|
|
|
|
**Escenario 8: Límites de zoom**
|
|
```gherkin
|
|
DADO que el usuario hace zoom in al máximo
|
|
CUANDO intenta hacer más zoom
|
|
ENTONCES el chart no hace más zoom
|
|
Y muestra mínimo 20 velas (zoom máximo)
|
|
|
|
DADO que hace zoom out al máximo
|
|
ENTONCES muestra todas las velas disponibles
|
|
Y no permite zoom out adicional
|
|
```
|
|
|
|
**Escenario 9: Timeline navigation**
|
|
```gherkin
|
|
DADO que el usuario ve el timeline debajo del chart
|
|
CUANDO hace click en una posición del timeline
|
|
ENTONCES el chart salta a ese rango temporal
|
|
Y se centra en la fecha clickeada
|
|
```
|
|
|
|
## Criterios Adicionales
|
|
|
|
- [ ] Animación suave de zoom y pan (60 FPS)
|
|
- [ ] Indicador visual del rango visible en timeline
|
|
- [ ] Mantener zoom level al cambiar timeframe
|
|
- [ ] Auto-scroll al último precio cuando hay nuevas velas
|
|
- [ ] Minimap para navegación rápida
|
|
|
|
---
|
|
|
|
## Tareas Técnicas
|
|
|
|
**Database:**
|
|
- No requiere cambios en DB
|
|
|
|
**Backend:**
|
|
- [ ] BE-TRD-094: Optimizar endpoint candles para rangos variables
|
|
- [ ] BE-TRD-095: Implementar paginación eficiente de velas históricas
|
|
|
|
**Frontend:**
|
|
- [ ] FE-TRD-093: Configurar zoom en Lightweight Charts
|
|
- [ ] FE-TRD-094: Implementar pan con mouse drag
|
|
- [ ] FE-TRD-095: Implementar zoom con wheel
|
|
- [ ] FE-TRD-096: Implementar pinch gestures (mobile/trackpad)
|
|
- [ ] FE-TRD-097: Crear componente ZoomControls.tsx
|
|
- [ ] FE-TRD-098: Crear componente Timeline.tsx
|
|
- [ ] FE-TRD-099: Implementar keyboard shortcuts
|
|
- [ ] FE-TRD-100: Implementar límites de zoom
|
|
- [ ] FE-TRD-101: Implementar hook useChartNavigation
|
|
|
|
**Tests:**
|
|
- [ ] TEST-TRD-046: Test unitario zoom logic
|
|
- [ ] TEST-TRD-047: Test integración pan y zoom
|
|
- [ ] TEST-TRD-048: Test E2E navegación completa
|
|
|
|
---
|
|
|
|
## Dependencias
|
|
|
|
**Depende de:**
|
|
- [ ] US-TRD-001: Ver chart - Estado: Pendiente
|
|
|
|
**Bloquea:**
|
|
- Ninguna
|
|
|
|
---
|
|
|
|
## Notas Técnicas
|
|
|
|
**Componentes UI:**
|
|
- `ZoomControls`: Botones de zoom
|
|
- `Timeline`: Barra de navegación temporal
|
|
- `ChartContainer`: Wrapper que maneja eventos
|
|
|
|
**Lightweight Charts Configuration:**
|
|
```typescript
|
|
const chart = createChart(container, {
|
|
timeScale: {
|
|
rightOffset: 12,
|
|
barSpacing: 3,
|
|
fixLeftEdge: false,
|
|
fixRightEdge: false,
|
|
lockVisibleTimeRangeOnResize: true,
|
|
rightBarStaysOnScroll: true,
|
|
borderVisible: true,
|
|
borderColor: '#fff000',
|
|
visible: true,
|
|
timeVisible: true,
|
|
secondsVisible: false,
|
|
shiftVisibleRangeOnNewBar: true,
|
|
},
|
|
handleScroll: {
|
|
mouseWheel: true,
|
|
pressedMouseMove: true,
|
|
horzTouchDrag: true,
|
|
vertTouchDrag: false,
|
|
},
|
|
handleScale: {
|
|
axisPressedMouseMove: true,
|
|
mouseWheel: true,
|
|
pinch: true,
|
|
},
|
|
});
|
|
```
|
|
|
|
**Zoom Implementation:**
|
|
```typescript
|
|
// Zoom with mouse wheel
|
|
function handleWheel(event: WheelEvent) {
|
|
event.preventDefault();
|
|
|
|
const chart = chartRef.current;
|
|
if (!chart) return;
|
|
|
|
const delta = event.deltaY;
|
|
const zoomFactor = delta > 0 ? 0.9 : 1.1; // Zoom out / Zoom in
|
|
|
|
const timeScale = chart.timeScale();
|
|
const visibleRange = timeScale.getVisibleRange();
|
|
|
|
if (!visibleRange) return;
|
|
|
|
const from = visibleRange.from as number;
|
|
const to = visibleRange.to as number;
|
|
const center = (from + to) / 2;
|
|
const newRange = (to - from) * zoomFactor;
|
|
|
|
timeScale.setVisibleRange({
|
|
from: center - newRange / 2,
|
|
to: center + newRange / 2,
|
|
});
|
|
}
|
|
|
|
// Zoom with buttons
|
|
function zoomIn() {
|
|
const timeScale = chart.timeScale();
|
|
const range = timeScale.getVisibleRange();
|
|
if (!range) return;
|
|
|
|
const center = (range.from + range.to) / 2;
|
|
const newRange = (range.to - range.from) * 0.8; // 20% zoom in
|
|
|
|
timeScale.setVisibleRange({
|
|
from: center - newRange / 2,
|
|
to: center + newRange / 2,
|
|
});
|
|
}
|
|
|
|
function zoomOut() {
|
|
const timeScale = chart.timeScale();
|
|
const range = timeScale.getVisibleRange();
|
|
if (!range) return;
|
|
|
|
const center = (range.from + range.to) / 2;
|
|
const newRange = (range.to - range.from) * 1.2; // 20% zoom out
|
|
|
|
timeScale.setVisibleRange({
|
|
from: center - newRange / 2,
|
|
to: center + newRange / 2,
|
|
});
|
|
}
|
|
|
|
// Reset zoom
|
|
function resetZoom() {
|
|
chart.timeScale().fitContent();
|
|
}
|
|
|
|
// Auto zoom (show latest)
|
|
function autoZoom() {
|
|
chart.timeScale().scrollToRealTime();
|
|
}
|
|
```
|
|
|
|
**Pan Implementation:**
|
|
```typescript
|
|
// Pan with mouse drag
|
|
let isDragging = false;
|
|
let startX = 0;
|
|
|
|
function handleMouseDown(event: MouseEvent) {
|
|
isDragging = true;
|
|
startX = event.clientX;
|
|
container.style.cursor = 'grabbing';
|
|
}
|
|
|
|
function handleMouseMove(event: MouseEvent) {
|
|
if (!isDragging) return;
|
|
|
|
const deltaX = event.clientX - startX;
|
|
startX = event.clientX;
|
|
|
|
const timeScale = chart.timeScale();
|
|
const range = timeScale.getVisibleRange();
|
|
if (!range) return;
|
|
|
|
const rangeWidth = range.to - range.from;
|
|
const containerWidth = container.clientWidth;
|
|
const pixelToTime = rangeWidth / containerWidth;
|
|
const shift = -deltaX * pixelToTime;
|
|
|
|
timeScale.setVisibleRange({
|
|
from: range.from + shift,
|
|
to: range.to + shift,
|
|
});
|
|
}
|
|
|
|
function handleMouseUp() {
|
|
isDragging = false;
|
|
container.style.cursor = 'default';
|
|
}
|
|
```
|
|
|
|
**Touch/Pinch Implementation:**
|
|
```typescript
|
|
let lastDistance = 0;
|
|
|
|
function handleTouchMove(event: TouchEvent) {
|
|
if (event.touches.length === 2) {
|
|
// Pinch zoom
|
|
event.preventDefault();
|
|
|
|
const touch1 = event.touches[0];
|
|
const touch2 = event.touches[1];
|
|
|
|
const distance = Math.hypot(
|
|
touch2.clientX - touch1.clientX,
|
|
touch2.clientY - touch1.clientY
|
|
);
|
|
|
|
if (lastDistance > 0) {
|
|
const zoomFactor = distance / lastDistance;
|
|
applyZoom(zoomFactor);
|
|
}
|
|
|
|
lastDistance = distance;
|
|
}
|
|
}
|
|
|
|
function handleTouchEnd() {
|
|
lastDistance = 0;
|
|
}
|
|
```
|
|
|
|
**Keyboard Shortcuts:**
|
|
```typescript
|
|
function handleKeyDown(event: KeyboardEvent) {
|
|
const timeScale = chart.timeScale();
|
|
|
|
switch (event.key) {
|
|
case '+':
|
|
case '=':
|
|
zoomIn();
|
|
break;
|
|
case '-':
|
|
case '_':
|
|
zoomOut();
|
|
break;
|
|
case 'ArrowLeft':
|
|
pan(-50); // Pan left 50 pixels
|
|
break;
|
|
case 'ArrowRight':
|
|
pan(50); // Pan right 50 pixels
|
|
break;
|
|
case 'Home':
|
|
timeScale.scrollToPosition(0, false);
|
|
break;
|
|
case 'End':
|
|
timeScale.scrollToRealTime();
|
|
break;
|
|
case '0':
|
|
resetZoom();
|
|
break;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Zoom Limits:**
|
|
```typescript
|
|
const MIN_VISIBLE_CANDLES = 20;
|
|
const MAX_VISIBLE_CANDLES = 1000;
|
|
|
|
function applyZoom(zoomFactor: number) {
|
|
const timeScale = chart.timeScale();
|
|
const range = timeScale.getVisibleRange();
|
|
if (!range) return;
|
|
|
|
const currentCandles = calculateVisibleCandles(range);
|
|
const newCandles = currentCandles / zoomFactor;
|
|
|
|
// Apply limits
|
|
if (newCandles < MIN_VISIBLE_CANDLES || newCandles > MAX_VISIBLE_CANDLES) {
|
|
return; // Don't zoom beyond limits
|
|
}
|
|
|
|
// Apply zoom
|
|
const center = (range.from + range.to) / 2;
|
|
const newRange = (range.to - range.from) * zoomFactor;
|
|
|
|
timeScale.setVisibleRange({
|
|
from: center - newRange / 2,
|
|
to: center + newRange / 2,
|
|
});
|
|
}
|
|
```
|
|
|
|
**Timeline Component:**
|
|
```typescript
|
|
function Timeline({ chart, candles }) {
|
|
const [visibleRange, setVisibleRange] = useState(null);
|
|
|
|
useEffect(() => {
|
|
const timeScale = chart.timeScale();
|
|
const updateRange = () => {
|
|
setVisibleRange(timeScale.getVisibleRange());
|
|
};
|
|
|
|
timeScale.subscribeVisibleTimeRangeChange(updateRange);
|
|
return () => timeScale.unsubscribeVisibleTimeRangeChange(updateRange);
|
|
}, [chart]);
|
|
|
|
const handleTimelineClick = (position: number) => {
|
|
// Jump to clicked position in timeline
|
|
const timeScale = chart.timeScale();
|
|
const totalRange = candles[candles.length - 1].time - candles[0].time;
|
|
const clickedTime = candles[0].time + (totalRange * position);
|
|
|
|
timeScale.scrollToPosition(clickedTime, true);
|
|
};
|
|
|
|
return (
|
|
<div className="timeline" onClick={(e) => {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const position = (e.clientX - rect.left) / rect.width;
|
|
handleTimelineClick(position);
|
|
}}>
|
|
{/* Render timeline visualization */}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Definition of Ready (DoR)
|
|
|
|
- [x] Historia claramente escrita
|
|
- [x] Criterios de aceptación definidos
|
|
- [x] Story points estimados
|
|
- [x] Dependencias identificadas
|
|
- [x] Sin bloqueadores
|
|
- [ ] Diseño/mockup disponible
|
|
- [ ] API spec disponible
|
|
|
|
## Definition of Done (DoD)
|
|
|
|
- [ ] Código implementado según criterios
|
|
- [ ] Tests unitarios escritos y pasando
|
|
- [ ] Tests de integración pasando
|
|
- [ ] Code review aprobado
|
|
- [ ] Documentación actualizada
|
|
- [ ] QA aprobado
|
|
- [ ] Desplegado en ambiente de pruebas
|
|
|
|
---
|
|
|
|
## Historial de Cambios
|
|
|
|
| Fecha | Cambio | Autor |
|
|
|-------|--------|-------|
|
|
| 2025-12-05 | Creación | Requirements-Analyst |
|
|
|
|
---
|
|
|
|
**Creada por:** Requirements-Analyst
|
|
**Fecha:** 2025-12-05
|
|
**Última actualización:** 2025-12-05
|