# chart

Composition-based SVG charting framework. A `<Chart>` root sets up dimensions and contexts; child components (axes, series, annotations, legend) consume context and render themselves in data space.

<iframe src="examples/chart/" class="example-frame" title="Chart example"></iframe>

```typescript
import {
    Chart,
    ChartAxis,
    ChartAxisTitle,
    ChartGridLines,
    ChartTickLabels,
    ChartLine,
    ChartArea,
    ChartBar,
    ChartLegend,
    ChartCrosshair,
    ChartTooltip,
    ChartBoxAnnotation,
    ChartVLineAnnotation,
    ChartHLineAnnotation,
    ChartCircleAnnotation,
    useChartProjectX,
    useChartProjectY,
    useChartAxisParams,
    useChartEvent,
    useChartSnap,
} from '@yaoc1996/js-tools/react-utils'
import type {
    ChartDataset,
    ChartAxisLayout,
    ChartEventMap,
    ChartEventType,
} from '@yaoc1996/js-tools/react-utils'
```

---

## Root

---

### `Chart({ data, layout?, children? })`

Sets up the chart viewport, observes its own resize, normalizes mouse events into data-space coordinates, and provides context to descendants. Renders a `<div>` wrapper with padding for margins and a full-bleed `<svg>` inside.

#### Props

| Name | Type | Default | Description |
|------|------|---------|-------------|
| `data` | `ChartDataset[]` | — | Series data. Each dataset is referenced by `name` by `<ChartLine>` / `<ChartBar>`. |
| `layout.margin` | `number` | `0` | Shorthand fallback for the four side-specific margins. |
| `layout.marginLeft` / `marginRight` / `marginTop` / `marginBottom` | `number` | inherits `margin` | Reserved padding around the SVG drawing area. |
| `layout.xAxis` | `Partial<ChartAxisLayout>` | `{ min: 0, max: 100, useLog: false }` | X-axis range and linear/log mode. |
| `layout.yAxis` | `Partial<ChartAxisLayout>` | `{ min: 0, max: 100, useLog: false }` | Y-axis range and linear/log mode. |
| `children` | `ReactNode` | — | Axes, series, annotations, legend. |

#### Example

```tsx
<Chart
    data={[{ name: 'a', x: [0, 1, 2], y: [3, 5, 4], color: 'red' }]}
    layout={{ marginLeft: 40, marginBottom: 30, xAxis: { min: 0, max: 2 }, yAxis: { min: 0, max: 10 } }}
>
    <ChartAxis dim="x" interval={1} tickSize={6}><ChartTickLabels /></ChartAxis>
    <ChartAxis dim="y" interval={2} tickSize={6}><ChartTickLabels /></ChartAxis>
    <ChartLine datasetName="a" />
</Chart>
```

#### Notes

- The SVG fades to `opacity: 0` (and is `aria-hidden`) until the first resize observation reports non-zero dimensions, to avoid a flash at (0, 0).
- Mouse events on the chart area are normalized: `clientX`/`clientY` are mapped back into data-space using the configured axis range and `useLog` flags, then dispatched through the internal `EventHandler` (subscribe via `useChartEvent`).

---

## Axes

---

### `ChartAxis({ dim, interval, tickSize, stroke?, strokeWidth?, className?, children? })`

Draws an axis line and tick marks, computes tick positions (linear or logarithmic), and provides them to descendants via context. Wrap `<ChartGridLines>`, `<ChartTickLabels>`, and `<ChartAxisTitle>` as children to render the connected pieces.

#### Props

| Name | Type | Default | Description |
|------|------|---------|-------------|
| `dim` | `"x" \| "y"` | — | Which dimension to draw. |
| `interval` | `number` | — | Spacing between linear ticks in data units. Ignored when `useLog` is set on the corresponding axis layout. |
| `tickSize` | `number` | — | Total tick length in pixels (extends `tickSize/2` on either side of the axis line). |
| `stroke`, `strokeWidth`, `className` | `string` / `number` / `string` | — | Applied to the axis path. |
| `children` | `ReactNode` | — | Typically `<ChartGridLines>`, `<ChartTickLabels>`, `<ChartAxisTitle>`. |

#### Notes

- In log mode the tick values are `min`, `10^0…10^ceil(log10(range))` that fall strictly inside `(min, max)`, then `max`.
- In linear mode the tick values are `min`, then `interval`-spaced values up to but excluding `max`, then `max`.

---

### `ChartGridLines({ stroke?, strokeWidth?, className? })`

