mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-26 11:36:58 -06:00
Merge PR #602 from NikitkaRa: Charts: polar radar, stock high-low lines and up/down bars, combo sample
This commit is contained in:
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Chart Elements",
|
||||
["Chart Design", "Chart Layouts"],
|
||||
None,
|
||||
"{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, id?}",
|
||||
"{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, id?}",
|
||||
has_selection,
|
||||
chart_options
|
||||
),
|
||||
@@ -785,6 +785,10 @@ fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
t.dedup();
|
||||
c.subtotals = t;
|
||||
}
|
||||
if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) {
|
||||
c.up_down_bars = b;
|
||||
c.raw = None;
|
||||
}
|
||||
if let Some(q) = p.get("quartiles").and_then(Value::as_str) {
|
||||
c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive");
|
||||
c.raw = None;
|
||||
|
||||
@@ -301,6 +301,10 @@ pub struct Chart {
|
||||
/// Box and whisker quartiles by the inclusive method (QUARTILE.INC) instead of exclusive.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub inclusive_quartiles: bool,
|
||||
/// A stock chart's up/down bars (`c:upDownBars`): open-high-low-close, the box between
|
||||
/// the first and last series, hollow when the price rose.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub up_down_bars: bool,
|
||||
/// A histogram's bin count (`cx:binCount`); with neither this nor `bin_size` the bins are
|
||||
/// automatic (Scott's rule, as Excel does).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -350,6 +354,7 @@ impl Default for Chart {
|
||||
subtotals: vec![],
|
||||
category_levels: vec![],
|
||||
inclusive_quartiles: false,
|
||||
up_down_bars: false,
|
||||
bin_count: None,
|
||||
bin_size: None,
|
||||
raw: None,
|
||||
@@ -393,6 +398,15 @@ impl Chart {
|
||||
c.legend = (kind == ChartType::BoxWhisker).then(|| "b".to_string());
|
||||
return c;
|
||||
}
|
||||
if kind == ChartType::Stock {
|
||||
// High-low-close over five days, the series in the order PowerPoint expects.
|
||||
c.categories = ["Mon", "Tue", "Wed", "Thu", "Fri"].iter().map(|s| s.to_string()).collect();
|
||||
let hlc = [("High", [55.0, 57.0, 57.0, 58.0, 58.0]), ("Low", [11.0, 12.0, 13.0, 11.0, 25.0]), ("Close", [25.0, 38.0, 50.0, 35.0, 43.0])];
|
||||
for (name, v) in hlc {
|
||||
c.series.push(Series { name: name.into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() });
|
||||
}
|
||||
return c;
|
||||
}
|
||||
let n_series =
|
||||
if matches!(kind, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Waterfall | ChartType::Treemap) { 1 } else { 3 };
|
||||
for s in 0..n_series {
|
||||
@@ -404,6 +418,12 @@ impl Chart {
|
||||
});
|
||||
}
|
||||
c.vary_colors = n_series == 1;
|
||||
if kind == ChartType::Combo {
|
||||
// Columns with the last series as a line, as PowerPoint's combo starts.
|
||||
for (i, s) in c.series.iter_mut().enumerate() {
|
||||
s.kind = Some(if i + 1 == n_series { ChartType::Line } else { ChartType::Column });
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
/// A series' own chart type: its own in a combo chart (columns when it names none), else the
|
||||
|
||||
@@ -188,7 +188,10 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
|
||||
ChartType::Radar
|
||||
}
|
||||
}
|
||||
"stockChart" => ChartType::Stock,
|
||||
"stockChart" => {
|
||||
c.up_down_bars = g.child("upDownBars").is_some();
|
||||
ChartType::Stock
|
||||
}
|
||||
"surfaceChart" | "surface3DChart" => ChartType::Surface,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
@@ -121,11 +121,13 @@ enum Group {
|
||||
Scatter,
|
||||
Radar,
|
||||
Bubble,
|
||||
Stock,
|
||||
}
|
||||
|
||||
fn group_of(k: ChartType) -> Group {
|
||||
match k {
|
||||
ChartType::Line | ChartType::LineMarkers | ChartType::StackedLine | ChartType::Stock => Group::Line,
|
||||
ChartType::Line | ChartType::LineMarkers | ChartType::StackedLine => Group::Line,
|
||||
ChartType::Stock => Group::Stock,
|
||||
ChartType::Area | ChartType::StackedArea | ChartType::Surface => Group::Area,
|
||||
ChartType::Pie => Group::Pie,
|
||||
ChartType::Doughnut | ChartType::Sunburst => Group::Doughnut,
|
||||
@@ -143,7 +145,32 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group)
|
||||
w.open0("c:tx");
|
||||
str_cache(w, &format!("Sheet1!${}$1", col(i + 1)), std::slice::from_ref(&s.name));
|
||||
w.close("c:tx");
|
||||
sp_pr(w, s.fill.as_ref(), s.line.as_ref());
|
||||
if g == Group::Stock && s.line.is_none() {
|
||||
// A stock chart's series are points joined by the high-low lines, not lines of their own.
|
||||
w.open0("c:spPr");
|
||||
w.open("a:ln", A::new().a("w", 19050));
|
||||
w.empty0("a:noFill");
|
||||
w.close("a:ln");
|
||||
w.close("c:spPr");
|
||||
} else {
|
||||
sp_pr(w, s.fill.as_ref(), s.line.as_ref());
|
||||
}
|
||||
if g == Group::Stock {
|
||||
// High-low-close: the close is a dash on the high-low line; with up/down bars (or a set
|
||||
// marker) the series keep their own.
|
||||
let close = !c.up_down_bars && i + 1 == c.series.len() && c.series.len() == 3;
|
||||
let sym = s.marker.as_deref().filter(|m| !m.is_empty() && m.chars().all(|ch| ch.is_ascii_alphanumeric())).unwrap_or(if close {
|
||||
"dash"
|
||||
} else {
|
||||
"none"
|
||||
});
|
||||
w.open0("c:marker");
|
||||
w.val("c:symbol", sym);
|
||||
if sym != "none" {
|
||||
w.val("c:size", 7);
|
||||
}
|
||||
w.close("c:marker");
|
||||
}
|
||||
if g == Group::Bar {
|
||||
w.val("c:invertIfNegative", 0);
|
||||
}
|
||||
@@ -377,6 +404,7 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)])
|
||||
Group::Scatter => "c:scatterChart",
|
||||
Group::Radar => "c:radarChart",
|
||||
Group::Bubble => "c:bubbleChart",
|
||||
Group::Stock => "c:stockChart",
|
||||
};
|
||||
w.open0(tag);
|
||||
match g {
|
||||
@@ -389,7 +417,10 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)])
|
||||
Group::Radar => w.val("c:radarStyle", if kind == ChartType::FilledRadar { "filled" } else { "marker" }),
|
||||
_ => {}
|
||||
}
|
||||
w.val("c:varyColors", vary);
|
||||
// `c:stockChart` has no `c:varyColors`.
|
||||
if g != Group::Stock {
|
||||
w.val("c:varyColors", vary);
|
||||
}
|
||||
for (i, s) in list {
|
||||
series(w, c, s, *i, kind, g);
|
||||
}
|
||||
@@ -405,6 +436,24 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)])
|
||||
}
|
||||
}
|
||||
Group::Line => w.val("c:marker", 1),
|
||||
Group::Stock => {
|
||||
w.open0("c:hiLowLines");
|
||||
w.open0("c:spPr");
|
||||
w.open("a:ln", A::new().a("w", 9525));
|
||||
w.open0("a:solidFill");
|
||||
w.empty("a:schemeClr", A::new().a("val", "tx1"));
|
||||
w.close("a:solidFill");
|
||||
w.close("a:ln");
|
||||
w.close("c:spPr");
|
||||
w.close("c:hiLowLines");
|
||||
if c.up_down_bars {
|
||||
w.open0("c:upDownBars");
|
||||
w.val("c:gapWidth", 150);
|
||||
w.empty0("c:upBars");
|
||||
w.empty0("c:downBars");
|
||||
w.close("c:upDownBars");
|
||||
}
|
||||
}
|
||||
Group::Pie => w.val("c:firstSliceAng", 0),
|
||||
Group::Doughnut => {
|
||||
w.val("c:firstSliceAng", 0);
|
||||
@@ -529,6 +578,7 @@ pub fn chart_xml(c: &Chart) -> Vec<u8> {
|
||||
| ChartType::Bubble
|
||||
| ChartType::Radar
|
||||
| ChartType::FilledRadar
|
||||
| ChartType::Stock
|
||||
) {
|
||||
log::warn!("pptx: {} chart written as a column chart", c.kind.label());
|
||||
}
|
||||
|
||||
@@ -1581,3 +1581,37 @@ fn chartex_levels_and_quartiles_round_trip() {
|
||||
assert_eq!(chart(&deckcraft_pptx::import(&again).unwrap()).unwrap().category_levels, before.category_levels, "{ty}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Radar, stock (with and without up/down bars) and combo charts keep their own plot groups
|
||||
/// through a save and reopen, written as PowerPoint writes them.
|
||||
#[test]
|
||||
fn radar_stock_and_combo_round_trip() {
|
||||
use deckcraft_engine::Session;
|
||||
let chart = |d: &Presentation| -> Option<Chart> {
|
||||
d.slides.iter().flat_map(|s| s.shapes.iter()).find_map(|sh| match &sh.kind {
|
||||
ShapeKind::Chart(c) => Some((**c).clone()),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
for (ty, bars) in [("radar", false), ("filledradar", false), ("stock", false), ("stock", true), ("combo", false)] {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap();
|
||||
s.execute("insert.chart", &serde_json::json!({"type": ty})).unwrap();
|
||||
let id = s.doc().unwrap().selection.shapes[0].0;
|
||||
if bars {
|
||||
s.execute("chart.options", &serde_json::json!({"id": id, "upDownBars": true})).unwrap();
|
||||
}
|
||||
let before = chart(&s.doc().unwrap().doc).unwrap();
|
||||
let out = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
|
||||
let after = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap();
|
||||
assert_eq!(after.kind, before.kind, "{ty}");
|
||||
assert_eq!(after.up_down_bars, bars, "{ty}");
|
||||
assert_eq!(after.series.len(), before.series.len(), "{ty}");
|
||||
let kinds: Vec<_> = after.series.iter().map(|x| after.series_kind(x)).collect();
|
||||
let want: Vec<_> = before.series.iter().map(|x| before.series_kind(x)).collect();
|
||||
assert_eq!(kinds, want, "{ty}");
|
||||
for (a, b) in after.series.iter().zip(&before.series) {
|
||||
assert_eq!(a.values, b.values, "{ty}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ pub fn series_color(ctx: &Ctx, c: &Chart, i: usize) -> Rgba {
|
||||
}
|
||||
}
|
||||
|
||||
fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart::Series>) -> Rgba {
|
||||
pub(crate) fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart::Series>) -> Rgba {
|
||||
match s.and_then(|s| s.fill.as_ref()) {
|
||||
Some(Fill::Solid { color: cc }) => ctx.color(cc, None),
|
||||
_ => series_color(ctx, c, i),
|
||||
@@ -38,7 +38,7 @@ 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 {
|
||||
pub(crate) fn series_line(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series) -> Rgba {
|
||||
match s.line.as_ref().and_then(|l| l.fill.as_ref()) {
|
||||
Some(Fill::Solid { color: cc }) => ctx.color(cc, None),
|
||||
_ => series_fill(ctx, c, i, Some(s)),
|
||||
@@ -275,6 +275,10 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
crate::chartex::draw(ctx, rctx, c, m, area, base, text_col);
|
||||
return;
|
||||
}
|
||||
if matches!(c.kind, ChartType::Radar | ChartType::FilledRadar) {
|
||||
crate::radar::draw(ctx, rctx, c, m, area, base, text_col);
|
||||
return;
|
||||
}
|
||||
let combo = c.kind == ChartType::Combo;
|
||||
// Bars are drawn first and lines over them; a combo chart has both.
|
||||
let bar_kind = c.series.iter().map(|s| c.series_kind(s)).find(|k| k.is_bar_like());
|
||||
@@ -528,11 +532,17 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Lines, areas, scatter, radar (drawn as lines): the series that aren't bars.
|
||||
// A stock chart's high-low lines and up/down bars (or close dashes) stand in for its series.
|
||||
if c.kind == ChartType::Stock {
|
||||
for i in 0..ncat {
|
||||
crate::radar::stock_marks(ctx, rctx, c, m, plot.x0 + band * (i as f64 + 0.5), band, i, &|v| to_axis(false, v));
|
||||
}
|
||||
}
|
||||
// Lines, areas, scatter: the series that aren't bars.
|
||||
let mut acc = vec![0.0; ncat];
|
||||
for (si, s) in c.series.iter().enumerate() {
|
||||
let kind = c.series_kind(s);
|
||||
if kind.is_bar_like() {
|
||||
if kind.is_bar_like() || kind == ChartType::Stock {
|
||||
continue;
|
||||
}
|
||||
let sec = c.on_secondary(s);
|
||||
|
||||
@@ -26,6 +26,7 @@ mod pattern;
|
||||
|
||||
mod pathgrad;
|
||||
mod placed;
|
||||
mod radar;
|
||||
mod ramp;
|
||||
mod table;
|
||||
mod transition;
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Radar charts: one spoke per category, the value axis running up the first spoke, a polygon
|
||||
//! of gridlines per major unit and each series a closed polygon (filled for a filled radar).
|
||||
|
||||
use deckcraft_color::Rgba;
|
||||
use deckcraft_model::chart::{Chart, ChartType};
|
||||
use deckcraft_model::resolve::Ctx;
|
||||
use deckcraft_model::text::{Align, Anchor};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Shape as _};
|
||||
use vello_cpu::RenderContext;
|
||||
|
||||
use crate::chart::{label, series_fill, series_line};
|
||||
use crate::color;
|
||||
|
||||
/// The point at `frac` of the radius along spoke `i` of `n` (the first spoke points up).
|
||||
fn spoke(cen: Point, r: f64, i: usize, n: usize, frac: f64) -> Point {
|
||||
let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * i as f64 / n.max(1) as f64;
|
||||
Point::new(cen.x + r * frac * a.cos(), cen.y + r * frac * a.sin())
|
||||
}
|
||||
|
||||
pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) {
|
||||
let n = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0));
|
||||
if n < 3 {
|
||||
// Fewer than three spokes make no polygon; PowerPoint draws a line, we draw nothing.
|
||||
return;
|
||||
}
|
||||
let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9);
|
||||
// Room for the category labels around the outside.
|
||||
let r = (area.width().min(area.height()) / 2.0 - base * 1.9).max(base);
|
||||
let cen = area.center();
|
||||
let (min, max, step) = c.axis_scale(false, 5).unwrap_or((0.0, 1.0, 0.2));
|
||||
let span = (max - min).max(1e-9);
|
||||
let ticks = (((max - min) / step.max(1e-12)).round() as i64).clamp(1, 20);
|
||||
ctx.set_transform(m);
|
||||
ctx.set_stroke(kurbo::Stroke::new(0.75));
|
||||
ctx.set_paint(color(grid_col, 1.0));
|
||||
if c.gridlines {
|
||||
for k in 1..=ticks {
|
||||
let frac = k as f64 / ticks as f64;
|
||||
let mut p = BezPath::new();
|
||||
for i in 0..n {
|
||||
let q = spoke(cen, r, i, n, frac);
|
||||
if i == 0 { p.move_to(q) } else { p.line_to(q) }
|
||||
}
|
||||
p.close_path();
|
||||
ctx.stroke_path(&p);
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to(cen);
|
||||
p.line_to(spoke(cen, r, i, n, 1.0));
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(grid_col, 1.0));
|
||||
ctx.stroke_path(&p);
|
||||
if let Some(name) = c.categories.get(i) {
|
||||
let q = spoke(cen, r + base * 1.3, i, n, 1.0);
|
||||
let w = name.chars().count() as f64 * base * 0.55 + base;
|
||||
let align = if (q.x - cen.x).abs() < base {
|
||||
Align::Center
|
||||
} else if q.x > cen.x {
|
||||
Align::Left
|
||||
} else {
|
||||
Align::Right
|
||||
};
|
||||
let x0 = match align {
|
||||
Align::Left => q.x - base * 0.3,
|
||||
Align::Right => q.x - w + base * 0.3,
|
||||
_ => q.x - w / 2.0,
|
||||
};
|
||||
label(ctx, rctx, m, name, Rect::new(x0, q.y - base * 0.7, x0 + w, q.y + base * 0.7), base, align, Anchor::Middle, text_col, false);
|
||||
}
|
||||
}
|
||||
// Value labels up the first spoke.
|
||||
if !c.y_hidden {
|
||||
for k in 0..=ticks {
|
||||
let v = min + step * k as f64;
|
||||
let q = spoke(cen, r, 0, n, k as f64 / ticks as f64);
|
||||
let t = c.axis_label(false, v);
|
||||
let w = t.chars().count() as f64 * base * 0.55 + base * 0.4;
|
||||
label(
|
||||
ctx,
|
||||
rctx,
|
||||
m,
|
||||
&t,
|
||||
Rect::new(q.x - w - base * 0.3, q.y - base * 0.6, q.x - base * 0.3, q.y + base * 0.6),
|
||||
base * 0.9,
|
||||
Align::Right,
|
||||
Anchor::Middle,
|
||||
text_col,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
let filled = c.kind == ChartType::FilledRadar;
|
||||
for (si, s) in c.series.iter().enumerate() {
|
||||
let pts: Vec<Point> = (0..n)
|
||||
.map(|i| {
|
||||
let v = s.values.get(i).copied().flatten().unwrap_or(min);
|
||||
spoke(cen, r, i, n, ((v - min) / span).clamp(0.0, 1.2))
|
||||
})
|
||||
.collect();
|
||||
let mut p = BezPath::new();
|
||||
for (i, q) in pts.iter().enumerate() {
|
||||
if i == 0 { p.move_to(*q) } else { p.line_to(*q) }
|
||||
}
|
||||
p.close_path();
|
||||
ctx.set_transform(m);
|
||||
if filled {
|
||||
ctx.set_paint(color(series_fill(rctx, c, si, Some(s)), 0.75));
|
||||
ctx.fill_path(&p);
|
||||
} else {
|
||||
let col = series_line(rctx, c, si, s);
|
||||
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));
|
||||
ctx.stroke_path(&p);
|
||||
if s.marker.is_some() {
|
||||
for q in &pts {
|
||||
ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1));
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.data_labels || s.data_labels == Some(true) {
|
||||
for (i, q) in pts.iter().enumerate() {
|
||||
if let Some(v) = s.values.get(i).copied().flatten() {
|
||||
let t = c.data_label(s, v);
|
||||
label(
|
||||
ctx,
|
||||
rctx,
|
||||
m,
|
||||
&t,
|
||||
Rect::new(q.x - 40.0, q.y - base * 1.4, q.x + 40.0, q.y - base * 0.2),
|
||||
base * 0.9,
|
||||
Align::Center,
|
||||
Anchor::Bottom,
|
||||
text_col,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stock chart's marks at one category: the high-low line, then the up/down bar between the
|
||||
/// first (open) and last (close) series, else a dash at the close.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn stock_marks(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, x: f64, band: f64, i: usize, to_y: &dyn Fn(f64) -> f64) {
|
||||
let vals: Vec<f64> = c.series.iter().filter_map(|s| s.values.get(i).copied().flatten()).collect();
|
||||
if vals.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let hi = vals.iter().copied().fold(f64::MIN, f64::max);
|
||||
let lo = vals.iter().copied().fold(f64::MAX, f64::min);
|
||||
let ink = Rgba::rgb(0x40, 0x40, 0x40);
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(ink, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(1.0));
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((x, to_y(hi)));
|
||||
p.line_to((x, to_y(lo)));
|
||||
ctx.stroke_path(&p);
|
||||
let open = c.series.first().and_then(|s| s.values.get(i).copied().flatten());
|
||||
let close = c.series.last().and_then(|s| s.values.get(i).copied().flatten());
|
||||
if c.up_down_bars && c.series.len() >= 4 {
|
||||
let (Some(o), Some(cl)) = (open, close) else { return };
|
||||
let w = band * 0.4;
|
||||
let (y0, y1) = (to_y(o.max(cl)), to_y(o.min(cl)));
|
||||
let r = Rect::new(x - w / 2.0, y0, x + w / 2.0, y1.max(y0 + 1.0));
|
||||
// PowerPoint's default: white when the price rose, dark when it fell.
|
||||
ctx.set_paint(color(if cl >= o { Rgba::WHITE } else { ink }, 1.0));
|
||||
ctx.fill_rect(&r);
|
||||
ctx.set_paint(color(ink, 1.0));
|
||||
ctx.stroke_path(&r.to_path(0.1));
|
||||
return;
|
||||
}
|
||||
if let Some(cl) = close {
|
||||
let col = c.series.last().map(|s| series_line(rctx, c, c.series.len() - 1, s)).unwrap_or(ink);
|
||||
let y = to_y(cl);
|
||||
let mut d = BezPath::new();
|
||||
d.move_to((x - band * 0.12, y));
|
||||
d.line_to((x + band * 0.12, y));
|
||||
ctx.set_paint(color(col, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(2.5));
|
||||
ctx.stroke_path(&d);
|
||||
}
|
||||
}
|
||||
@@ -2079,3 +2079,52 @@ fn region_map_draws_tiles_on_its_colour_scale() {
|
||||
}
|
||||
assert!(seen.len() >= 5, "{} colours", seen.len());
|
||||
}
|
||||
|
||||
/// Radar charts are polar, stock charts draw high-low lines (and up/down bars), and a combo
|
||||
/// chart draws its line series over its columns.
|
||||
#[test]
|
||||
fn radar_stock_and_combo_draw_their_own_layouts() {
|
||||
let mut ohlc = Chart::sample(ChartType::Stock);
|
||||
ohlc.series.insert(
|
||||
0,
|
||||
deckcraft_model::chart::Series {
|
||||
name: "Open".into(),
|
||||
values: [44.0, 25.0, 38.0, 50.0, 35.0].iter().map(|v| Some(*v)).collect(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
ohlc.up_down_bars = true;
|
||||
let charts = [
|
||||
("Radar", Chart::sample(ChartType::Radar)),
|
||||
("FilledRadar", Chart::sample(ChartType::FilledRadar)),
|
||||
("Stock", Chart::sample(ChartType::Stock)),
|
||||
("StockOHLC", ohlc),
|
||||
("Combo", Chart::sample(ChartType::Combo)),
|
||||
];
|
||||
for (name, c) in charts {
|
||||
let sh = Shape {
|
||||
id: ShapeId(625),
|
||||
xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)),
|
||||
kind: ShapeKind::Chart(Box::new(c.clone())),
|
||||
..Default::default()
|
||||
};
|
||||
let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
if let Ok(dir) = std::env::var("DECKCRAFT_CHART_SHOTS") {
|
||||
let _ = std::fs::write(format!("{dir}/{name}.png"), img.to_png());
|
||||
}
|
||||
let ink = |x: u32, y: u32| img.pixel(x, y)[..3] != [255, 255, 255];
|
||||
match name {
|
||||
// The radar's top spoke runs straight up from the centre; a line chart has nothing there.
|
||||
"Radar" | "FilledRadar" => assert!((150..260).any(|y| ink(480, y)), "{name}"),
|
||||
// No series lines: well right of the first category there is only gridline grey.
|
||||
"Stock" | "StockOHLC" => {
|
||||
let coloured = (60..480).step_by(2).flat_map(|y| (40..920).step_by(4).map(move |x| (x, y))).any(|(x, y)| {
|
||||
let p = img.pixel(x, y);
|
||||
p[2] > p[0].saturating_add(60)
|
||||
});
|
||||
assert!(!coloured || name == "Stock", "{name}: series drawn as lines");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -215,7 +215,10 @@ Detail in [file-format-parity.md](file-format-parity.md).
|
||||
have their own layouts, histograms and Paretos bin their data (count, width or Scott's rule),
|
||||
and box & whisker charts use exclusive or inclusive quartiles with 1.5 IQR whiskers. Region maps are kept
|
||||
(their cached geography too, also through edits) and drawn as value-coloured tiles with a
|
||||
colour scale, since DeckCraft ships no map boundaries.
|
||||
colour scale, since DeckCraft ships no map boundaries. Radar charts are drawn polar (filled
|
||||
or lines), stock charts as high-low lines with close dashes or up/down bars and saved as
|
||||
`c:stockChart`, and combo charts start as columns with a line. Secondary-axis writing and
|
||||
trendlines are tracked separately.
|
||||
- **Impact:** most business decks have charts; editing their data is routine.
|
||||
- **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user