use super::{AXIS, AXIS_TITLE_SIZE, Chart, ChartData, Ctx, DARK_TEXT, Fmt, GRID, HAlign, LABEL_SIZE, PI, Prim, Rgba, Scale, TEXT, VAlign, WHITE}; #[derive(Clone, Copy, Debug)] pub(super) struct Bx { pub(super) x: f32, pub(super) y: f32, pub(super) w: f32, pub(super) h: f32, } impl Bx { pub(super) fn right(&self) -> f32 { self.x + self.w } pub(super) fn bottom(&self) -> f32 { self.y + self.h } pub(super) fn ok(&self) -> bool { self.w.is_finite() && self.h.is_finite() && self.w > 4.0 && self.h > 4.0 } pub(super) fn cx(&self) -> f32 { self.x + self.w / 2.0 } pub(super) fn cy(&self) -> f32 { self.y + self.h / 2.0 } } /// Text colour readable on `fill`. pub(super) 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 } } pub(super) fn chart_title(chart: &Chart, data: &ChartData) -> Option { 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, } } pub(super) 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 } // ---------------------------------------------------------------- axes frame pub(super) enum CatAxis<'a> { /// Category labels and, for a multi-level axis, the outer levels (nearest the labels first; /// one entry per category, non-empty where a group starts). Labels(&'a [String], &'a [Vec]), /// A data table supplies the labels below a vertical chart. Its stub must clear the plot. Table(&'a [String], f32), Numeric(Scale, &'a Fmt), } /// Groups of an outer category level: `(first, end)` index pairs, a group starting at every /// non-empty entry (and at the first category). pub(super) fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> { let mut starts: Vec = (0..n).filter(|&j| j == 0 || level.get(j).is_some_and(|t| !t.is_empty())).collect(); starts.push(n); starts.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect() } pub(super) struct ValAxis<'a> { pub(super) scale: Scale, pub(super) 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. pub(super) 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 stub = match &cat { CatAxis::Table(_, width) => *width, _ => 0.0, }; 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).max(stub); let y2labels = y2.map(ValAxis::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 levels = match &cat { CatAxis::Labels(_, o) => o.len() as f32, CatAxis::Numeric(..) | CatAxis::Table(..) => 0.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 - lh * levels }; 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::Table(..) => {} CatAxis::Labels(l, outer) => { 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)); } // Outer levels, one row each below the labels: a group's name centred under its // categories, and a separator at each group boundary down through the rows. for (i, level) in outer.iter().enumerate() { let top = plot.bottom() + 3.0 + lh * (i as f32 + 1.0); for (first, end) in level_groups(level, n) { let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw); if let Some(t) = level.get(first).filter(|t| !t.is_empty()) { let t = ctx.fit(t, ls, x1 - x0 - 2.0); ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, (HAlign::Center, VAlign::Top)); } if first > 0 { ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75); } } ctx.vline(plot.right(), plot.bottom(), top + lh - 2.0, AXIS, 0.75); } } CatAxis::Numeric(s, f) => { let ticks = s.ticks(); let labels: Vec = 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, outer): (Vec, &[Vec]) = match cat { CatAxis::Labels(l, o) => (l.to_vec(), o), CatAxis::Table(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); // Outer levels stand in columns left of the labels, each as wide as its longest name. let level_w: Vec = outer.iter().map(|l| (l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15)).collect(); let levels_w: f32 = level_w.iter().sum(); let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0; let left = (lw + levels_w).max(stub); let plot = Bx { x: area.x + left, y: area.y + 2.0, w: area.w - left - 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)); } // Outer levels: a group's name centred beside its categories, separators at the // boundaries running right to the axis. let mut x1 = plot.x - lw; for (level, w) in outer.iter().zip(&level_w) { let x0 = x1 - w; for (first, end) in level_groups(level, n) { let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch); if let Some(t) = level.get(first).filter(|t| !t.is_empty()) { let t = ctx.fit(t, ls, w - 4.0); ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, (HAlign::Center, VAlign::Middle)); } if first > 0 { ctx.hline(x0, plot.x, y1, AXIS, 0.75); } } ctx.hline(x0, plot.x, plot.y, AXIS, 0.75); x1 = x0; } plot } } pub(super) fn value_range(it: impl Iterator) -> Option<(f64, f64)> { it.fold(None, |acc, v| match acc { None => Some((v, v)), Some((a, b)) => Some((a.min(v), b.max(v))), }) }