home / skills / jonathanbelolo / composable-svelte / composable-svelte-charts

composable-svelte-charts skill

/.claude/skills/composable-svelte-charts

This skill helps you create interactive charts with Svelte using Observable Plot and D3, including zoom, tooltips, and responsive charts.

npx playbooks add skill jonathanbelolo/composable-svelte --skill composable-svelte-charts

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
20.2 KB
---
name: composable-svelte-charts
description: Data visualization and chart components for Composable Svelte. Use when creating charts, graphs, or data visualizations. Covers chart types (scatter, line, bar, area, histogram), data binding, state-driven updates, interactive features (zoom, brush, tooltips), and responsive design from @composable-svelte/charts package built with Observable Plot and D3.
---

# Composable Svelte Charts

Interactive data visualization components built with Observable Plot and D3.

---

## PACKAGE OVERVIEW

**Package**: `@composable-svelte/charts`

**Purpose**: State-driven interactive charts and data visualizations.

**Technology Stack**:
- **Observable Plot**: Declarative visualization grammar from Observable
- **D3**: Low-level utilities for scales, shapes, and interactions
- **Motion One**: Smooth transitions and animations

**Chart Types**:
- Scatter plots
- Line charts
- Bar charts
- Area charts
- Histograms

**Interactive Features**:
- Zoom & pan
- Brush selection
- Tooltips (automatic)
- Range selection
- Responsive sizing

**State Management**:
All charts use pure reducers with type-safe actions following Composable Architecture patterns.

---

## QUICK START

```typescript
import { createStore } from '@composable-svelte/core';
import { Chart, chartReducer, createInitialChartState } from '@composable-svelte/charts';

// Sample data
const data = [
  { x: 1, y: 10, category: 'A' },
  { x: 2, y: 25, category: 'B' },
  { x: 3, y: 15, category: 'A' },
  { x: 4, y: 30, category: 'B' }
];

// Create chart store
const chartStore = createStore({
  initialState: createInitialChartState({ data }),
  reducer: chartReducer,
  dependencies: {}
});

// Render scatter plot
<Chart
  store={chartStore}
  type="scatter"
  x="x"
  y="y"
  color="category"
  width={800}
  height={400}
  enableZoom={true}
  enableTooltip={true}
/>
```

---

## CHART COMPONENT

**Purpose**: High-level wrapper for creating charts with Observable Plot.

### Props

- `store: Store<ChartState, ChartAction>` - Chart store (required)
- `type: 'scatter' | 'line' | 'bar' | 'area' | 'histogram'` - Chart type (default: 'scatter')
- `width: number` - Chart width (optional, responsive if omitted)
- `height: number` - Chart height (optional, defaults to 400px)
- `x: string | ((d) => any)` - X accessor (required)
- `y: string | ((d) => any)` - Y accessor (required)
- `color: string | ((d) => any)` - Color accessor (optional)
- `size: number` - Mark size (optional)
- `xDomain: [number, number] | 'auto'` - X domain (optional)
- `yDomain: [number, number] | 'auto'` - Y domain (optional)
- `enableZoom: boolean` - Enable zoom/pan (default: false)
- `enableBrush: boolean` - Enable brush selection (default: false)
- `enableTooltip: boolean` - Enable tooltips (default: true)
- `enableAnimations: boolean` - Enable transitions (default: true)
- `onSelectionChange: (selected: any[]) => void` - Selection callback (optional)

### Usage

```typescript
<Chart
  store={chartStore}
  type="scatter"
  x="date"
  y="value"
  color={(d) => d.category}
  size={4}
  xDomain="auto"
  yDomain={[0, 100]}
  enableZoom={true}
  enableTooltip={true}
  onSelectionChange={(selected) => console.log('Selected:', selected)}
/>
```

---

## CHART TYPES

### Scatter Plot

**Purpose**: Display individual data points in 2D space.

**Best for**: Correlations, distributions, outliers.

```typescript
<Chart
  store={chartStore}
  type="scatter"
  x="temperature"
  y="sales"
  color="region"
  size={5}
  enableZoom={true}
/>
```

