> For the complete documentation index, see [llms.txt](https://flarecharts.gitbook.io/docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://flarecharts.gitbook.io/docs/contributing/architecture.md).

# Architecture

Composable SVG chart components for Svelte 5 — runes-first, styled-mode-only, d3-for-math-never-DOM. This document explains the load-bearing decisions. It's written for contributors and anyone extending the library; if you only want to *use* FlareCharts, the rest of these docs are for you.

## The reactive context pattern (the foundation)

Svelte's `setContext`/`getContext` runs once at component init and is **not** reactive by itself. flarechart makes context reactive the canonical Svelte 5 way: set the context **once** to a **class instance whose fields are runes**. The instance never changes; the values inside it do.

```ts
// core/context.svelte.ts (abridged)
export class ChartContext {
	width = $state(0); // ← bound to the container
	height = $state(0);

	readonly #options: () => ChartOptions; // ← reactive closure over <Chart> props
	readonly #registry = new SvelteMap<symbol, () => NormalizedPoint[]>();

	constructor(options: () => ChartOptions) {
		this.#options = options;
	}

	padding = $derived.by(() => ({ ...DEFAULT_PADDING, ...this.#options().padding }));
	innerWidth = $derived(Math.max(0, this.width - this.padding.left - this.padding.right));
	seriesEntries = $derived.by(() => /* registry getters + visibility + resolved colors */);
	xScale = $derived.by(() => createScale({ kind: this.xType, domain: this.xDomain, ... }));
	// ...
}
```

```sveltehtml
<!-- components/Chart.svelte (abridged) -->
<script lang="ts">
	let { x, y, padding, children } = $props();
	const ctx = setChartContext(new ChartContext(() => ({ x, y, padding })));
</script>

<div role="img" aria-label={label} bind:clientWidth={ctx.width} bind:clientHeight={ctx.height}>
	{#if ctx.width > 0 && ctx.height > 0}{@render children?.()}{/if}
</div>
```

Three mechanisms make this work:

1. **`$state` for inputs from the DOM.** `width`/`height` are plain `$state` fields; `<Chart>` binds `clientWidth`/`clientHeight` straight onto the instance. Resize → state write → every `$derived` downstream recomputes.
2. **A closure for inputs from props.** The constructor receives `() => ChartOptions`, not a snapshot. Every `$derived` that calls `this.#options()` re-runs when `<Chart>`'s props change. The context entry itself is never replaced — children read fresh values through stable refs.
3. **`SvelteMap` for inputs from children** (the series registry, below).

Children call `getChartContext()` once and read `ctx.xScale`, `ctx.innerWidth`, `ctx.xPos(v)` in their templates. Reactivity is exactly as fine-grained as the fields they touch.

## Series registration

Scales need domains; domains need data; data lives in mark components (`<Line>`, `<Area>`, `<Points>`), not in `<Chart>`. So marks register themselves:

```ts
const points = $derived(normalizePoints(data, { x, y }));
const registration = ctx.registerSeries(() => ({ points, name, color: resolvedColor }));
onDestroy(registration.unregister);
```

The registry stores **getters** in a `SvelteMap`. `ctx.seriesEntries` derives from the map's values (points + name + resolved color + visibility); `xDomain`/`yDomain` derive from the points of **visible** entries; scales derive from domains. Add a series, change its data, toggle or destroy it — domains and scales re-derive automatically. Axis options (`min`/`max`/`categories`) pin domains and short-circuit the data scan.

`registerSeries` also hands out a stable, insertion-ordered **series index** that drives the default palette slot (`--fc-series-1..12`) and identifies the series for visibility toggling (`ctx.hiddenSeries`, a `SvelteSet`). Marks accept an `index` prop to override the palette slot (e.g. `<Points index={0}>` matching its `<Line>`). `<Legend>` is nothing but a view over `ctx.seriesEntries` plus `ctx.toggleSeries(index)` — hiding a series removes its points from domain derivation, so the chart rescales unless the axis domain is pinned.

## Bars: grouping and stacking

Bar layout is a cross-series concern, so it lives in the context, derived from the same registry (`RegisteredSeries.bar` metadata):

* **Slots** (`ctx.barSlots`): every visible unstacked bar series gets its own side-by-side slot within the band; every stack group shares one slot. Toggling a series via the legend frees its slot and the group reflows.
* **Stacks** (`ctx.stackFor(index)`): per stack group, `stackSeries` (core/stack.ts — d3.stack-backed, pure, tested) computes `[y0, y1]` extents in VALUE space, keyed by `stackKey(x)` (Date-safe). The `offset` chooses the layout: `normal` is diverging (positives accumulate up from zero, negatives down, independently); `percent` normalizes positives against the positive total and negatives against the negative total per x; `stream` (wiggle) and `silhouette` shift the whole stack off the baseline so it floats. Gap points skip without leaving holes.
* **Domain**: `yDomain` consumes stack extents instead of raw y for stacked series, and any visible bar series forces zero into the domain (bars anchor at zero). Components never re-derive stacking math — they read the context's extents and map value space to pixels.

## Interaction (tooltip, crosshair)

`<Chart>` owns ONE pointer listener and writes plot-area coordinates to `ctx.pointer` (`$state`, null when outside the plot). Everything else derives:

* Hit-testing is pure math in `core/hit.ts` + `core/bisect.ts` (no DOM, no Svelte, fully unit-tested): `hitBisectX` (binary search on x pixels), `hitNearest` (euclidean), `hitsAtBand` + `scales.bandInvert` (category columns). Gap points never match.
* `<Tooltip>` derives `TooltipData` from `ctx.pointer` + `ctx.seriesEntries` per its `mode` (`bisect-x` | `nearest` | `band`) and `shared` flag, renders an HTML overlay (absolute, pointer-events: none), and lets a snippet replace the default content — `Tooltip<T>` types `point.datum` for the snippet.
* `<Crosshair>` derives its snapped anchor from the same hit utilities and renders SVG lines inside `<Svg>` (markup order = paint order, so consumers choose whether it sits under or over series).

Multiple tooltips/crosshairs coexist trivially — they're all just readers of `ctx.hoverPointer`.

## Accessibility

The chart container is a `role="group"` (NOT `role="img"` — it contains interactive children: legend buttons, the keyboard-nav button). The SVG is `aria-hidden`; semantics live in three sr-only structures inside `<Chart>`:

1. a description paragraph,
2. a focusable nav button + `aria-live` announcer — arrow keys call `ctx.moveFocusBy()`, whose math is `moveFocus` in `core/keynav.ts` (pure, tested: gap skipping, end clamping, nearest-point snapping across series). The focused point becomes `ctx.keyboardPointer`, and `ctx.hoverPointer = pointer ?? keyboardPointer` means tooltip + crosshair follow keyboard focus with zero extra wiring,
3. a data table built from the registry (one column per visible series, one row per unique x), opt-out via `dataTable={false}`.

Series register `description` (announced on series entry) and `describePoint` (per-point announcement override). Reduced motion: the only transition in the library (arc hover opacity) is wrapped in a `prefers-reduced-motion` guard; everything else renders statically.

## Two API layers

* **L1 primitives** (`<Chart>` `<Svg>` `<Axis>` `<Grid>` `<Line>` …) compose in markup; paint order is markup order. Anything inside `<Svg>` renders in the padded plot-area coordinate system (origin at the plot's top-left).
* **L2 simple charts** (`src/lib/charts/`) are built ONLY from L1: `<LineChart>` `<AreaChart>` `<BarChart>` `<StackChart>` `<DonutChart>` `<Sparkline>`. Each is a thin wrapper that (1) measures its container, (2) applies responsive rules, (3) resolves series options through the precedence chain, and (4) renders plain L1 markup. L2 components never reach into context internals; if an L2 chart needs something, an L1 primitive grows it first. Both layers share the same data normalizer, so ejecting from L2 to L1 never means rewriting data shapes — the demo renders the same chart both ways, pixel-identical.

> In the user-facing docs, L1 is called **Primitives** and L2 is called **Charts**. The L1/L2 shorthand is kept here and in code comments.

## Options precedence + responsive (L2)

`resolveSeries` (core/options.ts) implements the chain end to end:

```
SERIES_DEFAULTS  <  chart plotOptions  <  per-series options  <  per-point
```

The first three layers go through `mergeOptions`; the per-point layer is the `colorFor(datum, index)` accessor, applied by marks at render time (`colorFor(d) ?? series color ?? palette`). `splitAxisOptions` separates what `<Chart>` needs (scale: type/min/max/categories/nice) from what `<Axis>` needs (display: title/ticks/format/rotate).

Responsive rules (`core/responsive.ts`) reuse the same merge: each `{ minWidth?, maxWidth?, options }` rule whose bounds match the measured container width is merged over the base props, later rules winning. Width comes from a `bind:clientWidth` on the L2 wrapper — container-driven, never window-driven, SSR-safe (width 0 applies nothing).

## Radial marks

`<Arc>` (pie/donut) lives inside `<Svg>` like any mark but ignores the x/y scales entirely — its geometry derives from `innerWidth`/`innerHeight` only (d3-shape `pie`/`arc` for the math, as ever DOM-free). It does not register in the series registry: slices are points, not series, so cartesian domains and the legend stay untouched. Slice colors fall back to the same palette slots; the `center` snippet renders consumer SVG at the donut's center.

## Data normalization (one normalizer, everywhere)

`normalizePoints` (core/normalize.ts) accepts `number[]`, `[x,y][]`, `{x,y,...}[]`, or `T[]` + accessor functions, and yields `{ x, y, datum, index }`. Rules:

* `null`/`NaN`/`±Infinity` y → `null` → a **gap** (d3-shape `defined()`), never zero.
* The original `datum` rides along untouched — it's what typed snippets (tooltips, labels) will receive.
* x type drives scale inference: `Date` → time, `string` → band, else linear.

## Scales

`createScale` (core/scales.ts) is the only place d3-scale is touched: linear, time, and band, with `nice` (bool or tick-count hint) and band padding. `scalePos` centers values inside bands so lines/points sit on category centers. Unmappable values map to `NaN` and are filtered by `defined()`/render guards — bad data degrades to gaps, not exceptions, at render time (the normalizer is where loud errors live).

## Styled mode (the only mode)

Every visual attribute resolves from a CSS custom property with a built-in fallback — `stroke: var(--fc-axis, #94a3b8)`. The fallback literals are the only literal colors in the library. Consumers theme by setting variables on any wrapper; dark mode is the consumer flipping variables, and the library deliberately does nothing. Per-series `color` props are passed through as raw CSS strings — `var(--anything)` included, never parsed or converted.

Every element also carries a stable class (`fc-axis`, `fc-grid`, `fc-series`, `fc-line`, `fc-point`, `fc-plot-band`, …) for plain-CSS/Tailwind restyling. The full variable + class surface is tabled in the [Theming](/docs/theming/theming.md) page.

## Options precedence

`mergeOptions(defaults, plotOptions, seriesOptions, pointOptions)` (core/merge.ts) — later layers win; plain objects merge deep; arrays/Dates/ functions replace; `undefined` never overwrites; `null` explicitly clears. This one utility implements the options-precedence chain and backs the L2 charts and responsive breakpoint rules.

## SSR

No `window`/`document` at module scope anywhere. Dimensions come from `bind:clientWidth/clientHeight` (no-ops on the server), so SSR emits the accessible container (`role="img"` + `aria-label`) and the plot renders on hydration once real dimensions exist.

## Testing

All math is plain TypeScript, tested with Vitest without a DOM: `normalize.test.ts`, `merge.test.ts`, `scales.test.ts`, `stack.test.ts`, `bisect.test.ts`, `hit.test.ts`, `keynav.test.ts`, and more. Components stay thin — they wire context to generators and render SVG; the logic they call is what's tested.

## File map

```
src/lib/
├── index.ts              # side-effect-free exports; tree-shakeable
├── core/
│   ├── context.svelte.ts # ChartContext class (the pattern above)
│   ├── normalize.ts      # data → NormalizedPoint[]
│   ├── merge.ts          # options precedence
│   ├── scales.ts         # createScale + scalePos/Ticks/TickFormat + bandInvert
│   ├── bisect.ts         # nearest-value binary search
│   ├── hit.ts            # tooltip/crosshair hit-testing + TooltipData types
│   ├── stack.ts          # d3.stack-backed stacking (normal/percent/stream/silhouette/none) + stackKey
│   ├── labels.ts         # greedy label collision avoidance
│   ├── options.ts        # L2 series options + precedence resolution
│   ├── responsive.ts     # container-width rules over the same merge
│   ├── keynav.ts         # keyboard point-navigation math
│   ├── curves.ts         # curve name → d3 CurveFactory
│   ├── palette.ts        # --fc-series-N var() strings + fallbacks
│   └── types.ts          # AxisOptions / ChartOptions / Padding
├── components/           # L1 primitives
└── charts/               # L2 simple charts (Sparkline, …) — pure L1 composition
```


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://flarecharts.gitbook.io/docs/contributing/architecture.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
