Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 75 additions & 4 deletions .claude/skills/rustmotion/rules/chart-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
| Chart Type | Best For | Data Shape |
| --- | --- | --- |
| `bar` | Comparing categories | `data: [{ value, label }]` |
| `horizontal_bar` | Rankings, long labels | `data: [{ value, label }]` + `show_labels: true` |
| `horizontal_bar` | Rankings, long labels | `data: [{ value, label }]` + `show_y_labels: true` |
| `line` | Trends over time | `data: [{ value, label }]` |
| `area` | Trends with volume emphasis | `data: [{ value }]` + `smooth: true`, `fill_opacity` |
| `pie` | Part-of-whole (max 6 slices) | `data: [{ value, label, color }]` |
Expand All @@ -17,16 +17,87 @@
| `funnel` | Conversion pipeline | `data` (descending values) + `direction: "horizontal"/"vertical"` |
| `waterfall` | Cumulative changes | `data` (positive = green, negative = red) |

## Axes & grid
## A chart draws NO text unless you ask for it

Add `show_grid: true`, `show_x_labels: true`, `show_y_labels: true` to cartesian charts (bar, line, area, stacked_bar, scatter, waterfall) for professional appearance. Customize with `grid_color`, `label_color`, `label_font_size`.
`show_grid`, `show_x_labels`, `show_y_labels` and `show_labels` all default to
**`false`**. A chart written as `{ "type": "chart", "chart_type": "bar", "data":
[...] }` renders coloured shapes and nothing else — no axis, no numbers, no
category names. `radar` is the only exception: it always draws its axis labels
and its grid, and ignores the four flags entirely.

Which flag produces text, per type — measured, not assumed:

| Type | `show_grid` | `show_y_labels` | `show_x_labels` | `show_labels` |
| --- | --- | --- | --- | --- |
| `bar` | grid lines | value ticks (left) | category names (below) | — |
| `stacked_bar` | grid lines | value ticks (left) | category names (below) | — |
| `waterfall` | grid lines | value ticks (left) | category names (below) | — |
| `line` / `area` | grid lines | value ticks (left) | point labels (below) | — |
| `scatter` | grid lines | y value ticks | x value ticks | — |
| `horizontal_bar` | grid lines (vertical) | category names (left gutter) | value ticks (below) | label in bar + value at row end |
| `funnel` | — | — | — | label inside each segment |
| `pie` / `donut` / `radial_bar` | — | — | — | — |

`pie`, `donut` and `radial_bar` have **no label support at all**: all four flags
are accepted by the schema and do nothing. If the viewer needs to know which
slice or ring is which, put the legend next to the chart yourself (a `flex` of
`badge` or coloured `shape` + `text` rows), or use `bar` / `horizontal_bar`
instead.

## Text size

`label_font_size` defaults to **12px**, which is below the project's legibility
floor of 1.2% of output height (≈13px on 1080p, ≈26px on 4K) — and the floor
check does **not** cover chart text, so `validate` will not warn you. On a 1080p
canvas set `label_font_size` to at least 14; scale it with the canvas above that.

## `horizontal_bar`: use the left gutter for names

