Skip to content
HDCharts

Entry Seam and Chart Policy

A chart takes public data, checks it, wraps it in the render model, clamps its style, then draws. Everything up to the clamp is written once, in ChartEntry in charts-core. A chart declares one ChartSpec object saying which checks apply to it and how it answers them.

The pipeline

Loading diagram…
StepWhat happensWhere it runs
ValidateCompares the data against the policy and against the values the style holdsSeam
ConvertWraps the validated data in the render model; only stacked bar changes its shapeSeam, through the spec's convert
ClampPuts style values into their drawable ranges at the current densitySeam, through the spec's clamp
DrawPicks a domain, aggregates, resolves colors, lays out header, plot and legend, and drawsThe chart's content composable

Everything up to and including the clamp is the seam. Each step is remembered against the values it reads, so it runs once per data, style, title or density change rather than on every recomposition.

Clamping is last rather than in stage order because invalid data returns before it: nothing is clamped for a chart that is about to render errors.

The content composable holds the draw step because drawing works in pixels. The domain and the normalization are not in that category — neither needs a measured size — so they could be hoisted to the seam too, and whether they should be is an open question. The catch is caller-chosen range bounds: line resolves its domain from style.range, so a caller can set a domain the data falls outside, and the clamp in normalizeValue is what keeps the draw inside the plot. Validation Errors has that case. Style Clamping has what clamping decides.

A chart that has one public composable lets its entry call the content directly. A chart with two public composables passes the content in as a lambda, so both share one entry — line is the case, and LineChartEntry takes a content lambda because LineChartImpl and LiveLineChartImpl differ.

The spec

ChartSpec is the whole of a chart's input declaration. One object per chart:

object LineChartSpec : ChartSpec<LineChartStyle> {
    override val policy = ChartPolicy(
        minValues = ValidationErrors.MIN_VALUES,
        allowNegative = true,
        singleSeries = false,
        hasAxis = true,
        hasFixedRange = true,
        colorsMatch = { data -> data.series.size },
    )

    override fun validationInputs(style: LineChartStyle) =
        ChartValidationInputs(
            colorCount = style.line.colors.size,
            rangeMin = style.range.min,
            rangeMax = style.range.max,
            xLabels = style.axis.xLabels,
            yLabels = style.axis.yLabels,
        )

    override fun clamp(style: LineChartStyle, density: Density) = style.clamp(density)
}

and the entry is one call:

ChartEntry(
    spec = LineChartSpec,
    data = data,
    style = style,
    errorStyle = style.chartContainerStyle,
    content = content,
    modifier = modifier,
    title = title,
)

LineChart and LiveLineChart share both, and differ only in what they draw below the seam. The four values that have to change together — policy, style mapping, clamping, conversion — are one declaration, so they cannot drift apart, and a chart that has two public composables states them once.

clamp is the one member no chart varies: all six specs delegate to the clamp(density) extension next to their own style, because the rules belong with the values they describe and the seam must not know any style. It stays on the spec so a chart that draws another chart's style has somewhere to map between the two.

The policy

ChartPolicy answers one question: which checks apply.

FieldTurns on
minValuesThe fewest values a point needs
allowNegativeNegative values reported as errors
singleSeriesvalidateSingleSeries, which matches the color count against the value count
hasAxisThe X and Y axis label checks
hasFixedRangeThe range-bound check
colorsMatchHow many colors the style must set, or no check at all

A policy cannot read a style, so the style values travel beside it in ChartValidationInputs: the color count, the range bounds, and both axis label styles. Each chart maps its own style onto them in its spec's validationInputs, next to its entry — LineChartSpec.validationInputs in LineChartEntry.kt.

The seam runs the enabled checks in one order: data shape, then range, then axis labels. Two charts with the same policy report the same problems in the same sequence.

The checks live in DataValidation.kt and AxisLabelValidation.kt. Validation Errors lists what each one reports.

Neither type has a default value, and no parameter of a check function has a default either. A chart writes colorCount = 0 or rangeMin = null to say it has no colors and no range. Leave a field out and the build fails.

A policy that declares an axis must supply both label styles. A missing pair is reported as an error: an axis with nothing to validate would draw unclamped labels and report nothing about them.

When the color count depends on the data

colorsMatch is a function, so a chart that cannot state the expected count as a constant says how it derives it. Radar is the case that needs it: one series cannot be mis-coloured, so the check runs only when the data holds more than one.

colorsMatch = { data -> data.series.size.takeIf { count -> count > 1 } }

null skips the check. The alternative — reporting colorCount = 0 — makes "this chart has no colors" mean "this chart declines to check", which is a rule hiding in a style value.

A singleSeries policy always matches its value count and never reads colorsMatch. Bar and histogram still declare one, so that their rows read the same way as every other chart's and a reader does not have to know the rule to compare two rows.

The policy table

Each chart has a row here and a row in ChartPolicyConformanceTest, in the charts module. Both are written by hand and change in the same commit.

ChartminValuesallowNegativesingleSerieshasAxishasFixedRangecolorsMatchOn the seam
Line2yesnoyesyesseries countyes
Bar2yesyesyesyesvalue countyes
Histogram2noyesyesyesvalue countyes
Radar3yesnononoseries count, only with more than one seriesyes
Stacked bar2nonoyesnoseries countyes
Stacked area2nonoyesnoseries countyes
Pie——————exception

Every chart except pie is on the seam. Bar and histogram draw the same plot, so they share BarChartInternalPlot. Their specs differ in the policy — histogram forbids negative bin heights — and their entries differ in what they pass the shared plot: aggregate = false, because compact histogram bins are already the aggregation, and a histogram test tag.

convert has a default implementation that passes the data through, so a chart whose render model is the caller's own data does not write one. Stacked bar is the only chart that overrides it, because it stacks segments and so transposes to one series per bar.

Moving a chart means writing internal/<Name>ChartEntry.kt with the chart's spec — policy, style values, clamping and conversion — pointing the public composable at it, and deleting the validation, clamping and conversion it used to do. The seam runs those steps in the same order, so the chart behaves the same. One chart per change; the module's tests and the screenshot baselines show whether anything moved.

The public composable keeps what is not an input stage: the selection lifecycle, and the layout. The selection index is resolved before the seam, because it counts the public data rather than the render model.

Pie has its own path

Pie takes List<PieSlice>. The seam has no ChartData to validate and no render model to convert into. Pie has no Cartesian axis, and it formats its readouts from its own helper. It validates slice values with validateValues and draws without a spec.

Adding a check

A message goes in ValidationErrors and the check goes in DataValidation.kt. Charts write no message text and no check of their own.

A check that applies to every chart calling a function goes into that function. A check that applies to some charts gets a ChartPolicy field, read in ChartPolicy.errorsFor. A chart that has not declared the field keeps running the older check, so the field and the behaviour it stands for move in the same commit.

Tests

BehaviorTests
A policy field turns on the check it namesChartPolicyTest in charts-core
Invalid data never reaches content, and clamping runs at the ambient densityChartEntryTest in charts-core
Each declared policy matches its rowChartPolicyConformanceTest in charts
A chart shows its errors for invalid inputEach chart's own tests

charts-core cannot see the chart modules' specs without a test dependency that inverts the dependency direction, so the table's test lives in the umbrella charts module, which already depends on every chart. The five scalar fields are compared by equality; colorsMatch is compared by asking each policy what it returns for one series and for two.

That test pins the table against the declarations, not against the codebase. It cannot notice a chart that has a spec and no row, because its chart list and its expectation are written side by side in one file — a new chart has to be added to both by hand.