V

Vuer-Viz

vuer-vizv1.0.2

Publication-Quality Visualizations

Zoom & Pan

Enable interactive exploration of large datasets with zoom and pan controls.

Overview

While vuer-viz charts are stateless (they don't manage zoom/pan internally), you can easily implement these features by controlling the xMin/xMax props and handling mouse events. This approach gives you complete control over the interaction behavior.

Key Concepts:

  • Charts accept xMin, xMax, yMin, yMax props to control the visible range
  • Mouse wheel events (with modifiers) can implement zoom
  • Mouse drag events can implement pan
  • Combine both for full interactive exploration

Horizontal Zoom

Zoom horizontally using mouse wheel + modifier key (Option/Alt):

Horizontal Zoom (Option + Scroll)00.20.40.60.8100.20.40.60.81
SampleValue
Signal

Hold Option/Alt + Scroll to zoom. Range: 0.0 - 199.0 (99.5% visible).

import { useState } from "react";
import { LineChart, TimeSeries, VerticalMarker, useZoom } from "@vuer-ai/vuer-viz";

const x = Array.from({ length: 200 }, (_, i) => i);
const y = x.map(i => 50 + Math.sin(i / 10) * 30 + Math.random() * 10);

export default function HorizontalZoomExample() {
  const [xMin, setXMin] = useState(0);
  const [xMax, setXMax] = useState(199);
  const [hoverX, setHoverX] = useState<number | null>(null);

  const { plotRef } = useZoom({min : xMin, max : xMax, setMin : setXMin, setMax : setXMax, minLimit : 0, maxLimit : 199});
  const visibleRange = ((xMax - xMin) / 200 * 100).toFixed(1);

  return (
    <div>
      <LineChart
        plotRef={plotRef}
        width={700}
        height={300}
        xLabel="Sample"
        yLabel="Value"
        title="Horizontal Zoom (Option + Scroll)"
        xMin={xMin}
        xMax={xMax}
        onChange={(e) => e.type === "hover" && setHoverX(e.x)}
      >
        <TimeSeries label="Signal" x={x} y={y} color="#3498db" />
        <VerticalMarker x={hoverX} color="#34495e" lineWidth={1} dashArray="4,2" />
      </LineChart>
      <p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
        Hold Option/Alt + Scroll to zoom. Range: {xMin.toFixed(1)} - {xMax.toFixed(1)} ({visibleRange}% visible).
      </p>
    </div>
  );
}

Implementation details:

  • Listen to onWheel events on a wrapper div
  • Check for e.altKey to require Option/Alt modifier
  • Calculate new range based on zoom factor (1.1 for zoom out, 0.9 for zoom in)
  • Update xMin and xMax props to reflect new range
  • Clamp values to prevent zooming beyond data bounds

Why use a modifier key? Requiring Option/Alt prevents accidental zooming during normal page scrolling.

Pan with Drag

Pan horizontally by clicking and dragging:

Pan with Drag00.20.40.60.8100.20.40.60.81
TimeTemperature (°C)
Temperature

Click and drag to pan. Viewing x = 0.0 to 99.0.

import { useState } from "react";
import { LineChart, usePan } from "@vuer-ai/vuer-viz";

const x = Array.from({ length: 200 }, (_, i) => i);
const y = x.map(i => 50 + Math.sin(i / 10) * 30 + Math.random() * 10);

export default function PanDragExample() {
  const [xMin, setXMin] = useState(0);
  const [xMax, setXMax] = useState(99);

  const { plotRef } = usePan({min : xMin, max : xMax, setMin : setXMin, setMax : setXMax, minLimit : 0, maxLimit : 199});

  const series = [
    {
      label: "Temperature",
      x,
      y,
      color: "#e74c3c",
    },
  ];

  return (
    <div>
      <LineChart
        plotRef={plotRef}
        series={series}
        width={700}
        height={300}
        xLabel="Time"
        yLabel="Temperature (°C)"
        title="Pan with Drag"
        xMin={xMin}
        xMax={xMax}
        enableInteractiveCursor={true}
      />
      <p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
        Click and drag to pan. Viewing x = {xMin.toFixed(1)} to {xMax.toFixed(1)}.
      </p>
    </div>
  );
}

Implementation details:

  • Track drag state with useState and useRef
  • On mouse down: record starting position and current x range
  • On mouse move: calculate pixel delta, convert to data units, update range
  • Update cursor style: grab when idle, grabbing when dragging
  • Clamp to data bounds to prevent panning beyond limits

Combined Zoom & Pan

Combine both interactions for complete dataset exploration:

Combined Zoom & Pan00.20.40.60.8100.20.40.60.81
Time (samples)Amplitude
Sensor Data

Option + Scroll to zoom, Click + Drag to pan. Viewing x = 0.0 to 199.0 (39.8% of data).

import { useState } from "react";
import { LineChart, VerticalMarker, useZoomPan } from "@vuer-ai/vuer-viz";

const x = Array.from({ length: 500 }, (_, i) => i);
const y = x.map(i => 50 + Math.sin(i / 20) * 30 + Math.cos(i / 15) * 20 + Math.random() * 10);

export default function CombinedZoomPanExample() {
  const [xMin, setXMin] = useState(0);
  const [xMax, setXMax] = useState(199);
  const [hoverX, setHoverX] = useState<number | null>(null);

  const { plotRef } = useZoomPan({min : xMin, max : xMax, setMin : setXMin, setMax : setXMax, minLimit : 0, maxLimit : 499});

  const series = [
    {
      label: "Sensor Data",
      x,
      y,
      color: "#9b59b6",
      lineWidth: 2,
    },
  ];

  const visiblePercent = ((xMax - xMin) / 500 * 100).toFixed(1);

  return (
    <div>
      <LineChart
        plotRef={plotRef}
        series={series}
        width={700}
        height={350}
        xLabel="Time (samples)"
        yLabel="Amplitude"
        title="Combined Zoom & Pan"
        xMin={xMin}
        xMax={xMax}
        onChange={(e) => e.type === "hover" && setHoverX(e.x)}
        enableInteractiveCursor={true}
      >
        <VerticalMarker x={hoverX} color="#34495e" />

        {/* Mini range indicator */}
        <rect
          x={60}
          y={15}
          width={Math.max(0, (xMin / 500) * 620)}
          height={8}
          fill="#95a5a6"
          opacity={0.3}
        />
        <rect
          x={60 + (xMin / 500) * 620}
          y={15}
          width={((xMax - xMin) / 500) * 620}
          height={8}
          fill="#9b59b6"
          opacity={0.7}
        />
        <rect
          x={60 + (xMax / 500) * 620}
          y={15}
          width={Math.max(0, ((500 - xMax) / 500) * 620)}
          height={8}
          fill="#95a5a6"
          opacity={0.3}
        />
      </LineChart>
      <p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
        Option + Scroll to zoom, Click + Drag to pan. Viewing x = {xMin.toFixed(1)} to {xMax.toFixed(1)} ({visiblePercent}% of data).
      </p>
    </div>
  );
}

Features in this example:

  • Option + Scroll to zoom horizontally
  • Click + Drag to pan
  • Hover to show cursor position
  • Mini range indicator showing current view within full dataset
  • Reset and zoom-out buttons for quick navigation

Implementation Patterns

Basic Zoom Handler

const handleWheel = useCallback((e: React.WheelEvent) => {
  if (!e.altKey) return;  // Require modifier key
  e.preventDefault();

  const zoomFactor = e.deltaY > 0 ? 1.1 : 0.9;
  const currentRange = xMax - xMin;
  const newRange = currentRange * zoomFactor;

  // Limit zoom range
  if (newRange < minRange || newRange > maxRange) return;

  // Zoom toward center
  const center = (xMax + xMin) / 2;
  setXMin(center - newRange / 2);
  setXMax(center + newRange / 2);
}, [xMin, xMax]);

Basic Pan Handler

const dragStartRef = useRef<{x: number, xMin: number, xMax: number} | null>(null);

const handleMouseDown = (e: React.MouseEvent) => {
  dragStartRef.current = {
    x: e.clientX,
    xMin,
    xMax,
  };
};

const handleMouseMove = (e: React.MouseEvent) => {
  if (!dragStartRef.current) return;

  const deltaPixels = e.clientX - dragStartRef.current.x;
  const plotWidth = 620;  // Approximate plot area width
  const dataRange = dragStartRef.current.xMax - dragStartRef.current.xMin;

  // Convert pixel delta to data delta (negative because drag right = pan left)
  const deltaData = -(deltaPixels / plotWidth) * dataRange;

  setXMin(dragStartRef.current.xMin + deltaData);
  setXMax(dragStartRef.current.xMax + deltaData);
};

const handleMouseUp = () => {
  dragStartRef.current = null;
};

Zoom Toward Mouse Position

For more intuitive zooming, zoom toward the mouse cursor instead of the center:

const handleWheelAtCursor = useCallback((e: React.WheelEvent) => {
  if (!e.altKey) return;
  e.preventDefault();

  const rect = e.currentTarget.getBoundingClientRect();
  const mouseX = e.clientX - rect.left;
  const plotWidth = 620;  // Approximate plot width

  // Calculate mouse position in data coordinates
  const mouseDataX = xMin + (mouseX / plotWidth) * (xMax - xMin);

  const zoomFactor = e.deltaY > 0 ? 1.1 : 0.9;
  const newRange = (xMax - xMin) * zoomFactor;

  // Zoom while keeping mouse position fixed
  const leftRatio = (mouseDataX - xMin) / (xMax - xMin);
  setXMin(mouseDataX - newRange * leftRatio);
  setXMax(mouseDataX + newRange * (1 - leftRatio));
}, [xMin, xMax]);

Range Indicators

Show users where they are in the full dataset:

function MiniRangeIndicator({ xMin, xMax, dataMin, dataMax, width }) {
  const totalRange = dataMax - dataMin;
  const startPercent = (xMin - dataMin) / totalRange;
  const endPercent = (xMax - dataMin) / totalRange;

  return (
    <g>
      {/* Background (unviewed regions) */}
      <rect
        x={60}
        y={15}
        width={startPercent * width}
        height={8}
        fill="#95a5a6"
        opacity={0.3}
      />
      {/* Current view */}
      <rect
        x={60 + startPercent * width}
        y={15}
        width={(endPercent - startPercent) * width}
        height={8}
        fill="#3498db"
        opacity={0.7}
      />
      {/* Remaining */}
      <rect
        x={60 + endPercent * width}
        y={15}
        width={(1 - endPercent) * width}
        height={8}
        fill="#95a5a6"
        opacity={0.3}
      />
    </g>
  );
}

<LineChart xMin={xMin} xMax={xMax}>
  <MiniRangeIndicator xMin={xMin} xMax={xMax} dataMin={0} dataMax={500} width={620} />
</LineChart>

Vertical Zoom

Zoom vertically by controlling yMin and yMax:

const [yMin, setYMin] = useState(0);
const [yMax, setYMax] = useState(100);

const handleVerticalZoom = useCallback((e: React.WheelEvent) => {
  if (!e.shiftKey) return;  // Shift + Scroll for vertical
  e.preventDefault();

  const zoomFactor = e.deltaY > 0 ? 1.1 : 0.9;
  const center = (yMax + yMin) / 2;
  const newRange = (yMax - yMin) * zoomFactor;

  setYMin(center - newRange / 2);
  setYMax(center + newRange / 2);
}, [yMin, yMax]);

<div onWheel={handleVerticalZoom}>
  <LineChart series={data} yMin={yMin} yMax={yMax} />
</div>

Constraints and Boundaries

Limit Zoom Levels

const MIN_RANGE = 10;
const MAX_RANGE = 1000;

if (newRange < MIN_RANGE || newRange > MAX_RANGE) {
  return;  // Don't apply zoom
}

Clamp to Data Bounds

let newXMin = center - newRange / 2;
let newXMax = center + newRange / 2;

// Clamp to data boundaries
if (newXMin < dataMin) {
  newXMin = dataMin;
  newXMax = dataMin + newRange;
}
if (newXMax > dataMax) {
  newXMax = dataMax;
  newXMin = dataMax - newRange;
}

setXMin(newXMin);
setXMax(newXMax);

Smooth Transitions

Add smooth transitions with CSS or animation libraries:

import { useSpring, animated } from '@react-spring/web';

const [{ xMin, xMax }, api] = useSpring(() => ({
  xMin: 0,
  xMax: 100,
}));

// Animate to new range
const zoomTo = (newXMin: number, newXMax: number) => {
  api.start({ xMin: newXMin, xMax: newXMax });
};

<LineChart xMin={xMin.get()} xMax={xMax.get()} />

Keyboard Navigation

Enhance accessibility with keyboard controls:

useEffect(() => {
  const handleKeyDown = (e: KeyboardEvent) => {
    const panAmount = (xMax - xMin) * 0.1;

    switch (e.key) {
      case 'ArrowLeft':
        setXMin(xMin - panAmount);
        setXMax(xMax - panAmount);
        break;
      case 'ArrowRight':
        setXMin(xMin + panAmount);
        setXMax(xMax + panAmount);
        break;
      case '+':
        // Zoom in
        const zoomInRange = (xMax - xMin) * 0.9;
        const center = (xMax + xMin) / 2;
        setXMin(center - zoomInRange / 2);
        setXMax(center + zoomInRange / 2);
        break;
      case '-':
        // Zoom out
        const zoomOutRange = (xMax - xMin) * 1.1;
        const center2 = (xMax + xMin) / 2;
        setXMin(center2 - zoomOutRange / 2);
        setXMax(center2 + zoomOutRange / 2);
        break;
    }
  };

  window.addEventListener('keydown', handleKeyDown);
  return () => window.removeEventListener('keydown', handleKeyDown);
}, [xMin, xMax]);

Touch Support

Add touch gestures for mobile devices:

const [touchStart, setTouchStart] = useState<{x: number, y: number} | null>(null);

const handleTouchStart = (e: React.TouchEvent) => {
  if (e.touches.length === 1) {
    setTouchStart({ x: e.touches[0].clientX, y: e.touches[0].clientY });
  }
};

const handleTouchMove = (e: React.TouchEvent) => {
  if (!touchStart || e.touches.length !== 1) return;

  const deltaX = e.touches[0].clientX - touchStart.x;
  const plotWidth = 620;
  const dataRange = xMax - xMin;
  const deltaData = -(deltaX / plotWidth) * dataRange;

  setXMin(xMin + deltaData);
  setXMax(xMax + deltaData);
};

<div
  onTouchStart={handleTouchStart}
  onTouchMove={handleTouchMove}
  onTouchEnd={() => setTouchStart(null)}
>
  <LineChart ... />
</div>

Best Practices

1. Use Modifier Keys

Require modifier keys (Alt/Option, Shift) for zoom to avoid conflicts with page scrolling:

if (!e.altKey) return;  // Require Option/Alt for zoom

2. Provide Visual Feedback

  • Change cursor during drag (grabgrabbing)
  • Show range indicators
  • Display current zoom level
  • Add loading states for large datasets

3. Add Reset Button

Always provide a way to reset to the default view:

<button onClick={() => { setXMin(0); setXMax(dataLength); }}>
  Reset Zoom
</button>

4. Debounce Updates

For expensive re-renders, debounce range updates:

import { useDebouncedCallback } from 'use-debounce';

const debouncedSetRange = useDebouncedCallback(
  (newXMin, newXMax) => {
    setXMin(newXMin);
    setXMax(newXMax);
  },
  16  // ~60fps
);

5. Clamp Values

Always validate and clamp range values to prevent invalid states:

const clamp = (value: number, min: number, max: number) =>
  Math.max(min, Math.min(max, value));

setXMin(clamp(newXMin, dataMin, dataMax));

Performance Considerations

Canvas Rendering for Large Datasets

Use canvas={true} for better performance with large datasets:

<LineChart
  series={largeDataset}
  canvas={true}  // Render data on canvas
  xMin={xMin}
  xMax={xMax}
>
  {/* Overlays still render as SVG */}
  <VerticalMarker x={hoverX} />
</LineChart>

Data Windowing

Only pass visible data to the chart:

const visibleData = useMemo(() => {
  return fullData.filter(d => d.x >= xMin && d.x <= xMax);
}, [fullData, xMin, xMax]);

<LineChart series={[{ label: "Data", data: visibleData }]} />