**Accessories**:
- `x`: X-axis position
- `y`: Y-axis position
- `color`: Point color (optional)
- `size`: Point size (optional)

### Line Chart

**Purpose**: Show trends over time or continuous data.

**Best for**: Time series, trends, comparisons.

```typescript
<Chart
  store={chartStore}
  type="line"
  x="date"
  y="price"
  color="ticker"
  enableZoom={true}
/>
```

**Notes**:
- Data should be sorted by X for proper rendering
- Multiple series via `color` accessor
- Supports missing data (gaps in line)

### Bar Chart

**Purpose**: Compare categorical data with rectangular bars.

**Best for**: Category comparisons, rankings, distributions.

```typescript
<Chart
  store={chartStore}
  type="bar"
  x="category"
  y="count"
  color="segment"
  enableTooltip={true}
/>
```

**Variants**:
- Vertical bars (default)
- Grouped bars (via `color`)
- Stacked bars (via config)

### Area Chart

**Purpose**: Line chart with filled area below.

**Best for**: Cumulative data, part-to-whole relationships.

```typescript
<Chart
  store={chartStore}
  type="area"
  x="date"
  y="value"
  color="category"
  enableZoom={true}
/>
```

**Notes**:
- Multiple series stack by default
- Baseline at Y=0 unless configured

### Histogram

**Purpose**: Distribution of numerical data into bins.

**Best for**: Data distributions, frequency analysis.

```typescript
<Chart
  store={chartStore}
  type="histogram"
  x="value"
  enableTooltip={true}
/>
```

**Notes**:
- Automatically bins data
- Y-axis shows frequency count
- Customize bins via state actions

---

## STATE MANAGEMENT

### ChartState Interface

```typescript
interface ChartState<T = unknown> {
  // Data
  data: T[];                    // Original data
  filteredData: T[];             // After filters applied

  // Visualization config
  spec: PlotSpec;                // Observable Plot spec
  dimensions: {
    width: number;
    height: number;
  };

  // Selection
  selection: {
    type: 'none' | 'point' | 'range' | 'brush';
    selectedData: T[];
    selectedIndices: number[];
    brushExtent?: [[number, number], [number, number]];
    range?: [number, number];
  };

  // Zoom/pan
  transform: {
    x: number;
    y: number;
    k: number;  // scale factor
  };
  targetTransform?: ZoomTransform;  // For animated zoom

  // Animation
  isAnimating: boolean;
  transitionDuration: number;
}
```

### ChartAction Types

```typescript
type ChartAction<T = unknown> =
  // Data
  | { type: 'setData'; data: T[] }
  | { type: 'filterData'; predicate: (d: T) => boolean }
  | { type: 'clearFilters' }

  // Selection
  | { type: 'selectPoint'; data: T; index: number }
  | { type: 'selectRange'; range: [number, number] }
  | { type: 'brushStart'; position: [number, number] }
  | { type: 'brushMove'; extent: [[number, number], [number, number]] }
  | { type: 'brushEnd' }
  | { type: 'clearSelection' }

  // Zoom/pan
  | { type: 'zoom'; transform: ZoomTransform }
  | { type: 'zoomAnimated'; targetTransform: ZoomTransform }
  | { type: 'zoomProgress'; transform: ZoomTransform }
  | { type: 'zoomComplete' }
  | { type: 'resetZoom' }

  // Dimensions
  | { type: 'resize'; dimensions: { width: number; height: number } }

  // Config
  | { type: 'updateSpec'; spec: Partial<PlotSpec> };
```

### Creating Initial State

```typescript
import { createInitialChartState } from '@composable-svelte/charts';

const initialState = createInitialChartState({
  data: myData,
  dimensions: { width: 800, height: 400 },
  transitionDuration: 300
});
```

---

## INTERACTIVE FEATURES

### Zoom & Pan

**Enable**: `enableZoom={true}`

**Controls**:
- Mouse wheel: Zoom in/out
- Click + drag: Pan
- Double-click: Reset zoom

