⚠️ Beta / Work in progress. The grammar-of-graphics and fluent authoring APIs are experimental and may change before a stable release. Pin a version if you depend on them.
Braven Charts has one configuration API — BravenChartPlus with
ChartSeries, ChartAnnotation and the config classes — and two optional
authoring layers above it. Both are additive: they change nothing about the
core API, and code that never imports them never sees them.
| Layer | What it is | Where it lives | How you get it |
|---|---|---|---|
| Fluent modifiers | Chained withX / withoutX / inheritX / clearX / updateX verbs generated over the existing config classes |
lib/src/fluent/generated/ (98 extensions, ~1160 verbs) |
import 'package:braven_charts/braven_charts_fluent.dart'; |
| Chart grammar | A typed grammar of graphics — data, marks, channels — that lowers onto those same config objects | lib/src/grammar/ |
already in package:braven_charts/braven_charts.dart |
They are independent. The fluent layer edits configs; the grammar describes a chart and then produces configs. You can use either, both, or neither.
Showcase: Chart Grammar (?page=chart-grammar) — six presets authored
through the chained facade only, each inside the Chart / Data / Split / Source
workbench, with a "Compare hand-built" toggle. The Reference lines preset
exercises the V2.0 verbs (.threshold / .grid / .title / per-mark
markers) so its Grammar Source tab shows a chain, not a diagnostic.
Layer 1 — the generated fluent surface
The opt-in barrel
import 'package:braven_charts/braven_charts_fluent.dart';
final crosshair = const CrosshairConfig()
.withMode(CrosshairMode.vertical)
.withSnapRadius(24);
braven_charts_fluent.dart re-exports the whole core barrel plus every
generated extension, so the one import is enough.
Why a separate barrel. Dart extension methods are unconditional: an extension that is in scope adds its members to every instance of the extended type, including in autocomplete. Exporting ~1160 verbs from the core barrel would push them onto every consumer of the package whether they wanted chained modifiers or not, and would bury the constructors — which remain the primary, complete way to build a config — under a wall of verbs. Keeping the extensions behind their own barrel makes the surface a decision the consumer makes once, per file.
The barrel itself is generated from the set of generated files, so a new
extension cannot go unexported. The generated files are checked in;
consumers never run build_runner. CI regenerates and fails on any diff.
Verb vocabulary
Two different kinds of "unset" exist on this config surface, and one clear
verb would have meant opposite things in each, so they get distinct verbs:
| Verb | Applies to | Lowers to |
|---|---|---|
withX(v) |
every modelled parameter | copyWith(x: v) |
withoutX() |
tri-state ChartStyleValue fields — SUPPRESS ("render nothing, do not inherit") |
copyWith(x: const ChartStyleValue<T>.none()) |
inheritX() |
tri-state ChartStyleValue fields — INHERIT (defer to the theme) |
copyWith(x: const ChartStyleValue<T>.inherit()) |
clearX() |
nullable parameters whose copyWith has a clear flag — UNSET ("back to the default") |
copyWith(clearX: true) |
updateX(fn) |
non-nullable nested config parameters | copyWith(x: fn(x)) |
The large nullable family owns the intuitive meaning of clear; tri-state
suppression is without. withX signatures always strip nullability, because
passing null through a ??-merging copyWith is a silent no-op rather than
an unset. Nullable parameters whose copyWith cannot unset them say so in
their generated dartdoc instead of shipping a null-accepting verb.
updateX is what makes the layer worth importing at fleet scale — editing one
leaf without restating the enclosing config:
final interaction = const InteractionConfig()
.updateCrosshair((c) => c.withMode(CrosshairMode.vertical))
.updateTooltip((t) => t.withEnabled(false));
What deliberately has no verb
Every chain step must produce a config that is valid and still connected to the rest of the chart. Four kinds of parameter fail that test, so they are force-excluded and documented as construction-only. This is the layer's central rule: construction is the complete path. A constructor sees every parameter at once and can assert across them; a mid-chain setter sees one.
- Cross-object join keys.
idon every series and annotation class (as it already was onYAxisConfig). Series ids bind axes, annotations and artifact documents; annotation ids bind selection state. A mid-chainwithIdwould detach the value from everything referencing it. - OHLC, as a unit.
CandlestickDataPoint'sopen/high/low/closeare assert-coupled, so no individual setter exists; they move together through one combined setter with required named parameters:withOhlc(open: …, high: …, low: …, close: …). Two-member couples stay positional (withRange(min, max)); three or more take named parameters, becausewithOhlc(1, 6, 0.5, 3)is a tuple nobody can read at a call site. - Bar width.
BarChartSeries.barWidthPercentandbarWidthPixelsare coupled by an OR-shaped assert (percent != null || pixels != null) — the two are alternatives, not a pair. An AND-shapedwithBarWidth(a, b)would be a verb whose arguments cannot both be honoured, and becausecopyWithmerges with??and exposes no clear flag for these, no verb can select one alternative and retire the other. Set the width at construction. RangeAnnotation's four bounds. Same OR shape: anwithBoundsthat took all four silently converted an X-only band into a 2-D box.- Ordered, typed point lists.
CandlestickChartSeries.points,RangeAreaChartSeries.pointsandPolarColumnChartSeries' parallel category arrays.copyWithwidens the element type toChartDataPointwhile the series requires its own point type and strictly increasingx, sowithPoints([...])could only throw for anything the series would have rejected at construction.
RangeAreaDataPoint's low/high follow the OHLC pattern rather than being
excluded: they are one value (high >= low) and move together through
withInterval(low, high), which also keeps the inherited y midpoint
consistent. A gap has no verb — build one with RangeAreaDataPoint.gap.
Preset factories get no fluent surface either — Dart factories already chain,
because the extension applies to the factory's result:
CrosshairConfig.tracking(...).withSnapRadius(12) works today.
Layer 2 — the chart grammar
The grammar describes a chart as data + geometries + encodings, and then compiles ("lowers") that description onto the ordinary config API. The render pipeline, artifact codecs, generated Source and the Workbench are untouched: they receive exactly the objects they already understand and have no idea the grammar exists.
The chained facade
The facade is the way most code should author a spec:
BravenChart.of(rides)
.x(rideMinute, label: 'Elapsed (min)')
.yAxis(YAxisConfig(
position: YAxisPosition.left,
label: 'Power',
unit: 'W',
).copyWith(id: 'watts'))
.yAxis(YAxisConfig(
position: YAxisPosition.right,
label: 'Heart rate',
unit: 'bpm',
).copyWith(id: 'bpm'))
.geomArea(
id: 'power',
y: ridePower,
name: 'Power',
yAxisId: 'watts',
color: const Color(0xFF2563EB),
fillOpacity: 0.18,
)
.trend(method: TrendType.movingAverage, windowSize: 5, name: 'Trend')
.geomLine(
id: 'hr',
y: rideHeartRate,
name: 'Heart rate',
yAxisId: 'bpm',
color: const Color(0xFFDC2626),
)
.geomPoint(
id: 'efforts',
yAxisId: 'watts',
size: const Channel<Ride>(rideEffort, label: 'Effort'),
categoryBy: const CategoryChannel<Ride>(rideZone, label: 'Zone'),
categories: zoneStyles,
)
.interaction(const InteractionConfig(
crosshair: CrosshairConfig(displayMode: CrosshairDisplayMode.tracking),
))
.build()
Properties of the facade worth knowing:
- It is a spec builder and nothing else.
toSpec()hands back the samePlotSpecyou could have typed by hand;build()wraps that spec in aBravenPlot. There is exactly one description of what each verb means, and the lowering parity suite therefore covers the facade for free. - Every verb returns a new builder. A chain can be branched — one shared base, two geometries on top — and a builder held in a field cannot be mutated by a caller who chains off it.
- It fails at the offending call, not at
build(). Ageom*with no accessor to use throwsGrammarSpecExceptionwithGrammarDiagnosticCode.missingEncodingright there, naming the verb and the channel; atrend()with nothing to fit throwsunknownTrendSourceat its own call. The stack trace points at the line that is wrong.
Accessors compare by identity
Encodings are ordinary Dart functions (typedef FieldAccessor<T, V> = V Function(T row)). Mark, Channel and PlotSpec all have value
equality, but a closure compares by identity — so (row) => row.power
written twice produces two marks that are not equal. Use top-level functions
or static methods as tear-offs. They are constant expressions, which also
makes marks const-constructible.
The typed spec
PlotSpec<T> is declarative and inert:
PlotSpec<Ride>(
data: rides,
marks: <Mark<Ride>>[
LineMark<Ride>(x: rideMinute, y: ridePower, id: 'power'),
TrendMark<Ride>(sourceMarkId: 'power'),
],
yAxes: <YAxisConfig>[YAxisConfig(position: YAxisPosition.left, label: 'W')],
)
Mark<T> is a sealed hierarchy, so a switch that misses a variant does
not compile — every dispatch site is checked when a variant is added. The table
below is the Cartesian half of that hierarchy; the sealed RadialMark<T>
subtree — PieMark<T>, DonutMark<T> and PolarMark<T> — lowers to the radial
series families and is covered under Radial geometries. A
spec holds one half or the other, never both (mixedCoordinateSystems). Within
the Cartesian half the first six lower to a series and the last four to
annotations:
| Mark | Lowers to |
|---|---|
LineMark<T> |
LineChartSeries |
AreaMark<T> |
AreaChartSeries |
BarMark<T> |
BarChartSeries |
ScatterMark<T> |
ScatterChartSeries |
CandlestickMark<T> |
CandlestickChartSeries |
RangeAreaMark<T> (V2.0) |
RangeAreaChartSeries — see Bands |
TrendMark<T> |
TrendAnnotation bound to its source series |
ThresholdMark<T> (V2.0) |
ThresholdAnnotation — a reference line at a value |
BandMark<T> (V2.0) |
RangeAnnotation — a 1-D shaded band |
PointMark<T> (V2.0) |
PointAnnotation — a marker on one series' point |
Like TrendMark, the three V2.0 reference marks produce no geometry of their
own — they append an annotation — and carry no copyWith.
Channels exist only where they can be honoured. Channel<T> (quantitative)
and CategoryChannel<T> (categorical) are constructor parameters of
ScatterMark; geomBar, geomLine and geomArea additionally carry a
colorBy colour channel and geomBar a sizeBy width channel, baked at
lowering, while size, opacityBy and categoryBy remain scatter-only. The
coordinate × geometry validity matrix is therefore a compile-time property, not
a runtime throw.
A channel says which field to read; the matching Scatter*Encoding says how
the scale is configured:
| Channel | Template | Required? |
|---|---|---|
size |
sizeEncoding |
optional — defaults to ScatterSizeEncoding() |
opacityBy |
opacityEncoding |
optional — defaults to ScatterOpacityEncoding() |
colorBy |
colorEncoding |
required — no default colour ramp exists |
categoryBy |
categories |
required — no categorical palette exists |
A channel without its required template raises missingChannelEncoding rather
than inventing design surface the rest of the library does not have. Each
channel also has exactly one scale the renderer implements — size → sqrt
(marker area is proportional to the value, the perceptually correct
mapping), colorBy and opacityBy → linear. Leaving Channel.scale null
selects it; naming the other one raises unsupportedChannelScale instead of
silently rendering a different mapping than the one that was asked for.
Marks have no copyWith
This is a decision, not an omission. The package's surface enforcement
(test/meta/surface_enforcement_test.dart) reads "instantiable, public, has a
copyWith" as "must carry @chartSurface" — so a copyWith on LineMark
would generate a fluent verb surface over the grammar layer, giving
LineMark.withStrokeWidth sitting beside LineChartSeries.withStrokeWidth: a
second vocabulary for the same objects. Marks are small. Modify one by
constructing a new one, or author through the chained facade. The same
reasoning covers PlotSpec, Channel, CategoryChannel and LoweredPlot.
V2.0 verbs — reference marks, chart-level options, per-mark markers
Three additions in V2.0 let the grammar author — and round-trip-emit — the
three most common charts V1 could only diagnose. None changes the render
pipeline or a config class; each is a mark/PlotSpec field that lowers onto
config the pipeline already understood.
Reference annotation marks. Non-trend annotations now have chain verbs. Each appends a mark that lowers to an annotation, not a series:
// A reference line at a value on one axis → ThresholdAnnotation.
BravenChart<T> threshold({
required double value,
AnnotationAxis axis = AnnotationAxis.y,
String? id, String? label, Color? color,
double? strokeWidth, List<double>? dashPattern,
});
// A 1-D shaded band between two values on one axis → RangeAnnotation
// (an X band or a Y band, never a 2-D box).
BravenChart<T> band({
required double start,
required double end,
AnnotationAxis axis = AnnotationAxis.y,
String? id, String? label, Color? color,
});
// A marker on one existing series' data point → PointAnnotation.
// This is NOT the scatter geometry: it annotates ONE point of a geometry
// already in the chain.
BravenChart<T> pointAt({
required String seriesId,
required int dataPointIndex,
String? id, String? label, Color? color,
double? markerSize, MarkerShape? markerShape,
});
Chart-level options. PlotSpec now carries a grid, a title/subtitle and
legend visibility, and BravenPlot forwards them to BravenChartPlus. A
null grid or showLegend reproduces the chart default (const GridConfig()
and a shown legend), so a V1 spec is unchanged:
BravenChart<T> grid(GridConfig grid);
BravenChart<T> title(String title, {String? subtitle});
BravenChart<T> legend(bool show);
Per-mark data-point markers and inline labels. geomLine and geomArea
gained showDataPointMarkers and dataPointLabels; geomBar gained
labelStyle (a bar is its own mark, so it has no per-point marker toggle).
Each defaults to unset and lowers to the matching ChartSeries field:
// on geomLine / geomArea:
bool? showDataPointMarkers, // → *ChartSeries.showDataPointMarkers
DataPointLabelConfig? dataPointLabels, // → *ChartSeries.dataPointLabels
// on geomBar:
BarLabelStyle? labelStyle, // → BarChartSeries.labelStyle
The path and marker fields. geomLine and geomArea carry the rest of what
LineChartSeries and AreaChartSeries already had, so a configured path chart
is no longer refused for styling the grammar could express all along:
// on geomLine AND geomArea:
double? tension, // → *ChartSeries.tension
double? dataPointMarkerRadius, // → *ChartSeries.dataPointMarkerRadius
DataPointMarkerStyle? dataPointMarkerStyle,
Color? dataPointMarkerBackground,
double? lineGlow,
SeriesInlineLabelConfig? inlineLabel,
PathAnimationStyle? pathAnimation,
// on geomArea only:
AreaGradient? fillGradient,
Color? aboveBaselineFillColor,
Color? belowBaselineFillColor,
Every one of them is nullable on the mark, and null means "the series
default", resolved once at lowering. Six of the shared seven —
everything but inlineLabel — are NON-nullable on their series with a
default, so the mark deliberately diverges from the class it lowers to rather
than carrying a copy of a default that can go stale. The defaults live on
LineChartSeries / AreaChartSeries alone.
The same rule runs backwards through the source emitter: a captured value that
equals the family default reverses to null on the mark and emits nothing,
so a chart that sets none of these produces exactly the text it produced before
they were carried. Only a value that differs is written.
Bands: geomRangeArea
A filled band between paired bounds, lowering to RangeAreaChartSeries. It sits
beside geomCandlestick as the second Cartesian geometry with no single y:
BravenChart<T> geomRangeArea({
required FieldAccessor<T, num?> low,
required FieldAccessor<T, num?> high,
FieldAccessor<T, num>? x, // defaults to the chart-wide .x
String? id, String? name, Color? color, String? unit, String? yAxisId,
FieldAccessor<T, String?>? label,
FieldAccessor<T, String?>? pointKey,
// Every styling argument left null keeps the RangeAreaChartSeries default:
LineInterpolation? interpolation, double? tension, double? fillOpacity,
RangeAreaBorderMode? borderMode,
RangeAreaBoundaryStyle? upperBoundaryStyle,
RangeAreaBoundaryStyle? lowerBoundaryStyle,
bool? connectGaps, bool? showBoundaryMarkers, double? markerRadius,
RangeAreaLabelConfig? labelConfig,
RangeAreaHitTestMode? hitTestMode,
AreaGradient? fillGradient,
PathAnimationStyle? pathAnimation,
});
lowandhighare NULLABLE accessors, unlike every other Cartesian encoding.RangeAreaDataPoint.gapis a real point with no interval, and a totalFieldAccessor<T, num>cannot express one. Returning null from both at a row lowers it toRangeAreaDataPoint.gap; returning null from exactly one raisesincompleteRangeAreaIntervalnaming the row, because a half-specified interval has no defensible reading. This mirrorsPolarMark.intervalLow/intervalHigh.- A band's rows must be strictly increasing in
x, andhigh >= low. Those areRangeAreaChartSeries' own invariants; the lowering translates its rawArgumentErrors intoinvalidRangeAreaRownaming the offending row, exactly asgeomCandlestickdoes withinvalidCandlestickRow. - No channels. Deliberate: the range-area painter reads no per-point colour
— it paints from the series colour,
fillOpacity, the two boundary styles and the theme — so acolorByor size channel would be accepted and then ignored. pathAnimationandfillGradientARE carried, as of the path-field slice, alongside the same two onAreaMarkandLineMark— the roadmap item that fixed them for every Cartesian family at once, rather than for range area alone.fillGradientis nullable on the series and passes straight through;pathAnimationis not, so null on the mark means the series default.isXOrderedis absent becauseRangeAreaChartSerieshard-codes ittruein its constructor, exactly asCandlestickChartSeriesdoes, so a knob would be a lie.
Radial geometries
Pie, donut, concentric donut and polar column are grammar geometries, not a
config-only corner. A radial spec holds radial marks only — mixing one with a
Cartesian mark is mixedCoordinateSystems, and Cartesian options (transposed
/ xAxis / yAxes / grid) on a radial spec are axisOptionOnRadialSpec:
BravenChart<T> geomPie({required category, required value, ...});
// `ring:` turns a donut into a CONCENTRIC composition — one series per ring
// key, each ring computing its shares against its own total. `concentric:`
// carries the whole ConcentricDonutConfig (radii, ring gap, order, weights,
// legend mode and the center); `center:` is the shorthand for the center
// alone, and a mark that sets both is refused by name
// (`conflictingConcentricCenter`).
BravenChart<T> geomDonut({
required category, required value,
FieldAccessor<T, Object?>? ring,
DonutCenterContent? center,
ConcentricDonutConfig? concentric,
...
});
// Polar is the ONE radial family that may appear several times in a spec: a
// layered / grouped / stacked composition is N series over one category
// domain, so it is N marks. `rose: true` selects the area-correct preset, and
// the four per-category channels are nullable — a category with no target
// draws no marker, which a synthesised 0 would not preserve.
BravenChart<T> geomPolar({
required category, required value,
bool rose = false,
FieldAccessor<T, Color?>? columnColor,
FieldAccessor<T, num?>? target,
PolarColumnTargetMarkerStyle? targetMarkerStyle,
FieldAccessor<T, num?>? intervalLow,
FieldAccessor<T, num?>? intervalHigh,
PolarColumnIntervalStyle? intervalStyle,
...
});
// The plot-level polar configuration — pane, angular/radial axes, the
// composition mode and thresholds — is ONE object shared by the N marks, so it
// lives on the spec. Setting it without a polar mark is
// `polarConfigOnNonPolarSpec`.
BravenChart<T> polarConfig(PolarChartConfig config);
Rendering: BravenPlot
BravenPlot<Ride>(spec, bravenChartController: controller)
BravenPlot is a thin, stateless adapter. It lowers the spec and hands the
resulting series, annotations and axis configs to BravenChartPlus. It owns
no rendering, no interaction and no state of its own.
It deliberately exposes no widget-level yAxis: the lowering attaches both
yAxisId and yAxisConfig to every series, which is what selects the
multi-axis path. Passing a widget-level yAxis would re-enter the legacy
single-axis path and silently change how the chart scales.
Lowering
final lowered = spec.lower(); // LoweredPlot
lower() is an extension method — extension PlotSpecLowering<T> on PlotSpec<T> — not a top-level function. There is no lower<T>(spec): the
package does not export a bare generic verb into a host's namespace.
LoweredPlot carries series, annotations, xAxis, yAxes, interaction
and theme, all ordinary members of the existing config API.
Ids
- A mark without an explicit
idbecomesmark-<index>, using its position inPlotSpec.marksand counting trend marks. That id becomes the series (or annotation) id, which is what axes, annotations and artifact documents bind to — so it is deterministic rather than generated, and a hand-written equivalent can spell it out. - An axis with an empty id becomes
axis-<index>. An emptyyAxeslist becomes one default left axis,axis-0. - Every series carries both
yAxisIdand the matchingyAxisConfig. That pair is what activates the multi-axis path; the legacy single-axis path is never targeted.
Explicit axis ids are set the production way, with
YAxisConfig(...).copyWith(id: 'watts') — YAxisConfig.withId is
@visibleForTesting.
Axis labels: .x / .y vs an explicit axis
.x(accessor, label: …) and .y(accessor, label: …) are a shorthand for the
common single-axis case: the label names the axis only when the chain
configures none explicitly.
.x(…, label: 'Elapsed')with no.xAxis(…)→XAxisConfig(label: 'Elapsed')..x(…, label: 'Elapsed').xAxis(const XAxisConfig(label: 'Time', unit: 's'))→ the explicit config wins, verbatim..y(…, label: 'Power')with no.yAxis(…)→ one left axis labelledPower.- Any
.yAxis(…)declaration suppresses the.ylabel entirely; declaration order is axis order, and marks bind to slots throughyAxisId.
Explicit configuration always wins. The shorthand never merges into it.
Validation order
lower() is total and fail-fast, and the order is fixed so a spec with
several problems always reports the same one first. Every data-independent
check runs above the emptyData guard, so an authoring error still surfaces
against a momentarily-empty dataset — which is exactly what lets BravenPlot
swallow emptyData alone and render an empty state (see Empty data is a
state below).
Shared prologue:
- a faceted spec handed to
lower()→facetedSpecNotLowerable - empty
marks→emptyMarks - mark ids (duplicates) →
duplicateMarkId .polarConfig(...)on a spec holding no radial mark →polarConfigOnNonPolarSpec
A radial spec then branches (below). A Cartesian spec continues:
- axis ids (duplicates) →
duplicateAxisId - a time/log x axis that also declares category slots →
conflictingAxisMode - transposition →
unsupportedTransposition - each mark, in spec order →
unknownAxisId,unknownTrendSource,invalidTrendWindow,missingChannelEncoding,orphanChannelEncoding,unsupportedChannelScale - unbound axes →
unboundAxis - empty
data→emptyData - materialization, per row →
invalidCandlestickRow,nonPositiveLogValue
A radial spec continues instead:
- several radial marks that are not all
geomPolar→multipleRadialGeoms - any non-radial mark alongside them →
mixedCoordinateSystems transposed/xAxis/yAxes/grid→axisOptionOnRadialSpec.polarConfig(...)on a pie/donut spec →polarConfigOnNonPolarSpecgeomDonutsetting bothconcentricandcenter→conflictingConcentricCenterconcentric:with noring:→concentricConfigOnRinglessDonut; then the config-only half of the concentric contract (pane radii, ring gap, ring weight magnitudes) →invalidConcentricComposition- a non-empty
dataLabelsByRing:with noring:, then a non-emptyringIds:with noring:→perRingOverrideOnRinglessDonut(an EMPTY map is a no-op, here and on the ringed path) - the shape-decidable half of the polar composition contract, in this fixed
order: the config-only half — everything
PolarChartConfig.validate()enforces, i.e. pane geometry, radial-axis bounds, the grouped sub-band padding, per-threshold finiteness and dash-pair parity, and the stacked zero baseline →invalidPolarComposition; clashing mark units →invalidPolarComposition; a grouped/stacked mode with fewer than twogeomPolarmarks →invalidPolarComposition; exactly one ofintervalLow/intervalHigh→incompletePolarInterval; clashingrosepresets →invalidPolarComposition - empty
data→emptyData - every radial mark whose rows carry no visible category →
emptyRadialCategories - then, per family. The ring-map guards read the BUCKET KEYS, so they run as
soon as the rows are partitioned and BEFORE any ring series is built — a
chart that is wrong both ways reports the mis-keyed map, not the rows:
- concentric donut (
geomDonutwithring:): bucket the rows by ring; adataLabelsByRing:and then aringIds:key naming a ring the rows never produce →unknownRingKey; aringIds:that names only SOME of the rings the rows produce →partialRingIds; then ring materialization →duplicateRadialCategory; then the row-dependent half of the concentric contract (ring weights and ids against the ids the rings actually lowered to) →invalidConcentricComposition - polar: column materialization →
duplicateRadialCategory; then the row-dependent half of the polar contract →invalidPolarComposition - pie / ring-less donut: materialization →
duplicateRadialCategory
- concentric donut (
facetedRadialUnsupported is raised earlier still, by BravenFacetPlot: a
radial spec cannot be faceted at all.
Every diagnostic carries a machine-readable GrammarDiagnosticCode alongside
its sentence, so a facade or a tool can react without string matching.
Three rules worth calling out:
transposed: truerequires an all-BarMarkspec. Transposition is implemented in this package by horizontal bar geometry, which transposes the whole plane. A mixed spec would render some geometries rotated and others not, so it raisesunsupportedTransposition. Bars therefore carry no orientation of their own; transposition is a whole-chart operation, spelled.transposed()on the chain.- Non-finite values pass through for line/area/bar/scatter.
ChartDataPointdocuments NaN/infinite coordinates and exposesisValid, the pipeline already skips invalid points, and that is how a gap in a line is expressed — rejecting them would make the grammar stricter than the API it lowers onto. Candlesticks and range-area bands are the exceptions, becauseCandlestickDataPointandRangeAreaDataPointreject them themselves: those rows raiseinvalidCandlestickRow/invalidRangeAreaRowwith the row index instead of leaking anArgumentErrorfrom deeper in the pipeline. - A candle is a unit.
geomCandlestickrequiresopen,high,lowandclosetogether; there is no per-channel candlestick geometry to compose, and rows must be strictly ordered on x. - A band is a unit too, but its gap is a row shape.
geomRangeArearequireslowandhightogether, and rows must be strictly ordered on x. Unlike a candle, its bounds are nullable: both null is a gap (RangeAreaDataPoint.gap), exactly one null raisesincompleteRangeAreaInterval. See Bands:geomRangeArea.
Empty data is a state, not a mistake
An empty data list is a runtime state — a filter cleared, a fetch returned
nothing — so it must not throw out of a widget's build. BravenPlot
therefore handles exactly GrammarDiagnosticCode.emptyData by building the
chart with no series, which is how every other entry point in this package
reaches the standard empty state; configure the presentation through
emptyStateConfig.
Every other diagnostic — an empty marks list, an unknown trend source, a
channel without its encoding — is an authoring mistake and still surfaces from
build. Calling spec.lower() directly always throws on empty data; it is
the widget that has an empty-state contract, not the lowering.
The lowering guarantee
A spec-built chart and the hand-written chart it lowers to are the same chart.
That is enforced at two levels, and both are load-bearing test suites rather than prose:
| Evidence | What it asserts |
|---|---|
test/unit/grammar/plot_lowering_parity_test.dart |
The lowered ChartSeries, ChartAnnotations and axis configs are equal to the hand-written ones, per shape. |
test/widgets/braven_plot_artifact_parity_test.dart |
Mounting both charts and extracting a chart document through the ordinary BravenChartController.extractDocument path produces identical JSON, across all six shapes. |
Because a chart document is what feeds transport, hydration, the native data
table, generated Dart Source, comparison and the AI surface, artifact-document
equality means nothing downstream can tell a spec-built chart from a
hand-built one. No id normalization is needed in that suite: the lowering's
mark-<index> / axis-<index> ids are deterministic and the hand-built
charts spell those same ids out. If that stops being true, the suite goes red
— a feature, not a nuisance.
The Chart Grammar showcase page demonstrates the same thing interactively: the
Source tab renders ordinary BravenChartPlus Dart with real ChartSeries
constructions and no trace of the spec, and the "Compare hand-built" toggle
swaps the widget without changing the picture.
Grammar source emission
The Workbench Source pane reads one chart in two forms, chosen with the
Config / Grammar toggle:
| Form | What it writes | Generator |
|---|---|---|
| Config | The BravenChartPlus this chart is |
ChartDartSourceGenerator |
| Grammar | A BravenChart.of(rows)… chain that rebuilds it |
ChartGrammarSourceGenerator |
Both read the same captured chart document, so switching form never
re-extracts the chart — the pane re-emits from the snapshot it already holds,
and "stale" keeps meaning "the chart moved on since this snapshot". Both
render through ChartCodeBlock, and the config form keeps its original
chart-source-code / chart-source-dark-window keys unchanged.
The toggle is a package feature, not a showcase one: every
BravenChartWorkbench consumer gets both forms on every chart.
grammarSourceOptions names what the chain is called
(ChartGrammarSourceOptions(variableName:, rowClassName:, rowsVariableName:)),
and initialSourceForm chooses which form the pane opens on.
The synthesised row type — the caveat that matters
A chart document stores materialised points per series. The grammar takes the opposite shape: one row list plus one total accessor per channel. The two cannot be bridged by renaming fields, so the generator synthesises a row class:
class GrammarRow {
const GrammarRow({
required this.x,
required this.power,
required this.heartRate,
});
final double x;
final double power;
final double heartRate;
}
final List<GrammarRow> rows = <GrammarRow>[
GrammarRow(x: 0.0, power: 168.0, heartRate: 112.0),
// …
];
final chart = BravenChart.of(rows)
.x((row) => row.x, label: 'Elapsed')
.yAxis(YAxisConfig.withId(id: 'watts', position: YAxisPosition.left, …))
.yAxis(YAxisConfig.withId(id: 'bpm', position: YAxisPosition.right, …))
.geomArea(id: 'power', y: (row) => row.power, name: 'Power', …)
.geomLine(id: 'hr', y: (row) => row.heartRate, name: 'Heart rate', …)
.theme(ChartTheme.light)
.build();
Field names derive from each series' name (falling back to its id),
lower-camel-cased into a valid, non-keyword Dart identifier and de-duplicated
with a numeric suffix. A candlestick series contributes
<base>Open / <base>High / <base>Low / <base>Close (plus
<base>Timestamp when its candles carry one); a scatter series contributes
<base>Size / <base>Color / <base>Opacity / <base>Category for whichever
channels it populates.
GrammarRow is not — and can never be — your row type. A document keeps
the numbers a chart was built from, never the objects they were read out of.
That is the honest limit of this direction. The Chart Grammar showcase page
surfaces the generated chain through the workbench Source tab's Grammar form,
whose synthesised GrammarRow stands in for the author's own type — and that
substitution is exactly the contrast that makes the limit visible.
Every synthesised field is non-nullable and required. That follows from the
x-alignment rule below rather than being a style choice: accessors are
num Function(T), so they are total, so a field that has no value at some row
cannot exist.
The x-alignment rule
Every series must have exactly one point at every x of one shared, ordered domain, compared element-wise for equality against the first series.
BravenChart.of(rows) hands every mark the same rows and reads each through
a total accessor. A "union of x values with nullable fields" form is therefore
not expressible: if a series has no point at some x, its accessor still has to
return a num, and inventing one would draw a chart the document does not
describe. Anything else is diagnosed, naming the series that disagree. (NaN
never compares equal, so a non-finite x — the way a gap is expressed — is
diagnosed rather than silently treated as a shared row key.)
Fidelity: the round-trip proof
Before emitting anything, the generator builds the spec it is about to
write, lowers it with the real PlotSpecLowering, and compares the resulting
ChartSeries, ChartAnnotations and axis configs to the ones the document
hydrated to. So "a chain was emitted" already means "this chain rebuilds
this chart" — the emitter never has to enumerate every option a V1 mark
happens not to carry.
Fidelity matrix
A chain that renders a different chart is worse than no chain, so nothing is degraded silently. Each unsupported case emits a named diagnostic and no code, in the same comment-header style the config emitter uses for runtime-only bindings.
| Case | Outcome |
|---|---|
| A radial family — pie, donut, concentric donut or polar column | EMITTED (V2.0) as geomPie / geomDonut(ring:) / geomPolar, carrying the series style, unit, selection and slice configs. A layered/grouped/stacked polar composition emits one geomPolar per series over a shared category field; a customised PolarChartConfig emits as .polarConfig(...) and a non-default ConcentricDonutConfig as geomDonut(concentric: ...). Narrowed by the Blocked radial rows that follow — read them together before reading this row as "every radial chart emits". |
| A radial family with no grammar geometry — radial bar, gauge | Blocked, naming each series and its family: no mark reverses it. |
| A range-area band | EMITTED (V2.0) as geomRangeArea(low:, high:), carrying the interval bounds, the per-point label and key, and the range-area-native styling (interpolation, tension, fill opacity, border mode, both boundary styles, gap connection, boundary markers, marker radius, label config and hit-test mode). A gap row travels as two null bounds and comes back a gap. fillGradient and pathAnimation are carried too, as of the path-field slice; a captured value equal to the family default emits nothing, so a plain band's text is unchanged. |
| A line or area carrying path, marker or fill styling | EMITTED as tension:, dataPointMarkerRadius:, dataPointMarkerStyle:, dataPointMarkerBackground:, lineGlow:, inlineLabel:, pathAnimation: and — on an area — fillGradient:, aboveBaselineFillColor: and belowBaselineFillColor:. Each is written only when the captured value differs from the family default, so a chart that sets none of them emits exactly the text it emitted before they were carried. The Line, Area and RangeArea uncarried-field arms are now empty; what remains for those families falls to the generic unnamed tail (style, metadata, the legend and tracking flags) or to _pointLossDetail. |
A concentric composition whose ring series ids do not follow '<markId>-<ring>' |
EMITTED (V2.0) as geomDonut(ringIds: {...}) — an explicit ring-key→series-id map, so a composition that chose its ids independently of its ring names keeps them. The emitter consults it only when the '<markId>-<ring>' pattern fails to recover a markId, so a conforming composition takes the original path and emits exactly the text it emitted before. The map is keyed by the BARE ring key and is ALL OR NOTHING: naming some rings and not others raises partialRingIds, a key naming no ring raises unknownRingKey, and a non-empty map with no ring: raises perRingOverrideOnRinglessDonut. |
| A concentric composition whose rings are unnamed or share a name | Blocked, naming each series and its name: the ring key is the series name, so no ring: channel could bucket those rows apart — every row would fall into one ring. |
A pie or donut carrying per-slice colours (sliceColors, i.e. a per-point PointStyle.color) |
EMITTED (V2.0) as a sliceColor: row channel. PieMark/DonutMark carry one of their own, mirroring PolarMark.columnColor; a concentric composition resolves it per ring bucket, so the same category may take a different colour in each ring. |
A donut centre setting labelStyle or valueStyle |
EMITTED (V2.0) as center: DonutCenterContent(...). DonutMark.center carries the captured centre VERBATIM, and the argument is written by the same renderer the config form's centerContent: uses, so both styles survive. |
A donut centre setting valueFormatter |
Emitted with a // valueFormatter: placeholder and a warning (isComplete == false), exactly as every other runtime callback is — a live closure has no literal form. |
A concentric composition whose rings carry different dataLabels |
EMITTED (V2.0) as geomDonut(dataLabelsByRing: {...}). Ring 0's config is the base the mark's dataLabels: carries, and only the rings that DIFFER from it are projected into the override map — so a uniform composition emits exactly what it did before. Inside the map an entry equal to the family default is a real override and IS written, unlike the single dataLabels: argument which elides one. The map is keyed by the BARE ring key; a key naming no ring raises unknownRingKey, and a non-empty map with no ring: raises perRingOverrideOnRinglessDonut. A per-ring valueFormatter emits a // valueFormatter: placeholder and a warning naming the ring (isComplete == false). |
| Polar series whose category domains differ | Blocked, naming the series that disagree: N geomPolar marks read ONE row list, so every polar series needs one value at every category of the shared domain, in the same order. |
| Series whose x domains differ | Blocked, naming the series that set the domain and the ones that disagree. |
| A partially populated scatter channel | Blocked, naming the channel and the populated/total counts: a Channel accessor is total. |
| Mixed bar orientations | Blocked: .transposed() is a whole-chart operation, so a transposed chain may contain horizontal bar marks only. |
A TrendAnnotation, ThresholdAnnotation, a clean 1-D RangeAnnotation or a PointAnnotation |
EMITTED (V2.0) as .trend(of:) / .threshold(...) / .band(...) / .pointAt(...) — the reference-mark verbs. |
| Any OTHER annotation (text, pin, chord, error-bar, legend), a 2-D or half-open range, or ANY series-level annotation | Blocked and LISTED, never dropped. |
| A grid, a title/subtitle, or a legend toggle | EMITTED (V2.0) as .grid(...) / .title(...) / .legend(...). PlotSpec now carries them and BravenPlot forwards them; a default grid or a shown legend carries nothing, reproducing the chart default. A subtitle with no title is the one gated corner — the verb only attaches a subtitle to a title. |
A chart-level option BravenPlot still does not forward — legendStyle, showToolbar, interactiveAnnotations, maxAxesPerSide, axisSwapMode, normalizationMode, width/height, background |
Blocked, naming each one. BravenPlot passes series, annotations, the X axis, interaction, the theme, and now the grid, title, subtitle and legend visibility. |
| Anything else the reconstructed chain would not reproduce exactly | Blocked by the round-trip proof, naming the series, annotation or axis that differs — and, where it can be pinned down cheaply, the specific option a V1 mark cannot carry (e.g. a fill gradient, a curve tension, an inline series label — showDataPointMarkers and inline data-point labels are carried as of V2.0) or the single-axis binding a config-authored chart leaves implicit. |
| A runtime interaction binding | Emitted with a warning, exactly as the config form does. |
Data above maxInlinePoints |
Emitted with a placeholder row list and a warning, exactly as the config form does. |
| A host-owned theme reference | Emitted without .theme(...), with a warning naming the reference. |
test/unit/source/chart_grammar_source_generator_test.dart is the evidence:
seven shapes (single line, shared-x multi-series, multi-axis, scatter with
channels, candlestick, transposed bars, trend) each compile through
dart format + dart analyze and each rebuild a document equal to the
original, and one test per matrix row asserts the diagnostic text.
Sharing one emitter
The chain hands the same XAxisConfig, YAxisConfig, ChartTheme,
InteractionConfig and scatter encodings to its verbs that BravenChartPlus
takes. Rather than fork a second set of literal writers that would drift, the
config emitter lives in a non-exported chart_config_dart_emitter.dart and
exposes a narrow seam of field-level writers (never the enclosing
name: Type( header, which differs between the forms). Its own output is
unchanged.
Added in V2.0
Several families of authoring — the charts V1 could only diagnose — now
round-trip. Each is a Mark/PlotSpec field lowering onto config the pipeline
already understood (details under V2.0 verbs above):
-
Non-trend reference annotation marks.
.threshold(value:),.band(start:, end:)and.pointAt(seriesId:, dataPointIndex:)lower toThresholdAnnotation/RangeAnnotation/PointAnnotation. -
Chart-level grid, title/subtitle and legend.
.grid(...),.title(...)and.legend(...)are carried onPlotSpecand forwarded byBravenPlot. -
Per-mark data-point markers and inline labels.
showDataPointMarkersanddataPointLabelsongeomLine/geomArea, andlabelStyleongeomBar. -
Range-area bands.
geomRangeArea(low:, high:)lowers toRangeAreaChartSeriesand reverses back out of a captured chart. Its bounds are nullable so a gap has a row shape. Verified on the MOUNTED page —example/test/showcase/selection_showcase_range_area_grammar_test.dartpumps the selection lab, selects its Range Area family through the real picker and runs the generator on the live document, which emits a complete, warning-free chain for both bands and the centre line. -
The path and marker fields on
geomLine,geomAreaandgeomRangeArea.tension,dataPointMarkerRadius,dataPointMarkerStyle,dataPointMarkerBackground,lineGlow,inlineLabelandpathAnimationon the first two, plusfillGradientand both baseline fill colours ongeomArea, andfillGradient+pathAnimationongeomRangeArea. Nullable throughout; a defaulted capture reverses to null, so existing emission is byte-identical. Verified on the MOUNTED page —example/test/showcase/range_area_charts_page_grammar_test.dartwalksRangeAreaChartsPage's own preset picker and holds 6 of its 7 presets emitting a complete, warning-free, compiling chain. The seventh,confidence, still refuses on the x-domain divergence between its band and its observed line — the shared-x limitation, a separate design — and that file pins the refusal by name so a later slice cannot count it here. -
Radial geometries.
geomPie,geomDonut(with thering:channel for a concentric composition andconcentric:for its configuration) andgeomPolar, plus the spec-level.polarConfig(...). Multi-series polar compositions and customisedPolarChartConfig/ConcentricDonutConfiground-trip.Stated precisely, because the showcase is the evidence:
-
Every radial Workbench Grammar pane whose family HAS a geometry emits a real chain instead of a diagnostic — pie, donut, concentric donut and polar column. (Radial bar and gauge have no
geom*verb at all; their panes still show a named diagnostic.) -
Polar: all eight showcase presentations (standard, rose, partial, layered, grouped, stacked, references, intervals), each verified against
polar_column_page.dart's own_buildSeriesList/_buildPolarConfigconstruction at that presentation's authored knob values. -
Pie, donut and concentric: verified on the MOUNTED page. Each test pumps the real page, reads the live document off the chart's own controller and runs the generator on it, so there is no fixture to drift —
pie_charts_page_grammar_test.dart,donut_charts_page_grammar_test.dart,concentric_donut_page_grammar_test.dartandselection_showcase_concentric_grammar_test.dart, all underexample/test/showcase/.concentric_donut_page.dartand the selection lab's concentric family emit complete, warning-free chains;pie_charts_page.dartanddonut_charts_page.dartemit withisComplete == false, because of live formatter callbacks that have no literal form — the established contract for runtime values, not a degradation. The counts differ and each gate pins its own whole warning set: the pie carries oneruntimeValueOmitted, covering the two radial label formatters it binds; the donut carries two — one for thevalueFormatteron its centre and one covering the two radial label formatters — leaving three placeholder comments in the chain. -
All four mounted-page gates COMPILE the chain they assert on, through the same
dart format+dart analyzeharness (test/helpers/generated_source_compile.dart) the polar gate uses. That is the floor, not a nicety: every other assertion in those tests reads the emitted text, andcontainscannot tell a chain that would compile from one that only looks right. It caught a real defect the text assertions had all passed over — the emitted preamble importspackage:braven_charts/braven_charts.dartandpackage:flutter/material.dartunprefixed, andTooltipTriggerModeis declared in both, so every chain that emitted a non-default tooltip trigger mode wasambiguous_import. The material import now carrieshidefor the ambiguous names the body actually uses, in both source forms;test/unit/source/generated_import_ambiguity_test.dartre-derives that list from the analyzer so a future collision fails a test instead of reaching a user as unpasteable source. -
A non-default
ConcentricDonutConfigemits — radii, ring gap, order, legend mode, per-ring weights and center all survive togeomDonut(concentric: ...). Ring series ids following'<markId>-<ring>'are no longer required — a composition that ids its rings independently emitsringIds:instead — and rings that carry DIFFERENTdataLabelsemit asdataLabelsByRing:. -
The concentric preconditions are a SET, not one. This paragraph used to say the one remaining precondition was that no ring carry a centre of its own. That was false.
DonutMarkholds ONEstyle, ONEselectionStyle, ONEunit, ONEsliceRadiusConfigand ONEsliceGroupingConfigfor the WHOLE composition, and_lowerConcentricRings(lib/src/grammar/plot_lowering.dart:1524) stamps each of them onto every ring — and, unlike the single-donut_lowerDonutbeside it, never passesmark.colorat all. So a concentric composition emits only when every ring:- shares one
donutStyle, - shares one
selectionStyle, - shares one
unit, - shares one
sliceRadiusConfig, - shares one
sliceGroupingConfig, - carries no series
colorof its own, - carries no centre of its own, and
- has a distinct, non-empty name.
Two clarifications the list alone would not give. On the five shared configs it is DIVERGENCE that is refused, not a non-default value: rings that all carry the same non-default
donutStyleemit, and the style is carried through. The seriescoloris different in kind — no ring may carry one even when every ring carries the same one, because the ring path never readsmark.color; a single, non-concentric donut's series colour DOES emit.Only the ring centre and the ring name are refused BY NAME. The five config divergences and the ring colour arrive as the round-trip proof's catch-all sentence, whose own round-trip list states that
unit, the series style, the selection style and the slice-radius and grouping configs round-trip — true of a conforming composition, and actively misleading to the author who just tripped one of them. That is a gap worth closing: a precondition a user can reach deserves a reason of its own.Pinned matched-pair style in the
fidelity matrix diagnosticsgroup oftest/unit/source/chart_grammar_source_generator_test.dart— the divergent-donutStylerefusal sits beside a control proving the SHARED non-default style emits and is carried, the other four divergences are refused together, and the ring-colour refusal sits beside the single-donut control that emits. - shares one
What stays refused in the radial families is narrow, and mostly named: a radial-bar or gauge chart (no grammar geometry at all), a concentric composition whose rings are unnamed or share a name (the ring key is the series name), a concentric composition whose rings disagree on
donutStyle/selectionStyle/unit/sliceRadiusConfig/sliceGroupingConfigor in which any ring carries a seriescoloror a centre of its own, and a per-pointPointStylecarrying more than a colour and a size. Of those, everything except the five ring-config divergences and the ring colour is refused with a reason of its own; those six are the catch-all cases described above.concentric_donut_page.dart's livedonutCenterBuilderis a widget builder the capture layer does not model at all; the emitted chain carries the portableConcentricDonutConfig.centerContentthat builder's own doc names as the artifact fallback — a capture-layer limitation shared with the config form, not a grammar one, and pinned in that page's test. -
Not in V1 (still deferred)
Deferred deliberately, so the V1 mark list stays closed:
- The radial families with no geometry. Radial bar and gauge have no
geom*verb; author them with their config APIs. (Pie, Donut, Concentric Donut and Polar Column ARE grammar geometries as of V2.0 — see Radial geometries above; Range Area is Cartesian and IS one — see Bands:geomRangeAreaabove.) Faceting a radial spec is refused by name (facetedRadialUnsupported). - Faceting / small multiples. These lower to multiple widgets plus a
ChartInteractionGroupController, which is a different shape from "one spec, one chart". - Log and time scale objects. Axis scaling stays in
XAxisConfig/YAxisConfig. - String-column data adapters. The grammar reads typed rows through typed
accessors; there is no
data['column']form. - Opacity channels and value-driven area fill on non-scatter families.
Colour channels now bake onto
geomBar/geomLine/geomArea(area colours the top edge, not the fill) andgeomBarcarries a linearsizeBywidth multiplier; opacity on non-scatter families and value-driven area fill remain deferred. - The remaining chart-level options.
legendStyle, the toolbar toggle,interactiveAnnotations,maxAxesPerSide, the axis-swap / normalization knobs, width/height and background live onBravenChartPlus, not onPlotSpec/BravenPlot. (The grid, title/subtitle and legend toggle ARE carried as of V2.0 — see above.) - Grammar-source emission of config-authored single-axis charts. The
Workbench Grammar form reproduces charts document-for-document; a chart on the
widget-level
yAxis:path (a series whoseyAxisIdis unset) is diagnosed rather than emitted, because the grammar always binds every series to an explicit axis. - Stat reactivity unification.
TrendMarklowers onto the existing trend annotation statistics; the grammar adds no new statistics of its own.
See also
- Public API overview
- Chart Workbench — the Chart / Data / Split / Source chrome the showcase page renders every preset inside
- Portable chart artifacts — the documents the parity suite compares