`show_y_labels: true` puts the category names in a left gutter sized to the
longest one — the standard ranking layout, and the only option that stays
legible when a bar is short or zero. `show_labels: true` instead writes the name
inside the bar (falling back to just past its end when it doesn't fit) and adds
the value, right-aligned at the end of the row.

Every row also gets a faint full-width track, so a zero-valued row still occupies
its slot instead of vanishing.

```json
{
"type": "chart", "chart_type": "horizontal_bar",
"data": [
{ "label": "Native TypeScript", "value": 6 },
{ "label": "Effect", "value": 0 }
],
"show_y_labels": true, "show_grid": true, "show_x_labels": true,
"show_labels": true, "label_font_size": 16,
"style": { "width": 1440, "height": 400 }
}
```

## Degenerate inputs

| Input | What renders |
| --- | --- |
| A zero value | Its slot is kept: bar/horizontal_bar keep the label and (horizontal_bar) the track; a zero pie slice is skipped |
| All values zero | `pie`/`donut` render nothing at all (there is no whole to be part of); cartesian types still draw axes and labels |
| Empty `data` | Nothing renders, and `validate` does **not** complain. Check your data is non-empty before you ship |
| A single point | `bar`/`pie`/`donut` fill the chart; `line`/`area` draw the axes plus one dot — prefer a `stat` or `counter` for one number |
| All values equal | `bar` fills every bar; `line`/`area`/`scatter` centre the flat series vertically |
| Negative values | `bar`/`horizontal_bar`/`waterfall`/`line`/`area` anchor the axis at zero and draw negatives the other side of it. `pie`/`donut`/`radial_bar`/`funnel` clamp them to zero — a negative share is meaningless there |
| More categories than fit | Nothing is dropped and nothing overflows the box, but the x labels collide as soon as the widest one exceeds the slot width (`chart_width / n`) — 30 four-character labels in an 800px-wide `bar` measured as an unreadable band. Use `horizontal_bar` (which stacks names down the gutter) or cut the data |
| Long category labels | `horizontal_bar` gutter grows to fit, capped at 45% of the width; `bar` x labels will overlap each other. `radar` anchors long axis labels inward so they stay in the box |

## BAD: using pie for too many items

```json
{ "chart_type": "pie", "data": [8 items...] }
```

Eight slices and no label support means eight anonymous colours.

## GOOD: use horizontal_bar for rankings

```json
{ "chart_type": "horizontal_bar", "data": [...], "show_labels": true }
{ "chart_type": "horizontal_bar", "data": [...], "show_y_labels": true, "show_labels": true }
```
73 changes: 72 additions & 1 deletion crates/rustmotion-components/src/chart/axes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,13 @@ impl Chart {
chart_x + (i as f32 / (n - 1).max(1) as f32) * chart_w
};
let label_w = measure_text_with_fallback(label, &font, &emoji_font, 0.0);
let lx = x - label_w / 2.0;
// Centring the first/last label on the axis end pushes half of
// it outside the component's own box (measured: the last label
// of a 5-point line chart bled 5px past the right edge). Clamp
// into [0, box_width] — `chart_margins()` always returns a
// right margin of 8, so the box ends at chart_x + chart_w + 8.
let box_w = chart_x + chart_w + 8.0;
let lx = (x - label_w / 2.0).clamp(0.0, (box_w - label_w).max(0.0));
let ly = chart_y + chart_h + ascent + 6.0;
draw_text_with_fallback(
canvas,
Expand All @@ -127,4 +133,69 @@ impl Chart {
}
}
}

/// Draw the *value* axis of a transposed (horizontal) cartesian chart:
/// vertical grid lines and value tick labels along the bottom.
///
/// [`draw_axes`] assumes the value axis is vertical (grid lines
/// horizontal, values down the left gutter), which is wrong for
/// `horizontal_bar` — there the value axis runs left-to-right and the
/// category axis is the vertical one.
pub(super) fn draw_value_axis_x(
&self,
canvas: &Canvas,
chart_x: f32,
chart_y: f32,
chart_w: f32,
chart_h: f32,
min_val: f64,
max_val: f64,
) {
if !self.show_grid && !self.show_x_labels {
return;
}
let Some(font) = self.make_label_font() else {
return;
};
let emoji_font =
emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, self.label_font_size));
let (_, metrics) = font.metrics();
let ascent = -metrics.ascent;

let grid_steps = 5;
let range = max_val - min_val;

for i in 0..=grid_steps {
let frac = i as f32 / grid_steps as f32;
let x = chart_x + frac * chart_w;

if self.show_grid {
let mut grid_paint = paint_from_hex(&self.grid_color);
grid_paint.set_style(PaintStyle::Stroke);
grid_paint.set_stroke_width(1.0);
grid_paint.set_anti_alias(true);
canvas.draw_line((x, chart_y), (x, chart_y + chart_h), &grid_paint);
}

if self.show_x_labels {
let label = format_number(min_val + range * frac as f64);
let mut label_paint = paint_from_hex(&self.label_color);
label_paint.set_anti_alias(true);
let label_w = measure_text_with_fallback(&label, &font, &emoji_font, 0.0);
let box_w = chart_x + chart_w + 8.0;
let lx = (x - label_w / 2.0).clamp(0.0, (box_w - label_w).max(0.0));
let ly = chart_y + chart_h + ascent + 6.0;
draw_text_with_fallback(
canvas,
&label,
&font,
&emoji_font,
0.0,
lx,
ly,
&label_paint,
);
}
}
}
}
Loading
Loading