From c176d42bb468388b57244254e85479a5df8e07ff Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:31:40 +0300 Subject: [PATCH] Charts: polar radar, stock high-low lines and up/down bars, combo sample Radar and filled radar charts are drawn polar: spokes per category, polygon gridlines and value labels up the first spoke. Stock charts draw high-low lines with close dashes, or up/down bars between open and close, and are saved as c:stockChart (hiLowLines, upDownBars) instead of a line chart; c:upDownBars is read, and chart.options takes upDownBars. New stock charts start as high-low-close and new combo charts as columns with a line. Co-authored-by: Cursor --- crates/engine/src/cmd/insert.rs | 6 +- crates/model/src/chart.rs | 20 ++++ crates/pptx/src/read/chart.rs | 5 +- crates/pptx/src/write/chart.rs | 56 +++++++++- crates/pptx/tests/roundtrip.rs | 34 ++++++ crates/render/src/chart.rs | 18 ++- crates/render/src/lib.rs | 1 + crates/render/src/radar.rs | 187 ++++++++++++++++++++++++++++++++ crates/render/src/tests.rs | 49 +++++++++ docs/gaps.md | 5 +- 10 files changed, 371 insertions(+), 10 deletions(-) create mode 100644 crates/render/src/radar.rs diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 8a0a7d28..5d6b334c 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -73,7 +73,7 @@ pub fn specs() -> Vec { "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 ), @@ -733,6 +733,10 @@ fn chart_options(s: &mut Session, p: &Value) -> Result { 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; diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 5b646f53..84a7d27f 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -217,6 +217,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")] @@ -266,6 +270,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, @@ -309,6 +314,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 { @@ -320,6 +334,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 diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 7cecf457..63e7fa24 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -166,7 +166,10 @@ pub(crate) fn chart_from(root: &El) -> Option { ChartType::Radar } } - "stockChart" => ChartType::Stock, + "stockChart" => { + c.up_down_bars = g.child("upDownBars").is_some(); + ChartType::Stock + } "surfaceChart" | "surface3DChart" => ChartType::Surface, _ => continue, }; diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 7b8c1718..4f399d33 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -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); } @@ -323,6 +350,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 { @@ -335,7 +363,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); } @@ -351,6 +382,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); @@ -475,6 +524,7 @@ pub fn chart_xml(c: &Chart) -> Vec { | ChartType::Bubble | ChartType::Radar | ChartType::FilledRadar + | ChartType::Stock ) { log::warn!("pptx: {} chart written as a column chart", c.kind.label()); } diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index ae144dd0..83a76180 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -1197,3 +1197,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 { + 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}"); + } + } +} diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index 140462b8..dda40b85 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -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)), @@ -279,6 +279,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()); @@ -510,11 +514,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); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e6570c5c..98b54a57 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -24,6 +24,7 @@ mod pattern; mod pathgrad; mod placed; +mod radar; mod ramp; mod table; mod transition; diff --git a/crates/render/src/radar.rs b/crates/render/src/radar.rs new file mode 100644 index 00000000..3416c7ce --- /dev/null +++ b/crates/render/src/radar.rs @@ -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 = (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 = 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); + } +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 28fe9887..ce8f4b3a 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1834,3 +1834,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"); + } + _ => {} + } + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 85dae7b9..3cff27e0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -152,7 +152,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).