V

Vuer-Viz

vuer-vizv1.2.14

Publication-Quality Visualizations

Event Handling

Add interactivity to your charts with event handling and overlay components.

Here's a fully interactive chart combining markers and tooltips. Hover over the chart to see both the vertical marker line and a tooltip showing all series values:

Interactive Chart with Marker and Tooltip0m1m00.20.40.60.81
Temperature
Humidity
Pressure
TimeValues
import { useState } from "react";
import { LineChart, VerticalMarker, SeriesTooltip } from "@vuer-ai/vuer-viz";

const xValues = Array.from({ length: 500 }, (_, i) => i);

const series = [
  {
    label: "Temperature",
    x: xValues,
    y: xValues.map(i => 20 + Math.sin(i / 5) * 8 + Math.random() * 3),
    color: "#e74c3c",
  },
  {
    label: "Humidity",
    x: xValues,
    y: xValues.map(i => 30 + Math.cos(i / 7) * 15 + Math.random() * 5),
    color: "#3498db",
  },
  {
    label: "Pressure",
    x: xValues,
    y: xValues.map(i => 70 + Math.sin(i / 10) * 5 + Math.random() * 2),
    color: "#2ecc71",
  },
];

export default function CompleteInteractiveExample() {
  const [cursorX, setCursorX] = useState<number | null>(null);

  return (
    <LineChart
      series={series}
      width={700}
      height={300}
      xLabel="Time"
      yLabel="Values"
      title="Interactive Chart with Marker and Tooltip"
      xFormat="minutes"
      onChange={(e) => setCursorX(e.x)}
    >
      <VerticalMarker x={cursorX} color="#f00" lineWidth={1} />
      <SeriesTooltip
        x={cursorX}
        series={series}
        xFormat="minutes"
      />
    </LineChart>
  );
}

Overview

Charts support a unified onChange event handler that fires when the user interacts with the chart:

  • Hover - Moving the mouse over the chart
  • Click - Clicking on the chart
  • Drag - Panning the chart (future feature)
  • Scroll - Zooming the chart (future feature)

The event provides canonical data coordinates, allowing you to build interactive features like cursor tracking, value inspection, and synchronized multi-chart views.

Event API

onChange Handler

Charts accept an onChange prop that receives a ChartChangeEvent:

interface ChartChangeEvent {
  // Current pointer position in canonical data units (null outside plot area)
  x: number | null;
  y: number | null;

  // Current visible bounds
  bounds: {
    xMin: number;
    xMax: number;
    yMin: number;
    yMax: number;
  };

  // Event type
  type: 'hover' | 'click' | 'drag' | 'scroll';

  // Nearest data point (LineChart only)
  nearestPoint?: {
    seriesId: string;
    seriesLabel: string;
    dataIndex: number;
    x: number;
    y: number;
  };

  // Original DOM event
  nativeEvent?: MouseEvent | WheelEvent;
}

Coordinate System

Events return values in canonical data units — the same units used in your series data:

  • x, y: Canonical values (e.g., Unix timestamps, sample indices, or any numeric values)
  • bounds: Always in the chart's canonical units
  • xFormatter / yFormatter: Convert canonical values to display strings

This design allows synchronized charts with the same canonical domain to share cursor positions directly.

Overlay Components

Overlay components are rendered as children of LineChart and receive chart context automatically.

VerticalMarker

Renders a vertical line at a specific x-coordinate. Pass null to hide it.

<LineChart series={series} onChange={(e) => setCursorX(e.x)}>
  <VerticalMarker x={cursorX} color="#666" lineWidth={1} dashArray="4,4" />
</LineChart>
Hover to Track Values00.20.40.60.8100.20.40.60.81
Temperature
Time (s)Temperature (°C)
import { useState } from "react";
import { LineChart, VerticalMarker } from "@vuer-ai/vuer-viz";

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

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

