1755 lines
72 KiB
Rust
1755 lines
72 KiB
Rust
//! 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])));
|
||
}
|
||
}
|