Charts/Base
XY Chart
<XYChart /> · 17 examples
Points, lines or bubbles placed by two numbers, with size and colour for a third and fourth.
Overview
Most questions that start with does this go with that? end up here. Taller people weigh more. Hotter servers fail more. Larger pull requests take longer to review, and the exceptions are the interesting part. An XY chart puts one number on the horizontal axis, another on the vertical axis, and lets your eye do what eyes are annoyingly good at: spotting the cloud, the line through it, and the one dot that is definitely not supposed to be there.
The idea is Descartes' (the coordinate plane carries his name, Cartesian, for good reason), and the habit of plotting measurements against each other became routine in the nineteenth century, when scientists discovered that a table of 400 numbers produces glazed eyes and a scatter plot produces an argument. That is the real job of the chart. It does not summarise. It shows you every row and trusts you to find the pattern.
In zopra-charts the XY chart is the plain, unsorted cousin of Trend. Neither axis has to be ordered, rows are drawn in the order you give them, and you can spend two more dimensions on size and colour. Four variables on one flat plot sounds like a crime. Used with restraint it is a very small, very legal one.
Props
API
<XYChart> plots numeric x/y pairs as points, lines, or both. A third value can drive point size, and another can drive point colour. If you have used Grafana's XY Chart panel, every option there has a counterpart here; see Coming from Grafana.
use zopra_charts::*;<XYChart><Series>- Style props
AxisConfigLegendTooltipColorandScheme- Data
- Enums
- Coming from Grafana
Not sure which option fits? See the guide.
<XYChart>
| Prop | Type | Default |
|---|---|---|
data | Vec<T>, Signal<Vec<T>>, Frame<N>, Signal<Frame<N>>, or your own Dataset | required |
| children | <Series> tags | none: series are created from data |
x_axis | AxisConfig | AxisConfig::new() |
y_axis | AxisConfig | AxisConfig::new() |
legend | Legend | Legend::list() at the bottom |
tooltip | Tooltip | Tooltip::single() |
class | Tailwind classes | fills its parent |
key | anything Into<ElementId> | – |
| style props | defaults for every series |
Where the series come from
- With
<Series>children, each child is one series. - Without children, the series come from a
Frame: the first field is x and every other field is a y series. AVec<T>has no field names, so a chart with no children and aVec<T>shows "No data".
The chart remembers which series you hid in the legend and which point is hovered. Give each chart a key when you render several of them in a loop, so that state stays with the right one.
<Series>
| Prop | Type | Default |
|---|---|---|
x | Fn(&T) -> f64 | required |
y | Fn(&T) -> f64 | required |
name | text | "Series 1", "Series 2", … |
size | Fn(&T) -> f64 | every point uses point_size |
color | Color<T>, or any Hsla / Rgba | the next colour from the theme palette |
key | anything Into<ElementId> | – |
| style props | inherited from <XYChart> |
Return f64::NAN from x or y to leave a row out.
Write the row type on the first closure of each tag. The other closures on that tag pick it up:
<Series x={|r: &Reading| r.temperature} y={|r| r.load} />Style props
<XYChart> and <Series> both take these props. A value on the series wins over the chart's value, and the chart's value wins over the default.
| Prop | Type | Default | Notes |
|---|---|---|---|
show | Show | Points | points, lines, or both |
point_size | f32 px | 5. | used when the series has no size |
size_range | RangeInclusive<f32> px | 5.0..=100.0 | smallest and largest point, when the series has a size |
point_shape | PointShape | Circle | |
point_stroke_width | f32 px | 1. | outline around each point |
fill_opacity | f32, 0.0–1.0 | 0.5 | colours with their own alpha keep it |
line_width | f32 px | 2. | |
line_style | LineStyle | Solid | |
hide_from | HideFrom | nothing hidden | hide a series from the legend, the tooltip, or the chart |
Point size grows by area: a value twice as large gets a point with twice the area, not twice the diameter. The smallest and largest size values across all series map to the two ends of size_range.
AxisConfig
AxisConfig::new()
.label("Load")
.placement(AxisPlacement::Right)
.soft_min(0.)
.scale(ScaleKind::Log { base: 10. })| Method | Argument | Default |
|---|---|---|
label | text | worked out from field names (see below) |
placement | AxisPlacement | x at the bottom, y on the left |
width | Pixels | fits the widest tick label |
soft_min / soft_max | f64 | none |
min / max | f64 | none |
scale | ScaleKind | Linear |
grid | bool | true |
border | bool | false |
centered_zero | bool | false |
color_mode | AxisColorMode | Text |
decimals | usize | chosen from the tick spacing |
format | |value| -> SharedString closure | none |
time | – : values are milliseconds since the Unix epoch, shown as dates and times in UTC | off |
utc_offset | i32 minutes | 0 |
reverse | bool: turns the axis around | false |
The axis range starts at the data's extent, rounded out to tidy tick values (whole powers for a log scale).
soft_min/soft_maxwiden the range when the data doesn't reach them, but never narrow it.centered_zeromakes the range symmetric around 0.min/maxpin the range exactly, and data outside it is cut off.
A log scale ignores zero and negative values. With no data the range is 0 to 1.
The label, when you don't set one, is the field name if every series shares it, or the words common to the start and end of the field names. Only Frame data has field names.
Tick and tooltip text uses format if you set it. Otherwise it uses decimals. Otherwise the decimals are chosen from the tick spacing. format takes a closure that doesn't capture anything; end it with .into():
AxisConfig::new().format(|v| format!("{v:.0} °C").into())Legend
Legend::table().right().width(px(240.)).calcs([Calc::Mean, Calc::Max]).sort_by(LegendSort::Calc(Calc::Mean)).sort_desc(true)| Method | Argument | Default |
|---|---|---|
Legend::list() / table() / hidden() | – | list |
bottom() / right() / placement | LegendPlacement | Bottom |
width | Pixels | fits its content (right placement only) |
calcs | a list of Calc | none |
sort_by | LegendSort::Name or LegendSort::Calc(..) | series order |
sort_desc | bool | false |
limit | usize | no limit |
wrap | bool | false: long names are cut off with an ellipsis |
mode | LegendMode | List |
Clicking a legend item shows only that series; click it again to show them all. Ctrl-click (Cmd-click on macOS) shows or hides a single series. Hidden series stay in the legend, dimmed. The axes rescale to the series still shown, while colours and point sizes stay the same.
Calcs
Calcs summarise each series' y values, skipping NaN. They always appear in this order, whatever order you list them in:
Calc | Shown as | Meaning |
|---|---|---|
First / Last | First / Last | first / last value |
Min / Max | Min / Max | smallest / largest |
Mean | Mean | average |
Median | Median | middle value |
Sum | Total | sum |
Count | Count | number of values |
Range | Range | Max - Min |
Diff | Difference | Last - First |
Variance / StdDev | Variance / StdDev | spread of the values |
Tooltip
| Method | Argument | Default |
|---|---|---|
Tooltip::single() / hidden() | – | single |
max_width | Pixels | none |
max_height | Pixels | none |
The same Tooltip type is shared with <Trend>, which also uses Tooltip::multi(), sort and hide_zeros. The XY Chart shows multi() like single(), as Grafana's XY Chart does.
Hovering a point highlights it and shows a tooltip next to it. The tooltip has:
- the series name and the point's colour;
- the x and y values, each labelled with its field name or axis label;
- the size and colour values, when the series has them.
Series with hide_from.tooltip are still highlighted on hover, but show no tooltip. Points from series drawn only as lines can't be hovered.
Color and Scheme
color={rgb(0x89b4fa)}
color={Color::by(|r: &Reading| r.memory, Scheme::GreenYellowRed)}
color={Color::by(|r: &Reading| r.load, Scheme::thresholds(vec![(0., green), (80., red)]))}Color<T> | Meaning |
|---|---|
Color::fixed(color), or any Hsla / Rgba | one colour for the whole series |
Color::by(value, scheme) | each point is coloured by its value, through scheme |
Scheme | Meaning |
|---|---|
GreenYellowRed, RedYellowGreen, BlueYellowRed, YellowRed, BluePurple, YellowBlue, Blues, Reds, Greens, Purples, Oranges | gradients from the theme |
Named("…") | a gradient you added to your theme file |
Scheme::gradient(colors) | your own gradient |
Scheme::thresholds([(value, color), …]) | fixed steps: a value takes the colour of the highest threshold at or below it, and values below the first threshold take the first colour |
gradient and thresholds take a Vec or a &'static slice. Prefer a static slice: it costs nothing to rebuild on every render. See Static colour lists.
A gradient spans the smallest to the largest colour value across all series that colour by value, so equal values look the same in every series. Points with a NaN colour value use the theme's fallback colour.
Data
Which data type
data | Use it when | Recomputed |
|---|---|---|
Vec<T> | the data is fixed, or comes from props | on every render |
Signal<Vec<T>> | the data lives in your component's state or changes over time | only when the signal is written |
Frame<N> | the data is a table of named numeric fields | on every render |
Signal<Frame<N>> | a table that lives in state or streams in | only when the signal is written |
Frame<N>
A table with N named numeric fields, one row per [f64; N].
| Method | Meaning |
|---|---|
Frame::new(names) | an empty frame with N field names |
Frame::from_columns([(name, values), …]) | a frame built from columns; short columns are padded with NaN |
.with_rows(rows) | sets the rows, from a Vec<[f64; N]> or an array of them |
.push(row) | appends a row |
.size(field) | without <Series>, sizes every series' points by this field |
.color(field, scheme) | without <Series>, colours every series' points by this field |
.index(field) | the position of a field |
.get(field) | a value accessor for <Series>, found by field name |
Without <Series>, the size and colour fields aren't drawn as series of their own. Words shared by every series name are trimmed: cpu load host-a and cpu load host-b become host-a and host-b, and the y axis is labelled cpu load.
With <Series>, read fields by position (x={|r: &[f64; 3]| r[1]}) or by name (x={frame.get("Requests")}).
Your own data source
Implement Dataset<T> to chart data stored some other way:
pub trait Dataset<T>: 'static {
fn rows<'a>(&'a self, cx: &'a App) -> &'a [T];
fn revision(&self, cx: &App) -> Option<Revision> { None }
fn schema<'a>(&'a self, cx: &'a App) -> Option<Schema<'a, T>> { None }
}
pub struct Schema<'a, T> {
pub names: &'a [SharedString],
pub read: fn(&T, usize) -> f64,
pub size: Option<usize>,
pub color: Option<(usize, &'a Scheme)>,
}| Method | Implement it to |
|---|---|
rows | return the rows |
revision | return a value that changes whenever the rows change. While it stays the same, the chart reuses its work. Revision::of(signal, cx) builds one from a zopra signal. |
schema | describe named numeric fields, so charts can build series (XY Chart) or bars (Bar Gauge) without accessors. read(row, field) returns one value; size and color pick fields for the XY Chart's auto mapping. |
Enums
| Enum | Variants |
|---|---|
Show | Points (default), Lines, PointsAndLines |
PointShape | Circle (default), Square |
LineStyle | Solid (default), Dash(&[on, off, …]), Dot(gap); ready-made LineStyle::DASH and LineStyle::DOT |
HideFrom | { legend, tooltip, viz }, all bool |
ScaleKind | Linear (default), Log { base }, Symlog { base, linear_threshold } |
AxisPlacement | Auto (default), Top, Right, Bottom, Left, Hidden |
AxisColorMode | Text (default), Series (tick labels use the first series' colour) |
LegendMode | List (default), Table, Hidden |
LegendPlacement | Bottom (default), Right |
LegendSort | Name, Calc(Calc) |
TooltipMode | Single (default), Multi (shown like Single), Hidden |
Dot draws small square dots along the line.
Coming from Grafana
| Grafana option | Here |
|---|---|
| Series mapping: Auto | no <Series> children, with a Frame |
| Series mapping: Manual | <Series> children |
| Series: X / Y / Size / Color field | <Series x y size color> |
| Series name | <Series name> |
| Legend | Legend |
| Tooltip | Tooltip |
| Field defaults (show, point size, line style, …) | style props on <XYChart> |
| Field overrides | style props on <Series> |
| Axis options, scale distribution | AxisConfig |
| Min, Max, Decimals | AxisConfig::min, max, decimals |
| Unit | AxisConfig::format |
| Color scheme | Color and Scheme, with the palette from your theme |
Not available here: several data frames in one chart (combine your rows first), separate y axes per unit, value mappings, data links, the tooltip's sort and hide-zeros options, and ordinal axes. fill_opacity goes from 0.0 to 1.0 rather than 0 to 100.
Run them
17 examples
Press Run to load an example into the live render at the top of the page. Open one to read its code.
How to read it
- 01Look at the overall shape first: a rising cloud, a flat one, two blobs, a curve. The shape is the finding. Individual points are footnotes.
- 02Find the outliers and ask whether they are errors or the story. A dot far from the cloud is either a bug in the data or a customer worth calling.
- 03If points have different sizes, remember that area tracks the value. A dot twice as big is twice the value, not four times.
- 04Hover a point for its x and y, plus its size and colour value when the series has them. Click a legend entry to isolate a series, and Ctrl-click (Cmd-click on macOS) to toggle just one.
When to use it, and when not to
Reach for it when
- Both axes are measurements: load against latency, dose against response, price against area.
- You want to see clusters, outliers or correlation before you decide what to say about them.
- A third value (a size) or a fourth (a colour) explains the cloud: the big dots are the big customers, the red ones are failing.
- The order of rows is the story, as in a path, a trajectory or a hysteresis loop. Lines follow your row order exactly.
- You need one chart for many groups: one
<Series>per group, each with its own colour, sharing the same axes.
Skip it when
- x is a category such as team or region. A bar chart gives each category a slot and a label. A scatter plot gives you ugly numbers on an axis that means nothing.
- x is time and the question is how a value moved. Time Series has a time axis, sensible ticks and zoom that understands clocks.
- You have tens of thousands of points that sit on top of each other. Lower
fill_opacityhelps up to a point. Past it, bin the data and use a Heatmap. - Size and colour carry different variables on every series. Readers will compare them as if they were the same thing, and then it is your fault.
The math behind it
From rows to pixels
Each row gives a pair . The chart finds the smallest and largest values on each axis, rounds them outwards to a nice range, and maps that range linearly onto the plot rectangle of width and height . Hidden series are left out before the extents are computed, so the axes rescale to what you can still see.
The nice range uses a step built from the raw span. With and the data limits, the raw step is . Write it as with , then pick the smallest from the list that is at least . The axis runs from to with , which is why your axis ends at 250 rather than 237.
A third value as area
When a series has a size accessor, its values are normalised across all series together, so two series use the same scale and can be compared. With and the extremes and the size_range (default to ), a point gets
The drawn diameter is the square root of that interpolated quantity, so a value twice as large covers about twice the area, not twice the width. Missing or non-finite sizes fall back to the smallest end of the range.
Which point is under the cursor
Hovering is a nearest-point question, and answering it by scanning every row on every mouse move would be rude. The chart drops each point into a coarse grid of pixel cells once, then on each move checks only the cells around the cursor. A point is hit when the cursor lies within the radius of its centre, where is the point's stroke width, and the closest such point wins. Points lying outside the plot area are not drawn and cannot be hit.
Real life usage
Forty-one goats and one very honest scatter plot
Mirela ran quality at a small cheese cooperative in the hills above Sibiu. The cooperative bought milk from forty-one farms, and for years the only thing anyone knew about those farms was the price per litre and whether the farmer would pick up the phone.
Then a batch of aged cheese came back from a distributor with a note that said, in the polite way these notes say things, that it tasted like a wet barn. Mirela had a spreadsheet with a row per farm: average somatic cell count in the milk, fat percentage, litres delivered per week. She also had a boss who wanted to know by Friday whether this was one bad farm or a pattern.
She started with the thing everyone starts with, a pivot table, and stared at it for an afternoon. Forty-one rows of numbers is an excellent way to feel busy.
On Thursday she put the data into an <XYChart> from zopra-charts. Cell count on the x axis, fat percentage on y, weekly litres as the point size, and a colour ramp from green to red for the number of failed taste panels. It took about twenty lines of Rust, most of which were opening the CSV.
The cloud had a clear tilt, which she half expected: higher cell count, lower fat. What she did not expect was one enormous red dot sitting alone at the top right, a farm that delivered more milk than any other and failed nearly every panel. Farm number 17 was also the only one that had been added in the last year, and the only one with a new milking shed that nobody had inspected.
The inspection found a cleaning schedule that existed mainly as a laminated sheet on the wall. After the fix the farm's dot slid left and down over two months, and the failed panels dropped from nine in a quarter to one. The distributor stopped writing notes about barns.
Mirela keeps the first version of the chart pinned above her desk. Under it she wrote one sentence: forty-one rows of data, one dot that mattered, and a pivot table that had been hiding it for years.
Pitfalls
- Lines are drawn in row order. If your rows are not sorted by x and you ask for lines, you get a scribble. Sort first, or use Trend, which does it for you.
- Overplotting. A thousand dots stacked in the same place look like one dot. Lower
fill_opacityso dense areas read darker. - Size range too wide. With the default 5 to 100 range, one huge value can bury its neighbours. Narrow
size_rangewhen the biggest bubble covers half the plot. - Colour without a scheme choice. A rainbow for a one-sided value suggests categories that are not there. Use a single-hue gradient for how much, thresholds for ok, warning, critical.
- Data as a plain
Vec. Passed directly, the per-row work is redone on every render of your component. Keep large data in a signal.
From around the web