Renders grid lines extending from the parent `<ChartAxis>` ticks across the chart area. Skips the first tick (at the axis line itself) to avoid overdraw.

---

### `ChartTickLabels({ overrideLabels?, className? })`

Renders the numeric tick value as `<text>` next to each tick. Use `overrideLabels` to format (e.g. dates, currency).

#### Props

| Name | Type | Default | Description |
|------|------|---------|-------------|
| `overrideLabels` | `(value: number, x: number, y: number) => ReactNode` | — | Returns a custom node for each tick. The default rendering is the raw numeric value. |
| `className` | `string` | — | Applied to every `<text>` element. |

---

### `ChartAxisTitle({ title?, className? })`

Renders an axis title positioned outside the chart area (below the x-axis or left of the y-axis). Uses an SVG `<defs>` / `<use>` pair so the same title element can be reused.

---

## Series

---

### `ChartLine({ datasetName, strokeWidth?, className? })`

Renders one dataset as a polyline. Stroke color comes from `dataset.color`. Renders nothing if the named dataset is missing or has fewer than 2 points.

---

### `ChartArea({ datasetName, baseline?, fill?, fillOpacity?, stroke?, strokeWidth?, className? })`

Renders one dataset as a filled area between its polyline and a baseline. The path closes from the last point back down to the baseline, across, and up to the first point.

#### Props

| Name | Type | Default | Description |
|------|------|---------|-------------|
| `datasetName` | `string` | — | Series to render. |
| `baseline` | `number` | `yAxis.min` | Data-space y value to close the area to. Defaults to the chart bottom so the fill looks right when `yAxis.min ≠ 0`. |
| `fill` | `string` | `dataset.color` | Area fill color. |
| `fillOpacity` | `number` | `0.3` | Fill alpha. |
| `stroke`, `strokeWidth`, `className` | — | — | Standard SVG `<path>` props. |

---

### `ChartBar({ datasetNames, gap?, strokeWidth?, className?, mode? })`

Renders one or more datasets as vertical bars at each x value. Default mode is grouped (datasets sit side-by-side within each x slot); `mode="stacked"` stacks them additively.

#### Props

| Name | Type | Default | Description |
|------|------|---------|-------------|
| `datasetNames` | `string[]` | — | Datasets to render, in stack/group order. Missing names are skipped. |
| `gap` | `number` | `0` | Horizontal gap between groups, in pixels. |
| `mode` | `"grouped" \| "stacked"` | `"grouped"` | Grouped: width per dataset = `(slot - gap) / count`. Stacked: full slot width per bar, each dataset's bar baseline = top of preceding dataset. |

#### Notes

- Assumes datasets share the same x values. Sparse or unaligned x arrays produce uneven group widths.
- In stacked mode, each dataset's y value is interpreted as the **delta above the previous dataset's stack top** (i.e. its own height), not an absolute coordinate. Two stacked datasets with y=2 and y=3 produce a total stack reaching `yAxis.min + 5`.
- Negative values in stacked mode are not supported and may render below the baseline.

---

## Legend

---

### `ChartLegend({ lineHeight, padding, xAnchor?, yAnchor?, fill?, stroke?, strokeWidth? })`

Renders a boxed legend with one row per dataset (color swatch + dataset name). Positioned via `<ChartBoxAnnotation>` anchored to the top-right corner of the data range by default.

#### Props

| Name | Type | Default | Description |
|------|------|---------|-------------|
| `lineHeight` | `number` | — | Vertical pitch between legend rows in pixels. |
| `padding` | `number` | — | Inner padding around the box content. |
| `xAnchor` / `yAnchor` | `number` | `0.7` / `0.2` | Box anchor in (0..1) of its own size relative to its data-space coordinate. |
| `fill`, `stroke`, `strokeWidth` | `string` / `string` / `number` | `"white"`, `"black"`, `1` | Box styling. |

---

## Interactivity

---

### `ChartCrosshair({ anchorDataset?, stroke?, strokeWidth?, dotRadius?, className? })`

Renders a vertical SVG line at the snapped x and one circle per series at that x. Snapping is driven by `useChartSnap` (data-space nearest-x against the anchor dataset). Renders nothing before the first `mousemove` and clears on `mouseleave`.

#### Props

| Name | Type | Default | Description |
|------|------|---------|-------------|
| `anchorDataset` | `string` | first dataset in `data` | Series whose x values define snap positions. |
| `stroke` | `string` | `"black"` | Line + dot stroke. |
| `strokeWidth` | `number` | `1` | Line + dot stroke width. |
| `dotRadius` | `number` | `4` | Per-series dot radius. |
| `className` | `string` | — | Applied to the crosshair line. |