**Programmatic zoom**:
```typescript
// Zoom in
chartStore.dispatch({
  type: 'zoom',
  transform: { x: 0, y: 0, k: 2 }  // 2x zoom
});

// Reset zoom
chartStore.dispatch({ type: 'resetZoom' });

// Animated zoom
chartStore.dispatch({
  type: 'zoomAnimated',
  targetTransform: { x: 100, y: 50, k: 1.5 }
});
```

### Brush Selection

**Enable**: `enableBrush={true}`

**Controls**:
- Click + drag: Create brush
- Drag corners: Resize brush
- Drag center: Move brush
- Click outside: Clear brush

**Access selected data**:
```typescript
const selected = $chartStore.selection.selectedData;
console.log('Selected points:', selected);
```

**Callback**:
```typescript
<Chart
  store={chartStore}
  enableBrush={true}
  onSelectionChange={(selected) => {
    console.log('Selected:', selected);
    // Do something with selected data
  }}
/>
```

### Tooltips

**Enable**: `enableTooltip={true}` (default)

**Behavior**:
- Hover over data points to show tooltip
- Automatically displays data values
- Tooltip content customizable via Observable Plot

**Custom tooltips**:
```typescript
// Via Plot spec
const spec = {
  marks: [
    Plot.dot(data, {
      x: 'x',
      y: 'y',
      title: (d) => `${d.name}: ${d.value}` // Custom tooltip
    })
  ]
};
```

### Point Selection

**Enable**: Click on points when `enableBrush={false}`

```typescript
// Listen for point selection
$effect(() => {
  if ($chartStore.selection.type === 'point') {
    const selected = $chartStore.selection.selectedData[0];
    console.log('Selected point:', selected);
  }
});

// Programmatic selection
chartStore.dispatch({
  type: 'selectPoint',
  data: myDataPoint,
  index: 5
});

// Clear selection
chartStore.dispatch({ type: 'clearSelection' });
```

---

## DATA BINDING

### Static Data

```typescript
const data = [
  { x: 1, y: 10 },
  { x: 2, y: 20 },
  { x: 3, y: 15 }
];

const chartStore = createStore({
  initialState: createInitialChartState({ data }),
  reducer: chartReducer,
  dependencies: {}
});
```

### Dynamic Data Updates

```typescript
// Update data
chartStore.dispatch({
  type: 'setData',
  data: newData
});

// Filter data
chartStore.dispatch({
  type: 'filterData',
  predicate: (d) => d.value > 10
});

// Clear filters
chartStore.dispatch({ type: 'clearFilters' });
```

### Real-time Data

```typescript
// Append new point
const currentData = $chartStore.data;
chartStore.dispatch({
  type: 'setData',
  data: [...currentData, newPoint]
});

// Update via Effect
Effect.run(async (dispatch) => {
  const newData = await fetchLatestData();
  dispatch({ type: 'setData', data: newData });
});
```

---

## RESPONSIVE DESIGN

### Auto-sizing

Omit `width` and `height` for responsive sizing:

```typescript
<Chart
  store={chartStore}
  type="scatter"
  x="x"
  y="y"
/>
```

Chart will:
- Use container width (100%)
- Default height (400px)
- Resize on window resize

### Fixed Dimensions

```typescript
<Chart
  store={chartStore}
  type="scatter"
  x="x"
  y="y"
  width={800}
  height={600}
/>
```

### Container-based Sizing

```svelte
<div class="chart-container">
  <Chart store={chartStore} ... />
</div>

<style>
  .chart-container {
    width: 100%;
    height: 500px;
  }
</style>
```

### Responsive Breakpoints

```typescript
let chartWidth = $state(800);

$effect(() => {
  const updateWidth = () => {
    chartWidth = window.innerWidth < 768 ? 400 : 800;
  };

  window.addEventListener('resize', updateWidth);
  updateWidth();

  return () => window.removeEventListener('resize', updateWidth);
});

<Chart store={chartStore} width={chartWidth} ... />
```

---

## ACCESSIBILITY

### ARIA Labels

Chart component includes:
- `role="img"` - Marks as image
- `aria-label` - Describes chart content
- `aria-describedby` - Links to summary

```svelte
<Chart
  store={chartStore}
  type="scatter"
  x="x"
  y="y"
  aria-label="Scatter plot showing relationship between X and Y"
/>
```

