mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
# Conflicts: # crates/pptx/src/read/chart.rs # crates/render/src/chart.rs # crates/render/src/tests.rs
1094 lines
44 KiB
Rust
1094 lines
44 KiB
Rust
//! Charts: title, plot area with axes and gridlines, series marks, legend.
|
||
|
||
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
|
||
use deckcraft_model::Shape;
|
||
use deckcraft_model::chart::{Chart, ChartType, TrendlineType, axis_scale};
|
||
use deckcraft_model::resolve::Ctx;
|
||
use deckcraft_model::style::{ColorRef, Fill};
|
||
use deckcraft_model::text::{Align, Anchor, Bullet, TextBody};
|
||
use kurbo::{Affine, BezPath, Point, Rect, Shape as _};
|
||
use vello_cpu::RenderContext;
|
||
|
||
use crate::color;
|
||
|
||
const SLOTS: [SchemeSlot; 6] =
|
||
[SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6];
|
||
|
||
/// Colour of series/point `i`: accents, then darker and lighter rounds.
|
||
pub fn series_color(ctx: &Ctx, c: &Chart, i: usize) -> Rgba {
|
||
let slot = SLOTS.get(i % 6).copied().unwrap_or(SchemeSlot::Accent1);
|
||
let base = ctx.color(&ColorRef::scheme(slot), None);
|
||
let mono = c.palette.starts_with("monochrome");
|
||
let base = if mono { ctx.color(&ColorRef::scheme(SchemeSlot::Accent1), None) } else { base };
|
||
let round = if mono { i } else { i / 6 };
|
||
match round {
|
||
0 => base,
|
||
1 => deckcraft_color::apply(base, &[ColorTransform::Shade(60000)]),
|
||
2 => deckcraft_color::apply(base, &[ColorTransform::Tint(60000)]),
|
||
n => deckcraft_color::apply(base, &[ColorTransform::LumMod(100000 - (n as i32 % 5) * 12000)]),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart::Series>) -> Rgba {
|
||
match s.and_then(|s| s.fill.as_ref()) {
|
||
Some(Fill::Solid { color: cc }) => ctx.color(cc, None),
|
||
_ => series_color(ctx, c, i),
|
||
}
|
||
}
|
||
|
||
/// The fill of point `k` of a series: its own (`c:dPt`) where the file gives one, else the series'.
|
||
fn point_fill(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series, k: usize) -> Rgba {
|
||
match s.point_fills.iter().find(|(p, _)| *p == k).map(|(_, f)| f) {
|
||
Some(Fill::Solid { color: cc }) => ctx.color(cc, None),
|
||
_ => series_fill(ctx, c, i, Some(s)),
|
||
}
|
||
}
|
||
|
||
/// Fill of one bar: a `<c:dPt>` fill wins, and a single-series chart that varies colours by point uses one accent per point
|
||
/// (not waterfalls, which are never read from a `<c:barChart>`).
|
||
fn bar_fill(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Rgba {
|
||
if s.point_fills.iter().any(|(k, _)| *k == pi) {
|
||
return point_fill(ctx, c, si, s, pi);
|
||
}
|
||
if c.vary_colors && c.series.len() == 1 && s.fill.is_none() && c.kind != ChartType::Waterfall {
|
||
return series_color(ctx, c, pi);
|
||
}
|
||
series_fill(ctx, c, si, Some(s))
|
||
}
|
||
|
||
fn polyline(pts: &[Point]) -> BezPath {
|
||
let mut p = BezPath::new();
|
||
for (k, q) in pts.iter().enumerate() {
|
||
if k == 0 {
|
||
p.move_to(*q);
|
||
} else {
|
||
p.line_to(*q);
|
||
}
|
||
}
|
||
p
|
||
}
|
||
|
||
/// A smoothed line (`c:smooth`) through `pts`: a Catmull-Rom spline as cubic Béziers, the curve
|
||
/// PowerPoint draws, passing through every point.
|
||
pub(crate) fn smooth_path(pts: &[Point]) -> BezPath {
|
||
let mut p = BezPath::new();
|
||
let Some(first) = pts.first() else { return p };
|
||
p.move_to(*first);
|
||
for k in 0..pts.len() - 1 {
|
||
let p0 = pts[k.saturating_sub(1)];
|
||
let (p1, p2) = (pts[k], pts[k + 1]);
|
||
let p3 = pts[(k + 2).min(pts.len() - 1)];
|
||
let c1 = p1 + (p2 - p0) / 6.0;
|
||
let c2 = p2 - (p3 - p1) / 6.0;
|
||
p.curve_to(c1, c2, p2);
|
||
}
|
||
p
|
||
}
|
||
|
||
/// A line series' colour: its line's own (where PowerPoint keeps it), else its fill's, else the
|
||
/// palette's.
|
||
pub(crate) fn series_line(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series) -> Rgba {
|
||
match s.line.as_ref().and_then(|l| l.fill.as_ref()) {
|
||
Some(Fill::Solid { color: cc }) => ctx.color(cc, None),
|
||
_ => series_fill(ctx, c, i, Some(s)),
|
||
}
|
||
}
|
||
|
||
/// A category label on two lines, split at its middle space or dash ("03.11.2025 - 09.11.2025").
|
||
fn split_label(name: &str) -> String {
|
||
let chars: Vec<char> = name.chars().collect();
|
||
let mid = chars.len() / 2;
|
||
let best = chars.iter().enumerate().filter(|(_, c)| c.is_whitespace()).map(|(i, _)| i).min_by_key(|i| i.abs_diff(mid));
|
||
match best {
|
||
Some(i) if i > 0 && i + 1 < chars.len() => {
|
||
let (a, b) = chars.split_at(i);
|
||
format!("{}\n{}", a.iter().collect::<String>().trim_end(), b.iter().collect::<String>().trim_start())
|
||
}
|
||
_ => name.to_string(),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn label(
|
||
ctx: &mut RenderContext,
|
||
rctx: &Ctx,
|
||
m: Affine,
|
||
text: &str,
|
||
r: Rect,
|
||
size: f64,
|
||
align: Align,
|
||
anchor: Anchor,
|
||
col: Rgba,
|
||
bold: bool,
|
||
) {
|
||
let mut body = TextBody::from_text(text);
|
||
body.body.inset_l = Some(0.0);
|
||
body.body.inset_r = Some(0.0);
|
||
body.body.inset_t = Some(0.0);
|
||
body.body.inset_b = Some(0.0);
|
||
body.body.anchor = Some(anchor);
|
||
body.body.wrap = Some(false);
|
||
for p in &mut body.paragraphs {
|
||
p.props.align = Some(align);
|
||
p.props.bullet = Some(Bullet::None);
|
||
p.props.margin_left = Some(0.0);
|
||
p.props.indent = Some(0.0);
|
||
for run in &mut p.runs {
|
||
run.props.size = Some(size);
|
||
run.props.bold = Some(bold);
|
||
run.props.fill = Some(Fill::solid(ColorRef::rgb(col)));
|
||
}
|
||
}
|
||
let tmp = Shape { text: Some(body.clone()), ..Default::default() };
|
||
crate::draw_text(ctx, rctx, &tmp, &body, r, m, &deckcraft_text::NoFields, None);
|
||
}
|
||
|
||
fn fmt_num(v: f64) -> String {
|
||
if (v - v.round()).abs() < 1e-9 { format!("{}", v.round() as i64) } else { format!("{v:.1}") }
|
||
}
|
||
|
||
/// A scatter or bubble chart's value x axis (min, max, major unit): one scale over every series'
|
||
/// x values, from zero when they're all positive. `None` for other charts and when there are no x values.
|
||
fn value_x_scale(c: &Chart, max_intervals: usize) -> Option<(f64, f64, f64)> {
|
||
if c.series.is_empty() || !c.series.iter().all(|s| matches!(c.series_kind(s), ChartType::Scatter | ChartType::Bubble)) {
|
||
return None;
|
||
}
|
||
let xs = c.series.iter().flat_map(|s| s.x_values.iter().flatten().copied()).filter(|v| v.is_finite());
|
||
let (lo, hi) = xs.fold(None, |r: Option<(f64, f64)>, v| Some(r.map_or((v, v), |(a, b)| (a.min(v), b.max(v)))))?;
|
||
Some(axis_scale(lo.min(0.0), hi, None, None, None, max_intervals, false))
|
||
}
|
||
|
||
/// Where the legend goes for `c:legendPos`, and the plot area left over.
|
||
/// `size` is the legend block (width, height); the result is its top-left corner and the plot area.
|
||
/// `r` (also the OOXML default and the fallback for unknown values) and `l` are vertical stacks centred
|
||
/// on the area, `tr` is a vertical stack in the top-right corner, `t` and `b` are centred rows.
|
||
/// An overlaid legend leaves the plot area as it was.
|
||
fn legend_layout(pos: &str, size: (f64, f64), area: Rect, base: f64, overlay: bool) -> (Point, Rect) {
|
||
let (lw, lh) = size;
|
||
let gap = base * 0.5;
|
||
let cx = (area.x0 + area.x1) / 2.0;
|
||
let cy = ((area.y0 + area.y1) / 2.0 - lh / 2.0).max(area.y0);
|
||
let mut plot = area;
|
||
let origin = match pos {
|
||
"b" => {
|
||
plot.y1 = area.y1 - lh - gap;
|
||
Point::new(cx - lw / 2.0, area.y1 - lh)
|
||
}
|
||
"t" => {
|
||
plot.y0 = area.y0 + lh + gap;
|
||
Point::new(cx - lw / 2.0, area.y0)
|
||
}
|
||
"l" => {
|
||
plot.x0 = area.x0 + lw + gap;
|
||
Point::new(area.x0, cy)
|
||
}
|
||
"tr" => {
|
||
plot.x1 = area.x1 - lw - gap;
|
||
Point::new(area.x1 - lw, area.y0)
|
||
}
|
||
_ => {
|
||
plot.x1 = area.x1 - lw - gap;
|
||
Point::new(area.x1 - lw, cy)
|
||
}
|
||
};
|
||
(origin, if overlay { area } else { plot })
|
||
}
|
||
|
||
pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) {
|
||
match c.kind {
|
||
// Histograms and Pareto charts are drawn as the column (and line) chart of their bins.
|
||
ChartType::Histogram | ChartType::Pareto => draw_inner(ctx, rctx, &c.histogram_view(), m, w, h, None),
|
||
ChartType::BoxWhisker => {
|
||
let (view, boxes) = crate::chartex::box_view(c);
|
||
draw_inner(ctx, rctx, &view, m, w, h, Some(&boxes));
|
||
}
|
||
_ => draw_inner(ctx, rctx, c, m, w, h, None),
|
||
}
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64, boxes: Option<&crate::chartex::Boxes>) {
|
||
let text_col = c.text_color.as_ref().map(|t| rctx.color(t, None)).unwrap_or(Rgba::rgb(0x59, 0x59, 0x59));
|
||
let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9);
|
||
if let Some(f) = &c.chart_fill {
|
||
crate::paint::fill_path(
|
||
ctx,
|
||
rctx,
|
||
f,
|
||
None,
|
||
&Rect::new(0.0, 0.0, w, h).to_path(0.1),
|
||
Rect::new(0.0, 0.0, w, h),
|
||
m,
|
||
deckcraft_geom::preset::FillMode::Norm,
|
||
1.0,
|
||
rctx.pres,
|
||
);
|
||
}
|
||
// The file's text sizes when it has them (PowerPoint draws chart text at them); otherwise a size
|
||
// that suits the chart.
|
||
let base = c.text_size.unwrap_or_else(|| (h.min(w) / 22.0).clamp(7.0, 18.0));
|
||
let title_size = c.title_size.unwrap_or(base * 1.4);
|
||
let mut area = Rect::new(w * 0.04, h * 0.04, w * 0.96, h * 0.96);
|
||
if let Some(t) = &c.title {
|
||
label(ctx, rctx, m, t, Rect::new(0.0, area.y0, w, area.y0 + title_size * 1.3), title_size, Align::Center, Anchor::Top, text_col, false);
|
||
area.y0 += title_size * 1.7;
|
||
}
|
||
let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut);
|
||
// Legend placed by `c:legendPos`.
|
||
let waterfall = c.kind == ChartType::Waterfall;
|
||
let legend_items: Vec<(String, Rgba)> = if waterfall {
|
||
["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), series_color(rctx, c, i))).collect()
|
||
} else if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) {
|
||
// A hierarchy's legend names its top-level groups.
|
||
c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), series_color(rctx, c, i))).collect()
|
||
} else if matches!(c.kind, ChartType::Funnel | ChartType::RegionMap) {
|
||
// A map draws its own colour scale.
|
||
vec![]
|
||
} else if pie {
|
||
c.categories.iter().enumerate().map(|(i, n)| (n.clone(), series_color(rctx, c, i))).collect()
|
||
} else {
|
||
c.series
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, s)| {
|
||
let k = c.series_kind(s);
|
||
let line = !k.is_bar_like() && !matches!(k, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar);
|
||
(s.name.clone(), if line { series_line(rctx, c, i, s) } else { series_fill(rctx, c, i, Some(s)) })
|
||
})
|
||
.collect()
|
||
};
|
||
if let (Some(pos), false) = (&c.legend, legend_items.is_empty()) {
|
||
let vertical = matches!(pos.as_str(), "l" | "r" | "tr");
|
||
let item_ws: Vec<f64> = legend_items.iter().map(|(n, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect();
|
||
let size = if vertical {
|
||
(item_ws.iter().copied().fold(0.0, f64::max).min(w * 0.4), legend_items.len() as f64 * base * 1.5)
|
||
} else {
|
||
(item_ws.iter().sum::<f64>().min(w * 0.9), base * 1.4)
|
||
};
|
||
let (origin, plot_area) = legend_layout(pos, size, area, base, c.legend_overlay);
|
||
area = plot_area;
|
||
let (mut x, mut y) = (origin.x, origin.y);
|
||
for (name, col) in &legend_items {
|
||
ctx.set_transform(m);
|
||
ctx.set_paint(color(*col, 1.0));
|
||
ctx.fill_rect(&Rect::new(x, y + base * 0.3, x + base * 0.7, y + base));
|
||
let tw = name.chars().count() as f64 * base * 0.55 + base;
|
||
label(ctx, rctx, m, name, Rect::new(x + base, y, x + base + tw, y + base * 1.3), base, Align::Left, Anchor::Middle, text_col, false);
|
||
if vertical {
|
||
y += base * 1.5;
|
||
} else {
|
||
x += tw + base * 0.6;
|
||
}
|
||
}
|
||
}
|
||
if pie {
|
||
let Some(s) = c.series.first() else { return };
|
||
let vals: Vec<f64> = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect();
|
||
let total: f64 = vals.iter().sum();
|
||
if total <= 0.0 {
|
||
return;
|
||
}
|
||
let r = (area.width().min(area.height()) / 2.0) * 0.95;
|
||
let cen = area.center();
|
||
let mut a0 = -std::f64::consts::FRAC_PI_2;
|
||
for (i, v) in vals.iter().enumerate() {
|
||
let sweep = v / total * std::f64::consts::TAU;
|
||
let mut p = BezPath::new();
|
||
p.move_to(cen);
|
||
let arc = kurbo::Arc::new(cen, (r, r), a0, sweep, 0.0);
|
||
p.line_to(Point::new(cen.x + r * a0.cos(), cen.y + r * a0.sin()));
|
||
p.extend(arc.append_iter(0.1));
|
||
p.close_path();
|
||
ctx.set_transform(m);
|
||
let col = s
|
||
.point_fills
|
||
.iter()
|
||
.find(|(k, _)| *k == i)
|
||
.and_then(|(_, f)| if let Fill::Solid { color: cc } = f { Some(rctx.color(cc, None)) } else { None })
|
||
.unwrap_or_else(|| series_color(rctx, c, i));
|
||
ctx.set_paint(color(col, 1.0));
|
||
ctx.fill_path(&p);
|
||
ctx.set_paint(color(Rgba::WHITE, 1.0));
|
||
ctx.set_stroke(kurbo::Stroke::new(1.0));
|
||
ctx.stroke_path(&p);
|
||
if c.data_labels {
|
||
let mid = a0 + sweep / 2.0;
|
||
let lp = Point::new(cen.x + r * 0.65 * mid.cos(), cen.y + r * 0.65 * mid.sin());
|
||
label(
|
||
ctx,
|
||
rctx,
|
||
m,
|
||
&c.data_label(s, *v),
|
||
Rect::new(lp.x - 30.0, lp.y - 10.0, lp.x + 30.0, lp.y + 10.0),
|
||
base,
|
||
Align::Center,
|
||
Anchor::Middle,
|
||
Rgba::WHITE,
|
||
false,
|
||
);
|
||
}
|
||
a0 += sweep;
|
||
}
|
||
if c.kind == ChartType::Doughnut {
|
||
ctx.set_transform(m);
|
||
ctx.set_paint(color(Rgba::WHITE, 1.0));
|
||
ctx.fill_path(&kurbo::Circle::new(cen, r * c.hole_size.clamp(0.1, 0.9)).to_path(0.1));
|
||
}
|
||
return;
|
||
}
|
||
if matches!(c.kind, ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::RegionMap) {
|
||
crate::chartex::draw(ctx, rctx, c, m, area, base, text_col);
|
||
return;
|
||
}
|
||
if matches!(c.kind, ChartType::Radar | ChartType::FilledRadar) {
|
||
crate::radar::draw(ctx, rctx, c, m, area, base, text_col);
|
||
return;
|
||
}
|
||
let combo = c.kind == ChartType::Combo;
|
||
// Bars are drawn first and lines over them; a combo chart has both.
|
||
let bar_kind = c.series.iter().map(|s| c.series_kind(s)).find(|k| k.is_bar_like());
|
||
let horiz = if combo { bar_kind.is_some_and(ChartType::horizontal) } else { c.kind.horizontal() };
|
||
// Axis titles: the left one turned to read upwards, the bottom one centred under the axis.
|
||
// On a horizontal bar chart the category axis is the left one.
|
||
let (left_title, bottom_title) =
|
||
if horiz { (c.axis_titles.0.as_deref(), c.axis_titles.1.as_deref()) } else { (c.axis_titles.1.as_deref(), c.axis_titles.0.as_deref()) };
|
||
let band_h = base * 1.6;
|
||
if let Some(t) = bottom_title.filter(|t| !t.is_empty()) {
|
||
let r = Rect::new(area.x0, area.y1 - band_h, area.x1, area.y1);
|
||
label(ctx, rctx, m, t, r, base, Align::Center, Anchor::Bottom, text_col, true);
|
||
area.y1 -= band_h + base * 0.3;
|
||
}
|
||
if let Some(t) = left_title.filter(|t| !t.is_empty()) {
|
||
let (cx, cy) = (area.x0 + band_h / 2.0, (area.y0 + area.y1) / 2.0);
|
||
let turned = m * Affine::translate((cx, cy)) * Affine::rotate(-std::f64::consts::FRAC_PI_2);
|
||
let half = area.height() / 2.0;
|
||
label(ctx, rctx, turned, t, Rect::new(-half, -band_h / 2.0, half, band_h / 2.0), base, Align::Center, Anchor::Middle, text_col, true);
|
||
area.x0 += band_h + base * 0.3;
|
||
}
|
||
// As many major units as the value axis has room for labels (PowerPoint's automatic unit).
|
||
let axis_len = if horiz { area.width() - base * 4.5 } else { area.height() - base * 1.8 };
|
||
let max_intervals = (axis_len / if horiz { base * 6.0 } else { base * 1.9 }).floor().clamp(2.0, 12.0) as usize;
|
||
// A logarithmic axis is scaled in exponents of its base.
|
||
let log = c.log_axis_scale(max_intervals).map(|sc| (c.y_log_base.unwrap_or(10.0), sc));
|
||
let (min, max, step) = match log {
|
||
Some((_, sc)) => sc,
|
||
None => c.axis_scale(false, max_intervals).unwrap_or_else(|| axis_scale(0.0, 1.0, None, None, None, max_intervals, true)),
|
||
};
|
||
let to_e = |v: f64| match log {
|
||
Some((b, _)) => v.max(f64::MIN_POSITIVE).log(b).max(min - 1.0),
|
||
None => v,
|
||
};
|
||
// A combo chart's secondary value axis, on the right (vertical value axes only).
|
||
let second = if horiz { None } else { c.axis_scale(true, max_intervals) };
|
||
let ticks = |(lo, hi, st): (f64, f64, f64)| {
|
||
let n = (((hi - lo) / st.max(1e-12)).round() as i64).clamp(1, 50);
|
||
(0..=n).map(move |k| lo + k as f64 * st)
|
||
};
|
||
// Label gutters as wide as the longest value label.
|
||
let primary_ticks: Vec<f64> = ticks((min, max, step)).map(|k| log.map_or(k, |(b, _)| b.powf(k))).collect();
|
||
let label_w = |sec: bool, sc: (f64, f64, f64)| {
|
||
let vals: Vec<f64> = if sec { ticks(sc).collect() } else { primary_ticks.clone() };
|
||
vals.iter().map(|v| c.axis_label(sec, *v).chars().count()).max().unwrap_or(1) as f64 * base * 0.55 + base * 0.6
|
||
};
|
||
let gutter = if horiz {
|
||
base * 4.5
|
||
} else if c.y_hidden {
|
||
base * 0.5
|
||
} else {
|
||
clamp_soft(label_w(false, (min, max, step)), base * 1.5, area.width() * 0.3)
|
||
};
|
||
let gutter_r = second.filter(|_| !c.y2_hidden).map(|sc| clamp_soft(label_w(true, sc), base * 1.5, area.width() * 0.3)).unwrap_or(0.0);
|
||
let plot = if horiz {
|
||
Rect::new(area.x0 + gutter, area.y0, area.x1, area.y1 - base * 1.6)
|
||
} else {
|
||
Rect::new(area.x0 + gutter, area.y0, area.x1 - gutter_r, area.y1 - base * 1.8)
|
||
};
|
||
if let Some(f) = &c.plot_fill {
|
||
crate::paint::fill_path(ctx, rctx, f, None, &plot.to_path(0.1), plot, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres);
|
||
}
|
||
let span = (max - min).max(1e-9);
|
||
// Values past the axis limits (fixed ones, or zero on an axis that starts above it) stop at
|
||
// the plot's edge.
|
||
let to_v = |v: f64| {
|
||
let v = to_e(v).max(min).min(max);
|
||
if horiz { plot.x0 + (v - min) / span * plot.width() } else { plot.y1 - (v - min) / span * plot.height() }
|
||
};
|
||
// A series' value as a position on its own axis.
|
||
let to_axis = |sec: bool, v: f64| match (sec, second) {
|
||
(true, Some((min2, max2, _))) => plot.y1 - (v.max(min2).min(max2) - min2) / (max2 - min2).max(1e-9) * plot.height(),
|
||
_ => to_v(v),
|
||
};
|
||
// Minor gridlines, under the major ones: five to a major unit.
|
||
if c.minor_gridlines {
|
||
let minor = step / 5.0;
|
||
let n = (((max - min) / minor.max(1e-12)).round() as i64).clamp(1, 500);
|
||
let mut g = BezPath::new();
|
||
for k in (0..=n).filter(|k| k % 5 != 0) {
|
||
// On a logarithmic axis `min`, `max` and `step` are exponents of the base.
|
||
let e = min + k as f64 * minor;
|
||
let pos = to_v(log.map_or(e, |(b, _)| b.powf(e)));
|
||
if horiz {
|
||
g.move_to((pos, plot.y0));
|
||
g.line_to((pos, plot.y1));
|
||
} else {
|
||
g.move_to((plot.x0, pos));
|
||
g.line_to((plot.x1, pos));
|
||
}
|
||
}
|
||
ctx.set_transform(m);
|
||
ctx.set_paint(color(Rgba::rgb(0xF2, 0xF2, 0xF2), 1.0));
|
||
ctx.set_stroke(kurbo::Stroke::new(0.75));
|
||
ctx.stroke_path(&g);
|
||
}
|
||
// Gridlines and value labels.
|
||
for &v in &primary_ticks {
|
||
let pos = to_v(v);
|
||
let text = if c.y_hidden { String::new() } else { c.axis_label(false, v) };
|
||
let mut g = BezPath::new();
|
||
if horiz {
|
||
g.move_to((pos, plot.y0));
|
||
g.line_to((pos, plot.y1));
|
||
label(
|
||
ctx,
|
||
rctx,
|
||
m,
|
||
&text,
|
||
Rect::new(pos - 30.0, plot.y1 + 2.0, pos + 30.0, plot.y1 + base * 1.5),
|
||
base,
|
||
Align::Center,
|
||
Anchor::Top,
|
||
text_col,
|
||
false,
|
||
);
|
||
} else {
|
||
g.move_to((plot.x0, pos));
|
||
g.line_to((plot.x1, pos));
|
||
label(
|
||
ctx,
|
||
rctx,
|
||
m,
|
||
&text,
|
||
Rect::new(area.x0, pos - base, plot.x0 - base * 0.4, pos + base),
|
||
base,
|
||
Align::Right,
|
||
Anchor::Middle,
|
||
text_col,
|
||
false,
|
||
);
|
||
}
|
||
if c.gridlines {
|
||
ctx.set_transform(m);
|
||
ctx.set_paint(color(grid_col, 1.0));
|
||
ctx.set_stroke(kurbo::Stroke::new(0.75));
|
||
ctx.stroke_path(&g);
|
||
}
|
||
}
|
||
// The secondary axis' labels, on the right, without gridlines of their own.
|
||
if let Some(sc) = second.filter(|_| !c.y2_hidden) {
|
||
for v in ticks(sc) {
|
||
let pos = to_axis(true, v);
|
||
label(
|
||
ctx,
|
||
rctx,
|
||
m,
|
||
&c.axis_label(true, v),
|
||
Rect::new(plot.x1 + base * 0.4, pos - base, area.x1, pos + base),
|
||
base,
|
||
Align::Left,
|
||
Anchor::Middle,
|
||
text_col,
|
||
false,
|
||
);
|
||
}
|
||
}
|
||
let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1);
|
||
// Forecast reserves empty category slots so the line can run past the data. Charts with no
|
||
// forecast keep the old band.
|
||
let (back, fwd) = forecast_pad(c);
|
||
let nspan = ncat as f64 + back + fwd;
|
||
let band = if horiz { plot.height() / nspan } else { plot.width() / nspan };
|
||
// Where category `i`'s band starts along its axis: left to right, or bottom to top on a
|
||
// horizontal bar chart, unless the axis is reversed. The forecast's empty slots stay on
|
||
// their own side of the data.
|
||
let backwards = horiz != c.cat_reversed;
|
||
let slot = |i: usize| if backwards { ncat.saturating_sub(i + 1) as f64 + fwd } else { i as f64 + back };
|
||
// Scatter and bubble charts have a value x axis: one scale over every series' x values, with its
|
||
// own tick labels in place of category names.
|
||
let xscale = if horiz { None } else { value_x_scale(c, (plot.width() / (base * 2.5)).floor().clamp(2.0, 12.0) as usize) };
|
||
let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / (hi - lo).max(1e-9) * plot.width());
|
||
if let Some(sc) = xscale {
|
||
for v in ticks(sc) {
|
||
let pos = plot.x0 + (v - sc.0) / (sc.1 - sc.0).max(1e-9) * plot.width();
|
||
label(
|
||
ctx,
|
||
rctx,
|
||
m,
|
||
&fmt_num(v),
|
||
Rect::new(pos - 30.0, plot.y1 + 3.0, pos + 30.0, plot.y1 + base * 1.6),
|
||
base,
|
||
Align::Center,
|
||
Anchor::Top,
|
||
text_col,
|
||
false,
|
||
);
|
||
}
|
||
}
|
||
// Category labels: wrapped onto two lines when they don't fit their band (long date ranges).
|
||
let wraps = !horiz && c.categories.iter().any(|n| n.chars().count() as f64 * base * 0.5 > band);
|
||
for (i, name) in c.categories.iter().enumerate().filter(|_| xscale.is_none()) {
|
||
let mid = if horiz { plot.y0 + band * (slot(i) + 0.5) } else { plot.x0 + band * (slot(i) + 0.5) };
|
||
if horiz {
|
||
label(
|
||
ctx,
|
||
rctx,
|
||
m,
|
||
name,
|
||
Rect::new(area.x0, mid - base, plot.x0 - base * 0.4, mid + base),
|
||
base,
|
||
Align::Right,
|
||
Anchor::Middle,
|
||
text_col,
|
||
false,
|
||
);
|
||
} else {
|
||
let text = if wraps { split_label(name) } else { name.clone() };
|
||
label(
|
||
ctx,
|
||
rctx,
|
||
m,
|
||
&text,
|
||
Rect::new(mid - band / 2.0, plot.y1 + 3.0, mid + band / 2.0, plot.y1 + base * if wraps { 2.8 } else { 1.6 }),
|
||
base,
|
||
Align::Center,
|
||
Anchor::Top,
|
||
text_col,
|
||
false,
|
||
);
|
||
}
|
||
}
|
||
if let Some(b) = boxes {
|
||
crate::chartex::draw_boxes(ctx, rctx, c, b, m, plot, band, horiz, &to_v);
|
||
return;
|
||
}
|
||
let zero = match log {
|
||
Some((b, _)) => to_v(b.powf(min)),
|
||
None => to_v(clamp_soft(0.0, min, max)),
|
||
};
|
||
if let Some(bk) = bar_kind {
|
||
let bars: Vec<(usize, &deckcraft_model::chart::Series)> =
|
||
c.series.iter().enumerate().filter(|(_, s)| c.series_kind(s).is_bar_like()).collect();
|
||
let ns = bars.len().max(1);
|
||
// Gap width and overlap are fractions of one bar's width: a category's band holds its bars
|
||
// (overlapping by `overlap` of a bar between neighbours) plus one gap.
|
||
let gap = c.gap_width.clamp(0.0, 5.0);
|
||
let stacked = bk.stacked();
|
||
let ov = if stacked { 1.0 } else { c.overlap.clamp(-1.0, 1.0) };
|
||
let n = ns as f64;
|
||
let bar = band / (n - (n - 1.0) * ov + gap);
|
||
let step = bar * (1.0 - ov);
|
||
let group = bar + step * (n - 1.0);
|
||
for i in 0..ncat {
|
||
let mut pos_acc = 0.0;
|
||
let mut neg_acc = 0.0;
|
||
let total: f64 = if bk.percent() {
|
||
bars.iter().map(|(_, s)| s.values.get(i).copied().flatten().unwrap_or(0.0).abs()).sum::<f64>().max(1e-9)
|
||
} else {
|
||
1.0
|
||
};
|
||
for (bi, (si, s)) in bars.iter().enumerate() {
|
||
let sec = c.on_secondary(s);
|
||
let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total;
|
||
let start = band * slot(i) + (band - group) / 2.0 + if stacked { 0.0 } else { step * bi as f64 };
|
||
let (a, b) = if waterfall {
|
||
c.waterfall_bars().get(i).copied().unwrap_or_default()
|
||
} else if stacked {
|
||
if v >= 0.0 {
|
||
let r = (pos_acc, pos_acc + v);
|
||
pos_acc += v;
|
||
r
|
||
} else {
|
||
let r = (neg_acc + v, neg_acc);
|
||
neg_acc += v;
|
||
r
|
||
}
|
||
} else {
|
||
(0.0f64.min(v), 0.0f64.max(v))
|
||
};
|
||
let r = if horiz {
|
||
Rect::new(to_v(a), plot.y0 + start, to_v(b), plot.y0 + start + bar)
|
||
} else {
|
||
Rect::new(plot.x0 + start, to_axis(sec, b), plot.x0 + start + bar, to_axis(sec, a))
|
||
}
|
||
.abs();
|
||
// A waterfall colours increases, decreases and totals, and joins each bar's end
|
||
// to the next bar's start.
|
||
let fill = if waterfall {
|
||
let slot = if c.subtotals.contains(&i) {
|
||
2
|
||
} else if b < a {
|
||
1
|
||
} else {
|
||
0
|
||
};
|
||
series_color(rctx, c, slot)
|
||
} else {
|
||
bar_fill(rctx, c, *si, i, s)
|
||
};
|
||
ctx.set_transform(m);
|
||
ctx.set_paint(color(fill, 1.0));
|
||
ctx.fill_rect(&r.abs());
|
||
if waterfall && i + 1 < ncat && !horiz {
|
||
let y = to_v(b);
|
||
let mut j = BezPath::new();
|
||
j.move_to((r.x1, y));
|
||
j.line_to((r.x1 + (band - bar), y));
|
||
ctx.set_paint(color(Rgba::rgb(0xA6, 0xA6, 0xA6), 1.0));
|
||
ctx.set_stroke(kurbo::Stroke::new(0.75));
|
||
ctx.stroke_path(&j);
|
||
}
|
||
if c.data_labels {
|
||
let t = c.data_label(s, s.values.get(i).copied().flatten().unwrap_or(0.0));
|
||
let lr = if horiz {
|
||
Rect::new(r.x1 + 2.0, r.y0, r.x1 + 40.0, r.y1)
|
||
} else {
|
||
Rect::new(r.x0 - 10.0, r.y0 - base * 1.4, r.x1 + 10.0, r.y0)
|
||
};
|
||
label(
|
||
ctx,
|
||
rctx,
|
||
m,
|
||
&t,
|
||
lr,
|
||
base * 0.9,
|
||
if horiz { Align::Left } else { Align::Center },
|
||
if horiz { Anchor::Middle } else { Anchor::Bottom },
|
||
text_col,
|
||
false,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// A stock chart's high-low lines and up/down bars (or close dashes) stand in for its series.
|
||
if c.kind == ChartType::Stock {
|
||
for i in 0..ncat {
|
||
crate::radar::stock_marks(ctx, rctx, c, m, plot.x0 + band * (i as f64 + 0.5), band, i, &|v| to_axis(false, v));
|
||
}
|
||
}
|
||
// Lines, areas, scatter: the series that aren't bars.
|
||
let mut acc = vec![0.0; ncat];
|
||
for (si, s) in c.series.iter().enumerate() {
|
||
let kind = c.series_kind(s);
|
||
if kind.is_bar_like() || kind == ChartType::Stock {
|
||
continue;
|
||
}
|
||
let sec = c.on_secondary(s);
|
||
let stacked = kind.stacked();
|
||
let area_kind = matches!(kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar);
|
||
let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) };
|
||
let mut pts = vec![];
|
||
for i in 0..ncat {
|
||
let Some(v) = s.values.get(i).copied().flatten() else { continue };
|
||
let v = if stacked {
|
||
let base_v = acc.get(i).copied().unwrap_or(0.0);
|
||
if let Some(a) = acc.get_mut(i) {
|
||
*a += v;
|
||
}
|
||
base_v + v
|
||
} else {
|
||
v
|
||
};
|
||
let x = match kind {
|
||
ChartType::Scatter | ChartType::Bubble => {
|
||
match (s.x_values.get(i).copied().flatten().and_then(to_x), s.x_values.iter().flatten().next()) {
|
||
(Some(x), _) => x,
|
||
// No x values at all: the points are numbered 1, 2, 3...
|
||
(None, None) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)),
|
||
(None, Some(_)) => continue,
|
||
}
|
||
}
|
||
_ => plot.x0 + band * (slot(i) + 0.5),
|
||
};
|
||
pts.push(Point::new(x, to_axis(sec, v)));
|
||
}
|
||
if pts.is_empty() {
|
||
continue;
|
||
}
|
||
let p = if s.smooth && !area_kind && pts.len() > 2 { smooth_path(&pts) } else { polyline(&pts) };
|
||
ctx.set_transform(m);
|
||
if area_kind {
|
||
let mut a = p.clone();
|
||
if let (Some(l), Some(f)) = (pts.last(), pts.first()) {
|
||
a.line_to((l.x, zero));
|
||
a.line_to((f.x, zero));
|
||
a.close_path();
|
||
}
|
||
ctx.set_paint(color(col, 0.85));
|
||
ctx.fill_path(&a);
|
||
} else if kind != ChartType::Scatter {
|
||
let lw = s.line.as_ref().and_then(|l| l.width).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(2.25).clamp(0.25, 20.0);
|
||
ctx.set_paint(color(col, 1.0));
|
||
ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round));
|
||
ctx.stroke_path(&p);
|
||
}
|
||
if matches!(kind, ChartType::LineMarkers | ChartType::Scatter | ChartType::Bubble) {
|
||
ctx.set_paint(color(col, 1.0));
|
||
for q in &pts {
|
||
ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1));
|
||
}
|
||
}
|
||
}
|
||
// A category position (0-based, fractional) as a slot, the way `slot` places the bars.
|
||
let slot_at = |x: f64| if backwards { ncat as f64 - 1.0 - x + fwd } else { x + back };
|
||
draw_trendlines(ctx, rctx, c, m, plot, band, &slot_at, xscale, ncat, horiz, base, text_col, &to_axis);
|
||
// Axis line.
|
||
let mut ax = BezPath::new();
|
||
if horiz {
|
||
ax.move_to((zero, plot.y0));
|
||
ax.line_to((zero, plot.y1));
|
||
} else {
|
||
ax.move_to((plot.x0, zero));
|
||
ax.line_to((plot.x1, zero));
|
||
}
|
||
ctx.set_transform(m);
|
||
ctx.set_paint(color(Rgba::rgb(0xBF, 0xBF, 0xBF), 1.0));
|
||
ctx.set_stroke(kurbo::Stroke::new(0.75));
|
||
ctx.stroke_path(&ax);
|
||
}
|
||
|
||
/// `v` kept within `lo..=hi` without `f64::clamp`'s panic when `lo > hi` (a chart smaller than
|
||
/// its label font) or a bound is NaN: the upper bound wins.
|
||
pub(crate) fn clamp_soft(v: f64, lo: f64, hi: f64) -> f64 {
|
||
let v = if v < lo { lo } else { v };
|
||
if v > hi { hi } else { v }
|
||
}
|
||
|
||
/// A corner of `plot` whose box stays clear of `pts` (the drawn trendline).
|
||
fn caption_at(plot: Rect, box_w: f64, box_h: f64, pts: &[Point], n: usize) -> (f64, f64) {
|
||
let pad = 4.0;
|
||
let shift = n as f64 * (box_h + 2.0);
|
||
let x_right = (plot.x1 - box_w - pad).max(plot.x0 + pad);
|
||
let y_bottom = (plot.y1 - box_h - pad - shift).max(plot.y0 + pad);
|
||
let y_top = (plot.y0 + pad + shift).min(y_bottom);
|
||
let corners = [(plot.x0 + pad, y_top), (x_right, y_top), (plot.x0 + pad, y_bottom), (x_right, y_bottom)];
|
||
let mut best = corners[0];
|
||
let mut best_d = -1.0;
|
||
for (x, y) in corners {
|
||
let mut min_d = f64::MAX;
|
||
for p in pts {
|
||
let dx = if p.x < x {
|
||
x - p.x
|
||
} else if p.x > x + box_w {
|
||
p.x - (x + box_w)
|
||
} else {
|
||
0.0
|
||
};
|
||
let dy = if p.y < y {
|
||
y - p.y
|
||
} else if p.y > y + box_h {
|
||
p.y - (y + box_h)
|
||
} else {
|
||
0.0
|
||
};
|
||
min_d = min_d.min((dx * dx + dy * dy).sqrt());
|
||
}
|
||
if pts.is_empty() {
|
||
min_d = 1.0;
|
||
}
|
||
if min_d > best_d {
|
||
best_d = min_d;
|
||
best = (x, y);
|
||
}
|
||
}
|
||
best
|
||
}
|
||
|
||
fn pad_units(v: Option<f64>) -> f64 {
|
||
v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(3.0)).unwrap_or(0.0)
|
||
}
|
||
|
||
/// Empty category slots reserved for a trendline forecast, capped so the data still fills the plot.
|
||
fn forecast_pad(c: &Chart) -> (f64, f64) {
|
||
if matches!(c.kind, ChartType::Scatter | ChartType::Bubble) {
|
||
return (0.0, 0.0);
|
||
}
|
||
let mut back = 0.0_f64;
|
||
let mut fwd = 0.0_f64;
|
||
for s in &c.series {
|
||
if matches!(c.series_kind(s), ChartType::Scatter | ChartType::Bubble) {
|
||
continue;
|
||
}
|
||
for t in s.trendlines.iter().take(8) {
|
||
if t.kind == TrendlineType::MovingAvg {
|
||
continue;
|
||
}
|
||
back = back.max(pad_units(t.backward));
|
||
fwd = fwd.max(pad_units(t.forward));
|
||
}
|
||
}
|
||
(back, fwd)
|
||
}
|
||
|
||
const TREND_INK: [Rgba; 4] = [Rgba::rgb(0x22, 0x22, 0x22), Rgba::rgb(0xB0, 0x30, 0x30), Rgba::rgb(0x1A, 0x5C, 0x7A), Rgba::rgb(0xA0, 0x68, 0x00)];
|
||
|
||
fn map_xy(
|
||
plot: Rect,
|
||
band: f64,
|
||
slot: &impl Fn(f64) -> f64,
|
||
horiz: bool,
|
||
scatter: bool,
|
||
sx0: f64,
|
||
sx1: f64,
|
||
sec: bool,
|
||
x: f64,
|
||
y: f64,
|
||
to_axis: &impl Fn(bool, f64) -> f64,
|
||
) -> Option<Point> {
|
||
if !x.is_finite() || !y.is_finite() {
|
||
return None;
|
||
}
|
||
let value = to_axis(sec, y);
|
||
if !value.is_finite() {
|
||
return None;
|
||
}
|
||
let along = if scatter && sx1 > sx0 {
|
||
plot.x0 + (x - sx0) / (sx1 - sx0) * plot.width()
|
||
} else {
|
||
let origin = if horiz { plot.y0 } else { plot.x0 };
|
||
origin + band * (slot(x - 1.0) + 0.5)
|
||
};
|
||
if !along.is_finite() {
|
||
return None;
|
||
}
|
||
Some(if horiz && !scatter { Point::new(value, along) } else { Point::new(along, value) })
|
||
}
|
||
|
||
/// The part of segment `a`–`b` that lies inside `r`.
|
||
fn clip_seg(a: Point, b: Point, r: Rect) -> Option<(Point, Point)> {
|
||
let (mut t0, mut t1) = (0.0, 1.0);
|
||
let dx = b.x - a.x;
|
||
let dy = b.y - a.y;
|
||
let p = [-dx, dx, -dy, dy];
|
||
let q = [a.x - r.x0, r.x1 - a.x, a.y - r.y0, r.y1 - a.y];
|
||
for i in 0..4 {
|
||
let (pi, qi) = (p[i], q[i]);
|
||
if pi.abs() < 1e-12 {
|
||
if qi < 0.0 {
|
||
return None;
|
||
}
|
||
} else {
|
||
let t = qi / pi;
|
||
if pi < 0.0 {
|
||
if t > t1 {
|
||
return None;
|
||
} else if t > t0 {
|
||
t0 = t;
|
||
}
|
||
} else if t < t0 {
|
||
return None;
|
||
} else if t < t1 {
|
||
t1 = t;
|
||
}
|
||
}
|
||
}
|
||
Some((Point::new(a.x + t0 * dx, a.y + t0 * dy), Point::new(a.x + t1 * dx, a.y + t1 * dy)))
|
||
}
|
||
|
||
fn draw_trendlines(
|
||
ctx: &mut RenderContext,
|
||
rctx: &Ctx,
|
||
c: &Chart,
|
||
m: Affine,
|
||
plot: Rect,
|
||
band: f64,
|
||
slot: &impl Fn(f64) -> f64,
|
||
xscale: Option<(f64, f64, f64)>,
|
||
ncat: usize,
|
||
horiz: bool,
|
||
base: f64,
|
||
text_col: Rgba,
|
||
to_axis: &impl Fn(bool, f64) -> f64,
|
||
) {
|
||
let mut label_n = 0usize;
|
||
for s in &c.series {
|
||
if s.trendlines.is_empty() {
|
||
continue;
|
||
}
|
||
let kind = c.series_kind(s);
|
||
let scatter = matches!(kind, ChartType::Scatter | ChartType::Bubble);
|
||
let sec = c.on_secondary(s);
|
||
let mut xs = Vec::new();
|
||
let mut ys = Vec::new();
|
||
for i in 0..ncat {
|
||
let Some(y) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue };
|
||
let x = if scatter {
|
||
let Some(x) = s.x_values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue };
|
||
x
|
||
} else {
|
||
(i + 1) as f64
|
||
};
|
||
xs.push(x);
|
||
ys.push(y);
|
||
}
|
||
// The same x extent the scatter marks use, so the fit sits on the points: the chart's
|
||
// value x axis where it has one, else the series' own x values.
|
||
let (sx0, sx1) = if let (true, Some((lo, hi, _))) = (scatter, xscale) {
|
||
(lo, hi)
|
||
} else if scatter {
|
||
let mut lo = f64::MAX;
|
||
let mut hi = f64::MIN;
|
||
for v in s.x_values.iter().copied().flatten().filter(|v| v.is_finite()) {
|
||
lo = lo.min(v);
|
||
hi = hi.max(v);
|
||
}
|
||
(lo, hi)
|
||
} else {
|
||
(0.0, 0.0)
|
||
};
|
||
for (ti, t) in s.trendlines.iter().take(8).enumerate() {
|
||
let Some(fit) = t.fit(&xs, &ys) else { continue };
|
||
let (x0, x1) = fit.span(t.forward, t.backward);
|
||
let samples = fit.samples(x0, x1);
|
||
let mut path = BezPath::new();
|
||
let mut prev: Option<Point> = None;
|
||
let mut ink_pts = Vec::new();
|
||
let mut any = false;
|
||
for w in samples.windows(2) {
|
||
let (Some(a), Some(b)) = (
|
||
map_xy(plot, band, slot, horiz, scatter, sx0, sx1, sec, w[0].0, w[0].1, to_axis),
|
||
map_xy(plot, band, slot, horiz, scatter, sx0, sx1, sec, w[1].0, w[1].1, to_axis),
|
||
) else {
|
||
prev = None;
|
||
continue;
|
||
};
|
||
let Some((ca, cb)) = clip_seg(a, b, plot) else {
|
||
prev = None;
|
||
continue;
|
||
};
|
||
let cont = prev.is_some_and(|p| (p.x - ca.x).abs() < 0.6 && (p.y - ca.y).abs() < 0.6);
|
||
if cont {
|
||
path.line_to(cb);
|
||
} else {
|
||
path.move_to(ca);
|
||
path.line_to(cb);
|
||
}
|
||
prev = Some(cb);
|
||
if ink_pts.len() < 64 {
|
||
ink_pts.push(ca);
|
||
ink_pts.push(cb);
|
||
}
|
||
any = true;
|
||
}
|
||
if !any {
|
||
continue;
|
||
}
|
||
ctx.set_transform(m);
|
||
let ink = TREND_INK.get(ti % TREND_INK.len()).copied().unwrap_or(Rgba::rgb(0x22, 0x22, 0x22));
|
||
ctx.set_paint(color(ink, 1.0));
|
||
ctx.set_stroke(kurbo::Stroke::new(1.6).with_dashes(0.0, [6.0, 3.5]).with_caps(kurbo::Cap::Round));
|
||
ctx.stroke_path(&path);
|
||
let caption = t.caption(&fit);
|
||
if caption.is_empty() {
|
||
continue;
|
||
}
|
||
let lines = caption.chars().filter(|ch| *ch == '\n').count() + 1;
|
||
let box_h = base * 1.3 * lines as f64 + 2.0;
|
||
let widest = caption.lines().map(|l| l.chars().count()).max().unwrap_or(1) as f64;
|
||
let box_w = clamp_soft(widest * base * 0.62, 48.0, plot.width() * 0.72);
|
||
let (x, y) = caption_at(plot, box_w, box_h, &ink_pts, label_n);
|
||
ctx.set_transform(m);
|
||
ctx.set_paint(color(Rgba::WHITE, 1.0));
|
||
ctx.fill_rect(&Rect::new(x, y, x + box_w, y + box_h));
|
||
label(
|
||
ctx,
|
||
rctx,
|
||
m,
|
||
&caption,
|
||
Rect::new(x, y, x + box_w, y + box_h),
|
||
(base * 0.9).clamp(8.0, 13.0),
|
||
Align::Left,
|
||
Anchor::Top,
|
||
text_col,
|
||
false,
|
||
);
|
||
label_n += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
const AREA: Rect = Rect::new(0.0, 0.0, 400.0, 300.0);
|
||
|
||
#[test]
|
||
fn scatter_x_is_a_value_scale_over_all_series() {
|
||
let mut c = Chart::sample(ChartType::Scatter);
|
||
c.series[0].x_values = vec![Some(0.5), Some(7.2), None];
|
||
assert_eq!(value_x_scale(&c, 8), Some((0.0, 8.0, 1.0)));
|
||
assert_eq!(value_x_scale(&Chart::sample(ChartType::Column), 8), None);
|
||
}
|
||
|
||
#[test]
|
||
fn right_legend_is_a_centred_column_and_narrows_the_plot() {
|
||
let (o, plot) = legend_layout("r", (60.0, 100.0), AREA, 10.0, false);
|
||
assert_eq!((o.x, o.y), (340.0, 100.0));
|
||
assert_eq!((plot.x0, plot.x1, plot.y0, plot.y1), (0.0, 335.0, 0.0, 300.0));
|
||
}
|
||
|
||
#[test]
|
||
fn left_top_and_corner_positions() {
|
||
let (o, plot) = legend_layout("l", (60.0, 100.0), AREA, 10.0, false);
|
||
assert_eq!((o.x, o.y, plot.x0, plot.x1), (0.0, 100.0, 65.0, 400.0));
|
||
let (o, plot) = legend_layout("t", (100.0, 14.0), AREA, 10.0, false);
|
||
assert_eq!((o.x, o.y, plot.y0, plot.y1), (150.0, 0.0, 19.0, 300.0));
|
||
let (o, plot) = legend_layout("tr", (60.0, 100.0), AREA, 10.0, false);
|
||
assert_eq!((o.x, o.y, plot.x1), (340.0, 0.0, 335.0));
|
||
}
|
||
|
||
#[test]
|
||
fn bottom_legend_keeps_the_old_row_layout() {
|
||
let (o, plot) = legend_layout("b", (100.0, 14.0), AREA, 10.0, false);
|
||
assert_eq!((o.x, o.y, plot.y1, plot.x1), (150.0, 286.0, 281.0, 400.0));
|
||
}
|
||
|
||
#[test]
|
||
fn overlay_does_not_shrink_the_plot() {
|
||
let (o, plot) = legend_layout("r", (60.0, 100.0), AREA, 10.0, true);
|
||
assert_eq!(plot, AREA);
|
||
assert_eq!(o.x, 340.0);
|
||
}
|
||
|
||
#[test]
|
||
fn clip_keeps_the_part_inside_the_plot() {
|
||
let r = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||
let (a, b) = clip_seg(Point::new(-50.0, 25.0), Point::new(150.0, 25.0), r).expect("clipped");
|
||
assert!((a.x - 0.0).abs() < 1e-6 && (b.x - 100.0).abs() < 1e-6);
|
||
assert!((a.y - 25.0).abs() < 1e-6);
|
||
assert!(clip_seg(Point::new(-10.0, -10.0), Point::new(-1.0, -1.0), r).is_none());
|
||
let (a, b) = clip_seg(Point::new(10.0, 10.0), Point::new(40.0, 20.0), r).expect("inside");
|
||
assert!((a.x - 10.0).abs() < 1e-6 && (b.x - 40.0).abs() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn caption_keeps_clear_of_a_line_into_the_top_right() {
|
||
let plot = Rect::new(0.0, 0.0, 200.0, 100.0);
|
||
let pts = [Point::new(20.0, 80.0), Point::new(180.0, 15.0)];
|
||
let (x, y) = caption_at(plot, 60.0, 20.0, &pts, 0);
|
||
assert!(x > 100.0 && y > 40.0, "label on the line at {x} {y}");
|
||
}
|
||
|
||
#[test]
|
||
fn forecast_reserves_at_most_three_category_slots() {
|
||
use deckcraft_model::chart::Trendline;
|
||
let mut c = Chart::sample(ChartType::Line);
|
||
c.series[0].trendlines.push(Trendline { forward: Some(10.0), backward: Some(0.5), ..Default::default() });
|
||
assert_eq!(forecast_pad(&c), (0.5, 3.0));
|
||
c.series[0].trendlines[0].kind = TrendlineType::MovingAvg;
|
||
assert_eq!(forecast_pad(&c), (0.0, 0.0));
|
||
c.kind = ChartType::Scatter;
|
||
c.series[0].trendlines[0].kind = TrendlineType::Linear;
|
||
assert_eq!(forecast_pad(&c), (0.0, 0.0));
|
||
}
|
||
}
|