export default function BasicHoverExample() {
  const [hoverX, setHoverX] = useState<number | null>(null);

  return (
    <LineChart
      series={series}
      width={600}
      height={300}
      xLabel="Time (s)"
      yLabel="Temperature (°C)"
      title="Hover to Track Values"
      onChange={(e) => setHoverX(e.x)}
    >
      <VerticalMarker x={hoverX} color="#3498db" lineWidth={2} />
    </LineChart>
  );
}

SeriesTooltip

Shows the value of every series at the hovered x position. Must be a child of LineChart. The header shows the nearest actual data point x; each row shows the series color indicator, label, and y value at that position.

<LineChart series={series} onChange={(e) => setCursorX(e.x)}>
  <SeriesTooltip
    x={cursorX}
    series={series}
    xFormatter="minutes"
    yFormatter="si"
  />
</LineChart>

Positioning

By default the tooltip follows the cursor (preferring the right side, falling back to the left). Use the position prop to pin it to a fixed corner of the plot area — useful for dense charts where a following tooltip would cover data.

positionBehavior
"follow" (default)Follows the cursor; prefers right side, falls back to left
"top-left"Fixed to the top-left corner of the plot area
"top-right"Fixed to the top-right corner of the plot area
"bottom-left"Fixed to the bottom-left corner of the plot area
"bottom-right"Fixed to the bottom-right corner of the plot area
Pinned Tooltip — Top Right00.20.40.60.8100.20.40.60.81StepValue
import { useState } from "react";
import { LineChart, VerticalMarker, SeriesTooltip } from "@vuer-ai/vuer-viz";

const x = Array.from({ length: 80 }, (_, i) => i);
const series = [
  { label: "Loss",     x, y: x.map(i => 2.5 * Math.exp(-i / 20) + 0.05 + Math.random() * 0.05), color: "#e74c3c" },
  { label: "Val Loss", x, y: x.map(i => 2.8 * Math.exp(-i / 22) + 0.12 + Math.random() * 0.08), color: "#3498db" },
  { label: "Accuracy", x, y: x.map(i => 1 - 0.9 * Math.exp(-i / 18) + Math.random() * 0.02),    color: "#2ecc71" },
];

export default function TooltipPositionExample() {
  const [cursorX, setCursorX] = useState<number | null>(null);

  return (
    <LineChart
      series={series}
      width={700}
      height={300}
      xLabel="Step"
      yLabel="Value"
      title="Pinned Tooltip — Top Right"
      legend={false}
      grid
      highlightOnHover
      highlightThreshold={20}
      onChange={(e) => setCursorX(e.x)}
    >
      <VerticalMarker x={cursorX} />
      <SeriesTooltip
        x={cursorX}
        series={series}
        position="top-right"
      />
    </LineChart>
  );
}

Integration with highlightOnHover

When LineChart's highlightOnHover prop is enabled, the row matching the nearest series receives a color-tinted background in the tooltip. See Line Chart → Highlight on Hover for details on highlightOnHover and highlightThreshold.

Tooltip

Displays formatted values at a position:

<Tooltip
  x={hoverX}                                          // Canonical x value
  y={hoverY}                                          // Optional y value
  formatX={(x) => new Date(x).toLocaleTimeString()}   // Format function
  formatY={(y) => y.toFixed(2)}                       // Format function
/>

Synchronized Charts

Cursor Synchronization

Lift cursor state to a shared parent and pass it to each chart's onChange. Charts with the same canonical x-domain can share the value directly:

Temperature Sensors12:00 AM00.20.40.60.81
Sensor A
Sensor B
TimeTemperature (°C)
Atmospheric Pressure12:00 AM00.20.40.60.81
Pressure
TimePressure (kPa)
Relative Humidity12:00 AM00.20.40.60.81
Humidity
TimeHumidity (%)
import { useState } from "react";
import { LineChart, SeriesTooltip, VerticalMarker } from "@vuer-ai/vuer-viz";
import { temperatureData, pressureData, humidityData } from "./synchronized-cursor-data";

