The chart family
August 20, 2026 ยท View on GitHub
For whoever changes a chart. This page sits at the
frameworks/root, besideAGENTS.md, for the reason that page gives for sitting there: a fact about the layers belongs to none of them, and a copy per layer is a copy that can disagree. Every rule here binds both layers, because the arithmetic behind a chart is authored once and held byte for byte identical between them.
It is a page of its own rather than a section of the roof because a chart is one category of one kind of component, and the roof is read by whoever changes any component at all. What binds every layer is there; what binds the eight charts is here, and the route to it is one link.
components/charts/ carries the worked example, and the rule that goes with it. Three modules
sit there rather than inside a chart or at the layer root, because every chart reads them and
nothing outside the category does: ChartScales.ts maps a datum to a number,
ChartAxis.ts lays out the plot frame, ChartMarks.ts turns numbers into an SVG path string.
Two shapes inside them are decisions rather than taste. A scale is plain data and the mapping
is a free function, never a closure factory: check-shared-arithmetic.ts compares function
bodies, and a factory hides its arithmetic inside a returned lambda the gate never reads, while
an Angular template cannot call a closure held in a computed() without rebuilding it every
cycle. So arenaLinearScale(min, max, from, to) returns a record and arenaScaleValue(scale, value) reads it. A y axis passes an inverted pixel range, bottom first, which is why "up is
more" needs no minus sign and arenaScaleZero(scale) falls out of the same arithmetic.
A role="img" subtree is presentational, and that one fact splits the chart's keyboard story
in two. Nothing focusable inside the graphic reaches a screen reader however correct its ARIA,
so the reader of a screen reader gets the hidden table, which is already there, and a sighted
keyboard user gets a data cursor. One tab stop for the whole plot, always: the rail carries
it whether it overflows or not, and the cursor moves inside the region rather than adding stops,
which is what focus.roving means in contracts/behaviour/figure-with-data-table.json. Arrows
clamp and never wrap, because an axis has ends and wrapping loses the reader's place.
Tap to read, drag to scroll. arenaPointerUpdates(pointerType, phase) is the whole rule: a
pointerdown always reads, and a pointermove reads for every pointer except touch, because a
touch move is a scroll and hijacking it is how a chart eats the page. Nothing calls
setPointerCapture and nothing calls preventDefault on pointerdown, both of which would
take the scroll away. No touch-action is set, deliberately: pan-x would block vertical
page scroll over a 280px-tall element, and the browser already pans the rail natively because
the component never intercepts it. A lifted finger has no leave, so a touch reading persists
until the next tap or Escape, while mouse and pen clear on pointerleave.
A chart takes series, and a series names itself. ArenaSeries is one member where five loose
ones would each describe ONE series, which is a shape that leaves a chart drawing two with
nowhere to put the second. Its label is not decoration: it heads that series' own column in the accessible table,
so a reader of the table never has to work out which number belongs to which line. The
component's own label is the chart's name and a different thing, and both are required and
guarded for the reason contracts/api/MemberForms.md gives. A series with no identity of its
own takes the ramp slot its POSITION gives it, so two series are never the same colour by
accident, and the ramp still clamps rather than cycling. A missing number is not a zero: a
series shorter than the others stops there, leaves an empty cell in the table, and draws no
mark, because inventing the difference is the one thing a chart may not do.
A domain carries its own step, and that is what puts zero on a tick. An axis that has to
hold a negative value cannot take its step from the count alone, because the two sides of zero
are not the same size: arenaNiceDomain(min, max) rounds ONE step to a nice number and then
rounds each end out to a whole number of steps, so zero lands exactly where a tick does and the
strong rule has something to sit on. arenaDomainTicks reads the count back out of the domain
rather than assuming four, and it has to: the step is what is nice, so the count is what gives.
The nice number is the STEP and never the ceiling, and getting that backwards costs half the
plot. arenaNiceStep snaps a rough step up to 1, 2, 2.5, 5 or 10 times its magnitude, and it
is handed the range divided by the tick count. Handing it the range itself, whose nice value is
then quartered, is what costs the plot: a maximum of 510 snaps to 1000 before anything is
divided, so forty-nine percent of the plot draws nothing, and a maximum of 22 produces ticks at
6.25. As it stands, across every whole-number maximum to two thousand, the worst axis spends a
third of itself on nothing rather than a half, and four of them draw a fractional step where
ninety would. Its own suite asserts those two as sweeps rather than pinning a table of values,
because a table of values is what lets a wrong shape sit unquestioned. The name says Step for
the same reason: arenaNiceMax, on something used as a maximum, is the name a mistake of this
exact shape hides behind. arenaScaleValue does not clamp: a scale maps, and where a value may not go is
the caller's rule. The doughnut keeps its own floor, because a negative share of a whole is
meaningless, and the bar and line charts keep none.
A shared appearance module is a manifest, and these charts have none by charter, so the
tooltip's arithmetic and its appearance part company. ChartTooltip.ts is paired and holds
arenaTooltipAnchor(x, y), which is where the hovered datum meets --chart-tooltip-offset; the
style objects stay where each layer already kept them. Angular's were module-level constants
duplicated across two components and now sit once in ChartTooltipStyles.ts, which is
deliberately not in PAIRED because its members are constants rather than functions, and
check-shared-arithmetic.ts compares only functions. React has no counterpart on purpose:
check-appearance.ts excuses a chart that draws geometry but not a loose module beside it, so
lifting the same literals out of the JSX would ask for the manifest the charter refuses. Do
not add one. ChartLegendStyles.ts is the second file that rule produced, on the same reading
and the same asymmetry: Angular's cartesian legend constants were about to be duplicated across
the bar and line charts, so they sit once, unpaired; React's stay in the JSX that draws them.
A cartesian legend is a key and not a control, so it takes no focus. It renders
aria-hidden="true", and that is the honest reading of the pattern rather than an omission:
contracts/behaviour/figure-with-data-table.json already refused a hidden listbox mirroring the
marks, because it is "a second copy of the same numbers in the same DOM", and a focusable list of
series names is that copy, since the accessible table already heads each column with the series'
own label. So the plot keeps its one tab stop and no chart binding gains an exception or an
addition. The doughnut's rows are the deliberate opposite and stay <button>: activating one
emits sliceActivate, and a ring has no sequence for a data cursor to walk, both of which its own
binding records.
A curve is monotone cubic and never Catmull-Rom, and that is the rule rather than a
preference. Catmull-Rom is shorter and is what most libraries reach for, and it overshoots:
between two measured points it draws a peak or a trough nobody measured. On an axis that holds
negatives it will dip a line under zero on data that never went there. Arena already refuses to
invent the difference when a series is short, on the ground that a missing number is not a zero;
fabricating the values BETWEEN two numbers is the same lie with better manners. So
arenaCurveTangents uses Fritsch-Carlson with a turning point forced flat, and its suite asserts
the PROPERTIES by sampling the cubic rather than pinning the path string: inside the band its own
two points define, no valley beside a spike, no zero crossing the data did not make.
A stack's radius belongs to the outermost segment of each DIRECTION, not of each bar. A
category holding both signs ends two runs and each gets its corner; every joint between segments
stays square, because a rounded joint reads as the end of something. ChartMarks.ts needed
nothing for this: arenaBarPath already takes a value end and a base end and rounds only the
value end, so a stack segment is that function with the base moved off the zero line and an
interior segment is the same call with a radius of nothing. The forked piece is the domain:
arenaStackDomain sums the two directions separately per category and takes the extremes of those
sums, where arenaSeriesDomain takes the extremes of the individual values.
Which arrow pair moves a data cursor is a property of the chart, so the cursor functions take
the axis and a chart answers one pair only. contracts/behaviour/figure-with-data-table.json
names both pairs and every chart excepts the one it does not use, which is verbose and is what the
flat requires map is for: one exception cannot quietly excuse three. A chart must not consume
the pair it has no sequence for, because preventDefault on ArrowUp takes the page scroll away
from a keyboard reader who only wanted to pass through, which is the reasoning that already keeps
touch-action off the rail. Both render suites press the idle pair and assert the cursor did not
move.
The value gutter is measured and chart.pad-left is its floor. A tick label is written by
the caller's own writer, so a prefix, a suffix, a locale and a fraction count all reach it, and a
gutter fixed at the pad renders 100 pts as 00 pts with nothing a consumer can do about it:
not a member, not a token, not application CSS, not a style plugin. arenaValueGutter(domain, write) writes every tick the axis will draw and takes the widest, and arenaPlotBox takes that
gutter rather than reading the pad itself. The measurement is arithmetic and never a
measurement of the DOM, which is what keeps it in the paired module and out of a layout pass:
a tick is set in the mono face, so its width is its length times chart.tick-char, and that
token is a consequence of the type scale the way chart.pad-bottom is. It holds only while the
ticks are monospaced. The pad stays as the floor, so every chart whose ticks already fit draws
what it drew, and the gutter takes its extra room from the plot rather than from the box.
A label on a bottom axis is centred on the mark it names, and the overhang at either end is
what that costs. Half of the last label falls past chart.pad-right and half of the first falls
into the value gutter, and neither can be reserved for: the gutter's arithmetic reaches a tick only
because a tick is monospaced, while a bottom label is set in the body face, which is the consumer's
own typeface. A token holding a proportional advance is a guess, wrong for any project whose face
is wider than the one it was measured against, and a plot reserving room by guessing is the DOM read
this rule refuses, made worse by being silent. Clamping the anchor instead is the trade that
cannot be made, because it buys those pixels with the one thing an axis label is for: an end
label anchored start begins at its mark's centre and runs off to the side, so it reads as the
neighbour's label, and where a label is wider than half a band it overlaps that neighbour outright.
A label that overhangs the plot by a few pixels is a label in the wrong place; a label centred on
the wrong mark is the wrong label. ArenaHorizontalBarChart and ArenaPyramidChart were already
paying the overhang on the same edge, so this is one rule across the five rather than a rule across
three and a silence about two, and both render suites assert the bottom row of every plot that
draws one writes a single anchor.
A gutter that a style plugin could answer is a role that cannot exist, and this is where that
is recorded. A role is a custom property with no value, answered in the cascade; a plot
position is JavaScript arithmetic that produces an SVG attribute, and reading a custom property
back out of the cascade to divide by it would put a layout pass between the data and the mark.
So the geometry belongs to the tokens, the tokens belong to the scales, and what a plugin can
say about a chart is what it can say about ArenaChartCard. That is the whole consequence of
the charter above: these charts carry no manifest, so they carry no slot, so they carry no part
hook, and a product's data visualisation is the one region of its screen a style plugin cannot
re-skin. It wears the skin all the same, because every value a chart paints is a token the
palette moves: the ramp, the surfaces, the muted ink, the mono face and the hairline.
Every mark of text inside a plot is set from the dz ladder, and none of it from fs. A tick,
a category name, a point label, a legend key and a tooltip are all chrome around data rather than
prose somebody reads, which is the cut contracts/design/Scales.md states for the two ladders. It
is also what makes the paragraph above true rather than nearly true: fs is one of the two ladders
a style plugin may re-value directly, so a plot with a single mark on it had half its text moving
with a plugin and half of it not, and the coherence a reader saw was whatever the plugin had not
touched yet. On dz the plot moves with density, which is a property of the surface it sits on,
and with nothing else.
A chart whose categories run down the plot carries no scrolling rail, and that is a decision
rather than a gap. ArenaBarChart overflows sideways when the points stop fitting, and sideways
is a direction a page does not use. Down is the direction a page already scrolls, so a rail there
would nest one scroller inside another and eat the gesture a reader was making to leave.
ArenaHorizontalBarChart and ArenaPyramidChart take the room through height instead, which on
that axis is what the data grows along. chart.pad-category is its own token rather than a wider
chart.pad-left because the two gutters hold different things, and sizing every chart's value
gutter for the longest category name anybody might write would spend it on every vertical chart in
the library.
A scatter takes a different series TYPE, and that type carries two parallel arrays rather than
an array of pairs. ArenaPointSeries is not a variant of ArenaSeries: the two disagree about
what a mark is, since an ArenaSeries value takes its place on the axis from its index and a pair
carries both coordinates. Folding them together would hand every chart in the library a member
most cannot use. The two arrays are not a workaround either: R1 in contracts/api/MemberForms.md
says a predefined object may hold an array of primitives and may not hold an array of objects, on
the recorded ground that an array of objects reopens a nesting depth the reader has no bottom for.
So the pairing is by index, which is the pairing labels and values already make on every other
chart, with the same rule when the two do not line up: a mark is drawn only where both arrays have
a value, because a pair with half a coordinate is not a point.
A bubble maps its value onto AREA and never onto the radius. A reader compares the blot, and
doubling a radius quadruples it, so a value four times larger drawn at four times the radius shows
as sixteen times the ink. arenaRadiusAt interpolates the squared radius between
chart.bubble-r-min and chart.bubble-r-max, and its suite asserts the ratio rather than a table
of radii, because a table of radii is what would have let the linear version pass. It is the same
class of lie the monotone curve refuses and the mirrored pyramid axis refuses: a drawing that
says something the numbers do not.
A member required only sometimes is guarded at render and not declared in the contract.
sizeLabel becomes required the moment any series carries r, and a contract has no way to say
that. The guard throws with the reason, the way label does, rather than falling back to a
heading that satisfies the table mechanically and names the quantity to nobody.
A cursor over marks with no sequence walks them in the order the table lists them. A scatter
has no order of its own, so the cursor goes series by series and within a series in the order
given, which is exactly what arenaPointTable emits. Sorting by x was weighed and refused: it
jumps between series and reads as one sequence where there are several, and it would put the two
readings of the chart into an order the table does not have.
A polar grid keeps its own floor at the centre, and its labels ride outside it. A radius
cannot be negative, so a value below zero is drawn at the centre rather than on the opposite axis,
where it would land as a different datum entirely. That floor is the chart's, not the scale's,
which is the same division arenaScaleValue already states and the doughnut already uses. The
label ring is arenaRadarRadius plus one label gap, and the grid is inset by chart.pad-bottom
to leave room for it: a grid that reached the edge of its box put every axis label under the
vertex of a full-value polygon, which is what a screenshot showed and no gate could. The labels
anchor by the side they point to, start on the right half and end on the left, because a
centred label on a horizontal axis reaches back over the shape it names. ChartPolar.ts starts at
12 o'clock and runs clockwise, the same as arenaDoughnutSlices, so two radial charts on one page
never begin in different places.
A pyramid negates its first series when it DRAWS it and nowhere else. Both sides carry counts,
the accessible table reads the numbers that were passed, and the axis is written in magnitudes,
because a tick reading a minus would say one side is a debt rather than a count. A negative value
is not corrected: it crosses the centre and draws on the other side, which is what the number says
and what the table says too. Taking the magnitude quietly would make this the one place in Arena
where the picture and the table can disagree. arenaMirrorDomain measures the larger side and
reaches the same distance on both, because two halves scaled to their own maxima look balanced
whatever the data said.
It appears at two series and not at one, and that is a consequence rather than a member. One
series is already named by the chart's label and by the only value column in its table, so a
one-row legend restates the chart's name and buys nothing with the plot height it spends. The
count is the whole rule, which is why the charts that shipped before it draw the same bytes they
drew: arenaLegendShows(count) is the only place it is written. The strip comes OUT of the plot,
so --chart-height stays the height of the whole component, the same trade the doughnut makes on
the other axis when its legend column takes width from the ring.
ArenaPlotBox spells its size w and h rather than width and height, because
check-dimension-literals.ts reads a property named width as a CSS dimension and follows the
local it was assigned from, so a plot box would report its Math.max(1, ...) floor as a raw px
forever. The floor is a guard against dividing by a collapsed container, not a dimension anybody
chose, and there is no token that could stand in for it. Renaming the field says what the record
is; four exemptions would only say that the gate was wrong four times.
That is a rule and not one record's quirk: any geometry a chart hands to a height or a
width attribute comes back inside a record, so the site reads strip.plotH rather than a bare
local. The gate follows an identifier used bare and stops at a member access, which is the whole
difference between a floor it reports forever and a floor it never sees. arenaLegendStrip
returns { plotH, stripH } for that reason and for no other; a pair of numbers would have been
shorter and would have cost an exemption per call site.
Each is named by PAIRED in scripts/check/arena/check-shared-arithmetic.ts, which compares
every function two copies export under one name, so the two copies are authored byte for byte
identical and add no DIVERGENT entry. That is only reachable while they hold no layer type:
arithmetic and path strings go in the paired module, and every style object stays in the
component that draws it. They also carry no comment, because allowsHeader() in
scripts/check/arena/check-docs.ts grants one only under scripts/ or a test path, which is
why the reasoning is here instead. DataVisuals.ts stays at the layer root beside them and
keeps the colour contract and the number writer, since arena-calendar-event reads
arenaCatColor(slot) too and a module a schedule grid consumes is not chart internals.