Density-aware chart indexing helpers for large numeric series.
The package adapts @moritzbrantner/data-density bins into chart-shaped samples,
renderer data, viewport summaries, and chart-specific React controls. It does
not own a primary chart renderer; Recharts, SVG, canvas, WebGL, or server-side
renderers can all consume the same sample contract.
Dense charts need more than point slicing. @moritzbrantner/charts builds a
queryable density index and returns chart-ready samples for the current viewport,
including summaries, percentiles, gap annotations, grouped series, distribution
views, and React controls for Recharts-backed interfaces.
bun add @moritzbrantner/charts react react-dom recharts
The package is published to public npm.
| Dependency | Supported range | Notes |
|---|---|---|
| React | ^19.0.0 |
Required for exported React controls. |
| React DOM | ^19.0.0 |
Required for examples and React control rendering. |
| Recharts | ^3.0.0 |
Used by the bundled chart components and examples. |
| TypeScript | Repository compiler version | Public types are checked from the generated package artifact. |
The package is pre-1.0. Public APIs may change, but intentional changes are
tracked through Changesets, changelog entries, and the committed API report.
Breaking changes should include migration notes.
This version intentionally cleans up the experimental public API:
ChartDensityValueMode is now ChartValueMode.ChartRangeSelector uses value and onValueChange instead of
activeRangeId and onRangeChange.ChartValueModeSelector uses value, onValueChange, and definitions
instead of valueMode, onValueModeChange, and modes.ChartValueModePreview receives a definition instead of a raw mode.import { createChartDensityIndex, createChartRenderData } from "@moritzbrantner/charts";
const index = createChartDensityIndex(points, { backend: "hybrid-js" });
const series = index.getChartSeries({
includeEmptyBins: true,
targetBinCount: 120,
valueMode: "average",
xDomain: [0, 1_440],
});
const rows = createChartRenderData(series.samples, {
modes: ["average", "count"],
xLabel: (sample) => `${Math.round(sample.x)}m`,
}).rows;
createChartDensityIndex adapts numeric points into a reusable
viewport-queryable index.index.getChartSeries(query) returns one ChartDensitySample per
visible bin with counts, y aggregates, percentiles, and first/last source
points.createChartRenderData converts samples into renderer-friendly
rows for Recharts, SVG, canvas, WebGL, or server-side payloads.createProgressiveChartDensityIndex renders immediately
through the hybrid JS backend and can warm the WASM index for later queries.createChartDensityWorkerIndex constructs a WASM density
index in a module worker and serves async query results off the main thread.layoutChartLabels and ChartLabelOverlay place annotations while
avoiding collisions with chart marks and other labels.createChartDensityIndex(points, options) / createChartSeriesIndex(points, options)createProgressiveChartDensityIndex(points, options)createChartDensityWorkerIndex(points, options, workerOptions)index.getChartSeries(query) / index.getBinnedSeries(query)createChartDensitySample(bin, valueMode) / createChartDensityViewportSummary(series)createChartRenderData(samples, options) / getChartGapAnnotations(samples)index.getHistogram(query) / index.getHeatmap(query) /
index.getGroupedChartSeries(query) / index.getChartPoints(query) /
index.getScatter(query)createGroupedChartRenderData(grouped, options)createChartBandRenderData(samples, options) / createChartBoxPlotData(samples, options)createChartWaterfallData(data, options) / createChartFunnelData(data)createChartTreemapLayout(root, options) / createChartSunburstLayout(root, options) /
createChartIcicleLayout(root, options) / createChartCirclePackLayout(root, options) /
createChartTreeLayout(root, options) / createChartFlameGraphLayout(root, options) /
createChartRadialTreeLayout(root, options) /
createChartIndentedTreeLayout(root, options)CHART_VALUE_MODE_DEFINITIONS, getChartValueModeDefinition(mode),
getChartValueModeDefinitions(modes)useProgressiveChartDensity(points, options) / useChartBinCount(options)BinnedChart, ChartMetricCard, ChartMetricStrip, ChartRangeSelector,
ChartValueModeSelectorChartBackendStatus, ChartSampleSparkline, ChartHotBinRow, ChartValueModePreviewChartScatterSvg, ChartWaterfallSvg, ChartFunnelSvg, ChartTreemapSvg,
ChartSunburstSvg, ChartIcicleSvg, ChartCirclePackSvg, ChartTreeSvg,
ChartFlameGraphSvg, ChartRadialTreeSvg, ChartIndentedTreeSvg,
ChartXAxisNavigationMenulayoutChartLabels, doChartLabelRectsIntersect, ChartLabelOverlayUse BinnedChart when a chart should share the same composition model for
styling, responsive binning, render rows, direct domain navigation, wheel-domain
changes, and a minimap. When onDomainChange is provided, users can drag the
main chart to pan, Shift-drag or Alt-drag to zoom to a selected range,
double-click to reset to fullDomain, scroll horizontally or Shift-scroll to
pan, and Ctrl-scroll or Meta-scroll to zoom around the pointer.
import { Line, LineChart } from "recharts";
import { BinnedChart } from "@moritzbrantner/charts";
export function TrendWithMinimap({ activeDomain, fullDomain, index, setActiveDomain }) {
return (
<BinnedChart
chartClassName="h-72 w-full"
config={{ average: { label: "Average", color: "var(--chart-1)" } }}
domain={activeDomain}
fullDomain={fullDomain}
index={index}
onDomainChange={setActiveDomain}
renderDataOptions={{ modes: ["average"] }}
valueMode="average"
>
{({ rows }) => (
<LineChart data={rows}>
<Line dataKey="average" dot={false} stroke="var(--color-average)" />
</LineChart>
)}
</BinnedChart>
);
}
Set drag={false} or dragOptions={{ disabled: true }} when a chart needs to
reserve pointer drags for custom overlays or renderer-specific interactions.
Use ChartWithLegend, ChartSeriesLegend, and useChartSeriesVisibility
when a chart needs side controls for hiding and showing individual series. The
visibility hook is renderer-agnostic: it tells the caller which ids are visible,
and the caller decides whether to render Recharts marks, SVG paths, canvas
layers, or another renderer.
import { Line, LineChart } from "recharts";
import {
ChartSeriesLegend,
ChartWithLegend,
useChartSeriesVisibility,
} from "@moritzbrantner/charts";
import { ChartContainer } from "@moritzbrantner/ui";
const legendItems = [
{ id: "average", label: "Average", color: "var(--chart-1)" },
{ id: "rolling", label: "Rolling", color: "var(--chart-2)" },
];
export function TrendWithLegend({ rows }) {
const visibility = useChartSeriesVisibility({
itemIds: legendItems.map((item) => item.id),
});
return (
<ChartWithLegend
legend={
<ChartSeriesLegend
items={legendItems}
hiddenIds={visibility.hiddenIds}
onHiddenIdsChange={visibility.setHiddenIds}
/>
}
>
<ChartContainer
className="h-72 w-full"
config={{
average: { label: "Average", color: "var(--chart-1)" },
rolling: { label: "Rolling", color: "var(--chart-2)" },
}}
>
<LineChart data={rows}>
{visibility.isVisible("average") ? (
<Line dataKey="average" dot={false} stroke="var(--color-average)" />
) : null}
{visibility.isVisible("rolling") ? (
<Line dataKey="rolling" dot={false} stroke="var(--color-rolling)" />
) : null}
</LineChart>
</ChartContainer>
</ChartWithLegend>
);
}
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
import {
createChartDensityIndex,
createChartRenderData,
useChartBinCount,
} from "@moritzbrantner/charts";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@moritzbrantner/ui";
const index = createChartDensityIndex(points);
export function DenseAreaChart() {
const { containerRef, targetBinCount } = useChartBinCount();
const series = index.getChartSeries({
includeEmptyBins: true,
targetBinCount,
valueMode: "average",
xDomain: [0, 1_440],
});
const chartData = createChartRenderData(series.samples, {
modes: ["average"],
xLabel: (sample) => `${Math.round(sample.x)}m`,
}).rows;
return (
<div ref={containerRef}>
<ChartContainer
className="min-h-72"
config={{ average: { label: "Average", color: "var(--chart-1)" } }}
>
<AreaChart data={chartData}>
<CartesianGrid vertical={false} />
<XAxis dataKey="label" tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area
dataKey="average"
fill="var(--color-average)"
fillOpacity={0.16}
stroke="var(--color-average)"
type="monotone"
/>
</AreaChart>
</ChartContainer>
</div>
);
}
Use ChartAxisTransformMenu when a Recharts-backed chart should expose axis
range and scale controls. The helper validates log scale domains and falls back
to linear rendering when a data domain includes zero or negative values.
import { useState } from "react";
import { Line, LineChart, XAxis, YAxis } from "recharts";
import {
ChartAxisTransformMenu,
resolveChartAxisTransformStatus,
type ChartAxisTransform,
} from "@moritzbrantner/charts";
export function TransformableChart({ rows }) {
const [yAxis, setYAxis] = useState<ChartAxisTransform>({
domain: null,
scale: "linear",
});
const status = resolveChartAxisTransformStatus({
dataDomain: yAxis.domain ?? [1, 1_000],
scale: yAxis.scale,
});
return (
<LineChart data={rows}>
<XAxis dataKey="x" tickFormatter={(value) => `${value}m`} type="number" />
<YAxis domain={yAxis.domain ?? ["auto", "auto"]} scale={status.renderScale} />
<Line dataKey="average" dot={false} type="monotone" />
<ChartAxisTransformMenu
axis="y"
dataDomain={[1, 1_000]}
onValueChange={setYAxis}
value={yAxis}
/>
</LineChart>
);
}
For switched axes, render Recharts with layout="vertical", place numeric values
on XAxis type="number", and place binned labels on YAxis type="category".
Use ChartXAxisNavigationMenu when right-clicking the x-axis should navigate the
visible domain. The menu can zoom around the clicked x value, pan by one visible
window, reset to the full domain, or apply preset ranges.
<LineChart data={rows}>
<XAxis dataKey="x" domain={domain} type="number" />
<YAxis />
<Line dataKey="average" dot={false} />
<ChartXAxisNavigationMenu
domain={domain}
fullDomain={fullDomain}
formatValue={(value) => `${value}m`}
onDomainChange={setDomain}
ranges={ranges}
/>
</LineChart>
getRechartsAnimationProps provides consistent Recharts mark animation props and
respects reduced-motion by default. useChartAnimatedDomain interpolates numeric
axis domains for rescale transitions, while useChartPlaybackDomain derives a
domain that expands over time for play/pause chart playback.
import { Area, AreaChart, XAxis, YAxis } from "recharts";
import { getRechartsAnimationProps, useChartAnimatedDomain } from "@moritzbrantner/charts";
export function AnimatedChart({ rows, yDomain }) {
const animatedYDomain = useChartAnimatedDomain({
domain: yDomain,
enabled: true,
});
const animation = getRechartsAnimationProps({
enabled: true,
mode: "draw-and-rescale",
});
return (
<AreaChart data={rows}>
<XAxis dataKey="x" type="number" />
<YAxis domain={animatedYDomain} />
<Area dataKey="average" type="monotone" {...animation} />
</AreaChart>
);
}
import { useState } from "react";
import { ChartSampleSparkline, useProgressiveChartDensity } from "@moritzbrantner/charts";
export function LinkedChartDetails({ points }) {
const { index } = useProgressiveChartDensity(points);
const [selectedSampleIndex, setSelectedSampleIndex] = useState<number | null>(null);
const series = index.getChartSeries({
includeEmptyBins: true,
targetBinCount: 120,
xDomain: [0, 1_440],
});
const selectedSample =
series.samples.find((sample) => sample.index === selectedSampleIndex) ?? null;
const point = selectedSample?.firstPoint
? index.getPointById(selectedSample.firstPoint.id)
: null;
return (
<>
<ChartSampleSparkline
samples={series.samples}
domain={series.summary.xDomain}
selectedSampleIndex={selectedSampleIndex}
onSampleSelect={(sample) => setSelectedSampleIndex(sample.index)}
/>
<pre>{JSON.stringify(point?.properties ?? null, null, 2)}</pre>
</>
);
}
import { ChartBackendStatus, useProgressiveChartDensity } from "@moritzbrantner/charts";
export function BackendPanel({ points }) {
const { status, warmWasmNow } = useProgressiveChartDensity(points, {
progressive: {
warmup: "manual",
},
});
return (
<ChartBackendStatus
status={status}
onWarmNow={warmWasmNow}
formatError={(error) => `Using hybrid JS fallback: ${String(error)}`}
/>
);
}
import { createChartDensityIndex, createChartRenderData } from "@moritzbrantner/charts";
const index = createChartDensityIndex(points, { backend: "hybrid-js" });
const series = index.getChartSeries({
includeEmptyBins: true,
targetBinCount: 96,
valueMode: "sum",
xDomain: [360, 720],
});
const payload = createChartRenderData(series.samples, {
gapBehavior: "preserve",
includeMetrics: true,
modes: ["sum", "count"],
});
Use value-mode definitions when controls, axes, previews, and tooltips need labels or formatting:
import { getChartValueModeDefinitions } from "@moritzbrantner/charts";
const definitions = getChartValueModeDefinitions(["average", "count", "max"]);
average: mean y value per bin, usually best for trend lines.count: source-point count per bin, usually best as bars.max: highest y in each bin, useful for peaks and thresholds.min: lowest y in each bin, useful for floors and ranges.sum: total y in each bin, useful for volume and totals.p50, p75, p90, p95, p99: percentile values per bin, useful for
medians, percentile lines, and latency-style dashboards. p10 and p25 are
also available when explicitly requested for band and box-plot helpers.Use the advanced index methods when a viewport needs distribution, heatmap, scatter/bubble, or grouped data derived from the indexed source points:
const histogram = index.getHistogram({
bucketCount: 24,
valueAccessor: "y",
xDomain: [360, 720],
});
const heatmap = index.getHeatmap({
xBinCount: 48,
xDomain: [360, 720],
yBinCount: 12,
});
const grouped = index.getGroupedChartSeries({
groupBy: { property: "plan" },
targetBinCount: 96,
valueMode: "count",
xDomain: [360, 720],
});
const stackedRows = createGroupedChartRenderData(grouped, {
xLabel: (sample) => `${Math.round(sample.x)}m`,
}).rows;
const scatter = index.getScatter({
maxPoints: 2_000,
sizeAccessor: { metric: "revenue" },
xDomain: [360, 720],
});
Percentile-enriched series power median lines, interquartile bands, and box plots. Separate layout helpers cover waterfall, funnel, treemap, and sunburst views:
const percentileSeries = index.getChartSeries({
includeEmptyBins: true,
percentiles: ["p25", "p50", "p75"],
targetBinCount: 96,
xDomain: [360, 720],
});
const bandRows = createChartBandRenderData(percentileSeries.samples, {
lower: "p25",
center: "p50",
upper: "p75",
}).rows;
const boxPlotData = createChartBoxPlotData(percentileSeries.samples);
const waterfallRows = createChartWaterfallData([
{ label: "Baseline", value: 120 },
{ label: "Expansion", value: 42 },
{ label: "Credits", value: -18 },
]);
const funnelRows = createChartFunnelData([
{ label: "Visits", value: 1_000 },
{ label: "Trials", value: 620 },
{ label: "Paid", value: 180 },
]);
const treemapNodes = createChartTreemapLayout(hierarchy, { width: 640, height: 320 });
const sunburstNodes = createChartSunburstLayout(hierarchy, { outerRadius: 160 });
const icicleNodes = createChartIcicleLayout(hierarchy, { width: 640, height: 320 });
const circlePackNodes = createChartCirclePackLayout(hierarchy, { width: 340, height: 340 });
const treeNodes = createChartTreeLayout(hierarchy, { width: 640, height: 320 });
const flameGraphNodes = createChartFlameGraphLayout(hierarchy, { width: 640, height: 320 });
const radialTreeNodes = createChartRadialTreeLayout(hierarchy, { width: 340, height: 340 });
const indentedTreeNodes = createChartIndentedTreeLayout(hierarchy, { width: 640 });
createChartRenderData supports four empty-bin policies:
preserve: keep empty bins with null values. This is the default.connect: drop empty bins from rows and return gap annotations.drop: drop empty bins without annotations.zero-fill: keep empty bins and convert missing values to 0.const connected = createChartRenderData(series.samples, { gapBehavior: "connect" });
console.log(connected.annotations);
Use ChartLabelOverlay inside Recharts charts when explicit annotations should stay readable
without covering chart marks or other labels. The overlay converts data coordinates through the
active Recharts axes, measures and wraps label text with @chenglou/pretext, then places labels
around their anchors. Lower-priority labels are hidden when no clean placement is available.
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
import { ChartLabelOverlay } from "@moritzbrantner/charts";
function AnnotatedTrend({ rows }) {
return (
<LineChart data={rows}>
<CartesianGrid vertical={false} />
<XAxis dataKey="label" />
<YAxis />
<Line dataKey="current" dot={false} stroke="var(--color-current)" />
<ChartLabelOverlay
labels={[
{
id: "launch",
priority: 100,
text: "Launch",
x: "D23 00:00",
y: 142,
},
]}
obstacles={rows.map((row) => ({
id: row.label,
kind: "mark",
radius: 4,
x: row.label,
y: row.current,
}))}
/>
</LineChart>
);
}
For renderer-agnostic use, call layoutChartLabels(labels, options) with pixel coordinates and
render the returned ChartPlacedLabel objects yourself. The font option should match the
rendered SVG text. Prefer a named font such as Inter; system-ui can be inaccurate for Pretext
measurement on some platforms.
By default, createChartDensityIndex renders immediately from hybrid-js, warms
a wasm-index in an idle slot, then uses method-level routing for later queries.
Pass backend: "hybrid-js" or backend: "wasm-index" to force the wrapper
construction policy.
The wasm-index backend is provided by @moritzbrantner/viz-engine. It owns
compact numeric arrays for sorted x/y values and metric columns, and currently
accelerates binning, percentiles, and histograms. Heatmap queries currently route
to the hybrid point-store implementation because public WASM heatmap result
mapping is slower in the large-data benchmark. Grouped series, render-row
shaping, gap annotations, React controls, label layout, and derived analytics
stay in TypeScript so the public API remains renderer-agnostic and easy to
compose.
Use hybrid-js when you need the smallest runtime surface or are running in an
environment that does not allow WebAssembly. Use wasm-index when you want the
native kernel immediately and can pay construction cost up front. Use
progressive for interactive screens: the first render uses JavaScript, then
chart-series queries can use WASM after warmup while heatmap and unsupported
operations continue through the hybrid fallback.
For large browser datasets where construction cost is visible, opt into worker warmup:
const index = createProgressiveChartDensityIndex(points, {
progressive: {
worker: true,
},
});
const workerIndex = await index.whenWorkerReady();
const series = await workerIndex?.getChartSeries({
targetBinCount: 400,
xDomain: [0, 1_000_000],
});
Worker indexes are async because browser workers cannot expose a synchronous
object API. Function-based filterPoint options and function-based
valueAccessor queries stay on the main-thread index; pass serializable data
and object accessors such as { metric: "revenue" } to the worker path.
The WASM binary is embedded by @moritzbrantner/viz-engine, so consumers do not
need a special .wasm asset loader for the package import.
Benchmarks generally show random or high-cardinality large-domain chart queries
and histograms as the primary WASM win cases. Sorted public-wrapper chart queries
can be faster in hybrid-js because they avoid WASM result mapping overhead.
Each index may expose getBackendCapabilities() for runtime inspection:
const capabilities = index.getBackendCapabilities?.();
if (capabilities?.usesWasm) {
// The active backend is using the Rust/WASM kernel.
}
Open the local examples app for a combined example with responsive binning, value-mode previews, viewport totals, sample selection, gap-safe render data, and source-point lookup.
The project is intentionally narrower than D3. The goal is a composable chart data kernel, not a full visualization framework. Near-term kernel modules are:
The local examples app covers:
Run the examples page with:
bun dev
Vite serves the React examples app from examples/ and aliases
@moritzbrantner/charts to the local src/index.ts entrypoint.
Published API documentation is available at https://moritzbrantner.github.io/charts/.
Generate the TypeDoc API reference with:
bun run docs
bun run docs:check validates the TypeDoc configuration without writing the
generated site.
bun run verifybun run testbun run test:coveragebun run api:checkbun run docs:checkbun run lintbun run format:checkbun run build:examplesbun run pack:checkbun run test:e2ebun run build && bun run bench:large-dataLarge-data benchmarks are intentionally diagnostic, not a promise that one
backend is always faster. hybrid-js is often faster for sorted public-wrapper
chart queries because it avoids mapping WASM results back into public objects.
wasm-index is expected to win on random or high-cardinality large-domain chart
queries, percentile-heavy chart work, and competitive histogram cases. Heatmap
currently routes through the hybrid point store because the mapped public WASM
heatmap result is slower.
Useful commands:
bun run bench:large-data
CHARTS_BENCH_FULL=1 bun run bench:large-data
CHARTS_BENCH_JSON=test-results/bench-large-data.json bun run bench:large-data
CHARTS_BENCH_PROFILE=1 CHARTS_BENCH_JSON=test-results/bench-large-data-profile.json bun run bench:large-data
bun run bench:large-data:json
bun run bench:large-data:full-json
bun run bench:large-data:profile
The JSON report includes raw results, structured backend comparisons,
slowBenchmarks, and wasmRatioFailures. Profiling adds profileResults with
construction, query-core, and public mapping timings. Treat fail comparisons as
actionable regressions. Treat warn comparisons as tracked known gaps unless the
ratio moves sharply against the documented backend behavior.
Releases are managed with Changesets and published to public npm with provenance from GitHub Actions. Add a release note with:
bun run changeset
Versioning and changelog updates are generated by the release workflow.
See RELEASE_CHECKLIST.md for release gates, benchmark
budgets, and 1.0 readiness criteria.