V

Vuer-Viz

vuer-vizv1.0.2

Publication-Quality Visualizations

Event Handling

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

Complete Example

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.81TimeValues
Temperature
Humidity
Pressure
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
  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 (for LineChart)
  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

VerticalMarker

Renders a vertical line at a specific x-coordinate:

<VerticalMarker
  x={scrubX}           // Canonical x value (null to hide)
  color="#666"         // Line color
  lineWidth={1}        // Line width in pixels
  dashArray="4,4"      // SVG dash pattern
/>

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

Examples

Basic Hover Tracking

Track the cursor position and display a vertical marker:

Hover to Track Values00.20.40.60.8100.20.40.60.81Time (s)Temperature (°C)
Temperature
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>
  );
}

Synchronized Cursor Across Multiple Charts

Share a single cursor position across multiple charts with the same canonical x-domain:

Temperature Sensors12:00 AM00.20.40.60.81TimeTemperature (°C)
Sensor A
Sensor B
Atmospheric Pressure12:00 AM00.20.40.60.81TimePressure (kPa)
Pressure
Relative Humidity12:00 AM00.20.40.60.81TimeHumidity (%)
Humidity
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.

Click to Pin Marker

Click to lock the marker position for detailed inspection:

Click to Pin Marker (Click again to unpin)00.20.40.60.8100.20.40.60.81SampleAmplitude
Signal
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>
  );
}

Working with Different Data Domains

Same Canonical Domain

When charts share the same canonical x-domain, synchronization is straightforward:

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

// All charts use timestamps in milliseconds
<LineChart
  series={temperatureData}  // x: 1699123456000, 1699123457000, ...
  xFormatter={(ms) => new Date(ms).toLocaleTimeString()}
  onChange={(e) => setSharedX(e.x)}
>
  <VerticalMarker x={sharedX} />
</LineChart>

<LineChart
  series={pressureData}  // x: 1699123456000, 1699123457000, ...
  xFormatter={(ms) => new Date(ms).toLocaleTimeString()}
  onChange={(e) => setSharedX(e.x)}
>
  <VerticalMarker x={sharedX} />
</LineChart>

Different Canonical Domains

If charts have incompatible domains (e.g., one uses timestamps, another uses sample indices), you must provide domain mapping functions:

const [referenceX, setReferenceX] = useState<number | null>(null);

// Convert between domains
const TIME_START = 1699123400000;
const SAMPLE_RATE = 10; // samples per second

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

const indexToTimestamp = (index: number) =>
  TIME_START + (index / SAMPLE_RATE) * 1000;

// Chart 1: Timestamps
<LineChart
  series={timeSeriesData}
  onChange={(e) => setReferenceX(e.x)}  // Store timestamp
>
  <VerticalMarker x={referenceX} />
</LineChart>

// Chart 2: Sample indices
<LineChart
  series={sampleData}
  onChange={(e) => setReferenceX(indexToTimestamp(e.x))}
>
  <VerticalMarker x={referenceX ? timestampToIndex(referenceX) : null} />
</LineChart>

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}              // High-performance rendering
  onChange={(e) => setX(e.x)}  // Events still work!
>
  <VerticalMarker x={x} />     // SVG overlay
</LineChart>

Future Features

The event system is designed to support additional interactions:

  • Pan/Drag: onChange will emit updated bounds as the user drags
  • Zoom/Scroll: onChange will emit new bounds when scrolling to zoom
  • Series Toggle: Click legend items to show/hide series

These features will be added while maintaining API compatibility.

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

{
  // Event handling
  onChange?: (event: ChartChangeEvent) => void;

  // Formatting
  xFormatter?: (x: number) => string;
  yFormatter?: (y: number) => string;

  // Overlays
  children?: React.ReactNode;
}

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
  color?: string;            // Default: "#666"
  lineWidth?: number;        // Default: 1
  dashArray?: string;        // Default: "4,4"
}

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