Charts
Draw data with Chart and its marks — lines, bars, points, axes, markers — in data units, with touch and click events that carry the datum
Charts
Chart gives a subtree a coordinate space. Everything inside it is a mark
positioned in data units — a Line through [x, y] points, Bars from rows,
an Axis, a Marker that pins ordinary Hypen components to a data point.
Nothing in the DSL ever names a pixel: the chart resolves the domains, scales
the marks, and re-lays them out when state, children, or the viewport change.
Chart(y: [0, 100]) {
Axis(x)
Axis(y, ticks: 4, grid: true)
Bars(data: @state.units, x: "month", y: "count")
.fill("#3b82f6")
.onClick(@actions.selectMonth)
Rule(y: 75).stroke("#f59e0b")
}
.height(240)Charts ship on all five renderers: web DOM, web canvas, Android, iOS and desktop. The mark set and the event payload below are the contract each of them meets, so a chart and its handlers behave the same everywhere. Glow is a true blur on web, Android and iOS; the desktop renderer approximates it with layered translucent strokes.
Data
Every data mark takes its rows from points: / data: (or the positional
argument) and accepts three shapes:
| Shape | Example | Meaning |
|---|---|---|
| Numbers | Line(points: [3, 5, 2]) | x is the index |
| Tuples | Line(points: [[1, 3], [2, 5]]) | [x, y] pairs |
| Objects | Bars(data: @state.rows, x: "month", y: "count") | field names from x: / y: |
A string x anywhere in the chart ("Jan", "Mon") switches the x axis to
categories: bars get equal bands, lines and points snap to the band
centre, Axis(x) labels every category.
Data almost always comes from state: compute the rows in your module and bind them. A chart re-lays out whenever the bound array changes.
export default app
.defineState<{ units: Array<{ month: string; count: number }> }>({ units: [] })
.onCreated(async (state) => {
state.units = await fetchMonthly();
})
.build();Domains
By default the chart's domain is the union of its marks' data, rounded out to
tick-friendly bounds. Bars always include zero so bar heights stay honest.
Pin either axis explicitly when you want a stable frame:
Chart(x: [0, 12], y: [0, 100]) { ... }Room for axis labels is reserved automatically. A chart with no Axis
children is drawn edge to edge — that is a sparkline:
Row {
Text("Revenue")
Chart { Line(points: @state.history).stroke("#10b981") }
.width(120)
.height(32)
}Marks
| Mark | Props | Draws |
|---|---|---|
Line | points/data, x, y, smooth | a polyline, or a smooth curve with smooth: true |
Area | points/data, x, y, smooth | the region between the line and zero |
Bars | data, x, y (or label, value), highlight, barWidth, radius | one bar per row |
Points | points/data, x, y, radius, highlight | one dot per row |
Axis | x or y (positional), ticks, label, grid | axis line, ticks, labels, optional grid |
Rule | x: or y: | a dashed reference line across the plot |
Marker | x, y, anchor | pins its children to a data point |
Path | d | an SVG path whose coordinates are in data units |
highlight on Bars / Points takes an index or a list of indices; the rest
are dimmed. Bind it to state to reflect a selection:
Bars(data: @state.units, x: "month", y: "count", highlight: @state.selected)Marker is the bridge back to normal UI. Its children are ordinary components
with ordinary applicators, positioned at a data coordinate. anchor is one of
top (default, content sits above the point), bottom, left, right,
center.
Marker(x: 7, y: 82) {
Badge("Launch")
}Path is the escape hatch: any shape, in data units, computed in your module.
Path(d: @state.outline).stroke("#8b5cf6")Styling
Marks are styled with the usual applicators. stroke, fill, strokeWidth,
fillOpacity, strokeDasharray and opacity apply to the mark's geometry;
color on the Chart (or any ancestor) sets the default for axes, lines and
fills, so a chart follows your text colour unless a mark says otherwise.
Chart {
Axis(y)
Area(points: @state.revenue).fill("#10b981").fillOpacity(0.2)
Line(points: @state.revenue, smooth: true).stroke("#10b981").strokeWidth(3)
}
.color("#64748b")Any applicator that resolves to CSS applies to a mark, because the mark's
<g> is a normal styling target. What differs from a Box is which
properties SVG geometry honours:
| Want | Use | Notes |
|---|---|---|
| Colour, thickness | stroke, fill, strokeWidth, strokeDasharray, strokeLinecap | inherit into every shape in the mark |
| Translucency | opacity, fillOpacity, strokeOpacity | unitless |
| Glow | glow(color), glow(radius: 10), glow(color: "#f59e0b", radius: 12) | a drop-shadow filter that follows the stroke |
| Shadow | shadow(...), boxShadow(...), elevation(n), dropShadow(...) | on SVG these all become drop-shadow; a box shadow would be invisible |
| Filters | blur, saturate, brightness, hueRotate, filter(...) | compose with each other and with glow |
| Blend | mixBlendMode | overlapping series |
| Animate | transition("d 300ms ease-out"), transition("stroke 200ms") | CSS transitions on d animate a Line between datasets (Chromium); stroke, opacity and filter transition everywhere |
| Cursor | cursor("pointer") | on interactive marks |
Layout applicators (padding, margin, width on a mark) have nothing to
lay out inside an <svg> and are ignored; size the Chart instead.
Interaction
Marks respond to touch and pointer input through the same event applicators as everything else. What differs is the payload: every event on a mark carries the datum it refers to, in data units, never pixels. Handlers therefore work identically on every renderer.
Bars(data: @state.units, x: "month", y: "count", highlight: @state.selected)
.onClick(@actions.selectMonth)
.onLongPress(@actions.showDetails)
Line(points: @state.revenue)
.onMove(@actions.hoverPoint)
.onMouseLeave(@actions.clearHover)The payload:
{ "series": "bars", "index": 3, "x": "Apr", "y": 62, "datum": { "month": "Apr", "count": 62 } }| Field | Meaning |
|---|---|
series | the mark's series: / name: prop, else its kind (bars, line, …) |
index | position of the datum in the bound array |
x, y | the datum's values |
datum | the original row, untouched |
Static arguments travel alongside: .onClick(@actions.pick, tag: "targets")
yields { tag, series, index, x, y, datum }.
Tapping a bar, a point, or a line vertex resolves that datum directly. A hit anywhere else on a line resolves the datum nearest the pointer along x, so a finger landing between two vertices still gets a useful answer. Touch targets around vertices and points are larger than what is drawn.
| Applicator | Fires |
|---|---|
onClick / onPress | tap or click |
onLongPress | press held for 500 ms |
onHover | pointer enters the mark (once) |
onMove | pointer moves across the mark, throttled to about a frame — mouse, touch and pen |
onMouseLeave | pointer leaves the mark |
Events on the Chart itself carry the pointer position in data units
({ x, y }), for "add a point where I tapped" interactions.
Selection and tooltips go through state
A mark holds no selection of its own. The handler writes to state, and the
chart reflects it — highlight for the selected bar, a Marker for the
tooltip.
Chart {
Axis(x)
Axis(y)
Line(points: @state.revenue, smooth: true)
.onMove(@actions.hoverPoint)
.onMouseLeave(@actions.clearHover)
Points(points: @state.hoverPoints).radius(5)
Marker(x: @state.hover.x, y: @state.hover.y) {
Card { Text("@{state.hover.label}") }.padding(6)
}
}.onAction<{ x: number; y: number }>("hoverPoint", ({ state, action }) => {
const { x, y } = action.payload!;
state.hover = { x, y, label: `Week ${x}: ${y}k` };
state.hoverPoints = [[x, y]];
})
.onAction("clearHover", ({ state }) => {
state.hover = null;
state.hoverPoints = [];
})A Marker whose coordinates are missing (here, state.hover is null) is
hidden, so no conditional is needed around it.
Composition
A dashboard is just layout. Charts size to their container like any block, so
put them in Rows, Grids and Cards, and give them a height.
Row {
Card {
Heading("Units")
Chart { Axis(x) Bars(data: @state.units, x: "month", y: "count") }.height(180)
}
Card {
Heading("Revenue")
Chart { Axis(y) Line(points: @state.revenue, smooth: true) }.height(180)
}
}
.gap(16)