use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range}; use super::legend::{cat_label, point_color}; use super::{ AXIS, Chart, ChartData, Ctx, DOWN, Fmt, GRID, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_MARKERS, Prim, QuartileMethod, STOCK, SeriesData, TAU, TEXT, UP, VAlign, WHITE, clean, nice_scale, }; // ---------------------------------------------------------------- histogram pub(super) fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let Some(s) = data.series.first() else { return }; let vals: Vec = 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 }; // Upper edge of each bin, exactly as shown in the labels below. Bins are right-inclusive (the first is closed on both sides), so a value // equal to an edge belongs to the bin that ends there. let edges: Vec = (0..bins).map(|b| if b + 1 == bins { hi.max(lo + b as f64 * width) } else { lo + (b + 1) as f64 * width }).collect(); let mut counts = vec![0usize; bins]; for v in &vals { let b = edges.partition_point(|e| e < v).min(bins - 1); if let Some(c) = counts.get_mut(b) { *c += 1; } } let f = Fmt::new(&s.number_format); let labels: Vec = (0..bins) .map(|b| { let a = lo + b as f64 * width; let e = edges.get(b).copied().unwrap_or(a); 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 pub(super) 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 = (0..ncat).map(|j| cat_label(data, j)).collect(); let mut vals: Vec = (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 pub(super) fn funnel(ctx: &mut Ctx, chart: &Chart, 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 = (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 = (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; if chart.data_labels { 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 pub(super) 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 = 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 = 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 } pub(super) 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 pub(super) 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 = (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, method: QuartileMethod) -> f64 { let n = sorted.len(); if n == 0 { return 0.0; } // Clamp exclusive ranks for short samples so one or two points remain drawable. let pos = match method { QuartileMethod::Inclusive => q * (n - 1) as f64, QuartileMethod::Exclusive => (q * (n as f64 + 1.0) - 1.0).clamp(0.0, (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 } pub(super) fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { let labels: Vec = 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, chart.gridlines); 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 = s.values.iter().flatten().copied().collect(); if v.is_empty() { continue; } v.sort_by(f64::total_cmp); let method = chart.series.get(i).map(|s| s.quartiles).unwrap_or_default(); let (q1, med, q3) = (quantile(&v, 0.25, method), quantile(&v, 0.5, method), quantile(&v, 0.75, method)); 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::() / 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 pub(super) fn stock(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(MAX_CATS); if ncat == 0 { return; } let labels: Vec = (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, chart.gridlines); 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 }); } } _ => {} } } } #[cfg(test)] mod quartile_tests { use super::*; #[test] fn box_whisker_default_quartiles_are_exclusive() { let values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; assert_eq!(quantile(&values, 0.25, QuartileMethod::default()), 1.75); assert_eq!(quantile(&values, 0.5, QuartileMethod::default()), 3.5); assert_eq!(quantile(&values, 0.75, QuartileMethod::default()), 5.25); } #[test] fn quartile_methods_and_short_samples() { for (values, exclusive, inclusive) in [ (vec![1., 2., 3.], (1., 3.), (1.5, 2.5)), (vec![1., 2., 3., 4.], (1.25, 3.75), (1.75, 3.25)), (vec![1., 2., 3., 4., 5.], (1.5, 4.5), (2., 4.)), (vec![1., 2., 3., 4., 5., 6., 7.], (2., 6.), (2.5, 5.5)), (vec![1., 1., 2., 3., 5., 8.], (1., 5.75), (1.25, 4.5)), (vec![1., 2.], (1., 2.), (1.25, 1.75)), (vec![1.], (1., 1.), (1., 1.)), (vec![], (0., 0.), (0., 0.)), ] { for (method, expected) in [(QuartileMethod::Exclusive, exclusive), (QuartileMethod::Inclusive, inclusive)] { assert_eq!((quantile(&values, 0.25, method), quantile(&values, 0.75, method)), expected); } } } }