Compare commits
5 Commits
main
...
land/chart
| Author | SHA1 | Date |
|---|---|---|
|
|
05bc0c8c04 | |
|
|
38b9e12cd1 | |
|
|
c4d1d7e1c8 | |
|
|
191323be3b | |
|
|
9995d5dfc8 |
|
|
@ -134,6 +134,10 @@ pub struct Series {
|
|||
/// Combo charts: this series' own type.
|
||||
pub kind: Option<ChartType>,
|
||||
pub secondary_axis: bool,
|
||||
/// The number format of the series' data labels (Excel code): the labels' own, else the
|
||||
/// source data's.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub number_format: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
|
@ -144,6 +148,8 @@ pub struct Chart {
|
|||
pub categories: Vec<String>,
|
||||
pub series: Vec<Series>,
|
||||
pub legend: Option<String>,
|
||||
/// `c:legend/c:overlay`: the legend is drawn over the plot instead of shrinking it.
|
||||
pub legend_overlay: bool,
|
||||
pub data_labels: bool,
|
||||
pub gridlines: bool,
|
||||
pub axis_titles: (Option<String>, Option<String>),
|
||||
|
|
@ -159,6 +165,20 @@ pub struct Chart {
|
|||
pub vary_colors: bool,
|
||||
pub y_min: Option<f64>,
|
||||
pub y_max: Option<f64>,
|
||||
/// The value axis' major unit, when the chart fixes it.
|
||||
pub y_major: Option<f64>,
|
||||
/// The value axis' number format (an Excel format code such as `#,##0` or `0.0%`).
|
||||
pub y_format: Option<String>,
|
||||
/// The value axis is deleted (`c:delete`): no labels, though gridlines may stay.
|
||||
pub y_hidden: bool,
|
||||
/// A combo chart's secondary value axis: bounds, major unit and number format.
|
||||
pub y2_min: Option<f64>,
|
||||
pub y2_max: Option<f64>,
|
||||
pub y2_major: Option<f64>,
|
||||
pub y2_format: Option<String>,
|
||||
pub y2_hidden: bool,
|
||||
/// The chart's language (`c:lang`, e.g. `ru-RU`): digit grouping and decimal mark.
|
||||
pub lang: Option<String>,
|
||||
/// Original chart XML part kept for round-trip of unmodelled options.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw: Option<String>,
|
||||
|
|
@ -172,6 +192,7 @@ impl Default for Chart {
|
|||
categories: vec![],
|
||||
series: vec![],
|
||||
legend: Some("b".into()),
|
||||
legend_overlay: false,
|
||||
data_labels: false,
|
||||
gridlines: true,
|
||||
axis_titles: (None, None),
|
||||
|
|
@ -186,6 +207,15 @@ impl Default for Chart {
|
|||
vary_colors: false,
|
||||
y_min: None,
|
||||
y_max: None,
|
||||
y_major: None,
|
||||
y_format: None,
|
||||
y_hidden: false,
|
||||
y2_min: None,
|
||||
y2_max: None,
|
||||
y2_major: None,
|
||||
y2_format: None,
|
||||
y2_hidden: false,
|
||||
lang: None,
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -210,6 +240,81 @@ 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)) })
|
||||
}
|
||||
|
||||
/// A value axis' scale — (min, max, major unit) — with at most `max_intervals` intervals:
|
||||
/// the bounds and unit the chart fixes, else PowerPoint's automatic ones (the data plus 5 %
|
||||
/// headroom, rounded out to a 1-2-5 unit). `None` when no series uses the axis.
|
||||
pub fn axis_scale(&self, secondary: bool, max_intervals: usize) -> Option<(f64, f64, f64)> {
|
||||
let (lo, hi) = self.axis_range(secondary)?;
|
||||
let (fmin, fmax, major) = if secondary { (self.y2_min, self.y2_max, self.y2_major) } else { (self.y_min, self.y_max, self.y_major) };
|
||||
let full_percent = !secondary && self.kind.percent();
|
||||
Some(axis_scale(lo, hi, fmin, fmax, major, max_intervals, !full_percent))
|
||||
}
|
||||
|
||||
/// A value axis label in the axis' number format and the chart's language.
|
||||
pub fn axis_label(&self, secondary: bool, v: f64) -> String {
|
||||
let code = if secondary { &self.y2_format } else { &self.y_format };
|
||||
crate::numfmt::format(code.as_deref().unwrap_or("General"), v, self.lang.as_deref())
|
||||
}
|
||||
|
||||
/// A data label of series `s`: its number format, else its axis', in the chart's language.
|
||||
pub fn data_label(&self, s: &Series, v: f64) -> String {
|
||||
let axis = if self.on_secondary(s) { &self.y2_format } else { &self.y_format };
|
||||
let code = s.number_format.as_ref().or(axis.as_ref());
|
||||
crate::numfmt::format(code.map_or("General", String::as_str), v, self.lang.as_deref())
|
||||
}
|
||||
|
||||
/// Value range across series (stacked sums for stacked types).
|
||||
pub fn value_range(&self) -> (f64, f64) {
|
||||
let mut lo: f64 = 0.0;
|
||||
|
|
@ -242,6 +347,53 @@ impl Chart {
|
|||
}
|
||||
}
|
||||
|
||||
/// The (min, max, major unit) of an axis over the data range [lo, hi]: `fmin`/`fmax`/`major`
|
||||
/// where the chart fixes them; otherwise the range takes in zero, gets 5 % headroom (`headroom`)
|
||||
/// and rounds out to the smallest 1-2-5 unit giving at most `max_intervals` intervals.
|
||||
pub fn axis_scale(
|
||||
lo: f64,
|
||||
hi: f64,
|
||||
fmin: Option<f64>,
|
||||
fmax: Option<f64>,
|
||||
major: Option<f64>,
|
||||
max_intervals: usize,
|
||||
headroom: bool,
|
||||
) -> (f64, f64, f64) {
|
||||
// Values past ±1e100 aren't axis values; clamping keeps the arithmetic finite.
|
||||
let fin = |v: Option<f64>| v.filter(|v| v.is_finite()).map(|v| v.clamp(-1e100, 1e100));
|
||||
let (fmin, fmax) = (fin(fmin), fin(fmax));
|
||||
let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi).clamp(-1e100, 1e100), lo.max(hi).clamp(-1e100, 1e100)) } else { (0.0, 1.0) };
|
||||
let mut lo = fmin.unwrap_or(lo.min(0.0));
|
||||
let mut hi = fmax.unwrap_or(hi.max(lo));
|
||||
if hi - lo < 1e-12 {
|
||||
hi = lo + lo.abs().max(1.0);
|
||||
}
|
||||
let span = hi - lo;
|
||||
if headroom && fmax.is_none() && hi > 0.0 {
|
||||
hi += span * 0.05;
|
||||
}
|
||||
if headroom && fmin.is_none() && lo < 0.0 {
|
||||
lo -= span * 0.05;
|
||||
}
|
||||
let n = max_intervals.clamp(1, 50) as f64;
|
||||
let step = match fin(major).filter(|m| *m > 0.0 && (hi - lo) / m <= 1000.0) {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
let mut step = 10f64.powf(((hi - lo) / n).log10().floor());
|
||||
let mut k = 0;
|
||||
// 1, 2, 5, 10, 20… × the magnitude until the intervals fit.
|
||||
while ((hi / step).ceil() - (lo / step).floor()) > n && k < 30 {
|
||||
step *= if matches!(k % 3, 1) { 2.5 } else { 2.0 };
|
||||
k += 1;
|
||||
}
|
||||
step
|
||||
}
|
||||
};
|
||||
let min = fmin.unwrap_or((lo / step + 1e-9).floor() * step);
|
||||
let max = fmax.unwrap_or((hi / step - 1e-9).ceil() * step).max(min + step);
|
||||
(min, max, step)
|
||||
}
|
||||
|
||||
/// "Nice" axis scale: (min, max, major step) covering [lo, hi].
|
||||
pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) {
|
||||
let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi), lo.max(hi)) } else { (0.0, 1.0) };
|
||||
|
|
@ -280,6 +432,67 @@ mod tests {
|
|||
assert!(b > a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_axis_scales_like_powerpoint() {
|
||||
// Weekly spend up to ~715 000 on a plot with room for 9 intervals: 0–800 000 by 100 000.
|
||||
assert_eq!(axis_scale(0.0, 715_000.0, None, None, None, 9, true), (0.0, 800_000.0, 100_000.0));
|
||||
// Clicks up to 6 600: 0–7 000 by 1 000.
|
||||
assert_eq!(axis_scale(0.0, 6_600.0, None, None, None, 9, true), (0.0, 7_000.0, 1_000.0));
|
||||
// Fewer intervals fit on a short axis.
|
||||
assert_eq!(axis_scale(0.0, 6_600.0, None, None, None, 4, true), (0.0, 8_000.0, 2_000.0));
|
||||
// Negative data gets room below; fixed bounds and unit are kept.
|
||||
let (min, max, _) = axis_scale(-30.0, 100.0, None, None, None, 8, true);
|
||||
assert!(min <= -30.0 && max >= 105.0);
|
||||
assert_eq!(axis_scale(0.0, 715_000.0, Some(100_000.0), Some(750_000.0), Some(50_000.0), 9, true), (100_000.0, 750_000.0, 50_000.0));
|
||||
// 100 % stacked stays at 0–1 without headroom.
|
||||
assert_eq!(axis_scale(0.0, 1.0, None, None, None, 5, false), (0.0, 1.0, 0.2));
|
||||
// Nonsense never loops or divides by zero.
|
||||
for (lo, hi) in [(f64::NAN, 1.0), (5.0, 5.0), (0.0, 0.0), (-1e300, 1e300)] {
|
||||
let (a, b, s) = axis_scale(lo, hi, None, None, Some(0.0), 0, true);
|
||||
assert!(a.is_finite() && b.is_finite() && s > 0.0 && b > a, "{lo} {hi}: {a} {b} {s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_labels_use_the_format_and_language() {
|
||||
let mut c = Chart::sample(ChartType::Column);
|
||||
c.y_format = Some(r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#.into());
|
||||
c.lang = Some("ru-RU".into());
|
||||
assert_eq!(c.axis_label(false, 800_000.0), "800\u{a0}000 ₽");
|
||||
assert_eq!(c.axis_label(false, 0.0), "- ₽");
|
||||
// No format: General.
|
||||
assert_eq!(c.axis_label(true, 2.5), "2,5");
|
||||
c.lang = None;
|
||||
assert_eq!(c.axis_label(true, 2.5), "2.5");
|
||||
// Data labels: the series' format, else the axis'.
|
||||
c.lang = Some("ru-RU".into());
|
||||
c.series[0].number_format = Some(r#"#,##0\ "₽""#.into());
|
||||
assert_eq!(c.data_label(&c.series[0], 516_600_000.0), "516\u{a0}600\u{a0}000 ₽");
|
||||
assert_eq!(c.data_label(&c.series[1], 0.0), "- ₽");
|
||||
}
|
||||
|
||||
#[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);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ pub mod anim;
|
|||
pub mod chart;
|
||||
pub mod defaults;
|
||||
pub mod edit;
|
||||
pub mod numfmt;
|
||||
pub mod resolve;
|
||||
pub mod style;
|
||||
pub mod table;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,256 @@
|
|||
//! Number formats: Excel format codes (`#,##0`, `0.0%`, `_-* # ##0 "₽"_-;…`, `General`) as chart
|
||||
//! axes and data labels show them, in the chart's language (digit grouping and decimal mark).
|
||||
|
||||
/// Digit grouping and decimal mark for a language tag (`ru-RU`, `de-DE`, `en-US`).
|
||||
fn marks(lang: Option<&str>) -> (char, char) {
|
||||
let l = lang.unwrap_or("en").split(['-', '_']).next().unwrap_or("en").to_ascii_lowercase();
|
||||
match l.as_str() {
|
||||
// Space-grouped, comma decimal.
|
||||
"ru" | "uk" | "be" | "kk" | "fr" | "pl" | "cs" | "sk" | "sv" | "fi" | "nb" | "nn" | "no" | "bg" | "lt" | "lv" | "et" | "hu" | "uz" | "hy"
|
||||
| "ka" | "az" => ('\u{a0}', ','),
|
||||
// Dot-grouped, comma decimal.
|
||||
"de" | "es" | "it" | "pt" | "nl" | "da" | "tr" | "el" | "ro" | "id" | "sr" | "hr" | "sl" | "vi" => ('.', ','),
|
||||
_ => (',', '.'),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Tok {
|
||||
/// Text shown as is.
|
||||
Lit(String),
|
||||
/// A digit placeholder: `0`, `#` or `?`.
|
||||
Digit(char),
|
||||
Dot,
|
||||
Comma,
|
||||
Percent,
|
||||
}
|
||||
|
||||
/// A section's tokens (`[$₽-419]` gives its symbol; colours, conditions and locales drop out).
|
||||
fn tokens(section: &str) -> Vec<Tok> {
|
||||
let mut out = vec![];
|
||||
let mut it = section.chars().peekable();
|
||||
while let Some(c) = it.next() {
|
||||
match c {
|
||||
'"' => {
|
||||
let mut s = String::new();
|
||||
for d in it.by_ref() {
|
||||
if d == '"' {
|
||||
break;
|
||||
}
|
||||
s.push(d);
|
||||
}
|
||||
out.push(Tok::Lit(s));
|
||||
}
|
||||
'\\' => {
|
||||
if let Some(d) = it.next() {
|
||||
out.push(Tok::Lit(d.to_string()));
|
||||
}
|
||||
}
|
||||
// Padding the width of the next character: a space.
|
||||
'_' => {
|
||||
it.next();
|
||||
out.push(Tok::Lit(" ".into()));
|
||||
}
|
||||
// Repeat-to-fill: nothing on an axis.
|
||||
'*' => {
|
||||
it.next();
|
||||
}
|
||||
'[' => {
|
||||
let mut s = String::new();
|
||||
for d in it.by_ref() {
|
||||
if d == ']' {
|
||||
break;
|
||||
}
|
||||
s.push(d);
|
||||
}
|
||||
if let Some(sym) = s.strip_prefix('$') {
|
||||
let sym = sym.split('-').next().unwrap_or("");
|
||||
if !sym.is_empty() {
|
||||
out.push(Tok::Lit(sym.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
'0' | '#' | '?' => out.push(Tok::Digit(c)),
|
||||
'.' => out.push(Tok::Dot),
|
||||
',' => out.push(Tok::Comma),
|
||||
'%' => out.push(Tok::Percent),
|
||||
'@' => {}
|
||||
_ => out.push(Tok::Lit(c.to_string())),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The format's sections split at `;` (outside quotes, brackets and escapes).
|
||||
fn sections(code: &str) -> Vec<&str> {
|
||||
let mut out = vec![];
|
||||
let (mut start, mut quoted, mut bracket, mut escaped) = (0, false, false, false);
|
||||
for (i, c) in code.char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'\\' if !quoted => escaped = true,
|
||||
'"' => quoted = !quoted,
|
||||
'[' if !quoted => bracket = true,
|
||||
']' if !quoted => bracket = false,
|
||||
';' if !quoted && !bracket => {
|
||||
out.push(code.get(start..i).unwrap_or(""));
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.push(code.get(start..).unwrap_or(""));
|
||||
out
|
||||
}
|
||||
|
||||
/// `v` in the General format: up to 10 significant digits, no grouping.
|
||||
fn general(v: f64, dec: char) -> String {
|
||||
if v == 0.0 {
|
||||
return "0".into();
|
||||
}
|
||||
let a = v.abs();
|
||||
let s = if !(1e-9..1e11).contains(&a) {
|
||||
format!("{v:.5E}")
|
||||
} else {
|
||||
let digits = (10 - (a.log10().floor() as i32 + 1)).clamp(0, 10) as usize;
|
||||
let s = format!("{v:.digits$}");
|
||||
if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }
|
||||
};
|
||||
s.replace('.', &dec.to_string())
|
||||
}
|
||||
|
||||
/// Group an integer's digits in threes with `sep`.
|
||||
fn group(digits: &str, sep: char) -> String {
|
||||
let n = digits.chars().count();
|
||||
let mut out = String::new();
|
||||
for (i, d) in digits.chars().enumerate() {
|
||||
if i > 0 && (n - i).is_multiple_of(3) {
|
||||
out.push(sep);
|
||||
}
|
||||
out.push(d);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `v` formatted with the Excel number format `code` in language `lang` (axis labels: the text
|
||||
/// section of a format doesn't apply).
|
||||
pub fn format(code: &str, v: f64, lang: Option<&str>) -> String {
|
||||
let (sep, dec) = marks(lang);
|
||||
if !v.is_finite() {
|
||||
return String::new();
|
||||
}
|
||||
let secs = sections(code);
|
||||
// The section for the value's sign; a negative or zero section shows the value unsigned.
|
||||
let (sec, v, minus) = match (secs.as_slice(), v) {
|
||||
([_, neg, ..], v) if v < 0.0 => (*neg, -v, false),
|
||||
([_, _, zero, ..], v) if v == 0.0 => (*zero, v, false),
|
||||
([pos, ..], v) => (*pos, v.abs(), v < 0.0),
|
||||
([], v) => ("General", v.abs(), v < 0.0),
|
||||
};
|
||||
if sec.trim().eq_ignore_ascii_case("general") || sec.trim().is_empty() {
|
||||
let s = general(v, dec);
|
||||
return if minus { format!("-{s}") } else { s };
|
||||
}
|
||||
let toks = tokens(sec);
|
||||
let first = toks.iter().position(|t| matches!(t, Tok::Digit(_)));
|
||||
let last = toks.iter().rposition(|t| matches!(t, Tok::Digit(_)));
|
||||
let (Some(first), Some(last)) = (first, last) else {
|
||||
// No digits: just the section's text ("-" for zero, a label).
|
||||
let s: String = toks.iter().filter_map(|t| if let Tok::Lit(s) = t { Some(s.as_str()) } else { None }).collect();
|
||||
return s.trim().to_string();
|
||||
};
|
||||
let percent = toks.contains(&Tok::Percent);
|
||||
let number = toks.get(first..=last).unwrap_or(&[]);
|
||||
let dot = number.iter().position(|t| *t == Tok::Dot);
|
||||
let (int_part, frac_part) = match dot {
|
||||
Some(d) => (number.get(..d).unwrap_or(&[]), number.get(d + 1..).unwrap_or(&[])),
|
||||
None => (number, &[][..]),
|
||||
};
|
||||
// Grouping: a comma (or the locale's space, written literally) between integer digits.
|
||||
let grouping = int_part.iter().any(|t| *t == Tok::Comma || matches!(t, Tok::Lit(s) if s.trim().is_empty()));
|
||||
// Commas right after the last digit scale by a thousand each (`#,##0,` = thousands).
|
||||
let after = toks.get(last + 1..).unwrap_or(&[]);
|
||||
let scale_commas = if dot.is_none() { after.iter().take_while(|t| **t == Tok::Comma).count() } else { 0 };
|
||||
let min_int = int_part.iter().filter(|t| **t == Tok::Digit('0')).count();
|
||||
let frac_digits: Vec<char> = frac_part.iter().filter_map(|t| if let Tok::Digit(c) = t { Some(*c) } else { None }).collect();
|
||||
let max_frac = frac_digits.len();
|
||||
let min_frac = frac_digits.iter().filter(|c| **c == '0').count();
|
||||
let mut x = v * if percent { 100.0 } else { 1.0 } / 1000f64.powi(scale_commas as i32);
|
||||
if !x.is_finite() {
|
||||
x = 0.0;
|
||||
}
|
||||
let s = format!("{x:.max_frac$}");
|
||||
let (ip, fp) = s.split_once('.').unwrap_or((s.as_str(), ""));
|
||||
let mut fp = fp.to_string();
|
||||
while fp.len() > min_frac && fp.ends_with('0') {
|
||||
fp.pop();
|
||||
}
|
||||
let ip = ip.trim_start_matches('0');
|
||||
let ip = if ip.len() < min_int { format!("{}{ip}", "0".repeat(min_int - ip.len())) } else { ip.to_string() };
|
||||
let mut num = if grouping { group(&ip, sep) } else { ip };
|
||||
if !fp.is_empty() {
|
||||
num.push(dec);
|
||||
num.push_str(&fp);
|
||||
}
|
||||
let lits = |ts: &[Tok]| -> String {
|
||||
ts.iter()
|
||||
.filter_map(|t| match t {
|
||||
Tok::Lit(s) => Some(s.clone()),
|
||||
Tok::Percent => Some("%".into()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let before = lits(toks.get(..first).unwrap_or(&[]));
|
||||
let after = lits(toks.get(last + 1..).unwrap_or(&[]));
|
||||
let out = format!("{before}{num}{after}");
|
||||
let out = out.trim();
|
||||
if minus && !out.is_empty() { format!("-{out}") } else { out.to_string() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const RUB: &str = r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#;
|
||||
|
||||
#[test]
|
||||
fn russian_accounting_roubles() {
|
||||
assert_eq!(format(RUB, 800_000.0, Some("ru-RU")), "800\u{a0}000 ₽");
|
||||
assert_eq!(format(RUB, 1_234_567.0, Some("ru-RU")), "1\u{a0}234\u{a0}567 ₽");
|
||||
assert_eq!(format(RUB, -5_000.0, Some("ru-RU")), "-5\u{a0}000 ₽");
|
||||
assert_eq!(format(RUB, 0.0, Some("ru-RU")), "- ₽");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_codes() {
|
||||
assert_eq!(format("#,##0", 7000.0, Some("ru-RU")), "7\u{a0}000");
|
||||
assert_eq!(format("#,##0", 7000.0, Some("en-US")), "7,000");
|
||||
assert_eq!(format("#,##0", 7000.0, Some("de-DE")), "7.000");
|
||||
assert_eq!(format("0.0%", 0.125, Some("ru-RU")), "12,5%");
|
||||
assert_eq!(format("0.0%", 0.125, None), "12.5%");
|
||||
assert_eq!(format("0%", 0.0, None), "0%");
|
||||
assert_eq!(format("General", 2.5, Some("ru-RU")), "2,5");
|
||||
assert_eq!(format("General", 400_000.0, Some("ru-RU")), "400000");
|
||||
assert_eq!(format("0.00", 1.23456, None), "1.23");
|
||||
assert_eq!(format("#,##0.0#", 1234.5, None), "1,234.5");
|
||||
assert_eq!(format("#,##0,", 12_345_678.0, None), "12,346");
|
||||
assert_eq!(format("[$₽-419]#,##0", 1500.0, Some("ru-RU")), "₽1\u{a0}500");
|
||||
assert_eq!(format("0;[Red]-0", -3.0, None), "-3");
|
||||
assert_eq!(format("0;0", -3.0, None), "3");
|
||||
assert_eq!(format("0", -3.0, None), "-3");
|
||||
assert_eq!(format("\"x\"0", 5.0, None), "x5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_panics() {
|
||||
for code in ["", ";", ";;;", "\"", "[", "\\", "0.", ".", "#,", "%", "_", "*", "@", "0E+00", "[$", "\"unterminated"] {
|
||||
for v in [0.0, -1.5, 1e300, f64::NAN, f64::INFINITY, 1e-300] {
|
||||
let _ = format(code, v, Some("ru"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -97,10 +97,30 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
|
|||
c.title = None;
|
||||
}
|
||||
c.legend = ch.child("legend").map(|l| l.child_val("legendPos").unwrap_or("r").to_string());
|
||||
c.legend_overlay = ch.child("legend").and_then(|l| l.child("overlay")).and_then(|o| o.bool("val")).unwrap_or(false);
|
||||
c.chart_fill = simple_fill(root.child("spPr"));
|
||||
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, bool)> =
|
||||
pa.children_named("valAx").filter_map(|a| Some((a.child_val("axId")?, matches!(a.child_val("axPos"), Some("l" | "r"))))).collect();
|
||||
// A group's value axis: its vertical one when it has two (scatter), else its one.
|
||||
let group_val_axis = |g: &El| {
|
||||
let ids: Vec<(&str, bool)> =
|
||||
g.children_named("axId").filter_map(|a| a.attr("val")).filter_map(|id| val_ids.iter().find(|(v, _)| *v == id).copied()).collect();
|
||||
ids.iter().find(|(_, vertical)| *vertical).or(ids.first()).map(|(id, _)| id.to_string())
|
||||
};
|
||||
// The primary value axis is the one on the left (bottom for bar charts); the secondary one
|
||||
// sits on the right or top, or crosses the category axis at its maximum.
|
||||
let secondary_placed = |a: &El| a.child_val("crosses") == Some("max") || matches!(a.child_val("axPos"), Some("r" | "t"));
|
||||
let used: Vec<String> = pa.elements().filter(|g| g.local().ends_with("Chart")).filter_map(group_val_axis).collect();
|
||||
let primary_val: Option<String> = used
|
||||
.iter()
|
||||
.find(|id| pa.children_named("valAx").find(|a| a.child_val("axId") == Some(id.as_str())).is_some_and(|a| !secondary_placed(a)))
|
||||
.or(used.first())
|
||||
.cloned();
|
||||
for g in pa.elements() {
|
||||
let name = g.local();
|
||||
if !name.ends_with("Chart") {
|
||||
|
|
@ -166,6 +186,8 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
|
|||
c.data_labels = true;
|
||||
}
|
||||
kinds.push(kind);
|
||||
let val_axis = group_val_axis(g);
|
||||
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(),
|
||||
|
|
@ -174,11 +196,22 @@ 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")) {
|
||||
ser.data_labels = Some(d);
|
||||
}
|
||||
// Data labels' number format: the series' labels', the group's, else the source data's.
|
||||
let label_fmt = |d: Option<&El>| {
|
||||
d.and_then(|d| d.child("numFmt"))
|
||||
.filter(|n| n.attr("sourceLinked") != Some("1"))
|
||||
.and_then(|n| n.attr("formatCode"))
|
||||
.map(str::to_string)
|
||||
};
|
||||
let source =
|
||||
s.child("val").or_else(|| s.child("yVal")).and_then(|v| v.find("numCache")).and_then(|c| c.child("formatCode")).map(|f| f.text());
|
||||
ser.number_format = label_fmt(s.child("dLbls")).or_else(|| label_fmt(g.child("dLbls"))).or(source).filter(|f| !f.is_empty());
|
||||
for dp in s.children_named("dPt") {
|
||||
if let (Some(i), Some(f)) = (dp.child("idx").and_then(|i| i.u32("val")), simple_fill(dp.child("spPr"))) {
|
||||
ser.point_fills.push((i as usize, f));
|
||||
|
|
@ -207,11 +240,29 @@ 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.
|
||||
c.lang = root.child_val("lang").map(str::to_string);
|
||||
let vertical = |a: &&El| matches!(a.child_val("axPos"), Some("l" | "r"));
|
||||
let val_ax = pa.children_named("valAx").find(vertical).or_else(|| pa.children_named("valAx").last());
|
||||
let by_id = |id: &Option<String>| pa.children_named("valAx").find(|a| id.is_some() && a.child_val("axId") == id.as_deref());
|
||||
let val_ax = by_id(&primary_val).or_else(|| pa.children_named("valAx").find(vertical)).or_else(|| pa.children_named("valAx").last());
|
||||
let num = |a: &El, k: &str| a.child_val(k).and_then(|v| v.parse::<f64>().ok()).filter(|v| v.is_finite());
|
||||
let format_code = |a: &El| a.child("numFmt").and_then(|n| n.attr("formatCode")).map(str::to_string);
|
||||
let deleted = |a: &El| a.child("delete").and_then(|d| d.bool("val")) == Some(true);
|
||||
if c.kind == ChartType::Combo && c.series.iter().any(|s| s.secondary_axis) {
|
||||
let primary_id = val_ax.and_then(|a| a.child_val("axId"));
|
||||
// The other vertical value axis is the secondary one.
|
||||
if let Some(a) = pa.children_named("valAx").find(|a| a.child_val("axId") != primary_id && vertical(a)) {
|
||||
let sc = a.child("scaling");
|
||||
c.y2_min = sc.and_then(|s| num(s, "min"));
|
||||
c.y2_max = sc.and_then(|s| num(s, "max"));
|
||||
c.y2_major = num(a, "majorUnit");
|
||||
c.y2_format = format_code(a);
|
||||
c.y2_hidden = deleted(a);
|
||||
}
|
||||
}
|
||||
let cat_ax = pa.child("catAx").or_else(|| pa.child("dateAx")).or_else(|| pa.children_named("valAx").find(|a| !vertical(a)));
|
||||
if let Some(v) = val_ax {
|
||||
c.gridlines = v.child("majorGridlines").is_some();
|
||||
|
|
@ -219,9 +270,70 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
|
|||
c.y_min = sc.and_then(|s| s.child_val("min")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite());
|
||||
c.y_max = sc.and_then(|s| s.child_val("max")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite());
|
||||
c.axis_titles.1 = v.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into()));
|
||||
c.y_major = num(v, "majorUnit");
|
||||
c.y_format = format_code(v);
|
||||
c.y_hidden = deleted(v);
|
||||
}
|
||||
if let Some(a) = cat_ax {
|
||||
c.axis_titles.0 = a.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into()));
|
||||
}
|
||||
Some(c)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legend_position_and_overlay_are_read() {
|
||||
let legend = r#"<c:legend><c:legendPos val="r"/><c:overlay val="1"/></c:legend>"#;
|
||||
let xml = format!(
|
||||
r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea>{legend}</c:chart></c:chartSpace>"#
|
||||
);
|
||||
let c = read_chart_xml(xml.as_bytes()).unwrap();
|
||||
assert_eq!(c.legend.as_deref(), Some("r"));
|
||||
assert!(c.legend_overlay);
|
||||
let xml = br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea><c:legend/></c:chart></c:chartSpace>"#;
|
||||
let c = read_chart_xml(xml).unwrap();
|
||||
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:lang val="ru-RU"/><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:numFmt formatCode="#,##0" sourceLinked="1"/><c:crossAx val="1"/><c:majorUnit val="100000"/></c:valAx>
|
||||
<c:valAx><c:axId val="4"/><c:scaling><c:max val="8000"/></c:scaling><c:axPos val="r"/><c:numFmt formatCode="0.0" sourceLinked="0"/><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)));
|
||||
// Each value axis' number format, bounds and unit; the chart's language.
|
||||
assert_eq!(c.y_format.as_deref(), Some("#,##0"));
|
||||
assert_eq!((c.y_major, c.y2_max, c.y2_format.as_deref()), (Some(100000.0), Some(8000.0), Some("0.0")));
|
||||
assert_eq!(c.lang.as_deref(), Some("ru-RU"));
|
||||
// Group order doesn't decide: columns on the right-hand axis are the secondary series even
|
||||
// when their group comes first, and the line on the left is primary.
|
||||
let swapped = String::from_utf8_lossy(xml)
|
||||
.replace(
|
||||
"<c:axPos val=\"l\"/><c:numFmt formatCode=\"#,##0\"",
|
||||
"<c:axPos val=\"r\"/><c:crosses val=\"max\"/><c:numFmt formatCode=\"#,##0\"",
|
||||
)
|
||||
.replace("<c:axPos val=\"r\"/><c:numFmt formatCode=\"0.0\"", "<c:axPos val=\"l\"/><c:numFmt formatCode=\"0.0\"");
|
||||
let c2 = read_chart_xml(swapped.as_bytes()).unwrap();
|
||||
assert!(c2.series[0].secondary_axis && !c2.series[1].secondary_axis);
|
||||
assert_eq!((c2.y_format.as_deref(), c2.y2_format.as_deref()), (Some("0.0"), Some("#,##0")));
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -467,7 +467,7 @@ pub fn chart_xml(c: &Chart) -> Vec<u8> {
|
|||
};
|
||||
w.open0("c:legend");
|
||||
w.val("c:legendPos", p);
|
||||
w.val("c:overlay", 0);
|
||||
w.val("c:overlay", u8::from(c.legend_overlay));
|
||||
w.close("c:legend");
|
||||
}
|
||||
w.val("c:plotVisOnly", 1);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
|
||||
use deckcraft_model::Shape;
|
||||
use deckcraft_model::chart::{Chart, ChartType, nice_scale};
|
||||
use deckcraft_model::chart::{Chart, ChartType, axis_scale};
|
||||
use deckcraft_model::resolve::Ctx;
|
||||
use deckcraft_model::style::{ColorRef, Fill};
|
||||
use deckcraft_model::text::{Align, Anchor, Bullet, TextBody};
|
||||
|
|
@ -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);
|
||||
|
|
@ -63,6 +86,42 @@ fn fmt_num(v: f64) -> String {
|
|||
if (v - v.round()).abs() < 1e-9 { format!("{}", v.round() as i64) } else { format!("{v:.1}") }
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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);
|
||||
|
|
@ -87,25 +146,43 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
|||
area.y0 += base * 2.4;
|
||||
}
|
||||
let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut);
|
||||
// Legend at the bottom.
|
||||
// Legend placed by `c:legendPos`.
|
||||
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 c.legend.is_some() && !legend_items.is_empty() {
|
||||
let ly = area.y1 - base * 1.4;
|
||||
let item_w = (legend_items.iter().map(|(n, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).sum::<f64>()).min(w * 0.9);
|
||||
let mut x = (w - item_w) / 2.0;
|
||||
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, ly + base * 0.3, x + base * 0.7, ly + base));
|
||||
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, ly, x + base + tw, ly + base * 1.3), base, Align::Left, Anchor::Middle, text_col, false);
|
||||
x += tw + base * 0.6;
|
||||
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;
|
||||
}
|
||||
}
|
||||
area.y1 = ly - base * 0.5;
|
||||
}
|
||||
if pie {
|
||||
let Some(s) = c.series.first() else { return };
|
||||
|
|
@ -162,26 +239,50 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
|||
}
|
||||
return;
|
||||
}
|
||||
let (lo, hi) = c.value_range();
|
||||
let (min, max, step) = nice_scale(lo.min(0.0), hi);
|
||||
let horiz = c.kind.horizontal();
|
||||
// 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)
|
||||
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() };
|
||||
// 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;
|
||||
let (min, max, step) = c.axis_scale(false, max_intervals).unwrap_or_else(|| axis_scale(0.0, 1.0, None, None, None, max_intervals, true));
|
||||
// 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 label_w =
|
||||
|sec: bool, sc: (f64, f64, f64)| ticks(sc).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 {
|
||||
Rect::new(area.x0 + gutter, area.y0, area.x1, area.y1 - base * 1.8)
|
||||
label_w(false, (min, max, step)).clamp(base * 1.5, area.width() * 0.3)
|
||||
};
|
||||
let gutter_r = second.filter(|_| !c.y2_hidden).map(|sc| label_w(true, sc).clamp(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);
|
||||
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 {
|
||||
let v = min + k as f64 * step;
|
||||
for v in ticks((min, max, step)) {
|
||||
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));
|
||||
|
|
@ -190,7 +291,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
|||
ctx,
|
||||
rctx,
|
||||
m,
|
||||
&fmt_num(v),
|
||||
&text,
|
||||
Rect::new(pos - 30.0, plot.y1 + 2.0, pos + 30.0, plot.y1 + base * 1.5),
|
||||
base,
|
||||
Align::Center,
|
||||
|
|
@ -205,7 +306,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
|||
ctx,
|
||||
rctx,
|
||||
m,
|
||||
&fmt_num(v),
|
||||
&text,
|
||||
Rect::new(area.x0, pos - base, plot.x0 - base * 0.4, pos + base),
|
||||
base,
|
||||
Align::Right,
|
||||
|
|
@ -221,9 +322,28 @@ 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(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);
|
||||
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 {
|
||||
|
|
@ -240,12 +360,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,
|
||||
|
|
@ -254,24 +375,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);
|
||||
|
|
@ -288,13 +412,13 @@ 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));
|
||||
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 {
|
||||
|
|
@ -315,66 +439,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -392,3 +522,40 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
|||
ctx.set_stroke(kurbo::Stroke::new(0.75));
|
||||
ctx.stroke_path(&ax);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const AREA: Rect = Rect::new(0.0, 0.0, 400.0, 300.0);
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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