---

### `ChartTooltip({ anchorDataset?, offsetX?, offsetY?, renderRow?, className?, style? })`

Renders an HTML overlay (positioned-absolute inside the chart's outer `<div>`, via `createPortal`) showing the snapped x and each series' y at that x. Disappears on `mouseleave`. Use `renderRow` to fully customize per-series row markup.

#### Props

| Name | Type | Default | Description |
|------|------|---------|-------------|
| `anchorDataset` | `string` | first dataset in `data` | Series whose x values define snap positions. |
| `offsetX` / `offsetY` | `number` | `12` / `0` | Pixel offset from the snapped point so the box doesn't sit under the cursor. |
| `renderRow` | `(name, x, y, color?) => ReactNode` | color swatch + `name: y` | Renders one row per series. |
| `className`, `style` | — | — | Applied to the outer tooltip `<div>`. |

#### Notes

- Tooltip is portaled into the chart's outer container `<div>`, **not** rendered inside the SVG. This avoids the known event-bubbling and layout problems of `<foreignObject>` in Safari/Firefox.
- Place `<ChartTooltip>` after the series components so the snapped y values are up to date when the row renders.

---

## Annotations

---

### `ChartBoxAnnotation({ x, y, padding, xAnchor, yAnchor, fill?, stroke?, strokeWidth?, className?, children? })`

A positioned, auto-sized SVG group anchored at a data-space `(x, y)`. Measures its children via `ResizeObserver` and renders a backing `<rect>` around them. Used internally by `<ChartLegend>` and exposed for custom overlays.

#### Notes

- The annotation is invisible (`opacity: 0`, `aria-hidden`) until the first size measurement returns.

---

### `ChartVLineAnnotation({ x, stroke?, strokeWidth?, className? })`

Draws a vertical line at data-space `x`, from `yAxis.min` to `yAxis.max`.

---

### `ChartHLineAnnotation({ y, stroke?, strokeWidth?, className? })`

Draws a horizontal line at data-space `y`, from `xAxis.min` to `xAxis.max`.

---

### `ChartCircleAnnotation({ cx, cy, r, fill?, stroke?, strokeWidth?, className? })`

Draws a circle at data-space center `(cx, cy)` with pixel radius `r`.

---

## Hooks

---

### `useChartProjectX() → (x: number) => number`

Returns a memoized projection from data-space x to pixel space, accounting for the configured x-axis range and `useLog` flag. Returns `width * 0.5` when `min === max` (degenerate range).

---

### `useChartProjectY() → (y: number) => number`

Same as `useChartProjectX`, for the y-axis. The projection inverts the axis so larger data values map to smaller pixel `y` (top of the SVG).

---

### `useChartAxisParams(dim: "x" | "y") → ChartAxisLayout`

Returns the current `{ min, max, useLog }` for the requested dimension.

---

### `useChartSnap(anchorDataset?) → ChartSnap | null`

Subscribes to chart `mousemove` and `mouseleave` events, finds the nearest x in the anchor dataset (defaults to the first dataset), and looks up each other dataset's y at that snapped x. Returns `null` before the first move and after `mouseleave`.

#### Returns

```typescript
type ChartSnap = {
    x: number
    perSeriesY: Record<string, number>
}
```

Used internally by `<ChartCrosshair>` and `<ChartTooltip>`; expose it directly if you need custom hover UI.

---

### `useChartEvent(type, listener)`

Subscribes to a chart mouse event. Coordinates carried by `mousemove` / `mousedown` are already projected into data space.

#### Parameters

| Name | Type | Description |
|------|------|-------------|
| `type` | `ChartEventType` | One of `"mousemove" \| "mousedown" \| "mouseup" \| "mouseleave"`. |
| `listener` | `EventListener<ChartEventMap, Type>` | Called with `{ x, y }` for move/down events; void for up/leave. |

#### Example

```tsx
function Cursor() {
    useChartEvent('mousemove', ({ x, y }) => {
        console.log('hovered data point', x, y)
    })
    return null
}
```

---

## Types

```typescript
type ChartDataset = {
    name: string
    x: number[]
    y: number[]
    color?: string
}

type ChartAxisLayout = {
    min: number
    max: number
    useLog: boolean
}

type ChartEventType = 'mousemove' | 'mousedown' | 'mouseup' | 'mouseleave'

type ChartEventMap = {
    mousemove: { x: number; y: number }
    mousedown: { x: number; y: number }
    mouseup: void
    mouseleave: void
}
```