export default function SynchronizedCursorExample() {
  const [scrubX, setScrubX] = useState<number | null>(null);

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
      <LineChart
        series={temperatureData}
        width={700}
        height={200}
        xLabel="Time"
        yLabel="Temperature (°C)"
        title="Temperature Sensors"
        xFormat="time"
        onChange={(e) => setScrubX(e.x)}
      >
        <VerticalMarker x={scrubX} />
        <SeriesTooltip x={scrubX} series={temperatureData} xFormat="time" />
      </LineChart>

      <LineChart
        series={pressureData}
        width={700}
        height={200}
        xLabel="Time"
        yLabel="Pressure (kPa)"
        title="Atmospheric Pressure"
        xFormat="time"
        onChange={(e) => setScrubX(e.x)}
      >
        <VerticalMarker x={scrubX} />
        <SeriesTooltip x={scrubX} series={pressureData} xFormat="time" />
      </LineChart>

      <LineChart
        series={humidityData}
        width={700}
        height={200}
        xLabel="Time"
        yLabel="Humidity (%)"
        title="Relative Humidity"
        xFormat="time"
        onChange={(e) => setScrubX(e.x)}
      >
        <VerticalMarker x={scrubX} />
        <SeriesTooltip x={scrubX} series={humidityData} xFormat="time" />
      </LineChart>
    </div>
  );
}

Key Insight: All three charts use the same canonical x-domain (Unix timestamps in milliseconds). The scrubX state is shared directly because the charts have compatible units. Each chart applies its own xFormatter to display the timestamps in a human-readable format.

const [sharedX, setSharedX] = useState<number | null>(null);

<LineChart series={temperatureData} onChange={(e) => setSharedX(e.x)}>
  <VerticalMarker x={sharedX} />
</LineChart>

<LineChart series={pressureData} onChange={(e) => setSharedX(e.x)}>
  <VerticalMarker x={sharedX} />
</LineChart>

If charts have different canonical domains (e.g. one uses timestamps, another uses sample indices), convert between them at the onChange boundary:

const TIME_START = 1699123400000;
const SAMPLE_RATE = 10; // samples per second

const timestampToIndex = (ms: number) => ((ms - TIME_START) / 1000) * SAMPLE_RATE;
const indexToTimestamp = (i: number)  => TIME_START + (i / SAMPLE_RATE) * 1000;

<LineChart series={timeData} onChange={(e) => setRefX(e.x)}>
  <VerticalMarker x={refX} />
</LineChart>

<LineChart series={sampleData} onChange={(e) => setRefX(indexToTimestamp(e.x))}>
  <VerticalMarker x={refX ? timestampToIndex(refX) : null} />
</LineChart>

Bounds Synchronization

Share xMin / xMax state across charts so zooming or panning one chart affects all. Each chart needs its own useZoomPan instance (separate DOM ref), but they all read and write the same bounds state:

Temperature Sensors12:00 AM00.20.40.60.81
Sensor A
Sensor B
TimeTemperature (°C)
Atmospheric Pressure12:00 AM00.20.40.60.81
Pressure
TimePressure (kPa)
Relative Humidity12:00 AM00.20.40.60.81
Humidity
TimeHumidity (%)

Option + Scroll to zoom, Click + Drag to pan. All charts share the same visible x-range.

import { useState } from "react";
import { LineChart, SeriesTooltip, VerticalMarker, useZoomPan } from "@vuer-ai/vuer-viz";
import { temperatureData, pressureData, humidityData } from "./synchronized-cursor-data";

