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:
Магда М.Г. 2026-10-12 16:00:52 +03:00 committed by Brandon Thomas
parent 191323be3b
commit c4d1d7e1c8
4 changed files with 284 additions and 73 deletions

View File

@ -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);

View File

@ -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));
}
}

View File

@ -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));
}
}
}

View File

@ -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();