### Screen Reader Summary

Auto-generated summary includes:
- Chart type
- Number of data points
- Selection status
- Filter status

**Example output**:
```
"Scatter plot showing 42 data points, 5 selected"
```

### Keyboard Navigation

- `Tab`: Focus chart
- `Arrow keys`: Pan (when zoomed)
- `+/-`: Zoom in/out
- `0`: Reset zoom
- `Escape`: Clear selection

---

## COMPLETE EXAMPLES

### Basic Scatter Plot

```typescript
<script lang="ts">
import { createStore } from '@composable-svelte/core';
import { Chart, chartReducer, createInitialChartState } from '@composable-svelte/charts';

const data = [
  { x: 10, y: 20, category: 'A' },
  { x: 15, y: 35, category: 'B' },
  { x: 20, y: 25, category: 'A' },
  { x: 25, y: 45, category: 'B' }
];

const chartStore = createStore({
  initialState: createInitialChartState({ data }),
  reducer: chartReducer,
  dependencies: {}
});
</script>

<Chart
  store={chartStore}
  type="scatter"
  x="x"
  y="y"
  color="category"
  size={6}
  width={800}
  height={400}
  enableZoom={true}
  enableTooltip={true}
/>
```

### Time Series Line Chart

```typescript
<script lang="ts">
import { createStore } from '@composable-svelte/core';
import { Chart, chartReducer, createInitialChartState } from '@composable-svelte/charts';

interface DataPoint {
  date: Date;
  value: number;
  series: string;
}

const data: DataPoint[] = [
  { date: new Date('2024-01-01'), value: 100, series: 'A' },
  { date: new Date('2024-01-02'), value: 120, series: 'A' },
  { date: new Date('2024-01-03'), value: 115, series: 'A' },
  { date: new Date('2024-01-01'), value: 80, series: 'B' },
  { date: new Date('2024-01-02'), value: 95, series: 'B' },
  { date: new Date('2024-01-03'), value: 105, series: 'B' }
];

const chartStore = createStore({
  initialState: createInitialChartState({ data }),
  reducer: chartReducer,
  dependencies: {}
});
</script>

<Chart
  store={chartStore}
  type="line"
  x="date"
  y="value"
  color="series"
  width={1000}
  height={400}
  enableZoom={true}
  enableTooltip={true}
/>
```

### Interactive Bar Chart

```typescript
<script lang="ts">
import { createStore } from '@composable-svelte/core';
import { Chart, chartReducer, createInitialChartState } from '@composable-svelte/charts';

const data = [
  { category: 'Q1', revenue: 45000, expenses: 32000 },
  { category: 'Q2', revenue: 52000, expenses: 38000 },
  { category: 'Q3', revenue: 48000, expenses: 35000 },
  { category: 'Q4', revenue: 61000, expenses: 42000 }
];

const chartStore = createStore({
  initialState: createInitialChartState({ data }),
  reducer: chartReducer,
  dependencies: {}
});

let selectedCategory = $state<string | null>(null);

function handleSelection(selected: any[]) {
  selectedCategory = selected[0]?.category || null;
}
</script>

<div>
  <Chart
    store={chartStore}
    type="bar"
    x="category"
    y="revenue"
    enableBrush={true}
    enableTooltip={true}
    onSelectionChange={handleSelection}
  />

  {#if selectedCategory}
    <p>Selected: {selectedCategory}</p>
  {/if}
</div>
```

### Real-time Data Visualization

```typescript
<script lang="ts">
import { createStore, Effect } from '@composable-svelte/core';
import { Chart, chartReducer, createInitialChartState } from '@composable-svelte/charts';
import { onMount } from 'svelte';

let data = $state<Array<{ time: number; value: number }>>([]);

const chartStore = createStore({
  initialState: createInitialChartState({ data }),
  reducer: chartReducer,
  dependencies: {}
});

// Simulate real-time data stream
let intervalId: number;

onMount(() => {
  let time = 0;

  intervalId = setInterval(() => {
    const newPoint = {
      time: time++,
      value: Math.random() * 100
    };

    data = [...data.slice(-50), newPoint]; // Keep last 50 points

    chartStore.dispatch({
      type: 'setData',
      data
    });
  }, 100);

  return () => clearInterval(intervalId);
});
</script>

<Chart
  store={chartStore}
  type="line"
  x="time"
  y="value"
  yDomain={[0, 100]}
  enableAnimations={true}
/>
```

