Charts: draw radar charts (standard, marker, filled) instead of a placeholder (#1890)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-15 01:31:37 +02:00
co-authored by Claude Sonnet 5.5
parent 4d2649738e
commit 9acb1241b5
3 changed files with 194 additions and 12 deletions
+1
View File
@@ -525,3 +525,4 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-09 | minor | Recently landed: RTL, Ukrainian and Brazilian Portuguese |
| 2026-10-06 | major | First honest estimate: 88% catalog, ~62% real parity, alpha ~85% of the way |
| 2026-10-12 | trivial | Progress log: recipient filter and sort, .xls and legacy-encoding recipient lists (#778) |
| 2026-10-15 | trivial | Charts: radar charts (standard, marker, filled) are drawn instead of a placeholder (#1890) |
+192 -11
View File
@@ -2,7 +2,8 @@
//!
//! Bar and column (clustered, stacked or 100% stacked), line, area, pie, doughnut and scatter
//! charts are drawn from the first supported chart type of the plot area, with their values as
//! data labels where `c:dLbls` shows them (`c:showVal`). A chart of only other types (radar, stock…)
//! data labels where `c:dLbls` shows them (`c:showVal`). Radar charts (standard, with markers or
//! filled) draw as spokes, concentric grid polygons and one polygon per series. A chart of only other types (stock, bubble…)
//! draws a placeholder: its frame, its title and the type it is. Text widths are estimated ([`CHAR_W`] em a character), so label places are approximate.
use std::f64::consts::{FRAC_PI_2, TAU};
@@ -23,6 +24,10 @@ const MAX_POINTS: usize = 10_000;
const MAX_CELLS: usize = 100_000;
/// Longest series name or category label kept.
const MAX_LABEL: usize = 200;
/// Most categories (spokes) of a radar chart.
const MAX_SPOKES: usize = 100;
/// Most series of a radar chart.
const MAX_RADAR_SERIES: usize = 50;
/// Most axis ticks drawn.
const MAX_TICKS: usize = 20;
/// Default text size, points.
@@ -75,6 +80,7 @@ pub(crate) fn chart_items(space: &El, theme: &[Rgb], w: f32, h: f32) -> Vec<Grap
}
match chart.kind {
Kind::Pie | Kind::Doughnut => pie(&mut out, &chart, area),
Kind::Radar => radar(&mut out, &chart, area),
_ => cartesian(&mut out, &chart, area),
}
out
@@ -87,7 +93,7 @@ fn title_block(out: &mut Vec<GraphicItem>, t: &str, area: Rect) -> Rect {
Rect { y: area.y + lh + PAD, h: (area.h - lh - PAD).max(1.0), ..area }
}
/// A chart whose plot area holds only chart types WordCraft doesn't draw (radar, stock…): its
/// A chart whose plot area holds only chart types WordCraft doesn't draw (stock, bubble…): its
/// frame with a light border, its title and a line saying which type it is, so it isn't blank
/// space. Nothing when the plot area has no chart type at all, or a supported one with nothing
/// to draw.
@@ -114,7 +120,6 @@ fn placeholder(out: &mut Vec<GraphicItem>, space: &El, theme: &[Rgb], w: f32, h:
/// What a placeholder calls a chart type element WordCraft doesn't draw.
fn unsupported_name(name: &str) -> &'static str {
match name {
"c:radarChart" => "Radar chart",
"c:stockChart" => "Stock chart",
"c:bubbleChart" => "Bubble chart",
"c:surfaceChart" | "c:surface3DChart" => "Surface chart",
@@ -144,6 +149,7 @@ enum Kind {
Pie,
Doughnut,
Scatter,
Radar,
}
impl Kind {
@@ -155,6 +161,7 @@ impl Kind {
"c:pieChart" => Kind::Pie,
"c:doughnutChart" => Kind::Doughnut,
"c:scatterChart" => Kind::Scatter,
"c:radarChart" => Kind::Radar,
_ => return None,
})
}
@@ -221,6 +228,10 @@ struct Chart {
/// Doughnut hole, fraction of the outer radius.
hole: f32,
first_angle: f64,
/// Radar: series polygons are filled (`c:radarStyle` = `filled`).
radar_fill: bool,
/// Radar: points carry markers (`c:radarStyle` = `marker`).
radar_marks: bool,
vary: bool,
/// Number of categories (scatter: points per series at most).
n: usize,
@@ -253,7 +264,9 @@ impl Chart {
return None;
}
let cats = ty.children("c:ser").find_map(|s| s.child("c:cat")).map(|c| labels(Some(c), MAX_POINTS, date1904)).unwrap_or_default();
let n = if matches!(kind, Kind::Pie | Kind::Doughnut) {
let n = if kind == Kind::Radar {
series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(cats.len()).min(MAX_SPOKES)
} else if matches!(kind, Kind::Pie | Kind::Doughnut) {
series.first().map_or(0, |s| s.values.len()).max(cats.len())
} else {
series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(cats.len())
@@ -262,6 +275,9 @@ impl Chart {
if let Some(keep) = MAX_CELLS.checked_div(n) {
series.truncate(keep.max(1));
}
if kind == Kind::Radar {
series.truncate(MAX_RADAR_SERIES);
}
let grouping = match ty.child("c:grouping").and_then(|g| g.attr("val")) {
Some("stacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Stacked,
Some("percentStacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Percent,
@@ -269,6 +285,7 @@ impl Chart {
};
let mut cats = cats;
cats.resize(n, String::new());
let radar_style = ty.child("c:radarStyle").and_then(|r| r.attr("val"));
let type_labels = show_values(ty.child("c:dLbls"));
for s in &mut series {
s.values.resize(n, None);
@@ -296,6 +313,8 @@ impl Chart {
overlap: num("c:overlap").unwrap_or(0).clamp(-100, 100) as f32 / 100.0,
hole: num("c:holeSize").unwrap_or(50).clamp(10, 90) as f32 / 100.0,
first_angle: num("c:firstSliceAng").unwrap_or(0).clamp(0, 360) as f64,
radar_fill: radar_style == Some("filled"),
radar_marks: radar_style == Some("marker"),
vary: matches!(kind, Kind::Pie | Kind::Doughnut) && ty.child("c:varyColors").and_then(|v| v.attr("val")).is_none_or(truthy),
n,
cats,
@@ -1324,7 +1343,7 @@ fn cartesian(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
Kind::Line => lines(out, c, &g, &mut labels),
Kind::Area => areas(out, c, &g, &mut labels),
Kind::Scatter => draw_scatter(out, c, &g, &mut labels),
Kind::Pie | Kind::Doughnut => {}
Kind::Pie | Kind::Doughnut | Kind::Radar => {}
}
draw_axes(out, c, &g);
draw_data_labels(out, &labels, c.text_size, g.horiz && c.kind == Kind::Bar);
@@ -1621,6 +1640,94 @@ fn pie(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
draw_data_labels(out, &labels, c.text_size, false);
}
/// A radar chart in `area`: category spokes from the centre, concentric grid polygons at the value
/// ticks, one polygon per series (filled, or outlined with markers) and the category labels at the
/// spoke ends. The lowest tick is the centre.
fn radar(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
const MARKER: f32 = 5.0;
if c.n == 0 {
return;
}
let fs = c.text_size;
let lh = fs * LINE;
let labelled = c.cats.iter().any(|s| !s.is_empty());
let side = if labelled { widest(&c.cats, fs).min(area.w * 0.25) + GAP + 2.0 } else { 0.0 };
let vert = if labelled { lh + GAP } else { 0.0 };
let r = ((area.w / 2.0 - side).min(area.h / 2.0 - vert) - 2.0) as f64;
if r.is_nan() || r < 8.0 {
return;
}
let (cx, cy) = ((area.x + area.w / 2.0) as f64, (area.y + area.h / 2.0) as f64);
let vs = Axes::of(c).vs;
let span = vs.hi - vs.lo;
let angle = |i: usize| i as f64 / c.n as f64 * TAU;
let at = |v: f64, i: usize| {
let t = if span > 0.0 { ((v - vs.lo) / span).clamp(0.0, 1.0) } else { 0.0 };
polar(cx, cy, r * t, angle(i))
};
let ticks = vs.ticks();
if c.val.grid {
for t in ticks.iter().skip(1) {
let mut segs: Vec<PathSeg> = (0..c.n).map(|i| if i == 0 { pt(at(*t, i)) } else { line_to(at(*t, i)) }).collect();
segs.push(PathSeg::Close);
stroke(out, segs, GRID, 0.75);
}
}
for i in 0..c.n {
let end = polar(cx, cy, r, angle(i));
stroke(out, vec![pt((cx, cy)), line_to(end)], GRID, 0.75);
}
let mut labels = Vec::new();
for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) {
let col = c.series_colour(j);
// A missing value counts as the centre, as Word draws it.
let ring: Vec<(f64, f64)> = (0..c.n).map(|i| at(s.values.get(i).copied().flatten().unwrap_or(vs.lo), i)).collect();
let mut segs: Vec<PathSeg> = ring.iter().enumerate().map(|(i, p)| if i == 0 { pt(*p) } else { line_to(*p) }).collect();
segs.push(PathSeg::Close);
if c.radar_fill {
fill(out, segs.clone(), col);
}
stroke(out, segs, col, s.line_w);
for (i, p) in ring.iter().enumerate() {
if c.radar_marks && s.values.get(i).copied().flatten().is_some() {
let rect = [p.0 as f32 - MARKER / 2.0, p.1 as f32 - MARKER / 2.0, MARKER, MARKER];
out.push(GraphicItem::Shape { rect, kind: ShapeKind::Ellipse, fill: Some(col), stroke: None, stroke_width: 0.0 });
}
if let Some(text) = label_of(s, i) {
labels.push(DataLabel { x: p.0 as f32, y: p.1 as f32, text, inside: false });
}
}
}
draw_data_labels(out, &labels, fs, false);
if !c.val.deleted {
for t in &ticks {
let y = at(*t, 0).1 as f32;
let s = format_value(*t, &c.val_fmt, vs.step);
let w = text_w(&s, fs) + 4.0;
text(out, Rect { x: cx as f32 - GAP / 2.0 - w, y: y - lh / 2.0, w, h: lh }, &s, fs, TextAlign::Right, false);
}
}
if labelled {
for (i, s) in c.cats.iter().enumerate().filter(|(_, s)| !s.is_empty()) {
let a = angle(i);
let (x, y) = polar(cx, cy, r + GAP as f64, a);
let (x, y) = (x as f32, y as f32);
let w = side - GAP;
let txt = clip_text(s, w, fs);
let (sx, sy) = (a.sin(), a.cos());
let (rect, align) = if sx > 0.2 {
(Rect { x, y: y - lh / 2.0, w, h: lh }, TextAlign::Left)
} else if sx < -0.2 {
(Rect { x: x - w, y: y - lh / 2.0, w, h: lh }, TextAlign::Right)
} else {
let cw = (text_w(&txt, fs) + 4.0).max(1.0);
(Rect { x: x - cw / 2.0, y: if sy > 0.0 { y - lh } else { y }, w: cw, h: lh }, TextAlign::Center)
};
text(out, rect, &txt, fs, align, false);
}
}
}
/// The data label of point `i` of series `s`, when it shows one: the value as the file has it,
/// in the series' number format.
fn label_of(s: &Series, i: usize) -> Option<String> {
@@ -1849,21 +1956,95 @@ mod tests {
fn unsupported_chart_types_draw_a_placeholder() {
let ser = r#"<c:ser><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numLit></c:val></c:ser>"#;
let title = r#"<c:title><c:tx><c:rich><a:p><a:r><a:t>Skills</a:t></a:r></a:p></c:rich></c:tx></c:title>"#;
let x = format!(r#"<c:chartSpace {NS}><c:chart>{title}<c:plotArea><c:radarChart>{ser}</c:radarChart></c:plotArea></c:chart></c:chartSpace>"#);
let radar = chart_items(&crate::xml::parse(x.as_bytes()).unwrap(), &theme(), 300.0, 200.0);
assert_eq!(texts(&radar), ["Skills", "Radar chart (not supported yet)"]);
let framed = radar.iter().any(|it| matches!(it, GraphicItem::Shape { rect, stroke: Some(_), .. } if *rect == [0.0, 0.0, 300.0, 200.0]));
assert!(framed, "a light border marks the chart's frame: {radar:?}");
let x = format!(r#"<c:chartSpace {NS}><c:chart>{title}<c:plotArea><c:bubbleChart>{ser}</c:bubbleChart></c:plotArea></c:chart></c:chartSpace>"#);
let bubble = chart_items(&crate::xml::parse(x.as_bytes()).unwrap(), &theme(), 300.0, 200.0);
assert_eq!(texts(&bubble), ["Skills", "Bubble chart (not supported yet)"]);
let framed = bubble.iter().any(|it| matches!(it, GraphicItem::Shape { rect, stroke: Some(_), .. } if *rect == [0.0, 0.0, 300.0, 200.0]));
assert!(framed, "a light border marks the chart's frame: {bubble:?}");
let stock = chart_items(&space(&format!("<c:stockChart>{ser}{ser}{ser}</c:stockChart>")), &theme(), 300.0, 200.0);
assert_eq!(texts(&stock), ["Stock chart (not supported yet)"]);
let other = chart_items(&space(&format!("<c:fancyChart>{ser}</c:fancyChart>")), &theme(), 300.0, 200.0);
assert_eq!(texts(&other), ["Chart (not supported yet)"]);
// A supported type in the plot area is drawn, an unsupported one beside it is skipped.
let combo = chart_items(&space(&format!("<c:radarChart>{ser}</c:radarChart><c:lineChart>{ser}</c:lineChart>")), &theme(), 300.0, 200.0);
let combo = chart_items(&space(&format!("<c:stockChart>{ser}</c:stockChart><c:lineChart>{ser}</c:lineChart>")), &theme(), 300.0, 200.0);
assert!(!texts(&combo).iter().any(|t| t.contains("not supported")), "{combo:?}");
}
fn radar_chart(style: &str, sers: &[String]) -> Vec<GraphicItem> {
let plot = format!(r#"<c:radarChart><c:radarStyle val="{style}"/>{}</c:radarChart><c:valAx><c:majorGridlines/></c:valAx>"#, sers.concat());
chart_items(&space(&plot), &theme(), 300.0, 220.0)
}
#[test]
fn radar_charts_draw_spokes_grid_and_series_polygons() {
let cats = ["Speed", "Power", "Range", "Armor", "Luck"];
let sers = [ser(0, "A", Some("FF0000"), &cats, &["3", "4", "5", "2", "1"]), ser(1, "B", Some("0000FF"), &cats, &["2", "2", "3", "5", "4"])];
let std = radar_chart("standard", &sers);
assert!(!texts(&std).iter().any(|t| t.contains("not supported")), "{std:?}");
for c in cats {
assert!(texts(&std).contains(&c), "category label {c}: {:?}", texts(&std));
}
let stroked = |items: &[GraphicItem], col: Rgb| items.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == col && segs.len() == 6)).count();
assert_eq!(stroked(&std, Rgb(0xFF, 0, 0)), 1, "one closed pentagon per series: {std:?}");
assert_eq!(stroked(&std, Rgb(0, 0, 0xFF)), 1);
assert!(filled(&std).is_empty(), "standard radar is not filled");
assert!(!std.iter().any(|it| matches!(it, GraphicItem::Shape { kind: ShapeKind::Ellipse, .. })));
// 5 spokes (2 segs each) plus grid pentagons.
let spokes = std.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == GRID && segs.len() == 2)).count();
assert_eq!(spokes, 5);
assert!(std.iter().any(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == GRID && segs.len() == 6)));
let marked = radar_chart("marker", &sers);
let dots = marked.iter().filter(|it| matches!(it, GraphicItem::Shape { kind: ShapeKind::Ellipse, .. })).count();
assert_eq!(dots, 10, "a marker per point");
let solid = radar_chart("filled", &sers);
let f = filled(&solid);
assert_eq!(f.len(), 2);
assert!(f.iter().any(|(c, ..)| *c == Rgb(0xFF, 0, 0)) && f.iter().any(|(c, ..)| *c == Rgb(0, 0, 0xFF)));
}
#[test]
fn radar_charts_show_a_legend() {
let cats = ["a", "b", "c"];
let plot = format!("<c:radarChart>{}</c:radarChart>", ser(0, "Team", None, &cats, &["1", "2", "3"]));
let items = with_legend(&plot, "r", 300.0, 200.0);
assert!(texts(&items).contains(&"Team"), "{:?}", texts(&items));
}
#[test]
fn hostile_radar_charts_never_panic() {
// No categories and no values.
let empty = r#"<c:ser><c:cat><c:strLit><c:ptCount val="0"/></c:strLit></c:cat><c:val><c:numLit><c:ptCount val="0"/></c:numLit></c:val></c:ser>"#;
let _ = chart_items(&space(&format!("<c:radarChart>{empty}</c:radarChart>")), &theme(), 300.0, 200.0);
let none = chart_items(&space("<c:radarChart/>"), &theme(), 300.0, 200.0);
assert!(none.is_empty(), "{none:?}");
// One category: a degenerate polygon, nothing to crash on; two as well.
for n in 1..=2 {
let cats: Vec<String> = (0..n).map(|i| format!("c{i}")).collect();
let refs: Vec<&str> = cats.iter().map(String::as_str).collect();
let vals = vec!["5"; n];
let items = radar_chart("filled", &[ser(0, "S", None, &refs, &vals)]);
assert!(texts(&items).contains(&"c0"), "{items:?}");
}
// NaN, infinities, junk and a tiny frame.
let junk = ser(0, "S", None, &["a", "b", "c"], &["NaN", "inf", "1e999"]);
let _ = radar_chart("filled", &[junk]);
let small = chart_items(&space(&format!("<c:radarChart>{}</c:radarChart>", ser(0, "S", None, &["a", "b", "c"], &["1", "2", "3"]))), &theme(), 20.0, 20.0);
let _ = small;
// Caps: thousands of categories and series stay bounded.
let cats: Vec<String> = (0..5000).map(|i| format!("c{i}")).collect();
let refs: Vec<&str> = cats.iter().map(String::as_str).collect();
let vals = vec!["1"; 5000];
let many: Vec<String> = (0..300).map(|i| ser(i, "S", None, &refs, &vals)).collect();
let items = radar_chart("marker", &many);
let polygons = items.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), .. } if *c != GRID)).count();
assert!(polygons <= MAX_RADAR_SERIES, "{polygons}");
let spokes = items.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == GRID && segs.len() == 2)).count();
assert_eq!(spokes, MAX_SPOKES);
}
/// Chart items for a chart with a legend at `pos` (`r`, `b`…).
fn with_legend(plot: &str, pos: &str, w: f32, h: f32) -> Vec<GraphicItem> {
let x = format!(
+1 -1
View File
@@ -33,7 +33,7 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin
| 10 | **Objects**: text in a freely rotated text box (free rotation and flips landed, #332; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) |
| 11 | **Interface languages**: 5 of the 12 key languages missing (hi, ar, id, ko, vi) and pt-PT (French landed, #729; 19 interface languages in `crates/ui-egui/src/i18n`); open PRs for Arabic (#737, #1042, #1032), Indonesian (#572) and pt-PT (#1028); no mirrored RTL interface yet; ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) |
| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); Ink to Shape also recognises single-stroke arrows (a line plus a V head drawn in one stroke becomes a straight connector with an arrowhead); missing: Ink to Math, two-stroke arrows and arrows with a closed triangle head, pressure, rendering/editing Word's own ink (drawing-level and direct run-level InkML content parts now survive DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 10/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) |
| 13 | **Charts and SmartArt editing** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), no combo/3D/radar/stock charts (charts of those types show a placeholder with their title and type, #998), axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) |
| 13 | **Charts and SmartArt editing** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), radar charts are drawn (#1890); no combo/3D/stock charts (charts of those types show a placeholder with their title and type, #998), axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) |
| 14 | **`.doc` can't be written; Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) |
| 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189) | | #790 stores sources in `customXml` and reads them back; `crates/zotero` | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) |
| 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) |