# Vistral

> Use when building React streaming data visualizations with @timeplus/vistral — creating charts, writing VistralSpec, using VistralChart, configuring streaming data sources, or wiring up live data via ChartHandle.

- Skill: `timeplus-io/vistral` (Agent Skill)
- Install (CLI): `npx skillmds@latest add timeplus-io/vistral`
- Raw SKILL.md: https://api.skillmd.com/api/skills/timeplus-io/vistral/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: timeplus-io (https://skillmd.com/u/timeplus-io)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/timeplus-io/vistral

---


# Vistral — Streaming Data Visualization

## Overview

`@timeplus/vistral` is a React library for real-time streaming charts built on AntV G2. Two APIs exist — pick the right one before writing any code.

## Which API to Use

```
Need custom marks / full G2 control?
  → Grammar API: VistralChart + VistralSpec

Want a quick chart with minimal config?
  → Config API: StreamChart + TimeSeriesConfig / BarColumnConfig / etc.
```

**AI agents should default to the Grammar API** — it's more expressive and maps cleanly to spec-driven generation.

---

## Grammar API (VistralChart + VistralSpec)

### Component

```tsx
import { VistralChart, type ChartHandle } from '@timeplus/vistral';
import type { VistralSpec } from '@timeplus/vistral';

const ref = useRef<ChartHandle>(null);

<VistralChart
  ref={ref}
  spec={spec}
  source={dataSource}   // optional: declarative initial data
  height={400}
  theme="dark"          // string | VistralTheme — 'dark', 'light', registered name, or VistralTheme object
  onReady={(handle) => {
    handle.append(rows);   // add rows, re-render
    handle.replace(rows);  // replace all data, re-render
    handle.clear();        // empty buffer, re-render
    handle.g2;             // raw G2 instance
  }}
/>
```

### VistralSpec Fields

| Field | Type | Description |
|-------|------|-------------|
| `marks` | `MarkSpec[]` | **Required.** Visual elements: `line`, `area`, `interval`, `point`, `rect`, `text`, etc. |
| `scales` | `Record<string, ScaleSpec>` | Scale config per channel (`x`, `y`, `color`). Per-mark scales override. |
| `transforms` | `TransformSpec[]` | Visual transforms: `stackY`, `dodgeX`, `bin`, `group` |
| `temporal` | `TemporalSpec` | Streaming time control (see below) |
| `streaming` | `StreamingSpec` | `maxItems`, `mode`, `throttle` |
| `axes` | `AxesSpec` | `{ x?, y? }` — title, grid, label format/rotate |
| `legend` | `LegendSpec \| false` | `{ position?, interactive? }` or `false` to hide |
| `tooltip` | `TooltipSpec \| false` | `{ title?, items[] }` — `items[].format` maps to G2 `valueFormatter` |
| `coordinate` | `CoordinateSpec` | `polar`, `theta`, `transpose`, etc. |
| `annotations` | `AnnotationSpec[]` | Reference lines, ranges, text overlays |
| `interactions` | `InteractionSpec[]` | `{ type: 'tooltip' \| 'brush' \| ... }` |
| `theme` | `string \| VistralTheme` | Spec-level theme — overridden by component `theme` prop. Prefer setting theme on the component. |
| `animate` | `boolean` | Disable for streaming: `animate: false` |
| `g2Overrides` | `Record<string, unknown>` | Raw G2 options deep-merged last — override anything |

### Common Mark Pattern

```tsx
const spec: VistralSpec = {
  marks: [{
    type: 'line',
    encode: { x: 'time', y: 'value', color: 'series' },
    scales: { x: { type: 'time' } },
    style: { shape: 'smooth' },
  }],
  temporal: { mode: 'axis', field: 'time', range: 5 },
  axes: {
    y: { labels: { format: (v) => `${Number(v).toFixed(1)}` } }
  },
  tooltip: {
    items: [{ field: 'value', name: 'Value', format: (v) => `${v}` }]
  },
  animate: false,
  theme: 'dark',
};
```

### Temporal Modes

| Mode | When to use | Key fields |
|------|-------------|------------|
| `axis` | Sliding time window on X | `field: 'time'`, `range: 5` (minutes) |
| `frame` | Latest timestamp only | `field: 'time'` |
| `key` | Latest value per entity | `field: 'id'` or `field: ['region','server']` |

### g2Overrides — Escape Hatch

Use for any G2 option not modeled by VistralSpec:

```tsx
g2Overrides: {
  axis: { y: { tickCount: 5 } },         // tick count
  paddingLeft: 60,                         // view padding
  tooltip: {                               // raw G2 tooltip
    items: [{ channel: 'y', valueFormatter: (v) => formatBps(v) }]
  }
}
```

---

## Custom Themes

