-
Notifications
You must be signed in to change notification settings - Fork 27
Chart System Architecture
How we think about data visualization in Astryx — the problems charts solve, the correctness guarantees the architecture enforces, and the design choices left to the developer.
For general Astryx conventions, see API Conventions and Astryx Philosophy.
Charts lie. Not usually on purpose — but the default behavior of most charting libraries makes it easy to produce misleading visualizations without realizing it.
The common failure modes:
Overlaid datasets use different scales. Two lines on the same chart look comparable but are actually mapped through different y-domains. The visual relationship is arbitrary. This happens when chart components compute their own ranges independently.
Axes and marks disagree. The y-axis says 0–100, but the bars are actually mapped to a different range because the bar component recomputed the domain from its own data subset. The grid lines don't match either.
Streaming data jitters. Real-time charts auto-scale on every frame, so the axis jumps around constantly. A value that was at the top of the chart is suddenly in the middle because a new point stretched the domain.
Silent data reduction hides outliers. Large datasets get downsampled for rendering, and the naive approach (skip every Nth point) drops the spikes that are the whole reason you're looking at the chart.
Color encodes data but excludes people. Sequential palettes that aren't perceptually ordered mislead about magnitude. Categorical palettes that aren't colorblind-safe exclude 8% of male users.
These aren't edge cases. They're the default behavior of most charting approaches, and they produce charts that are subtly wrong in ways that are hard to notice and easy to act on.
Some of these problems have objective answers — a chart where the axes don't match the data is wrong, full stop. Others are judgment calls where the right answer depends on context.
We split them into two tiers.
These are enforced by architecture, not documentation. A mark component physically cannot render at a different scale than its siblings because the only scale available is the one in context.
| Guarantee | Mechanism |
|---|---|
| All marks share one coordinate space | Single yScale/xScale in React context. Components read it; none create their own. |
| Axes match the data | Axes read from the same context scales as marks. They can't disagree. |
| Overlaid datasets are comparable | Same scale, same pixel mapping. A point at value 50 on series A and value 50 on series B land at the same y-pixel. |
| SVG and WebGL agree | Both call yScale(value) from context. Rendering backend is a detail, not a coordinate system. |
| Domain expands, never clips |
yDomain sets a floor. Data that exceeds it widens the range — nothing gets silently truncated. |
| Streaming uses the chart's scale |
ChartStreamGL reads yScale from context, not its own internal mapping. |
These aren't props. You can't opt out. They're the shape of the system.
These are design choices where the right answer depends on what you're building. We pick defaults that produce honest charts and let you override them.
The pattern: rational default, explicit override, documented tradeoff.
A bar chart that doesn't start at zero is sometimes the right call — we default to including zero, the docs explain why, and yBaseline='data' is one prop away. No warnings, no errors. You read the docs, you make the call.
Chart computes the scales from data and provides them to children via React context. Everything else reads from it.
const colors = useChartColors();
<Chart data={data} xKey="month" yKeys={['revenue', 'trend']} height={300}>
<ChartGrid horizontal />
<ChartAxis position="bottom" />
<ChartAxis position="left" />
<ChartBar dataKey="revenue" color={colors.categorical(2)[0]} />
<ChartLine dataKey="trend" color={colors.categorical(2)[1]} dots />
<ChartTooltip />
</Chart>The grid, axes, bars, line, and tooltip all read from the same context. There is no way for the bar to end up on a different scale than the axis next to it.
Domain control:
-
yDomain={[0, 100]}— sets a stable range. Data can push beyond it; it can't shrink below. -
yBaseline='auto'— includes zero when all values are positive (the rational default for bars). -
yBaseline='zero'— symmetric around zero (for profit/loss, seismograph, sentiment). -
yBaseline='data'— tight fit to data extent (stock prices, temperatures).
Charts are composed from independent components. Bars, lines, dots, areas, error bars, candlesticks — all siblings, all reading from the same context. You mix SVG and WebGL marks on the same chart. You add or remove interaction layers without touching the data mapping.
This also means each component is testable on its own. You can verify a bar renders at the correct pixel position without the rest of the chart.
Colors come from useChartColors() (React) or getChartColors(theme, mode) (non-React). Both return the same API, both resolve from theme tokens.
const colors = useChartColors();
colors.categorical(5) // 5 distinct series colors
colors.sequential.blue(3) // 3-stop blue ramp
colors.diverging.positiveNegative(7) // shamrock → gray-1 → red
colors.alpha('#0171E3', 0.5) // any color with opacitySequential ramps are perceptually ordered (dark = high). Diverging palettes have a neutral midpoint (gray-1, per design spec). Legends accept palette output directly: gradient={colors.sequential.blue(5)}.
For large datasets (1k+ points), WebGL marks render to a canvas overlay aligned pixel-for-pixel with the SVG chart space. The y-mapping still comes from the chart's yScale — the only difference is what draws the pixels.
-
ChartDotGL— scatter in a single draw call -
ChartDotGLInteractive— GPU color-picking for O(1) hover detection (encodes each point's index as a unique color in an offscreen framebuffer, reads back the pixel under the cursor) -
ChartHeatmapGL— 2D grid with color ramps -
ChartStreamGL— ring-buffer streaming line, zero React re-renders per frame
m4Reduce() implements the M4 algorithm — for each pixel column, keep the first, last, min, and max values. A million-point time series becomes ~4×width points with no visible loss. Outliers are preserved because min/max are always kept.
Data reduction is explicit (you call m4Reduce yourself). If we add auto-reduction later, it'll come with a visual disclosure so the user knows the data was aggregated.
| Default | Why | Override |
|---|---|---|
yBaseline='auto' |
Includes zero for bars — prevents visual exaggeration |
'data' or 'zero'
|
height=300 |
Reasonable aspect ratio for most data | Any number |
| Horizontal grid only | Horizontal lines aid y-value reading without clutter | Add vertical prop |
curveMonotoneX for lines |
Preserves data monotonicity — no false peaks between points | Future: curve prop |
| Sequential palettes dark→light | Perceptual convention: dark = high intensity | Custom colorRange
|
| No dual y-axes | Dual axes create arbitrary implied relationships between scales | Not implemented; if demanded, would be an explicit component with documented risks |
| Anti-Pattern | Why it's structurally impossible |
|---|---|
| Overlaid data on different scales | Single yScale in context. Marks can't create their own. |
| Axes disagree with marks | Both read from same context. |
| SVG and WebGL positions diverge | Both call yScale() from context. |
| Data silently clipped | yDomain is a floor, not a ceiling. |
| Anti-Pattern | Risk | Default | Override |
|---|---|---|---|
| Truncated bar axis | Exaggerates differences |
yBaseline='auto' includes 0 |
yBaseline='data' |
| Dual y-axes | False correlation | Not built | Explicit component if added |
| 3D effects | Distorts area perception | Not built | — |
| Color-only encoding | Excludes colorblind users | Documented guidance | Shapes/patterns (future) |
| Silent data reduction | Hides outliers | M4 is opt-in | Auto with disclosure (future) |
- Tufte, The Visual Display of Quantitative Information (1983)
- Cleveland & McGill, Graphical Perception (1984)
- Jugel et al., M4: Visualization-Oriented Time Series Data Aggregation (VLDB 2014)
- WCAG 2.2 — 1.4.1, 1.4.3, 1.4.11