Panics · P15 Charts: values within charts::LIMIT; scales, shares and tangents stay finite at the edges; entries past their lists are skipped

This commit is contained in:
ZacharyZhang-NY committed 2026-10-06 19:05:05 -04:00
1 parent 2ea16f4541
commit defbec006e
23 files changed
+483 -95

No files matched your search

+1 -1
View File
@@ -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.
+7 -3
View File
@@ -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<ElementId>, 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
}
+13 -9
View File
@@ -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<f64> = 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),
+11 -6
View File
@@ -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<SharedString>) -> 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<SharedString>) -> 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
}
+9 -5
View File
@@ -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<ElementId>, values: impl IntoIterator<Item = f64>) -> Self {
let values: Vec<f64> = 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<SharedString>, 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<f64> = 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
+23 -15
View File
@@ -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::<f64>();
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::<f64>(),
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::<f32>()
let tall: f32 = members(c).map(|ix| height(through[ix])).sum::<f32>()
+ 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<usize> = (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(),
+3 -2
View File
@@ -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<SharedString>, 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
+1
View File
@@ -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};
+7 -4
View File
@@ -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
}
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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<SharedString>, 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
+3 -2
View File
@@ -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<f64> = 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(),
+7 -7
View File
@@ -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<f64>], pad: f32) -> (Arcs, Vec<Arcs>) {
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<f64>], pad: f32) -> (Arcs, Vec<Arcs>) {
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(),
+12 -7
View File
@@ -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<f64>)
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)
+9 -6
View File
@@ -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
}
+29 -8
View File
@@ -7,12 +7,20 @@ pub struct Series {
pub(crate) values: Vec<f64>,
}
/// 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<SharedString>, values: impl IntoIterator<Item = f64>) -> Self {
let values: Vec<f64> = 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<Item = f64>) -> (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<f32> {
let count = points.len();
if count < 2 {
@@ -104,7 +112,10 @@ pub(crate) fn tangents(points: &[(f32, f32)]) -> Vec<f32> {
}
let slopes: Vec<f32> = 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<f32> = (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]]);
+17 -1
View File
@@ -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);
+3 -2
View File
@@ -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<SharedString>, 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(),
+9 -7
View File
@@ -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<SharedString>, 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<SharedString>, 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<f64> = 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();
+270
View File
@@ -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<Self>) -> 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<Cell<bool>>);
impl Render for Turning {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> 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<Self>) -> 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<Cell<bool>>);
impl Render for Shrinking {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> 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<Self>) -> 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);
}
}
@@ -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<Self>) -> 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);
}
+5 -4
View File
@@ -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<SharedString>, 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
+2 -1
View File
@@ -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.