---

## COMMON PATTERNS

### Multiple Charts with Shared Selection

```typescript
<script lang="ts">
const data = [...]; // Shared data

const chartStore1 = createStore({...});
const chartStore2 = createStore({...});

let selectedData = $state<any[]>([]);

function syncSelection(selected: any[]) {
  selectedData = selected;

  // Update both charts
  const indices = selected.map(d => data.indexOf(d));
  chartStore1.dispatch({ type: 'selectRange', range: [indices[0], indices[indices.length - 1]] });
  chartStore2.dispatch({ type: 'selectRange', range: [indices[0], indices[indices.length - 1]] });
}
</script>

<Chart store={chartStore1} ... onSelectionChange={syncSelection} />
<Chart store={chartStore2} ... onSelectionChange={syncSelection} />
```

### Linked Zoom

```typescript
<script lang="ts">
const masterStore = createStore({...});
const detailStore = createStore({...});

$effect(() => {
  const transform = $masterStore.transform;
  detailStore.dispatch({ type: 'zoom', transform });
});
</script>

<Chart store={masterStore} enableZoom={true} />
<Chart store={detailStore} /> <!-- Zooms with master -->
```

### Dynamic Filtering

```typescript
<script lang="ts">
let minValue = $state(0);
let maxValue = $state(100);

$effect(() => {
  chartStore.dispatch({
    type: 'filterData',
    predicate: (d) => d.value >= minValue && d.value <= maxValue
  });
});
</script>

<input type="range" bind:value={minValue} min="0" max="100" />
<input type="range" bind:value={maxValue} min="0" max="100" />
<Chart store={chartStore} ... />
```

---

## PERFORMANCE CONSIDERATIONS

### Large Datasets

**Problem**: Rendering 10,000+ points can be slow.

**Solutions**:
1. **Data aggregation**: Bin/group data before rendering
2. **Sampling**: Show subset of data (e.g., every 10th point)
3. **Level-of-detail**: Show more detail when zoomed in
4. **WebGL rendering**: Use Plot's WebGL marks (future)

```typescript
// Example: Downsample data
const downsample = (data: any[], factor: number) =>
  data.filter((_, i) => i % factor === 0);

const displayData = data.length > 1000
  ? downsample(data, Math.ceil(data.length / 1000))
  : data;

chartStore.dispatch({ type: 'setData', data: displayData });
```

### Frequent Updates

**Problem**: Real-time data updates cause re-renders.

**Solutions**:
1. **Batch updates**: Update every N milliseconds, not every data point
2. **Sliding window**: Keep fixed number of points (e.g., last 100)
3. **Throttle**: Limit update frequency

```typescript
// Throttle updates
let pendingData: any[] = [];
let updateTimer: number | null = null;

function queueUpdate(newData: any[]) {
  pendingData = newData;

  if (updateTimer === null) {
    updateTimer = setTimeout(() => {
      chartStore.dispatch({ type: 'setData', data: pendingData });
      updateTimer = null;
    }, 100); // Update max once per 100ms
  }
}
```

### Animation Performance

Disable animations for large datasets or frequent updates:

```typescript
<Chart
  store={chartStore}
  enableAnimations={false}
  ...
/>
```

---

## TESTING

### Basic Chart Testing

```typescript
import { TestStore } from '@composable-svelte/core';
import { chartReducer, createInitialChartState } from '@composable-svelte/charts';

const store = new TestStore({
  initialState: createInitialChartState({ data: [] }),
  reducer: chartReducer,
  dependencies: {}
});

// Test data update
await store.send({
  type: 'setData',
  data: [{ x: 1, y: 10 }]
}, (state) => {
  expect(state.data.length).toBe(1);
  expect(state.filteredData.length).toBe(1);
});

// Test filtering
await store.send({
  type: 'filterData',
  predicate: (d) => d.y > 5
}, (state) => {
  expect(state.filteredData.length).toBe(1);
});
```

