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