gridcraft/crates/chart/src/render.rs

1755 lines
72 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Chart layout: title, legend, axes and every chart kind, drawn into [`Prim`]s.
use std::collections::HashSet;
use std::f32::consts::{PI, TAU};
use gridcraft_model::{Chart, ChartKind, LegendPos, LineDash, Theme, TrendlineKind};
use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale};
use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color};
use crate::trend::{auto_name, fit_trendline, moving_average};
use crate::{ChartData, HAlign, Measure, Prim, Rgba, SeriesData, VAlign};
pub(crate) const TEXT: Rgba = [0x59, 0x59, 0x59, 0xFF];
const DARK_TEXT: Rgba = [0x26, 0x26, 0x26, 0xFF];
const GRID: Rgba = [0xD9, 0xD9, 0xD9, 0xFF];
const AXIS: Rgba = [0xBF, 0xBF, 0xBF, 0xFF];
const WHITE: Rgba = [0xFF, 0xFF, 0xFF, 0xFF];
const STOCK: Rgba = [0x40, 0x40, 0x40, 0xFF];
pub(crate) const UP: Rgba = [0x3E, 0x9B, 0x5F, 0xFF];
pub(crate) const DOWN: Rgba = [0xD6, 0x4F, 0x45, 0xFF];
const TITLE_SIZE: f32 = 14.0;
const LABEL_SIZE: f32 = 9.0;
const AXIS_TITLE_SIZE: f32 = 10.0;
/// Most categories drawn by category-axis charts.
const MAX_CATS: usize = 20_000;
/// Lines with more points than this are decimated (min/max per pixel column).
const MAX_LINE_PTS: usize = 2000;
/// Most markers per series (after de-duplicating by pixel).
const MAX_MARKERS: usize = 5000;
/// Data labels are skipped when a series has more points than this.
const MAX_LABELS: usize = 200;
#[derive(Clone, Copy, Debug)]
struct Bx {
x: f32,
y: f32,
w: f32,
h: f32,
}
impl Bx {
fn right(&self) -> f32 {
self.x + self.w
}
fn bottom(&self) -> f32 {
self.y + self.h
}
fn ok(&self) -> bool {
self.w.is_finite() && self.h.is_finite() && self.w > 4.0 && self.h > 4.0
}
fn cx(&self) -> f32 {
self.x + self.w / 2.0
}
fn cy(&self) -> f32 {
self.y + self.h / 2.0
}
}
struct Ctx<'a> {
out: Vec<Prim>,
m: &'a dyn Measure,
}
impl Ctx<'_> {
fn tw(&self, s: &str, size: f32) -> f32 {
let w = self.m.text_width(s, size, false);
if w.is_finite() { w.max(0.0) } else { 0.0 }
}
fn fit(&self, s: &str, size: f32, maxw: f32) -> String {
if self.tw(s, size) <= maxw {
return s.to_string();
}
let chars: Vec<char> = s.chars().collect();
let mut n = chars.len().min(400);
while n > 0 {
n -= 1;
let t: String = chars.iter().take(n).collect::<String>() + "…";
if self.tw(&t, size) <= maxw {
return if n == 0 { String::new() } else { t };
}
}
String::new()
}
#[allow(clippy::too_many_arguments)]
fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, align: HAlign, valign: VAlign) {
if s.is_empty() {
return;
}
self.out.push(Prim::Text { x, y, text: s.to_string(), size, color, bold: false, align, valign, rotation: 0.0 });
}
fn rect(&mut self, x: f32, y: f32, w: f32, h: f32, fill: Rgba, stroke: Option<(Rgba, f32)>) {
self.out.push(Prim::Rect { x, y, w, h, fill: Some(fill), stroke, radius: 0.0 });
}
fn line(&mut self, pts: Vec<[f32; 2]>, color: Rgba, width: f32) {
if pts.len() >= 2 {
self.out.push(Prim::Line { pts, color, width, dash: false });
}
}
fn hline(&mut self, x0: f32, x1: f32, y: f32, color: Rgba, width: f32) {
self.line(vec![[x0, y], [x1, y]], color, width);
}
fn vline(&mut self, x: f32, y0: f32, y1: f32, color: Rgba, width: f32) {
self.line(vec![[x, y0], [x, y1]], color, width);
}
}
/// Text colour readable on `fill`.
fn on(fill: Rgba) -> Rgba {
let l = 0.299 * fill[0] as f32 + 0.587 * fill[1] as f32 + 0.114 * fill[2] as f32;
if l < 150.0 { WHITE } else { DARK_TEXT }
}
/// How a legend entry's swatch is drawn.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Swatch {
Box,
Line,
LineMarker,
Marker,
}
/// The kind a series is drawn as (per-series kinds only apply to combo charts).
fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind {
if chart.kind == ChartKind::Combo { if s.kind == ChartKind::Combo { ChartKind::Line } else { s.kind } } else { chart.kind }
}
/// Colour of point `j` for charts that colour by point (pie, doughnut, treemap, sunburst).
fn point_color(data: &ChartData, j: usize) -> Rgba {
match (j, data.series.first()) {
(0, Some(s)) => s.color,
_ => series_color(&Theme::default(), j),
}
}
fn first_len(data: &ChartData) -> usize {
data.series.first().map(|s| s.values.len()).unwrap_or(0)
}
fn cat_label(data: &ChartData, j: usize) -> String {
data.categories.get(j).cloned().unwrap_or_else(|| (j + 1).to_string())
}
/// Legend entries (text, colour, swatch) `render` draws for this chart; empty when hidden.
pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swatch)> {
if chart.legend == LegendPos::None {
return vec![];
}
match chart.kind {
ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst => {
let n = first_len(data).min(MAX_CATS);
(0..n).map(|j| (cat_label(data, j), point_color(data, j), Swatch::Box)).collect()
}
ChartKind::Histogram | ChartKind::Funnel | ChartKind::Stock => vec![],
ChartKind::Waterfall => {
if data.series.is_empty() {
return vec![];
}
let total = data.series.first().map(|s| s.color).unwrap_or(TEXT);
vec![("Increase".into(), UP, Swatch::Box), ("Decrease".into(), DOWN, Swatch::Box), ("Total".into(), total, Swatch::Box)]
}
_ => {
let series = data.series.iter().take(MAX_SERIES);
let mut out: Vec<(String, Rgba, Swatch)> = series
.clone()
.map(|s| {
let sw = match plot_kind(chart, s) {
ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line,
ChartKind::LineMarkers => Swatch::LineMarker,
ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker,
_ => Swatch::Box,
};
(s.name.clone(), s.color, sw)
})
.collect();
// Trendlines follow the series, named "Linear (Sales)" unless given a name.
for s in series.filter(|s| supports_trendline(plot_kind(chart, s))) {
for t in s.trendlines.iter().take(MAX_TRENDLINES) {
out.push((t.spec.name.clone().unwrap_or_else(|| auto_name(t.spec.kind, &s.name)), t.color, Swatch::Line));
}
}
out
}
}
}
fn sanitize_vals(v: &[Option<f64>]) -> Vec<Option<f64>> {
v.iter().take(MAX_POINTS).map(|x| x.filter(|n| n.is_finite()).map(clean)).collect()
}
fn sanitize(d: &ChartData) -> ChartData {
ChartData {
categories: d.categories.iter().take(MAX_POINTS).cloned().collect(),
series: d
.series
.iter()
.take(MAX_SERIES)
.map(|s| SeriesData {
name: s.name.clone(),
values: sanitize_vals(&s.values),
x: s.x.as_deref().map(sanitize_vals),
sizes: s.sizes.as_deref().map(sanitize_vals),
color: s.color,
kind: s.kind,
secondary: s.secondary,
smooth: s.smooth,
trendlines: s.trendlines.iter().take(MAX_TRENDLINES).cloned().collect(),
number_format: s.number_format.clone(),
})
.collect(),
}
}
fn chart_title(chart: &Chart, data: &ChartData) -> Option<String> {
match &chart.title {
Some(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()),
None if data.series.len() == 1 => data.series.first().map(|s| s.name.clone()).filter(|t| !t.is_empty()),
None => None,
}
}
/// Lays out and draws a chart into a `w`×`h` box at the origin. Empty for a degenerate box.
pub fn render(chart: &Chart, data: &ChartData, w: f32, h: f32, m: &dyn Measure) -> Vec<Prim> {
if !(w.is_finite() && h.is_finite()) || w < 1.0 || h < 1.0 || w > 1e7 || h > 1e7 {
return vec![];
}
let data = sanitize(data);
let mut ctx = Ctx { out: Vec::new(), m };
ctx.out.push(Prim::Rect { x: 0.0, y: 0.0, w, h, fill: Some(WHITE), stroke: Some((GRID, 1.0)), radius: 0.0 });
let pad = (w.min(h) * 0.04).clamp(2.0, 10.0);
let mut area = Bx { x: pad, y: pad, w: w - 2.0 * pad, h: h - 2.0 * pad };
if let Some(t) = chart_title(chart, &data) {
let size = TITLE_SIZE.min(h * 0.12).max(4.0);
let t = ctx.fit(&t, size, area.w);
ctx.text(w / 2.0, area.y, &t, size, TEXT, HAlign::Center, VAlign::Top);
let used = size * 1.3 + 4.0;
area.y += used;
area.h -= used;
}
let entries = legend_entries(chart, &data);
if !entries.is_empty() && area.ok() {
area = draw_legend(&mut ctx, &entries, chart.legend, area);
}
if area.ok() && !data.series.is_empty() {
draw_kind(&mut ctx, chart, &data, area);
}
finalize(ctx.out, w, h)
}
fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
use ChartKind::*;
match chart.kind {
Pie => pie(ctx, chart, data, area, 0.0, false),
Doughnut => pie(ctx, chart, data, area, 0.5, false),
Sunburst => pie(ctx, chart, data, area, 0.2, true),
Treemap => treemap(ctx, chart, data, area),
Funnel => funnel(ctx, data, area),
Radar => radar(ctx, chart, data, area),
_ => {
let area = axis_titles(ctx, chart, area);
if !area.ok() {
return;
}
match chart.kind {
BarClustered | BarStacked | BarStacked100 => cartesian(ctx, chart, data, area, true),
Scatter | ScatterLines | Bubble => scatter(ctx, chart, data, area),
Histogram => histogram(ctx, chart, data, area),
Waterfall => waterfall(ctx, chart, data, area),
BoxWhisker => box_whisker(ctx, data, area),
Stock => stock(ctx, data, area),
_ => cartesian(ctx, chart, data, area, false),
}
}
}
}
fn axis_titles(ctx: &mut Ctx, chart: &Chart, mut area: Bx) -> Bx {
let lh = AXIS_TITLE_SIZE * 1.4 + 2.0;
if let Some(t) = chart.x_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, AXIS_TITLE_SIZE, area.w);
ctx.text(area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
area.h -= lh;
}
if let Some(t) = chart.y_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, AXIS_TITLE_SIZE, area.h);
if !t.is_empty() {
ctx.out.push(Prim::Text {
x: area.x,
y: area.cy(),
text: t,
size: AXIS_TITLE_SIZE,
color: TEXT,
bold: false,
align: HAlign::Center,
valign: VAlign::Top,
rotation: -PI / 2.0,
});
}
area.x += lh;
area.w -= lh;
}
area
}
// ---------------------------------------------------------------- legend
fn swatch_w(sw: Swatch, s: f32) -> f32 {
if sw == Swatch::Box { s } else { s * 2.0 }
}
fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) {
match sw {
Swatch::Box => ctx.rect(x, yc - s / 2.0, s, s, color, None),
Swatch::Line => ctx.hline(x, x + 2.0 * s, yc, color, 2.0),
Swatch::LineMarker => {
ctx.hline(x, x + 2.0 * s, yc, color, 2.0);
ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.35, fill: Some(color), stroke: None });
}
Swatch::Marker => ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.4, fill: Some(color), stroke: None }),
}
}
fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos, area: Bx) -> Bx {
let size = LABEL_SIZE;
let row_h = size * 1.6;
let s = size * 0.8;
let gap = 4.0;
let spacing = size * 1.3;
let item_w = |ctx: &Ctx, e: &(String, Rgba, Swatch)| swatch_w(e.2, s) + gap + ctx.tw(&e.0, size);
match pos {
LegendPos::None => area,
LegendPos::Top | LegendPos::Bottom => {
let max_rows = ((area.h * 0.3 / row_h).floor() as usize).max(1);
let mut rows: Vec<Vec<(usize, f32)>> = vec![vec![]];
let mut cur = 0.0f32;
for (i, e) in entries.iter().enumerate() {
let iw = item_w(ctx, e).min(area.w);
let need = if cur > 0.0 { spacing + iw } else { iw };
if cur > 0.0 && cur + need > area.w {
if rows.len() >= max_rows {
break;
}
rows.push(vec![]);
cur = 0.0;
}
cur += if cur > 0.0 { spacing + iw } else { iw };
if let Some(r) = rows.last_mut() {
r.push((i, iw));
}
}
let lh = rows.len() as f32 * row_h;
let y0 = if pos == LegendPos::Bottom { area.bottom() - lh } else { area.y };
for (ri, row) in rows.iter().enumerate() {
let total: f32 = row.iter().map(|(_, w)| w).sum::<f32>() + spacing * row.len().saturating_sub(1) as f32;
let mut x = area.x + (area.w - total).max(0.0) / 2.0;
let yc = y0 + ri as f32 * row_h + row_h / 2.0;
for &(i, iw) in row {
if let Some(e) = entries.get(i) {
draw_swatch(ctx, e.2, x, yc, s, e.1);
let sx = swatch_w(e.2, s) + gap;
let t = ctx.fit(&e.0, size, (iw - sx).max(0.0) + 0.5);
ctx.text(x + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle);
}
x += iw + spacing;
}
}
let used = lh + 4.0;
if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } }
}
LegendPos::Left | LegendPos::Right => {
let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35);
let max_rows = ((area.h / row_h).floor() as usize).max(1);
let n = entries.len().min(max_rows);
let lh = n as f32 * row_h;
let y0 = area.y + (area.h - lh).max(0.0) / 2.0;
let x0 = if pos == LegendPos::Right { area.right() - colw } else { area.x };
for (i, e) in entries.iter().take(n).enumerate() {
let yc = y0 + i as f32 * row_h + row_h / 2.0;
draw_swatch(ctx, e.2, x0, yc, s, e.1);
let sx = swatch_w(e.2, s) + gap;
let t = ctx.fit(&e.0, size, (colw - sx).max(0.0) + 0.5);
ctx.text(x0 + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle);
}
let used = colw + 8.0;
if pos == LegendPos::Right { Bx { w: area.w - used, ..area } } else { Bx { x: area.x + used, w: area.w - used, ..area } }
}
}
}
// ---------------------------------------------------------------- axes frame
enum CatAxis<'a> {
Labels(&'a [String]),
Numeric(Scale, &'a Fmt),
}
struct ValAxis<'a> {
scale: Scale,
fmt: &'a Fmt,
}
impl ValAxis<'_> {
fn labels(&self) -> Vec<(f64, String)> {
self.scale.ticks().into_iter().map(|v| (v, self.fmt.format(v, Some(self.scale.step)))).collect()
}
}
/// Draws gridlines, axis labels and the category axis line; returns the plot rectangle.
fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis>, horizontal: bool, grid: bool) -> Bx {
let ls = LABEL_SIZE;
let lh = ls * 1.4;
let ylabels = y.labels();
let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max);
if !horizontal {
let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4);
let y2labels = y2.map(|a| a.labels());
let right = match (&y2labels, &cat) {
(Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3),
(None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2),
_ => 4.0,
};
let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 };
if !plot.ok() {
return plot;
}
for (v, t) in &ylabels {
let yy = plot.bottom() - y.scale.frac(*v) * plot.h;
if grid {
ctx.hline(plot.x, plot.right(), yy, GRID, 0.75);
}
ctx.text(plot.x - 4.0, yy, t, ls, TEXT, HAlign::Right, VAlign::Middle);
}
if let (Some(a), Some(l)) = (y2, &y2labels) {
for (v, t) in l {
let yy = plot.bottom() - a.scale.frac(*v) * plot.h;
ctx.text(plot.right() + 4.0, yy, t, ls, TEXT, HAlign::Left, VAlign::Middle);
}
}
let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min };
let y0 = plot.bottom() - y.scale.frac(zero) * plot.h;
ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75);
match cat {
CatAxis::Labels(l) => {
let n = l.len().max(1);
let cw = plot.w / n as f32;
let lw = l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max);
let k = (((lw + 6.0) / cw.max(1e-3)).ceil() as usize).clamp(1, n);
for (j, t) in l.iter().enumerate().step_by(k) {
let t = ctx.fit(t, ls, cw * k as f32 - 2.0);
ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, HAlign::Center, VAlign::Top);
}
}
CatAxis::Numeric(s, f) => {
let ticks = s.ticks();
let labels: Vec<String> = ticks.iter().map(|v| f.format(*v, Some(s.step))).collect();
let lw = labels.iter().map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max);
let slot = plot.w / ticks.len().max(1) as f32;
let k = (((lw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1);
for (v, t) in ticks.iter().zip(&labels).step_by(k) {
let xx = plot.x + s.frac(*v) * plot.w;
ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top);
}
if s.min <= 0.0 && s.max >= 0.0 {
let xx = plot.x + s.frac(0.0) * plot.w;
ctx.vline(xx, plot.y, plot.bottom(), AXIS, 0.75);
}
}
}
plot
} else {
let labels: Vec<String> = match cat {
CatAxis::Labels(l) => l.to_vec(),
CatAxis::Numeric(..) => vec![],
};
let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35);
let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0;
let plot = Bx { x: area.x + lw, y: area.y + 2.0, w: area.w - lw - right, h: area.h - lh - 6.0 };
if !plot.ok() {
return plot;
}
let vw = maxw(ctx, &ylabels);
let slot = plot.w / ylabels.len().max(1) as f32;
let k = (((vw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1);
for (i, (v, t)) in ylabels.iter().enumerate() {
let xx = plot.x + y.scale.frac(*v) * plot.w;
if grid {
ctx.vline(xx, plot.y, plot.bottom(), GRID, 0.75);
}
if i % k == 0 {
ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top);
}
}
let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min };
let x0 = plot.x + y.scale.frac(zero) * plot.w;
ctx.vline(x0, plot.y, plot.bottom(), AXIS, 0.75);
let n = labels.len().max(1);
let ch = plot.h / n as f32;
let k = ((lh / ch.max(1e-3)).ceil() as usize).clamp(1, n);
for (j, t) in labels.iter().enumerate().step_by(k) {
let t = ctx.fit(t, ls, lw - 6.0);
ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, HAlign::Right, VAlign::Middle);
}
plot
}
}
// ---------------------------------------------------------------- helpers
/// Reduces a long polyline to first/min/max/last per pixel column.
fn decimate(pts: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> {
if pts.len() <= max {
return pts;
}
let mut out = Vec::with_capacity(max * 2);
let mut i = 0;
while let Some(p) = pts.get(i) {
let col = p[0].floor();
let (start, mut mn, mut mx) = (i, i, i);
let mut k = i + 1;
while let Some(q) = pts.get(k) {
if q[0].floor() != col {
break;
}
if pts.get(mn).is_some_and(|m| q[1] < m[1]) {
mn = k;
}
if pts.get(mx).is_some_and(|m| q[1] > m[1]) {
mx = k;
}
k += 1;
}
let mut idx = [start, mn, mx, k - 1];
idx.sort_unstable();
let mut last = usize::MAX;
for ix in idx {
if ix != last
&& let Some(q) = pts.get(ix)
{
out.push(*q);
last = ix;
}
}
i = k;
}
if out.len() > max * 4 {
let step = out.len() / (max * 2) + 1;
let last = out.last().copied();
let mut s: Vec<[f32; 2]> = out.into_iter().step_by(step).collect();
if let Some(l) = last
&& s.last() != Some(&l)
{
s.push(l);
}
return s;
}
out
}
/// Draws a polyline split at gaps (`None`), decimated when long. With `smooth` (the plot box),
/// each run is interpolated with a Catmull-Rom spline so the line curves through the data points.
fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: Option<Bx>) {
let mut run: Vec<[f32; 2]> = Vec::new();
for p in pts.iter().chain(std::iter::once(&None)) {
match p {
Some(p) => run.push(*p),
None => {
if run.len() >= 2 {
let r = decimate(std::mem::take(&mut run), MAX_LINE_PTS);
let r = match smooth {
Some(plot) => smooth_run(r, plot),
None => r,
};
ctx.line(r, color, width);
} else {
run.clear();
}
}
}
}
}
/// Catmull-Rom spline through `pts`: every segment gets the same number of sub-segments (up to
/// 100), chosen so the whole curve stays within `MAX_LINE_PTS * 2` points. Endpoints are
/// duplicated so the curve passes through the first and last points, and the curve is clamped
/// to the `plot` box (a spline overshoots its data near sharp turns).
fn smooth_run(pts: Vec<[f32; 2]>, plot: Bx) -> Vec<[f32; 2]> {
let n = pts.len();
if n < 3 {
return pts;
}
let cap = MAX_LINE_PTS * 2;
let steps = (cap / n).clamp(1, 100);
let mut out: Vec<[f32; 2]> = Vec::with_capacity(n.saturating_mul(steps).min(cap) + 1);
let at = |i: isize| -> [f32; 2] {
let j = i.clamp(0, n as isize - 1) as usize;
pts.get(j).copied().unwrap_or([0.0, 0.0])
};
let (x0, x1) = (plot.x.min(plot.right()), plot.x.max(plot.right()));
let (y0, y1) = (plot.y.min(plot.bottom()), plot.y.max(plot.bottom()));
let clip = |p: [f32; 2]| [p[0].clamp(x0, x1), p[1].clamp(y0, y1)];
for i in 0..n.saturating_sub(1) {
let (p0, p1, p2, p3) = (at(i as isize - 1), at(i as isize), at(i as isize + 1), at(i as isize + 2));
for s in 0..steps {
let t = s as f32 / steps as f32;
out.push(clip(catmull(p0, p1, p2, p3, t)));
}
}
out.push(clip(at(n as isize - 1)));
out
}
/// One Catmull-Rom point between `p1` and `p2` (uniform parameterisation), tangent-scaled by 0.5.
fn catmull(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], t: f32) -> [f32; 2] {
let t2 = t * t;
let t3 = t2 * t;
let mut o = [0.0f32; 2];
for k in 0..2 {
let (a0, a1, a2, a3) = (p0[k], p1[k], p2[k], p3[k]);
o[k] = 0.5 * ((2.0 * a1) + (-a0 + a2) * t + (2.0 * a0 - 5.0 * a1 + 4.0 * a2 - a3) * t2 + (-a0 + 3.0 * a1 - 3.0 * a2 + a3) * t3);
}
o
}
fn draw_markers(ctx: &mut Ctx, pts: impl Iterator<Item = [f32; 2]>, r: f32, color: Rgba) {
let mut seen = HashSet::new();
for p in pts {
if seen.len() >= MAX_MARKERS {
break;
}
if seen.insert(((p[0] * 0.5) as i32, (p[1] * 0.5) as i32)) {
ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(color), stroke: Some((WHITE, 0.75)) });
}
}
}
fn value_range(it: impl Iterator<Item = f64>) -> Option<(f64, f64)> {
it.fold(None, |acc, v| match acc {
None => Some((v, v)),
Some((a, b)) => Some((a.min(v), b.max(v))),
})
}
// ---------------------------------------------------------------- trendlines
/// Samples along a regression trendline.
const TREND_STEPS: usize = 200;
/// Whether series drawn as `k` show trendlines (Excel offers them on 2-D unstacked column,
/// bar, line, area, scatter and bubble charts).
pub fn supports_trendline(k: ChartKind) -> bool {
use ChartKind::*;
matches!(k, ColumnClustered | BarClustered | Line | LineMarkers | Area | Scatter | ScatterLines | Bubble)
}
/// A forecast distance as drawn: finite, non-negative and bounded.
fn forecast(v: f64) -> f64 {
if v.is_finite() && v > 0.0 { v.min(1e9) } else { 0.0 }
}
/// Longest forward forecast among `s`'s regression trendlines.
fn max_forward(s: &SeriesData) -> f64 {
s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.forward)).fold(0.0, f64::max)
}
/// Longest backward forecast among `s`'s regression trendlines.
fn max_backward(s: &SeriesData) -> f64 {
s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.backward)).fold(0.0, f64::max)
}
/// Liang–Barsky: the part of segment `a`–`b` inside the unit square, with whether the start and
/// end were cut.
#[allow(clippy::type_complexity)]
fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option<((f64, f64), (f64, f64), bool, bool)> {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let (mut t0, mut t1) = (0.0f64, 1.0f64);
for (p, q) in [(-dx, a.0), (dx, 1.0 - a.0), (-dy, a.1), (dy, 1.0 - a.1)] {
if p == 0.0 {
if q < 0.0 {
return None;
}
} else {
let r = q / p;
if p < 0.0 {
if r > t1 {
return None;
}
t0 = t0.max(r);
} else {
if r < t0 {
return None;
}
t1 = t1.min(r);
}
}
}
Some(((a.0 + t0 * dx, a.1 + t0 * dy), (a.0 + t1 * dx, a.1 + t1 * dy), t0 > 0.0, t1 < 1.0))
}
/// Clips a sampled polyline in plot fractions (`None` = gap) to the plot box (the unit
/// square), returning the visible runs.
fn clip_unit(pts: &[Option<(f64, f64)>]) -> Vec<Vec<(f64, f64)>> {
const LIM: f64 = 1e9;
let ok = |p: &(f64, f64)| p.0.is_finite() && p.1.is_finite();
let cl = |p: (f64, f64)| (p.0.clamp(-LIM, LIM), p.1.clamp(-LIM, LIM));
let mut runs: Vec<Vec<(f64, f64)>> = Vec::new();
let mut run: Vec<(f64, f64)> = Vec::new();
for w in pts.windows(2) {
let seg = match w {
[Some(a), Some(b)] if ok(a) && ok(b) => clip_segment(cl(*a), cl(*b)),
_ => None,
};
match seg {
Some((p, q, cut_start, cut_end)) => {
if cut_start || run.is_empty() {
if run.len() >= 2 {
runs.push(std::mem::take(&mut run));
}
run.clear();
run.push(p);
}
run.push(q);
if cut_end {
runs.push(std::mem::take(&mut run));
}
}
None => {
if run.len() >= 2 {
runs.push(std::mem::take(&mut run));
}
run.clear();
}
}
}
if run.len() >= 2 {
runs.push(run);
}
runs
}
/// Draws `s`'s trendlines fitted to its plotted points `pts` (data x, y). `frac` maps data to
/// fractions of the plot box (0..1 inside, along the x and y axes), `px` fractions to pixels.
/// Equation and R² label lines are appended to `labels`. Degenerate fits draw nothing.
fn draw_trendlines(
ctx: &mut Ctx,
s: &SeriesData,
pts: &[(f64, f64)],
frac: &dyn Fn(f64, f64) -> (f64, f64),
px: &dyn Fn((f64, f64)) -> [f32; 2],
labels: &mut Vec<String>,
) {
let Some((xlo, xhi)) = value_range(pts.iter().map(|p| p.0)) else { return };
for t in s.trendlines.iter().take(MAX_TRENDLINES) {
let spec = &t.spec;
let samples: Vec<Option<(f64, f64)>> = match spec.kind {
TrendlineKind::MovingAverage(p) => moving_average(pts, p).into_iter().map(|(x, y)| Some(frac(x, y))).collect(),
_ => {
let Some(fit) = fit_trendline(spec, pts) else { continue };
let (lo, hi) = (xlo - forecast(spec.backward), xhi + forecast(spec.forward));
if spec.display_equation {
labels.push(fit.equation());
}
if spec.display_r_squared {
labels.push(fit.r_squared());
}
(0..=TREND_STEPS)
.map(|i| {
let x = lo + (hi - lo) * i as f64 / TREND_STEPS as f64;
fit.eval(x).map(|y| frac(x, y))
})
.collect()
}
};
let width = spec.width.filter(|w| w.is_finite()).unwrap_or(1.5).clamp(0.25, 12.0);
let dash = spec.dash != Some(LineDash::Solid);
for run in clip_unit(&samples) {
let line = decimate(run.into_iter().map(px).collect(), MAX_LINE_PTS);
if line.len() >= 2 {
ctx.out.push(Prim::Line { pts: line, color: t.color, width, dash });
}
}
}
}
/// Trendline equation / R² labels, stacked in the plot's top-right corner.
fn draw_trend_labels(ctx: &mut Ctx, plot: Bx, labels: &[String]) {
let lh = LABEL_SIZE * 1.3;
for (i, t) in labels.iter().enumerate() {
let y = plot.y + 4.0 + i as f32 * lh;
if y + lh > plot.bottom() {
break;
}
let t = ctx.fit(t, LABEL_SIZE, plot.w - 8.0);
ctx.text(plot.right() - 4.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Top);
}
}
// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo)
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Fam {
Col,
ColStack,
Col100,
Line,
LineStack,
Area,
AreaStack,
}
fn family(k: ChartKind) -> Fam {
use ChartKind::*;
match k {
ColumnStacked | BarStacked => Fam::ColStack,
ColumnStacked100 | BarStacked100 => Fam::Col100,
Line | LineMarkers | Scatter | ScatterLines | Radar | Stock => Fam::Line,
LineStacked => Fam::LineStack,
Area => Fam::Area,
AreaStacked => Fam::AreaStack,
_ => Fam::Col,
}
}
fn is_col(f: Fam) -> bool {
matches!(f, Fam::Col | Fam::ColStack | Fam::Col100)
}
fn is_line(f: Fam) -> bool {
matches!(f, Fam::Line | Fam::LineStack)
}
fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizontal: bool) {
let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS);
if ncat == 0 {
return;
}
let kinds: Vec<ChartKind> = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect();
let fams: Vec<Fam> = kinds.iter().map(|k| family(*k)).collect();
let any_primary = data.series.iter().any(|s| !s.secondary);
let sec: Vec<bool> = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect();
// Totals for 100% stacking, per axis.
let mut totals = [vec![0.0f64; ncat], vec![0.0f64; ncat]];
for (i, s) in data.series.iter().enumerate() {
if fams.get(i) == Some(&Fam::Col100) {
let a = sec.get(i).copied().unwrap_or(false) as usize;
for (j, v) in s.values.iter().take(ncat).enumerate() {
if let (Some(v), Some(t)) = (v, totals.get_mut(a).and_then(|t| t.get_mut(j))) {
*t += v.abs();
}
}
}
}
// Stack accumulators keyed by (axis, family): positive and negative running sums.
let mut stacks: Vec<((bool, Fam), Vec<f64>, Vec<f64>)> = Vec::new();
let mut spans: Vec<Vec<Option<(f64, f64)>>> = Vec::with_capacity(data.series.len());
for (i, s) in data.series.iter().enumerate() {
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
let a = sec.get(i).copied().unwrap_or(false);
let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack);
let key = (a, fam);
let si = if stacked {
match stacks.iter().position(|(k, _, _)| *k == key) {
Some(p) => Some(p),
None => {
stacks.push((key, vec![0.0; ncat], vec![0.0; ncat]));
Some(stacks.len() - 1)
}
}
} else {
None
};
let mut row = Vec::with_capacity(ncat);
for j in 0..ncat {
let v = s.values.get(j).copied().flatten();
let span = match (si.and_then(|p| stacks.get_mut(p)), fam) {
(Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| {
let v = if fam == Fam::Col100 {
let t = totals.get(a as usize).and_then(|t| t.get(j)).copied().unwrap_or(0.0);
if t > 0.0 { v / t } else { 0.0 }
} else {
v
};
let acc = if v >= 0.0 { pos.get_mut(j) } else { neg.get_mut(j) }?;
let base = *acc;
*acc += v;
Some((base, *acc))
}),
(Some((_, pos, _)), _) => {
let v = v.unwrap_or(0.0);
pos.get_mut(j).map(|acc| {
let base = *acc;
*acc = clean(*acc + v);
(base, *acc)
})
}
(None, _) => v.map(|v| (0.0, v)),
};
row.push(span);
}
spans.push(row);
}
// Axis scales.
let mut axes: Vec<(Scale, Fmt)> = Vec::new();
for axis in [false, true] {
let members: Vec<usize> = (0..data.series.len()).filter(|i| sec.get(*i).copied().unwrap_or(false) == axis).collect();
if members.is_empty() {
continue;
}
let force_zero = members.iter().any(|i| fams.get(*i).is_some_and(|f| !is_line(*f)));
let all100 = members.iter().all(|i| fams.get(*i) == Some(&Fam::Col100));
let range = value_range(members.iter().flat_map(|i| {
let line = fams.get(*i).is_some_and(|f| is_line(*f));
spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] })
}));
let (lo, hi) = range.unwrap_or((0.0, 0.0));
let (scale, fmt) = if all100 {
(fixed_scale(if lo < -1e-9 { -1.0 } else { 0.0 }, if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, 0.2), Fmt::new("0%"))
} else {
let code = members.first().and_then(|i| data.series.get(*i)).map(|s| s.number_format.as_str()).unwrap_or("General");
(nice_scale(lo, hi, force_zero), Fmt::new(code))
};
axes.push((scale, fmt));
if !axis && sec.iter().all(|s| !s) {
break;
}
}
let Some((s1, f1)) = axes.first() else { return };
let y1 = ValAxis { scale: *s1, fmt: f1 };
let y2 = axes.get(1).map(|(s, f)| ValAxis { scale: *s, fmt: f });
// A forward forecast on a category axis adds empty category slots after the data.
let fwd = data
.series
.iter()
.enumerate()
.filter(|(i, _)| kinds.get(*i).is_some_and(|k| supports_trendline(*k)))
.map(|(_, s)| max_forward(s))
.fold(0.0, f64::max);
let ncat_slots = ncat.saturating_add(fwd.ceil() as usize).min(MAX_CATS.max(ncat));
let labels: Vec<String> = (0..ncat_slots).map(|j| if j < ncat { cat_label(data, j) } else { String::new() }).collect();
let plot = frame(ctx, area, CatAxis::Labels(&labels), &y1, y2.as_ref(), horizontal, chart.gridlines);
if !plot.ok() {
return;
}
let scale_of = |i: usize| -> Scale { if sec.get(i).copied().unwrap_or(false) { y2.as_ref().map(|a| a.scale).unwrap_or(*s1) } else { *s1 } };
let cat_len = if horizontal { plot.h } else { plot.w };
let cw = cat_len / ncat_slots as f32;
let center = |j: usize| if horizontal { plot.bottom() - (j as f32 + 0.5) * cw } else { plot.x + (j as f32 + 0.5) * cw };
let vpos = |v: f64, sc: &Scale| if horizontal { plot.x + sc.frac(v) * plot.w } else { plot.bottom() - sc.frac(v) * plot.h };
// Column slots: each clustered series gets one, each stack group shares one.
let mut slot_keys: Vec<(bool, Fam, usize)> = Vec::new();
let mut slot_of = vec![0usize; data.series.len()];
for (i, f) in fams.iter().enumerate() {
if !is_col(*f) {
continue;
}
let a = sec.get(i).copied().unwrap_or(false);
let key = if *f == Fam::Col { (a, *f, i) } else { (a, *f, usize::MAX) };
let k = match slot_keys.iter().position(|k| *k == key) {
Some(p) => p,
None => {
slot_keys.push(key);
slot_keys.len() - 1
}
};
if let Some(s) = slot_of.get_mut(i) {
*s = k;
}
}
let nslots = slot_keys.len().max(1) as f32;
let bw = cw / (nslots + 1.5);
let labels_on = chart.data_labels && ncat <= MAX_LABELS;
// Areas first, then columns, then lines on top.
for pass in 0..3 {
for (i, s) in data.series.iter().enumerate() {
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
let want = match pass {
0 => matches!(fam, Fam::Area | Fam::AreaStack),
1 => is_col(fam),
_ => is_line(fam),
};
if !want {
continue;
}
let sc = scale_of(i);
let fmt = Fmt::new(&s.number_format);
let row = match spans.get(i) {
Some(r) => r,
None => continue,
};
match pass {
0 => {
let mut top = Vec::with_capacity(ncat * 2);
let mut base = Vec::with_capacity(ncat);
for (j, sp) in row.iter().enumerate() {
let (b, t) = sp.unwrap_or((0.0, 0.0));
let c = center(j);
top.push([c, vpos(t, &sc)]);
base.push([c, vpos(b, &sc)]);
}
if top.len() == 1 {
// A single point: give it some width.
if let (Some(t), Some(b)) = (top.first().copied(), base.first().copied()) {
top = vec![[t[0] - cw * 0.3, t[1]], [t[0] + cw * 0.3, t[1]]];
base = vec![[b[0] - cw * 0.3, b[1]], [b[0] + cw * 0.3, b[1]]];
}
}
let top = decimate(top, MAX_LINE_PTS * 2);
let base = decimate(base, MAX_LINE_PTS * 2);
let mut poly = top;
poly.extend(base.into_iter().rev());
if poly.len() >= 3 {
ctx.out.push(Prim::Polygon { pts: poly, fill: s.color, stroke: None });
}
}
1 => {
let k = slot_of.get(i).copied().unwrap_or(0) as f32;
for (j, sp) in row.iter().enumerate() {
let Some((b, t)) = sp else { continue };
let (pb, pt) = (vpos(*b, &sc), vpos(*t, &sc));
let c = center(j);
let lo = pb.min(pt);
let len = (pb - pt).abs();
if horizontal {
let y = c + nslots * bw / 2.0 - (k + 1.0) * bw;
ctx.rect(lo, y, len, bw, s.color, None);
} else {
let x = c - nslots * bw / 2.0 + k * bw;
ctx.rect(x, lo, bw, len, s.color, None);
}
if labels_on && let Some(v) = s.values.get(j).copied().flatten() {
let t = fmt.format(v, None);
let stacked = fam != Fam::Col;
let mid = (pb + pt) / 2.0;
if horizontal {
let y = c + nslots * bw / 2.0 - (k + 0.5) * bw;
if stacked {
ctx.text(mid, y, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle);
} else if v >= 0.0 {
ctx.text(pb.max(pt) + 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Left, VAlign::Middle);
} else {
ctx.text(lo - 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle);
}
} else {
let x = c - nslots * bw / 2.0 + (k + 0.5) * bw;
if stacked {
ctx.text(x, mid, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle);
} else if v >= 0.0 {
ctx.text(x, lo - 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
} else {
ctx.text(x, lo + len + 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Top);
}
}
}
}
}
_ => {
let pts: Vec<Option<[f32; 2]>> = row.iter().enumerate().map(|(j, sp)| sp.map(|(_, t)| [center(j), vpos(t, &sc)])).collect();
draw_runs(ctx, &pts, s.color, 2.25, s.smooth.then_some(plot));
let k = kinds.get(i).copied().unwrap_or(ChartKind::Line);
if k == ChartKind::LineMarkers && cw >= 3.0 {
draw_markers(ctx, pts.iter().flatten().copied(), 3.5, s.color);
}
if labels_on {
for (j, p) in pts.iter().enumerate() {
if let (Some(p), Some(v)) = (p, s.values.get(j).copied().flatten()) {
ctx.text(p[0], p[1] - 5.0, &fmt.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
}
}
}
}
}
}
}
// Trendlines on top: x is the category number 1..n.
let mut trend_labels = Vec::new();
for (i, s) in data.series.iter().enumerate() {
if s.trendlines.is_empty() || !kinds.get(i).is_some_and(|k| supports_trendline(*k)) {
continue;
}
let sc = scale_of(i);
let pts: Vec<(f64, f64)> = s.values.iter().take(ncat).enumerate().filter_map(|(j, v)| v.map(|v| ((j + 1) as f64, v))).collect();
let frac = |x: f64, y: f64| ((x - 0.5) / ncat_slots as f64, (y - sc.min) / (sc.max - sc.min));
let px = |(fx, fy): (f64, f64)| {
if horizontal {
[plot.x + fy as f32 * plot.w, plot.bottom() - fx as f32 * plot.h]
} else {
[plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h]
}
};
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels);
}
draw_trend_labels(ctx, plot, &trend_labels);
}
// ---------------------------------------------------------------- scatter & bubble
fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let pts_of = |s: &SeriesData| -> Vec<Option<(f64, f64)>> {
s.values
.iter()
.enumerate()
.map(|(j, v)| {
let x = match &s.x {
Some(xs) => xs.get(j).copied().flatten(),
None => Some((j + 1) as f64),
};
match (x, v) {
(Some(x), Some(y)) => Some((x, *y)),
_ => None,
}
})
.collect()
};
let all: Vec<Vec<Option<(f64, f64)>>> = data.series.iter().map(pts_of).collect();
let Some((mut xlo, mut xhi)) = value_range(all.iter().flatten().flatten().map(|p| p.0)) else { return };
// Forecasts extend the x axis to the trendline's ends.
for (s, pts) in data.series.iter().zip(&all) {
if let Some((lo, hi)) = value_range(pts.iter().flatten().map(|p| p.0)).filter(|_| !s.trendlines.is_empty()) {
xlo = xlo.min(lo - max_backward(s));
xhi = xhi.max(hi + max_forward(s));
}
}
let Some((ylo, yhi)) = value_range(all.iter().flatten().flatten().map(|p| p.1)) else { return };
let xs = nice_scale(xlo, xhi, false);
let ys = nice_scale(ylo, yhi, false);
let fx = Fmt::new("General");
let fy = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
let plot = frame(ctx, area, CatAxis::Numeric(xs, &fx), &ValAxis { scale: ys, fmt: &fy }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
let map = |(x, y): (f64, f64)| [plot.x + xs.frac(x) * plot.w, plot.bottom() - ys.frac(y) * plot.h];
let bubble = chart.kind == ChartKind::Bubble;
let rmax = plot.w.min(plot.h) * 0.12;
for (s, pts) in data.series.iter().zip(&all) {
let px: Vec<Option<[f32; 2]>> = pts.iter().map(|p| p.map(map)).collect();
if bubble {
let sizes = s.sizes.as_deref().unwrap_or(&[]);
let smax = sizes.iter().flatten().fold(0.0f64, |a, b| a.max(*b));
let mut bubbles: Vec<([f32; 2], f64)> = px
.iter()
.enumerate()
.filter_map(|(j, p)| {
let sz = if sizes.is_empty() { Some(1.0) } else { sizes.get(j).copied().flatten() };
match (p, sz) {
(Some(p), Some(sz)) if sz > 0.0 => Some((*p, sz)),
_ => None,
}
})
.take(MAX_MARKERS)
.collect();
bubbles.sort_by(|a, b| b.1.total_cmp(&a.1));
let smax = if sizes.is_empty() { 1.0 } else { smax };
let fill = [s.color[0], s.color[1], s.color[2], 0xC0];
for (p, sz) in bubbles {
let r = ((sz / smax).sqrt() as f32 * rmax).max(1.0);
ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(fill), stroke: Some((WHITE, 0.75)) });
}
} else {
let lines = chart.kind == ChartKind::ScatterLines || s.kind == ChartKind::ScatterLines;
if lines {
draw_runs(ctx, &px, s.color, 2.0, s.smooth.then_some(plot));
}
if !lines || px.len() <= 500 {
draw_markers(ctx, px.iter().flatten().copied(), 3.5, s.color);
}
}
if chart.data_labels && pts.len() <= MAX_LABELS {
for (p, raw) in px.iter().zip(pts) {
if let (Some(p), Some((_, y))) = (p, raw) {
ctx.text(p[0], p[1] - 6.0, &fy.format(*y, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
}
}
}
}
let mut trend_labels = Vec::new();
for (s, pts) in data.series.iter().zip(&all) {
if s.trendlines.is_empty() {
continue;
}
let pts: Vec<(f64, f64)> = pts.iter().flatten().copied().collect();
let frac = |x: f64, y: f64| ((x - xs.min) / (xs.max - xs.min), (y - ys.min) / (ys.max - ys.min));
let px = |(fx, fy): (f64, f64)| [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h];
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels);
}
draw_trend_labels(ctx, plot, &trend_labels);
}
// ---------------------------------------------------------------- histogram
fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let Some(s) = data.series.first() else { return };
let vals: Vec<f64> = s.values.iter().flatten().copied().collect();
let Some((lo, hi)) = value_range(vals.iter().copied()) else { return };
let n = vals.len();
let span = hi - lo;
let mut bins = ((n as f64).sqrt().ceil() as usize).clamp(1, 100);
if !(span.is_finite() && span > 0.0) {
bins = 1;
}
let width = if bins == 1 && span.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { 1.0 } else { span / bins as f64 };
let mut counts = vec![0usize; bins];
for v in &vals {
let b = if width > 0.0 { ((v - lo) / width).floor() } else { 0.0 };
let b = if b.is_finite() && b > 0.0 { (b as usize).min(bins - 1) } else { 0 };
if let Some(c) = counts.get_mut(b) {
*c += 1;
}
}
let f = Fmt::new(&s.number_format);
let labels: Vec<String> = (0..bins)
.map(|b| {
let a = lo + b as f64 * width;
let e = if b + 1 == bins { hi.max(a) } else { a + width };
let open = if b == 0 { '[' } else { '(' };
format!("{open}{}, {}]", f.format(a, Some(width / 10.0)), f.format(e, Some(width / 10.0)))
})
.collect();
let maxc = counts.iter().copied().max().unwrap_or(0) as f64;
let ys = nice_scale(0.0, maxc, true);
let fc = Fmt::new("General");
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
let cw = plot.w / bins as f32;
for (b, c) in counts.iter().enumerate() {
let top = plot.bottom() - ys.frac(*c as f64) * plot.h;
let x = plot.x + b as f32 * cw;
ctx.rect(x, top, cw, plot.bottom() - top, s.color, Some((WHITE, 0.75)));
if chart.data_labels {
ctx.text(x + cw / 2.0, top - 2.0, &c.to_string(), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
}
}
}
// ---------------------------------------------------------------- waterfall
fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let Some(s) = data.series.first() else { return };
let mut ncat = data.categories.len().max(s.values.len()).min(MAX_CATS);
if ncat == 0 {
return;
}
let mut labels: Vec<String> = (0..ncat).map(|j| cat_label(data, j)).collect();
let mut vals: Vec<f64> = (0..ncat).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0)).collect();
let mut is_total = vec![false; ncat];
let last_total = labels.last().is_some_and(|l| l.to_lowercase().contains("total"));
if let Some(t) = is_total.last_mut() {
*t = last_total;
}
if !last_total {
labels.push("Total".into());
vals.push(0.0);
is_total.push(true);
ncat += 1;
}
let mut bars = Vec::with_capacity(ncat);
let mut cum = 0.0f64;
for (j, v) in vals.iter().enumerate() {
if is_total.get(j).copied().unwrap_or(false) {
let total = if last_total && j + 1 == ncat && *v != 0.0 { *v } else { cum };
bars.push((0.0, total, 0u8));
cum = total;
} else {
let top = clean(cum + v);
bars.push((cum, top, if *v >= 0.0 { 1 } else { 2 }));
cum = top;
}
}
let (lo, hi) = value_range(bars.iter().flat_map(|(b, t, _)| [*b, *t])).unwrap_or((0.0, 0.0));
let ys = nice_scale(lo, hi, true);
let f = Fmt::new(&s.number_format);
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
let cw = plot.w / ncat as f32;
let bw = cw / 1.5;
let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h;
for (j, (b, t, kind)) in bars.iter().enumerate() {
let color = match kind {
1 => UP,
2 => DOWN,
_ => s.color,
};
let x = plot.x + (j as f32 + 0.5) * cw - bw / 2.0;
let (yb, yt) = (vy(*b), vy(*t));
ctx.rect(x, yb.min(yt), bw, (yb - yt).abs().max(0.5), color, None);
if j + 1 < bars.len() && ncat <= 500 {
ctx.out.push(Prim::Line { pts: vec![[x + bw, yt], [x + cw, yt]], color: AXIS, width: 0.75, dash: true });
}
if chart.data_labels && ncat <= MAX_LABELS {
let v = if *kind == 0 { *t } else { t - b };
ctx.text(x + bw / 2.0, yb.min(yt) - 2.0, &f.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
}
}
}
// ---------------------------------------------------------------- funnel
fn funnel(ctx: &mut Ctx, data: &ChartData, area: Bx) {
let Some(s) = data.series.first() else { return };
let n = data.categories.len().max(s.values.len()).min(MAX_CATS);
if n == 0 {
return;
}
let vals: Vec<f64> = (0..n).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0).abs()).collect();
let max = vals.iter().copied().fold(0.0f64, f64::max);
if max.is_nan() || max <= 0.0 {
return;
}
let labels: Vec<String> = (0..n).map(|j| cat_label(data, j)).collect();
let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max) + 8.0).min(area.w * 0.35);
let plot = Bx { x: area.x + lw, w: area.w - lw, ..area };
if !plot.ok() {
return;
}
let slot = plot.h / n as f32;
let bh = slot * 0.85;
let f = Fmt::new(&s.number_format);
let show = slot >= LABEL_SIZE * 0.9;
for (j, v) in vals.iter().enumerate() {
let bw = (*v / max) as f32 * plot.w;
let x = plot.x + (plot.w - bw) / 2.0;
let y = plot.y + j as f32 * slot + (slot - bh) / 2.0;
if bw > 0.0 {
ctx.rect(x, y, bw, bh, s.color, None);
}
if show {
let yc = y + bh / 2.0;
let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None);
let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT };
ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle);
let l = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw - 8.0);
ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle);
}
}
}
// ---------------------------------------------------------------- pie, doughnut, sunburst
fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inner: f32, names: bool) {
let Some(s) = data.series.first() else { return };
let vals: Vec<f64> = s.values.iter().take(MAX_CATS).map(|v| v.map(f64::abs).unwrap_or(0.0)).collect();
let total: f64 = vals.iter().sum();
if !(total.is_finite() && total > 0.0) {
return;
}
let (cx, cy) = (area.cx(), area.cy());
let r = area.w.min(area.h) / 2.0 * 0.92;
if r < 2.0 {
return;
}
let ri = r * inner;
let f = Fmt::new(&s.number_format);
let mut a = 0.0f32;
let mut labels = Vec::new();
for (j, v) in vals.iter().enumerate() {
let sweep = (*v / total) as f32 * TAU;
if sweep > 1e-4 {
let fill = point_color(data, j);
ctx.out.push(Prim::Wedge { cx, cy, r_outer: r, r_inner: ri, a0: a, a1: (a + sweep).min(TAU), fill, stroke: Some((WHITE, 1.5)) });
if (chart.data_labels || names) && vals.len() <= MAX_LABELS {
let t = if names { cat_label(data, j) } else { f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None) };
let mid = a + sweep / 2.0;
let rr = if ri > 0.0 { (r + ri) / 2.0 } else { r * 0.65 };
let room = sweep * rr;
if ctx.tw(&t, LABEL_SIZE) <= room.max(r - ri) && room >= LABEL_SIZE {
labels.push((cx + rr * mid.sin(), cy - rr * mid.cos(), t, on(fill)));
}
}
}
a += sweep;
}
for (x, y, t, c) in labels {
ctx.text(x, y, &t, LABEL_SIZE, c, HAlign::Center, VAlign::Middle);
}
}
// ---------------------------------------------------------------- treemap
fn worst(row: &[f64], side: f64) -> f64 {
let s: f64 = row.iter().sum();
if s <= 0.0 || side <= 0.0 {
return f64::INFINITY;
}
let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v)));
let s2 = s * s;
let w2 = side * side;
(w2 * mx / s2).max(s2 / (w2 * mn))
}
/// Squarified treemap layout of (index, area) items, sorted descending, into `r`.
fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
let total: f64 = items.iter().map(|i| i.1).sum();
let area = (r.w as f64) * (r.h as f64);
if !(total > 0.0 && area > 0.0) {
return vec![];
}
let scale = area / total;
let mut out = Vec::with_capacity(items.len());
let mut rem = r;
let mut row: Vec<(usize, f64)> = Vec::new();
let mut i = 0;
let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| {
let s: f64 = row.iter().map(|x| x.1).sum();
if s <= 0.0 {
return;
}
if rem.w >= rem.h {
let cw = (s / rem.h.max(1e-6) as f64) as f32;
let mut y = rem.y;
for (j, a) in row {
let h = (*a / cw.max(1e-6) as f64) as f32;
out.push((*j, Bx { x: rem.x, y, w: cw, h }));
y += h;
}
rem.x += cw;
rem.w = (rem.w - cw).max(0.0);
} else {
let rh = (s / rem.w.max(1e-6) as f64) as f32;
let mut x = rem.x;
for (j, a) in row {
let w = (*a / rh.max(1e-6) as f64) as f32;
out.push((*j, Bx { x, y: rem.y, w, h: rh }));
x += w;
}
rem.y += rh;
rem.h = (rem.h - rh).max(0.0);
}
};
while let Some(&(j, v)) = items.get(i) {
let a = v * scale;
let side = rem.w.min(rem.h) as f64;
let cur: Vec<f64> = row.iter().map(|x| x.1).collect();
let mut with = cur.clone();
with.push(a);
if row.is_empty() || worst(&with, side) <= worst(&cur, side) {
row.push((j, a));
i += 1;
} else {
layout(&row, &mut rem, &mut out);
row.clear();
}
}
if !row.is_empty() {
layout(&row, &mut rem, &mut out);
}
out
}
fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let Some(s) = data.series.first() else { return };
let mut items: Vec<(usize, f64)> =
s.values.iter().take(MAX_MARKERS).enumerate().filter_map(|(j, v)| v.filter(|v| *v > 0.0).map(|v| (j, v))).collect();
items.sort_by(|a, b| b.1.total_cmp(&a.1));
let f = Fmt::new(&s.number_format);
for (j, b) in squarify(&items, area) {
let fill = point_color(data, j);
ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5)));
if b.w > 24.0 && b.h > LABEL_SIZE * 1.6 {
let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0);
ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top);
if chart.data_labels && b.h > LABEL_SIZE * 3.2 {
let v = items.iter().find(|x| x.0 == j).map(|x| x.1).unwrap_or(0.0);
let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0);
ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top);
}
}
}
}
// ---------------------------------------------------------------- radar
fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(1000);
if ncat == 0 {
return;
}
let labels: Vec<String> = (0..ncat).map(|j| cat_label(data, j)).collect();
let lw = labels.iter().map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max).min(area.w * 0.2);
let r = ((area.w - 2.0 * (lw + 8.0)).min(area.h - 2.0 * (LABEL_SIZE * 1.4 + 6.0)) / 2.0).max(0.0);
if r < 5.0 {
return;
}
let (cx, cy) = (area.cx(), area.cy());
let (lo, hi) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())).unwrap_or((0.0, 0.0));
let sc = nice_scale(lo, hi, true);
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
let ang = |j: usize| j as f32 / ncat as f32 * TAU;
let pt = |a: f32, rr: f32| [cx + rr * a.sin(), cy - rr * a.cos()];
let ticks = sc.ticks();
let spacing = r / ticks.len().saturating_sub(1).max(1) as f32;
let every = ((LABEL_SIZE * 1.3 / spacing.max(1e-3)).ceil() as usize).max(1);
for (ti, t) in ticks.into_iter().enumerate() {
let rr = sc.frac(t) * r;
if rr > 0.5 && chart.gridlines {
let mut ring: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), rr)).collect();
if ncat < 3 {
ring = (0..=48).map(|k| pt(k as f32 / 48.0 * TAU, rr)).collect();
} else if let Some(p) = ring.first().copied() {
ring.push(p);
}
ctx.line(ring, GRID, 0.75);
}
if ti % every == 0 {
ctx.text(cx - 3.0, cy - rr, &f.format(t, Some(sc.step)), LABEL_SIZE * 0.9, TEXT, HAlign::Right, VAlign::Middle);
}
}
let step = ncat.div_ceil(72).max(1);
for j in (0..ncat).step_by(step) {
let a = ang(j);
ctx.line(vec![[cx, cy], pt(a, r)], GRID, 0.75);
let p = pt(a, r + 6.0);
let (sa, ca) = (a.sin(), a.cos());
let align = if sa > 0.1 {
HAlign::Left
} else if sa < -0.1 {
HAlign::Right
} else {
HAlign::Center
};
let valign = if ca > 0.1 {
VAlign::Bottom
} else if ca < -0.1 {
VAlign::Top
} else {
VAlign::Middle
};
let t = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw.max(20.0));
ctx.text(p[0], p[1], &t, LABEL_SIZE, TEXT, align, valign);
}
for s in &data.series {
let mut pts: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), sc.frac(s.values.get(j).copied().flatten().unwrap_or(sc.min)) * r)).collect();
if let Some(p) = pts.first().copied() {
pts.push(p);
}
ctx.line(pts, s.color, 2.0);
}
}
// ---------------------------------------------------------------- box & whisker
fn quantile(sorted: &[f64], q: f64) -> f64 {
let n = sorted.len();
if n == 0 {
return 0.0;
}
let pos = q * (n - 1) as f64;
let i = pos.floor() as usize;
let frac = pos - i as f64;
let a = sorted.get(i).copied().unwrap_or(0.0);
let b = sorted.get(i + 1).copied().unwrap_or(a);
a + (b - a) * frac
}
fn box_whisker(ctx: &mut Ctx, data: &ChartData, area: Bx) {
let labels: Vec<String> = data.series.iter().map(|s| s.name.clone()).collect();
let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return };
let ys = nice_scale(lo, hi, false);
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, true);
if !plot.ok() {
return;
}
let n = data.series.len().max(1);
let cw = plot.w / n as f32;
let bw = (cw * 0.4).min(80.0);
let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h;
for (i, s) in data.series.iter().enumerate() {
let mut v: Vec<f64> = s.values.iter().flatten().copied().collect();
if v.is_empty() {
continue;
}
v.sort_by(f64::total_cmp);
let (q1, med, q3) = (quantile(&v, 0.25), quantile(&v, 0.5), quantile(&v, 0.75));
let iqr = q3 - q1;
let (flo, fhi) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr);
let wlo = v.iter().copied().find(|x| *x >= flo).unwrap_or(q1);
let whi = v.iter().rev().copied().find(|x| *x <= fhi).unwrap_or(q3);
let mean = v.iter().sum::<f64>() / v.len() as f64;
let c = plot.x + (i as f32 + 0.5) * cw;
ctx.vline(c, vy(whi), vy(q3), s.color, 1.25);
ctx.vline(c, vy(q1), vy(wlo), s.color, 1.25);
ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(whi), s.color, 1.25);
ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(wlo), s.color, 1.25);
let (yt, yb) = (vy(q3), vy(q1));
ctx.rect(c - bw / 2.0, yt, bw, (yb - yt).max(0.5), s.color, None);
ctx.hline(c - bw / 2.0, c + bw / 2.0, vy(med), WHITE, 1.5);
let (mx, my, d) = (c, vy(mean), 3.0);
ctx.line(vec![[mx - d, my - d], [mx + d, my + d]], WHITE, 1.25);
ctx.line(vec![[mx - d, my + d], [mx + d, my - d]], WHITE, 1.25);
for o in v.iter().filter(|x| **x < flo || **x > fhi).take(MAX_MARKERS) {
ctx.out.push(Prim::Circle { cx: c, cy: vy(*o), r: 2.5, fill: None, stroke: Some((s.color, 1.0)) });
}
}
}
// ---------------------------------------------------------------- stock
fn stock(ctx: &mut Ctx, data: &ChartData, area: Bx) {
let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS);
if ncat == 0 {
return;
}
let labels: Vec<String> = (0..ncat).map(|j| cat_label(data, j)).collect();
let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return };
let ys = nice_scale(lo, hi, false);
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, true);
if !plot.ok() {
return;
}
let ser = |k: usize| data.series.get(k);
let (open, high, low, close) = match data.series.len() {
0 => return,
1 => (None, None, None, ser(0)),
2 => (None, ser(0), ser(1), None),
3 => (None, ser(0), ser(1), ser(2)),
_ => (ser(0), ser(1), ser(2), ser(3)),
};
let get = |s: Option<&SeriesData>, j: usize| s.and_then(|s| s.values.get(j).copied().flatten());
let cw = plot.w / ncat as f32;
let tick = (cw * 0.25).max(1.0);
let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h;
for j in 0..ncat {
let c = plot.x + (j as f32 + 0.5) * cw;
if let (Some(h), Some(l)) = (get(high, j), get(low, j)) {
ctx.vline(c, vy(h), vy(l), STOCK, 1.25);
}
match (get(open, j), get(close, j)) {
(Some(o), Some(cl)) => {
let (yo, yc) = (vy(o), vy(cl));
let bw = (cw * 0.5).max(1.0);
let fill = if cl >= o { WHITE } else { STOCK };
ctx.rect(c - bw / 2.0, yo.min(yc), bw, (yo - yc).abs().max(0.75), fill, Some((STOCK, 1.0)));
}
(None, Some(cl)) => {
if high.is_some() {
ctx.hline(c, c + tick, vy(cl), STOCK, 1.25);
} else {
ctx.out.push(Prim::Circle { cx: c, cy: vy(cl), r: 2.5, fill: Some(STOCK), stroke: None });
}
}
_ => {}
}
}
}
// ---------------------------------------------------------------- final safety pass
fn fin(v: f32) -> bool {
v.is_finite()
}
fn clamp_stroke(s: Option<(Rgba, f32)>) -> Option<(Rgba, f32)> {
s.filter(|(_, w)| fin(*w) && *w > 0.0).map(|(c, w)| (c, w.min(50.0)))
}
/// Drops non-finite primitives and clamps every coordinate into the `w`×`h` box.
pub(crate) fn finalize(prims: Vec<Prim>, w: f32, h: f32) -> Vec<Prim> {
let cx = |v: f32| v.clamp(0.0, w);
let cy = |v: f32| v.clamp(0.0, h);
let pts_ok = |pts: &[[f32; 2]]| pts.iter().all(|p| fin(p[0]) && fin(p[1]));
prims
.into_iter()
.filter_map(|p| match p {
Prim::Rect { x, y, w: rw, h: rh, fill, stroke, radius } => {
if !(fin(x) && fin(y) && fin(rw) && fin(rh)) {
return None;
}
let (x0, x1) = (cx(x.min(x + rw)), cx(x.max(x + rw)));
let (y0, y1) = (cy(y.min(y + rh)), cy(y.max(y + rh)));
if x1 - x0 <= 0.0 || y1 - y0 <= 0.0 {
return None;
}
let radius = if fin(radius) { radius.clamp(0.0, (x1 - x0).min(y1 - y0) / 2.0) } else { 0.0 };
Some(Prim::Rect { x: x0, y: y0, w: x1 - x0, h: y1 - y0, fill, stroke: clamp_stroke(stroke), radius })
}
Prim::Line { pts, color, width, dash } => {
if !pts_ok(&pts) || pts.len() < 2 || !fin(width) {
return None;
}
let pts = pts.into_iter().map(|p| [cx(p[0]), cy(p[1])]).collect();
Some(Prim::Line { pts, color, width: width.clamp(0.1, 50.0), dash })
}
Prim::Polygon { pts, fill, stroke } => {
if !pts_ok(&pts) || pts.len() < 3 {
return None;
}
let pts = pts.into_iter().map(|p| [cx(p[0]), cy(p[1])]).collect();
Some(Prim::Polygon { pts, fill, stroke: clamp_stroke(stroke) })
}
Prim::Wedge { cx: x, cy: y, r_outer, r_inner, a0, a1, fill, stroke } => {
if ![x, y, r_outer, r_inner, a0, a1].iter().all(|v| fin(*v)) {
return None;
}
let (x, y) = (cx(x), cy(y));
let maxr = x.min(w - x).min(y).min(h - y);
let ro = r_outer.min(maxr);
if ro <= 0.0 {
return None;
}
Some(Prim::Wedge { cx: x, cy: y, r_outer: ro, r_inner: r_inner.clamp(0.0, ro), a0, a1, fill, stroke: clamp_stroke(stroke) })
}
Prim::Circle { cx: x, cy: y, r, fill, stroke } => {
if !(fin(x) && fin(y) && fin(r)) {
return None;
}
let (x, y) = (cx(x), cy(y));
let r = r.min(x.min(w - x).min(y).min(h - y));
if r <= 0.0 {
return None;
}
Some(Prim::Circle { cx: x, cy: y, r, fill, stroke: clamp_stroke(stroke) })
}
Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => {
if !(fin(x) && fin(y) && fin(size) && fin(rotation)) || text.is_empty() {
return None;
}
Some(Prim::Text { x: cx(x), y: cy(y), text, size: size.clamp(1.0, 200.0), color, bold, align, valign, rotation })
}
})
.collect()
}
#[cfg(test)]
mod smooth_tests {
use super::*;
#[test]
fn long_runs_are_smoothed_uniformly_within_the_cap() {
let plot = Bx { x: 0.0, y: 0.0, w: 3000.0, h: 100.0 };
let pts: Vec<[f32; 2]> = (0..1500).map(|i| [i as f32 * 2.0, if i % 2 == 0 { 20.0 } else { 80.0 }]).collect();
let out = smooth_run(pts, plot);
assert!(out.len() <= MAX_LINE_PTS * 2 + 1, "{}", out.len());
// Two sub-segments per segment all the way to the end, not 100 each and then a straight tail.
assert_eq!(out.len(), 1499 * 2 + 1);
assert_eq!(out.last().copied(), Some([2998.0, 80.0]));
}
#[test]
fn spline_overshoot_is_clamped_to_the_plot() {
let plot = Bx { x: 0.0, y: 10.0, w: 300.0, h: 100.0 };
// A step right at the plot edges: Catmull-Rom overshoots past both.
let pts = vec![[0.0, 110.0], [100.0, 110.0], [110.0, 10.0], [200.0, 10.0], [300.0, 10.0]];
let out = smooth_run(pts, plot);
assert!(out.len() > 5);
assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1])));
}
}