Charts: draw combo charts with columns, lines and a secondary axis
A combo chart (columns plus lines, often with the lines on a second value axis) was read with each series' type but drawn as lines only, on one scale, so a spend-and-clicks chart showed the clicks as a flat line along the bottom and no columns. - Reading: a plot group whose value axis isn't the first group's is on the secondary axis (Series::secondary_axis, now set). - Model: Chart::series_kind, on_secondary and axis_range (per axis; a combo stacks only its stacked series). - Rendering: bar-like series are drawn as bars (clustered or stacked among themselves), the others as lines/areas over them, each against its own axis; the secondary axis' labels go on the right. Line series use their line's colour and width (where PowerPoint keeps them), also in the legend. Long category labels wrap onto two lines.
This commit is contained in:
parent
191323be3b
commit
c4d1d7e1c8
|
|
@ -213,6 +213,58 @@ impl Chart {
|
|||
c.vary_colors = n_series == 1;
|
||||
c
|
||||
}
|
||||
/// A series' own chart type: its own in a combo chart (columns when it names none), else the
|
||||
/// chart's.
|
||||
pub fn series_kind(&self, s: &Series) -> ChartType {
|
||||
match (self.kind, s.kind) {
|
||||
(ChartType::Combo, Some(k)) if k != ChartType::Combo => k,
|
||||
(ChartType::Combo, _) => ChartType::Column,
|
||||
(k, _) => k,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a series is plotted against the secondary value axis (combo charts only).
|
||||
pub fn on_secondary(&self, s: &Series) -> bool {
|
||||
self.kind == ChartType::Combo && s.secondary_axis
|
||||
}
|
||||
|
||||
/// The value range of one value axis: the primary axis (`y_min`/`y_max` apply), or a combo
|
||||
/// chart's secondary axis. A combo chart stacks only its stacked series. `None` when no series
|
||||
/// uses that axis.
|
||||
pub fn axis_range(&self, secondary: bool) -> Option<(f64, f64)> {
|
||||
if self.kind != ChartType::Combo {
|
||||
return (!secondary).then(|| self.value_range());
|
||||
}
|
||||
let ser: Vec<&Series> = self.series.iter().filter(|s| self.on_secondary(s) == secondary).collect();
|
||||
if ser.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (mut lo, mut hi) = (0.0_f64, 0.0_f64);
|
||||
let n = ser.iter().map(|s| s.values.len()).max().unwrap_or(0);
|
||||
let mut percent = false;
|
||||
for i in 0..n {
|
||||
let (mut p, mut m) = (0.0, 0.0);
|
||||
for s in &ser {
|
||||
let k = self.series_kind(s);
|
||||
let Some(v) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue };
|
||||
if k.percent() {
|
||||
percent = true;
|
||||
} else if k.stacked() {
|
||||
if v >= 0.0 { p += v } else { m += v }
|
||||
} else {
|
||||
hi = hi.max(v);
|
||||
lo = lo.min(v);
|
||||
}
|
||||
}
|
||||
hi = hi.max(p);
|
||||
lo = lo.min(m);
|
||||
}
|
||||
if percent {
|
||||
hi = hi.max(1.0);
|
||||
}
|
||||
Some(if secondary { (lo, hi) } else { (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) })
|
||||
}
|
||||
|
||||
/// Value range across series (stacked sums for stacked types).
|
||||
pub fn value_range(&self) -> (f64, f64) {
|
||||
let mut lo: f64 = 0.0;
|
||||
|
|
@ -283,6 +335,28 @@ mod tests {
|
|||
assert!(b > a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combo_axis_ranges() {
|
||||
// Spend in columns on the primary axis, clicks as a line on the secondary one.
|
||||
let mut c = Chart::sample(ChartType::Combo);
|
||||
c.series.truncate(2);
|
||||
c.series[0].kind = Some(ChartType::Column);
|
||||
c.series[0].values = vec![Some(200_000.0), Some(310_000.0)];
|
||||
c.series[1].kind = Some(ChartType::Line);
|
||||
c.series[1].values = vec![Some(6_000.0), Some(7_400.0)];
|
||||
c.series[1].secondary_axis = true;
|
||||
assert_eq!(c.axis_range(false), Some((0.0, 310_000.0)));
|
||||
assert_eq!(c.axis_range(true), Some((0.0, 7_400.0)));
|
||||
assert_eq!(c.series_kind(&c.series[1]), ChartType::Line);
|
||||
// Without a secondary series there is no secondary axis; a non-combo chart never has one.
|
||||
c.series[1].secondary_axis = false;
|
||||
assert_eq!(c.axis_range(true), None);
|
||||
let col = Chart::sample(ChartType::Column);
|
||||
assert_eq!(col.axis_range(false), Some(col.value_range()));
|
||||
assert_eq!(col.axis_range(true), None);
|
||||
assert!(!col.on_secondary(&col.series[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_ranges() {
|
||||
let c = Chart::sample(ChartType::StackedColumn);
|
||||
|
|
|
|||
|
|
@ -102,6 +102,11 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
|
|||
c.plot_fill = simple_fill(pa.child("spPr"));
|
||||
|
||||
let mut kinds: Vec<ChartType> = vec![];
|
||||
// The value axis the first plot group uses is the primary one; a group on another value axis
|
||||
// (a combo chart's line on the right) is on the secondary axis.
|
||||
let val_ids: Vec<&str> = pa.children_named("valAx").filter_map(|a| a.child_val("axId")).collect();
|
||||
let group_val_axis = |g: &El| g.children_named("axId").filter_map(|a| a.attr("val")).find(|id| val_ids.contains(id)).map(str::to_string);
|
||||
let mut primary_val: Option<String> = None;
|
||||
for g in pa.elements() {
|
||||
let name = g.local();
|
||||
if !name.ends_with("Chart") {
|
||||
|
|
@ -167,6 +172,11 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
|
|||
c.data_labels = true;
|
||||
}
|
||||
kinds.push(kind);
|
||||
let val_axis = group_val_axis(g);
|
||||
if primary_val.is_none() {
|
||||
primary_val = val_axis.clone();
|
||||
}
|
||||
let secondary = val_axis.is_some() && val_axis != primary_val;
|
||||
for s in g.children_named("ser").take(1000) {
|
||||
let mut ser = Series {
|
||||
name: s.child("tx").map(strings).and_then(|v| v.into_iter().next()).unwrap_or_default(),
|
||||
|
|
@ -175,6 +185,7 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
|
|||
smooth: s.child("smooth").and_then(|v| v.bool("val")).unwrap_or(false),
|
||||
marker: s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")).filter(|m| *m != "none").map(String::from),
|
||||
kind: Some(kind),
|
||||
secondary_axis: secondary,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).and_then(|v| v.bool("val")) {
|
||||
|
|
@ -208,6 +219,7 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
|
|||
if c.kind != ChartType::Combo {
|
||||
for s in &mut c.series {
|
||||
s.kind = None;
|
||||
s.secondary_axis = false;
|
||||
}
|
||||
}
|
||||
// Axes.
|
||||
|
|
@ -243,4 +255,27 @@ mod tests {
|
|||
assert_eq!(c.legend.as_deref(), Some("r"));
|
||||
assert!(!c.legend_overlay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combo_chart_with_a_secondary_axis() {
|
||||
// Columns on axes 1/2, a line on axes 3/4 whose value axis is on the right.
|
||||
let xml = br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:chart><c:plotArea>
|
||||
<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:ser><c:idx val="0"/><c:tx><c:v>Spend</c:v></c:tx><c:cat><c:strLit><c:ptCount val="2"/><c:pt idx="0"><c:v>W1</c:v></c:pt><c:pt idx="1"><c:v>W2</c:v></c:pt></c:strLit></c:cat><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>200000</c:v></c:pt><c:pt idx="1"><c:v>310000</c:v></c:pt></c:numLit></c:val></c:ser><c:axId val="1"/><c:axId val="2"/></c:barChart>
|
||||
<c:lineChart><c:grouping val="standard"/><c:ser><c:idx val="1"/><c:tx><c:v>Clicks</c:v></c:tx><c:spPr><a:ln w="28575"><a:solidFill><a:srgbClr val="00B050"/></a:solidFill></a:ln></c:spPr><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>6000</c:v></c:pt><c:pt idx="1"><c:v>7400</c:v></c:pt></c:numLit></c:val></c:ser><c:marker val="1"/><c:axId val="3"/><c:axId val="4"/></c:lineChart>
|
||||
<c:catAx><c:axId val="1"/><c:axPos val="b"/><c:crossAx val="2"/></c:catAx><c:valAx><c:axId val="2"/><c:axPos val="l"/><c:crossAx val="1"/></c:valAx>
|
||||
<c:valAx><c:axId val="4"/><c:axPos val="r"/><c:crossAx val="3"/></c:valAx><c:catAx><c:axId val="3"/><c:delete val="1"/><c:axPos val="b"/><c:crossAx val="4"/></c:catAx>
|
||||
</c:plotArea></c:chart></c:chartSpace>"#;
|
||||
let c = read_chart_xml(xml).unwrap();
|
||||
assert_eq!(c.kind, ChartType::Combo);
|
||||
assert_eq!(c.series.len(), 2);
|
||||
assert!(!c.series[0].secondary_axis);
|
||||
assert!(c.series[1].secondary_axis);
|
||||
assert_eq!(c.series[1].kind, Some(ChartType::LineMarkers));
|
||||
assert!(c.series[1].line.as_ref().is_some_and(|l| l.width.is_some_and(|w| (w - 2.25).abs() < 0.01)));
|
||||
// A chart of one type on one axis has no secondary series.
|
||||
let single = String::from_utf8_lossy(xml)
|
||||
.replace("<c:axId val=\"3\"/><c:axId val=\"4\"/></c:lineChart>", "<c:axId val=\"1\"/><c:axId val=\"2\"/></c:lineChart>");
|
||||
let c = read_chart_xml(single.as_bytes()).unwrap();
|
||||
assert!(c.series.iter().all(|s| !s.secondary_axis));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,29 @@ fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart
|
|||
}
|
||||
}
|
||||
|
||||
/// A line series' colour: its line's own (where PowerPoint keeps it), else its fill's, else the
|
||||
/// palette's.
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -127,7 +150,15 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
|||
let legend_items: Vec<(String, Rgba)> = 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)| (s.name.clone(), series_fill(rctx, c, i, Some(s)))).collect()
|
||||
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");
|
||||
|
|
@ -208,21 +239,31 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
|||
}
|
||||
return;
|
||||
}
|
||||
let (lo, hi) = c.value_range();
|
||||
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 (lo, hi) = c.axis_range(false).unwrap_or_else(|| c.value_range());
|
||||
let (min, max, step) = nice_scale(lo.min(0.0), hi);
|
||||
let horiz = c.kind.horizontal();
|
||||
let horiz = if combo { bar_kind.is_some_and(ChartType::horizontal) } else { c.kind.horizontal() };
|
||||
// A combo chart's secondary value axis, on the right (vertical value axes only).
|
||||
let second = if horiz { None } else { c.axis_range(true).map(|(l, h)| nice_scale(l.min(0.0), h)) };
|
||||
// Axis label gutter.
|
||||
let gutter = base * 3.0;
|
||||
let plot = if horiz {
|
||||
Rect::new(area.x0 + gutter * 1.5, area.y0, area.x1, area.y1 - base * 1.6)
|
||||
} else {
|
||||
Rect::new(area.x0 + gutter, area.y0, area.x1, area.y1 - base * 1.8)
|
||||
Rect::new(area.x0 + gutter, area.y0, area.x1 - if second.is_some() { gutter } else { 0.0 }, 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);
|
||||
let to_v = |v: f64| 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 - min2) / (max2 - min2).max(1e-9) * plot.height(),
|
||||
_ => to_v(v),
|
||||
};
|
||||
// Gridlines and value labels.
|
||||
let n_steps = ((span / step).round() as i64).clamp(1, 50);
|
||||
for k in 0..=n_steps {
|
||||
|
|
@ -267,9 +308,30 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
|||
ctx.stroke_path(&g);
|
||||
}
|
||||
}
|
||||
// The secondary axis' labels, on the right, without gridlines of their own.
|
||||
if let Some((min2, max2, step2)) = second {
|
||||
let n2 = (((max2 - min2) / step2.max(1e-9)).round() as i64).clamp(1, 50);
|
||||
for k in 0..=n2 {
|
||||
let v = min2 + k as f64 * step2;
|
||||
let pos = to_axis(true, v);
|
||||
label(
|
||||
ctx,
|
||||
rctx,
|
||||
m,
|
||||
&fmt_num(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);
|
||||
let band = if horiz { plot.height() / ncat as f64 } else { plot.width() / ncat as f64 };
|
||||
// Category labels.
|
||||
// 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() {
|
||||
let mid = if horiz { plot.y0 + band * (i as f64 + 0.5) } else { plot.x0 + band * (i as f64 + 0.5) };
|
||||
if horiz {
|
||||
|
|
@ -286,12 +348,13 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
|||
false,
|
||||
);
|
||||
} else {
|
||||
let text = if wraps { split_label(name) } else { name.clone() };
|
||||
label(
|
||||
ctx,
|
||||
rctx,
|
||||
m,
|
||||
name,
|
||||
Rect::new(mid - band / 2.0, plot.y1 + 3.0, mid + band / 2.0, plot.y1 + base * 1.6),
|
||||
&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,
|
||||
|
|
@ -300,24 +363,27 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
|||
);
|
||||
}
|
||||
}
|
||||
let ns = c.series.len().max(1);
|
||||
let zero = to_v(0.0f64.clamp(min, max));
|
||||
if c.kind.is_bar_like() {
|
||||
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);
|
||||
let gap = c.gap_width.clamp(0.0, 5.0);
|
||||
let group = band / (1.0 + gap);
|
||||
let stacked = c.kind.stacked();
|
||||
let stacked = bk.stacked();
|
||||
let bar = if stacked { group } else { group / ns as f64 };
|
||||
for i in 0..ncat {
|
||||
let mut pos_acc = 0.0;
|
||||
let mut neg_acc = 0.0;
|
||||
let total: f64 = if c.kind.percent() {
|
||||
c.series.iter().map(|s| s.values.get(i).copied().flatten().unwrap_or(0.0).abs()).sum::<f64>().max(1e-9)
|
||||
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 (si, s) in c.series.iter().enumerate() {
|
||||
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 * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { bar * si as f64 };
|
||||
let start = band * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { bar * bi as f64 };
|
||||
let (a, b) = if stacked {
|
||||
if v >= 0.0 {
|
||||
let r = (pos_acc, pos_acc + v);
|
||||
|
|
@ -334,10 +400,10 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
|||
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_v(b), plot.x0 + start + bar, to_v(a))
|
||||
Rect::new(plot.x0 + start, to_axis(sec, b), plot.x0 + start + bar, to_axis(sec, a))
|
||||
};
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(series_fill(rctx, c, si, Some(s)), 1.0));
|
||||
ctx.set_paint(color(series_fill(rctx, c, *si, Some(s)), 1.0));
|
||||
ctx.fill_rect(&r.abs());
|
||||
if c.data_labels {
|
||||
let t = fmt_num(s.values.get(i).copied().flatten().unwrap_or(0.0));
|
||||
|
|
@ -361,66 +427,72 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
|||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Lines, areas, scatter, radar (drawn as lines).
|
||||
let stacked = c.kind.stacked();
|
||||
let mut acc = vec![0.0; ncat];
|
||||
for (si, s) in c.series.iter().enumerate() {
|
||||
let col = series_fill(rctx, c, si, Some(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 c.kind {
|
||||
ChartType::Scatter | ChartType::Bubble => {
|
||||
let xs: Vec<f64> = s.x_values.iter().flatten().copied().collect();
|
||||
let (xl, xh) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max));
|
||||
let xv = s.x_values.get(i).copied().flatten().unwrap_or(i as f64);
|
||||
if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + 0.5) }
|
||||
}
|
||||
_ => plot.x0 + band * (i as f64 + 0.5),
|
||||
};
|
||||
pts.push(Point::new(x, to_v(v)));
|
||||
}
|
||||
if pts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut p = BezPath::new();
|
||||
for (k, q) in pts.iter().enumerate() {
|
||||
if k == 0 {
|
||||
p.move_to(*q);
|
||||
} else {
|
||||
p.line_to(*q);
|
||||
}
|
||||
// Lines, areas, scatter, radar (drawn as lines): 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() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
ctx.set_transform(m);
|
||||
if matches!(c.kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar) {
|
||||
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();
|
||||
base_v + v
|
||||
} else {
|
||||
v
|
||||
};
|
||||
let x = match kind {
|
||||
ChartType::Scatter | ChartType::Bubble => {
|
||||
let xs: Vec<f64> = s.x_values.iter().flatten().copied().collect();
|
||||
let (xl, xh) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max));
|
||||
let xv = s.x_values.get(i).copied().flatten().unwrap_or(i as f64);
|
||||
if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + 0.5) }
|
||||
}
|
||||
ctx.set_paint(color(col, 0.85));
|
||||
ctx.fill_path(&a);
|
||||
} else if c.kind != ChartType::Scatter {
|
||||
ctx.set_paint(color(col, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(2.25).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round));
|
||||
ctx.stroke_path(&p);
|
||||
_ => plot.x0 + band * (i as f64 + 0.5),
|
||||
};
|
||||
pts.push(Point::new(x, to_axis(sec, v)));
|
||||
}
|
||||
if pts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut p = BezPath::new();
|
||||
for (k, q) in pts.iter().enumerate() {
|
||||
if k == 0 {
|
||||
p.move_to(*q);
|
||||
} else {
|
||||
p.line_to(*q);
|
||||
}
|
||||
if matches!(c.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));
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,6 +125,36 @@ fn effects_tables_charts_render() {
|
|||
assert!(px[0] < 240 && px[0] == px[2], "{px:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combo_chart_draws_columns_and_a_line_on_its_own_axis() {
|
||||
// Columns in the hundreds of thousands, a green line in the thousands on the secondary axis:
|
||||
// the line must sit up in the plot, not flat along the bottom of the primary scale.
|
||||
let mut c = Chart::sample(ChartType::Combo);
|
||||
c.title = None;
|
||||
c.legend = None;
|
||||
c.series.truncate(2);
|
||||
c.categories = vec!["A".into(), "B".into(), "C".into()];
|
||||
c.series[0].kind = Some(ChartType::Column);
|
||||
c.series[0].values = vec![Some(200_000.0), Some(300_000.0), Some(250_000.0)];
|
||||
c.series[1].kind = Some(ChartType::Line);
|
||||
c.series[1].values = vec![Some(7_000.0), Some(7_000.0), Some(7_000.0)];
|
||||
c.series[1].secondary_axis = true;
|
||||
c.series[1].line = Some(deckcraft_model::style::Line::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 176, 80)), 3.0));
|
||||
let sh = Shape { id: ShapeId(620), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
|
||||
let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let green = |x: u32, y0: u32, y1: u32| {
|
||||
(y0..y1).any(|y| {
|
||||
let p = img.pixel(x, y);
|
||||
p[1] > 120 && p[0] < 80 && p[2] < 140
|
||||
})
|
||||
};
|
||||
// The line is in the upper half (7 000 of an axis to 8 000), and columns fill the plot's bottom.
|
||||
assert!(green(480, 20, 270), "line not in the upper half");
|
||||
assert!(!green(480, 400, 500), "line along the bottom");
|
||||
let col = img.pixel(480, 480);
|
||||
assert!(col[0] < 250 || col[1] < 250 || col[2] < 250, "no column: {col:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_prompts_only_in_edit_view() {
|
||||
let p = Presentation::default();
|
||||
|
|
|
|||
Loading…
Reference in New Issue