```tsx
import { registerTheme, type VistralTheme } from '@timeplus/vistral';

// Register once at app startup (module level)
registerTheme('corporate', {
  extends: 'light',              // inherit from 'dark' (default) or 'light'
  palette: ['#0066CC', '#FF6600', '#00AA44'],
  font:    { family: 'Roboto, sans-serif', size: 12 },
  axis:    { grid: { color: '#E0E0E0', dash: [4, 4] }, label: { color: '#333' } },
  tooltip: { background: '#FFF', text: { color: '#111' }, border: { color: '#E0E0E0' } },
  legend:  { label: { color: '#333' } },
} satisfies VistralTheme);

// Use by name
<VistralChart spec={spec} theme="corporate" />
<StreamChart config={config} data={data} theme="corporate" />

// Or pass a VistralTheme object directly (no registration needed)
<VistralChart spec={spec} theme={{ palette: ['#FF73B6', '#8890FF'], axis: { grid: { color: '#1A1A2E' } } }} />
```

Built-in themes: `'dark'` (default) and `'light'`. Custom themes deep-merge onto the base.

---

## StreamDataSource Format

```tsx
import type { StreamDataSource } from '@timeplus/vistral';

const source: StreamDataSource = {
  columns: [
    { name: 'time',   type: 'datetime64' },
    { name: 'value',  type: 'float64' },
    { name: 'series', type: 'string' },
  ],
  data: [
    { time: '2024-01-01T00:00:00Z', value: 42.5, series: 'A' },  // object rows
    // OR array rows (must match column order):
    // ['2024-01-01T00:00:00Z', 42.5, 'A'],
  ],
};
```

**Column types:** `string`, `number`, `datetime`, `datetime64`, `float32`, `float64`, `int32`, `int64`, `boolean`

---

## Streaming Pattern (Grammar API)

```tsx
function LiveChart() {
  const handleRef = useRef<ChartHandle>(null);
  const loadedRef = useRef(false);  // ← REQUIRED: React 18 Strict Mode guard

  useEffect(() => {
    if (!loadedRef.current && handleRef.current) {
      loadedRef.current = true;
      handleRef.current.append(historicalRows);  // seed history once
    }
  }, []);

  useEffect(() => {
    const id = setInterval(() => {
      handleRef.current?.append(newRows);  // push live rows
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <VistralChart ref={handleRef} spec={spec} height={400} theme="dark" />;
}

// Note: use `ref` for ongoing appends, `onReady` for one-time setup.
// Do NOT duplicate seeding in both — use loadedRef to guard whichever you choose.
```

---

## Config API (StreamChart) — Quick Reference

```tsx
import { StreamChart, useStreamingData } from '@timeplus/vistral';
import type { TimeSeriesConfig, StreamDataSource } from '@timeplus/vistral';

function QuickChart() {
  // useStreamingData<ElementType> — generic is the ROW type, not array
  const { data, append } = useStreamingData<Record<string, unknown>>([], 1000);

  const config: TimeSeriesConfig = {
    chartType: 'line',
    xAxis: 'time',
    yAxis: 'value',
    color: 'series',
    lineStyle: 'curve',
    legend: true,
    temporal: { mode: 'axis', field: 'time', range: 5 },
  };

  // StreamChart has NO ref/ChartHandle (use VistralChart for imperative control)
  return <StreamChart config={config} data={{ columns, data }} theme="dark" />;
}
```

---

## Critical Gotchas

| Mistake | Fix |
|---------|-----|
| `useStreamingData<Row[]>` (wrong generic) | Use `useStreamingData<Row>` — element type, not array |
| `<StreamChart ref={...}>` | `StreamChart` has NO ref/ChartHandle. Use `VistralChart` for imperative append/replace/clear. |
| `append([singleRow])` when row is already an array | Wrap: `append([[singleRow]])` — `append` treats top-level array as multiple items |
| No `loadedRef` guard when seeding history | React 18 Strict Mode runs effects twice → doubled rows |
| `tooltip.items[].format` not working | `format` IS supported — translated to G2's `valueFormatter` internally (v0.1.6+). Use `g2Overrides.tooltip` only for raw G2 tooltip features beyond what `TooltipSpec` models. |
| `legend.interactive` silently ignored | Use `legend: { interactive: true }` — now translated correctly |

---

## Axis Label Formatting

```tsx
axes: {
  y: {
    title: 'Throughput',
    labels: {
      format: (v) => {
        const n = Number(v);
        if (n >= 1e9) return `${(n/1e9).toFixed(1)} Gbps`;
        if (n >= 1e6) return `${(n/1e6).toFixed(1)} Mbps`;
        if (n >= 1e3) return `${(n/1e3).toFixed(1)} Kbps`;
        return `${n.toFixed(0)} bps`;
      }
    }
  }
}
```

