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);