### Selection Testing

```typescript
await store.send({
  type: 'selectPoint',
  data: { x: 1, y: 10 },
  index: 0
}, (state) => {
  expect(state.selection.type).toBe('point');
  expect(state.selection.selectedData.length).toBe(1);
  expect(state.selection.selectedIndices).toEqual([0]);
});

await store.send({ type: 'clearSelection' }, (state) => {
  expect(state.selection.type).toBe('none');
  expect(state.selection.selectedData.length).toBe(0);
});
```

---

## TROUBLESHOOTING

**Chart not rendering**:
- Check Observable Plot installed: `npm install @observablehq/plot`
- Verify data is non-empty array
- Ensure x/y accessors match data properties

**Tooltips not showing**:
- Verify `enableTooltip={true}`
- Check Observable Plot version (0.6+ required)
- Ensure data points have valid values (not null/undefined)

**Zoom not working**:
- Verify `enableZoom={true}`
- Check chart has fixed dimensions (not 100% width/height)
- Ensure D3-zoom is installed

**Poor performance**:
- Reduce data points (aggregate, sample, or downsample)
- Disable animations for large datasets
- Use simpler mark types (dots vs complex shapes)

**Selection not updating**:
- Check `onSelectionChange` callback
- Verify `enableBrush={true}` or `enableSelection={true}`
- Ensure store is reactive (`$chartStore.selection`)

---

## CROSS-REFERENCES

**Related Skills**:
- **composable-svelte-core**: Store, reducer, Effect system
- **composable-svelte-components**: UI components (Button, Slider, etc.)
- **composable-svelte-testing**: TestStore for testing chart reducers

**When to Use Each Package**:
- **charts**: 2D data visualization, interactive charts
- **graphics**: 3D scenes, WebGPU/WebGL (see composable-svelte-graphics)
- **maps**: Geospatial data (see composable-svelte-maps)
- **code**: Code editors, media players (see composable-svelte-code)

Overview

This skill provides state-driven, interactive chart components for Composable Svelte built with Observable Plot and D3. It delivers scatter, line, bar, area, and histogram charts with built-in interactions (zoom, brush, tooltips), responsive sizing, and type-safe state management. Use it to embed production-ready visualizations that update from application state and streams.

How this skill works

Charts are rendered via a high-level Chart component that consumes a Chart store created with createInitialChartState and chartReducer. The component builds an Observable Plot spec and uses D3 utilities for scales and interactions; state changes (data, zoom, selection, dimensions) are dispatched as typed ChartAction values. Interactive features (zoom, brush, tooltips) update the store so UI and logic stay in sync.

When to use it

  • You need declarative, interactive charts in a Svelte app.
  • Charts must respond to application state or real-time data streams.
  • You require accessible, keyboard-navigable visualizations.
  • You want composable chart types with consistent APIs (scatter, line, bar, area, histogram).
  • You need programmatic control over zoom, selection, or filtering.

Best practices

  • Provide explicit x and y accessors or functions to avoid ambiguous parsing.
  • Sort time-series data before rendering line/area charts for correct paths.
  • Prefer responsive layout (omit width/height) for container-driven sizing and mobile support.
  • Use enableBrush for multi-point selection and onSelectionChange to lift selections to parent logic.
  • Customize tooltips via the Plot spec for meaningful, accessible hover content.

Example use cases

  • Interactive scatter to explore correlations with zoom and tooltips in an analytics dashboard.
  • Time series line chart with pan/zoom and multiple series colored by a key.
  • Bar chart for categorical comparisons with brush selection to filter other UI components.
  • Area chart showing stacked cumulative values with responsive resizing for reports.
  • Histogram to inspect distribution and adjust binning via state actions in an ETL tool.

FAQ

How do I update chart data in real time?

Dispatch a setData action with the new array (or append to existing data and dispatch setData). Effects can fetch and dispatch updates to keep the chart reactive.

Can I programmatically zoom or reset the view?

Yes. Dispatch zoom, zoomAnimated, or resetZoom actions with the desired transform to control zoom/pan from code.