README.md
Chart
Data-visualisation family built on the in-house, zero-dependency plotting
engine $libs/plots (scales · shapes ·
ticks · curves). SVG, themeable via design tokens, composable primitives plus
ergonomic presets.
<script lang="ts">
import { LineChart } from '$uix/eidos/components/chart';
</script>
<LineChart data={revenue} x="month" xScale="point" y={['revenue', 'expenses']} />
Surface
<Chart>— the compositional frame. Measures its width, computes the scales fromdata+x/y, and shares them with SVG children through context:<Chart.Grid>(alias<Chart.CartesianGrid>, x+y lines),<Chart.XAxis>,<Chart.YAxis>,<Chart.Line>,<Chart.Area>,<Chart.Bar>,<Chart.Scatter>, plus the config children<Chart.Tooltip>(cursor-following readout) and<Chart.Legend>(HTML legend below the plot). Point scales span edge-to-edge so the line/area touch the y-axis and the right edge.- Presets (named exports) —
LineChart,AreaChart,BarChart,ScatterChart,Sparkline(line oras="bars"). They compose the frame + axes + grid + marks + the HTML chrome (title + legend), with the hover tooltip on by default (tooltipprop). - Standalone (named exports, own SVG/HTML — non-cartesian geometry) —
<PieChart>(pie + donut viadonut={0..1}),<RadarChart>(spider),<BarList>(ranked rows),<BarSegment>(single stacked bar). Each owns its own themed hover tooltip.
All live in this one family directory — new chart types are added here as named
exports, never as separate top-level component directories. The improvement
roadmap (incl. the MUI X Charts comparison) lives in
IMPROVEMENTS.md.
Baseline
No Air baseline — charts did not exist in the legacy air/ layer. The
family is net-new, built on the in-house $libs/plots engine. The design
references are LayerCake / Pancake (compositional, Svelte) for the API shape and
d3-scale / d3-shape (algorithms, reimplemented) for the math — see
$libs/plots.
Passive justification (F-1.5)
scope: ['eidos'], 0 events. A chart is a derived render of its data
(data → scales → SVG), like Avatar, Image and QrCode. It has no
interactive state machine and emits no perceptual signal, so there is no soma
behaviour and no sema projection. The opt-in hover tooltip is handled
entirely inside the eidos frame (local pointer tracking → bisector.center →
crosshair + readout); it stays a passive display because it neither mutates
component state nor projects a sema signal — no morfo event is involved.
A2.3 exception — data marks
The mark parts (line, area, bar, point, slice) declare no data-*
beyond their part marker (data-chart-{kebab}). Their visual treatment is the
per-series colour, set inline from the theme palette
(var(--color-{role}-solid)) — there is no enum state to project as a
data-*. The axis carries data-chart-axis="x|y".
Subset (G-1.1)
Series colours cycle the canonical role palette via seriesColor(index):
primary · affirm · risk · secondary · fulfill · threat · loss · neutral. A
mark's color prop accepts any ColorRole. Charts do not take an intent
prop — the palette is categorical (series identity), not evaluative.
Comparativa
| Capability | Chart (UIX) | Chart.js | Recharts | unovis | LayerCake |
|---|---|---|---|---|---|
| Zero runtime deps | ✅ (own $libs/plots) |
❌ | ❌ (d3) | ❌ (d3) | ⚠️ (BYO d3) |
| Render | SVG | Canvas | SVG | SVG/Canvas/HTML | BYO |
| Compositional API | ✅ + presets | ❌ (config) | ✅ | ⚠️ | ✅ |
| Themed by tokens | ✅ (--color-*) |
⚠️ | ⚠️ | ⚠️ | ❌ |
| Svelte 5 runes-native | ✅ | ❌ | ❌ (React) | ⚠️ wrappers | ✅ |
Line/Bar/Area/Scatter/Pie/Sparkline cover the dashboard baseline. Heavy types (candlestick, sankey, treemap, geo, 3D) are out of scope — see Gaps.
Decisiones
- One family, one morfo.
chartis a single eidos component (like a layout family). The presets + pie sharechartMorfo(provider + grid + axis- line/area/bar/point/slice + legend + title, all optional). New chart types reuse the contract instead of inventing one each.
- Provider = the
<svg role="img">. The svg is the labelled graphic (aria-label); the responsive wrapper is the eidos-onlydata-chart-frameshell. Matches theQrCodeprecedent. - Pixel-space marks. Marks consume already-scaled points from the context, so shapes stay decoupled from scales (the engine's compositional model).
--chart-*recipe for geometry, global tokens for colour. Series colours come from--color-{role}-solidand chrome from--color-content-*/--color-border-*(categorical, theme-driven). Geometric constants (stroke widths, point/bar radius, area opacity, tooltip surface, transition timing) live in achartrecipe block (lib/recipes/base.ts→--chart-*), each sourced from a canonical primitive — no magic numbers inchart.css.- SVG primitives in
MorfoElement. Charts are the first SVG-heavy component;g · rect · circle · line · figure · figcaptionwere added to the element union + schema (additive).
Direction (RTL) — the rule for every chart in the family
Two decisions per chart, and they are not the same decision:
-
Does the composition mirror? Only if the graphic has a READING axis. A time or category series does (
chart's x scale,calendar-heatmap,heatmap's columns,funnel's label gutter,bar-list): under RTL the first datum sits on the right. Radial graphics do NOT (gauge,pie-chart,polar-area,radar-chart,smith-chart) — their positions are angles. Mirroring is done by inverting the pixel RANGE of the x scale, or by reflecting x coordinates (mx()); every mark reads from the same source, so they all move together. Y values are never mirrored — a chart is a sequence read the reader's way, not a mirror image. -
text-anchoris LOGICAL. Underdirection: rtl,endmeans the LEFT side. So:- composition mirrors → leave the anchor logical, it already lands right. Flipping it too cancels the mirroring out.
- composition does not mirror (a y-axis gutter, radar labels) → force the
physical anchor with
rtl.anchor()fromrtl.svelte.ts.
Getting this backwards is silent and looks like the labels walking across the graphic. Measured examples: a y-axis label ran to x=59.9 with the axis at 46; the funnel dropped its whole label column onto the funnel body.
rtl.svelte.ts holds both pieces — physicalAnchor() and createChartRtl(),
which runs the canonical chain prop dir → prefs → 'ltr' through
activeEidosDir, the eidos entry point (a chart has no soma provider to hand
activeDir a Soma). It used to read the resolved direction off the element;
the chain was simply not runnable from eidos until that entry point existed.
Contract: docs/canon/direction-contract.md §7.
⚠️ The raw dir goes on the chart's HTML wrapper (<figure data-chart-figure>,
<div data-chart-frame>), never on the <svg>: the attribute is mapped to
direction by the HTML UA stylesheet, which does not reach SVG elements — an
<svg dir="ltr"> under a dir="rtl" ancestor still computes rtl, measured in
Chrome. Since text-anchor is logical, stamping the wrong node makes the maths
mirror while the labels do not.
Verify by LOOKING, in RTL. Numbers mislead here: the bounding box of a mirrored path is identical to the unmirrored one, and a label 3.4px from its tick measures as "no overlap" while reading as contact.
Gaps
| Item | Disposition |
|---|---|
Hover tooltip + crosshair (bisector.center) |
✅ done (cursor-following, opt-in tooltip prop) |
Stacked bar / area (shape-stack ready in $libs/plots) |
Tier 1 — IMPROVEMENTS.md |
| Highlight/fade · reference lines · secondary axis · interactive legend | Tier 1 — IMPROVEMENTS.md |
Enter animation ($motion) · log scale · gradients |
Tier 2 — IMPROVEMENTS.md |
Keyboard data navigation + <table> data fallback |
Tier 3 a11y — IMPROVEMENTS.md |
| Canvas/WebGL · zoom/pan · candlestick / sankey / treemap / geo / 3D | descartar — out of scope (SVG, "basic") |
Theming
Contrato en lib/recipes/base.ts > chart — 45 claves (17 + 22 + 6 el
2026-08-22). Alcance del censo: 89 % (era 9 %), con global a CERO.
Guard R-5.4 (npm run theming:sentinel -- chart <url>): 43/45 en vivo;
las dos que callan son transition-duration / -ease, adjudicadas con su
medición (el guard congela transiciones a propósito).
Series: var(--color-{role}-solid), inline por serie.
El texto adopta DOS ROLES del canon, a través de una costura. El valor viene de los estilos con nombre; el knob es de chart, que es lo que permite retocar ESTE componente sin mover el rol en toda la app:
--chart-caption-font-size var(--style-caption-font-size) anotaciones: eje,
--chart-caption-fg var(--style-caption-color) leyenda, tooltip, smith
--chart-label-font-size var(--style-label-font-size) cromo: título, bar-list,
--chart-label-fg var(--style-label-color) funnel, empty
--chart-label-font-weight var(--style-label-font-weight) el énfasis
--chart-gauge-label-font-size var(--size-xl-font-size) el readout del gauge
Mismo molde que --accordion-trigger-font-family: var(--style-label-font-family)
(vertebración tipográfica). Tamaño y tinta son ortogonales: empty y
bar-list-value llevan tamaño label con tinta caption. El número del gauge
no tiene rol en el canon —un readout de dato no es un h2— y por eso su costura
toma el bundle de talla.
Lo que la receta SÍ posee es la tinta de lo que no es texto, porque nada más puede nombrarlo, y cada clave es UN concepto que el CSS repite:
--chart-axis-fg eje + rejilla del smith + conector del funnel
--chart-grid-fg rejilla + anillos y radios del radar
--chart-separator-fg el pelo de superficie que separa marcas contiguas:
porción, punto activo, punto smith, pétalo, segmento
--chart-smith-boundary-fg el borde del disco RF, más fuerte que su rejilla
--chart-track-bg el raíl del bar-list y el del gauge
Más la geometría del cromo (gap, los pares row-gap/column-gap de leyenda
y listas, swatch-size, track-height, radios y separaciones del bar-segment)
y la geometría de marca que ya existía (grosores, radios, area-opacity,
timing, superficie del tooltip, crosshair).
track-height lee el bundle de talla (--size-sm-control-height), no el
primitivo crudo --control-height-sm que el CSS traía: el crudo es lo que
recipe-css-contract prohíbe, y su var(--space-7) de respaldo estaba muerto.
Fuera de contrato por identidad: los nueve 100% («ocupa a tu padre») y el
min-inline-size: max-content del tooltip. Son los diez knobs que faltan para
el 100 %, y ninguno es tema.
chart.css es code-split (se importa desde chart.svelte).