diff --git a/AGENTS.md b/AGENTS.md index e5a0cf5..e4497e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,7 +154,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Taffy 0.13 collapses a child's vertical margin through a block parent with no padding or border on that side, and its size cache keeps only that block's size, so a box that measures it again leaves the margin out. A component moves a box with a relative offset, or with padding where what follows moves too, not with such a margin: `layout::Drawer` pulls and snaps its panel, and a fullscreen `overlays::Dialog` slides its card, with `top`; `motion::Stagger`, `Transition`, a centered Dialog and the palette's card rise with padding, so the box a scrim centers grows with them; the gallery's inspector sample is its probe's own child. A margin that stops at its parent is safe: a flex or grid item's own, or a child's of a flex item or of a box with padding or a border on that side. A flex wrapper stops the collapse but gives its items a definite height, so percent heights inside start to resolve (the Drawer's `max_h(relative(0.85))`). - Tiles in a picture's shape sit in flex rows of `flex_1` cells (`generative::GenerationGrid`). A cell's padding counts in its flex base, so a row of fewer cells gives each more width; cells that match across rows keep their inset on a box inside (`messaging::VideoCallGrid`). - Rise reads green and fall red unless a component's `red_up` swaps them, as markets in East Asia read. `finance::quotes::moves` picks the pair. -- Charts lay out in `f32` pixels of their own box, tested without gpui, and paint through `charts::paint::at`: a canvas's paint gets window coordinates, and `with_element_offset` works only in prepaint. Their sizes come from `theme.chart()`. +- Charts lay out in `f32` pixels of their own box, tested without gpui, and paint through `charts::paint::at`: a canvas's paint gets window coordinates, and `with_element_offset` works only in prepaint. Their sizes come from `theme.chart()`. A chart value is finite and within ±`charts::LIMIT` (1e30), so sums of values and their pixels stay inside `f32`; the builders refuse others, and a scale works in halves so a domain across the limit maps. - A label pinned to a point sits in a zero-size absolute box that centers it with flex (`charts::axes`), so no offset guesses at text size. - Tokens live in `src/theme`; the raw `px()` scan skips that folder only. - An owner's palette, edited or imported, reaches the theme per mode through `Theme::set_palette`, and `Theme::palette` names the one the shown mode fades to; high contrast strengthens Ely's own palettes alone. A color goes by its field's name through `token` and `token_mut`, on `Palette` and `Syntax` alike. Ely's palettes pass `tooling::A11yChecker`'s audit in both modes, with high contrast and without; a test holds them there. diff --git a/src/charts/burndown.rs b/src/charts/burndown.rs index 79da911..f77f4c6 100644 --- a/src/charts/burndown.rs +++ b/src/charts/burndown.rs @@ -11,6 +11,7 @@ use super::{ parts::ChartTooltip, scale::{Linear, compact, nice}, schedule::{days, marks}, + series::drawable, tiles::{Tiles, tracked}, }; use crate::{ @@ -62,7 +63,10 @@ impl BurndownChart { /// A sprint from its first day through its last, with `scope` to do, in points or issues. pub fn new(id: impl Into, first: Date, last: Date, scope: f64) -> Self { assert!(last > first, "a sprint ends after its first day"); - assert!(scope.is_finite() && scope > 0.0, "a sprint has work to do"); + assert!( + drawable(scope) && scope > 0.0, + "a sprint has work to do, within charts::LIMIT" + ); Self { base: div(), id: id.into(), @@ -85,8 +89,8 @@ impl BurndownChart { assert!( self.left .iter() - .all(|value| value.is_finite() && *value >= 0.0), - "work left is a count, zero or more" + .all(|value| drawable(*value) && *value >= 0.0), + "work left is a count, zero or more, within charts::LIMIT" ); self } diff --git a/src/charts/calendar.rs b/src/charts/calendar.rs index d783cda..2d7ddd0 100644 --- a/src/charts/calendar.rs +++ b/src/charts/calendar.rs @@ -4,7 +4,10 @@ use gpui::{ App, Div, ElementId, Entity, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, Refineable, RenderOnce, StyleRefinement, Styled, Window, canvas, div, fill, }; -use jiff::{Span, civil::Date}; +use jiff::{ + Span, + civil::{Date, date}, +}; use super::{ axes::{anchored, beside}, @@ -14,6 +17,7 @@ use super::{ parts::ChartTooltip, plot::Format, scale::compact, + series::drawable, tiles::{Tiles, key, tracked}, }; use crate::theme::{ActiveTheme, Radius, TextSize}; @@ -37,10 +41,13 @@ impl CalendarHeatmap { ) -> Self { let counts: Vec = counts.into_iter().collect(); assert!( - counts - .iter() - .all(|count| count.is_finite() && *count >= 0.0), - "a calendar needs counts of zero or more" + counts.iter().all(|count| drawable(*count) && *count >= 0.0), + "a calendar needs counts of zero or more, within charts::LIMIT" + ); + let span = Span::new().days(counts.len().saturating_sub(1) as i64); + assert!( + start.checked_add(span).is_ok(), + "a calendar's last day lies past the dates jiff holds" ); Self { base: div(), @@ -119,10 +126,7 @@ impl RenderOnce for CalendarHeatmap { .child(text(day(ix).strftime("%b").to_string())) }); let weekdays = [0usize, 2, 4].map(|weekday| { - let date = (0..7) - .map(day) - .find(|date| date.weekday().to_monday_zero_offset() as usize == weekday) - .expect("seven days hold every weekday"); + let date = date(2024, 1, 1 + weekday as i8); beside( top + side * (weekday as f32 + 0.5), sizes.gutter.to_pixels(rem), diff --git a/src/charts/cartesian.rs b/src/charts/cartesian.rs index ae961e0..aab9af7 100644 --- a/src/charts/cartesian.rs +++ b/src/charts/cartesian.rs @@ -10,6 +10,7 @@ use super::{ geometry::{Categories, Ink, Mark, categories, scatter}, plot::{Format, Layout, Pick, Plot, Scene, Tips, plot}, scale::compact, + series::drawable, series::{Points, Series, csv, points_csv}, }; use crate::theme::ActiveTheme; @@ -173,18 +174,22 @@ macro_rules! category_chart { /// A dashed level across the chart with its label, as a target or a limit. pub fn rule(mut self, value: f64, label: impl Into) -> Self { - assert!(value.is_finite(), "a rule needs a finite value"); + assert!( + drawable(value), + "a rule needs a value, within charts::LIMIT" + ); self.chart.rules.push((value, label.into())); self } /// A mark at category `ix` with its label, as a release or an incident. pub fn note(mut self, ix: usize, label: impl Into) -> Self { - assert!( - ix < self.chart.labels.len(), - "note {ix} is past the last label" - ); - self.chart.notes.push((ix, label.into())); + let label = label.into(); + if ix >= self.chart.labels.len() { + log::error!("chart: note {label} at {ix} is past the last label; skipped"); + return self; + } + self.chart.notes.push((ix, label)); self } diff --git a/src/charts/distribution.rs b/src/charts/distribution.rs index 97cf066..319fc63 100644 --- a/src/charts/distribution.rs +++ b/src/charts/distribution.rs @@ -11,6 +11,7 @@ use super::{ plot::{Layout, Pick, Plot, Scene, Tips}, scale::{Band, Linear, compact, nice}, series::Series, + series::drawable, stats::{Step, bins, density, five, steps}, }; @@ -77,8 +78,8 @@ impl Histogram { pub fn new(id: impl Into, values: impl IntoIterator) -> Self { let values: Vec = values.into_iter().collect(); assert!( - values.iter().all(|value| value.is_finite()), - "a histogram needs finite values" + values.iter().all(|value| drawable(*value)), + "a histogram needs values, within charts::LIMIT" ); Self { base: div(), @@ -130,7 +131,10 @@ impl WaterfallChart { /// A change, up or down, from the running total. pub fn step(mut self, label: impl Into, change: f64) -> Self { - assert!(change.is_finite(), "a step needs a finite change"); + assert!( + drawable(change), + "a step needs a change, within charts::LIMIT" + ); self.steps.push((label.into(), change, false)); self } @@ -257,8 +261,8 @@ macro_rules! grouped { ) -> Self { let values: Vec = values.into_iter().collect(); assert!( - values.len() >= 2 && values.iter().all(|value| value.is_finite()), - "a group needs two finite values" + values.len() >= 2 && values.iter().all(|value| drawable(*value)), + "a group needs two values, within charts::LIMIT" ); self.groups.push((label.into(), values)); self diff --git a/src/charts/flow.rs b/src/charts/flow.rs index 32b9880..ffea558 100644 --- a/src/charts/flow.rs +++ b/src/charts/flow.rs @@ -13,6 +13,7 @@ use super::{ parts::ChartTooltip, plot::Format, scale::compact, + series::drawable, }; use crate::{ theme::{ActiveTheme, Radius, TextSize}, @@ -118,15 +119,18 @@ pub(crate) fn sankey( let columns = column.iter().max().map_or(1, |last| last + 1); let placed = &column; let members = |c: usize| (0..count).filter(move |ix| placed[*ix] == c); + let total = |c: usize| members(c).map(|ix| through[ix]).sum::(); + let biggest = (0..columns).map(total).fold(0.0, f64::max); let scale = (0..columns) .map(|c| { - let (total, gaps) = ( - members(c).map(|ix| through[ix]).sum::(), - members(c).count().saturating_sub(1), - ); - (frame.h - padding * gaps as f32).max(0.0) / total.max(f64::EPSILON) as f32 + let gaps = members(c).count().saturating_sub(1); + f64::from((frame.h - padding * gaps as f32).max(0.0)) / (total(c) / biggest) }) - .fold(f32::MAX, f32::min); + .fold(f64::INFINITY, f64::min); + let height = |value: f64| match biggest > 0.0 { + true => (value / biggest * scale) as f32, + false => 0.0, + }; let mut nodes = vec![Rect::default(); count]; for c in 0..columns { let x = if columns > 1 { @@ -134,7 +138,7 @@ pub(crate) fn sankey( } else { frame.x }; - let tall: f32 = members(c).map(|ix| through[ix] as f32 * scale).sum::() + let tall: f32 = members(c).map(|ix| height(through[ix])).sum::() + padding * members(c).count().saturating_sub(1) as f32; let mut y = frame.y + (frame.h - tall) / 2.0; for ix in members(c) { @@ -142,7 +146,7 @@ pub(crate) fn sankey( x, y, w: node, - h: through[ix] as f32 * scale, + h: height(through[ix]), }; y += nodes[ix].h + padding; } @@ -152,7 +156,7 @@ pub(crate) fn sankey( .map(|link| Ribbon { from: (0.0, 0.0), to: (0.0, 0.0), - thick: link.2 as f32 * scale, + thick: height(link.2), }) .collect(); let mut order: Vec = (0..links.len()).collect(); @@ -200,13 +204,16 @@ impl SankeyChart { /// A flow of `value` from node `from` to node `to`, by their places in the list. pub fn link(mut self, from: usize, to: usize, value: f64) -> Self { + if from >= self.nodes.len() || to >= self.nodes.len() || from == to { + log::error!( + "sankey: a link {from} -> {to} among {} nodes; skipped", + self.nodes.len() + ); + return self; + } assert!( - from < self.nodes.len() && to < self.nodes.len() && from != to, - "a link joins two known nodes" - ); - assert!( - value.is_finite() && value > 0.0, - "a link carries a positive value" + drawable(value) && value > 0.0, + "a link carries a positive value, within charts::LIMIT" ); self.links.push((from, to, value)); self @@ -243,6 +250,7 @@ impl RenderOnce for SankeyChart { w: (f32::from(bounds.size.width) - room * 2.0).max(0.0), h: (f32::from(bounds.size.height) - pixels(sizes.inset) * 2.0).max(0.0), }; + let hover = hover.filter(|_| frame.w > 0.0); let (nodes, ribbons) = if frame.w > 0.0 { sankey( self.nodes.len(), diff --git a/src/charts/funnel.rs b/src/charts/funnel.rs index 8d0978e..3f9c929 100644 --- a/src/charts/funnel.rs +++ b/src/charts/funnel.rs @@ -13,6 +13,7 @@ use super::{ parts::ChartTooltip, plot::Format, scale::compact, + series::drawable, tiles::Tiles, }; use crate::{ @@ -41,8 +42,8 @@ impl FunnelChart { pub fn stage(mut self, name: impl Into, value: f64) -> Self { assert!( - value.is_finite() && value >= 0.0, - "a stage needs a value of zero or more" + drawable(value) && value >= 0.0, + "a stage needs a value of zero or more, within charts::LIMIT" ); self.stages.push((name.into(), value)); self diff --git a/src/charts/mod.rs b/src/charts/mod.rs index 1643306..56506bc 100644 --- a/src/charts/mod.rs +++ b/src/charts/mod.rs @@ -43,6 +43,7 @@ pub use radar::RadarChart; pub use realtime::RealtimeChart; pub use relations::ChordDiagram; pub use schedule::{GanttChart, Task}; +pub use series::LIMIT; pub use series::{Points, Series}; pub use sunburst::{Slice, Sunburst}; pub use targets::{Bullet, BulletChart, ProgressChart}; diff --git a/src/charts/network.rs b/src/charts/network.rs index 203dcbf..63920d2 100644 --- a/src/charts/network.rs +++ b/src/charts/network.rs @@ -129,10 +129,13 @@ impl NetworkGraph { /// An edge between two nodes, by their places in the list. pub fn edge(mut self, a: usize, b: usize) -> Self { - assert!( - a < self.nodes.len() && b < self.nodes.len() && a != b, - "an edge joins two known nodes" - ); + if a >= self.nodes.len() || b >= self.nodes.len() || a == b { + log::error!( + "network: an edge {a} - {b} among {} nodes; skipped", + self.nodes.len() + ); + return self; + } self.edges.push((a, b)); self } diff --git a/src/charts/parallel.rs b/src/charts/parallel.rs index de50bd5..f4cf370 100644 --- a/src/charts/parallel.rs +++ b/src/charts/parallel.rs @@ -12,6 +12,7 @@ use super::{ parts::ChartTooltip, plot::Format, scale::{Linear, compact, nice}, + series::drawable, tiles::{Tiles, tracked}, }; use crate::theme::{ActiveTheme, TextSize}; @@ -109,8 +110,8 @@ impl ParallelCoordinates { "a record needs a value per axis" ); assert!( - values.iter().all(|value| value.is_finite()), - "a record needs finite values" + values.iter().all(|value| drawable(*value)), + "a record needs values, within charts::LIMIT" ); self.records.push((name.into(), values)); self diff --git a/src/charts/pie.rs b/src/charts/pie.rs index c5fb330..b40b614 100644 --- a/src/charts/pie.rs +++ b/src/charts/pie.rs @@ -16,6 +16,7 @@ use super::{ paint::{finish, measure, tint}, parts::{ChartLegend, ChartTooltip}, scale::compact, + series::drawable, }; use crate::{ motion, @@ -191,8 +192,8 @@ impl PieChart { pub fn slice(mut self, name: impl Into, value: f64) -> Self { assert!( - value.is_finite() && value >= 0.0, - "a slice needs a value of zero or more" + drawable(value) && value >= 0.0, + "a slice needs a value of zero or more, within charts::LIMIT" ); self.slices.push((name.into(), value)); self diff --git a/src/charts/realtime.rs b/src/charts/realtime.rs index 3ad4103..58421f6 100644 --- a/src/charts/realtime.rs +++ b/src/charts/realtime.rs @@ -10,6 +10,7 @@ use super::{ geometry::{Rect, frame}, paint::{at, finish, measure, place, ring, tint, trace}, scale::{Linear, compact, nice}, + series::drawable, }; use crate::{ motion, @@ -43,8 +44,8 @@ impl RealtimeChart { ) -> Self { let values: Vec = values.into_iter().collect(); assert!( - values.iter().all(|value| value.is_finite()), - "a stream needs finite values" + values.iter().all(|value| drawable(*value)), + "a stream needs values, within charts::LIMIT" ); Self { base: div(), diff --git a/src/charts/relations.rs b/src/charts/relations.rs index 01eb0b9..1337aad 100644 --- a/src/charts/relations.rs +++ b/src/charts/relations.rs @@ -15,6 +15,7 @@ use super::{ pie::{bearing, toward, wedge}, plot::Format, scale::compact, + series::drawable, tiles::{Tiles, tracked}, }; use crate::theme::{ActiveTheme, TextSize}; @@ -26,10 +27,9 @@ type Arcs = Vec<(f32, f32)>; pub(crate) fn chords(matrix: &[Vec], pad: f32) -> (Arcs, Vec) { let grand: f64 = matrix.iter().flatten().sum(); let room = (TAU - pad * matrix.len() as f32).max(0.0); - let scale = if grand > 0.0 { - room / grand as f32 - } else { - 0.0 + let share = |value: f64| match grand > 0.0 { + true => (value / grand * f64::from(room)) as f32, + false => 0.0, }; let mut angle = -FRAC_PI_2 + pad / 2.0; let (mut groups, mut parts) = (Vec::new(), Vec::new()); @@ -38,7 +38,7 @@ pub(crate) fn chords(matrix: &[Vec], pad: f32) -> (Arcs, Vec) { parts.push( row.iter() .map(|value| { - let part = (angle, *value as f32 * scale); + let part = (angle, share(*value)); angle += part.1; part }) @@ -126,8 +126,8 @@ impl ChordDiagram { matrix .iter() .flatten() - .all(|value| value.is_finite() && *value >= 0.0), - "flows are zero or more" + .all(|value| drawable(*value) && *value >= 0.0), + "flows are zero or more, within charts::LIMIT" ); Self { base: div(), diff --git a/src/charts/scale.rs b/src/charts/scale.rs index 8c70d20..3be448a 100644 --- a/src/charts/scale.rs +++ b/src/charts/scale.rs @@ -1,3 +1,6 @@ +/// Shares past this many ranges lie off any screen; the bound keeps their pixels in `f32`. +const FAR: f64 = 1e4; + /// A linear map from a domain of values onto a range of pixels. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct Linear { @@ -22,16 +25,17 @@ impl Linear { } else { (f64::from(at) - f64::from(self.range.0)) / f64::from(span) }; - self.domain.0 + (self.domain.1 - self.domain.0) * share + let (low, high) = (self.domain.0 / 2.0, self.domain.1 / 2.0); + (low + (high - low) * share) * 2.0 } - /// Where `value` lands; a flat domain lands in the middle. + /// Where `value` lands; a flat domain lands in the middle. Halves keep wide domains finite. pub(crate) fn at(&self, value: f64) -> f32 { - let span = self.domain.1 - self.domain.0; - let share = if span == 0.0 { + let (low, high) = (self.domain.0 / 2.0, self.domain.1 / 2.0); + let share = if high == low { 0.5 } else { - (value - self.domain.0) / span + ((value / 2.0 - low) / (high - low)).clamp(-FAR, FAR) }; self.range.0 + (self.range.1 - self.range.0) * share as f32 } @@ -62,12 +66,13 @@ pub(crate) fn nice(low: f64, high: f64, count: usize) -> ((f64, f64), Vec) let (low, high) = (low.min(high), low.max(high)); let (low, high) = if high - low <= high.abs().max(low.abs()) * 1e-12 { let pad = (high.abs().max(low.abs()) * 0.1).max(1.0); - (low - pad, high + pad) + ((low - pad).max(f64::MIN), (high + pad).min(f64::MAX)) } else { (low, high) }; let step = nice_step(high - low, count); - let (start, end) = ((low / step).floor() * step, (high / step).ceil() * step); + let start = ((low / step).floor() * step).max(f64::MIN); + let end = ((high / step).ceil() * step).min(f64::MAX); let ticks = (0..count.max(1) * 4 + 4) .map(|ix| start + step * ix as f64) .take_while(|tick| *tick <= end + step * 1e-9) diff --git a/src/charts/schedule.rs b/src/charts/schedule.rs index 5e6f8d0..ff71bab 100644 --- a/src/charts/schedule.rs +++ b/src/charts/schedule.rs @@ -107,12 +107,15 @@ impl GanttChart { } /// A task; the ones it waits for come before it. - pub fn task(mut self, task: Task) -> Self { - assert!( - task.after.iter().all(|ix| *ix < self.tasks.len()), - "task {} waits for one not yet given", - task.name - ); + pub fn task(mut self, mut task: Task) -> Self { + let known = self.tasks.len(); + if task.after.iter().any(|ix| *ix >= known) { + log::error!( + "gantt: task {} waits for one not given; that wait is dropped", + task.name + ); + task.after.retain(|ix| *ix < known); + } self.tasks.push(task); self } diff --git a/src/charts/series.rs b/src/charts/series.rs index 9cea4a9..8e1b6f5 100644 --- a/src/charts/series.rs +++ b/src/charts/series.rs @@ -7,12 +7,20 @@ pub struct Series { pub(crate) values: Vec, } +/// The largest value a chart draws: sums and pixels of such values stay within `f32`. +pub const LIMIT: f64 = 1e30; + +/// A value a chart can draw: finite and within ±`LIMIT`. +pub(crate) fn drawable(value: f64) -> bool { + value.is_finite() && value.abs() <= LIMIT +} + impl Series { pub fn new(name: impl Into, values: impl IntoIterator) -> Self { let values: Vec = values.into_iter().collect(); assert!( - values.iter().all(|value| value.is_finite()), - "a series needs finite values" + values.iter().all(|value| drawable(*value)), + "a series needs values, within charts::LIMIT" ); Self { name: name.into(), @@ -36,8 +44,8 @@ impl Points { ) -> Self { let points: Vec<(f64, f64)> = points.into_iter().collect(); assert!( - points.iter().all(|(x, y)| x.is_finite() && y.is_finite()), - "points need finite values" + points.iter().all(|(x, y)| drawable(*x) && drawable(*y)), + "points need values, within charts::LIMIT" ); Self { name: name.into(), @@ -55,8 +63,8 @@ impl Points { "a bubble needs a size for each point" ); assert!( - sizes.iter().all(|size| size.is_finite() && *size >= 0.0), - "bubble sizes are not negative" + sizes.iter().all(|size| drawable(*size) && *size >= 0.0), + "bubble sizes are not negative, within charts::LIMIT" ); self.sizes = Some(sizes); self @@ -96,7 +104,7 @@ pub(crate) fn extent(values: impl Iterator) -> (f64, f64) { }) } -/// Tangents for a smooth line through `points` that never overshoots between them (Fritsch–Carlson). +/// Tangents for a smooth line through `points` that never overshoots between them (Fritsch–Carlson); points at one x rise straight. pub(crate) fn tangents(points: &[(f32, f32)]) -> Vec { let count = points.len(); if count < 2 { @@ -104,7 +112,10 @@ pub(crate) fn tangents(points: &[(f32, f32)]) -> Vec { } let slopes: Vec = points .windows(2) - .map(|pair| (pair[1].1 - pair[0].1) / (pair[1].0 - pair[0].0)) + .map(|pair| { + let slope = (pair[1].1 - pair[0].1) / (pair[1].0 - pair[0].0); + if slope.is_finite() { slope } else { 0.0 } + }) .collect(); let mut tangents: Vec = (0..count) .map(|ix| match ix { @@ -178,6 +189,16 @@ pub(crate) fn points_csv(clouds: &[Points]) -> String { mod tests { use super::*; + #[test] + fn points_at_one_x_rise_with_flat_tangents() { + let rising = tangents(&[(10.0, 0.0), (10.0, 5.0), (10.0, 9.0), (11.0, 9.5)]); + assert!( + rising.iter().all(|tangent| tangent.is_finite()), + "{rising:?}" + ); + assert_eq!(&rising[..2], &[0.0, 0.0]); + } + #[test] fn stacks_rise_from_zero_and_negatives_fall() { let stacks = stacked(&[&[2.0, -1.0], &[3.0, -2.0]]); diff --git a/src/charts/stats.rs b/src/charts/stats.rs index f4ec8a4..f2aaa11 100644 --- a/src/charts/stats.rs +++ b/src/charts/stats.rs @@ -45,7 +45,16 @@ pub(crate) struct Five { fn quantile(sorted: &[f64], share: f64) -> f64 { let at = share * (sorted.len() - 1) as f64; let (below, above) = (at.floor() as usize, at.ceil() as usize); - sorted[below] + (sorted[above] - sorted[below]) * (at - below as f64) + let (low, high, part) = (sorted[below], sorted[above], at - below as f64); + if low == high { + return low; + } + let lerp = low + (high - low) * part; + if lerp.is_finite() { + lerp + } else { + low * (1.0 - part) + high * part + } } pub(crate) fn five(values: &[f64]) -> Five { @@ -149,6 +158,13 @@ pub(crate) fn steps(changes: &[(f64, bool)]) -> Vec<(f64, f64, Step)> { mod tests { use super::*; + #[test] + fn quartiles_across_all_of_f64_stay_finite() { + let spread = five(&[-f64::MAX, f64::MAX]); + assert!(spread.q1.is_finite() && spread.q3.is_finite()); + assert_eq!((spread.low, spread.high), (-f64::MAX, f64::MAX)); + } + #[test] fn values_fall_into_equal_bins() { let bins = bins(&[1.0, 2.0, 2.5, 9.0, 10.0], 5); diff --git a/src/charts/sunburst.rs b/src/charts/sunburst.rs index e9a9003..e62b6b9 100644 --- a/src/charts/sunburst.rs +++ b/src/charts/sunburst.rs @@ -10,6 +10,7 @@ use super::{ paint::{finish, measure, tint}, pie::{bearing, wedge}, scale::compact, + series::drawable, tiles::Tiles, }; use crate::{ @@ -28,8 +29,8 @@ pub struct Slice { impl Slice { pub fn new(name: impl Into, value: f64) -> Self { assert!( - value.is_finite() && value >= 0.0, - "a slice needs a value of zero or more" + drawable(value) && value >= 0.0, + "a slice needs a value of zero or more, within charts::LIMIT" ); Self { name: name.into(), diff --git a/src/charts/targets.rs b/src/charts/targets.rs index 7b94062..ed934aa 100644 --- a/src/charts/targets.rs +++ b/src/charts/targets.rs @@ -18,6 +18,7 @@ use super::{ pie::{bearing, toward, wedge}, plot::Format, scale::{compact, nice}, + series::drawable, tiles::{Tiles, tracked}, }; use crate::{ @@ -38,8 +39,8 @@ pub struct Bullet { impl Bullet { pub fn new(label: impl Into, value: f64, target: f64) -> Self { assert!( - value.is_finite() && target.is_finite() && value >= 0.0 && target >= 0.0, - "a bullet reads values of zero or more" + drawable(value) && drawable(target) && value >= 0.0 && target >= 0.0, + "a bullet reads values of zero or more, within charts::LIMIT" ); Self { label: label.into(), @@ -54,8 +55,8 @@ impl Bullet { self.bands = ends.into_iter().collect(); assert!( self.bands.windows(2).all(|pair| pair[0] < pair[1]) - && self.bands.iter().all(|end| *end > 0.0), - "bands end in rising order" + && self.bands.iter().all(|end| *end > 0.0 && drawable(*end)), + "bands end in rising order, within charts::LIMIT" ); self } @@ -283,8 +284,8 @@ impl ProgressChart { /// A goal: how far it has come of its target. pub fn goal(mut self, name: impl Into, value: f64, target: f64) -> Self { assert!( - value.is_finite() && value >= 0.0 && target.is_finite() && target > 0.0, - "a goal has a positive target and a value of zero or more" + drawable(value) && value >= 0.0 && drawable(target) && target > 0.0, + "a goal has a positive target and a value of zero or more, within charts::LIMIT" ); self.goals.push((name.into(), value, target)); self @@ -313,9 +314,10 @@ impl RenderOnce for ProgressChart { .iter() .map(|(_, value, target)| value / target) .collect(); + let drawn: Vec = shares.iter().map(|share| share.min(1.0)).collect(); let slow = motion::duration(motion::SLOW, cx); let (glided, moving) = glide.update(cx, |glide, _| { - glide.follow(std::slice::from_ref(&shares), slow) + glide.follow(std::slice::from_ref(&drawn), slow) }); if moving { window.request_animation_frame(); diff --git a/src/charts/tests/limits.rs b/src/charts/tests/limits.rs new file mode 100644 index 0000000..245c2ae --- /dev/null +++ b/src/charts/tests/limits.rs @@ -0,0 +1,270 @@ +use std::{cell::Cell, rc::Rc}; + +use gpui::{ + AnyElement, Context, IntoElement, Modifiers, ParentElement, Render, Styled, TestAppContext, + Window, div, point, px, +}; +use jiff::civil::date; + +use super::settle; +use crate::{ + charts::{ + AreaChart, BarChart, BoxPlot, Bullet, BulletChart, CalendarHeatmap, ChordDiagram, + FunnelChart, HeatmapChart, Histogram, LIMIT, LineChart, ParallelCoordinates, PieChart, + ProgressChart, RadarChart, SankeyChart, Series, Slice, Sunburst, Treemap, ViolinPlot, + WaterfallChart, + }, + theme::{ActiveTheme, Theme}, +}; + +/// Every chart fed values at the edge of what it draws. +struct Edge(u8); + +impl Render for Edge { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let top = Series::new("s", [LIMIT, LIMIT]); + let chart: AnyElement = match self.0 { + 0 => SankeyChart::new("sankey", ["a", "b"]) + .link(0, 1, LIMIT) + .into_any_element(), + 1 => ChordDiagram::new("chord", ["a", "b"], vec![vec![0.0, LIMIT], vec![1.0, 0.0]]) + .into_any_element(), + 2 => Histogram::new("histogram", [-LIMIT, LIMIT]).into_any_element(), + 3 => ViolinPlot::new("violin") + .group("s", [LIMIT, LIMIT]) + .into_any_element(), + 4 => AreaChart::new("stack", ["a", "b"]) + .series(top.clone()) + .series(Series::new("t", [LIMIT, LIMIT])) + .stacked() + .into_any_element(), + 5 => WaterfallChart::new("waterfall") + .step("a", LIMIT) + .step("b", LIMIT) + .total("all") + .into_any_element(), + 6 => Sunburst::new( + "sunburst", + [Slice::new("root", 0.0) + .children([Slice::new("a", LIMIT), Slice::new("b", LIMIT)])], + ) + .into_any_element(), + 7 => Treemap::new("treemap") + .tile("a", LIMIT) + .tile("b", LIMIT) + .into_any_element(), + 8 => RadarChart::new("radar", ["a", "b", "c"]) + .series(Series::new("s", [LIMIT; 3])) + .into_any_element(), + 9 => LineChart::new("rule", ["a", "b"]) + .series(Series::new("s", [1.0, 2.0])) + .rule(LIMIT, "target") + .into_any_element(), + 10 => BoxPlot::new("box") + .group("s", [-LIMIT, LIMIT]) + .into_any_element(), + 11 => CalendarHeatmap::new("calendar", date(9999, 12, 31), [1.0]).into_any_element(), + 12 => ChordDiagram::new("tiny", ["a", "b"], vec![vec![0.0, 1e-40], vec![1e-40, 0.0]]) + .into_any_element(), + 13 => LineChart::new("far", ["a", "b"]) + .series(Series::new("s", [1e-20, 2e-20])) + .rule(LIMIT, "target") + .into_any_element(), + 14 => ChordDiagram::new( + "least", + ["a", "b"], + vec![vec![0.0, 1e-310], vec![1e-310, 0.0]], + ) + .into_any_element(), + 15 => Treemap::new("least-tiles") + .tile("a", 1e-310) + .tile("b", 1e-310) + .into_any_element(), + 16 => SankeyChart::new("least-flow", ["a", "b", "c"]) + .link(0, 1, 1e-310) + .link(1, 2, 1e-310) + .into_any_element(), + _ => LineChart::new("top", ["a", "b"]) + .series(top) + .into_any_element(), + }; + div().w(px(400.0)).h(px(240.0)).child(chart) + } +} + +#[gpui::test] +fn every_chart_draws_values_at_its_limit(cx: &mut TestAppContext) { + cx.update(Theme::init); + for case in 0..=17 { + let (_, cx) = cx.add_window_view(move |_, _| Edge(case)); + settle(cx); + } +} + +/// A line that turns from the top limit to the bottom one, and a goal from far past its target back to twice it. +struct Turning(Rc>); + +impl Render for Turning { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let turned = self.0.get(); + let value = if turned { -LIMIT } else { LIMIT }; + let (goal, target) = if turned { (2.0, 1.0) } else { (LIMIT, 1e-30) }; + div() + .w(px(400.0)) + .child(LineChart::new("line", ["a", "b"]).series(Series::new("s", [value, value]))) + .child(ProgressChart::new("progress").goal("g", goal, target)) + } +} + +#[gpui::test] +fn a_chart_glides_between_its_limits(cx: &mut TestAppContext) { + cx.update(Theme::init); + let turned = Rc::new(Cell::new(false)); + let seen = turned.clone(); + let (view, cx) = cx.add_window_view(|_, _| Turning(seen)); + settle(cx); + turned.set(true); + view.update(cx, |_, cx| cx.notify()); + settle(cx); +} + +#[test] +#[should_panic(expected = "within charts::LIMIT")] +fn a_value_past_the_limit_is_refused() { + let _ = Series::new("s", [LIMIT * 10.0]); +} + +#[test] +#[should_panic(expected = "within charts::LIMIT")] +fn a_band_past_the_limit_is_refused() { + let _ = Bullet::new("b", 1.0, 2.0).bands([LIMIT * 10.0]); +} + +/// A smooth line in a box barely wider than its gutter. +struct Sliver; + +impl Render for Sliver { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let chart = cx.theme().chart(); + let rem = window.rem_size(); + let width = chart.gutter.to_pixels(rem) + chart.inset.to_pixels(rem) + px(0.000015); + div().w(width).h(px(240.0)).child( + LineChart::new("sliver", ["a", "b", "c", "d", "e"]) + .series(Series::new("s", [1.0, 2.0, 3.0, 4.0, 5.0])) + .smooth(), + ) + } +} + +#[gpui::test] +fn a_smooth_line_in_a_sliver_draws(cx: &mut TestAppContext) { + cx.update(Theme::init); + let (_, cx) = cx.add_window_view(|_, _| Sliver); + settle(cx); +} + +/// A centered sankey that shrinks under a resting pointer. +struct Shrinking(Rc>); + +impl Render for Shrinking { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let width = if self.0.get() { 20.0 } else { 400.0 }; + div() + .w(px(400.0)) + .h(px(240.0)) + .flex() + .flex_col() + .items_center() + .child( + SankeyChart::new("shrinking", ["a", "b"]) + .link(0, 1, 5.0) + .w(px(width)) + .h(px(240.0)), + ) + } +} + +#[gpui::test] +fn a_hovered_sankey_that_shrinks_drops_its_hover(cx: &mut TestAppContext) { + cx.update(Theme::init); + let shrunk = Rc::new(Cell::new(false)); + let seen = shrunk.clone(); + let (view, cx) = cx.add_window_view(|_, _| Shrinking(seen)); + settle(cx); + cx.simulate_mouse_move(point(px(200.0), px(120.0)), None, Modifiers::none()); + settle(cx); + shrunk.set(true); + view.update(cx, |_, cx| cx.notify()); + settle(cx); +} + +const LEAST: f64 = 1e-310; + +/// Every other chart fed the smallest values it takes. +struct Least(u8); + +impl Render for Least { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let tiny = || Series::new("s", [LEAST, LEAST * 2.0, LEAST]); + let chart: AnyElement = match self.0 { + 0 => PieChart::new("pie") + .slice("a", LEAST) + .slice("b", LEAST) + .into_any_element(), + 1 => FunnelChart::new("funnel") + .stage("a", LEAST) + .stage("b", LEAST) + .into_any_element(), + 2 => ParallelCoordinates::new("parallel", ["a", "b"]) + .record("r", [LEAST, LEAST * 2.0]) + .record("q", [LEAST * 2.0, LEAST]) + .into_any_element(), + 3 => BulletChart::new("bullets") + .bullet(Bullet::new("b", LEAST, LEAST * 2.0).bands([LEAST, LEAST * 3.0])) + .into_any_element(), + 4 => ProgressChart::new("goals") + .goal("g", LEAST, LEAST) + .into_any_element(), + 5 => HeatmapChart::new("heat", ["a", "b"]) + .row("r", [LEAST, LEAST * 2.0]) + .into_any_element(), + 6 => Histogram::new("histogram", [LEAST, LEAST * 2.0, LEAST * 3.0]).into_any_element(), + 7 => BoxPlot::new("box") + .group("s", [LEAST, LEAST * 2.0, LEAST * 4.0]) + .into_any_element(), + 8 => ViolinPlot::new("violin") + .group("s", [LEAST, LEAST * 2.0]) + .into_any_element(), + 9 => WaterfallChart::new("waterfall") + .step("a", LEAST) + .total("all") + .into_any_element(), + 10 => RadarChart::new("radar", ["a", "b", "c"]) + .series(tiny()) + .into_any_element(), + 11 => Sunburst::new( + "sunburst", + [Slice::new("root", LEAST).children([Slice::new("a", LEAST)])], + ) + .into_any_element(), + 12 => AreaChart::new("area", ["a", "b", "c"]) + .series(tiny()) + .stacked() + .into_any_element(), + _ => BarChart::new("bars", ["a", "b", "c"]) + .series(tiny()) + .horizontal() + .into_any_element(), + }; + div().w(px(400.0)).h(px(240.0)).child(chart) + } +} + +#[gpui::test] +fn every_chart_draws_its_smallest_values(cx: &mut TestAppContext) { + cx.update(Theme::init); + for case in 0..=13 { + let (_, cx) = cx.add_window_view(move |_, _| Least(case)); + settle(cx); + } +} diff --git a/src/charts/tests.rs b/src/charts/tests/mod.rs similarity index 92% rename from src/charts/tests.rs rename to src/charts/tests/mod.rs index 88494ef..f160d4b 100644 --- a/src/charts/tests.rs +++ b/src/charts/tests/mod.rs @@ -1,3 +1,5 @@ +mod limits; + use std::{ cell::{Cell, RefCell}, rc::Rc, @@ -138,7 +140,7 @@ impl Render for Picked { } } -fn settle(cx: &mut VisualTestContext) { +pub(super) fn settle(cx: &mut VisualTestContext) { for _ in 0..3 { cx.run_until_parked(); cx.update(|window, _| window.refresh()); @@ -373,3 +375,36 @@ fn a_long_band_name_widens_the_gutter_to_half_the_chart(cx: &mut TestAppContext) } } } + +/// A note, a link, an edge and a wait that name what their chart lacks. +struct Dangling; + +impl Render for Dangling { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let line = LineChart::new("line", ["a", "b"]) + .series(Series::new("s", [1.0, 2.0])) + .note(7, "late"); + let sankey = SankeyChart::new("sankey", ["a", "b"]) + .link(0, 9, 5.0) + .link(0, 1, 5.0); + let network = NetworkGraph::new("network") + .node("a", 0) + .node("b", 0) + .edge(0, 9); + let gantt = GanttChart::new("gantt") + .task(Task::new("t", date(2026, 3, 1), date(2026, 3, 9)).after(4)); + div() + .w(px(400.0)) + .child(line) + .child(sankey) + .child(network) + .child(gantt) + } +} + +#[gpui::test] +fn entries_past_their_lists_are_skipped(cx: &mut TestAppContext) { + cx.update(Theme::init); + let (_, cx) = cx.add_window_view(|_, _| Dangling); + settle(cx); +} diff --git a/src/charts/tiles.rs b/src/charts/tiles.rs index 8a92369..812b327 100644 --- a/src/charts/tiles.rs +++ b/src/charts/tiles.rs @@ -14,6 +14,7 @@ use super::{ parts::ChartTooltip, plot::Format, scale::compact, + series::drawable, }; use crate::{ theme::{ActiveTheme, Radius, TextSize}, @@ -105,8 +106,8 @@ impl Treemap { pub fn tile(mut self, name: impl Into, value: f64) -> Self { assert!( - value.is_finite() && value >= 0.0, - "a tile needs a value of zero or more" + drawable(value) && value >= 0.0, + "a tile needs a value of zero or more, within charts::LIMIT" ); self.tiles.push((name.into(), value)); self @@ -282,8 +283,8 @@ impl HeatmapChart { "a heatmap row needs a value per column" ); assert!( - values.iter().all(|value| value.is_finite()), - "a heatmap needs finite values" + values.iter().all(|value| drawable(*value)), + "a heatmap needs values, within charts::LIMIT" ); self.rows.push((label.into(), values)); self diff --git a/tasks/panics.md b/tasks/panics.md index 3f33ed8..ca7c764 100644 --- a/tasks/panics.md +++ b/tasks/panics.md @@ -29,7 +29,7 @@ Each item reads every site in its folders, fixes what the rules name, updates ca - [x] P12 Data Display — `src/data_display` - [x] P13 Lists & Trees — `src/lists` - [x] P14 Tables — `src/tables` -- [ ] P15 Charts — `src/charts` +- [x] P15 Charts — `src/charts` - [ ] P16 Finance — `src/finance` - [ ] P17 Code Editor — `src/editor` - [ ] P18 Terminal — `src/terminal` @@ -79,3 +79,4 @@ One line per item: what changed, what stayed and why, review rounds. - P12: a featured column past the columns features none; a gauge past its scale pegs; usage parts past the total fill it and repeat hues past eight; stars above max log and fill. Kept: one-value contracts, a comparison row's shape, construction. Review: 3 rounds (round 2 met an edit that had not applied). - P13: a directory listing's selected name gone from its entries picks none. Kept: one-value contracts; activation, folder split, single-select sections and tree rows hold by construction. Review: 1 round. - P14: a pivot field missing from its data draws an error line; a gone group-by column leaves the table ungrouped; a merge past the sheet is skipped; each logs. Kept: row shapes of one snapshot, one-value contracts, builder misuse, construction. Review: 1 round. +- P15: chart values are finite and within ±`charts::LIMIT` (1e30), so sums and pixels stay in `f32`; scales work in halves and bound a share to ±1e4 ranges; chord and sankey size by shares; quartiles, weekday labels, flat tangents and a zero-width sankey's hover hold; notes, links, edges and waits past their lists are skipped. Review: 5 rounds, the cap; round 5's finding (points at one x) is fixed and tested, and P16's review checks it.