export default function SynchronizedBoundsExample() {
  // Shared bounds state - all charts will display the same x-range
  const startTime = temperatureData[0].x[0];
  const endTime = temperatureData[0].x[temperatureData[0].x.length - 1];

  const [xMin, setXMin] = useState(startTime);
  const [xMax, setXMax] = useState(startTime + (endTime - startTime) * 0.4); // Start with 40% visible
  const [scrubX, setScrubX] = useState<number | null>(null);

  // Each chart needs its own plotRef, but they all share the same xMin/xMax state
  const zoomPanOptions = {
    min: xMin,
    max: xMax,
    setMin: setXMin,
    setMax: setXMax,
    minLimit: startTime,
    maxLimit: endTime,
  };

  const { plotRef: plotRef1 } = useZoomPan(zoomPanOptions);
  const { plotRef: plotRef2 } = useZoomPan(zoomPanOptions);
  const { plotRef: plotRef3 } = useZoomPan(zoomPanOptions);

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
      <LineChart
        plotRef={plotRef1}
        series={temperatureData}
        width={700}
        height={200}
        xLabel="Time"
        yLabel="Temperature (°C)"
        title="Temperature Sensors"
        xFormat="time"
        xMin={xMin}
        xMax={xMax}
        onChange={(e) => e.type === "hover" && setScrubX(e.x)}
      >
        <VerticalMarker x={scrubX} />
        <SeriesTooltip x={scrubX} series={temperatureData} xFormat="time" />
      </LineChart>

      <LineChart
        plotRef={plotRef2}
        series={pressureData}
        width={700}
        height={200}
        xLabel="Time"
        yLabel="Pressure (kPa)"
        title="Atmospheric Pressure"
        xFormat="time"
        xMin={xMin}
        xMax={xMax}
        onChange={(e) => e.type === "hover" && setScrubX(e.x)}
      >
        <VerticalMarker x={scrubX} />
        <SeriesTooltip x={scrubX} series={pressureData} xFormat="time" />
      </LineChart>

      <LineChart
        plotRef={plotRef3}
        series={humidityData}
        width={700}
        height={200}
        xLabel="Time"
        yLabel="Humidity (%)"
        title="Relative Humidity"
        xFormat="time"
        xMin={xMin}
        xMax={xMax}
        onChange={(e) => e.type === "hover" && setScrubX(e.x)}
      >
        <VerticalMarker x={scrubX} />
        <SeriesTooltip x={scrubX} series={humidityData} xFormat="time" />
      </LineChart>

      <p className="text-sm text-gray-600 dark:text-gray-400">
        Option + Scroll to zoom, Click + Drag to pan. All charts share the same visible x-range.
      </p>
    </div>
  );
}

Key Insight: Each chart needs its own useZoomPan hook instance (for separate DOM refs), but they all share the same xMin/xMax state and setters. When any chart is zoomed or panned, it updates the shared state, causing all charts to re-render with the new bounds. Combined with cursor synchronization, this creates a fully linked multi-chart view.

Click to Pin

Click to lock the marker at a position for detailed inspection while the cursor moves freely:

Click to Pin Marker (Click again to unpin)00.20.40.60.8100.20.40.60.81
Signal
SampleAmplitude
Pinned at x = 25.0 (click near marker to unpin)
import { useState } from "react";
import { LineChart, VerticalMarker } from "@vuer-ai/vuer-viz";

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

const series = [
  {
    label: "Signal",
    x,
    y,
    color: "#3498db",
  },
];

export default function ClickToPinExample() {
  const [pinnedX, setPinnedX] = useState<number | null>(25);
  const [hoverX, setHoverX] = useState<number | null>(null);

  const handleChange = (e: any) => {
    if (e.type === "click") {
      // Toggle pin: if clicking near the pinned position, unpin
      if (pinnedX !== null && Math.abs(e.x - pinnedX) < 2) {
        setPinnedX(null);
      } else {
        setPinnedX(e.x);
      }
    } else if (e.type === "hover") {
      setHoverX(e.x);
    }
  };

  return (
    <div>
      <LineChart
        series={series}
        width={600}
        height={300}
        xLabel="Sample"
        yLabel="Amplitude"
        title="Click to Pin Marker (Click again to unpin)"
        onChange={handleChange}
      >
        {/* Pinned marker (solid red) */}
        {pinnedX !== null && (
          <VerticalMarker x={pinnedX} color="#e74c3c" lineWidth={2} />
        )}
        {/* Hover marker (dashed blue) - only show when not pinned at same location */}
        {hoverX !== null && hoverX !== pinnedX && (
          <VerticalMarker x={hoverX} color="#3498db" lineWidth={1} dashArray="4,1" />
        )}
      </LineChart>
      <div className="mt-2.5 text-sm text-gray-600 dark:text-gray-400">
        {pinnedX !== null ? (
          <div>
            <strong>Pinned at x = {pinnedX.toFixed(1)}</strong> (click near marker to unpin)
          </div>
        ) : (
          <div>No marker pinned. Click anywhere on the chart to pin.</div>
        )}
      </div>
    </div>
  );
}

