Skip to content
Merged
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
396 changes: 373 additions & 23 deletions crates/rustmotion-components/src/chart/bar.rs

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions crates/rustmotion-components/src/chart/line.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ impl Chart {
let (min_val, max_val, norm) = series_scale(self.data.iter().map(|d| d.value));

let n = self.data.len();
let x_labels: Vec<String> = self.data.iter().filter_map(|d| d.label.clone()).collect();
let x_labels: Vec<String> = self
.data
.iter()
.map(|d| d.label.clone().unwrap_or_default())
.collect();
self.draw_axes(
canvas, ml, mt, chart_w, chart_h, min_val, max_val, &x_labels, false,
);
Expand Down Expand Up @@ -131,7 +135,11 @@ impl Chart {
let (min_val, max_val, norm) = series_scale(self.data.iter().map(|d| d.value));

let n = self.data.len();
let x_labels: Vec<String> = self.data.iter().filter_map(|d| d.label.clone()).collect();
let x_labels: Vec<String> = self
.data
.iter()
.map(|d| d.label.clone().unwrap_or_default())
.collect();
self.draw_axes(
canvas, ml, mt, chart_w, chart_h, min_val, max_val, &x_labels, false,
);
Expand Down
92 changes: 91 additions & 1 deletion crates/rustmotion-components/src/chart/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,13 @@ impl Chart {
if !self.animated {
return 1.0;
}
let p = (time / self.animation_duration).clamp(0.0, 1.0) as f32;
// Ramp measured from `start_at`, not from scene time zero — matches
// `Counter::ramp_progress`. A chart delayed with `start_at` used to
// read raw scene time, so it was already fully drawn on the very
// first frame it became visible.
let start = self.timing.start_at.unwrap_or(0.0);
let elapsed = (time - start).max(0.0);
let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32;
// ease_out_cubic
1.0 - (1.0 - p).powi(3)
}
Expand Down Expand Up @@ -318,3 +324,87 @@ impl Painter for Chart {
let _ = self.paint(canvas, layout.width, layout.height, ctx.time);
}
}

#[cfg(test)]
mod tests {
use super::*;
use rustmotion_core::traits::TimingConfig;

fn base_chart() -> Chart {
Chart {
chart_type: ChartType::Bar,
data: Vec::new(),
animated: true,
animation_duration: 1.5,
colors: None,
inner_radius: 0.6,
fill_opacity: 0.3,
smooth: false,
categories: Vec::new(),
series: Vec::new(),
axes: Vec::new(),
radar_data: Vec::new(),
points: Vec::new(),
direction: None,
show_grid: false,
show_x_labels: false,
show_y_labels: false,
grid_color: default_grid_color(),
label_color: default_label_color(),
label_font_size: default_label_font_size(),
show_labels: false,
timing: TimingConfig::default(),
style: rustmotion_core::css::CssStyle::default(),
timeline: Vec::new(),
stagger: None,
}
}

#[test]
fn progress_ramp_starts_at_start_at_not_at_scene_time_zero() {
// #3's exact repro: a chart delayed with `start_at: 2.0` and
// `animation_duration: 1.5` was already fully drawn (progress 1.0)
// on the very first frame it became visible, because the ramp read
// raw scene time instead of time-since-`start_at` — the same defect
// `Counter::ramp_progress` was fixed for.
let mut chart = base_chart();
chart.animation_duration = 1.5;
chart.timing = TimingConfig {
start_at: Some(2.0),
end_at: None,
};

assert_eq!(
chart.progress_at(2.0),
0.0,
"no time has elapsed since start_at yet"
);
assert!(
chart.progress_at(2.75) < 1.0,
"still mid-ramp half a second after start_at"
);
assert_eq!(
chart.progress_at(3.5),
1.0,
"animation_duration has fully elapsed since start_at"
);
}

#[test]
fn progress_ramp_with_no_start_at_behaves_like_before() {
let chart = base_chart();
assert_eq!(chart.progress_at(0.0), 0.0);
assert_eq!(chart.progress_at(1.5), 1.0);
}

#[test]
fn progress_ramp_when_not_animated_is_always_complete() {
let mut chart = base_chart();
chart.animated = false;
chart.timing = TimingConfig {
start_at: Some(2.0),
end_at: None,
};
assert_eq!(chart.progress_at(0.0), 1.0);
}
}
6 changes: 5 additions & 1 deletion crates/rustmotion-components/src/chart/waterfall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ impl Chart {
let max_val = all_vals.iter().fold(f64::MIN, |a, &b| a.max(b));
let range = (max_val - min_val).max(0.001);

let x_labels: Vec<String> = self.data.iter().filter_map(|d| d.label.clone()).collect();
let x_labels: Vec<String> = self
.data
.iter()
.map(|d| d.label.clone().unwrap_or_default())
.collect();
self.draw_axes(
canvas, ml, mt, chart_w, chart_h, min_val, max_val, &x_labels, true,
);
Expand Down
6 changes: 5 additions & 1 deletion crates/rustmotion-components/src/dot_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,11 @@ impl DotMap {
if !self.animated {
return 1.0;
}
let p = (time / self.animation_duration).clamp(0.0, 1.0) as f32;
// Measure from `start_at`, like every other animated component: driving
// the ramp off raw scene time makes a delayed map arrive already drawn.
let start = self.timing.start_at.unwrap_or(0.0);
let elapsed = (time - start).max(0.0);
let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32;
1.0 - (1.0 - p).powi(3)
}

Expand Down
6 changes: 5 additions & 1 deletion crates/rustmotion-components/src/gauge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,11 @@ impl Gauge {
if !self.animated {
return 1.0;
}
let p = (time / self.animation_duration).clamp(0.0, 1.0) as f32;
// Measure from `start_at`, like every other animated component: driving
// the ramp off raw scene time makes a delayed gauge arrive already full.
let start = self.timing.start_at.unwrap_or(0.0);
let elapsed = (time - start).max(0.0);
let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32;
1.0 - (1.0 - p).powi(3)
}

Expand Down
155 changes: 137 additions & 18 deletions crates/rustmotion-components/src/heatmap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,16 @@ fn interpolate_color(scale: &[String], t: f32) -> (u8, u8, u8) {
return (r, g, b);
}
let n = scale.len() - 1;
let segment = (t * n as f32).floor() as usize;
let local_t = t * n as f32 - segment as f32;
let i = segment.min(n - 1);
let (r1, g1, b1, _) = parse_hex_color(&scale[i]);
let (r2, g2, b2, _) = parse_hex_color(&scale[i + 1]);
let scaled = t * n as f32;
// Clamp the segment index (t=1.0 lands exactly on `n`, one past the
// last valid segment), but re-derive `local_t` from the *clamped*
// segment rather than reusing the unclamped one — otherwise t=1.0
// computed local_t=0.0 against the clamped (second-to-last) segment and
// resolved to the second-to-last color instead of the last one.
let segment = (scaled.floor() as usize).min(n - 1);
let local_t = (scaled - segment as f32).clamp(0.0, 1.0);
let (r1, g1, b1, _) = parse_hex_color(&scale[segment]);
let (r2, g2, b2, _) = parse_hex_color(&scale[segment + 1]);
(
lerp_u8(r1, r2, local_t),
lerp_u8(g1, g2, local_t),
Expand All @@ -107,7 +112,13 @@ impl Heatmap {
if !self.animated {
return 1.0;
}
let p = (time / self.animation_duration).clamp(0.0, 1.0) as f32;
// Ramp measured from `start_at`, not from scene time zero — matches
// `Counter::ramp_progress`. A heatmap delayed with `start_at` used
// to read raw scene time, so it was already fully revealed on the
// very first frame it became visible.
let start = self.timing.start_at.unwrap_or(0.0);
let elapsed = (time - start).max(0.0);
let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32;
1.0 - (1.0 - p).powi(3)
}

Expand All @@ -121,17 +132,6 @@ impl Heatmap {

let progress = self.progress_at(time);

// Find min/max across all cells
let mut min_val = f64::MAX;
let mut max_val = f64::MIN;
for row in &self.data {
for &val in row {
min_val = min_val.min(val);
max_val = max_val.max(val);
}
}
let range = (max_val - min_val).max(0.001);

// Animation: clip rect expanding from left to right
let clip_w = w * progress;
canvas.save();
Expand All @@ -145,7 +145,15 @@ impl Heatmap {

for (row_idx, row) in self.data.iter().enumerate() {
for (col_idx, &val) in row.iter().enumerate() {
let normalized = ((val - min_val) / range) as f32;
// `color_scale` documents an absolute 0.0-1.0 semantic
// (SKILL.md: "2D array of f64, values 0.0-1.0"), not a
// per-render min-max scale. Renormalizing meant a grid of
// constant values (or any subrange, e.g. [0.8, 0.9, 1.0])
// painted identically to a grid of zeros — a flat or
// uniformly-high grid is not the same fact as "nothing
// happened". Clamp into the documented range instead of
// rescaling to whatever the data happens to span.
let normalized = (val as f32).clamp(0.0, 1.0);
let (r, g, b) = interpolate_color(&self.color_scale, normalized);

let x = col_idx as f32 * step;
Expand Down Expand Up @@ -179,3 +187,114 @@ impl Painter for Heatmap {
self.paint(canvas, layout.width, layout.height, ctx.time);
}
}

#[cfg(test)]
mod tests {
use super::*;
use rustmotion_core::traits::TimingConfig;

fn base_heatmap(data: Vec<Vec<f64>>) -> Heatmap {
Heatmap {
data,
color_scale: default_color_scale(),
cell_size: default_cell_size(),
cell_gap: default_cell_gap(),
cell_radius: default_cell_radius(),
animated: true,
animation_duration: 1.5,
timing: TimingConfig::default(),
style: CssStyle::default(),
timeline: Vec::new(),
stagger: None,
}
}

fn cell_color(heatmap: &Heatmap, w: i32, h: i32, time: f64) -> (u8, u8, u8) {
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface");
{
let canvas = surface.canvas();
heatmap.paint(canvas, w as f32, h as f32, time);
}
let snapshot = surface.image_snapshot();
let info = skia_safe::ImageInfo::new(
(1, 1),
skia_safe::ColorType::RGBA8888,
skia_safe::AlphaType::Premul,
None,
);
let mut buf = [0u8; 4];
// Sample the middle of the top-left cell.
let x = (heatmap.cell_size / 2.0) as i32;
let y = (heatmap.cell_size / 2.0) as i32;
snapshot.read_pixels(
&info,
&mut buf,
4,
skia_safe::IPoint::new(x, y),
skia_safe::image::CachingHint::Disallow,
);
(buf[0], buf[1], buf[2])
}

#[test]
fn a_uniformly_low_grid_is_not_identical_to_an_all_zero_grid() {
// #6's exact repro: `color_scale` is documented (SKILL.md) as an
// *absolute* 0.0-1.0 scale, but the painter renormalized min→max —
// so a grid of constant 5.0s (or any other constant) rendered
// pixel-for-pixel identical to a grid of constant 0.0s, both
// collapsing to the scale's first (lowest) color.
let uniform = base_heatmap(vec![vec![5.0, 5.0, 5.0], vec![5.0, 5.0, 5.0]]);
let zero = base_heatmap(vec![vec![0.0, 0.0, 0.0], vec![0.0, 0.0, 0.0]]);
let uniform_color = cell_color(&uniform, 200, 100, 10.0);
let zero_color = cell_color(&zero, 200, 100, 10.0);
assert_ne!(
uniform_color, zero_color,
"a grid of 5.0s must not render identically to a grid of 0.0s"
);
}

#[test]
fn absolute_values_are_not_renormalized_to_the_data_subrange() {
// A grid whose values happen to span [0.8, 1.0] must not stretch
// that subrange to fill the whole color scale — 0.8 reads as
// "mostly full", not as "the bottom of whatever this grid contains".
let high = base_heatmap(vec![vec![0.8, 0.9, 1.0]]);
let low = base_heatmap(vec![vec![0.0, 0.1, 0.2]]);
let high_first_cell = cell_color(&high, 200, 100, 10.0);
let low_first_cell = cell_color(&low, 200, 100, 10.0);
assert_ne!(
high_first_cell, low_first_cell,
"0.8 and 0.0 must not render as the same color"
);
}

#[test]
fn interpolate_color_at_the_top_of_the_scale_returns_the_last_color() {
// Surfaced while chasing #6: the clamped segment index was reused
// for `local_t` too, so t=1.0 exactly computed `local_t = 0.0` for
// the *clamped* (second-to-last) segment instead of `local_t = 1.0`
// — landing on the second-to-last color rather than the last
// (brightest) one.
let scale = default_color_scale();
let (r, g, b) = interpolate_color(&scale, 1.0);
let (er, eg, eb, _) = parse_hex_color(scale.last().unwrap());
assert_eq!(
(r, g, b),
(er, eg, eb),
"t=1.0 must resolve to the last color in the scale"
);
}

#[test]
fn progress_ramp_starts_at_start_at_not_at_scene_time_zero() {
let mut heatmap = base_heatmap(vec![vec![1.0]]);
heatmap.animation_duration = 1.5;
heatmap.timing = TimingConfig {
start_at: Some(2.0),
end_at: None,
};
assert_eq!(heatmap.progress_at(2.0), 0.0);
assert!(heatmap.progress_at(2.75) < 1.0);
assert_eq!(heatmap.progress_at(3.5), 1.0);
}
}
Loading
Loading