From 4ba3b6c0b566a7746b672fcca5eaeef094395e5b Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 2 Aug 2026 13:23:03 +0200 Subject: [PATCH] fix(chart): repair the component across its twelve types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited all twelve chart types by rendering each one standalone and inside a card, with the label flags off and on, and measuring ink, bounding boxes, ink outside the declared box, and text presence. Fourteen defects found, fourteen fixed. The headline is not any single bug: with default settings, eleven of the twelve types render zero text. An author writing the documented minimal chart JSON gets coloured shapes and nothing else. Four of them — pie, donut, radial_bar, radar — accept the show_grid / show_x_labels / show_y_labels flags and honour none; their renders with all four on are byte-identical to all four off. The worst defect was invisible in a chart and obvious in a frame: donut punched a fully transparent hole through the entire composited image, card background included, because it cleared its centre with BlendMode::Clear on the shared canvas. Measured at 19380 transparent pixels in mega-showcase.json; now zero. Negative values escaped the component box in five types — a bar chart with a -6 drew 58072 pixels past its bottom edge, and horizontal_bar reached the frame edge. All five now anchor on a zero baseline and stay inside. Pie summed raw values into its total, so one negative produced sweeps of 900 and -540 degrees. Degenerate inputs that silently rendered nothing now render: a single-point line or area drew no axes and no point; a pie slice at exactly 100% of the total vanished because Skia emits an empty arc at a 360-degree sweep; a constant series sat on the zero gridline, reading as a row of zeroes. The case that opened the issue — horizontal_bar with one value at zero — was missing its axis, its grid, both label sets and the zero row itself. It now renders both rows with tracks, both names, a value axis and the values annotated. Regression-checked by re-rendering all 36 fixtures byte-for-byte: bar, pie, funnel, radial_bar, stacked_bar and waterfall are unchanged for all-positive data, since the signed-baseline arithmetic reduces to the old form when the minimum is non-negative. mega-showcase.json changes by 20396 pixels, of which 20181 are the donut hole filling in and 215 are an axis label being pulled back inside its card. Left alone deliberately: waterfall, stacked_bar and area were checked and found correct. Labels and legends for pie, donut and radial_bar are absent entirely — adding them is feature work that would change every existing render, not a repair, so it is reported rather than built. Two things need files outside chart/: chart text is not covered by the legibility floor (geometry.rs names it in its own "not covered" list), and an empty data array renders blank while validating clean. Closes #138 --- .../skills/rustmotion/rules/chart-types.md | 79 +++++- .../rustmotion-components/src/chart/axes.rs | 73 +++++- crates/rustmotion-components/src/chart/bar.rs | 235 +++++++++++++----- .../rustmotion-components/src/chart/funnel.rs | 14 +- .../rustmotion-components/src/chart/line.rs | 88 +++++-- crates/rustmotion-components/src/chart/pie.rs | 121 ++++++--- .../rustmotion-components/src/chart/radar.rs | 29 ++- .../rustmotion-components/src/chart/radial.rs | 7 +- .../src/chart/scatter.rs | 34 ++- 9 files changed, 530 insertions(+), 150 deletions(-) diff --git a/.claude/skills/rustmotion/rules/chart-types.md b/.claude/skills/rustmotion/rules/chart-types.md index 087b3fe..9ec03bb 100644 --- a/.claude/skills/rustmotion/rules/chart-types.md +++ b/.claude/skills/rustmotion/rules/chart-types.md @@ -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 }]` | @@ -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 } ``` diff --git a/crates/rustmotion-components/src/chart/axes.rs b/crates/rustmotion-components/src/chart/axes.rs index deb83cc..6307ad6 100644 --- a/crates/rustmotion-components/src/chart/axes.rs +++ b/crates/rustmotion-components/src/chart/axes.rs @@ -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, @@ -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, + ); + } + } + } } diff --git a/crates/rustmotion-components/src/chart/bar.rs b/crates/rustmotion-components/src/chart/bar.rs index 93fb44f..f492341 100644 --- a/crates/rustmotion-components/src/chart/bar.rs +++ b/crates/rustmotion-components/src/chart/bar.rs @@ -1,9 +1,11 @@ use rustmotion_core::error::Result; use skia_safe::{Canvas, PaintStyle, Rect}; -use rustmotion_core::engine::renderer::{draw_text_with_fallback, emoji_typeface, paint_from_hex}; +use rustmotion_core::engine::renderer::{ + draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, +}; -use super::axes::contrast_text_color; +use super::axes::{contrast_text_color, format_number}; use super::Chart; impl Chart { @@ -12,28 +14,32 @@ impl Chart { let chart_w = w - ml - mr; let chart_h = h - mt - mb; - let max_val = self - .data - .iter() - .map(|d| d.value) - .fold(0.0_f64, f64::max) - .max(0.001); + // A bar chart's baseline is zero, not the smallest datum, so the scale + // has to span [min(0, min), max(0, max)]. Scaling by `max` alone drew + // negative bars downward from the bottom edge, straight out of the + // component box (measured: values [10, -6, 4] in a 800x500 box put + // 58072 ink pixels outside it, reaching 243px past the bottom). + // All-positive data is unaffected: min_val stays 0 and the arithmetic + // reduces to the previous `value / max_val`. + let (min_val, max_val, range) = self.value_extent(); let x_labels: Vec = self.data.iter().filter_map(|d| d.label.clone()).collect(); self.draw_axes( - canvas, ml, mt, chart_w, chart_h, 0.0, max_val, &x_labels, true, + canvas, ml, mt, chart_w, chart_h, min_val, max_val, &x_labels, true, ); let n = self.data.len(); let gap = 8.0; let bar_w = (chart_w - gap * (n + 1) as f32) / n as f32; + let zero_y = mt + chart_h - ((0.0 - min_val) / range) as f32 * chart_h; for (i, dp) in self.data.iter().enumerate() { let color = dp.color.as_deref().unwrap_or_else(|| self.get_color(i)); - let bar_h = (dp.value / max_val) as f32 * chart_h * progress; + let bar_h = (dp.value.abs() / range) as f32 * chart_h * progress; let x = ml + gap + i as f32 * (bar_w + gap); - let y = mt + chart_h - bar_h; + let negative = dp.value < 0.0; + let y = if negative { zero_y } else { zero_y - bar_h }; let mut paint = paint_from_hex(color); paint.set_style(PaintStyle::Fill); @@ -41,21 +47,39 @@ impl Chart { let rect = Rect::from_xywh(x, y, bar_w, bar_h); let radius = (bar_w * 0.15).min(8.0); - let rrect = skia_safe::RRect::new_rect_radii( - rect, - &[ - (radius, radius).into(), - (radius, radius).into(), - (0.0, 0.0).into(), - (0.0, 0.0).into(), - ], - ); + // Round the end away from the baseline. + let round = (radius, radius).into(); + let square = (0.0, 0.0).into(); + let radii = if negative { + [square, square, round, round] + } else { + [round, round, square, square] + }; + let rrect = skia_safe::RRect::new_rect_radii(rect, &radii); canvas.draw_rrect(rrect, &paint); } Ok(()) } + /// `(min, max, range)` for a zero-anchored value axis over `self.data`. + /// The range is never zero, so callers can divide by it unconditionally. + pub(super) fn value_extent(&self) -> (f64, f64, f64) { + let min_val = self + .data + .iter() + .map(|d| d.value) + .fold(0.0_f64, f64::min) + .min(0.0); + let max_val = self + .data + .iter() + .map(|d| d.value) + .fold(0.0_f64, f64::max) + .max(0.0); + (min_val, max_val, (max_val - min_val).max(0.001)) + } + pub(super) fn render_horizontal_bar( &self, canvas: &Canvas, @@ -63,21 +87,6 @@ impl Chart { h: f32, progress: f32, ) -> Result<()> { - let (mt, mr, mb, ml) = self.chart_margins(); - let chart_w = w - ml - mr; - let chart_h = h - mt - mb; - - let max_val = self - .data - .iter() - .map(|d| d.value) - .fold(0.0_f64, f64::max) - .max(0.001); - - let n = self.data.len(); - let gap = 8.0; - let bar_h = (chart_h - gap * (n + 1) as f32) / n as f32; - let Some(font) = self.make_label_font() else { return Ok(()); }; @@ -85,38 +94,148 @@ impl Chart { emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, self.label_font_size)); let (_, metrics) = font.metrics(); let ascent = -metrics.ascent; + let measure = |s: &str| measure_text_with_fallback(s, &font, &emoji_font, 0.0); + + // `chart_margins()` sizes the left gutter for *numeric* tick labels + // (3.5em), which is the wrong axis here: on a horizontal bar chart the + // vertical axis is categorical. Size the gutter to the widest category + // name instead, and cap it so the bars keep the majority of the width. + let mt = 8.0; + let mr = 8.0; + let mb = if self.show_x_labels { + self.label_font_size * 2.0 + 8.0 + } else { + 8.0 + }; + let gutter = if self.show_y_labels { + let widest = self + .data + .iter() + .filter_map(|d| d.label.as_deref()) + .map(measure) + .fold(0.0_f32, f32::max); + (widest + 14.0).min(w * 0.45) + } else { + 0.0 + }; + let ml = gutter + 8.0; + let chart_w = (w - ml - mr).max(1.0); + let chart_h = (h - mt - mb).max(1.0); + + let (min_val, max_val, range) = self.value_extent(); + self.draw_value_axis_x(canvas, ml, mt, chart_w, chart_h, min_val, max_val); + + let n = self.data.len(); + let gap = 8.0; + let row_h = (chart_h - gap * (n + 1) as f32) / n as f32; + if row_h <= 0.0 { + return Ok(()); + } + let zero_x = ml + ((0.0 - min_val) / range) as f32 * chart_w; for (i, dp) in self.data.iter().enumerate() { let color = dp.color.as_deref().unwrap_or_else(|| self.get_color(i)); - let bar_w = (dp.value / max_val) as f32 * chart_w * progress; - let x = ml; - let y = mt + gap + i as f32 * (bar_h + gap); + let bar_len = (dp.value.abs() / range) as f32 * chart_w * progress; + let negative = dp.value < 0.0; + let x = if negative { zero_x - bar_len } else { zero_x }; + let y = mt + gap + i as f32 * (row_h + gap); + let radius = (row_h * 0.15).min(8.0); + + // Track behind every row. Without it a zero-valued row is simply + // absent — the defect that made a "6 vs 0" comparison unreadable — + // and short bars have nothing to be read against. Matches the + // track `radial_bar` already draws for the same reason. + let mut track_paint = paint_from_hex("#FFFFFF"); + track_paint.set_style(PaintStyle::Fill); + track_paint.set_anti_alias(true); + track_paint.set_alpha_f(0.06); + canvas.draw_rrect( + skia_safe::RRect::new_rect_xy( + Rect::from_xywh(ml, y, chart_w, row_h), + radius, + radius, + ), + &track_paint, + ); let mut paint = paint_from_hex(color); paint.set_style(PaintStyle::Fill); paint.set_anti_alias(true); - let rect = Rect::from_xywh(x, y, bar_w, bar_h); - let radius = (bar_h * 0.15).min(8.0); - let rrect = skia_safe::RRect::new_rect_radii( - rect, - &[ - (0.0, 0.0).into(), - (radius, radius).into(), - (radius, radius).into(), - (0.0, 0.0).into(), - ], - ); - canvas.draw_rrect(rrect, &paint); + let rect = Rect::from_xywh(x, y, bar_len, row_h); + // Round the end away from the baseline. + let round = (radius, radius).into(); + let square = (0.0, 0.0).into(); + let radii = if negative { + [round, square, square, round] + } else { + [square, round, round, square] + }; + canvas.draw_rrect(skia_safe::RRect::new_rect_radii(rect, &radii), &paint); - // Draw label inside the bar - if self.show_labels || self.show_x_labels { - if let Some(label) = &dp.label { - let contrast_color = contrast_text_color(color); - let mut label_paint = paint_from_hex(&contrast_color); + let baseline_y = y + row_h / 2.0 + ascent / 2.0; + + // Value annotation, right-aligned in the track so it never depends + // on how long the bar happens to be. + let mut value_left = ml + chart_w; + if self.show_labels { + let text = format_number(dp.value); + let text_w = measure(&text); + value_left = ml + chart_w - text_w - 10.0; + let on_bar = !negative && x + bar_len >= value_left; + let color_hex = if on_bar { + contrast_text_color(color) + } else { + self.label_color.clone() + }; + let mut value_paint = paint_from_hex(&color_hex); + value_paint.set_anti_alias(true); + draw_text_with_fallback( + canvas, + &text, + &font, + &emoji_font, + 0.0, + value_left, + baseline_y, + &value_paint, + ); + } + + let Some(label) = &dp.label else { continue }; + + if gutter > 0.0 { + // Category names live in the left gutter, right-aligned against + // the bars — the classic ranking layout. + let mut label_paint = paint_from_hex(&self.label_color); + label_paint.set_anti_alias(true); + let lx = (gutter - measure(label)).max(0.0); + draw_text_with_fallback( + canvas, + label, + &font, + &emoji_font, + 0.0, + lx, + baseline_y, + &label_paint, + ); + } else if self.show_labels || self.show_x_labels { + // Inside the bar when it fits, otherwise just past its end in + // the label colour. Drawing it inside unconditionally left the + // text of a zero-length bar floating on the background in a + // colour picked for contrast against a bar that isn't there. + let label_w = measure(label); + let fits = bar_len >= label_w + 20.0; + let (lx, color_hex) = if fits { + (x + 10.0, contrast_text_color(color)) + } else { + (x + bar_len + 10.0, self.label_color.clone()) + }; + // Never run into the value annotation. + if lx + label_w <= value_left - 6.0 || fits { + let mut label_paint = paint_from_hex(&color_hex); label_paint.set_anti_alias(true); - let lx = x + 10.0; - let ly = y + bar_h / 2.0 + ascent / 2.0; draw_text_with_fallback( canvas, label, @@ -124,7 +243,7 @@ impl Chart { &emoji_font, 0.0, lx, - ly, + baseline_y, &label_paint, ); } diff --git a/crates/rustmotion-components/src/chart/funnel.rs b/crates/rustmotion-components/src/chart/funnel.rs index 64a05d2..60e9122 100644 --- a/crates/rustmotion-components/src/chart/funnel.rs +++ b/crates/rustmotion-components/src/chart/funnel.rs @@ -27,10 +27,11 @@ impl Chart { fn render_funnel_vertical(&self, canvas: &Canvas, w: f32, h: f32, progress: f32) -> Result<()> { let n = self.data.len(); + // Clamp negatives: a negative ratio flipped the trapezoid inside out. let max_val = self .data .iter() - .map(|d| d.value) + .map(|d| d.value.max(0.0)) .fold(0.0_f64, f64::max) .max(0.001); @@ -47,9 +48,9 @@ impl Chart { let ascent = -metrics.ascent; for (i, dp) in self.data.iter().enumerate() { - let ratio = (dp.value / max_val) as f32 * progress; + let ratio = (dp.value.max(0.0) / max_val) as f32 * progress; let next_ratio = if i + 1 < n { - (self.data[i + 1].value / max_val) as f32 * progress + (self.data[i + 1].value.max(0.0) / max_val) as f32 * progress } else { ratio * 0.6 }; @@ -107,10 +108,11 @@ impl Chart { progress: f32, ) -> Result<()> { let n = self.data.len(); + // Clamp negatives: a negative ratio flipped the trapezoid inside out. let max_val = self .data .iter() - .map(|d| d.value) + .map(|d| d.value.max(0.0)) .fold(0.0_f64, f64::max) .max(0.001); @@ -127,9 +129,9 @@ impl Chart { let ascent = -metrics.ascent; for (i, dp) in self.data.iter().enumerate() { - let ratio = (dp.value / max_val) as f32 * progress; + let ratio = (dp.value.max(0.0) / max_val) as f32 * progress; let next_ratio = if i + 1 < n { - (self.data[i + 1].value / max_val) as f32 * progress + (self.data[i + 1].value.max(0.0) / max_val) as f32 * progress } else { ratio * 0.6 }; diff --git a/crates/rustmotion-components/src/chart/line.rs b/crates/rustmotion-components/src/chart/line.rs index cc10c03..3576fc0 100644 --- a/crates/rustmotion-components/src/chart/line.rs +++ b/crates/rustmotion-components/src/chart/line.rs @@ -5,37 +5,70 @@ use rustmotion_core::engine::renderer::{paint_from_hex, parse_hex_color}; use super::Chart; +/// `(min, max, normalize)` for a min–max scaled series. +/// +/// `normalize` maps a value to `0.0..=1.0` (0 = bottom of the plot area). +/// When every value is identical the series is centred rather than pinned to +/// the axis: dividing by a `max(0.001)` floor mapped a constant series to 0, +/// so a flat line at, say, 7 rendered sitting exactly on the zero gridline and +/// read as a series of zeroes. +pub(super) fn series_scale( + values: impl Iterator + Clone, +) -> (f64, f64, impl Fn(f64) -> f32) { + let min_val = values.clone().fold(f64::INFINITY, f64::min); + let max_val = values.fold(f64::NEG_INFINITY, f64::max); + let (min_val, max_val) = if min_val.is_finite() && max_val.is_finite() { + (min_val, max_val) + } else { + (0.0, 0.0) + }; + let span = max_val - min_val; + let flat = span.abs() < f64::EPSILON; + let range = if flat { 1.0 } else { span }; + (min_val, max_val, move |v: f64| { + if flat { + 0.5 + } else { + ((v - min_val) / range) as f32 + } + }) +} + impl Chart { pub(super) fn render_line(&self, canvas: &Canvas, w: f32, h: f32, progress: f32) -> Result<()> { let (mt, mr, mb, ml) = self.chart_margins(); let chart_w = w - ml - mr; let chart_h = h - mt - mb; - let max_val = self - .data - .iter() - .map(|d| d.value) - .fold(0.0_f64, f64::max) - .max(0.001); - let min_val = self.data.iter().map(|d| d.value).fold(f64::MAX, f64::min); - let range = (max_val - min_val).max(0.001); + let (min_val, max_val, norm) = series_scale(self.data.iter().map(|d| d.value)); let n = self.data.len(); - if n < 2 { - return Ok(()); - } - let x_labels: Vec = self.data.iter().filter_map(|d| d.label.clone()).collect(); self.draw_axes( canvas, ml, mt, chart_w, chart_h, min_val, max_val, &x_labels, false, ); + if n < 2 { + // A one-point series has no line to draw, but returning before the + // axes were painted made the whole component render nothing at all + // (measured: 0 ink pixels, and `validate` clean). Plot the point. + if let Some(dp) = self.data.first() { + let x = ml + chart_w / 2.0; + let y = mt + chart_h - norm(dp.value) * chart_h; + let color = dp.color.as_deref().unwrap_or_else(|| self.get_color(0)); + let mut dot_paint = paint_from_hex(color); + dot_paint.set_style(PaintStyle::Fill); + dot_paint.set_anti_alias(true); + canvas.draw_circle((x, y), 4.0, &dot_paint); + } + return Ok(()); + } let mut path = Path::new(); let mut fill_path = Path::new(); for (i, dp) in self.data.iter().enumerate() { let x = ml + (i as f32 / (n - 1) as f32) * chart_w; - let y = mt + chart_h - ((dp.value - min_val) / range) as f32 * chart_h; + let y = mt + chart_h - norm(dp.value) * chart_h; if i == 0 { path.move_to((x, y)); @@ -77,7 +110,7 @@ impl Chart { // Dots for (i, dp) in self.data.iter().enumerate() { let x = ml + (i as f32 / (n - 1) as f32) * chart_w; - let y = mt + chart_h - ((dp.value - min_val) / range) as f32 * chart_h; + let y = mt + chart_h - norm(dp.value) * chart_h; let dot_color = dp.color.as_deref().unwrap_or(line_color); let mut dot_paint = paint_from_hex(dot_color); @@ -95,24 +128,25 @@ impl Chart { let chart_w = w - ml - mr; let chart_h = h - mt - mb; - let max_val = self - .data - .iter() - .map(|d| d.value) - .fold(0.0_f64, f64::max) - .max(0.001); - let min_val = self.data.iter().map(|d| d.value).fold(f64::MAX, f64::min); - let range = (max_val - min_val).max(0.001); + let (min_val, max_val, norm) = series_scale(self.data.iter().map(|d| d.value)); let n = self.data.len(); - if n < 2 { - return Ok(()); - } - let x_labels: Vec = self.data.iter().filter_map(|d| d.label.clone()).collect(); self.draw_axes( canvas, ml, mt, chart_w, chart_h, min_val, max_val, &x_labels, false, ); + if n < 2 { + // See `render_line`: draw the lone point rather than nothing. + if let Some(dp) = self.data.first() { + let x = ml + chart_w / 2.0; + let y = mt + chart_h - norm(dp.value) * chart_h; + let mut dot_paint = paint_from_hex(self.get_color(0)); + dot_paint.set_style(PaintStyle::Fill); + dot_paint.set_anti_alias(true); + canvas.draw_circle((x, y), 4.0, &dot_paint); + } + return Ok(()); + } // Compute points let pts: Vec<(f32, f32)> = self @@ -121,7 +155,7 @@ impl Chart { .enumerate() .map(|(i, dp)| { let x = ml + (i as f32 / (n - 1) as f32) * chart_w; - let y = mt + chart_h - ((dp.value - min_val) / range) as f32 * chart_h; + let y = mt + chart_h - norm(dp.value) * chart_h; (x, y) }) .collect(); diff --git a/crates/rustmotion-components/src/chart/pie.rs b/crates/rustmotion-components/src/chart/pie.rs index 422d3be..54065fb 100644 --- a/crates/rustmotion-components/src/chart/pie.rs +++ b/crates/rustmotion-components/src/chart/pie.rs @@ -5,65 +5,110 @@ use rustmotion_core::engine::renderer::paint_from_hex; use super::Chart; +/// Skia's `Path::arc_to` emits an *empty* arc when the sweep is exactly 360°, +/// so a slice that owns 100% of the total silently disappears at the end of +/// the animation (measured: a single-slice pie rendered 116336 ink pixels at +/// frame 44 and 0 at frame 45, the first frame where `progress` reaches 1.0). +/// Sweeps at or above this threshold are drawn as a full disc/ring instead. +const FULL_SWEEP: f32 = 359.99; + impl Chart { pub(super) fn render_pie(&self, canvas: &Canvas, w: f32, h: f32, progress: f32) -> Result<()> { - let total: f64 = self.data.iter().map(|d| d.value).sum(); + self.render_pie_ring(canvas, w, h, progress, 0.0) + } + + pub(super) fn render_donut( + &self, + canvas: &Canvas, + w: f32, + h: f32, + progress: f32, + ) -> Result<()> { + let inner = self.inner_radius.clamp(0.1, 0.95) as f32; + self.render_pie_ring(canvas, w, h, progress, inner) + } + + /// Paint the data as a disc (`inner_ratio == 0`) or a ring. + /// + /// The ring is built from annulus-sector paths rather than by painting a + /// full pie and punching the centre out with `BlendMode::Clear`: `Clear` + /// writes transparent pixels straight through the shared canvas, so the + /// hole erased the parent card *and* the video background — measured as + /// 19380 fully transparent pixels in a 158x157 box in the shipped + /// `examples/mega-showcase.json`. + fn render_pie_ring( + &self, + canvas: &Canvas, + w: f32, + h: f32, + progress: f32, + inner_ratio: f32, + ) -> Result<()> { + // A negative share is meaningless in a part-of-whole chart, and letting + // one through poisons `total` for every other slice (measured: values + // [10, -6] gave slice sweeps of 900 and -540 degrees). Clamp at 0. + let values: Vec = self.data.iter().map(|d| d.value.max(0.0)).collect(); + let total: f64 = values.iter().sum(); if total <= 0.0 { return Ok(()); } let cx = w / 2.0; let cy = h / 2.0; - let radius = cx.min(cy) - 8.0; - let oval = Rect::from_xywh(cx - radius, cy - radius, radius * 2.0, radius * 2.0); + let outer_r = cx.min(cy) - 8.0; + if outer_r <= 0.0 { + return Ok(()); + } + let inner_r = outer_r * inner_ratio; + + let outer = Rect::from_xywh(cx - outer_r, cy - outer_r, outer_r * 2.0, outer_r * 2.0); + let inner = Rect::from_xywh(cx - inner_r, cy - inner_r, inner_r * 2.0, inner_r * 2.0); let total_sweep = 360.0 * progress; let mut start_angle = -90.0_f32; - for (i, dp) in self.data.iter().enumerate() { - let sweep = (dp.value / total) as f32 * total_sweep; - let color = dp.color.as_deref().unwrap_or_else(|| self.get_color(i)); + for (i, &value) in values.iter().enumerate() { + let sweep = (value / total) as f32 * total_sweep; + if sweep <= 0.0 { + continue; + } + let color = self.data[i] + .color + .as_deref() + .unwrap_or_else(|| self.get_color(i)); let mut paint = paint_from_hex(color); paint.set_style(PaintStyle::Fill); paint.set_anti_alias(true); - let mut path = Path::new(); - path.move_to((cx, cy)); - path.arc_to(oval, start_angle, sweep, false); - path.close(); - canvas.draw_path(&path, &paint); + if sweep >= FULL_SWEEP { + if inner_r > 0.0 { + // Stroking the mid-radius circle gives an exact annulus + // without relying on path winding rules. + let mut ring = paint; + ring.set_style(PaintStyle::Stroke); + ring.set_stroke_width(outer_r - inner_r); + canvas.draw_circle((cx, cy), (outer_r + inner_r) / 2.0, &ring); + } else { + canvas.draw_circle((cx, cy), outer_r, &paint); + } + } else { + let mut path = Path::new(); + if inner_r > 0.0 { + // Outer arc forward, inner arc back: a closed annulus sector. + path.arc_to(outer, start_angle, sweep, false); + path.arc_to(inner, start_angle + sweep, -sweep, false); + } else { + path.move_to((cx, cy)); + path.arc_to(outer, start_angle, sweep, false); + } + path.close(); + canvas.draw_path(&path, &paint); + } start_angle += sweep; } Ok(()) } - - pub(super) fn render_donut( - &self, - canvas: &Canvas, - w: f32, - h: f32, - progress: f32, - ) -> Result<()> { - // Draw pie first - self.render_pie(canvas, w, h, progress)?; - - // Punch out center circle - let cx = w / 2.0; - let cy = h / 2.0; - let outer_radius = cx.min(cy) - 8.0; - let inner_r = outer_radius * self.inner_radius.clamp(0.1, 0.95) as f32; - - // Draw a circle filled with the background color (transparent black by default) - // We use save/restore with a blend mode to cut out the center - let mut center_paint = skia_safe::Paint::default(); - center_paint.set_style(PaintStyle::Fill); - center_paint.set_anti_alias(true); - center_paint.set_blend_mode(skia_safe::BlendMode::Clear); - canvas.draw_circle((cx, cy), inner_r, ¢er_paint); - - Ok(()) - } } diff --git a/crates/rustmotion-components/src/chart/radar.rs b/crates/rustmotion-components/src/chart/radar.rs index 1c9607f..de05e74 100644 --- a/crates/rustmotion-components/src/chart/radar.rs +++ b/crates/rustmotion-components/src/chart/radar.rs @@ -83,23 +83,46 @@ impl Chart { 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 lx = px - label_w / 2.0; + // Anchor by side — centring every label pushed the east/west ones + // out of the component box (measured: 389 ink pixels outside a + // 400x400 box, reaching 28px left and 45px right of it). Labels on + // the right start at the vertex, labels on the left end at it, and + // the result is clamped into the box as a backstop. + let cos = angle.cos(); + let anchored = if cos > 0.15 { + px + } else if cos < -0.15 { + px - label_w + } else { + px - label_w / 2.0 + }; + let lx = anchored.clamp(0.0, (w - label_w).max(0.0)); let ly = py + ascent / 2.0; draw_text_with_fallback(canvas, label, &font, &emoji_font, 0.0, lx, ly, &label_paint); } + // One scale for every series. Normalising each polygon by its own max + // made a [3, 2, 1, 2] series fill nearly the same area as a [10, 8, 6, + // 9] one, so overlaid series could not be compared at all — the single + // thing a radar chart exists to do. + let global_max = self + .radar_data + .iter() + .flat_map(|rd| rd.values.iter().copied()) + .fold(0.0_f64, f64::max) + .max(0.001); + // Draw data polygons for (di, rd) in self.radar_data.iter().enumerate() { if rd.values.len() != n_axes { continue; } - let max_val = rd.values.iter().fold(0.0_f64, |a, &b| a.max(b)).max(0.001); let color_str = rd.color.as_deref().unwrap_or_else(|| self.get_color(di)); let mut data_path = Path::new(); for (i, &val) in rd.values.iter().enumerate() { - let norm = (val / max_val) as f32 * progress; + let norm = (val.max(0.0) / global_max) as f32 * progress; let angle = -std::f32::consts::FRAC_PI_2 + i as f32 * angle_step; let r = radius * norm; let px = cx + r * angle.cos(); diff --git a/crates/rustmotion-components/src/chart/radial.rs b/crates/rustmotion-components/src/chart/radial.rs index 1353c7c..4e173e3 100644 --- a/crates/rustmotion-components/src/chart/radial.rs +++ b/crates/rustmotion-components/src/chart/radial.rs @@ -20,10 +20,13 @@ impl Chart { let track_width = (max_radius / (n as f32 * 1.5)).clamp(6.0, 20.0); let ring_gap = track_width * 0.5; + // Negative values are clamped: an arc can only sweep forward, so a + // negative one drew a ring backwards from 12 o'clock and read as a + // large positive value. let max_val = self .data .iter() - .map(|d| d.value) + .map(|d| d.value.max(0.0)) .fold(0.0_f64, f64::max) .max(0.001); @@ -46,7 +49,7 @@ impl Chart { // Fill arc let color = dp.color.as_deref().unwrap_or_else(|| self.get_color(i)); - let sweep = (dp.value / max_val) as f32 * 360.0 * progress; + let sweep = (dp.value.max(0.0) / max_val) as f32 * 360.0 * progress; let mut fill_paint = paint_from_hex(color); fill_paint.set_style(PaintStyle::Stroke); diff --git a/crates/rustmotion-components/src/chart/scatter.rs b/crates/rustmotion-components/src/chart/scatter.rs index 2a89d18..d8555d0 100644 --- a/crates/rustmotion-components/src/chart/scatter.rs +++ b/crates/rustmotion-components/src/chart/scatter.rs @@ -3,6 +3,8 @@ use skia_safe::{Canvas, PaintStyle}; use rustmotion_core::engine::renderer::paint_from_hex; +use super::axes::format_number; +use super::line::series_scale; use super::Chart; impl Chart { @@ -21,19 +23,29 @@ impl Chart { let chart_w = w - ml - mr; let chart_h = h - mt - mb; - let min_x = self.points.iter().map(|p| p.x).fold(f64::MAX, f64::min); - let max_x = self.points.iter().map(|p| p.x).fold(f64::MIN, f64::max); - let min_y = self.points.iter().map(|p| p.y).fold(f64::MAX, f64::min); - let max_y = self.points.iter().map(|p| p.y).fold(f64::MIN, f64::max); - - let range_x = (max_x - min_x).max(0.001); - let range_y = (max_y - min_y).max(0.001); - - self.draw_axes(canvas, ml, mt, chart_w, chart_h, min_y, max_y, &[], false); + let (min_x, max_x, norm_x) = series_scale(self.points.iter().map(|p| p.x)); + let (min_y, max_y, norm_y) = series_scale(self.points.iter().map(|p| p.y)); + + // `ScatterPoint` carries no label, so the previous empty slice meant + // `show_x_labels: true` reserved a bottom gutter and then drew nothing + // into it. A scatter's x axis is numeric: label it with ticks over + // min_x..max_x, at the same 5 fractions `draw_axes` uses for the y + // axis. Passing `categorical: false` places tick i at i/(n-1) of the + // width, which is exactly where those fractions fall. + let x_labels: Vec = if self.show_x_labels { + (0..=5) + .map(|i| format_number(min_x + (max_x - min_x) * (i as f64 / 5.0))) + .collect() + } else { + Vec::new() + }; + self.draw_axes( + canvas, ml, mt, chart_w, chart_h, min_y, max_y, &x_labels, false, + ); for (i, pt) in self.points.iter().enumerate() { - let px = ml + ((pt.x - min_x) / range_x) as f32 * chart_w; - let py = mt + chart_h - ((pt.y - min_y) / range_y) as f32 * chart_h; + let px = ml + norm_x(pt.x) * chart_w; + let py = mt + chart_h - norm_y(pt.y) * chart_h; let color = pt.color.as_deref().unwrap_or_else(|| self.get_color(i)); let mut paint = paint_from_hex(color);