BarChart Event Handling

BarCharts emit discrete selection events:

  • x: Bar index (0, 1, 2, ...)
  • y: Bar value
  • nearestPoint: Contains the bar's label and data
const [selectedBar, setSelectedBar] = useState<number | null>(null);

<BarChart
  data={barData}
  onChange={(e) => {
    if (e.type === 'click') {
      setSelectedBar(e.x);
    }
  }}
/>

Canvas Mode Compatibility

Event handling works with both SVG (canvas={false}) and hybrid canvas modes (canvas={true}):

  • Data renders on canvas for performance
  • Event layer and overlays use SVG
  • Full interactivity is preserved
<LineChart
  series={largeDataset}
  canvas={true}
  onChange={(e) => setX(e.x)}
>
  <VerticalMarker x={x} />
</LineChart>

Best Practices

  1. Use canonical coordinates — Store positions in data units, not pixels
  2. Share domains for synchronization — Synced charts should use the same x-units
  3. Provide formatters — Use xFormatter/yFormatter for display
  4. Handle null values — Overlay components should check for x === null
  5. Distinguish event types — Use e.type to handle hover vs click differently

API Reference

LineChart / BarChart Props

{
  onChange?: (event: ChartChangeEvent) => void;
  children?: React.ReactNode; // Overlay components
}

ChartChangeEvent

interface ChartChangeEvent {
  x: number | null;
  y: number | null;
  bounds: { xMin: number; xMax: number; yMin: number; yMax: number };
  type: 'hover' | 'click' | 'drag' | 'scroll';
  nearestPoint?: {
    seriesId: string;
    seriesLabel: string;
    dataIndex: number;
    x: number;
    y: number;
  };
  nativeEvent?: MouseEvent | WheelEvent;
}

VerticalMarker Props

interface VerticalMarkerProps {
  x: number | null;   // Canonical x position (null hides the marker)
  color?: string;     // Default: "#666"
  lineWidth?: number; // Default: 1
  dashArray?: string; // Default: "4,4"
}

SeriesTooltip Props

type SeriesTooltipPosition =
  | "follow"       // default — follows the cursor
  | "top-left"
  | "top-right"
  | "bottom-left"
  | "bottom-right";

interface SeriesTooltipProps {
  x: number | null;                 // Canonical x position (null hides the tooltip)
  series: LineSeries[];             // Must match the series passed to LineChart
  position?: SeriesTooltipPosition; // Default: "follow"
  offsetX?: number;                 // Cursor offset X when following. Default: 10
  offsetY?: number;                 // Cursor offset Y when following. Default: -10
  backgroundColor?: string;         // Uses theme default when omitted
  textColor?: string;               // Uses theme default when omitted
  fontSize?: number;                // Default: 12
  padding?: number;                 // Inner padding. Default: 8
  xFormatter?: Formatter;              // x-axis formatter
  yFormatter?: Formatter;              // y-axis formatter
}

Tooltip Props

interface TooltipProps {
  x: number | null;
  y?: number | null;
  content?: React.ReactNode;
  formatX?: (x: number) => string;
  formatY?: (y: number) => string;
  offset?: { x: number; y: number };
  backgroundColor?: string;
  textColor?: string;
  fontSize?: number;
  padding?: number;
}